← 全部材料 Vue 2
VUE 2 · 16 个高频点

先看清它的限制

Vue 2 在 2023 年底已经停止维护,但存量项目多,面试里照样问。 这份材料不按"背 API"来组织,而是围绕一个核心: Object.defineProperty 这个选择,带来了什么,又限制了它什么。 把这个想通了,$set、数组重写、Vue3 改用 Proxy 这些事全是同一个故事。

01先猜再看看到代码先自己在心里跑一遍输出,再看下面的结果。
02解释"为什么"能说出机制,才算真的会,光记住现象很快会忘。
03连上 Vue3每节的"限制"都是 Vue3 改动的动机,看完接 Vue3 那份。
01

模板最终变成了一个函数

template → AST → render function
Vue 的模板不是"边解析边渲染"的。它在构建时(或运行时首次编译时)就把模板 编译成一个 render 函数,之后每次更新只调用这个函数拿到 VNode,不再碰模板字符串。
parse模板字符串 → AST
→
optimize标记静态节点(static / staticRoot)
→
generateAST → render 函数字符串
→
new Function字符串变成真正的函数

中间那步 optimize 是 Vue2 编译期最有价值的一步。 它给每个 AST 节点打上 static 标记,标记过的节点在 patch 时会被整棵跳过——不对比、不更新,因为它的内容不可能变。 这就是"编译期优化",也是 Vue3 把这条路走到极致(静态提升、Patch Flag)的起点。

面试这样答
Vue 的模板编译分三步:parse 把模板解析成 AST,optimize 遍历 AST 标记静态节点,generate 把 AST 转成 render 函数的字符串,最后用 new Function 生成真正的函数。静态标记的作用是在 patch 阶段跳过整棵静态子树,减少对比开销。如果是 .vue 文件配合 vue-loader,这一步在构建时就完成了,运行时不需要编译器,所以包更小、启动更快。
02

响应式的核心:defineProperty★

getter 收集依赖,setter 派发更新
Vue2 在 initData 时递归遍历 data 的每个属性, 用 Object.defineProperty 把它们改写成 getter / setter。 读的时候收集"谁在用我",写的时候通知"用我的人该更新了"。
渲染时读 msg触发 getter
→
dep.depend()把渲染 watcher 存进 subs
→
之后某个时刻给 msg 赋值
→
dep.notify()通知所有 watcher 更新

这套机制的三个盲区,全部来自 defineProperty 只能"预先给已知的 key 装钩子"这个本质:

✕ 装不上钩子的操作

  • obj.newKey = 1 —— 新增时没钩子
  • delete obj.key —— 删除拦截不到
  • arr[0] = x —— 数组索引没装钩子
  • arr.length = 0 —— length 也拦不到

✓ 所以 Vue2 必须补两套方案

  • Vue.set / Vue.delete 手动装钩子并通知
  • 重写数组的 7 个变异方法,手动 notify
面试这样答
Vue2 用 Object.defineProperty 把 data 的每个属性改写成 getter/setter,初始化时递归遍历所有层级。getter 里做依赖收集——把当前正在求值的 watcher 存进这个属性对应的 Dep;setter 里做派发更新——通知 Dep 里所有 watcher 执行 update。因为 defineProperty 只能给"已存在的 key"装钩子,所以新增属性、删除属性、数组索引赋值都无法被感知,Vue2 只能用 Vue.set/Vue.delete 和重写数组方法这两个补丁来绕开。
03

$set 到底做了什么

手动补上一个钩子
this.$set(obj, key, val) 就是"帮你 defineReactive 一下, 然后手动通知一次"。它不是魔法,是给 defineProperty 的盲区打的补丁。
面试这样答
因为 Vue2 的响应式是初始化时用 defineProperty 逐个属性装钩子,新增和删除属性时钩子不存在,所以感知不到。Vue.set 的处理分三种情况:数组走重写过的 splice;已存在的 key 直接赋值;不存在的 key 先 defineReactive 装上 getter/setter,再手动调用 ob.dep.notify() 通知更新。本质就是"补一个钩子再手动触发一次"。
04

数组为什么要重写 7 个方法

prototype 拦截 + 手动 notify
数组元素太多,不可能给每个索引都装 defineProperty(性能扛不住, 而且 length 也拦不住)。所以 Vue2 换了个思路: 拦截能改变数组的方法本身,改完手动通知一次。
为什么恰好是这 7 个 因为它们会改变原数组本身:push / pop / shift / unshift / splice / sort / reverse
而 filter、map、concat、slice 返回新数组,不改变原数组,不需要拦截——用它们的结果重新赋值时,赋值本身就触发了 setter

