首页 / 文章 / React 流式应用中的状态分层:Context、Redux Toolkit 与 RTK Query

React 流式应用中的状态分层:Context、Redux Toolkit 与 RTK Query

跟随视频流前端的发展历程,从 prop drilling、嵌套提供者,到 Redux Toolkit slices 与 RTK Query,了解不同类型的状态应归属于哪种工具。

1694 词

几乎所有正在开发的 React 应用都会遇到这样的问题:根组件被一堆提供者所覆盖,而播放计时器又会意外地重新渲染通知图标。解决这个问题往往并非依赖某个单一的库,而是要明白不同类型的状态需要不同的存储方式。本指南以视频流前端作为案例研究,从属性传递逐步讲到 Context API,再到 Redux Toolkit 和 RTK Query,最后给出选择合适方案的实际准则。

示例:视频流前端

想象一下动漫或视频流服务的核心功能:

  • 登录功能以及免费或高级的订阅层级
  • 观看列表和“继续观看”功能
  • 播放器状态:当前剧集、播放进度及画质设置
  • 带有类型筛选功能的目录浏览与搜索功能
  • 新剧集通知与订阅提醒
  • 这些功能都需要在组件树中的某个位置存在,而相距较远的多个组件需要读取它们。正是这种组合决定了早期的状态设计决策是会带来好处,还是会在数月后演变成缓慢且成本高昂的代码重构。

    第一阶段:属性传递

    最初的思路是将状态提升到最近的公共祖先节点并向下传递。对于小型应用来说,这是正确的做法。但在流媒体界面中,从根节点到某个按钮的路径可能很长:

    App → MainLayout → ContentSection → AnimeGrid → AnimeCard → PlayButton

    假设 PlayButton 需要知晓订阅等级才能决定是否显示高级锁定图标。该等级信息必须通过 MainLayout、ContentSection 和 AnimeGrid 传递,但这三个组件其实并不使用该信息。它们在传输链中仅起到传递作用。

    一个 prop 传递问题尚可接受。但当另一个无关的值,比如监视列表,也必须沿相同路径传递时,问题就出现了。此时每个中间组件都会接收到它无法理解的 prop,这不仅使得这些组件更难在其他地方复用,也更难以单独测试和理解。在考虑使用库之前,值得先阅读为何仅凭 prop 传递问题不足以成为安装 Redux 或 Zustand 的理由;组合模式通常能够缩短这些数据传递链。不过在这个应用中,数据确实属于全局范围。

    第二阶段:上下文与提供者层级结构

    React 的 Context API 是自然而然的下一步。你可以创建一个 AuthContext,用 AuthProvider 将整个组件树包裹起来,PlayButton 则可以通过 useContext(AuthContext) 直接读取相关状态。这样一来就无需再层层嵌套查询了。

    接着其他全局状态管理需求也出现了,每个需求都需要对应的提供者,最终组件根节点的结构会变成这样:

    <AuthProvider>
      <SubscriptionProvider>
        <WatchlistProvider>
          <PlayerProvider>
            <NotificationProvider>
              <ThemeProvider>
                <App />
              </ThemeProvider>
            </NotificationProvider>
          </PlayerProvider>
        </WatchlistProvider>
      </SubscriptionProvider>
    </AuthProvider>
    

    这就是开发者们所说的“提供者地狱”:应用根节点变成了一组嵌套的包装层,每增加一层就多一层间接访问。视觉上的混乱还不是最严重的问题。

    调试需要一张结构图

    当播放功能出现异常时,首先需要确定是哪个提供者管理着相关状态,然后再通过嵌套结构追溯状态变化的位置。仅靠组件树本身是无法提供这些信息的。

    每次更新都会传递给所有使用该状态的组件

    上下文值的变化会重新渲染所有使用该上下文的组件,即便那些只使用了其中一小部分的组件也会被重新渲染。要持续监控内容,需要每隔几秒就保存一次playbackProgress值。如果该值与质量设置一同存储在PlayerContext中,那么仅负责显示质量标识的组件也会在每次更新时被重新渲染。

    提供者的顺序成为一种隐含约定

    WatchlistProvider需要从AuthProvider获取已登录用户的ID,因此它必须嵌套在后者内部。JSX代码中并未明确体现这种依赖关系,而在重构时调整提供者的顺序可能会导致难以追踪的应用故障。

    一个实际出现的症状是:某个团队将玩家相关上下文与通知相关上下文结合在了一起,而性能分析显示进度更新正在触发通知的重新渲染。表面上并没有什么问题,但 React Profiler 显示的渲染次数远远超出了 UI 的实际需求。

    可以通过一些方法调整上下文,比如将变化频繁的值单独放入独立的上下文中,或对提供者值进行记忆化处理,但每一种解决方案都会增加更多的提供者以及更复杂的操作流程。在这种情况下,使用专门的存储方案往往更为简单。

    第三阶段:Redux Toolkit 的 slice

    如今许多团队在这一阶段会选择更轻量的存储方案,如 Zustand。而当你有多个相互关联的状态 slice、需要时间回溯式调试,并且希望拥有一个可预测的单一数据源时,Redux Toolkit 依然是绝佳的选择,它还能消除传统 Redux 中那些令人头疼的冗余代码。

    每个关注点都对应一个切片。下方的玩家切片包含当前剧集、进度及质量信息,同时定义了用于修改剧集和更新进度的还原函数。请注意,这些还原函数看似是直接修改state的;实际上Redux Toolkit在底层使用了Immer,因此这样的赋值操作能够安全地生成新的不可变状态:

    // playerSlice.js
    const playerSlice = createSlice({
      name: 'player',
      initialState: {
        currentEpisode: null,
        playbackProgress: 0,
        quality: '1080p',
      },
      reducers: {
        setEpisode: (state, action) => {
          state.currentEpisode = action.payload;
        },
        updateProgress: (state, action) => {
          state.playbackProgress = action.payload;
        },
      },
    });
    

    嵌套结构已消失。现在只有一个 <Provider store={store}> 包裹着整个应用,各个组件通过 useSelector 仅读取所需数据。由于 useSelector 会在每次渲染时比较选中的值,因此 PlayButton 在选择不同的订阅层级时,也只有在层级发生变化时才会重新渲染,而不会随着进度变化而频繁刷新。这种选择性订阅模式消除了 Context 版本中产生的大部分不必要的渲染。

    不过使用 Selector 时仍需注意:如果从 Selector 中返回新创建的对象或数组,就会破坏比较机制,从而导致额外的渲染。

    另一个重要优势是 Redux DevTools。可以逐个查看每个已发送的动作,比如播放按钮被点击、进度更新或剧集切换,从而清晰地了解状态的变化过程,这比通过层层嵌套的提供者来追踪数值要更容易诊断播放故障。

    第四阶段:用于服务器状态的 RTK Query

    应用程序的复杂性大多并非来自 UI 状态,而是服务器状态:存储在后端的节目目录、搜索结果、剧集详情以及观看列表。传统做法是每次请求时结合使用 useEffect 和多个 useState 调用,手动跟踪数据、加载状态和错误信息,这种方式容易引发竞态条件以及重复请求的问题。

    RTK Query 使用 API slice 来替代之前的方式。下面的定义指定了基础 URL,并声明了两个查询端点:一个用于按类型筛选动漫,另一个用于获取剧集详情:

    export const catalogApi = createApi({
      reducerPath: 'catalogApi',
      baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
      endpoints: (builder) => ({
        getAnimeList: builder.query({
          query: (genre) => `/anime?genre=${genre}`,
        }),
        getEpisodeDetails: builder.query({
          query: (episodeId) => `/episodes/${episodeId}`,
        }),
      }),
    });
    

    RTK Query 会为每个端点生成一个与之同名的 React hook,这些 hook 可从该 slice 中导出:

    export const { useGetAnimeListQuery, useGetEpisodeDetailsQuery } = catalogApi;
    

    组件可以通过一行代码获取数据以及加载和错误状态:

    const { data: animeList, isLoading, error } = useGetAnimeListQuery('action');
    

    无需手动编写 effect 代码,也无需设置加载标志。其最突出的特点是缓存功能:如果用户打开“动作”类型后离开又返回,缓存的列表会立即显示,同时 RTK Query 会在后台重新获取数据(当数据过期时)。来自多个组件的相同请求会共享一次网络调用。

    在持续观看功能中,基于标签的无效化机制能够保持用户界面的准确性:查询通过providesTags指定所提供的数据,而变更操作则通过invalidatesTags指定需要修改的内容。当进度更新变更操作被执行时,RTK Query会自动重新获取受影响的查询数据,因此无需任何组件手动触发重新获取操作。标签功能需要在API切片中定义tagTypes字段以及对应的变更端点,而上述代码片段中并未包含这些内容;我们的关于使用RTK Query变更操作发送数据的指南中介绍了相关内容。另外请记住,为使缓存功能正常工作,必须将API切片的还原器与中间件添加到存储中。

    将不同类型的状态对应到相应工具

    对于这类应用,合理的划分方式如下:

    • 使用 useState 管理那些永远不会离开组件的本地状态:表单输入、切换状态、悬停状态和展开状态。
    • Context API 适用于那些很少变化的简单全局值,比如主题或语言设置。当存在大量上下文或需要频繁更新的值时,它的效率会大打折扣。
    • Redux Toolkit 用于处理复杂的、相互关联的客户端状态,例如身份认证信息、订阅等级、播放器状态以及关注列表,这些状态会被许多无关的组件读写。
    • RTK Query 用于处理所有来自后端的数据,能够解决一系列问题:数据过时、请求竞争以及重复的获取操作。

    常见的错误是将此视为非此即彼的选择,要么把所有内容都放在 Context 中,要么全部放入 Redux 中。这些工具解决的是不同的问题,成熟的应用通常会将它们结合使用。

    关键要点

    • Prop drilling 是一个提示,表明应先进行代码重构;只有当数据确实需要在组件树的远端部分之间共享时,才考虑使用全局状态。
    • Context 会在每次数据变化时重新渲染所有使用它的组件,因此不适合存储播放进度这类高频更新的数值。
    • 提供者之间隐藏的依赖关系会带来维护风险,而且每新增一个 Context,这种风险就会增加。
    • Redux Toolkit 基于选择器的订阅机制以及开发者工具,使得共享的客户端状态既更易于管理,也更容易调试。
  • 避免让服务器状态影响手动实现的特效:只要存储和标签的连接正确,RTK Query 的缓存机制与标签失效处理功能就会自动保障数据的新鲜度。