普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月22日首页

7.响应式系统比对:手写一个响应式状态库并应用在 React 上

作者 Cobyte
2026年4月22日 08:58

前言

我们通过第一篇文章总结出的结论是,基于依赖追踪的响应式系统的本质是在读取数据的时候收集依赖,在更新数据的时候触发依赖,在后续的文章中我们又知道了所谓的依赖收集在发布订阅模式中就是订阅者进行订阅操作,触发依赖则是发布者进行发布操作,那么基于此原理,我们只需要把数据的读写进行分离再结合发布订阅模式那么可以实现数据响应式了。为了实现数据读写劫持,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())

打印结果如下:

01.png

那么根据上文我们就可以在访问沙箱作用域中的对象的时候进行依赖收集,修改该对象的时候进行依赖触发,从实现了该对象的读写代理了。

通过发布订阅模式实现数据响应式

同时通过上文对我们前面所学的知识的总结我们又知道了所谓的依赖收集在发布订阅模式中就是订阅者进行订阅操作,触发依赖则是发布者进行发布操作,那么基于此原理,我们只需要把数据的读写进行分离再结合发布订阅模式那么可以实现数据响应式了。

通过前面文章的学习我们知道实现发布订阅模式需要一个变量来存储订阅者,那么在这里我们可以把这个变量设置在 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)

打印结果如下:

02.png

我们可以看到成功打印了如期的结果。

至此,我们就通过发布订阅模式实现数据响应式。

实现响应式副作用函数

根据我们前面所学的知识,我们知道不管是 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)

打印结果如下:

02.png

我们可以看到成功打印了如期的结果。

我们通过前面的学习,我们知道不管 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)

打印结果如下:

03.png

应用到 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)

打印结果如下:

tutieshi_550x220_5s.gif

我们可以看到如期打印了结果,说明我们成功手写了一个数据响应式系统,并且应用到了 React 上。

总结

在本文章中我们成功通过沙箱模式实现了对数据的代理,再通过发布订阅模式实现了数据的响应式,再结合我们前面所学的知识成功把我们的数据响应式系统应用到了 React 上。可能有细心的同学就会发现了我们的所谓的手写响应式状态库,其实就是 SolidJS 的数据响应式的实现原理。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

昨天以前首页

6.响应式系统比对:通过 Vue3 响应式库写 React 应用

作者 Cobyte
2026年4月20日 09:04

前言

鉴于 Vue3 已经把响应式库进行了独立,也就是 @vue/reactivity,既然 Mobx 也是一个响应式库都可以应用在 React 上,那么 @vue/reactivity 可不可以也应用在 React 上呢?很显然是可以的,社区里也有很多关于这么方面的实践。那么我们这里也提供一个参考 Mobx 实现的版本。

跟 Mobx 对比的话,@vue/reactivity 就相当于 mobx 库,所以我们只需要参考 mobx-react-lite 实现一个 vue-react-lite 即可。

实现 vue-react-lite

我们通过上一篇文章可以知道 Mobx 是通过 mobx-react-lite 实现与 React 进行链接的,其中最重要的函数就是 observer,那么我们也在 vue-react-lite 中实现一个 observer 函数。根据我们前篇所学的知识知道 observer 是一个高阶函数,所以我们初步把 observer 的基础架构搭建出来。

function observer(baseComponent) {
    return (props) => {
        return baseComponent(props)
    }
}

接下来我们知道 Mobx 中是通过 Reaction 这个订阅者中介来实现不同组件函数的代理的,而在 @vue/reactivity 中的跟 Reaction 相同角色的的则是 ReactiveEffect,那么我们就可以通过它来实现我们想要的功能。

代码实现如下:

import { useState, useRef } from "react"
import { ReactiveEffect  } from "@vue/reactivity"
function observer(baseComponent) {
    return (props) => {
        const [, setState] = useState()
        const admRef = useRef(null)
        if (!admRef.current) {
            admRef.current = new ReactiveEffect(() => {
                return baseComponent(props)
            }, () => {
                setState(Symbol())
            })
        }
        const effect = admRef.current
        return effect.run()
    }
}

那么我们就通过 ReactiveEffect 实现了一个跟 mobx-react-lite 中的 observer 一样的功能的函数。

如果大家对 Vue3 的 effect 函数熟悉的话,我们上述 observer 的实现过程跟 Vue3 的 effect 实现很类似的。我们可以回顾一下 Vue3 的 ReactiveEffect 类的功能,它本质是一个订阅者中介,跟 Vue2 的 Watcher 类是一样的角色。ReactiveEffect 的第一个参数就是具体的订阅者函数,而第二个参数则是一个叫 scheduler 的回调函数,在更新的时候如果存在 scheduler 回调函数则执行 scheduler 回调函数,否则执行第一个参数的函数。基于这个原理,我们就在 ReactiveEffect 的第二个参数中设置执行 React 的更新 setState(Symbol()),同时 ReactiveEffect 上存在一个 run 方法,需要通过手动执行进行初始化。

