Vue原理解析

  • Post author:
  • Post category:vue




1. VUE的响应式原理

响应式的基本原理:

双向数据绑定

,就是把

Model

绑定到

View

,当我们用

JavaScript

代码更新

Model

时,

View

就会自动更新,在单向绑定的基础上,如果用户更新了

View



Model

的数据也会自动更新。

双向绑定由三个重要部分构成:

数据层(

Model

):应用数据及业务逻辑

视图层(

View

):应用的展示效果,各类UI组件

业务逻辑层(

ViewModel

):框架封装的核心,负责将数据与视图关联起来



1.1 ViewModel

作用:

  • 数据变化更新视图
  • 视图变化更新数据

它还有两个主要部分组成:

  • 监听器(

    Observer

    ):对所有数据的属性进行监听
  • 解析器(

    Compiler

    ):对每个节点的指令进行扫描跟解析,根据指令模板替换数据,以及绑定相应的更新函数



1.2 双向绑定的基本原理



JavaScript

中有两种劫持属性访问的方式:

Object.defineProperty



Proxy


  • Vue 2

    使用

    Object.defineProperty

    完全由于需支持更旧版本浏览器的限制。


  • Vue 3

    中使用了

    Proxy

    来创建响应式对象,将

    getter/setter

    用于

    ref

在这里插入图片描述

首先要对

数据(data)

进行

劫持监听

。所以需要设置一个监听器

Observer

,用来监听所有的属性。

每一个组件都有一个

Watcher

实例。如果属性发生变化,需要通知订阅者

Watcher

,看是否需要更新。因为订阅者有多个,所以需要一个消息订阅器(发布者)

Dep

(订阅者集合的管理数组)来专门收集这些订阅者,在

Observer



Watcher

之间进行统一管理。

还需要一个指令解析器

Compile

,对每个节点元素进行扫描和解析,将相关指令初始化为一个订阅者

Watcher

,并替换模板数据或绑定相应的函数,此时当订阅者

Watcher

接收到相应属性的变化,就会执行对应的更新函数,从而更新视图。

1、实现一个监听器

Observer

,用来劫持并监听所有属性,如果发生变化,就通知订阅者。

2、实现一个订阅者

Watcher

,可以收到属性的变化通知并执行相应的函数,从而更新视图。

3、实现一个解析器

Compile

,可以扫描和解析每个节点的相关指令,并据此初始化视图和订阅器Watcher。



1.3 什么是响应性

如果我们在 JavaScript 写类似的逻辑:

let A0 = 1
let A1 = 2
let A2 = A0 + A1

console.log(A2) // 3

A0 = 2
console.log(A2) // 仍然是 3

当我们更改 A0 后,A2 不会自动更新。

那么我们如何在 JavaScript 中做到这一点呢?首先,为了能重新运行计算的代码来更新 A2,我们需要将其包装为一个函数:

let A2

function update() {
  A2 = A0 + A1
}

然后,我们需要定义几个术语:

  • 这个

    update()

    函数会产生一个

    副作用

    ,或者就简称为

    作用

    ,因为它会更改程序里的状态。

  • A0



    A1

    被视为这个作用的

    依赖

    ,因为它们的值被用来执行这个作用。因此这次作用也可以说是一个它依赖的

    订阅者

我们需要一个魔法函数,能够在

A0



A1

(这两个依赖) 变化时调用

update()

(产生作用)。

whenDepsChange(update)

这个

whenDepsChange()

函数有如下的任务:

  • 当一个变量被读取时进行追踪。例如我们执行了表达式

    A0 + A1

    的计算,则

    A0



    A1

    都被读取到了。
  • 如果一个变量在当前运行的

    副作用

    中被读取了,就将该

    副作用

    设为此

    变量

    的一个

    订阅者

    。例如由于

    A0



    A1



    update()

    执行时被访问到了,则

    update()

    需要在第一次调用之后成为

    A0



    A1

    的订阅者。
  • 探测一个变量的变化。例如当我们给

    A0

    赋了一个新的值后,应该通知其所有订阅了的副作用重新执行。



