热更新实现原理

热更新是现代前端开发环境中必不可少的一项,本文整理了一些打包工具parcelwebpackvite热更新的使用机制,同时了解热更新的实现原理。

<!--more-->

参考

1. 背景

先来考虑一下,在传统的静态页面开发中,浏览器访问静态资源。每次更新代码后,手动触发重新打包,然后刷新页面,就可以看见改动后的效果了

这种效率太低了,不手动打包行不行?不刷新页面行不行?行

自动打包这个比较简单:可以启动一个服务端,服务端会负责返回打包后的静态资源,服务端可以监听文件的变化,自动触发打包。全量打包比较耗时,可以使用增量打包,只打包变化了的文件。

自动刷新呢?由于变化是在服务端进行的,因此需要将文件变化的消息发给浏览器,可以用 WebSocket。那需要我们手动写 websocket 吗?不用,在开发环境打包的时候服务端夹点私货,把 socket 客户端接收消息的代码一起打进去就可以了。

看起来还不错,也很容易理解,但自动刷新在某些场景下就不太适用了,比如一个很多表单项的页面,填了一大半内容了,如果改个文案,页面自动刷新,哦豁,填的表单内容都没了,全得重新填。

因此还需要一个局部刷新的功能,局部刷新指的是文件变化时,只刷新页面上使用该文件的部分,而不是整个页面location.reload

学术名叫HMR,全称Hot Module Replacement,其最大的作用是可以保持应用的状态。

那么 HMR 是怎么实现的呢?本文主要会研究这个问题,下面展示一下不同打包工具是怎么实现 HRM 的

2. 一些开发工具是怎么实现HMR的

2.1. parcel 是怎么做的

文档中描述到,parcel 默认会完全重载页面,而在某些时候会自动进行 HMR,如 CSS 改动、内置 HMR 支持的框架(React、Vue)等

可以通过module.hot手动开启 HRM

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

在某个模块文件中声明accept之后,再修改这个模块,就会替换该模块的代码,然后重新检测它及其所有父模块

来写个简单的 demo

显示 parcel 的入口文件

<h1>hello</h1>
<input type="text" id="myInput" />
<button id="myBtn">click me</button>
<script src="./index.js"></script>

然后是index.js

// index.js
let inputEl = document.querySelector("#myInput");

// 热更新的时候可以看见input编辑的内容不会随着刷新丢失
console.log(inputEl.value);

if (module.hot) {
    // 只是简单的声明当前模块文件改动时使用HMR,而不是完全刷新
    module.hot.accept(() => {});
}

然后启动开发环境parcel index.html,在 input 随便输入一点东西,比如123,然后改动index.js,随便写点新内容

+ console.log('hrm change')

这个时候查看页面,就可以发现 input 的内容还在,说明浏览器没有全部刷新,而控制台在 clear 之后也重新输出了新的内容

除了浏览器 input 的内容之外,热更新还期望保存应用的状态。我们再修改一下index.js

let inputEl = document.querySelector("#myInput");
let myBtn = document.querySelector("#myBtn");

const app = {
    text: "",
};

// input改动时将值记录到app.text中
inputEl.oninput = function () {
    app.text = inputEl.value;
};

// 点击按钮时获取app保存的数据
myBtn.onclick = function () {
    console.log(app.text);
};

if (module.hot) {
    module.hot.accept(() => {});
}

如果还是找之前那样改动一下index.js触发 HMR,虽然浏览器的 input 值还在,但是由于模块的重载,app.text的值会被重置成空字符串。

为了保存应用的状态,需要在在模块被替换之前保存的状态,然后再模块被替换之后恢复数据,parcel 提供的是hot.disposehot.accept

if (module.hot) {
    // 模块将被替换时触发
    module.hot.dispose((data) => {
        data.text = inputEl.value;
    });

    // 替换完毕后触发
    module.hot.accept(() => {
        // 恢复应用状态
        const { text } = module.hot.data;
        app.text = text;
    });
}

这下看看起来,一切都理所应当了。由于HRM只是用于开发期间提高效率,如果在每个模块中都手动保存和恢复应用状态,就会显得十分繁琐,所以成熟的框架内部都支持HMR,无需开发者关心这些逻辑。

从parcel可以看出HRM实现需要的几个核心概念

  • server端和client端的通信
  • 一个可以替换部分模块的模块管理系统
  • 提供一些钩子保存和恢复状态

