虚拟 DOM 和 diff:数据没错,页面为什么还是渲染错位

“数据是对的,页面却画错了” 这种 bug 最适合拿来逼自己补框架底层。因为一旦能确认状态本身没乱,问题就不再是业务逻辑,而是框架在更新节点时到底复用了谁、替换了谁。

这次拖拽排序组件的问题就是这样:列表顺序变了,Vuex 里的 checked 字段也没错,偏偏页面上的复选框状态串到了别的行上。顺着这个现象往下挖,最后一定会碰到虚拟 DOM、diff 策略和 key 的作用。

后面会从这个“数据没错、渲染错位”的现场切进去,把 VNode、节点复用、key、列表 diff 和常见误区重新讲清楚。

先在组件里抓现行

打开那个组件,v-for 那一行很快就露出了马脚:

1<li v-for="(item, index) in list" :key="index">
2  <input type="checkbox" v-model="item.checked">
3  <span>{{ item.name }}</span>
4</li>

:key="index"。我盯着这行代码想了一会儿,之前只在面试题里见过"别用 index 做 key"这句话,没细想过它到底会导致什么后果。既然怀疑就是它,先验证再说,改成用数据里自带的 id

1<li v-for="item in list" :key="item.id">

保存、刷新、重新拖拽排序——复选框状态跟着数据走了,问题消失。定位没错,但我不想止步于"改了就好了",得弄明白它为什么会好。这就绕不开虚拟 DOM 和 diff 算法,只有理解了框架更新页面时到底在比较什么,才知道 key 在这中间扮演的到底是什么角色。

VNode:框架眼里的页面长什么样

一段真实 DOM:

1<div id="app">
2  <p class="text">hello</p>
3</div>

可以用一个普通对象描述:

1const vnode = {
2  tag: 'div',
3  props: {
4    id: 'app'
5  },
6  children: [
7    {
8      tag: 'p',
9      props: {
10        class: 'text'
11      },
12      children: ['hello']
13    }
14  ]
15}

这个对象就是虚拟 DOM 的简化形式。框架先生成 VNode,再据此创建真实 DOM;数据变化后生成新的 VNode,拿新旧两棵树去比较,找出真正需要更新的部分,再把差异打到真实 DOM 上。这里有个容易被忽略的前提:为什么要在内存里维护这么一份 JS 对象树,而不是每次都去读真实 DOM 的当前状态来对比?因为访问真实 DOM 是昂贵操作——读一个节点的属性、尺寸可能触发浏览器的重排重绘,逐个去读几百个节点会很慢。而 VNode 只是纯 JS 对象,在内存里比来比去几乎不花什么钱。把"比较"这件事放在轻量的 JS 对象层做,只在算出差异之后才去碰一次真实 DOM,这才是虚拟 DOM 能把"频繁的状态变化"收敛成"最少的 DOM 操作"的底层逻辑。

我打开 vue-template-compiler 编译出来的调试代码看了一眼,Vue 2 的 VNode 上挂的字段比我上面写的示例多得多:tagdata(属性、事件、指令全塞在这里)、childrentextelm(对应的真实 DOM 引用)、keycomponentOptions。刚翻源码那会儿被这堆字段唬住了,看多了才想明白核心就那几个:tag 说明这是个什么节点,children 说明它里面装了什么,key 标记身份,elm 是创建出真实 DOM 之后回填进去的引用——后续更新时靠它直接找到对应的真实节点,不用每次都重新查一遍 DOM 树。

我们写的模板会被编译成一个 render 函数,函数体里调用 h(也就是 createElement)生成这些 VNode:

1// 模板
2// <div id="app"><p class="text">{{ msg }}</p></div>
3
4// 编译后大致等价于
5render(h) {
6  return h('div', { attrs: { id: 'app' } }, [
7    h('p', { class: 'text' }, this.msg)
8  ])
9}

想通这一层之后,之前一直没弄明白的一件事也顺带解开了:为什么隔壁做 React 的朋友说 JSX 在 Vue 项目里也能配起来用。因为 JSX 说到底也只是另一种生成 VNode 的写法,模板是语法糖,JSX 也是语法糖,落到最后都是同一个 render 函数,殊途同归。

为什么要多这一层,而不是直接改 DOM

