webpack-dev-server 为你提供了一个简单的 web 服务器,可以实时从新加载。如下内容将主要介绍它是如何实现实现静态资源服务以及热更新的。javascript
webpack-dev-server 会使用当前的路径做为请求的资源路径 ,就是咱们运行webpack-dev-server命令的路径。能够经过指定 content-base 来修改这个默认行为,这个路径标识的是静态资源的路径 。html
contentBase只和咱们的静态资源相关也就是图片,数据等,须要和output.publicPath和output.path作一个区分。后面二者指定的是咱们打包出文件存放的路径,output.path是咱们实际的存放路径,设置的output.publicPath会在咱们打包出的html用以替换path路径,可是它所指向的也是咱们的output.path打包的文件。java
例如咱们有这么一个配置:react
output: { filename: '[name].[hash].js', //打包后的文件名称 path: path.resolve(__dirname, '.hmbird'), //打包后的路径,resolve拼接绝对路劲 publicPath: 'http://localhost:9991/' },
打包出的html模块webpack
有一个疑问就是咱们contentBase指定的静态资源路径下有一个index.html,而且打包出的结果页也有一个index.html,也就是两个文件路径访问的路径相同的话,会返回哪个文件?web
结果就是会返回咱们打包出的结果页面,静态资源的优先级是低于打包出的文件的。express
接下来介绍的是咱们的webpack-dev-server是如何提供静态资源服务的。原理其实就是启动一个express服务器,调用app.static方法。npm
源码以下:json
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: {\n\t"*": "<your current contentBase configuration>"\n}' ); // 重定向咱们的请求到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: {\n\t"*": "//localhost:<your current contentBase configuration>"\n}' ); // 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。api
1. iframe mode 咱们的页面被嵌套在一个iframe中,当资源改变的时候会从新加载。只须要在路径中加入webpack-dev-server就能够了,不须要其余的任何处理。(http://localhost:9991/webpack-dev-server/index.html)
2. inline mode,再也不单独引入一个js,而是将建立客户端soket.io的代码一同打包进咱们的js中。
首先介绍一下使用方式:
第一步:
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);
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); }