在 react 中使用 mobx 接管所有状态

本文最后更新于:2023年9月2日 下午

前言

在 react hooks 中,useEffect 是最常用的 hooks 函数,但其手动管理依赖状态的 api 体验被人诟病已久,社区出现了无数关于如何正确使用 useEffect 的文章,但仍然拦不住更多新人很难正确使用这个 api 的事实,也被戏称为 react 新人墙。而且,在流行 web 框架有且仅有 react 是需要手动管理 hooks 依赖的。其他框架,例如 vue、svelte 与 solidjs 都不需要手动管理依赖。最近 react 社区关于 signals 的突然火热讨论也正反应了更多人认识到了这种 dx 的糟糕之处,preact 甚至 官方支持了 signals

signal 是什么

这个概念的流行源于 solidjs,它有一个 createSignal 的函数用于创建响应式的状态,并且也有两个相关的 memo 和 effect 概念,和 react hooks 很相似。但它解决了两个关键的 dx 问题

  1. react 需要手动管理依赖
  2. react 的状态修改完之后不能立刻读取到新值
  3. 可以在框架之外使用 – 不是关键问题

例如

1
2
3
4
const [first, setFirst] = createSignal('JSON')
const [last, setLast] = createSignal('Bourne')

createEffect(() => console.log(`${first()} ${last()}`))

这段代码会在控制台输出 JSON Bourne,并且当 first 或 last 发生变化时,会再次输出新的值,值得注意的是 createEffect 没有第二个依赖数组的参数。

但 solidjs 仍然分离了 get 与 set 接口,这导致了 get 必须是一个函数,才能在使用状态时创建订阅,这有点奇怪,所以 solidjs 社区也有人使用 get/set 函数封装了一层,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function ref<T>(value: T): {
value: T
} {
const [state, setState] = createSignal(value)
return {
get value() {
return state()
},
set value(v: T) {
setState(() => v)
},
}
}

const count = ref(1)
console.log(count.value) // 1
count.value += 1
console.log(count.value) // 2

不过 solidjs 仍然不支持任意写,例如下面这段代码仍然不会触发渲染。

1
2
3
4
const store = ref({ count: 1 })
const increment = () => {
store.value.count += 1
}

而且在组件中的代码只会运行一次,而在函数结尾返回的 jsx 则会多次渲染,这会导致一些有趣的行为。

例如下面两个组件在 solidjs 中不一样

1
2
3
4
5
6
7
8
function One(props) {
const doubleCount = props.count * 2
return <div>{doubleCount}</div>
}

function Two(props) {
return <div>{props.count * 2}</div>
}

为什么 signals 突然火了

吾辈猜测是 solidjs 的采用导致的。vue、svelte 虽然也不需要手动管理依赖,但它们与 react 的编写方式差异很大,它们都有自己的模版语法,vue 甚至需要额外的插件才能使用 jsx,而且体验也不算太好,所以更多人将它们视为不同框架的差异 – 而不是哪个 hooks api 更好。而 solidjs 完全采用了 jsx 的语法与社区相关的工具链,但状态管理则对开发者更友好,使用 useEffect/useMemo/useCallabck 不再需要手动管理依赖项,而是以高效的方式自动处理。
下面这段视频最能表达吾辈的看法

link: https://www.youtube.com/embed/hRCN_FJWutQ

react 的一通操作猛如虎,结果一个 signals 将所有问题更优雅的解决了。
其中展示的操作有

  • 虚拟 dom
  • 不可变数据
  • hooks
  • 依赖数组
  • 编译器优化和自动缓存

举个例子

依赖传递有依赖性,useEffect/useMemo/useCallback 这些函数都依赖有 deps array 参数。而且它们之间还可以互相依赖,例如 useMemo 的值可以被 useCallback 作为依赖,简而言之,如果你使用了这些常见的 react hooks,就必须手动管理它们之间的依赖图。如果没有正确管理,就可能会产生非常微妙的错误。react 提供了 eslint 规则来检查,但一方面并不是所有项目都使用 eslint,另一方面,这个 eslint 规则通常显得过于严格,在一些情况下必须手动关闭,例如使用 useEffect 时希望根据 a 值的变化触发副作用,但同时需要读取最新的 b 值,而在这方面 eslint 规则就会爆炸。另一方面,react 的状态在修改后立刻读取并不能读取到最新的,这不是 react hooks 带来的,而是 react 中一直存在的一个问题。

状态的更新与读取

传统的心智模型,你修改完变量就立刻读取到最新的值。

1
2
3
4
let i = 0
console.log(i) // 0
i += 1
console.log(i) // 1

react 的心智模型,使用 await new Promise(resolve => setTimeout(0, resolve)) 等待下一次的循环才能读取到最新的值。

1
2
3
4
5
6
const [i, setI] = useState(0)
console.log(i) // 0
setI(i + 1)
console.log(i) // 0
await new Promise((resolve) => setTimeout(0, resolve))
console.log(i) // 1

这种方法主要问题是冗长不够直观而且不是特别可靠。


或者使用临时变量保存新值,并在后续使用新值。

1
2
3
4
5
const [i, setI] = useState(0)
console.log(i) // 0
const newI = i + 1
setI(newI)
console.log(newI) // 1

