virtual Dom 是什么

虚拟 DOM 其实就是js对象,通过对象的方式表示真实的 DOM 结构,将页面的状态抽象成js对象的形式

为什么需要虚拟dom

频繁的操作 DOM 会使得网站的性能下降,为了保证性能,我们需要使得 DOM 的操作尽量精简,我们可以通过操作虚拟 DOM 的方法,去比较新旧节点的差异然后精确的获取最小的,最为必要的 DOM 集合,最终挂载到真实的 DOM 上。因为操作数据结构,远比我们直接修改 DOM 节点来的快,我们真实的 DOM 操作在最好的情况下,其实只需要在最后来那么一下,他们喊是patch补丁一下

  • 保证各框架性能下限,在不进行手动优化的情况下,提供系统过得去的性能
  • 跨平台,例如服务端渲染(这个能力的根本,是 Javascript 代码能低代价地在各个平台运行(得利于浏览器在各个平台的普及和 NodeJS),也就是常说的 Javascript 的优势之一是跨平台)

虚拟dom优缺点

  • 优点是其抽象能力和常驻内存的特性,让vue/react框架能更容易实现更强大的 diff 算法(由于 Virtual DOM 的存在,diff 算法可以更方便且更强大)
  • 缺点是增加了框架复杂度,也占用了更多的内存

(本想说一下几个优势,后来发现有大佬觉得这不是虚拟dom的优势)

大佬总结的几个关于 Virtual DOM 优势误区:

  • 操作 DOM 太慢,操作 Virtual DOM 对象快 ❌
    Virtual DOM 很快,但这并不是它的优势,因你本可以选择不使用 Virtual DOM 。

  • 使用 Virtual DOM 可以避免频繁操作 DOM ,能有效减少回流和重绘次数 ❌
    无论你在一次事件循环中调用多少次的 DOM API ,浏览器也只会触发一次回流与重绘(如果需要),并且如果多次调用并没有修改 DOM 状态,那么回流与重绘一次都不会发生。批量操作也不能减少回流与重绘。

  • Virtual DOM 有跨平台优势 ❌
    跨平台是 Javascript 的优势,与 Virtual DOM 无关。

virtual dom 实现

主要是这四个步骤,基本上大差不差

  • 用js对象模拟DOM树
  • render方法生成真实 DOM 节点
  • 计算新老虚拟dom差异-即diff算法
  • 将两个虚拟 DOM 对象的差异应用到真正的 DOM 树

用js对象模拟DOM树

  • tagName 对应真实的标签类型,比如ul标签p标签等等
  • attrs 表示节点上的所有属性,比如arrts: {class: 'list', style: "color:green"}
  • child 表示该节点的孩子节点, 比如['Vue']
1
2
3
4
5
6
7
8
9
10
export class Element {
constructor(tagName, attrs = {}, child = []) {
this.tagName = tagName
this.attrs = attrs
this.child = child
}
}
function newElement(tag, attr, child) {
return new Element(tag, attr, child)
}

调用 newElement方法即可生成

render方法生成真实 DOM 节点

  • 增加设置对象属性的方法setAttr()
  • 类的内部添加创建真实DOm节点的 render 方法
  • 最后通过一个renderDom方法将dom 渲染到浏览器(appendChild)
    1
    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
    // 对标签设置属性
    export const setAttr = function (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;
    }
    }
    export class Element {
    constructor(tagName, attrs = {}, child = []) {
    this.tagName = tagName
    this.attrs = attrs
    this.child = child
    }
    render() {
    let ele = document.createElement(this.tagName)
    let attrs = this.attrs
    for (let key in attrs) {
    setAttr(ele, key, attrs[key])
    }
    let childNodes = this.child
    // 如果是虚拟dom就继续递归遍历
    // 不是就代表文本节点 直接创建
    childNodes.forEach(function (child) {
    let childEle = child instanceof Element
    ? child.render()
    : document.createTextNode(child)
    // 挂在到真实的dom节点上
    ele.appendChild(childEle)
    })
    return ele
    }
    }
    ...
    const RealDom = VdObj1.render()
    // console.dir(RealDom)
    const renderDom = function (element, target) {
    target.appendChild(element)
    }
    export function start() {
    renderDom(RealDom, document.body)
    }

比较新老虚拟dom差异-即diff算法

