ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

this指向谁?调用者视角彻底搞懂JavaScript的this绑定

this指向谁?调用者视角彻底搞懂JavaScript的this绑定 刚开始带前端团队的时候我最头疼的事情之一就是面试。问十个候选人八个能把this的规则背得滚瓜烂熟默认绑定、隐式绑定、显式绑定、new绑定甚至箭头函数不绑定this都能一字不差说出来。结果我一给代码让他们说输出什么立马露馅。原因很简单大多数人学this用的是“背答案”的方式而不是“看本质”的方式。标题里这句话是我一直想跟所有前端开发者说的this指向谁指向真正的调用者。把这句话真正理解透了你会发现面试官再怎么变着花样出题核心永远就那么一个。这篇文章不打算给你列一堆“八股文”规则让你背而是从“调用者”这个视角把this的底层逻辑拆开揉碎讲清楚。同时我会把实际开发里遇到的各种this丢失场景、排查思路、修复手段全部摊开来讲。不管你是刚入门的前端新手还是被this坑过几次的初中级开发这篇文章都能帮你把这块短板彻底补上。1. 整体设计思路为什么“调用者视角”比“规则背诵”更靠谱1.1 传统八股文的局限性你打开任意一篇讲this的文章大概率会看到这样的总结谁调用指向谁、没调用者指向window、严格模式下是undefined、call和apply可以改指向、箭头函数不绑定this。这些结论有错吗没错。但问题是它们都是“结论”不是“方法”。面试题稍微换一层皮比如把对象方法赋值给一个变量再调用、把方法作为回调传进去、或者嵌套一层函数很多人的大脑就当场死机。为什么因为背规则的人脑子里装的是“规则库”而不是“推导能力”。遇到见过的题型套规则能解出来遇到没见过的题型规则的优先级自己都搞不清楚。比如隐式绑定和显式绑定冲突时该听谁的new和call同时出现时谁说了算这些在八股文里虽然也有顺序表但你光靠背根本理解不了背后的逻辑。1.2 调用者视角是怎么回事我来换个思路。想象你有一段代码const person { name: 张三, greet() { console.log(你好我是${this.name}); } }; person.greet();按照调用者视角我们应该问一个问题greet这个方法在被调用的那一刻是通过谁去调的答案是person。因为写的是person.greet()在greet前面有一个清晰的调用者person。这就好比你在公司里喊了一声“帮我倒杯水”这个指令是谁发出的是你发出的所以执行的人知道该听谁的。JavaScript里的this本质上就是在问这个函数的调用者是谁我就把this指向谁。一旦你切换到这种视角很多看似复杂的规则其实就是大白话函数直接调用前面没有“点”调用者就是undefined或全局对象函数前面有“点”比如obj.method()调用者就是obj你想强制指定调用者用call、apply、bind把调用者“绑”上去用new调用函数时系统帮你新建了一个对象并且把this指向这个新对象箭头函数比较特殊它压根没有自己的this它用的this是定义它时外层作用域的那个this。看到没这些规则本质上都是围绕“调用者”展开的。你不需要背优先级表格只需要在每次看到函数调用时下意识问一句这个函数的调用者是谁1.3 这套思路能解决什么问题用调用者视角去看代码最大的好处是你能动态分析一段代码而不是靠记忆匹配。动态分析的能力才是面试官真正想考察的东西。因为实际开发中你不会总是写obj.method()这种规规矩矩的代码更多时候是回调、事件绑定、函数传参、解构赋值这些场景里this特别容易丢。而丢了this的根本原因往往是调用者变了——本来调用者应该是某个对象结果因为某种操作调用者变成了全局对象或者干脆变成了undefined。所以这篇文章的后续内容会以“找调用者”为核心线索把各种场景串起来。只要你会问“谁在调用它”你就已经赢了一半。2. 核心细节解析绑定的几种真实场景与背后逻辑2.1 默认绑定没有调用者时的兜底策略先看最简单的情况function sayName() { console.log(this.name); } var name 全局名字; sayName();这里sayName()前面没有点没有call没有new它就是一个光秃秃的函数调用。那调用者是谁严格来说没有调用者。在非严格模式下JavaScript引擎会把这个this默认指向全局对象浏览器里是windowNode.js里是global在严格模式下则直接给undefined。你可能会问为什么非严格模式要默认指向全局对象这是历史遗留问题。早期JavaScript设计时没有模块概念所有顶层变量天然挂在全局对象上所以函数里的this默认指向全局也算是一种“自然设计”。后来ES5引入了严格模式意识到这种默认行为很容易造成误操作——你在函数里随手写个this.xxx 1结果污染了全局——所以严格模式下改成undefined逼着开发者明确指定调用者。这个差异虽然简单但在面试题里经常作为一个小坑出现。我记得有个很经典的题目是这样的function func() { use strict; console.log(this); } func();输出是undefined。因为严格模式生效范围包括函数内部无论你在哪调用函数体内的this被严格模式约束为undefined。这题看似考严格模式其实考的仍然是“没有调用者时this怎么处理”这个底层逻辑。2.2 隐式绑定找到那个“点”前面的对象隐式绑定就是标题所说的“真正的调用者”最直观的体现。当一个函数作为对象的一个属性被调用时即obj.method()这种形式this会指向obj。const user { name: 李四, age: 28, introduce() { console.log(我是${this.name}今年${this.age}岁); } }; user.introduce();输出我是李四今年28岁。因为调用introduce时的调用者是user所以this.name就是user.name。这个逻辑看起来简单但有几个变种特别容易让人迷糊。第一个变种是对象方法赋值给变量const fn user.introduce; fn();此时输出是什么答案是Cannot read properties of undefined之类的报错或者this.name是undefined。原因很简单当你执行const fn user.introduce时你只是把函数的引用取出来了并没有调用它。等到fn()执行时调用者已经变成了光秃秃的全局环境前面没有点user对象和这次调用已经没有任何关系。这就是典型的“丢this”不是this自己跑了是你把函数和它的调用者拆散了。第二个变种是嵌套函数const obj { name: 内部对象, outer() { function inner() { console.log(this.name); } inner(); } }; obj.outer();这里obj.outer()执行时outer内部定义了一个普通函数inner并直接调用inner()。注意inner的调用者是全局环境不是obj。所以输出的是全局的name或者undefined。很多人误以为外层函数的this会传进去实际上普通函数不会继承外层函数的this。这个坑在ES6之前尤其常见当时大家都用var that this来手动保存外层this。第三个变种是对象方法链const objA { name: A, method() { console.log(this.name); } }; const objB { name: B, child: objA }; objB.child.method();这里虽然写了两层点但真正调用method的“最后一级”调用者是objA所以this.name是A。判断规则就一条看调用那一刻点前面紧挨着的那个对象是谁。不需要往再上层看。2.3 显式绑定手动指定调用者的三个工具有些场景下函数的调用者不好改或者你想强制指定this这时候就需要call、apply、bind这三个工具。它们的作用本质上是同一个绕过默认的调用者关系手动指定this该指向谁。function greet() { console.log(你好${this.name}); } const user1 { name: 王五 }; const user2 { name: 赵六 }; greet.call(user1); // 你好王五 greet.apply(user2); // 你好赵六 const boundGreet greet.bind(user1); boundGreet(); // 你好王五call和apply的区别只在于传参方式call用逗号分隔参数apply用数组传参。bind则比较特殊它不会立即执行函数而是返回一个新函数这个新函数的this被永久固定为传入的对象。这里有个非常关键的细节bind返回的新函数this是不可再变的。即使你之后对这个新函数再用call、apply想改this也改不回来。只有new操作符能绕过bind的this绑定这在面对一些源码级别的面试题时会考到但实际开发中很少遇到。我后面在“常见问题”里会专门讲一个bind new的坑。显式绑定理解起来不难真正重要的是搞清楚它适合用在什么场景。最常见的场景就是回调函数丢失this。举个例子const counter { count: 0, add() { this.count; } }; document.getElementById(btn).addEventListener(click, counter.add);这段代码的问题是当你把counter.add作为事件回调传进去时浏览器在触发事件时会调用这个函数而调用它的是事件系统不是counter对象。所以回调执行时this指向的是按钮元素counter.add里的this.count就是undefined结果自然是NaN。解决办法就是用bind把this绑回去document.getElementById(btn).addEventListener(click, counter.add.bind(counter));这样不管事件系统怎么调用add内部的this始终是counter。2.4 new绑定与箭头函数两个特殊的存在用new调用函数时JavaScript引擎会做这几件事创建一个新对象把这个新对象的原型指向函数的prototype然后把这个新对象作为this传入函数执行最后如果函数没有返回对象就返回这个新对象。所以new绑定其实也是一种“指定调用者”的方式只不过这个调用者是系统帮你新建的。function Person(name) { this.name name; } const p new Person(测试); console.log(p.name); // 测试这里Person内部的this指向的就是new创建出来的新对象p。从调用者视角来看你可以理解为new在幕后做了一次“强行指定调用者”的操作把this指向了一个崭新的对象。箭头函数则完全不同。它没有自己的this它的this在定义时就确定了取决于定义它的那个位置所在的词法作用域。换句话说箭头函数的this不取决于“谁调用它”而取决于“它在哪定义”。const obj { name: 箭头函数测试, greet: () { console.log(this.name); } }; obj.greet();这个代码输出什么很多人以为输出箭头函数测试因为greet是obj的方法。但实际上输出的是全局的name或者undefined。为什么因为箭头函数没有自己的this它会向外层作用域找this。而对象字面量{}不构成作用域所以greet定义时所在的作用域是全局作用域this就是全局对象。如果你换成普通函数写法obj.greet()的this就是obj。这就是箭头函数和普通函数在this上的根本区别。理解了这个你就能明白为什么React类组件时代在render里绑定事件时要用箭头函数来保持this指向组件实例。3. 实操过程从调用点判断this指向的完整流程3.1 建立“调用点分析”思维纸上谈兵没意思真正要练的是拿到一段代码能快速准确地分析出每个函数调用的调用点。我自己的方法是走一套固定流程四步走第一步找出代码里所有函数调用标注调用形式。这一步是在脑子里过一遍划出每个函数被调用的位置。第二步判断调用形式属于哪一类是直接调用fn()还是作为方法调用obj.fn()还是通过call/apply/bind调用还是通过new调用还是箭头函数。第三步根据调用形式确定this直接调用看严格模式和全局方法调用看点前面是谁显式调用看传入对象是谁new调用看新对象是谁箭头函数看定义处外层作用域的this。第四步如果遇到复合场景比如bind之后的函数又被调用、方法赋值后又用call调用按照优先级和调用顺序逐步推理。这套流程看着简单但实际操作中需要大量练习才能形成条件反射。我下面拿几个经典场景逐个拆解。3.2 场景一方法赋值后调用const animal { name: 小猫, speak() { console.log(${this.name}在叫); } }; const speakFn animal.speak; speakFn();分析speakFn()是一个直接调用调用者不是animal而是全局环境。因此this指向全局对象非严格模式或undefined严格模式。所以speakFn执行时this.name大概率是undefined输出undefined在叫。这个题目是面试高频题本质就是考的“方法的引用被抽出来了调用者丢了”。修复方式有两种要么在赋值时用bind把animal绑上去要么不要在赋值后光秃秃地调用。3.3 场景二回调函数里的thisconst numbers [1, 2, 3]; const helper { factor: 10, multiply(arr) { return arr.map(function (item) { return item * this.factor; }); } }; const result helper.multiply(numbers);这里helper.multiply(numbers)调用时multiply内部的this是helper没问题。但map里的回调是一个普通函数它在执行时是map内部直接调用的调用者是undefined严格模式或全局非严格模式因此this.factor取不到helper.factor结果是item * undefined也就是NaN。如果把这个回调改成箭头函数就能正常得到[10, 20, 30]。为什么因为箭头函数没有自己的this它在定义时捕获了外层multiply函数作用域的this而multiply的this是helper。这就是所谓“词法作用域的this”——箭头函数把this流动到了定义它的那一刻。这种例子在业务代码里太常见了特别是在处理数组、Promise回调、异步函数时。我建议在所有这类场景里优先使用箭头函数简单又安全。3.4 场景三bind、call和new的联合使用这是一道比较狠的面试题function Foo() { this.name Foo实例; } const obj { name: obj }; const BoundFoo Foo.bind(obj); const instance new BoundFoo(); console.log(instance.name);我们来分析。Foo.bind(obj)返回一个新函数BoundFoo这个函数的this被固定为obj。但是当用new调用BoundFoo时new操作符创建了一个新对象并且这个新对象会覆盖bind的this绑定。所以在Foo内部this.name Foo实例里的this不是obj而是new创建出来的新对象instance因此instance.name是Foo实例。这个知识点官方文档里写得比较隐晦但实际验证过确实如此。记住一个结论new绑定的优先级高于bind。这是唯一一个能“打破”bind固定this的情况。这种题目在实际开发中遇到概率极低但在面试中属于区分中高级开发者的经典题。3.5 场景四事件监听与类方法在React类组件时代这个坑几乎人人踩过class Button extends React.Component { constructor(props) { super(props); this.state { clicked: false }; } handleClick() { this.setState({ clicked: true }); } render() { return button onClick{this.handleClick}点击/button; } }如果在render里直接写onClick{this.handleClick}等到点击事件触发时this已经丢了。因为React事件系统调用回调时不会以Button实例作为调用者。解决办法就是在constructor里加上this.handleClick this.handleClick.bind(this)或者在JSX里写成箭头函数onClick{() this.handleClick()}。这个场景几乎可以当作一个“this丢失的教科书案例”来理解类的实例方法本质上是一个普通函数它本身不自动绑定this只有通过实例调用时点前面才是实例。一旦你把它单独拿出来当作回调传出去调用者就变了。4. 常见问题与排查技巧实录4.1 回调函数中this丢失的三种修复方案实际开发中this丢失几乎都发生在回调场景。我总结了三种主流修复方案按使用频率排序。第一种绑定固定this用bind。这个前面已经说过适用于你已经明确知道回调执行时想要谁作为this。第二种利用箭头函数捕获外层this。箭头函数代码简洁没有多余的this绑定而且遵循词法作用域规则不会出现“this被调用者override”的问题。第三种在调用位置直接改成箭头函数包装。如果回调的this预期值就是当前调用点所在作用域的this那就是直接在代码里写() targetFunction(params)。这三种方案适用场景略有不同bind最通用能保留原函数引用箭头函数适合内联回调场景箭头函数包装适合需要传参数时。我在实际代码评审时往往会建议团队统一约定除非有特殊原因否则回调一律用箭头函数。4.2 定时器与异步操作中的thisconst timer { seconds: 0, start() { setInterval(function () { this.seconds; console.log(this.seconds); }, 1000); } }; timer.start();这个例子初看没问题实际上每次输出都是NaN。因为setInterval的回调是定时器系统直接调用的使用普通函数时this指向全局对象this.seconds是undefined加1之后变成NaN。很多人第一反应是改成箭头函数start() { setInterval(() { this.seconds; }, 1000); }这样确实能用因为箭头函数捕获了start方法作用域的this而start的this是timer。如果你更倾向于不用箭头函数也可以start() { setInterval(function () { this.seconds; }.bind(this), 1000); }这两种写法在结果上没有区别但箭头函数会让我写起来更舒服因为不需要在回调里再想“要绑谁”。4.3 解构赋值与方法提取引起的this丢失ES6解构特别方便但也特别容易在无意中丢thisconst store { count: 0, increment() { this.count; } }; const { increment } store; increment();这里increment()是光秃秃的调用调用者不是store而是全局所以this.count变成了NaN。类似的问题还会出现在把对象方法传给第三方库时比如array.map(store.method)、Promise.resolve().then(store.method)等。在代码评审中我经常提醒团队把方法单独拿出来用之前先想想它内部有没有依赖this。如果依赖就应该使用bind或者箭头函数包装来确保this不丢。4.4 如何用调试工具快速确认this指向有时候分析半天不如实际打断点看一眼。在Chrome DevTools里给函数内部加断点然后在Console面板执行console.log(this)就能直接看到当前的this指向谁。这一招非常快尤其适合排查复杂调用链。另外一个很有用的方式是利用Function.prototype.call的行为反推。如果你怀疑某个函数内部this可能不对可以在调用它之前显式地调用fn.call(某个对象, ...args)如果能修复问题说明问题确实出在this绑定上再进一步判断该怎么改。4.5 常见问题速查表我把开发中最常见的几种“this异常”场景整理成一个速查表方便你排查时快速对照。场景表现根本原因推荐修复方式对象方法直接调用正常调用者是对象本身无需修复方法赋值后调用this指向全局或undefined调用者变成了全局环境bind或箭头函数回调函数传参this丢失第三方库以自身上下文调用bind或箭头函数定时器/事件监听this指向window/元素宿主环境直接调用回调bind或箭头函数嵌套普通函数this与外层不一致普通函数不继承外层this箭头函数或var that thisnew调用bind返回函数指向新对象而非bind目标new优先级高于bind无需修复理解即可类方法作为事件回调this丢失方法未绑定实例构造函数里bind或箭头函数这张表覆盖了我在实际项目中遇到的绝大多数this问题。如果你的场景不在表里大概率属于“函数被以某种特殊方式间接调用”的情况回到调用者视角去分析就行。5. 写在最后我的练习方法与经验总结我教新人学this从来不让他们背规则我让他们做一件事找一段别人写的代码把里面所有函数的调用画出来标出每个调用点的调用者然后输出预测结果再实际运行验证。坚持练上十几道题基本就能形成肌肉记忆。这个方法听起来简单但真的比看十篇文章都管用。因为一旦你养成了“先看调用者再判断this”的习惯以后写代码时就会下意识地避免写出容易被误解的this绑定代码质量自然会提升。还有一个小技巧是团队项目里如果this问题反复出现我建议在代码规范里做一条约定回调函数优先使用箭头函数类方法如果作为回调使用要么在构造函数里bind要么就在调用点用箭头函数包一层。别小看这个约定它能直接砍掉一多半this相关的debug时间。最后再说一句别被“this”这个名字带偏了它并不是“这个函数所属的对象”而是“这次调用所属的对象”。区别在于前者是静态的、确定的后者是动态的、取决于调用方式的。理解了“每次调用都是一次独立的现场”你就能彻底掌握this了。
返回列表