React SSR 子组件获取不到 Context 问题记录
xiaoxiaojx opened this issue · 0 comments
背景
服务端渲染的项目本地模拟线上环境运行报了如下的一个错误,然而本地开发模式运行和真实的线上生产模式运行均没有问题。当听到这个问题描述时,我只觉得这个临床表现透露着诡异的氛围 😢
本地模拟线上环境是先构建出生产模式的代码,然后运行 SSR Server 。其目的是更接近真实的线上生产环境的效果, 通常用于复现与 debug 线上环境出现的问题。
// 错误信息
Invariant Violation: You should not use <Switch> outside a <Router>
问题简述
上面的错误信息造成原因通常有两个
- Switch 组件的上层没有 Router 组件,解决办法是使用基于 Router 组件的 BrowserRouter 组件或 HashRouter 组件作为 Switch 的父组件
服务端运行使用的其实是 StaticRouter, 服务端渲染的是一个请求 path 的页面快照, 不存在客户端路由会切换的情况
- 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 项目来进行不同了,排查重点在于
- package.json 中的依赖
- 脚手架配置文件的配置信息
在一阵对比后, 还是发现了关键的信息。本地模拟线上环境运行的是下面的命令
模拟线上 NODE_ENV 最好是应该设置成 production, 这里却设置成了 development
"co-start": "yarn build && NODE_ENV=development DOCKER=true yarn start"
生产环境 yarn build 打包后,代码开始按如下顺序运行
- 读取脚手架配置文件的配置信息
- 创建 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