xiaoxiaojx/blog

React SSR 子组件获取不到 Context 问题记录

xiaoxiaojx opened this issue · 0 comments

image

背景

服务端渲染的项目本地模拟线上环境运行报了如下的一个错误,然而本地开发模式运行和真实的线上生产模式运行均没有问题。当听到这个问题描述时,我只觉得这个临床表现透露着诡异的氛围 😢

本地模拟线上环境是先构建出生产模式的代码,然后运行 SSR Server 。其目的是更接近真实的线上生产环境的效果, 通常用于复现与 debug 线上环境出现的问题。

// 错误信息

Invariant Violation: You should not use <Switch> outside a <Router>

问题简述

上面的错误信息造成原因通常有两个

  1. Switch 组件的上层没有 Router 组件,解决办法是使用基于 Router 组件的 BrowserRouter 组件或 HashRouter 组件作为 Switch 的父组件

服务端运行使用的其实是 StaticRouter, 服务端渲染的是一个请求 path 的页面快照, 不存在客户端路由会切换的情况

  1. node_modules 中 react-router 有多个版本,解决办法是收拢依赖,只能允许一个版本

如果 react-router 有多个版本, 使用 Router 组件的 RouterContext 与使用 Switch 组件的 RouterContext 将会是在两个版本的文件中,造成 RouterContext 不是同一个引用,平时这一点较难发现

熟悉 React 的同学应该知道, 子组件要能从 RouterContext.Consumer 中获取到父组件 RouterContext.Provider 注入的数据, 其 RouterContext 必须是同一个对象才行

如下 react-router 的代码, 说明了 Switch 组件是需要从 Router 组件获取必要的 context 信息, context 不存在则抛错

// react-router

class Router extends React.Component {

  render() {
    return (
      <RouterContext.Provider
        value={{
          history: this.props.history,
          location: this.state.location,
          match: Router.computeRootMatch(this.state.location.pathname),
          staticContext: this.props.staticContext
        }}
      >
        <HistoryContext.Provider
          children={this.props.children || null}
          value={this.props.history}
        />
      </RouterContext.Provider>
    );
  }
}

class Switch extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {context => {
          // 抛错处 👇
          invariant(context, "You should not use <Switch> outside a <Router>");

          const location = this.props.location || context.location;

          let element, match;

          // We use React.Children.forEach instead of React.Children.toArray().find()
          // here because toArray adds keys to all child elements and we do not want
          // to trigger an unmount/remount for two <Route>s that render the same
          // component at different URLs.
          React.Children.forEach(this.props.children, child => {
            if (match == null && React.isValidElement(child)) {
              element = child;

              const path = child.props.path || child.props.from;

              match = path
                ? matchPath(location.pathname, { ...child.props, path })
                : context.match;
            }
          });

          return match
            ? React.cloneElement(element, { location, computedMatch: match })
            : null;
        }}
      </RouterContext.Consumer>
    );
  }
}

问题排查

1. 确认 RouterContext 是同一个引用

从 yarn.lock 文件看出 react-router 确实只有一个版本,不过仍然存在 node_modules 文件缓存没有删除成功,导致残留了旧版本的可能性。此时我们需要分别在 Router 和 Switch render 时加上 debugger, 确认代码运行时 RouterContext.Provider 与 RouterContext.Consumer 是同一个 RouterContext 引用

通过 debugger 断点也确认了 RouterContext 是同一个引用, 那么子组件通过 Consumer 仍然拿不到 context 岂不是 React 的 bug ?

// react-router

class Switch extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {context => {
          // 抛错处 👇
          invariant(context, "You should not use <Switch> outside a <Router>");

		  // ...
      </RouterContext.Consumer>
    );
  }
}

2. React 的 bug ?

此时我们还不能确认是 React 的 bug, 要先摆脱 react-router 的嫌疑。写了如下的 demo, 发现 console.log 依然没有值,不过把 demo 复制到相同 react 版本的另一个 SSR 项目中 console.log 是有值的,得出不是 React 的 bug

import React from 'react'

const MyRouterContext = React.createContext({})

function App() {
  return (
    <MyRouterContext.Provider value={{ test: 1 }}>
      <MyRouterContext.Consumer>
        {(ctx) => {
          console.log(ctx)
          return 11111
        }}
      </MyRouterContext.Consumer>
    </MyRouterContext.Provider>
  )
}

排查了一圈下来发现大家都是被冤枉的 😢

  • react 和 react-router 没有问题
  • 本地开发模式运行和真实的线上生产模式运行也没有问题

3. 对比关键信息的异同

最后只能和正常能运行的 SSR 项目来进行不同了,排查重点在于

  1. package.json 中的依赖
  2. 脚手架配置文件的配置信息

在一阵对比后, 还是发现了关键的信息。本地模拟线上环境运行的是下面的命令

模拟线上 NODE_ENV 最好是应该设置成 production, 这里却设置成了 development

    "co-start": "yarn build && NODE_ENV=development DOCKER=true yarn start"

生产环境 yarn build 打包后,代码开始按如下顺序运行

  1. 读取脚手架配置文件的配置信息
  2. 创建 SSR Server 实例
    • 一些初始化操作, 生产模式运行会强制初始化 NODE_ENV 为 production
    • 创建实例, 开始监听端口

在步骤1中, NODE_ENV 是 co-start 命令设置的 development, 该配置文件 import 了一个包 packageA, packageA 下某个包又 import 了 react , 所以此时 Node.js 缓存住了 react 模块, 其值为 development 环境的 ./cjs/react.development.js 的模块导出

// react/index.js

'use strict';

if (process.env.NODE_ENV === 'production') {
  module.exports = require('./cjs/react.production.min.js');
} else {
  module.exports = require('./cjs/react.development.js');
}

在步骤 2 中, 判断此时是生产模式运行就强制初始化 NODE_ENV 为 production, 使得后面运行的 import { renderToString } from 'react-dom/server' 部分的代码, react-dom 的值为 production 环境下 ./cjs/react-dom-server.node.production.min.js 的模块导出

react 和 react-dom 一个使用的是开发版本, 一个使用的是生产版本

此时我们篡改 node_modules 中 react-dom 与 react 的代码, 统一替换 process.env.NODE_ENV === 'production' 为 true 或者 false, 使得 react-dom 与 react 引用环境保持一致, 发现一切就能正常运行了 ✅

// react-dom/server.node.js

'use strict';

if (process.env.NODE_ENV === 'production') {
  module.exports = require('./cjs/react-dom-server.node.production.min.js');
} else {
  module.exports = require('./cjs/react-dom-server.node.development.js');
}

小结

版本信息: react@16.14.0 react-dom@16.14.0 node@12.20.1

运行 react 与 react-dom 时的 process.env.NODE_ENV 的值不一致将会导致服务端渲染时 Consumer 组件拿不到 Provider 组件透传下来的 Context