从零实现Vue响应系统(一、概念与基础架构)

213 阅读20分钟

在 Vue.js 中,响应系统贯穿了这款框架的设计始终。如果说 React 是遵循 单向数据流 的框架,Vue 则通过 响应式数据绑定 实现视图与数据的自动同步。那么 Vue3 中的响应式系统究竟是怎么实现的呢?我很好奇!

注:本文是 Vue 设计与实现 一书的阅读笔记,包括原文的部分摘抄以及本人的总结,尽可能用自己的理解描述一遍,并附上完整的实现代码。

本文中的完整代码地址:github.com/nonhana/dem…

响应式数据与副作用函数

副作用函数

当我们在接触 React 或者是一些函数式编程概念的时候,我们或多或少都有听过一个名词:副作用。而副作用函数也就是那些会产生副作用的函数。

那么什么是副作用?

function effect() {
  document.body.textContent = 'Hello World'
}

对于这个函数而言,它的作用是 设置 body 的文本内容,但是 除了 effect 以外的任何函数都可以读取或设置 body 的文本内容。所以这个函数的执行很可能会 直接或间接地影响其他函数的执行。这个时候我们就会说 effect 函数产生了副作用。

因此,副作用函数的本质就是 这个函数内部进行的操作会影响到外部某些事物的变化,而这些外部的事物能够被其他的函数使用。

更直观的例子:

let val = 1

function effect() {
  val = 2
}

function effect2() {
  console.log(val + 1)
}

如果我们不执行 effect,那么最后会打印 2;如果执行了 effect,最后会打印 3。

而影响打印结果的并不是最终进行打印的 effect2 函数,那么我们就可以说 effect 函数产生了副作用,effect2 函数就是受到了副作用的影响。如果我们不关注具体代码中在哪里调用了 effect 函数而只关注 effect2,那么我们就会发现输出的值是 不可预测的

为了做对比,我们也来探讨一下在函数式编程领域最重要的一个概念:纯函数

纯函数是指:

  1. 给定相同的输入,永远会得到相同的输出。这意味着函数的输出仅仅依赖于它的输入参数,而与外部的状态或变量无关。无论函数被调用多少次,只要输入相同,输出也一定相同。
  2. 没有副作用。副作用指的是函数内部改变了外部世界的状态,或者对外部环境(如全局变量、I/O 操作等)产生影响。纯函数不会修改外部的变量或状态,也不会与外部环境产生任何交互(例如,打印到控制台、写入文件、修改全局变量等)。

举个例子:

// 纯函数
function add(a, b) {
  return a + b
}

console.log(add(2, 3)) // 输出 5
console.log(add(2, 3)) // 每次调用返回的结果相同

这就是一个标准的简单纯函数,它不修改任何外部状态,也不依赖任何外部的全局变量。

响应式数据

响应式数据简单来说,就是这个数据能够 响应 其发生的变化。当数据变了的时候,会 自动的触发某些操作

const obj = { text: 'Hello world!' }

function effect() {
  document.body.textContent = obj.text
}

我们假设这个 obj 已经是一个响应式数据,那么我们期望 obj 里面这个 text 属性变了的时候能够重新触发这个 effect 函数,然后重新设置这个 textContent 属性。

响应式数据的基本实现

在刚才的描述中,我们能够发现,响应式数据和副作用函数 是两个相互依赖的关系。

  • 当副作用函数 effect 执行时,会触发 obj.text读取 操作;
  • 当修改 obj.text 的值时,会触发 obj.text设置 操作。

那么,我们只要能够拦截到一个对象的 读取设置 操作,我们就能够在这些操作上面去做手脚了。

当对某个字段属性进行读取时,我们可以把对应的副作用函数存储到一个 bucket 里面,在设置字段属性时我们把副作用函数从 bucket 中取出并重新执行就可以了。

而在 Vue3 中,拦截一个对象属性的读取和设置操作我们都知道是通过 Proxy 来实现的。

可以简单的使用 JavaScript 来进行初步实现:

// 存储副作用函数的 bucket,这里采用 Set 来做
const bucket = new Set()

// 原始数据
const data = { text: 'Hello world!' }

