【Virtual DOM】虚拟 DOM 和 Snabbdom 库
前言
笔记来源:拉勾教育 大前端高薪训练营
阅读建议:建议通过左侧导航栏进行阅读
Virtual DOM
基本介绍
什么是 Virtual DOM
Virtual DOM(虚拟 DOM),是由普通的的 JS 对象来描述 DOM 对象,因为不是真实的 DOM 对象,所以叫 Virtual DOM。
真实 DOM 成员
let element = document.querySelector('#app') let s = '' for (var key in element) { s += key + ',' }console.log(s) // 打印结果 align,title,lang,translate,dir,hidden,accessKey,draggable,spellcheck,aut ocapitalize,contentEditable,isContentEditable,inputMode,offsetParent,off setTop,offsetLeft,offsetWidth,offsetHeight,style,innerText,outerText,onc opy,oncut,onpaste,onabort,onblur,oncancel,oncanplay,oncanplaythrough,onc hange,onclick,onclose,oncontextmenu,oncuechange,ondblclick,ondrag,ondrag end,ondragenter,ondragleave,ondragover,ondragstart,ondrop,ondurationchan ge,onemptied,onended,onerror,onfocus,oninput,oninvalid,onkeydown,onkeypr ess,onkeyup,onload,onloadeddata,onloadedmetadata,onloadstart,onmousedown ,onmouseenter,onmouseleave,onmousemove,onmouseout,onmouseover,onmouseup, onmousewheel,onpause,onplay,onplaying,onprogress,onratechange,onreset,on resize,onscroll,onseeked,onseeking,onselect,onstalled,onsubmit,onsuspend ,ontimeupdate,ontoggle,onvolumechange,onwaiting,onwheel,onauxclick,ongot pointercapture,onlostpointercapture,onpointerdown,onpointermove,onpointe rup,onpointercancel,onpointerover,onpointerout,onpointerenter,onpointerl eave,onselectstart,onselectionchange,onanimationend,onanimationiteration ,onanimationstart,ontransitionend,dataset,nonce,autofocus,tabIndex,click ,focus,blur,enterKeyHint,onformdata,onpointerrawupdate,attachInternals,n amespaceURI,prefix,localName,tagName,id,className,classList,slot,part,at tributes,shadowRoot,assignedSlot,innerHTML,outerHTML,scrollTop,scrollLef t,scrollWidth,scrollHeight,clientTop,clientLeft,clientWidth,clientHeight ,attributeStyleMap,onbeforecopy,onbeforecut,onbeforepaste,onsearch,eleme ntTiming,previousElementSibling,nextElementSibling,children,firstElement Child,lastElementChild,childElementCount,onfullscreenchange,onfullscreen error,onwebkitfullscreenchange,onwebkitfullscreenerror,setPointerCapture ,releasePointerCapture,hasPointerCapture,hasAttributes,getAttributeNames ,getAttribute,getAttributeNS,setAttribute,setAttributeNS,removeAttribute ,removeAttributeNS,hasAttribute,hasAttributeNS,toggleAttribute,getAttrib uteNode,getAttributeNodeNS,setAttributeNode,setAttributeNodeNS,removeAtt ributeNode,closest,matches,webkitMatchesSelector,attachShadow,getElement sByTagName,getElementsByTagNameNS,getElementsByClassName,insertAdjacentE lement,insertAdjacentText,insertAdjacentHTML,requestPointerLock,getClien tRects,getBoundingClientRect,scrollIntoView,scroll,scrollTo,scrollBy,scr ollIntoViewIfNeeded,animate,computedStyleMap,before,after,replaceWith,re move,prepend,append,querySelector,querySelectorAll,requestFullscreen,web kitRequestFullScreen,webkitRequestFullscreen,createShadowRoot,getDestina tionInsertionPoints,ELEMENT_NODE,ATTRIBUTE_NODE,TEXT_NODE,CDATA_SECTION_ NODE,ENTITY_REFERENCE_NODE,ENTITY_NODE,PROCESSING_INSTRUCTION_NODE,COMME NT_NODE,DOCUMENT_NODE,DOCUMENT_TYPE_NODE,DOCUMENT_FRAGMENT_NODE,NOTATION _NODE,DOCUMENT_POSITION_DISCONNECTED,DOCUMENT_POSITION_PRECEDING,DOCUMEN T_POSITION_FOLLOWING,DOCUMENT_POSITION_CONTAINS,DOCUMENT_POSITION_CONTAI NED_BY,DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC,nodeType,nodeName,baseU RI,isConnected,ownerDocument,parentNode,parentElement,childNodes,firstCh ild,lastChild,previousSibling,nextSibling,nodeValue,textContent,hasChild Nodes,getRootNode,normalize,cloneNode,isEqualNode,isSameNode,compareDocu mentPosition,contains,lookupPrefix,lookupNamespaceURI,isDefaultNamespace ,insertBefore,appendChild,replaceChild,removeChild,addEventListener,remo veEventListener,dispatchEvent
可以使用 Virtual DOM 来描述真实 DOM,示例:
{ sel: "div", data: {}, children: undefined, text: "Hello Virtual DOM", elm: undefined, key: undefined }
为什么使用 Virtual DOM
- 手动操作 DOM 比较麻烦,还需要考虑浏览器兼容性问题,虽然有 jQuery 等库简化 DOM 操作,但是随着项目的复杂 DOM 操作复杂提升;
- 为了简化 DOM 的复杂操作于是出现了各种 MVVM 框架,MVVM 框架解决了视图和状态的同步问题;
- 为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题,于是 Virtual DOM 出现了;
- Virtual DOM 的好处是当状态改变时不需要立即更新 DOM,只需要创建一个虚拟树来描述DOM, Virtual DOM 内部将弄清楚如何有效(diff)的更新 DOM;
- 参考 github 上 virtual-dom 的描述
1,虚拟 DOM 可以维护程序的状态,跟踪上一次的状态
2,通过比较前后两次状态的差异更新真实 DOM
虚拟 DOM 的作用
- 维护视图和状态的关系;
- 复杂视图情况下提升渲染性能;
- 除了渲染 DOM 以外,还可以实现 SSR(服务端渲染)(Nuxt.js/Next.js)、原生应用(Weex/React Native)、小程序(mpvue/uni-app)等
Virtual DOM 库
Snabbdom
1,Vue 2.x 内部使用的 Virtual DOM 就是改造的 Snabbdom
2,大约 200 SLOC(single line of code)
3,通过模块可扩展
4,源码使用 TypeScript 开发
5,最快的 Virtual DOM 之一virtual-dom
Snabbdom
基本使用
创建项目
打包工具为了方便使用 parcel
创建项目,并安装 parcel
# 创建项目目录 $ md snabbdom-demo # 进入项目目录 $ cd snabbdom-demo # 创建 package.json $ yarn init -y # 本地安装 parcel$ yarn add parcel-bundler
配置 package.json 的 scripts
{"scripts": { "dev": "parcel index.html --open", "build": "parcel build index.html" } }
创建目录结构
│ index.html │ package.json └─src 01-basicusage.js
导入 Snabbdom
Snabbdom 文档
- 看文档的意义
1,学习任何一个库都要先看文档
2,通过文档了解库的作用
3,看文档中提供的示例,自己快速实现一个 demo
4,通过文档查看 API 的使用 - 文档地址
1,https://github.com/snabbdom/snabbdom
2,中文翻译
安装 Snabbdom
- 安装 Snabbdom
# 版本 0.7.4$ yarn add snabbdom
导入 Snabbdom
Snabbdom 的官网 demo 中导入使用的是 commonjs 模块化语法,我们使用更流行的 ES6 模块化的语法 import;
关于模块化的语法请参考阮一峰老师的 Module 的语法;
ES6 模块与 CommonJS 模块的差异
import { init, h, thunk } from 'snabbdom'
Snabbdom 的核心仅提供最基本的功能,只导出了三个函数 init()、h()、thunk()
1,init() 是一个高阶函数,返回 patch()
2,h() 返回虚拟节点 VNode,这个函数我们在使用 Vue.js 的时候见过new Vue({ router, store, render: h => h(App) }).$mount('#app')
3,thunk() 是一种优化策略,可以在处理不可变数据时使用
注意:导入时候不能使用 import snabbdom from ‘snabbdom’
原因:node_modules/src/snabbdom.ts 末尾导出使用的语法是 export 导出 API,没有使用export default 导出默认输出
基本案例
- 具体实现,代码如下:
import { init, h, thunk } from 'snabbdom' /** -- init()* 参 数: 数组,将来可以传入模块,处理属性/样式/事件等 * 返回值:patch函数,作用:对比两个vnode的差异,更新到真实DOM*/// 使用 init() 函数创建 patch() let patch = init([]) /** -- h()* 第一个参数:标签 + 选择器* 第二个参数:若是字符串,则表示 标签中的内容* 若是数组, 则表示创建标签中的子元素*/// 使用 h() 函数创建 vnode let vnode = h('div.cls', [ h('h1', 'Hello Snabbdom'), h('p', '这是段落') ])// 获取占位元素const app = document.querySelector('#app') /** -- patch()* 第一个参数:可以是 DOM元素,则内部会把DOM元素转换成VNode;也可以是 VNode* 第二个参数:VNode* 返回值:新的 VNode */// 把 vnode 渲染到空的 DOM 元素(替换)let oldVnode = patch(app, vnode) setTimeout(() => { vnode = h('div.cls', [ h('h1', 'Hello World'), h('p', '这是段落') ])// 把老的视图更新到新的状态 oldVnode = patch(oldVnode, vnode) // 卸载 DOM,文档中 patch(oldVnode, null) 有误 // h('!') 是创建注释 patch(oldVnode, h('!')) }, 2000)
内置模块
Snabbdom 的核心库并不能处理元素的属性/样式/事件等,如果需要处理的话,可以使用模块。
常用模块
官方提供了 6 个模块。
attributes
- 设置 DOM 元素的属性,使用
setAttribute ()
- 处理布尔类型的属性
props
- 和
attributes
模块相似,设置 DOM 元素的属性element[attr] = value
- 不处理布尔类型的属性
class
- 切换类样式
- 注意:给元素设置类样式是通过
sel
选择器
dataset
- 设置
data-*
的自定义属性
eventlisteners
- 注册和移除事件
style
- 设置行内样式,支持动画
- delayed/remove/destroy
模块使用
模块使用步骤:
- 导入需要的模块,类似插件,不在 Snabbdom 的核心库内
- init() 中注册模块
- 使用 h() 函数创建 VNode 的时候,可以把第二个参数设置为对象,其他参数往后移
案例演示
- 具体实现,代码如下:
import { h, init } from 'snabbdom' // 导入需要的模块 import style from 'snabbdom/modules/style' import eventlisteners from 'snabbdom/modules/eventlisteners' // 使用 init() 函数创建 patch() // init() 的参数是数组,将来可以传入模块,处理属性/样式/事件等 let patch = init([ // 注册模块 style, eventlisteners ])// 使用 h() 函数创建 vnode,h() 函数的第二个参数,传入模块需要的数据(对象)let vnode = h('div.cls', { // 设置 DOM 元素的行内样式 style: { color: '#DEDEDE', backgroundColor: '#181A1B' }, // 注册事件 on: { click: clickHandler } }, [h('h1', 'Hello Snabbdom'), h('p', '这是段落') ])function clickHandler () { // 此处的 this 指向对应的 vnode console.log(this.elm.innerHTML) }const app = document.querySelector('#app')// 把 vnode 渲染到空的 DOM 元素(替换) // 会返回新的 vnodepatch(app, vnode)
源码解析
基本介绍
如何学习源码
- 先宏观了解,整理了解库的核心执行过程
- 带着目标看源码
- 看源码的过程要不求甚解
- 调试
- 参考资料
Snabbdom 的核心
- 使用 h() 函数创建 JavaScript 对象(VNode)描述真实 DOM
- init() 设置模块,创建 patch()
- patch() 比较新旧两个 VNode
- 把变化的内容更新到真实 DOM 树上
Snabbdom 源码
源码地址:
- https://github.com/snabbdom/snabbdom
src 目录结构
│ h.ts h() 函数,用来创建 VNode │ hooks.ts 所有钩子函数的定义 │ htmldomapi.ts 对 DOM API 的包装 │ is.ts 判断数组和原始值的函数 │ jsx-global.d.ts jsx 的类型声明文件 │ jsx.ts 处理 jsx │ snabbdom.bundle.ts 入口,已经注册了模块 │ snabbdom.ts 初始化,返回 init/h/thunk │ thunk.ts 优化处理,对复杂视图不可变值得优化 │ tovnode.ts DOM 转换成 VNode │ vnode.ts 虚拟节点定义 │├─helpers │ attachto.ts 定义了 vnode.ts 中 AttachData 的数据结构 │└─modules 所有模块定义 attributes.ts class.ts dataset.ts eventlisteners.ts hero.ts example 中使用到的自定义钩子 module.ts 定义了模块中用到的钩子函数 props.ts style.ts
源码解析
h 函数
h()
函数介绍1,在使用 Vue 的时候见过 h() 函数
new Vue({ router, store, render: h => h(App) // 组件机制}).$mount('#app')
2,h() 函数最早见于 hyperscript,使用 JavaScript 创建超文本
3,Snabbdom 中的 h() 函数不是用来创建超文本,而是创建 VNode
函数重载
1,概念
- 参数个数或类型不同的函数
- JavaScript 中没有重载的概念
- TypeScript 中有重载,不过重载的实现还是通过代码调整参数
2,重载的示意
function add (a, b) { console.log(a + b) }function add (a, b, c) { console.log(a + b + c) }add(1, 2) add(1, 2, 3)
源码位置:src/h.ts
// h 函数的重载export function h(sel: string): VNode;export function h(sel: string, data: VNodeData): VNode;export function h(sel: string, children: VNodeChildren): VNode;export function h(sel: string, data: VNodeData, children: VNodeChildren): VNode;export function h(sel: any, b?: any, c?: any): VNode {var data: VNodeData = {}, children: any, text: any, i: number;// 处理参数,实现重载的机制if (c !== undefined) {// 处理三个参数的情况// sel、data、children/textdata = b;if (is.array(c)) { children = c; }// 如果 c 是字符串或者数字else if (is.primitive(c)) { text = c; }// 如果 c 是VNodeelse if (c && c.sel) { children = [c]; }} else if (b !== undefined) {// 处理两个参数的情况// 如果 b 是数组if (is.array(b)) { children = b; }// 如果 b 是字符串或者数字else if (is.primitive(b)) { text = b; }// 如果 b 是VNodeelse if (b && b.sel) { children = [b]; }else { data = b; }}if (children !== undefined) {// 处理 children 中的原始值(string/number)for (i = 0; i < children.length; ++i) {// 如果 child 是 string/number,创建文本节点if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined);}}if (sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&(sel.length === 3 || sel[3] === '.' || sel[3] === '#')) {// 如果是 svg,添加命名空间addNS(data, children, sel);}// 返回 VNodereturn vnode(sel, data, children, text, undefined);};// 导出模块export default h;
VNode
- 一个 VNode 就是一个虚拟节点用来描述一个 DOM 元素,如果这个 VNode 有 children 就是Virtual DOM
- 源码位置:src/vnode.ts
// interface 接口,// 目的:约束实现这个接口的所有对象都拥有相同的属性export interface VNode {// 选择器sel: string | undefined;// 模块,节点数据:属性/样式/事件等data: VNodeData | undefined;// 子节点,和 text 只能互斥children: Array<VNode | string> | undefined;// 记录 vnode 对应的真实 DOMelm: Node | undefined;// 节点中的内容,和 children 只能互斥text: string | undefined;// 优化用key: Key | undefined;}export function vnode(sel: string | undefined,data: any | undefined,children: Array<VNode | string> | undefined,text: string | undefined,elm: Element | Text | undefined): VNode {let key = data === undefined ? undefined : data.key;return {sel, data, children, text, elm, key};}export default vnode;
patch 的整体流程
- patch(oldVnode, newVnode) –
snabbdom 核心
- 打补丁,把新节点中变化的内容渲染到真实 DOM,最后返回新节点作为下一次处理的旧节点
- 对比新旧 VNode 是否相同节点(节点的 key 和 sel 相同)
- 如果不是相同节点,删除之前的内容,重新渲染
- 如果是相同节点,再判断新的 VNode 是否有 text,如果有并且和 oldVnode 的 text 不同,直接更新文本内容
- 如果新的 VNode 有 children,判断子节点是否有变化,判断子节点的过程使用的就是 diff 算法
- diff 过程只进行同层级比较
init
**功能:**init(modules, domApi),返回 patch() 函数(高阶函数)
为什么要使用高阶函数?
1,因为 patch() 函数再外部会调用多次,每次调用依赖一些参数,比如:modules/domApi/cbs
2,通过高阶函数让 init() 内部形成闭包,返回的 patch() 可以访问到 modules/domApi/cbs,而不需要重新创建init() 在返回 patch() 之前,首先收集了所有模块中的钩子函数存储到 cbs 对象中
源码位置:src/snabbdom.ts
// 存储了钩子函数的名字const hooks: (keyof Module)[] = ['create', 'update', 'remove', 'destroy', 'pre', 'post'];// domAPI 执行DOM操作export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {let i: number, j: number, cbs = ({} as ModuleHooks);// 初始化转换虚拟节点的 apiconst api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;// 把传入的所有模块的钩子函数,统一存储到 cbs 对象中// 最终构建的 cbs 对象的形式 cbs = { create: [], update: [], ... }for (i = 0; i < hooks.length; ++i) {// cbs.create = [], cbs.update = [], ...cbs[hooks[i]] = [];for (j = 0; j < modules.length; ++j) {// modules 传入的模块数组// 获取模块中的 hook 函数// hook = modules[0][create]...const hook = modules[j][hooks[i]];if (hook !== undefined) {// 把获取到的hook函数放入到 cbs 对应的钩子函数数组中(cbs[hooks[i]] as Array<any>).push(hook);}}}..................// init 内部返回 patch 函数,把vnode渲染成真实 dom,并返回vnode// 高阶函数,在一个函数内部返回一个函数return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {.....};}
patch
功能:
1,传入新旧 VNode,对比差异,把差异渲染到 DOM
2,返回新的 VNode,作为下一次 patch() 的 oldVnode执行过程:
1,首先执行模块中的钩子函数 pre
2,如果 oldVnode 和 vnode 相同(key 和 sel 相同)- 调用 patchVnode(),找节点的差异并更新 DOM
3,如果 oldVnode 是 DOM 元素
- 把 DOM 元素转换成 oldVnode
- 调用 createElm() 把 vnode 转换为真实 DOM,记录到 vnode.elm
- 把刚创建的 DOM 元素插入到 parent 中
- 移除老节点
- 触发用户设置的 create 钩子函数
源码位置:src/snabbdom.ts
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {let i: number, elm: Node, parent: Node;// 保存新插入节点的队列,为了触发钩子函数const insertedVnodeQueue: VNodeQueue = [];// 执行模块的 pre 钩子函数,pre 预处理for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();// 如果 oldVnode 不是 VNode,创建 VNode 并设置 elmif (!isVnode(oldVnode)) {// 把 DOM 元素转换成空的 VNodeoldVnode = emptyNodeAt(oldVnode);}// 如果新旧节点是相同节点(key 和 sel 相同)if (sameVnode(oldVnode, vnode)) {// 找节点的差异并更新 DOMpatchVnode(oldVnode, vnode, insertedVnodeQueue);} else {// 如果新旧节点不同,vnode 创建对应的 DOM// 获取当前的 DOM 元素elm = oldVnode.elm!;parent = api.parentNode(elm);// 创建 vnode 对应的 DOM 元素,并触发 init/create 钩子函数createElm(vnode, insertedVnodeQueue);if (parent !== null) {// 如果父节点不为空,把 vnode 对应的 DOM 插入到文档中// ! typescript 语法,告诉编译器vnode.elm是百分百有值的api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));// 移除老节点removeVnodes(parent, [oldVnode], 0, 0);}}// 执行用户设置的 insert 钩子函数for (i = 0; i < insertedVnodeQueue.length; ++i) {(((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]);}// 执行模块的 post 钩子函数for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();// 返回 vnodereturn vnode;};
createElm
功能:
1,createElm(vnode, insertedVnodeQueue),返回创建的 DOM 元素
2,创建 vnode 对应的 DOM 元素执行过程:
1,首先触发用户设置的 init 钩子函数
2,如果选择器是!,创建评论节点
3,如果选择器为空,创建文本节点
4,如果选择器不为空- 解析选择器,设置标签的 id 和 class 属性
- 执行模块的 create 钩子函数
- 如果 vnode 有 children,创建子 vnode 对应的 DOM,追加到 DOM 树
- 如果 vnode 的 text 值是 string/number,创建文本节点并追击到 DOM 树
- 执行用户设置的 create 钩子函数
- 如果有用户设置的 insert 钩子函数,把 vnode 添加到队列中
源码位置:src/snabbdom.ts
// 作用:把 VNode 转换成对应的 DOM 元素,但是并不会把 DOM 渲染到页面中function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {let i: any, data = vnode.data;if (data !== undefined) {// 执行用户设置的 init 的钩子函数const init = data.hook?.init; if (isDef(init)) { init(vnode); data = vnode.data; }}// 把 vnode 转换成真实 DOM 对象(没有渲染到页面)let children = vnode.children, sel = vnode.sel;if (sel === '!') {// 如果选择器是!,创建注释节点if (isUndef(vnode.text)) {vnode.text = '';}vnode.elm = api.createComment(vnode.text!);} else if (sel !== undefined) {// 如果选择器不为空// 解析选择器// Parse selector const hashIdx = sel.indexOf('#');const dotIdx = sel.indexOf('.', hashIdx);const hash = hashIdx > 0 ? hashIdx : sel.length;const dot = dotIdx > 0 ? dotIdx : sel.length;const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel;// data.ns 是否有命名空间const elm = vnode.elm = isDef(data) && isDef(i = (data as VNodeData).ns) ? api.createElementNS(i, tag): api.createElement(tag);if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot));if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '));// 执行模块的 create 钩子函数for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);// 如果 vnode 中有子节点,创建子 vnode 对应的 DOM 元素,并追加到 DOM 树上if (is.array(children)) {for (i = 0; i < children.length; ++i) {const ch = children[i];if (ch != null) {api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue));}}} else if (is.primitive(vnode.text)) {// 如果 vnode 的 text 值是 string/number,创建文本节点,并追加到 DOM 树上api.appendChild(elm, api.createTextNode(vnode.text));}const hook = vnode.data!.hook; if (isDef(hook)) { // 执行用户传入的钩子 create hook.create?.(emptyNode, vnode); if (hook.insert) { // 把 vnode 添加到队列中,为后续执行 insert 钩子做准备insertedVnodeQueue.push(vnode); } }} else {// 如果选择器为空,创建文本节点vnode.elm = api.createTextNode(vnode.text!);}// 返回新创建的 DOMreturn vnode.elm;}
思维导图
patchVnode
功能:
1,patchVnode(oldVnode, vnode, insertedVnodeQueue)
2,对比 oldVnode 和 vnode 的差异,把差异渲染到 DOM执行过程:
1,首先执行用户设置的 prepatch 钩子函数
2,执行 create 钩子函数- 首先执行模块的 create 钩子函数
- 然后执行用户设置的 create 钩子函数
3,如果 vnode.text 未定义
- 如果 oldVnode.children 和 vnode.children 都有值
1. 调用 updateChildren()
2. 使用 diff 算法对比子节点,更新子节点 - 如果 vnode.children 有值, oldVnode.children 无值
1. 清空 DOM 元素
2. 调用 addVnodes() ,批量添加子节点 - 如果 oldVnode.children 有值, vnode.children 无值
调用 removeVnodes() ,批量移除子节点 - 如果 oldVnode.text 有值
清空 DOM 元素的内容
4,如果设置了 vnode.text 并且和和 oldVnode.text 不等
- 如果老节点有子节点,全部移除
- 设置 DOM 元素的 textContent 为 vnode.text
5,最后执行用户设置的 postpatch 钩子函数
源码位置:src/snabbdom.ts
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {const hook = vnode.data?.hook;// 首先执行用户设置的 prepatch 钩子函数hook?.prepatch?.(oldVnode, vnode);const elm = vnode.elm = oldVnode.elm!;let oldCh = oldVnode.children as VNode[];let ch = vnode.children as VNode[];// 如果新老 vnode 相同,直接返回if (oldVnode === vnode) return;if (vnode.data !== undefined) {// 执行模块的 update 钩子函数for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);// 执行用户设置的 update 钩子函数vnode.data.hook?.update?.(oldVnode, vnode);}// 如果 vnode.text 未定义if (isUndef(vnode.text)) {// 如果新老节点都有 childrenif (isDef(oldCh) && isDef(ch)) {// 使用 diff 算法对比子节点,更新子节点if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);} else if (isDef(ch)) {// 如果新节点有 children,老节点没有 children// 如果老节点有 text,清空 dom 元素的内容if (isDef(oldVnode.text)) api.setTextContent(elm, '');// 批量添加子节点addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);} else if (isDef(oldCh)) {// 如果老节点有 children,新节点没有 children// 批量移除子节点removeVnodes(elm, oldCh, 0, oldCh.length - 1);} else if (isDef(oldVnode.text)) {// 如果老节点有 text,清空 DOM 元素api.setTextContent(elm, '');}} else if (oldVnode.text !== vnode.text) {// 如果没有设置 vnode.textif (isDef(oldCh)) {// 如果老节点有 children,移除removeVnodes(elm, oldCh, 0, oldCh.length - 1);}// 设置 DOM 元素的 textContent 为 vnode.textapi.setTextContent(elm, vnode.text!);}// 最后执行用户设置的 postpatch 钩子函数hook?.postpatch?.(oldVnode, vnode);}
思维导图
updateChildren
功能:
diff 算法的核心,对比新旧节点的 children,更新 DOM
执行过程:
要对比两棵树的差异,我们可以取第一棵树的每一个节点依次和第二课树的每一个节点比较,但是这样的时间复杂度为 O(n^3)
在DOM 操作的时候我们很少会把一个父节点移动/更新到某一个子节点
因此只需要找同级别的子节点依次比较,然后再找下一级别的节点比较,这样算法的时间复杂度为 O(n)
在进行同级别节点比较的时候,首先会对新老节点数组的开始和结尾节点设置标记索引,遍历的过程中移动索引
在对开始和结束节点比较的时候,总共有四种情况
1,oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
2,oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)
3,oldStartVnode / oldEndVnode (旧开始节点 / 新结束节点)
4,oldEndVnode / newStartVnode (旧结束节点 / 新开始节点)开始节点和结束节点比较,这两种情况类似
1,oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
2,oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)如果 oldStartVnode 和 newStartVnode 是 sameVnode (key 和 sel 相同)
1,调用 patchVnode() 对比和更新节点
2,把旧开始和新开始索引往后移动 oldStartIdx++ / oldEndIdx++
oldStartVnode / newEndVnode (旧开始节点 / 新结束节点) 相同
1,调用 patchVnode() 对比和更新节点
2,把 oldStartVnode 对应的 DOM 元素,移动到右边- 更新索引
- 更新索引
oldEndVnode / newStartVnode (旧结束节点 / 新开始节点) 相同
1,调用 patchVnode() 对比和更新节点
2,把 oldEndVnode 对应的 DOM 元素,移动到左边
3,更新索引
如果不是以上四种情况
1,遍历新节点,使用 newStartNode 的 key 在老节点数组中找相同节点
2,如果没有找到,说明 newStartNode 是新节点- 创建新节点对应的 DOM 元素,插入到 DOM 树中
3,如果找到了
- 判断新节点和找到的老节点的 sel 选择器是否相同
- 如果不相同,说明节点被修改了
重新创建对应的 DOM 元素,插入到 DOM 树中 - 如果相同,把 elmToMove 对应的 DOM 元素,移动到左边
循环结束
1,当老节点的所有子节点先遍历完 (oldStartIdx > oldEndIdx),循环结束
2,新节点的所有子节点先遍历完 (newStartIdx > newEndIdx),循环结束如果老节点的数组先遍历完(oldStartIdx > oldEndIdx),说明新节点有剩余,把剩余节点批量插入到右边
如果新节点的数组先遍历完(newStartIdx > newEndIdx),说明老节点有剩余,把剩余节点批量删除
源码位置:src/snabbdom.ts
// VNode 的核心function updateChildren(parentElm: Node,oldCh: Array<VNode>,newCh: Array<VNode>,insertedVnodeQueue: VNodeQueue) {// 新老开始节点的索引let oldStartIdx = 0, newStartIdx = 0;// 老的结束节点的索引let oldEndIdx = oldCh.length - 1;// 老的开始节点let oldStartVnode = oldCh[0];// 老的结束节点let oldEndVnode = oldCh[oldEndIdx];// 新的结束节点的索引let newEndIdx = newCh.length - 1;// 新的开始节点let newStartVnode = newCh[0];// 新的结束节点let newEndVnode = newCh[newEndIdx];let oldKeyToIdx: any;let idxInOld: number;let elmToMove: VNode;let before: any;// 对比所有的新旧子节点while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {// 索引变化后,可能会把节点设置为空if (oldStartVnode == null) {// 节点为空移动索引oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left} else if (oldEndVnode == null) {oldEndVnode = oldCh[--oldEndIdx];} else if (newStartVnode == null) {newStartVnode = newCh[++newStartIdx];} else if (newEndVnode == null) {newEndVnode = newCh[--newEndIdx];// 比较开始和结束节点的四种情况} else if (sameVnode(oldStartVnode, newStartVnode)) {// 1. 比较老的开始节点和新的开始节点patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);oldStartVnode = oldCh[++oldStartIdx];newStartVnode = newCh[++newStartIdx];} else if (sameVnode(oldEndVnode, newEndVnode)) {// 2. 比较老的结束节点和新的结束节点patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);oldEndVnode = oldCh[--oldEndIdx];newEndVnode = newCh[--newEndIdx];} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right// 3. 比较老的开始节点和新的结束节点patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!));oldStartVnode = oldCh[++oldStartIdx];newEndVnode = newCh[--newEndIdx];} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left// 4. 比较老的结束节点和新的开始节点patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!);oldEndVnode = oldCh[--oldEndIdx];newStartVnode = newCh[++newStartIdx];} else {// 开始节点和结束节点都不相同// 使用 newStartNode 的 key 在老的节点数组中找相同节点// 先设置记录 key 和 index 的对象if (oldKeyToIdx === undefined) {oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);}// 遍历 newStartVnode,从老的节点中找相同 key 的 oldVnode 的索引idxInOld = oldKeyToIdx[newStartVnode.key as string];// 如果是新的 vnodeif (isUndef(idxInOld)) { // New element// 如果没找到,newStartVnode 是新节点// 创建元素插入 DOM 树api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!);// 重新给 newStartVnode 赋值,指向下一个新节点newStartVnode = newCh[++newStartIdx];} else {// 如果找到相同 key 相同的老节点,记录到 elmToMove 遍历elmToMove = oldCh[idxInOld];if (elmToMove.sel !== newStartVnode.sel) {// 如果新旧节点的选择器不同// 创建新开始节点对应的 DOM 元素,插入到 DOM 树中api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!);} else {// 如果相同,patchVnode()// 把 elmToMove 对应的 DOM 元素,移动到左边patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);oldCh[idxInOld] = undefined as any;api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);}// 重新给 newStartVnode 赋值,指向下一个新节点newStartVnode = newCh[++newStartIdx];}}}// 循环结束,老节点数组先遍历完成或者新节点数组先遍历完成if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {if (oldStartIdx > oldEndIdx) {// 如果老节点数组先遍历完成,说明有新的节点剩余// 把剩余的新节点都插入到右边before = newCh[newEndIdx+1] == null ? null : newCh[newEndIdx+1].elm;addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);} else {// 如果新节点数组先遍历完成,说明老节点有剩余// 批量删除老节点removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);}}}
调试 updateChildren
- 具体实现,代码如下:
<ul><li>首页</li> <li>微博</li> <li>视频</li> </ul> <ul><li>首页</li> <li>视频</li> <li>微博</li> </ul>
调试带 key 的情况
- 具体实现,代码如下:
<ul><li key="a">首页</li> <li key="b">微博</li> <li key="c">视频</li> </ul> <ul><li key="a">首页</li> <li key="c">视频</li> <li key="b">微博</li> </ul>
总结
通过以上调试 updateChildren,我们发现不带 key 的情况需要进行两次 DOM 操作,带 key 的情况只需要更新一次 DOM 操作(移动 DOM 项),所以带 key 的情况可以减少 DOM 的操作,如果 li 中的子项比较多,更能体现出带 key 的优势。
Modules 源码
- patch() -> patchVnode() -> updateChildren()
- Snabbdom 为了保证核心库的精简,把处理元素的属性/事件/样式等工作,放置到模块中
- 模块可以按照需要引入
- 模块的使用可以查看官方文档
- 模块实现的核心是基于 Hooks
Hooks
- 预定义的钩子函数的名称
- 源码位置:src/hooks.ts
export interface Hooks {// patch 函数开始执行的时候触发pre?: PreHook;// createElm 函数开始之前的时候触发// 在把 VNode 转换成真实 DOM 之前触发init?: InitHook;// createElm 函数末尾调用// 创建完真实 DOM 后触发create?: CreateHook;// patchVnode 函数末尾执行// 真实 DOM 添加到 DOM 树中触发insert?: InsertHook;// patchVnode 函数开头调用// 开始对比两个 VNode 的差异之前触发prepatch?: PrePatchHook;// patchVnode 函数开头调用// 两个 VNode 对比过程中触发,比 prepatch 稍晚update?: UpdateHook;// patchVnode 的最末尾调用// 两个 VNode 对比结束执行postpatch?: PostPatchHook;// removeVnodes -> inVokeDestroyHook 中调用// 在删除元素之前触发,子节点的 destroy 也被触发destroy?: DestroyHook;// removeVnodes 中调用// remove?: RemoveHook;post?: PostHook;}
Modules
模块文件的定义
Snabbdom 提供的所有模块在:src/modules 文件夹下,主要模块有:
attributes.ts
1,使用 setAttribute/removeAttribute 操作属性
2,能够处理 boolean 类型的属性class.ts
切换类样式
dataset.ts
操作元素的
data-*
属性eventlisteners.ts
注册和移除事件
module.ts
定义模块遵守的钩子函数
props.ts
和 attributes.ts 类似,但是是使用 elm[attrName] = value 的方式操作属性
style.ts
1,操作行内样式
2,可以使动画更平滑hero.ts
自定义的模块,examples/hero 示例中使用
attributes.ts
模块到出成员
export const attributesModule = { create: updateAttrs, update: updateAttrs } as Module; export default attributesModule;
updateAttrs 函数功能
1,更新节点属性
2,如果节点属性值是 true 设置空置
3,如果节点属性值是 false 移除属性updateAttrs 实现
function updateAttrs(oldVnode: VNode, vnode: VNode): void {var key: string, elm: Element = vnode.elm as Element,oldAttrs = (oldVnode.data as VNodeData).attrs,attrs = (vnode.data as VNodeData).attrs;// 新老节点没有 attrs 属性,返回if (!oldAttrs && !attrs) return;// 新老节点的 attrs 属性相同,返回if (oldAttrs === attrs) return;oldAttrs = oldAttrs || {};attrs = attrs || {};// update modified attributes, add new attributes// 遍历新节点的属性for (key in attrs) {// 新老节点的属性值const cur = attrs[key];const old = oldAttrs[key];// 如果新老节点的属性值不同if (old !== cur) {// 布尔类型值的处理if (cur === true) {elm.setAttribute(key, "");} else if (cur === false) {elm.removeAttribute(key);} else {// xChar -> x// <svg xmlns="http://www.w3.org/2000/scg">if (key.charCodeAt(0) !== xChar) {elm.setAttribute(key, cur);} else if (key.charCodeAt(3) === colonChar) {// colonChar -> :// Assume xml namespaceelm.setAttributeNS(xmlNS, key, cur);} else if (key.charCodeAt(5) === colonChar) {// Assume xlink namespace// <svg xmlns:xlink="http://www.w3.org/1999/xlink">elm.setAttributeNS(xlinkNS, key, cur);} else {elm.setAttribute(key, cur);}}}}// remove removed attributes// use `in` operator since the previous `for` iteration uses it (.i.e. add even attributes with undefined value)// the other option is to remove all attributes with value == undefined// 如果老节点的属性在新节点中不存在,移除for (key in oldAttrs) {if (!(key in attrs)) {elm.removeAttribute(key);}}}
【Virtual DOM】虚拟 DOM 和 Snabbdom 库相关推荐
- Vue进阶之Virtual DOM(虚拟DOM) 实现原理
Vue进阶之Virtual DOM(虚拟DOM) 实现原理 Virtual DOM(虚拟 DOM),是由普通的 JS 对象来描述 DOM 对象,因为不是真实的 DOM 对象,所以叫 Virtual D ...
- Vue 源码学习—Virtual DOM(虚拟 DOM)
Virtual DOM Virtual DOM是什么 真实DOM成员 引入原因 作用 Virtual DOM是什么 定义: 普通的js对象来描述DOM对象,不是真实的DOM,顾名思义,称为虚拟DOM ...
- javascript --- 将DOM结构转换成虚拟DOM 虚拟DOM转换成真实的DOM结构
虚拟DOM的实现 使用虚拟DOM的原因: 减少回流与重绘 将DOM结构转换成对象保存到内存中 <img /> => { tag: 'img'} 文本节点 => { tag: u ...
- virtual DOM和真实DOM的区别_让虚拟DOM和DOMdiff不再成为你的绊脚石
来源 | https://juejin.im/post/5c8e5e4951882545c109ae9c Keep Moving 时至今日,前端对于知识的考量是越来越有水平了,逼格高大上了 各类框架大 ...
- 简要分析git作用及应公司业务要求分析,什么是响应式和虚拟dom面试题
git Git是什么 Git版本控制系统是一个分布式的系统,是用来保存工程源代码历史状态(游戏存档)的命令行工具 Git是一个命令行(小黑窗)工具,用于版本控制(存档器) Git的作用是什么? 版本管 ...
- 探秘vue核心之虚拟DOM与diff算法
探秘vue核心之虚拟DOM与diff 一.真实DOM和其解析流程 所有的浏览器渲染引擎工作流程大致分为5步: 创建 DOM 树 -> 创建 Style Rules -> 构建 Render ...
- 【Vue.js源码解析 二】-- 虚拟 DOM
前言 笔记来源:拉勾教育 大前端高薪训练营 阅读建议:建议通过左侧导航栏进行阅读 虚拟 DOM 基本介绍 什么是虚拟 DOM 虚拟 DOM(Virtual DOM) 是使用 JavaScript 对象 ...
- vue核心之虚拟DOM(vdom)与真实DOM页面渲染过程
一.真实DOM和其解析流程? 浏览器渲染引擎工作流程都差不多,大致分为5步,创建DOM树--创建StyleRules--创建Render树--布局Layout--绘制Painting 第一步,用HTM ...
- 深入浅出虚拟 DOM 和 Diff 算法,及 Vue2 与 Vue3 中的区别
因为 Diff 算法,计算的就是虚拟 DOM 的差异,所以先铺垫一点点虚拟 DOM,了解一下其结构,再来一层层揭开 Diff 算法的面纱,深入浅出,助你彻底弄懂 Diff 算法原理 认识虚拟 DOM ...
最新文章
- IDEA svn 更换项目,拉新项目的时候 提示 No appropriate protocol
- 计算机基础知识二进制转换,计算机基础知识数制转换
- Shell 反引号、$() 和 ${} 的区别
- Python爬虫基础之Urllib
- 【学习教程】CMIP6数据处理方法与典型案例分析
- 手机签名工具_iOS越狱神器复活!自签工具 ReProvision 又可以愉快使用了
- ubuntu更新时Not enough free disk space
- js 浏览器永久保存数据:localStorage
- 【科研数据处理】[实践]类别变量频数分析图表、数值变量分布图表与正态性检验(包含对数正态)
- 图新地球(LSV)常见问题汇总(图源、全景、倾斜摄影、点云应用、图新地球模糊等等)------持续更新
- SAP AFS 升级后由于RETURN PO带来的ARUN程序问题
- Calendar类-日历类常用方法(JAVA)
- 看完Alibaba“Java成长笔记”我懂了! 为什么阿里的程序员成长如此之快?
- ggplot2-为可视化建模2
- 推荐4款最好用的远程桌面访问软件,亲测好评
- 惠普 ProBook 笔记本下的 WIN10 解决 VMware 开启Intel VT-x问题
- yahoo(雅虎)工程师提供css 初始化代码
- 应该担心什么不应该关心什么(做正确的事,把事情做对)
- somachine V4.1如何注册
- illustrator下载_在Illustrator和手绘中创建矢量图形