🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

【錯誤監控】別只做工具人了!手把手帶你寫一個前端錯誤監控 SDK

你是否一直對前端錯誤監控系統的底層原理充滿好奇?
想知道那些「黑科技」是如何攔截報錯、上報數據的嗎?
與其只做工具的使用者,不如深入底層,探尋其背後的實現機制。
本文將從原理角度切入,手把手帶你設計並實現一個輕量級、功能完備的前端錯誤監控 SDK

學習完本文,你將收穫什麼?

通過手寫這個 SDK,你不僅能獲得一個可用的監控工具,更能深入掌握以下核心知識點:

  1. 瀏覽器底層原理:事件冒泡/捕獲機制,以及 onerrorunhandledrejection 等 API 的工作細節。
  2. AOP 面向切面編程:學會如何通過劫持(Hook)原生方法(如 XMLHttpRequestfetch)來實現無感監控。
  3. 高可靠數據上報:掌握 Navigator.sendBeacon 的使用場景,確保在頁面卸載時也能穩定上報數據。
  4. 工程化實踐:從架構設計到 NPM 發布,體驗完整的 SDK 開發全流程。

1. 架構設計

別被「監控系統」這四個字嚇到了。拆解下來,核心邏輯就三步:監聽 -> 收集 -> 上報

在開始編碼之前,我們先梳理一下 SDK 的整體架構。我們需要監控 JS 運行時錯誤網路請求錯誤 以及 資源加載錯誤,並將這些數據統一格式化後上報到服務端。

image.png

專案結構

為了保持代碼的模組化和可維護性,我們採用以下目錄結構:

error-monitor/
├── dist/                # 打包產物
├── src/                 # 原碼目錄
│   ├── index.ts         # 入口文件
│   ├── errorHandler.ts  # JS 錯誤捕獲
│   ├── networkMonitor.ts # 網路請求監控
│   ├── resourceMonitor.ts # 資源加載監控
│   ├── sender.ts        # 上報邏輯
│   └── utils.ts         # 工具函數
├── test/                # 測試靶場
│   ├── server.js        # 本地測試服務
│   └── index.html       # 錯誤觸發頁面
├── package.json         # 專案配置
├── rollup.config.js     # Rollup 打包配置
├── tsconfig.json        # TypeScript 配置
└── README.md

錯誤監控原碼在 src 目錄下,最終使用 rollup 對代碼進行打包,dist 是打包產物;test 目錄下是對打包產物的測試:能否攔截 JS/請求/資源錯誤,能否穩妥上報。現在就從 0 到 1 開幹,做個 mini 版的錯誤監控 SDK。

🚀 瀏覽專案的完整代碼及示例可以點擊這裡 error-monitor,如果對您有幫助歡迎 Star。

2. 核心實現詳解

2.1 SDK 初始化入口 (index.ts)

SDK 的入口主要負責接收配置(如上報地址、專案名稱)並啟動各個監控模組。

// src/index.ts
import { monitorJavaScriptErrors } from './errorHandler';
import { monitorNetworkErrors } from './networkMonitor';
import { monitorResourceErrors } from './resourceMonitor';

interface ErrorMonitorConfig {
  reportUrl: string; // 上報接口地址
  projectName: string; // 專案識別
  environment: string; // 環境 (dev/prod)
}

export const initErrorMonitor = (config: ErrorMonitorConfig) => {
  const { reportUrl, projectName, environment } = config;

  // 啟動三大監控模組
  monitorJavaScriptErrors(reportUrl, projectName, environment);
  monitorNetworkErrors(reportUrl, projectName, environment);
  monitorResourceErrors(reportUrl, projectName, environment);
};

2.2 全局異常捕獲 (errorHandler.ts)

這是錯誤監控的「基本盤」。瀏覽器中的 JavaScript 錯誤主要分為兩類,必須「兵分兩路」進行攔截:

  1. 同步運行時錯誤,這是最經典的錯誤類型(比如 undefined is not a function)。
    我們使用老牌的 window.onerror 進行捕獲。它雖然古老,但依然是獲取錯誤行號、列號和堆棧信息最直接、相容性最好的方式。

  2. 隨著 async/await 的普及,未被 catch 的 Promise 錯誤越來越常見。這部分錯誤 不會 觸發 onerror,需要通過監聽 unhandledrejection 事件來捕獲。

