一句话总结
虚拟 DOM 是用 JS 对象描述真实 DOM 树——数据变化后先在新旧两棵 JS 树之间做 diff,算出最小变更再一次性更新真实 DOM。Vue2 用双端对比(头尾四个指针),Vue3 在编译时预处理静态内容 + 运行时用最长递增子序列尽量少移动节点。key 是节点的身份标识,diff 靠它判断"同一个节点"能否复用。
初级理解
为什么要虚拟 DOM:直接操作 DOM 代价高且逻辑分散。有了 vdom,渲染流程变成:模板 → render 函数 → vdom(JS对象)→ diff → 最小化真实 DOM 操作。好处有二:① 声明式开发,只管数据;② JS 对象可运行在任何环境(SSR、小程序、Canvas),实现跨平台。
diff 的基本原则:只做同层级比较(不跨层移动),节点 type 和 key 都相同才视为可复用;类型不同直接销毁重建,不再深入比较。
key 的作用:给每个节点一个稳定身份。没有 key 时 Vue 默认"就地复用"——只修补内容不移动节点,这在列表顺序变化时会出现状态错位;错误地用 index 作 key 等于没有 key。
中级深入
Vue2 双端 diff:新旧列表各持头尾两个指针,每轮循环依次尝试四种命中:旧头-新头、旧尾-新尾、旧头-新尾、旧尾-新头;四种都未命中时,把旧列表的 key→index 映射表拿出来查找复用。这个策略对反转、平移这类常见变更非常高效。
Vue3 diff(patchKeyedChildren):先做预处理——从头比头、从尾比尾,把两边相同的部分直接跳过(常见场景:往头部/尾部插入元素,复杂度直接 O(n));剩下的中间乱序部分才进入核心对比:
- 用新列表的 key 建映射,遍历旧中间部分:能复用的打上"新列表中的位置索引",不能复用的标记删除
- 对可复用节点求最长递增子序列(LIS)——序列内的节点相对顺序不变,不需要移动;只移动 LIS 之外的节点
高级拓展
Vue3 的编译时优化(Vue2 完全没有的部分):
- 静态提升(hoistStatic):纯静态的 vnode 提到 render 函数外只创建一次,diff 时直接跳过
- PatchFlags:编译时标记 vnode 的动态点(TEXT=只有文本动态 / CLASS / PROPS / STYLE…),diff 只比对标记的字段,不再全量遍历 props
- Block Tree:模板根节点/分支节点作为 Block 收集所有动态后代节点(拍平结构),更新时只遍历动态节点列表,跳过整棵静态子树
- 事件缓存 cacheHandlers:inline 事件(如 @click="count++")被缓存,避免每次渲染生成新函数导致子组件无效更新
与 React 对比的经典话题:React 运行时不知道哪个组件会变,setState 后整棵子树 re-render(靠 shouldComponentUpdate/memo 手动拦);Vue 在编译时就知道动态点,配合响应式系统的精确依赖,天然做到组件级精准更新。这是"编译时优化"与"运行时优化"两条路线的代表性差异。
实战场景
场景一:index 作 key,输入框内容串行
场景二:随机 key 强制重建的性能坑
场景三:大列表 diff 卡顿
面试模拟
Q:Vue2 和 Vue3 的 diff 算法核心区别?
A:三点——① Vue2 全量进入双端对比,Vue3 先做头尾预处理,常见的前插/后插直接 O(n);② 乱序部分 Vue3 用最长递增子序列让最少的节点移动;③ Vue3 有编译时优化(静态提升、PatchFlags、Block Tree),diff 只碰动态内容,Vue2 是纯运行时全量比对。
Q:为什么不建议用 index 作 key?什么情况下 index 作 key 无害?
A:index 与"位置"绑定而不与"数据"绑定,列表中间插入/删除/排序时,key 不变的节点内容却变了,Vue 复用旧节点导致内部状态(输入值、勾选、滚动、子组件实例)错位。只有列表纯展示、无状态组件、不会中间增删重排时 index 作 key 才无害。
Q:有了虚拟 DOM,为什么 Vue3 还要做大量编译时优化?
A:因为虚拟 DOM 解决的是"声明式的性能下限",而运行时 diff 再快也要遍历节点。Vue3 把"哪些内容是动态的"提前在编译期算好(PatchFlags/Block Tree),运行时直接跳过静态部分,把 diff 从"整棵树"缩小到"动态点集合",这是 React 那套纯运行时方案做不到的。