reactive 实现原理
渐进式实现 vue 的响应式系统
V1 依赖追踪
// 存储副作用的桶const bucket = new WeakMap(); // {target: map} 原始对象: map// 为什么weakmap是弱引用的
// 存储被注册的副作用函数let activeEffect;
// 注册副作用函数
function effect(fn) { // 当调用effect时 activeEffect被激活 activeEffect = fn; // 执行副作用 fn();}
const data = { text: 'hello world', ok: true };
// 代理原始数据const obj = new Proxy(data, { get(target, key, receiver) { track(target, key); return Reflect.get(target, key, receiver); }, set(target, key, value, receiver) { // 先set, 下面获取到的才是新的值 const result = Reflect.set(target, key, value, receiver); trigger(target, key); return result; },});
// 存储副作用,方便后续追踪function track(target, key) { // activeEffect 副作用函数没有激活, 直接return if (!activeEffect) return;
// 把target存到桶里去 (先看桶里原来有没有) let depMap = bucket.get(target); // 没有,则添加 !depMap && bucket.set(target, (depMap = new Map())); // 如果有, 则看key
// map里存储的 key和 Set let deps = depMap.get(key); // 没有, 则添加 !deps && depMap.set(key, (deps = new Set()));
// 将当前激活的副作用添加到桶里 deps.add(activeEffect);}
// 执行副作用 触发变化function trigger(target, key) { // 同理取出 target对应的map(存放诸多key和effect的map) const depMap = bucket.get(target); // 没有就返回 if (!depMap) return;
// 取出 key 对应的 set(effect) const effects = depMap.get(key); // 遍历执行所有副作用 effects.forEach(fn => fn());}
effect(() => { console.log('副作用执行了:', obj.ok ? obj.text : 'not ok');});setTimeout(() => { obj.ok= false; obj.text = 'hello reactive'}, 1000);
问题
一开始 obj.ok 是 true, 读取了 obj.ok 和 obj.text,导
致这两个属性的依赖集合 Set 都收集了这个副作用函数 actviceEffect
1 秒后,obj.ok 改成 false,副作用函数会重新执行

但是副作用里已经不需要读取 obj.text 了,理想情况下,此时修改 obj.text 不应该再触发这个副作用函数。
原因
obj.text 的依赖集合里还残留着这个函数,导致依赖会被触发,产生了冗余的副作用,会导致副作用函数进行不必要的更新

解决思路
在每次副作用函数重新执行前,清除上一次建立的响应联系,而当副作用函数重新执行后,会再次建立新的响应联系,新的响应联系不存在冗余副作用问题
V2 分支切换与嵌套处理
1.修改 effect 函数, 给副作用函数挂在一个 deps 数组,用来存储它的所有依赖结合。并在执行前调用 cleanup 进行清理
2.cleanup 中重置 effectFn.deps(多个存放 key 和 Set 的 map 数组)

/* 主要流程 1.代理数据 2.执行副作用effect(()=>{xxx})中的()=>{xxx} 3.若副作用中读取响应式数据,触发track 收集依赖(副作用函数也收集到了其关联的Set) 4.若交互修改了响应式数据,触发trigger执行副作用 触发变化 6.清空副作用函数收集到的关联的Set,最后执行fn(因为fn里又有读取响应式数据,所所有先清空依赖再重新收集新的)*/
// 存储副作用的桶const bucket = new WeakMap(); // {target: map} 原始对象: map// 为什么weakmap是弱引用的
// 存储被注册的副作用函数let activeEffect;
// 注册副作用函数function effect(fn) { const effectFn = () => { // 清除旧的响应联系 trigger触发effectFn执行 cleanup(effectFn);
// effectFn执行时,设置为当前激活的副作用函数 // 将包装好的函数赋值给全局变量 activeEffect = effectFn; // 执行副作用 fn(); }; // 存储所有与该副作用相关的依赖集合(需要副作用函数自己记住被放到了那个依赖集合Set里) effectFn.deps = [];
// 执行副作用函数 effectFn();}
const data = { text: 'hello world', ok: true };
// 主流程第一步:代理原始数据const obj = new Proxy(data, { get(target, key, receiver) { track(target, key); return Reflect.get(target, key, receiver); }, set(target, key, value, receiver) { // 先set, 下面获取到的才是新的值 const result = Reflect.set(target, key, value, receiver); trigger(target, key); return result; },});
// 主流程第三步:收集依赖:存储副作用,方便后续追踪function track(target, key) { // activeEffect 副作用函数没有激活, 直接return if (!activeEffect) return;
// 把target存到桶里去 (先看桶里原来有没有) let depMap = bucket.get(target); // 没有,则添加 !depMap && bucket.set(target, (depMap = new Map())); // 如果有, 则看key
// map里存储的 key和 Set let deps = depMap.get(key); // 没有, 则添加 !deps && depMap.set(key, (deps = new Set()));
// 将当前激活的副作用添加到桶里 deps.add(activeEffect);
// 新增:将当前副作用函数的依赖集合deps(引用地址),添加到effectFn.deps中 activeEffect.deps.push(deps);}
// 主流程第四步:交互修改了数据,执行副作用 触发变化function trigger(target, key) { // 同理取出 target对应的map(存放诸多key和effect的map) const depMap = bucket.get(target); // 没有就返回 if (!depMap) return;
// 取出 key 对应的 set(effect) const effects = depMap.get(key); // 遍历执行所有副作用 /* effects.forEach(fn => fn()); 执行fn时,先调用cleanup将函数从Set中删除, 然后执行fn()又触发了track,把fn又重新加回了Set里 原因:如果一个值已经被访问过了,但是这个值被删除并重新添加到集合里, 如果此时forEach遍历没有结束,那么这个值会重新被访问 从列表拿出一个任务-》删除任务-》执行任务-》任务又自己塞回列表末尾-》接着拿任务 */ // 新增: 构造一个新的set集合并遍历,防止无限循环 const effectsToRun = new Set(effects); effectsToRun.forEach(effectFn => effectFn());}
// 清理旧的响应联系函数function cleanup(effectFn) { // effectFn.deps 是多个 key和set的map数组 for (let i = 0; i < effectFn.deps.length; i++) { // 依赖集合Set (每个具体的set,因为Set是target对应map内对应key的Set引用地址) const deps = effectFn.deps[i]; // 这里其实就是删除对应依赖集合中的当前副作用函数 deps.delete(effectFn); } // 重置effectFn.deps数组 没有使用=[]因为原修改减少gc优化性能 effectFn.deps.length = 0;}//主流程第二步:执行副作用effect(() => { console.log('副作用执行了:', obj.ok ? obj.text : 'not ok');});setTimeout(() => { obj.ok = false; obj.text = 'hello reactive'; // 设置虽然设置text的值,但是因为ok为false,不会读取到text的值,text一直都是旧的,只有在读取到text的时候才会更新}, 1000);问题
1.嵌套导致的 activeEffect 被覆盖
原因
activeEffect 全局变量存储正在执行的副作用函数,在父子组件嵌套时,内层(子组件)的 activeEffect 执行完后无法及时归还给外层(父组件)的
let temp1, temp2;
// effect1 (模拟父组件)effect(() => { console.log('effect1 执行');
// effect2 (模拟子组件) effect(() => { console.log('effect2 执行'); temp2 = obj.bar; // 读取 bar,收集了 effect2 });
// 重点:回到 effect1 继续执行 temp1 = obj.foo; // 读取 foo,此时 activeEffect 是谁?});- 理想情况:
bar应该收集effect2,foo应该收集effect1。 - 现实情况:当
effect2执行完后,activeEffect**依然指向 **effect2。导致foo也收集了effect2。一旦foo发生变化,effect2会重新执行,而effect1却再也不会被触发了

解决思路
引入一个栈结构,在调用副作用函数之前将当前副作用函数压入栈中,当一个副作用函数执行完毕后,将其从栈中弹出,并把 activeEffect 还原为上一个值

// 存储副作用的桶const bucket = new WeakMap(); // {target: map} 原始对象: map// 为什么weakmap是弱引用的
// 存储被注册的副作用函数let activeEffect;// 新增:副作用函数栈const effectStack = [];
// 注册副作用函数function effect(fn, name = '?') { const effectFn = () => { // 清除旧的响应联系 trigger触发effectFn执行 cleanup(effectFn);
// effectFn执行时,设置为当前激活的副作用函数 // 1.将包装好的函数赋值给全局变量 activeEffect = effectFn; effectFn.name= name;
// 2.调用之前,将当前副作用函数压入栈中 effectStack.push(effectFn); // 执行副作用 fn();
// 3.执行完毕后,将当前副作用函数从栈中弹出 effectStack.pop(); // 4.将activeEffect 还原为栈顶函数(外层副作用函数) activeEffect = effectStack[effectStack.length - 1]; }; // 存储所有与该副作用相关的依赖集合(需要副作用函数自己记住被放到了那个依赖集合Set里) effectFn.deps = [];
// 执行副作用函数 effectFn();}
const data = { text: 'hello world', ok: true, foo: 1, bar: 2 };
// 主流程第一步:代理原始数据const obj = new Proxy(data, { get(target, key, receiver) { track(target, key); return Reflect.get(target, key, receiver); }, set(target, key, value, receiver) { // 先set, 下面获取到的才是新的值 const result = Reflect.set(target, key, value, receiver); trigger(target, key); return result; },});
// 主流程第三步:收集依赖:存储副作用,方便后续追踪function track(target, key) { // activeEffect 副作用函数没有激活, 直接return if (!activeEffect) return;
// 把target存到桶里去 (先看桶里原来有没有) let depMap = bucket.get(target); // 没有,则添加 !depMap && bucket.set(target, (depMap = new Map())); // 如果有, 则看key
// map里存储的 key和 Set let deps = depMap.get(key); // 没有, 则添加 !deps && depMap.set(key, (deps = new Set()));
// 将当前激活的副作用添加到桶里 deps.add(activeEffect);
// 将当前副作用函数的依赖集合deps(引用地址),添加到effectFn.deps中 activeEffect.deps.push(deps);
console.log(` [track] 读取 "${key}" → 当前 activeEffect = ${activeEffect.name?? '?'}`);}
// 主流程第四步:交互修改了数据,执行副作用 触发变化function trigger(target, key) { // 同理取出 target对应的map(存放诸多key和effect的map) const depMap = bucket.get(target); // 没有就返回 if (!depMap) return;
// 取出 key 对应的 set(effect) const effects = depMap.get(key); // 遍历执行所有副作用 /* effects.forEach(fn => fn()); 执行fn时,先调用cleanup将函数从Set中删除, 然后执行fn()又触发了track,把fn又重新加回了Set里 原因:如果一个值已经被访问过了,但是这个值被删除并重新添加到集合里, 如果此时forEach遍历没有结束,那么这个值会重新被访问 从列表拿出一个任务-》删除任务-》执行任务-》任务又自己塞回列表末尾-》接着拿任务 */ // 构造一个新的set集合并遍历,防止无限循环 const effectsToRun = new Set(effects); effectsToRun.forEach(effectFn => effectFn());}
// 清理旧的响应联系函数function cleanup(effectFn) { // effectFn.deps 是多个 key和set的map数组 for (let i = 0; i < effectFn.deps.length; i++) { // 依赖集合Set (每个具体的set,因为Set是target对应map内对应key的Set引用地址) const deps = effectFn.deps[i]; // 这里其实就是删除对应依赖集合中的当前副作用函数 deps.delete(effectFn); } // 重置effectFn.deps数组 没有使用=[]因为原修改减少gc优化性能 effectFn.deps.length = 0;}//主流程第二步:执行副作用let temp1, temp2;effect(() => { console.log('effect1 执行');
// effect2 (模拟子组件) effect(() => { console.log('effect2 执行'); temp2 = obj.bar; // 读取 bar,收集了 effect2 }, 'effect2');
// 重点:回到 effect1 继续执行 console.log('--- 回到 effect1,接下来读取 obj.foo ---'); temp1 = obj.foo; // 读取 foo,此时 activeEffect 是谁?}, 'effect1');
setTimeout(() => { // obj.ok = false; // obj.text = 'hello reactive'; // 设置虽然设置text的值,但是因为ok为false,不会读取到text的值,text一直都是旧的,只有在读取到text的时候才会更新}, 1000);
/* 主要流程 1.代理数据 2.执行副作用effect(()=>{xxx})中的()=>{xxx} 3.若副作用中读取响应式数据,触发track 收集依赖(副作用函数也收集到了其关联的Set) 4.若交互修改了响应式数据,触发trigger执行副作用 触发变化 6.清空副作用函数收集到的关联的Set,最后执行fn(因为fn里又有读取响应式数据,所所有先清空依赖再重新收集新的)*/2.无限递归导致栈溢出
原因
如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
const obj = reactive({ foo: 1 });effect(() => { obj.foo++; // 这一行等于:obj.foo = obj.foo + 1});**读取 **obj.foo:触发 track,将副作用函数收集。
**设置 **obj.foo:触发 trigger,尝试重新执行该副作用函数。
结果:此时该副作用函数正在执行中,trigger 又立刻叫它再跑一遍。这就导致了无限递归调用,最后报 Maximum call stack size exceeded 错误。
解决思路
在 trigger 中增加判断
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());}V3 调度系统
const obj = reactive({ foo: 1 })
effect(() => { console.log(obj.foo)})
obj.foo++console.log('结束')目前输出的顺序是 1-》2-》结束
如果我希望顺序变成 1-》结束-》2, 或者连续执行 obj.foo++ 100 次,effect 跟着执行 100 次是极大的性能浪费
需要调度机制,让 effect 能够受控的执行
实现思路
1.像 effect.deps 一样,给 effect 增加第二个参数 options,用来传递调度器 scheduler
function effect(fn, options = {}) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn effectStack.push(effectFn) fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1] } // 将 options 挂载到 effectFn 上,方便在 trigger 中读取 effectFn.options = options effectFn.deps = [] effectFn()}2.在 trigger 中触发调度
在触发更新时,不再直接调用 effectFn(),而是检查它有没有配置 scheduler。
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() } })}3.异步派发(连续修改,只执行一次)
// 存储副作用的桶const bucket = new WeakMap(); // {target: map} 原始对象: map// 为什么weakmap是弱引用的
// 存储被注册的副作用函数let activeEffect;// 副作用函数栈const effectStack = [];
// 注册副作用函数function effect(fn, options) { const effectFn = () => { // 清除旧的响应联系 trigger触发effectFn执行 cleanup(effectFn);
// effectFn执行时,设置为当前激活的副作用函数 // 1.将包装好的函数赋值给全局变量 activeEffect = effectFn;
// 2.调用之前,将当前副作用函数压入栈中 effectStack.push(effectFn); // 执行副作用 fn();
// 3.执行完毕后,将当前副作用函数从栈中弹出 effectStack.pop(); // 4.将activeEffect 还原为栈顶函数(外层副作用函数) activeEffect = effectStack[effectStack.length - 1]; }; // 存储所有与该副作用相关的依赖集合(需要副作用函数自己记住被放到了那个依赖集合Set里) effectFn.deps = []; // 新增: 挂载options effectFn.options = options; // 执行副作用函数 effectFn(); return effectFn;}
const data = { text: 'hello world', ok: true, foo: 1, bar: 2 };
// 主流程第一步:代理原始数据const obj = new Proxy(data, { get(target, key, receiver) { track(target, key); return Reflect.get(target, key, receiver); }, set(target, key, value, receiver) { // 先set, 下面获取到的才是新的值 const result = Reflect.set(target, key, value, receiver); trigger(target, key); return result; },});
// 主流程第三步:收集依赖:存储副作用,方便后续追踪function track(target, key) { // activeEffect 副作用函数没有激活, 直接return if (!activeEffect) return;
// 把target存到桶里去 (先看桶里原来有没有) let depMap = bucket.get(target); // 没有,则添加 !depMap && bucket.set(target, (depMap = new Map())); // 如果有, 则看key
// map里存储的 key和 Set let deps = depMap.get(key); // 没有, 则添加 !deps && depMap.set(key, (deps = new Set()));
// 将当前激活的副作用添加到桶里 deps.add(activeEffect);
// 将当前副作用函数的依赖集合deps(引用地址),添加到effectFn.deps中 activeEffect.deps.push(deps);}
// 主流程第四步:交互修改了数据,执行副作用 触发变化function trigger(target, key) { // 同理取出 target对应的map(存放诸多key和effect的map) const depMap = bucket.get(target); // 没有就返回 if (!depMap) return;
// 取出 key 对应的 set(effect) const effects = depMap.get(key); // 遍历执行所有副作用 /* effects.forEach(fn => fn()); 执行fn时,先调用cleanup将函数从Set中删除, 然后执行fn()又触发了track,把fn又重新加回了Set里 原因:如果一个值已经被访问过了,但是这个值被删除并重新添加到集合里, 如果此时forEach遍历没有结束,那么这个值会重新被访问 从列表拿出一个任务-》删除任务-》执行任务-》任务又自己塞回列表末尾-》接着拿任务 */ // 构造一个新的set集合并遍历,防止无限循环 const effectsToRun = new Set(); // 如果trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不执 effects && effects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn); } });
effectsToRun.forEach(effectFn => { // 如果存在调度器,则将执行权交给调度器 if (effectFn.options.scheduler) { effectFn.options.scheduler(effectFn); } else { effectFn(); } });}
// 清理旧的响应联系函数function cleanup(effectFn) { // effectFn.deps 是多个 key和set的map数组 for (let i = 0; i < effectFn.deps.length; i++) { // 依赖集合Set (每个具体的set,因为Set是target对应map内对应key的Set引用地址) const deps = effectFn.deps[i]; // 这里其实就是删除对应依赖集合中的当前副作用函数 deps.delete(effectFn); } // 重置effectFn.deps数组 没有使用=[]因为原修改减少gc优化性能 effectFn.deps.length = 0;}
/* 任务队列 start */const jobQueue = new Set(); // 任务队列自动去重const p = Promise.resolve(); // 创建一个微任务let isFlushing = false; // 是否正在刷新队列
function flushJob() { if (isFlushing) return; isFlushing = true;
p.then(() => { // 在一个微任务中一次性执行读完队列里所有的job jobQueue.forEach(job => job()); }).finally(() => { isFlushing = false; });}/* 任务队列 end */
//主流程第二步:执行副作用effect( () => { console.log('obj.foo的值为:', obj.foo); obj.foo++; obj.foo++; obj.foo++; obj.foo++; obj.foo++; // 这里读取并设置了这么多次, // 在第一次执行jobQueue时,jobQueue就被设置为了true, // 随后p.then里面的函数被推入微任务队列, // 任何继续执行同步代码obj.foo++ // fn被继续添加到flushJob中,但是被Set去重了 // 同步任务执行完毕后,推入微任务队列中的函数开始执行,此时obj.foo已经是 }, { scheduler(fn) { jobQueue.add(fn); flushJob(); }, });
/* 主要流程 1.代理数据 2.执行副作用effect(()=>{xxx})中的()=>{xxx} 3.若副作用中读取响应式数据,触发track 收集依赖(副作用函数也收集到了其关联的Set) 4.若交互修改了响应式数据,触发trigger执行副作用 触发变化 6.清空副作用函数收集到的关联的Set,最后执行fn(因为fn里又有读取响应式数据,所所有先清空依赖再重新收集新的)*/
v4 computed 计算属性
基础:Lazy 延迟执行
之前的 effect 是立即执行的。但对于计算属性,我们希望只有在读取它的值时,才去计算。
修改 effect 函数
我们需要让 effect 返回包装后的 effectFn,这样我们就能手动执行它并拿到返回值。
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 = []
// 如果不是 lazy,才立即执行 if (!options.lazy) { effectFn() } return effectFn // 返回副作用函数}核心:实现缓存机制
计算属性最大的特点是:如果依赖没变,多次访问应该直接返回缓存。
两个关键变量:
value:用来缓存上一次计算的结果。dirty:一个标志位,true表示“脏了”(依赖变了,需要重新计算),false表示“干净”(直接拿缓存)。
function computed(getter) { let value let dirty = true // 默认是脏的,第一次读取需要计算
// 把 getter 作为副作用函数 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}解决嵌套失效问题
问题
计算属性没有因为内部值的变化而更新
const sum = computed(() => obj.foo + obj.bar)
effect(() => { console.log(sum.value) // 在另一个 effect 中使用计算属性})
obj.foo++ // 修改依赖,会导致 effect 重新执行吗?答案是:不会。 因为 sum.value 只是一个普通对象的 getter,它并没有把外层的 effect 收集到自己的依赖里。当 obj.foo 变化时,只会触发 computed 内部的 scheduler(把 dirty 设为 true),但没人告诉外层 effect 该刷新了。

解决方案
手动 track 和 trigger:当读取 computed 时,我们手动调用 track;当 computed 依赖变化时,在 scheduler 里手动调用 trigger。
function computed(getter) { let value let dirty = true
const effectFn = effect(getter, { lazy: true, scheduler() { dirty = true // 当依赖变化时,手动触发指向 computed 对象本身的更新 trigger(obj, 'value') } })
const obj = { get value() { if (dirty) { value = effectFn() dirty = false } // 当读取 value 时,手动追踪 track(obj, 'value') return value } }
return obj}4.总结 computed 的运行逻辑
- 初始化:创建一个 lazy 的
effect。 - 首次读取:触发
getter,dirty变false,缓存结果。同时,如果是在另一个effect里读取的,会把那个effect收集到computed的依赖桶里。 - 依赖变更:底层数据(如
obj.foo)变了,触发computed的scheduler。此时dirty变回true,并通知外层effect更新。 - 再次读取:外层
effect重新执行,读取sum.value,因为此时dirty是true,重新计算,拿到最新值。
5.为什么 computed 不直接用 reactive 包裹?
其实 Vue 内部确实可以这样做,但 computed 有它特殊的内部逻辑(比如 dirty 标志位的判断、延迟计算等)。为了精准控制什么时候该重新计算、什么时候该通知外部更新,手动编写 track 和 trigger 是最清晰、性能最高的方式。
reactive 像是一条声控灯走廊:只要你走过去(读取),灯就自动感应并记住你(track)。
computed 像是一个带开关的密室:你进去后,必须手动按一下墙上的登记表(显式调用 track),外面的管理员(effect)才知道你在里面。如果你不按,外面的人就永远不知道里面发生了什么。
// 存储副作用的桶const bucket = new WeakMap(); // {target: map} 原始对象: map// 为什么weakmap是弱引用的
// 存储被注册的副作用函数let activeEffect;// 副作用函数栈const effectStack = [];
// 注册副作用函数function effect(fn, options = {}) { const effectFn = () => { // 清除旧的响应联系 trigger触发effectFn执行 cleanup(effectFn);
// effectFn执行时,设置为当前激活的副作用函数 // 1.将包装好的函数赋值给全局变量 activeEffect = effectFn;
// 2.调用之前,将当前副作用函数压入栈中 effectStack.push(effectFn); // 执行副作用 难道用户函数的返回值 const res = fn();
// 3.执行完毕后,将当前副作用函数从栈中弹出 effectStack.pop(); // 4.将activeEffect 还原为栈顶函数(外层副作用函数) activeEffect = effectStack[effectStack.length - 1]; // 返回结果 return res; }; // 存储所有与该副作用相关的依赖集合(需要副作用函数自己记住被放到了那个依赖集合Set里) effectFn.deps = []; // 新增: 挂载options effectFn.options = options; // 不是 lazy, 才立即执行副作用函数 if (!options.lazy) { effectFn(); } // 返回副作用函数 return effectFn;}
const data = { text: 'hello world', ok: true, foo: 1, bar: 2 };
// 主流程第一步:代理原始数据const obj = new Proxy(data, { get(target, key, receiver) { track(target, key); return Reflect.get(target, key, receiver); }, set(target, key, value, receiver) { // 先set, 下面获取到的才是新的值 const result = Reflect.set(target, key, value, receiver); trigger(target, key); return result; },});
// 主流程第三步:收集依赖:存储副作用,方便后续追踪function track(target, key) { // activeEffect 副作用函数没有激活, 直接return if (!activeEffect) return;
// 把target存到桶里去 (先看桶里原来有没有) let depMap = bucket.get(target); // 没有,则添加 !depMap && bucket.set(target, (depMap = new Map())); // 如果有, 则看key
// map里存储的 key和 Set let deps = depMap.get(key); // 没有, 则添加 !deps && depMap.set(key, (deps = new Set()));
// 将当前激活的副作用添加到桶里 deps.add(activeEffect);
// 将当前副作用函数的依赖集合deps(引用地址),添加到effectFn.deps中 activeEffect.deps.push(deps);}
// 主流程第四步:交互修改了数据,执行副作用 触发变化function trigger(target, key) { // 同理取出 target对应的map(存放诸多key和effect的map) const depMap = bucket.get(target); // 没有就返回 if (!depMap) return;
// 取出 key 对应的 set(effect) const effects = depMap.get(key); // 遍历执行所有副作用 /* effects.forEach(fn => fn()); 执行fn时,先调用cleanup将函数从Set中删除, 然后执行fn()又触发了track,把fn又重新加回了Set里 原因:如果一个值已经被访问过了,但是这个值被删除并重新添加到集合里, 如果此时forEach遍历没有结束,那么这个值会重新被访问 从列表拿出一个任务-》删除任务-》执行任务-》任务又自己塞回列表末尾-》接着拿任务 */ // 构造一个新的set集合并遍历,防止无限循环 const effectsToRun = new Set(); // 如果trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不执 effects && effects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn); } });
effectsToRun.forEach(effectFn => { // 如果存在调度器,则将执行权交给调度器 if (effectFn.options.scheduler) { effectFn.options.scheduler(effectFn); } else { effectFn(); } });}
// 清理旧的响应联系函数function cleanup(effectFn) { // effectFn.deps 是多个 key和set的map数组 for (let i = 0; i < effectFn.deps.length; i++) { // 依赖集合Set (每个具体的set,因为Set是target对应map内对应key的Set引用地址) const deps = effectFn.deps[i]; // 这里其实就是删除对应依赖集合中的当前副作用函数 deps.delete(effectFn); } // 重置effectFn.deps数组 没有使用=[]因为原修改减少gc优化性能 effectFn.deps.length = 0;}
/* 任务队列 start */const jobQueue = new Set(); // 任务队列自动去重const p = Promise.resolve(); // 创建一个微任务let isFlushing = false; // 是否正在刷新队列
function flushJob() { if (isFlushing) return; isFlushing = true;
p.then(() => { // 在一个微任务中一次性执行读完队列里所有的job jobQueue.forEach(job => job()); }).finally(() => { isFlushing = false; });}/* 任务队列 end */
/* computed start */function computed(getter) { // getter为调用computed传入的副作用函数 let value; let dirty = true; // 第一次需要读取计算,默认为true
// effectFn = xxx, 也就是将effect声明中return effectFn的目的 const effectFn = effect(getter, { lazy: true, scheduler() { dirty = true; // 当依赖变化时,手动触发指向 computed 对象本身的更新 trigger(obj, 'value'); }, }); const obj = { get value() { if (dirty) { //只有执行了这里 执行obj.xx, 触发track,将getter收集到依赖中 value = effectFn(); // 副作用函数在dirty为true才会执行, dirty = false; } // 这里返回的value是一个普通对象的getter /* effect(() => { console.log(sum.value); });
没有把这个effect添加到依赖中,obj.foo变了,dirty设置为了true,但是没有依赖可以触发
原因: obj是一个普通的带有get value()的对象,没有通过new Proxy代理, 没有track可以执行,也就没法收集sum的依赖 */ // 读取value时,手动追踪 track(obj, 'value'); return value; }, }; return obj;}/* computed end */
const sum = computed(() => obj.foo + obj.bar);//主流程第二步:执行副作用effect(() => { console.log('sum.value:', sum.value);});
obj.foo++;/* 主要流程 1.代理数据 2.执行副作用effect(()=>{xxx})中的()=>{xxx} 3.若副作用中读取响应式数据,触发track 收集依赖(副作用函数也收集到了其关联的Set) 4.若交互修改了响应式数据,触发trigger执行副作用 触发变化 6.清空副作用函数收集到的关联的Set,最后执行fn(因为fn里又有读取响应式数据,所所有先清空依赖再重新收集新的)*/
V5 watch 侦听器及清理机制
阶段一:基础侦听
最简单的 watch 只需要以下两步:
- 触发追踪:读取响应式数据,让
effect收集依赖。 - 触发回调:在数据变化时,通过
scheduler执行用户传入的cb。
function watch(source, cb) { effect( // 1. 这里的执行会触发读取,从而进行 track() => source.foo, { scheduler() { // 2. 当 source.foo 变化时,执行回调 cb() } } )}
// 使用:watch(obj, () => { console.log('数据变了') })obj.foo++阶段二:递归观测(Traverse)
上面的实现有个硬伤:如果 source 是一个对象,你只写 () => source 是没用的。因为响应式系统只会在你读取具体某个属性(如 source.foo)时才进行 track。
我们需要一个通用的 traverse**(递归遍历)** 函数,把对象里的每一个属性都“摸一遍”。
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) { effect( // 调用 traverse 递归读取,完成全量依赖收集 () => traverse(source), { scheduler() { cb() } } )}阶段三:获取新值与旧值(New/Old Value)
这是 watch 最常用的功能:cb(newValue, oldValue)。
要实现它,我们需要利用 effect 的 lazy 选项。手动控制 effect 的执行,从而在变化发生前拿到旧值,变化发生后拿到新值。
function watch(source, cb) { let getter // 如果 source 是函数,说明是 getter(如 () => obj.foo)// 如果 source 是对象,则递归遍历if (typeof source === 'function') { getter = source } else { getter = () => traverse(source) }
let oldVal, newVal
// 使用 effect 的 lazy 选项 const effectFn = effect(getter, { lazy: true, scheduler() { // 在 scheduler 中重新执行,获取新值 newVal = effectFn() cb(newVal, oldVal) // 执行完后,更新旧值,为下一次做准备 oldVal = newVal } })
// 第一次手动执行,拿到初始值作为“旧值” oldVal = effectFn()}阶段四:立即执行(immediate)
有时候我们希望 watch 创建时就立刻跑一遍回调(就像 Vue 里的 immediate: true)。
function watch(source, cb, options = {}) { let getter if (typeof source === 'function') { getter = source } else { getter = () => traverse(source) }
let oldVal, newVal
// 提取 job 逻辑const job = () => { newVal = effectFn() cb(newVal, oldVal) oldVal = newVal }
const effectFn = effect(getter, { lazy: true, scheduler: job // 变化时跑 job })
if (options.immediate) { // 立即跑一次 job job() } else { // 否则只是获取初始的 oldVal oldVal = effectFn() }}进阶:如何处理“竞态问题”与清理?
想象一下这个场景:
watch观测到了obj.foo的变化,发起了一个网络请求 A。- 请求 A 还没回来,
obj.foo又变了,触发了请求 B。 - 结果: 请求 B 先回来了,页面渲染了新数据;过了一会儿,请求 A 才回来,旧数据把新数据覆盖了。
Vue 提供了一个 onCleanup 机制来解决它。
实现 onCleanup
我们需要在调用 cb 时,传入一个函数让用户注册“清理动作”。
function watch(source, cb, options = {}) { let getter if (typeof source === 'function') { getter = source } else { getter = () => traverse(source) }
let oldVal, newVal
// 1. 定义 cleanup 变量 let cleanup function onCleanup(fn) { cleanup = fn }
const job = () => { newVal = effectFn() // 2. 在执行回调前,先执行上一次注册的清理函数 if (cleanup) { cleanup() } // 3. 将 onCleanup 作为第三个参数传给用户 cb(newVal, oldVal, onCleanup) oldVal = newVal }
const effectFn = effect(getter, { lazy: true, scheduler: job })
if (options.immediate) { job() } else { oldVal = effectFn() }}使用
watch(obj, async (newVal, oldVal, onCleanup) => { let expired = false// 注册清理函数:当 watch 即将再次触发时,这个函数会先运行 onCleanup(() => { expired = true })
const res = await fetch('/api/data')
// 如果已经“过期”了,说明后面又有新的请求了,就不要更新视图了if (!expired) { data.value = res }})watch 第一个入参的对比
| 第一个参数 | getter 实际是什么 | 依赖收集范围 | 何时触发 | newVal / oldVal 含义 |
| 对象 `obj` | `() => traverse(obj)` | 整个对象所有属性(含嵌套) | 任意被遍历到的属性变化 | 当前/上一次的整个对象(同引用,需深拷贝才有真正旧值) |
| `() => obj` | `() => obj` | 无(未访问任何属性) | 不会再次触发 | 仅首次执行时有值,后续 obj 变化不触发 |
| `() => obj.foo` | 该函数本身 | 仅 `obj.foo` | `obj.foo` 变化 | 当前/上一次 getter 的返回值 |
| `() => obj.foo + obj.bar` | 该函数本身 | `obj.foo`、`obj.bar` | 二者之一变化 | 当前/上一次的计算结果 |
// 存储副作用的桶const bucket = new WeakMap(); // {target: map} 原始对象: map// 为什么weakmap是弱引用的
// 存储被注册的副作用函数let activeEffect;// 副作用函数栈const effectStack = [];
// 注册副作用函数function effect(fn, options = {}) { const effectFn = () => { // 清除旧的响应联系 trigger触发effectFn执行 cleanup(effectFn);
// effectFn执行时,设置为当前激活的副作用函数 // 1.将包装好的函数赋值给全局变量 activeEffect = effectFn;
// 2.调用之前,将当前副作用函数压入栈中 effectStack.push(effectFn); // 执行副作用 难道用户函数的返回值 const res = fn();
// 3.执行完毕后,将当前副作用函数从栈中弹出 effectStack.pop(); // 4.将activeEffect 还原为栈顶函数(外层副作用函数) activeEffect = effectStack[effectStack.length - 1]; // 返回结果 return res; }; // 存储所有与该副作用相关的依赖集合(需要副作用函数自己记住被放到了那个依赖集合Set里) effectFn.deps = []; // 新增: 挂载options effectFn.options = options; // 不是 lazy, 才立即执行副作用函数 if (!options.lazy) { effectFn(); } // 返回副作用函数 return effectFn;}
const data = { text: 'hello world', ok: true, foo: 1, bar: 2 };
// 主流程第一步:代理原始数据const obj = new Proxy(data, { get(target, key, receiver) { track(target, key); return Reflect.get(target, key, receiver); }, set(target, key, value, receiver) { // 先set, 下面获取到的才是新的值 const result = Reflect.set(target, key, value, receiver); trigger(target, key); return result; },});
// 主流程第三步:收集依赖:存储副作用,方便后续追踪function track(target, key) { // activeEffect 副作用函数没有激活, 直接return if (!activeEffect) return;
// 把target存到桶里去 (先看桶里原来有没有) let depMap = bucket.get(target); // 没有,则添加 !depMap && bucket.set(target, (depMap = new Map())); // 如果有, 则看key
// map里存储的 key和 Set let deps = depMap.get(key); // 没有, 则添加 !deps && depMap.set(key, (deps = new Set()));
// 将当前激活的副作用添加到桶里 deps.add(activeEffect);
// 将当前副作用函数的依赖集合deps(引用地址),添加到effectFn.deps中 activeEffect.deps.push(deps);}
// 主流程第四步:交互修改了数据,执行副作用 触发变化function trigger(target, key) { // 同理取出 target对应的map(存放诸多key和effect的map) const depMap = bucket.get(target); // 没有就返回 if (!depMap) return;
// 取出 key 对应的 set(effect) const effects = depMap.get(key); // 遍历执行所有副作用 /* effects.forEach(fn => fn()); 执行fn时,先调用cleanup将函数从Set中删除, 然后执行fn()又触发了track,把fn又重新加回了Set里 原因:如果一个值已经被访问过了,但是这个值被删除并重新添加到集合里, 如果此时forEach遍历没有结束,那么这个值会重新被访问 从列表拿出一个任务-》删除任务-》执行任务-》任务又自己塞回列表末尾-》接着拿任务 */ // 构造一个新的set集合并遍历,防止无限循环 const effectsToRun = new Set(); // 如果trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不执 effects && effects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn); } });
effectsToRun.forEach(effectFn => { // 如果存在调度器,则将执行权交给调度器 if (effectFn.options.scheduler) { effectFn.options.scheduler(effectFn); } else { effectFn(); } });}
// 清理旧的响应联系函数function cleanup(effectFn) { // effectFn.deps 是多个 key和set的map数组 for (let i = 0; i < effectFn.deps.length; i++) { // 依赖集合Set (每个具体的set,因为Set是target对应map内对应key的Set引用地址) const deps = effectFn.deps[i]; // 这里其实就是删除对应依赖集合中的当前副作用函数 deps.delete(effectFn); } // 重置effectFn.deps数组 没有使用=[]因为原修改减少gc优化性能 effectFn.deps.length = 0;}
/* 任务队列 start */const jobQueue = new Set(); // 任务队列自动去重const p = Promise.resolve(); // 创建一个微任务let isFlushing = false; // 是否正在刷新队列
function flushJob() { if (isFlushing) return; isFlushing = true;
p.then(() => { // 在一个微任务中一次性执行读完队列里所有的job jobQueue.forEach(job => job()); }).finally(() => { isFlushing = false; });}/* 任务队列 end */
/* traverse 递归函数 访问所有属性 start */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;}/* traverse 递归函数 end */
/* computed start */function computed(getter) { // getter为调用computed传入的副作用函数 let value; let dirty = true; // 第一次需要读取计算,默认为true
// effectFn = xxx, 也就是将effect声明中return effectFn的目的 const effectFn = effect(getter, { lazy: true, scheduler() { dirty = true; // 当依赖变化时,手动触发指向 computed 对象本身的更新 trigger(obj, 'value'); }, }); const obj = { get value() { if (dirty) { //只有执行了这里 执行obj.xx, 触发track,将getter收集到依赖中 value = effectFn(); // 副作用函数在dirty为true才会执行, dirty = false; } // 这里返回的value是一个普通对象的getter /* effect(() => { console.log(sum.value); });
没有把这个effect添加到依赖中,obj.foo变了,dirty设置为了true,但是没有依赖可以触发
原因: obj是一个普通的带有get value()的对象,没有通过new Proxy代理, 没有track可以执行,也就没法收集sum的依赖 */ // 读取value时,手动追踪 track(obj, 'value'); return value; }, }; return obj;}/* computed end */
/* watch start */function watch(source, cb, options = {}) { let getter; // 如果 source 是函数,说明是 getter(如 () => obj.foo) if (typeof source === 'function') getter = source; // 如果 source 是对象,则递归遍历 else getter = () => traverse(source);
let oldVal, newVal;
// 处理watch的异步静态问题 /* 1.watch 观测到了 obj.foo 的变化,发起了一个网络请求 A。
2.请求 A 还没回来,obj.foo 又变了,触发了请求 B。
3.结果: 请求 B 先回来了,页面渲染了新数据;过了一会儿,请求 A 才回来,旧数据把新数据覆盖了。 */ let cleanup; function onCleanup(fn) { cleanup = fn; }
const job = () => { // 重新执行获取新值 newVal = effectFn(); // 执行回调前,先执行上一次注册的清理函数 cleanup?.(); // 将 onCleanup 作为第三个参数传给用户 cb(newVal, oldVal, onCleanup); // 执行完之后,新的就变成老的了 oldVal = newVal; };
const effectFn = effect( // 1. 这里的副作用,在执行时触发读取,从而进行 track // () => source.foo, // 这里只能写具体的key很不方便 // () => traverse(source), // 进行全量的依赖收集 getter, { lazy: true, scheduler: job, /* 提取到job中 scheduler() { // 2. 当source.foo 变化时, 执行回调 // cb(); // 重新执行获取新值 newVal = effectFn(); cb(oldVal, newVal); // 执行完之后,新的就变成老的了 oldVal = newVal; }, */ } );
// 初始执行,初始值就是旧值 // oldVal = effectFn(); if (options.immediate) { // 立即跑一次 job job(); } else { oldVal = effectFn(); }}/* watch end */
watch( () => obj, async (newVal, oldVal, onCleanup) => { let expired = false; console.log('数据变了', newVal, oldVal); // 注册清理函数:当 watch 即将再次触发时,这个函数会先运行 onCleanup(() => { expired = true; }); // 发送请求... // const res = await fetch('/api/data'); // 如果已经“过期”了,说明后面又有新的请求了,就不要更新视图了 if (!expired) { // data.value = res; } }, { immediate: true, deep: true });obj.foo = 2;/* 主要流程 1.代理数据 2.执行副作用effect(()=>{xxx})中的()=>{xxx} 3.若副作用中读取响应式数据,触发track 收集依赖(副作用函数也收集到了其关联的Set) 4.若交互修改了响应式数据,触发trigger执行副作用 触发变化 6.清空副作用函数收集到的关联的Set,最后执行fn(因为fn里又有读取响应式数据,所所有先清空依赖再重新收集新的)*/v6 数组的响应式
数组它虽然本质上是对象,但它的语义和普通对象大不相同:
- 索引与长度的纠缠:设置
arr[100] = 1会隐含地改变length。 - 长度反向影响索引:设置
arr.length = 0会隐含地删除所有索引。 - 栈溢出陷阱:
push等方法既读取length又修改length,极易导致死循环。 - 对象身份错乱:
includes查不到原生对象的问题。
索引与 length 的联动
设置索引引发 length 变化
普通对象 obj.foo = 1 只是 SET。但数组 arr[0] = 1 可能是 SET(修改),也可能是 ADD(新增,如果索引大于当前长度)。
需要在 set 拦截器中区分操作类型。
function createReactive(obj) { return new Proxy(obj, { set(target, key, newVal, receiver) { const oldVal = target[key]
// 判断操作类型:// 如果是数组,且 key 是索引,看它是否小于 length// 如果小于,说明是 SET(修改);如果大于等于,说明是 ADD(新增)const type = Array.isArray(target) ? (Number(key) < target.length ? 'SET' : 'ADD') : Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'const res = Reflect.set(target, key, newVal, receiver)
// 只有当值真的变了,才触发 triggerif (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) { // 关键:把 type 和 newVal 传给 trigger trigger(target, key, type, newVal) } return res } // ... 其他拦截器 get, has, ownKeys 等 })}trigger 的响应(反向联动)
现在需要升级 trigger。
- 如果触发的是
ADD操作(比如arr[10] = 1),那么依赖length的副作用也应该被触发。 - 如果直接修改了
length(比如arr.length = 0),那么所有索引>= newLength的副作用都应该被触发(因为它们被删了)。
function trigger(target, key, type, newVal) { const depsMap = bucket.get(target) if (!depsMap) returnconst effectsToRun = new Set()
// 1. 常规逻辑:把 key 对应的副作用加进去// ... (省略常规 add 逻辑)// 2.【数组新增特性】如果是数组,且操作是 ADD,说明 length 变了// 需要触发那些依赖 "length" 属性的副作用if (type === 'ADD' && Array.isArray(target)) { const lengthEffects = depsMap.get('length') lengthEffects && lengthEffects.forEach(effectFn => { if (effectFn !== activeEffect) effectsToRun.add(effectFn) }) }
// 3.【数组长度修改】如果直接修改的是 length 属性if (Array.isArray(target) && key === 'length') { // 找出所有索引 >= 新 length 的副作用,把它们触发掉(因为这些元素被删了) depsMap.forEach((effects, key) => { if (key >= newVal) { effects.forEach(effectFn => { if (effectFn !== activeEffect) effectsToRun.add(effectFn) }) } }) }
// ... 执行 effectsToRun}遍历数组 (for…in)
对数组进行 for...in 循环时,会触发 ownKeys 拦截。 对于普通对象,我们用 ITERATE_KEY 追踪。但对于数组,决定循环次数的是 length。
ownKeys(target) { // 如果是数组,用 'length' 作为 key 去建立联系// 这样当 length 变化(比如 push),for...in 循环就会重新执行 track(target, Array.isArray(target) ? 'length' : ITERATE_KEY) return Reflect.ownKeys(target)}查找方法 (includes, indexOf)
const obj = {}const arr = reactive([obj])
console.log(arr.includes(arr[0])) // trueconsole.log(arr.includes(obj)) // false (居然是 false?)为什么?arr.includes 内部会通过 this(也就是 proxy)去访问索引。arr[0] 得到的是 obj 的代理对象。
arr.includes(arr[0])-> 代理对象 vs 代理对象 -> 匹配成功。arr.includes(obj)-> 代理对象 vs 原始对象 -> 匹配失败。
解决方案:重写数组方法 我们需要拦截这些方法,先试着在代理对象里找,找不到再去原始对象里找。
const arrayInstrumentations = {}
;['includes', 'indexOf', 'lastIndexOf'].forEach(method => { const originMethod = Array.prototype[method] arrayInstrumentations[method] = function(...args) { // 1. 在代理对象中查找let res = originMethod.apply(this, args)
if (res === false || res === -1) { // 2. 找不到?通过 this.raw 拿到原始数组,再去查找 res = originMethod.apply(this.raw, args) } return res }})
// 在 get 拦截器中使用function createReactive(obj) { return new Proxy(obj, { get(target, key, receiver) { // 支持通过 raw 访问原始对象if (key === 'raw') return target
// 如果是数组,且访问的是 instrumentations 里的方法,返回重写后的方法if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) { return arrayInstrumentations[key] }
// ... 常规 track 逻辑 } })}修改器方法的栈溢出 (push)
effect(() => { arr.push(1)})发生了什么?
push会读取length属性 -> Tracklength。push会设置length属性 -> Triggerlength。trigger发现length变了,重新执行effect。effect再次执行push-> 读取length-> 设置length…- 死循环。
解决方案:暂停追踪 在调用
push等方法期间,我们要人为屏蔽依赖收集。
let shouldTrack = true // 全局标记// 重写 push, pop, shift, unshift, splice;['push', 'pop', 'shift', 'unshift', 'splice'].forEach(method => { const originMethod = Array.prototype[method] arrayInstrumentations[method] = function(...args) { // 1. 暂停追踪 shouldTrack = false// 2. 执行原始方法const res = originMethod.apply(this, args) // 3. 恢复追踪 shouldTrack = truereturn res }})
// 修改 track 函数,配合 shouldTrackfunction track(target, key) { if (!activeEffect || !shouldTrack) return // 如果暂停了,直接返回// ...}总结
数组的响应式实现,本质上是对 Proxy 行为的“补丁”。因为 JS 引擎内部实现的数组逻辑(Length 自动更新等)对于 Proxy 来说是黑盒,需要手动去模拟和纠正这些行为:
- Trigger 补丁:手动处理
index和length的互相影响。 - Lookup 补丁:
includes要同时查代理和原值。 - Mutator 补丁:
push必须暂停依赖收集以防死循环。
// 存储副作用的桶const bucket = new WeakMap(); // {target: map} 原始对象: map// 为什么weakmap是弱引用的
// 存储被注册的副作用函数let activeEffect;// 副作用函数栈const effectStack = [];
// 注册副作用函数function effect(fn, options = {}) { const effectFn = () => { // 清除旧的响应联系 trigger触发effectFn执行 cleanup(effectFn);
// effectFn执行时,设置为当前激活的副作用函数 // 1.将包装好的函数赋值给全局变量 activeEffect = effectFn;
// 2.调用之前,将当前副作用函数压入栈中 effectStack.push(effectFn); // 执行副作用 难道用户函数的返回值 const res = fn();
// 3.执行完毕后,将当前副作用函数从栈中弹出 effectStack.pop(); // 4.将activeEffect 还原为栈顶函数(外层副作用函数) activeEffect = effectStack[effectStack.length - 1]; // 返回结果 return res; }; // 存储所有与该副作用相关的依赖集合(需要副作用函数自己记住被放到了那个依赖集合Set里) effectFn.deps = []; // 新增: 挂载options effectFn.options = options; // 不是 lazy, 才立即执行副作用函数 if (!options.lazy) { effectFn(); } // 返回副作用函数 return effectFn;}
const data = { text: 'hello world', ok: true, foo: 1, bar: 2 };
// 主流程第一步:代理原始数据const obj = new Proxy(data, { get(target, key, receiver) { // 支持通过 raw 访问原始对象 if (key === 'raw') return target;
// 如果是数组,且访问的是 instrumentations 里的方法,返回重写后的方法 if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) { return arrayInstrumentations[key]; }
track(target, key); return Reflect.get(target, key, receiver); }, set(target, key, newVal, receiver) { const oldVal = target[key]; const type = Array.isArray(target) ? // 如果是数组,key为索引,小于length表示修改SET,大于为新增 Number(key) < target ? 'SET' : 'ADD' : Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD';
// 先set, 下面获取到的才是新的值 const result = Reflect.set(target, key, newVal, receiver); // 只有当值真的变了,才触发 trigger // oldVal === oldVal || newVal === newVal js中只要 x为NaN时, x!==x if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) { // 关键:把 type 和 newVal 传给 trigger trigger(target, key, type, newVal); }
return result; },
ownKeys(target) { // 如果是数组,用 'length' 作为 key 去建立联系 // 这样当 length 变化(比如 push),for...in 循环就会重新执行 track(target, Array.isArray(target) ? 'length' : ITERATE_KEY); return Reflect.ownKeys(target); },});
// 主流程第三步:收集依赖:存储副作用,方便后续追踪function track(target, key) { // activeEffect 副作用函数没有激活, 直接return if (!activeEffect || !shouldTrack) return;
// 把target存到桶里去 (先看桶里原来有没有) let depMap = bucket.get(target); // 没有,则添加 !depMap && bucket.set(target, (depMap = new Map())); // 如果有, 则看key
// map里存储的 key和 Set let deps = depMap.get(key); // 没有, 则添加 !deps && depMap.set(key, (deps = new Set()));
// 将当前激活的副作用添加到桶里 deps.add(activeEffect);
// 将当前副作用函数的依赖集合deps(引用地址),添加到effectFn.deps中 activeEffect.deps.push(deps);}
// 主流程第四步:交互修改了数据,执行副作用 触发变化function trigger(target, key, type, newVal) { // 同理取出 target对应的map(存放诸多key和effect的map) const depMap = bucket.get(target); // 没有就返回 if (!depMap) return;
// 取出 key 对应的 set(effect) const effects = depMap.get(key); // 遍历执行所有副作用 /* effects.forEach(fn => fn()); 执行fn时,先调用cleanup将函数从Set中删除, 然后执行fn()又触发了track,把fn又重新加回了Set里 原因:如果一个值已经被访问过了,但是这个值被删除并重新添加到集合里, 如果此时forEach遍历没有结束,那么这个值会重新被访问 从列表拿出一个任务-》删除任务-》执行任务-》任务又自己塞回列表末尾-》接着拿任务 */ // 构造一个新的set集合并遍历,防止无限循环 const effectsToRun = new Set();
// 如果trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不执 // 1. 常规逻辑:把 key 对应的副作用加进去 effects && effects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn); } }); // 2.【数组新增特性】如果是数组,且操作是 ADD,说明 length 变了 if (type === 'ADD' && Array.isArray(target)) { const lengthEffects = depMap.get('length'); lengthEffects && lengthEffects.forEach(effectFn => { if (effectFn !== activeEffect) effectsToRun.add(effectFn); }); } // 3.【数组长度修改】如果直接修改的是 length 属性 if (Array.isArray(target) && key === 'length') { // 找出所有索引 >= 新 length 的副作用,把它们触发掉(因为这些元素被删了) depMap.forEach((effects, key) => { // newVal 是修改后的数组长度 if (key >= newVal) { effects.forEach(effectFn => { if (effectFn !== activeEffect) effectsToRun.add(effectFn); }); } }); }
effectsToRun.forEach(effectFn => { // 如果存在调度器,则将执行权交给调度器 if (effectFn.options.scheduler) { effectFn.options.scheduler(effectFn); } else { effectFn(); } });}
// 查找方法 (includes, indexOf)// 拦截这些方法,先试着在代理对象里找,找不到再去原始对象里找。const arrayInstrumentations = {};
['includes', 'indexOf', 'lastIndexOf'].forEach(method => { const originMethod = Array.prototype[method]; arrayInstrumentations[method] = function (...args) { // 1. 在代理对象中查找 let res = originMethod.apply(this, args);
if (res === false || res === -1) { // 2. 找不到?通过 this.raw 拿到原始数组,再去查找 res = originMethod.apply(this.raw, args); } return res; };});
// 修改器方法的栈溢出 (push)let shouldTrack = true; // 全局标记
// 重写 push, pop, shift, unshift, splice['push', 'pop', 'shift', 'unshift', 'splice'].forEach(method => { const originMethod = Array.prototype[method]; arrayInstrumentations[method] = function (...args) { // 1. 暂停追踪 shouldTrack = false; // 2. 执行原始方法 const res = originMethod.apply(this, args); // 3. 恢复追踪 shouldTrack = true; return res; };});
// 清理旧的响应联系函数function cleanup(effectFn) { // effectFn.deps 是多个 key和set的map数组 for (let i = 0; i < effectFn.deps.length; i++) { // 依赖集合Set (每个具体的set,因为Set是target对应map内对应key的Set引用地址) const deps = effectFn.deps[i]; // 这里其实就是删除对应依赖集合中的当前副作用函数 deps.delete(effectFn); } // 重置effectFn.deps数组 没有使用=[]因为原修改减少gc优化性能 effectFn.deps.length = 0;}
/* 任务队列 start */const jobQueue = new Set(); // 任务队列自动去重const p = Promise.resolve(); // 创建一个微任务let isFlushing = false; // 是否正在刷新队列
function flushJob() { if (isFlushing) return; isFlushing = true;
p.then(() => { // 在一个微任务中一次性执行读完队列里所有的job jobQueue.forEach(job => job()); }).finally(() => { isFlushing = false; });}/* 任务队列 end */
/* traverse 递归函数 访问所有属性 start */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;}/* traverse 递归函数 end */
/* computed start */function computed(getter) { // getter为调用computed传入的副作用函数 let value; let dirty = true; // 第一次需要读取计算,默认为true
// effectFn = xxx, 也就是将effect声明中return effectFn的目的 const effectFn = effect(getter, { lazy: true, scheduler() { dirty = true; // 当依赖变化时,手动触发指向 computed 对象本身的更新 trigger(obj, 'value'); }, }); const obj = { get value() { if (dirty) { //只有执行了这里 执行obj.xx, 触发track,将getter收集到依赖中 value = effectFn(); // 副作用函数在dirty为true才会执行, dirty = false; } // 这里返回的value是一个普通对象的getter /* effect(() => { console.log(sum.value); });
没有把这个effect添加到依赖中,obj.foo变了,dirty设置为了true,但是没有依赖可以触发
原因: obj是一个普通的带有get value()的对象,没有通过new Proxy代理, 没有track可以执行,也就没法收集sum的依赖 */ // 读取value时,手动追踪 track(obj, 'value'); return value; }, }; return obj;}/* computed end */
/* watch start */function watch(source, cb, options = {}) { let getter; // 如果 source 是函数,说明是 getter(如 () => obj.foo) if (typeof source === 'function') getter = source; // 如果 source 是对象,则递归遍历 else getter = () => traverse(source);
let oldVal, newVal;
// 处理watch的异步静态问题 /* 1.watch 观测到了 obj.foo 的变化,发起了一个网络请求 A。
2.请求 A 还没回来,obj.foo 又变了,触发了请求 B。
3.结果: 请求 B 先回来了,页面渲染了新数据;过了一会儿,请求 A 才回来,旧数据把新数据覆盖了。 */ let cleanup; function onCleanup(fn) { cleanup = fn; }
const job = () => { // 重新执行获取新值 newVal = effectFn(); // 执行回调前,先执行上一次注册的清理函数 cleanup?.(); // 将 onCleanup 作为第三个参数传给用户 cb(newVal, oldVal, onCleanup); // 执行完之后,新的就变成老的了 oldVal = newVal; };
const effectFn = effect( // 1. 这里的副作用,在执行时触发读取,从而进行 track // () => source.foo, // 这里只能写具体的key很不方便 // () => traverse(source), // 进行全量的依赖收集 getter, { lazy: true, scheduler: job, /* 提取到job中 scheduler() { // 2. 当source.foo 变化时, 执行回调 // cb(); // 重新执行获取新值 newVal = effectFn(); cb(oldVal, newVal); // 执行完之后,新的就变成老的了 oldVal = newVal; }, */ } );
// 初始执行,初始值就是旧值 // oldVal = effectFn(); if (options.immediate) { // 立即跑一次 job job(); } else { oldVal = effectFn(); }}/* watch end */
watch( () => obj, async (newVal, oldVal, onCleanup) => { let expired = false; console.log('数据变了', newVal, oldVal); // 注册清理函数:当 watch 即将再次触发时,这个函数会先运行 onCleanup(() => { expired = true; }); // 发送请求... // const res = await fetch('/api/data'); // 如果已经“过期”了,说明后面又有新的请求了,就不要更新视图了 if (!expired) { // data.value = res; } }, { immediate: true, deep: true });obj.foo = 2;/* 主要流程 1.代理数据 2.执行副作用effect(()=>{xxx})中的()=>{xxx} 3.若副作用中读取响应式数据,触发track 收集依赖(副作用函数也收集到了其关联的Set) 4.若交互修改了响应式数据,触发trigger执行副作用 触发变化 6.清空副作用函数收集到的关联的Set,最后执行fn(因为fn里又有读取响应式数据,所所有先清空依赖再重新收集新的)*/