React 跨组件共享状态方式权衡

1,843 阅读7分钟

React 跨组件共享状态方式权衡

日常开发过程中经常遇到需要跨组件共享状态,在React中我们经常借助 Mobox、React-redux以及其衍生出的【Dva、Saga等】等进行复杂状态管理。 这些工具都很强大,各有各的优势。在复杂场景中,能够大发神威完全是开发利器。然而在很多场景下,使用不上这种太重的工具进行状态管理,简单的 context 和组件之间的状态提升已经能够cover住我们的状态共享诉求。 本文主旨是对比分析 context状态提升的使用场景以及如何权衡选择,复杂状态管理的部分在后续文章会在做分析解读。

状态提升

将需要共享的状态提升到消费该状态的组件的公共父组件上,通过props进行状态传递及状态修改入口传递【更新函数】。共享状态的组件消费对应状态,交互时绑定触发更新逻辑。

使用方式

案例:展示一个评论列表,要求实现了模式不展示内容,点击 show 按钮展示该条内容。

组件组织结构如下:

  • Accordtion
    • Panel
    • Panel

每个 Panel 存在一个 isActive 状态,当点击按钮时设置 isActive=true。isActive为true时展示详细内容。

import { useState } from 'react';


function Panel({ title, children }) {
  const [isActive, setIsActive] = useState(false);
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={() => setIsActive(true)}>
          Show
        </button>
      )}
    </section>
  );
}


export default function Accordion() {
  return (
    <>
      <h2>Almaty, Kazakhstan</h2>
      <Panel title="About">
         Panel 1 展示的内容详情
      </Panel>
      <Panel title="Etymology">
        Panel 2 z展示的内容详情。<b>呵呵!</b>
      </Panel>
    </>
  );
}

Accorditon 实例化了两次 Panel 组件,彼此之间状态相互独立,互不影响。

状态:初始化时 状态变化:彼此独立 更新时互不影响

效果如下:点击show展示内容

改动:需要点击 Panel 1 的时候,屏蔽 Panel 2。也就是同一时刻只能存在一个 Panel 处于active 状态。

此时我们需要在两个Panel之间共享一个状态用来控制最近选中的是哪个Panel, 显然控制状态放在 Panel1 或者Panel2 都不合适。需要进行状态提升改造,步骤如下:

  1. 移除 Panel 中的 isActive state,Panel 中的控制状态 isActive 从props传入。
function Panel({ title, children, isActive }) {
  //const [isActive, setIsActive] = useState(false);
  ...
}
  1. 在公共父节点中增加 isActive 状态维护,并定义更新函数,一并通过props传递给子组件。
import { useState } from 'react';

function Panel({
  title,
  children,
  isActive,
  onShow
}) {
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={onShow}>
          Show
        </button>
      )}
    </section>
  );
}

export default function Accordion() {
  const [activeIndex, setActiveIndex] = useState(0);
  return (
    <>
      <h2>Almaty, Kazakhstan</h2>
      <Panel
        title="About"
        isActive={activeIndex === 0}
        onShow={() => setActiveIndex(0)} 
       >
         Panel 1 展示的内容详情
      </Panel>
      <Panel
        title="Etymology"
        isActive={activeIndex === 1}
        onShow={() => setActiveIndex(1)}
      >
        Panel 2 z展示的内容详情。<b>呵呵!</b>
      </Panel>
    </>
  );
}

状态从Panel中移动到公共父节点中,进行统一维护。

初始化:指定其中一个处于激活 激活时:action信号 -> 父组件更新activeIndex -> 所有Panel 更新

效果如图:

至此我们实现了状态提升过程,主要是提取关键 state 维护到公共父组件中。然后通过props 下发更新方式以及最新的state内容。状态提升虽然简单,但是适用场景也比较有限。对于跨层级比较深的情况,需要通过不断的 props 转发,每一层都需要转发上一层的 props 。 如下代码,着实有点难维护,比起代码维护还有一些严重的性能问题。

稍微改造一下代码:

import React, { useState } from 'react'

function Panel({ title, children, isActive, onShow }) {
  return (
    <section className="panel">
      <h3>{title}</h3>
      {
          isActive ? <p>{children}</p> 
          : <button onClick={onShow}>Show</button>
       }
    </section>
  )
}

function A(props) {
  // some code
  return <B {...props} />
}

function B(props) {
  // some code
  return <C {...props} />
}

function C({ title, isActive, onShow }) {
  return (
    <div>
      <Panel 
       title={title} 
       isActive={isActive} 
       onShow={onShow}
      >
        Panel 1 展示的内容详情: A -> B -> C -> Panel
      </Panel>
    </div>
  )
}

export default function Accordion() {
  const [activeIndex, setActiveIndex] = useState(0)
  return (
    <>
      <h2>Almaty, Kazakhstan</h2>
      <A
        title="About"
        isActive={activeIndex === 0}
        onShow={() => setActiveIndex(0)}
      />
      <Panel
        title="Etymology"
        isActive={activeIndex === 1}
        onShow={() => setActiveIndex(1)}
      >
        Panel 2 z展示的内容详情。<b>呵呵!</b>
      </Panel>
    </>
  )
}

