Next.js 项目热更新失败排查

image

热更新失败

无意间听到同学 A 说开发项目 B 这么久了, 开发时修改代码后页面内容未进行重新渲染, 甚至页面连刷新也没有 😨, 所以平时是手动刷新了一次浏览器, 惊讶之余就得快速解决这个问题。

热更新介绍

不同于 nodejs 项目修改代码后 pm2, nodemon, forever 等会对进程进行一下重启生效, 前端代码修改后的热更新流程还是比较长的, 主要为 webpack-dev-server 通过 websocket 去通知到浏览器, 参考图如下

image

图片来自于 https://segmentfault.com/a/1190000020310371

前端代码热更新除了上图其实还有另一种方式, 即没有使用 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 流输出数据时就被坑过
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 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 中查看如下图示
    image

问题复现

启动问题项目, 修改代码后也是在进行正常的重新编译, 编译完成后浏览器也貌似收到了信息, 最后的日志停止在了 [Fast Refresh] done, 就没有下文了, 页面内容没有进行更新, 浏览器也没刷新
image

问题定位

1. 修改后返回的是旧代码 ?

  • 分析: 通常修改代码后, HotModuleReplacementPlugin 会生成一个 xxx.hot-update.js, 如果它出了故障, 返回的这个 js 有问题的话就能解释热更新失败
    image
  • 结论: ❌ 仔细看了 .hot-update.js 内容后, 发现其实是带上了最新改动的内容, 故排除这个可能
    image

2. 应用新代码的某个流程出错了 ?

这里就需要对 nextjs 客户端 SSE 部分的代码从起点开始进行一个 debug

  1. SSE 客户端的实现文件, 这部分通常是标准的 api 调用不会有什么问题
    1
    2
    3
    4
    5
    6
    // packages/next/client/dev/error-overlay/eventsource.js

    source = new window.EventSource(options.path)
    source.onopen = handleOnline
    source.onerror = handleDisconnect
    source.onmessage = handleMessage
  2. 收到服务器消息增加关键的监听函数, 发现了关键的 ⚠️ [Fast Refresh] done 日志, 继续挖 onRefresh 函数实现
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    packages/next/client/dev/error-overlay/hot-dev-client.js

    function onFastRefresh(hasUpdates) {
    DevOverlay.onBuildOk()
    if (hasUpdates) {
    DevOverlay.onRefresh()
    }

    console.log('[Fast Refresh] done')
    }
  3. 看样子 nextjs 实现了一个简单的 event, onRefresh 函数的作用为发布 TYPE_REFFRESH 事件
    1
    2
    3
    4
    5
    // packages/react-dev-overlay/src/client.ts

    function onRefresh() {
    Bus.emit({ type: Bus.TYPE_REFFRESH })
    }
  4. App 最顶层的 ReactDevOverlay 组件订阅了 TYPE_REFFRESH 事件, 然后 state 状态发生变化, 触发重新渲染
    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
    // 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 在用户的组件上包裹了太多层父组件, 如果某个父组件出了问题也是能造成热更新失败
    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
    // 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>
    )
    image

🐛 debug 问题比较重要的一点是分段排查, 就像网络了出了问题, 专业维修人员总会分段去检查, 直到排查到最近未通的线路

这里我们把 nextjs 内部的热更新监听的代码给搬移到我们自己的组件中来, 测试我们自己的线路, 然后在 TYPE_REFFRESH 事件后进行一个强制渲染的操作, 看是否能热更新生效

1
2
3
4
5
6
7
8
9
10
11
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 一次来取到最新赋的值, 其如果存在父组件等还是引用的是旧的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 一个简单的热替换的实现的例子

function __enableHotModuleReplacement() {
if (module.hot) {
if (module.hot._acceptedDependencies['@components/App']) {
console.warn('[${PKG_NAME}]: Hot updates have already been registered')
} else {
module.hot.accept('@components/App', () => {
const _App = require('@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
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    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 大概率不成立

那么我们把上面的代码补丁继续完善一下, 自己手动清除所有模块缓存解决仅存的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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}/>
}

image
是的, 虽然我们此时还未找到真正的问题, 但是根据问题反映的种种现象使用一个粗糙的补丁给解决了。

5. 检查 next.config.js

到第 4 步, 本已经打算按现有的补丁结案, 不成想因为另一个小问题发现了热更新失败的真正原因

在 next.config.js 中有一个 externals 的配置, 有过了解的同学应该知道配置了 externals 是需要到模版的 html 中手动引入带有 externals 配置包的 cdn js 文件

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

⚠️ 这不就一下有了两份 react, react-dom 了吗 ? 那么我们把 externals 相关的给去掉试试, 使得只有一份 react, react-dom 了?

✅ 发现去掉补丁后, 热更新也能正常运行了, 问题解决 ~

小结

老项目虽然有些坑, 尽量要做到通过一些粗糙的补丁基本解决问题, 有些黑盒不可能花过多时间去研究。其次是找异同点, 比如 nextjs 项目, 最大的不同无异于配置文件 next.config.js 与包的版本, 这些关键地方需要重点去排查。