1.4 Vue 中的响应性是如何工作的

我们是可以追踪一个

对象的属性

进行读和写的。



JavaScript

中有两种劫持属性访问的方式:

getter/setters



Proxies

。Vue 2 使用

getter/setters

完全由于需支持更旧版本浏览器的限制。而在

Vue 3

中使用了

Proxy

来创建响应式对象,将

getter/setter

用于

ref

。下面的伪代码将会说明它们是如何工作的:

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      track(target, key)
      return target[key]
    },
    set(target, key, value) {
      target[key] = value
      trigger(target, key)
    }
  })
}

function ref(value) {
  const refObject = {
    get value() {
      track(refObject, 'value')
      return value
    },
    set value(newValue) {
      value = newValue
      trigger(refObject, 'value')
    }
  }
  return refObject
}
  • 当你将一个响应性对象的属性解构为一个局部变量时,响应性就会“断开连接”,因为对局部变量的访问不再触发

    get / set

    代理捕获。


  • reactive()

    返回的代理尽管行为上表现得像原始对象,但我们通过使用

    ===

    运算符还是能够比较出它们的不同。



track()

内部,我们会检查当前是否有正在运行的副作用。如果有,我们会查找到一个所有追踪了该属性的订阅者,它们存储在一个

Set

中,然后将当前这个副作用添加到该

Set

中。

// 这会在一个副作用就要运行之前被设置
// 我们会在后面处理它
let activeEffect

function track(target, key) {
  if (activeEffect) {
    const effects = getSubscribersForProperty(target, key)
    effects.add(activeEffect)
  }
}

副作用订阅将被存储在一个全局的

WeakMap<target, Map<key, Set<effect>>>

数据结构中。如果在第一次追踪时没有找到对相应属性订阅的副作用集合,它将会在这里新建。这就是

getSubscribersForProperty()

函数所做的事。为了简化描述,我们跳过了它其中的细节。



trigger()

之中,我们会再查找到该属性的所有订阅副作用。但这一次我们是去调用它们:

function trigger(target, key) {
  const effects = getSubscribersForProperty(target, key)
  effects.forEach((effect) => effect())
}

现在让我们回到

whenDepsChange()

函数中:

function whenDepsChange(update) {
  const effect = () => {
    activeEffect = effect
    update()
    activeEffect = null
  }
  effect()
}

它包装了原先的

update

函数到一个副作用中,并在运行实际的更新之前,将它自己设为当前活跃的副作用。而在更新期间开启的

track()

调用,都将能定位到这个当前活跃的副作用。

此时,我们已经创建了一个能自动跟踪其依赖关系的副作用,它会在依赖关系更改时重新运行。我们称其为

响应式副作用

Vue 提供了一个 API 来让你创建响应式副作用

watchEffect()

。事实上,你会发现它的使用方式和我们上面示例中说的魔法函数

whenDepsChange()

非常相似。我们可以用真正的

Vue API

改写上面的例子:

import { ref, watchEffect } from 'vue'

const A0 = ref(0)
const A1 = ref(1)
const A2 = ref()

watchEffect(() => {
  // 追踪 A0 和 A1
  A2.value = A0.value + A1.value
})

// 将触发副作用
A0.value = 2

使用一个响应式副作用来更改一个 ref 并不是最优解,事实上使用计算属性会更直观简洁:

import { ref, computed } from 'vue'

const A0 = ref(0)
const A1 = ref(1)
const A2 = computed(() => A0.value + A1.value)

A0.value = 2

在内部,

computed

会使用响应式副作用来管理失效与重新计算的过程。

那么,常见的响应式副作用的用例是什么呢?自然是更新 DOM!我们可以像下面这样实现一个简单的“响应式渲染”:

import { ref, watchEffect } from 'vue'

const count = ref(0)

watchEffect(() => {
  document.body.innerHTML = `计数:${count.value}`
})

// 更新 DOM
count.value++

实际上,这与 Vue 组件保持状态和 DOM 同步的方式非常接近。每个组件实例创建一个响应式副作用来渲染和更新 DOM。当然,Vue 组件使用了比