至于"为什么不给索引装钩子",答案是两个:一是数组可能上万项,全量 defineProperty 代价太高;二是 length 变化 (arr.length = 0)根本没法用 defineProperty 有效拦截。 权衡之后,Vue 选择了"拦截操作方法"这条性价比更高的路。

面试这样答
因为 Vue2 用 defineProperty 无法拦截数组的索引赋值和 length 变化,给每个元素都装钩子性能又太差,所以改成了拦截数组的变异方法:Object.create(Array.prototype) 造一个中间原型,把 push、pop、shift、unshift、splice、sort、reverse 这七个会改变原数组的方法重写,在调用原生方法之后手动 dep.notify(),并处理新插入的元素。这样只用一个原型劫持就覆盖了绝大部分数组操作。
05

组件的 data 为什么必须是函数

reference sharing
组件是可复用的,会被实例化多次。如果 data 是对象, 所有实例就共享同一个引用,改一个全变。写成函数,每次实例化都返回一个全新对象。
实际输出 用对象写法渲染三个计数器组件,点击第一个的 +1 按钮:
三个组件的数字同时变成 1
Vue 会给出警告:data functions should return an object(组件场景)

根实例 new Vue({ data: {...} }) 确实可以写成对象, 因为全局只有一个实例,不怕共享。但只要是被复用的组件,就必须是函数。

面试这样答
因为组件是可复用的,同一个组件可能被实例化多次。如果 data 直接写成对象,这个对象在模块加载时就创建好了,所有组件实例拿到的是同一个引用,修改其中一个会影响到其他所有实例。写成函数后,每次创建实例都会调用一次 data,返回一个全新的对象,各实例的数据相互独立。根实例可以写对象是因为它只会有一个实例。
06

computed 的缓存是怎么做的

lazy watcher + dirty flag
computed 背后是一个 lazy 的 watcher,它带一个 dirty 标记。 依赖没变,dirty 就是 false,直接返回缓存值, 连求值函数都不会执行。
首次渲染时控制台 computed 执行 ← 只打印一次,第二次直接用缓存
method 执行
method 执行 ← 模板里调两次就执行两次

注意最后那段:依赖变化时,computed 不会立刻重算,只是把 dirty 设为 true。如果不读它,就永远不算——这就是"惰性求值"。

面试这样答
computed 内部为每个计算属性创建一个 lazy watcher,并带一个 dirty 标记。取值时如果 dirty 是 false,直接返回上次的计算结果,不会重新执行;依赖发生变化时,update 只把 dirty 置为 true,并不立刻重算,等到下次被访问才真正求值,所以是惰性求值。methods 没有这层缓存,模板里调用几次就执行几次。所以"派生数据用 computed、纯粹的行为用 methods"。
07

watch 和 computed 怎么选

derive vs react
computed 回答"这个值等于什么",必须 return,同步、有缓存。 watch 回答"这个值变了以后做什么",做副作用,可以异步。

✓ 用 computed

全名 = 姓 + 名、购物车总价、筛选后的列表、按钮是否可点。特点:由别的数据算出来的值。

✓ 用 watch

搜索词变了发请求、路由参数变了重新拉数据、某个值变了存 localStorage。特点:数据变化触发的动作。

面试这样答
核心区别是用途:computed 用于从一个或多个响应式数据派生出新值,有缓存、必须同步返回、不能有副作用;watch 用于在数据变化时执行副作用,比如发请求、操作 DOM、写缓存,可以异步,支持 deep 和 immediate。选的时候看语义——如果你需要的是"一个值",用 computed;如果需要的是"一件事",用 watch。能用 computed 表达的不要用 watch,因为 computed 有缓存、不会重复执行。
08

改了数据,DOM 为什么还没变★

async update queue + nextTick
Vue 的更新是异步的、批量的。赋值只是把 watcher 推进队列, 真正的 DOM 更新在同一个事件循环的微任务里统一执行。 想拿到更新后的 DOM,用 this.$nextTick。
this.msg = xsetter 触发
→
dep.notify()通知 watcher
→
queueWatcher入队 + 按 id 去重
→
nextTick注册 flush 回调
→
微任务里 flush真正 patch 到 DOM
△ 可交互:连续改三次,只渲染一次
界面上的 msg
a
更新队列内部过程
点上面按钮试试

✕ 不要这样想

"赋值了就立刻变了。" —— 于是写出 this.msg = x; this.$refs.box.offsetWidth 这种读到旧布局的代码。

