vuejs设计与实现-响应系统的设计与实现
响应式数据与副作用函数
副作用函数: 函数fn
的执行会直接或间接影响其他函数的执行, fn
就产生了副作用.
let val = 1
function effect(){
val = 2
}
// 修改了全局变量, 对外部产生了影响, 所以 effect 是副作用函数
effect()
响应式数据: 当值发生变化时, 如果副作用函数会自动重新执行, 那么obj
就是响应式数据
const obj = { text: 'hello world' }
function effect(){
document.body.innerText = obj.text
}
// 修改obj.text的值同时希望副作用函数会重新执行
obj.text = 'hello vue3'
响应式数据的基本实现
我们可以拦截一个对象的读取和设置操作, 当读取字段obj.text
时, 把副作用函数effect
存储到一个“桶”里. 当设置obj.text
时, 再把副作用函数从“桶”中取出执行. 以上就是一个响应式数据.
- 当副作用函数
effect
执行时, 会触发obj.text
的读取操作 - 当修改
obj.text
的值时, 会触发obj.text
的设置操作
// 存储副作用的桶
const bucket = new Set()
const data = { text: 'hello world' }
const obj = new Proxy(data, {
get(target, key){
bucket.add(effect)
return target[key]
},
set(target, key, newVal){
target[key] = newVal
bucket.forEach(fn => fn())
return true
}
})
以上是简单的实现, 通过proxy
代理拦截读取和设置操作.虽然有很多缺陷(硬编码), 但是执行也会得到期望的结果. 而vue2中通过Object.defineProperty
实现.
function effect(){
document.body.innerText = obj.text
}
// 触发读取操作, 将副作用存入桶中
effect()
obj.text = 'hello world'
设计一个完善的响应系统
优化副作用函数的注册机制
前面硬编码
副作用函数的名字(effect
), 然而副作用函数的名字一旦改变整个逻辑就无法工作, 所以需要提供注册副作用函数的机制.
// 全局变量 存储被注册的副作用函数
let activeEffect
// 重新定义effect 用于注册副作用函数
function effect(fn){
activeEffect = fn
// 默认执行副作用函数, 进行读取操作
fn()
}
// 收集通过 effect 方法注册的副作用函数
const obj = new Proxy(data, {
get(target, key){
// 收集缓存的副作用函数
if(activeEffect){
bucket.add(activeEffect)
}
return target[key]
},
// set(target, key, newVal){ ... }
})
// effect 用来注册一个匿名副作用函数
effect(() => {
document.body.innerText = obj.text
})
副作用函数与被操作的目标字段需要建立明确的关系
我们优化了副作用函数的注册机制, 但是在尝试设置不存在的字段obj.notRxist
时, 副作用函数同样会执行. 是因为只要出发 set
就会执行桶中的副作用函数. 因此需要重新设计桶的结构, 使副作用函数与字段值建立关系(树形结构图).
// 使用 WeakMap 实现存储副作用的 桶
// bucket(WeakMap) target -> depsMap
// depsMap (Map) target 的 key -> Set
// deps (Set) 副作用函数组成的Set
const bucket = new WeakMap()
const obj = new Proxy(data, {
get(target, key){
// weakMap: target -> (depsMap: (key -> Set(effects)))
if(!activeEffect) return target[key]
let depsMap = bucket.get(target)
if(!depsMap) {
bucket.set(target, depsMap = new Map())
}
// key 的依赖集合
let deps = depsMap.get(key)
if(!deps) {
depsMap.set(key, deps = new Set())
}
deps.add(activeEffect)
return target[key]
},
set(target, key, newVal){
// 根据target从桶中取得depsMap, 根据key从map中副作用函数
target[key] = newVal
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
})
Set数据结构
存储的副作用函数集合就是key
的依赖集合. 同时使用weakMap
方便当用户的代理对target
没有引用时回收.
通过track
和trigger
封装代理对象的读取拦截操作.
// 读取操作, 追踪变化
fcuntion track(target, key){
if(!activeEffect) return target[key]
let depsMap = bucket.get(target)
if(!depsMap) {
bucket.set(target, depsMap = new Map())
}
// key 的依赖集合
let deps = depsMap.get(key)
if(!deps) {
depsMap.set(key, deps = new Set())
}
deps.add(activeEffect)
}
// 触发操作, 执行副作用函数
fcuntion trigger(target, key){
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
分支切换与cleanup
在三元表达式中, 当obj.ok
发生变化时代码执行的分支会发生变化. 分支切换可能会产生遗留的副作用函数, 发生不必要的更新.
const data = { ok: true, text: 'hello world' }
const obj = new Proxy(data, { ... })
effect(function effectFn(){
console.log(obj.ok ? obj.text: 'nothing')
})
- 当
obj.ok
为true时,effectFn
被obj.ok
和obj.text
所对应的依赖收集, 此时两个值的变化都会触发副作用函数执行. - 当
obj.ok
为false时, 理论上obj.text
的变化不应该再触发副作用函数. 但是由于之前收集了依赖, 所以obj.text
会触发不必要的更新.
理想状态下, 此时effectFn
不应被obj.text
所对应的依赖收集. 需要在副作用函数执行时从所有的依赖集合中删除, 执行完毕后再重新建立联系.
要将副作用函数从所有与之关联的依赖集合中删除, 就要知道哪些依赖集合中包含它. 因此可以重新设计副作用函数注册机制.
let activeEffect
function effect(fn){
const effectFn = () => {
// 将副作用函数从依赖集合中清除
cleanup(effectFn)
activeEffect = effectFn
// 传入的副作用函数, 通过 obj.text 触发再次收集依赖
fn()
}
// 定义 deps 存储所有与该副作用函数关联的依赖集合
effectFn.deps = []
effectFn()
}
// 修改, 将关联的依赖集合收集起来
fcuntion track(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)
// 将依赖集合push到副作用函数的deps属性中, 从而实现 cleanup.
activeEffect.deps.push(deps)
}
// 获取, 通过依赖执行对应的副作用函数
fcuntion trigger(target, key){
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
// 需要构建一个新的Set<effects>进行遍历
// 因为遍历effects会执行集合中的副作用函数 , 调用 cleanup 从集合中删除当前执行的副作用函数
// 但同时又重新被收集, 会导致Set集合无限执行遍历
const effectsToRun = new Set(effects)
effectsToRun.forEach(fn => fn())
// effects && effects.forEach(fn => fn())
}
// cleanup 用于清除副作用函数的 effectFn.deps
function cleanup(effectFn){
for(let i = 0; i < effectFn.deps.length; i++){
// deps 依赖集合 Set实例
const deps = effectFn.deps[i]
// 移除方便重新收集依赖
deps.delete(effectFn)
}
// 重置effectFn.deps数组
effectFn.deps.length = 0
}
嵌套的effect与effect栈
effect是可以发生嵌套的, vuejs的渲染函数就是在一个effect中执行的, 因此需要支持嵌套的情况.
activeEffect
作为全局变量同一时刻只能存储一个副作用函数. 当发生嵌套时, 内层副作用函数会覆盖原来activeEffect
的值. 这时如果有响应式数据进行依赖收集(即使在外层副作用函数读取), 只能收集到内层副作用函数. 所以我们需要一个副作用函数栈effectStack
, 当副作用函数执行时压入栈中, 执行完毕从栈中弹出. 保证activeEffect
始终指向栈顶的副作用函数.
let activeEffect
const effectStack = []
function effect(fn){
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
// 执行前压入栈中
effectStack.push(effectFn)
fn()
// 执行完毕后弹出, 并还原 activeEffect
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// 定义 deps 存储所有与该副作用函数关联的依赖集合
effectFn.deps = []
effectFn()
}
避免无限递归循环
自增操作既读取也会设置obj.foo
的值. 先触发track
操作收集依赖, 随后赋值触发trigger
操作, 触发副作用函数执行. 但此时副作用函数正在执行中, 就会无限递归地调用自己.
const data = { foo: 1 }
const obj = new Proxy(data, { ... })
effect(() => {
obj.foo++
})
因此如果trigger函数触发执行的副作用函数与当前正在执行的副作用函数相同, 则不触发执行
fcuntion 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(fn => fn())
}
调度执行
可调度性就是当trigger触发副作用函数重新执行时, 有能力决定副作用函数执行的时机、次数以及方式.
function effect(fn, options = {}){
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// 挂载options属性
effectFn.options = options
effectFn.deps = []
effectFn()
}
//
fcuntion 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(fn => {
// 如果 scheduler 存在就调用, 并将副作用函数作为参数传递
if(fn.options.scheduler){
fn.options.scheduler(fn)
}else {
fn()
}
})
}
// 调度器
const jobQueue = new Set()
const p = Promise.resolve()
let isFlushing = false
function flushJob(){
if(isFlushing) return
isFlushing = true
p.then(() => {
jobQueue.forEach(job => job())
}).finally(() => {
isFlushing = false
})
}
//
effect(() => {
console.log(obj.text)
}, {
scheduler(fn){
jobQueue.add(fn)
flushJob()
}
})
// 连续两次自增操作, 会连续执行调度函数
// 同一个副作用函数会被 jobQueue.add 添加两次(本身Set去重能力).
// flushJob 也会执行两次, 由于 isFlushing 标识, 只会对 jobQueue(只存储了一个副作用函数) 进行一次遍历执行 (一个事件循环内只执行一次)
obj.text = '123'
obj.text = '234'
vuejs中连续修改响应式数据但只会触发一次更新, 思路与之相同. 将实际更新操作放在微任务中执行(任务队列是Set结构), 因此同步地修改响应式数据最终只会触发一次更新.
计算属性computed与lazy
lazy 属性为true
时将副作用函数返回, 允许我们手动执行副作用函数.
// 对effect进行改造
function effect(fn, options = {}){
// effectFn 对副作用函数fn进行包装
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
// 保存真正的副作用函数结果
const res = fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
// 作为effectFn副作用函数的返回值
return res
}
// 挂载options属性
effectFn.options = options
effectFn.deps = []
if(options.lazy) {
effectFn()
}
// 将副作用函数返回
return effectFn
}
computed计算属性对effect
做了一层包装, computed
接收一个getter
方法作为副作用函数, 并返回一个对象(其value是访问器属性). 只有读取value
的值时才会执行副作用函数并将其结果返回. 同时对其进行缓存, 只有当getter
方法依赖的响应式数据变化时才会重新计算.
function computed(getter){
let value, dirty = true
const effectFn = effect(getter, {
lazy: true,
scheduler(){
if(!dirty) {
dirty = true
// 变化时trigger触发响应
trigger(obj, 'value')
}
}
})
const obj = {
get value(){
// 缓存计算属性, 只有dirty为真时进行计算
if(dirty) {
// 通过返回值获取执行副作用函数结果
value = effectFn()
dirty = false
}
// 读取value时操作进行track追踪
track(obj, 'value')
return value
}
}
// 返回对象, 其value是一个访问器属性
return obj
}
// 使用计算属性
const sumRes = computed(() => obj.foo + obj.bar)
// 本质是effect嵌套, 外层的effect并不会被内层effect的响应式数据收集, 所以需要在获取计算属性进行track, 计算属性变化时进行trigger
effect(() => {
// 读取计算数学的值
console.log(sumRes.value)
})
// obj.foo变化时, sumRes重新计算, 同时也会执行副作用函数进行log打印
obj.foo++
watch的实现原理
watch的实现本质上是利用了effect和options.scheduler选项, 观测一个响应式数据, 当数据变化时通知并执行相应的回调函数.
function watch(source, cb){
let getter
if(type of souce === 'function'){
getter = source
} else {
// 递归读取, 每个属性变化时都能触发回调函数
getter = () => traverse(source)
}
let oldV, newV
const effectFn = effect(
() => getter,
{
lazy: true,
scheduler(){
newV = effectFn()
cb(newV, oldV)
oldV = newV
}
}
)
oldV = effectFn()
}
立即执行的watch与回调执行时机
watch是对effect
的二次封装. 默认情况下回调只会在响应式数据变化时执行, 当immediate
为true时会立即执行一次回调.
watch(obj, () => {
console.log('obj变化了')
}, {
immediate: true
})
过期的副作用
function watch(source, cb, options = {}){
let getter
if(type of souce === 'function'){
getter = source
} else {
// 递归读取, 每个属性变化时都能触发回调函数
getter = () => traverse(source)
}
let oldV, newV
let cleanup
function onInvalidate(fn){
cleanup = fn
}
const job = () => {
newV = effectFn()
if(cleanup){ cleanup() }
cb(newV, oldV, onInvalidate)
}
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler(){
if(options.flush === 'post') {
Promise.resolve().then(job)
} else {
job()
}
}
}
)
if(options.immediate){
job()
} else {
oldV = effectFn()
}
}
watch(obj, async(newV, oldV, onInvalidate) => {
let expired = false
onInvalidate(() => {
expired = true
})
const res = await fetch('/path/to/request');
if(!expired) {
finalData = res
}
})
总结
- 响应式数据的实现依赖对
get
和set
的拦截, 在副作用函数与响应式数据建立关系. weakMap
和Map
配合, 在响应式数据与副作用函数之间建立精确的关系.weakMap<target, Map<key, Set<effectFn>>>
- 副作用函数重新执行前,清除上次的响应联系; 重新执行后, 再重新建立联系. 从而解决冗余副作用的问题.
- 嵌套的副作用函数. 使用副作用函数栈来存储不同的副作用函数, 避免嵌套时响应式数据与副作用函数的联系发生错乱.
- 可调度性指
trigger
触发副作用函数执行的时机、次数以及方式. 通过scheduler
选项完成任务调度工作. 还可以通过调度器实现任务去重. - 计算属性是一个懒执行的副作用函数. 当读取计算属性的值时只需手动执行副作用函数.
watch
利用了副作用函数执行时的可调度性.watch
会创建一个effect
, 当依赖的响应式数据发生变化时便会执行scheduler
, 在scheduler
中执行用户注册的回调.- 过期的副作用函数会导致竞态问题. 所以vuejs为
watch
的回调设计了onInvalidate
参数, 用来注册过期回调.
转载自:https://juejin.cn/post/7206391499263721529