1.Snabbdom的核心
- 使用h()函数创建vNode对象描述真实DOM
- init()设置模块,创建并返回patch()
- patch()比较新旧两个vNode,更新新内容到真实DOM
源码通常比较难理解,我们不可能完全阅读源码,应该有个重点目标,比如:
- vNode是如何创建的
- vNode是如何渲染成真实DOM…
2. 分析 h
函数
在Vue项目的main.js中也看见过h(),只是升级了Snabbdon的h().
在上一篇博客,我就说过了h()是很多形态的,即函数重载,参数的类型或个数,解决了调用哪个h()。
看h()源码:(重点注释都已标注出来)
//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
//无论是哪种形态,最终都会执行这个h(),因为这里的第二第三个参数都使用了"?",作为了可选参数,可以匹配上面四种状态
export function h (sel: any, b?: any, c?: any): VNode {
//变量data是一个自定义的VNodeData类型
var data: VNodeData = {}
//用于记录该节点的子节点
var children: any
//用于记录该结点的文本内容
var text: any
var i: number
//下面一系列的判断是给新建的vNode这个对象初始化
//先判断c是否有值,c有值说明传了3个参数
//有三个参数情况:sel、data、children或text字符串
//对应上面的第四种h()重载形态:h (sel: string, data: VNodeData | null, children: VNodeChildren): VNode
if (c !== undefined) {
//如果b不为null,那么b就是VNodeData类型的数据
if (b !== null) {
data = b
}
//如果c是一个数组,那么数组里面的元素就是该vNode的子vNode
if (is.array(c)) {
children = c
//判断c是不是字符串或数字
} else if (is.primitive(c)) {
text = c
//如果不是字符串或数字,那就判断c是不是一个单一的vNode
} else if (c && c.sel) {
//把单一的vNode也变成数组类型,便于children统一处理,因为多个vNode就是用数组存储的
children = [c]
}
//下面就是两个参数的情况,即c未定义
//对应h()的第二、第三种重载形态
} else if (b !== undefined && b !== null) {
//如果b是数组,那么就是第三种形态:h (sel: string, children: VNodeChildren): VNode
if (is.array(b)) {
children = b
//如果b是字符串或数字,就是第二种形态:h (sel: string, data: VNodeData | null): VNode
} else if (is.primitive(b)) {
text = b
} else if (b && b.sel) {
children = [b]
} else { data = b }
}
//如果children在上面被初始化了,就说明vNode有子节点,那就每个子节点要调用vnode()去生成对应的vNode
if (children !== undefined) {
for (i = 0; i < children.length; ++i) {
if (is.primitive(children[i]))
//每个子节点生成相对应的子vNode
children[i] = vnode(undefined, undefined, undefined, children[i], undefined)
}
}
//如果选择器是svg类型,那就是svg格式图片,调用addNS给它空间
if (
sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
(sel.length === 3 || sel[3] === '.' || sel[3] === '#')
) {
addNS(data, children, sel)
}
//返回由传进来的参数生成的vNode,作为下次对比Vnode的旧虚拟节点
return vnode(sel, data, children, text, undefined)
};
这段源码复杂的地方是h()
函数的重载,它有两个可选参数,这两个可选参数根据参数类型决定存储的内容。
比如第三个参数:
- c如果是字符串,那么它是该节点的内容
- c如果是数组,那么它就是该结点的子虚拟节点
注意:h()
主要是负责初始化text、children、data
,然后利用这些数据,再调用vnode()
生成vNode节点。
我们看看上面data的接口类型,即VNodeData:
export interface VNodeData {
props?: Props
attrs?: Attrs
class?: Classes
style?: VNodeStyle
dataset?: Dataset
on?: On
hero?: Hero
attachData?: AttachData
hook?: Hooks
key?: Key
ns?: string // for SVGs
fn?: () => VNode // for thunks
args?: any[] // for thunks
[key: string]: any // for any other 3rd party module
}
所以data对象可以传递一些props、attrs、on等等属性。
3. 分析vnode
函数
上面我们说过h函数在初始化好数据后,是调用vnode函数去生成虚拟节点的,下面我们就看看vnode函数。
export function vnode (sel: string | undefined,
data: any | undefined,
children: Array<VNode | string> | undefined,
text: string | undefined,
elm: Element | Text | undefined): VNode {
const key = data === undefined ? undefined : data.key
return { sel, data, children, text, elm, key }
}
这个函数非常简单,就是返回一个JS对象,所以虚拟节点
就是一个JS的对象而已。
到这里,我们已经了解了vNode是如何创建的了。
4. 分析 patch
函数
下面这部分学习就是关于vNode如何渲染成真实DOM
的,这部分比上面更加复杂。patch
是重点,通过patch实现对比新旧vNode的差异,进而更新DOM.
看源码:
function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node
const insertedVnodeQueue: VNodeQueue = []
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
if (!isVnode(oldVnode)) {
oldVnode = emptyNodeAt(oldVnode)
}
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue)
} else {
elm = oldVnode.elm!
parent = api.parentNode(elm) as Node
createElm(vnode, insertedVnodeQueue)
if (parent !== null) {
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
removeVnodes(parent, [oldVnode], 0, 0)
}
}
for (i = 0; i < insertedVnodeQueue.length; ++i) {
insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
}
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
return vnode
}
}
分析后的源码:
function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number;
//ele就是一个真实的DOM,在虚拟vNode中,就是利用ele属性记录着vNode对应哪个真实DOM的
let elm: Node;
//待会用于记录旧节点的父节点
let parent: Node;
//一个数组,存储着很多vNode
const insertedVnodeQueue: VNodeQueue = []
//通过for循环,将对比前需要调用的钩子函数都调用
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
//判断是否是虚拟节点,如果不是,则调用emptyNodeAt(),该函数内部调用vnode()去生成虚拟节点并返回
if (!isVnode(oldVnode)) {
oldVnode = emptyNodeAt(oldVnode)
}
//判断新旧节点是不是相同的节点,利用key和sel属性判断,两者都相等,则是相同节点
if (sameVnode(oldVnode, vnode)) {
//如果相同,就调用patchVnode()去找出两者的差异
patchVnode(oldVnode, vnode, insertedVnodeQueue)
} else {
//如果不相同,则获取旧虚拟节点的真实DOM,通过DOM找出它的父元素
elm = oldVnode.elm!
parent = api.parentNode(elm) as Node
//通过新的vnode,调用createEle()去创建真实的DOM,此时页面还没有显示该元素
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
//将新的DOM利用appendChild(),插入到父元素中,此时页面显示该元素
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
//移除父元素里面的那个老节点
removeVnodes(parent, [oldVnode], 0, 0)
}
}
//执行用户设置的insert钩子函数
for (i = 0; i < insertedVnodeQueue.length; ++i) {
insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
}
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
return vnode
}
上面调用了createEle()、patchVnode()、removeVnodes()比较复杂,我们下面再分析。
path的工作流程大致如下:
- 对比新旧vNode是否是相同的节点(节点的key和sel相同,则相同)
- 如果不是相同节点,则把新节点绑定到旧节点的父节点上,然后删除旧节点
- 如果是相同的节点,再判断vNode是否又text,如果有并且和oldVNode的text不同,直接更新文本内容
- 如果新的vNode还有children,判断子节点是否有变化,判断过程使用diff算法,diff过程只能进行同层级比较,即相互对比的是同一层的子节点。
5.分析createEle
函数
该函数主要根据vnode创建真实的DOM。
看源码:
function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
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
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
const elm = vnode.elm = isDef(data) && isDef(i = data.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, ' '))
for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode)
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)) {
api.appendChild(elm, api.createTextNode(vnode.text))
}
const hook = vnode.data!.hook
if (isDef(hook)) {
hook.create?.(emptyNode, vnode)
if (hook.insert) {
insertedVnodeQueue.push(vnode)
}
}
} else {
vnode.elm = api.createTextNode(vnode.text!)
}
return vnode.elm
}
分析源码:
function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
let i: any
let data = vnode.data
//这个if主要是执行init函数,调用钩子函数
//data是调用h()函数传入的第二个参数
if (data !== undefined) {
const init = data.hook?.init
if (isDef(init)) {
init(vnode)
data = vnode.data
}
}
//把vnode转换成真实DOM。(没有渲染到页面)
const children = vnode.children
const sel = vnode.sel
//sel是元素的选择器
//如果sel是"!",则说明是创建一个“注释”节点
if (sel === '!') {
if (isUndef(vnode.text)) {
vnode.text = ''
}
//api.createComment()内部就是创建了注释节点
vnode.elm = api.createComment(vnode.text!)
//下面是选择器不为空
} else if (sel !== undefined) {
// 下面是解析sel是什么标签以及类型、id
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
//根据上面分析出来的标签创建DOM
//ns是命名空间,通常是svg
const elm = vnode.elm = isDef(data) && isDef(i = data.ns)
? api.createElementNS(i, tag)
: api.createElement(tag)
//api.createElement(tag)本质就是调用document.createElement(tag)创建dom
//给节点添加id、class
if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot))
if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '))
//cbs记录的是钩子函数,执行
for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode)
//如果children是数组,那么就是子节点,递归调用createElm,生成子节点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)) {
api.appendChild(elm, api.createTextNode(vnode.text))
}
const hook = vnode.data!.hook
if (isDef(hook)) {
hook.create?.(emptyNode, vnode)
if (hook.insert) {
//把vnode添加到队列中,为后续执行insert钩子做准备
insertedVnodeQueue.push(vnode)
}
}
} else {
//如果sel选择器为空,则创建文本节点
vnode.elm = api.createTextNode(vnode.text!)
}
//返回真实DOM
return vnode.elm
}
分析:
- 创建的节点有三种类型,一种是普通的标签,一种是注释标签,还有一种是文本标签
- 根据选择器类型来判断创建不同的标签。最复杂的就是创建普通标签,需要给元素添加class、id,以及判断是否还有子节点。
6. 分析patchVnode
函数
该函数是用来对比新旧vNode的差异的。
先来看看patchVnode的流程:
上面流程图很清晰的表明了如何对比新旧节点的差异。
看源码:
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
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)
}
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!)
}
hook?.postpatch?.(oldVnode, vnode)
}
分析源码:
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
//获取钩子函数
const hook = vnode.data?.hook
hook?.prepatch?.(oldVnode, vnode)
//获取DOM,因为两个vNode是相同的,所以两者的elm指向同一个DOM
const elm = vnode.elm = oldVnode.elm!
//获取新旧节点的子节点,两者可能就是子节点发生了变化,页面需要更新
const oldCh = oldVnode.children as VNode[]
const ch = vnode.children as VNode[]
//如果新旧节点相同,即没有发生变化,则直接返回即可
if (oldVnode === vnode) return
//如果新的vNode的data是有定义的,那么就触发update钩子函数
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)
//如果只是新的vNode有子节点
} else if (isDef(ch)) {
//且旧节点有text,则清空text后再将子节点田家庵进去
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)
//如果旧节点有text,则也要清除text
} else if (isDef(oldVnode.text)) {
api.setTextContent(elm, '')
}
//如果定义了文本,就对比新旧节点文本是否相同
} else if (oldVnode.text !== vnode.text) {
//新旧文本不相同,则需要清空旧节点的子元素
if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
}
//清空后,将新vNode的文本放入元素中
api.setTextContent(elm, vnode.text!)
}
hook?.postpatch?.(oldVnode, vnode)
}