likes
comments
collection
share

2023 再谈 React 组件通信

作者站长头像
站长
· 阅读数 16

随着 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 标签信息,如 classNamesrcaltwidthheight
  • 对象或其他任意类型的值
  • 父组件中的 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 传递给子组件,你可以点击这个 官方例子 看具体的实现。

2023 再谈 React 组件通信

通过以上例子我们不难发现,在多级嵌套组件的场景下,使用 props 进行通信是一件成本极高的事情。

Context

为此,React 官方提供了 Context 避免一级级的属性传递,

2023 再谈 React 组件通信

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

事件总线的本质就是发布订阅,目前有非常多的开源实现(如 miittiny-emitter 等),

2023 再谈 React 组件通信

我们也可以考虑自己实现一个 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 查看这几个状态管理库 近一年的下载量趋势图

2023 再谈 React 组件通信

可以看到,Redux 在下载量上依然遥遥领先其他状态管理库,而往年热度仅次于 Redux 的 Mobx,有逐渐被 zustand 超越的趋势,其他的一些 “hooks 时代” 的状态管理库热度也在逐步上升。我们先从 “class 时代” 走过来的老大哥 Redux 说起。

redux

Redux 是一个基于 Flux 架构的一种实现,遵循“单向数据流”和“不可变状态模型”的设计思想,

2023 再谈 React 组件通信

通过 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),

2023 再谈 React 组件通信

如果你选择将 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 状态管理库,

2023 再谈 React 组件通信

与 redux 的理念类似,zustand 也是基于不可变状态模型和单向数据流,区别在于,

  • redux 需要包装一个全局 / 局部的 Context Provider,而 zustand 不用
  • redux 基于 reducers 纯函数更新状态,zustand 通过类原生 useState 的 hooks 语法,更简单灵活
  • zustand 中的状态更新是同步的,不需要异步操作或中间件

zustand 的心智模型非常简单,包含一个发布订阅器和渲染层,工作原理如下,

2023 再谈 React 组件通信

其中 Vanilla 层是发布订阅模式的实现,提供了setState、subscribe 和 getState 方法,React 层是 Zustand 的核心,实现了 reselect 缓存和注册事件的 listener 的功能,并且通过 forceUpdate 对组件进行重渲染,发布订阅相信大家都比较了解了,我们重点介绍下渲染层。

首先思考一个问题,React hooks 语法下,我们如何让当前组件刷新?

是不是只需要利用 useStateuseReducer 这类 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 树上,

2023 再谈 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,对作者也是一种鼓励。