Vuejs设计与实现读书笔记-响应式
2022-6-24
| 2023-2-22
0  |  0 分钟
password
Created
Feb 19, 2023 04:49 PM
type
Post
status
Published
date
Jun 24, 2022
slug
summary
Vuejs设计与实现读书笔记-响应式
tags
Vue源码
Vue
category
Vue
icon

一. 响应式的实现

 

1. 副作用函数

一般会命名为effect, 也就是当里面的响应式数据改变的时候, 会被动执行一遍的函数
那么如何将一个数据变成响应式呢?
在Vue2中, 使用了Object.defineProperty的方式对对象进行监听, 从而达到实现响应式的目的
在Vue3中, 我们可以使用代理对象Proxy
  • 为什么要用proxy替代definePropery?
    • defineProperty的劫持方式是这样的:
      const a = { b : 1 } Object.defineProperty(a, 'b', { set: () => {}, get: () => {} })
      而Proxy的劫持方式是这样的:
      const a = { b : 1 } const observeA = new Proxy(a, { get: (target, key) => {}, set: (target, key, newVal) => {} })
      在这里其实你应该可以猜到, 为什么vue2中新增的key需要调用`Vue.$set`了, 因为defineProperty是给每个key值增加了响应式, 那么新增key值, 自然就需要额外进行响应式设置, 而proxy则不需要
其次, proxy可以直接代理数组的简单写入的变更, 监听数组变化, 而defineProperty则不行, 需要对数组的方法进行重写
proxy除了数组的优势外, 还有apply(代理函数), ownKeys, deleteProperty等能力, 这是defineProperty所不具备的
那么也就是说, 在我们在数据get的阶段进行effect的收集, 在数组set的阶段执行副作用函数, 即可实现我们预期的副作用函数自动执行了
 

2. 实现基本的响应式

