webpack-dev-server 为你提供了一个简单的 web 服务器,能够实时重新加载。以下内容将主要介绍它是如何实现实现静态资源服务以及热更新的。
静态资源服务
webpack-dev-server 会使用当前的路径作为请求的资源路径 ,就是我们运行webpack-dev-server命令的路径。可以通过指定 content-base 来修改这个默认行为,这个路径标识的是静态资源的路径 。
contentBase只和我们的静态资源相关也就是图片,数据等,需要和output.publicPath和output.path做一个区分。后面两者指定的是我们打包出文件存放的路径,output.path是我们实际的存放路径,设置的output.publicPath会在我们打包出的html用以替换path路径,但是它所指向的也是我们的output.path打包的文件。
例如我们有这么一个配置:
output: { filename: '[name].[hash].js', //打包后的文件名称 path: path.resolve(__dirname, '.hmbird'), //打包后的路径,resolve拼接绝对路劲 publicPath: 'http://localhost:9991/' },
打包出的html模块
有一个疑问就是我们contentBase指定的静态资源路径下有一个index.html,并且打包出的结果页也有一个index.html,也就是两个文件路径访问的路径相同的话,会返回哪一个文件?
结果就是会返回我们打包出的结果页面,静态资源的优先级是低于打包出的文件的。
接下来介绍的是我们的webpack-dev-server是如何提供静态资源服务的。原理其实就是启动一个express服务器,调用app.static方法。
源码如下:
setupStaticFeature() { const contentBase = this.options.contentBase; const contentBasePublicPath = this.options.contentBasePublicPath; if (Array.isArray(contentBase)) { //1.数组 contentBase.forEach((item) => { this.app.use(contentBasePublicPath, express.static(item)); }); } else if (isAbsoluteUrl(String(contentBase))) { //2.绝对的url(例如http://www.58.com/src) 不推荐使用,建议通过proxy来进行设置 this.log.warn( 'Using a URL as contentBase is deprecated and will be removed in the next major version. Please use the proxy option instead.' ); this.log.warn( 'proxy: { "*": "<your current contentBase configuration>" }' ); // 重定向我们的请求到contentBase this.app.get('*', (req, res) => { res.writeHead(302, { Location: contentBase + req.path + (req._parsedUrl.search || ''), }); res.end(); }); } else if (typeof contentBase === 'number') { //3.数字 不推荐使用 this.log.warn( 'Using a number as contentBase is deprecated and will be removed in the next major version. Please use the proxy option instead.' ); this.log.warn( 'proxy: { "*": "//localhost:<your current contentBase configuration>" }' ); // Redirect every request to the port contentBase this.app.get('*', (req, res) => { res.writeHead(302, { Location: `//localhost:${contentBase}${req.path}${req._parsedUrl .search || ''}`, }); res.end(); }); } else { //4.字符串 // route content request this.app.use( contentBasePublicPath, express.static(contentBase, this.options.staticOptions) ); } }
热更新
实现的方式主要有两种iframe mode和inline mode。
1. iframe mode 我们的页面被嵌套在一个iframe中,当资源改变的时候会重新加载。只需要在路径中加入webpack-dev-server就可以了,不需要其他的任何处理。(http://localhost:9991/webpack-dev-server/index.html)
2. inline mode,不再单独引入一个js,而是将创建客户端soket.io的代码一同打包进我们的js中。
webpack-dev-server如何实现HMR(模块热更新)呢?也就是在不刷新页面的情况下实现页面的局部刷新。
首先介绍一下使用方式:
第一步:
devServer: { hot: true }
第二步:
if (module.hot) { module.hot.accept(); } //这段代码用于标志哪个模块接收热加载,如果是代码入口模块的话,就是入口模块接收
Webpack 会从修改模块开始根据依赖关系往入口方向查找热加载接收代码。如果没有找到的话,默认是会刷新整个页面的。如果找到的话,会替换那个修改模块的代码为修改后的代码,并且从修改模块到接收热加载之间的模块的相关依赖模块都会重新执行返回新模块值,替换点模块缓存。
简单来说就是,有一个index.js引入了一个文件home.js,如果我们修改了home.js内容,热加载模块如在home.js则只更新home.js,如果在index.js则更新index.js和home.js两个文件的内容。如果两个文件都没有热更新模块,则刷新整个页面。
(由于 Webpack 的热加载会重新执行模块,如果是使用 React,并且模块热加载写在入口模块里,那么代码调整后就会重新执行 render。但由于组件模块重新执行返回了新的组件,这时前面挂在的组件状态就不能保留了,效果就等于刷新页面。
需要保留组件状态的话,需要使用 react-hot-loader 来处理。)
webpack-dev-server在我们的entry中添加的hot模块内容
//webpack-dev-server/utils/lib/addEntries.js
if (options.hotOnly) {
hotEntry = require.resolve('webpack/hot/only-dev-server');
} else if (options.hot) {
hotEntry = require.resolve('webpack/hot/dev-server');
}
在我们的入口文件下添加了两个webpack的文件
1. only-dev-server :检查模块的更新
2. dev-server :模块热替换的相关内容
HMR原理
上图注释:
绿色是webpack控制区域,蓝色是webpack-dev-server控制区域,红色是文件系统,青色是我们项目本身。
第一步:webpack监听文件变化并打包(1,2)
webpack-dev-middleware 调用 webpack 的 api 对文件系统 watch,当文件发生改变后,webpack 重新对文件进行编译打包,然后保存到内存中。 打包到了内存中,不生成文件的原因就在于访问内存中的代码比访问文件系统中的文件更快,而且也减少了代码写入文件的开销
第二步: webpack-dev-middleware对静态文件的监听(3)
webpack-dev-server 对文件变化的一个监控,这一步不同于第一步,并不是监控代码变化重新打包。当我们在配置文件中配置了devServer.watchContentBase 为 true 的时候,Server 会监听这些配置文件夹中静态文件的变化,变化后会通知浏览器端对应用进行 live reload。注意,这儿是浏览器刷新,和 HMR 是两个概念
第三步:devServer 通知浏览器端文件发生改变(4)
sockjs 在服务端和浏览器端建立了一个 webSocket 长连接,以便将 webpack 编译和打包的各个阶段状态告知浏览器,最关键的步骤还是 webpack-dev-server 调用 webpack api 监听 compile的 done
事件,当compile 完成后,webpack-dev-server通过 _sendStatus
方法将编译打包后的新模块 hash 值发送到浏览器端。
第四步:webpack 接收到最新 hash 值验证并请求模块代码(5,6)
webpack-dev-server/client 端并不能够请求更新的代码,也不会执行热更模块操作,而把这些工作又交回给了 webpack,webpack/hot/dev-server 的工作就是根据 webpack-dev-server/client 传给它的信息以及 dev-server 的配置决定是刷新浏览器呢还是进行模块热更新。当然如果仅仅是刷新浏览器(执行步骤11),也就没有后面那些步骤了。
第五步:HotModuleReplacement.runtime 对模块进行热更新(7,8,9)
是客户端 HMR 的中枢,它接收到上一步传递给他的新模块的 hash 值,它通过 JsonpMainTemplate.runtime 向 server 端发送 Ajax 请求,服务端返回一个 json,该 json 包含了所有要更新的模块的 hash 值,获取到更新列表后,该模块再次通过 jsonp 请求,获取到最新的模块代码。
第六步:HotModulePlugin 将会对新旧模块进行对比(10)
HotModulePlugin 将会对新旧模块进行对比,决定是否更新模块,在决定更新模块后,检查模块之间的依赖关系,更新模块的同时更新模块间的依赖引用 ,第一个阶段是找出 outdatedModules 和 outdatedDependencies。第二个阶段从缓存中删除过期的模块和依赖。第三个阶段是将新的模块添加到 modules 中,当下次调用 __webpack_require__ (webpack 重写的 require 方法)方法的时候,就是获取到了新的模块代码了。
webpack-dev-server是如何实现从内存中加载打包好的文件的呢?
关键就在于webpack-dev-middleware,作用就是,生成一个与webpack的compiler绑定的中间件,然后在express启动的服务app中调用这个中间件。
这个中间件的主要作用有3个:
1. 通过watch mode,监听资源的变更,然后自动打包。
2. 使用内存文件系统,快速编译。
3. 返回中间件,支持express的use格式。
对于 webpack-dev-middleware,最直观简单的理解就是一个运行于内存中的文件系统。你定义了 webpack.config,webpack 就能据此梳理出所有模块的关系脉络,而 webpack-dev-middleware 就在此基础上形成一个微型的文件映射系统,每当应用程序请求一个文件——比如说你定义的某个 entry
,它匹配到了就把内存中缓存的对应结果作为文件内容返回给你,反之则进入到下一个中间件。
源码结构如下:
除去utils等工具方法文件,最主要的文件就是index.js和middleware.js
index.js:watch mode && 输出到内存
//index.js export default function wdm(compiler, options = {}) { ... //绑定钩子函数 setupHooks(context); ... //输出到内存 setupOutputFileSystem(context); ... // 启动监听 context.watching = context.compiler.watch(watchOptions, (error) => { if (error) { context.logger.error(error); } }); }
index.js是一个中间件的容器包装函数,接受两个参数:一个是webpack的compiler,另一个是配置对象,经过一系列处理后返回一个中间件函数。
主要完成是事件有已上三个:
setupHooks();
setupOutputFileSystem()
context.compiler.watch()
setupHooks
此函数的作用是在 compiler 的 invalid、run、done、watchRun 这 4 个编译生命周期上,注册对应的处理方法
//utils/setuohooks.js ... context.compiler.hooks.watchRun.tap('DevMiddleware', invalid); context.compiler.hooks.invalid.tap('DevMiddleware', invalid); context.compiler.hooks.done.tap('DevMiddleware', done);
- 在 done 生命周期上注册 done 方法,该方法主要是 report 编译的信息以及执行 context.callbacks 回调函数
- 在 invalid、run、watchRun 等生命周期上注册 invalid 方法,该方法主要是 report 编译的状态信息
setupOutputFileSystem
其作用是使用 memory-fs 对象替换掉 compiler 的文件系统对象,让 webpack 编译后的文件输出到内存中
//utils/setupOutputFileSystem.js import { createFsFromVolume, Volume } from 'memfs'; ... outputFileSystem = createFsFromVolume(new Volume());
context.compiler.watch
调用的就是compiler的watch方法,一旦我们改动文件,就会重新执行编译打包。
middleware.js:返回中间件
此文件返回的是一个 express 中间件函数的包装函数,其核心处理逻辑主要针对 request 请求,根据各种条件判断,最终返回对应的文件内容
export default function wrapper(context) { return function middleware(req, res, next) { //1. 定义goNext方法 function goNext() { ... } ... //2.请求类型判断,若请求不包含于配置中(默认 GET、HEAD 请求),则直接调用 goNext() 方法处理请求 const acceptedMethods = context.options.methods || ['GET', 'HEAD']; if (acceptedMethods.indexOf(req.method) === -1) { return goNext(); } //3.根据请求的url地址,在内存中寻找对应文件,并构造response返回 return new Promise((resolve) => { function processRequest() { ... } ... ready(context, processRequest, req); }); } }
goNext方法
该方法判断是否是服务端渲染。如果是,则调用 ready() 方法(此方法即为 ready.js 文件,作用为根据 context.state 状态判断直接执行回调还是将回调存储 callbacks 队中)。如果不是,则直接调用 next() 方法,流转至下一个 express 中间件
function goNext() { if (!context.options.serverSideRender) { return next(); } return new Promise((resolve) => { ready( context, () => { // eslint-disable-next-line no-param-reassign res.locals.webpack = { devMiddleware: context }; resolve(next()); }, req ); }); }
ready.js文件
判断 context.state 的状态,将直接执行回调函数 fn,或在 context.callbacks 中添加回调函数 fn。这也解释了上文提到的另一个特性 “在编译期间,停止提供旧版的 bundle 并且将请求延迟到最新的编译结果完成之后”。若 webpack 还处于编译状态,context.state 会被设置为 false,所以当用户发起请求时,并不会直接返回对应的文件内容,而是会将回调函数processRequest添加至 context.callbacks 中,而上文中我们说到在 compile.hooks.done 上注册了回调函数done,等编译完成之后,将会执行这个函数,并循环调用 context.callbacks。。
//utils/ready.js if (context.state) { return callback(context.stats); } const name = (req && req.url) || callback.name; context.logger.info(`wait until bundle finished${name ? `: ${name}` : ''}`); context.callbacks.push(callback);
processRequest函数
在返回的中间件实例中定义了一个processRequest函数,此方法通过url查找到filename路径,如果filename不存在直接调用goNext方法,否则的话找到对应文件构造response对象返回。在ready方法中调用processRequest函数。
function processRequest() { const filename = getFilenameFromUrl(context, req.url); //查找文件 if (!filename) { return resolve(goNext()); } ... //构造response对象,并返回 let content; try { content = context.outputFileSystem.readFileSync(filename); } catch (_ignoreError) { return resolve(goNext()); } content = handleRangeHeaders(content, req, res); ... res.send(content); }