多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Vue组件中this丢失之谜:从JavaScript绑定机制到回调函数修复方案

Vue组件中this丢失之谜:从JavaScript绑定机制到回调函数修复方案 并不是标题党。几乎每个写过Vue的开发者都撞上过这么一幕在methods里定义了一个方法模板里click调用一切正常this妥妥指向组件实例。然而有一天你把它丢进setTimeout、addEventListener或者某个第三方库的回调里this突然就变成了undefined甚至直接给你报一个“Cannot read properties of undefined”。明明函数还是那个函数代码看起来也没动但结果就是天差地别。这就像你出门前把钥匙放进外套口袋走了两步一摸钥匙不见了。Vue组件里的this就是那把钥匙回调函数就是那“走两步”。这篇文章想把这桩“this失踪案”彻底讲明白为什么回调函数里拿不到this、JavaScript的this到底是怎么决定指向的、以及有哪些可以直接抄作业的救回方案。内容覆盖从this底层绑定机制到Vue组件实例的指向逻辑再落到具体代码写法不绕弯子适合刚接触Vue的新手也适合被this坑过好几次的老开发查漏补缺。1. 先复盘一下案发现场——问题复现与具体现象1.1 最常见的三个“this失踪”场景先看最典型的场景。我在工作里遇到、也在各个前端社区回答里反复看到的基本就三种每种的报错方式还不太一样。第一种是定时器场景。在Vue组件的mounted里写mounted() { setTimeout(function() { console.log(this.message); }, 1000); }正常来说你期望1秒后打印出data里的message实际得到的却是undefined或者直接报错。这个场景太经典了几乎每个新手都从这里开始怀疑人生。第二种是事件监听场景。你给window或者某个DOM元素绑定事件mounted() { window.addEventListener(click, function() { this.someMethod(); }); }点击页面的时候报错“this.someMethod is not a function”。明明这个方法就在组件的methods里定义得明明白白怎么到这里就“不是函数”了。第三种是数组方法场景。在methods的方法内部用forEach、map配合普通函数methods: { handler() { this.list.forEach(function(item) { // 在这里面访问this.xxx会失败通常是undefined }); } }这类问题有个共同点回调函数执行的时候this已经不再指向Vue组件实例了。很多人第一反应是“是不是我的Vue版本有问题”“是不是我配置哪里不对”其实这和Vue版本没有任何关系问题出在JavaScript本身的this机制上。1.2 为什么说这口锅不该Vue来背我想先把结论放在前面你在Vue的methods里定义的普通方法调用的时候this能拿到组件实例这不是Vue的魔法而是Vue在内部帮你做了方法绑定。具体来说Vue初始化组件实例的时候会遍历methods对象里的每一个方法把每个方法都绑定到实例上。这也是为什么你在模板里写clicksomeMethod方法内部用this访问data和methods都畅通无阻。Vue在内部通过bind这类手段保证了它管理范围内的方法调用时this稳定指向组件实例。但是当你把这个方法或者一个新写的function传给setTimeout、addEventListener、或者任何第三方库时这个函数就离开了Vue的“管辖范围”。在那个环境里调用者变了this指向自然跟着变。说句公道话不是Vue没收好你的this而是你在Vue的管辖区域以外调用了一个函数它不认Vue的规矩了。打个比方在Vue组件里this就像你在自己家里钥匙在你手里每个房间随便进。回调函数相当于你走出家门把钥匙交给了一个陌生人保管那个人不是你钥匙自然也不是你的。2. this绑定机制拆解——搞清楚JavaScript到底怎么定this2.1 最常见的四种绑定方式要侦破这桩失踪案光看现象没用得把JavaScript决定this的底层规则拆开。这里先说一个很多新手没意识到的事实JavaScript函数的this不是在定义时决定的而是在调用时决定的。同一个函数用不同方式调用this指向完全可能不同。我把常见的调用方式概括成四种。第一种是默认绑定。函数不带任何修饰地直接调用function foo() { console.log(this); } foo();这种写法在非严格模式下this指向全局对象浏览器里就是window在严格模式下this就是undefined。Vue CLI创建的项目默认启用了严格模式所以你在回调函数里看到this打印出来是undefined完全符合规范。第二种是隐式绑定。函数作为对象的方法被调用时const obj { name: 张三, foo: function() { console.log(this.name); } }; obj.foo();此时this指向obj输出“张三”。这就是为什么Vue组件的方法在methods里通过this.someMethod()的方式调用时this能指向组件实例。调用方式是this.someMethod()this在调用点上就是组件实例本身。第三种是显式绑定。使用call、apply、bind来人为指定this指向。这个后面讲解决方案时会重点用到。第四种是new绑定。用new调用一个构造函数时this指向新创建的那个对象。理解了这四种绑定再看回调函数的困境就清楚多了。2.2 回调函数为什么总在“丢”thisjs里的setTimeout接收一个函数作为参数到时间后就把它当作普通函数来调用。从调用方式上看本质上等同于const fn function() { ... }; fn(); // 默认绑定这就是前面说的默认绑定。所以不管这个函数定义在哪里就算定义在Vue的methods里一旦被setTimeout内部拿出来当作普通函数调用this就只能按照默认绑定来走严格模式下就是undefined。事件监听也是同样的道理。addEventListener把回调挂到目标上以后触发时是以事件目标为this去调用回调的。给window添加事件回调里的this就是window给一个button添加回调里的this就是那个button。反正不会是你心心念念的Vue组件实例。forEach的情况稍微特殊一点。forEach的第二个参数可以传一个thisArg用来指定回调里的this。如果你不传回调函数又是普通函数那回调里的this就是undefined严格模式或者window。很多老手在Vue项目里宁愿用for循环也不用forEach不是因为forEach性能差就是因为它这个this太容易出问题了。到这里这桩失踪案的“作案机制”其实已经清楚了回调函数内部会以它自己的方式重新决定this定义位置和原来的调用方式都不作数。3. Vue组件里的this到底指向什么——一个被忽略的关键细节3.1 Vue实例的this是什么结构在Vue的选项式API里组件方法的this指向组件实例这个实例是一个对象上面挂载了data里的响应式数据、methods里的方法、computed里的计算属性。如果你在控制台打印一个Vue组件实例会看到很多带下划线或美元符号的属性那就是Vue内部为响应式系统准备的东西。你在methods里写this.message本质上是访问这个实例对象上的message属性。这个属性由data选项经过响应式处理后挂上去所以你不仅读得到它的值改了之后视图还会跟着更新。Vue官方文档里有一句关键的说明Vue在实例化时会自动将methods中的方法绑定到实例上以保证事件监听时this始终指向组件实例。注意这里说的“事件监听”主要指模板里的click这类用法。Vue编译器在生成渲染函数的时候已经帮你把this的正确指向处理好了。这也是很多新手混乱的根源在模板里用clickhandler挺好用的于是以为这个方法走到哪里this都靠谱结果一传进setTimeout就翻车。本质上是没有意识到“Vue帮你绑了”和“函数本身天然如此”是两回事。Vue帮你绑的是它管得到的范围管不到的范围它也无能为力。3.2 为什么箭头函数在Vue组件里不能乱用还有一个容易搞混的点值得单独拎出来说既然箭头函数不改变this那在methods里是不是可以把所有方法都写成箭头函数答案是不能一刀切。原因在于Vue的methods选项本身不推荐写箭头函数。methods里的方法被Vue绑定到实例上时Vue期望这些方法是普通函数这样它才有办法把this动态绑定到实例上。如果你在methods里写箭头函数那这个箭头函数的this在定义时就被固定成了定义处的词法作用域——也就是组件选项对象所在的作用域通常是模块作用域而不是组件实例。结果就是在methods里用箭头函数定义的某个方法内部访问this.message会得到undefined。打个比方普通函数是“你出门前临时决定带哪把钥匙”箭头函数是“你早就把钥匙名字写死了”。普通函数灵活但容易丢箭头函数稳定但有时候稳定过了头指向的根本不是你想要的实例。那箭头函数什么时候用回调函数内层可以用而且强烈推荐。比如mounted() { setTimeout(() { console.log(this.message); // 这里的this是组件实例 }, 1000); }这个写法之所以有效是因为箭头函数本身没有自己的this它继承了定义时外层作用域的this。这个箭头函数的外层是mounted函数而mounted是Vue以组件实例为this调用的所以箭头函数拿到的this就是组件实例。简单说箭头函数的值取决于它写在哪一层不在它自己身上。4. 救回this的四个方案与对比——直接抄作业4.1 方案一箭头函数包裹最推荐处理回调函数this丢失最简单直接的办法就是把普通函数换成箭头函数。在Vue组件方法内部不管是setTimeout、事件监听还是数组遍历一律用箭头函数把this固定在组件实例上。mounted() { setTimeout(() { this.fetchList(); }, 1000); window.addEventListener(click, () { this.handleClick(); }); this.list.forEach(item { console.log(item, this.someData); }); }这里要强调一个关键前提箭头函数拿的是定义处外层作用域的this。上面的三个箭头函数外层都是mountedmounted由Vue以组件实例为this调用所以箭头函数内部this正确指向组件实例。如果箭头函数写在模块顶层外层this是undefinedESModule环境那你照样拿不到东西。所以结论是箭头函数要用在合适的位置不是用了就万事大吉。4.2 方案二bind方法显式绑定如果不想改动函数定义的方式比如你这个方法是个公共方法既要在Vue组件里用也要能够独立调用那就用bind显式绑定。mounted() { setTimeout(this.fetchList.bind(this), 1000); }bind的作用是创建一个新函数这个新函数无论被谁调用this都固定为传入的第一个参数。bind生成的函数是永久绑定这一点和call/apply不同。call和apply是在调用时临时指定一次thisbind是产出一个this固定死的副本。使用bind时要特别注意一点bind会创建新函数。如果你用bind绑定了一个事件监听回调后面想移除监听必须保存一下绑定后的引用mounted() { this.boundHandler this.handleClick.bind(this); window.addEventListener(click, this.boundHandler); }, beforeUnmount() { window.removeEventListener(click, this.boundHandler); }不保存引用的话removeEventListener里的函数和addEventListener里的函数不是同一个函数事件永远解不掉内存泄漏就是这么来的。这个坑不少有几年经验的老手也踩过我自己早期就吃过这个亏。4.3 方案三定义时缓存this到变量在箭头函数还没普及的年代最经典的做法是先把this缓存到一个变量里回调函数里用这个变量。mounted() { const vm this; setTimeout(function() { console.log(vm.message); vm.fetchList(); }, 1000); }这个方案看上去有点笨但某些场景依然有价值。比如代码要兼容非常老的浏览器环境或者某些第三方库对函数类型有要求不认箭头函数那这个方案就是稳妥的选择。它的逻辑很直白既然回调函数里的this不靠谱那就不指望它在外层靠谱的作用域里先把this存下来。你可以自己定义变量名常见的有vm、self、_this我习惯用vm因为在Vue生态里vm通常就代表组件实例语义清晰。4.4 方案四使用Vue的实例方法来规避Vue自身提供的实例方法在某些场景下能帮你直接绕过this问题。最典型的就是$nextTick。如果你改了数据想等DOM更新后做点什么有的同学会用setTimeout 0来模拟但更标准的写法是methods: { updateMessage() { this.message 新内容; this.$nextTick(function() { // 这里的this竟然能拿到组件实例 console.log(this.message); }); } }$nextTick的回调里Vue已经把this绑定到了组件实例所以这里写普通函数也没事。你可能会问前面不是说Vue不背锅吗对Vue不会自动帮你绑定所有回调但$nextTick是Vue自己的API它完全有能力规定回调执行时的this它定义了this指向组件实例。既然是Vue自己的API你用的时候就可以依赖它的this绑定规则。这个方法可以延伸到Vue 3里的watch、onMounted等组合式函数不过组合式API里大家基本不写this了直接用setup上下文解出来的变量即可这个后面再说。4.5 方案对比什么时候选哪种上面四个方案没有绝对的优劣关键看场景。方案适用场景注意点推荐指数箭头函数在组件方法内直接写回调确认箭头函数定义位置的外层this五星bind绑定函数需要复用、需要解除监听记得保存绑定后的引用四星缓存this变量老代码兼容、特殊库场景代码略丑但稳定可靠三星$nextTick等Vue实例APIVue自身提供的异步场景只对Vue的API生效管不了第三方库四星我个人最常用的组合是内部回调一律箭头函数需要跨方法复用或者动态解除监听时换bind。缓存this变量的写法现在用得少了但有时候在维护老项目的时候还会见到了解它才能看懂老代码在想什么。5. 常见报错与排查心法——遇到问题先别慌5.1 报错信息对照表我整理了一下平时遇到比较多的几种报错以及各自该往哪个方向排查。新手拿不准的可以直接对照着看。报错信息出现场景排查方向Cannot read properties of undefined (reading xxx)回调函数里访问this.xxx检查回调是否为普通函数改用箭头函数或bindthis.xxxx is not a function回调里调用methods方法确认回调执行环境的this指向用方案一或二处理Cannot read properties of undefined (reading message)定时器或事件回调里访问data数据确认this指向为undefined还是window按需求绑定message is not defined模板中访问未定义属性检查data是否声明、属性名是否拼错和this无关不过要提醒一句报错不一定都是this的锅。你在回调里访问this.someField如果someField本身就没在data或computed里声明就算是this指向正确结果依然是undefined。所以排查顺序要固定下来第一步确认this切向是否正确第二步再看字段到底存不存在。我见过有同学对着一个根本不在data里的字段查了一下午this方向错了越查越偏。5.2 三个排查实用技巧第一个技巧是打日志确认this。不确定this是什么的时候直接在回调函数第一行写console.log(this)什么都别猜。打出来是undefined基本可以锁定是this丢失打出来是预期的组件实例那就往数据方向查。这一步能帮你省下至少一小时。第二个技巧是必须区分Vue 3组合式API的情况。如果你用的是组合式APIsetup、script setup那思路完全变了。组合式API里this用得很少因为响应式数据都直接用变量声明了根本不需要通过this来拿。setup函数执行的时候this是undefined这一点和选项式API不一样。你在选项式API里学到的东西搬到组合式API要重新理解。第三个技巧也是我最想强调的排查这类问题的时候别急着改代码先把“调用这个函数的地方”找出来。this的真相永远在调用点不在定义处。回调函数的调用点可能在第三方库内部你没法直接看到但你可以推理“库内部是用什么方式调用这个函数的”。如果是普通函数调用那this就别指望了该绑就绑、该换箭头函数就换。只要想清楚这个推理链条大部分问题五分钟内能定位。6. 写在代码之外的几句经验核心的机制和方案都讲完了最后不想再重复什么总结就分享几个我自己写Vue、带新人时形成的习惯。第一我写methods里的方法时默认都用普通函数。这不仅是Vue官方建议的写法也是为了让方法本身保持“可被bind、可被call”的灵活性。如果我把某个方法从一开始就写成箭头函数那它就没法被动态绑定未来的复用空间会被追封。写代码的时候多留一手灵活性后面受益的是自己。第二我在写回调函数时第一反应是箭头函数不是bind。bind不是不好而是绑出来的函数是新的函数你需要额外管理引用。箭头函数直接用心智负担最小。只有当下回调函数需要复用、需要解除绑定时我才会用bind并且老老实实保存引用。第三遇到this相关的bug我习惯走一条小检查清单这个函数是谁调用的调用方式是什么回调有没有可能是普通函数调用如果回调是普通函数调用this有没有被绑定一条一条过下来问题基本能定位。这套排查流程我用了好几年比凭感觉改代码高效得多。最后再提一个容易被忽略的细节组件销毁之后别忘了移除事件监听器和清理定时器。在beforeUnmountVue 3或者beforeDestroyVue 2里做清理不只避免内存泄漏也避免了组件销毁后回调还在执行产生的各种诡异报错。这类报错有时候也表现为“this拿不到”但根源已经不是this而是清理时机没到。基础没打牢的时候这类报错特别迷惑人。说到底this只是JavaScript函数调用规则下的一个产物理解它背后的机制比死记“回调里要用箭头函数”这一条结论重要得多。
返回列表