Space Cowboy

生死去来 棚头傀儡 一线断时 落落磊磊

0%

Vue3 源码阅读笔记(三)—— 响应式?不过是数据劫持罢了

简述

事实上,响应式并不是 Vue3 的新概念,它是一个核心概念。在实际代码中,响应式系统经常与组件化相提并论。更进一步的说,组件化赋予了开发者模块化代码的能力,而响应式系统则让开发者可以通过数据控制组件的呈现方式。

不过这东西从本质上来讲,其实就是劫持数据的变化,在数据变化后自动的执行一些副作用函数。

如果你大致的了解过这个东西,你大概会知道,Vue2Vue3 的响应式系统实现有些略微不同。
从表象上来说,Vue2 的响应式系统是黑盒。Vue2 承包了一切工作,你只需要将数据定义在诸如 datapropscomputed 等选项中就行。而 Vue3 则是把这个决定权交给了开发者。由开发者来决定究竟哪些数据应该是响应式的。
从实现上来说,Vue2 使用 Object.defineproperty 来实现数据劫持,而 Vue3 则使用了 proxy

从逻辑上讲,其实二者是相同的,都是为了实现核心的数据劫持。
不过 Vue 毕竟是一个投入到实际生产中使用的框架,仅仅完成理论上的实现当然是不行的。从 Object.definepropertyproxy 的切换实际上也表现出了一些技术上的选择。

简单来说,因为 Vue2 对响应式数据黑盒化的设计,在框架初始化的时候,会递归遍历所有数据,然后使用 Object.defineproperty 来做劫持。这是一个解决方案,但并不够好。因为首先会付出很多性能消耗,其次,并不是每一条数据都需要变成响应式的。递归消耗的性能支出是否合算,全看开发者的具体实现方式。另外,Object.defineproperty 也不能监听到对象属性的新增与删除。
所以 Vue3 使用了 proxy 和显式的 reactice API。这样不仅可以让开发者自行决定哪些数据需要变成响应式的,还能减少劫持时的数据消耗。

除此之外,在具体实现中,Vue3 也做了一些调整来优化性能。更具体的东西,来直接看源码吧。

入口 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// reactive.ts

export function reactive<T extends object>(target: T): UnwrapNestedRefs<T>;
export function reactive(target: object) {
// if trying to observe a readonly proxy, return the readonly version.
if (target && (target as Target)[ReactiveFlags.IS_READONLY]) {
return target;
}

return creativeReactiveObject(
target,
false,
mutableHandlers,
mutableCollectionHandlers
);
}

简单来说,这个入口函数主要是处理一下只读的 proxy,直接返回。如果不是只读的,那么就进入 creativeReactiveObject 函数的创建逻辑。
这样做的好处就是封装,一个函数只专注于一件事情。让创建响应式对象的函数去关心传入的对象是不是只读的,属实有点职责不清。

creativeReactiveObject

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export const reactiveMap = new WeakMap<Target, any>();
export const readonlyMap = new WeakMap<Target, any>();

function createReactiveObject(
target: Target,
isReadonly: boolean,
baseHandlers: ProxyHandler<any>,
collectionHandlers: ProxyHandler<any>
) {
// ...

const proxyMap = isReadonly ? readonlyMap : reactiveMap;

// ...

const proxy = new Proxy(
target,
targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
);

proxyMap.set(target, proxy);
return proxy;
}

简单来说,这个创建过程只有两步:

  1. 使用 Proxy 做好劫持
  2. 创建 WeakMap 映射

mutableHandlers

根据数据是否是 collection 类型的,划分出了两种劫持实现。因为我们关注的是响应式系统的原理,所以就选择 mutableHandlers 来研究。如果你有兴趣的话,可以自己读一下 mutableCollectionHandlers 的代码。

1
2
3
4
5
6
7
export const mutableHandlers: ProxyHandler<object> = {
get,
set,
deleteProperty,
has,
ownKeys,
};

代码一目了然,就是劫持了这些操作。
接下来,就是在 get 中进行依赖收集,然后在 set 中派发通知了。我们分别说说这俩。

get 到 track 的依赖收集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const get = createGetter();

function createGetter(isReadonly = false, shallow = false) {
return function get(target: Target, key: string | symbol, receiver: object) {
// ...

const res = Reflect.get(target, key, receiver);

// ...

if (!isReadonly) {
track(target, TrackOpTypes.GET, key);
}

// ...

return res;
};
}

