# 响应式数据与副作用函数
响应系统也是 Vue.js 的重要组成部分。
# 响应式数据与副作用函数
# 副作用函数
副作用函数指的是会产生副作用的函数
//直接或者间接的影响其他函数的执行结果
function effect() {
document.body.innerText = 'hello vue3'
}
当 effect
函数执行时,它会设置 body 的文本内容,但除了 effect
函数之外的任何函数都可以读取或设置 body
的文本内容。
// 全局变量
let val = 1
function effect() {
val = 2 // 修改全局变量,产生副作用
}
一个函数修改了全局变量,这就是一个副作用。
# 响应式数据
响应式数据指的是数据变化时,依赖它的函数都会自动更新。
const obj = {
text: 'hello world'
}
function effect() {
// effect 函数的执行会读取 obj.text
document.body.innerText = obj.text
}
obj.text = 'hello vue3' // 修改 obj.text 的值,同时希望副作用函数会重新执行
# 响应式数据的基本实现
- 当副作用函数 effect 执行时,会触发字段 obj.text 的读取操作;
- 当修改 obj.text 的值时,会触发字段 obj.text 的设置操作
当读取字段 obj.text 时,我们可以把副作用函数 effect 存储到一个“桶”里
接着,当设置 obj.text 时,再把副作用函数 effect 从“桶”里取出并执行即可

