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: {\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。
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); }