// 对应的副作用函数
function effect() {
  document.body.textContent = obj.text
}

// 对原始数据使用 Proxy 进行代理
const obj = new Proxy(data, {
  // get 表示拦截读取操作
  get(target, key) {
    bucket.add(effect) // 往 bucket 里面添加副作用函数
    return target[key] // 返回属性值,相当于是 get 的默认操作
  },
  set(target, key, newVal) {
    target[key] = newVal // 设置属性值,相当于是 set 的默认操作
    bucket.forEach(fn => fn()) // 从 bucket 取出函数并执行
    return true // 必须返回一个 boolean 来确认是否操作成功
  },
})

effect()

setTimeout(() => {
  obj.text = 'Hello non_hana!'
}, 2000)

responsive-1

当然,上面这段代码只是最简单的根据固定的对象和函数进行的实现,这种 硬编码 的方式是我们平时在编写代码时应当全力避免的。

那么,如何设计一个完善的响应式系统呢?在 Vue.js 中,一个完善的响应式系统主要需要考虑以下几个因素:

设计一个完善的响应式系统

如何注册函数

我们都已经知道了,响应系统的一般工作流程如下:

  1. 发生 读取 操作时,将副作用函数收集到 bucket 里面
  2. 发生 设置 操作时,从 bucket 里面取出副作用函数,然后依次执行

在上面我们刚刚实现的响应式系统,我们的副作用函数直接就是 effect 命名的,注册也用的这个名字,这肯定不行,我们必须要把 所有依赖于某个属性的函数全部放到 bucket 里面才可以,不管他是不是叫 effect 还是匿名函数。

所以,我们必须要想办法去实现一个机制,这个机制 专门用来把依赖于某个对象的某个属性的副作用函数收集到 bucket 里面,我们也可以直接称其为 注册 机制。

我们用 JavaScript 来实现一下:

let activeEffect

function effect(fn) {
  activeEffect = fn
  fn()
}

我们用这个 activeEffect 专门来存那些需要被收集的副作用函数,而需要被收集的副作用函数需要通过 effect 来进行注册。effect 直接接受一个函数,所以不管你取什么名字都可以。

而对应的,在执行 fn 的时候会触发 get,这个时候需要收集的不是 effect 而是 activeEffect

// get 表示拦截读取操作
get(target, key) {
  // 如果 activeEffect 有值,说明是通过 effect 函数进行主动注册的
  if (activeEffect) {
    bucket.add(activeEffect)
  }
  return target[key] // 返回属性值,相当于是 get 的默认操作
},

怎么使用?

effect(() => {
  document.body.textContent = obj.text
})

这样子算是解决了 如何注册函数 的问题。

响应式系统的数据结构

但是,我们稍微测试一下就可以发现新问题。比如,我们在 obj 这个 Proxy 上面设置一个不存在的属性的时候:

effect(() => {
  console.log('effect run')
  document.body.textContent = obj.text
})

setTimeout(() => {
  obj.notExist = 'non_hana'
}, 1000)

image-20241222134414953

可以看到,执行了两次这个匿名副作用函数,但是我们的 notExist 属性实际上是不存在的,所以这个属性不应该建立响应联系。

不过这个问题肯定一堆人都看出来了,因为 Proxy 本身就是拦截 某个对象 的操作,并没有细化到 某个对象的某个属性 的操作。所以当对 obj.notExist 进行操作时,肯定会触发 set 操作,然后重新执行,没啥好奇怪的。

所以我们需要 重新设计数据结构,将副作用函数的收集细化到某个对象的某个属性。

我们原本是使用 set,直接把函数往里面一扔一拿就完事了,但是这是不行的。

我们可以看一下我们现在注册副作用函数的代码:

effect(() => {
  document.body.textContent = obj.text
})

这个函数主要包含了三个部分:

  1. 被操作 & 读取的代理对象 obj
  2. 被操作 & 读取的字段名 text
  3. 副作用函数 effectFn

我们可以根据这三层,来构建一个 树形结构。为什么是树形结构?因为一个代理对象可能有多个属性值,一个属性值又可能有多个副作用函数,所以很容易就联想到树形结构的枝桠。

