
刷新 /login 直接 404這個(gè)問(wèn)題在 Vue 和 React 項(xiàng)目里反復(fù)出現(xiàn)。我見(jiàn)過(guò)不少團(tuán)隊(duì)本地開(kāi)發(fā)環(huán)境跑得好好的路由跳轉(zhuǎn)、登錄流程全都正常結(jié)果部署到服務(wù)器之后頁(yè)面一刷新就崩了瀏覽器地址欄里的 /login 直接變成一個(gè) 404 頁(yè)面控制臺(tái)里還寫著 Cannot GET /login。這不是某個(gè)框架的 bug。只要是單頁(yè)應(yīng)用SPA只要前端用了 history 模式的路由就會(huì)遇到這個(gè)問(wèn)題。Vue Router 的 createWebHistory、React Router 的 BrowserRouter都是一個(gè)道理。這篇文章會(huì)把這個(gè)坑從原理講到配置再把 Nginx、Apache、Node.js 等常見(jiàn)部署場(chǎng)景的解法一一列出來(lái)最后聊聊我踩過(guò)的一些邊界問(wèn)題。無(wú)論你現(xiàn)在用的是 Vue 還是 React這篇都適用建議先收藏。1. 先看清真相刷新時(shí)服務(wù)器拿到了什么請(qǐng)求要解決這個(gè)問(wèn)題先得搞清楚單頁(yè)應(yīng)用的路由到底是怎么回事。我們平時(shí)說(shuō)的前端路由實(shí)際上有兩種形態(tài)哈希路由hash和 history 路由。兩者的差異直接決定了會(huì)不會(huì)踩刷新 404這個(gè)坑。1.1 兩種路由模式的本質(zhì)差別哈希路由的 URL 長(zhǎng)這樣https://example.com/#/loginhistory 路由的 URL 長(zhǎng)這樣https://example.com/login。注意哈希路由里真正發(fā)給服務(wù)器的請(qǐng)求永遠(yuǎn)是 https://example.com/因?yàn)?# 后面的內(nèi)容屬于瀏覽器端錨點(diǎn)根本不會(huì)發(fā)送給服務(wù)器。也就是說(shuō)不管用戶在哈希模式下訪問(wèn) #/login 還是 #/dashboard服務(wù)器收到的始終是根路徑 /只要根路徑下有 index.html頁(yè)面就能正常加載怎么刷新都不會(huì) 404。history 路由就不一樣了。它借助 HTML5 的 History API把路由狀態(tài)直接寫到 URL 路徑上/login 就是 /login這串完整路徑會(huì)原封不動(dòng)地發(fā)給服務(wù)器。服務(wù)器一看網(wǎng)站目錄里沒(méi)有 login 這個(gè)文件也不存在 login 這個(gè)目錄只能返回 404。這就是刷新 /login 無(wú)法訪問(wèn)的根源。兩種模式的對(duì)比整理如下表對(duì)比項(xiàng)哈希路由 HashHistory 路由URL 示例/#/login/login路由部分是否發(fā)給服務(wù)器否只發(fā)根路徑是完整路徑刷新頁(yè)面是否需要服務(wù)端配置不需要天然兼容需要必須配置 fallbackURL 美觀度一般帶 #好看標(biāo)準(zhǔn) URLSEO 友好度較差較好仍需配合預(yù)渲染/SSR實(shí)際項(xiàng)目中我絕大多數(shù)情況會(huì)選擇 history 路由因?yàn)?URL 干凈也方便后續(xù)做 SEO。但選 history 的前提就是你必須解決好服務(wù)端回退的問(wèn)題。1.2 “點(diǎn)擊能跳刷新就崩”的完整鏈路很多人會(huì)有個(gè)疑問(wèn)為什么我在頁(yè)面里點(diǎn)鏈接、調(diào) router.push一切都正常唯獨(dú)手動(dòng)刷新或者直接在地址欄輸入 /login 會(huì) 404因?yàn)檫@兩種操作走的根本不是同一條路徑。點(diǎn)擊導(dǎo)航時(shí)前端路由會(huì)攔截這次跳轉(zhuǎn)JS 直接修改瀏覽器的歷史記錄并重新渲染組件整個(gè)過(guò)程壓根沒(méi)發(fā)起新的頁(yè)面請(qǐng)求服務(wù)器自然不參與。而手動(dòng)刷新、直接輸入 URL、或者從別的地方比如郵件、書(shū)簽跳進(jìn)來(lái)時(shí)瀏覽器會(huì)老老實(shí)實(shí)發(fā)一個(gè) GET 請(qǐng)求到服務(wù)器請(qǐng)求的路徑就是你地址欄里的 /login。所以問(wèn)題的本質(zhì)就是單頁(yè)應(yīng)用只有一個(gè)真正的入口文件 index.html但 history 路由在 URL 上造出了一堆虛擬路徑/login、/dashboard、/user/123服務(wù)器并不知道這些虛擬路徑也沒(méi)法用它們?nèi)フ艺鎸?shí)文件。要解決只有一個(gè)思路讓服務(wù)器把這類查無(wú)此文件的請(qǐng)求全部回退到 index.html再由前端路由接管渲染出對(duì)應(yīng)的頁(yè)面。做個(gè)類比index.html 是商店唯一的大門history 路由相當(dāng)于在大門口掛了一堆門牌路徑刷新相當(dāng)于有人直接舉著門牌號(hào)來(lái)問(wèn)路。服務(wù)器如果不認(rèn)識(shí)這些門牌就會(huì)把客人攆走404。我們要做的就是讓服務(wù)器統(tǒng)一回復(fù)不管門牌號(hào)是啥先帶我去唯一的大門。2. 標(biāo)準(zhǔn)解法把未知路徑統(tǒng)一回退到入口 HTML先說(shuō)結(jié)論服務(wù)端只需配置一個(gè)兜底規(guī)則凡是找不到的真實(shí)文件、真實(shí)目錄一律返回 index.html。下面按常見(jiàn)部署環(huán)境挨個(gè)說(shuō)。2.1 Nginxtry_files 是最常用的解法大部分前端項(xiàng)目部署都用 Nginx配置也最簡(jiǎn)單。核心就是 location / 里的 try_files 指令server { listen 80; server_name example.com; root /var/www/dist; index index.html; location / { try_files $uri $uri/ /index.html; } }這段配置的含義是當(dāng)請(qǐng)求進(jìn)來(lái)時(shí)先按當(dāng)前路徑 $uri 找真實(shí)文件找不到就找同名目錄$uri/再找不到就回退到 /index.html?;赝酥蟮?/index.html 其實(shí)是一次內(nèi)部重新定向?yàn)g覽器地址欄里的 URL 不會(huì)變但服務(wù)器返回的內(nèi)容變成 index.html。瀏覽器拿到 HTML 后會(huì)加載里面的 JS然后前端路由根據(jù) URL 路徑自動(dòng)展示 /login 頁(yè)面。對(duì)于后端接口記得單獨(dú)配置代理并且別讓 try_files 把接口請(qǐng)求也吞掉這一點(diǎn)第 3 節(jié)專門講location /api/ { proxy_pass http://127.0.0.1:8080; }只要 /api/ 這個(gè) location 匹配優(yōu)先級(jí)高于 location //api/login 這類請(qǐng)求就會(huì)走反向代理不會(huì)落到 index.html。Nginx 會(huì)優(yōu)先匹配帶 ^~ 或最長(zhǎng)前綴等規(guī)則的 location所以把 location /api/ 放在前面通常就夠了。2.2 Apache 和 Caddy寫法不同思路一致Apache 環(huán)境也常見(jiàn)核心是用 mod_rewrite 模塊做一個(gè)不是真實(shí)文件、不是真實(shí)目錄就重寫到 index.html的規(guī)則IfModule mod_rewrite.c RewriteEngine On RewriteBase / RewriteRule ^index\.html$ - [L] RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule . /index.html [L] /IfModule這里的關(guān)鍵是 RewriteCond 兩個(gè)條件!-f 表示不是真實(shí)文件!-d 表示不是真實(shí)目錄只有同時(shí)滿足才重寫。這樣 /assets/app.js 這種真實(shí)文件仍然能正常返回而 /login 這種虛擬路徑會(huì)被重寫到 index.html。效果和 Nginx 的 try_files 一模一樣只是寫法不同。如果你用的是 Caddy那更簡(jiǎn)單example.com { root * /var/www/dist try_files {path} /index.html file_server }Caddy 的 try_files 語(yǔ)法更直白能匹配到真實(shí)文件就返回文件否則回退到 /index.html。2.3 Node.js 服務(wù)Express 等框架怎么做有些項(xiàng)目的前端頁(yè)面直接由 Node.js 服務(wù)托管比如用 Express 同時(shí)提供 API 和靜態(tài)頁(yè)面。這時(shí)候同樣要做回退否則一樣 404。Express 里最簡(jiǎn)單的寫法是const express require(express); const path require(path); const app express(); // 靜態(tài)資源 app.use(express.static(path.join(__dirname, dist))); // 除靜態(tài)資源外的所有 GET 請(qǐng)求都返回前端入口 app.get(/.*/, (req, res) { res.sendFile(path.join(__dirname, dist, index.html)); });注意這個(gè)通配路由必須放在靜態(tài)資源中間件之后并且寫在 API 路由之后否則會(huì)把 API 請(qǐng)求也一并返回 HTML。如果你用的 Koa思路一致用 koa-static 托管 dist再寫一個(gè)兜底中間件返回 index.html 就好。順便提一句本地開(kāi)發(fā)時(shí)為什么很少遇到這個(gè)坑因?yàn)?Webpack Dev Server、Vite 這類開(kāi)發(fā)服務(wù)器內(nèi)置了 historyApiFallback當(dāng)你訪問(wèn) /login 時(shí)開(kāi)發(fā)服務(wù)器自動(dòng)把請(qǐng)求回退到 index.html。這也是很多人本地正常、上線就 404的原因——差別就在服務(wù)器配置上。2.4 靜態(tài)托管和容器部署也要注意現(xiàn)在不少項(xiàng)目用對(duì)象存儲(chǔ)加 CDN或者各種靜態(tài)托管平臺(tái)。這類平臺(tái)一般會(huì)提供索引文檔和錯(cuò)誤文檔兩個(gè)配置項(xiàng)索引文檔通常填 index.html錯(cuò)誤文檔也填 index.html這樣刷新生效。如果平臺(tái)只支持配置一個(gè) 404 頁(yè)面就把 404 頁(yè)指到 index.html大多數(shù)情況也能繞過(guò)去。如果完全無(wú)法配置自定義規(guī)則那只能退回 hash 路由模式或者換一個(gè)支持自定義跳轉(zhuǎn)規(guī)則的托管方式。Docker 部署的話常見(jiàn)做法是把構(gòu)建產(chǎn)物打進(jìn) Nginx 鏡像然后在鏡像內(nèi)置一份帶 try_files 的 nginx.conf。這里順便提醒一個(gè)坑很多基礎(chǔ)鏡像里沒(méi)有 /etc/nginx/conf.d/default.conf你需要自己把配置文件 COPY 進(jìn)去否則容器里的 Nginx 用的是鏡像自帶配置你的 try_files 根本不會(huì)生效。3. 配置完不等于結(jié)束這些邊界情況必須處理很多人配完 try_files刷新 /login 確實(shí)不 404 了然后就開(kāi)始遇到一堆新問(wèn)題。我把實(shí)戰(zhàn)中常見(jiàn)的邊界情況列一下這些才是真正區(qū)分會(huì)部署和部署好了的分水嶺。3.1 靜態(tài)資源路徑必須用絕對(duì)路徑回退配置生效后服務(wù)器返回的是 index.html但 index.html 里引用的 JS、CSS 路徑同樣決定頁(yè)面能不能正常渲染。如果你的資源是相對(duì)路徑比如 ./assets/app.js那在 /login 這種深層路徑下可能還能解析對(duì)但如果路由嵌套得深比如 /user/123/profile相對(duì)路徑就可能解析出 /user/123/assets/app.js然后資源 404頁(yè)面白屏。最穩(wěn)妥的方式是讓構(gòu)建產(chǎn)物使用絕對(duì)路徑。Vite 里設(shè)置 baseVue CLI 里設(shè)置 publicPathReact 里設(shè)置 homepage 或 PUBLIC_URL最終讓 index.html 里的資源引用以 / 開(kāi)頭// vite.config.js export default defineConfig({ base: /, })// Vue Router 里 history 的 base 也要對(duì)應(yīng) const router createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes, })// React Router 的 basename 同理 BrowserRouter basename/一句話部署到根路徑時(shí)資源和路由的 base 都設(shè)為 /部署到子路徑時(shí)統(tǒng)一改成子路徑前綴。前后端對(duì)不上刷新就會(huì)出現(xiàn)千奇百怪的 404。3.2 不要讓 try_files 把 API 請(qǐng)求吞掉這是我最??吹降膯?wèn)題之一。有人配了 location / { try_files $uri $uri/ /index.html; } 后發(fā)現(xiàn)不僅頁(yè)面刷新好了連接口請(qǐng)求也正常了——返回的是 HTML前端代碼里報(bào)錯(cuò)說(shuō) JSON parse error但后端日志里卻看不到這個(gè)請(qǐng)求。原因就是 try_files 的匹配范圍太寬把 /api/login 這種請(qǐng)求也回退到了 index.html。解決辦法有兩個(gè)一是給接口單獨(dú)配置 location讓接口請(qǐng)求優(yōu)先走代理二是盡量不要用 location / 通配所有路徑而是按實(shí)際需要配置location /api/ { proxy_pass http://backend; } location / { root /var/www/dist; try_files $uri $uri/ /index.html; }Nginx 匹配規(guī)則里帶前綴的 location 優(yōu)先級(jí)高于不帶前綴的 location所以 /api/ 的 location 會(huì)被優(yōu)先命中。這樣接口請(qǐng)求不會(huì)落到前端兜底邏輯里。3.3 子路徑部署根源上是“前端 base 服務(wù)端 location”沒(méi)對(duì)齊如果項(xiàng)目部署在 https://example.com/admin/ 這種子目錄下事情會(huì)復(fù)雜一截。前端路由、靜態(tài)資源、服務(wù)端路徑必須三處保持同步。以 Vite React 為例// vite.config.js子路徑部署時(shí) export default defineConfig({ base: /admin/, })// React Router 加 basename BrowserRouter basename/adminNginx 這邊也要讓 /admin/ 開(kāi)頭的請(qǐng)求落到正確的目錄并回退到子路徑下的 index.htmllocation ^~ /admin/ { alias /var/www/myapp/dist/; try_files $uri $uri/ /admin/index.html; }這里的關(guān)鍵是 alias 后面要寫清楚 dist 的真實(shí)位置同時(shí) try_files 的回退目標(biāo)要寫成子路徑下的 index.html而不是根 index.html。很多子路徑部署問(wèn)題表面看是刷新 404本質(zhì)是這三處路徑不一致導(dǎo)致的。3.4 頁(yè)面能打開(kāi)、刷新卻卡在登錄死循環(huán)有一種隱蔽情況Nginx 配置好了直接訪問(wèn) /login 能打開(kāi)但刷新之后頁(yè)面雖然加載了卻因?yàn)?token 過(guò)期或者 cookie 沒(méi)帶馬上被路由守衛(wèi)重定向到 /login然后又因?yàn)槟撤N原因一直留在 /login看起來(lái)像死循環(huán)。這種情況往往不是路由配置問(wèn)題而是登錄態(tài)的問(wèn)題。檢查點(diǎn)有三個(gè)cookie 的 Path 是否覆蓋了你的子路徑接口請(qǐng)求帶不帶 cookie前端路由守衛(wèi)判斷登錄狀態(tài)的邏輯是否把 /login 當(dāng)成需要登錄的頁(yè)面。調(diào)試時(shí)打開(kāi) DevTools 的 Application 面板看 cookie再打開(kāi) Network 面板看請(qǐng)求頭基本就能定位。4. 常見(jiàn)問(wèn)題速查與排查套路遇到問(wèn)題別慌按照下面這個(gè)速查表逐項(xiàng)排除會(huì)比漫無(wú)目的地改配置高效得多。現(xiàn)象可能原因排查手段處理辦法刷新 /login 直接顯示 404服務(wù)端缺少 fallback 規(guī)則curl -I 看響應(yīng)狀態(tài)碼配置 try_files / RewriteRule刷新后返回 index.html 但白屏靜態(tài)資源 base 配置錯(cuò)誤DevTools Network 看 JS/CSS 是否 404統(tǒng)一設(shè)置絕對(duì) base接口請(qǐng)求返回 HTML前端報(bào) JSON 解析錯(cuò)誤location 覆蓋順序問(wèn)題看 Response 內(nèi)容是不是 HTML單獨(dú)配置 /api 代理 location子路徑部署大量 404base 與 Nginx alias 不一致對(duì)比 index.html 中資源 URL 與請(qǐng)求路徑統(tǒng)一子路徑前綴/login 能打開(kāi)但刷新就重定向回登錄頁(yè)cookie 未攜帶或路徑不對(duì)看請(qǐng)求頭 Cookie 字段調(diào)整 cookie Path / 域名發(fā)布新版本后刷新出現(xiàn)舊頁(yè)面index.html 被瀏覽器或 CDN 緩存看響應(yīng)頭 Cache-Controlindex.html 設(shè)置 no-cache另外給一個(gè)我自己常用的快速定位套路先用 curl 直接看服務(wù)器到底返回了什么。# 看響應(yīng)頭確認(rèn)狀態(tài)碼和 Content-Type curl -I https://example.com/login # 看響應(yīng)內(nèi)容判斷返回的是 HTML 還是 404 文本 curl -s https://example.com/login | head -20如果返回的內(nèi)容是 index.html 的完整 HTML說(shuō)明 fallback 已經(jīng)生效問(wèn)題大概率在前端資源加載如果返回的是 404 頁(yè)或者 Cannot GET /login說(shuō)明服務(wù)端配置還沒(méi)到位。走到這一步問(wèn)題基本能縮小到明確的方向。再提醒一個(gè)容易被忽略的點(diǎn)刷新 /login 和直接訪問(wèn) /login其實(shí)是同一件事。如果你在本地用 npx serve 這類靜態(tài)工具測(cè)試很多靜態(tài)工具默認(rèn)自帶 fallback 行為所以本地測(cè)不出來(lái)一定要在線上環(huán)境驗(yàn)證或者換一個(gè)不帶 fallback 的靜態(tài)服務(wù)試一次。5. 部署 SPA 時(shí)的一些長(zhǎng)期建議最后分享幾個(gè)我自己的習(xí)慣算不上什么高深技術(shù)但確實(shí)能在以后的項(xiàng)目里少踩很多坑。第一如果你選 history 路由就把直接訪問(wèn)任意前端路由必須可用寫進(jìn)發(fā)布檢查清單。每次上線前除了驗(yàn)證首頁(yè)還要專門直接訪問(wèn) /login、/dashboard、/user/123 這類深層路由刷新一次。很多問(wèn)題恰恰是發(fā)布后才暴露的因?yàn)楸镜氐拈_(kāi)發(fā)服務(wù)器默認(rèn)為你做了一切。第二index.html 不要緩存帶內(nèi)容 hash 的靜態(tài)資源可以長(zhǎng)緩存。配置上可以這么區(qū)分/index.html 返回 Cache-Control: no-cache/assets/ 下的文件返回 Cache-Control: max-age31536000。這樣既保證用戶能拿到最新的 HTML又能讓靜態(tài)資源充分利用緩存不會(huì)出現(xiàn)刷新后 HTML 是最新的但引用的還是舊版 JS這種尷尬場(chǎng)景。第三如果項(xiàng)目實(shí)在沒(méi)有條件配置服務(wù)端 fallback比如純靜態(tài)托管且不支持自定義規(guī)則hash 路由是最后的退路。代價(jià)是 URL 里多個(gè) #看著丑一點(diǎn)但對(duì)服務(wù)器零要求。決策時(shí)想清楚優(yōu)先級(jí)功能可用大于 URL 好看。第四盡量把前端路由和后端 API 的路徑前綴劃清界限。比如前端負(fù)責(zé)所有不帶 /api 的路徑后端只認(rèn) /api 開(kāi)頭的請(qǐng)求。這也是為什么很多項(xiàng)目設(shè)計(jì) /api 前綴的原因——不僅是規(guī)范更是為了讓 Nginx 的 location 規(guī)則可以簡(jiǎn)單又可靠。這個(gè)問(wèn)題我在項(xiàng)目里反復(fù)遇到每次解決方案大同小異但細(xì)節(jié)處各有各的坑。希望這篇能把原理和配置一次講全以后再遇到刷新 /login 404你就能直接定位到服務(wù)端 fallback 這一層而不是在路由代碼里折騰半天。