我是如何調試 Webpack 問題的
事情是這樣的,前兩天有個小伙伴問我:「為啥我的 webpack 運行完看不到我寫的頁面,而是:」
嗯?文件列表頁?好吧,這種情況我似乎沒遇到過,一下子沒法給出答案,只能要來關鍵代碼:
重點看看 webpack.config.js 配置,用到 devServer + HMR 功能,其中:
- Webpack 版本為 5.37.0
- webpack-dev-server 版本為 3.11.2
看了半天,沒問題呀,給了幾個紙糊的建議還是解決不了問題,剛好在開會這事就暫且放下了。過了一會,小伙伴興沖沖跑過來跟我說經過一番盲猜,問題被解決了:
- output.publicPath = '/' 時一切正常
- output.publicPath = './' 時出錯,返回文件列表頁
啊?這玩意還會影響 devServer 的效果,直覺告訴我不應該啊。
emmm,成功勾起我的好奇心了,雖然寫過一些 Webpack 源碼分析的文章,但 webpack-dev-server 確實不在我的知識范圍,好在我有秘籍《如何閱讀源碼 —— 以 Vetur 為例》,是時候展示真正的技術了!
第一步:定義問題
先復盤一下問題發生的過程:
- webpack.config.js 同時配置了 ouput.publicPath 與 devServer
- 運行 npx webpack serve 啟動開發服務器
- 瀏覽器訪問 http://localhost:9000 沒有按預期返回用戶代碼,而是返回了文件列表頁面;但如果恢復 output.publicPath 的默認配置,一切如常
講道理, ouput.publicPath 應該只是影響了最終產物引用的路徑,試試命令行工具運行 curl 檢測首頁返回的內容:
Tips:有時候可以試試繞過瀏覽器的復雜邏輯,用最簡單的工具驗證 http 請求返回的內容。
可以看到,請求 http://localhost:9000 地址返回一大串 html 代碼,且頁面的 title 為 listing directory —— 也就是我們看到的文件列表頁面:
雖然不知道這是在那一層生成的,但可以肯定絕對不是我寫的,而且這是在 HTTP 層面發生的。
所以問題的核心就是:「為何 Webpack 的 output.publicPath 會影響 webpack-dev-server 的運行效果」?
第二步:回顧背景
帶著問題我又 review 了一遍 Webpack 官方文檔。
publicPath配置
首先 output.publicPath 是這么描述的:
This is an important option when using on-demand-loading or loading external resources like images, files, etc. If an incorrect value is specified you'll receive 404 errors while loading these resources.
大意就是,這是一個控制按需加載或資源文件加載的選項,如果對應的路徑資源加載失敗時會返回 404。
嗐,其實這段描述就非常不明所以了,簡單理解 output.publicPath 會改變產物資源在 html 文件的路徑,比如說 Webpack 編譯完生成了 bundle.js 文件,默認情況下寫到 html 的路徑是:
- <script src="bundle.js" />
如果設置了 output.publicPath 值,就會在路徑前增加前綴:
- <script src="${output.publicPath}/bundle.js" />
看起來很簡單。
devServer配置項
再來看看 devServer 配置:
This set of options is picked up by webpack-dev-server and can be used to change its behavior in various ways.
大意就是,devServer 配置最終會被 webpack-dev-server 消費,而 webpack-dev-server 提供了包括 HMR —— 模塊熱更新在內的 web 服務。
感受一下,包括 vue-cli、create-react-app 之類的腳手架工具底層都依賴于 webpack-dev-server ,它的作用和重要性就可想而知了吧。
第三步:分析問題
按照現有的情報,加上我對 HTTP 協議的理解,可以基本推斷問題必然是出在 webpack-dev-server 框架處理首頁請求的邏輯上,大概率是 output.publicPath 屬性影響到首頁資源的判定邏輯,導致 webpack-dev-server 找不到對應的資源文件,返回兜底的文件列表頁面。
嗯,我覺得靠譜,那就沿著這個思路挖一挖源碼,找到具體原因吧。
第四步:分析代碼
結構分析
書上得來終須淺,debug 還需看源碼啊,啥都別說了先打開 webpack-dev-server 包的代碼看看內容吧:
Tips: 讀者也可以試試 clone webpack-dev-server 倉庫的代碼,有驚喜~~
項目結構并不復雜,按 Webpack 的習慣可以推斷主要代碼都在 lib 目錄:
cloc 是一個非常好用的代碼統計工具,官網:https://www.npmjs.com/package/cloc
代碼量也就 2000 出頭,還好還好。
接下來再打開 package.json 文件,看看有哪些 dependency,一個個捋過去之后,與我們的問題強相關的依賴有:
- express:應用不用多介紹了吧
- webpack-dev-middleware:這個應該大多數人沒有注意過,從官網文檔判斷這是一個橋接 Webpack 編譯過程與 express 的中間件
- serve-index:「提供特定目錄下文件列表頁面的 express 中間件」!!!
- 按照這個描述,這鍋肯定出在 serve-index 的調用上啊,感覺離答案很近了。
局部分析
切入點:驗證 serve-index 包的作用
經過上面的分析,雖然我還不知道問題具體出在哪里,但大致可以判定跟 serve-index 包強相關,先搜一下 webpack-dev-server 在哪些地方引用這個包:
很幸運,只在 lib/Server.js 文件中用到,那就簡單多了,「靜態分析」調用語句前后的語句,大致上可以推導出:
- serveIndex 調用被包裹在 this.app.use 內,推測 this.app 指向 express 示例,use 函數用于注冊中間件,所以整個 serveIndex 就是一個中間件
- 除 setupStaticServeIndexFeature 外,Server 類型中還包含了其它命名為 setupXXXFeature 的函數,基本上都用于添加 express 中間件,這些中間件組合拼裝出 webpack-dev-server 提供的 HMR、proxy、ssl 等功能
也看不出別的啥了,先做個對照實驗,運行起來「動態分析」代碼的實際執行過程,驗證到底是不是這個地方出錯吧。先在 serveIndex 函數之前插入 debugger 語句,之后:
- 先按照正常情況,也就是 output.publicPath = '/' 執行 ndb npx webpack serve,結果是如常打開了頁面,沒有命中斷點,沒有中斷
- 再按照 ouput.publicPath = './' 執行 ndb npx webpack serve,進入斷點:
Tips: ndb 是一個開箱即用的 node debugger 工具,不需要做任何配置就能調試 node 應用,非常方便
OK,答案揭曉了,在 ouput.publicPath = './' 場景下會命中這個中間件,執行 serveIndex 函數返回文件目錄列表,這很 make sense。
不過,作為一個有追求的程序員怎么會止步于此呢,我們繼續往下挖呀:到底是那一段代碼決定了流程會不會進入 serveIndex 中間件?
切入點:確定 serveIndex 的上游中間件
思考一下,express 架構的特點就是 —— 基于中間件的洋蔥模型,而中間件之間通過 next 函數調起下一個中間件。
嗯,有思路了,我們沿著 webpack-dev-server 的 middleware 隊列,找到 serveIndex 之前都有哪些中間件,分析這些中間件的代碼應該就能解答:
到底是那一段代碼決定了流程會不會進入 serveIndex 中間件?
但是,express 中間件架構下,從 next 調用到實際中間件函數隔著很遠的調用鏈路,很難通過斷點的調用堆棧判斷出上一級中間件,以及更更上一級中間件在哪里啊:
這時候不能硬剛,得換一個技巧了 —— 找到創建 express 示例的代碼,用魔法包裹住 use 函數:
Tips: 這種技巧在某些復雜場景下特別有用,比如我在學習 Webpack 源碼的時候,就經常配合 Proxy 類對 hook 植入 debugger 語句,追蹤鉤子被誰監聽,在哪里被觸發
通過這種重寫函數,植入斷點的方式,我們就能輕松追溯到 webpack-dev-server 用到了哪些中間件,以及中間件注冊的順序:
- setupCompressFeature => 注冊資源壓縮中間件
- setupMiddleware => 注冊 webpack-dev-middleware 中間件
- setupStaticFeature => 注冊靜態資源服務中間件
- setupServeIndexFeature => 注冊 serveIndex 中間件
可以看到,在當前 Webpack 配置下總共注冊了這四個中間件函數,按照 express 的執行邏輯這四個中間件會按注冊順序從上往下執行,所以 serveIndex 函數的直接上游就是 setupStaticFeature 注冊的靜態資源服務中間件了。
繼續看看 setupStaticFeature 函數的代碼:
這里只是調用標準化的 [express.static](https://expressjs.com/en/starter/static-files.html) 函數,注入靜態資源服務功能,如果這個中間件運行的時候按路徑找不到對應的文件資源,會調用下一個中間件繼續處理請求,看起來跟我們的問題沒啥關系。
繼續往上,看看 setupMiddleware 函數:
注冊了 webpack-dev-middleware,從名字就可以看出這個中間件跟 webpack-dev-server 應該關系匪淺,那就繼續打開 webpack-dev-middleware 看看里面的代碼:
我去。。。也不少啊,這看起來太費勁了,我只是想找到這個 bug 的原因,沒必要全看吧!那就直接搜關鍵詞 publicPath 試試吧:
比較幸運,publicPath 關鍵字出現的頻率還是比較少的:
- webpack-dev-middleware/lib/middleware.js 文件中被使用了 1 次
- webpack-dev-middleware/lib/util.js 文件中被使用了 23 次
那,就先挑軟柿子捏,看看 middleware.js 文件中是怎么用的:
- const { getFilenameFromUrl } = require('./util');
- module.exports = function wrapper(context) {
- return function middleware(req, res, next) {
- function goNext() {
- // ...
- resolve(next());
- }
- // ...
- let filename = getFilenameFromUrl(
- context.options.publicPath,
- context.compiler,
- req.url
- );
- if (filename === false) {
- return goNext();
- }
- return new Promise((resolve) => {
- handleRequest(context, filename, processRequest, req);
- // ...
- });
- };
- };
注意代碼中有一個邏輯,就是調用 util 文件的 getFilenameFromUrl 函數,并判斷返回的 filename 值是否為 false,是的話調用 next 函數,這看起來很像那么回事了!
那就繼續進去看看 getFilenameFromUrl 的代碼:
逐行分析下來,注意看紅框框出來這一句:
- if(xxx && url.indexOf(publicPath) !== 0){
- return false;
- }
講道理,從字面意義上這個 url 應該是客戶端發過來的請求連接,publicPath 應該就是我們在 webpack.config.js 中配置的 output.publicPath 項的值了吧?運行起來看看:
果然,斷點進去之后可以看到這兩個值確確實實符合前面的猜想,問題就出在這里,此時:
- url = '/`'
- publicPath = output.publicPath = '/helloworld'
- 所以 url.indexOf(publicPath) === false 實錘
getFilenameFromUrl 函數執行結果為 false,所以 webpack-dev-middleware 會直接調用 next 方法進入下一個中間件。
如果手動在默認打開的路徑后加上 output.publicPath 的內容:
果然,它又行了。
第五步:總結
嗐,你看,這就是源碼分析的過程,繁瑣但不復雜,簡直人人都能成為技術大牛啊。回顧一下代碼的流程:
- webpack-dev-server 啟動后會調用自動打開瀏覽器訪問默認路徑 http://localhost:9000
- 此時 webpack-dev-server 接收到默認路徑請求,沿著 express 邏輯逐步走到 webpack-dev-middleware 中間件中
- webpack-dev-middleware 中間件內部呢,又繼續調用 webpack-dev-middleware/lib/util.js 文件的 getFilenameFromUrl 方法
- getFilenameFromUrl 內部判斷 url.indexOf(publicPath)
- 若 getFilenameFromUrl 返回 false 則 webpack-dev-middleware 直接調用 next ,流程進入下一個中間件 express.static
- express.static 嘗試讀取 http://localhost:9000 對應的資源文件,發現文件不存在,流程繼續進入最后一個中間件 serveIndex
- serveIndex 返回產物目錄結構界面,不符合開發者預期
歸根結底,這里面的問題:
- Webpack 官網關于 output.publicPath 的介紹只說了會影響 bundle 產物路徑,沒說會影響主頁面的索引路徑,開發者表示很 confuse 咯
- webpack-dev-server 啟動后,自動打開頁面時沒有在鏈接后面自動追加 output.publicPath 值導致默認打開的路徑與真正的 index 首頁不一致,而且還沒返回 「404」 一類通用的錯誤提示,取而代之以一個不明所以的「文件列表頁」,開發者很難迅速 get 到問題到底出在哪
到這里就把問題從表象,到原理,到最最根本的問題所在都挖出來了,以后可以跟其他同學說:
開發階段,盡量避免配置 output.publicPath 項,否則會有驚喜哦~~