innerHTML

更高效的方式来更新 DOM。这会在

渲染机制

一章中详细介绍。


ref()



computed()



watchEffect()

这些 API 都是

组合式 API

的一部分,如果你至今只使用过选项式 API,那么你需要知道的是组合式 API 更贴近 Vue 底层的响应式系统。事实上,Vue 3 中的选项式 API 正是基于组合式 API 建立的。对该组件实例 (

this

) 所有的属性访问都会触发

getter/setter

的响应式追踪,而像

watch



computed

这样的选项也是在内部调用相应等价的组合式 API。



2. Vue 渲染机制



2.1 虚拟 DOM

const vnode = {
  type: 'div',
  props: {
    id: 'hello'
  },
  children: [
    /* 更多 vnode */
  ]
}

这里所说的

vnode

即一个纯 JavaScript 的对象 (一个“虚拟节点”),它代表着一个

<div>

元素。它包含我们创建实际元素所需的所有信息。它还包含更多的子节点,这使它成为虚拟 DOM 树的根节点。

一个运行时渲染器将会遍历整个虚拟 DOM 树,并据此构建真实的 DOM 树。这个过程被称为

挂载 (mount)

如果我们有两份虚拟 DOM 树,渲染器将会有比较地遍历它们,找出它们之间的区别,并应用这其中的变化到真实的 DOM 上。这个过程被称为

修补 (patch)

,又被称为“

比较差异 (diffing)

”或“

协调 (reconciliation)

”。

虚拟 DOM 带来的主要收益是它赋予了开发者编程式地、声明式地创建、审查和组合所需 UI 结构的能力,而把直接与 DOM 相关的操作交给了渲染器。



2.2 渲染管线


  1. 编译

    :Vue 模板被编译为了

    渲染函数

    :即用来返回虚拟 DOM 树的函数。这一步骤可以通过构建步骤提前完成,也可以通过使用运行时编译器即时完成。

  2. 挂载

    :运行时渲染器调用渲染函数,遍历返回的虚拟 DOM 树,并基于它创建实际的 DOM 节点。这一步会作为

    响应式副作用

    执行,因此它会追踪其中所用到的所有响应式依赖。

  3. 修补

    :当一个依赖发生变化后,副作用会重新运行,这时候会创建一个更新后的虚拟 DOM 树。运行时渲染器遍历这棵新树,将它与旧树进行比较,然后将必要的更新应用到真实 DOM 上去。

在这里插入图片描述



2.3 带编译时信息的虚拟 DOM

虚拟 DOM 在 React 和大多数其他实现中都是纯运行时的:协调算法无法预知新的虚拟 DOM 树会是怎样,因此它总是需要遍历整棵树、比较每个 vnode 上 props 的区别来确保正确性。另外,即使一棵树的某个部分从未改变,还是会在每次重渲染时创建新的 vnode,带来了完全不必要的内存压力。这也是虚拟 DOM 最受诟病的地方之一:这种有点暴力的协调过程通过牺牲效率来换取可声明性和正确性。

但实际上我们并不需要这样。在 Vue 中,框架同时控制着编译器和运行时。这使得我们可以为紧密耦合的模板渲染器应用许多编译时优化。编译器可以静态分析模板并在生成的代码中留下标记,使得运行时尽可能地走捷径。与此同时,我们仍旧保留了边界情况时用户想要使用底层渲染函数的能力。我们称这种混合解决方案为

带编译时信息的虚拟 DOM

下面,我们将讨论一些 Vue 编译器用来提高虚拟 DOM 运行时性能的主要优化:



2.3.1 静态提升

在模板中常常有部分内容是不带任何动态绑定的:

<div>
  <div>foo</div> <!-- 需提升 -->
  <div>bar</div> <!-- 需提升 -->
  <div>{{ dynamic }}</div>
</div>


foo



bar

