React Core Team 成員開發的「火焰圖組件」技術揭秘
前言
最近在業務的開發中,業務方需要我們性能監控平臺提供火焰圖來展示函數堆棧以及相關的耗時信息。
根據 Brendan Gregg 在 FlameGraph[1] 主頁中的定義:
Flame graphs are a visualization of profiled software, allowing the most frequent code-paths to be identified quickly and accurately
火焰圖是一種可視化分析軟件,讓我們可以快速準確的發現調用頻繁的函數堆棧。
可以在這里查看火焰圖的示例[2]。
其實不光是調用頻率,火焰圖也同樣適合描述函數調用的堆棧以及耗時頻率,比如 Chrome DevTools 中的火焰圖:
其實根節點在頂部,葉子節點在底部的這種圖形稱為 Icicle charts(冰柱圖)更合適,不過為了理解方便,下文還是統一稱為火焰圖。
本文想要分析的源碼并不是上面的任意一種,而是 React 瀏覽器插件中使用的火焰圖組件,它是由 React 官方成員 Brian Vaughn 開發的 react-flame-graph[3]。
本地調試
react-flame-graph 這個庫本身是由 rollup 負責構建,而 react-flame-graph 的示例網站[4]則是用 webpack 構建。
所以本地想要調試的話,clone 這個庫以后:
- 分別在根目錄和 website 目錄安裝依賴。
- 在根目錄執行 npm link 鏈接到全局,再去 website 目錄 npm link react-flame-graph 建立軟鏈接。
- 在根目錄執行 npm run start 開啟 rollup 的 watch 編譯模式,把 react-flame-graph 編譯到 dist 目錄。
- 在 website 目錄執行 npm run start 開啟 webpack dev 模式,進入示例網站,通過編寫 React App Demo 進行調試。
由于這個庫比較老,最好用 nrm 把 node 版本調整到 10.15.0,我是在這個版本下才成功安裝了依賴。
先來簡單看一下火焰圖的效果:
組件揭秘
使用
想要使用這個組件,必須傳入的數據是 width 和 data,
width 是指整個火焰圖容器的寬度,后續計算每個的寬度都需要用到。
data 格式則是樹形結構:
- 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,
- },
- ],
- },
- ],
- };
除了標準樹的 name, children 外,這里還有一個必須的屬性 value,根據每一層的 value 也就決定了每一個火焰圖塊的寬度。
比如這個數據的寬度樹是
- width: 5
- - width 1
- - width 3
- - width 2
那么生成的火焰圖也會遵循這個寬度比例:
而在業務場景中,這里一般每個矩形塊對應一次函數調用,它會統計到總耗時,這個值就可以用作為 value。
數據轉換
這個組件的第一步,是把這份遞歸的數據轉化為拉平的數組。
遞歸數據雖然比較直觀的展示了層級,但是用作渲染卻比較麻煩。
整個火焰圖的渲染,其實就是每個層級對應的所有矩形塊逐行渲染而已,所以平級的數組更適合。
我們的目標是把數據整理成這樣的結構:
- 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 對應層級關系和每層的節點 id,nodes 則是 id 所對應的節點數據。
其實這一步很關鍵,這個數據基本把渲染的層級和樣式決定好了。
這里的 nodes 中的 width 經過了 width: value / maxValue 這樣的處理,而 maxValue其實就是根節點定義的那個 width,本例中對應數值為 5,所以:
- 第一層的節點寬度是 5 / 5 = 1
- 第二層的節點的寬度自然就是 1 / 5 = 0.2, 3 / 5 = 0.6。
在這里處理的好處是渲染的時候可以直接通過和火焰圖容器的寬度,也就是真實 dom 節點的寬度相乘,得到矩形塊真實寬度。
轉換部分其實就是一次遞歸,代碼如下:
- 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 屬性是(當前節點 value / 根元素的 value)
- width: value / maxValue,
- });
- // 記錄每個 level 對應的 uid 列表
- if (levels.length <= depth) {
- levels.push([]);
- }
- levels[depth].push(uidOrCounter);
- // 把全局的 UID 計數器 + 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,
- };
- }
渲染列表
轉換好數據結構后,就要開始渲染部分了。這里作者 Brian Vaughn 用了他寫的 React 虛擬滾動庫 react-window[5] 去優化長列表的性能。
- // 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>;
這里需要注意的是把外部傳入的一些數據整合成了虛擬列表組件所需要的 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 是一個用來做函數緩存的庫,它的作用是傳入的參數不發生改變的情況下,直接返回上一次計算的值。
對于新版的 React 來說,直接用 useMemo 配合依賴也可以達到類似的效果。
這里就是簡單的把數據保存了一下,唯一不同的就是新定義了一個方法 scale:
- scale: value => (value / focusedNode.width) * width,
它是負責計算真實 DOM 寬度的,所有節點的寬度都會參照 focuesdNode 的寬度再乘以火焰圖容易的真實 DOM 寬度來計算。
所以點擊了某個節點聚焦它后,它的子節點寬度也會發生變化。
focuesdNode為根節點時:
點擊 custom background color 這個節點后:
這里 children 的位置用花括號的方式放了一個組件引用 ItemRenderer,其實這是 render props 的用法,相當于:
- <List>{(props) => <ItemRenderer {...props} />}</List>
而 ItemRenderer 組件其實就負責通過數據來渲染每一行的矩形塊,由于數據中有 3 層 level,所以這個組件會被調用 3 次。
每一次都可以拿到對應層級的 uids,通過 uid 又可以拿到 node 相關的信息,完成渲染。
- // 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}
- />
- );
- });
這里所有的數值量都是通過 scale 根據容器寬度算出來的真實 DOM 寬度。
這里計算偏移量比較巧妙的點在于,最終傳遞給矩形塊組件LabeledRect的 x 也就是橫軸的偏移量,是根據 focusedNode 的 left 值計算出來的。
如果父節點被 focus 后,它是占據整行的,子節點的 x 也會緊隨父節點偏移到最左邊去。
比如這個圖中聚焦的節點是 foo,那么最底下的 leaf 節點計算偏移量時,focusedNodeLeft 就是 0,它的偏移量就保持自身的 left 不變。
而聚焦的節點變成 custom background color 時,由于聚焦節點的 left 是 200,所以leaf 節點也會左移 200 像素。

也許有同學會疑惑,在 custom background color 聚焦時,它的父節點 foo 節點本身偏移量就是 0 了,再減去 200,不是成負數了嘛,那能父節點的矩形塊保證占據一整行嗎?
這里再回顧 scale 的邏輯:value => (value / focusedNode.width) * width,計算父節點的寬度時是 scale(父節點的寬度),而此時父節點的 width 是大于聚焦的節點的,所以最終的寬度能保證在偏移一定程度的負數時,父節點還是占滿整行。
最后 LabeledRect 就是用 svg 渲染出矩形,沒什么特殊的。
總結
看似復雜的火焰圖,在設計了良好的數據結構以及組件結構以后,一層層梳理下來,其實也并不難。
短短一篇文章下來,我們已經完整解析了 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 的示例網站: https://react-flame-graph.now.sh/[5]react-window: https://github.com/bvaughn/react-window
本文轉載自微信公眾號「前端從進階到入院」,可以通過以下二維碼關注。轉載本文請聯系前端從進階到入院眾號。