搭建带热更新功能的本地开发node server

时间:2023-03-08 17:20:48
搭建带热更新功能的本地开发node server

引言

使用webpack有一段时间了,对其中的热更新的大概理解是:对某个模块做了修改,页面只做局部更新而不需要刷新整个页面来进行更新。这样就能节省因为整个页面刷新所产生开销的时间,模块热加载加快了开发的速度。

热加载的基础是模块热替换(HMR,Hot Module Replacement)。

具体的是:webpack可以监控文件的改动,在模块文件代码发生改动时,并发送 HMR 更新消息(HMR update)给HMR 运行时(HMR runtime)环境,它决定模块的替换,具体可以参考下图:

搭建带热更新功能的本地开发node server

HMR实现的具体效果可以先看下下图的效果:搭建带热更新功能的本地开发node server

可是最近,亲自搭建一个webpack应用项目时,在实现开发环境的模块热更新时,遇到这样那样的问题。由于之前都是使用第三方插件来实现应用的热更新,它们都封装了实现热更新的一些细节,导致在不用第三方插件实现模块热更新时出现问题,其实还是理解的不够深入。于是在搞明白之后写下此文与大家分享。

Hot Module Replacement(HMR)

webpack的自带的HMR插件HotModuleReplacementPlugin是使用webpack热更新功能的基础。其他的第三方插件如webpack-hot-middlewarereact-hot-loaderbabel-plugin-dva-hmr等等都是要配合webpack自带的HotModuleReplacementPlugin插件提供的api来实现代码的热更新。例如下面在某个模块中使用HMR代码一个例子:

 if (module.hot) {
module.hot.accept('./containers/rootContainer.js', () => {
const NextRootContainer = require('./containers/rootContainer.js').default;
render(<NextRootContainer />, document.getElementById('react-root'));
}
}

当然HotModuleReplacementPlugin为可以使用HMR的模块提供了module.hot,它为一个对象,其含有很多api,具体可以参考这里。这样利用插件提供的这些api可以为模块实现自定义的热更新逻辑。

但是,在开发过程中,你们可能也发现了,我们并没有为项目中的每个模块提供这种多余的HMR代码,尽管所有代码都有可能变化。那么当这些代码没有HMR代码的模块发生变化时,他是如何实现热更新的呢?这就要说到webpack HMR更新的冒泡(bubble)机制。具体可以看下图所展示的冒泡机制:

搭建带热更新功能的本地开发node server

从图中可以看出:

  • 模块C发生了变化,但是模块C没有用HMR代码捕获变化,则模块C的变化消息将冒泡到依赖C模块的其他模块A和B中。

  • 模块B由于使用了HMR代码进行捕获变化,那么应用的变化就按照代码进行了更新。并且不会再冒泡了。

  • 模块A由于同样没有HMR代码捕获变化,同样将变更消息冒泡到依赖A模块的模块entry中。

  • 入口entry模块没有HMR代码捕获变化的话:

    • 1、 若项目使用webpack-dev-server的webpack/hot/dev-server,则页面会刷新整个页面来加载变化;若使用webapck/hot/only-dev-server的话,不会刷新页面,会在控制台展示一些有用的信息供开发者参考。具体可以参考这里

    • 2、若为webpack-hot-middleware配置了reload:true,那么页面就会整个刷新来加载加载变化,这就变成liveroad模式;否则webpack就不知道如何加载变化模块,控制台也会有对应的提示。

例如,在本人的实例中,修改了searchForm.jsx模块,可以在控制台清晰的看到,它一直冒泡到入口模块index.js。如下图:

搭建带热更新功能的本地开发node server

开发过程中遇到的问题

在用webpack构建的项目中,在开发阶段我们为了实现开发过程代码的热更新,如果对使用HMR不熟悉,可能会遇到这样或者那样的问题。下面就在本人开发过程中遇到过:

1、在cli中使用带--hot选项的webpack-dev-server命令时,不要在webpack的配置文件在配置HMR插件。

否则会报下面的错误,具体可参考这里

搭建带热更新功能的本地开发node server

注意:

webpack-dev-server的node api模式下配置hot: true仍然需要在webpack配置文件中配置该插件

重要更新:

\(\color{#FF0000}{该规则已不是问题,目前的webpack4已做了处理,即若webpack的配置项配置过HMR插件就不做处理,没有配置则会主动帮我们添加。}\)

其中源码如下:

[].concat(config).forEach((config) => {
config.entry = prependEntry(config.entry || './src'); if (options.hot || options.hotOnly) {
config.plugins = config.plugins || [];
if (
!config.plugins.find(
(plugin) =>
plugin.constructor === webpack.HotModuleReplacementPlugin
)
) {
config.plugins.push(new webpack.HotModuleReplacementPlugin());
}
}
});

2、在不使用第三方HMR库,纯搭建自己的本地node server时,一定要在项目的入口模块添加module.hot.accept代码来接受更新消息以实现热更新。

在本人另一个项目中,使用dora插件系列的dora-plugin-webpack-hmr插件来实现热更新,由于没有在入口模块添加HMR代码来接受变更,导致模块一产生变化就刷新整个页面。

具体是因为:dora-plugin-webpack-hmr使用webpack-hot-middleware时,默认配置了其reload:true(参考这里),所以每次修改都会刷新整个页面。

第三方HMR插件/库的实现细节

前面说到,要想实现webpack的HMR功能,需要两点:webpack配置HMR入口文件添加HMR代码。二者缺一不可,否则模块热更新就会失败。

但是,在开发过程中,我们可能根本没有配置过上面所说的两点;这主要是因为我们在项目中使用第三方HMR插件或者库,它们自动替我们完成这些;要么是二者都会给配置掉,要么就配置其中之一。 比方在本人项目中使用过的dora-plugin-webpack-hmrbabel-plugin-dva-hmr,以及Gaearon大神的react-hot-loader;下面就来说说他们的他们为我们做了什么隐蔽的事。

dora-plugin-webpack-hmr

该插件是为dora系列的插件,主要用在基于dora的项目中。该插件是基于webpack-hot-middleware库来实现热加载的,它主要为我们做了两件事:

  • 代码更新没有捕获时会刷新整个页面来加载更新。 也就是为webpack-hot-middleware的reload属性默认配置true,可看源码1

  • 自动为webpack配置项添加HMR插件配置。具体看源码2这样,我们使用该插件就不需要在webpack中配置HMR,否则会遇到常见问题1中的情况。

所以:

使用dora-plugin-webpack-hmr插件还是需要在入口模块添加module.hot.accept来接受更新,否则达不到热更新效果。

babel-plugin-dva-hmr

该插件是与dva配套的,用在使用dva框架下的代码热更新插件。该插件自动替我们在入口模块添加HMR代码,具体可看源码3,开发环境下入口模块添加的代码如下图:

搭建带热更新功能的本地开发node server

由此该插件只帮我们在入口模块添加HMR代码接受变更,但是它没有帮我们在webpack中配置HMR,这样HMR的api是不能用的。所以:

使用babel-plugin-dva-hmr插件还需要在webpack配置项中配置HMR。

react-hot-loader@❤️.0.0

该loader的目的是:保持组件状态的热更新。即不仅达到模块的热更新,还要保持各个模块的状态不会丢失,具体可参考Gaearon大神的Hot Reloading in React。它如何保持状态不在本文范围,可自行查询。

在该loader的3.0.0版本前,与babel-plugin-dva-hmr插件类似,它也是自动为我们在模块中注入接受更新的HMR代码而没有在webpack配置项自动添加HMR配置,具体可参考源码4。但是它与前者不同是:它为每个启用该loader的js文件都注入接受更新的HMR代码

例如,在webpack.config.js中为js文件配置该插件:

//这样src目录下的所有.js文件都将被自动添加HMR热更新代码
loaders: [{
test: /\.js$/,
loaders: ['react-hot', 'babel'],
include: path.join(__dirname, 'src')
}]

自动添加的有关HMR代码如下,只截取部分代码:

搭建带热更新功能的本地开发node server

但是同样的,

我们需要在webpack配置项中添加HMR插件配置。

注意:

react-hot-loader在3.0.0版本之后就废弃掉该方式,不会自动添加HMR热更新代码,需要开发者在项目入口模块手动添加HMR代码,参考这里

搭建带HMR的本地开发node sever

之前,与webpack配合的webpack-dev-server服务,通过配置就可以实现代码热更新,但是隐藏了实现细节。下面我们手动搭建一个自带HMR功能的本地开发node sever。

1、使用webpack-dev-middleware搭建本地服务

webpack-dev-server就是基于webpack-dev-middleware来搭建内部node server。我们搭建自己的开发环境就用它来直接搭建。

2、使用webpack-hot-middleware来实现客户端与服务端的通信以接受更新

该模块只是负责客户端与服务器通信及接受变化,但是如何实现根据热加载来完成应用的无缝变化衔接就超出了该模块的范围,正如其官网所描述:

This module is only concerned with the mechanisms to connect a browser client to a webpack server & receive updates. It will subscribe to changes from the server and execute those changes using webpack's HMR API. Actually making your application capable of using hot reloading to make seamless changes is out of scope, and usually handled by another library.

这句话的意思是:

What this means in practice, is you either need to add some code which calls module.hot.accept(), or use a plugin which can automatically add this code to your modules - otherwise webpack doesn't know how to apply the hot update.

也就是, 要么你在模块中增加调用module.hot.accept()的代码,要么使用第三方插件自动的为你模块添加这些代码;否则webpack不知道怎么更新这些模块。具体可以参考这里

另外,要使用HMR功能,需要在webpack的配置项的每个入口项数组中添加webpack-hot-middleware/client,即:

 entry: {
index: ['./src/index','webpack-hot-middleware/client']
}

3、配置HMR

正如上文所描述的,它分为两步:

  • 首先,要在webpack的配置项plugins需要配置HMR插件即
plugins: [ new webpack.HotModuleReplacementPlugin()]
  • 其次,需要在项目的入口模块中添加HMR代码捕获变化以做热更新。例如下面:
if(module.hot){
module.hot.accpet() //接受模块更新的事件,同时阻止这个事件继续冒泡
}

若为每个模块添加HMR代码来热更新对应的模块机制是不可取的,这会产生大量冗余代码,极不推荐这种做法,除非像第三方插件那样自动帮我们完成。

一般在入口模块添加module.hot的相关api来更新具体变化,入口模块没有添加的话就不会达到热更新的效果,浏览器控制台也会出现如下警告(前提是webpack-hot-middleware的reload配置为false):

搭建带热更新功能的本地开发node server

在浏览器控制台中出现这样一句提示:

This is usually because the modules which have changed (and their parents) do not know how to hot reload themselves.

正如提示所说的,修改某个子模块时,若不在模块本身或者*的入口模块添加热更新接受机制,那么产生变化的模块及其父模块不知道怎么加载他们。

最终,用户自定义的开发环境node server具体的核心开发代码如下:

//dev-server.js 文件
var webpackDevMiddleware = require('webpack-dev-middleware');
var webpackHotMiddleware = require('webpack-hot-middleware'); Object.keys(webpackConfig.entry).forEach(function(name){
webpackConfig.entry[name] = ['webpack-hot-middleware/client'].concat(webpackConfig.entry[name]);
})
var compiler = webpack(webpackConfig); var devMiddleware = webpackDevMiddleware(compiler, {
publicPath: webpackConfig.output.publicPath,
hot: true,
noInfo: true,
stats: {
colors: true
}
});
var hotMiddleware = webpackHotMiddleware(compiler);
app.use(devMiddleware);
app.use(hotMiddleware); app.listen(port, function(err){
if(err){
console.log(err);
}else {
var url = 'http://localhost:' + port;
console.log("listening on port %s", port);
}
})

另外,我们可能会想到,在使用redux的react项目中,这种热更新会导致应用的state丢失,为了防止state随热更新而丢失,一般需要在针对reducer的修改来实现进行state的保存,最常用的做法是在store模块下添加如下reducer热更新代码:

if(module.hot){
module.hot.accept('../reducers/index.js', ()=>{
const nextReducer = require('../reducers/index.js');
store.replaceReducer(nextReducer || nextReducer.default);
})
}

至此,一个带HMR代码热更新功能的本地开发node server就搭建成功了。

其他文件热更新的实现

上面的带HMR热更新功能的node server虽已搭建,但是就能满足我们的开发需求了么?我想答案是否定的。上面的热更新其实是针对js文件的热更新,也就是说对js文件的变更做热更新。在实际项目中,我们修改的可不仅仅是js文件,还有css文件html文件等等,这些都需要考虑热更新。

1、html文件的热更新

在项目中,我们使用html-webpack-plugin来生成webpack spa页面。由于该插件不支持HMR,为了支持html的HMR,我们需要利用webpack-hot-middleware提供对外接口来实现。具体需要三步:

  • 首先,在上面的dev-server.js中为html-webpack-plugin钩子html-webpack-plugin-after-emit增加回调,释放一个信号表示html页面已经构建完成。
// dev-server.js
compiler.plugin('compilation', function (compilation) {//webpack编译完成
//在这个插件合成出页面之后,添加一个回调,调用中间件emit一个action为reload的事件,对应另一边client订阅的事件,实现浏览器的刷新
compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) {
hotMiddleware.publish({action: 'reload'})
cb()
})
});
  • 其次,为html页面构建完成后添加回调,用于实现热更新逻辑
// 新建一个build/dev-client.js文件
var hotClient = require('webpack-hot-middleware/client');
// 添加一个订阅事件,当监听到 event.action === 'reload' 时执行页面刷新
hotClient.subscribe(function (event) {
if (event.action === 'reload') {
window.location.reload()
}
})
  • 最后,修改webpack的entry,为其添加前缀,即第二步创建的文件build/dev-client
// 在webpack配置中设置
Object.keys(config.entry).forEach(function (name, i) {
config.entry[name] = ['./build/dev-client'].concat(config.entry[name])
})

至此,html文件的热更新就完成了,不过这里不是真正意义上的热更新,而是刷新整个页面。

2、css文件的热更新

一般情况下,webpack项目中的css处理都是通过 extract-text-webpack-plugin 插件把css抽离到单独css文件中,但令人遗憾的是该插件是不支持热加载的,具体可以参考issue

但是,可喜的是webpack的style-loader是支持css热加载的。 该插件通过js创建一个 style 标签,然后注入内联的css。

所以,按照上面描述,要想实现css的热加载,只需要: 开发环境不要用extract-text-webpack-plugin插件,而是用style-loader代替。 但是,这种做法被开发者狠狠的吐槽了,并且还列出的原因:

  • 用隔离的css文件能更好的调试

  • 开发和生产环境的尽可能的一致,可以保证尽可能少的bug

吐槽归吐槽,官方还是没有提供热加载支持,但是社区出现了extract-text-webpack-plugin支持热加载的各种实现方式,虽然有些是hack,但是能工作的很好啊,例如下面的列举的实现:

  • 类似于html文件热更新,采用事件通知机制来实现,可以参加这里

  • 将引入js模块中的css模块文件,如require('<path to css file>')这行代码抽取成一个单独的js文件,并在该js文件实现模块更新接收,可以参考这里

  • 基于webpack2热加载机制的事件实现(参考这里以及基于此为避免FOUC升级实现)

  • 用一个babel插件css-hot-loader来实现。

该插件的实现原理:

每次热加载都是一个 js 文件的修改,每个 css 文件在 webpack 中也是一个 js 模块,那么只需要在这个 css 文件对应的模块里面加一段代码就可以实现 css 文件的更新了(具体的是更新外链link的地址url,为其添加时间戳),它会自动在每个css文件中添加如下代码:

 if(module.hot) {
// ${Date.now()}
const cssReload = require(${loaderUtils.stringifyRequest(this, require.resolve('./hotModuleReplacement'))})(${JSON.stringify(options)});
module.hot.dispose(cssReload);
module.hot.accept(undefined, cssReload);
}

最终对应的CSS文件编译生成的代码可能是这样子:

// removed by extract-text-webpack-plugin
if(module.hot) {
// 1498744720173
const cssReload = require("../../../node_modules/css-hot-loader/hotModuleReplacement.js")({"fileMap":"{fileName}"});
module.hot.dispose(cssReload);
module.hot.accept(undefined, cssReload);
} /*****************
** WEBPACK FOOTER
** ./src/routes/main.less
** module id = 636
** module chunks = 1
**/

3、其他配置文件变动的更新

这里不说代码热更新,而是提供一种代码变动更新机制。

在项目中,我们可以很容易实现js、css和html文件的热更新;但是,我们有没有想到过,在我们项目中其他文件变更时也要加载变化后的文件,例如项目中package.json或者webpack.config.js配置文件发生了变化,我们也想浏览器有所反应而不是无动于衷,那么我们可以监控这些文件的变化来实现。具体:

  • 在上述的dev-server.js文件中用chokidar添加对指定文件的监控,比如webpack.config.js
//dev-server.js
var chokidar = require('chokidar');
chokidar.watch(path.resolve(process.cwd(), 'webpack.dev.conf.js')).on('change', function(){
process.send('restart'); //向父进程传递消息信号
})
  • 创建本地node server主入口文件,用于创建dev-server.js对应的子进程。
//dev-server-main.js
var cp = require('child_process');
function start(){
const p = cp.fork(__dirname + '/dev-server.js');
p.on('message', function(data){
if(data === 'restart'){
p.kill('SIGINT');
start();
}
})
}
if(!process.send){
start();
}
  • 最后用node dev-server-main.js开启服务

这样,就可以实现修改webpack.config.js达到重新加载配置的目的。不过它的做法是webpack重新对项目编译。

参考文献