Virtual DOM 的实现原理
1、什么是虚拟DOM
- VirtualDOM(虚拟DOM),是由普通的JS对象来描述DOM对象
- 真实DOM成员
由此看出创建一个DOM对象的成本是非常高的
- 使用VirtualDOM来描述真实DOM
创建虚拟DOM对象,成员非常少,也就是创建一个虚拟DOM对象比创建一个真实DOM对象的成本小很多
虚拟DOM就是一个普通的JAVAScript的对象用来描述真实DOM
2、为什么使用虚拟DOM
为什么要使用VirtualDOM
- 前端开发刀耕火种的时代
- MVVM框架解决视图和状态同步问题
- 模板引擎可以简化视图操作,没办法跟踪状态
- 虚拟DOM跟踪状态变化
- 参考github上 virtual-dom 的动机描述
- 虚拟DOM可以维护程序的状态,跟踪上一次的状态
- 通过比较前后两次状态差异更新真实DOM
3、虚拟DOM的作用和虚拟DOM库
- 维护视图和状态的关系
- 复杂视图情况下提升渲染性能
- 跨平台
- 浏览器平台渲染DOM
- 服务端渲染SSR(Nuxt.js/Next.js) :服务端渲染就是把虚拟DOM转换成普通的html字符串。因为虚拟DOM就是普通的js对象,所以可以对它做任意的编程处理。
- 原生应用(Weex/ReactNative)
- 小程序(mpvue/uni-app)等
虚拟DOM库
-Snabbdom - Vue.js2.x内部使用的虚拟DOM就是改造的Snabbdom
- 大约200SLOC(singlelineofcode)
- 通过模块可扩展
- 源码使用TypeScript开发
- 最快的VirtualDOM之一
- virtual-dom 最早的虚拟DOM开源库
4、Snabbdom 基本使用
1、创建项目
- 步骤
- 安装parcel
- 配置scripts
- 目录结构
- 安装parcel
# 创建项目目录
md snabbdom-demo
# 进入项目目录
cd snabbdom-demo
# 创建 package.json
npm init -y
# 本地安装 parcel
npm install parcel-bundler -D
- 配置scripts
"scripts":{
"dev": "parcel index.html --open",
"build": "parcel build index.html"
}
- 目录结构
2、导入 Snabbdom
Snabbdom文档
- 看文档的意义
- 学习任何一个库都要先看文档
- 通过文档了解库的作用
- 看文档中提供的示例,自己快速实现一个demo
- 通过文档查看API的使用
- Snabbdom文档
- https://github.com/snabbdom/snabbdom
- 当前版本v2.1.0
- 安装Snabbdom
- npm intall snabbdom@2.1.0
- 导入Snabbdom
- Snabbdom的两个核心函数init和h()
- init()是一个高阶函数,返回patch()
- h()返回虚拟节点VNode,这个函数我们在使用Vue.js的时候见过
- Snabbdom的两个核心函数init和h()
- 文档中导入的方式
init函数:接收一个数组用于加载snabbdom的模块
patch函数:作用把虚拟DOM转换成真实DOM渲染到界面上
h函数:用来创建虚拟节点 - 实际导入的方式 防止路径·查找错误报错
- parcel/webpack4不支持package.json中的exports字段 webpack5支持
- parcel/webpack4不支持package.json中的exports字段 webpack5支持
3、案例
1、体会init h patch这几个函数的使用
import { init } from 'snabbdom/build/package/init'
import { h } from 'snabbdom/build/package/h'
const patch = init([]) // 调用init函数返回patch函数
// 第一个参数:标签+选择器
// 第二个参数:如果是字符串就是标签中的文本内容
// let vnode = h('div#container.cls', 'Hello World')
let vnode = h('div#container.cls',{
// 源码中有data.hook
hook: {
init (vnode) { // init是在创建dom之前执行的。这个函数中获取不到vnode对应的dom元素
console.log(vnode.elm)
},
create (emptyNode, vnode) { // create是在创建dom元素之后执行的
console.log(vnode.elm)
}
}
}, 'Hello World')
let app = document.querySelector('#app')
// 第一个参数:旧的 VNode,可以是 DOM 元素
// 第二个参数:新的 VNode
// 返回新的 VNode
let oldVnode = patch(app, vnode) // patch内部会对比这两个vnode的差异,把这个差异更新到真实DOM,并且把第二个参数也就是新的vnode返回作为下次patch的oldvnode 也就是把当前的状态保存起来
// 如果新创建的div的内容有变化。这里会重新创建一个VNode的对象
vnode = h('div#container.xxx', 'Hello Snabbdom')
patch(oldVnode, vnode) // 对比新旧vnode的差异更新到视图
1、从let oldVnode = patch(app, vnode)打断点 f5刷新 调试patch函数
2、按f11单步执行(一行一行代码来执行)
3、定义了一些内部成员
4、遍历cbs找到所有模块的pre钩子函数,然后执行这些钩子函数。因为init的时候没有传入任何模块,所以这里没有pre钩子函数
5、isVnode函数 判断oldVnode是否是vnode类型的对象 发现是dom对象
6、继续执行emptyNodeAt函数 真实dom转换成虚拟节点vnode 内部: 处理id与 classname 又调用vnode函数把真实dom转换成vnode对象。这边创建 vnode的时候 传入的第一个参数先获取到dom元素的tagname并转换为小写,然后在拼接处理好的id和类选择器,然后设置后续的几个参数,最后把当前传入的dom元素作为新创建的vnode的elm
7、进入vnode函数
先去获取data中的key属性也就是vnode的唯一值,作为创建VNode对象的最后一个参数。当前没有传入,所以此时是undefined
vnode函数最后是把传入的这些数据,组合成一个js对象返回。返回的就是我们想要的vnod类型的对象
8、sameVnode判断新旧vnode节点是否是相同的vnode节点(key、sel)。如果相同则不会再创建dom元素,而是比较两个节点的差异,然后把差异更新到dom元素的内容上来。
进入该函数发现新旧vnode都是undefined没有设置值 。oldVnode.sel是div#app 新的vnode.sel是div#container.cls 不相同 所以不是相同的节点
9、因为不相同所以不会执行patchVnode函数。
创建vnode节点对应的dom元素,然后渲染到界面上。并且会把oldVnode对应的dom元素从界面上移除
执行:
首先获取oldVnode对应的dom元素
获取elm的父元素,因为接下里要把新节点对应的dom元素插入到这个父元素中
老节点的dom元素和父元素都获取到之后
9、调用createElm创建新节点对应的dom元素
f10跳过方法或者函数继续往后执行
新节点对应的dom元素创建好之后,要把这个dom元素插入到parent中
判断parent是否为null,不会null执行插入操作
此时界面上可以看到更新后的内容
然后移除oldVnode对应的dom元素
此时页面渲染工作就完成了
10、再往后就是触发用户传入的insert钩子函数以及模块中的post钩子函数。因为本案例没有传入任何的钩子函数。所以会跳过下面的这些循环。
11、最后返回vnode作为下次处理的oldvnode
这是patch函数首次执行的调试的过程。
2、h函数创建一个div,可以创建div里面的子元素
import { init } from 'snabbdom/build/package/init'
import { h } from 'snabbdom/build/package/h'
const patch = init([])
let vnode = h('div#container', [
h('h1', 'Hello Snabbdom'),
h('p', '这是一个p')
])
let app = document.querySelector('#app')
let oldVnode = patch(app, vnode)
setTimeout(() => {
// vnode = h('div#container', [
// h('h1', 'Hello World'),
// h('p', 'Hello P')
// ])
// patch(oldVnode, vnode)
// 清除div中的内容
patch(oldVnode, h('!')) // h('!') 创建空的注释节点
}, 2000);
5、Snabbdom中的模块
模块
•模块的作用
•官方提供的模块
•模块的使用步骤
1、模块的作用
- Snabbdom的核心库并不能处理DOM元素的属性/样式/事件等,可以通过注册Snabbdom默认提供的模块来实现
- Snabbdom中的模块可以用来扩展Snabbdom的功能
- Snabbdom中的模块的实现是通过注册全局的钩子函数来实现的
2、官方提供的模块
- attributes 设置vnode对应的DOM元素的属性,内部使用的是DOM元素的标准方法setAttribute来实现的。这个模块内部会对DOM元素的不类型的属性(如:selected,checked等)做判断
- props 类似 attributes 都是用来设置DOM对象的属性。不同的是props模块内部设置DOM对象的属性是通过对象点属性的方式来设置的。另外它内部不会去处理布尔类型的属性
- dataset 用来处理html5中提供的data-这样的自定义属性
- class 用来切换类样式
- style 用来设置行类样式,并且通过这个模块可以很容易设置过渡动画,它内部还注册了transitionEnd这个事件
- eventlisteners 用来注册和移除事件
3、模块的使用步骤
- 导入需要的模块
- init()中注册模块
- h()函数的第二个参数处使用模块 第二个参数可以设置成对象,这个对象就是设置模块中所需要的数据。这里可以设置DOM的属性、行内样式、事件等
import { init } from 'snabbdom/build/package/init'
import { h } from 'snabbdom/build/package/h'
// 1. 导入模块
import { styleModule } from 'snabbdom/build/package/modules/style'
import { eventListenersModule } from 'snabbdom/build/package/modules/eventlisteners'
// 2. 注册模块
const patch = init([
styleModule,
eventListenersModule
])
// 3. 使用h() 函数的第二个参数传入模块中使用的数据(对象)
let vnode = h('div', [
h('h1', { style: { backgroundColor: 'red' } }, 'Hello World'),
h('p', { on: { click: eventHandler } }, 'Hello P')
])
function eventHandler () {
console.log('别点我,疼')
}
let app = document.querySelector('#app')
patch(app, vnode)
6、Snabbdom 源码解析
如何学习源码
•宏观了解
•带着目标看源码
•看源码的过程要不求甚解
•调试
•参考资料
Snabbdom的核心
- init()设置模块,创建patch()函数
- 使用h()函数创建JavaScript对象(VNode)描述真实DOM
- patch()比较新旧两个Vnode
- 把变化的内容更新到真实DOM树
Snabbdom源码- 源码地址
- https://github.com/snabbdom/snabbdom
- 当前版本:v2.1.0
- 克隆代码
- git clone -b v2.1.0 --depth=1
- https://github.com/snabbdom/snabbdom.git
- 源码地址
1、h函数
核心就是处理参数 并且调用VNode函数创建一个VNode对象返回
h函数介绍
- 作用:创建VNode对象
- Vue中的h函数
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
- h函数最早见于hyperscript,使用JavaScript创建超文本
函数重载
- 参数个数或参数类型不同的函数
- JavaScript中没有重载的概念
- TypeScript中有重载,不过重载的实现还是通过代码调整参数
函数
函数重载-参数个数
函数重载-参数类型
2、常用快捷键
1、定位:光标–> F12 返回: Alt + <-
2、定位:Ctrl+鼠标左键 返回: Alt + <-
前进到刚刚的位置 Alt + ->
3、VNode
VNode对象用来描述真实DOM,创建VNode的时候可以根据需要传递相应应的参数
4、patch整体过程分析
patch整体过程分析
- patch(oldVnode,newVnode)
- 把新节点中变化的内容渲染到真实DOM,最后返回新节点作为下一次处理的旧节点
- 对比新旧VNode是否相同节点(节点的key和sel相同)
- 如果不是相同节点,删除之前的内容,重新渲染
- 如果是相同节点,再判断新的VNode是否有text,如果有并且和oldVnode的text不同,直接更新文本内容
- 如果新的VNode有children,判断子节点是否有变化
5、init
返回patch函数(oldvnode, vnode)
6、patch
patch内部会对比这两个vnode的差异,把这个差异更新到真实DOM,并且把第二个参数也就是新的vnode返回作为下次patch的oldvnode 也就是把当前的状态保存起来
7、调试 patch 函数
8、createElm
9、调试createElm
10、removeVnodes和addVnodes
11、patchVnode
12、updateChildren整体分析
13、Diff算法
- 虚拟DOM中的Diff算法
- 查找两颗树每一个节点的差异
- 查找两颗树每一个节点的差异
- Snbbdom根据DOM的特点对传统的diff算法做了优化
- DOM操作时候很少会跨级别操作节点
- 只比较同级别的节点
执行过程
- 在对开始和结束节点比较的时候,总共有四种情
- oldStartVnode/newStartVnode(旧开始节点/新开始节点)
- oldEndVnode/newEndVnode(旧结束节点/新结束节点)
- oldStartVnode/oldEndVnode(旧开始节点/新结束节点)
- oldEndVnode/newStartVnode(旧结束节点/新开始节点)
执行过程
开始和结束节点
- 如果新旧开始节点是sameVnode(key和sel相同)
- 调用patchVnode()对比和更新节点
- 把旧开始和新开始索引往后移动oldStartIdx++/oldEndIdx++
旧开始节点/新结束节点
- 调用patchVnode()对比和更新节点
- 把oldStartVnode对应的DOM元素,移动到右边,更新索引
旧结束节点/新开始节点
- 调用patchVnode()对比和更新节点
- 把oldEndVnode对应的DOM元素,移动到左边,更新索引
非上述四种情况
非上述四种情况
- 遍历新节点,使用newStartNode的key在老节点数组中找相同节点
- 如果没有找到,说明newStartNode是新节点
- 创建新节点对应的DOM元素,插入到DOM树中
- 如果找到了
- 判断新节点和找到的老节点的sel选择器是否相同
- 如果不相同,说明节点被修改了
- 重新创建对应的DOM元素,插入到DOM树中
- 如果相同,把elmToMove对应的DOM元素,移动到左边
循环结束
- 当老节点的所有子节点先遍历完(oldStartIdx>oldEndIdx),循环结束
- 新节点的所有子节点先遍历完(newStartIdx>newEndIdx),循环结束
oldStartIdx>oldEndIdx
- 如果老节点的数组先遍历完(oldStartIdx>oldEndIdx)
- 说明新节点有剩余,把剩余节点批量插入到右边
- 说明新节点有剩余,把剩余节点批量插入到右边
newStartIdx>newEndIdx
- 如果新节点的数组先遍历完(newStartIdx>newEndIdx)
- 说明老节点有剩余,把剩余节点批量删除
- 说明老节点有剩余,把剩余节点批量删除
14、updateChildren
15、调试updateChildren
16、调试带key的情况
节点对比过程
17、Key的意义
用来在diff算法中比较vnode是否是相同节点。如果不设置key会最大程度重用当前dom元素,但是重用dom元素有的时候会有问题