Skip to main content

组件通信

React 的组件通信从近到远可以分为:父子通信(Props/回调)、跨层级通信(Context / 发布订阅模式)、以及全局状态管理(Redux / Zustand / MobX 等)。

1. Props / Callback (常规父子通信)

最常规的通信方式,遵循 React 的单向数据流哲学。

  • 父传子:父组件通过属性向下传递数据。
  • 子传父:父组件将一个回调函数(Callback)通过 Props 传给子组件,子组件在需要时调用该函数,将数据作为参数回传给父组件。
  • 面试坑点:如果是极其深层的嵌套(如 A -> B -> C -> D),一直通过 Props 往下传(Props Drilling)会导致中间组件被迫接收无关属性,引发无谓的重新渲染,且代码极难维护。

2. React Context (跨层级通信)

通过 createContext 创建一个上下文,专门用于解决 Props Drilling(属性钻取)的问题。

常规用法代码示例

  1. 创建 Context 并导出

    // ThemeContext.js
    import { createContext } from "react";
    export const ThemeContext = createContext("light"); // 默认值
  2. 顶层组件提供数据 (Provider)

    // App.js
    import { ThemeContext } from "./ThemeContext";

    function App() {
    return (
    <ThemeContext.Provider value="dark">
    <Toolbar />
    </ThemeContext.Provider>
    );
    }
  3. 底层组件消费数据 (useContext)

    // Button.js (Toolbar的深层子组件)
    import { useContext } from "react";
    import { ThemeContext } from "./ThemeContext";

    function Button() {
    const theme = useContext(ThemeContext);
    return <button className={`btn-${theme}`}>按钮</button>; // 拿到 "dark"
    }

底层原理

  • Provider 传递Provider 在 React render 过程中,当其 value 发生变化时,会向下遍历子代 FiberNode,找到消费该 context 的 FiberNode 并提升其更新优先级。
  • 栈 (Stack) 结构管理:当存在多个相同的 Provider 嵌套时,React 内部使用的形式维护。在 beginWork(递)时 push 进栈,在 completeWork(归)时 pop 出栈,从而确保子组件永远取得的是离它最近的那个 Provider 的值。

🚨 面试高频灾难题:Context 穿透与性能陷阱

面试官问:使用 Context 有什么严重的性能隐患?你平时是怎么优化的?

性能陷阱(Context 穿透):Context 变动是无视 React.memo 或者 shouldComponentUpdate 的拦截的。也就是说,只要 Provider 传入的 value 对象发生了一丁点变化(甚至只是引用地址变了),所有订阅了这个 Context(调用了 useContext)的后代组件都会被强制重新渲染 (forceUpdate)!即使某个组件只用到了里面根本没变的一个小字段。

三大终极优化方案(阻断 Context 穿透渲染)

  1. 切分上下文 (Split Context):不要把整个应用的状态塞进一个巨大的 AppStoreContext 中。应该按业务拆分,比如分成 ThemeContext(低频变化)和 AuthContext
  2. 读写分离 (分离 State 和 Dispatch):将数据(State)和修改数据的方法(Dispatch)分到两个不同的 Context 中。这样当 State 变化时,那些只订阅了 Dispatch(只负责触发动作的按钮组件)就不会跟着无辜重渲染。
  3. 必加 useMemo + 组件拆分结合 React.memo
    • 首先,在 Provider 的 value 传入处,必须使用 useMemo 缓存传入的对象。否则父组件一刷新,就会生成全新的字面量对象,导致所有子组件雪崩式重渲染。
    • 其次,如果一个大组件中只有一小块需要用到 Context,不要在顶层直接 useContext,而是把用到 Context 的那块代码单独抽离成一个很小的子组件,并在外层套上 React.memo。这样 Context 更新只会打穿并渲染那个极小的子组件,大组件本身被 memo 保护。
  4. 终极方案:放弃 Context,使用第三方状态库:如果你的数据更新极其频繁(比如画布拖拽坐标),不要用 Context!直接上 Zustand 或 MobX。它们底层基于发布订阅模式,可以做到精准的字段级更新(细粒度订阅),彻底解决渲染穿透问题。

❌ 错误示范(雪崩重渲染)

function App() {
const [count, setCount] = useState(0);
const [other, setOther] = useState(0);

// 当 other 发生变化触发 App 重渲染时,这里的 { count, setCount } 是一个全新的对象地址
// 导致所有消费了 Context 的子组件全部被迫强制重新渲染!
return (
<MyContext.Provider value={{ count, setCount }}>
<Child />
</MyContext.Provider>
);
}

✅ 正确示范(使用 useMemo 阻断渲染)

