你是否一直對前端使用者行為監控系統的底層原理充滿好奇?想知道那些“黑科技”是如何攔截點擊、統計 PV(頁面瀏覽量)與 UV(獨立訪客數)、精確計算頁面停留時長的嗎?與其只做工具的使用者,不如深入底層,探尋其背後的實現機制。本文將從原理角度切入,手把手帶你設計並實現一個輕量級、功能完備的使用者行為監控 SDK。
通過手寫這個 SDK,你不僅能獲得一個可用的監控工具,更能深入掌握以下核心知識點:
在動手寫代碼之前,咱們先統一一下“黑話”,搞清楚兩個最基礎的指標:PV 和 UV。
PV (Page View - 頁面瀏覽量) :簡單說就是頁面被打開了多少次。
舉個例子:小明打開了你的网站首頁(PV+1),手抖刷新了一下(PV+1),又點進去看詳細頁(PV+1)。小明一個人就貢獻了 3 個 PV。
核心意義:衡量網站被訪問的頻次,看流量大小(看熱鬧)。
UV (Unique Visitor - 獨立訪客數) :簡單說就是有多少個不同的人來過。
舉個例子:還是小明,他今天瘋狂刷新了你的网站 100 次(PV=100),但他還是小明這一個人,所以 UV 只算 1。
核心意義:衡量網站被多少真實用戶使用了(看人頭)。
別急著寫代碼,先看圖! 👇下面這張流程圖清晰地展示了數據在 SDK 內部是如何流轉的:

