什麼是 Heap snapshot#
Chrome DevTools 的堆分析器顯示您的頁面 JavaScript 對象和相關 DOM 節點的內存分佈
DevTools 的堆快照性能分析會顯示網頁的 JavaScript 對象和相關 DOM 節點中的內存分配情況。
一個常見的疑惑是,Profile 面板中快照結果里的 Comparison(需生成兩個堆快照才會出現)、Dominator(已廢棄)、Containment 和 Summary 四種視圖之間有什麼區別。 四個視圖分別從不同角度分析快照數據:
Comparison 視圖(比較視圖)可以顯示哪些對象已經被垃圾回收正確釋放了,一般在該視圖中比較一次操作前後的內存快照數據。通過檢查空閒內存中的變量增量和引用數來確定內存泄露的存在和原因。
Dominator 視圖用來確認對象已經沒有其他未知的引用,並且垃圾回收可以正常工作。(新版本 Chrome 中,該面板已經去掉,新增了 Statistics(統計信息),統計不同類型數據所佔的內存)
Summary 視圖(摘要視圖)可以按照類型追蹤對象及其內存使用情況,對象會以構造器名分組顯示,主要用於尋找 DOM 內存泄露的場景。
Containment 視圖(控制視圖)可以更清晰的了解到對象的結構,借此可以分析出在全局作用域中對該對象的引用情況(例如 window),可以用來分析閉包,以更低的層次去查看對象情況。
Cypress 是否支持 Chrome DevTools 協議#
通過 Cypress 運行 e2e 測試方式(node)去拍攝運行時的堆快照,那必須要支持 Chrome Debugging 協議(以下簡稱 CDP),從 Cypress 官方文檔或者 Github 上的 issue 不難看出,Cypress 是支持 CDP 的。
圖片來源: https://www.processon.com/embed/5edc4c37e401fd69195b7f23
Github 相關 issue:
https://github.com/cypress-io/cypress/issues/7942
Cypress 提供了 Cypress.automation("remote:debugger:protocol", {...}
可以滿足一些常用的場景,比如設置瀏覽器的語言:
Cypress.automation("remote:debugger:protocol", {
command: "Emulation.setLocaleOverride",
params: {
locale: "de-AT",
},
})
Cypress 如何拍攝快照#
事實上使用 Cypress.automation
監聽 remote:debugger:protocol
事件沒有具體的類型推導以及相關的快捷封裝等,使用起來會比較有心智負擔,可以結合
Chrome DevTools Protocol 官方文檔輔助去調用命令滿足部分場景。
個人認為可以封裝一個 cdp 插件,並使用 on('task', {...})
監聽,在 e2e 測試文件中可以直接使用 cy.task('takeHeapSnapshot')
方式進行拍攝堆快照。
Cypress 運行一個 node 進程並通過 ws 連接與瀏覽器進行通訊,結合 chrome-remote-interface 庫,在 e2e.setupNodeEvents
中通過監聽 task
並封裝對應的任務即可在 e2e 測試文件中進行調用。
// cypress.config.ts
// ...
e2e: {
// ...
setupNodeEvents(on, config) {
// ...
on('task', {
takeHeapSnapshot: () => {}
}
// ...
}
// ...
}
// ...
// demo.test.ts
cy.task('takeHeapSnapshot');
在日常開發過程,例如 VSCode 中 Launch Chrome 的話,都需要在一個已知的端口上運行,首先需要配置好 --remote-debugging-port=xxx
(默認 9222 端口號),這樣才可以使用調試功能。
同樣地,Cypress 測試代碼運行在瀏覽器端,在瀏覽器加載前需要獲取可用的端口號並設置 --remote-debugging-port
,也為後面初始化 CDP 客戶端(基於 chrome-remote-interface 庫)提供有效的端口。
// cdpPlugin.ts
let port = 0;
const setRdpPortWhenBrowserLaunch = (launchOptionsOrArgs: Cypress.BrowserLaunchOptions) => {
const args = Array.isArray(launchOptionsOrArgs) ? launchOptionsOrArgs : launchOptionsOrArgs.args;
const ensureRdpPort = (args: string[] | (Cypress.BrowserLaunchOptions & any[])) => {
const existing = args.find(arg => arg.slice(0, 23) === '--remote-debugging-port');
if (existing) {
return Number(existing.split('=')[1]);
}
const port = 40000 + Math.round(Math.random() * 25000);
args.push(`--remote-debugging-port=${port}`);
return port;
};
port = ensureRdpPort(args);
console.log('Ensure remote debugging port %d', port);
};
// cypress.config.ts
export default defineConfig({
// ...
e2e: {
setupNodeEvents(on, config) {
const { setRdpPortWhenBrowserLaunch } = cdpPlugin();
on('before:browser:launch', (_, launchOptionsOrArgs) => {
setRdpPortWhenBrowserLaunch(launchOptionsOrArgs);
});
return config;
},
},
// ...
});
接下來初始化 chrome-remote-interface 庫,綁定剛才獲取到的端口號。
// cdpPlugin.ts
import CDP from 'chrome-remote-interface';
let client: CDP.Client | null = null;
// ...
const initCDPClient = async () => {
if (!port) {
throw new Error('Please set the remote debugging port first!');
}
if (!client) {
client = await CDP({
port,
});
}
};
// cypress.config.ts
export default defineConfig({
// ...
e2e: {
setupNodeEvents(on, config) {
const { setRdpPortWhenBrowserLaunch, initCDPClient } = cdpPlugin();
on('before:browser:launch', (_, launchOptionsOrArgs) => {
setRdpPortWhenBrowserLaunch(launchOptionsOrArgs);
});
on('task', {
takeHeapSnapshot: async (opts: TakeHeapSnapshotType) => {
await initCDPClient();
return null;
},
});
return config;
},
},
// ...
});
參照 Chrome DevTools Protocol 官方文檔,找到拍攝堆快照的方式,並且將其以流形式存儲到 .snapshot
文件內(提供文件存儲位置 filePath),方便後續分析內存泄漏。
// cdpPlugin.ts
import CDP from 'chrome-remote-interface';
import fs from 'fs';
export interface TakeHeapSnapshotType {
filePath: string;
beforeTakeCallback?: () => void;
afterTakeCallback?: () => void;
}
let port = 0;
let client: CDP.Client | null = null;
const setRdpPortWhenBrowserLaunch = (launchOptionsOrArgs: Cypress.BrowserLaunchOptions) => {
const args = Array.isArray(launchOptionsOrArgs) ? launchOptionsOrArgs : launchOptionsOrArgs.args;
const ensureRdpPort = (args: string[] | (Cypress.BrowserLaunchOptions & any[])) => {
const existing = args.find(arg => arg.slice(0, 23) === '--remote-debugging-port');
if (existing) {
return Number(existing.split('=')[1]);
}
const port = 40000 + Math.round(Math.random() * 25000);
args.push(`--remote-debugging-port=${port}`);
return port;
};
port = ensureRdpPort(args);
console.log('Ensure remote debugging port %d', port);
};
const initCDPClient = async () => {
if (!port) {
throw new Error('Please set the remote debugging port first!');
}
if (!client) {
client = await CDP({
port,
});
}
};
const takeHeapSnapshot = async (opts: TakeHeapSnapshotType) => {
if (!client) {
throw new Error('Please init the cdp client first!');
}
const { filePath, beforeTakeCallback = null, afterTakeCallback = null } = opts;
if (beforeTakeCallback) {
beforeTakeCallback();
}
const writeStream = fs.createWriteStream(filePath, { encoding: 'utf-8' });
const dataHandler = (data: { chunk: string }) => {
writeStream.write(data.chunk);
};
const progressHander = (data: { done: number; total: number; finished: boolean }) => {
const percent = ((100 * data.done) / data.total) | 0;
console.log(`heap snapshot ${percent}% complete`);
};
client.on('HeapProfiler.addHeapSnapshotChunk', dataHandler);
client.on('HeapProfiler.reportHeapSnapshotProgress', progressHander as SafeAny);
await client.send('HeapProfiler.takeHeapSnapshot', {
reportProgress: true,
captureNumericValue: true,
});
writeStream.end();
if (afterTakeCallback) {
afterTakeCallback();
}
};
export const cdpPlugin = () => {
return {
setRdpPortWhenBrowserLaunch,
initCDPClient,
takeHeapSnapshot,
};
};
// cypress.config.ts
export default defineConfig({
// ...
e2e: {
setupNodeEvents(on, config) {
const { setRdpPortWhenBrowserLaunch, initCDPClient } = cdpPlugin();
on('before:browser:launch', (_, launchOptionsOrArgs) => {
setRdpPortWhenBrowserLaunch(launchOptionsOrArgs);
});
on('task', {
takeHeapSnapshot: async (opts: TakeHeapSnapshotType) => {
await initCDPClient();
await takeHeapSnapshot(opts);
return null;
},
});
return config;
},
},
// ...
});
配置好後在對應的 e2e 測試文件內調用。
// xxx.e2e.ts
describe('test', () => {
it('test', () => {
// baseline
cy.task('takeHeapSnapshot', {
// filePath 為寫入的文件存放地址
filePath: path.join(__dirname, `../heap/s1.heapsnapshot`),
});
cy.contains('xxx').click();
// ...
// target
cy.task('takeHeapSnapshot', {
filePath: path.join(__dirname, `../heap/s2.heapsnapshot`)
});
// back
cy.get('xxx').click();
// final
cy.task('takeHeapSnapshot', {
filePath: path.join(__dirname, `../heap/s3.heapsnapshot`)
});
})
});
注意:部分項目本身比較大,可能導致運行 task 超時問題,可以將 Cypress 任務的超時時間設置為 2 分鐘。
// cypress.config.ts
export default defineConfig({
// ...
e2e: {
taskTimeout: 120000,
},
// ...
});
若想在某些特定模式下才針對 e2e 測試文件執行拍攝堆快照,其他情況下跳過,可以設置 --env XXXX=xxx
。
// package.json
{
"scripts": {
"cy-test:e2e": "pnpm exec cypress open -C ./cypress/cypress.config.ts --e2e --browser chrome --env LOCAL_MODE=1",
}
}
// cypress.config.ts
export default defineConfig({
// ...
e2e: {
setupNodeEvents(on, config) {
on('task', {
takeHeapSnapshot: async (opts: TakeHeapSnapshotType) => {
/** 只在指定的模式下運行,否則跳過 */
if (!config.env.LOCAL_MODE) {
console.log('Skip take heap snapshot.');
return null;
}
// ...
return null;
},
});
return config;
},
},
// ...
});