通过以上的理论, 基本可以实现我们需要的响应式了
const data = { text: 'hello world' } const bucket = new Set() let activeEffect const obj = new Proxy(data, { get(target, key) { if (activeEffect) { bucket.add(activeEffect) } }, set(target, key, newVal) { target[key] = newVal bucket.forEach(fn => fn()) return true } }) function effect(fn) { // 当调用effect注册副作用函数的时候, 将副作用函数fn赋值给activeEffect activeEffect = fn // 然后再执行副作用函数 fn() } effect(() => { document.body.innerText = obj.text }) setTimeout(() => { obj.text = 'hello vue3' }, 1000)
运行示例, 我们会发现副作用成功在一秒后执行, 页面的内容成功在一秒后改成了hello vue3
但是这样依然存在问题, 使用一个set来存储副作用函数, 但我们需要精细化的控制副作用的触发

3. 精细化控制副作用的触发

我们有精细化的控制副作用的诉求, 也就意味着我们不能直接将其存在set中, 而是应该存储在每个响应式key对应的位置, 这样产生了一个双向联系, 对应的响应式的key发生了改变, 那么通过这个key找到对应的副作用函数即可
 
// 我们先设置一个新的WeakMap结构来进行effect函数的存储 const bucket = new WeakMap() const data = {text: 'hello world'} // 同样声明一个activeEffect来存储当前的副作用函数 let activeEffect const obj = new Proxy(data, { get(target, key) { if (!activeEffect) return target[key] // 这里不同的是, 我们通过bucket先取出的是一个map的关系 // map关系使得我们的副作用函数与对应的key形成了相互之间的映射关系 let depsMap = bucket.get(target) // 如果不存在depsMap, 证明是第一次进行get, 此时我们需要将这个map结构初始化一下 if (!depsMap) bucket.set(target, (depsMap = new Map())) // 再根据key从depsMap中取得deps, 它是一个Set类型 // 里面存储这所有与当前key相关联的副作用函数 let deps = depsMap.get(key) // 同样的, 如果deps不存在, 也会新建一个set并与key相关联 if (!deps) depsMap.set(key, (deps = new Set())) // 最后, 将当前激活的副作用函数添加到对应key的桶里 if (activeEffect) deps.add(activeEffect) activeEffect = null return target[key] }, set(target, key, newVal) { target[key] = newVal // 从bucket中取出对应的target对应的set const depsMap = bucket.get(target) if (!depsMap) return // 再根据key来取出所有的副作用函数effects const effects = depsMap.get(key) // 最后, 执行这个key所对应的副作用函数 effects && effects.forEach(v => v()) } })
如上, 我们使用了一个weakMap结构来存储响应式的对象, 通过map结构存储响应式对象中的key值, 然后map的value则是响应式key对应的Set结构的副作用函数列表
  • 为什么要使用WeakMap作为存储响应式对象的数据结构?
    • 简单来讲, weakMap对key是弱引用, 不影响垃圾回收的工作, 能够更好的保证key这个响应式对象如果一旦不再被需要时. 能够安全地被GC所回收
      说明weakmap的价值, 下面有一个例子:
      当我们使用map的时候, 如果将map的key原始值置空, map的key并不会被清除, 而是对之前的key值有引用, 不会被GC回收
      let data = { text: 'hello world' } const map = new Map() map.set(data, '') data = null console.log(map) // key: {text: 'hello world'} value: ''
      weakmap则不同, 当我们给原始key值设置为null的时候, 其key及value就会被清除
      let data = { text: 'hello world' } const map = new WeakMap() map.set(data, '') data = null console.log(map) // {}
当然, 刚刚的函数有些职责未分离, 更好的方式是将依赖收集和依赖追踪分别放到一个函数里进行处理, 这里不再列出了

4. 依赖的清除

上面的函数可以很好的解决问题, 但是存在的问题是: 如果我们有一些分支条件的情况下, 某个key下的副作用函数是存在收集和清除的动态变化的
显然目前的实现中无法做到这一点, 因为我们并未实现副作用的清除动作
其次, 因为activeEffect并未置空, 我们多次执行副作用的时候, 可能会出现副作用挂载异常的情况
这个时候, 我们就需要在副作用执行的同时, 将其清除掉, 后续再来根据get再追踪副作用
function effect(fn) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn fn() } effectFn.deps = [] 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 }
但这样依然存在问题, 问题在于: 当我们执行自增等操作的时候, 同一时间, 读完成之后马上就是写了, 那么读收集了依赖, 写又消费了依赖, 如果这个时候有个副作用函数, 那么因为set的特性, 就会造成死循环
const set = new Set([1]) const newSet = new Set(set) newSet.forEach(item => { set.delete(1) set.add(1) console.log('遍历中') //就一直死循环了 })
因此, 为了避免这个问题, 我们应该在执行的时候, 使用一个新的set来执行
function trigger(target, key) { 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(effectFn => effectFn()) // 删除 }

5. 处理effect的嵌套关系

众所周知, effect是可以进行嵌套的, 显然目前的实现无法支持到这样的结构
主要原因是: 我们执行的时候, activeEffect只能存储一个当前active的effect, 所以进入到里层执行的时候, 就会出现问题了
因此, 我们需要改造activeEffect, 使其变成一个栈结构, 如果有内嵌的effect, 应当入栈处理
// 用一个全局变量存储当前激活的 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() }

6. 解决无限递归循环的问题

当我们在effect中对一个响应式数据进行自增操作的时候, 会发现它会一直运行到栈溢出
因为我们在执行effect期间, 如果有set操作时, 会在set中再执行这个effect, 这样就无限嵌套递归下去了, 因此在trigger执行阶段, 我们需要判断一下当前的执行副作用函数和当前上下文的副作用函数是不是一致的, 如果一致, 则不能执行
const effectsToRun = new Set() effects && effects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn) } }) effectsToRun.forEach(effectFn => effectFn())
 

二. computed与lazy

1. scheduler

