14109 字
71 分钟
reactive实现原理
2026-03-01
2026-03-03
统计加载中...

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.oktrue, 读取了 obj.okobj.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 应该收集 effect2foo 应该收集 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 的运行逻辑#

  1. 初始化:创建一个 lazy 的 effect
  2. 首次读取:触发 getterdirtyfalse,缓存结果。同时,如果是在另一个 effect 里读取的,会把那个 effect 收集到 computed 的依赖桶里。
  3. 依赖变更:底层数据(如 obj.foo)变了,触发 computedscheduler。此时 dirty 变回 true,并通知外层 effect 更新。
  4. 再次读取:外层 effect 重新执行,读取 sum.value,因为此时 dirtytrue,重新计算,拿到最新值。

5.为什么 computed 不直接用 reactive 包裹?#

其实 Vue 内部确实可以这样做,但 computed 有它特殊的内部逻辑(比如 dirty 标志位的判断、延迟计算等)。为了精准控制什么时候该重新计算、什么时候该通知外部更新,手动编写 tracktrigger 是最清晰、性能最高的方式。

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 只需要以下两步:

  1. 触发追踪:读取响应式数据,让 effect 收集依赖。
  2. 触发回调:在数据变化时,通过 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)

要实现它,我们需要利用 effectlazy 选项。手动控制 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()
}
}

进阶:如何处理“竞态问题”与清理?#

想象一下这个场景:

  1. watch 观测到了 obj.foo 的变化,发起了一个网络请求 A
  2. 请求 A 还没回来,obj.foo 又变了,触发了请求 B
  3. 结果: 请求 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 数组的响应式#

数组它虽然本质上是对象,但它的语义和普通对象大不相同:

  1. 索引与长度的纠缠:设置 arr[100] = 1 会隐含地改变 length
  2. 长度反向影响索引:设置 arr.length = 0 会隐含地删除所有索引。
  3. 栈溢出陷阱push 等方法既读取 length 又修改 length,极易导致死循环。
  4. 对象身份错乱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])) // true
console.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)
})

发生了什么?

  1. push 会读取 length 属性 -> Track length
  2. push 会设置 length 属性 -> Trigger length
  3. trigger 发现 length 变了,重新执行 effect
  4. effect 再次执行 push -> 读取 length -> 设置 length
  5. 死循环解决方案:暂停追踪 在调用 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 来说是黑盒,需要手动去模拟和纠正这些行为:

  1. Trigger 补丁:手动处理 indexlength 的互相影响。
  2. Lookup 补丁includes 要同时查代理和原值。
  3. 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里又有读取响应式数据,所所有先清空依赖再重新收集新的)
*/