結束了一季的忙碌,我這封筆已久的部落格也終究該從春困的咒印中復甦,想來寫些實用易讀的作為開篇,自然是最好不過。
新開個 webpack 外掛/工具介紹的文章系列,約莫每週更新一篇篇幅適中的文章聊以共勉,興許合適。
原本期望每篇文章裡可以介紹若干個外掛,但鑑於部分外掛略為複雜,且單篇內容不想寫的脣焦舌敝惹人倦煩,所以像本文要介紹的 webpack-dev-server 就獨立一文了。
迴歸主題,今天你或許會花上30分鐘的時間讀完本章,並掌握 webpack-dev-server 的使用方法、理清一些容易困惑的配置(諸如 publicPath)或概念(如HMR)。
另外,本章涉及的相關用例,可以在我的github(https://github.com/VaJoy/webpack-plugins/tree/master/char1)上下載到。
一. webpack-dev-server 他爹和他爹的朋友
我們並不急著把 webpack-dev-server 直接拉出來介紹一通,我們先了解下他的兩位長輩 —— 他爹 webpack-dev-middleware,以及他爹的朋友 webpack-hot-middleware。
他們三人有著某些親密的聯絡,不少讀者可能會對其身份存在認知混亂,所以很有必要按輩分次序來分別介紹。
1.1 webpack-dev-middleware
假設我們在服務端使用 express 開發一個站點,同時也想利用 webpack 對靜態資源進行打包編譯,那麼在開發環節,每次修改完檔案後,都得先執行一遍 webpack 的編譯命令,等待新的檔案打包到本地,再做進一步除錯。雖然我們們可以利用 webpack 的 watch mode 來監聽變更、自動打包,但等待 webpack 重新執行的過程往往很耗時。
而 webpack-dev-middleware 的出現很好地解決了上述問題 —— 作為一個 webpack 中介軟體,它會開啟 watch mode 監聽檔案變更,並自動地在記憶體中快速地重新打包、提供新的 bundle。
說白了就是 —— 自動編譯(watch mode)+速度快(全部走記憶體)!
webpack-dev-middleware 的配置與使用其實很輕鬆,我們通過一個非常簡單的專案來示例(可以點這裡獲取):
1 2 3 4 5 6 7 8 9 10 11 12 13 |
PROJECT │ app.js //應用入口檔案 │ express.config.js // express 服務啟動配置 │ package.json │ webpack.config.js // webpack 配置 │ └─src ├─html │ index.html //首頁 │ └─js └─page index.js //首頁尾本模組 |
它的 webpack.config.js 配置檔案如下:
1 2 3 4 5 6 7 8 |
module.exports = { entry: './app.js', output: { publicPath: "/assets/", filename: 'bundle.js', //path: '/' //只使用 dev-middleware 的話可以忽略本屬性 } }; |
這裡有一個非常關鍵的配置 —— publicPath,熟悉 webpack 的同學都知道,它是生成的新檔案所指向的路徑,可以用於模擬 CDN 資源引用。
打個比方,當我們使用 url-loader 來處理圖片時,把 publickPath 設為“http://abcd/assets/”,則最終打包後,樣式檔案裡所引用的圖片地址會加上這個字首:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
/**-------------webpack配置項--------------**/ output: { publicPath: "http://abcd/assets/", //模擬CDN地址 filename: 'bundle.js', path: path.join(__dirname, 'dist/') }, module: { rules: [{ test: /.css$/, loader: ['style-loader', 'css-loader'] }, { test: /.(png|jpg|gif)$/, loader: 'url-loader' }] } /**-------------頁面引入的樣式模組 index.css--------------**/ section{ width:300px; height: 300px; background-image: url(a.jpg); } |
打包後(dist/bundle.js 裡的樣式執行效果):
當然如果你沒把資源(比如這張md5化後的圖片)託管到CDN上,是請求不到的,不過通過Fiddler配置代理對映,可以解決這個問題。
然而,在使用 webpack-dev-middleware (或其它走記憶體的工具)的情況下,publicPath 只建議配置相對路徑 —— 因為 webpack-dev-middleware 在使用的時候,也需要再配置一個 publicPath(見下文 express.config.js 的配置),用於標記從記憶體的哪個路徑去存放和查詢資源,這意味著 webpack-dev-middleware 的 publicPath 必須是相對路徑。
而如果 webpack.config.js 裡的 publicPath 跟 webpack-dev-middleware 的 publicPath 不一致的話(比如前者配置了 http 的路徑),會導致資源請求到了記憶體外的地方去了(本地也沒這個檔案,也沒法走 Fiddler 代理來解決),從而返回404~
如果上面這段話瞧著糊塗,建議暫時擱置它,後續回過頭再來咀嚼,我們先了解下所謂的“webpack-dev-middleware 的 publicPath”是什麼。
如下是 express.config.js 檔案:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
const path = require('path'); const express = require("express"); var ejs = require('ejs'); const app = express(); const webpack = require('webpack'); const webpackMiddleware = require("webpack-dev-middleware"); let webpackConf = require('./webpack.config.js'); app.engine('html', ejs.renderFile); app.set('views', path.join(__dirname, 'src/html')); app.set("view engine", "html"); var compiler = webpack(webpackConf); app.use(webpackMiddleware(compiler, { //使用 webpack-dev-middleware publicPath: webpackConf.output.publicPath //保持和 webpack.config.js 裡的 publicPath 一致 })); app.get("/", function(req, res) { res.render("index"); }); app.listen(3333); |
可見 webpack-dev-middleware 的使用語法其實就這麼簡練,不外乎是:
1 2 3 |
var webpackMiddleware = require("webpack-dev-middleware"); app.use(webpackMiddleware(webpack(webpackConfig), options)); |
其中 options 是 webpack-dev-middleware 的配置物件,詳盡的可選項可參考官方文件,限於篇幅,此處只介紹 publicPath —— 它用於決定 webpack 打包編譯後的檔案,要存放在記憶體中的哪一個虛擬路徑,並提供一個 SERVER,將路徑和檔案對映起來(即使它們都是虛擬的,但依舊可請求的到)。
當前的例子,是將記憶體路徑配置為 /assets/,這意味著打包後的 bundle.js 會存放在虛擬記憶體路徑 SERVERROOT/assets/ 下(這裡的“SERVERROOT”實際上即 html 檔案的訪問路徑),也意味著我們可以直接在 src/html/index.html 中通過 src=’assets/bundle.js’ 的形式引用和訪問記憶體中的 bundle 檔案:
1 2 3 4 |
<body> <div></div> <script src="assets/bundle.js"></script> </body> |
我們執行一遍 node express.config,然後訪問 http://localhost:3333,便能正常訪問頁面、請求和執行 bundle.js:
同時,只要我們修改了頁面的指令碼模組(比如 src/js/index.js),webpack-dev-middleware 便會自行重新打包到記憶體,替換掉舊的 bundle,我們只需要重新整理頁面即可看到剛才的變更。
這裡寫個關於 webpack-dev-middleware 的小 tips:
1. webpack-dev-middleware 配置項裡的 publicPath 要與 webpack.config 裡的 output.publicPath 保持一致(並且只能是相對路徑),不然會出現問題;
2. 使用 webpack-dev-middleware 的時候,其實可以完全無視 webpack.config 裡的 output.path,甚至不寫也可以,因為走的純記憶體,output.publicPath 才是實際的 controller;
3. publicPath 配置的相對路徑,實際是相對於 html 檔案的訪問路徑。
1.2 HMR
機智的小夥伴們在讀完 webpack-dev-middleware 的介紹後,會洞悉出它的一處弱點 —— 雖然 webpack-dev-middleware 會在檔案變更後快速地重新打包,但是每次都得手動重新整理客戶端頁面來訪問新的內容,還是略為麻煩。這是因為 webpack-dev-middleware 在應用執行的時候,沒辦法感知到模組的變化。
那麼是否有辦法可以讓頁面也能自動更新呢?webpack-hot-middleware 便是幫忙填這個坑的人,所以我在前文稱之為 —— webpack-dev-middleware 的好朋友。
webpack-hot-middleware 提供的這種能力稱為 HMR,所以在介紹 webpack-hot-middleware 之前,我們先來科普一下 HMR。
HMR 即模組熱替換(hot module replacement)的簡稱,它可以在應用執行的時候,不需要重新整理頁面,就可以直接替換、增刪模組。
webpack 可以通過配置 webpack.HotModuleReplacementPlugin 外掛來開啟全域性的 HMR 能力,開啟後 bundle 檔案會變大一些,因為它加入了一個小型的 HMR 執行時(runtime),當你的應用在執行的時候,webpack 監聽到檔案變更並重新打包模組時,HMR 會判斷這些模組是否接受 update,若允許,則發訊號通知應用進行熱替換。
這裡提及的“判斷模組是否接受 update”是指判斷模組裡是否執行了 module.hot.accept(), 這裡舉個小例子:
如圖,白色的部分是編譯後的模組依賴樹,這時候我們修改了 B 模組,導致 B 模組以及依賴它的 A 模組都出現了變化(綠色部分)。
模組變更的時候,webpack 會順著依賴樹一層一層往上冒泡,查詢哪個模組是接受 update 的,查詢到了則終止冒泡,並通知 SERVER 更新其爬過的模組。
假設我們把 module.hot.accept() 放在 B 模組執行,則 webpack 會查詢到 B 模組的變更就停止繼續往上冒泡查詢了(A
是不允許變更的模組)—— 如果 B 的內容變更,是直接在 B 模組呼叫的,那頁面就能直接展示出新的內容出來,這樣效率也高(繞過了A模組);但如果 B 的內容,實際上是要經過 A 來呼叫,才能在頁面上展示出來,那此時頁面就不會重新整理(即使 B 的內容變了)。
說白了就是 module.hot.accept() 放的好,就可以繞過一些不必要的模組變更檢查來提升效率,不過對於懶人來說,直接置於最頂層的模組(比如入口模組)最為省心。
關於更多的 HMR 的知識點,可以參考官方文件。
1.3 webpack-hot-middleware
聊完了 HMR,我們回頭瞭解下 webpack-hot-middleware 的使用。
我們試著對前文使用的專案來做一番改造 —— 引入 webpack-hot-middleware 來提升開發體驗。
首先往 express.config.js 加上一小段程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
app.engine('html', ejs.renderFile); app.set('views', path.join(__dirname, 'src/html')); app.set("view engine", "html"); var compiler = webpack(webpackConf); app.use(webpackMiddleware(compiler, { publicPath: webpackConf.output.publicPath })); //新增的程式碼段,引入和使用 webpack-hot-middleware app.use(require("webpack-hot-middleware")(compiler, { path: '/__webpack_hmr' })); app.get("/", function(req, res) { res.render("index"); }); app.listen(3333); |
即在原先的基礎上引入了 webpack-hot-middleware:
1 |
app.use(require("webpack-hot-middleware")(webpackCompiler, options)); |
這裡的 options 是 webpack-hot-middleware 的配置項,詳細見官方文件,這裡我們們只填一個必要的 path —— 它表示 webpack-hot-middleware 會在哪個路徑生成熱更新的事件流服務,且訪問的頁面會自動與這個路徑通過 EventSource 進行通訊,來拉取更新的資料重新粉飾自己。
這裡要了解下,實際上 webpack-hot-middleware 最大的能力,是讓 SERVER 能夠和 HMR 執行時進行通訊,從而對模組進行熱更新。
然後是 webpack.config.js 檔案:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
const path = require('path'); const webpack = require('webpack'); module.exports = { entry: ['webpack-hot-middleware/client', './app.js'], //修改點1 output: { publicPath: "/assets/", filename: 'bundle.js' }, plugins: [ //修改點2 new webpack.HotModuleReplacementPlugin(), new webpack.NoEmitOnErrorsPlugin() //出錯時只列印錯誤,但不重新載入頁面 ] }; |
首先是 entry 裡要多加上 ‘webpack-hot-middleware/client’,此舉是與 server 建立連線。
接著加上兩個相關的外掛來打通 webpack HMR 的任督二脈,其中的 webpack.HotModuleReplacementPlugin 我們在上一節提及過,它是 HMR 的功能提供者。
最後一步很重要,很多新手容易漏掉。我們需要在入口檔案 app.js 里加上一小段程式碼:
1 2 3 4 5 6 7 8 |
import {init} from './src/js/page/index'; //灰常重要,知會 webpack 允許此模組的熱更新 if (module.hot) { module.hot.accept(); } init(); |
此處的 module.hot.accept() 是知會 webpack 接受此模組的 HMR update,在上一節已經提及多次。
補充好上述的程式碼,執行 node express.config 並訪問 http://localhost:3333,之後的模組修改,都會自動打包並更新客戶端頁面模組:
1.4 webpack-dev-server
雖然 webpack-dev-middleware + webpack-hot-middleware 的組合為開發過程提供了便利,但它們僅適用於服務側開發的場景。
很多時候我們僅僅對客戶端頁面做開發,沒有直接的 server 來提供支援,這時候就需要 webpack-dev-server 來解囊相助了。
顧名思義,webpack-dev-server 相對前兩個工具多了個“server”,實際上它的確也是在 webpack-dev-middleware 的基礎上多套了一層殼來提供 CLI 及 server 能力(這也是為何我稱 webpack-dev-middleware 是 webpack-dev-server 他爹)。
此處依舊以一個簡單的專案來展示如何配置、使用 webpack-dev-server,你可以點這裡獲取相關程式碼。
脫離了 express,我們不再需求配置後端指令碼,不過對於 webpack.config.js,需要多加一個名為“devServer”的 webpack-dev-server 配置項:
1 2 3 4 5 6 7 8 9 10 11 12 |
const path = require('path'); module.exports = { entry: './app.js', output: { publicPath: "/assets/", filename: 'bundle.js' }, devServer: { //新增配置項 contentBase: path.join(__dirname, "src/html"), port: 3333 } }; |
其中 devServer.port 表示 SERVER 的監聽埠,即執行後我們可以通過 http://localhost:3333 來訪問應用;
而 devServer.contentBase 表示 SERVER 將從哪個目錄去查詢內容檔案(即頁面檔案,比如 HTML)。
確保安裝好 webpack-dev-server 後執行其 CLI 命令來召喚支援熱更新的 SERVER:
1 |
webpack-dev-server |
接著訪問 http://localhost:3333,似乎便能獲得前文 webpack-dev-middleware + webpack-hot-middleware 的熱更新能力~
不過事實並非如此,雖然在我們修改模組後,頁面的確自動重新整理了。但截止此處,webpack-dev-server 跑起來其實只相當於捎上了 SERVER 的 webpack-dev-middleware,而沒有 HMR —— 在我們修改應用模組後,頁面是整個重新整理了一遍,而並非熱更新。
希望讀者們可以記住,HMR 提供了區域性更新應用模組的能力,而不需要重新整理整個應用頁面!
這塊的驗證也很簡單,直接在 index.html 里加個 script 列印 Date.now() 即可,若重新整理頁面,列印的值直接會變。
要讓 webpack-dev-server 加上 HMR 的翅膀,其實就得像前面 webpack-hot-middleware 的配置那樣,把 HMR 相關的東西通通加上,同時將 devServer.hot 設為 true:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// webpack.config.js const path = require('path'); const webpack = require('webpack'); module.exports = { entry: './app.js', output: { publicPath: "/assets/", filename: 'bundle.js' }, devServer: { contentBase: path.join(__dirname, "src/html"), port: 3333, hot: true // 讓 dev-server 開啟 HMR }, plugins: [ new webpack.HotModuleReplacementPlugin() //讓 webpack 啟動全域性 HMR ] }; |
1 2 3 4 5 6 7 8 |
// 入口檔案 app.js import {init} from './src/js/page/index'; if (module.hot) { // 知會 webpack 該模組接受 HMR update module.hot.accept(); } init(); |
這時候,再執行 webpack-dev-server,才是正宗的有 HMR 加持的 SERVER。
關於完整的 devServer 配置項可參考官方文件,在文章的最後,我們羅列幾個常用項做簡單介紹。
1. contentBase
即 SERVERROOT,如上方示例配置為 “path.join(__dirname, “src/html”)”,後續訪問 http://localhost:3333/index.html 時,SERVER 會從 src/html 下去查詢 index.html 檔案。
它可以是單個或多個地址的形式:
1 2 3 |
contentBase: path.join(__dirname, "public") //多個: contentBase: [path.join(__dirname, "public"), path.join(__dirname, "assets")] |
若不填寫該項,預設為專案根目錄。
2. port
即監聽埠,預設為8080。
3. compress
傳入一個 boolean 值,通知 SERVER 是否啟用 gzip。
4. hot
傳入一個 boolean 值,通知 SERVER 是否啟用 HMR。
5. https
可以傳入 true 來支援 https 訪問,也支援傳入自定義的證書:
1 2 3 4 5 6 7 |
https: true //也可以傳入一個物件,來支援自定義證書 https: { key: fs.readFileSync("/path/to/server.key"), cert: fs.readFileSync("/path/to/server.crt"), ca: fs.readFileSync("/path/to/ca.pem"), } |
6. proxy
代理配置,適用場景是,除了 webpack-dev-server 的 SERVER(SERVER A) 之外,還有另一個在執行的 SERVER(SERVER B),而我們希望能通過 SERVER A 的相對路徑來訪問到 SERVER B 上的東西。
舉個例子:
1 2 3 4 5 6 7 8 |
devServer: { contentBase: path.join(__dirname, "src/html"), port: 3333, hot: true, proxy: { "/api": "http://localhost:5050" } } |
執行 webpack-dev-server 後,你若訪問 http://localhost:3333/api/user,則相當於訪問 http://localhost:5050/api/user。
更多可行的 proxy 配置見 https://webpack.js.org/configuration/dev-server/#devserver-proxy,這裡不贅述。
7. publicPath
如同 webpack-dev-middleware 的 publicPath 一樣,表示從記憶體中的哪個路徑去存放和檢索靜態檔案。
不過官方文件有一處錯誤需要堪正 —— 當沒有配置 devServer.publicPath 時,預設的 devServer.publicPath 並非根目錄,而是 output.publicPath:
這也是為何我們們的例子裡壓根沒寫 devServer.publicPath,但還能正常請求到 https://localhost:3333/assets/bundle.js。
8. setup
webpack-dev-server 的服務應用層使用了 express,故可以通過 express app 的能力來模擬資料回包,devServer.setup 方法就是幹這事的:
1 2 3 4 5 6 7 8 9 10 |
devServer: { contentBase: path.join(__dirname, "src/html"), port: 3333, hot: true, setup(app){ //模擬資料 app.get('/getJSON', function(req, res) { res.json({ name: 'vajoy' }); }); } } |
然後我們可以通過請求 http://localhost:3333/getJSON 來取得模擬的資料: