尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

箭头函数 this 完全解析:从动态绑定到词法继承

箭头函数 this 完全解析:从动态绑定到词法继承 先说结论箭头函数之所以能让this不再丢失本质是它放弃了自己创建this的能力改为从定义它的外层作用域继承this。也就是说箭头函数里的this不是它自己决定的而是它“出生”在哪个函数里就把这个函数的this当作自己的this来用。这个设计解决了回调函数里this飘忽不定的老问题同时也带来了一些新的边界限制。这篇文章适合被setTimeout、事件监听、对象方法里this指向搞晕的读者也适合面试前想彻底理清 JavaScript 作用域和绑定规则的人。我平时排查这类问题会先问一句话这个this到底是在函数定义时确定的还是在函数调用时确定的如果答案是“调用时”那它就是普通函数的动态绑定存在丢失的可能如果答案是“定义时”那它就是箭头函数的词法绑定反而不会因为调用位置变了就换一个对象。下面按实际开发里最容易踩坑的顺序拆一遍。1. this 是怎么从“丢失”变成“不再丢”的1.1 普通函数this 是在调用时决定的理解箭头函数之前一定要先理解普通函数为什么会在某些场景下丢this。普通函数的this不是定义的时候确定的而是每次调用的时候根据调用方式确定的。同一个函数用不同方式调用this可能是完全不同的人。function show() { console.log(this); } // 独立调用 show(); // 非严格模式下指向全局对象浏览器里是 window // 严格模式下是 undefined const obj { name: obj, show: show, }; // 作为 obj 的方法调用 obj.show(); // this 指向 obj这就是最基础的隐式绑定规则函数作为某个对象的方法调用时this指向这个对象。一旦把方法从对象上拆下来独立传递出去原来的绑定关系就断了。const fn obj.show; fn(); // this 不再是 obj而是全局对象或 undefined很多人第一次遇到this丢失就是在回调函数里obj.show明明已经写好了为什么传给setTimeout之后this就不是obj了原因就是setTimeout内部实际上是独立调用了这个函数它不会像obj.show()那样把obj作为调用者传进去。1.2 回调里面 this 为什么容易丢看一个典型例子const counter { count: 0, add() { this.count; console.log(this.count); }, }; setTimeout(counter.add, 100);我经常拿这个例子给同事讲setTimeout(counter.add, 100)里面的写法看起来是把方法传进去了但到了定时器触发的时候函数是在定时器环境里被独立调用的调用表达式不是counter.add()所以this不会自动指向counter。结果就是this.count要么是undefined要么报错。再比如事件监听button.addEventListener(click, obj.handleClick);这行代码同样存在类似风险。addEventListener内部会以目标元素作为this来调用回调但如果你已经在handleClick里写了this.state之类的逻辑这个this就未必是你期望的对象。这类问题之所以普遍是因为 JavaScript 在设计时把this定位成了一个“调用上下文”而不是“定义上下文”。函数被传递一次调用方式就变一次this也跟着变。箭头函数改变了这一点它用“定义位置的外层this”把动态绑定固定了下来。2. 箭头函数用什么逻辑代替了动态 this2.1 词法 this从外层作用域继承箭头函数不绑定this这句话的准确意思不是“箭头函数没有this”而是“箭头函数不会创建自己的this”。当你在箭头函数里面写this时它更像一个普通变量会沿着作用域链一层一层往上找直到找到最近的一个普通函数作用域然后使用那个函数当前的this。const obj { name: demo, say() { const arrow () { console.log(this); }; arrow(); }, }; obj.say(); // 这里 arrow 里的 this 沿作用域链找到了 say 函数 // say 的 this 是 obj所以 arrow 里的 this 也是 objarrow是在say内部定义的say调用时this是obj所以arrow拿到的就是obj。即使你把arrow传出去在别的地方调用this也不会变成调用者因为它早就“锁定”了定义时的外层this。const arrow obj.say; // 假如把 say 方法拆出来 const another { name: another, }; another.say obj.say; another.say(); // 这里的 say 的 this 是 another注意如果外层普通函数的this变化了箭头函数的this也会跟着变化。这不是说箭头函数自己会变而是它继承的那个外层this在变。function outer() { const inner () { console.log(this); }; inner(); } outer.call({ id: 1 }); // 这里 outer 的 this 是 { id: 1 }inner 打印 { id: 1 } outer.call({ id: 2 }); // outer 的 this 变成 { id: 2 }inner 打印 { id: 2 }箭头函数像一面镜子外层this指向哪里它就照出哪里。但它本身不会因为call、apply、bind而改变。2.2 一个反直觉的验证对象字面量里直接写箭头函数很多人以为“方法写成箭头函数this就指向当前对象”这是误解。对象字面量不是一个作用域它不生成独立的this。在对象字面量里写箭头函数箭头函数的外层作用域是对象所在的模块或全局作用域而不是这个对象。const name global; const obj { name: obj, foo: () { console.log(this.name); }, }; obj.foo(); // 这里的 this 不是 obj // 在浏览器非严格模式下this 可能是 window输出 global // 在 Module 环境下this 是 undefined访问 this.name 会报错这个例子每次讲到都值得单独强调一遍。箭头函数能不能拿到正确的this核心看它的定义位置而不是看它被放在哪个对象里。在对象字面量属性位置写箭头函数通常拿不到对象本身所以对象方法更合适用普通函数。3. 实际开发中箭头函数解决的是哪几类 this 丢失3.1 定时器、事件注册、异步回调最朴素的修复方案就是箭头函数。拿前面的计数器举例const counter { count: 0, add() { this.count; console.log(this.count); }, start() { setInterval(() { this.add(); }, 1000); }, }; counter.start();setInterval的回调是一个箭头函数它定义在start方法内部start的this是counter所以箭头函数里的this也是counter调用this.add()顺理成章。如果没有箭头函数传统方案一般是先存一份临时变量start() { const self this; setInterval(function () { self.add(); }, 1000); }临时变量方案没问题但读起来多一层心理负担。箭头函数直接把这种需求变成了语言特性代码里不需要额外声明self了。事件回调也能这样做const widget { init() { this.load(); }, load() { fetch(/api/data) .then((res) res.json()) .then((json) { this.render(json); }); }, render(data) { console.log(data); }, };fetch的.then回调如果写成普通函数this往往会变成undefined或全局对象。写成箭头函数后this自动保持widget链路通畅。3.2 数组遍历和链式调用数组的forEach、map、filter、reduce回调也经常导致this丢失。回调函数被数组方法内部调用时调用者不是原来的对象普通函数里的this不可控。const store { items: [1, 2, 3], factor: 10, calc() { return this.items.map(function (item) { return item * this.factor; // 这里 this.factor 大概率是 undefined }); }, };改成箭头函数calc() { return this.items.map((item) item * this.factor); }map回调里的this继承了calc方法的this也就是store本身代码简洁结果正确。这种场景在实际业务代码里出现频率很高特别是做数据处理、列表转换时。3.3 类中声明方法ES6 的 class 方法和普通函数一样遵循动态this规则。如果你把一个 class 实例方法传给事件处理函数同样会丢this。传统修复方式是在构造函数里bindclass Timer { constructor() { this.seconds 0; this.tick this.tick.bind(this); } tick() { this.seconds; console.log(this.seconds); } }使用类字段语法加箭头函数以后可以省去构造函数里的手动bindclass Timer { seconds 0; tick () { this.seconds; console.log(this.seconds); }; start() { setInterval(this.tick, 1000); } } const timer new Timer(); timer.start();这里的tick是实例上的一个箭头函数属性它定义在构造函数执行阶段外层作用域是构造函数而构造函数里的this指向新建实例所以this.seconds能正确指向实例。这个写法在现代项目里很常见但要注意浏览器兼容性和构建配置老环境可能需要 transpile。4. 箭头函数不是万能 this 解药4.1 构造函数和原型方法不能依赖箭头函数箭头函数没有prototype属性不能作为构造函数使用new一个箭头函数会直接报错。const Fn () {}; const instance new Fn(); // TypeError: Fn is not a constructor原因是箭头函数没有自己的this而new操作符的核心之一就是创建一个新对象并把这个新对象绑定为this。箭头函数从一开始就“拒绝”接收这样的this所以引擎直接禁止它作为构造函数。原型方法也一样。如果写在class里普通方法会挂到prototype上实例通过原型链共享。普通原型方法在调用时this是当前实例。若把原型方法写成箭头函数this固定为定义时的外层往往拿不到实例。class Box { constructor(value) { this.value value; } getValue () { return this.value; }; }这种写法确实能拿到实例的value因为类字段箭头函数创建在实例化过程中。但需要注意它没有挂在Box.prototype上而是每个实例单独拥有一份函数引用内存占用比原型方法高。业务中一般量级下影响不大但如果要追求极致的原型语义就使用普通方法。4.2 需要动态 this 的场景不适合箭头函数有些场景恰恰需要this随调用者变化这时箭头函数反而不合适。最典型的是事件监听中根据this获取触发元素button.addEventListener(click, () { console.log(this); // 这里不是 button而是外层 this });正确做法是使用普通函数button.addEventListener(click, function () { console.log(this); // button });如果你在箭头函数里也想要当前触发元素应该使用事件对象的currentTargetbutton.addEventListener(click, (event) { console.log(event.currentTarget); // button });所以不是说所有回调都应该用箭头函数。需要借助this来获得“调用者信息”时普通函数反而是好选择。对象方法里也不要默认使用箭头函数const user { name: Tom, greet() { console.log(Hello, ${this.name}); }, }; user.greet(); // Hello, Tom如果把greet改成箭头函数const user { name: Tom, greet: () { console.log(Hello, ${this.name}); // 输出错误 }, };箭头函数并不能让this指向user因为对象字面量不创建函数作用域。方法本身的标准语义就是通过调用者来确定this所以对象方法保持普通函数最稳妥。4.3 其他缺失能力箭头函数不仅没有自己的this还没有自己的arguments对象。传统函数内部的arguments是参数列表的类数组对象而箭头函数会沿作用域链找到外层函数的arguments。function outer() { const inner () { console.log(arguments[0]); }; inner(); } outer(first); // first箭头函数里的arguments其实是outer的arguments。如果需要确切拿到自己的参数更推荐使用 rest 参数const handle (...args) { console.log(args); };此外箭头函数不能作为 generator 函数使用不能定义yield表达式。call、apply、bind也无法改变箭头函数的this。const arrow () { console.log(this); }; const target { id: 1 }; arrow.call(target); // 箭头函数 this 仍然是定义时的外层不是 target这一点经常被忽略。面试时看到这类题目只要判断出是箭头函数就直接说“call没有效果”即可不要再去纠结传入对象会不会变成this。5. 写代码时的判断依据和排查方法5.1 五步判断是否该用箭头函数我在实际项目里一般按下面这个顺序判断能避免大部分this混乱。第一这个函数是否需要被new如果需要必须用普通函数或 class。第二这个函数是否需要访问自己的arguments如果需要建议用普通函数或 rest 参数。第三这个函数里的this是否需要随调用者动态变化比如事件监听回调里的this指向触发元素那普通函数更合适。第四这个回调是否需要保持外层函数的this比如setTimeout、ajax回调、Promise链、数组遍历回调箭头函数非常合适。第五这个函数是否会被作为独立函数传递出去如果会而且又希望this保持定义时的上下文箭头函数是最省事的方案。这五步里前面三步行不通就放弃箭头函数后面两步行不通才考虑箭头函数。顺序很重要。5.2 排查 this 异常的顺序如果发现代码里this不对我建议按这个顺序排查不要凭空猜测。先看函数是用function声明还是定义的。如果是箭头函数直接找定义位置的外层普通函数看那个函数调用时的this是什么。如果是普通函数看它被谁调用是obj.method()这种隐式调用还是独立调用。隐式调用时this是等号左边的对象独立调用时可能是全局对象或undefined。再看有没有call、apply、bind包裹。bind会把this固定到指定对象上并且返回新函数call和apply是调用时临时指定this。但如果先bind再call第一次bind的this仍然有效。最后看函数的传递链。如果一个函数被传了两次以上中间任何一步改变了调用方式都可能影响this。这时候可以临时在函数第一行打印this用实际输出判断绑定结果。function debug() { console.log(this is:, this); }打印一次通常立刻就能看出是哪个环节出了问题。6. 原理还原所谓 this 丢失其实是从动态绑定回到了静态绑定6.1 this 在 JavaScript 里为什么特殊普通变量和this的查找方式不同。普通变量沿着作用域链找定义在哪里就决定了能访问哪些变量。this是执行上下文的一个绑定普通函数每次调用都会重新生成一个新的this绑定取决于调用方式。箭头函数把自己的this从执行上下文中删除了。访问this变成了一条普通变量查找路径沿着词法作用域一层层向外找。所以箭头函数里的this是“静态”的不再受外部调用方式影响。这也解释了为什么箭头函数适合做“保留外层this”的操作。它不是魔法它只是把this当成了一个普通变量来继承。6.2 多种修复 this 的方案对比我把常见修复方案放在一张表里方便对照方案写法优点缺点临时变量const self this;兼容老环境直观需要手动命名嵌套多时容易乱bindfn.bind(this)创建固定 this 的新函数多一层函数包裹调用栈变深箭头函数() {}写法简洁天然继承外层 this不能动态绑定不能 new没有 argumentsclass 字段箭头函数tick () {}方法独立this 稳定每实例一个函数兼容性需注意没有绝对最好的方案。很多老项目里const self this依然好使但新代码里我更推荐箭头函数因为它让“谁能访问到哪个this”变得可预测。6.3 推荐实践写新代码时我的个人经验是回调函数里需要外层this优先箭头函数对象方法和构造函数保持普通函数事件回调如果需要触发元素用普通函数或event.currentTarget类方法传递到外部使用类字段箭头函数或构造函数里bind。如果是在 TypeScript 项目里这些规则也适用只是类型系统能帮助提前发现一部分this使用错误。不过类型检查代替不了运行时绑定规则该理解还是要理解。踩过几次之后你会意识到this丢不丢关键不在于用没用箭头函数而在于你把函数定义在哪里、又是从哪个调用点把它传出去的。箭头函数只是给了你一个固定this的工具但工具的使用边界永远得靠对作用域和调用方式的理解来兜底。
返回列表