本篇文章代码思路来自Vue3.0源码, 部分理解来源于霍春阳 《Vue.js设计与实现》这本书的理解, 感兴趣的小伙伴可以自行购买阅读。可以非常明确的感受到作者对 Vue 的深刻理解以及用心, 富含非常全面的 Vue 的知识点, 强烈推荐给大家。
为了防止有小伙伴不知道副作用函数的, 在开讲之前我先来介绍一个副作用函数。副作用函数, 顾名思义指的是会产生副作用的函数。例如一个函数, 它修改了全局变量, 那么就产生了一个副作用, 它就是一个副作用函数(更加详细的可以自行查阅纯函数、副作用相关概念)。
let value = 1 // 全局变量
function foo() {
value = 100 // 修改全局变量
}
什么是响应式? 相信大家都能够回答, 所谓响应式无非就是数据发生变换时, 页面自动更新嘛。这样的回答是没有问题的, 但是并不是响应式的本质。这里给大家抛出一个结论, 响应式本质: 当页面数据发生变化时, 会自动的运行相关函数。
举个栗子, 我们有如下一个obj对象, 和一个effect副作用函数, 副作用函数effect中, 获取到body并向其添加一个文本, 文本内容为obj.name, 最终效果会在浏览器中会显示"chenyq"。
const obj = { name: "chenyq" }
function effect() {
document.body.innerText = obj.name
}
effect(obj)
此时, 我们再对obj.name的值进行修改, 我们可以发现页面中显式的内容并没有发生变化, 仍然为"chenyq"。
// 对name属性修改
obj.name = "abc"
现在我们修改了obj.name的值, 我们期望的是页面中也能够进行同步更新, 如果能够完成这个目标, 那么obj对象就是一个响应式数据。那么如何能否完成这个目标呢? 如果再修改完obj.name属性之后, 再次调用effect函数, 那么页面中的数据就会随之变化。
obj.name = "abc"
// 修改后再次调用effect函数
effect(obj)
但是, 我们期望是能够自动调用effect函数, 而不是我们自己手动调用。经过这个例子, 我们完全可理解到响应式的本质, 以及为什么说响应式的本质是, 当页面数据发生变化时, 自动运行相关函数。但是我们目前是手动调用的, 并不是自动运行的。
接上文, 我们如何让obj变成一个响应式数据呢? 或者说, 我们如何自动的运行obj的相关函数? 其实我们可以通过以下两点思路出发:
如果我们能够拦截obj对象的get和set操作, 那么我们就可以实现了。具体的做法是: 当我们进行obj.name触发get操作时, 就可以将effect函数存入到一个桶中, 因为effect函数可能不止一个, 所以我们需要存放到一个桶中; 当我们触发set操作的时候, 我们再从中桶取出全部相关函数进行执行。
现在我们就将问题转变为如何才能拦截一个对象的get和set操作, 相信大家很快就反应过来, 我们可以通过Object.defineProperty或Proxy实现。在Vue.js2中就是通过Object.defineProperty函数实现的响应式, 而ES6新增了一个代理对象Proxy, 它相对于Object.defineProperty来说更具有优势, 我们可以通过Proxy代理对象来实现, Vue.js3也是使用的Proxy实现的。
下面我们就根据上面的思路, 使用Proxy进行实现, 首先我们创建一个桶bucket, 用于存放副作用函数。这里的bucket我为什么使用Set而不是数组呢? 这是因为get这个操作我们是可能不仅仅触发一次的, 当触发了get就会将effect函数添加到bucket中, 如果是数组的话, 当再次触发get我们又会将effect函数添加到bucket中; 这样数组中就存放了两个相同的effect函数, 在触发set操作时, 就会对同一个函数进行两次调用。除非是对数组进行去重, 不然就会存在一个函数调用多次的问题, 因此使用Set集合, 保证存放的effect函数不会重复。
// 存储副作用函数的桶
const bucket = new Set()
接着定义一个原始数据data, 并使用Proxy对原始数据data进行代理, 在代理对象中, 通过get和set方法分别用于拦截读取和设置的操作。在get操作中, 将effect函数添加到bucket, 再返回属性值; 在set操作中, 设置属性值, 并遍历执行bucket中的副作用函数。
const data = { name: "chenyq" }
const obj = new Proxy(data, {
get(target, key) {
// 添加副作用函数到bucket中
bucket.add(effect)
// 返回属性值
return target[key]
},
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 遍历执行bucket中的副作用函数
bucket.forEach(fn => fn())
return true
}
})
这样我们就实现了一个响应式数据, 我们可以通过setTimeout测试一下, 在等待一秒后, 对obj.name属性进行修改。
function effect() {
document.body.innerText = obj.name
}
// 执行副作用函数, 触发读取
effect()
// 1秒后对obj.name属性进行修改
setTimeout(() => {
obj.name = "abc"
}, 1000)
运行上面代码, 发现可以得到我们期望的效果。到这里我们实现了最简单的响应式, 但是它依然存在着问题和缺陷。比如添加到bucket中的effect函数的函数名是硬编码的, 不具备通用性且不灵活。但是这里主要目的也不是实现响应式, 而是帮助大家理解响应式及响应式的工作原理。
上面的响应式是存在着缺陷, 不够完善的, 现在我们尝试构建一个更加完善的响应式系统。
下面代码是我们已经实现的响应式。
// 原始数据
const data = { name: "chenyq" };
// 存储副作用函数的桶
const bucket = new Set();
const obj = new Proxy(data, {
get(target, key) {
// 添加副作用函数到bucket中
bucket.add(effect);
// 返回属性值
return target[key];
},
set(target, key, newVal) {
// 设置属性值
target[key] = newVal;
// 遍历执行bucket中的副作用函数
bucket.forEach((fn) => fn());
return true;
},
});
function effect() {
document.body.innerText = obj.name;
}
// 执行副作用函数, 触发读取
effect();
// 1秒后对obj.name属性进行修改
setTimeout(() => {
obj.name = "abc";
}, 1000);
上面代码中, effect的函数名我们是硬编码的, 如果副作用函数不叫effect, 那么这段代码就不能正确地工作了, 没有办法将副作用收集到桶bucket中的。既然思考一下, 我们该如何收集副作用函数呢? 甚至如果副作用函数effect是一个匿名函数, 我们还能够正常将副作用函数effect收集到桶bucket中吗?
针对以上问题, 我们可以定义一个全局变量activeEffect, 它的初始值是undefined, 用来表示当前正在执行的副作用函数。当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect, 接着在正常调用fn函数, 调用完成后, 再将activeEffect修改回undefined, 如下所示:
let activeEffect;
function effect(fn) {
activeEffect = fn;
fn();
activeEffect = undefined;
}
同时Proxy中的get拦截器也需要做对应的修改, 当activeEffect有值的时候, 将activeEffect中存储的副作用函数收集到桶bucket中。
const obj = new Proxy(data, {
get(target, key) {
// 将activeEffect中存储的副作用函数收集到桶
if (activeEffect) bucket.add(activeEffect);
return target[key];
},
set(target, key, newVal) {
target[key] = newVal;
bucket.forEach((fn) => fn());
return true;
},
});
现在我们可以按照如下所示的方式使用effect函数, 调用effect传入一个函数, 甚至是匿名函数。当effect函数执行时, 首先会将接收到的函数参数fn赋值给activeEffect。接着执行传入的fn函数, 执行fn函数时会读取obj的属性, 进而触发代理对象Proxy的get操作。在get拦截器中, 当activeEffect有值的时候, 会将activeEffect存放的副作用函数添加到bucket桶中, 这样响应系统就不依赖副作用函数的名字了。
// 执行副作用函数, 触发读取
effect(() => {
document.body.innerText = obj.name;
});
但是我们的响应式系统依然有漏洞, 比如数据源obj有name, 和age两个属性, 当我们直接修改age或设置一个不存在的属性时, 依然会将副作用函数执行一次。如下代码, effect是和obj.name属性相关的, 我们期望做到的是只有操作effect函数依赖的属性时, 才会重新执行effect函数。而不是像现在这样, 操作其他属性, 也会执行只依赖obj.name的effect函数, 理论上age和notExist属性并没有与副作用函数之间建立起关系, 因此定时器中的操作不应该触发副作用函数执行。
const data = { name: "chenyq", age: 18 };
effect(() => {
console.log("effect is running"); // 会执行两次
document.body.innerText = obj.name;
});
setTimeout(() => {
obj.age = 19; // 操作其他属性
// obj.notExist = "abc"; 或操作不存在的属性
}, 1000);
其实上面这个问题导致的原因是, 收集的副作用函数effect并没有和被操作的目标属性之间建立明确的关系。为了解决这个问题, 我们就需要对桶bucket重新进行设计, 直接使用一个Set集合, 是没有办法明确的描述effect和被操作目标之间的关系。当读取属性时, 无论读取的是哪一个属性, 其实实现效果上来说是一样的, 都会把副作用函数收集到桶bucket里;当设置属性时, 无论设置的是哪一个属性, 也都会把桶bucket里的副作用函数effect取出并执行。
我们再来看看副作用函数effect执行的代码, 这段代码中有三个角色:
effect(function effectFn() {
document.body.innerText = obj.name;
});
这三种角色我们可以分别表示一下: 使用target来表示代理对象的原始对象, 使用key来表示被操作的属性, 使用effectFn来表示要被注册的副作用函数, 那么这三个角色就可以建立如下所示的关系, 一种树型结构。
target
└── key
└── effectFn
也会存在下面一些情况(方便理解, 可以看看其他例子):
effect(function effectFn1() {
document.body.innerText = obj.name;
});
effect(function effectFn2() {
document.body.innerText = obj.name;
});
target
└── name
└── effectFn1
└── effectFn2
effect(function effectFn() {
obj.name;
obj.age;
});
target
└── name
└── effectFn
└── age
└── effectFn
effect(function effectFn1() {
obj1.name;
});
effect(function effectFn2() {
obj2.age;
});
target1
└── name
└── effectFn1
target2
└── age
└── effectFn2
按照这种结构, 我们就可以在任何情况下, 对副作用函数和被操作对象的属性直接建立明确的关系。我们创建使用WeekMap代替Set来创建一个桶bucket, 具体的做法如下:
它们的关系如下所示:
WeekMap
└── target1 --> Map1
└── target2 --> Map2
└── ...
└── target3 --> Map3
└── key1 --> Set1
└── key2 --> Set2
└── ...
└── key3 --> Set3
└── effectFn1
└── effectFn2
└── effectFn3
└── ...
接下来我们就在代码中实现这个新的桶bucket, 以及修改Proxy的get/set拦截器:
const obj = new Proxy(data, {
get(target, key) {
// 没有activeEffect, 直接return
if (!activeEffect) return target[key];
// 根据target从桶中取出depsMap
let depsMap = bucket.get(target);
// 如果depsMap不存在, 那么就需要创建一个depsMap与之关联
if (!depsMap) bucket.set(target, (depsMap = new Map()));
// 再根据key, 从depsMap中取出deps, deps是一个Set集合, 里面存放的是与当前key相关的所有副作用函数
let deps = depsMap.get(key);
// 如果deps不存在, 则创建一个deps, 并将其添加到depsMap中
if (!deps) depsMap.set(key, (deps = new Set()));
// 最后将当前激活的副作用函数添加到桶里
deps.add(activeEffect);
// 返回属性值
return target[key];
},
set(target, key, newVal) {
// 设置属性值
target[key] = newVal;
// 根据target从桶中取出depsMap
const depsMap = bucket.get(target);
if (!depsMap) return;
// 取出与key相关的副作用函数
const deps = depsMap.get(key);
// 执行副作用函数
deps && deps.forEach((fn) => fn());
},
});
下面我们在对上面的代码进行一个封装, 好的做法是, 我们将get和set中的逻辑分别封装到一个单独的函数中。在get操作中, 将依赖搜集到桶中的逻辑, 我们可以封装到一个track的函数中, 表示追踪的意思; 在set操作中, 我们把触发副作用函数这个操作封装到一个trigger中, 表示触发的意思。
const obj = new Proxy(data, {
get(target, key) {
// 将副作用函数收集到桶中
track(target, key);
// 返回属性值
return target[key];
},
set(target, key, newVal) {
// 设置属性值
target[key] = newVal;
// 从桶中取出副作用函数执行
trigger(target, key);
},
});
function track(target, key) {
// 没有activeEffect, 直接return
if (!activeEffect) return target[key];
// 根据target从桶中取出depsMap
let depsMap = bucket.get(target);
// 如果depsMap不存在, 那么就需要创建一个depsMap与之关联
if (!depsMap) bucket.set(target, (depsMap = new Map()));
// 再根据key, 从depsMap中取出deps, deps是一个Set集合, 里面存放的是与当前key相关的所有副作用函数
let deps = depsMap.get(key);
// 如果deps不存在, 则创建一个deps, 并将其添加到depsMap中
if (!deps) depsMap.set(key, (deps = new Set()));
// 最后将当前激活的副作用函数添加到桶里
deps.add(activeEffect);
}
function trigger(target, key) {
// 根据target从桶中取出depsMap
const depsMap = bucket.get(target);
if (!depsMap) return;
// 取出与key相关的副作用函数
const deps = depsMap.get(key);
// 执行副作用函数
deps && deps.forEach((fn) => fn());
}
现在我们就实现了一个基本完善的响应式系统, 事实上的响应式系统还会更加复杂, 比如三元运算符分支切换会有哪些影响? 遗留的副作用函数如何处理? 如何避免无限递归循环? 问题等等一系列的, 我会在后面的文章进行更新, 不管怎么说目前我们已经实现了比较完善的响应式系统, 最后把本文的最终代码给到大家。
const data = { name: "chenyq", age: 18 };
const bucket = new WeakMap();
const obj = new Proxy(data, {
get(target, key) {
// 将副作用函数收集到桶中
track(target, key);
// 返回属性值
return target[key];
},
set(target, key, newVal) {
// 设置属性值
target[key] = newVal;
// 从桶中取出副作用函数执行
trigger(target, key);
},
});
function track(target, key) {
// 没有activeEffect, 直接return
if (!activeEffect) return target[key];
// 根据target从桶中取出depsMap
let depsMap = bucket.get(target);
// 如果depsMap不存在, 那么就需要创建一个depsMap与之关联
if (!depsMap) bucket.set(target, (depsMap = new Map()));
// 再根据key, 从depsMap中取出deps, deps是一个Set集合, 里面存放的是与当前key相关的所有副作用函数
let deps = depsMap.get(key);
// 如果deps不存在, 则创建一个deps, 并将其添加到depsMap中
if (!deps) depsMap.set(key, (deps = new Set()));
// 最后将当前激活的副作用函数添加到桶里
deps.add(activeEffect);
}
function trigger(target, key) {
// 根据target从桶中取出depsMap
const depsMap = bucket.get(target);
if (!depsMap) return;
// 取出与key相关的副作用函数
const deps = depsMap.get(key);
// 执行副作用函数
deps && deps.forEach((fn) => fn());
}
let activeEffect;
function effect(fn) {
activeEffect = fn;
fn();
activeEffect = undefined;
}
// 测试部分
// 执行副作用函数, 触发读取
effect(() => {
document.body.innerText = obj.name;
});
// 1秒后对obj.name属性进行修改
setTimeout(() => {
obj.name = "abc";
// obj.age = 19;
// obj.notExist = "abc";
}, 1000);