写这个组件的同事当初要是图省事,完全可以这么写:

1document.querySelector('.text').innerHTML = 'hello'

直接操作 DOM 当然可以跑起来,可这个拖拽列表一复杂——排序、勾选、删除、动画都要同步维护,手写 DOM 操作很容易乱成一团。我猜当时也是图快,才在 v-for 上随手写了 :key="index",没想那么多,结果这笔账落到了我头上。

虚拟 DOM 的价值不是"比手写 DOM 快",而是让 UI 可以由状态描述,框架负责把状态变化映射到真实 DOM 更新。这一点必须单独强调,因为面试八股里太多人把虚拟 DOM 和"快"划等号。真要论极致性能,针对一个已知节点的精准 textContent 赋值,永远比"生成新 VNode、diff、patch"这一整套流程快,毕竟后者多了创建对象和逐层比较的开销。虚拟 DOM 真正解决的是可维护性:在没有它的年代,jQuery 式的代码长这样——

1function updateItem(item) {
2  $('#name-' + item.id).text(item.name)
3  if (item.checked) {
4    $('#checkbox-' + item.id).prop('checked', true)
5  } else {
6    $('#checkbox-' + item.id).prop('checked', false)
7  }
8  // ...还有十几行这样的命令式操作
9}

每加一个字段,就要手动补一条"数据变了、找到对应 DOM、改它"的链路。状态一多,这些链路彼此纠缠,漏改、错改是家常便饭。虚拟 DOM 把这件事倒过来了:你只描述"当前状态下页面该长什么样",至于怎么从旧样子变到新样子,交给框架去算。我宁可让框架多做一点 diff 的计算,也不想再手写那种命令式的 DOM 同步代码——这笔账,对大多数中后台业务页面来说是划算的。

diff 的假设:不追求完美,只追求够用

完整比较两棵树成本很高,理论上限是 O(n³)。框架不会真的这么干,而是做了几条工程假设:

  • 不同类型的节点,直接替换,不再比较子树;
  • 只做同层比较,不跨层级搬运节点;
  • 靠 key 判断列表项的身份,尽量复用而不是重建。
1old: <div></div>
2new: <span></span>

标签不同,直接销毁旧的、创建新的。这条假设背后的道理很朴素:实际项目里,一个 <div> 几乎不可能在下一次渲染时变成 <span> 还想保留原来的子树。与其花成本去比较两棵结构大概率完全不同的树,不如直接换掉,反而更快。diff 的复杂度能从 O(n³) 降到 O(n),靠的就是这几条"反正实际情况不会那么刁钻"的假设。

那个 O(n³) 值得多说一句,不然"降到 O(n)"这句话没有分量。理论上要严格比较两棵树的最小编辑距离,是个经典的树编辑距离问题:对每个旧节点,要去另一棵树里找它可能对应的位置(一层 n),配对之后还要处理子树的重排(又一层 n),加上整体的比对(再一层 n),乘起来就是 O(n³) 这个量级。对一个几百上千节点的中后台页面来说,n³ 是根本跑不动的数字。框架不追求"理论最优的最小改动",它认下"节点大概率还在原来那层、顺序变化有规律"这些工程假设,把三层嵌套的比较砍成"只在同一层、从两端往中间扫一遍",复杂度直接落到 O(n)。这是一笔非常划算的买卖:用"极少数刁钻情况下可能多做一点无用改动",换来了绝大多数情况下线性时间就能算完。

"只做同层比较"这条假设也不是没有代价。假如某次更新真把一个节点从一层挪到了另一层——比如把一个 <li> 从这个 <ul> 拎出来塞进另一个 <ul>——diff 不会去跨层认领它,而是把原来那个当删除、新位置那个当新建,节点其实是被销毁重建的,节点上的状态和 DOM 也就丢了。好在真实模板里跨层搬运本来就少见,这个代价平时碰不到,但心里得有数:框架的高效是建立在"你的 UI 变化符合它的假设"之上的,一旦你写出反假设的结构,它就退化。