既然是 getter 函数,那么求值就是必要的。这里使用了 Reflect 来求值。
接着,如果不是只读的,那么就调用 track 函数来收集依赖。下面是 track 函数的实现

track

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const targetMap = new WeakMap<any, KeyToDepMap>();

export function track(target: object, type: TrackOpTypes, key: unknown) {
// ...

let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
let dep = depsMap.get(key);
if (!dep) {
depsMap.set(key, (dep = new Set()));
}

if (!dep.has(activeEffect)) {
dep.add(activeEffect);
activeEffect.deps.push(dep);

// ...
}
}

这是一个嵌套的映射结构。代码很清晰,不过如果用图展示映射关系的话,会更清晰:
依赖图

根据代码,我们放入 depsMap 中的是一些 activeEffect。这就是所谓的副作用函数。换句话说,就是数据变化时,需要自动执行的那些函数。

你现在应该已经清楚了,依赖收集到底是什么?依赖收集其实就是搞了一堆映射关系,记录一下哪些数据有哪些副作用函数。不过你可能对依赖这东西到底是啥还有点迷惑。那么我们就来看看怎么设定一个依赖函数。

effect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// effect.ts

const effectStack: ReactiveEffect[] = [];
let activeEffect: ReactiveEffect | undefined;

export function effect<T = any>(
fn: () => T,
options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
// ...
const effect = createReactiveEffect(fn, options);
// ...
return effect;
}

function createReactiveEffect<T = any>(
fn: () => T,
options: ReactiveEffectOptions
): ReactiveEffect<T> {
const effect = function reactiveEffect(): unknown {
// 注意,以下是简化过的核心逻辑代码
activeEffect = effect;
return fn();
} as ReactiveEffect;

// ...

return effect;
}

为了说明最本质的东西,我改写了一下 createReactiveEffect 函数。其实这个函数就做了两件事,将 effect 指向全局的 activeEffect,然后执行绑定的 fn 函数。
另外的,你应该已经注意到了,这个 effect 实际上是个函数,是个待执行的函数。那么它将在什么时候执行呢?答案就是 set 派发通知的时候。

真正的 createReactiveEffect

不过我们先不看派发通知。刚才为了说明逻辑,大幅简化了 createReactiveEffect 函数的逻辑,不过如果你在看源码,你会发现这里的实现比较复杂。
简单来说,就是使用了 来执行依赖函数,这样做的原因是为了处理嵌套 effect。我们直接来看完整版的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const effectStack: ReactiveEffect[] = [];
let activeEffect: ReactiveEffect | undefined;

function createReactiveEffect<T = any>(
fn: () => T,
options: ReactiveEffectOptions
): ReactiveEffect<T> {
const effect = function reactiveEffect(): unknown {
if (!effect.active) {
return options.scheduler ? undefined : fn();
}

if (!effectStack.includes(effect)) {
cleanup(effect);

try {
enableTracking();
effectStack.push(effect);
activeEffect = effect;
return fn();
} finally {
effectStack.pop();
resetTracking();
activeEffect = effectStack[effectStack.length - 1];
}
}
} as ReactiveEffect;

effect.id = uid++;
// ...

return effect;
}

处理嵌套 effect

你应该注意到了这两句代码:

1
2
3
effectStack.push(effect);
// ...
effectStack.pop();

这是个典型的入栈出栈操作。
这个设计是为了解决嵌套的 effect 而实现的。我们按照嵌套方式,将函数压入栈,然后从栈顶的函数开始执行,将 activeEffect 指向这个函数。然后执行完一个弹出一个,再执行外层的 effect 函数。
也许你会觉得这个设计很熟悉,对了,这个其实就是函数的执行栈在处理闭包时候的操作。

cleanup

另外的,你应该注意到了在实际处理依赖函数前,我们调用了 cleanup 函数。下面是它的实现:

1
2
3
4
5
6
7
8
9
function cleanup(effect: ReactiveEffect) {
const { deps } = effect;
if (deps.length) {
for (let i = 0; i < deps.length; i++) {
deps[i].delete(effect);
}
deps.length = 0;
}
}

代码很清楚,就是在清除 deps 的映射。下面解释一下这样做的理由。
data 集合,是页面代码所有数据的集合。renderedData 集合,是渲染在了页面上的数据的集合。postRenderedData 是页面变化后渲染在页面上的数据的集合。
那么,有以下几个结论:

  1. renderedDatadata 的子集。
  2. postRenderedDatadata 的子集。
  3. postRenderedDatarenderedData 不一定相等。

