React Core Team 成員開發(fā)的「火焰圖組件」技術(shù)揭秘
前言
最近在業(yè)務(wù)的開發(fā)中,業(yè)務(wù)方需要我們性能監(jiān)控平臺提供火焰圖來展示函數(shù)堆棧以及相關(guān)的耗時信息。
根據(jù) Brendan Gregg 在 FlameGraph[1] 主頁中的定義:
Flame graphs are a visualization of profiled software, allowing the most frequent code-paths to be identified quickly and accurately
火焰圖是一種可視化分析軟件,讓我們可以快速準(zhǔn)確的發(fā)現(xiàn)調(diào)用頻繁的函數(shù)堆棧。
可以在這里查看火焰圖的示例[2]。
其實不光是調(diào)用頻率,火焰圖也同樣適合描述函數(shù)調(diào)用的堆棧以及耗時頻率,比如 Chrome DevTools 中的火焰圖:
其實根節(jié)點在頂部,葉子節(jié)點在底部的這種圖形稱為 Icicle charts(冰柱圖)更合適,不過為了理解方便,下文還是統(tǒng)一稱為火焰圖。
本文想要分析的源碼并不是上面的任意一種,而是 React 瀏覽器插件中使用的火焰圖組件,它是由 React 官方成員 Brian Vaughn 開發(fā)的 react-flame-graph[3]。
本地調(diào)試
react-flame-graph 這個庫本身是由 rollup 負責(zé)構(gòu)建,而 react-flame-graph 的示例網(wǎng)站[4]則是用 webpack 構(gòu)建。
所以本地想要調(diào)試的話,clone 這個庫以后:
- 分別在根目錄和 website 目錄安裝依賴。
- 在根目錄執(zhí)行 npm link 鏈接到全局,再去 website 目錄 npm link react-flame-graph 建立軟鏈接。
- 在根目錄執(zhí)行 npm run start 開啟 rollup 的 watch 編譯模式,把 react-flame-graph 編譯到 dist 目錄。
- 在 website 目錄執(zhí)行 npm run start 開啟 webpack dev 模式,進入示例網(wǎng)站,通過編寫 React App Demo 進行調(diào)試。
由于這個庫比較老,最好用 nrm 把 node 版本調(diào)整到 10.15.0,我是在這個版本下才成功安裝了依賴。
先來簡單看一下火焰圖的效果:
組件揭秘
使用
想要使用這個組件,必須傳入的數(shù)據(jù)是 width 和 data,
width 是指整個火焰圖容器的寬度,后續(xù)計算每個的寬度都需要用到。
data 格式則是樹形結(jié)構(gòu):
- const simpleData = {
- name: "foo",
- value: 5,
- children: [
- {
- name: "custom tooltip",
- value: 1,
- tooltip: "Custom tooltip shown on hover",
- },
- {
- name: "custom background color",
- value: 3,
- backgroundColor: "#35f",
- color: "#fff",
- children: [
- {
- name: "leaf",
- value: 2,
- },
- ],
- },
- ],
- };
除了標(biāo)準(zhǔn)樹的 name, children 外,這里還有一個必須的屬性 value,根據(jù)每一層的 value 也就決定了每一個火焰圖塊的寬度。
比如這個數(shù)據(jù)的寬度樹是
- width: 5
- - width 1
- - width 3
- - width 2
那么生成的火焰圖也會遵循這個寬度比例:
而在業(yè)務(wù)場景中,這里一般每個矩形塊對應(yīng)一次函數(shù)調(diào)用,它會統(tǒng)計到總耗時,這個值就可以用作為 value。
數(shù)據(jù)轉(zhuǎn)換
這個組件的第一步,是把這份遞歸的數(shù)據(jù)轉(zhuǎn)化為拉平的數(shù)組。
遞歸數(shù)據(jù)雖然比較直觀的展示了層級,但是用作渲染卻比較麻煩。
整個火焰圖的渲染,其實就是每個層級對應(yīng)的所有矩形塊逐行渲染而已,所以平級的數(shù)組更適合。
我們的目標(biāo)是把數(shù)據(jù)整理成這樣的結(jié)構(gòu):
- levels: [
- ["_0"],
- ["_1", "_2"],
- ["_3"],
- ],
- nodes: {
- _0: { width: 1, depth: 0, left: 0, name: "foo", …}
- _1: { width: 0.2, depth: 1, left: 0, name: "custom tooltip", …}
- _2: { width: 0.6, depth: 1, left: 0.2, name: "custom background color", …}
- _3: { width: 0.4, depth: 2, left: 0.2, name: "leaf", …}
- }
一目了然,levels 對應(yīng)層級關(guān)系和每層的節(jié)點 id,nodes 則是 id 所對應(yīng)的節(jié)點數(shù)據(jù)。
其實這一步很關(guān)鍵,這個數(shù)據(jù)基本把渲染的層級和樣式?jīng)Q定好了。
這里的 nodes 中的 width 經(jīng)過了 width: value / maxValue 這樣的處理,而 maxValue其實就是根節(jié)點定義的那個 width,本例中對應(yīng)數(shù)值為 5,所以:
- 第一層的節(jié)點寬度是 5 / 5 = 1
- 第二層的節(jié)點的寬度自然就是 1 / 5 = 0.2, 3 / 5 = 0.6。
在這里處理的好處是渲染的時候可以直接通過和火焰圖容器的寬度,也就是真實 dom 節(jié)點的寬度相乘,得到矩形塊真實寬度。
轉(zhuǎn)換部分其實就是一次遞歸,代碼如下:
- export function transformChartData(rawData: RawData): ChartData {
- let uidCounter = 0;
- const maxValue = rawData.value;
- const nodes = {};
- const levels = [];
- function convertNode(
- sourceNode: RawData,
- depth: number,
- leftOffset: number
- ): ChartNode {
- const {
- backgroundColor,
- children,
- color,
- id,
- name,
- tooltip,
- value,
- } = sourceNode;
- const uidOrCounter = id || `_${uidCounter}`;
- // 把這個 node 放到 map 中
- const targetNode = (nodes[uidOrCounter] = {
- backgroundColor:
- backgroundColor || getNodeBackgroundColor(value, maxValue),
- color: color || getNodeColor(value, maxValue),
- depth,
- left: leftOffset,
- name,
- source: sourceNode,
- tooltip,
- // width 屬性是(當(dāng)前節(jié)點 value / 根元素的 value)
- width: value / maxValue,
- });
- // 記錄每個 level 對應(yīng)的 uid 列表
- if (levels.length <= depth) {
- levels.push([]);
- }
- levels[depth].push(uidOrCounter);
- // 把全局的 UID 計數(shù)器 + 1
- uidCounter++;
- if (Array.isArray(children)) {
- children.forEach((sourceChildNode) => {
- // 進一步遞歸
- const targetChildNode = convertNode(
- sourceChildNode,
- depth + 1,
- leftOffset
- );
- leftOffset += targetChildNode.width;
- });
- }
- return targetNode;
- }
- convertNode(rawData, 0, 0);
- const rootUid = rawData.id || "_0";
- return {
- height: levels.length,
- levels,
- nodes,
- root: rootUid,
- };
- }
渲染列表
轉(zhuǎn)換好數(shù)據(jù)結(jié)構(gòu)后,就要開始渲染部分了。這里作者 Brian Vaughn 用了他寫的 React 虛擬滾動庫 react-window[5] 去優(yōu)化長列表的性能。
- // FlamGraph.js
- const itemData = this.getItemData(
- data,
- focusedNode,
- ...,
- width
- );
- <List
- height={height}
- innerTagName="svg"
- itemCount={data.height}
- itemData={itemData}
- itemSize={rowHeight}
- width={width}
- >
- {ItemRenderer}
- </List>;
這里需要注意的是把外部傳入的一些數(shù)據(jù)整合成了虛擬列表組件所需要的 itemData,方法如下:
- import memoize from "memoize-one";
- getItemData = memoize(
- (
- data: ChartData,
- disableDefaultTooltips: boolean,
- focusedNode: ChartNode,
- focusNode: (uid: any) => void,
- handleMouseEnter: (event: SyntheticMouseEvent<*>, node: RawData) => void,
- handleMouseLeave: (event: SyntheticMouseEvent<*>, node: RawData) => void,
- handleMouseMove: (event: SyntheticMouseEvent<*>, node: RawData) => void,
- width: number
- ) =>
- ({
- data,
- disableDefaultTooltips,
- focusedNode,
- focusNode,
- handleMouseEnter,
- handleMouseLeave,
- handleMouseMove,
- scale: (value) => (value / focusedNode.width) * width,
- }: ItemData)
- );
memoize-one 是一個用來做函數(shù)緩存的庫,它的作用是傳入的參數(shù)不發(fā)生改變的情況下,直接返回上一次計算的值。
對于新版的 React 來說,直接用 useMemo 配合依賴也可以達到類似的效果。
這里就是簡單的把數(shù)據(jù)保存了一下,唯一不同的就是新定義了一個方法 scale:
- scale: value => (value / focusedNode.width) * width,
它是負責(zé)計算真實 DOM 寬度的,所有節(jié)點的寬度都會參照 focuesdNode 的寬度再乘以火焰圖容易的真實 DOM 寬度來計算。
所以點擊了某個節(jié)點聚焦它后,它的子節(jié)點寬度也會發(fā)生變化。
focuesdNode為根節(jié)點時:
點擊 custom background color 這個節(jié)點后:
這里 children 的位置用花括號的方式放了一個組件引用 ItemRenderer,其實這是 render props 的用法,相當(dāng)于:
- <List>{(props) => <ItemRenderer {...props} />}</List>
而 ItemRenderer 組件其實就負責(zé)通過數(shù)據(jù)來渲染每一行的矩形塊,由于數(shù)據(jù)中有 3 層 level,所以這個組件會被調(diào)用 3 次。
每一次都可以拿到對應(yīng)層級的 uids,通過 uid 又可以拿到 node 相關(guān)的信息,完成渲染。
- // ItemRenderer
- const focusedNodeLeft = scale(focusedNode.left);
- const focusedNodeWidth = scale(focusedNode.width);
- const top = parseInt(style.top, 10);
- const uids = data.levels[index];
- return uids.map((uid) => {
- const node = data.nodes[uid];
- const nodeLeft = scale(node.left);
- const nodeWidth = scale(node.width);
- // 太小的矩形塊不渲染
- if (nodeWidth < minWidthToDisplay) {
- return null;
- }
- // 超出視圖的部分就直接不渲染了
- if (
- nodeLeft + nodeWidth < focusedNodeLeft ||
- nodeLeft > focusedNodeLeft + focusedNodeWidth
- ) {
- return null;
- }
- return (
- <LabeledRect
- ...
- onClick={() => itemData.focusNode(uid)}
- x={nodeLeft - focusedNodeLeft}
- y={top}
- />
- );
- });
這里所有的數(shù)值量都是通過 scale 根據(jù)容器寬度算出來的真實 DOM 寬度。
這里計算偏移量比較巧妙的點在于,最終傳遞給矩形塊組件LabeledRect的 x 也就是橫軸的偏移量,是根據(jù) focusedNode 的 left 值計算出來的。
如果父節(jié)點被 focus 后,它是占據(jù)整行的,子節(jié)點的 x 也會緊隨父節(jié)點偏移到最左邊去。
比如這個圖中聚焦的節(jié)點是 foo,那么最底下的 leaf 節(jié)點計算偏移量時,focusedNodeLeft 就是 0,它的偏移量就保持自身的 left 不變。
而聚焦的節(jié)點變成 custom background color 時,由于聚焦節(jié)點的 left 是 200,所以leaf 節(jié)點也會左移 200 像素。

