:404/500 與錯誤邊界全解析)
Next.js 項目里的默認錯誤頁面說實話就是那種最標準的“404 Not Found”白底黑字或者說“Application error: a client-side exception has occurred”的提示。如果你只見過本地開發(fā)環(huán)境沒上過線可能永遠注意不到這事。但一旦用戶訪問到某個失效鏈接或者服務(wù)端拋了一個未捕獲異常那個粗糙的默認頁面會瞬間拉低產(chǎn)品質(zhì)感甚至讓用戶懷疑網(wǎng)站是不是掛了。這個項目就是圍繞 Next.js 中自定義錯誤頁面展開的核心覆蓋 App Router 和 Pages Router 兩套體系下的 404 頁面、500 頁面、錯誤邊界組件error.js、global-error.js、not-found.js的實現(xiàn)方式。適合正在維護 Next.js 項目的前端開發(fā)者尤其是產(chǎn)品已經(jīng)上線、開始關(guān)心用戶體驗和可觀測性的團隊。讀完你會知道為什么錯誤頁面不能隨便寫寫以及如何做出一個既能挽留用戶、又能幫你定位問題的錯誤頁體系。1. 項目背景與需求解析為什么錯誤頁面值得單獨做1.1 默認錯誤頁面的“原罪”我見過很多團隊功能頁面精雕細琢但錯誤頁面用的是框架默認模板。不是說默認的不能用而是它有幾個實際痛點第一信息量完全不可控。默認 404 頁面不會告訴用戶“你從哪里來、可以去哪里”用戶除了點擊瀏覽器返回按鈕沒有任何引導(dǎo)。如果這個用戶是第一次進入你的網(wǎng)站大概率直接流失。第二無法傳遞品牌調(diào)性。一個服務(wù)中斷、突然報錯這時候用戶是最焦慮的。如果錯誤頁能延續(xù)網(wǎng)站的語言風(fēng)格和視覺體系焦慮感會明顯降低如果突然換成瀏覽器默認樣式用戶會懷疑“我是不是被帶到了一個陌生網(wǎng)站”。第三排查問題困難。默認 500 頁面只有一個通用文案服務(wù)端異常的具體信息被吞掉了。開發(fā)人員在用戶報障之后只能靠日志系統(tǒng)去反查而一套好的自定義錯誤頁面本身就能配合日志、錯誤上報把發(fā)生異常的上下文信息結(jié)構(gòu)化地記錄下來。這個項目的本質(zhì)需求其實就是三件事把 404 做成有引導(dǎo)的落地頁、把 500 做成可反饋可重試的中轉(zhuǎn)頁、把錯誤信息變成可觀測的數(shù)據(jù)源。1.2 自定義錯誤頁面的四個實際收益從業(yè)務(wù)價值角度看錯誤頁面不是一個“做了就行”的邊緣功能它直接關(guān)聯(lián)四個指標用戶留存給出相似導(dǎo)航、推薦內(nèi)容、搜索入口的 404 頁用戶跳出率會明顯低于只有一個“返回首頁”鏈接的頁面。用戶信任友好的錯誤提示加上明確的“我們已知悉”類文案比冷冰冰的異常堆棧更能維護產(chǎn)品口碑。開發(fā)效率錯誤頁把 digest、statusCode、cause 等字段渲染出來配合詳情內(nèi)容報障溝通成本大幅降低。合規(guī)與安全默認錯誤頁在某些框架下會透露框架版本號、內(nèi)部路徑等敏感信息自定義頁面能完全過濾掉這些細節(jié)。1.3 動手前必須分清的路由模式Next.js 現(xiàn)在有兩套路由體系舊的 Pages Router 和新的 App Router。這也是新手最容易踩的第一個坑。Pages Router 的目錄是pages/自定義錯誤的入口是404.js、500.js、_error.jsApp Router 的目錄是app/自定義錯誤的入口是not-found.js、error.js、global-error.js。兩個體系的文件命名、組件形態(tài)、數(shù)據(jù)獲取方式差異很大如果你混著用很容易出現(xiàn)“我明明寫了 404 頁面為什么不管用”的情況。我的建議是新項目直接用 App Router存量項目如果已經(jīng)在 Pages Router 上穩(wěn)定運行也不必急著遷移先把對應(yīng)體系的錯誤頁補齊才是當務(wù)之急。下面兩個部分我會分別拆解這兩套方案。2. 核心原理拆解Next.js 到底是怎么處理錯誤的2.1 App Router 的“三件套”error.js、not-found.js、global-error.jsApp Router 下錯誤處理遵循“就近捕獲”原則由三個文件配合完成not-found.js處理兩類情況。一類是訪問了不存在的 URL 路徑框架自動渲染另一類是你在服務(wù)端組件或路由處理程序里主動調(diào)用notFound()函數(shù)手動觸發(fā) 404 頁面。這個文件可以是服務(wù)端組件也可以加use client變成客戶端組件比較靈活。error.js處理路由段segment內(nèi)部拋出的運行時錯誤包括服務(wù)端組件里同步拋出的異常、客戶端組件事件處理中的渲染異常。它必須是一個客戶端組件文件頂部必須有use client。它接收兩個 properror和reset。error對象里包含錯誤信息和digest字段一個哈希值用于日志對賬reset是一個函數(shù)調(diào)用后會在不刷新整個頁面的情況下重渲染當前路由段。global-error.js處理根布局root layout里拋出的錯誤以及error.js沒有捕獲到的、向上冒泡到根節(jié)點的錯誤。這個文件比較特殊因為它會替換掉整個根布局所以組件里必須自己包含html和body標簽。三者的捕獲邊界可以這樣理解not-found.js 處理“找不到”error.js 處理“當前段出錯了”global-error.js 處理“連布局都炸了”的終極情況。2.2 錯誤邊界的嵌套邏輯React 本身有 Error Boundary 機制Next.js 在 App Router 里把它做了文件約定層面的封裝。每個路由段都可以有自己的error.js這個組件會包裹住該路由段下面的所有子頁面和子布局。嵌套規(guī)律是最內(nèi)層的 error.js 優(yōu)先捕獲。比如你有app/dashboard/和app/dashboard/analytics/兩個層級在 analytics 頁面的數(shù)據(jù)獲取里拋錯只有 analytics 目錄下的 error.js 會觸發(fā)如果app/dashboard/layout.js本身拋錯那么 dashboard 目錄下的 error.js 才能捕獲。如果一直向上冒泡都沒有對應(yīng)的 error.js最后會到 global-error.js。這個機制很重要它意味著你可以針對不同業(yè)務(wù)模塊做更精細的錯誤提示而不是所有錯誤都走到同一個頁面。2.3 notFound() 與狀態(tài)碼的關(guān)系notFound()是 Next.js 提供的一個服務(wù)端函數(shù)調(diào)用后不僅會渲染not-found.js還會向客戶端發(fā)送真實的 404 狀態(tài)碼。這一點對 SEO 很關(guān)鍵因為搜索引擎爬蟲會依賴 HTTP 狀態(tài)碼判斷頁面是否存在如果你返回 200 但內(nèi)容顯示“沒找到”搜索引擎會認為這是個軟 404長期影響收錄質(zhì)量。另外注意一個細節(jié)如果你在客戶端組件里想觸發(fā) 404不能直接用notFound()它是服務(wù)端函數(shù)需要在服務(wù)端組件、服務(wù)端操作Server Action或路由處理器Route Handler里調(diào)用??蛻舳俗霾坏骄蛼佉粋€非 404 的異?;蛘哂胷edirect()路由到一個專門展示 404 的頁面。2.4 Pages Router 時代的傳統(tǒng)機制Pages Router 自定義錯誤的入口要簡單很多pages/404.js靜態(tài)渲染專門對應(yīng) 404 狀態(tài)碼。pages/500.js靜態(tài)渲染專門對應(yīng) 500 狀態(tài)碼。pages/_error.js動態(tài)錯誤頁可以處理任意狀態(tài)碼的錯誤通過getInitialProps拿到statusCode后自行判斷渲染內(nèi)容。Pages Router 這套方案的問題在于_error.js里的錯誤處理比較粗糙——它能拿到狀態(tài)碼但拿不到詳細的錯誤對象。而且它也不支持錯誤邊界這種細粒度的捕獲方式一個錯誤頁管所有錯誤。這也是為什么 App Router 的“三件套”出來之后我傾向于推薦新項目用它。2.5 兩套體系的完整對照維度App RouterPages Router404 直接頁面app/not-found.jspages/404.js500 直接頁面app/error.js客戶端pages/500.js動態(tài)兜底頁面app/global-error.jspages/_error.js局部錯誤邊界每個路由段一個 error.js不支持主動觸發(fā) 404服務(wù)端調(diào)用notFound()pages/_error.js內(nèi)判斷狀態(tài)碼錯誤對象error、digest、resetstatusCode、err布局修復(fù)支持global-error 可重建 html/body不支持3. 實操過程App Router 下實現(xiàn)一套完整的錯誤頁面體系3.1 第一步設(shè)計信息結(jié)構(gòu)與視覺框架在寫代碼之前我的習(xí)慣是先決定錯誤頁要展示什么信息?;A(chǔ)元素包括狀態(tài)碼、錯誤標題、用戶可讀的說明文字、行動按鈕回首頁、重試、可選的錯誤追蹤 ID。這里有一個取舍問題是否把error.message直接渲染給用戶我建議是永遠不要。錯誤消息本身是給開發(fā)者看的里面可能有數(shù)據(jù)庫連接串、文件路徑等敏感信息直接展示在頁面上不僅暴露內(nèi)部結(jié)構(gòu)用戶也完全看不懂。正確做法是把error.message和digest寫入日志上報系統(tǒng)頁面上只展示一個脫敏的“追蹤編號”比如ERROR-20250512-8f3a91b2用戶反饋時把這個編號發(fā)給你你就能在日志系統(tǒng)里快速定位。3.2 第二步實現(xiàn) not-found.js以app/not-found.tsx為例一個實用的 404 頁面長這樣import Link from next/link; export default function NotFound() { return ( main classNameflex min-h-screen flex-col items-center justify-center gap-6 px-4 text-center p classNametext-6xl font-bold text-slate-300404/p h1 classNametext-2xl font-medium你訪問的頁面已經(jīng)搬遷或不存在/h1 p classNamemax-w-md text-slate-500 你可以回到首頁繼續(xù)瀏覽或者檢查一下網(wǎng)址是否輸入有誤。如果這個問題一直出現(xiàn) 建議把頁面地址反饋給我們的值班同學(xué)。 /p div classNameflex gap-4 Link href/ classNamerounded-md bg-slate-900 px-5 py-2.5 text-white 回到首頁 /Link Link href/search classNamerounded-md border px-5 py-2.5 去搜索 /Link /div /main ); }這個組件會在兩種場景下渲染路由根本不存在或者你在某個頁面里主動調(diào)用了notFound()。如果業(yè)務(wù)上有需要你可以給這個文件加上use client在 404 頁面里做一些客戶端交互。但我個人建議保持它是服務(wù)端組件服務(wù)端渲染更快也能享受流式傳輸?shù)膬?yōu)化。3.3 第三步實現(xiàn)帶重置能力的 error.jsapp/error.tsx必須是客戶端組件代碼結(jié)構(gòu)上要注意三點文件頂端必須有use client、組件簽名接收{(diào) error, reset }、不能包含html或body標簽?zāi)鞘?global-error.js 的職責(zé)。use client; import Link from next/link; import { useEffect } from react; export default function ErrorPage({ error, reset, }: { error: Error { digest?: string }; reset: () void; }) { useEffect(() { // 這里把錯誤上下文發(fā)送到監(jiān)控平臺 console.error(路由段渲染異常, { message: error.message, digest: error.digest, path: window.location.pathname, time: new Date().toISOString(), }); }, [error]); return ( main classNameflex min-h-screen flex-col items-center justify-center gap-6 px-4 text-center p classNametext-6xl font-bold text-slate-300500/p h1 classNametext-2xl font-medium頁面開小差了/h1 p classNamemax-w-md text-slate-500 我們的服務(wù)短暫遇到了問題稍微等一下再試試。 /p p classNametext-sm text-slate-400追蹤編號{error.digest ?? 未知}/p div classNameflex gap-4 button onClick{reset} classNamerounded-md bg-slate-900 px-5 py-2.5 text-white 重新加載 /button Link href/ classNamerounded-md border px-5 py-2.5 回到首頁 /Link /div /main ); }reset函數(shù)的語義值得展開說它不會重新刷新整個瀏覽器頁面而是讓 React 重新渲染發(fā)生錯誤的那個路由段。這聽起來很美好但有一個坑——如果錯誤來源于某個外部服務(wù)的臨時故障比如接口超時重渲染大概率還是會失敗。所以更好的體驗是onClick里先調(diào)用reset()同時設(shè)置一個標志位如果再次觸發(fā)同一個錯誤就引導(dǎo)用戶刷新整個頁面。3.4 第四步實現(xiàn)兜底的 global-error.jsapp/global-error.tsx是最后一道防線。它捕獲的是根布局app/layout.tsx里的錯誤也就是渲染骨架本身出錯時的情況。因為根布局已經(jīng)崩了這個組件只能自己輸出完整的 HTML 結(jié)構(gòu)use client; export default function GlobalError({ error, reset, }: { error: Error; reset: () void; }) { return ( html langzh-CN body main style{{ minHeight: 100vh, display: flex, flexDirection: column, alignItems: center, justifyContent: center, gap: 16px, fontFamily: system-ui, sans-serif, }} h1服務(wù)暫時不可用/h1 button onClick{reset} style{{ padding: 10px 20px, borderRadius: 8px, border: 1px solid #ccc, background: #fff, cursor: pointer, }} 重試 /button /main /body /html ); }注意兩點根布局里引用的全局樣式比如 Tailwind 的globals.css在 global-error.js 里加載不到因為 root layout 已經(jīng)失效了。所以這個文件里盡量用內(nèi)聯(lián)樣式或者簡單的style標簽別依賴外部樣式表。另外這個文件也接收error和reset但它的reset語義更弱一些我實際測試下來觸發(fā)全局錯誤后reset能成功的情況非常少通常還是走整頁刷新。3.5 第五步處理嵌套路由的局部錯誤在一個中大型項目里你大概率不會只寫一個頂層 error.js而是按模塊拆分。比如訂單中心和用戶中心是兩個完全不同的業(yè)務(wù)域用戶在訂單列表頁遇到網(wǎng)絡(luò)錯誤時希望看到的文案是“訂單數(shù)據(jù)加載失敗”用戶中心則是“賬號信息同步失敗”。做法很簡單在對應(yīng)路由目錄下各自新建一個error.js即可。還要注意一個細節(jié)loading.js和error.js是配套存在的。錯誤如果出現(xiàn)在服務(wù)端數(shù)據(jù)獲取階段Next.js 會先展示loading.js如果在相同路由段定義了然后異步渲染完成后再決定是展示頁面還是觸發(fā)error.js。這個機制能避免用戶面對一個長時間空白屏幕體驗上比直接白屏要好得多。3.6 樣式適配讓錯誤頁融入整體設(shè)計錯誤頁最容易犯的設(shè)計錯誤就是“用力過猛”。我見過不少團隊把動漫插畫、大型動畫塞進 404 頁面結(jié)果因為動畫庫加載失敗反而觸發(fā)二次錯誤。穩(wěn)健的樣式方案包含這幾個原則純靜態(tài)視覺不要依賴 JS 動畫庫、使用全局已有的 CSS 變量和設(shè)計 token、保證高對比度文案、移動端單列布局。如果你在用 Tailwind直接沿用項目里的工具類就行錯誤頁也是常規(guī)模板的一部分不屬于需要特殊隔離的場景。暗色模式適配要特別注意。如果你的站點支持明暗主題錯誤頁里的自定義色值務(wù)必和主題系統(tǒng)打通否則用戶切到暗色模式后再訪問 404 頁面會出現(xiàn)一個刺眼的白色錯誤塊。4. 兼容方案Pages Router 下的自定義錯誤頁實現(xiàn)4.1 簡單的 404 靜態(tài)頁面如果你的項目還在用 Pages Router方案文件結(jié)構(gòu)如下。首先是pages/404.jsimport Link from next/link; export default function Custom404() { return ( div style{{ textAlign: center, padding: 80px 20px }} h1404/h1 p頁面不存在或已被移除。/p Link href/回到首頁/Link /div ); }這個文件在構(gòu)建時就被靜態(tài)化了性能很好。但它的局限在于所有 404 都走同一個頁面無法按模塊區(qū)分。4.2 動態(tài)兜底頁面 _error.jspages/_error.js的作用更像一個動態(tài)分發(fā)器。它會在項目拋出任意未捕獲錯誤時被調(diào)用通過getInitialProps拿到statusCode和err對象function ErrorPage({ statusCode }) { return ( div style{{ textAlign: center, padding: 80px 20px }} h1{statusCode || 客戶端錯誤}/h1 p {statusCode ? 服務(wù)端返回了錯誤狀態(tài)碼 ${statusCode} : 頁面在客戶端渲染時出現(xiàn)了未知問題} /p a href/回到首頁/a /div ); } ErrorPage.getInitialProps async ({ res, err }) { const statusCode res ? res.statusCode : err ? err.statusCode : 404; return { statusCode }; }; export default ErrorPage;這里有個坑Pages Router 會把_error.js用于所有錯誤包括服務(wù)端渲染錯誤和客戶端渲染錯誤??蛻舳藗?cè)的err對象往往信息不全所以getInitialProps里的兜底邏輯必須健壯。另外_error.js本身也會被用于 404 和 500如果你同時定義了404.js和500.js它們會優(yōu)先于_error.js被使用。4.3 Pages Router 與 App Router 的取舍建議從工程實踐角度我對兩套方案的使用推薦是新項目無腦 App Router老項目如果只是簡單靜態(tài)站Pages Router 的404.js500.js完全夠用如果老項目已經(jīng)深度使用getServerSideProps、有復(fù)雜的自定義服務(wù)器邏輯遷移 App Router 的成本需要單獨評估不必為了錯誤頁而強行遷移。但無論哪套方案錯誤頁的“狀態(tài)碼正確性”都是底線。我用一個簡單命令就能檢查線上部署是否正常curl -o /dev/null -w %{http_code} https://你的域名/一個不存在的路徑期望輸出是404如果輸出200說明錯誤頁沒有走框架的 404 邏輯而是被某個中間件、重寫規(guī)則或者自定義服務(wù)器吞掉了這種情況必須排查。5. 常見問題與排查技巧實錄5.1 error.js 沒生效頁面直接白屏這種狀況我遇到好幾次每次都是同一個原因文件頂部忘記寫use client。error.js的本質(zhì)是一個 React Error BoundaryError Boundary 只能存在于客戶端組件體系中。如果你把它寫成服務(wù)端組件Next.js 不會報編譯錯誤但運行時它無法捕獲錯誤最終就會走到 global-error.js。排查方法先看瀏覽器控制臺有沒有報錯再看代理網(wǎng)絡(luò)面板里返回的狀態(tài)碼。如果狀態(tài)碼是 500 但頁面顯示的是 global-error 的內(nèi)容那就是 error.js 所在的層級不對或者根本缺失。5.2 global-error.js 樣式全亂global-error.js會替換根布局所以它自己必須包含html和body標簽。但全局樣式文件globals.css是在根布局里被 import 的根布局崩了樣式自然也就不在了。我的經(jīng)驗是在global-error.js里不要依賴任何外部樣式全部用內(nèi)聯(lián)樣式或者內(nèi)置一個極簡style標簽。雖然代碼看起來沒那么優(yōu)雅但它是最后兜底環(huán)——兜底的東西要求的就是簡單、可靠、不依賴外部資源。5.3 點擊“重新加載”后還是同一個錯誤reset()重新渲染路由段時并不會重置 React 組件的本地狀態(tài)。如果錯誤發(fā)生在某個客戶端組件的useEffect里而這個組件的依賴數(shù)組沒變化那么重渲染后可能立刻再次觸發(fā)相同錯誤。我處理這種問題時會在error.tsx里做一版“失敗計數(shù)”const [retryCount, setRetryCount] useState(0); async function handleReset() { if (retryCount 1) { window.location.reload(); return; } setRetryCount((c) c 1); reset(); }這個思路就是“給重試限次”第一次失敗后按鈕文案改成“刷新頁面”引導(dǎo)用戶整頁重新加載避免在兩個錯誤頁之間反復(fù)橫跳。5.4 404 頁面的 HTTP 狀態(tài)碼不是 404出現(xiàn)這種情況常見于你在app/not-found.js里寫了一個會執(zhí)行跳轉(zhuǎn)的客戶端邏輯或者你的next.config.js里配置了rewrites規(guī)則把不存在的路徑重寫到了某個頁面。另一個常見原因是中間件middleware里對路徑做了兜底處理直接返回頁面內(nèi)容。驗證方法是先臨時注釋掉所有 rewrite 和 middleware 邏輯再測試一個不存在的深層路徑例如/a/b/c/nonexist。如果這時狀態(tài)碼正確變成 404那問題就在重寫規(guī)則或中間件邏輯上。5.5 局部 error.js 一直不觸發(fā)報錯總是跑到頂層如果你的子路由目錄下已經(jīng)有error.js但錯誤還是冒泡到了app/error.js排查順序如下子目錄的error.js是否出現(xiàn)在正確的分段目錄下不是子頁面里的page.jspage 文件自身不能命名為 error。子目錄的page.tsx是否沒有自己執(zhí)行數(shù)據(jù)獲取反而在布局layout.tsx里做異步請求——布局拋錯會冒泡到布局所在段的 error.js而不是頁面所在段的。子目錄的error.js里是否也拋出了異常錯誤邊界自己出錯了會繼續(xù)向上傳播。注意error.js 組件本身如果拋錯會直接跳過同一層次的捕獲向上層冒泡。所以寫 error.js 的時候組件內(nèi)部邏輯要格外簡單不要在里面做網(wǎng)絡(luò)請求、狀態(tài)管理等復(fù)雜操作。5.6 需要后臺管理的“錯誤上報”怎么做錯誤頁只是前端展示層真正的價值在于和日志系統(tǒng)聯(lián)動。在app/error.tsx的useEffect里向監(jiān)控平臺發(fā)送事件時至少包含以下字段error.message原始錯誤、error.digest框架生成的追蹤 ID、pathname當前路徑、renderTime渲染時間戳、source標識來自 error.js 還是 global-error.js。error.digest這個字段非常實用它是 Next.js 服務(wù)端錯誤和客戶端錯誤共用的哈希標識。服務(wù)端日志和前端上報通過 digest 關(guān)聯(lián)用戶報障時只需要把頁面上的“追蹤編號”發(fā)給你你就能在日志平臺里精確搜索到當時完整的錯誤堆棧。6. 后續(xù)可以怎么擴展這套方案我做這一套錯誤頁體系時最后臨時加的幾樣?xùn)|西反而成了最常用的部分。第一個是“健康檢查專用路徑”在平臺做可用性監(jiān)控時走獨立路由判斷整個應(yīng)用的基礎(chǔ)依賴是否可用這個和用戶側(cè)的 404/500 頁面互不干擾但對線上穩(wěn)定性判斷很有幫助。第二個是“錯誤頁 A/B 文案池”不同業(yè)務(wù)狀態(tài)下用戶需要的引導(dǎo)不同。比如大促期間 404 頁面引導(dǎo)到活動主會場平時引導(dǎo)到搜索頁。這個用not-found.js里加一個簡單的服務(wù)端配置讀取就能實現(xiàn)不需要為了文案變化頻繁發(fā)版。第三個是“CSP 兼容性檢查”在做內(nèi)容安全策略時錯誤頁展示的內(nèi)聯(lián)樣式、上報腳本的域名白名單都容易成為漏網(wǎng)之魚。我踩過一次坑錯誤頁上報接口被 CSP 攔截結(jié)果就是頁面正常顯示了但日志里完全沒有錯誤記錄。上線前用瀏覽器控制臺看一眼有沒有 CSP 違規(guī)提示這個動作應(yīng)該寫進發(fā)布清單里。根據(jù)我在實際項目里的經(jīng)驗自定義錯誤頁面這件事最容易被低估但它的性價比其實極高——寫一個頁面用的時間不過半天換來的是用戶流失降低、線上問題定位提速、以及品牌調(diào)性在所有場景下的統(tǒng)一。你如果正在規(guī)劃這個功能我的建議是先做通 App Router 的“三件套”再補上全局錯誤上報日志字段這兩個完成之后這套體系基本就立住了。