大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 開發 DocFlow。這是一個面向 AI 場景的協同文件平台,整合了基於
Tiptap的富文本編輯、NestJS後端服務、即時協作與智慧化工作流程等核心模組。在這個專案的持續打磨過程中,我累積了不少實戰經驗,不只是
Tiptap的深度客製化、編輯器效能優化與協同方案設計,也包括前端工程化建置、React 原始碼理解以及複雜專案架構實作。如果你對 AI 全端開發、文件編輯器、前端工程化或者 React 原始碼相關內容有興趣,歡迎加我的微信
yunmz777一起交流。覺得專案不錯也歡迎給 DocFlow 點個 star ⭐

完整的前端監控平台通常分成三塊:採集與上報、整理與儲存、展示與分析。本文只講第一塊,從 0 搭一個可運行的埋點 SDK,並把指標採集方式對齊到當前瀏覽器與 Core Web Vitals 的常見做法。
名字會影響記憶與傳播。這裡把 SDK 叫做「四維」,英文 four-dimension,簡寫 FD,寓意盡量用上帝視角看清頁面裡發生的事。下文用 TypeScript 寫示例,便於類型同時當文件。
自研採集層還要提前想好幾條邊界:是否採集可能含個人資訊的欄位、是否對錯誤堆疊與 URL 做脫敏、是否在低端裝置做抽樣。這些決定往往比多寫一個 observer 更影響能不能上線。
採集端可以拆成四件事:配置、快取與上報策略、各類 observer 與事件鉤子、統一入口類。資料流與模組邊界可以對照下圖來記,和下面 Mermaid 圖表達的是同一條主線。
如下圖所示。

從頁面事件到記憶體隊列,再到空閒或離開時發往後端的一整條鏈路。

業務端只需要改上報位址、應用識別等。建議配置物件可合併覆蓋,避免散落魔法字串。可預留 release、environment 欄位,方便與後端版本分群對齊。userId 若涉及合規,建議只傳 hash 後的業務 id,或預設不傳,由登入域自行下發自洽識別。
在 config.ts 中集中維護預設值,並導出 setConfig,便於在業務入口覆蓋:
export interface MonitorConfig {
reportUrl: string;
appId: string;
userId?: string;
projectName?: string;
release?: string;
environment?: "development" | "staging" | "production";
sampleRate?: number;
}
const config: MonitorConfig = {
reportUrl: "http://localhost:8000/report",
appId: "fd-example",
projectName: "fd-example",
environment: "development",
sampleRate: 1,
};
export function setConfig(partial: Partial<MonitorConfig>): void {
Object.assign(config, partial);
}
export function getConfig(): Readonly<MonitorConfig> {
return config;
}
FourDimension 負責在建構時拉起各模組。初始化不要依賴建構參數時,可以保持無參構造,只在 init 裡註冊監聽,避免重複呼叫時重複掛鉤子。
import { initPerformance } from "./performance";
import { initBehavior } from "./behavior";
import { initError } from "./error";
export class FourDimension {
private inited = false;
init(): void {
if (this.inited) return;
this.inited = true;
initPerformance();
initError();
initBehavior();
}
}
業務裡建議非同步載入 SDK 腳本,初始化時 new FourDimension().init() 即可。若腳本可能被多次執行,務必保留類似 inited 的冪等守衛,否則 fetch 會被包一層又一層。
navigator.sendBeacon 適合監控:非同步、不搶主線程、在頁面卸載時仍有機會送出。注意它送的是 POST,適合帶 Blob 指定 Content-Type,而不是假設後端只收 GET 查詢串。
限制也要心裡有數:無回應體、舊環境可能不存在、單次 payload 有實際上限(常見討論量級在數十 KB,宜壓縮 body 體積)。實務上常見優先順序是 sendBeacon 優先,其次 1x1 圖片 GET(資料需壓縮且控制長度),再次帶 keepalive: true 的 fetch 或 XMLHttpRequest。sendBeacon 回傳 false 說明瀏覽器拒絕排隊,應立刻換通道。
下面封裝一個帶降級的 sendReport。sendBeacon 分支用 Blob 傳 JSON,圖片分支再把資料塞進查詢參數(注意瀏覽器對 URL 長度的限制)。
export function isSupportSendBeacon(): boolean {
return (
typeof navigator !== "undefined" &&
typeof navigator.sendBeacon === "function"
);
}
export function reportImage(url: string, payload: unknown): void {
const qs = encodeURIComponent(JSON.stringify(payload));
const img = new Image();
img.src = `${url}?reportData=${qs}`;
}
export function reportWithXhr(url: string, body: string): void {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
xhr.send(body);
}
export function sendReport(url: string, body: string): void {
if (isSupportSendBeacon()) {
const blob = new Blob([body], { type: "application/json" });
const ok = navigator.sendBeacon(url, blob);
if (ok) return;
}
reportImage(url, JSON.parse(body) as unknown);
}
真實專案裡可以在 sendBeacon 回傳 false 時再嘗試 XHR,把失敗樣本寫入 sessionStorage 下次補發。接收端要核實:閘道是否允許 Content-Type: application/json 的 POST,是否對 OPTIONS 預檢放行,否則 beacon 在跨域情境會靜默失敗,需在 Network 面板核對狀態碼。
上報降級順序若畫成一張小抄,方便和運維對口徑。
如下圖所示。