✓ 正确的时序

赋值只是"提交变更申请"。要在 DOM 更新之后做事,一律放进 $nextTick 回调。

面试这样答
Vue 的更新是异步批量的。数据变化时 setter 调用 dep.notify,watcher 会进入一个队列 queueWatcher,队列用 watcher 的 id 做去重,所以同一个 watcher 在一轮里只会入队一次,多次赋值最终只渲染一次。真正的 DOM 更新在 nextTick 注册的回调里执行,底层优先用 Promise.then 这样的微任务,时机是当前宏任务结束、浏览器渲染之前。所以赋值后立刻读 DOM 拿到的是旧值,要用 this.$nextTick 或者 await 拿到更新后的 DOM。flush 前还会按 id 排序,保证父组件先于子组件更新。
09

虚拟 DOM 和 patch 在做什么

vnode → patch → real dom
render 函数返回的是 VNode(一个描述 DOM 的普通对象)。 patch 拿新旧两棵 VNode 树对比,只把差异落到真实 DOM 上。

注意 sameVnode 这个判断——它只有两个条件。 所以 key 一变,Vue 就认为"这不是原来那个节点了",直接重建。 key 的第一个作用就在这里。

面试这样答
render 函数返回 VNode,也就是用普通对象描述"我想要什么样的 DOM"。patch 的入口先比较新旧节点的 key 和 tag,只有两者都相同才认为是同一个节点、进入 patchVnode 做深度对比;否则直接整棵重建。patchVnode 里对文本、children 的组合分情况处理,双方都有 children 时调用 updateChildren 做列表 diff。这样做的好处是不用直接操作真实 DOM,先把变化算清楚再一次性提交,减少回流重绘。
10

双端比较:diff 到底怎么走★

four pointers, O(n) with keys
Vue2 的 updateChildren 维护四个指针: 旧的头部/尾部、新的头部/尾部。每一轮尝试 4 种"便宜"的匹配, 都不中才去建 key 映射表找。这样常见的头尾增删能做到 O(1)。

举个最容易理解的例子:把列表整体反转。
旧 [a, b, c] → 新 [c, b, a]。 第一轮 ①③ 都不中,但 ③"旧头 a vs 新尾 a" 命中 → a 直接挪到末尾; 第二轮 ③ 又命中 b;第三轮 ③ 命中 c。全程三次移动,零次新建。

✓ 双端比较的优势

  • 头插、尾插、头删、尾删:一轮命中,只动一个节点
  • 反转:每轮都能命中"旧头 vs 新尾",移动次数最少
  • 不需要给所有节点建 key 表,常见情况更省

✕ 它的不足(Vue3 改掉的地方)

  • 中间乱序时需要建 key 表暴力查
  • 某些"跨位置移动"场景会产生多余 DOM 移动
  • Vue3 改成"掐头去尾 + 最长递增子序列",移动次数理论最优
面试这样答
Vue2 的 diff 是双端比较:新旧两个 children 数组各有两个指针指向头尾,每轮先试四种组合——旧头对新头、旧尾对新尾、旧头对新尾、旧尾对新头。命中就复用节点、只做必要的 insertBefore 移动;四种都不中才用 key 建立旧节点的索引表,找到可复用的节点移动过来,找不到就新建。循环结束后处理剩余节点:旧数组先走完说明要新增,新数组先走完说明要删除。这种策略让头尾增删和反转这类常见操作的开销接近常数。
11

key 为什么不能用 index

identity vs position
在 sameVnode 里,key 相等就意味着 "这两个是同一个节点,可以复用 DOM"。用 index 当 key, 等于告诉 Vue"位置就是身份"——顺序一变,身份就全错。
操作:在第一行输入"123",然后删除第一项 列表变成只剩第二项(正确)
但第二行输入框里出现了 "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 和状态都跟着自己那条数据,不会串。

面试这样答
key 是 patch 时判断"新旧节点是不是同一个"的依据,sameVnode 只比较 tag 和 key。用 index 当 key,意味着节点的身份绑定在位置上:列表一旦插入或删除,原本的第 0 项变成了第 1 项,但 key 还是 0,Vue 会认为还是同一个节点,于是复用原来的 DOM,只更新数据。结果就是带内部状态的元素——比如输入框的值、组件的 data、transition 的状态——会串到别的行。所以只要有稳定的唯一标识,就应该用它当 key。
12

生命周期:不只是顺序★

