前言
「响应式数据」在前端框架中被广泛使用,如我们熟知的 Vue 响应式数据劫持 、React 生态 - 状态管理 Mobx。
这得益于 ES6 Proxy
对数据的 "拦截" 特性。外界对该对象的 访问和修改 ,都必须先通过这层拦截,其核心是借助 Proxy 的 get()
与 set()
方法,实现一些想要的逻辑处理。
基于这个特性,我们来实现一个简易版 reactive
来理解「响应式数据」原理。
一、observable 创建响应式对象
observable()
函数用于创建一个响应式对象,它接收一个目标对象,返回创建后的响应式对象。
- 使用示例:
js
const { observable, autorun, batch, define, toJS } = reactive;
const object = {
name: '小明',
school: {
name: '清华大学',
}
}
const obs = observable(object);
autorun(() => {
console.log(obs.name); // 消费响应式数据
});
obs.name = '小红'; // 修改响应式数据值
示例中调用 observable
基于 object 原始对象创建了一个响应式对象,在 autorun(下文介绍)
注册的函数中消费了响应式数据。
autorun()
函数有一个特点:默认会执行一次传入的函数(tracker
),若其中 消费 了响应式数据,在响应式数据值发生变化时,会再次执行。
而响应式原理就是:数据变化时,通知消费它的函数去重新执行。
在示例中代码输出如下:
js
小明
小红
- 代码实现:
observable
接收一个原始对象,其内部会基于原始对象创建一个响应式对象并进行返回,它的实现如下:
js
const observable = (target) => {
return createObservable(null, null, target, false);
}
observable.shallow = (target) => {
return createObservable(null, null, target, true);
}
observable
会 深度劫持 创建一个响应式对象,在这里我们扩展一个 observable.shallow
,用于创建 浅劫持 响应式对象,也就是只会对目标对象的第一级属性操作响应。
它们都是执行 createObservable()
创建,区别在于传递给此函数的第四参数,为 true
代表 浅劫持。
createObservable()
方法四个参数分别指:
- 第一参数
target
,要进行数据劫持的父级对象,在这里传入的对象属于最顶级,所以值为 null; - 第二参数
key
,在父级对象下所使用的 key,同理这里值为 null; - 第三参数
value
,要深度劫持的目标对象, - 第四参数
shallow
,是否对 目标对象 进行浅劫持。
js
const ProxyRaw = new WeakMap(); // 所有 Proxy 集合(key 是 proxy,值为 target)
const RawProxy = new WeakMap(); // 深观察的 Proxy 集合(key 是 target,值为 proxy)
const RawShallowProxy = new WeakMap(); // 浅观察的 Proxy 集合(key 是 target,值为 proxy)
const createObservable = (
target, // 父级对象
key, // 对应 key
value, // 要观察的值
shallow, // 是否浅观察(只处理一层)
) => {
// 只对 对象 进行劫持
if (typeof value !== 'object') return value;
// 如果 value 已经是一个响应式对象,直接返回
if (ProxyRaw.get(value)) return value;
// 这里判断是否属于浅劫持,发生在深度递归处理时。如果父劫持对象的类型属于一个浅劫持,则不对当前对象做劫持处理。
// (如果一开始不理解这里,可以先跳过)
if (target) {
// target 可能是普通对象,也可能是 Proxy 实例,先考虑 Proxy 实例
const parentRaw = ProxyRaw.get(target) || target;
const isShallowParent = RawShallowProxy.get(parentRaw);
if (isShallowParent) return value;
}
// 创建 Proxy
return createProxy(value, shallow);
}
const createProxy = (target, shallow) => {
// 创建响应式对象
const proxy = new Proxy(target, handlers); // 核心处理在 handlers
ProxyRaw.set(proxy, target);
(shallow ? RawShallowProxy : RawProxy).set(target, proxy);
return proxy;
}
在 createObservable
中会先做一些条件判断确认是否要对数据进行劫持,若满足条件进入 createProxy
方法中执行 new Proxy(target, handlers)
创建一个响应式对象并进行返回,handlers
是一个对象,提供了 get
和 set
方法处理劫持逻辑。
现在,一个响应式数据创建完成。到这里我们可以得到以下信息:
- 当对响应式数据值进行访问或修改时,真正的处理逻辑在
handlers
提供的get
和set
方法中; - 深度劫持并不会在
observable
这里深度递归去完成,其实发生在对属性操作时触发的劫持get
和set
方法中(后面就理解了);
在看数据劫持 get
和 set
的实现之前,我们先来看下 autorun
做了什么事情,之后结合 observable
与 autorun
一起来看这两个方法的具体实现。
二、autorun 监听响应式数据
从上面示例代码可以看出 autorun
起到了 监听 响应式数据的作用,它有以下特点:
- 接收一个函数(叫
tracker
),在 tracker 中可以去消费响应式数据; - 默认会将 tracker 执行一次(目的是让 响应式数据 能够收集到它,用于后续数据变更时的通知);
- 消费的响应式数据发生变化时重新执行 tracker;
- 此外
autorun
会返回一个函数(销毁函数
),用于取消对响应式数据的监听。
基于上述特点,autorun
的实现如下:
js
const ReactionStack = []; // 记录 autorun 参数 tracker
const autorun = (ticker) => {
const reaction = () => {
if (typeof tracker !== 'function') return;
if (ReactionStack.indexOf(tracker) === -1) {
try {
ReactionStack.push(reaction); // 1. 入栈
tracker(); // 2. 执行函数去 消费 响应式数据
} finally {
ReactionStack.pop(); // 3. 出栈
}
}
}
reaction(); // 最开始执行一次
return () => {
// return 的函数用于销毁(结合 get、set 实现一起看便于理解)
// 将 reaction 从观察列表中移除
if (reaction._reactionsSet) {
reaction._reactionsSet.forEach(reactionsMap => {
reactionsMap.forEach(reactions => reactions.delete(reaction));
});
delete reaction._reactionsSet;
}
}
}
这里为 ticker
包装了一层 reaction
(反应),注意看这里的执行顺序:
- 执行
tracker
前先将reaction
存储到ReactionStack
中; - 执行
tracker
; - 将
reaction
从ReactionStack
中移除。
这样做的目的是:在执行第二步 tracker
时,若内部消费了响应式数据,则可以在 ReactionStack
中拿到 reaction
和响应式数据进行关联(收集),当响应式数据发生修改时就可以执行收集到的 reaction
实现通知。
下面我们来看 收集 reaction 的逻辑。
三、劫持的具体实现:get 与 set
1、get() 收集 reaction
当在 autorun tracker
中消费响应式数据时,会触发 Proxy get()
方法,主要做的事情是:是收集订阅者 reaction
,并且对消费数据尝试深度劫持:
js
const handlers = {
// 参数:目标对象、属性名、proxy 实例本身(可选)
get: function(target, key, receiver) {
const result = target[key];
// 收集使用此变量的 reaction
const reaction = ReactionStack[ReactionStack.length - 1];
reaction && addReactionsMapToReaction(reaction, addRawReactionsMap(target, key, reaction));
// 尝试深度观察,满足条件,要 return Proxy
return createObservable(target, key, result);
},
set: function(target, key, value, receiver) {
// ...
}
}
addRawReactionsMap
方法起到收集 reaction
的作用,与所消费的响应式数据进行关联,为后面响应式数据更新时,对收集到的 reaction
进行一一执行(下面 set()
的逻辑)
js
const RawReactionsMap = new WeakMap(); // 记录每一个 target 对象,它下面所有属性,保存的 reactions 集合
// 为 target[key] reactions 集合,添加 reaction
const addRawReactionsMap = (target, key, reaction) => {
const reactionsMap = RawReactionsMap.get(target); // 获取 target Map 属性集合
if (reactionsMap) {
const reactions = reactionsMap.get(key); // 获取 target[key] Set reaction 集合
if (reactions) {
reactions.add(reaction);
} else {
reactionsMap.set(key, new Set([reaction]));
}
return reactionsMap;
} else {
const reactionsMap = new Map([
[key, new Set([reaction])], // Set 可以去重
]);
RawReactionsMap.set(target, reactionsMap);
return reactionsMap;
}
}
addReactionsMapToReaction
则是反向处理,将响应式数据通过属性指针记录在 reaction
中,用于销毁 autorun
。
js
// 将 reactionsMap 绑定在 reaction 函数属性指针上(autorun 销毁时使用)
const addReactionsMapToReaction = (reaction, reactionsMap) => {
reaction._reactionsSet = reaction._reactionsSet || new Set();
reaction._reactionsSet.add(reactionsMap);
return reaction._reactionsSet;
}
2、set() 通知 reaction
当修改响应式数据时,会触发 set()
方法,主要做的事情是:执行 get()
时收集到的 reaction
:
js
const handlers = {
get: function(target, key, receiver) {
// ...
},
set: function(target, key, value, receiver) {
const oldValue = target[key];
const newValue = createObservable(target, key, value);
if (oldValue !== newValue) {
// 更新值,并且执行 reactions
target[key] = newValue;
const runReactions = (() => {
const reactions = RawReactionsMap.get(target)?.get(key);
reactions && reactions.forEach(reaction => {
reaction();
});
})();
}
return true;
}
}
至此,一个 observable
响应式数据与 autorun
结合就实现了数据更新,消费该数据的 tracker
重新执行。
四、batch 批量更新数据
我们先来看一个例子,一个 autorun tracker
中消费了多个响应式数据值,当在同一时刻对它们进行更新时,tracker
会被执行多次。
js
const obs = observable({});
autorun(() => {
console.log(obs.a, obs.b, obs.c);
});
obs.a = 'a';
obs.b = 'b';
obs.c = 'c';
// 输出结果:
undefined undefined undefined
a undefined undefined
a b undefined
a b c
可见这并不是我们想要的,这个场景我们期望:同一时刻更新 autorun tracker
中消费的响应式数据,仅更新一次。
将更新逻辑放在 batch()
中处理可以实现。
js
batch(() => {
obs.a = 'a';
obs.b = 'b';
obs.c = 'c';
});
// 输出结果:
undefined undefined undefined
a b c
batch
表示 批量 的意思,批量更新的原理是:启用 batch
时将执行上下文标记为批量更新,在执行到 Proxy set() 方法时先不通知
reaction而是收集起来,等批量更新完成后统一执行一次
reaction`。
js
// 标记正在进行 batch 批量处理(标记执行上下文为 batch)
const BatchCount = { value: 0 };
// 存储 batch 需要批量执行的 reactions。Set 在这里可用于去重
const PendingReactions = new Set();
const batch = (fn) => {
// 1. 标记进入 batch 批量处理机制
BatchCount.value ++;
// 2. 执行 fn
fn();
// 3. 退出 batch 批量处理
BatchCount.value --;
// 执行 PendingReactions
if (BatchCount.value === 0) {
PendingReactions.forEach(reaction => {
reaction();
});
}
}
对应的 Proxy set()
要判断一下是否处于 batch
上下文:
js
const handlers = {
get: function(target, key, receiver) {
// ...
},
set: function(target, key, value, receiver) {
const oldValue = target[key];
const newValue = createObservable(target, key, value);
if (oldValue !== newValue) {
// 更新值,并且执行 reactions
target[key] = newValue;
const runReactions = (() => {
const reactions = RawReactionsMap.get(target)?.get(key);
reactions && reactions.forEach(reaction => {
- // reaction();
// 处于 batch 批量处理机制下,将 reaction 加入到 Pending 集合中
+ if (BatchCount.value > 0) {
+ PendingReactions.add(reaction);
+ } else {
+ reaction();
+ }
});
})();
}
return true;
}
}
五、toJS 转换为原始对象
用于深度递归将一个 observable
对象转换成普通 JS 对象
。
js
const toJS = (values) => {
const weakset = new WeakMap(); // WeakSet 的成员只能是对象,便于后续深度解析
// 递归函数
const _toJS = (values) => {
if (weakset.has(values)) return values; // 避免死循环
if (isObservable()) {
if (Array.isArray(values)) {
weakset.add(values);
const res = [];
values.forEach(item => res.push(_toJS(item)));
weakset.delete(values);
return res;
} else if (Object.prototype.toString.call(values) === '[object Object]') {
weakset.add(values);
const res = {};
for (const key in values) {
res[key] = _toJS(values[key]);
}
weakset.delete(values);
return res;
}
}
return values;
}
return _toJS(values);
}
六、扩展 - 集成在 React 中使用
从上面信息我们知道:响应式数据发生修改时可以通知到 消费
它的 函数
。假如用在 React
中会怎样呢?
我们期望在响应式数据发生变化时,所消费它的 React 组件
能够进行更新重渲染,类似于一些 状态库 ,但它更精准:仅在组件内所消费的数据发生变化时进行更新(按需更新)。
1、Tracker
要实现这个需求,需要借助一个新的 API Tracker
,它的作用与 autorun
类似用于 消费及订阅更新,但实现上有所不同。
它接收一个 scheduler
作为数据更新时执行的通知函数,而消费响应式数据则是放在了 Tracker.track()
中,这样就做到了 数据消费 和 监听更新 的功能分离。
放在 React 组件中可以理解为:组件 render
类比为 track()
消费数据,监听更新要做的事情仅是 forceUpdate()
重渲染。
Tracker
是作为接入在 React/Vue
的依赖追踪工具。
我们先看代码示例:
js
const obs = observable({
name: '初始值',
})
// Tracker 的参数时一个 scheduler
const tracker = new Tracker(() => {
// 3、数据变化,执行特定的逻辑
console.log('执行特定逻辑,比如 forceUpdate()');
})
// Tracker 实例提供一个 track,与 autorun 的 track 作用一致
tracker.track(() => {
// 1、消费响应式数据
console.log(obs.name);
})
// 2、修改响应式数据
obs.name = '更新值';
再来看 Tracker
代码实现:
js
class Tracker {
constructor(scheduler) {
// _scheduler 用作 更新 执行函数
this.track._scheduler = scheduler;
}
// track 用作消费
track = (tracker) => {
if (typeof tracker !== 'function') return;
let result;
try {
ReactionStack.push(this.track); // 1. 入栈
result = tracker(); // 2. 执行函数去 消费 响应式数据
} finally {
ReactionStack.pop(); // 3. 出栈
}
return result;
}
}
可以看到 track
的实现与 autorun reaction
相似,不过这里需要做下兼容处理:在 Proxy set()、batch
中对 Tracker
执行其 _scheduler
。
js
const handlers = {
get: function(target, key, receiver) {
// ...
},
set: function(target, key, value, receiver) {
const oldValue = target[key];
const newValue = createObservable(target, key, value);
if (oldValue !== newValue) {
// 更新值,并且执行 reactions
target[key] = newValue;
const runReactions = (() => {
const reactions = RawReactionsMap.get(target)?.get(key);
reactions && reactions.forEach(reaction => {
if (BatchCount.value > 0) {
PendingReactions.add(reaction);
+ } else if (typeof reaction._scheduler === 'function') {
+ reaction._scheduler(); // Tracker
} else {
reaction();
}
});
})();
}
return true;
}
}
const batch = (fn) => {
// 1. 标记进入 batch 批量处理机制
BatchCount.value ++;
// 2. 执行 fn
fn();
// 3. 退出 batch 批量处理
BatchCount.value --;
// 执行 PendingReactions
if (BatchCount.value === 0) {
PendingReactions.forEach(reaction => {
+ if (typeof reaction._scheduler === 'function') {
+ reaction._scheduler();
+ } else {
+ reaction();
}
});
}
}
2、observer
observer
是连接 React 组件
与 响应式数据
实现依赖追踪的桥梁。
借助 Tracker
的特性,
- 将组件的
render
逻辑作为Tracker.track()
去消费响应式数据; - 提供组件的
forceUpdate
作为Tracker scheduler()
,被用于消费数据更新的处理函数。
这样,当消费的数据发生更新时,便会执行组件的 forceUpdate
进行重渲染,以此来达到 数据更新视图同步更新 的目的。
js
// reactive-react.jsx
import { useReducer, useRef } from 'react';
import { Tracker } from './reactive';
const observer = (FunctionComponent) => {
const wrappedComponent = (props) => {
const [, forceUpdate] = useReducer(v => v + 1, 0);
// forceUpdate 作为 scheduler
const trackerRef = useRef(new Tracker(forceUpdate));
// 组件渲染去消费响应式数据
return trackerRef.current.track(() => FunctionComponent(props));
}
return wrappedComponent;
}
export default observer;
一个简单的 React 代码示例:
jsx
import React from 'react';
import observer from './reactive-react';
import { observable } from './reactive';
const state = observable({
title: 'reactive',
content: '响应式数据'
});
const Head = observer(() => {
console.log('head render.');
return <div>
{state.title},
<input value={state.title} onChange={e => state.title = e.target.value} />
</div>;
});
const Content = observer(() => {
console.log('content render.');
return <div>
{state.content},
<textarea value={state.content} onChange={e => state.content = e.target.value} />
</div>;
});
const App = () => {
console.log('app render.');
return <div>
<Head name="head" />
<Content name="content" />
</div>
}
export default App;
文末
感谢阅读。理解响应式原理,对今后实现业务需求能够起到一定的高效帮助。
本文 reactive
实现参考于 Formily @formily/reactive
,还有一些其他的 API 可供使用,可查看 @formily/reactive 了解更详细的使用。