《Vue.js 设计与实现》读书笔记 - 第 4 章、响应系统的作用与实现
第 4 章、响应系统的作用与实现
4.1 响应式数据与副作用
副作用函数就是会对外部造成影响的函数,比如修改了全局变量。
响应式:修改了某个值的时候,某个会读取该值的副作用函数能够自动重新执行。
4.2 响应系统的简单实现
如何实现响应式:
1、副作用读取值的时候,把函数放到值的某个桶里
2、重新给值赋值的时候,执行桶里的函数
在 Vue2 中通过 Object.defineProperty
实现,Vue3 通过 Proxy
实现。
点击查看代码
const data = { text: 'hello world' }
function effect() {
document.body.innerText = obj.text
}
const bucket = new Set()
const obj = new Proxy(data, {
get(target, key) {
bucket.add(effect)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
bucket.forEach(fn => fn())
// 返回true代表设置操作成功
return true
}
})
// 触发读取
effect()
// 修改响应式数据
setTimeout(() => {
obj.text = 'hello vue3'
}, 1000)
4.3 设计一个完善的响应系统
上面的实现硬编码了函数名,现在在全局维护一个变量来存储这个副作用函数。
点击查看代码
// 全局变量,用于存储被注册的副作用函数
let activeEffect;
// effect 函数用于注册副作用函数
function effect(fn) {
activeEffect = fn;
fn();
}
const data = { text: 'hello world' };
const bucket = new Set();
const obj = new Proxy(data, {
get(target, key) {
if (activeEffect) {
bucket.add(activeEffect);
}
return target[key];
},
set(target, key, newVal) {
target[key] = newVal;
bucket.forEach((fn) => fn());
// 返回true代表设置操作成功
return true;
},
});
// 触发读取
effect(
// 一个匿名的副作用函数
() => {
document.body.innerText = obj.text;
}
);
// 修改响应式数据
setTimeout(() => {
obj.text = 'hello vue3';
}, 1000);
但是现在 bucket
并没有和 text
字段绑定,也就是说我修改任何值都会触发函数的执行。我们需要重新设计“桶”的数据结构。很简单,把 Set
改成 Map
来存储。
bucket
应该先绑定响应式对象,再绑定字段。
点击查看代码
// 全局变量,用于存储被注册的副作用函数
let activeEffect;
// effect 函数用于注册副作用函数
function effect(fn) {
activeEffect = fn;
fn();
activeEffect = undefined; // 不过书上没有这一句
}
const bucket = new WeakMap();
const data = { text: 'hello world', text1: 'before' };
const obj = new Proxy(data, {
get(target, key) {
if (activeEffect) {
let depsMap = bucket.get(target);
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
deps.add(activeEffect);
}
return target[key];
},
set(target, key, newVal) {
target[key] = newVal;
let depsMap = bucket.get(target);
if (depsMap) {
let effects = depsMap.get(key);
effects && effects.forEach((fn) => fn());
}
// 返回true代表设置操作成功
return true;
},
});
// 触发读取
effect(
// 一个匿名的副作用函数
() => {
document.body.innerText = obj.text;
}
);
effect(() => {
console.log(obj.text1);
});
// 修改响应式数据
setTimeout(() => {
obj.text = 'hello vue3';
}, 1000);
setTimeout(() => {
obj.text1 = 'after';
}, 2000);
这里使用了 WeakMap
,其对 key
是弱引用,不影响垃圾回收。一旦对象被回收,对象的键和值就无法被访问到,所以再监听就没有意义了。否则一直引用会导致内存溢出。
把 get
和 set
中的操作分别封装到 track
和 trigger
。
点击查看代码
const data = { text: 'hello world', text1: 'before' };
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);
// 返回true代表设置操作成功
return true;
},
});
function track(target, key) {
if (!activeEffect) return;
let depsMap = bucket.get(target);
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
deps.add(activeEffect);
}
function trigger(target, key) {
let depsMap = bucket.get(target);
if (depsMap) {
let effects = depsMap.get(key);
effects && effects.forEach((fn) => fn());
}
}
4.4 切换分支与 cleanup
如下代码
const data = { ok: true,text: 'hello world' }
const obj = new proxy(data, {...})
effect(() => {
document.body.innerText = obj.ok ? obj.text : 'not'
})
effect
在 obj.ok
取不同值的时候,执行代码会发生变化,叫做分支切换。
很明显在 obj.ok = true
时 effect
依赖 obj.text
,但是当 obj.ok = false
的时候,就和 obj.text
无关,obj.text
再修改时也不应该触发函数重新执行了。
为了实现这个效果,上面的代码需要修改,每次副作用函数重新执行的时候,我们要先把它从所有与之关联的依赖集合中删除。执行后会建立新的关联。
点击查看代码
let activeEffect;
function effect(fn) {
const effectFn = () => {
// 执行前先清除依赖
cleanup(effectFn);
activeEffect = effectFn;
fn();
activeEffect = undefined
};
// 用来存储与该副作用函数相关联的依赖集合
effectFn.deps = [];
effectFn();
}
function cleanup(effectFn) {
// 很简单 就是在每个依赖集合中把该函数删除
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i];
deps.delete(effectFn);
}
effectFn.deps.length = 0;
}
const bucket = new WeakMap();
// 在 track 中记录 deps
function track(target, key) {
if (!activeEffect) return;
let depsMap = bucket.get(target);
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
deps.add(activeEffect);
// 当前副作用函数也记录下关联的依赖
activeEffect.deps.push(deps);
}
function trigger(target, key) {
let depsMap = bucket.get(target);
if (depsMap) {
let effects = depsMap.get(key);
// 不能直接执行effects 因为执行 effects 会先 cleanup 清除 bucket 中的依赖集合
// 但是再次执行后会再在集合中添加副作用函数
// 这样会导致死循环
const effectsToRun = new Set(effects);
effectsToRun.forEach((fn) => fn());
}
}
const data = { ok: true, text: 'hello world' };
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);
// 返回true代表设置操作成功
return true;
},
});
effect(() => {
document.body.innerText = obj.ok ? obj.text : 'not';
console.log('run!');
});
setTimeout(() => {
obj.ok = false;
obj.text = 'changed';
}, 1000);
注意其中的 trigger
函数
function trigger(target, key) {
let depsMap = bucket.get(target);
if (depsMap) {
let effects = depsMap.get(key);
// 不能直接执行effects 因为执行 effects 会先 cleanup 清除 bucket 中的依赖集合
// 但是再次执行后会再在集合中添加副作用函数
// 这样会导致死循环
const effectsToRun = new Set(effects);
effectsToRun.forEach((fn) => fn());
}
}
4.3 嵌套的 effect 与 effect 栈
在 Vue 中如果我们使用组件嵌套组件,就会有 effect
嵌套执行。
如果有嵌套的 effect
执行,我们就需要在保存当前 effect
函数的同时,记录之前的 effect
函数,并在当前的函数之前完之后,把上一层的 effect
赋值为 activeEffect
。很简单的会想到用栈来实现这个功能。
let activeEffect;
const effectStack = [];
function effect(fn) {
const effectFn = () => {
// 执行前先清除依赖
cleanup(effectFn);
// 执行前先压入栈中
activeEffect = effectFn;
effectStack.push(effect);
fn();
// 执行后弹出
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
};
// 用来存储与该副作用函数相关联的依赖集合
effectFn.deps = [];
effectFn();
}
4.6 避免无限递归循环
const data = { foo: 1 };
const obj = new Proxy(data, {
// ......
});
effect(() => {
obj.foo++;
});
上面的代码会导致死循环,因为 obj.foo++;
既有取值又有赋值操作。读取的时候会把该函数添到依赖集合,赋值的时候会导致副作用函数再执行。
所以我们在 trigger
中执行副作用函数的时候,不执行当前正在处理的副作用函数,即 activeEffect
。
function trigger(target, key) {
let depsMap = bucket.get(target);
if (depsMap) {
let effects = depsMap.get(key);
// 不能直接执行effects 因为执行 effects 会先 cleanup 清除 bucket 中的依赖集合
// 但是再次执行后会再在集合中添加副作用函数
// 这样会导致死循环
const effectsToRun = new Set();
effects &&
effects.forEach((effectFn) => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
effectsToRun.forEach((fn) => fn());
}
}
4.7 调度执行
可调度,指的是当 trigger
动作触发副作用函数重新执行时,又能力决定副作用函数的执行时机、次数以及方式。
在 effect
函数增加选项,可以指定执行副作用函数的调度器。
点击查看代码
function effect(fn, options = {}) {
const effectFn = () => {
// 执行前先清除依赖
cleanup(effectFn);
// 执行前先压入栈中
activeEffect = effectFn;
effectStack.push(effectFn);
fn();
// 执行后弹出
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
};
// 把 options 挂在 effectFn 上
effectFn.options = options;
// 用来存储与该副作用函数相关联的依赖集合
effectFn.deps = [];
effectFn();
}
function trigger(target, key) {
let depsMap = bucket.get(target);
if (depsMap) {
let 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();
}
});
}
}
具体的使用代码
effect(
() => {
console.log('obj.foo', obj.foo);
},
{
scheduler(fn) {
setTimeout(fn);
},
}
);
obj.foo++;
console.log('结束');
// obj.foo 1
// 结束
// obj.foo 2
我们也可以指定副作用函数的执行次数,比如我们对同一个变量连续操作了多次,我们只需要对最终的结果执行副作用函数,中间值可以被忽略。
基于调度器实现这个功能
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('obj.foo', obj.foo);
},
{
scheduler(fn) {
jobQueue.add(fn);
flushJob();
},
}
);
obj.foo++;
obj.foo++;
obj.foo++;
console.log('结束');
// obj.foo 1
// 结束
// obj.foo 4
通过 Set 实现去重,防止函数执行多次,通过 isflushing
做标记,执行过程中不会再次执行。
4.8 计算属性 computed 和 lazy
如果我们有时希望副作用函数不要立即执行,则需要提供一个选项,lazy
来决定是否立即执行。
function effect(fn, options = {}) {
const effectFn = () => {
// 执行前先清除依赖
cleanup(effectFn);
// 执行前先压入栈中
activeEffect = effectFn;
effectStack.push(effectFn);
const res = fn();
// 执行后弹出
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
// 存储fn的计算结果并返回
return res;
};
// 把 options 挂在 effectFn 上
effectFn.options = options;
// 用来存储与该副作用函数相关联的依赖集合
effectFn.deps = [];
if (!options.lazy) {
effectFn();
}
// 将副作用函数作为返回值返回
return effectFn;
}
函数只有在不指定 lazy
的时候才立即执行,同时把函数返回,这样就可以把函数在外部获取并随时手动执行。同时 effectFn
函数返回了函数执行的结果。
现在我们如果把 effect
函数的返回值作为某个属性的 getter
那么我们每次读取这个值的时候,都会触发副作用函数的执行,也会同时收集依赖。
function computed(getter) {
const effectFn = effect(getter, {
lazy: true,
});
const obj = {
get value() {
return effectFn();
},
};
return obj;
}
const sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value);//3
obj.foo = 2;
console.log(sumRes.value);//4
现在的问题时,每一次读取值,都会触发 getter
的执行,但其实只要依赖不改变,计算属性的值是不会变的。这时我们一方面可以设置一个 flag 标识是否需要重新计算,同时在依赖修改时,只需要更新这个 flag 即可,并不需要重新计算,只需要在读取时计算即可。
function computed(getter) {
let value;
// 当前的值是否需要重新计算
let dirty = true;
const effectFn = effect(getter, {
lazy: true,
// 神来之笔啊!如果依赖修改了 并不需要重新计算 getter 但是需要更新 dirty
// 只需要在 scheduler 中指定调度方式即可
scheduler() {
dirty = true;
}
});
const obj = {
get value() {
if (dirty) {
value = effectFn();
dirty = false;
}
return value;
},
};
return obj;
}
const sumRes = computed(() => {
console.log('重新计算了getter');
return obj.foo + obj.bar;
});
console.log(sumRes.value);
// 重新计算了getter
// 3
obj.foo = 2;
obj.foo = 3;
obj.foo = 4;
console.log(sumRes.value);
// 重新计算了getter
// 6
console.log(sumRes.value);
// 6
console.log(sumRes.value);
// 6
现在仍然有一个问题,就是在另一个 effect 中读取计算属性的时候,没有办法让计算属性收集依赖。
点击查看代码
function computed(getter) {
let value;
// 当前的值是否需要重新计算
let dirty = true;
const effectFn = effect(getter, {
lazy: true,
scheduler() {
if (!dirty) {
dirty = true;
// 因为当 computed 的依赖改变时 computed 的值应该被重新计算
// 这个时候需要手动触发依赖
// 如果有依赖的话 就会重新计算 computed 的值了
trigger(obj, 'value');
}
},
});
const obj = {
get value() {
if (dirty) {
value = effectFn();
dirty = false;
}
// 读取 value 时手动进行依赖收集
track(obj, 'value');
return value;
},
};
return obj;
}
const sumRes = computed(() => {
return obj.foo + obj.bar;
});
effect(() => {
console.log('sumRes', sumRes.value); // sumRes 3
});
obj.foo = 2; // sumRes 4
obj.foo = 4; // sumRes 6
在读取的时候做了依赖收集,同时在 computed
的依赖改变时,对收集的依赖触发执行。
4.9 watch 的实现原理
点击查看代码
function watch(source, cb) {
effect(
// 调用 traverse 递归地读取
() => traverse(source),
{
scheduler() {
cb();
},
}
);
}
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;
}
watch(
obj,
() => {
console.log('foo 的值变了');
}
);
obj.foo = 1;
obj.foo = 2;
watch
通过 effect
实现,第一个参数遍历传入的对象,读取对象的每一个值,第二个参数是一个回调函数,在 scheduler
中执行,也就是依赖每次更新都会执行。
不过 watch
第一个参数不一定亚奥传入一个值,也会传一个 getter
函数。同时,在 watch
中我们还希望获取改变前后的值。
点击查看代码
function watch(source, cb) {
let getter;
if (typeof source === 'function') {
getter = source;
} else {
getter = () => traverse(source);
}
let oldValue, newValue;
const effectFn = effect(() => getter(), {
lazy: true,
scheduler() {
newValue = effectFn();
cb(newValue, oldValue);
oldValue = newValue;
},
});
oldValue = effectFn();
}
watch(
() => obj.foo,
(newValue, oldValue) => {
console.log('foo 的值变了', newValue, oldValue);
}
);
改动1,判断用户传入的参数是否是函数
改动2,在 scheduler
手动调用副作用函数,获取最新的值并缓存,然后在回调时传入。这里使用了 lazy
,是为了手动调用第一次副作用函数以获取 oldValue
。
4.10 立即执行的 watch
与回调执行时机
新增 immediate
选项,让回调函数可以立即执行。
点击查看代码
function watch(source, cb, options = {}) {
let getter;
if (typeof source === 'function') {
getter = source;
} else {
getter = () => traverse(source);
}
let oldValue, newValue;
// 把scheduler调度函数提取为job函数
const job = () => {
newValue = effectFn();
cb(newValue, oldValue);
oldValue = newValue;
};
const effectFn = effect(() => getter(), {
lazy: true,
scheduler: job,
});
if (options.immediate) {
// 当immediate=true回立即执行回调函数
job()
} else {
oldValue = effectFn();
}
}
watch(
() => obj.foo,
(newValue, oldValue) => {
console.log('foo 的值变了', newValue, oldValue);
},
{
immediate: true
}
);
这样,在第一次执行获取初始值之后也会立即执行回调函数,不过第一次的 oldValue
是 undefined
。
除了立即执行,我们也可以通过其他方式指定回调函数执行时机。
点击查看代码
function watch(source, cb, options = {}) {
let getter;
if (typeof source === 'function') {
getter = source;
} else {
getter = () => traverse(source);
}
let oldValue, newValue;
// 把scheduler调度函数提取为job函数
const job = () => {
newValue = effectFn();
cb(newValue, oldValue);
oldValue = newValue;
};
const effectFn = effect(() => getter(), {
lazy: true,
scheduler: () => {
// 设置post就放入微任务队列中执行
if (options.flush === 'post') {
const p = Promise.resolve();
p.then(job);
} else {
job(); // 同步执行
}
},
});
if (options.immediate) {
// 当immediate=true回立即执行回调函数
job();
} else {
oldValue = effectFn();
}
}
watch(
() => obj.foo,
(newValue, oldValue) => {
console.log('foo 的值变了', newValue, oldValue);
},
{
flush: 'post', // 'pre'|'post'|'sync'
}
);
这样的话会导致连续修改数据的时候,执行结果有点问题,毕竟相当是数据全部改变后统一执行回调函数了。
4.11 过期的副作用
竞态问题,连续发送两次请求,后面的先返回,导致先发送的返回结果覆盖了后面的请求。而一般的需求是,保留最后一次请求的结果。
在第二次发送请求的时候,第一次请求已经“过期”,我们应该将其设置无效。
假设在 watch
我们会发送一个异步请求
let finalData
watch(obj, async () => {
const res = await fetch('/path/to/request')
finalData = res
})
可以通过在 watch
的回调函数中新增一个 onInvalidate
参数解决。
点击查看代码
function watch(source, cb, options = {}) {
let getter;
if (typeof source === 'function') {
getter = source;
} else {
getter = () => traverse(source);
}
let oldValue, newValue;
// cleanup 用于存储用户注册的过期回调
let cleanup;
function onInvalidate(fn) {
cleanup = fn;
}
// 把scheduler调度函数提取为job函数
const job = () => {
newValue = effectFn();
// 在执行回调函数之前 先执行过期函数
// 我们在回调函数中会调用失效函数 会把过期函数绑在cleanup上
// 我们先调用的回调会先把失效函数绑定
// 而如果在上一次回调函数执行之前 就触发了下一次的执行 就会调用失效函数
// 也就是上一次的回调函数对应的失效函数 则上一次的结果会被取消
if (cleanup) {
cleanup();
}
cb(newValue, oldValue, onInvalidate);
oldValue = newValue;
};
const effectFn = effect(() => getter(), {
lazy: true,
scheduler: () => {
// 设置post就放入微任务队列中执行
if (options.flush === 'post') {
const p = Promise.resolve();
p.then(job);
} else {
job(); // 同步执行
}
},
});
if (options.immediate) {
// 当immediate=true回立即执行回调函数
job();
} else {
oldValue = effectFn();
}
}
let t = 0;
const mock = () => {
return new Promise((resolve) => {
if (++t <= 2) {
// 模拟一下 前两次需要1s返回 第3次立即返回
t = 1000;
}
setTimeout(() => {
resolve(1);
}, t);
});
};
watch(
() => obj.foo,
async (newValue, oldValue, onInvalidate) => {
console.log('foo 的值变了', newValue, oldValue);
let expired = false;
onInvalidate(() => {
expired = true;
});
const res = await mock(newValue);
console.log(expired ? '过期了' : '未过期', 'newValue=' + newValue);
if (!expired) {
finalData = res;
}
}
);
原理有点复杂,就是说第一次每次执行回调的请求之前给 watch
传一个过期函数,然后 watch
把它保存起来,然后在这个过程中如果再次执行 watch
了,就会执行之前保存的过期函数,就会把上次的请求设置为不合法,还挺有趣的=。= 之前面试官还问过我这个问题emmm我都不会。
总结
就是通过 effect
执行副作用函数,把当前执行的函数保存到全局,因为有嵌套执行的情况所以需要用栈来保存。在对象的 get
收集依赖,set
时执行对应依赖,而且每一次执行之前还需要情况上一次执行的依赖。
还有注意事项就是在 get
中如果又 set
当前正在执行的副作用函数,不会触发执行。
computed
和 watch
在 effect
上面进行一层封装,加了 lazy
和 schedular
等选项。
还有竞态问题,通过 onInvalidate
解决。
把整章代码全部敲了一遍,感觉确实学到了不少:D