8 hooks + parent/child order
四个阶段:创建、挂载、更新、销毁,每个阶段前后各一个钩子。 真正被追问的不是"有哪些",而是父子组件的执行顺序和 "哪些阶段能拿到什么"。
beforeCreate实例刚建,data / methods 都没有
→
createddata / methods / computed 可用,DOM 不可用
↓
beforeMount模板已编译成 render,尚未挂载
→
mounted真实 DOM 已就位,可操作 $el
↓(数据变化)
beforeUpdate数据新、DOM 旧
→
updated数据和 DOM 都是新的
↓(销毁)
beforeDestroy实例还活着,适合做清理
→
destroyed解绑 watcher、子组件、事件监听

记忆方法:创建和挂载是"子先完成",更新和销毁是"父先开始"。 因为父组件要先把自己的模板解析出来,才知道子组件存在(所以 beforeMount 在子之前); 但子组件挂载完成后父组件才能说自己整体完成(所以 mounted 在子之后)。

面试这样答
Vue2 有 8 个生命周期钩子,加 keep-alive 的 activated/deactivated 共 10 个。首次渲染时顺序是父 beforeCreate、created、beforeMount,然后子 beforeCreate、created、beforeMount、mounted,最后父 mounted——因为父组件要先编译模板才知道子组件的存在,但必须等子组件挂载完才能算自己挂载完成。更新时是父 beforeUpdate、子 beforeUpdate、子 updated、父 updated。销毁时是父 beforeDestroy、子 beforeDestroy、子 destroyed、父 destroyed。实际使用上,请求放 created 或 mounted,DOM 操作放 mounted,清理放 beforeDestroy。
13

v-if 和 v-show,还有那个优先级陷阱

render or hide
v-if 是真的销毁/重建,v-show 只是 display: none。切换频繁用 v-show,条件很少变用 v-if。

v-if

  • 惰性:初始为 false 时什么都不渲染
  • 切换时销毁/重建组件,触发完整生命周期
  • 切换开销大、初始开销小

v-show

  • 总是渲染,只是切换 display
  • 切换开销小、初始开销大
  • 不支持 <template> 和 v-else

真正容易答错的是下面这个:

版本差异(很爱考) Vue 2:v-for 优先级 高于 v-if
Vue 3:v-if 优先级 高于 v-for(且同时用会直接报错)
面试这样答
v-if 是条件渲染,切换时会真正销毁和重建 DOM 与组件实例,是惰性的,初始条件为假就什么都不渲染;v-show 是条件显示,元素始终渲染,只切换 display 属性。所以频繁切换用 v-show,运行时条件很少变用 v-if。另外要注意 v-for 和 v-if 的优先级:Vue2 里 v-for 更高,会先循环再逐个判断,很浪费,应该用 computed 先过滤数据;Vue3 里改成了 v-if 优先级更高,同时使用甚至会直接编译报错。
14

组件通信的六种方式

and when each one hurts
按"关系"选方案,不要按"哪个方便"选。 父子用 props/$emit,跨层级用 provide/inject, 全局共享用 Vuex,兄弟之间尽量别用 EventBus(它没有清理机制,容易泄漏)。

✕ EventBus 的三个坑

  • 忘记 $off → 内存泄漏、重复触发
  • 事件名散落各处,搜不到谁在监听
  • 数据流向不清晰,调试困难

✓ 可以这样替代

  • 简单场景:把状态提升到共同父级,用 props 传
  • 跨层级:provide / inject
  • 复杂全局状态:Vuex / Pinia
面试这样答
Vue2 的通信方式按关系划分:父子用 props 下行、$emit 上行,配合 .sync 或 v-model 做双向;父访问子用 $refs,子访问父用 $parent;跨层级用 provide/inject,注意默认不是响应式的,要传对象或用函数返回;兄弟组件可以用 EventBus,但必须在 beforeDestroy 里 $off,不然会泄漏和重复触发;全局状态用 Vuex,遵循单向数据流。选型原则是优先用 props/$emit 保持数据流清晰,只在跨越很多层时才用 provide/inject,EventBus 尽量少用。
15

mixins 的问题:Vue3 为什么非要改

the reason Composition API exists
用 mixins 复用逻辑,会带来三个问题:来源不透明、命名冲突、隐式依赖。 这不是写法问题,是"按选项组织代码"这个模型本身的上限。

这四块代码在文件里被 data、computed、 watch、methods、mounted 隔开几百行。当你读到一个 this.loading,得往上翻很久才知道它从哪来。 功能越复杂,这个文件越难读——这才是真正的痛点。