这样的代码使用状态提升会带来两个问题: 代码结构混乱,逻辑容易出现错误性能问题,会导致其他无关组件被额外渲染【组件re-render的触发条件:props、state、context 其中一个出现变化】。这个跨层级比较深的组件之间需要实现状态共享,context 是一大利器。

结论一:状态提升适用于兄弟组件之间的状态共享,也就是状态共享的两个组件的父组件就是公共父组件。

context 为实现跨组件状态共享而生

对于需要在一棵组件树不同位置访问和维护【read & write】同一个状态,通过 props 下钻的方式传递并不是明智之选。React 官方推荐使用 context Passing Data Deeply with Context 。使用 context 的三个步骤:

  • 使用 React.createContext(initialValue) 创建 context 对象。可传递初始值进行初始化,一般创建 context 都是分离到单独的文件实现,方便维护组件【保持组件干净和纯粹】。context 经常和reducer进行搭配,好处十分明显。
// user.ts

import React from "react";

export const UserInfoContext = React.createContext({ name: 'xxx' });
  • 在组件树的上层 provider 这个 context。context 其对应的 Provider 在注入的组件位置开始,往下所有的子孙组件都可以自由读取context里面的内容,如果需要修改这个context 则还需要配套一个context传递修改函数【稍后看个🌰】。
// layout.tsx

import React from "react";
import { UserInfoContext } from './user.ts';

export default function Layout({ children }) {
  const [user, setUser] = useState(null);
  // 登录时,获取到用户信息
  function login() {
    setUser({ name: 'balabala' })
  }
  
    return (
      <UserInfoContext.Provider value={user}>
        {children}
      </UserInfoContext.Provider>
    );
}

在这个例子中,从 Layout 中挂载了 UserInfoContext 的 Provider 并提供了value。此后 Layout的所有子孙组件都可以访问到user的内容了,怎么访问呢?接下来是第三步。

  • 使用React.useContext(context) 访问 context 内容, 还有一种旧的访问方式context.Consumer 包裹子组件,将对应的内容从 props 传递到子组件,FC组件建议使用 useContext ,简单又方便。
// info.tsx 

import React from 'react';

import { UserInfoContext } from './user.ts';

export default function InfoComp() {
  const user = React.useContext(UserInfoContext);
  
  return (
    <div>name: {user.name}</div>
  );
  
}
// 注意 InfoComp 组件是作为 Layout 子孙组件存在的

至此,context的三步走已经完成。可以访问user的内容啦!那么如何修改user呢?现在我们只能在Layout 组件进行修改,我们想在子孙组件中也可以修改user的内容怎么办才好?答案是将修改user的函数放入新的 context 中,再走一遍 ‘三步骤’。代码合并在一起写啦!

// user.ts
+
+ export const  UpdateUserContext = React.createContext(()=>{});
+

// Layout.tsx
import React from "react";
import { UserInfoContext, UpdateUserContext } from './user.ts';

export default function Layout({ children }) {
  const [user, setUser] = useState(null);
  // 登录时,获取到用户信息
  function login() {
    setUser({ name: 'balabala' })
  }
  
  return (
    <UserInfoContext.Provider value={user}>
      <UpdateUserContext.Provider value={setUser}>
        {children}
      </UpdateUserContext.Provider>
    </UserInfoContext.Provider>
  );
}

// userInfoEdit.tsx
import { UpdateUserContext, UserInfoContext } from './user.ts';
export default function Edit(){
  const update = React.useContext(UpdateUserContext);
  const user = React.useContext(UserInfoContext);
  
  function handleChange(name){
    update({name});
  }
  
  return <input value={user.name} onChange={handleChange}>
}

至此,user 的 read & write 都实现了。那么什么情况下使用context才好呢?那就是需要共享的状态在多个组件之间而且层级相差很大。一般在项目中主要用来干这样几件事情:

  • 通用能力注入。比如埋点统一触发函数,其他平台相关的通用能力,在整个app中都可能被使用到的能力,业务不同场景不同,对应的通用能力定义不同。
  • theme 主题样式、用户信息等通用数据的存储。这类信息write受限,访问不受限。也就是说修改用户信息的范围是固定的,不让整个app都去修改用户信息,但是大家都可以访问【这时候修改用户信息的函数就要不能共享在context中了,需要限制给制定的组件】。
  • 实现 route 路由、 复杂状态管理。一般都让地方三库实现了,底层还是context。

组件结构 context 访问可达子孙组件

结论二: context 用于实现跨层级过多组件之间的状态共享,如果不是全局通用状态不要挂载在根组件,一定范围内共享状态注意 context.Provider 注入的位置。层级越多越难管理。

在通常业务开发中我们使用Mobox 或者 React-redux 类的状态管理可能对于大部分场景来说都过重了。简单的场景这两种方式已然够用。


参考文档