# 实现响应式数据
在 ES2015 之前,只能通过 Object.defineProperty 函数实现,这也是 Vue.js 2 所采用的方式。在 ES2015+ 中,我们可以使用代理对象 Proxy 来实现,这也是 Vue.js 3 所采用的方式。
// 存储副作用函数的桶
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())
// 返回 true 代表设置操作成功
return true
}
})
// 副作用函数
function effect() {
document.body.innerText = obj.text
}
// 执行副作用函数,触发读取
effect()
// 1 秒后修改响应式数据
setTimeout(() => {
obj.text = 'hello vue3'
}, 1000)
上述只是实现了最基本的响应式数据,实际上还有很多细节需要考虑,简单的实现了响应式数据的基本实现和工作原理。
# 设计一个完善的响应式系统
# 修改副作用函数的封装
effect 函数接收一个参数 fn,即要注册的副作用函数,这样可以用一个函数来注册多个副作用函数。
// 存储副作用函数的桶
const bucket = new Set()
// 原始数据
const data = {
text: 'hello world'
}
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}
const obj = new Proxy(data, {
get(target, key) {
// 将 activeEffect 中存储的副作用函数收集到“桶”中
if (activeEffect) { // 新增
bucket.add(activeEffect) // 新增
} // 新增
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
bucket.forEach(fn => fn())
return true
}
})
effect(
// 一个匿名的副作用函数
() => {
document.body.innerText = obj.text
}
)
setTimeout(() => {
obj.text2 = 'hello vue3'
}, 1000)
上述代码中,如果在 setTimeout
中修改了其他的变量值,在匿名函数中并没有读取到,所以不应该触发副作用函数的执行。但是实际上,却执行了,这是不正确的。因为我们并没有在副作用函数和属性之间建立联系。
# 不同属性之间和副作用函数的联系
在副作用函数与被操作的字段之间建立联系即可,这就需要我们重新设计“桶”的数据结构
effect(function effectFn() {
document.body.innerText = obj.text
})
// 被操作(读取)的代理对象 obj;
// 被操作(读取)的字段名 text;
// 使用 effect 函数注册的副作用函数 effectFn。
那么三者的联系应该如下关系
target
└── key
└── effectFn
如果有两个副作用函数同时读取同一个对象的属性值:
effect(function effectFn1() {
obj.text
})
effect(function effectFn2() {
obj.text
})
target
└── text
└── effectFn1
└── effectFn2
如果一个副作用函数中读取了同一个对象的两个不同属性:
effect(function effectFn() {
obj.text1
obj.text2
})
target
└── text1
└── effectFn
└── text2
└── effectFn
如果在不同的副作用函数中读取了两个不同对象的不同属性:
effect(function effectFn1() {
obj1.text1
})
effect(function effectFn2() {
obj2.text2
})
target1
└── text1
└── effectFn1
target2
└── text2
└── effectFn2
用树型架构建立起来之后,单个字段之前更新,副作用函数不会被重复执行。
# 实现响应式系统的完整流程
需要更改 Set
为WeakMap
(opens new window),以便存储多个副作用函数。
// 存储副作用函数的桶
const bucket = new WeakMap()
// 原始数据
const data = {
text: 'hello world'
}
// 对原始数据的代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 没有 activeEffect,直接 return
if (!activeEffect) return target[key]
// 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key -->effects
let depsMap = bucket.get(target)
//如果不存在 depsMap,则创建一个新的 Map 并与 target 建立联系
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型,
// 里面存储着所有与当前 key 相关联的副作用函数:effects
let deps = depsMap.get(key)
// 如果 deps 不存在,同样新建一个 Set 并与 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())
}
})
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}
effect(() => {
console.log('effect run')
document.body.innerText = obj.text
})
setTimeout(() => {
obj.text = 'hello vue3'
}, 1000)
构建数据结构的方式,我们分别使用了WeakMap、Map 和 Set
- WeakMap 由 target --> Map 构成
- Map 由 key --> Set 构成。
WeakMap 的键是原始对象 target,WeakMap 的值是一个Map 实例,而 Map 的键是原始对象 target 的 key,Map 的值是一个由副作用函数组成的 Set
采用 WeakMap
的原因,WeakMap 对 key 是弱引用,不影响垃圾回收器的工作。据这个特性可知,一旦 key 被垃圾回收器回收,那么对应的键和值就访问不到了
const map = new Map();
const weakmap = new WeakMap();
(function() {
const foo = {
foo: 1
};
const bar = {
bar: 2
};
map.set(foo, 1);
weakmap.set(bar, 2);
})()
最后把 get
和 set
方法做一些封装处理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, key)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 把副作用函数从桶里取出并执行
trigger(target, key)
}
})
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
// 没有 activeEffect,直接 return
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)
}
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
# 分支切换与 cleanup
# 分支切换的问题
在 effectFn 函数内部存在一个三元表达式,根据字段 obj.ok值的不同会执行不同的代码分支。当字段 obj.ok 的值发生变化时,代码执行的分支会跟着变化,这就是所谓的分支切换。
const data = {
ok: true,
text: 'hello world'
}
const obj = new Proxy(data, {
/* ... */
})
effect(function effectFn() {
document.body.innerText = obj.ok ? obj.text : 'not'
})
分支切换的副作用函数如下
data
└── ok
└── effectFn
└── text
└── effectFn
- 如果
obj.ok
的值从true
变为false
,那么obj.text
的值应该就不会被读取。 - 如果再修改
obj.text
的值,effectFn
应该不会再被执行。但是实际上,effectFn
还是会被执行。
# 断开副作用函数与响应式数据之间的关系
我们需要做到每次副作用函数执行前,将其从相关联的依赖集合中移除。
修改 effect
函数, 在内部定义 effectFn
函数,并添加一个 effectFn.deps
到依赖集合中。该属性是数组,用来存储所有包含该副作用函数的依赖项
// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
function track(target, key) {
// 没有 activeEffect,直接 return
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 中
deps.add(activeEffect)
// deps 就是一个与当前副作用函数存在联系的依赖集合
// 将其添加到 activeEffect.deps 数组中
activeEffect.deps.push(deps) // 新增
}
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
// effects && effects.forEach(fn => fn()) // 问题出在这句代码 这里会导致重复执行
const effectsToRun = new Set(effects) // 新增
effectsToRun.forEach(effectFn => effectFn()) // 新增
}
function cleanup(effectFn) {
// 遍历 effectFn.deps 数组
for (let i = 0; i < effectFn.deps.length; i++) {
// deps 是依赖集合
const deps = effectFn.deps[i]
// 将 effectFn 从依赖集合中移除
deps.delete(effectFn)
}
// 最后需要重置 effectFn.deps 数组
effectFn.deps.length = 0
}
effect(() => {
console.log('effect run')
document.body.innerText = obj.ok ? obj.text : 'not'
})
# 嵌套的 effect 与 effect 栈
嵌套的 effect 指的是一个 effect 函数内部调用了另一个 effect 函数。
effect(function effectFn1() {
effect(function effectFn2() {
/* ... */
})
/* ... */
})
当组件发生嵌套时,例如 Foo 组件渲染了 Bar 组件
// Bar 组件
const Bar = {
render() {
/* ... */
},
}
// Foo 组件渲染了 Bar 组件
const Foo = {
render() {
return <Bar / > // jsx 语法
},
}
//相当于
effect(() => {
Foo.render()
// 嵌套
effect(() => {
Bar.render()
})
})
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffect = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
当前的逻辑是不支持嵌套的,用全局变量 activeEffect 来存储通过 effect 函数注册的副作用函数,这意味着同一时刻 activeEffect 所存储的副作用函数只能有一个。
为了解决这个问题,我们需要一个副作用函数栈 effectStack,在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让 activeEffect 指向栈顶的副作用函数。
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
// effect 栈
const effectStack = [] // 新增
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn) // 新增
fn()
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把还原为之前的值activeEffect
effectStack.pop() // 新增
activeEffect = effectStack[effectStack.length - 1] // 新增
}
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
# 避免无限递归循环
const data = {
foo: 1
}
const obj = new Proxy(data, {
/*...*/
})
effect(() => obj.foo++)
// Uncaught RangeError: Maximum call stack size exceeded
在这个语句中,既会读取 obj.foo 的值,又会设置 obj.foo 的值, 在执行中,还没执行完毕,就开始下一次的执行。这样就会导致无限递归循环,最终导致栈溢出。
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) { // 新增
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => effectFn())
// effects && effects.forEach(effectFn => effectFn())
}
# 调度执行
当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式
const data = {
foo: 1
}
const obj = new Proxy(data, {
/* ... */
})
effect(() => {
console.log(obj.foo)
})
obj.foo++
console.log('结束了')
// 输出为
// 1
// 2
// 结束了
//需要改动为
// 1
// 结束了
// 2
# 控制执行的时机
effect 函数注册副作用函数时,可以传递第二个参数 options。它是一个对象,其中允许指定scheduler 调度函数,同时在 effect 函数内部我们需要把options 选项挂载到对应的副作用函数上
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压栈
effectStack.push(effectFn)
fn()
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把
activeEffect 还原为之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// 将 options 挂载到 effectFn 上
effectFn.options = options // 新增
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => {
// 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
if (effectFn.options.scheduler) { // 新增
effectFn.options.scheduler(effectFn) // 新增
} else {
// 否则直接执行副作用函数(之前的默认行为)
effectFn() // 新增
}
})
}
const data = {
foo: 1
}
const obj = new Proxy(data, {
/* ... */
})
effect(
() => {
console.log(obj.foo)
},
// options
{
// 调度器 scheduler 是一个函数
scheduler(fn) {
// 将副作用函数放到宏任务队列中执行
setTimeout(fn)
}
}
)
obj.foo++
console.log('结束了')
使用调度器就可以控制副作用函数的执行时机。
# 控制执行的次数
const data = {
foo: 1
}
const obj = new Proxy(data, {
/* ... */
})
effect(() => {
console.log(obj.foo)
})
obj.foo++
obj.foo++
//输出
// 1
// 2
// 3
上述输出2只是一个过渡状态,我们期望最终结果是3,因为副作用函数只执行一次。
// 定义一个任务队列
const jobQueue = new Set()
// 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务添加到微任务队列
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) {
// 每次调度时,将副作用函数添加到 jobQueue 队列中
jobQueue.add(fn)
// 调用 flushJob 刷新队列
flushJob()
}
})
obj.foo++
obj.foo++
使用微任务调度器,将副作用函数添加到 jobQueue 队列中,执行 flushJob 函数, 该函数通过开关 isFlushing 控制队列的刷新,确保副作用函数只执行一次。
Vue.js 中连续多次修改响应式数据但只会触发一次更新,实际上 Vue.js 内部实现了一个更加完善的调度器,思路与上文介绍的相同。
← 设计思路