也許有同學(xué)會疑惑,在 custom background color 聚焦時,它的父節(jié)點 foo 節(jié)點本身偏移量就是 0 了,再減去 200,不是成負數(shù)了嘛,那能父節(jié)點的矩形塊保證占據(jù)一整行嗎?
這里再回顧 scale 的邏輯:value => (value / focusedNode.width) * width,計算父節(jié)點的寬度時是 scale(父節(jié)點的寬度),而此時父節(jié)點的 width 是大于聚焦的節(jié)點的,所以最終的寬度能保證在偏移一定程度的負數(shù)時,父節(jié)點還是占滿整行。
最后 LabeledRect 就是用 svg 渲染出矩形,沒什么特殊的。
總結(jié)
看似復(fù)雜的火焰圖,在設(shè)計了良好的數(shù)據(jù)結(jié)構(gòu)以及組件結(jié)構(gòu)以后,一層層梳理下來,其實也并不難。
短短一篇文章下來,我們已經(jīng)完整解析了 react-devtools 中被大家廣泛使用的火焰圖組件,這種性能分析的利器也就這樣掌握了原理。
參考資料
[1]FlameGraph: http://www.brendangregg.com/flamegraphs.html[2]火焰圖的示例: http://www.brendangregg.com/FlameGraphs/cpu-mysql-updated.svg[3]react-flame-graph: react-flame-graph[4]react-flame-graph 的示例網(wǎng)站: https://react-flame-graph.now.sh/[5]react-window: https://github.com/bvaughn/react-window
本文轉(zhuǎn)載自微信公眾號「前端從進階到入院」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系前端從進階到入院眾號。