回到上面的问题,我们只需要将副作用函数和代理对象的关系约束到属性层面,就可以解决问题了。为了适应我们的树形结构,我们将数据结构也改为两层。用 TypeScript 来描述一下:

const bucket = new WeakMap<object, Map<string | symbol, Set<() => void>>>()

最外层是 WeakMap,其值为一个 Map,这个 Map 保存着对象属性和副作用函数的映射。副作用函数列表本身则保存在 Set 中,需要通过指定的对象和属性方可取出。

为什么是 WeakMap 而不是 Map 呢?

  1. WeakMap 本身的 key 只能存储 object 类型
  2. WeakMap 对于 key 是弱引用,一旦对应 object 的表达式执行完毕,就会将其从内存中移除,不影响垃圾回收器的回收行为。因此特别适合 想要临时在某个对象上挂载数据 的行为。

image-20241222140858178

基于最新的数据结构,我们来改写一下 Proxy 部分的代码:

const obj = new Proxy(data, {
  get(target, key) {
    if (!activeEffect)
      return target[key]
    let depsMap = bucket.get(target)
    if (!depsMap) {
      bucket.set(target, (depsMap = new Map()))
    }
    let deps = depsMap.get(key)
    if (!deps) {
      depsMap.set(key, (deps = new Set()))
    }
    deps.add(activeEffect)

    return target[key]
  },
  set(target, key, newVal) {
    target[key] = newVal
    const depsMap = bucket.get(target)
    if (!depsMap)
      return
    const effects = depsMap.get(key)
    effects && effects.forEach(fn => fn())
  },
})

代码改写的条理还是很清晰的。

get 部分先根据 target 这个原始对象拿到这个对象内部的属性和副作用函数的映射 Map,如果没有就 new 一个。同理,再根据 key 来拿到副作用函数的 Set,没有就 new 一个,然后往这个 Set 里面把新函数塞进去。

set 部分也是分层拿,没拿到就直接返回,如果拿到了就一个个拿出来执行就行。

可以再进行一层封装:

const obj = new Proxy(data, {
  get(target, key) {
    track(target, key)
    return target[key]
  },
  set(target, key, newVal) {
    target[key] = newVal
    trigger(target, key)
  },
})

// 追踪依赖
function track(target, key) {
  if (!activeEffect)
    return
  let depsMap = bucket.get(target)
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  let deps = depsMap.get(key)
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  deps.add(activeEffect)
}

// 触发副作用函数的重新执行
function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap)
    return
  const effects = depsMap.get(key)
  effects && effects.forEach(fn => fn())
}

这样子拆代码能够减少耦合性,使得各个部分更加的各司其职。

分支切换

这不是 git checkout 那个分支切换,主要指的是在表达式中 根据条件执行不同的代码。我们可以看一下下面:

const data = { ok: true, text: 'Hello world!' }
const obj = new Proxy({ /* ... */ })
effect(() => {
  document.body.textContent = obj.ok ? obj.text : 'not'
})

这里用了个三元表达式,根据 obj.ok 的值来切换执行不同的代码分支。

**分支切换可能产生遗留的副作用函数。**在上面,需要首先读 obj.ok 才能确定接下来要读 obj.text,然后再读 obj.text,触发了 obj 两个属性的读取操作,所以相当于是给 obj 的两个属性都加上了副作用函数的依赖。

image-20241222145430582

那么,我们把 obj.ok 设置为 false,此时 obj.text 就不会被读取了。理想情况下的依赖收集情况应该是这样:

image-20241222145549359

但是现在很显然是做不到的,切换 obj.ok 后也只能保持第一种的收集方式,那么多出来的那个副作用收集,也就是 obj.text 对应的那个副作用函数收集就是 遗留的

**遗留的副作用函数会导致不必要的更新。**那么怎么解决这个问题呢?

我们可以先想想副作用函数在执行的时候会发生什么?答案是 副作用函数执行的时候会读取某个对象的某个属性的内容。而读取的时候会发生什么?会把这个副作用函数给放到对应 obj 对应 key 的 Set 里面。也就是说,即便副作用函数被注册了,在对某个响应式对象的某个属性进行 set 操作的时候,还是会照样触发,照样走一遍注册的流程。而因为是 Set,Set 保留了这个函数的引用,因此塞不进去了。

