vue底层原理

虚拟Dom

虚拟DOM是一个在内存中的数据结构,它是真实DOM的抽象表示。虚拟DOM是由一系列的虚拟节点(VNode)组成的,每一个虚拟节点都对应一个真实的DOM节点。

作用:
虚拟DOM的主要作用是提供一个在内存中操作DOM的平台,使得我们可以在不直接操作真实DOM的情况下进行复杂的操作。当状态变化时,Vue会生成一个新的虚拟节点树,然后和旧的虚拟节点树进行对比,找出差异,然后只更新差异部分到真实的DOM上,这个过程称为“打补丁”。

优势:

性能优化:由于直接操作DOM通常是前端性能瓶颈的主要原因,虚拟DOM通过在内存中进行计算,避免了直接操作DOM,从而提高了性能。
跨平台:虚拟DOM不仅可以表示浏览器中的DOM,还可以表示其他的平台,比如移动应用的UI。
缺陷:

首次渲染开销大:虚拟DOM需要在内存中构建一颗DOM树,所以在首次渲染时,虚拟DOM的开销会比直接操作DOM大。
内存消耗:虚拟DOM需要在内存中维护一颗DOM树,这会消耗一定的内存。

一个虚拟Dom节点至少包括三个属性:type、attr、children

响应式

effect 副作用

effect是一个‘桶’, 他收集并触发指定方法


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
/**
* 1.
* effect 函数
* @param fn 执行方法
* @returns 以 ReactiveEffect 实例为 this 的执行函数
*/
function effect(fn) {
const _effect = new ReactiveEffect(fn)
_effect.run()
}
/**
* 2.
* 响应性触发依赖时的执行类
*/
let activeEffect //当前副作用
class ReactiveEffect {
constructor(fn){
this.fn = fn
}
run() {
//告诉全局当前执行的 ReactiveEffect 的实例
activeEffect = this
return this.fn()
}
}

计算属性的实现流程

1
2
3
4
5
6
7
8
const fullName = computed(()=> firstName.value + lastName.value)
//计算属性创建了一个Ref,并且这个Ref的getter会调用此函数。
//内部有一个dirty代表值是不是脏的,初始为true。用作缓存功能。
//本质上调用了
effect(()=>firstName.value + lastName.value)
//此时读取了firstName和lastName,触发了依赖收集。且此时activeEffect指向此箭头函数
//此函数作为依赖被收集进了set里,每当firstName或lastName改变,就会触发trigger重新调用此箭头函数,并把dirty=true
//如果下一次读取计算属性,且dirty==true,那么会重新调用此函数,如果false则使用缓存。但是对于在模板里使用的计算属性则会响应式的更新而不是等待下一次读取。

响应式系统设计

  • 依赖,就是需要重复调用的函数
  • 用set保存依赖,防止重复调用
  • 用map关联属性和依赖集合,key一个对象里的属性,value指向依赖集合
  • 用weakmap管理多个对象。

reactive

reactive 只能监听复杂数据类型,因为proxy只能监听负责数据类型的getter和setter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function reactive(obj){
return new Proxy(obj, {
get(target, key, recv){
const res = Reflect.get(...arguments)
//收集依赖
track(target, key)
return res
}
set(target, key, newValue, recv){
const res = Reflect.set(...arguments)
//触发依赖
trigger(target, key)
return res
}
})
}

为什么用Reflect
Reflect 就是反射,可以直接调用对象的基本方法。

1
2
3
4
5
6
7
let obj = {
a: 'a',
b: 'b',
get c(){
return this.a+this.b;
}
}
1
2
3
4
5
6
7
const proxy = new Proxy(obj, {
get(target, key, receiver) {
console.log('读:', target[key]);
return target[key]
}
})
proxy.c; //只输出, 读:'ab'
1
2
3
4
5
6
7
8
9
10
const proxy = new Proxy(obj, {
get(target, key, receiver) {
console.log('读:', target[key]);
return Reflect.get(...arguments);
}
})
proxy.c;
//读:'ab'
//读:'a'
//读:'b'

ref

那么ref怎么监听原始值类型?