如圖所示,整個流程主要分為三步走:
localStorage,判斷是新客還是回頭客(UV 校驗)。搞懂了數據是怎麼“流”的之後,接下來我們得明確一下源頭到底要“抓”什麼。
簡單來說,這個 SDK 就是你安插在頁面里的偵察兵,主要負責收集這 4 類情報:
針對上面這些目標,我們逐個擊破:
抓 PV(頁面瀏覽量):這事兒分兩頭堵。
window.load,加載完就報。history.pushState 和 replaceState,再監聽 popstate,路由一變,PV 立馬 +1。注:SPA和MPA區別:
window.load。window.load。抓 UV(獨立訪客數):在瀏覽器本地 (localStorage) 盖个章。如果發現今天已經蓋過章了,就不重複上報了。
抓使用者點擊:利用 事件委託 技術,在最外層 (document) 裝個竊聽器。不管你點了裡面的哪個元素,最終都會被我捕獲。
抓頁面停留時長:這個也得分頭行動。
beforeunload(頁面要關了)和 visibilitychange(頁面隱藏了),一旦觸發就計算時間。pushState/popstate),除了報 PV,還得把上一頁的停留時間給結清了。為了保持代碼的模組化和可維護性,我們採用以下目錄結構:
behavior-monitor/
├── dist/ # 打包產物
├── src/ # 源碼目錄
│ ├── index.ts # 入口文件
│ ├── tracker.ts # 行為採集邏輯(PV/Click/Dwell)
│ ├── storage.ts # 本地存儲與 ID 管理
│ └── sender.ts # 上報邏輯
├── test/ # 測試靶場
│ ├── server.js # 本地測試服務
│ └── index.html # 行為觸發頁面
├── package.json # 專案配置
├── rollup.config.js # Rollup 打包配置
├── tsconfig.json # TypeScript 配置
└── README.md
行為監控源碼在 src目錄下 ,最終使用rollup對代碼進行打包,dist是打包產物 ; test目錄下是對打包產物的測試。現在就從 0 到 1 開幹,做個mini版的使用者行為監控 SDK。
🚀 瀏覽專案的完整代碼及示例可以點擊這裡 user-behavior-monitor ,如果對您有幫助歡迎Star。
入口文件負責對外暴露初始化方法,串聯各個模塊。在這裡我們進行 UV 的初步檢查。
import { trackUserBehavior } from './tracker';
import { getUserID, isUVRecorded, setUVRecorded } from './storage';
import { sendBehaviorData } from './sender';
export interface InitOptions {
projectName: string;
reportUrl: string;
}
export const initUserBehaviorMonitor = (options: InitOptions) => {
const { projectName, reportUrl } = options;
const userId = getUserID();
// UV 邏輯:如果本地未記錄,則上報 UV 並標記
if (!isUVRecorded()) {
sendBehaviorData({
behavior: 'uv',
userId,
projectName,
timestamp: new Date().toISOString()
}, reportUrl);
setUVRecorded();
}
// 啟動行為追蹤
trackUserBehavior(projectName, reportUrl);
};
這也是 SDK 最核心的部分。為了方便理解,我們將功能拆分為四個具體的任務模塊:
trackUserBehavior 是總指揮,它負責啟動所有的監控任務:
export const trackUserBehavior = (projectName: string, reportUrl: string) => {
// 1. 點擊監控:通過事件委派監聽使用者的點擊操作
trackClicks(projectName, reportUrl);
// 2. MPA(傳統頁面) PV 監控:監聽頁面首次加載
trackMpaPageView(projectName, reportUrl);
// 3. 停留時長監控:在頁面關閉或隱藏時,計算並上報時長
trackPageDwellTime(projectName, reportUrl);
// 4. SPA 路由監控:專門處理單頁應用的路由跳轉
trackSpaBehavior(projectName, reportUrl);
};
這樣拆分後,職責非常清晰:
trackClicks: 負責監控點擊操作。trackMpaPageView: 只管首次打開網頁的那一次 PV。trackSpaBehavior: 負責處理後續的路由跳轉。trackPageDwellTime: 兜底處理所有非路由跳轉引起的頁面關閉。對於傳統的 MPA 網站,我們只需要監聽 window.load 事件。不僅要記錄“PV + 1”,還要記錄 document.referrer,告訴伺服器用戶是從哪裡跳過來的(比如從百度搜索進入)。
const trackMpaPageView = (projectName: string, reportUrl: string) => {
window.addEventListener('load', () => {
// 獲取用戶id
const userId = getUserID();
// 增加 PV 計數 => (曝光+1)
const pv = incrementPV();
// 發送 PV 數據到服務器
sendBehaviorData({
behavior: 'pv',
userId,
projectName,
timestamp: new Date().toISOString(),
pageUrl: window.location.href,
referrer: document.referrer || '', // 記錄來源
pv,
}, reportUrl);
...
});
};
SPA(單頁應用)的特點是頁面跳轉不刷新。
所以,我們需要主動監聽路由變化並手動處理數據的上報與傳遞。
在 SPA(如 Vue/React)中,路由跳轉主要有三種方式,導致我們需要不同的監聽手段:
代碼跳轉(如 router.push 或者 router.replace)
history.pushState 或 history.replaceState。pushState 和 replaceState 這兩個原生方法劫持(重寫),在它幹活之前,先插播一段我們的上報邏輯。瀏覽器後退/前進
pushState 了,而是觸發 popstate 事件了。popstate 事件。Hash 模式 (#)
# 變了。hashchange 事件。關鍵技巧:Referrer 接力
在 SPA 內部跳轉時,瀏覽器不會更新 document.referrer。我們需要手動維護一個 lastPageUrl 變量,把“上一個頁面的 URL”傳給“下一個頁面”,這樣才能串聯起完整的使用者訪問路徑。
const trackSpaBehavior = (projectName: string, reportUrl: string) => {
const handleRouteChange = () => {
// 1. 防抖校驗:如果 URL 沒變(比如 hashchange 和 popstate 同時觸發),直接退出
if (window.location.href === lastPageUrl) return;
// 2. 結算上一頁:上報前一個頁面的停留時間
reportDwellTime(projectName, reportUrl);
// 3. 記錄當前 URL 為 referrer (在更新 lastPageUrl 之前!)
const referrer = lastPageUrl;
// 4. 更新狀態:保存當前 URL,為下一次跳轉做準備
pageLoadTime = Date.now();
lastPageUrl = window.location.href;
// 5. 記錄新頁面:上報 PV
const userId = getUserID();
const pv = incrementPV();
sendBehaviorData({
behavior: 'pv',
userId,
projectName,
timestamp: new Date().toISOString(),
pageUrl: window.location.href,
referrer: referrer, // 這裡的 referrer 是跳轉前的頁面 URL
pv,
}, reportUrl);
};
// 1. 監聽 Hash 和瀏覽器後退/前進
window.addEventListener('hashchange', handleRouteChange);
window.addEventListener('popstate', handleRouteChange);
// 2. 劫持 History API (解決 pushState/replaceState 不觸發事件的問題)
const originalPush = history.pushState;
const originalReplace = history.replaceState;
// 路由跳轉,劫持 pushState
history.pushState = function (...args: Parameters<typeof history.pushState>) {
originalPush.apply(this, args);
handleRouteChange();
};
// 路由跳轉,劫持 replaceState
history.replaceState = function (...args: Parameters<typeof history.replaceState>) {
originalReplace.apply(this, args);
handleRouteChange();
};
};
正如前面所說,抓取時長要分頭行動:
第一步:通用招數(處理關閉/隱藏)
使用者直接關閉頁面或切換到後台時,觸發結算:
const trackPageDwellTime = (projectName: string, reportUrl: string) => {
// 1. 頁面關閉/刷新時
window.addEventListener('beforeunload', () => {
reportDwellTime(projectName, reportUrl);
});
// 2. 兼容移動端(部分移動端不觸發 beforeunload,只觸發 pagehide)
window.addEventListener('pagehide', () => {
reportDwellTime(projectName, reportUrl);
});
// 3. 頁面隱藏/切後台時
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
reportDwellTime(projectName, reportUrl);
}
});
};
第二步:SPA 特攻(處理路由切換)
這部分邏輯其實已經包含在上面的 (3) SPA 路由監聽 里了。在 handleRouteChange 函數中,我們在跳轉前第一件事就是調用 reportDwellTime(),把上一頁的時間結清。
// 回顧一下 trackSpaBehavior 裡的邏輯
const handleRouteChange = () => {
// 1. 路由要變了?先結帳!上報停留時長
reportDwellTime(projectName, reportUrl);
// ...
};
第三步:停留時長防抖 (避免重複上報)
痛點:使用者關閉頁面時,瀏覽器可能會同時觸發 beforeunload、pagehide 等多個事件。如果不處理,可能會導致同一段停留時間被重複上報。
解決辦法:引入一個標記變量 lastDwellReportedForLoadTime。只要當前時間段已經上報過一次,就直接跳過,不再重複處理。
// 記錄上一次上報停留時間的時間戳
let lastDwellReportedForLoadTime: number | null = null;
const reportDwellTime = (projectName: string, reportUrl: string) => {
// 防抖:如果當前加載時間段已經上報過,直接跳過
if (lastDwellReportedForLoadTime === pageLoadTime) return;
// ... 計算並上報 ...
// 標記已上報
lastDwellReportedForLoadTime = pageLoadTime;
};
如果給頁面上每個按鈕都單獨綁定事件,性能會很差。更高效的做法是利用事件冒泡:只在最外層的 document 上綁定一個監聽器。不管使用者點了哪個按鈕,事件最終都會冒泡到 document,我們在這裡統一攔截處理。
const trackClicks = (projectName: string, reportUrl: string) => {
document.addEventListener('click', (event) => {
// 只關心那些帶 data-track-click 屬性的元素
const target = event.target as HTMLElement;
if (target && target.dataset.trackClick) {
// ... 上報 ...
}
});
};
使用方式:在 HTML 元素上添加屬性即可自動采集。
<button data-track-click="buy_button">購買</button>
這一層主要充當 SDK 的記性。它得清楚地記得:這個用戶是誰?今天來了幾次?今天有沒有報到過?
為了保證刷新頁面也不會“失憶”,我們利用瀏覽器的 localStorage 來實現持久化存儲。
localStorage。你是誰?(獲取 UserID)
邏輯:先去 localStorage 翻翻有沒有身份證(USER_ID)。
/**
* @description: 獲取用戶ID
* @return {string} 用戶ID
*/
export const getUserID = (): string => {
let userId = localStorage.getItem(USER_ID_KEY);
if (!userId) {
// 給他發個新身份證
userId = generateUUID();
// 存起來,下次就認識了
localStorage.setItem(USER_ID_KEY, userId);
}
return userId;
};
/**
* @description: 生成唯一標識符
* 簡單來說,這就是用來生成一個獨一無二的字符串 ID。
* 它通過隨機替換模板中的字符來保證唯一性,就像給每個人發一個不重複的號碼牌。
* @return {string} 唯一標識符
*/
const generateUUID = (): string => {
return 'xxxx-xxxx-4xxx-yxxx-xxxx'.replace(/[xy]/g, (char) => {
const random = (Math.random() * 16) | 0;
const value = char === 'x' ? random : (random & 0x3) | 0x8;
return value.toString(16);
});
};
今天來了第幾次?(PV 計數)
邏輯:不能只存一個總數,因為 PV 通常是按天統計的。
技巧:在 Key 裡帶上日期,比如 pv_count_2023-10-01。這樣到了第二天,日期變了,Key 也變了,計數器自動歸零,重新開始。
/* 當天 PV +1 */
export const incrementPV = (): number => {
// 獲取當天的日期
const today = new Date().toISOString().split('T')[0];
const pvData = localStorage.getItem(`${PV_COUNT_KEY}_${today}`);
const newPV = (pvData ? parseInt(pvData, 10) : 0) + 1;
localStorage.setItem(`${PV_COUNT_KEY}_${today}`, newPV.toString());
return newPV;
};
今天記過人頭了嗎?(UV 標記)
邏輯:UV 是按天去重的。如果今天已經上報過這個人的 UV 了,就別再發了,省流量。
實現:在 localStorage 裡存一個標記 uv_record_date = '2023-10-01'。每次初始化時檢查一下,如果存的日期是今天,說明“已閱”,不用再報。
/* 當前版本:存在即認為已記錄 */
export const isUVRecorded = (): boolean => {
const today = new Date().toISOString().split('T')[0];
return localStorage.getItem(UV_STORAGE_KEY) === today;
};
收集到數據後,如何發給後端?這看似簡單,實則暗藏玄机。
用戶看完網頁直接關掉(或者刷新跳轉),這時候瀏覽器會無情地殺掉當前頁面進程裡所有正在跑的異步請求(XHR/Fetch)。結果就是:監控數據還沒發出去,就死在半路上了。
為了確保數據必達,我們採用一套組合拳:
首選 Navigator.sendBeacon:
它是專門為“頁面卸載上報”設計的。
特點:瀏覽器會在後台默默把數據發完,不阻塞頁面關閉,也不會被殺掉。
次選 fetch + keepalive:
如果瀏覽器不支持 Beacon,或者你需要自定義 Header(Beacon 不支持自定義 Header),就用 fetch 並開啟 keepalive: true。
特點:告訴瀏覽器“這個請求很重要,頁面關了也請幫我發完”。
export const sendBehaviorData = (data: Record<string, any>, url: string) => {
// 1. 包裝數據:加上一些公共信息(比如 UserAgent,螢幕解析度等)
const dataToSend = {
...data,
userAgent: navigator.userAgent,
// screenWidth: window.screen.width, // 可選
};
// 2. 優先使用 sendBeacon (最穩,且不阻塞)
// 注意:sendBeacon 不支持自定義 Content-Type,默認是 text/plain
// 這裡用 Blob 強制指定為 application/json
if (navigator.sendBeacon) {
const blob = new Blob([JSON.stringify(dataToSend)], {
type: 'application/json',
});
// sendBeacon 返回 true 表示進入隊列成功
navigator.sendBeacon(url, blob);
return;
}
// 3. 降級方案:使用 fetch + keepalive
// 即使頁面關閉,keepalive 也能保證請求發出
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dataToSend),
keepalive: true, // <--- 關鍵參數!防止頁面關閉時請求被殺
}).catch((err) => {
console.error('上報失敗:', err);
});
};
既然是 SDK,最好的分發方式當然是發佈到 NPM。這樣其他專案只需要一行命令就能接入你的前端錯誤監控系統。這裡我們選擇 Rollup對代碼進行打包,因為它比 Webpack 更適合打包庫(Library),生成的代碼更簡潔。
package.json)package.json 不僅僅是依賴管理,它還定義了你的包如何被外部使用。配置不當會導致用戶引入報錯或無法獲得代碼提示。
{
"name": "behavior-monitor-sdk",
"version": "1.0.0",
"description": "A lightweight front-end behavior 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": ["behavior-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 目錄)。源碼、測試代碼等不需要發上去,以減小包體積。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 下的所有文件
}
rollup.config.js)為了兼容各種使用場景,我們配置 Rollup 輸出三種格式:
.esm.js): 給現代構建工具(Vite, Webpack)使用,支持 Tree Shaking。.cjs.js): 給 Node.js 或舊版工具使用。.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: 'frontendBehaviorMonitor', // <script> 引入時的全局變量名
sourcemap: true,
plugins: [terser()], // UMD 格式進行壓縮體積
},
],
plugins: [
typescript({
tsconfig: './tsconfig.json',
declaration: true,
declarationDir: 'dist',
}),
],
};
package.json 裡的 name,確保沒有被佔用。如果不幸重名,改個獨特的名字,比如 behavior-monitor-sdk-vip。打開終端(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] 字樣,恭喜你,發佈成功!現在,全世界的開發者都可以通過 npm install behavior-monitor-sdk 來使用你的作品了!
SDK 發佈後,支持多種引入方式,適配各種開發場景。
適用於現代前端專案(Vue, React, Vite, Webpack 等)。
# 請將 behavior-monitor-sdk 替換為你實際發佈的包名
npm install behavior-monitor-sdk
在你的業務代碼入口(如 main.ts 或 app.js)引入並初始化:
// 請將 initUserBehaviorMonitor 替換為你實際發佈的包名
import { initUserBehaviorMonitor } from 'behavior-monitor-sdk';
initUserBehaviorMonitor({
projectName: 'MyMallProject', // 專案名稱
reportUrl: 'https://api.yourserver.com/v1/report' // 上報介面地址
});
適用於不使用構建工具的傳統專案或簡單的 HTML 頁面。
<!-- 請將 behavior-monitor-sdk 替換為你實際發佈的包名,x.x.x 替換為具體版本號 -->
<script src="https://unpkg.com/[email protected]/dist/index.umd.js"></script>
<script>
// UMD 版本會將 SDK 掛載到 window.frontendBehaviorMonitor
window.frontendBehaviorMonitor.initUserBehaviorMonitor({
projectName: 'MyMallProject',
reportUrl: 'https://api.yourserver.com/v1/report',
});
</script>
本 SDK 支持自動采集 PV、UV 和停留時長,但點擊事件需要手動標記。
在需要監控點擊的元素上添加 data-track-click 屬性,值為該按鈕的業務標識:
<!-- 比如:監控購買按鈕的點擊 -->
<button data-track-click="buy_now_btn">立即購買</button>
<!-- 比如:監控輪播圖點擊 -->
<div data-track-click="banner_ad_01">...</div>
至此,我們已經親手打造了一個麻雀雖小、五臟俱全的前端行為監控 SDK。回顧這段旅程,我們不僅實現了代碼,更重要的是深入理解了瀏覽器的底層機制:
history API 的劫持原理、sendBeacon 的可靠性優勢以及事件委託的性能價值。當然,這只是個起點。在企業級的生產環境中,你還可以繼續擴展:
Performance API,監控首屏加載時間 (FCP)、最大內容繪製 (LCP) 等性能指標。error 和 unhandledrejection 事件,捕獲 JS 報錯和介面異常。🚀 閱讀:關於錯誤監控的