这里还有个前置问题:框架凭什么判定"新旧这两个节点是同类型、值得往下比",而不是直接换掉?源码里有个 sameVnode 的判断,核心就两条——key 相不相等、tag 是不是一样(再加上几个次要条件,比如是不是注释节点、input 的 type 一不一致)。key 相等且 tag 相同,才认定是"同一个节点",进入下面的 patchVnode 走复用;否则一律当成不同节点,销毁重建。这条判断把 key 的地位摆得很明白:它不是可选的优化项,而是"两个节点算不算同一个"这件事里权重最高的字段,直接决定后面走复用还是走重建。

被判定为同类型的节点才进入 patchVnode,我照着源码顺了一遍它做的事:先比对 data 里的属性、class、style、事件,把变化的更新到真实 DOM 上;再处理 children。children 这块分几种情况——

1// 新旧都有子节点:进入最关键的 updateChildren,逐个 diff
2// 新有子节点、旧没有:直接新增
3// 新没有、旧有:直接删除旧的
4// 都是文本节点:文本不同就改 textContent

updateChildren 是整个 diff 里最精妙的部分,用的是"双端比较":新旧两个子节点数组各拿首尾两个指针,按"旧头对新头、旧尾对新尾、旧头对新尾、旧尾对新头"四种方式两两试探。为什么要折腾这四种?因为真实场景里列表的变化往往有规律——往头部插一个、往尾部加一个、或者整个倒序,双端比较能用最少的移动次数命中这些常见模式。

我拿纸笔顺了一遍最能体现它精妙的那种情况——列表整个倒序。旧的是 [A, B, C, D],新的是 [D, C, B, A]

旧: A B C D    新头指针指向 D,旧头指针指向 A
新: D C B A

第一轮,"旧头 A 对新头 D"不匹配、"旧尾 D 对新尾 A"不匹配、"旧头 A 对新尾 A"不匹配……走到"旧尾 D 对新头 D"命中了!于是把旧尾的 D 节点直接移到最前面复用,旧尾指针左移一位、新头指针右移一位。第二轮同理,"旧尾 C 对新头 C"又命中,再把 C 移过去。就这样每轮都靠"旧尾对新头"命中,四个节点一个都没重建,只是挪了位置。要是没有这套双端策略、老老实实从头比到尾,倒序这种情况几乎每个位置都对不上,会退化成大量的删除和新建。

不过双端比较也有它够不着的地方。我们那个拖拽列表就是重灾区:拖一下往往是"从中间某一项挪到另一处",四种首尾试探都命不中。这时候框架才退回到"用 key 建一张 key -> 旧节点位置 的映射表去查"的兜底路径,而这也正是接下来 key 要登场的地方。

key:告诉框架这是谁

1<li v-for="item in list" :key="item.id">
2  {{ item.name }}
3</li>

key 用来告诉框架:这个节点对应哪一条数据。双端比较四种方式都没命中时,框架会用 key -> 旧节点位置 的映射表,O(1) 地查到"这条新数据对应的旧节点在哪",从而尽量移动复用而不是销毁重建。

没有 key,或者 key 不稳定(比如这次的 index),框架会怎么办?我在源码里找到了答案:会退化成"按位置就地复用"(in-place patch)——第一个新节点复用第一个旧节点,第二个对第二个,依次往下,完全不管这条数据原本是谁。列表只追加、不打乱顺序时这样没问题;一旦顺序变了,麻烦就来了。

我们这次的复选框错位就是典型:拖拽把第 3 项挪到了第 1 项,数据层面 list 数组的顺序确实变了、checked 字段也跟对了。但因为 key 是 index,框架看到的是"index 0 这个位置的节点还在,内容换一下就行",于是把第 1 项的文本内容改成了新数据,可是这个位置上的 <input> 是原来那个 DOM 节点原封不动地被复用下来的。复选框的勾选状态是浏览器原生维护的"DOM 自身状态",不受 v-model 反向控制的那一刻起,就已经跟错了位置。这种 bug 用 console.log 打数据永远是对的,因为问题根本不在数据层,之前那位同事大概率没意识到这一点,才会心安理得地留下 :key="index"

有没有可能 index 做 key 也没事

我把这个疑问也记下来了,免得以后见到 index 就一律打回去,忘了为什么。

1<li v-for="(item, index) in list" :key="index">
2  {{ item.name }}
3</li>

如果列表只是静态展示,从不增删排序,index 全程不变,那和用 id 做 key 没有区别。真正出问题的场景是列表会插入、删除、排序:

