7.响应式系统比对:手写一个响应式状态库并应用在 React 上
前言
我们通过第一篇文章总结出的结论是,基于依赖追踪的响应式系统的本质是在读取数据的时候收集依赖,在更新数据的时候触发依赖,在后续的文章中我们又知道了所谓的依赖收集在发布订阅模式中就是订阅者进行订阅操作,触发依赖则是发布者进行发布操作,那么基于此原理,我们只需要把数据的读写进行分离再结合发布订阅模式那么可以实现数据响应式了。为了实现数据读写劫持,Vue 中不同的版本采用了不同的 JavaScript 原生 API,具体就是 Vue2 中采用了 Object.defineProperty,Vue3 中采用了 Proxy + Object.defineProperty(ref 本质上是通过 Object.defineProperty 实现的,class 的 getter 方式只是一个语法糖)。同时我们在第一篇文章的也介绍到了可以通过沙箱模式实现数据的读写分离,从而实现数据的响应式,那么在这篇文章中就让我们通过沙箱模式来实现一个数据响应式系统,并把它应用到 React 上吧。
通过沙箱模式实现代理
JS 沙箱我们或多或少都接触过,只是可能我们不了解不多,接触过也不知道。在计算机领域中,沙箱技术(Sandbox)是一种用于隔离正在运行程序的安全机制,其目的是限制不可信进程或不可信代码运行时的访问权限。比如说我们如果开发过微信小程序,我们就有比较深刻的体验,很多在浏览器端可以访问的 API,在小程序上都不可以使用,这是因为小程序上的 JavaScript 代码被运行在一个 JS 沙箱中了,从而限制了一些访问权限,还有一些微前端框架的实现也是通过 JS 沙箱的机制来实现的,还有我们的 Vue 中的模板其实也是运行在一个 JS 沙箱中。
我们这里对 JS 沙箱的各种实现不过作过多深入的解析,JS 沙箱的本质是创建一个独立的运行环境,然后可以暴露一些方法给外部环境访问,然后当外部环境访问这些沙箱中暴露的方法时,在沙箱内部就可以对这些方法进行一些操作了。那么利用这个特点,那么我们就可以创建一个沙箱环境,在沙箱内部创建一个对象,然后暴露一个可以让外部环境访问该对象的方法和一个修改该对象的方法,这样我们就可以在访问该对象的时候进行依赖收集,修改该对象的时候进行依赖触发,从实现了该对象的代理了。
那么根据目的以及功能的不同创建一个 JS 沙箱环境的方式也有很多,其中比较简单一种方式就使用闭包或IIFE(立即执行函数表达式)来实现。通过闭包可以创建一个独立的作用域,然后暴露一些公开的方法,用于与外部环境进行通信。
// 创建作用域沙箱环境
function createSandbox(value) {
// 创建一个与外部环境隔离的对象变量,并且接收一个外部环境传进来的值作为初始值 value
const context = {
value
}
// 创建一个外部环境可以访问 context 对象的方法
function getter() {
return context.value
}
// 创建一个外部环境可以修改 context 对象的方法
function setter(val) {
context.value = val
}
// 暴露外部环境可以访问 context 对象的方法
return [getter, setter]
}
通过上述的方式,我们仅仅只是创建一个作用域沙箱,并不是一个独立的运行环境,但通过它可以实现我们想要代理一个对象的读写功能了。
const [count, setCount] = createSandbox(0)
// 访问对象的值
console.log('访问对象的值:', count())
// 修改对象的值
setCount(2)
console.log('修改后的值', count())
打印结果如下:
那么根据上文我们就可以在访问沙箱作用域中的对象的时候进行依赖收集,修改该对象的时候进行依赖触发,从实现了该对象的读写代理了。
通过发布订阅模式实现数据响应式
同时通过上文对我们前面所学的知识的总结我们又知道了所谓的依赖收集在发布订阅模式中就是订阅者进行订阅操作,触发依赖则是发布者进行发布操作,那么基于此原理,我们只需要把数据的读写进行分离再结合发布订阅模式那么可以实现数据响应式了。
通过前面文章的学习我们知道实现发布订阅模式需要一个变量来存储订阅者,那么在这里我们可以把这个变量设置在 context 对象中。
function createSandbox(value) {
// 创建一个与外部环境隔离的对象变量,并且接收一个外部环境传进来的值作为初始值 value
const context = {
value,
+ observers: null, // 存储订阅者的变量
}
}
然后在访问 context 对象的 value 值的时候我们可以去判断存不存在订阅者,如果存在就存储到 observers 变量中,同时为了去重,我们把 observers 设置成 Set 类型。同时根据前面文章我们知道需要一个全局订阅者中间变量,这样我们在判断存不存在订阅者的时候就方便很多了,在这里我们把这个全局订阅者中间变量命名为 Listener。
代码迭代如下:
+ // 全局订阅者中间变量
+ let Listener
function createSandbox(value) {
// 创建一个与外部环境隔离的对象变量,并且接收一个外部环境传进来的值作为初始值 value
const context = {
value,
observers: null, // 存储订阅者的变量
}
// 创建一个外部环境可以访问 context 对象的方法
function getter() {
+ // 进行订阅者添加
+ if (Listener) {
+ if (!context.observers) {
+ context.observers = new Set([Listener])
+ } else {
+ context.observers.add(Listener)
+ }
+ }
return context.value
}
// 省略...
}
通过上述迭代我们就实现订阅者的订阅,那么很自然的接下来迭代实现的功能就是触发依赖了,也就是发布者进行发布。实现也很简单,具体就是把存储订阅者的变量的订阅者全部通知一次。
代码迭代如下:
function createSandbox(value) {
// 省略...
// 创建一个外部环境可以访问 context 对象的方法
function setter(val) {
context.value = val
+ // 把存储订阅者的变量的订阅者全部通知一次
+ context.observers.forEach(fn => fn());
}
}
这样我们就可以进行测试了:
const [count, setCount] = createSandbox(0)
// 订阅者小明
Listener = () => {
console.log(`计算结果是:${count()}`)
}
// 初始化
Listener()
Listener = null
// 更改计算
setCount(2)
打印结果如下:
我们可以看到成功打印了如期的结果。
至此,我们就通过发布订阅模式实现数据响应式。
实现响应式副作用函数
根据我们前面所学的知识,我们知道不管是 Vue 还是 Mobx 都存在响应式副作用函数,例如 Vue3 中的 effect,Mobx 中的 autorun。那么这里我们实现一个满足上面响应式数据需求的副作用函数,其实它们的实现原理都是一致的。首先需要传递一个需要观察的函数,从发布订阅模式角度理解,这个函数就是一个订阅者,然后把这个函数赋值到一个中间变量上,然后执行这个函数,进行初始化,本质是在触发响应式数据的依赖收集。
function createEffect(fn) {
// 把需要观察的函数赋值到一个中间变量中去
Listener = fn
// 初始化
fn()
Listener = null
}
接着我们就可以测试了
const [count, setCount] = createSandbox(0)
createEffect(() => {
console.log(`计算结果是:${count()}`)
})
setCount(2)
打印结果如下:
我们可以看到成功打印了如期的结果。
我们通过前面的学习,我们知道不管 Mobx 还是 Vue 中的订阅者中介上都存在一个调度器的参数,在 Mobx 中是 Reaction 中的 onInvalidate 参数,在 Vue3 中则是 ReactiveEffect 的 scheduler 参数,它们的主要作用是在触发依赖的时候,如果存在调度器则调用调度器,从而改变程序的执行顺序。
在这里我们也可以给我们的手写的数据响应式系统简单实现一个调度器,其实很简单,我们给 createEffect 函数传递第二个参数作为调度器,那么当触发依赖的时候,就会去执行第二个参数,而不会执行第一个参数。
-function createEffect(fn) {
+function createEffect(fn, onInvalid) {
// 把需要观察的函数赋值到一个中间变量中去
- Listener = fn
+ Listener = {
+ fn,
+ onInvalid
+ }
// 初始化
fn()
Listener = null
}
接着我们需要修改我们的触发依赖部分的代码
function createSandbox(value) {
// 省略...
// 创建一个外部环境可以访问 context 对象的方法
function setter(val) {
context.value = val
// 把存储订阅者的变量的订阅者全部通知一次
- context.observers.forEach(fn => fn())
+ // 如果存在调度器则执行调度器函数
+ context.observers.forEach(o => o.onInvalid ? o.onInvalid() : o.fn())
}
}
接着我们就可以测试了
const [count, setCount] = createSandbox(0)
createEffect(() => {
console.log(`计算结果是:${count()}`)
}, () => {
console.log(`我是调度器,更新的时候先执行调度器`)
})
setCount(2)
打印结果如下:
应用到 React 上
我们有了前面的 Mobx 和 Vue3 数据响应式库 @vue/reactivity 应用在 React 上的经验,我们再来把我们的上面实现的数据响应式系统应用到 React 上也是非常容易的。我们通过前面的学习知道 Mobx 是通过 observer 函数实现与 React 进行链接结合的,那么我们也在这里实现一个类似 observer 函数则可,为了跟我们上面的副作用函数名称有关联,我们把这个函数命名为 createRenderEffect。根据我们前篇所学的知识知道 observer 是一个高阶函数,所以我们初步把 createRenderEffect 的基础架构搭建出来。
function createRenderEffect(baseComponent) {
return (props) => {
return baseComponent(props)
}
}
通过前面学习我们知道需要通过 React 中的 useRef 来保存订阅者中介类的实例对象,而我们这里并没有实现订阅者中介类,所以我们只需要保存我们上面 createEffect 中的字面量的订阅者中介即可。代码实现如下:
function createRenderEffect(baseComponent) {
return (props) => {
const [, setState] = useState()
const adm = useRef()
let renderResult
if (!adm.current) {
// 保存字面量的订阅者中介
adm.current = {
fn: baseComponent,
onInvalid: () => {
setState(Symbol())
}
}
}
Listener = adm.current
renderResult = Listener.fn(props)
Listener = null
return renderResult
}
}
同时为了顾名思义,我们将上面实现响应式数据的函数 createSandbox 重新命名为 createSignal
// 创建作用域沙箱环境
-function createSandbox(value) {
+function createSignal(value) {
// 省略...
}
接着我们就可以测试了
const [count, setCount] = createSignal(1)
const TimerView = createRenderEffect(({ count }) => <span>this counter is: {count()}</span>)
function App() {
return (
<TimerView count={count}></TimerView>
);
}
setInterval(() => {
setCount(count() + 1)
}, 1000)
打印结果如下:
我们可以看到如期打印了结果,说明我们成功手写了一个数据响应式系统,并且应用到了 React 上。
总结
在本文章中我们成功通过沙箱模式实现了对数据的代理,再通过发布订阅模式实现了数据的响应式,再结合我们前面所学的知识成功把我们的数据响应式系统应用到了 React 上。可能有细心的同学就会发现了我们的所谓的手写响应式状态库,其实就是 SolidJS 的数据响应式的实现原理。
我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。