要讲computed和lazy, 要从调度说起
当我们并不想要整个副作用的执行是按顺序的, 而是可以通过自己的一些操作控制, 那么这个时候就需要增加一个调度的参数了
我们为effect函数增加第二个对象参数, 用来传入这些内容
effect(() => { }, { scheduler(fn) { //... fn() } })
那么在effect函数中, 我们则需要将effect的第二个参数挂载到activeEffect上, 此处就不再赘述了
同样的, 在触发阶段, 如果options中有传递scheduler, 则执行此scheduler, 代替直接执行effectFn即可
if (effectFn.options.scheduler) { effectFn.options.scheduler(effectFn) } else { effectFn() }
同样的, 依据scheduler, 我们还可以实现对状态变更的合并了
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(() => { // ... }, { scheduler(fn) { jobQueue.add(fn) flushJob() } })
在scheduler调度器中, 我们每次会将副作用函数放到jobQueue, 也就是任务队列中, 然后执行flushJob以进行任务队列刷新
flushJob使用了Promise, 目的是在一个微调用栈中将jobQueue中的内容进行逐个的执行, 同时, 因为有一个isFlushing的布尔值, 后续调用flushJob是无效的, 当第一个flushJob完成调用的时候, 已经走完一个微调用栈, 此时是已经完成了同步的状态修改调用的
这样就完成了状态的合并了
 

2. lazy

lazy的实现其实也很简单, 主要是将第一次的effect执行变成了”懒执行”
也就是, 当我们需要在什么地方执行就可以在什么地方执行
这里其实处理起来也很简单, 我们假定options中有一个lazy布尔值, 同时将effectFn从effect中返回, 当lazy为true的时候不再effect函数中执行, 而是返回由其他地方执行即可
function effect(fn, options = {}) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn effectStack.push(effectFn) const res = fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1] return res } effectFn.options = options effectFn.deps = [] if (!options.lazy) { effectFn() } return effectFn }
 

3. computed

有了lazy的能力, 实现computed就很简单了, 设置lazy为true, 并在特定位置执行返回的函数即可
function computed(getter) { const effectFn = effect(getter, { lazy: true }) const obj = { get value() { return effectFn() } } return obj }
 

三. watch

watch API使我们可以精确控制监听的响应式对象
watch的第一个参数是所需要监听的响应式对象source, 支持对象类型, ref类型, 函数类型的传值
第二个参数是callback, 可以在callback中获取到新旧的值
 
source的处理其实很好理解, vue会对source进行包装, 处理成一个副作用函数, 并使用effect来执行
因为callback的执行中可以获取到新旧值, 那么需要使用调度器来处理新旧值的关系
function watch(source, cb) { let getter if (typeof source === 'function') { getter = source } else { getter = () => traverse(source) } let newValue, oldValue const effectFn = effect(() => getter(), { lazy: true, scheduler() { newValue = effectFn() cb(newValue, oldValue) oldValue = newValue } }) oldValue = effectFn() }
watch的核心实现就是这样了, 但是还有些我们没有处理的问题
  1. watch默认是懒执行的, 可以立即执行吗?
答案是可以的, 官方API中提供了immediate选项, 为true时可以立即执行
其实这样也很好处理, 我们在immediate的时候, 执行一次scheduler函数即可
  1. 如何解决watch的竟态问题
 

四. 响应式总结

  1. 如何实现响应式?
在vue3中, 使用了ProxyAPI, 对对象实现了getter和setter的拦截, 在getter的时候收集依赖的副作用函数, 在setter的时候执行收集的副作用函数, 从而实现响应式
 
  1. 如何实现副作用函数的嵌套?
在vue3中, effect在执行过程中会将其放入activeEffect的队列中, 当副作用函数中还有副作用函数, 会对activeEffect压栈处理, 里面的effect函数执行完之后再出栈, 这样做就保证了effect之间的嵌套关系.
但是这本身对开发者来讲会造成一些理解上的混乱, 一般不太建议这样子做处理
对开发者的讨好?
 
  1. vue是如何实现多次set data的合并?
多次相同的副作用函数执行的时候, vue会对所有的相同副作用压入相同的jobQueue中, 同时执行一个flushQueue方法, 并声明一个isFlushing的状态, 第一次将其置为true, 并启动一个promise, 微任务执行的时候, 同步任务已经结束, 这样达到了data合并的目的
 
  1. computed实现原理
computed其实是对effect的包装, 此时effect并不会立即执行, 而是返回执行函数, 当computed的内容被读取的时候触发执行
也就是说computed中又声明了一个对象, 对象getter触发的时候, 执行computed中的effect
 
  1. watch的实现原理
watch可以前置声明所监听的响应式对象, 本质上是effect及其调度机制的封装
当effect触发的时候, 调度器中会处理和维护新旧的value, 以达到callback能获取到新旧value的目的
 

五. 非原始值的响应式

1. Proxy和Reflect

Proxy能够代理所有的对象类型, 函数也包括在内
const fn = (name) => { console.log(name) } const p2 = new Proxy(fn, { apply(target, thisArg, argArray) { target.call(thisArg, ...argArray) } }) p2('hello')
Reflect拥有与Proxy一样的API, 一般用来与Proxy组合使用, 主要是用于代理一些对象中有this调用的时候, this丢失的情况, Reflect中有第三个receiver参数, 可以用来绑定this
 

2. 常规对象和异质对象

在 JavaScript中,对象的实际语义是由对象的内部方法(internal method)指定的
对象的内部方法有很多种, 而其中, 有些列出的内部方法, 如果是按照标准实现的, 那么就是常规对象, 而没有按照标准实现的, 就是异质对象
 

3. 处理for…in循环

使用ownKeys可以实现对for…in循环的拦截操作
for..in循环中, key是变量, 因此, 我们使用track函数追踪的时候, 需要使用一个symbol作为唯一的标识, 以便触发响应的时候能够正确根据key来触发
同样的, 因为是循环, 所以同一个key对应了有多个值的副作用函数, 因此我们在执行期间需要将key对应的副作用函数拿出来执行
在ADD或DELETE, 也就是给对象增加或删除key值的时候, 需要触发其关联副作用函数重新执行
if (type === 'ADD' | type === 'DELETE') { const iterateEffects = depsMap.get(ITERATE_KEY) iterateEffects && iterateEffects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn) } }) } // ...
 

