精华 nextjs 项目热更新失败排查
发布于 3 年前 作者 xiaoxiaojx 8526 次浏览 来自 分享

hi, all 今天来分享一个 nextjs 项目的 debug 过程, 更多可以持续关注 🌟 github | blog 🌟

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 流输出数据时就被坑过
// 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 调用不会有什么问题
// packages/next/client/dev/error-overlay/eventsource.js

source = new window.EventSource(options.path)
source.onopen = handleOnline
source.onerror = handleDisconnect
source.onmessage = handleMessage
  1. 收到服务器消息增加关键的监听函数, 发现了关键的 ⚠️ [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')
}
  1. 看样子 nextjs 实现了一个简单的 event, onRefresh 函数的作用为发布 TYPE_REFFRESH 事件
// packages/react-dev-overlay/src/client.ts

function onRefresh() {
  Bus.emit({ type: Bus.TYPE_REFFRESH })
}
  1. 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}/>
}

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

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 文件中 image

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

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

小结

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

7 回复

写的真不错

@hyj1991 向大佬学习 ~

真技术贴,这个排查bug的思路和过程值得学习~ “debug工程师”没跑了哈哈

在ykfe/ssr里都有现成的,这些都是标配

@DevinXian 😄 日常 debug,解决 bug,写 bug

@i5ting 给狼叔倒阔乐 ~

回到顶部