diff 算法用来比较两棵 Virtual DOM 树的差异,如果需要两棵树的完全比较,那么 diff 算法的时间复杂度为O(n^3)。但是在前端当中,你很少会跨越层级地移动 DOM 元素,所以 Virtual DOM 只会对同一个层级的元素进行对比,如下图所示, div 只会和同一层级的 div 对比,第二层级的只会跟第二层级对比,这样算法复杂度就可以达到 O(n)

  • 深度优先遍历,记录差异
  • 根据差异更改原先真实的dom结构,即更新dom

深度优先遍历 记录俩棵树的差异

1
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
const diff = (oldNode, newNode) => {
// 用来存放补丁的对象
let difference = {}
// 递归树 然后把比较后的结果放到补丁里
walk(oldNode, newNode, 0, difference)
return difference
}
let initIndex = 0
const REMOVE = 'remove'
const MODIFY_TEXT = 'modify_text'
const CHANGE_ATTRS = 'change_attrs'
const TACEREPLACE = 'replace'
// 获取最小差异数组
const walk = (oldNode, newNode, index, difference) => {
// 每个元素都有一个补丁
let diffResult = []
if (!newNode) {
diffResult.push({
index,
type: REMOVE
})
// 文本节点直接替换
} else if (typeof newNode === 'string' && typeof oldNode === 'string') {
if (oldNode !== newNode) {
diffResult.push({
index,
value: newNode,
type: MODIFY_TEXT
})
}
} else if (oldNode.tagName === newNode.tagNam) {
// 节点类型相同 则继续比较熟悉是否相同
let storeAttrs = {}
for (let key in oldNode.attrs) {
if (oldNode.attrs[key] !== newNode.attrs[key]) {
storeAttrs[key] = oldNode.attrs[key]
}
}
for (let key in newNode.attrs) {
if (!oldNode.attrs.hasOwnProperty(key)) {
storeAttrs[key] = newNode.attrs[key]
}
}
if (Object.keys(storeAttrs).length > 0) {
diffResult.push({
index,
value: storeAttrs,
type: CHANGE_ATTRS
})
}
// 递归遍历子节点
if (oldNode.child && oldNode.child.length) {
oldNode.child.forEach((child, index) => {
// 深度递归遍历所有要保留的index
getDiff(child, newNode.child[index], ++initIndex, difference)
})
}
} else if (oldNode.tagName !== newNode.tagName) {
// 节点类型不相同 直接替换
diffResult.push({
index,
type: TACEREPLACE,
newNode
})
}
if (!oldNode) {
diffResult.push({
newNode,
type: TACEREPLACE
})
}
// 当前元素补丁放到大补丁
if (diffResult.length) {
difference[index] = diffResult
}
}

根据差异更改原先真实的dom结构,即更新dom

现在我们已经生成了两个虚拟 DOM, 并且将两个 DOM 之间的差异用对象的方式保存了下来,接下来,我们就要通过这些来将差异更新到真实的 DOM 上面去!!下面的代码很长,但确实是这样的

1
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
const patch = (node, difference) => {
let pacer = { index: 0 }
// 给元素打补丁
patchNode(node, pacer, difference)
}
const patchNode = (node, pacer, difference) => {
let currentDifference = difference[pacer.index]
let childNodes = node.childNodes
// 先序深度优先遍历
childNodes.forEach((child) => {
pacer.index++
patchNode(child, pacer, difference)
})
if (currentDifference) {
doPatch(node, currentDifference)
}
}
// 根据传递的diff进行遍历操作node节点
const doPatch = (node, difference) => {
difference.forEach(item => {
switch (item.type) {
case 'change_attrs':
const attrs = item.value
for (let key in attrs) {
if (node.nodeType !== 1) return
const value = attrs[key]
if (value) {
setAttr(node, key, value)
} else {
node.removeAttribute(key)
}
}
break
case 'modify_text':
node.textContent = item.value
break
case 'replace':
// 新节点替换老节点 需要先判断新节点是不是element实例 如果是需要调用render方法渲染新节点
// 如果不是则表明新节点是文本节点,直接创建一个文本节点
let newNode = (item.newNode instanceof Element)
? item.newNode.render(item.newNode)
: document.createTextNode(item.newNode)
// 调用父级的replaceChild方法替换为新的节点
node.parentNode.replaceChild(newNode, node)
break
case 'remove':
node.parentNode.removeChild(node)
break
default:
break
}
})
}

最终调用的时候,这样

1
2
3
4
5
export function start() {
renderDom(RealDom, document.body)
const diffs = diff(VdObj1, VdObj2)
patch(RealDom, diffs)
}

dom diff过程