一句話總結

  • onerror 抓同步, unhandledrejection 抓異步;兩條線一起上,漏報率直降。

關鍵原則:不破壞原有邏輯

監控 SDK 的定位永遠是「旁聽者」,絕不能「反客為主」。它不能改變頁面原本的錯誤處理結果、不該屏蔽控制台的報錯輸出、更不該影響其他第三方庫的行為。

所以在實現時,要遵守以下三點:

  1. 優先使用 addEventListener 能用事件監聽就別直接賦值覆蓋。通過 window.addEventListener('unhandledrejection', ...) 可以形成「鏈式處理」,讓你的監控和其他邏輯並存,大家都能收到通知,互不打架。
  2. 劫持必須「有借有還」 如果必須劫持 window.onerror (或者 window.onunhandledrejection),一定要 先保存原有的回調函數。在執行完你的上報邏輯後, 必須 把控制權交還給原回調,並且返回值。如果你隨手返回了 true,控制台的紅字報錯就被你吞掉了,這會讓調試變得非常痛苦。
// src/errorHandler.ts
import { sendErrorData } from './sender';

export const monitorJavaScriptErrors = (
  reportUrl: string,
  projectName: string,
  environment: string
) => {
  // 1. 捕獲 JS 運行時錯誤
  const originalOnError = window.onerror;
  window.onerror = (message, source, lineno, colno, error) => {
    const errorInfo = {
      type: 'JavaScript Error',
      message,
      source,
      lineno,
      colno,
      stack: error ? error.stack : null,
      projectName,
      environment,
      timestamp: new Date().toISOString(),
    };
    sendErrorData(errorInfo, reportUrl);

    // 關鍵點:如果原來有 onerror 處理函數,繼續執行它,避免覆蓋用戶邏輯
    // 這樣做是為了不破壞宿主環境(例如用戶自己寫的或其他 SDK)已有的錯誤處理邏輯
    if (originalOnError) {
      return originalOnError(message, source, lineno, colno, error);
    }
  };

  // 2. 捕獲未處理的 Promise Rejection
  const originalOnUnhandledRejection = window.onunhandledrejection;
  window.onunhandledrejection = (event) => {
    const errorInfo = {
      type: 'Unhandled Promise Rejection',
      message: event.reason?.message || event.reason,
      stack: event.reason?.stack,
      projectName,
      environment,
      timestamp: new Date().toISOString(),
    };
    sendErrorData(errorInfo, reportUrl);

    // 關鍵點:執行原有的 Promise 錯誤處理邏輯
    // 這樣做是為了不破壞宿主環境(例如用戶自己寫的或其他 SDK)已有的錯誤處理邏輯
    if (originalOnUnhandledRejection) {
      return originalOnUnhandledRejection.call(window, event);
    }
  };
};

2.3 網路請求監控 (networkMonitor.ts)

接口監控是監控的難點,因為瀏覽器並沒有提供一個全局的 onNetworkError 事件。

解決方案:AOP(面向切面編程)重寫

簡單來說,就是把原生的方法「包」一層:在請求發出前/響應返回後,插入我們的監控代碼,然後再執行原有的邏輯。這樣業務代碼完全無感知,而我們卻能拿到所有的請求細節。

難點與細節:

  1. Fetch 的特殊性: fetch 在遇到 HTTP 4xx/5xx 錯誤碼時 不會 reject(不會拋出異常),只有在網路斷開或 DNS 解析失敗時才會 reject。因此我們需要手動檢查 response.ok。
  2. 死循環防護:監控 SDK 自身的上報請求( reportUrl )必須被排除,否則「上報失敗」會觸發「新的上報」,導致無限遞歸,瞬間打掛伺服器。
