如题, 暂无开场白(有需要者可自行脑补👽👽)
一些必要的前置知识(📚📚📚):
- ES6 Proxy (MDN: developer.mozilla.org/zh-CN/docs/…)
- ES6 WeakMap (MDN: developer.mozilla.org/zh-CN/docs/…)
- ES6 Map: (MDN: developer.mozilla.org/zh-CN/docs/…)
- ES6 Set: (MDN: developer.mozilla.org/zh-CN/docs/…)
- Vue3 reactive api 基础 (官网: vue3js.cn/docs/zh/gui…)
什么是响应式数据?🤯🤯🤯
仔细想了一下, 或许我应该再给新同学固定一下**响应式数据(reactive data)**的一个概念, 因为不仅是新同学, 就算是大厂里的很多人对响应式的这个概念理解都有所偏差
let numberOne = 1;
let numberTwo = 2;
document.write(numberOne + numberTwo);
上面的JS文件我们引入到一个html文件中并在浏览器打开, 我们会发现浏览器是输出了一个3在 页面上
我们在JS下面加一行代码尝试修改numberOne的值
...
numberOne = 4;
这个时候页面里面的3还是静悄悄的呆在那, 但是我们知道, 实际上numberOne + numberTwo的值已经到了5了
所以我们说numberOne和numberTwo都不是响应式数据, 反之, 如果视图层(或者任何第三方变量)可以根据numberOne的变化而变化, 我们则把numberOne称之为响应式的, 这里有个重点我们要知道响应式是和浏览器无关的
我再举个例子, 帮大家巩固一下响应式的概念
let numberA = 1;
let numberB = 2;
let count = numberA + numberB;
console.log("count", count);
numberA = 3;
console.log("count", count);
// 上述的例子里, 如果numberA的值变了以后, count的值也相应变化了, 我们就把numberA称之为响应式数据
而响应式数据的本质概念就是: 响应式数据的变化一定会触发某些副作用操作, 这些副作用操作可以是对另一个变量进行计算, 也可以是对视图层产生变更, 理解这个概念非常重要
就好比, 在Vue中, 一个响应式数据的变化始终牵动这视图层的重新渲染, 在我们上面的例子里, 两个数字的变化始终牵动着count的重新计算一样
基于上面的例子, 如果我想要当numberA或者numberB的值更改了以后, 视图层会进行响应的变更的话, 那么我要做的最重要的一件事就是我要知道numberOne和numberTwo什么时候改了, 这一点应该不难理解
所以我们要做的事情就是拦截numberOne和numberTwo的变更, 首先涉及到一个基础知识, 在vue里, 如果numberOne和numberTwo是一个原始值, 我们能够追踪他们的变更吗, 答案是否定的, 如果我们想要追踪某些变量的一个变更, 我们必须要使用代理
我们有多少种方式来实现代理?
- Object.defineProperty
- Proxy
- Object accessor
而我们接下来要说的ref和reactive都是代理的一个实现
ref
要实现ref, 很简单, 首先我们知道, ref是一定会返回一个携带value值的对象
当我们对ref的value值进行修改的时候, 对应的代理操作会触发, 同时会执行副作用函数, 介于我们是操作视图层, 所以和vue一样, 我们的副作用操作是重新渲染页面
// 我们的副作用操作
function render() {
document.body.innerText = numberOne.value + numberTwo;
}
// 对于ref的代理操作, vue采用的是object accessor的方式
function ref(rawValue) {
return {
get value() {
return rawValue;
},
set value(newValue) {
if (newValue === rawValue) return;
rawValue = newValue;
// 既然这里是一个函数了, 那么我们就可以把我们的副作用操作放在这来
render();
}
}
}
我们可以实验一下我们的成果
function render() {
document.body.innerText = numberOne.value + numberTwo;
}
function ref(rawValue) {
return {
get value() {
return rawValue;
},
set value(newValue) {
if (newValue === rawValue) return;
rawValue = newValue;
// 既然这里是一个函数了, 那么我们就可以把我们的副作用操作放在这来
render();
}
}
}
let numberOne = ref(1);
let numberTwo = 2;
render(); // 我们先执行一次, 让页面渲染东西, 就和vue的render一样
// 我们修改了页面上的值, 我们看看页面会不会变化
setTimeout(() => {
numberOne.value = 3;
}, 1000)
我们会发现一秒钟以后, 页面重新渲染了
上面可能会有的同学看不懂那个rawValue = newValue的概念, 如果对这个赋值有疑问的话你可能需要看一下函数的执行期上下文对应的概念,rawValue是会作为AO的key保留在执行期上下文中的, 所以赋值是没有问题的
trace & trigger (依赖收集 / effect触发)
上面其实已经达到了我们的一个基本目的, 但是这还远远不够, 因为我们知道, 当你变成了响应式数据以后, 别人可以基于你来进行计算(比如
computed), 我们的目前的副作用操作是固定死的, 就是重新渲染页面, 那么以为着, 我们没法写出类似于computed这样的东西, 这是我们目前的缺陷, 他和ref关系不大, 我们需要做的就是建立一个依赖地图
来看我操作💁💁💁
一样的由简入繁
// 这个Map是用来干嘛的呢, 一个键名, 对应了依赖这个键的所有副作用函数(用来创建1对多的关系)
// 为啥要用WeakMap, 因为WeakMap的key只能是对象
let depMap = new WeakMap();
let activeEffect = null; // 当前正在运行的effect
// 我为什么要定义这个effect函数来跑副作用函数
// 因为我们知道副作用函数可以有很多, 比如computed, render
// 当我读到某个值的时候, 比如obj.a, 如果我要收集他的依赖
// 我是不是必须在某个副作用函数里, 那比如这个obj.a在副作用函数render中
// 读到的, 是不是代表着我当前运行的effect函数是render
// 所以我用这个effect来跑副作用函数, 是为了帮我记录当前是在哪个副作用环境下
// 所以我们又得出结论: 不在副作用下访问响应式属性 是不用收集依赖的(比如console.log), 听懂掌声
function effect(eff) {
activeEffect = eff;
activeEffect();
activeEffect = null;
}
// trace函数用来帮我们收集键名所对应的effect函数
function trace(target, key) {
if (activeEffect) {
// 我们会干嘛, 会往Map里丢属性和effect
const currentWorkMap = depMap.get(target);
if (!currentWorkMap) {
// 如果这个key没有, 代表这个依赖还是第一次收集吧
// activeEffect是不是一定是当前跑的effect,
// 因为我们必须在某一个effect里才有必要收集依赖
// 而某个effect的执行恰好就是activeEffect的执行吧
depMap.set(target, new Map().set(key, new Set([activeEffect])));
} else {
// 如果不是第一次, 是不是代表这个key对应的value值是一个Map
const matchTargetKey = currentWorkMap.get(key);
if (!matchTargetKey) {
// 代表虽然这个Map有东西, 但是这个key还没有被追踪过
// 为啥要用Set, 因为Set的值不会重复
currentWorkMap.set(key, new Set([activeEffect]));
} else {
// 代表有Set了, 我直接加就行
matchTargetKey.add(activeEffect);
}
}
}
}
// 我们再来一个trigger方法
// trigger方法是干啥的, 当我们去设置一个响应式数据的值的时候
// 我们是不是希望他对应的所有effect都执行,
// 所以trigger就是用来做这个事儿的
function trigger(target, key) {
const currentMap = depMap.get(target);
if (!currentMap) return; // 如果Map都找不到直接拜拜
const currentWorkEffects = currentMap.get(key);
if (!currentWorkEffects) return;
// 直接将所有的副作用全部执行
currentWorkEffects.forEach(effect => effect());
}
这个时候, 我们改造一下我们的ref函数, 并且多加一个副作用函数
function ref(rawValue) {
const proxyObj = {
get value() {
// 取值的时候一定是在某个effect里操作的
// 否则我们就没必要追踪依赖
trace(proxyObj, "value");
return rawValue;
},
set value(newValue) {
if (newValue === rawValue) return;
rawValue = newValue;
trigger(proxyObj, "value");
}
}
}
// 上面就是Set的时候触发, Get的时候追踪了, 非常nice
这个时候我们再来实验一下我们的工作效果, 我们再来一个副作用函数称之为computedCount
// 副作用操作1
function render() {
document.body.innerText = numberOne.value + numberTwo;
}
// 副作用操作2
let totalCount = 0;
function computedCount() {
totalCount = numberOne.value + numberTwo;
}
...
...
// 我们先跑totalCount的effect
effect(computedCount);
// 然后跑render的effect
effect(render);
console.log("totalCount", totalCount);
// 这个时候我们将numberOne改为4, 我们看一下totalCount的值
numberOne.value = 4;
console.log("totalCount", totalCount);
这个时候我们会发现无论是页面还是控制台都输出了我们想要的结果, 所以我们也达到了一个多监听effect的效果
当具备了trace和trigger方法以后, 我们的ref算是完美完成了
当然, vue官方是处理了更多的细节的, 比如你丢了一个响应式数据给ref, ref就会原封不动的返回什么的, 这就是一些细节喽🤖🤖🤖
reactive
上面的ref帮我们解决了响应式的一部分问题, 但是实际上ref解决的是对原始值的代理问题, 你想想你能用proxy代理原始值吗, 所以才有了ref那玩意, 而reactive就是用来代理对象的(你想想你用ref代理对象也够呛啊, 你本来就是一个对象, 你还用.value去访问他, 恶不恶心啊), 所以, 在reactive中也是用到了proxy
有了上面的trace和trigger 我们的reactive非常好写
function reactive(target) {
let handler = {
get(target, key, receiver) {
trace(target, key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
if (value === target[key]) return;
Reflect.set(target, key, value, receiver);
trigger(target, key);
}
}
return new Proxy(target, handler);
}
就这样就ok了, 所以我们也知道了, vue3的响应式核心原理就是 代理 + 发布订阅模式
ok, 今天的博客就到这里了, 希望这篇对Vue3的一个响应式原理的剖析可以帮助到你, see u 🧑🎄🧑🎄🧑🎄