前言:前面写了1篇vue 观察者模式响应式数据的解析,里面关于dom操作部分是直接操作的真实dom,并没有涉及到虚拟dom部分,然后这里就做一个虚拟dom的实现解析。
首先,还是说什么是虚拟dom,我之前一篇mini-react中说过虚拟dom的概念,这里再重复一下。
- Virtual DOM(虚拟 DOM),是由普通的 JS 对象来描述 DOM 对象,因为不是真实的 DOM 对象,所以叫 Virtual DOM
- 可以使用 Virtual DOM 来描述真实 DOM,示例:
{
sel:'div',
data:{},
children:undefined,
text:'hello word',
elm:undefined,
key:undefined
}
这里和我之前写的 mini-react中说的虚拟dom结构不一样,这里是以Snabbdom生成的虚拟dom结构为例子,表述,因为vue2.0内部使用就是Snabbdom,只是在它的基础上改造了一些新的功能,它也是最快的虚拟dom库之一。
然后就是动机,这里说下为啥要用虚拟dom:
- 手动操作 DOM 比较麻烦,还需要考虑浏览器兼容性问题,虽然有 jQuery 等库简化 DOM 操作,但是随着项目的复杂 DOM 操作复杂提升
- 为了简化 DOM 的复杂操作于是出现了各种 MVVM 框架,MVVM 框架解决了视图和状态的同步问题
- 为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题,于是Virtual DOM 出现了
- Virtual DOM 的好处是当状态改变时不需要立即更新 DOM,只需要创建一个虚拟树来描述DOM, Virtual DOM 内部将弄清楚如何有效(diff)的更新 DOM
虚拟dom的作用:
- 维护视图和状态的关系
- 复杂视图情况下提升渲染性能
- 除了渲染 DOM 以外,还可以实现 SSR(Nuxt.js/Next.js)、原生应用(Weex/React Native)、小程序(mpvue/uni-app)等
虚拟dom是可以做(真实dom,ssr,原生应用,小程序)的转换的
前面说了vue 的虚拟dom基础就是Snabbdom,下面我们就来做下Snabbdom 的源码分析,方便后续进一步了解vue的源码
首先准备工作:和mini-react一样打包工具使用parcel,这里贴一下package.json的配置
{
"name": "snabbdom-demo",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"dev": "parcel index.html --open",
"build": "parcel build index.html"
},
"dependencies": {
"parcel-bundler": "^1.12.4",
"snabbdom": "^0.7.4"
}
}
注意:这里用的是snabbdom 0.7.4版本,版本不一致使用方式可能会略有变化,可以去查看官网,最新版是2.1.0,核心逻辑没有什么特别大的变化。
首先没了解过它的,可以先看下它的官网中文翻译:
snabbdom中文翻译
基本使用可以查看官网,可以参考上面的中文翻译
我们这里使用例子用的是es6模块导入,官网是commonjs规范,
import{init,h,thunk}from'snabbdom'
// 1. 导入模块
import style from 'snabbdom/modules/style'
import eventlisteners from 'snabbdom/modules/eventlisteners'
snabbdom核心提供三个函数,init,h,thunk。
- init是一个高阶函数,返回patch函数用来对比更新dom。
- h函数用来返回虚拟节点vNode,vue中经常会看到.
- thunk是一种优化策略,处理不可变数据时使用
模块,Snabbdom 的核心库并不能处理元素的属性/样式/事件等,如果需要处理的话,可以使用模块,官方提供了6个模块。
attributes
- 设置 DOM 元素的属性,使用setAttribute()
- 处理布尔类型的属性
props
- 和attributes模块相似,设置 DOM 元素的属性element[attr]=value
- 不处理布尔类型的属性
class
- 切换类样式
- 注意:给元素设置类样式是通过sel选择器
dataset
- 设置data-*的自定义属性
eventlisteners
- 注册和移除事件
style
- 设置行内样式,支持动画
- delayed/remove/destroy
这里放一个使用模块的例子:
import { init, h } from 'snabbdom'
// 1. 导入模块
import style from 'snabbdom/modules/style'
import eventlisteners from 'snabbdom/modules/eventlisteners'
// 2. 注册模块
let patch = init([
style,
eventlisteners
])
// 3. 使用 h() 函数的第二个参数传入模块需要的数据(对象)
let vnode = h('div', {
style: {
backgroundColor: 'red'
},
on: {
click: eventHandler
}
}, [
h('h1', 'Hello Snabbdom'),
h('p', '这是p标签')
])
其实它核心主要分这几步:
- 使用init设置模块,创建patch函数
- 使用h函数创建js对象(vNode)描述真实dom
- patch函数对比新旧两个vNode
- 把变化内容更新到真实dom树上
我们来看下node_modules中snabbdom它的源码src下的结构
我们主要分析的有:
- h函数 src/h.ts
- vnode src/vnode.ts
- init src/snabbdom.ts
- patch src/snabbdom.ts
- createElm src/snabbdom.ts
- patchVnode src/snabbdom.ts
- updateChildren src/snabbdom.ts
这里先说下对应函数的大概功能,最后我会把我做过注释的源代码都贴出来,有兴趣的可以把package.json拿走,下载然后找到对应node_modules里的snabbdom里的代码和我贴的代码注释对比看下。
h函数
- Snabbdom 中的 h() 函数不是用来创建超文本,而是创建 VNode
函数重载:
- 参数个数或类型不同的函数
- JavaScript 中没有重载的概念
- TypeScript 中有重载,不过重载的实现还是通过代码调整参数
h.ts中就使用了重载,源码中最后一个导出是对重载的实现,也是根据传入参数来判断的。
vnode
- 一个 VNode 就是一个虚拟节点用来描述一个 DOM 元素,如果这个 VNode 有 children 就是Virtual DOM
init
init(modules, domApi),返回 patch() 函数(高阶函数)
- 因为 patch() 函数再外部会调用多次,每次调用依赖一些参数,比如:modules/domApi/cbs
- 通过高阶函数让 init() 内部形成闭包,返回的 patch() 可以访问到 modules/domApi/cbs,而不需要重新创建
- init() 在返回 patch() 之前,首先收集了所有模块中的钩子函数存储到 cbs 对象中
patch
它的作用:
- 打补丁,把新节点中变化的内容渲染到真实 DOM,最后返回新节点作为下一次处理的旧节点
- 回新节点作为下一次处理的旧节点对比新旧 VNode 是否相同节点(节点的 key 和 sel 相同)
- 如果不是相同节点,删除之前的内容,重新渲染
- 如果是相同节点,再判断新的 VNode 是否有 text,如果有并且和 oldVnode 的 text 不同,直接更新文本内容
- 如果新的 VNode 有 children,判断子节点是否有变化,判断子节点的过程使用的就是 diff 算法
- diff 过程只进行同层级比较
功能:
- 传入新旧 VNode,对比差异,把差异渲染到 DOM
- 返回新的 VNode,作为下一次 patch() 的 oldVnode
执行过程:
- 首先执行模块中的钩子函数pre
如果 oldVnode 是 DOM 元素
- 把 DOM 元素转换成 oldVnode
如果 oldVnode 和 vnode 相同(key 和 sel 相同)
- 调用 patchVnode(),找节点的差异并更新 DOM
如果不相同
- 调用 createElm() 把 vnode 转换为真实 DOM,记录到 vnode.elm
- 把刚创建的 DOM 元素插入到 parent 中
- 移除老节点
- 触发用户设置的insert钩子函数
- 触发模块post钩子
createElm
功能:
- createElm(vnode, insertedVnodeQueue),返回创建的 DOM 元素
- 创建 vnode 对应的 DOM 元素,放到vnode的elm上
执行过程:
- 首先触发用户设置的init钩子函数
- 如果选择器是!,创建注释节点
如果选择器不为空
- 解析选择器,生成真实dom,设置标签的 id 和 class 属性
- 执行模块的create钩子函数
- 如果 vnode 有 children,递归调用createElm创建子 vnode 对应的 DOM,追加到 DOM 树
- 如果 vnode 的 text 值是 string/number,创建文本节点并追击到 DOM 树
- 执行用户设置的create钩子函数
- 如果有用户设置的 insert 钩子函数,把 vnode 添加到队列中
- 如果选择器为空,创建文本节点
patchVnode
功能:
- patchVnode(oldVnode, vnode, insertedVnodeQueue)
- 对比 oldVnode 和 vnode 的差异,把差异渲染到 DOM
执行过程
- 首先执行用户设置的prepatch钩子函数
- 执行先模块update 钩子函数再用户的update钩子函数
如果vnode.text未定义
如果oldVnode.children和vnode.children都有值
- 新旧的children不相等 调用updateChildren()
- 使用 diff 算法对比子节点,更新子节点
如果vnode.children有值,oldVnode.children无值
- 清空oldVnode中文本 DOM 元素
- 调用addVnodes(),批量添加子节点
如果oldVnode.children有值,vnode.children无值
- 调用removeVnodes(),批量移除子节点
如果oldVnode.text有值,新旧的children都没有
- 清空 DOM 元素的内容
如果设置了vnode.text并且和和oldVnode.text不等
- 如果老节点有子节点,全部移除
- 设置 DOM 元素的textContent为vnode.text
- 最后执行用户设置的postpatch钩子函数
updateChildren
功能:diff 算法的核心,对比新旧节点的 children,更新 DOM(市面上虚拟dom的库,diff算法核心都是更新对比子集)
执行过程:
- 要对比两棵树的差异,我们可以取第一棵树的每一个节点依次和第二课树的每一个节点比较,但是这样的时间复杂度为 O(n^3)
- 在DOM 操作的时候我们很少很少会把一个父节点移动/更新到某一个子节点
- 因此只需要找同级别的子节点依次比较,然后再找下一级别的节点比较,这样算法的时间复杂度为 O(n)
- 在进行同级别节点比较的时候,首先会对新老节点数组的开始和结尾节点设置标记索引,遍历的过程中移动索引
在对开始和结束节点比较的时候,总共有四种情况
- oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
- oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)
- oldStartVnode / oldEndVnode (旧开始节点 / 新结束节点)
- oldEndVnode / newStartVnode (旧结束节点 / 新开始节点)
下面就是具体执行顺序的比较了,如下:
重点1:
开始节点和结束节点比较,这两种情况类似
- oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
- oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)
如果 oldStartVnode 和 newStartVnode 是 sameVnode (key 和 sel 相同,同一个)
- 调用 patchVnode() 对比和更新节点
- 把旧开始和新开始索引往后移动 oldStartIdx++ / oldEndIdx++
这两种情况,这会是不需要调整位置的,因为都是开始节点。
重点2
oldStartVnode / newEndVnode (旧开始节点 / 新结束节点) 相同
- 调用 patchVnode() 对比和更新节点
把 oldStartVnode 对应的 DOM 元素,移动到右边
- 更新索引