三種通道的優先順序與跨域核對點。
目標是對主線程影響盡量小。常見組合是:
離開頁面時優先依賴 pagehide 與 visibilitychange,比單純 beforeunload 更穩,尤其在行動端背景化場景。visibilitychange 在分頁隱藏時就能先 flush 一輪,pagehide 在真正離開時再做最後一跳。兩個事件都可能觸發 flush 時,要麼在 flushQueue 內做「空隊列直接返回」,要麼加發送中鎖,避免重複上報同一批。
從 bfcache 恢復的頁面會再走 pageshow,persisted 為 true 時會話可能延續,停留時長統計要把可見時間分段累加,不能假設一次進頁到一次離開。
type ReportPayload = Record<string, unknown>;
const queue: ReportPayload[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;
export function enqueue(payload: ReportPayload): void {
queue.push(payload);
}
export function flushQueue(reportUrl: string, immediate = false): void {
if (!queue.length) return;
const batch = queue.splice(0, queue.length);
const body = JSON.stringify({ batch });
if (immediate) {
sendReport(reportUrl, body);
return;
}
const run = () => sendReport(reportUrl, body);
if (typeof requestIdleCallback === "function") {
requestIdleCallback(run, { timeout: 3000 });
} else {
setTimeout(run, 0);
}
}
export function scheduleFlush(reportUrl: string, delayMs = 2000): void {
if (flushTimer) clearTimeout(flushTimer);
flushTimer = setTimeout(() => {
flushTimer = null;
flushQueue(reportUrl, false);
}, delayMs);
}
export function bindLifecycleFlush(reportUrl: string): void {
const onHide = () => {
if (document.visibilityState === "hidden") {
flushQueue(reportUrl, true);
}
};
window.addEventListener("pagehide", () => flushQueue(reportUrl, true));
document.addEventListener("visibilitychange", onHide);
}
getCache 若要對呼叫方回傳快照,需要深拷貝避免外部改陣列。深拷貝實作注意處理循環引用以外的普通 JSON 友好結構即可。
PerformanceObserver 仍是採集繪製與佈局類指標的主力,buffered: true 讓你晚注入腳本也能拿到已經發生過的條目。導航類指標優先讀 PerformanceNavigationTiming,比自己在事件裡 performance.now() 更貼近瀏覽器統計。
在掛 observer 之前可以用靜態方法偵測當前環境到底支援哪些 entryTypes,避免 observe 直接拋錯。下面是一段可放進工具模組的偵測邏輯。
export function supportedPerfTypes(): string[] {
if (typeof PerformanceObserver !== "function") return [];
return PerformanceObserver.supportedEntryTypes ?? [];
}
export function canObserve(type: string): boolean {
return supportedPerfTypes().includes(type);
}
在 Chrome DevTools 的 Performance 或 Lighthouse 裡跑一遍同頁,把面板裡的 LCP、CLS 與 SDK 打上去的值比對,數量級應一致。若差一個數量級,先查是否重複統計、是否在 iframe 裡採集、是否混用了導航時間與繪製時間。
截至 Google 面向站長的公開說明,Core Web Vitals 核心指標是 LCP、INP、CLS。FID 已被 INP 取代,自研 SDK 仍可同時上報 FID 做歷史對比,但產品解讀應以 INP 為主。
指標含義與推薦採集方式:
三個核心指標與採集入口的關係,適合印在團隊 wiki 首頁當速查圖。
如下圖所示。

LCP、INP、CLS 與對應 observer 類型名稱的對應關係。
下面示例合併監聽 first-paint 與 first-contentful-paint,並在拿到 FCP 後斷開,避免重複回呼。若你希望兩種 paint 都上報,應在兩種都見到後再 disconnect,或乾脆不斷開、由後端按 paintName 去重。
import { enqueue, scheduleFlush } from "./queue";
import { getConfig } from "./config";
function safeObserverSupported(): boolean {
return typeof PerformanceObserver !== "undefined";
}
export function observePaint(): void {
if (!safeObserverSupported()) return;
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (
entry.name !== "first-paint" &&
entry.name !== "first-contentful-paint"
)
continue;
const json = entry.toJSON();
enqueue({
type: "performance",
subType: "paint",
paintName: entry.name,
startTime: json.startTime,
pageURL: location.href,
});
if (entry.name === "first-contentful-paint") {
obs.disconnect();
scheduleFlush(getConfig().reportUrl);
break;
}
}
});
obs.observe({ type: "paint", buffered: true });
}
LCP 在頁面生命週期內可能更新,規範語義是「最後一個回報的 LCP 條目代表當前候選」。簡單實作可以在回呼裡每次都上報最新一條,由後端取同會話最後一次,或在客戶端只保留最大 startTime 的那條再上報。注意 LCP 回呼觸發時 entry.element 可能已被移除,DOM 參考要謹慎,上報 tagName 與資源 URL 即可。
export function observeLcp(): void {
if (!safeObserverSupported()) return;
const obs = new PerformanceObserver((list) => {
const entries = list.getEntries() as PerformanceEntry[];
const last = entries[entries.length - 1] as LargestContentfulPaint &
PerformanceEntry;
const json = last.toJSON();
enqueue({
type: "performance",
subType: "lcp",
startTime: json.startTime,
element: last.element?.tagName,
url: "url" in last ? String((last as { url?: string }).url ?? "") : "",
pageURL: location.href,
});
scheduleFlush(getConfig().reportUrl);
});
obs.observe({ type: "largest-contentful-paint", buffered: true });
}
上面用到 LargestContentfulPaint 時,若專案 lib.dom 較舊,可把 last 標成 PerformanceEntry 並謹慎讀取可選欄位。
CLS 需要過濾使用者操作附近的偏移,避免把有意交互造成的佈局變動算成體驗問題。
export function observeCls(): void {
if (!safeObserverSupported()) return;
let clsScore = 0;
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries() as PerformanceEntry[]) {
const ls = entry as LayoutShift & {
hadRecentInput?: boolean;
value?: number;
};
if (ls.hadRecentInput) continue;
clsScore += ls.value ?? 0;
enqueue({
type: "performance",
subType: "cls",
value: ls.value,
cumulativeLayoutShift: clsScore,
pageURL: location.href,
});
}
scheduleFlush(getConfig().reportUrl);
});
obs.observe({ type: "layout-shift", buffered: true });
}
INP 依賴 type: 'interaction' 的 PerformanceObserver,瀏覽器支援面仍在演進。生產環境若要省心,可直接使用 web-vitals 套件,它會在不支援時降級或給出相容策略。最小接入示意如下,真實專案裡把 console.log 換成 enqueue 即可。
import { onINP } from "web-vitals";
onINP((metric) => {
const v = metric.value;
console.log("INP ms", v);
});
自研最小實作可以封裝為「支援則訂閱,不支援則不上報」,避免把未定義行為寫死進業務。
更穩的做法是讀取 performance.getEntriesByType('navigation')[0],得到 PerformanceNavigationTiming,用相對 fetchStart 或 startTime 的各階段時刻算 DNS、TCP、TTFB、DOM 解析等。欄位含義以 MDN 上的 PerformanceNavigationTiming 為準,換公式前用一次 console.table 把 nav 打出來核對。
export function collectNavigationTiming(): void {
const [nav] = performance.getEntriesByType(
"navigation",
) as PerformanceNavigationTiming[];
if (!nav) return;
enqueue({
type: "performance",
subType: "navigation",
dns: nav.domainLookupEnd - nav.domainLookupStart,
tcp: nav.connectEnd - nav.connectStart,
ttfb: nav.responseStart - nav.requestStart,
domContentLoaded: nav.domContentLoadedEventEnd - nav.fetchStart,
load: nav.loadEventEnd - nav.fetchStart,
pageURL: location.href,
});
scheduleFlush(getConfig().reportUrl);
}
可在 load 事件觸發後再呼叫一次,確保 loadEventEnd 已非 0。單頁應用在用戶端路由切換時不會產生新的 navigation 條目,若要監控「軟導航」,需要結合框架路由鉤子或 Performance API 裡仍在演進的軟導航相關能力單獨設計,不能把 PV 與導航耗時混在一條 navigation 記錄裡硬解釋。
資源條目用 type: 'resource'。注意不要在每個 entry 上都 disconnect,否則只會收到第一條資源。比較合理的是頁面 load 後一次性讀取 performance.getEntriesByType('resource'),或長期觀察但在 disconnect 前處理完整批次。
跨域資源若沒有正確的 Timing-Allow-Origin,多數細粒度時長在瀏覽器裡會被抹成 0,這是安全策略不是 SDK 壞了。核實方式是比對同源靜態資源與 CDN 資源的 transferSize、domainLookupStart 等是否突然全 0。
export function observeResources(): void {
if (!safeObserverSupported()) return;
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries() as PerformanceResourceTiming[]) {
enqueue({
type: "performance",
subType: "resource",
name: entry.name,
initiatorType: entry.initiatorType,
duration: entry.duration,
dns: entry.domainLookupEnd - entry.domainLookupStart,
tcp: entry.connectEnd - entry.connectStart,
ttfb: entry.responseStart - entry.requestStart,
protocol: entry.nextHopProtocol,
transferSize: entry.transferSize,
encodedBodySize: entry.encodedBodySize,
decodedBodySize: entry.decodedBodySize,
pageURL: location.href,
});
}
scheduleFlush(getConfig().reportUrl);
});
obs.observe({ type: "resource", buffered: true });
}
若擔心資源量過大,可在客戶端按域名白名單或按耗時閾值過濾後再入隊。也可按 config.sampleRate 隨機丟棄非錯誤樣本,只保留長尾。
只劫持 XMLHttpRequest 會漏掉現代程式碼裡大量的 fetch。可以同時包裝 window.fetch 與 XMLHttpRequest.prototype。包裝 fetch 時不要假設呼叫方不會 clone Response 去讀體,監控端只讀 status 與 header 即可,避免和消費方搶讀同一個 body 流。
export function patchFetch(): void {
const orig = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const start = performance.now();
const req = input instanceof Request ? input : new Request(input, init);
try {
const res = await orig(req);
const end = performance.now();
enqueue({
type: "performance",
subType: "fetch",
url: req.url,
method: req.method,
status: res.status,
duration: end - start,
pageURL: location.href,
});
scheduleFlush(getConfig().reportUrl);
return res;
} catch (err) {
const end = performance.now();
enqueue({
type: "error",
subType: "fetch",
url: req.url,
method: req.method,
duration: end - start,
message: err instanceof Error ? err.message : String(err),
pageURL: location.href,
});
scheduleFlush(getConfig().reportUrl);
throw err;
}
};
}
XHR 劫持仍可用 open、send 包裝,在 loadend 上打點時間戳,與上文思路一致,此處不重複貼全。
資源錯誤與 JS 執行時錯誤要分開通道。window.addEventListener('error', …, true) 在捕獲階段能拿到 script、link、img 等載入失敗,event.target 指向元素。純 JS 語法與執行時錯誤在同一事件裡 target 往往為空,可配合 window.onerror 或同一監聽裡分支處理。ErrorEvent 上的 message 在跨域腳本且未正確設定 crossorigin 時可能是統一口令,需要和原站 CORS 設定一起核實。
Promise 未處理拒絕用 unhandledrejection。上報體裡盡量帶 reason 的堆疊資訊,字串化時注意大物件。
事件路徑不要用已棄用的 event.path,改用 event.composedPath()。
錯誤從頁面鑽進隊列前,按類型分流,便於後端路由到不同看板。
如下圖所示。

