响应系统#响应系统的作用与实现
一、响应系统的作用与实现#响应式数据与副作用函数
副作用函数:函数的执行会直接或间接影响其他函数的执行。
javascript
// 全局变量
let val = 1
function effect() {
val = 2 // 修改全局变量,产生副作用
}
响应式数据 :当 val
的值变化时,effect
函数自动执行,则:val
为响应式数据。
二、响应系统的作用与实现#响应式数据的基本实现
工作原理:拦截数据的读取和设置操作。
- 读取:把副作用函数存储到一个"桶"里
- 设置:把副作用函数从"桶"里取出并执行
基本实现:
-
ES2015之前:通过
Object.defineProperty
函数实现 ------Vue.js 3
-
ES2015+:使用代理对象
Proxy
实现 ------Vue.js 3
javascript// 存储副作用函数的桶 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()) return true }, })
测试代码:
javascript
function effect() {
document.body.innerText = obj.text
}
// 执行副作用函数,触发读取操作
effect()
// 1秒后修改响应式数据
setTimeout(() => {
obj.text = 'hello vue3'
}, 1000)
三、响应系统的作用与实现#设计一个完善的响应系统
一个响应系统的工作流程:
- 当读取 操作发生时,将副作用函数存储到"桶"中;
- 当设置 操作发生时,从"桶"中取出副作用函数并执行。
完善点一:响应系统解绑副作用函数名 ------ 提供注册副作用函数机制
-
提供一个用来注册副作用函数的机制 :确保有效且正确地将副作用函数收集到"桶"中。
javascript// 用一个全局变量存储被注册的副作用函数 let activeEffect function effect(fn) { // 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect activeEffect = fn // 执行副作用函数 fn() }
-
举例:注册一个匿名的副作用函数
javascript// 使用effect注册副作用函数 effect( // 注册一个匿名的副作用函数 () => { document.body.innerText = obj.text } )
-
调整响应系统的读取操作逻辑,解绑副作用函数名
javascript// ... // 对原始数据的代理 const obj = new Proxy(data, { // 拦截读取操作 get(target, key) { // 将副作用函数添加到存储副作用函数的桶中【解绑副作用函数名】 if (activeEffect) { bucket.add(activeEffect) } return target[key] }, // ... })
完善点二:在副作用函数与被操作的目标字段之间建立明确的联系 ------ 调整"桶"的数据结构:Set 调整为 WeakMap
-
Set
数据结构:副作用函数与被操作的字段之间没有明确的联系。- 读取任意属性时,都会把副作用函数收集到"桶"里。
- 设置任意属性时,都会把"桶"里的副作用函数取出并执行。
-
WeakMap
数据结构:明确副作用函数与被操作的字段之间的关系。javascriptconst bucket = new WeakMap() const data = { text: 'hello world' } const obj = new Proxy(data, { get(target, key) { track(target, key) return target[key] }, set(target, key, newVal) { target[key] = newVal trigger(target, key) }, }) // track 函数追踪变化 const 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) } // trigger 函数触发变化 const trigger = (target, key) => { const depsMap = bucket.get(target) if (!depsMap) return const effects = depsMap.get(key) effects && effects.forEach(fn => fn()) }
WeakMap
对key
是弱引用,不影响垃圾回收器的工作。经常用于存储哪些只有当key
所引用的对象存在时(没有被回收)次啊有价值的信息。如果使用
Map
来代替WeakMap
,则即使用户侧的代码对target
没有任何引用,这个target
也不会被回收,最终可能导致内存溢出。对代码做适当的封装处理,如
track(target, key)
,trigger(target, key)
,可以有效提升代码灵活性。
四、响应系统的作用与实现#分支切换与cleanup
分支切换 :代码执行的分支跟随取值变化,如:if
语句,三目运算符,switch
语句等。
分支切换可能会产生遗留的副作用函数。而遗留的副作用函数会导致不必要的更新。
解决思路:
- 每次副作用函数执行时,先把它从所有与之关联的依赖集合中删除。
- 当副作用函数执行完毕后,会重新建立联系,但在新的联系中不会包含遗留的副作用函数。
要将一个副作用函数从所有与之关联的依赖集合中移除,就需要明确知道哪些依赖集合中包含它 ------ 重新设计副作用函数。
javascript
// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect(fn) {
const effectFn = () => {
// 新增清除
cleanup(effectFn)
// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
fn()
}
// 添加 effectFn.deps 属性,用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
然后在 track
函数中通过 activeEffect.deps.push(deps)
来完成 effectFn.deps
数组对依赖集合的收集。
javascript
const cleanup = (effectFn) => {
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i]
deps.delete(effectFn)
}
effectFn.deps.length = 0
}
cleanup
函数接收副作用函数作为参数,遍历副作用函数的 effectFn.deps
数组,将该副作用函数从依赖集合中移除,最后重置 effectFn.deps
数组。
语言规范 :在调用 forEach
遍历 Set
集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时 forEach
遍历没有结束,那么该值会重新被访问。
解决办法 :构造另外一个 Set
集合并遍历它。
javascript
const set = new Set([1])
const newSet = new Set(set)
newSet.forEach(item => {
set.delete(1)
set.add(1)
console.log(item, '遍历中...')
})
五、响应系统的作用与实现#嵌套的effect与effect栈
用全局变量 activeEffect
来存储通过 effect
函数注册的副作用函数,这意味着:同一时刻,activeEffect
所存储的副作用函数只能有一个。当副作用函数发生嵌套时,内层副作用函数的执行会覆盖 activeEffect
的值,并且永远不会恢复到原来的值。这时,如果再有响应式数据进行依赖收集,即使这个响应式数据是在外层副作用函数中读取的,它们收集到的副作用函数也都会是内层副作用函数。
解决方案 :提供一个副作用函数栈 effectStack
------ 在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让 activeEffect
指向栈顶的副作用函数。
javascript
let activeEffect
// effect 栈
const effectStack = []
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
// 在调用副作用函数之前,将当前副作用函数压入栈中
effectStack.push(effectFn)
fn()
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.deps = []
effectFn()
}
当副作用函数发生嵌套时,栈底存储的是外层副作用函数,栈顶存储的是内层副作用函数。
六、响应系统的作用与实现#避免无限递归循环
在 trigger
触发执行前添加守卫条件 :如果 trigger
触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行 ------ effectFn !== activeEffect
。
七、响应系统的作用与实现#调度执行
可调度 :当 trigger
动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。
可调度性是响应系统非常重要的特性。
javascript
let activeEffect
const effectStack = []
// 为 effect 函数设计一个选项参数 options,允许用户指定调度器
function effect(fn, options) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// 将 options 挂载到 effectFn 上
effectFn.options = options
effectFn.deps = []
effectFn()
}
在 trigger
函数中触发副作用函数重新执行时,优先判断该副作用函数是否存在调度器,如果存在,则直接调用调度器函数,并把当前副作用函数作为参数传递过去,由用户自己控制如何执行;否则保留之前的行为,直接执行副作用函数。
javascript
// ...
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
// ...
八、响应系统的作用与实现#计算属性computed与lazy
javascript
function computed(getter) {
// 值缓存
let value
// 是否需要重新计算标识
let dirty = true
const effectFn = effect(getter, {
// 懒计算
lazy: true,
// 调度器重置计算标识:getter 函数中所依赖的响应式数据变化时执行
scheduler() {
if (!dirty) {
dirty = true
trigger(obj, 'value')
}
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
track(obj, 'value')
return value
}
}
return obj
}
- 懒计算:真正需要的时候,才进行计算并得到值。
- 值缓存:值变化,重新计算,否则读取缓存的值。
九、响应系统的作用与实现#watch的实现原理
watch
本质就是观测一个响应式数据,当数据发生变化时,通知并执行相应的回调函数。
实际上,watch
的实现本质上就是利用了 effect
以及 options.scheduler
选项。
javascript
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
const effectFn = effect(
// 触发读取操作,建立联系 ------ 递归读取,代替硬编码
() => getter(),
{
lazy: true,
// 当数据变化时,会执行 scheduler 调度函数
scheduler() {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
}
)
oldValue = effectFn()
}
// 递归读取:支持任意属性发生变化都能触发回调函数执行
function traverse(value, 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
}
watch
的本质是对 effect
的二次封装。
十、响应系统的作用与实现#立即执行的watch与回调执行时机
立即执行
默认情况下,一个 watch
的回调只会在响应式数据发生变化时才执行。
在 Vue.js
中,可以通过选项参数 immediate
来指向是否需要立即执行。
javascript
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(),
{
lazy: true,
scheduler: job
}
)
if (options.immediate) {
// 立即执行回调函数
job()
} else {
oldValue = effectFn()
}
}
当 immediate
选项存在并且为 true
时,回调函数会在该 watch
创建时立刻执行一次。
执行时机
flush
指定调度函数的执行时机。
-
pre
:组件更新前。 -
post
:组件更新后。 调度函数需要将副作用函数放到一个微任务队列中,并等待DOM
更新结束后再执行。javascript// ... scheduler: () => { if (options.flush === 'post') { // 异步延迟执行 const p = Promise.resolve() p.then(job) } else { job() } } // ...
-
sync
:同步执行。
十一、过期的副作用
watch
的 onInvalidate
标识过期与否:过期-废弃,未过期-取用。