一、开篇
想要更加深入了解 Vue3的响应原理,建议看一下 保姆式地带你从0到1实现Vue3 Effect,对你阅读这里的代码更容易理解
二、响应数据的完成代码
// 存储副作用的桶
const bucket = new WeakMap();
// 原始数据
const data = { text: "Hello World" };
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
// effect 栈
const effectStack = [];
// 对原始数据的代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, key);
// 返回属性值
return target[key];
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal;
// 把副作用从桶中取出并执行
trigger(target, key);
return true;
},
});
// 在get 拦截函数内调用 track 函数 追踪变化
function track(target, key) {
// 没有 activeEffect 直接 return
if (!activeEffect) return;
// 根据 target 从 桶中取出 depsMap,它也是一个map 类型,key-->effects
let depsMap = bucket.get(target);
// 如果 depsMap 不存在,创建一个新的map,并与 target 关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
// 再根据 key 从depsMap 中取得 deps,它是一个 Set 集合
// 里面存储着所有与当前 key 相关的副作用函数:effects
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 最后将当前激活的副作用函数添加到桶里
deps.add(activeEffect);
// deps 就是一个与当前副作用函数存在联系的依赖集合
// 将其添加到 effectFn.deps 数组中
activeEffect.deps.push(deps);
}
// 在 set 拦截函数内调用 trigger 函数 触发变化
function trigger(target, key) {
// 从桶中取出 depsMap,它也是一个map 类型,key-->effects
const depsMap = bucket.get(target);
if (!depsMap) return true;
// 再根据 key 从depsMap 中取得 副作用函数 effects
const effects = depsMap.get(key);
const effects2Run = new Set();
effects &&
effects.forEach((effectFn) => {
// 如果trigger触发执行的副作用函数与当前的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effects2Run.add(effectFn);
}
});
effects2Run.forEach((effectFn) => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn);
} else {
// 否则直接执行副作用函数
effectFn();
}
});
}
// 用于注册副作用函数
function effect(fn, options = {}) {
const effectFn = () => {
// 删除与 effectFn 相关的关系
cleanup(effectFn);
// 调用effect函数的时候,将 effectFn 赋值给 activeEffect
activeEffect = effectFn;
// 在调用副作用函数之前,将当前副作用函数添加到 effectStack 栈中
effectStack.push(effectFn);
// 执行副作用函数
fn();
// 在调用副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
};
// 将 options 挂在到 effectFn 上
effectFn.options = options;
// effectFn.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
// 只有非lazy的时候才会执行
if (!options.lazy) {
effectFn();
}
// 将副作用函数作为返回值返回
return effectFn;
}
function cleanup(effectFn) {
// 遍历 effectFn.deps 数组
effectFn.deps.forEach((deps) => {
// 将 effectFn 从依赖集合中删除
deps.delete(effectFn);
});
// 清空 effectFn.deps
effectFn.deps.length = 0;
}
const effectFn = effect(
() => {
document.body.innerText = obj.text;
},
{
lazy: true,
}
);
三、watch的实现
1. 需要实现的功能
const data = { foo: "foo", bar: "bar" };
const obj = new Proxy(data, {...})
// 第一种
watch(obj,function cb(preValue, value) {
console.log(preValue, value.foo)
})
// 第二种
watch(()=>obj.foo,function cb(preValue, value) {
console.log(preValue, value)
})
- 当监听 obj 值发生变化的时候,重新执行 cb并将上一个obj值和当前的obj值作为参数给 cb
- 当监听 obj.foo 值发生变化的时候,重新执行 cb并将上一个obj.foo值和当前的obj.foo值作为参数给 cb
1). 代码实现
function watch(source, cb) {
let getter;
if (typeof source === "function") {
// 如果是function,直接作为 getter
getter = source;
} else {
// 如果是对象,监听source内所以的属性
getter = () => traverse(source);
}
// 定义旧值和新值
let oldVal, newVal;
// 使用 effect 注册将副作用函数时候,开启 lazy 选项,并把返回值存储到 effectFn ,便于后面手动调用
const effectFn = effect(() => getter(), {
lazy: true,
scheduler: () => {
newVal = effectFn();
cb(oldVal, newVal);
oldVal = newVal;
},
});
// 手动调用,将执行的结果存储到 oldVal 中
oldVal = effectFn();
}
function traverse(source, seen = new Set()) {
// 如果要读取的数据是原始数据,或者已经被读取过,那么什么都不做
if (typeof source !== "object" || source === null || seen.has(source)) return;
// 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起死循环
seen.add(source);
// 暂时不考虑数组等的情况
// 假设 source 是一个对象,使用 for in 循环遍历读取每一个值,并递归调用 traverse 进行处理
for (let key in source) {
// 如果是对象,递归调用
traverse(source[key], seen);
}
return source;
}
// 第一种
watch(obj, function cb(preValue, value) {
console.log("第一种", preValue.foo, value.foo);
});
// 第二种
watch(
() => obj.foo,
function cb(preValue, value) {
console.log("第二种", preValue, value);
}
);
2)效果
可能眼尖小伙伴会发现,第一种preVal输出的和newVal是同一个值,是因为 preVal newVal 指向的是同一个引用类型。
2. 立即执行watch
1) 代码实现
2) 效果
3. 回调时机
post代表异步执行,sync 同步执行,就相当于直接执行job,pre 需要结合冬 需要结合组件更新执行
4. 过期的副作用
1)问题描述
watch(
() => obj.foo,
async function cb() {
const res = fetch("/get/user/list");
finalData = res;
}
);
obj.foo = "foo1";
setTimeout(() => {
obj.foo = "foo2";
}, 500);
变更两次 obj.foo 导致 watch 会执行两次,会发送两次 fetch1 fetch2,如果fetch1 响应 晚于 fetch2,那么会导致 finalData 最终获取的数据不是最新的。这个也就类似竞态问题
2) 问题分析
watch需要告诉在开发者,在哪个watch的cb是过期的副作用
3)代码实现
四、完成代码
// 存储副作用的桶
const bucket = new WeakMap();
// 原始数据
const data = { foo: "foo", bar: "bar" };
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
// effect 栈
const effectStack = [];
// 对原始数据的代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, key);
// 返回属性值
return target[key];
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal;
// 把副作用从桶中取出并执行
trigger(target, key);
return true;
},
});
// 在get 拦截函数内调用 track 函数 追踪变化
function track(target, key) {
// 没有 activeEffect 直接 return
if (!activeEffect) return;
// 根据 target 从 桶中取出 depsMap,它也是一个map 类型,key-->effects
let depsMap = bucket.get(target);
// 如果 depsMap 不存在,创建一个新的map,并与 target 关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
// 再根据 key 从depsMap 中取得 deps,它是一个 Set 集合
// 里面存储着所有与当前 key 相关的副作用函数:effects
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 最后将当前激活的副作用函数添加到桶里
deps.add(activeEffect);
// deps 就是一个与当前副作用函数存在联系的依赖集合
// 将其添加到 effectFn.deps 数组中
activeEffect.deps.push(deps);
}
// 在 set 拦截函数内调用 trigger 函数 触发变化
function trigger(target, key) {
// 从桶中取出 depsMap,它也是一个map 类型,key-->effects
const depsMap = bucket.get(target);
if (!depsMap) return true;
// 再根据 key 从depsMap 中取得 副作用函数 effects
const effects = depsMap.get(key);
const effects2Run = new Set();
effects &&
effects.forEach((effectFn) => {
// 如果trigger触发执行的副作用函数与当前的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effects2Run.add(effectFn);
}
});
effects2Run.forEach((effectFn) => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn);
} else {
// 否则直接执行副作用函数
effectFn();
}
});
}
// 用于注册副作用函数
function effect(fn, options = {}) {
const effectFn = () => {
// 删除与 effectFn 相关的关系
cleanup(effectFn);
// 调用effect函数的时候,将 effectFn 赋值给 activeEffect
activeEffect = effectFn;
// 在调用副作用函数之前,将当前副作用函数添加到 effectStack 栈中
effectStack.push(effectFn);
// 执行副作用函数,得到返回值
const res = fn();
// 在调用副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
// 将返回值 return 出去
return res;
};
// 将 options 挂在到 effectFn 上
effectFn.options = options;
// effectFn.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
// 只有非lazy的时候才会执行
if (!options.lazy) {
effectFn();
}
// 将副作用函数作为返回值返回
return effectFn;
}
function cleanup(effectFn) {
// 遍历 effectFn.deps 数组
effectFn.deps.forEach((deps) => {
// 将 effectFn 从依赖集合中删除
deps.delete(effectFn);
});
// 清空 effectFn.deps
effectFn.deps.length = 0;
}
// 遍历 source 内所有的属性,从而触发 track 函数
function traverse(source, seen = new Set()) {
// 如果要读取的数据是原始数据,或者已经被读取过,那么什么都不做
if (typeof source !== "object" || source === null || seen.has(source)) return;
// 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起死循环
seen.add(source);
// 暂时不考虑数组等的情况
// 假设 source 是一个对象,使用 for in 循环遍历读取每一个值,并递归调用 traverse 进行处理
for (let key in source) {
// 如果是对象,递归调用
traverse(source[key], seen);
}
return source;
}
function watch(source, cb, options = {}) {
let getter;
if (typeof source === "function") {
// 如果是function,直接作为 getter
getter = source;
} else {
// 如果是对象,监听source内所以的属性
getter = () => traverse(source);
}
// 存储用户注册过的过期回调
let cleanup;
// 定义 过期回调
function onInvalidate(fn) {
// 将过期回调存储起来
cleanup = fn;
}
const job = () => {
newVal = effectFn();
// 需要注意的是,第一次执行 watch cb的时候,不执行 过期回调,再次触发watch cb就会执行 上一个过期回调
if (cleanup) {
// 在调用cb之前,执行过期回调
cleanup();
}
cb(oldVal, newVal, onInvalidate);
oldVal = newVal;
};
// 定义旧值和新值
let oldVal, newVal;
// 使用 effect 注册将副作用函数时候,开启 lazy 选项,并把返回值存储到 effectFn ,便于后面手动调用
const effectFn = effect(() => getter(), {
lazy: true,
scheduler: () => {
if (options.flush === "post") {
// flush 为 post,放到微任务队列中执行
Promise.resolve().then(job);
} else {
job();
}
},
});
if (options.immediate) {
job();
} else {
// 手动调用,将执行的结果存储到 oldVal 中
oldVal = effectFn();
}
}
watch(
() => obj.foo,
async function cb(preVal, newVla, onInvalidate) {
// 记录是否是过期副作用
let expired = false;
onInvalidate(() => {
// 当过期时候,expired 设置为 true
expired = true;
});
const res = fetch("/get/user/list");
// 只有非过期的时候,才将响应的数据赋值给finalData
if (!expired) {
finalData = res;
}
}
);
obj.foo = "foo1";
setTimeout(() => {
obj.foo = "foo2";
}, 500);
五、总结
watch实际是对effect的二次封装,然后添加tract 属性、执行时机、提供给用户过期副作用的处理
六、参考
《Vue.js 设计与实现》