那么很简单了,既然每次执行副作用函数都会进行一次注册的操作,那我在执行这个副作用函数之前,先把这个副作用函数从所有依赖的 Set 里面 filter 掉不就可以了?反正你还得重新注册,我全删了你重新来一遍就行。 这巧妙的解决了分支切换的问题,因为在重新执行之前,一些分支可能切换了,导致这个副作用函数读的属性可能发生了变化,那么我重新执行之前清理掉冗余的副作用函数,用全新的空依赖去注册,就行了。

为了实现这个东东,我们需要 明确的知道哪些依赖集合中包括这个副作用函数,所以得重新设计一下这个副作用函数,给这个副作用函数加一个 deps 属性来存所有包含这个副作用函数的依赖集合。相当于 Map 是存了属性和副作用函数 Set,而副作用函数也存一个 数组 来反向标记哪些依赖,是一个双向的过程

好,我们开始改写:

let activeEffect

function effect(fn) {
  const effectFn = () => {
    activeEffect = fn // 原来的逻辑
    fn() // 原来的逻辑
  }
  effectFn.deps = [] // 函数也是对象哦
  effectFn()
}

很简单吧,把原来的逻辑单独拆到一个函数里面,函数也是个对象,我们可以直接挂一个 deps 的属性,然后执行这个函数就行了。

接下来我们着重处理这个 deps 数组的 依赖收集依赖收集这个词,终于出现了!

在哪里收集呢?在 track 函数里面。

// 追踪依赖
function track(target, key) {
  if (!activeEffect)
    return
  let depsMap = bucket.get(target)
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  let deps = depsMap.get(key) // Set<() => void>
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  deps.add(activeEffect)

  activeEffect.deps.push(deps)
}

可以看到,在最后一步将这个 target 的这个 key 的所有副作用函数 Set 当作依赖项放入 deps 里面,建立了如下的关系:

image-20241222151924644

明确了这个关系之后,我们可以在每次副作用函数执行之前,根据当前这个副作用函数的所有依赖项的副作用函数 Set 来移除它:

let activeEffectFn // 临时存需要被注册的副作用函数

// 副作用函数注册函数
function effect(effectFn) {
  const fn = () => {
    cleanup(effectFn)
    activeEffectFn = effectFn
    effectFn()
  }
  effectFn.deps = [] // 函数也是对象哦
  fn()
}

function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i] // deps 是 Set<() => void>[] 类型
    deps.delete(effectFn) // 从每个 Set 里面把这个副作用函数删掉
  }
  effectFn.deps.length = 0
}

注意,为啥能这样做,因为 JS 中对于一个对象默认都是用 引用 的方式进行保存的。所以可以直接遍历 effectFn.deps 数组,取出 Set,然后删掉要执行的副作用函数。

这样子能够避免分支切换导致的副作用函数遗留。

不过还是有点问题,现在其实会无限循环运行。可以看一下我们目前的 trigger 函数:

// 触发副作用函数的重新执行
function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap)
    return
  const effects = depsMap.get(key)
  effects && effects.forEach(fn => fn())
}

现在的 trigger 函数遍历 effects 集合,集合中的每个副作用函数执行的时候会调用 cleanup 进行清除,实际上就是从 当前 effects 集合 中将当前执行的副作用函数剔除。但是 副作用函数的执行会导致其重新被收集到这个 effects 集合当中,而此时的 forEach 还没遍历完呢,所以会导致一直卡在同一个副作用函数的执行上走不动了。

所以,我们需要在执行的时候,用一个新的集合进行遍历,确保正在遍历的这个集合不是原本的集合,就行了。

// 触发副作用函数的重新执行
function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap)
    return
  const effects = depsMap.get(key)

  const effectsToRun = new Set(effects)
  effectsToRun.forEach(fn => fn())
}

这样子,我们的分支切换算是解决了。

嵌套的 effect 与 effects 栈