// src/networkMonitor.ts
export const monitorNetworkErrors = (
  reportUrl: string,
  projectName: string,
  environment: string
) => {
  // 1. 劫持 XMLHttpRequest
  const originalXhrOpen = XMLHttpRequest.prototype.open;
  XMLHttpRequest.prototype.open = function (
    method: string,
    url: string | URL,
    ...args: any[]
  ) {
    // 關鍵點:排除上報接口自身的請求,防止死循環
    const urlStr = typeof url === 'string' ? url : String(url);
    if (urlStr.includes(reportUrl)) {
      return originalXhrOpen.apply(this, [method, url, ...args] as any);
    }

    // 監聽 error 事件
    this.addEventListener('error', () => {
      sendErrorData(
        {
          type: 'Network Error',
          message: `Request Failed: ${method} ${url}`,
          projectName,
          environment,
        },
        reportUrl
      );
    });
    return originalXhrOpen.apply(this, [method, url, ...args] as any);
  };

  // 2. 劫持 Fetch
  const originalFetch = window.fetch;
  window.fetch = async (input, init) => {
    // 關鍵點:排除上報接口自身的請求,防止死循環
    const urlStr = (input instanceof Request) ? input.url : String(input);
    if (urlStr.includes(reportUrl)) {
      return originalFetch(input, init);
    }

    try {
      const response = await originalFetch(input, init);
      if (!response.ok) {
        sendErrorData(
          {
            type: 'Fetch Error',
            message: `HTTP ${response.status}: ${response.statusText}`,
            url: input instanceof Request ? input.url : input,
            projectName,
            environment,
          },
          reportUrl
        );
      }
      return response;
    } catch (error) {
      // 網路故障等無法發出請求的情況
      sendErrorData(
        {
          type: 'Fetch Error',
          message: `Fetch Failed: ${input}`,
          projectName,
          environment,
        },
        reportUrl
      );
      throw error;
    }
  };
};

2.4 資源加載監控 (resourceMonitor.ts)

這裡有一個常見的誤區:很多人認為 window.onerror 可以捕獲所有錯誤,但實際上它無法捕獲 資源加載錯誤 (如 img 、 script 、 link 的 404)。

  1. 原因:因為資源加載失敗產生的 error 事件是 不冒泡 的。 window.onerror 機制依賴於事件冒泡到頂層窗口,因此它對資源加載錯誤無能為力。
  2. 解決方案:我們必須利用 addEventListener捕獲階段 (將第三個參數設為 true)。
    雖然錯誤事件不冒泡,但在 捕獲階段(事件從 window 向下傳播到目標元素的過程),我們依然有機會在 window 層級攔截到這些錯誤。

我們需要專門編寫一個模組,通過 window.addEventListener('error', handler, true) 並在回調中通過 event.target 過濾出 IMG 、 SCRIPT 等標籤的錯誤。

// src/resourceMonitor.ts
export const monitorResourceErrors = (
  reportUrl: string,
  projectName: string,
  environment: string
) => {
  // 注意:useCapture 設置為 true,在捕獲階段處理
  window.addEventListener(
    'error',
    (event) => {
      const target = event.target as HTMLElement;
      // 過濾掉 window 自身的 error,只處理資源元素的 error
      if (target && (target.tagName === 'IMG' || target.tagName === 'SCRIPT')) {
        sendErrorData(
          {
            type: 'Resource Load Error',
            message: `Failed to load ${target.tagName}: ${
              target.getAttribute('src') || target.getAttribute('href')
            }`,
            projectName,
            environment,
          },
          reportUrl
        );
      }
    },
    true // 捕獲階段
  );
};

2.5 數據上報 (sender.ts)

收集到錯誤數據後,如何發給後端?這看似簡單,實則暗藏玄机。

痛點:頁面卸載時的「遺言」發不出去

用戶遇到 Bug 的第一反應往往是關閉頁面。如果我們使用普通的 fetchXHR 上報:

  1. 異步請求可能會被取消:頁面關閉時,瀏覽器通常會 cancel 掉所有未完成的請求。
  2. 同步請求會阻塞跳轉:雖然能強行發出去,但會卡住頁面切換,嚴重影響體驗。

