ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue中this失踪案:回调函数this丢失原因与解决方案

Vue中this失踪案:回调函数this丢失原因与解决方案 开头先放个真实场景。上个月有个刚转Vue的同事跑来问我为什么我在setTimeout里面写this.saveData控制台直接报TypeError: this.saveData is not a function我明明在methods里定义了啊。这个问题我太熟了。Vue开发里的this失踪案几乎是每个新手必修的入门课也是Vue前端面试题里的常客。你在模板事件、生命周期钩子、computed里写this它都好端端地指向当前组件实例可一旦进了回调函数它就凭空消失了要么变成undefined要么悄悄变成window怎么都找不着。这篇文章就拿我实际处理过的几次排查记录展开把this在Vue回调函数里失踪的原因、原理和解决方案一次讲透。不管你是Vue2老项目踩坑还是Vue3刚起步遇到this undefined都可以直接照着笔记排查。1. 失踪案现场三个逼疯新手的典型场景先还原我最近几个月里处理过的三份真实报错记录大家可以对照一下自己踩过哪个。这三类场景基本覆盖了Vue开发里九成以上的this问题。1.1 setTimeout回调this突然不在了某个后台管理项目的搜索列表功能代码简化后长这样export default { methods: { initData() { // 模拟延迟一次搜索请求 setTimeout(function () { this.getList() }, 1000) }, getList() { // 拉列表数据 console.log(getting list...) } } }页面一加载就报错TypeError: this.getList is not a function。新手的第一个反应是我方法名拼错了——不是getList确实在methods里定义着但你在setTimeout这个回调函数里拿不到this。这个场景最典型因为它用到了一个日常开发几乎天天碰到的API而且报错信息非常容易误导人。1.2 数组遍历this变成window另一个项目里有个处理下拉框选项去重的逻辑当时报错信息变成了另一种画风methods: { mergeOptions(list) { const map {} list.forEach(function (item) { if (item.hasChildren) { map[item.id] this.getChildrenIds(item.id) // 报错this.getChildrenIds is not a function } }) return map }, getChildrenIds(id) { // 处理子节点 } }这个更迷惑。forEach是数组自带的遍历方法参数里写的明明是一个局部函数为什么this就丢了呢更坑的是在非严格模式下这个this甚至不会报undefined而是悄悄变成window。如果你一查window.getChildrenIdsundefined心里更堵了。这种不报错但是结果不对的问题比直接报错更折磨人因为你可能要在数据渲染异常之后回头排查很久才找到源头。1.3 路由守卫this直接undefined到了Vue3项目里问题又换了副面孔。我在路由配置文件里给某个页面加前置守卫检查登录状态// router/index.js { path: /dashboard, component: Dashboard, beforeEnter: (to, from) { // 想调用当前组件实例的方法发现根本没有this console.log(this) } }这里this直接打印出undefined。很多刚用Vue3的同事会问路由守卫不是能拿到to和from两个参数吗怎么连组件都拿不到这就是第三种失踪形态也是Vue新手最不设防的一类场景。因为它不发生在业务代码里而在路由配置这种边角料位置你根本不会往this绑定方向想。三个场景摆在一起其实指向同一个底层问题回调函数和普通方法调用在JavaScript的this绑定机制里就是两种完全不同的处境。下一节我把这个机制彻底拆开讲清楚。2. 定案关键JavaScript的this绑定规则到底怎么走要破案先得搞清楚this到底是怎么被决定的。这里有个很多人忽略的核心概念this不是在函数定义时决定的而是在函数调用时决定的。同一个函数换一种调用方式this完全可能指向另一个对象。2.1 四条绑定规则JavaScript的this绑定绝大多数逃不出下面这四类绑定类型调用方式this指向默认绑定独立调用如 fn()非严格模式指向window严格模式或ES模块里是undefined隐式绑定对象方法调用如 obj.fn()调用它的那个对象obj显式绑定call / apply / bind你传入的对象new绑定new Fn()新建出来的实例拿最常见的隐式绑定举例const user { name: Hank, say() { console.log(this.name) } } user.say() // 输出 Hank但如果你把say从user身上取出来再单独调用const say user.say say() // 输出 undefined严格模式下直接报错这就是所谓的隐式绑定丢失。函数本身不知道自己属于谁它只是在某个时刻被user调用了一次而已。你把它单独拿出来放到全局环境下执行this自然就按默认绑定规则走了。说人话版本this相当于一张工牌。company.employee.call(employee)的时候工牌是employee的但如果这个函数被单独抽出来传给了别的模块别人执行它时不会记得这函数原来挂在user身上只会把它当成普通函数来调这时候工牌就没了。2.2 回调函数为什么是重灾区回调函数就是隐式绑定丢失的高发区。原因是回调的调用方根本不在你的掌控范围内setTimeout是浏览器定时器线程回调执行的forEach是数组内部工具方法执行的事件监听器是事件系统触发的。它们执行你的回调函数时不会关心这个函数本来是从某个Vue组件里拿来的只会把它当作一个独立函数去调用于是全部走了默认绑定。再举一个特别上头的例子const app { count: 0, init() { document.querySelector(button) .addEventListener(click, this.increase) }, increase() { this.count } }addEventListener把this.increase当作回调传了出去。事件触发时浏览器事件系统执行这个函数但不会帮你把this绑定到app上。这里this指向的是被监听的button元素于是this.count读取的是button上的count属性结果是undefined最终this.count得出的值是NaN。你点按钮点了十几次数字纹丝不动——比找不到this更让人抓狂。2.3 箭头函数唯一的例外箭头函数之所以能成为Vue回调场景的救星是因为它没有自己的this。它的this是在定义的时候从外层作用域捕获的之后无论谁调用它都改变不了这个绑定。const app { count: 0, init() { // 箭头函数捕获init执行时所在作用域的this const fn () { this.count } fn() } }因为init是被app调用的init内部作用域的this是app所以箭头函数捕获到的this也就是app。这就是为什么箭头函数能成为回调函数里救this的全能选手它不关心以后谁调用它只关心定义时的外层this是谁。需要补充一个容易记混的点箭头函数不是this指向变了而是根本没有this严格来说它是对外层this的引用。所以在箭头函数里call/apply/bind改this是无效的。记忆方法就是回调里this丢了先问自己这个回调能不能改成箭头函数。3. 特殊地形Vue框架帮了忙也埋了坑原理看完回到Vue框架本身。为什么在模板里写clickhandleClickhandleClick内部的this永远没问题为什么一到setTimeout里就出问题这里面有一条非常重要的分界线Vue帮我们绑定了methods里的函数但只有框架自己能管到的地方才会生效。3.1 Vue2的methods被强行绑定的thisVue2源码在初始化实例时会对methods里的每个方法做一次bind处理把它绑定到组件实例上。所以你在模板、methods的其他函数、生命周期钩子里写this.xxxthis始终指向组件实例。这也是为什么绝大多数Vue代码里你根本感觉不到this绑定问题的存在觉得this天生就该指向组件。但关键分界线在于Vue只绑定了methods里声明的那几个函数本身并不会修改methods内部嵌套的其他普通函数。当你写methods: { load() { setTimeout(function () { this.doSomething() // 这个匿名函数不是Vue的methods成员 }, 1000) }, doSomething() {} }setTimeout里的这个匿名函数Vue从头到尾没碰过它。浏览器执行它时按默认绑定处理this自然就丢了。反过来如果你把load方法本身作为回调传出去由于load已经被Vue绑定过它的this依然指向组件实例反而是安全的。很多人把这个坑搞反——以为是methods方法传出去丢了实际真正丢的是methods内部嵌套的普通函数。这里还要补充一个容易忽略的细节Vue2的methods方法本身不要写成箭头函数。因为箭头函数没有自己的thisbind对它是无效的。如果你在methods里声明了箭头函数方法内部使用this时它指向的是定义时外层作用域的this而不是组件实例。正确组合是methods方法本身用普通函数声明内部嵌套的回调用箭头函数。3.2 Vue3里this变了setup没有thisVue3的组合式API把这一套又简化了一层。setup函数在组件初始化阶段执行这个阶段的this是undefined。在script setup语法下官方直接鼓励声明ref、reactive等响应式常量不再依赖this。但注意Options API在Vue3里依然存在methods、computed、watch里使用this的规则和Vue2保持一致。也就是说如果你在Vue3项目里继续用Options API上面那些坑一个都不会少如果改用组合式API把业务函数定义成普通函数循环、定时器、Promise回调里直接调用函数名即可因为普通函数不依赖this。这也是Vue3从设计层面消灭this失踪案的一个重要考量。3.3 路由和插槽this在两个夹缝里路由守卫是典型的this真空区。全局守卫不管是beforeEach、beforeEnter还是afterEach都注册在router实例上在组件生命周期之外运行里面没有组件实例可供绑定写this必然得到undefined。这里需要区分的是组件内守卫beforeRouteLeave和beforeRouteUpdate这两个钩子执行时组件实例已经存在可以直接访问this只有beforeRouteEnter特殊因为此时实例还没创建没法访问this但可以通过next回调的vm参数拿到实例。插槽的情况更微妙。平时大家写作用域插槽template #default{ item }插槽内容里的事件绑定比如clickhandleClick这个handleClick的this指向父组件实例因为插槽内容编译在父组件作用域里。作用域插槽只是让子组件额外提供数据给插槽内容使用并不会改this。真正容易出问题的是在script里用h函数手动渲染插槽或者把插槽内容封装成一个函数传给第三方库时这时的回调this才可能变成undefined。日常模板开发遇到插槽this问题的概率不高但遇到动态路由和异步组件的场景就要开始警惕了。4. 逐案破解每个场景的可行解法回到第一节的三个典型场景逐个给出可以直接抄走的解法顺带补充几个开发中出现频率同样很高的变体场景。每个解法我都会说明为什么这么写方便你举一反三。4.1 定时器和异步回调箭头函数直接救setTimeout场景的修复最直接把回调函数改成箭头函数methods: { initData() { setTimeout(() { this.getList() }, 1000) }, getList() { console.log(getting list...) } }原理就是前面说的箭头函数捕获了initData执行时作用域里的this而initData是Vue绑定过的methods方法它内部的this是组件实例所以回调里顺着捕获关系就拿到了组件实例。同理适用于setInterval、requestAnimationFrame、以及大部分Promise场景。这里补充一个新手容易误会的细节async函数里await之后的代码this不会丢。因为async函数作为一个整体它的this绑定是由外层决定的await并不会改变函数内部的this指向。真正会丢的从来都是把函数交给别人去调用这种场景。4.2 数组遍历回调thisArg参数组合拳forEach和map、filter、find、some、every这些数组方法基本都接受一个可选的第二个参数thisArg它会把回调里的this显式指向你传入的对象methods: { mergeOptions(list) { const map {} list.forEach(function (item) { if (item.hasChildren) { map[item.id] this.getChildrenIds(item.id) } }, this) // 第二个参数传入 this return map }, getChildrenIds(id) { // ... } }不过我实测下来箭头函数写法可读性更好团队评审时也更愿意放行list.forEach((item) { map[item.id] this.getChildrenIds(item.id) })两种都能跑箭头函数一眼就能看出this来自外层不用去翻API文档确认thisArg参数的存在。需要特别提醒的是sort和reduce这两个方法没有thisArg参数遇到它们就老老实实用箭头函数或者bind。这个坑我见过不止一次有人把forEach的经验迁移到reduce上结果误以为reduce也支持第二个参数是this实际写了个初始值进去逻辑直接跑偏。4.3 Promise与事件监听器Promise场景的常见错误长这样methods: { fetchData() { // 错then里用了function回调 fetch(url).then(function (res) { this.handleRes(res) // 这里this必然丢失 }) }, handleRes(res) {} }修复方案两个换成箭头函数或者bind(this)。我推荐箭头函数原因不只是简洁而是Promise链后续的.catch、.finally如果要继续用this箭头函数一次修改就能覆盖整条链。事件监听器的修复要更小心。addEventListener注册的回调在移除事件监听时必须保持同一个函数引用。如果你在监听时写this.handleResize.bind(this)每次都会生成一个新函数后面removeEventListener用另一个bind结果去解绑根本解不掉。我在实际项目里的标准做法是在mounted里用一个箭头函数包一层并保存引用data() { return { _onWindowResize: null } }, methods: { handleResize() { this.updateSize() } }, mounted() { this._onWindowResize () this.handleResize() window.addEventListener(resize, this._onWindowResize) }, beforeDestroy() { // Vue3 里记得改成 beforeUnmount if (this._onWindowResize) { window.removeEventListener(resize, this._onWindowResize) } }这里面有个常见的纠结点既然Vue已经绑定过methods方法了直接监听this.handleResize不行吗行this不会丢。我习惯再包一层箭头函数是为了保持整个项目里回调一律箭头函数的统一风格避免有人在一个地方直接传methods方法、另一个地方包箭头函数代码风格来回漂移。4.4 路由守卫中的正确拿this姿势全局路由守卫确实拿不到组件实例但有三条路可以走对应不同的职责需求。方法一使用组件内的beforeRouteEnter守卫这是唯一一个可以通过next回调拿到组件实例的内置路由守卫// 组件内部 beforeRouteEnter(to, from, next) { next(vm { // vm就是组件实例可以调用vm上的任何方法 vm.checkAuth() }) }方法二把判断逻辑放到全局状态管理里路由守卫只负责读状态不依赖组件this。这个是我最推荐的方案因为生产项目里路由守卫的职责边界本就应该是校验和跳转不该去调组件里的业务方法// router/index.js import store from /store beforeEach((to, from, next) { if (to.path.startsWith(/admin) !store.state.isLogin) { next(/login) } else { next() } })方法三把配置放到路由meta里在组件内部watch $route来响应路由变化既能拿到this也让路由和组件逻辑解耦。适合一些需要组件内部状态参与判断的页面级逻辑。动态路由配置里如果也遇到beforeEnter回调里拿不到this优先考虑方法二把公共状态放到store里。用store替代this几乎是所有路由回调场景的统一答案。5. 避免下次再犯一身习惯和一套排查链路把原理和方案都过完我不希望你只是会修这一个坑最好能从项目层面把这类问题根治一部分。下面这三块内容是我在实际项目管理中沉淀下来的东西没有一条是教科书上直接抄的。5.1 代码习惯的硬性约定我给自己带的项目定了几条关于this的硬性约定全部写进了团队的代码规范文档里。第一条在methods内部或普通函数内部只要新嵌套回调函数一律使用箭头函数。偶尔有需要访问事件对象的情况也写成e {}的箭头形式不要用function。因为事件对象可以从参数里拿没必要为了这个放弃this绑定。第二条不把一个methods方法毫无包装地到处传。如果确实要把方法作为回调传给别人比如传给子组件props、传给第三方库优先用箭头函数包一层// 推荐接收方怎么调都不影响this Child some-event() handleIt() / // 不推荐把this.handleIt直接传出去 Child some-eventhandleIt /不是说第二种一定出错因为Vue已经绑定过methods方法this大概率还在。但一旦接收方内部再做解构、赋值、嵌入调用链后续所有环节的this判断都会变得难以追踪。用箭头函数包装后接收方拿到的是一个完全自包含的函数心智负担最小。第三条Vue3新代码优先使用组合式API。把业务逻辑抽成普通函数函数之间用参数和返回值传递数据不要依赖this.state这类写法。把这句话结合今天的主题看你就能理解它其实是在从代码结构上绕开this绑定的心理负担。5.2 自查与排查链路遇到任何this不对的bug按下面这套链路走一遍通常五分钟内能定位第一步先确认出错的函数是普通函数还是箭头函数。如果是箭头函数this一般不会丢真丢了说明外层作用域的this本身就不对比如外层函数就是独立调用。第二步找到这个函数被谁调用。看调用点是obj.method()这种对象方法调用还是作为参数传出去之后的独立回调。重点关注两种赋值现场const fn obj.method以及someApi(this.method)。第三步在可疑函数第一行打印this看不清楚就再打印一次console.trace()看完整调用栈我排查这类问题必用这一招。对比正常情况下this的形态就知道它是在哪个环节被换掉的。第四步确认是回调丢失后按第四节的方案修复。如果修复后依然不对再检查是不是某个库或者生命周期隐藏了对回调的额外包装用bind显式绑定并留意返回值。工具链上ESLint官方推荐的Vue规则集能拦截掉一部分明显的this引用错误。编辑器方面Volar会在Vue单文件组件里自动提示methods中this的类型新项目建议直接切换过去。Vue2老项目则务必保留Vetur或对等工具的类型提示能力配合console.trace能省下大量排查时间。5.3 用组合式API从根源解决最后聊聊我观察到的趋势。Vue3组合式API出来后很多新项目已经不写this了。setup里定义的是一个一个变量和普通函数import { ref, onMounted } from vue export default { setup() { const list ref([]) const getList () { // 这里根本不需要this list.value [item1, item2, item3] } onMounted(() { getList() }) return { list, getList } } }你在定时器、Promise、forEach回调里直接使用getList这个闭包变量闭包变量会自动捕获定义时作用域里的list不需要担心this去向。这也是我在新项目里极力推荐组合式API的核心原因之一不是因为它更炫而是它让新手少踩一个隐形雷。如果你正在从Vue2往Vue3迁移或者刚开始学Vue3我的建议是不必刻意回避Options API但如果新代码里涉及大量回调嵌套尽量用组合式API组织。老项目改造成本太高就把前面几节的解法用熟尤其是箭头函数和bind足够应对99%的this问题。最后分享一个我自己的小习惯每次怀疑this出问题我都会先问自己一句这个函数现在是谁在调用——而不是这个函数属于谁。大部分时候把这个问题想清楚答案就已经出来了。this不会无缘无故失踪它只是换了一个主人。如果你现在正卡在某个报错上先别急着改代码把你报错的那一行和最近的调用处贴出来对照一下大概率就是上面某个场景。希望这份排查笔记能帮你把this失踪案一次破干净。
返回列表