携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情 >>
序言
看完本篇文章你将学到:
- 什么是“虚拟 DOM”(VDOM) 以及他的组成?
- 基于“虚拟 DOM”框架的是如何完成首次渲染流程。
- 为什么说“虚拟 DOM”,可以跨平台?
- 模块化 + hook 机制的好处?
一、snabbdom 是什么?
说到 VDOM (virtual DOM) 这里就要说了一个著名的 VDOM 开源库 —— snabbdom 官方描述 :A virtual DOM library with focus on simplicity, modularity, powerful features and performance. 一个专注于简单性、模块化、强大特性和性能的虚拟DOM库。通过我们来分析他来找答案吧。
首先我们从一个最简单的例子看下:
import { init } from 'snabbdom/init'
import { h } from 'snabbdom/h' // helper function for creating vnodes
const patch = init([])
const container = document.getElementById('container')
const vnode = h('div', {}, ' 我是一个div 节点')
// Patch into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode)
运行完后我们就看到页面上就渲染了
现在我们就来看下他是如何完成首次渲染的,在 snabbdom/src/package/init.ts
文件中我们找到了定义 init
的地方,通过观察该函数为一个高阶函数,返回了一个 patch
函数,然后继续调用 h
函数。
// snabbdom/src/package/init.ts
export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
let i: number
let j: number
const cbs: ModuleHooks = {
create: [],
update: [],
remove: [],
destroy: [],
pre: [],
post: []
}
// 省略部分源码
return 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]()
// 1、判断是否是 Vnode 不是就转成 Vnode
if (!isVnode(oldVnode)) {
oldVnode = emptyNodeAt(oldVnode)
}
// 2、判断是否是相同的 Vnode
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue)
} else {
// 3、不是相同的 Vnode
elm = oldVnode.elm!
parent = api.parentNode(elm) as Node
createElm(vnode, insertedVnodeQueue) // 4、生成真实DOM
if (parent !== null) {
// 插入到真实 DOM 树上
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
}
}
二、h函数是什么?
1、看看 h
函数长什么样:
// snabbdom/src/package/h.ts
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 {
let data: VNodeData = {}
let children: any
let text: any
let i: number
// 省略部分代码
return vnode(sel, data, children, text, undefined)
};
// snabbdom/src/package/vnode.ts
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 }
}
在 snabbdom/src/package/h.ts
中我们找到了定义 h函数的地方,通过函数的签名可知,他的返回值是VNode
对象,函数内部返回了一个 vnode()
函数, 可以看出是一个 VNode
的工厂方法,根据参数返回 VNode
对象。
三、什么是 Vnode ?
1、看看 Vnode 长什么样子
// snabbdom/src/package/vnode.ts
export interface VNode {
sel: string | undefined
data: VNodeData | undefined
children: Array<VNode | string> | undefined
elm: Node | undefined
text: string | undefined
key: Key | undefined
}
export interface VNodeData {
props?: Props
attrs?: Attrs
class?: Classes
style?: VNodeStyle
on?: On
//... 省略部分代码
[key: string]: any // for any other 3rd party module
}
在 snabbdom/src/package/vnode.ts
文件中,我们找到了 VNode
的类型定义。通过 VNode
的类型定义可知,VNode
本质是一个对象,他的 children
属性包含了他的子节点信息,类型也是 VNode
类型,VNodeDat
a 中保存着节点包含的各种属性如 class
、style
以及绑定的事件 on
。
四、如何完成首次渲染?
最后调用 patch
函数。那我们就来看看 patch
做了什么。
// snabbdom/src/package/init.ts
export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
// 省略部分源码
return 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]()
// 1、判断是否是 Vnode 不是就转成 Vnode
if (!isVnode(oldVnode)) {
oldVnode = emptyNodeAt(oldVnode)
}
// 2、判断是否是相同的 Vnode
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue)
} else {
// 3、不是相同的 Vnode
elm = oldVnode.elm!
parent = api.parentNode(elm) as Node
createElm(vnode, insertedVnodeQueue) // 4、生成真实DOM
if (parent !== null) {
// 插入到真实 DOM 树上
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
}
}
通过分析 patch
函数接收2个参数,oldVnode
和 vnode
,返回 Vnode
,在上面的例子中,我们第一个参数传入的是一个真实 DOM
,那他是如何转成 Vnode
,然后再对比,最后在挂载的呢?
const container = document.getElementById('container')
const vnode = h('div', {}, ' 我是一个div 节点')
// Patch into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode)
4.1 如何判断对象是 Vnode?
// snabbdom/src/package/init.ts
function isVnode (vnode: any): vnode is VNode {
return vnode.sel !== undefined
}
通过 isVnode
方法可知 snabbdom
通过 sel
属性来判断当前传入的 Vnode
是否是一个 Vnode
, 在上面一个例子中,我们传入的是一个真实 DOM
,所以返回 false
。
4.2 如何将真实 Node 变为为 Vnode?
// snabbdom/src/package/init.ts
function emptyNodeAt (elm: Element) {
const id = elm.id ? '#' + elm.id : ''
const c = elm.className ? '.' + elm.className.split(' ').join('.') : ''
return vnode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm)
}
// snabbdom/src/package/init.ts
function sameVnode (vnode1: VNode, vnode2: VNode): boolean {
return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
}
我们找到了 emptyNodeAt
的方法,可以看出内部调用了 vnode
工厂方法,返回了 Vnode
对象,并添加了 elm
属性指向真实 NODE
节点,这样就真实 DOM 就变成了 Vnode
。然后就继续执行 oldVnode
,与 Vnode
是否相同,很明显不相同,然后继续执行,createElm
方法。
// snabbdom/src/package/init.ts
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
}
通过观察 createElm
函数的入参与出参可知,该函数 接收一个 Vnode
参数,然后返回 Vnode
的 elm
,该属性在上面流程中讲到了,他是一个 Vnode
属性指向一个真实的 DOM
对象,因此调用 createElm
放回了一个真实 DOM
, 然后继续执行 insertBefore
方法, 在 snabbdomsrc/package/htmldomapi.ts
中我们找到了定义该函数的地方。可以知道他在 oldVnode
的 父节点上插入新的 Vnode
然后就完成了首次渲染了。
// snabbdomsrc/package/htmldomapi.ts
function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node | null): void {
parentNode.insertBefore(newNode, referenceNode)
}
总结如下:
五、Vnode的作用是什么?
细心的小伙伴可能发现了定义 init
函数的第二个可选参数,domApi
可以看出他是 DOMAPI
类型,
export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
// 省略部分源码
return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
// 省略部分源码
return vnode
}
}
DOMAPI:
// snabbdom/src/package/htmldomapi.ts
export interface DOMAPI {
createElement: (tagName: any) => HTMLElement
createElementNS: (namespaceURI: string, qualifiedName: string) => Element
createTextNode: (text: string) => Text
createComment: (text: string) => Comment
insertBefore: (parentNode: Node, newNode: Node, referenceNode: Node | null) => void
removeChild: (node: Node, child: Node) => void
appendChild: (node: Node, child: Node) => void
parentNode: (node: Node) => Node | null
nextSibling: (node: Node) => Node | null
tagName: (elm: Element) => string
setTextContent: (node: Node, text: string | null) => void
getTextContent: (node: Node) => string | null
isElement: (node: Node) => node is Element
isText: (node: Node) => node is Text
isComment: (node: Node) => node is Comment
}
在 snabbdom/src/package/htmldomapi.ts
文件中我们找到了定义该类型的地方,通过分析发现他内部定义了一些操作 DOM
节点的方法,但是并没有去具体实现。这是为什么呢?
因为这样做就实现 VNode 抽象层,实现跨平台渲染了,当在浏览器环境中,使用 Document
文档对象生成真实 DOM
绘制,而在其他环境中,使用其平台提供的绘制 "DOM" 方法,然后统一实现定义好的 Api
,这样就可以做到在核心代码不变的情况下, 我只要自定义实现绘制的 API ,因此可以渲染到 DOM
以外的平台,实现 SSR
、同构渲染这些高级特性,各种跨端的框架就是应用的这一特性,比如 Taro
、 uni-app
等跨平台框架。
六、模块化的应用
在上面的流程中,我们并没有给我们的元素添加事件监听,以及 class
样式绑定,那我们设想如果要自己做你会如何实现呢? 在创建 DOM
的地方直接绑定事件? 设置各种属性?
让我们看看再 snabbdom
中是如何实现的吧,我们先引入简单的 2 个模块试试。分析下他是如何做的。
import { h } from 'snabbdom/src/package/h'
import { init } from 'snabbdom/src/package/init'
import { propsModule } from 'snabbdom/src/package/modules/props'
import { eventListenersModule } from 'snabbdom/src/package/modules/eventlisteners'
const patch = init([propsModule, eventListenersModule])
通过查看 init 函数可知,模块初始化时其中定义了Vnode
生命周期6种钩子,然后在Vnode
生命周期流程中依次触发,实现了流程的解耦。
// snabbdom/src/package/init.ts
const hooks: Array<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post']
export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
let i: number
let j: number
// 初始化 模块钩子
const cbs: ModuleHooks = {
create: [],
update: [],
remove: [],
destroy: [],
pre: [],
post: []
}
const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi
for (i = 0; i < hooks.length; ++i) {
cbs[hooks[i]] = []
for (j = 0; j < modules.length; ++j) {
const hook = modules[j][hooks[i]]
if (hook !== undefined) {
(cbs[hooks[i]] as any[]).push(hook)
}
}
}
// 省略部分源码
}
然后看下模块与钩子是如何联系起来的,通过目录结构我们可以看出,模块放在 modules
目录下每个文件单独处理一种情况,这就体现了软件设计原则中的——单一原则,我们先打开一个我们传入 eventListenersModule
模块进行分析下。
在 snabbdom/src/package/modules/eventlisteners.ts
文件中,我们找到了绑定事件的模块,通过观察,我们发现只有特定的三个时机会调用 updateEventListeners
函数, 在 updateEventListeners
函数中,处理处理事件的逻辑。
// snabbdom/src/package/modules/eventlisteners.ts
function createListener () {
return function handler (event: Event) {
handleEvent(event, (handler as any).vnode)
}
}
function updateEventListeners (oldVnode: VNode, vnode?: VNode): void {
// 省略部分源码
if (on) {
// reuse existing listener or create new
const listener = (vnode as any).listener = (oldVnode as any).listener || createListener()
// update vnode for listener
listener.vnode = vnode
// if element changed or added we add all needed listeners unconditionally
if (!oldOn) {
for (name in on) {
// add listener if element was changed or new listeners added
elm.addEventListener(name, listener, false)
}
} else {
for (name in on) {
// add listener if new listener added
if (!oldOn[name]) {
elm.addEventListener(name, listener, false)
}
}
}
}
}
// 最后导出模块,最后根据特定时机调用模块方法
export const eventListenersModule: Module = {
create: updateEventListeners, // create 时机
update: updateEventListeners, // update 时机
destroy: updateEventListeners // destroy 时机
}
通过断点调试可以看到初始化模块时会把我们的模块写的钩子合并起来,然后统一调用。这样就做到了模块的按需引入,同时只需要我们实现 ModuleHooks 中定义好的接口即可灵活替换模块。
// snabbdom/src/package/init.ts
export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node
const insertedVnodeQueue: VNodeQueue = []
// 触发 pre 钩子
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
// ...省略部分源码
createElm(vnode, insertedVnodeQueue)
// ...省略部分源码
// 触发 post 钩子
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
return vnode
}
}
最后当我们的钩子初始化完毕后就会在特定时机调用了。
一张图总结下流程:
七、总结
本文主要以 Snabbdom-demo 仓库为学习示例,知道了什么是“虚拟 DOM”(VDOM) 以及他的组成,基于“虚拟 DOM”框架的是如何完成首次渲染流程,以及为什么说“虚拟 DOM”,可以跨平台,模块化 + hook 机制的好处。