大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于
Tiptap的富文本编辑、NestJS后端服务、实时协作与智能化工作流等核心模块。在这个项目的持续打磨过程中,我积累了不少实战经验,不只是
Tiptap的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信
yunmz777一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐
Taimili 艾米莉 ( 一款专业的 GitHub star 管理和github 加星涨星工具taimili.com )
艾米莉 是一款优雅便捷的 GitHub star 管理和github 加星 涨星工具,基于 PHP & javascript 构建, 能对github star fork follow watch 刷星管理和提升,最适合github 的深度用户 购买
完整的前端监控平台通常分成三块:采集与上报、整理与存储、展示与分析。本文只讲第一块,从 0 搭一个可运行的埋点 SDK,并把指标采集方式对齐到当前浏览器与 Core Web Vitals 的常见做法。
名字会影响记忆和传播。这里把 SDK 叫做"四维",英文 four-dimension,简写 FD,寓意尽量用上帝视角看清页面里发生的事。下文用 TypeScript 写示例,便于类型即文档。
自研采集层还要提前想好几条边界:是否采集可能含个人信息的字段、是否对错误栈与 URL 做脱敏、是否在低端机做采样。这些决定往往比多写一个 observer 更影响能不能上线。
整体结构
采集侧可以拆成四件事:配置、缓存与上报策略、各类 observer 与事件钩子、统一入口类。数据流与模块边界可以对照下图来记,和下面 Mermaid 图表达的是同一条主线。
如下图所示。
从页面事件到内存队列,再到空闲或离开时发往服务端的一整条链路。
配置与入口类
业务侧只需要改上报地址、应用标识等。配置对象建议可合并覆盖,避免散落魔法字符串。可预留 release、environment 字段,方便和后端版本聚类对齐。userId 若涉及合规,建议只传哈希后的业务 id,或默认不传,由登录域自行下发自洽标识。
在 config.ts 中集中维护默认值,并导出 setConfig,便于在业务入口覆盖:
typescript
体验AI代码助手
代码解读
复制代码
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 里注册监听,避免重复调用时重复挂钩子。
typescript
体验AI代码助手
代码解读
复制代码
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 会被包一层又一层。
上报通道 sendBeacon、图片打点与 XHR
navigator.sendBeacon 适合监控:异步、不抢主线程、在页面卸载时仍有机会发出。注意它发的是 POST,适合带 Blob 指定 Content-Type,而不是假设服务端只收 GET 查询串。
限制也要心里有数:无响应体、旧环境可能不存在、单次 payload 有实际上限(常见讨论量级在数十 KB,宜压 body 体积)。实践里常见优先级是 sendBeacon 优先,其次 1x1 图片 GET(数据需压缩且控制长度),再次带 keepalive: true 的 fetch 或 XMLHttpRequest。sendBeacon 返回 false 说明浏览器拒绝排队,应立刻换通道。
下面封装一个带降级的 sendReport。sendBeacon 分支用 Blob 传 JSON,图片分支再把数据塞进查询参数(注意浏览器对 URL 长度的限制)。
typescript
体验AI代码助手
代码解读
复制代码
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 面板核对状态码。
上报降级顺序若画成一张小抄,方便和运维对口径。
如下图所示。
三种通道的优先顺序与跨域核对点。
缓存与上报时机
目标是对主线程影响尽量小。常见组合是:
- 内存里先攒一批,再批量上报
- 用
requestIdleCallback在空闲时 flush,不支持时用setTimeout兜底 - 页面离开时把剩余队列一次性发出
离开页面时优先依赖 pagehide 与 visibilitychange,比单纯 beforeunload 更稳,尤其在移动端后台化场景。visibilitychange 在标签隐藏时就能先 flush 一轮,pagehide 在真正离开时再做最后一跳。两个事件都可能触发 flush 时,要么在 flushQueue 内做"空队列直接返回",要么加发送中锁,避免重复上报同一批。
从 bfcache 恢复的页面会再走 pageshow,persisted 为 true 时会话可能延续,停留时长统计要把可见时间分段累加,不能假设一次进页到一次离开。
typescript
体验AI代码助手
代码解读
复制代码
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 直接抛错。下面是一段可放进工具模块的探测逻辑。
typescript
体验AI代码助手
代码解读
复制代码
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 里采集、是否混用了导航时间与绘制时间。