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

image

背景

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

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

1
2
3
// 错误信息

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

问题简述

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// 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 ?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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

1
"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 的模块导出

1
2
3
4
5
6
7
8
9
// 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 引用环境保持一致, 发现一切就能正常运行了 ✅

1
2
3
4
5
6
7
8
9
// 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