# 响应式数据与副作用函数
响应系统也是 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 内部实现了一个更加完善的调度器,思路与上文介绍的相同。
# 计算属性 computed 与 lazy
指定
scheduler调度器来控制副作用函数的执行时机和方式;用track函数追踪和收集依赖,用trigger函数触发更新 来实现Vue.js中的计算属性computed。
# 懒执行
在有些场景下,我们并不希望它立即执行,而是希望它在需要的时候才执行,例如计算属性。
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
// 将 fn 的执行结果存储到 res 中
const res = fn() // 新增
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
// 将 res 作为 effectFn 的返回值
return res // 新增
}
effectFn.options = options
effectFn.deps = []
//如果指定了lazy选项,则不立即执行
if (!options.lazy) {
effectFn()
}
// 将副作用函数作为返回值返回
return effectFn
}
function computed(getter) {
// 把 getter 作为副作用函数,创建一个 lazy 的 effect
const effectFn = effect(getter, {
lazy: true
})
const obj = {
// 当读取 value 时才执行 effectFn
get value() {
return effectFn()
}
}
return obj
}
定义一个 computed 函数,它接收一个 getter 函数作为参数,我们把 getter 函数作为副作用函数,用它创建一个 lazy的 effect。computed 函数的执行会返回一个对象,该对象的value 属性是一个访问器属性,只有当读取 value 的值时,才会执行effectFn 并将其结果作为返回值返回。
# 值缓存
计算属性的值缓存,可以避免不必要的重新计算。
上面的代码多次访问时,都会重复执行 effectFn ,即使值没有变化,这会导致不必要的计算
function computed(getter) {
// value 用来缓存上一次计算的值
let value
// dirty 标志,用来标识是否需要重新计算值,为 true 则意味着“脏”,需要计算
let dirty = true
const effectFn = effect(getter, {
lazy: true
})
const obj = {
get value() {
// 只有“脏”时才计算值,并将得到的值缓存到 value 中
if (dirty) {
value = effectFn()
// 将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值
dirty = false
}
return value
}
}
return obj
}
上述逻辑只会在第一次访问时计算值,之后的访问直接使用缓存的值,并将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值。如果修改了依赖的值,我们需要将 dirty 设置为 true,下一次访问时重新计算值。
function computed(getter) {
let value
let dirty = true
const effectFn = effect(getter, {
lazy: true,
// 添加调度器,在调度器中将 dirty 重置为 true
scheduler() {
dirty = true //这里将在重置依赖值时取消缓存
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
return value
}
}
return obj
}
# computed 返回的值 与 依赖项
const sumRes = computed(() => obj.foo + obj.bar)
effect(() => {
// 在该副作用函数中读取 sumRes.value
console.log(sumRes.value)
})
// 修改 obj.foo 的值
obj.foo++
上述代码修改了
obj.foo的值,但sumRes.value的副作用函数并没有重新执行。
当读取计算属性的值时,我们可以手动调用 track 函数进行追踪;当计算属性依赖的响应式数据发生变化时,我们可以手动调用 trigger 函数触发响应
function computed(getter) {
let value
let dirty = true
const effectFn = effect(getter, {
lazy: true,
scheduler() {
if (!dirty) {
dirty = true
// 当计算属性依赖的响应式数据变化时,手动调用 trigger 函数触发响应
trigger(obj, 'value')
}
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
// 当读取 value 时,手动调用 track 函数进行追踪
track(obj, 'value')
return value
}
}
return obj
}
建立这样的关系
computed(obj)
└── value
└── effectFn
# watch 的实现原理
观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。
watch(obj, () => {
console.log('数据变了')
})
// 修改响应数据的值,会导致回调函数执行
obj.foo++
** watch 的本质其实是对 effect 的二次封装以及 options.scheduler 。**
effect(() => {
console.log(obj.foo)
}, {
scheduler() {
// 当 obj.foo 的值变化时,会执行 scheduler 调度函数
}
})
const data = {
foo: 1
}
const obj = new Proxy(data, {
/* ... */
})
watch(obj, () => {
console.log('数据变化了')
})
obj.foo++
上述代码针对于 obj.foo 的读取操作,只能监听 obj.foo 的值变化,而不能监听 obj 的其他属性的变化,所以需要对 obj 进行递归遍历。
function watch(source, cb) {
effect(
// 调用 traverse 递归地读取
() => traverse(source), {
scheduler() {
// 当数据变化时,调用回调函数 cb
cb()
}
}
)
}
//使用for in 可以 触发 对象属性 getter 的 getter 函数
function traverse(value, seen = new Set()) {
// 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
if (typeof value !== 'object' || value === null ||
seen.has(value)) return
// 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
seen.add(value)
// 暂时不考虑数组等其他结构
// 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
for (const k in value) {
traverse(value[k], seen)
}
return value
}
** watch 函数监听响应式数据之外,还可以监听一个 getter 函数。**
function watch(source, cb) {
// 定义 getter
let getter
// 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getter
if (typeof source === 'function') {
getter = source
} else {
// 否则按照原来的实现调用 traverse 递归地读取
getter = () => traverse(source)
}
effect(
// 执行 getter
() => getter(), {
scheduler() {
cb()
}
}
)
}
** watch 函数获取新值与旧值**
watch(
() => obj.foo,
(newValue, oldValue) => {
console.log(newValue, oldValue) // 2, 1
}
)
obj.foo++
function watch(source, cb) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
// 定义旧值与新值
let oldValue, newValue
// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到effectFn 中以便后续手动调用
const effectFn = effect(
() => getter(), {
lazy: true,
scheduler() {
// 在 scheduler 中重新执行副作用函数,得到的是新值
newValue = effectFn()
// 将旧值和新值作为回调函数的参数
cb(newValue, oldValue)
// 更新旧值,不然下一次会得到错误的旧值
oldValue = newValue
}
}
)
// 手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn()
}
# 立即执行的 watch 与回调执行时机
用 immediate 来指定回调是否需要立即执行
当
immediate选项存在并且为 true 时,回调函数会在该watch创建时立刻执行一次
watch(obj, () => {
console.log('变化了')
}, {
// 回调函数会在 watch 创建时立即执行一次
immediate: true
})
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
// 提取 scheduler 调度函数为一个独立的 job 函数
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
const effectFn = effect(
// 执行 getter
() => getter(), {
lazy: true,
// 使用 job 函数作为调度器函数
scheduler: job
}
)
if (options.immediate) {
// 当 immediate 为 true 时立即执行 job,从而触发回调执行
job()
} else {
oldValue = effectFn()
}
}
指定回调函数的执行时机,例如在 Vue.js 3 中使用 flush 选项来指定
当 flush 的值为 'post' 时,代表调度函数需要将副作用函数放到一个微任务队列中,并等待 DOM 更新结束后再执行
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
const effectFn = effect(
// 执行 getter
() => getter(), {
lazy: true,
scheduler: () => {
// 在调度函数中判断 flush 是否为 'post',如果是,将其放到微任务队列中执行
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)
if (options.immediate) {
job()
} else {
oldValue = effectFn()
}
}
# 过期的副作用
watch监听的响应式数据发生多次变化,会触发多次副作用函数的执行,如果是异步的副作用函数,可能会导致副作用函数的执行时机不确定。
let finalData
watch(obj, async () => {
// 发送并等待网络请求
const res = await fetch('/path/to/request')
// 将请求结果赋值给 data
finalData = res
})
上述代码中, watch 监听 obj 的变化,并在副作用函数中发送网络请求,并等待请求结果,赋值给 finalData 。如果多次修改 obj 的值,赋值的数据就不能确定是那次的值,因为副作用函数的执行时机不确定。
第一次修改 obj 对象的某个字段 值,这会导致回调函数执行,同时发送了第一次请求 A。随着时间的 推移,在请求 A 的结果返回之前,我们对 obj 对象的某个字段值进行 了第二次修改,这会导致发送第二次请求 B。此时请求 A 和请求 B 都 在进行中,有可能B的结果先返回,但是A返回后又会覆盖掉 B 的结果。
watch(obj, async (newValue, oldValue, onInvalidate) => {
// 定义一个标志,代表当前副作用函数是否过期,默认为 false,代表没有过期
let expired = false
// 调用 onInvalidate() 函数注册一个过期回调
onInvalidate(() => {
// 当过期时,将 expired 设置为 true
expired = true
})
// 发送网络请求
const res = await fetch('/path/to/request')
// 只有当该副作用函数的执行没有过期时,才会执行后续操作。
if (!expired) {
finalData = res
}
})
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
// cleanup 用来存储用户注册的过期回调
let cleanup
// 定义 onInvalidate 函数
function onInvalidate(fn) {
// 将过期回调存储到 cleanup 中
cleanup = fn
}
const job = () => {
newValue = effectFn()
// 在调用回调函数 cb 之前,先调用过期回调
if (cleanup) {
cleanup()
}
// 将 onInvalidate 作为回调函数的第三个参数,以便用户使用
cb(newValue, oldValue, onInvalidate)
oldValue = newValue
}
const effectFn = effect(
// 执行 getter
() => getter(), {
lazy: true,
scheduler: () => {
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)
if (options.immediate) {
job()
} else {
oldValue = effectFn()
}
}
逻辑的关键在 job 函数,每次执行回调 cb 之前,先检查是否存在过期回调。
# 完整的代码
// 存储副作用函数的桶
const bucket = new WeakMap()
// 原始数据
const data = {
foo: 1,
bar: 2
}
// 对原始数据的代理
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)
}
})
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)
activeEffect.deps.push(deps)
}
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()
}
})
// effects && effects.forEach(effectFn => effectFn())
}
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
// effect 栈
const effectStack = []
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
activeEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压栈
effectStack.push(effectFn)
const res = fn()
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并还原 activeEffect 为之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
return res
}
// 将 options 挂在到 effectFn 上
effectFn.options = options
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
if (!options.lazy) {
effectFn()
}
return effectFn
}
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i]
deps.delete(effectFn)
}
effectFn.deps.length = 0
}
// =========================
function traverse(value, seen = new Set()) {
if (typeof value !== 'object' || value === null || seen.has(value)) return
seen.add(value)
for (const k in value) {
traverse(value[k], seen)
}
return value
}
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
const job = () => {
newValue = effectFn()
cb(oldValue, newValue)
oldValue = newValue
}
const effectFn = effect(
// 执行 getter
() => getter(), {
lazy: true,
scheduler: () => {
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)
if (options.immediate) {
job()
} else {
oldValue = effectFn()
}
}
watch(() => obj.foo, (newVal, oldVal) => {
console.log(newVal, oldVal)
}, {
immediate: true,
flush: 'post'
})
setTimeout(() => {
obj.foo++
}, 1000)
← 设计思路 非原始值的响应式方案 →