在实际的代码编写中,effect 是有可能会发生嵌套的,比如我们有时候会这样子写代码:

effect(() => {
  effect(() => {
    // ...
  })
  // ...
})

这个场景其实很常见。我们知道 Vue 是可以组件套组件的,Vue 的组件经过编译之后就是一个普通的渲染函数,而这个渲染函数中肯定有一些响应式变量,这个渲染函数就是副作用函数。那么,我们在一个父组件里面套一个子组件,这个时候的渲染函数执行情况就是类似于上面的情况。

所以 effect 必须要设计成可嵌套的。

而我们上面的实现其实并不支持 effect 嵌套。比如这样写:

// 原始数据
const data = { name: 'non_hana', age: 16 }

// 对原始数据使用 Proxy 进行代理
const obj = new Proxy(data, {
  // ...
})

let temp1, temp2

effect(() => {
  console.log('effectFn1 被执行')
  effect(() => {
    console.log('effectFn2 被执行')
    temp2 = obj.age
  })
  temp1 = obj.name
})

setTimeout(() => {
  obj.name = 'hana'
}, 1000)

如果这么写了,我们想要的结果应该是,修改 obj.name 的值之后,会触发外层函数的执行从而间接触发内层函数的执行;而修改 obj.age 的值之后,只会触发内层函数的执行。但是很明显,结果并没有按我们的期望输出:

image-20241223114211636

前两行是初始化数据,但是到后面我们发现居然内层的执行了,外层的没执行。

不过我们稍微想想,其实能够找到原因。为什么 触发内层的副作用函数呢? 外层的哪里去了? 答案是 被内层的给覆盖了

我们上面的代码,只使用了一个 activeEffect 变量来存需要注册的副作用函数,而且它是一个全局变量,那也就意味着在同一时刻 只能存一个副作用函数。而嵌套 effect 的写法,相当于在第一次初始化的时候,activeEffect 先是被赋值为外层的副作用函数,然后里面又有个 effect,这个 effect 在执行的时候又有个内层的副作用函数,这个时候又重新走了一遍注册副作用函数的流程,所以内层的就会直接把外层的副作用函数给覆盖了。

所以简单来说,我们目前的代码 只能够注册嵌套 effect 中最内层的副作用函数 。而根据我们改完后的依赖收集数据结构,Map 里面的 key 倒都是能够正常一个个注册的,但是由于副作用函数被最内层的覆盖了,所以 每个 key 对应的副作用函数 Set 都会是同一个。所以你无论改了什么属性的内容,都只会触发最内层的副作用函数。举一个更更更直观的例子:

let temp1, temp2, temp3, temp4, temp5

effect(() => {
  console.log('effectFn1 被执行')
  effect(() => {
    console.log('effectFn2 被执行')
    effect(() => {
      console.log('effectFn3 被执行')
      effect(() => {
        console.log('effectFn4 被执行')
        effect(() => {
          console.log('effectFn5 被执行')
          temp5 = obj.arr3
        })
        temp4 = obj.arr2
      })
      temp3 = obj.arr1
    })
    temp2 = obj.age
  })
  temp1 = obj.name
})

setTimeout(() => {
  obj.name = 'hana'
}, 1000)

image-20241223115721721

所以怎么解决呢?核心问题是现在的方案会导致内层的把外层的副作用函数给 覆盖 并且 无法复原。为了能够保留以前的副作用函数,我们需要用一个 来存这些副作用函数。

当副作用函数执行时,把当前的副作用函数压入栈中 ,待其执行完毕后从栈中弹出,而 activeEffect 始终指向栈顶的副作用函数。这样子能够实现 响应式数据只会收集直接读取它值的副作用函数 而不会相互影响。

所以我们可以改代码,加一个 effectFnStack

let activeEffectFn // 临时存需要被注册的副作用函数
const effectFnStack = [] // 副作用函数栈,为了解决嵌套 effect 的问题

// 副作用函数注册函数
function effect(effectFn) {
  const fn = () => {
    cleanup(effectFn)
    activeEffectFn = effectFn
    effectFnStack.push(effectFn) // 在调用之前先把这个副作用函数压入栈
    effectFn()
    effectFnStack.pop() // 在调用之后再把这个副作用函数弹出栈
    activeEffectFn = effectFnStack[effectFnStack.length - 1] // 恢复上一个副作用函数
  }
  effectFn.deps = [] // 函数也是对象哦
  fn()
}

