前言
笔记来源:拉勾教育 大前端高薪训练营
阅读建议:建议通过左侧导航栏进行阅读
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 之一
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 的语法;
-
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 元素(替换) // 会返回新的 vnode patch(app, vnode)
源码解析
基本介绍
如何学习源码
- 先宏观了解,整理了解库的核心执行过程
- 带着目标看源码
- 看源码的过程要不求甚解
- 调试
- 参考资料
Snabbdom 的核心
- 使用 h() 函数创建 JavaScript 对象(VNode)描述真实 DOM
- init() 设置模块,创建 patch()
- patch() 比较新旧两个 VNode
- 把变化的内容更新到真实 DOM 树上
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/text data = b; if (is.array(c)) { children = c; } // 如果 c 是字符串或者数字 else if (is.primitive(c)) { text = c; } // 如果 c 是VNode else 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 是VNode else 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); } // 返回 VNode return 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 对应的真实 DOM elm: 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); // 初始化转换虚拟节点的 api const 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 并设置 elm if (!isVnode(oldVnode)) { // 把 DOM 元素转换成空的 VNode oldVnode = emptyNodeAt(oldVnode); } // 如果新旧节点是相同节点(key 和 sel 相同) if (sameVnode(oldVnode, vnode)) { // 找节点的差异并更新 DOM patchVnode(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](); // 返回 vnode return 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!); } // 返回新创建的 DOM return 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)) { // 如果新老节点都有 children if (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.text if (isDef(oldCh)) { // 如果老节点有 children,移除 removeVnodes(elm, oldCh, 0, oldCh.length - 1); } // 设置 DOM 元素的 textContent 为 vnode.text api.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]; // 如果是新的 vnode if (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 namespace elm.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); } } }