2023 再谈 React 组件通信
我正在参加「掘金·启航计划」
随着 2019 年 2 月 React 稳定版 hooks 在 16.8 版本发布,涌现了越来越多的 “hooks 时代” 的状态管理库(如 zustand、jotai、recoil 等),“class 时代” 的状态管理库(如 redux)也全面拥抱了 hooks。无一例外,它们都聚焦于解决 组件通信 的问题,
- 组件通信的方式有哪些?
- 这么多的状态管理库要怎么选?
- 可变状态 or 不可变状态?
截至目前,React 中组件间的通信方式一共有 5 种,
- props 和 callback
- Context(官方)
- Event Bus(事件总线)
- ref 传递
- 状态管理库(如:redux、mobx、zustand、recoil、valtio、jotai、hox 等)
props & callback
React 组件最基础的通信方式是使用 props 来传递信息,props 是只读的,每个父组件都可以提供 props 给它的子组件,从而将一些信息传递给它,这里的信息可以是,
- JSX 标签信息,如
className
、src
、alt
、width
和height
等 - 对象或其他任意类型的值
- 父组件中的 state
- children
- ...
我们通常在 “父传子” 的通信场景下使用 props,下面是一个 props 通信的例子,
import React, { useState } from "react";
function Parent() {
const [count, setCount] = useState<number>(0);
return (
<>
<button type="button" onClick={() => setCount(count + 1)}>Add</button>
<Child count={count}>Children</Child>
</>
);
}
function Child(props) {
const { count, children } = props;
return (
<>
<p>Received props from parent: {count}</p>
<p>Received children from parent: {children}</p>
</>
);
}
callback 回调函数也可以是 props,利用回调,我们也可以实现简单的 “子传父” 场景,
import React, { useState } from "react";
function Parent() {
const [count, setCount] = useState<number>(0);
return (
<>
<div>Received from child: {count}</div>
<Child updateCount={(value: number) => setCount(value)} />
</>
);
}
function Child(props) {
const { updateCount } = props;
const [count, setCount] = useState<number>(0);
return (
<button
type="button"
onClick={() => {
const newCount: number = count + 1;
setCount(newCount);
updateCount(newCount);
}}
>
Add
</button>
);
}
此外,如果多个组件需要共享 state,且层级不是太复杂时,我们通常会考虑 状态提升,实现的思路是:将公共 state 向上移动到它们的最近共同父组件中,再使用 props 传递给子组件,你可以点击这个 官方例子 看具体的实现。
通过以上例子我们不难发现,在多级嵌套组件的场景下,使用 props 进行通信是一件成本极高的事情。
Context
为此,React 官方提供了 Context 避免一级级的属性传递,
Context 让父组件可以为它下面的整个组件树提供数据,这在一些特定的场景下非常有用,比如,
- 主题:可以在应用顶层放一个 context provider,并在需要调整其外观的组件中使用该 context
- 全局的共享信息:如当前登录的用户信息,将它放到 context 中可以方便地在树中任何位置读取
- 路由:大多数路由解决方案在内部使用 context 保存当前路由,用于判断链接是否处于活动状态
下面是一个使用 Context 完成主题切换的例子,
import React, { useState, useContext } from "react";
enum Theme {
Light,
Dark,
}
interface ThemeContextType {
theme: Theme;
toggle?: () => void;
}
const ThemeContext = React.createContext<ThemeContextType>({ theme: Theme.Light });
function ThemeProvider(props) {
const { children } = props;
const [theme, setTheme] = useState<Theme>(Theme.Light);
const toggle = () => {
setTheme(theme === Theme.Light ? Theme.Dark : Theme.Light);
};
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
function App() {
const context: ThemeContextType = useContext(ThemeContext);
const { theme, toggle } = context;
return (
<ThemeProvider>
<div>
<p>Current theme: {theme === Theme.Light ? "light" : "dark"}</p>
<button type="button" onClick={toggle}>toggle theme</button>
</div>
</ThemeProvider>
);
}
需要注意的是,使用 Context 我们需要考量具体的场景,因为 Context 本身存在以下问题,
- context 的值一旦变化,所有依赖该 context 的组件全部都会 force update
- context 会穿透
React.memo
和shouldComponentUpdate
的对比
此外,对于异步请求和数据间的联动,Context 也没有提供任何 API 支持,如果使用 Context,需要自己做一些封装。除了上述两个通信方案外,基于发布订阅的全局事件总线也是常见一种组件通信方案。
Event Bus
事件总线的本质就是发布订阅,目前有非常多的开源实现(如 miit、tiny-emitter 等),
我们也可以考虑自己实现一个 EventBus,
type Callback = (...args: any[]) => void;
class EventBus {
private events: Map<string, Callback[]>;
constructor() {
this.events = new Map();
}
on(eventName: string, callback: Callback): void {
if (!this.events.has(eventName)) {
this.events.set(eventName, []);
}
this.events.get(eventName).push(callback);
}
off(eventName: string, callback: Callback): void {
if (this.events.has(eventName)) {
const callbacks = this.events.get(eventName);
const index = callbacks.indexOf(callback);
if (index !== -1) {
callbacks.splice(index, 1);
}
}
}
emit(eventName: string, ...args: any[]): void {
if (this.events.has(eventName)) {
this.events.get(eventName).forEach((callback) => {
callback(...args);
});
}
}
}
EventBus 可以实现跨层级的组件通信,但由于事件的订阅和发布都是在运行时动态绑定的,这会增加代码的复杂度和调试难度。此外,我们通常还需要遵循一定的规范和约定,来更好地管理事件,避免事件名重复或滥用等问题。
ref
使用 ref
可以访问到由 React 管理的 DOM 节点,ref
一般适用以下的场景,
- 管理焦点,获取子组件的值,文本选择或媒体播放
- 触发强制动画
- 集成第三方 DOM 库
ref
也是组件通信的一种方案,通过 ref
可以获取子组件的实例,以 input
元素的输入值为例,
import React, { useRef, useState } from "react";
interface ChildProps {
inputRef: React.RefObject<HTMLInputElement>;
}
const Child: React.FC<ChildProps> = ({ inputRef }) => <input ref={inputRef} />;
const Parent: React.FC = () => {
const [text, setText] = useState<string>("");
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = () => {
if (inputRef.current) {
setText(inputRef.current.value);
}
};
return (
<div>
<Child inputRef={inputRef} />
<button type="button" onClick={handleClick}>Get Input Value</button>
<p>Input Value: {text}</p>
</div>
);
};
状态管理库
上述的组件通信方案都有各自的使用场景,如果你的项目庞大,组件状态复杂,你可能需要考虑状态管理库。众所周知,React 的状态管理库一直以来都是 React 生态中非常内卷的一个领域,截至目前比较常见的状态管理库包括,
我们可以在 npmtrends 查看这几个状态管理库 近一年的下载量趋势图,
可以看到,Redux 在下载量上依然遥遥领先其他状态管理库,而往年热度仅次于 Redux 的 Mobx,有逐渐被 zustand 超越的趋势,其他的一些 “hooks 时代” 的状态管理库热度也在逐步上升。我们先从 “class 时代” 走过来的老大哥 Redux 说起。
redux
Redux 是一个基于 Flux 架构的一种实现,遵循“单向数据流”和“不可变状态模型”的设计思想,
通过 Action-Reducer-Store
的工作流程实现状态的管理,具有以下的优点,
- 可预测和不可变状态,行为稳定可预测、可运行在不同环境
- 单一 store ,单项数据流集中管理状态,在做 撤销/重做、 状态持久化 等场景有天然优势
- 成熟的开发调试工具,Redux DevTools 可以追踪到应用的状态的改变
使用 Redux 就得遵循他的设计思想,包括其中的 “三大原则”,
- 使用单一 store 作为数据源
- state 是只读的,唯一改变 state 的方式就是触发 action
- 使用纯函数来执行修改,接收之前的 state 和 action,并返回新的 state
下面是一个使用 Redux 简单的示例,
import React from "react";
import { createStore, combineReducers } from "redux";
import { Provider, useSelector, useDispatch } from "react-redux";
// 定义 action 类型
const INCREMENT = "INCREMENT";
const DECREMENT = "DECREMENT";
// 定义 action 创建函数
const increment = () => ({ type: INCREMENT });
const decrement = () => ({ type: DECREMENT });
// 定义 reducer
const counter = (state = 0, action: { type: string }) => {
switch (action.type) {
case INCREMENT:
return state + 1;
case DECREMENT:
return state - 1;
default:
return state;
}
};
// 创建 store
const rootReducer = combineReducers({ counter });
const store = createStore(rootReducer);
// 定义 Counter 组件
const Counter: React.FC = () => {
const count = useSelector((state: { counter: number }) => state.counter);
const dispatch = useDispatch();
return (
<div>
<h2>Counter: {count}</h2>
<button type="button" onClick={() => dispatch(increment())}>add</button>
<button type="button" onClick={() => dispatch(decrement())}>dec</button>
</div>
);
};
// 使用 Provider 包裹根组件
const App: React.FC = () =>
<Provider store={store}>
<Counter />
</Provider>
可以看到,由于没有规定如何处理异步加上相对约定式的设计,导致 Redux 存在以下的一些问题,
- 陡峭的学习曲线,副作用扔给中间件来处理,导致社区一堆中间件,学习成本陡然增加
- 大量的模版代码,包括 action、action creator 等大量和业务逻辑无关的模板代码
- 性能问题,状态量大的情况下,state 更新会影响所有组件,每个 action 都会调用所有 reducer
虽然 Redux 一致尝试致力解决上述部分问题,比如后面推出的 redux toolkit,但即便如此,对于开发者(尤其是初学者)而言,仍然有比较高的学习成本和心智负担。
mobx
相比之下,Mobx 的心智模型更加简单,Mobx 将应用划分为 3 个概念,
- State(状态)
- Actions(动作)
- Derivations(派生)
其中 Derivations 又分为,
- Computed Values(计算值), 总是可以通过纯函数从当前的可观测 State 中派生
- Reactions(副作用), 当 State 改变时需要自动运行的副作用
Mobx 的整个工作流程非常简单,首先创建可观察的状态,然后通过 Actions 修改状态,Mobx 会自动更新所有的派生(Derivations),包括计算值(Computed value)以及副作用(Reactions),
如果你选择将 Mobx 结合 React 来用,那同样可以考虑直接使用 Vue,因为 Mobx 的实现基于 mutable + proxy,导致了与 React 结合使用时有一些额外成本,例如,
- 要给 DOM render 包一层 useObserver/Observer
- 副作用触发需要在 useEffect 里再跑一个 autorun/reaction
尤大在知乎的 这个回答 里也提到,一定程度上,React + Mobx 也可以被认为是更繁琐的 Vue。
zustand
zustand 是一个轻量级的状态管理库,经过 Gzip 压缩后仅 954B 大小,zustand 凭借其函数式的理念,优雅的 API 设计,成为 2021 年 Star 数增长最快的 React 状态管理库,
与 redux 的理念类似,zustand 也是基于不可变状态模型和单向数据流,区别在于,
- redux 需要包装一个全局 / 局部的 Context Provider,而 zustand 不用
- redux 基于 reducers 纯函数更新状态,zustand 通过类原生 useState 的 hooks 语法,更简单灵活
- zustand 中的状态更新是同步的,不需要异步操作或中间件
zustand 的心智模型非常简单,包含一个发布订阅器和渲染层,工作原理如下,
其中 Vanilla 层是发布订阅模式的实现,提供了setState、subscribe 和 getState 方法,React 层是 Zustand 的核心,实现了 reselect 缓存和注册事件的 listener 的功能,并且通过 forceUpdate 对组件进行重渲染,发布订阅相信大家都比较了解了,我们重点介绍下渲染层。
首先思考一个问题,React hooks 语法下,我们如何让当前组件刷新?
是不是只需要利用 useState
或 useReducer
这类 hook 的原生能力即可,调用第二个返回值的 dispatch 函数,就可以让组件重新渲染,这里 zustand 选择的是 useReducer
,
const [, forceUpdate] = useReducer((c) => c + 1, 0) as [never, () => void]
有了 forceUpdate 函数,接下来的问题就是什么时候调用 forceUpdate,我们参考源码来看,
// create 函数实现
// api 本质就是就是 createStore 的返回值,也就是 Vanilla 层的发布订阅器
const api: CustomStoreApi = typeof createState === 'function' ? createStore(createState) : createState
// 这里的 useIsomorphicLayoutEffect 是同构框架常用 API 套路,在前端环境是 useLayoutEffect,在 node 环境是 useEffect
useIsomorphicLayoutEffect(() => {
const listener = () => {
try {
// 拿到最新的 state 与上一次的 compare 函数
const nextState = api.getState()
const nextStateSlice = selectorRef.current(nextState)
// 判断前后 state 值是否发生了变化,如果变化调用 forceUpdate 进行一次强制刷新
if (!equalityFnRef.current(currentSliceRef.current as StateSlice, nextStateSlice)) {
stateRef.current = nextState
currentSliceRef.current = nextStateSlice
forceUpdate()
}
} catch (error) {
erroredRef.current = true
forceUpdate()
}
}
// 订阅 state 更新
const unsubscribe = api.subscribe(listener)
if (api.getState() !== stateBeforeSubscriptionRef.current) {
listener()
}
return unsubscribe
}, [])
我们首先从第 24 行 api.subscribe(listener)
开始,这里先创建了 listener 的订阅,这就使得任何的 setState 调用都会触发 listener 的执行,接着回到 listener 函数的内部,利用 api.getState()
拿到了最新 state,以及上一次的 compare 函数 equalityFnRef,然后执行比较函数后判断值前后是否发生了改变,如果改变则调用 forceUpdate 进行一次强制刷新。
这就是 zustand 渲染层的原理,简单而精巧,zustand 实现状态共享的方式本质是将状态保存在一个对象里,与之相对的是一些原子化的状态管理工具,比如接下来我们要介绍的 recoil。
recoil
recoil 是 React Europe 2020 Conference 上 Facebook 官方推出的一个 React 状态管理库,是对 React 内置的状态管理能力的一个补充,recoil 实现的动机如下,考虑到 React 原生状态管理的一些局限性,
- 组件间状态共享只能通过将 state 提升至它们的公共祖先,可能导致重新渲染一颗巨大的组件树
- Context 只能存储单一值,无法存储多个各自拥有消费者的值的集合
在实现上,recoil 定义了一个有向图 (directed graph),正交且天然连结于 React 树上,
看着很高级,其实就是定义原子状态,然后通过原子状态在组件中进行自由地组合和订阅。
recoil 中提供了两个核心方法用于定义状态,
- atom: 定义原子状态,即组件的某个状态的最小集
- selector: 定义派生状态,其实就是 Computed Value
消费状态的方式有 useRecoilState、useRecoilValue、useSetRecoilState 等,用法和 react 的 useState 类似,所以几乎没有上手成本,下面是一个简单的示例可以直观感受下,
import React from "react";
import { RecoilRoot, atom, useRecoilState } from "recoil";
// 定义一个原子
const countState = atom({
key: "countState",
default: 0,
});
function Counter() {
// 获取 countState 的值和修改函数
const [count, setCount] = useRecoilState(countState);
const handleIncrement = () => setCount(count + 1);
const handleDecrement = () => setCount(count - 1);
return (
<div>
<h1>Count: {count}</h1>
<button onClick={handleIncrement}>+1</button>
<button onClick={handleDecrement}>-1</button>
</div>
);
}
function DisplayCount() {
// 组件间共享 countState 原子状态
const [count] = useRecoilState(countState);
return <h2>Count: {count}</h2>;
}
function App() {
return (
// 使用 RecoilRoot 包裹组件
<RecoilRoot>
<Counter />
<DisplayCount />
</RecoilRoot>
);
}
与 recoil 类似的原子状态管理库还有 jotai,它们的设计理念基本相同。
jotai
recoil 最为人诟病的是原子状态(atom)定义时需要一个唯一的键值 key,这一反人类的设计导致键命名本身就是一个繁琐的任务,为了降低开发成本和心智负担,jotai 中定义 atom 不需要指定键值,同时也支持非 Provider 包裹的语法,
jotai 在实现上使用了 Context 和订阅机制相结合,核心的概念只有四个,
- atom,定义原子状态,不需要指定唯一键值
- useAtom,消费原子状态,与 useState hook 语法一致
- Store,存储原子状态,可以不使用,默认会使用
getDefaultStore
创建默认 Store - Provider,为组件子树提供状态,可以不使用
同样是上面的例子,使用 jotai 改写会简单很多,
import React from "react";
import { atom, useAtom } from "jotai";
// 定义原子状态不需要唯一 key
const countAtom = atom(0);
function Counter() {
const [count, setCount] = useAtom(countAtom);
const handleIncrement = () => setCount(count + 1);
const handleDecrement = () => setCount(count - 1);
return (
<div>
<h1>Count: {count}</h1>
<button onClick={handleIncrement}>+1</button>
<button onClick={handleDecrement}>-1</button>
</div>
);
}
function DisplayCount() {
const [count] = useAtom(countAtom);
return <h2>Count: {count}</h2>;
}
function App() {
return (
// 不需要 Provider 包裹
<div>
<Counter />
<DisplayCount />
</div>
);
}
zustand 和 jotai 都来自同一个组织 pmndrs,包括我们接下来要介绍的 valtio。
valtio
valtio 是一个基于可变状态模型和 Proxy 实现的状态管理库,核心实现非常简洁,只有两个方法,
- proxy,将原始对象包装为一个可观察的状态(observable state)
- useSnapshot,获取 proxy 的快照,可以访问和修改
import React from "react";
import { proxy, useSnapshot } from "valtio";
// 创建一个状态对象
const state = proxy({
count: 0,
});
function Counter() {
// 使用 useSnapshot 获取状态快照
const snapshot = useSnapshot(state);
return (
<div>
<p>Count: {snapshot.count}</p>
<button onClick={() => state.count++}>Increment</button>
</div>
);
}
valtio 和 mobx 都是基于 Proxy 的状态管理库,理念上 valtio 较于 mobx 更为简单和自由。
以上的库都聚焦于状态管理,接下来我们要介绍的是一个专注于 “组件共享状态” 的库。
hox
hox 聚焦于一个痛点:如何在多个组件间共享状态,是一个简单、轻量的状态共享库。
hox 只有一个 API,
- createStore,1.x 版本叫 createModel
createStore
会返回一个数组,包含两个参数,
- useStore,订阅和消费 store 中的数据
- StoreProvider,状态容器,底层依赖 React Context
import { useState } from "react";
import { createStore } from "hox";
// 使用 createStore 包装一个自定义 hook
export const [useTaskStore, TaskStoreProvider] = createStore(() => {
const [tasks, setTasks] = useState([]);
function addTask(task) {
setTasks((v) => [...v, task]);
}
return {
tasks,
addTask,
};
});
function TaskList() {
// 消费状态
const { tasks } = useTaskStore();
return (
<>
{tasks.map((task) => (
<div key={task.id}>{task}</div>
))}
</>
);
}
function App() {
return (
// Provider 包裹的组件间可以进行状态共享
<TaskStoreProvider>
<TaskList />
</TaskStoreProvider>
);
}
hox 底层实现是一个基于 React Context 的 singleton 单例,感兴趣的可以点击 这里 阅读它的源码。
小结
时至 2023 年,对于 React 组件的通信,我们有太多可选的方式,对于选型可以参考以下大致的思路,
- 如果组件间需要共享 state,且层级不是太复杂时,我们通常会考虑状态提升
- Context 更适合存储一些全局的共享信息,如主题,用户登陆信息等
- ref 更适用于管理焦点,获取子组件的值,触发强制动画,第三方 DOM 库集成等场景
- EventBus 可以用于跨层级组件通信,由于本质是发布订阅需要结合一些约定和规范来管理事件
- 如果你习惯了不可变更新,可以考虑生态丰富的 redux 和轻量的 zustand
- 如果你习惯了类 Vue 的响应式可变模型,mobx 和 valtio 可能更适合
- 如果你想尝试原子状态的方案,recoil 和 jotai 是个不错的选择
- 如果你想基于 custom hook 实现状态持久化和共享,hox 可能更适合
写在最后
本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!
如果有疑问或者发现错误,可以在评论区进行提问和勘误,
如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。
转载自:https://juejin.cn/post/7244408781576634405