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