前言
鉴于 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;
打印结果如下:
我们发现已经成功把 @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;
我们通过把响应式数据代理到实例对象上,优化了引用类型的使用方式。
至此,我们受 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 应用开发。