这几个结论不难理解。页面在变化的过程中,并不是所有的数据都会呈现在页面上。但是在第一次呈现的时候,会触发 get 函数,进而进行依赖收集。可是在页面变化后,可能有一部分已经被收集依赖的数据并不会呈现在页面上。
如果我们不做任何处理,那么一旦那些未被渲染的数据改变,也会触发整体页面的重新渲染。这种逻辑显然是错误的,页面怎么能因为一个没有被展现在页面上的数据而重新渲染呢?

所以,这就是 cleanup 函数的理由。清除这些依赖。防止预期之外的重新渲染。

set 到 trigger 的派发通知

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const set = createSetter();

function createSetter(shallow = flase) {
return function set(
target: object,
key: string | symbol,
value: unknown,
receiver: object
): boolean {
// ...

const hadKey =
isArray(target) && isIntegerKey(key)
? Number(key) < target.length
: hasOwn(target, key);
const result = Reflect.set(target, key, value, receiver);

// don't trigger if target is something up in the prototype chain of original
if (target === toRaw(receiver)) {
if (!hadKey) {
trigger(target, TriggerOpTypes.ADD, key, value);
} else if (hasChanged(value, oldValue)) {
trigger(target, TriggerOpTypes.SET, key, value, oldValue);
}
}

return result;
};
}

简单来说,分为两步:

  1. 使用 Reflect.set 设定了值
  2. 调用 trigger 来派发通知

在具体调用 trigger 时判断了一下原型。如果 target 的原型是一个 proxy,那么使用 Reflect.set 设置属性的时候会再次触发 setter。所以这里需要优化。
不过这部分对于核心流程的理解来说无足轻重。

trigger

下面是 trigger 函数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
export function trigger(
target: object,
type: TriggerOpTypes,
key?: unknown,
newValue?: unknown,
oldValue?: unknown,
oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
const depsMap = targetMap.get(target);
// ...

const effects = new Set<RectiveEffect>();
const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
// ...
};

// SET | ADD | DELETE | Map.SET
// 在 depsMap 添加对应 effect

const run = (effect: ReactiveEffect) => {
// ...
if (effect.options.scheduler) {
// 调度执行
effect.options.scheduler(effect);
} else {
// 直接执行
effect();
}
};

effects.forEach(run);
}

这个函数的实际源码很长,不过做的事情很简单,就两件事:

  1. 将 depsMap 中的 effect 函数,根据所做事情的 key,添加到 effects 集合里
  2. 遍历 effects 集合,执行副作用函数

渲染副作用函数

过在上一章结尾,我们提到了一句渲染流程和响应式系统结合的部分,就是这句代码:

1
2
3
4
5
6
7
8
9
10
instance.update = effect(
function componentEffect() {
if (!instance.isMounted) {
// ...
} else {
// ...
}
},
__DEV__ ? createDevEffectOptions(instance) : prodEffectOptions
);

注意到了吗,这里其实就是注册了副作用函数,然后将这东西绑定在了 instance.update 上。这就是为什么在你更改一个响应式数据后,页面会自动刷新了。

补充

如果你也去看一下 watchcomputed 功能的实现,你会发现其实它们也是通过 effect 来注册副作用函数的。例如实现 computed 的这段源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// apiWatch.ts

// ...
function doWatch(
source: WatchSource | WatchSource[] | WatchEffect,
cb: WatchCallback || null,
{ immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ,
instance = currentInstance
): WatchStopHandle {
// ...

const runner = effect(getter, {
lazy: true,
onTrack,
onTrigger,
scheduler
})

// ...

return () => {
stop(runner)
if (instance) {
remove(instance.effects!, runner)
}
}
}

不过这些实际 api 的实现留到以后再说。

总结

到这里,响应式系统的核心逻辑就已经介绍完了。大致总结一下:

  1. 响应式系统使用 proxy 来实现数据劫持
  2. 数据劫持中 getter 调用 track 函数收集依赖
  3. 数据劫持中 setter 调用 trigger 函数派发通知

另外的,响应式系统还提供了一些其他的常用 api,例如 readonlyref 等,你可以自己去查看源码。

最后,你还可以参考 官方的响应式原理教程,这是学习的最好资料。