利用了get 和 set 标记语法

  1. ref 接收任意数据
  2. ref 返回RefImpl实例
  3. 利用get和set监听
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
class RefImpl {
constructor(value){
this._value = value
this.dep = null //存放相关副作用
}
get value() {
//收集依赖
if(activeEffect){
//在实例里以set集合形式存放依赖
this.dep || (this.dep = new Set())
this.dep.add(activeEffect)
}
return this._value
}
set value(newValue) {
this._value = newValue
//触发依赖
if(this.dep) {
triggerEffects(this.dep)
}
}
}
function ref(value) {
return new RefImpl(value)
}
function triggerEffects(dep) {
//执行副作用, dep集合里装的是一个个ReactiveEffect 实例
dep.forEach(_effect => {
_effect.run()
});
}
let activeEffect = null; //当前执行的副作用实例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 测试一下!
let myname = ref('blank')
effect(() => {
console.log('响应式:',myname.value)
})
setTimeout(()=>{
myname.value = 'lzy'
console.log('修改完毕...')
},1000)
/*
响应式: blank
响应式: lzy
修改完毕...
/*

上述effect函数和activeEffect都是简化后的,没有判断该副作用到底依赖了谁

编辑器

模板DSL

把template模板编译为render函数

三大流程:

  1. 通过 parse 方法进行解析,得到 AST(抽象语法树)
  2. 通过 transform 方法对 AST 进行转化,得到 JavaScript AST(大部分和AST相同)
  3. 通过 generate 方法根据 AST 生成 render 函数
1
2
3
<div v-if="isShow">
<p class="m-title">hello world</p>
</div>

得到的AST如下

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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
{
 "type": 0, // NodeTypes.ROOT
 "children": [
  {
     "type": 1, // NodeTypes.ELEMENT
     "ns": 0,
     "tag": "div",
     "tagType": 0,
     "props": [
      {
         "type": 7, // NodeTypes.DIRECTIVE
         "name": "if",
         "exp": {
           "type": 4, // NodeTypes.SIMPLE_EXPRESSION
           "content": "isShow",
           "isStatic": false,
           "constType": 0,
           "loc": {
             "start": { "column": 12, "line": 2, "offset": 12 },
             "end": { "column": 18, "line": 2, "offset": 18 },
             "source": "isShow"
          }
        },
         "modifiers": [],
         "loc": {
           "start": { "column": 6, "line": 2, "offset": 6 },
           "end": { "column": 19, "line": 2, "offset": 19 },
           "source": "v-if="isShow""
        }
      }
    ],
     "isSelfClosing": false,
     "children": [
      {
         "type": 1, // NodeTypes.ELEMENT
         "ns": 0,
         "tag": "p",
         "tagType": 0,
         "props": [
          {
             "type": 6, // NodeTypes.ATTRIBUTE
             "name": "class",
             "value": {
               "type": 2, // NodeTypes.TEXT
               "content": "title",
               "loc": {
                 "start": { "column": 12, "line": 3, "offset": 32 },
                 "end": { "column": 19, "line": 3, "offset": 39 },
                 "source": ""title""
              }
            },
             "loc": {
               "start": { "column": 6, "line": 3, "offset": 26 },
               "end": { "column": 19, "line": 3, "offset": 39 },
               "source": "class="title""
            }
          }
        ],
         "isSelfClosing": false,
         "children": [
          {
             "type": 2, // NodeTypes.ELEMENT
             "content": "hello world",
             "loc": {
               "start": { "column": 20, "line": 3, "offset": 40 },
               "end": { "column": 31, "line": 3, "offset": 51 },
               "source": "hello world"
            }
          }
        ],
         "loc": {
           "start": { "column": 3, "line": 3, "offset": 23 },
           "end": { "column": 35, "line": 3, "offset": 55 },
           "source": "<p class="title">hello world</p>"
        }
      }
    ],
     "loc": {
       "start": { "column": 1, "line": 2, "offset": 1 },
       "end": { "column": 7, "line": 4, "offset": 64 },
       "source": "<div v-if="isShow">\n <p class="title">hello world</p> \n</div>"
    }
  }
],
 "helpers": [],
 "components": [],
 "directives": [],
 "hoists": [],
 "imports": [],
 "cached": 0,
 "temps": 0,
 "loc": {
   "start": { "column": 1, "line": 1, "offset": 0 },
   "end": { "column": 5, "line": 5, "offset": 69 },
   "source": "\n<div v-if="isShow">\n <p class="title">hello world</p> \n</div>\n   "
}
}

v-model原理

在原生元素上

transform之后不会生成modelValue属性,但会生成onUpdate:modelValue回调函数和vModelText自定义指令

vModelText指令在create钩子里监听input或change事件,来调用onUpdate:modelValue实现view流向vm; 在mount和beforeUpdate钩子里修改原生元素的value实现 model->view

在组件上

const model = defineModel() // 返回一个ref

首先在vue编译阶段

  1. 解析出AST

此时v-model当作一个属性名

  1. 调用transform函数处理AST

transform函数内部调用不同的函数来解析各种指令。经过transform处理AST,表示该节点的对象的属性codeGenNode 从 undefined 变成一个对象

经过transform处理后AST
经过transform处理后AST

两张图片看到经过处理后的codeGenNode对象内部。其中properties数组里每一个item都是有key和value。modelValue对应值,onUpdate:modelValue对应方法。

他们都用在后续步骤拼接成render函数。

例如: transformModel、transformIf

  1. 调用generate生成render函数

把上面两个放入组件实例里

此时v-model最终被编译成了 :modelValue属性 和 @update:modelValue事件

参考🔗

vue 工作流程

  1. 编译
    1. 通过 parse 方法进行解析,得到 AST(抽象语法树)
    2. 通过 transform 方法对 AST 进行转化,得到 JavaScript AST(大部分和AST相同)
    3. 通过 generate 方法根据 JavaScript AST 生成 render 函数
  2. 创建Vue实例
  3. 处理响应式数据
  4. 挂载阶段
    1. 调用render生成虚拟dom
    2. 生成真实dom并插入dom树指定位置
    3. 记录vnode为preVnode

至此初次渲染完成,如果改变了响应式数据导致需要更新视图则会进入更新阶段

  1. 更新阶段
    1. 重新调用render生成新虚拟dom
    2. 调用patch方法
      1. 利用diff算法比较新旧虚拟dom找到差异
      2. 根据差异更新真实dom

diff算法

本质是一个对比算法,旧DOM更新为新DOM时怎么提高效率

但是不是DOM更新了就会触发diff,而是DOM挂载、卸载或变换顺序才会触发diff

原本两个树的完全的 diff 算法是一个时间复杂度为 O(n3) , Vue 进行了优化转换成 O(n) 复杂度的问题(只比较同级不考虑跨级问题)

节点比较:首先,Vue.js会比较新旧两个虚拟DOM树的根节点。如果根节点类型不同(例如,一个是div,另一个是p),那么Vue.js会直接替换旧的根节点及其所有子节点。如果根节点类型相同,那么Vue.js会保留该节点,并进一步比较其属性和子节点。
属性比较:Vue.js会比较同一节点的新旧属性,包括样式、类名、事件监听器等。如果新旧属性不同,那么Vue.js会更新这些属性。
子节点比较:如果节点类型相同并且属性也相同,那么Vue.js会进一步比较节点的子节点。这是一个递归过程,Vue.js会继续比较子节点的节点类型、属性和子节点,直到所有节点都被比较。
列表优化:在比较子节点时,Vue.js使用了一种称为keyed diffing的优化策略。通过给每个子节点分配一个唯一的key,Vue.js可以更快地找到新旧子节点列表中相同的节点,从而避免不必要的节点创建和删除操作。

v-for key 的意义

1
2
3
<!-- 原本 item = [{id=1,title='a'},{id=2,title='b'},{id=3,title='c'}] -->
<li v-for='item in items' :key='item.id'>{{item.title}}</li>
<!-- 修改后 item = [{id=1, title='a'},{id=2, title='b'},{id=4, title='d'}] -->

此时diff算法开始工作,

首先,怎么比较?isSameVNodeTyppe(n1: VNode, n2: VNode) 方法通过比较n1和n2的typekey,如果都相等就说明没有变化,无需挂载卸载DOM元素

  • type 是AST中的type,表示节点类型
  • key 则是 v-for 中的key

上述情况可知,第三个li变了需要更新

diff 算法五大步骤

  1. sync from start:自前向后的对比

    • 把两组 dom 自前开始,相同的 dom 节点(vnode)完成对比处理
  2. sync from end:自后向前的对比

    • 把两组 dom 自后开始,相同的 dom 节点(vnode)完成对比处理
  3. common sequence + mount:新节点多于旧节点,需要挂载

  4. common sequence + unmount:旧节点多于新节点,需要卸载

  5. unknown sequence:乱序

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
//前者是一组旧DOM的key数组 后者是更新后新DOM的key数组
oldChildren= [0,1,2,3,4]
newChildren= [0,1,2,5,6,3,4]
/*
1-经过阶段1:
(0,1,2,) 3,4
(0,1,2,)5,9,3,4
此时 i = 3 代表下标,oldChildrenEnd = 4, newChildrenEnd = 6

2- 经过阶段2:
(0,1,2,) (3,4)
(0,1,2,)5,6,(3,4)
此时 i = 3 , oldChildrenEnd = 2, newChildrenEnd = 4

3- if (i > oldChildrenEnd) 进入阶段3:
while(i<=newChildrenEnd){
patch(normalize(newChildren[i])) //1. VNode挂载到DOM 2. 根据新旧VNode跟新DOM
i++
}
此时 i = 5, newChildrenEnd = 4

4- else if (i > newChildrenEnd)
不进入阶段4

5- else
不进入阶段5

阶段5是最复杂的,单独考虑一个进入阶段5的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
oldChildren= [a, b, c, d, e, f, g]
newChildren= [a, b, e, d, c, h, f, g]
...
/*
a b [c d e] f g
a b [e d c h] f g
5- 进入阶段5
根据newChildren 生成一个map (key, index) ,如下
map {<e, 2>, <d, 3>, <c, 4>,<h, 5>}
旧节点可能在newChildren里也可能不在,
如果不在就卸载
如果在 就根据map得到newIndex
再对比newIndex和oldIndex 决定要不要移动
如果要移动会生成一个最长递增子序列,便于移动
*/

总结

  1. _update
    1
    2
    3
    4
    5
    6
    7
    function _update(vnode) {
    const oldVnode = this._vnode //保存旧虚拟dom树
    this._vnode = vnode//赋值新虚拟dom树
    //对比新旧doom patch
    patch(oldVnode, this._vnode)
    //重新渲染render() 生成新真实dom树
    }