虚拟 DOM 是什么?从 Snabbdom 理解 Vue 的 DOM 更新机制

虚拟 DOM 是什么?从 Snabbdom 理解 Vue 的 DOM 更新机制
一、前言在前端开发中如果页面数据发生变化最终还是要反映到真实 DOM 上。最直接的做法当然是手动操作 DOM比如找到某个节点然后修改它的内容document.querySelector(#app).innerHTMLp新的内容/p;这种方式简单粗暴小页面里也确实能用。但如果页面结构复杂频繁使用innerHTML整块替换 DOM就容易带来一些问题原来的 DOM 节点会被销毁新节点会重新创建。绑定在旧 DOM 节点上的事件可能会丢失。浏览器需要重新解析 HTML 字符串重新生成节点更新成本会变高。开发者需要自己判断哪些地方该改代码后期会越来越难维护。所以在 Vue 2 之后我们经常会接触到一个概念虚拟 DOM。它不是为了让我们完全不操作真实 DOM而是框架在真实 DOM 之前加了一层“描述层”。我们只需要关心数据和视图结构至于真实 DOM 应该怎么更新则交给框架内部处理。这篇文章主要从 Snabbdom 入手理解一下虚拟 DOM 的基本思想。二、虚拟 DOM1. 什么是虚拟 DOM在 Vue 中视图通常是由数据驱动的。比如我们修改一个数据this.message新的内容;页面会跟着变化。但页面变化的背后并不是“数据直接变成了 DOM”中间还会经历一套渲染流程。简单理解就是数据变化 - 生成新的虚拟 DOM - 对比新旧虚拟 DOM - 更新真实 DOM那虚拟 DOM 到底是什么简单来说虚拟 DOM 就是用 JavaScript 对象来描述真实 DOM 结构。它本身并不是真实的 DOM 节点不能直接调用appendChild、querySelector这类 DOM API。它只是一个普通的 JS 对象用来告诉框架我想要的页面结构长什么样。2. 虚拟 DOM 的结构比如下面这段真实 DOMdividappclassmainContainerulclasscontainer-ulstylecolor:#fff;li第一项/lili第二项/lili第三项/li/ul/div我们可以用一个 JS 对象来描述它。这里先用一个简化版结构表示方便理解{tag:div,props:{id:app,className:mainContainer},children:[{tag:ul,props:{className:container-ul,style:{color:#fff}},children:[{tag:li,children:[第一项]},{tag:li,children:[第二项]},{tag:li,children:[第三项]}]}]}这个对象大致包含三个核心信息tag当前节点的标签名比如div、ul、li。props当前节点的属性比如id、className、style。children当前节点的子节点可以是文本也可以是其他虚拟节点。需要注意的是不同框架或虚拟 DOM 库的字段命名不一定一样。比如 Snabbdom 中的 VNode 更接近下面这些字段sel、data、children、text、elm、keyVue 里的虚拟节点结构也有自己的实现细节。所以我们上面的tag / props / children只是为了方便理解并不是某个库的完整源码结构。三、为什么需要虚拟 DOM如果没有虚拟 DOM我们更新页面时很容易写出这种代码ul.innerHTMLli第一项/li li第二项 - 已更新/li li第三项/li;这样确实能更新页面但问题是即使只有第二项发生变化整个ul里面的内容也可能被重新生成。而有了虚拟 DOM 之后框架可以先在 JS 层面比较新旧结构旧的虚拟 DOMh(ul,[h(li,第一项),h(li,第二项),h(li,第三项)]);新的虚拟 DOMh(ul,[h(li,第一项),h(li,第二项 - 已更新),h(li,第三项)]);通过对比框架可以发现ul没有变。第一个li没有变。第二个li的文本变了。第三个li没有变。最后只需要把第二个li的文本更新掉就可以了。这里要注意一个说法虚拟 DOM 的 diff 算法并不是每次都能算出“全局最小修改方案”。更准确地说它是通过一些规则和策略尽量减少不必要的 DOM 操作让更新过程更加可控。四、Snabbdom 简介Snabbdom 是一个轻量级虚拟 DOM 库。它的核心思想比较清晰用h函数创建虚拟节点。用patch函数把虚拟节点渲染成真实 DOM。数据变化后生成新的虚拟节点。再次调用patch对比新旧虚拟节点并更新真实 DOM。Vue 2 的虚拟 DOM 实现和 Snabbdom 的思路比较接近所以用 Snabbdom 来理解虚拟 DOM 是一个不错的切入点。五、Snabbdom 的基本使用1. 安装npminstallsnabbdom2. 创建 patch 函数Snabbdom 中需要先通过init创建一个patch函数。import{init,classModule,propsModule,styleModule,eventListenersModule,h}fromsnabbdom;constpatchinit([classModule,propsModule,styleModule,eventListenersModule]);这些模块负责处理不同类型的 DOM 更新classModule处理 class。propsModule处理 DOM property。styleModule处理样式。eventListenersModule处理事件监听。Snabbdom 的核心非常小很多能力都是通过模块扩展进去的。这也是它比较适合拿来学习虚拟 DOM 的原因结构不会太绕。3. 使用 h 函数创建虚拟 DOMSnabbdom 通常使用h函数创建虚拟节点constvnodeh(div#app.mainContainer,[h(ul.container-ul,{style:{color:#fff}},[h(li,第一项),h(li,第二项),h(li,第三项)])]);这里的h(div#app.mainContainer)表示创建一个类似这样的节点dividappclassmainContainer/div也就是说Snabbdom 的h函数支持类似 CSS 选择器的写法。六、第一次渲染假设页面中一开始有这样一个节点dividapp/div我们可以这样把虚拟 DOM 渲染出来constappdocument.getElementById(app);letoldVnodepatch(app,vnode);这里有一个容易误解的地方。patch(app, vnode)并不是简单地把 vnode 生成的 DOM 插入到app里面而是会用 vnode 生成的真实 DOM 去替换原来的app节点。所以在示例中最好让真实 DOM 和虚拟节点的选择器保持一致比如真实节点是dividapp/div虚拟节点也写成h(div#app.mainContainer,[])这样替换之后页面结构仍然是我们预期的div#app只是它已经变成了 Snabbdom 管理的节点。第一次patch执行之后我们把返回值保存到oldVnode中。后面再次更新时就需要拿它和新的 vnode 做对比。七、更新视图当数据变化后我们重新创建一个新的虚拟 DOMconstnewVnodeh(div#app.mainContainer,[h(ul.container-ul,{style:{color:#fff}},[h(li,第一项),h(li,第二项 - 已更新),h(li,第三项)])]);oldVnodepatch(oldVnode,newVnode);这一次patch的第一个参数不再是真实 DOM而是上一次返回的旧 vnode。Snabbdom 会对比oldVnode和newVnode发现只有第二个li的文本内容发生了变化于是只更新对应的文本节点而不是重新创建整棵 DOM 树。这就是虚拟 DOM 更新的基本过程。八、diff 算法虚拟 DOM 中最关键的一步就是 diff。所谓 diff就是比较新旧两个虚拟 DOM找出它们之间的差异然后把这些差异应用到真实 DOM 上。不过为了性能考虑虚拟 DOM 的 diff 通常不会做非常复杂的跨层级比较而是采用一些简化策略。常见规则可以简单理解为如果两个节点类型不同直接用新节点替换旧节点。如果两个节点类型相同就继续比较它们的属性和子节点。比较子节点时通常会结合key来判断哪些节点可以复用哪些需要移动、创建或删除。举个简单例子constoldVnodeh(p,旧内容);constnewVnodeh(p,新内容);这两个节点都是p所以不需要重新创建p标签只需要更新里面的文本。但如果是这样constoldVnodeh(p,旧内容);constnewVnodeh(div,新内容);节点类型从p变成了div这时通常就会直接替换节点。九、为什么列表中需要 key在列表渲染中key是一个非常重要的概念。假设有这样一个列表constlist[{id:1,text:第一项},{id:2,text:第二项},{id:3,text:第三项}];渲染成虚拟 DOMconstvnodeh(ul,[h(li,{key:1},第一项),h(li,{key:2},第二项),h(li,{key:3},第三项)]);如果后来列表顺序变成constlist[{id:3,text:第三项},{id:1,text:第一项},{id:2,text:第二项}];有了key之后diff 算法就能知道这些节点不是全都变成了新节点而是原来的节点位置发生了变化。这样框架就可以尽量复用已有 DOM而不是盲目删除再创建。这也是为什么在 Vue 中使用v-for时通常建议给每一项加上稳定且唯一的keyliv-foritem in list:keyitem.id{{ item.text }}/li这里的key最好使用业务上稳定的唯一值比如id。不太建议使用数组下标作为key尤其是列表会新增、删除、排序的时候。因为下标会随着位置变化而变化可能导致节点复用不符合预期。十、虚拟 DOM 一定更快吗虚拟 DOM 不一定在任何情况下都比直接操作真实 DOM 快。比如只是改一个明确的文本document.querySelector(#title).textContent新标题;这种写法肯定很直接也没有创建虚拟 DOM 和 diff 的过程。所以不能简单地说“虚拟 DOM 一定比真实 DOM 快”。虚拟 DOM 更重要的价值在于让我们用声明式的方式描述 UI。让框架统一管理视图更新。在复杂页面中减少不必要的 DOM 操作。让组件化、跨平台渲染、服务端渲染等能力更容易实现。也就是说虚拟 DOM 解决的重点不是某一次 DOM 操作谁更快而是复杂应用里视图更新如何变得更好维护。十一、Vue 中的虚拟 DOM 更新流程结合 Vue 来看整体流程大致是这样的模板被编译成渲染函数。渲染函数执行后生成虚拟 DOM。数据发生变化。Vue 重新执行渲染函数生成新的虚拟 DOM。新旧虚拟 DOM 进行 diff。找出需要更新的地方后更新真实 DOM。可以简单理解为模板 - 渲染函数 - 虚拟 DOM - 真实 DOM数据变化时数据变化 - 新的虚拟 DOM - diff - 更新真实 DOM这也是为什么我们在 Vue 中大多数时候只需要关心数据而不需要手动操作 DOM。十二、总结虚拟 DOM 本质上就是一个普通的 JavaScript 对象它用来描述真实 DOM 的结构。它的大致更新流程是用 JS 对象描述页面结构。初次渲染时根据虚拟 DOM 创建真实 DOM。数据变化后生成新的虚拟 DOM。通过 diff 比较新旧虚拟 DOM。将变化应用到真实 DOM 上。需要注意的是虚拟 DOM 并不是性能万能药。它的意义更多在于让页面更新变得可预测、可维护同时把复杂的 DOM 更新逻辑交给框架处理。对于 Vue、React 这类现代前端框架来说虚拟 DOM 是连接“数据状态”和“真实页面”的中间层。开发者主要关注数据和组件结构而真实 DOM 如何更新则由框架内部完成。参考资料Snabbdom GitHub READMEVue 官方文档Rendering Mechanism