救星:Navigator.sendBeacon

sendBeacon 是專門為此場景設計的 API。它有三大優勢:

  1. 可靠:即使頁面卸載,瀏覽器也會在後台保證數據發送成功。
  2. 異步:完全不阻塞頁面關閉或跳轉。
  3. 高效:傳輸少量數據時性能極佳。

因此,我們的上報策略是:優先 sendBeacon,不支持則降級為 fetch

// src/sender.ts
export const sendErrorData = (errorData: Record<string, any>, url: string) => {
  // 補充瀏覽器信息(UserAgent 等)
  const dataToSend = {
    ...errorData,
    userAgent: navigator.userAgent,
    // 還可以添加更多環境信息,如螢幕解析度、當前 URL 等
  };

  // 優先使用 sendBeacon (異步,不阻塞,頁面卸載時仍有效)
  if (navigator.sendBeacon) {
    const blob = new Blob([JSON.stringify(dataToSend)], {
      type: 'application/json',
    });
    navigator.sendBeacon(url, blob);
  } else {
    // 降級使用 fetch
    fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(dataToSend),
    }).catch(console.error);
  }
};

💡 知識擴展:經典的 1x1 GIF 打點

你可能聽說過用 new Image().src = 'http://api.com/report?data=...' 這種方式上報。這在統計 PV/UV 時非常流行,因為它相容性極好且天然跨域。

但在錯誤監控場景下,通常不推薦作為主力方案。

核心原因正是數據量

  1. URL 長度限制:GIF 打點本質是 GET 請求,數據都掛在 URL 上。瀏覽器對 URL 長度有限制(通常 2KB~8KB)。
  2. 堆棧過長:一個完整的報錯堆棧(Stack Trace)動輒幾千字符,很容易就被瀏覽器截斷,導致我們看不到關鍵的報錯信息。

所以,對於體積較大的錯誤數據,走 POST 通道的 sendBeaconfetch 是更穩妥的選擇。

2.6 進階優化:採樣與緩衝,別把伺服器搞崩了

如果線上出現大規模故障,成千上萬的用戶同時上報錯誤,可能會瞬間把監控伺服器打掛(DDoS 既視感)。

這時候我們需要引入兩個機制:

  1. 採樣 (Sampling)

    • 大白話:不要每個錯誤都報。比如只允許 20% 的運氣不好的用戶上報,剩下的忽略。這樣既能發現問題,又能節省 80% 的流量。
    • 實現if (Math.random() > 0.2) return;
  2. 緩衝 (Buffering)

    • 大白話:不要出一條錯就發一個請求,太浪費資源。先把錯誤攢在數組裡,湊夠 10 條或者每隔 5 秒統一發一車。
    • 注意:記得在頁面卸載(關閉)時,把車上剩下的貨強制發出去,別丟了。

3. 工程化構建配置

既然是 SDK,最好的分發方式當然是發布到 NPM。這樣其他專案只需要一行命令就能接入你的前端錯誤監控系統。

這裡我們選擇 Rollup 對代碼進行打包,因為它比 Webpack 更適合打包庫(Library),生成的代碼更簡潔。

3.1 package 配置 (package.json)

package.json 不僅僅是依賴管理,它還定義了你的包如何被外部使用。配置不當會導致用戶引入報錯或無法獲得代碼提示。

{
  "name": "error-monitor-sdk",
  "version": "1.0.0",
  "description": "A lightweight front-end error monitoring SDK",
  "main": "dist/index.cjs.js", // CommonJS 入口
  "module": "dist/index.esm.js", // ESM 入口
  "browser": "dist/index.umd.js", // UMD 入口
  "type": "module",
  "scripts": {
    "build": "rollup -c",
    "dev": "rollup -c -w"
  },
  "keywords": ["error-monitor", "frontend", "sdk"],
  "license": "MIT",
  "files": ["dist"], // 發布時僅包含 dist 目錄
  "devDependencies": {
    "rollup": "^4.9.0",
    "@rollup/plugin-typescript": "^11.1.0",
    "@rollup/plugin-terser": "^0.4.0", // 用於壓縮代碼
    "typescript": "^5.3.0",
    "tslib": "^2.6.0"
  }
}