1old: [A, B, C]
2new: [D, A, B, C]

原来 index 0 是 A,现在 index 0 是 D,框架用 index 做 key 时会认为"这还是 A 对应的节点",于是错误复用。更稳的做法是用业务唯一 id:

1:key="item.id"

有个常见的纠结:数据本身没有现成 id 怎么办?我这几天顺手把处理顺序理了一下——后端能给稳定 id 最好,直接用;给不了,但某个字段组合能保证唯一(比如 codename + type),就用这个组合;实在没有唯一标识,就在数据进前端时由前端自己补一个稳定的本地 id:

1let uid = 0
2this.list = rawList.map(item => ({ ...item, _localId: ++uid }))

关键词是"稳定"——同一条数据无论排第几,这个 key 都不能变。我见过更糟的写法,:key="Math.random()",每次渲染 key 全是新的,框架认为整个列表都换了,所有节点销毁重建,性能和过渡动画、输入焦点全部搭进去。也见过 :key="item.name + index" 这种自以为更"唯一"的写法,其实只要带上 index,顺序一变 key 照样跟着变,等于白搭。

还有一个方向相反、同样致命的坑是 key 不唯一——两条不同数据用了同一个 key(比如拿一个并不唯一的 status 字段当 key)。前面说过框架靠 key -> 旧节点位置 建映射表,key 撞了,这张表就出现覆盖,同一个 key 只能指向一个旧节点,另一个就丢了,复用逻辑直接乱套,diff 结果不可预期。开发环境下 Vue 会在控制台警告 Duplicate keys detected,别把它当噪音划过去——它说的就是"你的身份标识不管用了"。所以 key 的要求是两头都要满足:既要稳定(同一条数据 key 不变),又要唯一(不同数据 key 不同),少一头都会出事。

什么时候 index 做 key 才是安全的?三个条件同时满足:列表纯静态展示、不增删不排序;列表项内部没有非受控状态,没有输入框、没有组件自身 state;不依赖列表过渡动画。满足这些,用 index 没问题,没必要为了"规范"硬造 id。规则是用来理解的,不是拿来无脑套的。

key 不只用在列表里:反过来逼组件重建

弄明白 key 的复用机制后,我顺手解决了另一个一直靠"歪招"对付的问题。详情页顶部有个商品切换的下拉,切不同商品时,下面那块表单组件里的一些内部状态(展开的折叠面板、临时校验状态)不会跟着重置,还留着上一个商品的痕迹。之前我是在 watch 里手动一个个字段清,写得又长又容易漏。

理解 diff 之后才反应过来:这本质上是"框架把它当成同一个组件复用了,只更新了 props"。既然 key 是身份标识,那我给这个组件挂一个跟着商品 id 变的 key,商品一换、key 一变,框架就认为"这是一个全新的节点",直接把旧组件销毁、建一个全新的:

1<!-- productId 变化时,整个 EditForm 销毁重建,内部状态天然清空 -->
2<edit-form :key="productId" :product-id="productId" />

这是 key 的另一面:列表里我们用它"保住复用",避免错位;这里我们反过来用它"打断复用",故意触发重建。同一个机制,两个方向的用法。当然这招是有代价的——销毁重建比更新 props 重得多,组件里所有子节点全部重新走一遍创建流程,不能拿它当偷懒的万能重置。只有在"确实需要一整块彻底回到初始态、手动清又清不干净"的时候才值得用。

顺带把过渡动画那条坑也想明白了

前面反复提到"不稳定的 key 会毁掉列表过渡动画",我一直是把它当条要背的规矩,这次总算把因果讲清楚了。列表如果套在 <transition-group> 里,Vue 要在节点移动时给它加上位移动画(靠的是 v-move 那个类和 FLIP 技术),前提是它得认得出"这个节点从旧位置挪到了新位置,是同一个节点"。这个"认出是同一个"的依据,正是 key。

1<transition-group name="list" tag="ul">
2  <li v-for="item in list" :key="item.id">{{ item.name }}</li>
3</transition-group>