总结来说:

  • 用JS对象模拟DOM(虚拟DOM)
  • 把此虚拟DOM转成真实DOM并插入页面中(render)
  • 如果有事件发生修改了虚拟DOM,比较两棵虚拟DOM树的差异,得到差异对象(diff)
  • 把差异对象应用到真正的DOM树上(patch)

vue的虚拟dom

看了这么多别人的关于虚拟dom的文章,今天的主角依然是vue中的虚拟dom和diff算法,不然面试的时候依然直接跪!

模板转换成视图的过程

vue 虚拟dom

  • Vue.js通过编译将template 模板转换成渲染函数(render function) ,执行渲染函数就可以得到一个虚拟节点树
  • 在对数据模型进行操作的时候,会触发对应 Dep 中的 Watcher 对象。Watcher 对象会调用对应的 update 来修改视图。这个过程主要是将新旧虚拟节点进行差异对比,然后根据对比结果进行DOM操作来更新视图。

针对上图有几个概念说下:

  • 渲染函数:渲染函数是用来生成Virtual DOM的。Vue推荐使用模板来构建我们的应用界面,在底层实现中Vue会将模板编译成渲染函数,当然我们也可以不写模板,直接写渲染函数,以获得更好的控制。

  • VNode 虚拟节点:它可以代表一个真实的 dom 节点。通过 createElement 方法能将 VNode 渲染成 dom 节点。简单地说,vnode可以理解成节点描述对象,它描述了应该怎样去创建真实的DOM节点。

  • patch(也叫做patching算法):虚拟DOM最核心的部分,它可以将vnode渲染成真实的DOM,这个过程是对比新旧虚拟节点之间有哪些不同,然后根据对比结果找出需要更新的的节点进行更新。Vue的Virtual DOM Patching算法是基于Snabbdom的实现,并在此基础上作了很多的调整和改进

Vue模板编译原理

Vue首先会将template模板进行编译,其中包括parse/optimize/generate三个过程

parse

parse会使用正则表达式解析template模板中的指令、class/style等数据,形成AST,及上文的这样

1
2
3
4
5
6
7
8
9
10
11
12
13
// js模拟DOM结构
var element = {
tagName: 'ul', // 节点标签名
attrs: { // DOM的属性,用一个对象存储键值对
class: 'item',
id: 'list'
},
children: [ // 该节点的子节点
{tagName: 'li', attrs: {class: 'item1'}, children: "Item 1"},
{tagName: 'li', attrs: {class: 'item2'}, children: "Item 2"},
{tagName: 'li', attrs: {class: 'item3', style: 'font-size: 20px'}, children: "Item 3"},
]
}

optimize

optimize过程主要是为了优化后面的diff算法,vue在编译过程中会主动标记static静态节点,后续update更新视图的时候,patch过程中会直接跳过这些标记静态节点

generate

最后通过generate 将 AST 转化成 render function 字符串,得到结果是 render 的字符串以及 staticRenderFns 字符串,render函数字符串可能长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function _render() {
with (this) {
return __h__(
'ul',
{staticClass: "list"},
[
" ",
__h__('li', {class: item}, [String((msg))]),
" ",
__h__('li', {class: item}, [String((msg))]),
"",
__h__('li', {class: item}, [String((msg))]),
""
]
)
};
}

VNode模拟virtual-dom结构

从虚拟dom到真实DOM要经过vNode定义,diff、patch等过程,总结一下主要的流程

  • 引入编译时版本vue.esm.js,执行initMixin方法

  • 初始化vue,创建一个Vue实例,执行this._init方法,然后执行vm.$mount(vm.$options.el)方法准备挂载,生命周期函数callHook(vm, ‘created’)之后

  • Vue实例挂载,调用mountComponent方法,该方法核心就是先实例化一个渲染Watcher,在它的回调函数中会调用 updateComponent 方法,在此方法中调用 vm._render 方法先生成虚拟 VNode,最终调用 vm._update 更新 DOM

  • 创建虚拟VNode,vm._render,会调用createElement方法,该方法最终会创建一个VNode实例 new VNode()

VNode类解析

在 Vue.js 中,Virtual DOM 是用 VNode 这个 Class 去描述,它定义在 src/core/vdom/vnode.js 中 ,从以下代码块中可以看到 Vue.js 中的 Virtual DOM 的定义较为复杂一些,因为它这里包含了很多 Vue.js 的特性。实际上 Vue.js 中 Virtual DOM 是借鉴了一个开源库 snabbdom 的实现,然后加入了一些 Vue.js 的一些特性