💡 關鍵字段解讀:

  • name: 包的「身份證號」。在 NPM 全球範圍內必須唯一,發布前記得先去搜一下有沒有重名。
  • 入口文件「三劍客」(決定了別人怎麼引用你的包):
    • main: CommonJS 入口。給 Node.js 環境或老舊構建工具(如 Webpack 4)使用的。
    • module: ESM 入口。給現代構建工具(Vite, Webpack 5)使用的。支持 Tree Shaking(搖樹優化),能減小體積。
    • browser: UMD 入口。給瀏覽器直接通過 <script> 標籤引入使用的(如 CDN)。
  • files: 發布白名單。指定 npm publish 時只上傳哪些文件(這裡我們只傳編譯後的 dist 目錄)。原碼、測試代碼等不需要發上去,以減小包體積。

3.2 TypeScript 配置 (tsconfig.json)

我們需要配置 TypeScript 如何編譯代碼,並生成類型聲明文件(.d.ts),這對使用 TS 的用戶非常友好。

{
  "compilerOptions": {
    "target": "es5", // 編譯成 ES5,兼容舊瀏覽器
    "module": "esnext", // 保留 ES 模塊語法,交給 Rollup 處理
    "declaration": true, // 生成 .d.ts 類型文件 (關鍵!)
    "declarationDir": "./dist", // 類型文件輸出目錄
    "strict": true, // 開啟嚴格模式,代碼更健壯
    "moduleResolution": "node" // 按 Node 方式解析模塊
  },
  "include": ["src/**/*"] // 編譯 src 下的所有文件
}

3.3 Rollup 打包配置 (rollup.config.js)

為了兼容各種使用場景,我們配置 Rollup 輸出三種格式:

  1. ESM (.esm.js): 給現代構建工具(Vite, Webpack)使用,支持 Tree Shaking。
  2. CJS (.cjs.js): 給 Node.js 或舊版工具使用。
  3. UMD (.umd.js): 可以直接在瀏覽器通過 <script> 標籤引入,會掛載全局變量。
import typescript from '@rollup/plugin-typescript';
import terser from '@rollup/plugin-terser';

export default {
  input: 'src/index.ts', // 入口文件
  output: [
    {
      file: 'dist/index.cjs.js',
      format: 'cjs',
      sourcemap: true,
    },
    {
      file: 'dist/index.esm.js',
      format: 'es',
      sourcemap: true,
    },
    {
      file: 'dist/index.umd.js',
      format: 'umd',
      name: 'ErrorMonitor', // <script> 引入時的全局變量名
      sourcemap: true,
      plugins: [terser()], // UMD 格式進行壓縮體積
    },
  ],
  plugins: [
    typescript({
      tsconfig: './tsconfig.json',
      declaration: true,
      declarationDir: 'dist',
    }),
  ],
};

4. 發布到 NPM (保姆級教程)

4.1 準備工作

  1. 註冊帳號:去 npmjs.com 註冊一個帳號(記得驗證郵箱,否則無法發布)。
  2. 檢查包名:在 NPM 搜一下你的 package.json 裡的 name,確保沒有被佔用。如果不幸重名,改個獨特的名字,比如 error-monitor-sdk-vip

4.2 終端操作三步走

打開終端(Terminal),在專案根目錄下操作:

第一步:登錄 NPM

npm login
  • 輸入命令後按回車,瀏覽器會彈出登錄頁面。
  • 或者在終端根據提示輸入用戶名、密碼和郵箱驗證碼。
  • 登錄成功後會顯示 Logged in as <your-username>.
  • 注意:如果你之前切換過淘寶源,發布時必須切回官方源:npm config set registry https://registry.npmjs.org/

第二步:打包代碼

確保 dist 目錄是最新的,不要發布空代碼。

npm run build

第三步:正式發布

npm publish --access public
  • --access public 參數用於確保發布的包是公開的(特別是當包名帶 @ 前綴時)。
  • 看到 + [email protected] 字樣,恭喜你,發布成功!

