文章说明:本文章为拉钩大前端训练营所做笔记和心得,若有不当之处,还望各位指出与教导,谢谢 !
Virtual DoM
什么是Virtual 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
}
创建虚拟DOM的开销要比创建真实DOM的开销小很多。
为什么使用Virtual DOM
- 手动操作DOM比较麻烦,还需要考虑浏览器兼容性问题,虽然有jQuery等库简化DOM操作,但是随着项目的复杂DOM操作复杂提升;
- 为了简化 DOM 的复杂操作于是出现了各种 MVVM 框架,MVVM 框架解决了视图和状态的同步问题;
- 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
基本使用
创建项目:
# 创建项目目录
$ 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"
}
}
- 创建目录结构:
导入snabbdom
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()
- init() 是一个高阶函数,返回 patch()
- h() 返回虚拟节点 VNode,这个函数我们在使用 Vue.js 的时候见过
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
- thunk() 是一种优化策略,可以在处理不可变数据时使用
注意:导入时候不能使用 import snabbdom from ‘snabbdom’
原因:node_modules/src/snabbdom.ts 末尾导出使用的语法是 export 导出 API,没有使用export default 导出默认输出
基本案例
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>snabbdom-demo</title>
</head>
<body>
<div id="app"></div>
<script src="./src/02-basicusage.js"></script>
</body>
</html>
01-basicusage.js:
import {
h,init } from 'snabbdom'
// 1.hello world
// 参数:数组,模块
// 返回值:patch函数,作用对比两个vnode的差异更新到真实DOM
let patch = init([])
// 第一个参数:标签+选择器
// 第二个参数:如果是字符串的话就是标签中的内容
let vnode = h('div#container.cls','hello World')
let app = document.querySelector('#app')
// 第一个参数:可以是DOM元素,内部会把DOM元素转换成VNode
// 第二个参数:VNode
// 返回值:VNode
let oldVnode = patch(app,vnode)
// 假设的时刻
vnode = h('div','Hello Snabbdom')
patch(oldVnode,vnode)
// 2.div中放置子元素 h1,p
02-basicusage.js:
// 2.div 中放置子元素
import {
h,init} from 'snabbdom'
let 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)
// 清空页面元素 --错误
// patch(oldVnode,null)
patch(oldVnode,h('!'))
},2000);
Snabbdom中的模块
Snabbdom 的核心库并不能处理元素的属性/样式/事件等,如果需要处理的话,可以使用模块
常用模块:
官方提供了 6 个模块:
-
attributes
设置 DOM 元素的属性,使用 setAttribute ()
处理布尔类型的属性 -
props
和 attributes 模块相似,设置 DOM 元素的属性 element[attr] = value
不处理布尔类型的属性 -
class
切换类样式
注意:给元素设置类样式是通过 sel 选择器 -
dataset
设置 data-* 的自定义属性 -
eventlisteners
注册和移除事件 -
style
设置行内样式,支持动画
delayed/remove/destroy
模块使用:
- 模块使用步骤:
1.导入需要的模块
2.init()中注册模块
3.使用 h() 函数创建 VNode 的时候,可以把第二个参数设置为对象,其他参数往后移
案例实现:
index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>snabbdom-demo</title>
</head>
<body>
<div id="app"></div>
<script src="./src/03-modules.js"></script>
</body>
</html>
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标签')
])
function eventHandler(){
console.log('点击我了')
}
let app = document.querySelector('#app')
patch(app,vnode)
Snabbdom 源码解析
如何学习源码:
- 先宏观了解
- 带着目标看源码
- 看源码的过程要不求甚解(看源码的过程要围绕核心目标,因为一个开源项目的工程会非常多,代码的分支逻辑会非常多,分支会干扰我们看源码,先走通主线,涉及分支的部分可以先不看)
- 调试
- 参考资料
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函数介绍
- 作用:创建VNode对象
- Vue中的h函数:
- h函数最早见于hyperscript,使用JavaScript创建超文本
函数重载:
- 参数个数或类型不同的函数
- JavaScript中没有重载的概念
- TypeScript中有重载,不过重载的实现还是通过代码调整参数
重载的失意:
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)
- 打补丁,把新节点中变化的内容渲染到真实 DOM,最后返回新节点作为下一次处理的旧节点
- 对比新旧 VNode 是否相同节点(节点的 key 和 sel 相同,key是节点的唯一标识,sel是节点的选择器)
- 如果不是相同节点,删除之前的内容,重新渲染
- 如果是相同节点,再判断新的 VNode 是否有 text,如果有并且和 oldVnode 的 text 不同,直接更新文本内容
- 如果新的 VNode 有 children,判断子节点是否有变化,判断子节点的过程使用的就是 diffff 算法
- diffff 过程只进行同层级比较
init 函数 - 功能:init(modules,domApi),返回patch()函数(高阶函数)
- 为什么要使用高阶函数?
1.因为 patch() 函数再外部会调用多次,每次调用依赖一些参数,比如:modules/domApi/cbs
2.通过高阶函数让 init() 内部形成闭包,返回的 patch() 可以访问到 modules/domApi/cbs,而不需要重新创建 - 源码位置:src/init.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函数
功能:
-
createElm(vnode, insertedVnodeQueue),返回创建的 DOM 元素
-
创建 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
功能:
- patchVnode(oldVnode, vnode, insertedVnodeQueue)
- 对比 oldVnode 和 vnode 的差异,把差异渲染到 DOM
执行过程:
-
首先执行用户设置的 prepatch 钩子函数
-
执行 create 钩子函数
首先执行模块的 create 钩子函数
然后执行用户设置的 create 钩子函数 -
如果 vnode.text 未定义:
- 如果oldVnode.children 和 vnode.children 都有值
调用 updateChildren()
使用 diff 算法对比子节点,更新子节点- vnode.children 有值, oldVnode.children 无值
清空 DOM 元素
调用 addVnodes() ,批量添加子节点- 如果 oldVnode.children 有值, vnode.children 无值
调用 removeVnodes() ,批量移除子节点
- 如果oldVnode.text有值
清空 DOM 元素的内容
-
如果设置了 vnode.text 并且和和 oldVnode.text 不等
如果老节点有子节点,全部移除
设置 DOM 元素的 textContent 为 vnode.text -
最后执行用户设置的 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)
- 在进行同级别节点比较的时候,首先会对新老节点数组的开始和结尾节点设置标记索引,遍
历的过程中移动索引 - 在对开始和结束节点比较的时候,总共有四种情况:
oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)
oldStartVnode / oldEndVnode (旧开始节点 / 新结束节点)
oldEndVnode / newStartVnode (旧结束节点 / 新开始节点)
- 要对比两棵树的差异,我们可以取第一棵树的每一个节点依次和第二课树的每一个节点比
- 开始节点和结束节点比较,这两种情况类似:
oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)
- 如果 oldStartVnode 和 newStartVnode 是 sameVnode (key 和 sel 相同)
调用 patchVnode() 对比和更新节点
把旧开始和新开始索引往后移动 oldStartIdx++ / oldEndIdx++
-
oldStartVnode / newEndVnode (旧开始节点 / 新结束节点) 相同
调用 patchVnode() 对比和更新节点
把 oldStartVnode 对应的 DOM 元素,移动到右边
更新索引:
-
oldEndVnode / newStartVnode (旧结束节点 / 新开始节点) 相同
调用 patchVnode() 对比和更新节点;
把 oldEndVnode 对应的 DOM 元素,移动到左边;
更新索引;
-
如果不是以上四种情况
遍历新节点,使用 newStartNode 的 key 在老节点数组中找相同节点;
如果没有找到,说明 newStartNode 是新节点:
1、创建新节点对应的 DOM 元素,插入到 DOM 树中
如果找到了:
1.判断新节点和找到的老节点的 sel 选择器是否相同;
2.如果不相同,说明节点被修改了:
重新创建对应的 DOM 元素,插入到 DOM 树中
3.如果相同,把 elmToMove 对应的 DOM 元素,移动到左边;
-
循环结束
当老节点的所有子节点先遍历完 (oldStartIdx > oldEndIdx),循环结束
新节点的所有子节点先遍历完 (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
调试带 key 的情况
总结
通过以上调试 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
使用 setAttribute/removeAttribute 操作属性
能够处理 boolean 类型的属性 - class.ts
切换类样式 - dataset.ts
操作元素的 data-* 属性 - eventlisteners.ts
注册和移除事件 - module.ts
定义模块遵守的钩子函数 - props.ts
和 attributes.ts 类似,但是是使用 elm[attrName] = value 的方式操作属性 - style.ts
操作行内样式
可以使动画更平滑 - hero.ts
自定义的模块,examples/hero 示例中使用
attributes.ts
- 模块到出成员
export const attributesModule = {
create: updateAttrs,
update: updateAttrs
} as Module;
export default attributesModule;
- updateAttrs 函数功能
更新节点属性
如果节点属性值是 true 设置空置
如果节点属性值是 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);
}
}
}