2.2. webpack 是怎么做的

我们再来看看webpack的HMR,参考webpack的HMR文档

首选通过webpack.config.js的devServer.hot配置项开启热更新

devServer: {
    port: 9000,
    hot: true,
}

为了方便测试,我们使用上面parcel同一个html模板,然后新建一个入口文件index.js

与parcel类似,当模块改动时,webpack默认也是刷新全部页面,当模块注册了module.hot.accept之后,就会走HMR

// index.js

// 启动热更新
if (module.hot) {
    module.hot.accept(()=>{
        console.log('module selft change')
    })
}

启动服务npx webpack serve --mode=development

先在页面上的input里面输入一点东西,方便观察是不是全量刷新

然后改动一下index.js文件并保存

+ console.log('hrm change')

就可以看见控制台模块的热更新,而不是刷新整个页面了。

module.hot.accept除了注册当前模块的热更新,还可以通过传入依赖模块,注册对应依赖模块的热更新

module.hot.accept(
  dependencies, // Either a string or an array of strings
  callback, // Function to fire when the dependencies are updated
  errorHandler // (err, {moduleId, dependencyId}) => {}
);

同理,如果需要保存状态,可以使用module.hot.dispose

2.3. vite 是怎么做的

vite采用了完全不同的开发模式,通过现代import直接加载服务端资源,避免了打包启动时的漫长时间。

由于运行方式不同,热更新的实现也有比较大的差异,参考vite源码

packages/vite/src/client/client.ts文件中,实现了createHotContext,该方法会返回一个hot对象,提供acceptdispose等接口

然后packages/vite/src/node/importAnalysis.ts中,在拼接响应的模块内容时,如果开启了HMR,会拼接createHotContext

因此每个模块的import.meta.hot就是暴露的HMR接口,与上面parcel、webpack的module.hot类似

然后来看看vite文件变化时热更新的流程

在服务端

  • 文件file变化时触发handleHMRUpdate(file, server)
  • moduleGraph.getModulesByFile获取文件依赖的模块,然后执行updateModules
  • updateModules中遍历传入的模块列表,获得updates,然后通过websocket发送消息update到浏览器

在浏览器端

  • 收到update的消息,遍历payload.updates,触发fetchUpdate(update)
  • 对于每一个update,最终会触发import(filePath)重新请求新的资源,获取到新的模块,然后执行模块的mod.callbacks

mod.callbacks就是hot.accpet注册的回调。因此模块只要注册了hot.accpet方法,就可以实现HMR了。

2.4. 小结

看起来每个开发工具都实现了module.hot类似的接口,暴露给开发者用于决定当前模块是否需要实现HMR

  • accept
  • dispose

3. 一些实现HMR接口的模块的例子

下面列举了一些常见的实现了HMR接口的模块的例子

3.1. style-loader

style-loader的主要功能是将css样式表的内容转换成JS可执行的代码,然后通过操作DOM将样式添加到页面上,

因此其大概的功能应该是

const styleCode = `
.title {
    background: blue;
}
`;

function findStyleTag() {
    const id = module.id;
    let style = document.querySelector(`[data-id="${id}"]`);
    if (!style) {
        style = document.createElement("style");
        style.setAttribute("data-id", id);
        document.body.appendChild(style);
    }

    return style;
}

const style = findStyleTag();
style.textContent = styleCode;

if (module.hot) {
    module.hot.accept(() => {});
}

可见在import 一个xx.css文件的时候,style-loader应该需要

  • 获取css文本的内容
  • 动态像页面插入一个style标签
  • css内容改变时,更新style标签的内容

接下来看看style-loader的源码

以默认配置的injectType: styleTag为例

当配置项开启了hot之后,会通过getStyleHmrCode注入热更新代码

实际上就是注册了module.hot等方法,简化一下getStyleHmrCode中拼接的代码