資源、腳本、Promise 三類錯誤進入同一條上報管道前的分流意象。
function elementPath(ev: Event): string[] {
const path = typeof ev.composedPath === "function" ? ev.composedPath() : [];
return path
.filter((n): n is Element => n instanceof Element)
.map((el) => el.tagName);
}
export function initGlobalErrorHandlers(): void {
window.addEventListener(
"error",
(ev) => {
const t = ev.target;
if (
t &&
t instanceof HTMLElement &&
(t instanceof HTMLImageElement ||
t instanceof HTMLScriptElement ||
t instanceof HTMLLinkElement)
) {
const url =
"src" in t && t.src ? t.src : "href" in t && t.href ? t.href : "";
enqueue({
type: "error",
subType: "resource",
url,
tag: t.tagName,
paths: elementPath(ev),
pageURL: location.href,
});
scheduleFlush(getConfig().reportUrl);
return;
}
if (!ev.message) return;
enqueue({
type: "error",
subType: "js",
message: ev.message,
filename: ev.filename,
lineno: ev.lineno,
colno: ev.colno,
stack: ev.error instanceof Error ? ev.error.stack : "",
pageURL: location.href,
});
scheduleFlush(getConfig().reportUrl);
},
true,
);
window.addEventListener("unhandledrejection", (ev) => {
const reason = ev.reason;
enqueue({
type: "error",
subType: "promise",
stack: reason instanceof Error ? reason.stack : String(reason),
pageURL: location.href,
});
scheduleFlush(getConfig().reportUrl);
});
}
若擔心第三方腳本堆疊污染,可在入口做抽樣或域名過濾。生產環境應上傳 source map 到私有桶,由後端按 release 解譯堆疊,而不是把完整檔案路徑暴露給前端函式庫。
PV 在每次路由或首屏進入時打一條,帶上 document.referrer 與本地產生的會話或裝置識別。UV 必須在後端用 cookie、登入 id 或可信指紋聚合,客戶端只能提供匿名 id。單頁應用要在路由變化時手動調一次 reportPv,僅依賴首屏載入會嚴重低估。
停留時長用 visibilitychange 記錄可見累計時間,比只在 beforeunload 減一次更準,尤其是背景分頁與 bfcache 場景。離開頁面時再發一條彙總,欄位裡帶 visibleMs 即可。下面是一段與隊列解耦的計時思路,需與上文的 enqueue、flushQueue、getConfig 同模組配合使用。
import { enqueue, flushQueue } from "./queue";
import { getConfig } from "./config";
let visibleAccum = 0;
let lastVisibleStart = performance.now();
document.addEventListener("visibilitychange", () => {
const now = performance.now();
if (document.visibilityState === "visible") {
lastVisibleStart = now;
} else {
visibleAccum += now - lastVisibleStart;
}
});
window.addEventListener("pagehide", () => {
if (document.visibilityState === "visible") {
visibleAccum += performance.now() - lastVisibleStart;
}
enqueue({
type: "behavior",
subType: "dwell",
visibleMs: Math.round(visibleAccum),
pageURL: location.href,
});
flushQueue(getConfig().reportUrl, true);
});
點擊監聽建議去抖動,避免長按或滑動誤觸暴風上報。座標與 outerHTML 體積要限制長度,防止隊列爆炸。敏感頁面不要上傳完整 outerHTML,可只保留 data- 業務埋點鍵名。
下面用 sessionStorage 存會話 id,首次訪問時用 crypto.randomUUID() 生成。若需相容極舊環境,可再降級到時間戳加長隨機串。
function createSessionId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
let sessionId = sessionStorage.getItem("fd_sid") ?? "";
if (!sessionId) {
sessionId = createSessionId();
sessionStorage.setItem("fd_sid", sessionId);
}
export function reportPv(): void {
enqueue({
type: "behavior",
subType: "pv",
pageURL: location.href,
referrer: document.referrer,
sessionId,
});
scheduleFlush(getConfig().reportUrl);
}
export function reportClickDebounced(delayMs = 500): void {
let timer: ReturnType<typeof setTimeout> | null = null;
window.addEventListener("pointerdown", (ev) => {
if (!(ev.target instanceof Element)) return;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
const el = ev.target as Element;
const r = el.getBoundingClientRect();
enqueue({
type: "behavior",
subType: "click",
tag: el.tagName,
x: r.left,
y: r.top,
paths: elementPath(ev),
pageURL: location.href,
sessionId,
});
scheduleFlush(getConfig().reportUrl);
}, delayMs);
});
}
把下面幾項當成發佈前 checklist,在 Chrome 與一種目標內核(如 Safari 或內建瀏覽器)各測一遍。
| 核對項 | 怎麼核實 | 常見坑 |
|---|---|---|
| sendBeacon 是否到達 | Network 裡看 report 請求體與狀態碼 | 跨域未放行 POST、413 體積過大 |
| LCP 是否合理 | 用 Lighthouse 與 SDK 同頁比對 | iframe、Shadow DOM、元素已移除 |
| 資源耗時是否全 0 | 挑一條 CDN 資源看 responseStart | 缺 Timing-Allow-Origin |
| 軟導航 PV | 手動點路由後看是否產生新 pv 事件 | 只監聽了首次 load |
| 重複 flush | 快速切換分頁看上報條數是否翻倍 | visibility 與 pagehide 未去重 |
把上報做成「隊列 + 空閒 flush + 離開兜底」,用 sendBeacon 携帶 JSON Blob,效能側用 PerformanceObserver 與 PerformanceNavigationTiming 對齊現代指標,並補上 CLS、INP 的採集意識;錯誤側區分資源與腳本並改用 composedPath;行為側把 PV、軟導航與可見停留時間說清楚,就是一個可演進的最小監控採集層。儲存與查詢、告警與大盤屬於下一篇文章。