# 虚拟dom
和虚拟节点VNode
# 什么是虚拟dom
通过js对象模拟出一个我们需要渲染到页面上的
dom
树的结构,实现了一个修改js对象即可修改页面dom
的快捷途径,避免了我们手动再去一次次操作dom-api
的繁琐,而且其提供了diff
算法可以使得用最少的dom
操作进行修改。(虚拟DOM
最终也要挂到浏览器上成为真实DOM
节点的,因此使用虚拟DOM
并不能使操作DOM
次数减少,但能够精确地获取最小的,最必要的操作DOM
的集合)
虚拟
DOM
解决方式就是通过状态生成一个虚拟节点树,然后使用虚拟节点树进行渲染。在渲染之前,会使用新生成的虚拟节点树和上一次生成的虚拟节点树进行对比,只渲染不同的部分。虚拟节点树其实是由组件树建立起来的整个虚拟节点(
Virtual Node
,简写vnode
)树虚拟
DOM
除了它的数据结构定义,映射到真实的DOM
实际上要经历VNode
的create
,diff
,patch
等过程。VNode
的create
是通过createElement
方法创建的。
# vue.js中的虚拟 dom
在vue.js中,我们使用模板来描述状态与
DOM
之间的映射关系。Vue.js通过编译将模板转换成渲染函数createElement
,执行渲染函数就可以得到一个虚拟节点树,使用这个虚拟节点树就可以渲染页面。(通过patch
把虚拟节点渲染成视图)
为了避免不必要的
DOM
操作,虚拟DOM
在虚拟节点映射到视图的过程中,将虚拟节点与上一次渲染视图所使用的旧虚拟节点(oldVnode
)做对比,找出真正需要更新的节点来进行DOM
操作,从而避免操作不用修改的DOM
。
虚拟
DOM
在 vue.js 中所做的事情其实并没有想象中那么复杂,主要做两件事:1. 提供与真实DOM
节点所对应的虚拟节点vnode
。2. 将虚拟节点vnode
和旧虚拟节点oldVnode
进行对比,然后更新视图。
# createElement
方法
Vue.js利用createElement
方法创建VNode
createElement
方法实际上是对_createElement
的封装,允许传入的参数更加灵活,处理这些参数之后,调用真正创建VNode
的函数就是_createElement
五个参数类型
context
表示VNode的上下文环境,是Component
的类型;tag
表示标签,可以是一个字符串,也可以是一个Component
;data
表示VNode
的数据;children
表示当前的VNode
的子节点,任意类型;normalizationType
表示子节点规范的类型,主要参考render
函数是编译生成的还是用户手动生成的。
以下分析createElement
两个重点流程,children
的规范化和VNode
如何创建。
# children的规范化
由于
Virtual DOM
实际上是⼀个树状结构,每⼀个VNode
可能会有若⼲个⼦节点,这些⼦节点应该也是VNode
的类型。_createElement
接收的第4
个参数children
是任意类型的,因此我们需要把它们规范成VNode
类型。这⾥根据normalizationType
的不同,调⽤了normalizeChildren(children)
和simpleNormalizeChildren(children)
⽅法。
export function simpleNormalizeChildren (children: any) {
for (let i = 0; i < children.length; i++) {
if (Array.isArray(children[i])) {
return Array.prototype.concat.apply([], children)
}
}
return children
}
2
3
4
5
6
7
8
simpleNormalizeChildren
⽅法调⽤场景是render
函数当函数是编译⽣成的。理论上编译⽣成的children
都已经是VNode
类型的,但这⾥有⼀个例外,就是functional component
函数式组件返回的是⼀个数组⽽不是⼀个根节点,所以会通过Array.prototype.concat
⽅法把整个children
数组打平,让它的深度只有⼀层。
export function normalizeChildren (children: any): ?Array<VNode> {
return isPrimitive(children)
? [createTextVNode(children)]
: Array.isArray(children)
? normalizeArrayChildren(children)
: undefined
}
2
3
4
5
6
7
normalizeChildren
⽅法的调⽤场景有2种,⼀个场景是render
函数是⽤户⼿写的,当children
只有⼀个节点的时候,Vue.js从接⼝层⾯允许⽤户把children
写成基础类型⽤来创建单个简单的⽂本节点,这种情况会调⽤createTextVNode
创建⼀个⽂本节点的VNode
;另⼀个场景是当编译slot
、v-for
的时候会产⽣嵌套数组的情况,会调⽤normalizeArrayChildren
⽅法,接下来看⼀下它的实现:
function normalizeArrayChildren (children: any, nestedIndex?: string): Array<VNode> {
const res = []
let i, c, lastIndex, last
for (i = 0; i < children.length; i++) {
c = children[i]
if (isUndef(c) || typeof c === 'boolean') continue
lastIndex = res.length - 1
last = res[lastIndex]
// nested
if (Array.isArray(c)) {
if (c.length > 0) {
c = normalizeArrayChildren(c, `${nestedIndex || ''}_${i}`)
// merge adjacent text nodes
if (isTextNode(c[0]) && isTextNode(last)) {
res[lastIndex] = createTextVNode(last.text + (c[0]: any).text)
c.shift()
}
res.push.apply(res, c)
}
} else if (isPrimitive(c)) {
if (isTextNode(last)) {
// merge adjacent text nodes
// this is necessary for SSR hydration because text nodes are
// essentially merged when rendered to HTML strings
res[lastIndex] = createTextVNode(last.text + c)
} else if (c !== '') {
// convert primitive to vnode
res.push(createTextVNode(c))
}
} else {
if (isTextNode(c) && isTextNode(last)) {
// merge adjacent text nodes
res[lastIndex] = createTextVNode(last.text + c.text)
} else {
// default key for nested array children (likely generated by v-for)
if (isTrue(children._isVList) &&
isDef(c.tag) &&
isUndef(c.key) &&
isDef(nestedIndex)) {
c.key = `__vlist${nestedIndex}_${i}__`
}
res.push(c)
}
}
}
return res
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
normalizeArrayChildren
接收2个参数,children
表⽰要规范的⼦节点,nestedIndex
表⽰嵌套的索引,因为单个child
可能是⼀个数组类型。normalizeArrayChildren
主要的逻辑就是遍历children
,获得单个节点c,然后对c的类型判断,如果是⼀个数组类型,则递归调⽤normalizeArrayChildren
; 如果是基础类型,则通过createTextVNode
⽅法转换成VNode
类型; 否则就已经是VNode
类型了,如果children
是⼀个列表并且列表还存在嵌套的情况,则根据nestedIndex
去更新它的key
。这⾥需要注意⼀点,在遍历的过程中,对这3种情况都做了如下处理:如果存在两个连续的text
节点,会把它们合并成⼀个text
节点。经过对children
的规范化,children
变成了⼀个类型为VNode
的Array
。
# VNode
在Vue.js中存在一个
VNode
类,使用它可以实例化不同类型的vnode
实例,而不同类型的vnode
实例各自表示不同类型的DOM
元素。
export default class VNode {
tag: string | void;
data: VNodeData | void;
children: ?Array<VNode>;
text: string | void;
elm: Node | void;
ns: string | void;
context: Component | void; // rendered in this component's scope
key: string | number | void;
componentOptions: VNodeComponentOptions | void;
componentInstance: Component | void; // component instance
parent: VNode | void; // component placeholder node
// strictly internal
raw: boolean; // contains raw HTML? (server only)
isStatic: boolean; // hoisted static node
isRootInsert: boolean; // necessary for enter transition check
isComment: boolean; // empty comment placeholder?
isCloned: boolean; // is a cloned node?
isOnce: boolean; // is a v-once node?
asyncFactory: Function | void; // async component factory function
asyncMeta: Object | void;
isAsyncPlaceholder: boolean;
ssrContext: Object | void;
fnContext: Component | void; // real context vm for functional nodes
fnOptions: ?ComponentOptions; // for SSR caching
devtoolsMeta: ?Object; // used to store functional render context for devtools
fnScopeId: ?string; // functional scope id support
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Vue.js对状态采取中等粒度的侦测策略。当状态发生变化,只通知到组件级别,然后组件内使用虚拟DOM
来渲染视图。Vue1
采取细粒度,这样一个细小的状态发生变化,都会利用watcher
侦测,大大消耗了内存。但如果一个组件只有一个状态发生变化,整个组件就要重新渲染,这样也会造成很大的性能损耗,所以对vnode
进行缓存就尤为重要了。
# VNode类型
- vnode类型
- 注释节点
- 文本节点
- 元素节点
- 组件节点
- 函数式组件
- 克隆节点
因为通过
VNode
类实例的vnode
实例对象,只是有效属性不同罢了。因为通过参数为实例设置属性时,无效的属性默认被赋值为undefined
和false
,无效属性直接忽略就好。
constructor (
tag?: string,
data?: VNodeData,
children?: ?Array<VNode>,
text?: string,
elm?: Node,
context?: Component,
componentOptions?: VNodeComponentOptions,
asyncFactory?: Function
) {
this.tag = tag
this.data = data
this.children = children
this.text = text
this.elm = elm
this.ns = undefined
this.context = context
this.fnContext = undefined
this.fnOptions = undefined
this.fnScopeId = undefined
this.key = data && data.key
this.componentOptions = componentOptions
this.componentInstance = undefined
this.parent = undefined
this.raw = false
this.isStatic = false
this.isRootInsert = true
this.isComment = false
this.isCloned = false
this.isOnce = false
this.asyncFactory = asyncFactory
this.asyncMeta = undefined
this.isAsyncPlaceholder = false
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 注释节点
export const createEmptyVNode = (text: string = '') => {
const node = new VNode()
node.text = text
node.isComment = true
return node
}
2
3
4
5
6
可以看出一个注释节点只有text
和isComment
两个有效属性。
例如:
<!-- 注释节点 -->
所以vnode对应
{
text: '注释节点',
isComment: true
}
2
3
4
# 文本节点
export function createTextVNode (val: string | number) {
return new VNode(undefined, undefined, undefined, String(val))
}
2
3
可以看出文本节点只有text
属性
# 克隆节点
克隆节点是将现有节点的属性复制到新节点,让新创建的节点和被克隆节点的属性保持一致,从而实现克隆效果。它的作用是优化静态节点和插槽节点。
export function cloneVNode (vnode: VNode): VNode {
const cloned = new VNode(
vnode.tag,
vnode.data,
// #7975
// clone children array to avoid mutating original in case of cloning
// a child.
vnode.children && vnode.children.slice(),
vnode.text,
vnode.elm,
vnode.context,
vnode.componentOptions,
vnode.asyncFactory
)
cloned.ns = vnode.ns
cloned.isStatic = vnode.isStatic
cloned.key = vnode.key
cloned.isComment = vnode.isComment
cloned.fnContext = vnode.fnContext
cloned.fnOptions = vnode.fnOptions
cloned.fnScopeId = vnode.fnScopeId
cloned.asyncMeta = vnode.asyncMeta
cloned.isCloned = true
return cloned
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
以静态节点为例子,当组件内的某个状态发生改变后,当前组件会通过虚拟
DOM
重新渲染视图,静态节点因为内容没有改变所以就没必要执行渲染函数重新生成vnode
。因此这个时候就可以使用克隆节点将vnode
克隆一份,使用克隆节点进行渲染。这样就不用重新执行渲染函数生成新的静态节点vnode
,从而提升一定性能
克隆节点和被克隆节点唯一区别就是isCloned
属性,前者为true
,后者为false
# 元素节点
tag
: 顾名思义,一个节点名称
data
:该属性包含一些节点上的数据
children
: 当前节点的子节点列表
context
: 它是当前组件的Vue.js实例
# 组件节点
组件节点和元素节点类似,有以下两个独有的属性:
componentOptions
:顾名思义,就是组件节点的选项参数,其中包含propsData
,tag
,children
等信息。
componentInstance
:组件的实例,也就是vue.js的实例。事实上,在vue.js中,每个组件都是一个vue.js实例。
# 函数式组件
函数式组件和组件节点类似,它有两个独有的属性fnContext
和fnOptions
# 创建VNode
回到createElement
函数,规范化children
之后,接下来就会创建一个VNode实例
if (typeof tag === 'string') {
let Ctor
ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
if (config.isReservedTag(tag)) {
// platform built-in elements
if (process.env.NODE_ENV !== 'production' && isDef(data) && isDef(data.nativeOn)) {
warn(
`The .native modifier for v-on is only valid on components but it was used on <${tag}>.`,
context
)
}
vnode = new VNode(
config.parsePlatformTagName(tag), data, children,
undefined, undefined, context
)
} else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
// component
vnode = createComponent(Ctor, data, context, children, tag)
} else {
// unknown or unlisted namespaced elements
// check at runtime because it may get assigned a namespace when its
// parent normalizes children
vnode = new VNode(
tag, data, children,
undefined, undefined, context
)
}
} else {
// direct component options / constructor
vnode = createComponent(tag, data, context, children)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
这⾥先对
tag
做判断,如果是string
类型,则接着判断如果是内置的⼀些节点,则直接创建⼀个普通VNode
,如果是为已注册的组件名,则通过createComponent
创建⼀个组件类型的VNode
,否则创建⼀个未知的标签的VNode
。如果是tag
⼀个Component
类型,则直接调⽤createComponent
创建⼀个组件类型的VNode
节点。
TIP
之所以需要使用状态生成VNode,是因为如果直接用状态生成真实DOM
,会有一定程度的性能浪费。而先创建虚拟节点再渲染视图,就可以将虚拟节点缓存,然后使用新创建的虚拟节点和上一次渲染时缓存的虚拟节点进行对比,然后根据对比结果只更新需要更新的真实DOM节点,从而避免不必要的DOM
操作,节省一定的性能开销。
# 虚拟DOM
到真实DOM
的一个伪代码过程
学习《前端开发核心进阶》积累
// 首先实现一个setAttribute方法。使用该方法来对DOM节点进行属性设置
const setAttribute = (node,key,value) => {
switch(key) {
case 'style':
node.style.cssText = value
break;
case 'value':
let tagName = node.tagName || ''
tagName = tagName.toLowerCase()
if(tagName === 'input' || tagName === 'textarea') {
node.value = value
} else {
node.setAttribute(key,value)
}
break;
default:
node.setAttribute(key,value)
break;
}
}
// 实现一个用于生成虚拟DOM的类
class Element {
constructor(tagName, attributes={}, children = []) {
this.tagName = tagName
this.attributes = attributes
this.children = children
}
// 加入render函数,根据虚拟DOM生成真实DOM片段
render() {
let element = document.createElement(this.tagName)
let attributes = this.attributes
for(let key in attributes) {
setAttribute(element,key,attributes[key])
}
let children = this.children
children.forEach(child => {
let childElement = child instanceof Element
? child.render()
: document.createTextNode(child) // 若是字符串直接创建文本节点
element.appendChild(childElement)
})
return element
}
}
function element(tagName,attributes,children) {
return new Element(tagName,attributes,children)
}
//将真实DOM节点渲染到浏览器上
const renderDom = (element,target) => {
target.appendChild(element)
}
// 测试例子
const chap = element('ul',{id: 'list'},[
element('li',{class: 'chapter'},['chapter1']),
element('li',{class: 'chapter'},['chapter2']),
element('li',{class: 'chapter'},['chapter3'])
])
const dom = chap.render()
renderDom(dom,document.body)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# diff
算法伪代码
《前端开发核心知识进阶》一书积累所得
由上可以产出一份虚拟DOM
,并渲染在浏览器中。用户进行特定操作后,会产出一份新的虚拟DOM
,如何得出前后两份虚拟DOM
的差异,并交给浏览器需要更新的结果呢?
const diff = (oldVirtualDom,newVirtualDom) => {
let patches = {}
// 递归树,将比较后的结果存储到patches中
walkToDiff(oldVirtualDom,newVirtualDom,0,patches)
return patches
}
let initialIndex = 0;
const walkToDiff = (oldVirtualDom,newVirtualDom,index,patches) => {
let diffResult = []
// 如果newVirtualDom不存在,则说明节点已经移除,接着可以将type为REMOVE的对象推进diffResult并记录index
if(!newVirtualDom) {
diffResult.push({
type: 'REMOVE',
index
})
} else if(typeof oldVirtualDom === 'string' && typeof newVirtualDom === 'string') {
// 如果新旧节点都是文本节点
if(oldVirtualDom !== newVirtualDom) {
diffResult.push({
type: 'MODIFY_TEXT',
index,
data: newVirtualDom
})
}
} else if(oldVirtualDom.tagName === newVirtualDom.tagName) {
// 新旧节点类型相同
// 比较属性是否相同
let diffAttributeResult = {}
for(let key in oldVirtualDom) {
if(oldVirtualDom[key] !== newVirtualDom[key]) {
// 同一个key下,旧新节点的属性不一致,则直接替换掉旧节点的属性
diffAttributeResult[key] = newVirtualDom[key]
}
}
for(let key in newVirtualDom) {
//旧节点不存在的新属性
if(!oldVirtualDom.hasOwnProperty(key)) {
diffAttributeResult[key] = newVirtualDom[key]
}
}
if(Object.keys(diffAttributeResult).length > 0) {
diffResult.push({
type: 'MODIFY_ATTRIBUTES',
diffAttributeResult
})
}
// 如果有子节点则遍历子节点
oldVirtualDom.children.forEach((child,index) => {
walkToDiff(child,newVirtualDom.children[index],++initialIndex,patches)
})
} else {
// 如果节点类型不同,已经被直接替换了,则直接将新的结果放入diffResult数组中
diffResult.push({
type: 'REPLACE',
newVirtualDom
})
}
if(!oldVirtualDom) {
diffResult.push({
type: 'REPLACE',
newVirtualDom
})
}
if(diffResult.length) {
patches[index] = diffResult
}
}
// 测试案例
const chap = element('ul',{id: 'list'},[
element('li',{class: 'chapter'},['chapter1']),
element('li',{class: 'chapter'},['chapter2']),
element('li',{class: 'chapter'},['chapter3'])
])
const chap2 = element('ul',{id: 'list2'},[
element('li',{class: 'chapter2'},['chapter4']),
element('li',{class: 'chapter2'},['chapter5']),
element('li',{class: 'chapter2'},['chapter6'])
])
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
walkToDiff
前两个参数是需要比较的虚拟DOM
对象;第三个参数是用来记录nodeIndex
,在删除节点时会使用。初始值为0
;第四个参数为一个闭包变量,用来记录diff
的结果。
那么如何将这个差异更新到真实的DOM
上呢,需要使用patch
方法来完成了。
const patch = (node, patches) => {
let walker = {index: 0}
walk(node,walker,patches)
}
const walk = (node,walker,patches) => {
let currentPatch = patches[walker.index]
let childrenNodes = node.childrenNodes
childrenNodes.forEach(child => {
walker.index++
walk(child,walker,patches)
})
if(currentPatch) {
doPatch(node,currentPatch)
}
}
// walk函数自身递归,对当前节点的差异调用doPatch方法进行更新
const doPatch = (node,patches) => {
patches.forEach(patch => {
switch(patch.type) {
case 'MODIFY_ATRIBUTES':
const attributes = patch.diffAttributeResult.attributes
for(let key in attributes) {
if(node.nodeType !== 1) return
const value = attributes[key]
if(value) {
setAttribute(node,key,value)
} else {
node.removeAttribute(key)
}
}
break;
case 'MODIFY_TEXT':
node.textContent = patches.data
break;
case 'REPLACE':
let newNode = (patch.newNode instanceof Element) ? render(patch.newNode) : document.createTextNode(patch.newNode)
node.parentNode.replaceChild(newNode,node)
break;
case 'REMOVE':
node.parentNode.removeChild(node)
break;
default:
break;
}
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49