4. 处理原型上的数据变更导致触发响应式的问题

保存原始对象, 当target和receiver相等时才触发响应式
 

5. 代理数组

本身Proxy是具备数组代理的能力的
但是还存在一些边缘情况, 例如: 当索引值大于当前数组长度时, 会隐式修改length属性值, 当我们声明了响应式数组的length的副作用函数的时候, 就无法追踪到了
因此, 当数据为ADD的时候, 我们需要做一次length的副作用函数判断
function createReactive(...) { return new Proxy(..., { ... set(target, key, newVal, receiver) { ... const type = Array.isArray(target) ? Number(key) < target.length ? 'SET' : 'ADD' : Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD' } }) } function trigger(target, key, type) { const depsMap = bucket.get(target) if (!depsMap) return if (type === 'ADD' && Array.isArray(target)) { const lengthEffects = depsMap.get('length') lengthEffects && lengthEffects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn) } } // ... }
同样的, 如果length出现修改, 那么也可能会影响到原有的数组值
// 为 trigger 函数增加第四个参数,newVal,即新值 function trigger(target, key, type, newVal) { // ... if (Array.isArray(target) && key === 'length') { depsMap.forEach((effects, key) => { if (key >= newVal) { effects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn) } }) } }) } // ... }
includes有this问题, 需要改写
const arrayInstrumentations = {} ;['includes', 'indexOf', 'lastIndexOf'].forEach(method => { const originMethod = Array.prototype[method] arrayInstrumentations[method] = function(...args) { // this 是代理对象,先在代理对象中查找,将结果存储到 res 中 let res = originMethod.apply(this, args) if (res === false || res === -1) { // res 为 false 说明没找到,通过 this.raw 拿到原始数组,再去其中查找,并更新 res 值 res = originMethod.apply(this.raw, args) } // 返回最终结果 return res } })
 

6. 代理Map和Set

 
 

7. 总结

首先, 我们明确了常规对象和异质对象, 异质对象是没有实现标准的内部方法的对象, 如函数 数组等
其次, 对于函数代理, 我们可以使用apply
其中Reflect的核心作用是与Proxy一起使用, 修改其receiver, 也就是this的指向关系
对于for…in的遍历, 数组和对象的处理方式是类似的, 都是声明一个Symbol作为key, 以便正常触发响应式
对于数组来说, 比较麻烦的点在于其值和length之间的联动关系, 相互修改是会相互被动触发响应式的, 因此我们需要针对性处理
 
 

六. 原始值的响应式

1. ref

ref可以用来包装原始值, 也就是字符串 数字 布尔值等等
实现很简单, 就是用wrapper包裹一层, 同时加一个__v_isRef来标记以区分
 

2. toRef

对于响应式丢失的问题, 我们可以使用toRef将其包装成为响应式
 
 
 
Vue
  • Vue源码
  • Vue
  • Vuejs设计与实现读书笔记-渲染机制Rollup概念与运行原理
    目录