7141076e-3f21-4ab4-91d3-5f5918624c9b.png

現在,全世界的開發者都可以通過 npm install error-monitor-sdk 來使用你的作品了!

5. 如何使用

SDK 發布後,支持多種引入方式,適配各種開發場景。

方式 1:NPM + ES Modules (推薦)

適用於現代前端專案(Vite, Webpack, Rollup 等)。

# 請將 error-monitor-sdk 替換為你實際發布的包名
npm install error-monitor-sdk

在你的業務代碼入口(如 main.tsapp.js)引入並初始化:

// 請將 error-monitor-sdk 替換為你實際發布的包名
import { initErrorMonitor } from 'error-monitor-sdk';

initErrorMonitor({
  reportUrl: 'http://localhost:3000/error-report',
  projectName: 'MyAwesomeProject',
  environment: 'production',
});

方式 2:NPM + CommonJS

適用於 Node.js 環境或舊版打包工具。

# 請將 error-monitor-sdk 替換為你實際發布的包名
npm install error-monitor-sdk
// 請將 error-monitor-sdk 替換為你實際發布的包名
const { initErrorMonitor } = require('error-monitor-sdk');

initErrorMonitor({
  reportUrl: 'http://localhost:3000/error-report',
  projectName: 'MyAwesomeProject',
  environment: 'production',
});

方式 3:CDN 直接引入

適用於不使用構建工具的傳統專案或簡單的 HTML 頁面。

<!-- 請將 error-monitor-sdk 替換為你實際發布的包名,x.x.x 替換為具體版本號 -->
<script src="https://unpkg.com/error-monitor-sdk/dist/index.umd.js"></script>
<script>
  // UMD 版本會將 SDK 掛載到 window.ErrorMonitor
  ErrorMonitor.initErrorMonitor({
    reportUrl: 'http://localhost:3000/error-report',
    projectName: 'MyAwesomeProject',
    environment: 'production',
  });
</script>

6. 總結與展望

到這裡,我們這個「麻雀雖小,五臟俱全」的錯誤監控 SDK 就算是跑起來了。

回頭看看,幾百行代碼沒白寫,實打實搞定了三件事:

  1. 啥都能抓:JS 報錯、Promise 掛了、接口 500、圖片 404,一個都跑不掉,統統收入囊中。
  2. 死活都能報:用了 Navigator.sendBeacon,哪怕用戶秒關頁面,最後那條「遺言」也能顽強地發給伺服器。
  3. 拿來就能用:打包好了三種格式,還送了個「靶場」頁面,點點按鈕就能看效果,主打一個省心。

不過說實話,這離真正的「企業級」監控還有點距離。

想在生產環境(特別是高流量業務)扛大旗,還得把下面這些坑填了:

  • 別盲猜 Bug:線上代碼都是壓縮的,得搞定 Sourcemap 還原,不然對著 a.b is not a function 只有哭的份。
  • 頁面白了沒:有時候沒報錯但頁面一片白,這種「假死」得靠 白屏檢測 來發現。
  • 到底快不快:光不報錯不夠,還得看 性能指標 (FCP/LCP),監控頁面加載速度。
  • 用戶幹了啥:復現 Bug 全靠猜?不行,得把用戶出事前的點擊、路由跳轉全記下來,來個 行為回溯(案發現場還原)。
  • 別把伺服器搞崩:報錯太多得限流、去重,引入 採樣率,不然監控服務先掛了就尷尬了。

貪多嚼不爛,這次我們先聚焦在最核心的「錯誤監控」閉環。

至於上面那些進階玩法,我們下篇文章接著聊,帶你一步步把這個系統打磨得更完美。

造輪子不是為了重複造,而是為了親手拆開看看裡面的齒輪是怎麼轉的,這才是學習的本質。

希望這篇文章能是你打造專屬監控系統的起點。Happy Coding!


原文出處:https://juejin.cn/post/7580674010837549102


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝17   💬10   ❤️5
420
🥈
我愛JS
📝2   💬8   ❤️4
90
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付