这两个 div 是完全静态的,没有必要在重新渲染时再次创建和比对它们。Vue 编译器自动地会提升这部分 vnode 创建函数到这个模板的渲染函数之外,并在每次渲染时都使用这份相同的 vnode,渲染器知道新旧 vnode 在这部分是完全相同的,所以会完全跳过对它们的差异比对。

此外,当有足够多连续的静态元素时,它们还会再被压缩为一个“静态 vnode”,其中包含的是这些节点相应的纯 HTML 字符串。这些静态节点会直接通过

innerHTML

来挂载。同时还会在初次挂载后缓存相应的 DOM 节点。如果这部分内容在应用中其他地方被重用,那么将会使用原生的

cloneNode()

方法来克隆新的 DOM 节点,这会非常高效。



2.3.2 修补标记 Flags

对于单个有动态绑定的元素来说,我们可以在编译时推断出大量信息:

<!-- 仅含 class 绑定 -->
<div :class="{ active }"></div>

<!-- 仅含 id 和 value 绑定 -->
<input :id="id" :value="value">

<!-- 仅含文本子节点 -->
<div>{{ dynamic }}</div>

在为这些元素生成渲染函数时,Vue 在 vnode 创建调用中直接编码了每个元素所需的更新类型:

createElementVNode("div", {
  class: _normalizeClass({ active: _ctx.active })
}, null, 2 /* CLASS */)

最后这个参数 2 就是一个修补标记 (patch flag)。一个元素可以有多个修补标记,会被合并成一个数字。运行时渲染器也将会使用位运算来检查这些标记,确定相应的更新操作:

if (vnode.patchFlag & PatchFlags.CLASS /* 2 */) {
  // 更新节点的 CSS class
}

位运算检查是非常快的。通过这样的修补标记,Vue 能够在更新带有动态绑定的元素时做最少的操作。

Vue 也为 vnode 的子节点标记了类型。举个例子,包含多个根节点的模板被表示为一个片段 (fragment),大多数情况下,我们可以确定其顺序是永远不变的,所以这部分信息就可以提供给运行时作为一个修补标记。

export function render() {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    /* children */
  ], 64 /* STABLE_FRAGMENT */))
}



2.3.3 树结构打平

再来看看上面这个例子中生成的代码,你会发现所返回的虚拟 DOM 树是经一个特殊的

createElementBlock()

调用创建的:

export function render() {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    /* children */
  ], 64 /* STABLE_FRAGMENT */))
}

这里我们引入一个概念“区块”,内部结构是稳定的一个部分可被称之为一个区块。在这个用例中,整个模板只有一个区块,因为这里没有用到任何结构性指令 (比如

v-if

或者

v-for

)。

每一个块都会追踪其所有带修补标记的后代节点 (不只是直接子节点),举个例子:

<div> <!-- root block -->
  <div>...</div>         <!-- 不会追踪 -->
  <div :id="id"></div>   <!-- 要追踪 -->
  <div>                  <!-- 不会追踪 -->
    <div>{{ bar }}</div> <!-- 要追踪 -->
  </div>
</div>

编译的结果会被打平为一个数组,仅包含所有动态的后代节点:

div (block root)
- div 带有 :id 绑定
- div 带有 {{ bar }} 绑定

当这个组件需要重渲染时,只需要遍历这个打平的树而非整棵树。这也就是我们所说的树结构打平,这大大减少了我们在虚拟 DOM 协调时需要遍历的节点数量。模板中任何的静态部分都会被高效地略过。


v-if



v-for

指令会创建新的区块节点:

<div> <!-- 根区块 -->
  <div>
    <div v-if> <!-- if 区块 -->
      ...
    <div>
  </div>
</div>

一个子区块会在父区块的动态子节点数组中被追踪,这为他们的父区块保留了一个稳定的结构。



2.3.4 对 SSR 激活的影响

修补标记和树结构打平都大大提升了 Vue

SSR 激活

的性能表现:

  • 单个元素的激活可以基于相应 vnode 的修补标记走更快的捷径。
  • 在激活时只有区块节点和其动态子节点需要被遍历,这在模板层面上实现更高效的部分激活。



版权声明:本文为fmk1023原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。