概要
Vue3框架的响应系统是其核心特性之一,文章将会简单介绍其背景,然后阐述响应系统的实现思路并给出基础版本代码。接下来会在基础版本代码之上逐渐迭代,达到完善形态。
文章的主要代码来自于《Vue.js 设计与实现》一书。但是不同的是,这里给出的代码是TypeScript版本,而非书中的JavaScript。通过TypeScript类型机制,以便实现对代码更好的理解。
背景
Vue3设计响应系统的主要作用是实现数据和视图的自动同步,能简化开发过程和提升应用的性能。具体来说有两大好处:
自动化视图更新:当响应式数据发生变化时,相关的视图会自动更新组件颗粒度级别的视图更新:精确追踪每个组件对响应式数据的依赖关系,从而只更新受影响的组件部分,避免不必要的全局重新渲染
响应式数据的基础实现
响应式数据的目标
正如在使用Vue3框架时,修改响应式数据后,视图会自动更新。基础版响应式数据的实现目标是:修改了某个对象的属性后,使用了该属性值的副作用函数会自动执行。
以下面代码为例,当修改obj.text的值后,我们希望effect函数会自动执行。
const obj = {
text: 'hello world'
}
const effect = () => {
const text = obj.text
console.log('effect1', text)
}
// 修改obj.text后,希望effect能执行
obj.text = 'foo'
实现思路
要实现上述逻辑,需要做到两件事情:
- 知道
对象的属性值对应哪些副作用函数 - 在
对象的属性值更新后,触发其对应的副作用函数的执行
通过ES6的Prxoy创建对象的代理能实现这一点:
- 当对象的
属性值被副作用函数访问,就在Prxoy的get中收集副作用函数 - 当对象的
属性值发生修改,就在Prxoy的set触发该属性收集的所有副作用函数
可以用这段简短的代码来辅助理解
// 存储副作用函数的桶
const bucket = new Set()
// 原始数据
const data = { text: 'hello world' }
// 对原始数据的代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数 effect 添加到存储副作用函数的桶中
bucket.add(effect)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 把副作用函数从桶里取出并执行
bucket.forEach(fn => fn())
}
})
// 副作用函数
function effect () {
const text = obj.text
console.log(text)
}
effect()
// 修改 obj.text 的值,会触发 effect 的执行
obj.text = 'foo'
这段代码是响应式数据的最基础实现逻辑,目前还存在两个问题:
- 代码
bucket.add(effect)是通过函数名去获取副作用函数,这种硬编码方式很不灵活。 - 存储
副作用函数的bucket,直接存入了所有副作用函数。但我们要做到对象的属性与其对应的副作用函数精细映射,否则这会导致一个属性值变化后,与该属性的无关的副作用函数也会执行。
具体实现
通过如下措施解决上述两个问题:
- 通过一个全局变量记录当前的
副作用函数,解决硬编码方式获取副作用函数的问题。 - 建立更精细的
bucket数据结构,实现对象的属性到其对应的副作用函数的映射。即建立对象映射属性,属性映射副作用函数的数据结构。细节如下:- 用
WeakMap作为bucket,WeakMap的key是对象,value是一个Map。Map的key是对象的属性,value是一个存储副作用函数的Set。 - 用
TypeScript伪代码描述bucket的数据类型:
- 用
WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
接下来是实现响应式数据的运行逻辑:
- 通过一个叫做
effect的函数来注册副作用函数,副作用函数是effect函数的参数。在effect内部,有一个全局变量activeEffect会记录传入effect的副作用函数,并且副作用函数会被调用,从而触发Prxoy的get。 Prxoy的get方法的前两个参数分别是目标对象和被获取的属性名,再加上记录当前副作用函数的变量activeEffect,就能在get方法中更新bucket的数据,从而建立对象->属性名->副作用函数的映射关系。- 当修改
Prxoy实例的属性,会触发Prxoy的set方法,该方法的前两个参数分别是目标对象和被获取的属性名,通过bucket记录的映射关系,配合前两个参数,就能获取并执行该属性对应的副作用函数。
完整代码
下面是具体实现,也是理解Vue3响应系统的关键。虽然看起来有100多行,但是核心代码不到20行:
/**
* @desc 基础版响应式数据的实现
*/
/**
* @Type 属性值为任意类型的对象
*/
interface AnyObj {
[prop: string | symbol]: any
}
/**
* @Type 副作用函数
*/
type EffectFn = () => any
/**
* 用一个WeakMap记录响应式对象的属性变化后, 需要执行的副作用函数
* 具体映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
*/
const bucket: WeakMap<AnyObj, Map<string | symbol, Set<EffectFn>>> = new WeakMap()
// 通过全局变量, 记录当前的副作用函数
let activeEffect: EffectFn | undefined
/**
* 注册副作用函数
*/
const effect = (fn: EffectFn) => {
activeEffect = fn
fn()
}
// 响应式对象的原始值
const data: AnyObj = {
ok: true,
text: 'hello world'
}
// 将原始数据转换为响应式对象
const obj = new Proxy(data, {
// 在get中收集对象属性对应的副作用函数
get(target, key) {
track(target, key)
return target[key]
},
// 在set中触发对象属性对应的副作用函数
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
return true
}
})
// Proxy的 get 中会调用 track 函数, 从而建立 对象属性 到 副作用函数 的映射
const track = (target: AnyObj, key: string | symbol) => {
/**
* 从映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
* 读取 Map<原始对象的属性, Set<该属性对应的副作用函数>>
*/
if (!activeEffect) return
let depsMap = bucket.get(target)
// 要考虑初始化
if(!depsMap) {
depsMap = new Map
bucket.set(target, depsMap)
}
/**
* 从映射关系: Map<原始对象的属性, Set<该属性对应的副作用函数>>
* 读取 Set<该属性对应的副作用函数>
*/
let deps = depsMap.get(key)
// 要考虑初始化
if (!deps) {
deps = new Set()
depsMap.set(key, deps)
}
// 将副作用函数存入 Set<该属性对应的副作用函数>
deps.add(activeEffect)
}
// Proxy的 set 中会调用 track 函数, 触发副作用函数
const trigger = (target: AnyObj, key: string | symbol) => {
/**
* 根据映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
* 执行 Set<该属性对应的副作用函数> 中收集的副作用函数
*/
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
// 注册一个的副作用函数
effect(() => {
const text = obj.text
console.log('effect1', text)
})
// 注册一个的副作用函数
effect(() => {
const text = `${obj.text} ---`
console.log('effect2', text)
})
// 修改响应式数据, 会触发obj.text对应的副作用函数
obj.text = 'foo'
分支切换与cleanup
分支切换带来的问题
我们已经实现了一个较基础的响应式数据,但存在一个缺陷:副作用函数内部可能存在条件判断逻辑,有些在原本会执行的响应式数据的属性访问,由于条件判断状态的变化,导致不再会被访问。但由于bucket已经建立了依赖关系,当修改该响应式数据的属性后,副作用函数还是会被执行,这个执行是多余的。
可以用这个例子辅助理解,当obj.ok设置为false后,obj.text不会再被读取,按照预期我们不希望修改obj.text的值后,触发该副作用函数。但是由于先前已经建立了依赖关系,会导致副作用函数被执行。
// 注册一个副作用函数
effect(() => {
// obj.ok的初始值是true,obj.text会被读取,会在bucket中建立obj.text对该副作用函数的依赖
const text = obj.ok ? obj.text : 'not'
console.log('effect1', text)
})
obj.ok = false
// obj.flag已经是false, 上述副作用函数的 三元运算 不会进入ture分支从而读取 obj.text
// 但是修改 obj.text, 副作用还是会执行, 这是不必要的
obj.text = 'foo'
解决思路
这就是分支切换带来的问题,解决方案如下:
- 除了之前
bucket建立的属性到副作用函数的映射,还需要建立副作用函数到收集依赖数据的集合的映射。换个说法:bucket的数据结构是WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>,要建立副作用函数到Set<该属性对应的副作用函数>的映射。 - 在
副作用函数执行前,先清除该副作用函数在所有收集依赖数据的集合中的存在,然后执行副作用函数,这样又能重新收集对副作用函数的依赖。
具体实现
对之前响应式数据的基础实现的代码稍加修改就能实现:
修改effect函数
- 修改
effect函数:- 在
effect内部用一个叫做effectFn的函数对副作用函数进行封装。并给effectFn添加一个叫做deps属性,其值为数组,数组的元素是收集该副作用函数的依赖集合。 - 先遍历
effectFn.deps中的依赖集合,这些依赖集合移除对effectFn的收集。这个逻辑封装在需要新增的函数cleanup中。 - 执行
effectFn,触发副作用并重新收集依赖
- 在
这么说有点绕,看代码比较好理解:
/**
* 遍历所有收集了 effectFn 的依赖集合, 在每个依赖集合中都将 effectFn 移除
*/
const cleanup = (effectFn: EffectFn) => {
for(const deps of effectFn.deps) {
deps.delete(effectFn)
}
// 重置 effectFn.deps 数组长度
effectFn.deps.length = 0
}
/**
* 注册副作用函数
*/
const effect = (fn: () => any) => {
const effectFn = (() => {
// 清除 effectFn 在所有依赖集合中的存在
cleanup(effectFn)
activeEffect = effectFn
fn()
}) as unknown as EffectFn
// effectFn.deps用来存储所有与effectFn相关联的依赖集合
effectFn.deps = [] as Set<EffectFn>[]
// 执行副作用函数
effectFn()
}
修改track函数
2.修改track函数,实现effectFn.deps对依赖集合的记录。只需在track函数最后加一行代码:
const track = (target: AnyObj, key: string | symbol) => {
/**
* 从映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
* 读取 Map<原始对象的属性, Set<该属性对应的副作用函数>>
*/
if (!activeEffect) return
let depsMap = bucket.get(target)
// 要考虑初始化
if(!depsMap) {
depsMap = new Map
bucket.set(target, depsMap)
}
/**
* 从映射关系: Map<原始对象的属性, Set<该属性对应的副作用函数>>
* 读取 Set<该属性对应的副作用函数>
*/
let deps = depsMap.get(key)
// 要考虑初始化
if (!deps) {
deps = new Set()
depsMap.set(key, deps)
}
// 将副作用函数存入 Set<该属性对应的副作用函数>
deps.add(activeEffect)
/**
* @新增代码 将deps添加到 activeEffect.deps中
*/
activeEffect.deps.push(deps)
}
新的问题:死循环
这样看来好像已经解决分支切换问题了,但是实际运行代码我们会发现程序进入死循环。问题出在 trigger函数
// Proxy的 set 中会调用 track 函数, 触发副作用函数
const trigger = (target: AnyObj, key: string | symbol) => {
/**
* 根据映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
* 执行 Set<该属性对应的副作用函数> 中收集的副作用函数
*/
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
trigger中的最后一行代码effects && effects.forEach(fn => fn())是问题所在:
forEach里面的fn就是effect函数中的effectFn,effectFn会调用cleanup清除自身在effects中的存在,但是effectFn内部接下来会执行副作用函数,导致effects又添加effectFn。- 总的来说就是:
effects中刚被清除effectFn,马上又被添加effectFn,形成了死循环。
解决死循环问题
我们通过构造一个新的Set来解决这个问题:
// Proxy的 set 中会调用 track 函数, 触发副作用函数
const trigger = (target: AnyObj, key: string | symbol) => {
/**
* 根据映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
* 执行 Set<该属性对应的副作用函数> 中收集的副作用函数
*/
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set(effects) // 新增
effectsToRun.forEach(effectFn => effectFn()) // 新增
// effects && effects.forEach(fn => fn()) 删除
}
完整代码
至此,分支切换问题解决。完整代码如下:
/**
* @Type 属性值为任意类型的对象
*/
interface AnyObj {
[prop: string | symbol]: any
}
/**
* @Type 副作用函数
*/
interface EffectFn extends Function {
// EffectFn.deps 用来存储所有与该副作用函数相关联的依赖集合
deps: Set<EffectFn>[];
}
/**
* 用一个WeakMap记录响应式对象的属性变化后, 需要执行的副作用函数
* 具体映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
*/
const bucket: WeakMap<AnyObj, Map<string | symbol, Set<EffectFn>>> = new WeakMap()
// 通过全局变量, 记录当前的副作用函数
let activeEffect: EffectFn | undefined
/**
* 注册副作用函数
*/
const effect = (fn: () => any) => {
const effectFn = (() => {
// 清除 effectFn 在所有依赖集合中的存在
cleanup(effectFn)
activeEffect = effectFn
fn()
}) as unknown as EffectFn
// effectFn.deps用来存储所有与effectFn相关联的依赖集合
effectFn.deps = [] as Set<EffectFn>[]
// 执行副作用函数
effectFn()
}
/**
* 遍历所有收集了 effectFn 的依赖集合, 在每个依赖集合中都将 effectFn 移除
*/
const cleanup = (effectFn: EffectFn) => {
for(const deps of effectFn.deps) {
deps.delete(effectFn)
}
// 重置 effectFn.deps 数组长度
effectFn.deps.length = 0
}
// 响应式对象的原始值
const data: AnyObj = {
ok: true,
text: 'hello world'
}
// 将原始数据转换为响应式对象
const obj = new Proxy(data, {
// 在get中收集对象属性对应的副作用函数
get(target, key) {
track(target, key)
return target[key]
},
// 在set中触发对象属性对应的副作用函数
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
return true
}
})
// Proxy的 get 中会调用 track 函数, 从而建立 对象属性 到 副作用函数 的映射
const track = (target: AnyObj, key: string | symbol) => {
/**
* 从映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
* 读取 Map<原始对象的属性, Set<该属性对应的副作用函数>>
*/
if (!activeEffect) return
let depsMap = bucket.get(target)
// 要考虑初始化
if(!depsMap) {
depsMap = new Map
bucket.set(target, depsMap)
}
/**
* 从映射关系: Map<原始对象的属性, Set<该属性对应的副作用函数>>
* 读取 Set<该属性对应的副作用函数>
*/
let deps = depsMap.get(key)
// 要考虑初始化
if (!deps) {
deps = new Set()
depsMap.set(key, deps)
}
// 将副作用函数存入 Set<该属性对应的副作用函数>
deps.add(activeEffect)
/**
* @新增代码 将deps添加到 activeEffect.deps中
*/
activeEffect.deps.push(deps)
}
// Proxy的 set 中会调用 track 函数, 触发副作用函数
const trigger = (target: AnyObj, key: string | symbol) => {
/**
* 根据映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
* 执行 Set<该属性对应的副作用函数> 中收集的副作用函数
*/
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set(effects) // 新增
effectsToRun.forEach(effectFn => effectFn()) // 新增
// effects && effects.forEach(fn => fn()) 删除
}
// 注册一个的副作用函数
effect(() => {
const text = obj.ok ? obj.text : 'not'
console.log('effect1', text)
})
obj.ok = false
// obj.flag已经是false, 上述副作用函数的 三元运算 不会进入ture分支从而读取 obj.text
// 修改 obj.text, 副作用不会执
obj.text = 'foo'
嵌套的effect与effect栈
背景
effect可以发生嵌套。Vue.js中组件的渲染函数render是在一个effect中执行的,组件的嵌套意味着effect的嵌套。比如一个叫Foo的组件嵌套了一个叫Bar的组件:
<Foo>
<Bar/>
</Foo>
对应的effect嵌套就是:
effect(() => {
Foo.render()
effect(() => {
Bar.render()
})
})
理解问题
用下面代码来说明effect嵌套带来的问题:
// 原始数据
const data: AnyObj = {
foo: true,
bar: true
}
// 代理对象
const obj = new Proxy(data, { /* ... */ })
let temp1
let temp2
effect(function effectFn1() {
console.log('effectFn1 执行')
effect(function EffectFn2() {
console.log('effectFn2 执行')
temp2 = obj.bar
})
temp1 = obj.foo
})
obj.foo = false
再回顾一下effect函数中的这行代码:activeEffect = effectFn
/**
* 注册副作用函数
*/
function effect (fn: () => any) {
const effectFn = (() => {
// 清除 effectFn 在所有依赖集合中的存在
cleanup(effectFn)
activeEffect = effectFn
fn()
}) as unknown as EffectFn
// effectFn.deps用来存储所有与effectFn相关联的依赖集合
effectFn.deps = [] as Set<EffectFn>[]
// 执行副作用函数
effectFn()
}
现在能看出问题了,当上面实例代码执行到temp1 = obj.foo时,变量activeEffect的值还是EffectFn2。这会导致proxy的get中,obj.foo收集的副作用函数是EffectFn2,也就是说修改obj.foo的值,会导致EffectFn2的执行。但我们的预期是让EffectFn1执行,因为temp2 = obj.bar对应的副作用函数是EffectFn1。
问题解决思路
通过一个叫effectStack的栈来维护activeEffect值的变化:
- 在
effectFn的内部,执行fn()之前,把activeEffect赋值effectFn的同时,还要把effectFn入栈effectStack - 在
effectFn的内部,在执行fn()之后,对effectStack弹出栈顶元素,这样effectStack的栈顶元素就变回调用当前effectFn的外层effectFn了。把activeEffect赋值为新的栈顶元素,这样activeEffect的值就变成了外层effectFn。
通过维护effectStack,就解决了effect嵌套导致activeEffect指向不正确的问题。
具体实现
代码中通过// 新增注释标记了改动之处:
// 使用stack维护`activeEffect`值的变化
const effectStack: EffectFn[] = [] // 新增
/**
* 注册副作用函数
*/
const effect = (fn: () => any) => {
const effectFn = (() => {
// 清除 effectFn 在所有依赖集合中的存在
cleanup(effectFn)
activeEffect = effectFn
// 执行 fn() 之前,把 effectFn 入栈 effectStack
effectStack.push(effectFn) // 新增
fn()
// 执行 fn() 之后,将当前 effectFn 出栈,这样栈顶元素就变成了调用当前effectFn的上层effectFn
effectStack.pop() // 新增
activeEffect = effectStack[effectStack.length - 1] // 新增
}) as unknown as EffectFn
// effectFn.deps用来存储所有与effectFn相关联的依赖集合
effectFn.deps = [] as Set<EffectFn>[]
// 执行副作用函数
effectFn()
}
避免无限递归循环
理解问题
如果副作用函数中存在对同一个响应式对象的属性的读取与赋值,就会发生无限递归循环。
比如这个例子:
const data = {
foo: 1
}
const obj = new Proxy(data, { /* ... */ })
effect(function effectFn1() {
obj.foo = obj.foo + 1
})
对obj.foo的读取会触发track,接着对obj.foo的赋值会触发trigger,这就导致effectFn1无限递归调用自己,产生函数的调用栈溢出。
解决问题
这个问题很好解决:如果trigger中执行的副作用函数与activeEffect相同,就不执行。代码如下:
const trigger = (target: AnyObj, key: string | symbol) => {
/**
* 根据映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
* 执行 Set<该属性对应的副作用函数> 中收集的副作用函数
*/
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set(effects)
effectsToRun.forEach(effectFn => {
// 如果 trigger 中执行的 副作用函数 与 activeEffect相同,就不执行
if(effectFn !== activeEffect) { // 新增
effectFn()
}
})
}
调度执行
可调度性是响应式系统的关键特性,可调度指的是在tigger触发副作用函数重新执行时,有能力决定副作用函数的执行时机、次数以及方式。
举例说明
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })
effect(() => {
console.log(obj.foo)
})
obj.foo++
console.log('end')
这段代码的输出顺序是
1
2
'end'
如果我们希望改变打印顺序为1 'end' 2,并且不调整代码。这就需要响应系统支持调度,我们希望这样使用调度:
const data = {foo: 1}
const obj = new Proxy(data, { /* ... */})
effect(
() => {
console.log(obj.foo)
},
// options 用于配置effect的选项
{
// 指定调度器的实现
scheduler(fn) {
setTimeout(fn)
}
}
)
obj.foo++
console.log('end')
给effect增加第二个参数,用于设置effect的选项,其中scheduler配置项就是调度器。在这个例子中,调度器通过setTimeout实现了obj.foo++的异步打印,从而让打印顺序变为1 'end' 2。
设计调度器
明确用法后,实现方案也很明显了:
- 给
effect函数增加一个options参数,类型是对象,表示配置项 - 在
effect中,把options挂载到副作用函数上,即:effctFn.options = options - 在
trigger中,执行副作用函数时,判断一下这个副作用函数有没有调度器,即effctFn?.options?.scheduler
具体实现
修改effect和trigger
/**
* 注册副作用函数
*/
const effect = (fn: () => any, options: EffectOptions = {}) => { // 新增代码:添加 options 参数
const effectFn = (() => {
// 清除 effectFn 在所有依赖集合中的存在
cleanup(effectFn)
activeEffect = effectFn
// 执行 fn() 之前,把 effectFn 入栈 effectStack
effectStack.push(effectFn)
fn()
// 执行 fn() 之后,将当前 effectFn 出栈,这样栈顶元素就变成了调用当前effectFn的上层effectFn
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}) as unknown as EffectFn
//
effectFn.options = options
// effectFn.deps用来存储所有与effectFn相关联的依赖集合
effectFn.deps = [] as Set<EffectFn>[]
// 执行副作用函数
effectFn()
}
// Proxy的 set 中会调用 track 函数, 触发副作用函数
const trigger = (target: AnyObj, key: string | symbol) => {
/**
* 根据映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
* 执行 Set<该属性对应的副作用函数> 中收集的副作用函数
*/
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set(effects)
effectsToRun.forEach(effectFn => {
// 新增:如果 副作用函数存在调度器,就通过调度器执行副作用函数
if(effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
完整代码
/**
* @desc 基础版响应式数据的实现
*/
/**
* @Type 属性值为任意类型的对象
*/
interface AnyObj {
[prop: string | symbol]: any
}
// effect函数的配置项参数
interface EffectOptions {
// 调度器
scheduler?: (effectFn: EffectFn) => any
}
/**
* @Type 副作用函数
*/
interface EffectFn extends Function {
// EffectFn.deps 用来存储所有与该副作用函数相关联的依赖集合
deps: Set<EffectFn>[];
options: EffectOptions
}
/**
* 用一个WeakMap记录响应式对象的属性变化后, 需要执行的副作用函数
* 具体映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
*/
const bucket: WeakMap<AnyObj, Map<string | symbol, Set<EffectFn>>> = new WeakMap()
// 通过全局变量, 记录当前的副作用函数
let activeEffect: EffectFn | undefined
// 使用stack维护`activeEffect`值的变化
const effectStack: EffectFn[] = []
/**
* 注册副作用函数
*/
const effect = (fn: () => any, options: EffectOptions = {}) => { // 新增代码:添加 options 参数
const effectFn = (() => {
// 清除 effectFn 在所有依赖集合中的存在
cleanup(effectFn)
activeEffect = effectFn
// 执行 fn() 之前,把 effectFn 入栈 effectStack
effectStack.push(effectFn)
fn()
// 执行 fn() 之后,将当前 effectFn 出栈,这样栈顶元素就变成了调用当前effectFn的上层effectFn
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}) as unknown as EffectFn
//
effectFn.options = options
// effectFn.deps用来存储所有与effectFn相关联的依赖集合
effectFn.deps = [] as Set<EffectFn>[]
// 执行副作用函数
effectFn()
}
/**
* 遍历所有收集了 effectFn 的依赖集合, 在每个依赖集合中都将 effectFn 移除
*/
const cleanup = (effectFn: EffectFn) => {
for(const deps of effectFn.deps) {
deps.delete(effectFn)
}
// 重置 effectFn.deps 数组长度
effectFn.deps.length = 0
}
// 原始值
const data: AnyObj = {foo: 1}
// 将原始数据转换为响应式对象
const obj = new Proxy(data, {
// 在get中收集对象属性对应的副作用函数
get(target, key) {
track(target, key)
return target[key]
},
// 在set中触发对象属性对应的副作用函数
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
return true
}
})
// Proxy的 get 中会调用 track 函数, 从而建立 对象属性 到 副作用函数 的映射
const track = (target: AnyObj, key: string | symbol) => {
/**
* 从映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
* 读取 Map<原始对象的属性, Set<该属性对应的副作用函数>>
*/
if (!activeEffect) return
let depsMap = bucket.get(target)
// 要考虑初始化
if(!depsMap) {
depsMap = new Map
bucket.set(target, depsMap)
}
/**
* 从映射关系: Map<原始对象的属性, Set<该属性对应的副作用函数>>
* 读取 Set<该属性对应的副作用函数>
*/
let deps = depsMap.get(key)
// 要考虑初始化
if (!deps) {
deps = new Set()
depsMap.set(key, deps)
}
// 将副作用函数存入 Set<该属性对应的副作用函数>
deps.add(activeEffect)
// 将deps添加到 activeEffect.deps中
activeEffect.deps.push(deps)
}
// Proxy的 set 中会调用 track 函数, 触发副作用函数
const trigger = (target: AnyObj, key: string | symbol) => {
/**
* 根据映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
* 执行 Set<该属性对应的副作用函数> 中收集的副作用函数
*/
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set(effects)
effectsToRun.forEach(effectFn => {
// 新增:如果 副作用函数存在调度器,就通过调度器执行副作用函数
if(effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
effect(
() => {
console.log(obj.foo)
},
// options 用于配置effect的选项
{
// 指定调度器的实现
scheduler(fn) {
setTimeout(fn)
}
}
)
obj.foo++
console.log('end')
调度的意义
Vue3 调度执行机制的主要目的是优化组件更新的性能和响应速度。具体包括以下几个关键目的:
- 避免重复更新:合并多次状态变化,确保组件只进行必要的更新,减少不必要的重复渲染。
- 批量更新:在同一个事件循环内的多次状态变化,不会立即触发更新,而是等所有变化完成后,再进行统一的批量更新,减少 DOM 操作频率。
- 优先级调度:为不同的更新任务设置优先级,确保高优先级任务优先执行,提升用户体验的流畅性。
使用调度避免重复更新
如果一个状态多次更新,我们希望只执行最后一次的副作用函数。以下面代码为例:
const data = {foo: 1}
const obj = new Proxy(data, { /* ... */ })
effect(() => {
console.log(obj.foo)
})
obj.foo++
obj.foo++
obj.foo会执行两次自增,打印结果是:
1
2
3
我们不关心过渡状态,打印2是多余的副作用函数执行,我们期望的打印是:
1
3
为了实现这一目标,基于调度器,利用微任务就能解决,代码如下:
// 定义一个任务队列
const jobQueue:Set<EffectFn> = new Set()
// 利用 Promise.resolve().then 可以将任务添加到微任务队列
const p = Promise.resolve()
// 一个标志代表是否正在刷新队列
let isFlushing = false
// 刷新任务队列
function flushJob() {
// 如果队列正在刷新,则什么也不做
if(isFlushing) return
// 设置未true,表示正在刷新
isFlushing = true
// 在微任务中刷新 jobQueue 队列
p.then(() => {
jobQueue.forEach(job => job())
}).finally(() => {
// 结束后重置 isFlushing
isFlushing = false
})
}
effect(
() => {
console.log(obj.foo)
},
{
scheduler(fn: EffectFn) {
// 每次调度时,将副作用函数添加到jobQueue
jobQueue.add(fn)
// 刷新任务队列
flushJob()
}
}
)
obj.foo++
obj.foo++
computed 与 lazy
给effect函数加上lazy功能
这里要实现的目标是,给effect的options参数添加一个lazy配置项,如果lazy为true,effect函数会不直接调用副作用函数,而是返回副作用函数。代码如下:
const effect = <T>(fn: () => T, options: EffectOptions = {}) => {
const effectFn = (() => {
// 清除 effectFn 在所有依赖集合中的存在
cleanup(effectFn)
activeEffect = effectFn
// 执行 fn() 之前,把 effectFn 入栈 effectStack
effectStack.push(effectFn)
const res = fn() // @新增
// fn() 删除
// 执行 fn() 之后,将当前 effectFn 出栈,这样栈顶元素就变成了调用当前effectFn的上层effectFn
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
return res // @新增
}) as unknown as EffectFn
effectFn.options = options
// effectFn.deps用来存储所有与effectFn相关联的依赖集合
effectFn.deps = [] as Set<EffectFn>[]
// 如果 lazy 为 false, 返回 执行 effectFn()
// 如果 lazy 为 true, 返回 effectFn
if(!options.lazy) { // @新增
// 执行副作用函数
effectFn()
} else {
// 返回 effectFn
return effectFn
}
}
值得注意的是,这里不仅增加了lazy对应的if条件语句,effectFn也做了修改:effectFn是对原初副作用函数fn的包装,effectFn需要返回原初副作用函数的返回值。
把lazy视作getter
通过lazy,可以把effect函数的返回视作一个getter,这个getter可以返回一些有趣的内容:
// effect 返回的 effectFn 可以视为一个 getter
const effectFn = effect(
() => obj.foo + obj.bar,
{
lazy: true
}
)
// 获取 getter 的返回值
const value = effectFn()
利用lazy实现computed
利用lazy和对象的get属性访问器,就实现了一个基本的computed
function computed<T>(getter: (...args: any[] ) => T): {value: T} {
const effectFn = effect(getter, { lazy: true })
const obj = {
get value() {
return effectFn() as T
}
}
return obj
}
const data = { foo: 1, bar: 2}
const obj = new Proxy(data, { /* ... */ })
const sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value) // 3
接着通过一个dirty标识来实现computed的对结果缓存功能
function computed<T>(getter: (...args: any[] ) => T): {value: T} {
// 缓存上一次计算的结果
let value: T // @新增
// 使用dirty来标记是否需要重新计算值, true意味"脏",需要重新计算
let dirty = true // @新增
const effectFn = effect(getter, { lazy: true })
const obj = {
// @重写get
get value() {
// "脏"时需要重新计算结果并缓存
if(dirty) {
// 缓存结果到dirty
value = effectFn() as T
// dirty 设置为 false,下一次访问可以直接使用 value 的缓存值
dirty = false
}
return value
}
}
return obj
}
const data = { foo: 1, bar: 2}
const obj = new Proxy(data, { /* ... */ })
const sumRes = computed(() => {
console.log('运行计算')
return obj.foo + obj.bar
})
console.log(sumRes.value) // 3
// 多亏computed的缓存功能,多次读取 sumRes.value,也不会发生重复计算
console.log(sumRes.value) // 3
然而上面的代码有一个问题,考虑这种情况:当obj.foo自增后,sumRes.value应该要变成4,但实际打印输出来是3。
const data = { foo: 1, bar: 2}
const obj = new Proxy(data, { /* ... */ })
const sumRes = computed(() => {
console.log('运行计算')
return obj.foo + obj.bar
})
console.log(sumRes.value) // 3
obj.foo++
console.log(sumRes.value) // 打印出来依旧为3,预期是4
问题出在obj.foo自增后,dirty的值没有正确重置为true,通过在scheduler中重置dirty解决这个问题:
function computed<T>(getter: (...args: any[] ) => T): {value: T} {
// 缓存上一次计算的结果
let value: T
// 使用dirty来标记是否需要重新计算值, true意味"脏",需要重新计算
let dirty = true
const effectFn = effect(getter, {
lazy: true,
// 添加调度器 ,在调度器中重置 dirty 为 true
scheduler() {
dirty = true
}
})
const obj = {
get value() {
// "脏"时需要重新计算结果并缓存
if(dirty) {
// 缓存结果到dirty
value = effectFn() as T
// dirty 设置为 false,下一次访问可以直接使用 value 的缓存值
dirty = false
}
return value
}
}
return obj
}
对computed的设计,到这里已经快完善了,但还有一个缺陷:如果有另一个effect调用了计算属性的值,当计算属性的值发生变化,effect中的副作用函数不会重新执行。以这段代码为例:
const sumRes = computed(() => {
return obj.foo + obj.bar
})
effect(() => {
// 在副作函数中读取计算属性的值
console.log(sumRes.value)
})
// 修改obj.foo不会导致副作用函数中的console重新执行
obj.foo++
这个问题的本质两个effect的嵌套。但是内层的effect设置了lazy。有两个原因造成了这个问题:
- 如果是两个没设置
lazy为true的effect发生嵌套,内层effect的副作用函数执行,不会强行伴随外层的effect的副作用函数执行。可以类比这种情况:子组件视图更新,父组件视图不一定要更新。但是计算的情况不太一样,计算属性的值(在上面的例子中对应sumRes.value)会被外层effect的副作用函数使用,而sumRes本身只是一个普通对象,不会收集外层effect的副作用函数。 - 当
计算属性的值发生变化时,由于lazy的原因,如果不主动读取计算属性的值,是不会触发副作用函数的。
所以我们通过手动调用track和trigger来解决这两个问题:
- 在
get属性访问器中调用track收集外层effect的副作用函数的依赖 - 在
scheduler中调用trigger,触发外层effect的副作用函数的执行
function computed<T>(getter: (...args: any[] ) => T): {value: T} {
// 缓存上一次计算的结果
let value: T //
// 使用dirty来标记是否需要重新计算值, true意味"脏",需要重新计算
let dirty = true //
const effectFn = effect(getter, {
lazy: true,
// 添加调度器 ,在调度器中重置 dirty 为 true
scheduler() {
if(!dirty) {
dirty = true
// 计算属性发生变化时,手动调用trigger触发响应
trigger(obj, 'value') // @新增
}
}
})
const obj = {
get value() {
// "脏"时需要重新计算结果并缓存
if(dirty) {
// 缓存结果到dirty
value = effectFn() as T
// dirty 设置为 false,下一次访问可以直接使用 value 的缓存值
dirty = false
}
// 当读取 value 时,手动调用 track 函数进行跟踪
track(obj, 'value') // @新增
return value
}
}
return obj
}
完整代码
这是解决了上述问题之后,实现computed的完整代码
/**
* @Type 属性值为任意类型的对象
*/
interface AnyObj {
[prop: string | symbol]: any
}
// effect函数的配置项参数
interface EffectOptions {
// 调度器
scheduler?: (effectFn: EffectFn) => any
lazy?: boolean
}
/**
* @Type 副作用函数
*/
interface EffectFn extends Function {
// EffectFn.deps 用来存储所有与该副作用函数相关联的依赖集合
deps: Set<EffectFn>[];
options: EffectOptions
}
/**
* effect的返回类型
* 如果 EffectOptions中的 lazy 是 true,返回类型就是EffectFn,否则是 undefined
*/
type EffectReturn<K extends EffectOptions> = K["lazy"] extends true ? EffectFn : undefined;
/**
* 用一个WeakMap记录响应式对象的属性变化后, 需要执行的副作用函数
* 具体映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
*/
const bucket: WeakMap<AnyObj, Map<string | symbol, Set<EffectFn>>> = new WeakMap()
// 通过全局变量, 记录当前的副作用函数
let activeEffect: EffectFn | undefined
// 使用stack维护`activeEffect`值的变化
const effectStack: EffectFn[] = []
/**
* 注册副作用函数
*/
const effect = <T, K extends EffectOptions>(
fn: () => T, options: Partial<K> = {}
): EffectReturn<K> => {
const effectFn = (() => {
// 清除 effectFn 在所有依赖集合中的存在
cleanup(effectFn)
activeEffect = effectFn
// 执行 fn() 之前,把 effectFn 入栈 effectStack
effectStack.push(effectFn)
const res = fn() //
// fn() @删除
// 执行 fn() 之后,将当前 effectFn 出栈,这样栈顶元素就变成了调用当前effectFn的上层effectFn
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
return res
}) as unknown as EffectFn
effectFn.options = options
// effectFn.deps用来存储所有与effectFn相关联的依赖集合
effectFn.deps = [] as Set<EffectFn>[]
// 如果 lazy 为 false, 返回 执行 effectFn()
// 如果 lazy 为 true, 返回 effectFn
if(!options.lazy) {
// 执行副作用函数
effectFn()
return undefined as EffectReturn<K>;
} else {
// 返回 effectFn
return effectFn as EffectReturn<K>
}
}
/**
* 遍历所有收集了 effectFn 的依赖集合, 在每个依赖集合中都将 effectFn 移除
*/
const cleanup = (effectFn: EffectFn) => {
for(const deps of effectFn.deps) {
deps.delete(effectFn)
}
// 重置 effectFn.deps 数组长度
effectFn.deps.length = 0
}
// 原始值
const data: AnyObj = { foo: 1, bar: 2}
// 将原始数据转换为响应式对象
const obj = new Proxy(data, {
// 在get中收集对象属性对应的副作用函数
get(target, key) {
track(target, key)
return target[key]
},
// 在set中触发对象属性对应的副作用函数
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
return true
}
})
// Proxy的 get 中会调用 track 函数, 从而建立 对象属性 到 副作用函数 的映射
const track = (target: AnyObj, key: string | symbol) => {
/**
* 从映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
* 读取 Map<原始对象的属性, Set<该属性对应的副作用函数>>
*/
if (!activeEffect) return
let depsMap = bucket.get(target)
// 要考虑初始化
if(!depsMap) {
depsMap = new Map
bucket.set(target, depsMap)
}
/**
* 从映射关系: Map<原始对象的属性, Set<该属性对应的副作用函数>>
* 读取 Set<该属性对应的副作用函数>
*/
let deps = depsMap.get(key)
// 要考虑初始化
if (!deps) {
deps = new Set()
depsMap.set(key, deps)
}
// 将副作用函数存入 Set<该属性对应的副作用函数>
deps.add(activeEffect)
// 将deps添加到 activeEffect.deps中
activeEffect.deps.push(deps)
}
// Proxy的 set 中会调用 track 函数, 触发副作用函数
const trigger = (target: AnyObj, key: string | symbol) => {
/**
* 根据映射关系: WeakMap<原始对象, Map<原始对象的属性, Set<该属性对应的副作用函数>>>
* 执行 Set<该属性对应的副作用函数> 中收集的副作用函数
*/
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set(effects)
effectsToRun.forEach(effectFn => {
// 如果 副作用函数 存在 调度器,就通过调度器执行副作用函数
if(effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
// 定义一个任务队列
const jobQueue:Set<EffectFn> = new Set()
// 利用 Promise.resolve().then 可以将任务添加到微任务队列
const p = Promise.resolve()
// 一个标志代表是否正在刷新队列
let isFlushing = false
// 刷新任务队列
function flushJob() {
// 如果队列正在刷新,则什么也不做
if(isFlushing) return
// 设置未true,表示正在刷新
isFlushing = true
// 在微任务中刷新 jobQueue 队列
p.then(() => {
jobQueue.forEach(job => job())
}).finally(() => {
// 结束后重置 isFlushing
isFlushing = false
})
}
function computed<T>(getter: (...args: any[] ) => T): {value: T} {
// 缓存上一次计算的结果
let value: T
// 使用dirty来标记是否需要重新计算值, true意味"脏",需要重新计算
let dirty = true //
const effectFn = effect(getter, {
lazy: true,
// 添加调度器 ,在调度器中重置 dirty 为 true
scheduler() {
if(!dirty) {
dirty = true
// 计算属性发生变化时,手动调用trigger触发响应
trigger(obj, 'value')
}
}
})
const obj = {
get value() {
// "脏"时需要重新计算结果并缓存
if(dirty) {
// 缓存结果到dirty
value = effectFn() as T
// dirty 设置为 false,下一次访问可以直接使用 value 的缓存值
dirty = false
}
// 当读取 value 时,手动调用 track 函数进行跟踪
track(obj, 'value')
return value
}
}
return obj
}
const sumRes = computed(() => {
return obj.foo + obj.bar
})
effect(() => {
// 在副作函数中读取计算属性的值
console.log(sumRes.value)
})
// 会打印出预期结果 4
obj.foo++
watch的实现原理
初步实现
watch的本质就是观测一个数据,数据发生变化时,通知并执行相应回调函数
这是有两个使用watch的例子,他们的区别在于第一个参数的不同:
- watch第一个参数是
() => obj.foo的情况,watch会监听obj.foo的变化 - watch第一个参数是obj的情况,watch会深度监听obj的所有属性值
watch(
() => obj.foo,
(oldValue, newValue) => {
console.log(oldValue, newValue)
}
)
watch(
obj,
(oldValue, newValue) => {
console.log(oldValue, newValue)
}
)
watch的实现,直接看代码就能理解了:
// 封装一个能遍历对象属性的函数
function traverse(value: any, seen = new Set()) {
// 如果要读取的数据是原始值或者已经被读取过,那么什么都不用做
if(typeof value !== 'object' || value === null || seen.has(value)) return
// 标记数据的读取状态,避免循环引用导致的死循环
seen.add(value)
// 先简单假设value是一个对象
for (const k in value) {
traverse(value[k], seen)
}
return value
}
function watch(source: any, cb: Function) {
let getter: Function
if(typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
// 定义旧值和新值
let oldValue: any
let newValue: any
// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到effectFn 中,以便后续调用
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler() {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
}
)
// 手动调用副作用函数,effectFn()返回的就是旧值
oldValue = effectFn()
}
这里有两个关键点:
traverse函数:递归遍历对象属性,确保它们被响应式系统跟踪,同时还要注意循环引用问题。watch函数做了两件事情:- 处理
source:如果是函数,直接使用;如果是对象,使用traverse函数进行深度遍历。 - 注册一个
lazy副作用函数,并在scheduler中维护oldValue和newValue
- 处理
更完善的实现
上面的watch实现是最基本的形态,其实还有立即执行watch、解决watch的竞态问题需要实现,暂时先到这里。
后续
到这里响应系统的实现已经初步完成了,但实际的实现比这复杂的多,比如拦截和追踪for in循环、对数组的代理、深响应、浅响应,都是要解决的问题。