重要的事情放到开头说。
首先,我尽可能的仿照 Vue3 里的函数起名和划分,所以如果你发现文章里的例子或者 vheel 代码里,可能函数划分的太繁琐了,那是为了方便以后进一步的添加功能。
其次,我建议在电脑上读这个系列的文章,方便打开 vheel 的代码,看看函数和文件的划分,甚至是一些文章中没提到的细节。
最好跟着一起造轮子,毕竟编程是像骑自行车一样,是贴近实践的活动。
一个简单的响应式模块
我们先分析一下这个响应式模块要做什么,才能设计主要函数。
当数据变化的时候,我们需要监测到变化,并且再次执行组件的 render 函数,重新生成新的 vnode,更新到真实 DOM 中去。
其中,监测数据和执行 render 都是响应式模块的责任。
数据大多数时候都是 Object 类型,它不会开口说话,为了能监测它,我们需要用 Proxy 进行代理,捕获一些对数据的操作。
这就需要一个reactive函数,接受 data,返回 proxy。
const data = {
counter: 0,
};
const dataProxy = reactive(data);
dataProxy.counter = 1; // do operations on proxy
后续把我们想在 data 上进行的操作,转而用到 dataProxy 上,这样操作才能被代理拦截到。
而且,我们还要定义数据更新后,要做的事情,处理后续影响(effect)
effect(function jobAfterDataChange() {
/* maybe need to re-render and do other things here */
});
将这些要做的事情,作为一个 callback 函数传入 effect 中,每次数据更新,都会触发(trigger)这个 callback 函数的执行。
将以上的思路合起来,写成代码流程:
const data = {
counter: 0,
};
const dataProxy = reactive(data);
let myCounter = 0; // equals to data.counter
effect(function jobAfterDataChange() {
myCounter = data.counter
});
data.counter = 1;
// myCounter should also be 1 now
我希望 myCounter 的值永远等于 data.counter 的值,
如果没有 effect 来处理 data.counter 变化之后的影响(也就是随之更新 myCounter)。
则必须每次都要自己手动再赋值:myCounter = data.counter。
而现在有了 effect,当 data.counter 变成 1,effect 里的 callback 自动执行,这样 myCounter 自动更新成 1。
以上就是我们今天要做的事情,实现 reactive 和 effect。
前者在数据的基础上,建立代理,拦截对于数据的操作,从而触发更新时,通知effect。
effect则接受一个callback函数,callback里定义了数据更新后需要处理的后续工作。
二者结合起来,就能实现:
-
数据的监测
-
自动执行数据变动引起的的后续影响
数据代理中的追踪和触发
昨天我们已经简单举了个关于 Proxy 的例子。完全可以在 get 和 set 操作的时候,进行拦截。
如果是 get,就说明这条数据被使用,需要追踪(track)。
如果是 set,就说明这条数据被修改,需要触发更新(trigger)。
举个例子:
let target = {
msg1: 'hello',
msg2: 'everyone',
};
const handler = {
// intercept `get` method
get: function (target, prop, reveiver) {
track();
return Reflect.get(...arguments);
},
// intercept `set` method
set: function (target, prop, value, receiver) {
trigger();
return Reflect.set(target, prop, value, receiver);
},
};
const proxy = new Proxy(target, handler);
console.log(proxy.msg1); // track proxy.msg1
proxy.msg1 = 'fuck'; // should trigger because proxy.msg1 is tracked
我们先进行关于追踪功能的代码实现,这是触发的前提。
而不管是追踪还是触发,都要先给数据对象创建 proxy。
这就是 reactive 函数。
reactive函数
reactive 是一个入口函数,负责将“传入”的数据对象变得 reactive,也就是“返回”代理 proxy。
想明白了参数和返回什么,这个函数可以写成这样:
(为了行文流畅,正文里我不再提函数在哪个文件,文件在哪个路径。大家去 github 上看吧)
export function reactive(target: object) {
return createReactiveObject(target, mutableHandlers);
}
这只是个入口,具体的创建 proxy 的代码,放到 createReactiveObject 里。
创建前,先检查下传入的数据是不是对象,是不是已经创建过 proxy。
如果创建过了,不用重复,直接返回对应的 proxy。
如果没有,那就 new 一个proxy吧。记得把 new 出来的 proxy 记录到 reactiveMap 里哦。
function createReactiveObject(target: Target, baseHandlers: ProxyHandler<any>) {
if (!isObject(target)) {
return target;
}
// check if target `has` a proxy (reactiveMap {target: proxy})
const existingProxy = reactiveMap.get(target);
if (existingProxy) {
return existingProxy;
}
const proxy = new Proxy(target, baseHandlers);
reactiveMap.set(target, proxy); // keep new proxy in proxyMap
return proxy;
}
以上是创建 proxy 的简单流程,而一个 proxy 最关键的地方当然是 handler 了。
我们要拦截的 get 和 set 方法,以及拦截后要干的事情,都在 baseHandlers 里。
目前我们只需要最简单的 get 和 set,其他的 deleteProperty,has,ownKeys 方法暂时不拦截,后面再搞。
export const mutableHandlers: ProxyHandler<object> = {
get,
set,
// deleteProperty,
// has,
// ownKeys,
};
感兴趣的可以提前去看看 MDN 上 proxy 的文档,看看 deleteProperty,has,ownKeys 什么情况下会用到。
get 和 set 才是目前核心,先从比较简单的 get 开始实现。
function get(target: Target, key: string | symbol, receiver: object) {
const res = Reflect.get(target, key, receiver);
// TrackOpTypes.GET is to mark this operation as 'get'.
// It is helpful for debugging
track(target, TrackOpTypes.GET, key);
if (isObject(res)) {
return reactive(res);
}
return res;
}
够简化了吧,一共就干三件事,把需要 get 的值找到,追踪这个key,当然,如果值是个 Object,将它也变成 reactive。
这里最关键的是,要追踪(track)这条数据(每条数据都有个对应的 key, 用 key 追踪)。
track函数
解释 track,最简单的比喻还是订阅模型,虽然有细微差别。
就好比你运营 N 个公众号,什么《b 站女主播精选》,《p 站本月最火》等等等等。
每次有更新了,为了触发通知,告诉订阅者。
这就需要维护一个订阅名单,哪个 lsp 订阅了哪个公众号。
所以track 这个函数,就是在把 lsp 添加到这个名单里,以便后续的通知。
区别在于,名单里的 lsp 变成了一个个 effect函数等待被执行(后面介绍 effect)
“名单”的结构简单的说,分为三层。
{
"targetA": {
key1: [effect1, effect2];
key2: [effect1, effect8];
},
"targetB": {
key3: [effect1, effect3];
}
}
首先要根据不同的数据对象建立一层,每个数据有不同的 key(第二层),每个 key 有依赖它的 effect(第三层)。
类比下,运营主体 -> 公众号 -> 订阅的 lsp
多说一句,key 和 effect 是个多对多的关系,一个 effect 可以依赖多个 key(多条数据),一条数据也可以有多个 effect。
每次 track 执行,要把effect添加到第三层,肯定开头一层层检查,
第一层建立过没有,没有就新建第一层,建过就直接添加 target 进去。
第二层建立过没有,没有就新建第二层,建过就直接添加 key 进去。
第三层建立过没有,没有就新建第三层,建过就直接添加 effect 进去。
export function track(target: object, type: TrackOpTypes, key: unknown) {
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let dep = depsMap.get(key);
if (!dep) {
dep = new Set();
depsMap.set(key, dep);
}
if (!dep.has(activeEffect)) {
dep.add(activeEffect);
activeEffect.deps.push(dep);
// this for debugging, you can ignore it
if (__DEV__ && activeEffect.options.onTrack) {
activeEffect.options.onTrack({
effect: activeEffect,
target,
type,
key,
});
}
}
}
最好结尾检查下是不是开发环境(__DEV__),如果是的话,可以把每次 track 的信息放进一个队列,方便 debug。
effect函数
在 track 里提到的 effect 到底是个什么,我们这就开始实现它。
export function effect(fn, options) {
const effect = createReactiveEffect(fn, options);
//* run effect immediately
if (!options.lazy) {
effect();
}
return effect;
}
还是做一个简单的入口函数,一方面接受后续要自动执行的 callback function,另一方面提供一个选项,方便对effect进行细微的配置。
比如,这个 effect 是创建后立刻执行一次,然后数据每次更新也执行。
还是创建后不立刻执行?
这就可以添加个 lazy 作为配置 effect 的 option。
重点还是createReactiveEffect这个函数。
let uid = 0;
function createReactiveEffect(fn, options) {
const effect = function reactiveEffect() {
// avoid infinite loop when set inside effect callback function
if (!effectStack.includes(effect)) {
try {
effectStack.push(effect);
activeEffect = effect;
return fn();
} finally {
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
}
}
};
effect.id = uid++;
effect._isEffect = true;
effect.active = true;
effect.raw = fn;
effect.deps = [];
effect.options = options;
return effect;
}
简单来说,effect 是个函数,同时带了一堆 props,描述它自身的信息,比如 id,是否 active,有哪些依赖(deps),配置的选项(options)等等。
执行 effect,自然就会执行 callback(这里是 fn)。
所以回想我们的三层结构的“订阅名单”,每当一个 key 对应的数据发生了改变,比如data.counter = 1
找到 key(此时是 counter),然后把 key 对应的 effect 全部执行一遍,不就把 callback 全部执行了一遍, 是不是就把更新的工作做了?
{
"targetA": {
counter: [effect1, effect2]; // a set of effects
key2: [effect1, effect8];
},
"targetB": {
key3: [effect1, effect3];
}
}
trigger函数
目前我们的 trigger 都是简单的 set 引起的(先忽略删除),所以只在 set 里执行 trigger 就够了。
在 set 里先检查是 key 是否存在,如果不存在那就是 ADD(添加)类型的操作。当然,不管是add还是set,都要触发trigger就对了。
function set(target, key, value, receiver): boolean {
const oldValue = target[key];
// check key exists or not(modify or add)
const hadKey = hasOwn(target, key);
const result = Reflect.set(target, key, value, receiver);
if (!hadKey) {
trigger(target, TriggerOpTypes.ADD, key, value);
} else if (hasChanged(value, oldValue)) {
trigger(target, TriggerOpTypes.SET, key, value, oldValue);
}
return result;
}
之前提过,key 和 effect 可以是多对多的关系,一个 key(也就是一条数据)可以被许多个 effect 依赖。
所以在trigger函数里,我们 new 一个 Set(集合)来存放这些需要执行的 effect,并且定义个 addEffects 方法来将 effect 添加进集合。
最终一口气把集合里的 effect 执行一遍。
当然,结尾还是检查下是不是开发环境(__DEV__),保存下 trigger 事件的记录,方便后续 debug。
export function trigger(
target,
type,
key,
newValue,
oldValue,
oldTarget
) {
const depsMap = targetMap.get(target);
if (!depsMap) {
// never been tracked
return;
}
// save effects that should execute, run them at the end
const effectsToExe = new Set<ReactiveEffect>();
const addEffects = (effectsToAdd) => {
if (!effectsToAdd) return;
effectsToAdd.forEach((effect) => {
effectsToExe.add(effect);
});
};
// SET | ADD
if (key !== undefined) {
const dep = depsMap.get(key);
addEffects(dep);
}
// run
effectsToExe.forEach((effect) => {
if (__DEV__ && effect.options.onTrigger) {
effect.options.onTrigger({
effect,
target,
key,
type,
newValue,
oldValue,
oldTarget,
});
}
effect();
});
}
demo跑一遍
以上就是 reactivity 模块的全部关键函数。
如果例子太散,有看不懂的地方,可以去 github 上看 vheel,我稍后建个新 branch(03-simple-reactive-1),把今天的代码传上去。
我们试验一下 reactive 和 effect,看能不能做到监测数据,自动更新。
这种试验轮子新功能的地方,在vheel/playground/main.js。如果你在我之前搭的环境里跑,直接npm run dev就能启动浏览器。
试验内容就是,让 myCounter 这个变量,永远等于 data.counter 的值。
每次更新 data.counter,myCounter 都会自动更新,而不用每次手动给 myCounter 更新。
import { reactive, effect } from '../packages/reactivity/src/index';
// for effect options,to save debug info
const onTrackEvents = [];
const onTriggerEvents = [];
const debuggerOptions = {
onTrack: (event) => {
onTrackEvents.push(event);
},
onTrigger: (event) => {
onTriggerEvents.push(event);
},
};
const data = {
counter: 0,
};
const dataProxy = reactive(data);
let myCounter = 0;
effect(() => {
myCounter = dataProxy.counter;
}, debuggerOptions);
dataProxy.counter = 1; // set data's counter to 1
console.log(onTrackEvents);
console.log(onTriggerEvents);
console.log(myCounter); // myCounter will become 1 automatically
如果你的代码没问题,那么 onTrackEvents,onTriggerEvents 应该分别记录了一次 get 操作和 set 操作。
并且 myCounter 自动变成了 1。
以上就是今天的极简 reactivity 模块,让reactive和effect函数运作了起来。
接下来,我们可以想到,把组件里的 data 变得 reactive,把 render 函数传给 effect,
那么是不是就可以让虚拟节点在每次数据变化后,都能自动更新了呢?
债见~
一般文章首发会在公众号:奔三程序员Club。
后面有空会再次修改打磨下,搬到掘金上。
惯例附上 github 链接:github.com/yangjiang39…