1
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
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
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
}
}

抓重点,看几个关键的属性定义即可:
tag 属性即这个vnode的标签属性,比如h1/span
data 属性包含了最后渲染成真实dom节点后,节点上的class,attribute,style以及绑定的事件,比如attrs: {id: "app", class: "rootApp"}
children 属性是vnode的子节点
text 属性是文本属性
elm 属性为这个vnode对应的真实dom节点
key 属性是vnode的标记,在diff过程中可以提高diff的效率

源码创建 VNode 过程

1.引入编译时版本vue.esm.js,执行initMixin方法
2.初始化vue
实例化一个Vue实例时,即new Vue时,执行的src/core/instance/index.js 中定义的 Function 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
function Vue (options) {
if (process.env.NODE_ENV !== 'production' &&
!(this instanceof Vue)
) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options)
}
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

通过查看 Vue 的 function,我们知道 Vue 只能通过 new 关键字初始化,然后调用 this._init 方法,该方法在 src/core/instance/init.js 中定义。

1
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
export function initMixin (Vue: Class<Component>) {
Vue.prototype._init = function (options?: Object) {
const vm: Component = this
vm._uid = uid++
let startTag, endTag
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
vm._self = vm
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
// 初始化状态
initState(vm)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created')

if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
}

3.Vue实例挂载
Vue 中是通过 $mount 实例方法去挂载 dom 的,下面我们通过分析 compiler 版本的 mount 实现,相关源码在目录src/platforms/web/runtime/entry-runtime-with-compiler.js 文件中定义:

1
2
3
4
5
6
7
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}

我们发现$mount 方法实际上会去调用 mountComponent 方法,这个方法定义在 src/core/instance/lifecycle.js 文件中

1
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
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el
// created之后 也就是beforeMount之前已经能拿到$el
callHook(vm, 'beforeMount')
let updateComponent
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
// 实例化一个渲染Watcher,在它的回调函数中会调用 updateComponent 方法
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
hydrating = false
// manually mounted instance, call mounted on self
// mounted is called for render-created child components in its inserted hook
if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
return vm
}

从上面的代码可以看到,mountComponent 核心就是先实例化一个渲染Watcher,在它的回调函数中会调用 updateComponent 方法,在此方法中调用 vm._render 方法先生成虚拟 Node,最终调用 vm._update 更新 DOM。

4.创建虚拟dom
紧接上面的vm._render,Vue实例的一个私有方法,它用来把实例渲染成一个虚拟 Node,并返回该虚拟dom。它的定义在 src/core/instance/render.js 文件中:

1
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.prototype._render = function (): VNode {
const vm: Component = this
const { render, _parentVnode } = vm.$options
vm.$vnode = _parentVnode
// render self
let vnode
try {
currentRenderingInstance = vm
// 调用vm.$createElement
vnode = render.call(vm._renderProxy, vm.$createElement)
} catch (e) {
handleError(e, vm, `render`)
if (process.env.NODE_ENV !== 'production' && vm.$options.renderError) {
try {
vnode = vm.$options.renderError.call(vm._renderProxy, vm.$createElement, e)
} catch (e) {
handleError(e, vm, `renderError`)
vnode = vm._vnode
}
} else {
vnode = vm._vnode
}
} finally {
currentRenderingInstance = null
}
// set parent
vnode.parent = _parentVnode
return vnode
}

vnode = render.call(vm._renderProxy, vm.$createElement)生成虚拟dom,调用createElement方法

1
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)

接着再调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export function createElement (
context: Component,
tag: any,
data: any,
children: any,
normalizationType: any,
alwaysNormalize: boolean
): VNode | Array<VNode> {
return _createElement(context, tag, data, children, normalizationType)
}
export function _createElement (
context: Component,
tag?: string | Class<Component> | Function | Object,
data?: VNodeData,
children?: any,
normalizationType?: number
): VNode | Array<VNode> {
// 创建虚拟 vnode
vnode = new VNode(
config.parsePlatformTagName(tag), data, children,
undefined, undefined, context
)
}

createElement 方法有 5 个参数,
context 表示 VNode 的上下文环境,它是 Component 类型;
tag表示标签,它可以是一个字符串,也可以是一个 Component;
data 表示 VNode 的数据,它是一个 VNodeData 类型,可以在 flow/vnode.js 中找到它的定义;
children 表示当前 VNode 的子节点,它是任意类型的,需要被规范为标准的 VNode 数组;

vue对Dom更新做了哪些标记优化处理

参考