if (module.hot) {
    module.hot.accept(modulePath, function(){
        content = require(${modulePath})
        content = content.__esModule ? content.default : content;
        update(content);
    })
    module.hot.dispose(function() {
        update()
    }
}

可以看到依赖一个update方法,回到loader.pitch,一层层向上讯号

update = API(content, options);

然后全局搜一下API方法的定义,发现是在getImportStyleAPICode(esModule,this)这里

function getImportStyleAPICode(esModule, loaderContext) {
  const modulePath = stringifyRequest(
    loaderContext,
    `!${path.join(__dirname, "runtime/injectStylesIntoStyleTag.js")}`
  );

  return esModule
    ? `import API from ${modulePath};`
    : `var API = require(${modulePath});`;
}

injectStylesIntoStyleTag这个文件里面定义就定义了更新style标签的方法,其流程与上面写的简易版本基本一致。

可见,如果期望模块实现热更新,需要模块自己注册module.hot等HMR接口。

3.2. vue-loader

之前写过vue-loader源码分析,这里主要关注一下HRM的实现。

直接查看目录可以发现lib/codegen/hotRealod方法,查看一下引用

想必这里就是注入热更新代码的地方,看看它的源码

const hotReloadAPIPath = JSON.stringify(require.resolve('vue-hot-reload-api'))

exports.genHotReloadCode = (id, functional, templateRequest) => {
  return `
/* hot reload */
if (module.hot) {
  var api = require(${hotReloadAPIPath})
  api.install(require('vue'))
  if (api.compatible) {
    module.hot.accept()
    if (!api.isRecorded('${id}')) {
      api.createRecord('${id}', component.options)
    } else {
      api.${functional ? 'rerender' : 'reload'}('${id}', component.options)
    }
    ${templateRequest ? genTemplateHotReloadCode(id, templateRequest) : ''}
  }
}
  `.trim()
}

可以看到,在genHotReloadCode中,实际上注册了module.hot.accept,然后通过vue-hot-reload-api这个包来完成热更新,

  • 如果sfc文件的id没有被记录,则调用api.createRecord(id, component.options)
  • 如果有记录,则说明是更新
    • 函数组件调用api.rerender(id, component.options)
    • 常规组件调用api.reload(id, component.options)

追根溯源,看看vue-hot-reload-api的实现

exports.createRecord = (id, options) => {
  if(map[id]) return

  let Ctor = null
  if (typeof options === 'function') {
    Ctor = options
    options = Ctor.options
  }
  // 包装options
  makeOptionsHot(id, options)

  // 保存sfc文件对应的记录
  map[id] = {
    Ctor,
    options,
    instances: []
  }
}

makeOptionsHot需要看一下

function makeOptionsHot(id, options) {
  if (options.functional) {
    // 函数组件,劫持render
    const render = options.render
    options.render = (h, ctx) => {
      const instances = map[id].instances
      if (ctx && instances.indexOf(ctx.parent) < 0) {
        instances.push(ctx.parent)
      }
      return render(h, ctx)
    }
  } else {
    // initHookName 在2.0.0-alpha.7之前的版本是init,之后的是beforeCreate
    injectHook(options, initHookName, function() {
      const record = map[id]
      if (!record.Ctor) {
        record.Ctor = this.constructor
      }
      // 加入一个钩子方法,在组件创建时记录组件实例,后续更新的时候会用到
      record.instances.push(this)
    })
    injectHook(options, 'beforeDestroy', function() {
      const instances = map[id].instances
      instances.splice(instances.indexOf(this), 1)
    })
  }
}

最后来看看reload

exports.reload = tryWrap((id, options) => {
  const record = map[id]
  if (options) {
    // 重新包装 options
    makeOptionsHot(id, options)
    // ...
  }

  record.instances.slice().forEach(instance => {
    // 调用组件的forceUpdate()刷新
    if (instance.$vnode && instance.$vnode.context) {
      instance.$vnode.context.$forceUpdate()
    }
  })
})

整理一下流程

  • 初始化时,通过makeOptionsHot,方便在初始化组件时获得组件实例
  • 通过vue-loader,在热更新时插入调用api.reload
  • api.reload中会调用当前文件对应的组件实例,执行foreceUpdate

4. 实现一个最简单版 HMR

参考:从零实现webpack热更新HMR

后面尝试实现一下

5. 小结

总结一下,热更新实际上是

  • 打包工具会提供一些HMR接口,模块可以实现这些接口,在文件变化前后做一些操作,比如保存状态、局部更新应用数据等
  • 打包工具启动了一个服务端,在打包文件入口同时注入了ws客户端的代码,这样服务端和浏览器就可以双向通信
  • 文件变化后,会推送消息到浏览器,浏览器拉取变化的模块,并用新的模块替换旧的模块

最后留一个问题:为什么import动态模块多了之后热更新就会变慢?