✕ mixins 解决了什么、又留下什么

  • 解决了"逻辑能抽走"
  • 但混入后 this 上多了什么,看不出来
  • 两个 mixin 有同名 data/method → 静默覆盖,难排查
  • mixin 之间互相依赖(A 用了 B 的数据)→ 无法表达
  • 多个 mixin 时,配置合并规则(钩子合并成数组、data 递归合并)很绕

✓ Vue3 的解法

把"同一个功能的东西"放进同一个函数,显式返回需要暴露的部分。名字可见、依赖可见、来源可见。

面试这样答
mixins 的问题有三个:一是来源不透明,混入后 this 上多了哪些属性看不出来;二是命名冲突,多个 mixin 定义同名属性会被静默覆盖,出问题很难排查;三是隐式依赖,mixin 之间如果互相访问对方的数据,这种依赖无法在代码里表达。根本原因在于 Vue2 是按选项组织代码的,一个功能的 data、watch、methods 被打散到不同位置。Vue3 的 Composition API 改成按功能组织,把同一个逻辑的响应式状态、计算、监听、生命周期都收在一个函数里,显式 return 需要暴露的部分,从而解决了这三个问题。
16

速答清单

last 30 minutes before the interview

考前扫一遍。卡住的回去看对应那节。

QVue 的响应式原理一句话概括?
初始化时递归遍历 data,用 Object.defineProperty 把每个属性改写成 getter/setter;getter 里把当前 watcher 收进 Dep,setter 里通知 Dep 中所有 watcher 更新。配合 Dep 做依赖收集、Watcher 做订阅、Scheduler 做异步批量更新。
QVue2 响应式有哪些缺陷?
四个:新增属性感知不到、删除属性感知不到、数组索引赋值感知不到、数组 length 变化感知不到。本质是 defineProperty 只能给已存在的 key 装钩子。解法是 Vue.set / Vue.delete 和重写数组的 7 个变异方法。
Q为什么不支持 IE8?
IE8 不支持 Object.defineProperty 对普通对象使用(只能在 DOM 上),也不支持 __proto__ 改原型。Vue2 的响应式和数组拦截都依赖这两个能力,所以官方只支持 IE9+。Vue3 用 Proxy,连 IE11 都不支持。
QVue 的更新是同步还是异步?
异步批量。赋值后 watcher 进队列并按 id 去重,在 nextTick(微任务)里统一 flush,所以一轮里多次改数据只渲染一次。想拿更新后的 DOM 用 this.$nextTick 或 await this.$nextTick()。
QnextTick 底层是怎么实现的?
把回调推进一个 callbacks 数组,用一个 pending 标记保证只注册一次微任务。降级顺序是 Promise.then → MutationObserver → setImmediate → setTimeout(fn, 0)。优先微任务是为了在浏览器渲染前完成 DOM 更新,避免看到中间状态。
Qcomputed 和 watch 的底层都是一个 watcher,区别在哪?
computed 是 lazy watcher,带 dirty 标记,依赖变化只把 dirty 置 true,下次被读取才求值,所以有缓存;watch 是普通 user watcher,依赖变化就执行回调,支持 deep 和 immediate。前者是"算值",后者是"做事"。
Q为什么 v-for 要加 key,为什么不能是 index?
key 是 sameVnode 判断"是否复用 DOM"的依据,加了 key 才能建立索引表,把 diff 从 O(n²) 降到 O(n)。用 index 会让节点身份绑在位置上,列表增删排序时 DOM 被错误复用,带内部状态的元素(input、组件 data、transition)会串位。
QVue2 的 diff 和 React 的 diff 有什么不同?
相同点是都只做同层比较、都靠 key 复用、都假设类型不同就重建。不同点是策略:React 从左到右单向遍历,靠 key 找位置,移动可能更多;Vue2 是双端比较,头尾四个指针同时收缩,头尾增删和反转这类操作开销更小。
Q$nextTick 和 setTimeout 有什么区别?
$nextTick 用微任务,在当前宏任务结束、浏览器渲染之前执行,能保证拿到本次更新后的 DOM,而且比 setTimeout 更早。setTimeout 是宏任务,要等浏览器渲染完、下一轮事件循环,可能中间还插了别的任务,时机不确定。
QVue2 的性能瓶颈主要在哪?
两个:一是初始化时递归遍历整个 data 全量 defineProperty,深层大对象启动慢;二是响应式的粒度是"组件级",一个数据变化会触发整个组件重新渲染,没有 Vue3 那种编译期标记可以跳过静态部分。这两个正是 Vue3 用 Proxy 和编译优化要解决的问题。