前言
在多分頁的 Web 應用中,跨 Tab 同步狀態是常見需求——例如閒置登出同步、Session 過期通知、T&C 接受後其他 Tab 強制刷新等。瀏覽器原生的 BroadcastChannel API 是最直覺的方案,搭配 VueUse 的 useBroadcastChannel 封裝更是方便。
但在實際開發中,我們踩了一個非常隱晦的坑——同一個 Tab 內建立多個 BroadcastChannel 實例,會互相收到訊息。本文記錄我們從「能用」到「好用」的架構演進過程。
在多產品平台中,每個產品(體育、娛樂城、棋牌等)可能由不同的合作夥伴(Provider)開發,使用的技術棧也各不相同——React、Angular、甚至純 JS。但平台需要在所有產品頁面上維持一致的品牌形象、導航列、會員狀態和法律聲明。
要求每個 Provider 自行實作這些 UI 元件既不現實也難以維護。因此我們設計了 star4partialview — 一個獨立打包的 Vue 3 應用,輸出單一 partialView.js,Provider 只需引入一個 <script> 標籤就能獲得完整的 Header + Footer。
兩個獨立的 JS Runtime 透過 window.postMessage 通訊,實現了一種輕量級的 Micro Frontend 模式。
之前在正式環境遇到一個棘手的問題:使用者回報某支 API 回傳 HTTP 200,瀏覽器卻直接噴出 ERR_CONTENT_DECODING_FAILED。當下排查了很久才發現問題出在 Response Header 中多了一個不該出現的 gzip,特別記錄一下整個排查過程。
打開 DevTools 檢查異常 API 的 Response Headers,可以看到幾個關鍵資訊:
Content-Encoding: gzip — 伺服器宣稱回傳內容經過 gzip 壓縮Transfer-Encoding: chunked — 資料以分段方式傳輸X-Powered-By: ASP.NET — 請求可能直接打到後端 .NET 伺服器,或經過了某層 Proxy 轉發瀏覽器收到 Content-Encoding: gzip 後,會自動嘗試用 gzip 解碼器解壓內容。但實際上這支 API 的 Response Body 是空的(長度為 0),解碼器無法處理一個空的壓縮流,因此直接報出 ERR_CONTENT_DECODING_FAILED。
為什麼 Body 會是空的?根據 Header 中的 X-Powered-By: ASP.NET,可能有兩種原因:
進一步對比同專案中兩支 API 的 Response Headers,差異一目了然:
| 欄位 | 異常 API | 正常 API |
|---|---|---|
| Content-Encoding | gzip | (無) |
| X-Powered-By | ASP.NET | (無) |
| X-Aspnet-Version | 4.0.30319 | (無) |
正常的 API 回傳的 Headers 很乾淨,是經過 Nuxt BFF(Node.js)處理後回傳的;而異常的 API 卻直接暴露了 ASP.NET 的資訊。這代表:
後端伺服器其實沒有噴錯,它正常回傳了資料(只是內容為空陣列或空物件)。真正的問題出在 Nuxt Server(Nitro)端的 API Handler 寫法。
檢查 Server 端程式碼後,發現異常的 API Handler 是這樣寫的:
1 | const response = await api.memberGetMemberPriorityHistories(query); |
這裡 api.memberGetMemberPriorityHistories() 回傳的是一個繼承自原生 Response 的物件。當直接 return response 時,Nitro 引擎會將其視為透傳(Proxy),把後端的所有原始標頭原封不動地轉發給瀏覽器。
整個流程是這樣的:
Content-Encoding: gzip 的壓縮內容return response,Nitro 把後端的原始 Headers(包含 Content-Encoding: gzip)照搬給瀏覽器Content-Encoding: gzip 標頭,嘗試解壓 Body —— 但 Body 早就被 Node.js 解壓過了,已經不是 gzip 格式ERR_CONTENT_DECODING_FAILED簡單來說:gzip 被 Nuxt Server Side 解開了一次,但 gzip 標頭卻還在,瀏覽器又試圖解第二次,自然就失敗了。
這也解釋了為什麼對比表中異常 API 會帶有 X-Powered-By: ASP.NET 和 X-Aspnet-Version — 這些都是後端的原始標頭被透傳出來的結果。
根因找到後,修正方式很明確:不要直接回傳整個 Response 物件,改為只回傳 response.data。 這樣 Nitro 會重新封裝成乾淨的 JSON Response,由 Nuxt 統一處理壓縮,後端的 ASP.NET 標頭就不會外洩。
修改前:
1 | export default defineLogAndAuthHandler(async (event) => { |
修改後:
1 | export default defineLogAndAuthHandler(async (event) => { |
這個修正需要系統性地檢查所有 Server API Handler,只要有直接 return response 的地方都應該改為 return response.data,避免同樣的問題在其他 API 上重演。
這個問題的根因不在後端、也不在前端元件,而是 Nuxt Server(Nitro)的 API Handler 直接透傳了後端 Response 物件。Node.js 在接收後端回應時已經解開了 gzip,但透傳卻把原始的 Content-Encoding: gzip 標頭一併帶給瀏覽器,導致瀏覽器嘗試二次解壓而失敗。
排查這類問題的關鍵:對比正常與異常 API 的 Response Headers。當你發現某支 API 突然多了 X-Powered-By: ASP.NET 和 Content-Encoding: gzip,而其他 API 都沒有,很可能就是 Nitro 把後端的原始標頭透傳出來了。回去檢查 Server API Handler 的回傳值,把 return response 改成 return response.data 就能解決。
在開發後台管理系統時,我們經常會遇到一個問題:每個 API Handler 都需要處理認證驗證、日誌記錄、錯誤捕獲等重複性邏輯。如果在每個檔案中都寫一遍,不僅冗長,還容易遺漏。
本文將介紹如何在 Nuxt 3 的 Server API 中,利用 Higher-Order Function (高階函式) 的概念,打造一個統一的 defineLogAndAuthHandler,讓所有 API 自動擁有以下功能:
在 JavaScript/TypeScript 中,高階函式 (HOF) 是指「接收函式作為參數」或「回傳函式」的函式。我們的設計正是利用這個概念:
export const defineLogAndAuthHandler = <T extends EventHandlerRequest>(
handler: EventHandler<T>
): EventHandler<T> => defineEventHandler<T>(async (event) => {
// 在這裡加入認證、日誌等邏輯
const response = await handler(event); // 執行原本的 Handler
// 在這裡加入回應處理邏輯
return response;
});
這就像是在原本的 API Handler 外面「包一層糖衣」,讓它自動具備額外的能力。
功能拆解
1. Session 認證與過期檢查
// 檢查是否已認證
if (!event.context?.session?.isAuthenticated && !isPublicRoute) {
return createError({
statusCode: 401,
statusText: 'Session Expired',
});
}
// 檢查 Session 是否已過期
const currentTime = new Date().getTime();
const lastLoginTimestamp = event.context?.session?.lastLoginTime;
const maxTimeDiff = (currentTime - lastLoginTimestamp) / 1000;
if (maxTimeDiff >= parseInt(maxExpiryInSeconds, 10)) {
// 清除 Session 並回傳 401
event.context.session.isAuthenticated = false;
return createError({ statusCode: 401, statusText: 'Session Expired' });
}
設計重點:
白名單機制:登入、登出等 API 不需要認證
雙重檢查:不只檢查是否登入,還檢查 Session 是否超時
2. 請求日誌記錄與敏感資料遮蔽
const ignoreLogParams = ['password', 'newPassword', 'oldPassword'];
// 遮蔽敏感欄位
if (typeof body === 'object') {
const filterBody = Object.assign({}, body);
for (const key in filterBody) {
if (ignoreLogParams.includes(key)) {
filterBody[key] = '***mask***';
}
}
logger.log('info', `[POST] ${JSON.stringify(filterBody)}`);
}
設計重點:
自動遮蔽密碼等敏感欄位,避免日誌外洩
支援 GET/POST/PUT/DELETE 不同方法的日誌格式
3. 效能監控
const startTime = Date.now();
const response = await handler(event);
const endTime = Date.now();
const duration = endTime - startTime;
logger.performance.log('info', `[${pathWithoutQueryString}] [${duration}ms]`);
設計重點:
記錄每個 API 的執行時間
方便後續分析效能瓶頸
4. 回應日誌與大型回應處理
const byPassLogUrls = ['/api/general/settings', '/api/promotion/promotions'];
function truncateString(body: string) {
const resultSizeLimit = 1024;
const stringSize = Buffer.byteLength(body);
if (stringSize > resultSizeLimit) {
return `Result size - ${stringSize} bytes`;
}
return body;
}
設計重點:
大型回應只記錄大小,避免日誌檔案爆炸
特定 API 可跳過輸出日誌(如設定檔、大量資料查詢)
5. 統一錯誤處理
catch (error: any) {
console.error('API Error:', error);
logger.error.log('error', `URL: ${event.path}, Message: ${error.message}`);
return createError({
statusCode: error.statusCode || 500,
statusMessage: error.statusMessage || 'Internal Server Error',
message: error.message || 'An unexpected error occurred',
});
}
設計重點:
所有未捕獲的異常都會被統一處理
錯誤資訊會被記錄到日誌中,方便追蹤
使用方式
使用這個封裝後,原本的 API Handler 變得非常簡潔:
// server/api/member/summary.get.ts
import { GetMemberSummary } from '@/bospi/GetMemberSummary';
export default defineLogAndAuthHandler(async (event) => {
const query = getQuery(event);
const api = new GetMemberSummary();
const response = await api.getMemberSummary(query);
return response.data;
});
只需要專注在業務邏輯,認證、日誌、錯誤處理全部自動搞定!
架構圖
┌─────────────────────────────────────────────────────────┐
│ Frontend (Browser) │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ defineLogAndAuthHandler (Wrapper) │
│ ┌─────────────────────────────────────────────────────┐│
│ │ 1. Session 認證檢查 ││
│ │ 2. 請求日誌記錄 (含敏感資料遮蔽) ││
│ │ 3. 效能計時開始 ││
│ └─────────────────────────────────────────────────────┘│
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐│
│ │ 實際的 API Handler (業務邏輯) ││
│ └─────────────────────────────────────────────────────┘│
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐│
│ │ 4. 效能計時結束 ││
│ │ 5. 回應日誌記錄 ││
│ │ 6. 錯誤捕獲與格式化 ││
│ └─────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Backend SPI (ASP.NET) │
└─────────────────────────────────────────────────────────┘
這個設計模式其實就是 AOP (Aspect-Oriented Programming) 的體現——將「橫切關注點」(如日誌、認證、錯誤處理)從業務邏輯中抽離出來。