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