性能利器,通过Vue3深度解析webpack热更新原理

1,537 阅读9分钟

原文发布于BestVue3社区

最近在解决 Vue3 的 JSX 不支持热更新的问题,所以较为深度地研究了 Webpack 的热更新的原理,以及应该如何实现 Vue3 的组件热更新, 本文就来深度分析一下关于 Webpack 热更新的原理和实现。需要注意的是热更新不是 Webpack 的专利,其他的打包工具也是有的,并且会有一些区别。 本文主要关注 Webpack。

我已经给 Vue3 的 babel-jsx 插件提了PR,有兴趣可以看一下我是如何实现的。

参考资料:

Webpack 的热更新

具体的 webpack 热更新流程如下:

  • 应用让 HRM 运行时来检查是否具有热更新
  • 运行时同步下载代码并且通知应用
  • 应用告诉运行时需要执行更新
  • 运行时同步地执行更新

我们在启动 webpack-dev-server 之后,应该都有看到过在浏览器命令行或者 network 区域有一些websocket相关的内容, 曾经我不止一次奇怪为什么我好像没有加入任何 websocket 相关代码,哪来的这些提醒呢? 这就是因为 webpack 的热更新的需要。

在我们修改了项目代码之后,webpack 会监听到文件内容的变化,并且重新进行编译等工作,然后会把新的代码通过 websocket 发送给浏览器。 浏览器获取到新的代码之后会重新执行模块代码,并且替换模块的内容。需要注意我们本文不讨论 webpack 如何替换模块内容

我们需要在 webpack 的配置中开启热更新,才会让 webpack 能够执行以上的操作,如何开启:

{
    devServer: {
        hot: true
    }
}

在开启了热更新之后,我们代码中的module上会有热更新相关的属性,最常见的就是这样的代码:

if (module.hot) {
    // 关于如何处理这个模块的代码
    module.hot.accept()
}

大部分热更新的插件都会通过这种方式来判断当前是否开启了热更新。

假如我们有如下的 Vue3 组件文件代码:

export const Comp1 = defineComponent({
    setup() {
        return () => <div>Hello Comp1</div>
    },
})

export const Comp2 = defineComponent({
    setup() {
        return () => <div>Hello World</div>
    },
})

我们一般会使用一些插件来添加热更新的代码,比如我们这里会用 Vue3 的 babel-jsx 插件,这个插件在编译代码的时候会往这个文件增加类似如下代码:

// ... 组件代码

if (module.hot) {
    module.hot.accept()
}

我们这里执行module.hot.accept()来通知 webpack 的 HMR 我们接收了这个模块,HMR 并不需要再重新执行模块的替换。 如果我们执行了这句代码,我们就需要自行替换这个文件的模块代码,来达到运行的应用更新模块的目的。 如果在这里你没有去执行一些其他更新模块功能的代码,那么这个模块并不会被更新。 而如果你不执行这句代码,那么这个模块的所有代码都会被更新。在这里例子里面,我们如果把Comp2的内容改为Hello Vue3, 我们如果不执行module.hot.accept()那么Comp1Comp2都会被重新渲染。

这勉强能够达到热更新的目的,但是追求精益求精的我们,肯定不知会满足于此。

Vue3 的 HMR

Vue3 专门实现了热更新的功能,Vue3 在 window 上会挂载一个__VUE_HMR_RUNTIME__对象,来提供组件重新挂载渲染的功能。 其源码在runtime-core/src/hmr.ts,大家有兴趣可以去看一下实现。

我们可以通过__VUE_HMR_RUNTIME__.reload来重新渲染挂载一个组件,通过__VUE_HMR_RUNTIME__.createRecord来记录一个组件, 还有__VUE_HMR_RUNTIME__.rerender来重新渲染某个组件。

然后我们来看看,我们希望中的 HMR 的最终目的是啥呢?

  • 只有代码更新了的组件才会被重新渲染
  • 重新渲染的组件能够保持组件之前的状态(state)
  • 如果当前文件并不是只 export 组件,那么需要完全地更新所有模块

只重新渲染更新的组件

第一条很多同学可能不太好理解,尤其是没有用过 JSX 来进行开发的同学,如果你之前都是用 SFC 来写组件的,你可能认为一个文件只能 export 一个组件。 但是如果我们使用 JSX 来进行开发,就像上面的例子,一个文件 export 多个组件是很正常的。 在这种情况下,如果我们只改了一个组件的代码,但是所有组件都重新渲染,这其实就变得没有必要。

那么我们如何实现这个功能呢?在使用 babel 插件进行代码编译的时候,我们给所有的组件计算一个 ID,并且根据组件的源码计算其hash值,编译之后的代码大致如下:

Comp1.__id = 'comp1'
Comp1.__hash = 'xxxxx'

然后我们声明一个全局对象,来存储组件的 Id 和 hash 的映射:

const $VueCompHashMap$ = (window.$VueCompHashMap$ =
    window.$VueCompHashMap$ || {})

接下去,在每次模块更新之后,我们会执行以下代码来接收模块的更新:

if (module.hot) {
    if ($VueCompHashMap$[Comp1.__id] !== Comp1.__hash) {
        __VUE_HMR_RUNTIME__.reload(Comp1.__id, Comp1)
    }
}

那么只要组件的源码不变,他的hash也不会改变,在模块重新执行之后,组件也就不会被重新渲染。

保持组件的 state

Vue3 是提供了方法让我们能够保持组件的 state 的,前面提到的__VUE_HMR_RUNTIME__.rerender就是用来实现这个目的的。

但是并不是所有情况都能够实现保持状态的,比如我个人更喜欢的开发方式就无法实现:

export const Comp1 = defineComponent({
    setup() {
        const state = reactive({
            count: 1,
        })
        return () => <div>{state.count}</div>
    },
})

对于这样一个组件,其render函数是来自于setup的返回值的,而其对于state的引用是来自于闭包,这里的state并没有挂载到任何的对象上。 如果Comp1更新了,我们需要重新获取其render函数,就需要重新执行setup,那么闭包就会重新生成。所以目前没有什么好的办法来保持这种类型组件的 state。

但是我们可以改个写法:

export const Comp1 = defineComponent({
    setup() {
        const state = reactive({
            count: 1,
        })

        return {
            state,
        }
    },
    render() {
        return <div>{this.state.count}</div>
    },
})

对于这样的组件,我们可以直接通过Comp1.render来获取其render函数,而statesetup返回之后,会被 Vue3 挂载到组件的this上, 那么对于这样一个组件,我们只需要获取其render函数,然后通过__VUE_HMR_RUNTIME__.rerender来执行重新渲染,而保持this上的所有 state。

事实上,SFC 的状态得以保持,就是因为 SFC 组件状态是必然保持在this上。而且因为script部分是单独分离的,对于 SFC 的状态代码是否有更新, 可以直观的根据script部分代码是否有修改来判断。而在 JSX 写的组件上,则相对难以判断setup中的代码是否有更新。

我之前跟尤老师讨论过这个问题,在尤老师关于他完成了 Vite 的 JSX 热更新功能的 twitter 上。

跟尤老师的讨论

他也提到了 Composition API 实现的 state 很难保持,并且他提到了 React hooks 的状态在热更新过程中可以被保持的原因, 主要是因为 React hooks 的状态其实在组件的实例上是有保存的,而且是根据 hooks 执行的顺序和类型可以判断状态代码是否有改变。 我在React 源码解析中写过 hooks 能够保持状态的原因。

尤老师专门提了这一点,果然 Vue 和 React 的对比是永远逃不过的话题。

需要更新整个模块的代码

有些情况下即便只有某个组件更新了,我们还是需要让整个模块被更新。主要的情况就是如果这个文件向外 export 了非组件代码,我们就需要更新整个模块。

因为我们 export 出去的代码必然是会被其他地方调用的,如果我们执行了module.hot.accept(), 那么 HMR 运行时并不会更新其他引用了这个文件的模块的代码,这就会有问题了,其他模块在执行的时候可能会使用老的当前模块的代码。 我们处理了组件的更新,因为 Vue3 提供了这个功能。

而其他的 export 的内容就没有这么幸运,有框架来提供这些功能。这种情况下,你可以实现自己的热更新逻辑来更新这些功能,当然这会比较麻烦。 那么最简单的方式,自然就是让 HMR 运行时直接更新整个模块,所以在这种情况下我们就不应该执行module.hot.accept()

我们在 babel-jsx 插件中增加了如下代码:

if (module.hot) {
    if ($isVueHMRAcceptable(module)) {
        module.hot.accept()
    }
}

$isVueHMRAcceptable这个函数就是来判断当前模块向外导出的是否都是组件的,只有在都是的情况下才accept

总结

以上就简单明了地向大家讲解了 webpack 的 HMR 的原理。我们借由实现 Vue3 的热更新的过程,来展示了一次热更新中会经历哪些过程,以及需要考虑哪些问题。

核心的模块更新能力,其实 webpack 已经帮助我们实现了,我们更多的其实是需要考虑我们使用的框架该如何更小代价地执行更新。

React fast Refresh 也是去年才真正成为官方的热更新方案,之前的 React-Hot-Loader 一直存在一些问题,却也一直活跃在 React 生态中。

这次对于 Vue3 热更新的实现,也是更多参考了 React fast Refresh 的设计。但是很遗憾目前没有找到办法来保持组件状态,期望未来能找到方法解决这个问题吧。

BestVue3社区专注于提供Vue3最新鲜最优质的学习内容,你可以搜索微信公众号 BestVue3 进行关注。