Vue2的响应式实现是借助 Object.defineProperty
通过重写getter和setter方法来进行的数据劫持,Vue3通过Proxy代理拦截对象中任意属性的变化,通过Reflect反射对源对象的属性进行操作,然后再在get里收集依赖在set里派发更新。
上面中的这句话应该都知道,那我们先来了解一下什么是副作用函数,以及它有什么用?它和响应式数据又有什么关系?
举例:当一个函数修改了全局变量。
let a = 1;
function effect(){
a = 2;
}
那么怎么才能让数据变成响应式数据呢?
上代码前请看两个地方:
上图中Set数据结构所存储的副作用函数集合我们称为 key的依赖集合。
// 存储副作用函数的桶
const bucket = new WeakMap();
// 原始数据
const data = { text: "hello world" };
const obj = new Proxy(data, {
get(target, key) {
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(对应着:key --> effects),它是一个Set类型
let deps = depsMap.get(key);
// 如果 deps 不存在,同样新建一个 Set 并与 key 关联
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 添加到桶里
deps.add(activeEffect);
return target[key];
},
set(target, key, newValue) {
target[key] = newValue;
// 根据 target 从桶中取得 depsMap,它是Map类型: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根据 key 取出所有副作用函数
const effects = depsMap.get(key);
ettects && ettects.forEach((fn) => fn());
},
});
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
// effect函数用于注册副作用函数
function effect(fn) {
// 将副作用函数fn 赋值给 activeEffect
activeEffect = fn;
// 执行副作用函数
fn();
}
上面的get 和 set里面的逻辑太长了,我们把它抽离到track和trigger函数中(学习官方的做法)。
function track(target, key) {
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(对应着:key --> effects),它是一个Set类型
let deps = depsMap.get(key);
// 如果 deps 不存在,同样新建一个 Set 并与 key 关联
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 添加到桶里
deps.add(activeEffect);
}
function trigger(target, key) {
// 根据 target 从桶中取得 depsMap,它是Map类型: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根据 key 取出所有副作用函数
const effects = depsMap.get(key);
effects && effects.forEach((fn) => fn());
}
此时的get和set:
const obj = new Proxy(data, {
get(target, key) {
track(target, key);
return target[key];
},
set(target, key, newValue) {
target[key] = newValue;
trigger(target, key);
},
});
这时一个稍微完善的响应式系统(破产版)就实现了!下面就对它继续完善。
分支切换:比如下面的代码,随着obj.ok的值不同而执行不同的分支。
const data = { ok: true, text: 'hello world' }
const obj = new Proxy(data, {/* */ })
effect(function effectFn() {
document.body.innerHTML = obj.ok ? obj.text : 'not'
})
它可能会产生遗留的副作用函数。obj.ok== true,读取obj.text的值,所以当副作用函数执行时会触发两个属性的读取操作。
理想状态下,副作用函数只会收集一个依赖放入依赖集合中。
解决思路:
每次当副作用函数执行时,先把它从所有与之关联的依赖集合中删除(如下图)。
当副作用函数执行完毕后,重新建立联系,新的联系中不包含遗留的副作用函数(对应上面的理想状态)。
那么怎么做呢?要将一个副作用函数从所有和它关联的依赖集合中删除,我们需要明确知道哪些依赖集合中包含它。
重新设计副作用函数:
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
function effect(fn) {
const effectFn = () => {
// 当effectFn执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn;
fn();
};
// 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
在track函数中完成依赖集合的收集:新增一行代码即可
function track(target, key) {
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(对应着:key --> effects),它是一个Set类型
let deps = depsMap.get(key);
// 如果 deps 不存在,同样新建一个 Set 并与 key 关联
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 添加到桶里
deps.add(activeEffect);
// deps就是与当前副作用函数存在联系的依赖集合,将其添加到数组中
activeEffect.deps.push(deps); // 新增
}
完成了对依赖集合的收集,接下来就是将副作用函数从依赖集合中删除:
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
function effect(fn) {
const effectFn = () => {
// 调用cleanup 函数完成清除工作
cleanup(effectFn); // 新增
// 当effectFn执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn;
fn();
};
// 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
cleanup函数的实现:
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
// deps 是依赖集合
const deps = effectFn.deps[i];
// 将effectFn从依赖集合中移除
deps.delete(effectFn);
}
// 最后将数组进行重置
effectFn.deps.length = 0;
}
修改trigger函数避免无限执行:
出现原因:因为在trigger函数内部,遍历effects集合时,它是Set数据结构的,里面存储着副作用函数。当执行时调用cleanup进行清除,但是副作用函数的执行会导致其重新被收集到集合中,而此时遍历仍在进行。
function trigger(target, key) {
// 根据 target 从桶中取得 depsMap,它是Map类型: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根据 key 取出所有副作用函数
const effects = depsMap.get(key);
// 新增两行代码
const effectsToRun = new Set(effects);
effectsToRun.forEach(effectFn => effectFn());
// effects && effects.forEach(effectFn => effectFn()) // 删除
}
Vue.js中什么时候会发生effect嵌套?
举个例子:当组件发生嵌套时,例如Foo组件渲染了Bar组件。
const Bar = {
render() {/* */ }
}
// Foo组件渲染了Bar组件
const Foo = {
render() {
return // jsx 语法
}
}
// 等价于
effect(() => {
Foo.render()
// 嵌套
effect(() => {
Bar.render()
})
})
这就是effect要设计成可嵌套的原因。但是我们上面设计的很明显不符合这种情况,那么要怎么做呢?
effectStack
来解决,当副作用函数执行时,将当前副作用函数压入栈中,执行完毕后弹出,并始终让activeEffect
指向栈顶的副作用函数。activeEffect
来存储通过effect函数注册的副作用函数,这意味着着同一时刻 activeEffect
所存储的副作用函数只能有一个。当发生嵌套时,会出现覆盖的情况。如下代码:
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
// 新增栈
const effectStack = [];
function effect(fn) {
const effectFn = () => {
// 调用cleanup 函数完成清除工作
cleanup(effectFn); // 新增
// 当effectFn执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn;
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn); // 新增
fn();
// 执行完毕后,出栈并还原activeEffect之前的值
effectStack.pop(); // 新增
activeEffect = effectStack[effectStack.length - 1]; // 新增
};
// 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
这样一来便实现了响应式数据只会收集直接读取其值的副作用函数为依赖,避免发生混乱。
举个例子:下面这段代码中,既读取了obj.foo的值,又修改了它的值,就造成了无限递归调用自身。
effect(() => obj.foo++);
// 等价于
effect(() => {
obj.foo = obj.foo + 1;
})
此时的执行流程:
解决方法:
activeEffect
,我们可以在trigger动作发生时增加守卫条件:如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。如下代码:
function trigger(target, key) {
// 根据 target 从桶中取得 depsMap,它是Map类型: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根据 key 取出所有副作用函数
const effects = depsMap.get(key);
const effectsToRun = new Set(effects);
effects && effects.forEach(effectFn => { // 新增
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
})
effectsToRun.forEach(effectFn => effectFn());
// effects && effects.forEach(effectFn => effectFn()) // 删除
}
我们可以为effect函数设计一个选项参数 options,允许用户指定调度器,来实现控制函数的执行顺序以及次数。
function effect(fn, options = {}) {
const effectFn = () => {
// 调用cleanup 函数完成清除工作
cleanup(effectFn);
// 当effectFn执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn;
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn);
fn();
// 执行完毕后,出栈并还原activeEffect之前的值
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
};
// 将 options 挂载到 effectFn 上
effectFn.options = options; // 新增
// 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
修改trigger函数,当触发副作用函数重新执行时,如果用户传了调度器,则直接调用。
function trigger(target, key) {
// 根据 target 从桶中取得 depsMap,它是Map类型: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根据 key 取出所有副作用函数
const effects = depsMap.get(key);
const effectsToRun = new Set(effects);
effects && effects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
})
effectsToRun.forEach(effectFn => { // 修改这部分
// 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn);
} else {
effectFn();
}
});
}
先上结论:计算属性实际上是一个懒执行的副作用函数,我们通过lazy选项使得副作用函数可以懒执行。
懒执行:
options.lazy
为true时,不立即执行副作用函数。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; // 新增
}
接下来就可以实现计算属性了:
function computed(getter) {
const effectFn = effect(getter, {
lazy: true
})
const obj = {
get value() {
return effectFn()
}
}
return obj;
}
测试:
const data = { foo: 1, bar: 2 }
const obj = new Proxy(data, /* 复制之前的即可 */);
const sumRes = computed(() => obj.foo + obj.bar);
console.log(sumRes.value); // 3
现在计算属性能正常工作了,但是还做不到对值进行缓存。(即obj.foo+obj.bar的值没有发生变化,但还是会进行多次计算)
修改后的computed:
function computed(getter) {
// value用来缓存上一次计算的值
let value;
// dirty标志用来标识是否需要重新计算值,为true则意味"脏",需要重新计算
let dirty = true;
const effectFn = effect(getter, {
lazy: true,
// 添加调度器,在调度器中将dirty重置为true
scheduler() {
if (!dirty) {
dirty = true;
// 当计算属性依赖的响应式数据变化时,手动调用trigger函数触发响应式
trigger(obj, 'value');
}
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn();
dirty = false;
}
// 当读取value时,手动调用track函数进行追踪
track(obj, 'value');
return value;
}
}
return obj;
}
至此,一个稍微完善的computed就实现了。
watch本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。它的本质其实就是利用了effect
以及options.sheduler
选项。
最简单的watch实现:
function watch(source, cb) {
effect(
() => source.foo,
{
scheduler(){
// 当数据变化时,调用回到函数cb
cb()
}
}
)
}
但是上面其实硬编码了对source.foo
的读取,我们可以封装一个通用的读取操作:
function watch(source, cb) {
effect(
() => traverse(source),
{
scheduler() {
// 当数据变化时,调用回到函数cb
cb()
}
}
)
}
function traverse(value, seen = new Set()) {
// 如果要读取的数据是原始值,或者被读取过了,则什么都不做
if (typeof value !== 'object' || value === null || seen.has(value)) {
return;
}
// 将数据添加到seen中,代表遍历读取过了,避免循环引用引起死循环
seen.add(value);
// 暂时不考虑数组等其他结构
// 假设value就是一个对象,使用for...in 读取对象的每一个值,并递归调用处理
for (const k in value) {
traverse(value[k], seen);
}
return value;
}
watcher函数除了可以观测响应式数据之外,还可以接收一个getter函数:
function watch(source, cb) {
let getter;
// 如果用户传递进来的是函数,则直接使用
if (typeof source === 'function') {
getter = source;
// 如果不是函数类型,则保留之前的做法,即调用traverse函数递归读取
} else {
getter = () => traverse(source);
}
effect(
() => getter(),
{
scheduler() {
cb();
}
}
)
}
其实上面的代码还缺少一个非常重要的能力,即在回调函数中拿不到旧值与新值,我们可以充分利用effect函数的lazy选项来解决这个问题:
function watch(source, cb) {
let getter;
// 如果用户传递进来的是函数,则直接使用
if (typeof source === 'function') {
getter = source;
// 如果不是函数类型,则保留之前的做法,即调用traverse函数递归读取
} else {
getter = () => traverse(source);
}
// 定义新值与旧值
let oldValue, newValue;
// 使用effect注册副作用函数时,开启lazy选项,并把返回值存储到effectFn中方便后续手动调用
const effectFn = effect(
() => getter(),
{
lazy: true,
// 在scheduler中重新执行副作用函数,得到的是新值
scheduler() {
newValue = effectFn();
cb(newValue, oldValue);
oldValue = newValue;
}
}
)
// 手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn();
}
在Vue.js中我们有通过选项参数 immediate
来指定回调是否需要立即执行。
function watch(source, cb, options = {}) {
let getter;
// 如果用户传递进来的是函数,则直接使用
if (typeof source === 'function') {
getter = source;
// 如果不是函数类型,则保留之前的做法,即调用traverse函数递归读取
} else {
getter = () => traverse(source);
}
// 定义新值与旧值
let oldValue, newValue;
// 提取scheduler调度函数为一个独立的 job函数
const job = () => {
newValue = effectFn();
cb(newValue, oldValue);
oldValue = newValue;
}
// 使用effect注册副作用函数时,开启lazy选项,并把返回值存储到effectFn中方便后续手动调用
const effectFn = effect(
() => getter(),
{
lazy: true,
// 使用job函数作为调度器函数
scheduler: job
}
)
if (options.immediate) {
// 当immediate为true时立即执行job,从而触发执行回调
job();
} else {
oldValue = effectFn();
}
}
如此一来便实现了watch的立即执行功能。因为此时的回调函数第一次执行时没有oldValue
,故此时oldValue
的值为undefined。
我们先来回顾一下,
我们还解决了分支切换导致的冗余副作用问题,以及嵌套的effect函数(常发生在父子组件中)、如何避免副作用函数无限递归调用自身,相应系统的调度执行,computed和watch的实现原理等。后面将继续对响应式数据进行完善。