key 稳定,排序变化时 Vue 就能识别出每个节点是"移动",于是播放平滑的位移动画;一旦 key 用了 index 或者 Math.random(),Vue 认为节点是"新建/销毁"而不是"移动",动画就变成了生硬的闪现,甚至根本不触发。所以"过渡动画要求稳定 key"不是一条孤立的规矩,它和列表错位其实是同一个根:框架能不能靠 key 认对"这是谁"。认对了,复用、移动、动画全都对;认错了,状态错位、动画失效一起来。

把烂摊子的其他角落也翻一遍

修完复选框这个 bug,我没急着关工单,顺手把整个组件又读了一遍,防止还有别的坑埋在里面,结果真挖出两处。

第一处是列表项内部还嵌了一个子组件,用来展示每一项的"最后编辑时间",这段代码写了个 v-if 来控制显示或隐藏,而且切换很频繁,每次拖拽结束都会触发一次重新计算和显隐。v-if 在 diff 层面是真的从 VNode 树里增删节点,切换一次就要经历一次组件的创建和销毁;v-show 只是切换 display,节点全程留在树里。像这种反复切换、切换本身没有太多业务含义的场景,换成 v-show 更划算,能省掉反复 patch 和组件重建的开销;如果只是首次渲染时决定要不要这块内容、之后基本不会变,v-if 更省常驻开销。这一段我给它换成了 v-show,拖拽的手感肉眼可见地顺滑了一些。

第二处更隐蔽:这个列表在勾选状态变化时,会整体重新赋值 this.list = res.data,而不是修改已有数组里对应项的字段。数据量小的时候看不出问题,但如果以后列表涨到几百行,每次操作都整体替换数组引用,就意味着几百个 VNode 全部要重新走一遍 patchVnode,即便绝大多数节点其实什么都没变。这个坑我没在这次改,写了条注释留给自己,等以后列表数据量真涨上来了,再改成只更新变化的那几项。

diff 也不是免费的午餐

顺着这个思路我又想起前阵子帮监控大屏那边看过的一个类似性能问题,正好可以拿来对照。那个大屏左侧是一张几百行的实时表格,右侧每秒推一次新数据,最初的写法是数据一来就把整个 tableData 数组重新赋值,页面卡得风扇狂转。排查下来问题不在 diff 算法本身,而在于每次都生成了全新的数组和对象引用,导致几百行全部重新进入 patch 流程,行内还有格式化函数在 render 时反复执行。后来做了两件事就好了:一是只更新真正变化的那几行,按 id 定位后局部更新;二是把行内的重计算搬进 computed 缓存起来。虚拟 DOM 帮你算出了"哪里变了",但它没法帮你减少"你让多少东西参与了比较",后者得靠自己控制数据的变更范围。

大列表里那些"只负责展示、没有自身状态"的小项,我还试了改成函数式组件(functional component)。函数式组件没有实例、没有响应式数据、没有生命周期,Vue 处理它时省掉了创建组件实例、建立 watcher 这些开销,对"渲染几百上千次的纯展示单元"来说是笔实在的节省:

1// 一个纯展示的行组件,标成 functional
2export default {
3  functional: true,
4  props: ['item'],
5  render: function (h, ctx) {
6    var item = ctx.props.item
7    return h('li', [item.name])
8  }
9}

代价是它确实"什么都没有"——不能有自己的 state,模板里也拿不到 this,一切输入都得从 ctx.propsctx.listeners 里取。所以它只适合真正无状态的展示节点,一旦这一项内部要维护勾选、展开这类状态,就不该用它。这算是另一个"针对 diff/渲染开销做的工程手段",和控制数据变更范围是一路的:框架把该省的都省了,剩下的得靠我们选对组件形态。

大列表要分页或者上虚拟滚动、避免无意义的整体重新渲染、key 保持稳定、组件拆分要合理——这几条我抄在了笔记本里,提醒自己不要把"用了虚拟 DOM"当成性能的万能答案。

社区里"虚拟 DOM 是不是纯开销"的争论

查这些资料时我还翻到一篇挺尖锐的文章,作者是做 Svelte 那个编译框架的,观点很直接:虚拟 DOM 本身就是"纯开销"(pure overhead)。他的论证不难懂——每次更新,虚拟 DOM 方案都要先生成一整棵新的 VNode 树,再拿它跟旧树逐层 diff,最后才动真实 DOM。而生成新树、比较两棵树这两步,是真实 DOM 更新之外额外多出来的成本。他的框架走了另一条路:在编译阶段就分析出"这段模板里哪些是死的静态内容、哪些是会变的动态绑定",编译成直接操作 DOM 的命令式代码,运行时根本不需要 VNode,也就没有 diff。