这种方法在实践中可能是使用比较多的,主要就是需要创建额外的变量


或者使用 immer,你可以使用 produce 包一层,以在 callback 中修改后可以读取到最新的值。

1
2
3
4
5
6
7
8
9
10
11
12
import produce from 'immer'

const [i, setI] = useState(0)
console.log(i) // 0
setI(
produce(i, (draft) => {
draft += 1
console.log(draft) // 1
return draft
}),
)
console.log(i) // 0

但该函数与异步函数配合的不是很好,例如以下代码是不可能的,因为 produce 接受的 callback 返回 Promise 时,produce 函数的结果也会是一个 Promise,这对于 react 的 set 函数而言不可用。当然你可以加 await,但多个状态时你又需要合并与拆分,这些样板代码都很烦人。

1
2
3
4
5
6
7
8
setI(
produce(i, async (draft) => {
setTimeout(() => {
draft += 1
}, 0)
return draft
}),
)

使用 mobx 的代码

1
2
3
4
const store = useLocalStore(() => ({ value: 0 }))
console.log(store.value) // 0
store.value += 1
console.log(store.value) // 1

这种模型的好处是,你可以直接修改状态,而不需要使用 set 函数,而且你可以直接读取最新的值,而不需要使用 await 等待下一次循环。基本上,它与 vue 的 reactive hooks 类似,生成一个可变的对象,然后可以修改和读取,即便是深层的。某种意义上,vue3 hooks 确实是 react + mobx 的简化,但模板相比于 jsx 让许多人不习惯(不喜欢)。

依赖地狱

例如下面这段代码在 react 中很常见

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { useState, useEffect } from 'react'

function App() {
const [text, setText] = useState('')
const [result, setResult] = useState('')
useEffect(() => {
fetch('/api?text=' + text)
.then((response) => response.text())
.then((data) => {
setText(data)
})
}, [text])

return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<div>{result}</div>
</div>
)
}

使用 mobx 可以改写为

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
import { observer, useLocalStore } from 'mobx-react-lite'

const App = observer(() => {
const store = useLocalStore(() => ({
text: '',
result: '',
setText(text: string) {
this.text = text
fetch('/api?text=' + this.text)
.then((response) => response.text())
.then((data) => {
this.result = data
})
},
}))

return (
<div>
<input
value={store.text}
onChange={(e) => store.setText(e.target.value)}
/>
<div>{store.result}</div>
</div>
)
})

不过一般而言可能会将 mobx 仅管理状态,而相关的功能函数放在组件顶级。

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
import { observer, useLocalStore, useObserver } from 'mobx-react-lite'

const App = observer(() => {
const store = useLocalStore(() => ({
text: '',
result: '',
}))

useObserver(() => {
fetch('/api?text=' + store.text)
.then((response) => response.text())
.then((data) => {
store.result = data
})
})

return (
<div>
<input
value={store.text}
onChange={(e) => (store.text = e.target.value)}
/>
<div>{store.result}</div>
</div>
)
})

useMemo 也是一样的,可以使用 mobx 的 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
28
29
import { observer, useLocalStore, useObserver } from 'mobx-react-lite'

const App = observer(() => {
const store = useLocalStore(() => ({
text: '',
result: '',
get computedResult() {
return this.result + this.text
},
}))

useObserver(() => {
fetch('/api?text=' + store.text)
.then((response) => response.text())
.then((data) => {
store.result = data
})
})

return (
<div>
<input
value={store.text}
onChange={(e) => (store.text = e.target.value)}
/>
<div>{store.computedResult}</div>
</div>
)
})

封装一些工具 hooks

当然,mobx 可能有一些样板代码,但可以通过一些封装解决,看起来像是 vue hooks xd。

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
34
35
36
37
import { useLocalStore, useObserver } from 'mobx-react-lite'

/**
* 声明一个状态,一般用于原始值,例如数字或者字符串
*/
export function useLocalRef<T>(value: T): { value: T } {
return useLocalStore(() => ({ value }))
}

/**
* 声明一个状态,一般用于非原始值,例如对象或者数组
*/
export function useLocalReactive<T extends Record<string, any>>(value: T): T {
return useLocalStore(() => value)
}

/**
* 声明根据状态变更运行副作用
*/
export function useLocalWatchEffect(f: () => void, dep?: () => any) {
useObserver(() => {
dep?.()
return f()
})
}

/**
* 声明一个计算属性
*/
export function useLocalComputed<T>(f: () => T): { value: T } {
const r = useLocalStore(() => ({
get value() {
return f()
},
}))
return r
}

局限性

虽然 mobx 很好,但它也有一些局限性

  • 需要一些样板代码 observer/useLocalStore
  • 子组件可以修改传入的状态
  • 结构化克隆时需要使用 toJS 将 proxy 代理对象转换为普通 js 对象
  • 没有直接的显式声明依赖运行副作用的方法
  • 不能完全避免使用 react hooks 自带的一些方法,尤其是依赖于一些第三方库时

在 react 中使用 mobx 接管所有状态
https://blog.rxliuli.com/p/1fcd1517c14c4e68b764d7e6df071805/
作者
rxliuli
发布于
2023年3月14日
许可协议