本文最后更新于: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 问题
- react 需要手动管理依赖
- react 的状态修改完之后不能立刻读取到新值
- 可以在框架之外使用 – 不是关键问题
例如
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) count.value += 1 console.log(count.value)
|
不过 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) i += 1 console.log(i)
|
react 的心智模型,使用 await new Promise(resolve => setTimeout(0, resolve))
等待下一次的循环才能读取到最新的值。
1 2 3 4 5 6
| const [i, setI] = useState(0) console.log(i) setI(i + 1) console.log(i) await new Promise((resolve) => setTimeout(0, resolve)) console.log(i)
|
这种方法主要问题是冗长不够直观而且不是特别可靠。
或者使用临时变量保存新值,并在后续使用新值。
1 2 3 4 5
| const [i, setI] = useState(0) console.log(i) const newI = i + 1 setI(newI) console.log(newI)
|
这种方法在实践中可能是使用比较多的,主要就是需要创建额外的变量
或者使用 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) setI( produce(i, (draft) => { draft += 1 console.log(draft) return draft }), ) console.log(i)
|
但该函数与异步函数配合的不是很好,例如以下代码是不可能的,因为 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) store.value += 1 console.log(store.value)
|
这种模型的好处是,你可以直接修改状态,而不需要使用 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 自带的一些方法,尤其是依赖于一些第三方库时