应用 vue-react-lite

那么我们上面通过 ReactiveEffect 实现了 observer 函数,这样我们就可以在 React 中应用 Vue3 的数据响应式库了。下面我们来测试一下:

import { reactive } from "@vue/reactivity";
import { observer } from "./vue-react-lite"

const proxy = reactive({ name: 'Cobyte', secondsPassed: 0 })

const TimerView = observer(({ proxy }) => <span>the content run in `@vue/reactivity` is "Seconds passed: {proxy.secondsPassed}"</span>)

function App() {
  return (
    <TimerView proxy={proxy}></TimerView>
  );
}

setInterval(() => {
  proxy.secondsPassed +=1
}, 1000)

export default App;

打印结果如下:

tutieshi_640x195_5s.gif

我们发现已经成功把 @vue/reactivity 库应用到 React 中了。

根据 Mobx 的启发实现 Vue 数据响应式的 OOP

我们知道 Mobx 的写法是更倾向 OOP 的,同时是严格遵守单向数据流,所以我们也可以在通过 Vue 响应式库提供的 shallowRef API 实现 OOP。

import { reactive, shallowRef } from "@vue/reactivity"
import { observer } from "./vue-react-lite"

class DataService {
  constructor(val) {
    this.r = shallowRef(val)
  }
  get count() {
    return this.r.value
  }
  setCount(val) {
    this.r.value = val
  }
}
const dataService = new DataService(0)
const TimerView = observer(({ proxy }) => <span>the content run in @vue/reactivity is "Seconds passed: {proxy.count}"</span>)

function App() {
  return (
    <TimerView proxy={dataService}></TimerView>
  );
}

setInterval(() => {
  dataService.setCount(Date.now())
}, 1000)

export default App;

但上述方式还是不能堵住别人可以通过直接修改对象的方式更改响应式的值,从而打破单向数据流的规则。

例如下面的例子:

setInterval(() => {
    dataService.r.value = Date.now()
}, 1000)

那么为了堵住这个漏洞,我们可以通过私有变量来解决:

class DataService {
  #r
  constructor(val) {
    this.#r = shallowRef(val)
  }
  get count() {
    return this.#r.value
  }
  setCount(val) {
    this.#r.value = val
  }
}
const dataService = new DataService(0)

这个时候我们就不能通过直接修改对象的方式更改响应式的值了。

setInterval(() => {
    dataService.#r.value = Date.now()
}, 1000)

我们上述这种方式比较适合基本数据类型的情况,如果是引用类型的话,就不太适用了。如果是引用类型我们不可能在上面写那么多属性访问器,我们可以像 Vue2 那样把所有的响应式数据代理到 Vue 的实例对象上,然后可以通过 this 进行访问。

修改如下:

import { shallowRef } from "@vue/reactivity";
class DataService {
  #r
  constructor(val) {
    this.#r = shallowRef(val)
    // 像 Vue2 一样把响应式数据代理到实例对象上
    return new Proxy(this, {
      get(target, key) {
        // 如果是响应式数据就返回响应式数据
        if (target.#r.value[key]) {
          return target.#r.value[key]
        } else {
          // 如果是自身的属性就返回自身属性,例如 setState
          return target[key]
        }
      },
      set(target, key, val) {
        throw new Error('请通过 setState 方法进行更新')
      }
    })
  }
  setState(val) {
    this.#r.value = val
  }
}

const dataService = new DataService({ name: 'Cobyte', date: '2024-03-22', now: { time: 123 } })
const TimerView = observer(({ proxy, now }) => <span>the content run in @vue/reactivity "author: {proxy.name}, the date is: {proxy.date} now is {proxy.now.time}"</span>)

function App() {
  return (
    <TimerView proxy={dataService} now={dataService.now}></TimerView>
  );
}

setInterval(() => {
  dataService.setState({ name: '掘金签约作者', date: '2024年3月22日', now: { time: Date.now() }})
}, 1000)

export default App;

我们通过把响应式数据代理到实例对象上,优化了引用类型的使用方式。

tutieshi_640x284_4s.gif

至此,我们受 Mobx 的启发实现了在 React 中使用 Vue3 的响应式数据库,同时跟 Mobx、Flux、Redux 一样实现单向数据流。不过我们目前采用的是最新的技术私有变量,这个方案目前兼容性并不好,但作为技术交流也可以给大家一个启发。

为什么 Vue 可以通过重新运行组件 render 函数进行更新?

我们在前篇文章通过相对比较简洁的代码实现了 Mobx 的核心原理,同时对比了同时响应式的 Vue 和 Mobx 的最大设计区别,在 Vue 中创建的响应式数据,是可以随意在任何地方通过普通属性访问器进行修改的,但 Mobx 中则不提倡这种可以随意修改 state 的方式,在 Mobx 中希望开发者通过 actions 来改变 state,本质是像 React 那样通过一个函数来修改 state,或者说是遵循 Flux 和 Redux 的单向数据流思想。同时 Mobx 中的订阅者中介 Reaction 和 Vue 中的订阅者中介实现则有比较大的区别,主要是因为 Mobx 主要的设计受 React 的影响,在更新的时候需要特别的设置,而不像 Vue 那样直接重新运行副作用函数就可以了,这个说到底也是因为 React 不是靠依赖追踪来实现响应式的缘故。