我们改完后可以重新模拟一下嵌套 effect 被调用的过程。拿这个例子来:

effect(() => {
  console.log('effectFn1 被执行')
  effect(() => {
    console.log('effectFn2 被执行')
    temp2 = obj.age
  })
  temp1 = obj.name
})

setTimeout(() => {
  obj.name = 'hana'
}, 1000)

首先,传入的参数 effectFn 是这个函数:

function fn1() {
  console.log('effectFn1 被执行')
  effect(() => {
    console.log('effectFn2 被执行')
    temp2 = obj.age
  })
  temp1 = obj.name
}
  • activeEffectFn 就等于这个 fn1,然后将 fn1 压入 effectFnStack,然后执行 fn1

    此时的 effectFnStack

    索引
    0fn1
  • 执行 fn1 的时候,遇到了第二个 effect。这个 effect 包含的副作用函数是:

    function fn2() {
      console.log('effectFn2 被执行')
      temp2 = obj.age
    }
    

    然后,activeEffectFn 就被 覆盖了。没错,还是会被覆盖的!不过我们之后利用栈能够拿回原来的!

    然后将 fn2 压入 effectFnStack,此时的栈为:

    索引
    0fn1
    1fn2

    压完之后,执行副作用函数。它 读取了 obj.age,触发了我们的 track 函数,实现了依赖追踪,把 fn2obj.age 建立了联系。现在的 Map 里面,存的是 age 和只包含 fn2Set

  • fn2 执行完之后,栈把 fn2 给弹出来,只剩下 fn1,然后把现在是栈顶的 fn1 又重新赋值给 activeEffect

  • 之后,我们才走到 temp1 = obj.name 这一行,读取 obj.name,然后建立起映射。

    所以现在的 Map 映射如下:

    KeyValue
    agefn2
    namefn1

    而我们知道,fn1 实际上是包含了 fn2 的:

    function fn1() {
      console.log('effectFn1 被执行')
      effect(fn2)
      temp1 = obj.name
    }
    

    所以现在我们修改 obj.name 的值,就会从上到下依次重新触发 fn1fn2 的内部流程。

    但是,fn1 重新触发的时候还是会执行 effect 函数啊?这有没有问题啊?

    没问题,即使重新执行了一遍,也只是把内部的嵌套函数注册流程又走了一遍,每次执行的时候都会 cleanup 然后再 push 到 Set 里面。不过重复执行 effect,确实有点浪费性能啊!

至此,嵌套 effect 的问题我们算是解决了。

避免无限递归循环

我们看一个例子:

effect(() => {
  obj.count++
})

这个简单的副作用函数里面写了个自增操作。我们知道自增操作相当于是:

effect(() => {
  obj.count = obj.count + 1
})

既会读取 obj.count 的值,又会设置 obj.foo 的值 。在执行这个代码的时候,首先是读 obj.count 触发 track 函数来收集依赖,把这个函数本身放到 Set 里面。

放好了之后,就是赋值语句,触发了 trigger 操作,会重新拿出 Set 里面的函数执行。但问题是这个 Set 刚刚被塞进来了你自己 ,所以拿出你自己执行,又会重新走一遍上面的流程,导致 Set 被无限的推进你自己,然后又无限的执行无数个你自己,导致了栈溢出。

这个问题的核心在于 读取和设置操作是在同一个副作用函数里面进行的。所以我们可以加一个条件,如果 trigger 触发执行的副作用函数和当前正在执行的副作用函数( activeEffect )相同,就不触发执行

我们改一下代码:

// 触发副作用函数的重新执行
function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap)
    return
  const effects = depsMap.get(key)

  const effectsToRun = new Set()
  effect
  && effects.forEach((effectFn) => {
    if (effectFn !== activeEffectFn) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => effectFn())
}

这样就可以把 副作用函数数据源副作用函数执行栈 给拆了开来,因此能够确保正常执行。