function App() {
const [count, setCount] = useState(0);
const [other, setOther] = useState(0);

// 只有当 count 变化时,才会生成新的对象地址
// other 变化导致的 App 重渲染,不会波及到 Context 的消费者
const contextValue = useMemo(() => ({ count, setCount }), [count]);

return (
<MyContext.Provider value={contextValue}>
<Child />
</MyContext.Provider>
);
}

3. 全局状态管理 (Redux vs Zustand vs MobX)

在高级前端面试中,不仅要懂 API,更要懂技术选型背后的 Trade-off(权衡)

3.1 Redux (传统老大哥)

  • 核心理念:单一数据源、状态不可变(Immutable)、纯函数 Reducer 修改状态。
  • 优点:时间旅行(Time Travel)调试体验极佳;状态流转极其可控,适合极其复杂的大型金融/中后台系统。
  • 缺点:令人发指的模板代码(Boilerplate)。写一个功能要建 Action、Type、Reducer,开发心智负担极重。
  • 底层原理:结合 react-redux,底层利用了 React 18 的新 Hook useSyncExternalStore 来订阅外部状态,实现了细粒度更新,完美避开了 Context 的全量渲染陷阱。

3.2 MobX (响应式代表)

  • 核心理念:基于响应式(Reactive)的面向对象状态管理,依赖收集(类似 Vue 的 Proxy / Object.defineProperty)。
  • 优点:几乎没有模板代码,直接像修改普通 JS 对象一样 store.count++ 即可更新视图。由于底层是响应式的,谁用到了谁才更新,性能极佳。
  • 缺点:状态修改过于自由,容易导致数据流向难以追踪;脱离了 React 的 Immutable 哲学。

3.3 Zustand (现代 React 社区新宠)

  • 核心理念:极简的 Hooks 风格 API,即插即用。
  • 优点
    1. 极其轻量:源码极简,打包体积不到 2KB。
    2. 无需 Provider 包裹:打破了层级限制,不用在根节点包一层 Provider。
    3. 完美的细粒度订阅:通过 selector const bears = useStore(state => state.bears) 精准订阅所需片段。只有 bears 变了当前组件才渲染。
    4. 完全免疫 Context 陷阱:底层同样基于发布订阅和外部存储,不会引发无辜组件重渲染。

状态管理选型方案与权衡 (Zustand vs Redux vs MobX)

“在目前的新项目中,除非是百人协作的大型应用,否则我首选 Zustand。”

  1. 什么时候适合用 MobX?(性能极限与面向对象) 如果你的项目是重交互的图形编辑器(如在线 Excel、白板、Canvas 画图),里面充斥着几万个对象,且这些对象之间有复杂的关联计算。这种场景下:
    • 性能极限:MobX 靠数据劫持做到了“魔法级”的细粒度更新,修改一个单元格的值绝不会导致整个表格重绘。
    • 面向对象:它可以完美配合 OOP(面向对象编程),把逻辑封装在 Class 里。
    • 缺点:违背 React 的 Immutable 哲学,user.name = 'xxx' 这种自由的修改会导致常规业务的数据流追踪变得极其困难。所以它只适合这种“偏客户端/游戏引擎”的重型 Web 软件。
  2. 为什么日常业务首选 Zustand?(平衡的艺术) 绝大多数普通的后台/C端项目根本碰不到 MobX 的性能瓶颈。Zustand 坚守了 Immutable 的规矩,却把原本像 Redux 那样繁琐的模板代码砍到了极致。它的 Hooks API 设计最符合现代 React 直觉,性能足够好,且不破坏数据流规矩。
  3. 什么时候必须请出老大哥 Redux?(自由 vs 约束) Zustand 太自由,且异步处理偏弱。如果在金融或复杂中后台场景,Redux 具有不可替代的价值:
    • 极致的调试审计:必须派发 Action 的死板规定,换来的是完美的时间旅行 (Time Travel) 和状态审计日志。
    • 异步流控制:面对“竞态、轮询、重试”等变态异步流,redux-saga 的生态降维打击其他库。
    • 强架构约束:繁琐的模板代码本质上是给百人团队定下的“防呆规矩”,兜住了大型项目代码质量的下限。

4. Event Bus (发布订阅模式)

对于一些没有任何层级关系(如兄弟节点,甚至跨路由)的组件,偶尔需要发送一次性指令,可以使用 Event Bus(如 mitt 或自己手写一个简单的发布订阅类)。

  • 优点:极度解耦。
  • 缺点:滥用会导致代码变成“意大利面条”,很难追踪事件是从哪里发出来的;且必须记得在 useEffect 的 cleanup 中调用 off 解绑,否则会导致内存泄漏