那么问题就来了,为什么 Vue 可以通过重新运行组件 render 函数进行更新,而 React 则不行?当然 React 在普通情况下,你在更新的时候是不知道哪个组件函数需要更新,但我们通过 Mobx 就可以实现了依赖收集,就可以知道更新的时候那些组件函数需要重新执行,但即便这样 React 也不能通过重新执行组件函数来实现更新,这是为什么呢?

一个组件要渲染到页面上需要哪些必备条件呢?我们先看看下面的一个 React 应用的渲染例子:

ReactDOM.render(App, document.getElementById("root")

那么从上述的 React 应用渲染的例子我们可以知道,一个组件渲染到页面上是一定要知道渲染到哪个元素容器中的,这一点无论是 React 还是 Vue 都是一样的。如果仅仅只是执行一个组件函数是不能实现渲染的,所以在实现 Mobx 的 Reaction 的时候,不能像 Vue 的订阅者中介那样实现。那么为什么在 Vue 中可以通过重新运行组件 render 函数进行更新呢,或者是直接重新运行组件函数进行更新呢?

这是因为在 Vue 中被收集到订阅者记录变量中的函数,并不是组件的 render 函数,而是一个高阶函数,在高阶函数内部才最后执行组件的 render 函数。我们这里以 Vue3 中的情况进分析,在 Vue3 中最后处理组件 render 函数的地方是在 setupRenderEffect 函数中,下面是 setupRenderEffect 的简洁实现代码结构。

function setupRenderEffect(instance, initialVNode, container, anchor, parentSusp) {
    const componentUpdateFn = () => {
        if (!instance.isMounted) {
            // 初始化走这里
            const subTree = (instance.subTree = renderComponentRoot(instance))
            // 通过 patch 函数进行挂载,第三个参数就要挂载的HTML容器
            patch(
                null,
                subTree,
                container, // 目标挂载点
                anchor,
                instance,
                parentSuspense,
                isSVG
            )
            instance.isMounted = true
        } else {
            // 更新走这里
            // 重新执行组件 render 函数
            const nextTree = renderComponentRoot(instance)
            // 上一次的生成的虚拟DOM为旧的虚拟DOM
            const prevTree = instance.subTree
            instance.subTree = nextTree
            // 更新也是通过 patch 函数进行挂载,也同样需要提供挂载的HTML容器,也就是第三个参数
            patch(
                prevTree,
                nextTree,
                // parent may have changed if it's in a teleport
                hostParentNode(prevTree.el!)!, // 更新的时候也需要提供渲染的目标挂载HTML元素
                // anchor may have changed if it's in a fragment
                getNextHostNode(prevTree),
                instance,
                parentSuspense,
                isSVG
            )
        }
    }
    // 从这我们可以看到被收集的依赖并不是组件的 render 函数,而是一个包装函数 componentUpdateFn
    const effect = (instance.effect = new ReactiveEffect(
      componentUpdateFn,
      () => queueJob(update), // 调度函数 scheduler,最后还是执行 update 方法
      instance.scope // track it in component's effect scope
    ))
    // 初始化的时候需要执行 run 方法
    const update = (instance.update = () => effect.run())
    // 执行
    update()
}

我们从上面的 Vue3 的 setupRenderEffect 的简洁实现代码中可以看到在 Vue 中所谓收集依赖的依赖并不是组件的渲染函数,而是一个包装函数,在包装函数中在初始化和更新阶段都是通过执行组件的 render 函数获得组件的虚拟DOM,然后再通过 patch 函数进行渲染挂载到具体的元素节点下。而在 Vue 的内部中是可以获取到具体需要渲染挂载的元素节点的,而我们在 React 的应用层首先是无法通过组件函数获得需要挂载的元素节点的,其次 React 的更新流程本质上就跟 Vue 这类型通过依赖收集的数据响应式框架不一样。

总结

本文受 Mobx 启发,利用 @vue/reactivity 的 ReactiveEffect 实现了类似 mobx-react-lite 的 observer 高阶函数,成功将 Vue 响应式库集成到 React 中,实现了单向数据流和依赖追踪。同时,通过私有变量和 Proxy 代理优化了 OOP 风格下的响应式数据访问,避免了直接修改状态。最后,从底层机制解释了 Vue 能够直接重新运行组件 render 函数更新,而 React 不能的根本原因:Vue 的依赖收集针对的是包含 patch 挂载逻辑的包装函数,可获取具体渲染容器;React 的更新流程不依赖此类追踪,且组件函数层面无法获取挂载节点。这揭示了两种框架在设计哲学与实现机制上的本质差异。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

❌
❌