Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

2023 再谈 React 组件通信 #49

Open
campcc opened this issue Jun 14, 2023 · 0 comments
Open

2023 再谈 React 组件通信 #49

campcc opened this issue Jun 14, 2023 · 0 comments

Comments

@campcc
Copy link
Owner

campcc commented Jun 14, 2023

随着 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 传递给子组件,你可以点击这个 官方例子 看具体的实现。

image.png

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

Context

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

image.png

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 等),

image.png

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

image.png

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

redux

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

image.png

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

image.png

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

image.png

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

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

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

image.png

其中 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 树上,

image.png

看着很高级,其实就是定义原子状态,然后通过原子状态在组件中进行自由地组合和订阅。

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant