- 了解虚拟DOM,以及虚拟DOM的作用
- Snabbdom的基本使用
- Snabbdom的源码解析
虚拟DOM的作用和虚拟DOM库
h函数的用法h函数创建虚拟dom
-
第一个参数:标签+选择器
-
第二个参数:
- 可以是一个数组,数组中的每一个元素也都是一个Vnode
let vnode = h('div#container',[ h('h1','Hello Snabbdom'), h('p','这是一个p') ])
- 如果是字符串就是标签的文本内容
let vnode = h('div#container.cls','Hello World')
patch函数对比dom的差异
- patch 对比两个vnode把两个vnode的差异更新到真实dom上
- 第一个参数:旧的vnode,可以是一个真实的Dom,会将真实dom转为vnode
- 第二个参数:vnode
- 返回新的VNODE,可以作为下一次调用时老的VNODE
let app = document.querySelector('#app')
let oldVnode = patch(app,vnode)
snabbdom模块的使用
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. 使用和()函数的第二个参数传入模块中使用的数据(对象)
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)
snabbdom源码解析
-
克隆源码
git clone -b v2.1.0 --depth=1 https://github.com/snabbdom/snabbdom.git
-
安装依赖
-
编译
-
新版本的snabbdom的h函数的事件件绑定不在支持以数组的方式传递参数,改成用函数的方式调用传参
h('div.btn.rm-btn', { on: { click: [remove, movie] } }, 'x')
// 改为
h('div.btn.rm-btn', { on: { click: ()=>{
remove(movie)
} } }, 'x')
// 其它同理
项目结构
- src
- package 源码目录
- helpers
- attachto.ts vnode中使用的attachData的数据结构
- modules 模块
- attributes.ts 绑定属性boolean类型的属性
- class.ts 切换类名
- dataset.ts 处理html5中提供的data-这样的自定义属性
- eventlisteners.ts 事件的绑定
- hero.ts hero示例里演示用的自定义模块
- module.ts 定义模块中所有使用到的钩子函数
- props.ts 文本属性
- style.ts 内链样式
- h.ts h函数
- hooks.ts vnode中所有的声明周期函数
- htmldomapi.ts 对domapi的包装创建元素,删除元素等
- init.ts init函数,用来加载模块 domapi 返回patch函数
- is.ts 辅助模块 判断数组和原始值
- jsx-global.ts jsx类型声明文件
- jsx.ts 处理jsx
- thunk.ts dui 优化处理 对复杂视图不可变值的优化
- tovnode.ts 将dom转换为vnode
- ts-transform-js-extension.cjs 编译时的配置文件
- tsconfig.json 编译时的配置文件
- vnode.ts 定义vnode的结构
- helpers
- test 单元测试
- package 源码目录
h函数
只是简单的对函数的参数进行判定,然后调用vnode方法
// h 函数的重载
export function h (sel: string): VNode
export function h (sel: string, data: VNodeData | null): VNode
export function h (sel: string, children: VNodeChildren): VNode
export function h (sel: string, data: VNodeData | null, children: VNodeChildren): VNode
export function h (sel: any, b?: any, c?: any): VNode {
var data: VNodeData = {}
var children: any
var text: any
var i: number
// 处理参数,实现重载的机制
if (c !== undefined) {
// 处理三个参数的情况
// sel、data、children/text
if (b !== null) {
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 !== null) {
if (is.array(b)) {
children = b
} else if (is.primitive(b)) {
// 如果 b 是字符串或者数字
text = b
} else if (b && b.sel) {
// 如果 b 是 VNode
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)
};
VNode对象
VNode接口用来约束VNode对象的属性
- sel 选择器
- data 模块中所需要的数据
- children 跟text 属性互斥 描述子节点
- elm 存储当前VNode对象转化之后的dom元素
- text 与children互斥 记录 对应文本节点的文本内容
- key 唯一标识当前的VNode对象
VNodeData接口
约束data属性的类型,属性后的问好表示当前的属性可以为空
vnode函数
接收5个参数,返回VNode对象,VNode对象有六个属性,key通过data(data.key)进行赋值
patch整体过程分析
patch源码在init.ts文件中,patch俗称打补丁
- patch(oldVnode, newVnode)
- 打补丁,把新节点中变化的内容渲染到真实 DOM,最后返回新节点作为下一次处理的旧节点
- 对比新旧 VNode 是否相同节点(节点的 key 和 sel 相同)(diff算法)
- 如果不是相同节点,删除之前的内容,重新渲染
- 如果是相同节点,再判断新的 VNode 是否有 text,如果有并且和 oldVnode 的 text 不同,直接更新文本内容
- 如果新的 VNode 有 children,判断子节点是否有变化,判断子节点的过程使用的就是 diff 算法
- diff 过程只进行同层级比较
init
返回path函数
hooks数组,存储钩子函数的名称
init函数的参数
- module:数组 存储一些钩子函数,存储在cbs中
- domApi:用来把VNode对象转换成其它平台下对应的元素,默认为操作浏览器平台dom元素的api,
初始化 cbs对象 用来存储模块中的钩子函数 cbs --> {create:[fn1,fn2],update:[fn1,fn2],...}
init函数返回path函数将mdoules domApi缓存 ,init函数是一个高阶函数返回patch函数,调用path函数是只需要传递oldVnode,newVnode就可以了
patch
第一个参数可以是一个真实Dom
insertedVnodeQueu常量用来存储新插入对象的队列,用来触发Vnode的insert钩子函数
遍历cbs对象,触发pre函数
判断oldVnode是否是Vnode对象,判断是否有sel对象,如果没有就将其转换为一个Vnode对象,将dom元素的id和class转换成选择器的形式,调用vnode函数
判断是否是相同节点(判断vnode的key与sel是否相同),如果是调用patchVnode寻找两个节点的差异,跟新差异到dom上。如果不是创建newVnode对应的dom元素,插入新创建的dom元素,移除老元素,首先或去oldVnode的dom元素(ts语法!
标识一个属性一定是有值的),然后获取dom元素的父元素,用于挂在新创建的元素,创建新的dom元素,判断parent是否为空插入新元素
触发对应的钩子函数,遍历insertedVnodeQueue这个队列里存储的是具有insert钩子函数的新的Vnode节点,队列中的元素实在createElement函数中添加的,insert钩子函数是在Vnode的data属性中获取的,即是用户传递过来的,即对应的节点添加的dom树上之后用户可以执行对应的操作
遍历cbs中的post钩子函数,触发模块中的post钩子函数
返回新的vnode
patch函数内部调用的createElm函数
作用把Vnode对象转换为对应的dom元素,把dom元素存储在Vnode的elm属性中,但是并不会把创建的dom元素挂在到对应的dom树上,
function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
// 第一个过程 执行用户设置的 init 钩子函数
// init函数在创建dom之前让用户可以做一次修改
let i: any
let data = vnode.data
if (data !== undefined) {
const init = data.hook?.init
if (isDef(init)) {
init(vnode)
data = vnode.data
}
}
const children = vnode.children
const sel = vnode.sel
// 第二个过程 把vnode抓换成真实dom对象(没有渲染到页面)
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
// 上边的5行代码解析选择器
const elm = vnode.elm = isDef(data) && isDef(i = data.ns)// 判断是否有值 ns命名空间
? api.createElementNS(i, tag)// 如果有命名空间一般创建svg
: 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, ' '))// 设置id和class
// 执行模块的 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 // 此处的hook为h函数传入的钩子函数
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
}
patch函数中的removeVnodes函数和addVnodes函数
removeVnodes函数批量移除vnode对应的dom元素
addVnodes函数批量添加vnodes对应的dom元素到dom树上
removeVnodes
参数
要删除元素所在的父元素,虚拟元素数组,数组中要删除节点的开始和结束节点的位置
function removeVnodes (parentElm: Node,
vnodes: VNode[],
startIdx: number,
endIdx: number): void {
for (; startIdx <= endIdx; ++startIdx) {
let listeners: number
let rm: () => void
const ch = vnodes[startIdx] // 遍历vnodes数组
if (ch != null) {
if (isDef(ch.sel)) { // 判断是否有sel属性如果没有是文本节点
invokeDestroyHook(ch) // 触发vnode的destroy函数
listeners = cbs.remove.length + 1
rm = createRmCb(ch.elm!, listeners) // listrners防止重复删除元素 从热爱teRm Cb高阶函数返回真正删除dom元素的函数
for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm) // 调用remove钩子函数,调用rm函数删除dom元素
const removeHook = ch?.data?.hook?.remove // 获取用户传递的钩子函数
if (isDef(removeHook)) {// 如果用户传递了remove钩子函数,需要用户手动调用rm删除dom元素
removeHook(ch, rm)// 都会调用rm钩子函数
} else {
rm()
}
} else { // Text node
api.removeChild(parentElm, ch.elm!) // 文本节点直接删除
}
}
}
}
invokeDestroyHook函数
function invokeDestroyHook (vnode: VNode) {
const data = vnode.data
if (data !== undefined) {
data?.hook?.destroy?.(vnode)// 执行用户传递过来的destroy函数
for (let i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode)
if (vnode.children !== undefined) { // 是否有直接点
for (let j = 0; j < vnode.children.length; ++j) {
const child = vnode.children[j]
if (child != null && typeof child !== 'string') {
invokeDestroyHook(child) // 递归用函数,删除子节点destroy是在删除dom之前执行的
}
}
}
}
}
createRmCb函数
function createRmCb (childElm: Node, listeners: number) {
return function rmCb () {
if (--listeners === 0) {// listeners记录的是remove函数的个数加一,当所有的remove函数都执行过时删除元素
const parent = api.parentNode(childElm) as Node
api.removeChild(parent, childElm)
}
}
}
addVnodes函数
function addVnodes (
parentElm: Node,
before: Node | null,
vnodes: VNode[],
startIdx: number,
endIdx: number,
insertedVnodeQueue: VNodeQueue
) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (ch != null) {
api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before)
}
}
}
patch函数中的patchVnode函数
patchVnode对比两个Vnode的差异,更新到dom上
首先触发prePatch和update钩子函数,都是对比之之前执行的
判断新旧节点中是否有text属性,text和children是互斥的,如果新节点有text而且不等于老节点的text属性,要把text赋值给对应的dom的textContent属性。赋值之前会判断老节点是否有children属性,如果有,执行removeVnodes移除子节点。
新老节点都有children,且不相等,调用updateChildren函数对比新旧节点的子节点,并更新子节点的差异,核心函数
只有新节点有children属性,老节点有text属性,清空老节点的文本内容,调用addVnodes函数添加子节点
老节点有children属性,新节点没有,移除说有老节点
只有老节点有text属性,清空对应dom元素的textContent
对比完成并更新之后会触发postpatch钩子函数
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
// 过程一 触发prepatch和update钩子函数
const hook = vnode.data?.hook
hook?.prepatch?.(oldVnode, vnode)
const elm = vnode.elm = oldVnode.elm!
const oldCh = oldVnode.children as VNode[]
const ch = vnode.children as VNode[]
if (oldVnode === vnode) return
if (vnode.data !== undefined) {
for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
vnode.data.hook?.update?.(oldVnode, vnode)
}
// 过程二 对比新旧vnode差异的地方
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue) // 核心函数
} else if (isDef(ch)) {
if (isDef(oldVnode.text)) api.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
api.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
}
api.setTextContent(elm, vnode.text!)
}
// 触发postpatch钩子函数
hook?.postpatch?.(oldVnode, vnode)
}
updateChildren函数
diff算法
虚拟Dom中为为什么要使用diff算法,渲染真实的dom的开销很大,dom操作会引起浏览器的重排和重绘,也就是重新渲染。