hi, all 今天来分享一个 nextjs 项目的 debug 过程, 更多可以持续关注 🌟 github | blog 🌟
热更新失败
无意间听到同学 A 说开发项目 B 这么久了, 开发时修改代码后页面内容未进行重新渲染, 甚至页面连刷新也没有 😨, 所以平时是手动刷新了一次浏览器, 惊讶之余就得快速解决这个问题。
热更新介绍
不同于 nodejs 项目修改代码后 pm2, nodemon, forever 等会对进程进行一下重启生效, 前端代码修改后的热更新流程还是比较长的, 主要为 webpack-dev-server 通过 websocket 去通知到浏览器, 参考图如下
前端代码热更新除了上图其实还有另一种方式, 即没有使用 webpack-dev-server, 而是自己写的一个 dev-server, 热更新方面集成了 webpack-hot-middleware 实现, 后者通知到浏览器是使用了 SSE 服务器推送事件, 因为有 dev-server 去单向通知浏览器就可以了, 不需要双向的 websocket
本次有问题的项目是一个比较旧的 nextjs 项目, 其采用的就是后者 SSE 的方式, SSE 服务端的核心这里也简单说一下
- 主要还是 Content-Type 的设置需要为 text/event-stream
- 其次是 X-Accel-Buffering, 通常是不需要的, 主要用于中间还有 nginx 代理的情况, 让 nginx 有数据直接就发送出去, 不需要囤着, 之前做 node 流输出数据时就被坑过
// SSE 服务端实现
var headers = {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'text/event-stream;charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
};
res.writeHead(200, headers);
res.write('\n');
devtool 中查看如下图示
问题复现
启动问题项目, 修改代码后也是在进行正常的重新编译, 编译完成后浏览器也貌似收到了信息, 最后的日志停止在了 [Fast Refresh] done, 就没有下文了, 页面内容没有进行更新, 浏览器也没刷新
问题定位
1. 修改后返回的是旧代码 ?
- 分析: 通常修改代码后, HotModuleReplacementPlugin 会生成一个 xxx.hot-update.js, 如果它出了故障, 返回的这个 js 有问题的话就能解释热更新失败
- 结论: ❌ 仔细看了 .hot-update.js 内容后, 发现其实是带上了最新改动的内容, 故排除这个可能
2. 应用新代码的某个流程出错了 ?
这里就需要对 nextjs 客户端 SSE 部分的代码从起点开始进行一个 debug
- SSE 客户端的实现文件, 这部分通常是标准的 api 调用不会有什么问题
// packages/next/client/dev/error-overlay/eventsource.js
source = new window.EventSource(options.path)
source.onopen = handleOnline
source.onerror = handleDisconnect
source.onmessage = handleMessage
- 收到服务器消息增加关键的监听函数, 发现了关键的 ⚠️ [Fast Refresh] done 日志, 继续挖 onRefresh 函数实现
packages/next/client/dev/error-overlay/hot-dev-client.js
function onFastRefresh(hasUpdates) {
DevOverlay.onBuildOk()
if (hasUpdates) {
DevOverlay.onRefresh()
}
console.log('[Fast Refresh] done')
}
- 看样子 nextjs 实现了一个简单的 event, onRefresh 函数的作用为发布 TYPE_REFFRESH 事件
// packages/react-dev-overlay/src/client.ts
function onRefresh() {
Bus.emit({ type: Bus.TYPE_REFFRESH })
}
- App 最顶层的 ReactDevOverlay 组件订阅了 TYPE_REFFRESH 事件, 然后 state 状态发生变化, 触发重新渲染
// packages/react-dev-overlay/src/internal/ReactDevOverlay.tsx
const ReactDevOverlay: React.FunctionComponent = function ReactDevOverlay({
children,
}) {
const [state, dispatch] = React.useReducer<
React.Reducer<OverlayState, Bus.BusEvent>
>(reducer, { nextId: 1, buildError: null, errors: [] })
React.useEffect(() => {
Bus.on(dispatch)
return function () {
Bus.off(dispatch)
}
}, [dispatch])
const isMounted = hasBuildError || hasRuntimeErrors
return (
<React.Fragment>
<ErrorBoundary onError={onComponentError}>
{children ?? null}
</ErrorBoundary>
{isMounted ? (
<ShadowPortal>
<CssReset />
<Base />
<ComponentStyles />
{hasBuildError ? (
<BuildError message={state.buildError!} />
) : hasRuntimeErrors ? (
<Errors errors={state.errors} />
) : undefined}
</ShadowPortal>
) : undefined}
</React.Fragment>
)
}
- 结论: ❌ 到第 4 步打个断点发现能够顺利运行, 既然热更新的 xxx.hot-update.js 是最新的, 客户端收到消息后最顶层组件也触发了重新渲染, 那么问题出现在哪了 ?
3. nextjs 内部组件出了问题 ?
- 分析: 这个可能性主要由于 nextjs 在用户的组件上包裹了太多层父组件, 如果某个父组件出了问题也是能造成热更新失败
// packages/next/next-server/server/render.tsx
const AppContainer = ({ children }: any) => (
<RouterContext.Provider value={router}>
<AmpStateContext.Provider value={ampState}>
<HeadManagerContext.Provider
value={{
updateHead: (state) => {
head = state
},
updateScripts: (scripts) => {
scriptLoader = scripts
},
scripts: {},
mountedInstances: new Set(),
}}
>
<LoadableContext.Provider
value={(moduleName) => reactLoadableModules.push(moduleName)}
>
{children}
</LoadableContext.Provider>
</HeadManagerContext.Provider>
</AmpStateContext.Provider>
</RouterContext.Provider>
)
🐛 debug 问题比较重要的一点是分段排查, 就像网络了出了问题, 专业维修人员总会分段去检查, 直到排查到最近未通的线路
这里我们把 nextjs 内部的热更新监听的代码给搬移到我们自己的组件中来, 测试我们自己的线路, 然后在 TYPE_REFFRESH 事件后进行一个强制渲染的操作, 看是否能热更新生效
componentDidMount() {
if (process.env.NODE_ENV === "development") {
const Bus = require("@next/react-dev-overlay/lib/internal/bus")
Bus.on((event: Record<string, string>) => {
if (event.type === Bus.TYPE_REFFRESH) {
this.forceUpdate()
}
})
}
}
❌ 答案是还是未能热更新, 其实到这里需要 🤔 思考一下 热更新的本质 ?
- 当我们这个组件的子组件代码更新后, 父组件 forceUpdate 为什么没有导致页面重新渲染了
当客户端收到的 xxx.hot-update.js 代码执行后, 内存里面缓存所有模块的 installedModules 对象如下就会把 key 值为 @components/App 的值给更新了, 但是如果不在热更新的回调中重新 require 一次来取到最新赋的值, 其如果存在父组件等还是引用的是旧的值
// 一个简单的热替换的实现的例子
function __enableHotModuleReplacement() {
if (module.hot) {
if (module.hot._acceptedDependencies['[@components](/user/components)/App']) {
console.warn('[${PKG_NAME}]: Hot updates have already been registered')
} else {
module.hot.accept('[@components](/user/components)/App', () => {
const _App = require('[@components](/user/components)/App').default
if (_App) {
ReactDOM.render(<_App />, document.getElementById('root'))
} else {
location.reload()
}
})
console.log('[${PKG_NAME}]: Hot Update Registration Successful')
}
}
}
__enableHotModuleReplacement()
通过上面的例子的分析, 我们补丁代码需要下面的改动才能更新成功
- 在更新订阅的回调中重新 require 来获取最新的引用值
- 通过 setState 去更新渲染最新的子组件 Component
componentDidMount() {
if (process.env.NODE_ENV === "development") {
const Bus = require("@next/react-dev-overlay/lib/internal/bus")
Bus.on((event: Record<string, string>) => {
if (event.type === Bus.TYPE_REFFRESH) {
const NewComponent = require('views').default
this.setState({ Component: NewComponent })
}
})
}
}
render() {
const { Component } = this.state
return <Component {...this.props}/>
}
到这里我们在自己的代码中打了一个补丁, 修复了热更新的能力, 不过我们还需要测试一下该组件的孙子组件修改后, 是否也能正常生效 ?
❌ 答案是否定的, 孙子组件未能生效! 那么结论是 nextjs 内部组件没有出问题, 是谁把这个 installedModules 缓存给破坏了 ?
4. react-refresh-webpack-plugin 的问题 ?
- 分析: 当从 NewComponent 起点重新往下执行后, 其 import 的组件引用应该都是最新的才对, 是谁动了 installedModules 的缓存数据 ? 而 react-refresh 为了最小的局部更新, 会在构建时给每个文件的前后加了一些注册代码, 这部分小料如果逻辑不够缜密可能是原因
- 结论: ❌ 把 react-refresh-webpack-plugin 升级到小版本最新, 发现并非解决, 该猜想某小版本 bug 大概率不成立
那么我们把上面的代码补丁继续完善一下, 自己手动清除所有模块缓存解决仅存的问题
componentDidMount() {
if (process.env.NODE_ENV === "development") {
const Bus = require("@next/react-dev-overlay/lib/internal/bus")
Bus.on((event: Record<string, string>) => {
if (event.type === Bus.TYPE_REFFRESH) {
Object.keys(require.cache).forEach(key => {
delete require.cache[key]
})
const NewComponent = require('views').default
this.setState({ Component: NewComponent })
}
})
}
}
render() {
const { Component } = this.state
return <Component {...this.props}/>
}
是的, 虽然我们此时还未找到真正的问题, 但是根据问题反映的种种现象使用一个粗糙的补丁给解决了。
5. 检查 next.config.js
到第 4 步, 本已经打算按现有的补丁结案, 不成想因为另一个小问题发现了热更新失败的真正原因
在 next.config.js 中有一个 externals 的配置, 有过了解的同学应该知道配置了 externals 是需要到模版的 html 中手动引入带有 externals 配置包的 cdn js 文件
// next.config.js
if (!isServer) {
const e = {
react: "React",
"react-dom": "ReactDOM"
}
config.externals.unshift(e)
}
但是发现 nextjs 代码中尽然写死了 react, react-dom 作为 dll entry, 了解 dll 的同学应该知道, 它会把配置的入口前置构建一次, 且 autodll 插件会把它自动插入到模版 html 文件中
⚠️ 这不就一下有了两份 react, react-dom 了吗 ? 那么我们把 externals 相关的给去掉试试, 使得只有一份 react, react-dom 了?
✅ 发现去掉补丁后, 热更新也能正常运行了, 问题解决 ~
小结
老项目虽然有些坑, 尽量要做到通过一些粗糙的补丁基本解决问题, 有些黑盒不可能花过多时间去研究。其次是找异同点, 比如 nextjs 项目, 最大的不同无异于配置文件 next.config.js 与包的版本, 这些关键地方需要重点去排查。
写的真不错
@hyj1991 向大佬学习 ~
真技术贴,这个排查bug的思路和过程值得学习~ “debug工程师”没跑了哈哈
在ykfe/ssr里都有现成的,这些都是标配
@DevinXian 😄 日常 debug,解决 bug,写 bug
@i5ting 给狼叔倒阔乐 ~
哈啊哈