这个说法一开始让我有点动摇,但顺着前面自己想通的那条线捋一遍就站稳了:虚拟 DOM 的卖点从来不是"运行时最快",而是"用声明式的心智模型换掉手写命令式 DOM 操作",以及由此带来的跨平台能力——同一套 VNode,既能 patch 到浏览器 DOM,也能渲染成原生控件或者服务端字符串,中间隔着一层抽象。编译期优化和虚拟 DOM 也不是完全对立的,Vue 本身在编译模板时就做了不少事:把纯静态的节点提升(static hoisting)出去、标记出哪些节点是动态的,让运行时 diff 尽量跳过那些永远不变的部分。所以这场争论对我这个业务开发的实际意义是:别把虚拟 DOM 神化成"天生快",也别听风就是雨觉得它一无是处——它是一个"可维护性、跨平台优先,性能靠工程手段和编译优化来补"的权衡产物。眼下我手上是 Vue 2,这些编译期优化能吃多少是多少,剩下的还得靠自己控制数据变更范围。

这两周社区里 Vue 3 的 Composition API RFC 讨论得很热闹,尤大他们那边也在提新的响应式方案和更细粒度的更新思路。具体能不能落地成我能用的东西,现在还早,我只当新闻看着,暂时没打算深究,手上这套 Vue 2 的 diff 机制才是眼下要吃透的。隔壁 React 阵营那边,二月刚发布的 16.8 Hooks 之外,他们从 16.0(那是两年前的事了)起就换上了一套叫 Fiber 的协调引擎,把原来一次性递归的 diff 过程拆成了可以中断、分片执行的小任务,避免大量更新时长时间占用主线程——这个思路和我前面处理监控大屏时手动"分批更新"的土办法,方向上还挺像,只是人家是框架内置的能力,我们是业务代码里手动补的。Vue 这边目前还是一次性同步走完 diff 和 patch,多了解一句当个见识,不代表我们现在就能用上。

还有一个和这次 bug 相关、之前没细想过的点:Vue 的数据变化不会立刻同步触发 DOM 更新,而是把需要重新渲染的组件塞进一个队列,在同一个事件循环的微任务里(或者降级成宏任务)批量执行一次,这就是 nextTick 机制的由来。同一个事件处理函数里改十次数据,也只会触发一次真正的 diff 和 patch,不会改一次渲染一次。

这个队列还做了去重:同一个组件的 watcher 在一次 tick 里被触发多次,只会入队一次,靠的是 watcher 的 id 去标记。所以哪怕你在一个循环里给同一个响应式数组 push 一百次,最终也只对应一次组件级的重渲染,而不是一百次。理解这层之后,很多"我改了数据为什么 DOM 没马上变"的困惑就顺了——不是没变,是排到队列里等这一轮同步代码跑完才统一处理。这也是为什么改完数据后立刻去读 DOM 拿到的还是旧值,非要读新值就得等 this.$nextTick(() => {...})。这次修复复选框 bug 时我一度怀疑过是不是这个机制在捣鬼,翻了一圈发现不是,但顺手把这块也补齐了,免得下次真遇到跟它有关的坑,抓瞎。

交上工单

工单里我把结论写清楚了:根因是 :key="index",改成 :key="item.id" 后复选框状态跟数据对齐;顺手把一处频繁切换的 v-if 换成了 v-show,把一处整体重渲染的隐患记在了待办里。QA 又拖拽测了十几次,复选框再没跑偏过。

回头看这半页纸的交接文档,什么都没说,但那一行 :key="index" 替我把 diff 算法、key 的作用、渲染性能这几件事全串起来考古了一遍。虚拟 DOM 是用 JavaScript 对象描述 UI,diff 是比较新旧描述、算出最小的更新代价,key 是这套算法能不能认对"这是谁"的唯一线索。接手别人的烂摊子这件事本身不算愉快,但它逼着我把一直含糊带过的框架原理,一点一点挖到了底。