2653 字
13 分钟
Reactive
Reactive
概括
一个响应式数据最基本的实现依赖于对“读取”和“设置”操作的拦截,从而在副作用函数与响应式数据之间建立联系。 当“读取”操作发生时,我们将当前执行的副作用函数存储到“桶”中; 当“设置”操作发生时,再将副作用函数从“桶”里取出并执行。这就是响应系统的根本实现原理。
重点
- 问题:响应式数据与副作用函数之间建立更加精确的联系 解决:使用 WeakMap 配合 Map 构建了新的“桶”结构。
- 问题:WeakMap 与 Map 这两个数据结构之间的区别 原因: WeakMap 是弱引用的,它不影响垃圾回收器的工作。 当用户代码对一个对象没有引用关系时,WeakMap 不会阻止垃圾回收器回收该对象。
- 问题:分支切换导致的冗余副作用的问题,会导致副作用函数进行不必要的更新。 解决: 在每次副作用函数重新执行之前,清除上一次建立的响应联系, 而当副作用函数重新执行后,会再次建立新的响应联系,新的响应联系中不存在冗余副作用问题。
- 问题:遍历 Set 数据结构导致无限循环的新问题, 原因:在调用 forEach 遍历 Set 集合时,如果一个值已经被访问过了,但这个值被删除并重新添加到集合,如果此时 forEach 遍历没有结束,那么这个值会重新被访问。 解决:解决方案是建立一个新的 Set 数据结构用来遍历。
- 问题:嵌套的副作用函数的问题(父子组件)。响应式数据与副作用函数之间建立的响应联系发生错乱。 解决: 需要使用副作用函数栈来存储不同的副作用函数。 当一个副作用函数执行完毕后,将其从栈中弹出。 当读取响应式数据的时候,被读取的响应式数据只会与当前栈顶的副作用函数建立响应联系。
- 问题:副作用函数无限递归地调用自身,导致栈溢出的问题。 原因:对响应式数据的读取和设置操作发生在同一个副作用函数内。 解决:如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。
- 问题:当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式(响应系统的可调度性)。 解决: 为 effect 函数增加了第二个选项参数,可以通过 scheduler 选项指定调用器,这样用户可以通过调度器自行完成任务的调度。 通过调度器实现任务去重,即通过一个微任务队列对任务进行缓存,从而实现去重。
代码实现
proxy
const data = { name: 'james', age: 20, }; const bucket = new WeakMap(); const effectStack = []; let activeEffect; const proxy = new Proxy(data, { // receiver 代表谁在读取对象 可以理解为含义调用过程中的this; 如:p.bar 代表 p在读取bar属性 get(target, key, receiver) { track(target, key); // 这里的this实际是原始对象data, target[key]访问无法建立响应联系 // return target[key]; return Reflect.get(target, key, receiver); }, set(target, key, newVal, receiver) { // target[key] = newVal; const res = Reflect.set(target, key, newVal, receiver); trigger(target, key); return res; }, });track
function track(target, key, receiver) { if (!activeEffect) return Reflect.get(target, key, receiver); // **解决**:响应式数据与副作用函数之间建立更加精确的联系
// 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key -->effects; let depsMap = bucket.get(target); // 如果不存在depsMap 则建立一个map并与target关联 if (!depsMap) { depsMap = new Map(); bucket.set(target, depsMap); } // 根据key从depsMap中取出deps // 里面存储着所有与当前 key 相关联的副作用函数:effects let deps = depsMap.get(key); // 如果 deps 不存在,同样新建一个 Set 并与 key 关联 if (!deps) { deps = new set(); depsMap.set(key, deps); } // 最后将当前激活的副作用函数添加到“桶”里 deps.add(activeEffect); // deps 就是一个与当前副作用函数存在联系的依赖集合 // 将其添加到 activeEffect.deps 数组中 activeEffect.deps.push(deps); }trigger
function trigger(target, key) { const depsMap = bucket.get(target); if (!depsMap) return; const effects = depsMap.get(key); const effectsToRun = new set(); effects?.forEach(effectFn => { // 解决: 副作用函数无限递归地调用自身,导致栈溢出的问题 // 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行 if (effectFn !== activeEffect) { // 新增 effectsToRun.add(effectFn); } }); effectsToRun.forEach(effectFn => { //**解决**:当trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式(响应系统的可调度性) // 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递; if (effectFn.options.scheduler) { effectFn.options.scheduler(effectFn); } else { effectFn(); } }); }effect
function effect(fn, options = {}) { const effectFn = () => { // **解决**:分支切换导致的冗余副作用的问题,会导致副作用函数进行不必要的更新 cleanup(effectFn);
// 当 effectFn 执行时,将其设置为当前激活的副作用函数 activeEffect = effectFn; // **解决**: 嵌套的副作用函数的问题(父子组件)。响应式数据与副作用函数之间建立的响应联系发生错乱。 // 在调用副作用函数之前将当前副作用函数压入栈中 effectStack.push(effectFn); const res = fn(); // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值 effectStack.pop(); activeEffect = effectStack[effectStack.length - 1]; return res; };
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++) { // deps 是依赖集合 const deps = effectFn.deps[i]; // 将 effectFn 从依赖集合中移除 deps.delete(effectFn); } effectFn.deps.length = 0; }promise 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( () => { console.log(123); }, { scheduler(fn) { // 每次调度时,将副作用函数添加到jobQueue队列中 jobQueue.add(fn); // 刷新队列 flushJob(); }, lazy: false, } );computed
计算属性实际上是一个懒执行的副作用函数,我们通过 lazy 选项使得副作用函数可以懒执行。 被标记为懒执行的副作用函数可以通过手动方式让其执行。 当读取计算属性的值时,只需要手动执行副作用函数即可。 当计算属性依赖的响应式数据发生变化时,会通过 scheduler 将 dirty 标记设置为 true,代表“脏”。 下次读取计算属性的值时,会重新计算真正的值。
function computed(getter) { let value; let dirty = true; const effectFn = effect(getter, { // 当计算属性依赖的响应式数据发生变化时,会通过 scheduler 将 dirty 标记设置为 true,代表“脏”。 // 下次读取计算属性的值时,会重新计算真正的值。 scheduler() { if (!dirty) { dirty = true; trigger(obj, value); } }, lazy: true, });
const obj = { get value() { if (dirty) { // 当读取计算属性的值时,只需要手动执行副作用函数 value = effectFn(); dirty = false; } track(obj, 'value'); return value; }, };
return obj; }watch
它本质上利用了副作用函数重新执行时的可调度性。 一个 watch 本身会创建一个 effect,当这个 effect 依赖的响应式数据发生变化时,会执行该 effect 的调度函 数,即 scheduler。 这里的 scheduler 可以理解为“回调”,所以我们只需要在 scheduler 中执行用户通过 watch 函数注册的回调函即 可。 立即执行回调的 watch,通过添加新的 immediate 选项来实现, 控制回调函数的执行时机,通过 flush 选项来指定回调函数具体的执行时机,本质上是利用了调用器和异步的微队列。
问题:过期的副作用函数,导致竞态问题 解决: watch 的回调函数设计了第三个参数,即 onInvalidate。它是一个函数,用来注册过期回调。 每当 watch 的回调函数执行之前,会优先执行用户通过 onInvalidate 注册的过期回调。 用户就有机会在过期回调中将上一次的副作用标记为“过期”,从而解决竞态问题。
function watch(source, cb, options = {}) { let getter; // 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getter if (typeof source === 'function') { getter = source; } else { getter = () => traverse(source); } let oldValue, newValue; let cleanup;
// **解决**: 过期的副作用函数,导致竞态问题。 function onInvalidate(fn) { // 将过期回调存储到cleanup中 cleanup = fn; }
const job = () => { // 在scheduler 中重新执行副作用函数,得到的是新值 newValue = effectFn(); // 将新值和旧值作为回调函数的参数 cb(newValue, oldValue, onInvalidate); // 更新旧值,不然下一次会得到错误的旧值 oldValue = newValue; }; const effectFn = effect(() => getter(), { lazy: true, scheduler: () => { // 在调度函数中判断flush是否为post, 如果是,将其放到微任务队列中执行 if (options.flush === 'post') { const p = Promise.resolve(); p.then(job); } else { job(); } }, }); if (options.immediate) { // 当immediate 为true时立即执行job,从而触发回调执行 job(); } else { // 手动调用副作用函数,得到的值就是旧值 oldValue = effectFn(); } }
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 key in value) { traverse(value[k], seen); } return value; }