先看清它的限制
Vue 2 在 2023 年底已经停止维护,但存量项目多,面试里照样问。 这份材料不按"背 API"来组织,而是围绕一个核心: Object.defineProperty 这个选择,带来了什么,又限制了它什么。 把这个想通了,$set、数组重写、Vue3 改用 Proxy 这些事全是同一个故事。
模板最终变成了一个函数
中间那步 optimize 是 Vue2 编译期最有价值的一步。
它给每个 AST 节点打上 static 标记,标记过的节点在
patch 时会被整棵跳过——不对比、不更新,因为它的内容不可能变。
这就是"编译期优化",也是 Vue3 把这条路走到极致(静态提升、Patch Flag)的起点。
- 运行时编译 vs 构建时编译:带
template选项的写法需要编译器(体积大);.vue文件在构建时就编译好了,运行时只需要 render 函数,所以生产包更小。 - render 函数里的
with(this):让模板里的msg能直接找到this.msg。严格模式下不能用with,所以编译产物不是严格模式代码。 _c/_v/_s分别是 createElement、createTextVNode、toString 的简写,为了减小产物体积。
响应式的核心:defineProperty★
initData 时递归遍历 data 的每个属性,
用 Object.defineProperty 把它们改写成 getter / setter。
读的时候收集"谁在用我",写的时候通知"用我的人该更新了"。
这套机制的三个盲区,全部来自 defineProperty 只能"预先给已知的 key 装钩子"这个本质:
✕ 装不上钩子的操作
obj.newKey = 1—— 新增时没钩子delete obj.key—— 删除拦截不到arr[0] = x—— 数组索引没装钩子arr.length = 0—— length 也拦不到
✓ 所以 Vue2 必须补两套方案
Vue.set / Vue.delete手动装钩子并通知- 重写数组的 7 个变异方法,手动 notify
- 为什么是递归全量遍历:初始化时就要把所有层级都装上钩子。深层大对象 → 启动时慢,这是 Vue2 的性能瓶颈之一。
Dep.target是个全局变量:它是"当前正在执行的那个 watcher",Watcher 求值前把自己设成 target,求值后复原。这是"依赖收集"的关键技巧。- 一个 watcher 可能对应多个 dep:渲染 watcher 用到了 msg 和 list,就同时被两边的 dep 收集。
$set 到底做了什么
this.$set(obj, key, val) 就是"帮你 defineReactive 一下,
然后手动通知一次"。它不是魔法,是给 defineProperty 的盲区打的补丁。
- 为什么已存在的 key 可以直接赋值:因为它早就被
defineReactive处理过了,setter 自己会 notify。 - 为什么需要
__ob__:这是 Vue 在响应式对象上挂的一个隐藏属性(不可枚举),指向 Observer 实例,用来判断"这个对象是不是响应式的"。 - 根数据对象上直接赋值也不行:
this.$data.newKey = 1同样不响应,得用this.$set(this.$data, ...)。
数组为什么要重写 7 个方法
defineProperty(性能扛不住,
而且 length 也拦不住)。所以 Vue2 换了个思路:
拦截能改变数组的方法本身,改完手动通知一次。
而 filter、map、concat、slice 返回新数组,不改变原数组,不需要拦截——用它们的结果重新赋值时,赋值本身就触发了 setter
至于"为什么不给索引装钩子",答案是两个:一是数组可能上万项,全量
defineProperty 代价太高;二是 length 变化
(arr.length = 0)根本没法用 defineProperty 有效拦截。
权衡之后,Vue 选择了"拦截操作方法"这条性价比更高的路。
__proto__的兼容性问题:IE10 及以下不支持改__proto__,Vue 会降级成"直接在数组实例上挂这 7 个方法",只处理被调用过的数组。observeArray:push 进去的新对象也要被 observe,否则新加的元素改起来不响应。- 副作用:这些方法的
enumerable被设成 false,所以for...in不会多出东西,但console.log(arr)展开时可能看不到它们。
组件的 data 为什么必须是函数
data 是对象,
所有实例就共享同一个引用,改一个全变。写成函数,每次实例化都返回一个全新对象。
三个组件的数字同时变成 1
Vue 会给出警告:
data functions should return an object(组件场景)
根实例 new Vue({ data: {...} }) 确实可以写成对象,
因为全局只有一个实例,不怕共享。但只要是被复用的组件,就必须是函数。
- 本质是 JS 的对象引用:
{ count: 0 }字面量在模块加载时只创建一次,之后所有实例都指向它。 - 这也是为什么 return 里面要新建对象:如果
return someSharedObject,问题一样存在。 - Vue3 用
<script setup>时不存在这个问题:setup本身就是函数,每次实例化都执行一遍。
computed 的缓存是怎么做的
dirty 标记。
依赖没变,dirty 就是 false,直接返回缓存值,
连求值函数都不会执行。
method 执行
method 执行 ← 模板里调两次就执行两次
注意最后那段:依赖变化时,computed 不会立刻重算,只是把
dirty 设为 true。如果不读它,就永远不算——这就是"惰性求值"。
- computed 也能写 setter:
{ get() {...}, set(v) {...} },用于v-model绑定一个派生值。 - computed 里不能有副作用:它是纯计算,不要在里面发请求或改 state。
- 缓存失效条件:只有它响应式依赖变化时才置 dirty。非响应式变量变了它不知道。
watch 和 computed 怎么选
deep: true有成本:它会递归遍历对象,给每一层都收集依赖。数据量大时(比如几千条列表)要慎用,能监听具体路径就别开 deep。immediate: true时 oldVal 是 undefined:因为这是"第一次",没有上一个值。- 数组的 watch:监听数组时不需要 deep(Vue 会处理),但监听"数组里对象的属性变化"还是需要。
- watch 里能写异步:
await请求、$nextTick、DOM 操作都可以;computed 里不行。
✓ 用 computed
全名 = 姓 + 名、购物车总价、筛选后的列表、按钮是否可点。特点:由别的数据算出来的值。
✓ 用 watch
搜索词变了发请求、路由参数变了重新拉数据、某个值变了存 localStorage。特点:数据变化触发的动作。
改了数据,DOM 为什么还没变★
this.$nextTick。
✕ 不要这样想
"赋值了就立刻变了。" —— 于是写出 this.msg = x; this.$refs.box.offsetWidth 这种读到旧布局的代码。
✓ 正确的时序
赋值只是"提交变更申请"。要在 DOM 更新之后做事,一律放进 $nextTick 回调。
- nextTick 底层用什么:优先
Promise.then(微任务),降级MutationObserver→setImmediate→setTimeout。微任务的时机是"当前宏任务结束、浏览器渲染之前",正好卡在用户看到画面之前。 - 为什么要按 id 排序:父组件的 watcher id 一定小于子组件(先创建),排序后保证"父先更新",避免子组件重复渲染。
watcher.run()里有值比较:求值后如果新值和旧值相同,会跳过 patch。这是第二层优化。$nextTick拿到的回调在 DOM 更新之后执行,所以能安全读取$el的新尺寸。
虚拟 DOM 和 patch 在做什么
patch 拿新旧两棵 VNode 树对比,只把差异落到真实 DOM 上。
注意 sameVnode 这个判断——它只有两个条件。
所以 key 一变,Vue 就认为"这不是原来那个节点了",直接重建。
key 的第一个作用就在这里。
- VNode 不是 DOM:它没有 DOM 的任何能力(不能 appendChild、没有 offsetWidth),只是一个 plain object。
elm是双向绑定的锚点:vnode 记住它的真实 DOM,下次 patch 就能直接拿到。- 首次 patch 传的
oldVnode是$el:也就是真实 DOM,所以要先emptyNodeAt转一下。
双端比较:diff 到底怎么走★
updateChildren 维护四个指针:
旧的头部/尾部、新的头部/尾部。每一轮尝试 4 种"便宜"的匹配,
都不中才去建 key 映射表找。这样常见的头尾增删能做到 O(1)。
举个最容易理解的例子:把列表整体反转。
旧 [a, b, c] → 新 [c, b, a]。
第一轮 ①③ 都不中,但 ③"旧头 a vs 新尾 a" 命中 → a 直接挪到末尾;
第二轮 ③ 又命中 b;第三轮 ③ 命中 c。全程三次移动,零次新建。
✓ 双端比较的优势
- 头插、尾插、头删、尾删:一轮命中,只动一个节点
- 反转:每轮都能命中"旧头 vs 新尾",移动次数最少
- 不需要给所有节点建 key 表,常见情况更省
✕ 它的不足(Vue3 改掉的地方)
- 中间乱序时需要建 key 表暴力查
- 某些"跨位置移动"场景会产生多余 DOM 移动
- Vue3 改成"掐头去尾 + 最长递增子序列",移动次数理论最优
- 时间复杂度 O(n):前提是"只做同层比较 + 用 key 找对应"。没有 key 也建不了索引表,就退化成逐个查找,O(n²)。
oldCh[idxInOld] = undefined:处理过的位置置空,后面遇到undefined就跳过(源码里有专门的isUndef分支)。insertBefore是唯一真正动 DOM 的操作:所有"移动"都靠它,没匹配上的才createElm新建。
key 为什么不能用 index
sameVnode 里,key 相等就意味着
"这两个是同一个节点,可以复用 DOM"。用 index 当 key,
等于告诉 Vue"位置就是身份"——顺序一变,身份就全错。
但第二行输入框里出现了 "123"
→ 输入内容串到别的行去了
过程是这样的:删掉第一项后,list 从 [A, B] 变成 [B]。
新的 key 是 0,旧的 key 0 还存在 → sameVnode 返回 true
→ 复用第一个 <li> 节点,只把 <span> 的文字从 A 改成 B。
那个输入框 DOM 根本没被替换,用户敲的字就留在原地。
✕ :key="index"
只要列表会插入、删除、排序、筛选,就可能出现:输入框串值、复选框勾选错位、transition 动画乱跳、组件内部状态错乱。
✓ :key="item.id"
身份跟着数据走。删除时 Vue 能准确知道"要删的是 A 这个节点",其余节点的 DOM 和状态都跟着自己那条数据,不会串。
- 用 index 也不会报错:这是最坑的地方——功能看起来正常,只有涉及"节点内部有状态"时才暴露。
- 什么时候可以接受:列表只用于渲染、绝不增删排序,且项内没有任何有状态的东西。这种场景很少。
- 没有唯一 id 时:可以用"业务上稳定的字段组合",或者给数据加一个生成 id 的步骤。都不要用 index。
- key 不要用随机数:
Math.random()每次渲染都变,会导致整列表重建,性能极差。
生命周期:不只是顺序★
记忆方法:创建和挂载是"子先完成",更新和销毁是"父先开始"。 因为父组件要先把自己的模板解析出来,才知道子组件存在(所以 beforeMount 在子之前); 但子组件挂载完成后父组件才能说自己整体完成(所以 mounted 在子之后)。
- 发请求放
created还是mounted? 都可以。created 更早,SSR 也能执行;mounted 能拿到 DOM。不要放在 beforeMount(此时拿不到 DOM,而且还会被服务端渲染跳过)。 - 操作 DOM 必须
mounted之后,而且如果要读"更新后"的尺寸,还得配合$nextTick。 - 清理逻辑写
beforeDestroy:清定时器、解绑 EventBus、移除 window 监听。写在 destroyed 里也能跑,但那时组件已经开始拆,时机偏晚。 <keep-alive>多两个钩子:activated/deactivated,缓存的组件切回来时触发,不会重新走 created。
v-if 和 v-show,还有那个优先级陷阱
v-if 是真的销毁/重建,v-show 只是
display: none。切换频繁用 v-show,条件很少变用 v-if。
v-if
- 惰性:初始为 false 时什么都不渲染
- 切换时销毁/重建组件,触发完整生命周期
- 切换开销大、初始开销小
v-show
- 总是渲染,只是切换
display - 切换开销小、初始开销大
- 不支持
<template>和v-else
真正容易答错的是下面这个:
v-for 优先级 高于 v-ifVue 3:
v-if 优先级 高于 v-for(且同时用会直接报错)
- 为什么 Vue3 反过来了:Vue2 的顺序导致每次循环都要跑一遍条件,白耗性能;Vue3 改成 v-if 先判断,逻辑上更合理,同时直接编译报错逼你改写法。
- v-if 写在
<template>上可以一次控制多个元素,不产生额外 DOM。 - v-if 会让组件重新走一遍完整生命周期:所以如果子组件里有请求,切换时就会重新请求。想缓存用
<keep-alive>。
组件通信的六种方式
$refs:父直接调子的方法/拿子的数据。属于"打破封装"的做法,适合表单校验这类场景,别到处用。.sync修饰符:<Child :visible.sync="show" />会被展开成:visible+@update:visible,是"多个 v-model"的 Vue2 方案。Vue3 里被v-model:xxx取代。$parent/$children:耦合太强、顺序不稳定,基本只在写高阶组件库时用。- Vuex:中大型项目的标准答案。单向数据流:组件 dispatch action → action commit mutation → mutation 改 state → state 变化触发视图更新。
✕ EventBus 的三个坑
- 忘记
$off→ 内存泄漏、重复触发 - 事件名散落各处,搜不到谁在监听
- 数据流向不清晰,调试困难
✓ 可以这样替代
- 简单场景:把状态提升到共同父级,用 props 传
- 跨层级:provide / inject
- 复杂全局状态:Vuex / Pinia
mixins 的问题:Vue3 为什么非要改
这四块代码在文件里被 data、computed、
watch、methods、mounted
隔开几百行。当你读到一个 this.loading,得往上翻很久才知道它从哪来。
功能越复杂,这个文件越难读——这才是真正的痛点。
✕ mixins 解决了什么、又留下什么
- 解决了"逻辑能抽走"
- 但混入后
this上多了什么,看不出来 - 两个 mixin 有同名 data/method → 静默覆盖,难排查
- mixin 之间互相依赖(A 用了 B 的数据)→ 无法表达
- 多个 mixin 时,配置合并规则(钩子合并成数组、data 递归合并)很绕
✓ Vue3 的解法
把"同一个功能的东西"放进同一个函数,显式返回需要暴露的部分。名字可见、依赖可见、来源可见。
- Vue2 里也能部分模拟:可以用
Vue.observable+ 工厂函数做一点,但没有ref/computed这样好用的原语,写起来很别扭。 - mixin 的合并规则:生命周期钩子会被合并成一个数组(mixin 的先执行),data 和 methods 后覆盖先。这个规则容易记错。
- 不是"mixins 不能用":全局混入做埋点、权限这类横切关注点仍然合理。问题出在"用 mixin 做主业务的逻辑复用"。
速答清单
考前扫一遍。卡住的回去看对应那节。