Hot Module Replacement (HMR) exchanges, adds, or removes modules while an application is running without a page reload.

HMR(模块热替换)带来了比 LiveReload 更加愉悦的开发体验,允许在不刷新页面的情况下更新改动的模块。这里的模块包括 JS, CSS, 图片等,部分模块如 HTML 如果不允许热替换则会触发页面刷新。如果是 React 开发的话,写一个下午代码可能也不需要刷新页面的说(当然 Redux 和 React-Router 要折腾一下先)。

实现

Webpacks adds a small HMR runtime to the bundle, during the build process, that runs inside your app. When the build completes, Webpack does not exit but stays active, watching the source files for changes. If Webpack detects a source file change, it rebuilds only the changed module(s). Depending on the settings, Webpack will either send a signal to the HMR runtime, or the HMR runtime will poll webpack for changes. Either way, the changed module is sent to the HMR runtime which then tries to apply the hot update. First it checks whether the updated module can self-accept. If not, it checks those modules that have required the updated module. If these too do not accept the update, it bubbles up another level, to the modules that required the modules that required the changed module. This bubbling-up will continue until either the update is accepted, or the app entry point is reached, in which case the hot update fails.

差不多就是在开发构建的过程中,webpack 将 HMR 相关的 runtime 同时打包到项目中,然后 webpack 监听文件改动,在增量编译之后将改动的模块信息通过 EventSource 发送到浏览器端,HMR runtime 收到信号后判断改动的模块是否接受更新,否则更新事件向上冒泡。这里判断是否接受更新的依据是模块会带有如下代码:

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

module.hot

module.hot 的常见方法如下

accept

accept(dependencies: string[], callback: (updatedDependencies) => void) => void
accept(dependency: string, callback: () => void) => void

接受指定依赖模块的代码更新。callback 将在依赖模块被替换的时候调用。

accept([errHandler]) => void

接受本模块的代码更新且不通知 parents 模块。如果模块没有 exports 任何方法或者属性,则应该用这种方法接受更新。errHandler 将在加载更新模块抛出异常的时候被调用。

decline

decline(dependencies: string[]) => void
decline(dependency: string) => void

不接受指定依赖模块的更新。如果依赖模块发生代码变动,则更新会被 decline 的状态码停止。

decline() => void

标识当前模块不接受模块热替换。如果当前模块发生代码变动,则更新会被 decline 的状态码停止。

dispose/addDisposeHandler

dispose(callback: (data: object) => void) => void
addDisposeHandler(callback: (data: object) => void) => void

添加一个 one time handler, 在当前模块发生热替换的时候执行。允许销毁和移除之前声明或者创建的内容(比如绑定的 DOM 事件)。如果想从旧的模块传递状态到更新后的模块,可以把数据放在 data 对象。在替换后的模块中允许通过 module.hot.data 访问。

removeDisposeHandler

removeDisposeHandler(callback: (data: object) => void) => void

移除 handler。允许添加临时的 dispose handler, 并在需要的时候移除。比如在多个步骤的异步函数执行的过程中,如果进行代码热替换则需要触发 handler 销毁状态,而当执行完成时可以移除 handler。

更多内容见 Github wiki

使用

这里提供一个简单配置的例子,使用 webpack-dev-server 作为开发服务器并配置启用 HMR。

webpack.config.js

配置文件需要关注的是 entry, output.publicPathplugins

- entry

需要在每个入口文件注入 runtime,以便浏览器能够处理更新信号。同时也需要带上开发服务器的 IP 和端口。

entry: [
'webpack-dev-server/client?http://127.0.0.1:3000',
'webpack/hot/dev-server',
'./src/index.js'
],

- output.publicPath

publicPath 是发布路径,需要与 path 构建输出路径区分开。这里设置为 /,则在开发服务器中引用打包文件的方式为 http://127.0.0.1/bundle.js

- plugins

需要在 plugins 中增加两个插件如下。前者在增量编译的过程中提供热替换所使用的模块 (updated chunks),后者使得在编译过程抛出异常时不会输出错误的构建资源,也不会触发 HMR。

plugins: [
new webpack.HotModuleReplacementPlugin(),
new webpack.NoErrorsPlugin()
],

完整的配置文件如下:

// webpack.config.js
'use strict';
var webpack = require('webpack');
var path = require('path');
module.exports = {
entry: [
'webpack-dev-server/client?http://127.0.0.1:3000',
'webpack/hot/dev-server',
'./src/index.js'
],
output: {
path: path.join(process.cwd(), 'build/asserts/'),
filename: 'bundle.js',
publicPath: '/'
},
module: {
loaders: [{
test: /\.js?$/,
loader: 'babel?presets[]=es2015',
exclude: /node_modules/
}]
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
new webpack.NoErrorsPlugin()
],
devtool: 'eval'
};

server.js

通过 webpack-dev-server 启用开发服务器。实际开发过程中,直接启用服务器就能根据 webpack.config.js 的配置内容编译并监听文件改动。配置方式如下,比较简单。值得注意的是控制台输出信息的配置需要在这里控制。

// server.js
'use strict';
var webpack = require('webpack');
var WebpackDevServer = require('webpack-dev-server');
var config = require('./webpack.config.js');
new WebpackDevServer(webpack(config), {
publicPath: config.output.publicPath,
hot: true,
historyApiFallback: true,
stats: {
colors: true,
noInfo: true,
quiet: true,
chunks: false
}
}).listen(3000, '127.0.0.1', function(err, result) {
if (err) {
console.log(err);
} else {
console.log('Listening at http://127.0.0.1:3000');
}
});

index.html & index.js

在根目录下准备一个 index.html 做为测试页面,同时准备好入口文件 src/index.js

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<script type="text/javascript" src="http://127.0.0.1:3000/bundle.js"></script>
</body>
</html>
// index.js
console.log('It works');
if(module.hot) {
module.hot.accept();
}

需要注意的地方是,需要带上 module.hot 以接受更新替换,否则可以在浏览器控制台看到更新信息,模块却没有被替换。详细见 module.hot

启动服务器

$ node server.js

访问 http://127.0.0.1:3000/

已经完成了所有工作,可以尝试修改 index.js 并通过浏览器控制台查看更新结果了。完整代码见 Github

其他

如果是与其他服务器配合使用 HMR 的话,需要 webpack-dev-middlewarewebpack-hot-middleware。具体的实现也跟这里差不多的思路,就不展开了。提供部分主要代码,基于 Express

// webpack.config.js
{
entry: [
'webpack-hot-middleware/client?reload=true',
'webpack/hot/dev-server',
'./src/index.html'
],
}
// server.js
compiler = webpack(utils.extendOptions(webpackDevConf));
app.use(webpackDevMiddleware(compiler, {
noInfo: true,
quiet: false,
lazy: false,
watchOptions: {
aggregateTimeout: 300,
poll: true
},
stats: {
colors: true
}
}));
app.use(webpackHotMiddleware(compiler));

小结

以前会把 webpack 与 FIS3 比较,并尝试配合 Gulp 之类的任务管理工具放在项目中去处理各种开发需求和多人协作,然后会觉得在不同项目中跳跃的时候 webpack 的配置文件会造成不方便和额外的使用成本。然而最近几个自己玩耍的项目都使用 webpack 作为打包工具,可以看到启动一个项目十分简单,而且 webpack 足够强大去满足自己的各种需求,开发体验甩了 FIS3 一大截,特别是配合 React 的开发体验更是愉悦。

HMR 真是极好~
webpack 真是极好~