Recharts 是一款圖表處理的類庫,利用 React 的特性,重新定義了圖表的配置和組合方式,大大地提高了圖表自定義樣式的靈活度。本文記錄了使用 Recharts 結合 SVG 開發自定義樣式圖表的踩坑歷程。
背景
ABCmouse 學校版 為老師們提供了孩子學習情況反饋的模組,其中有一部分資料需要以圖表的方式直觀展示。
這也涉足到了資料視覺化的領域。這個領域細節繁多,靠個人力量難以考慮周全,便需要依賴第三方元件庫。結合這一個需求,在資料視覺化元件庫的選擇上,主要考慮兩點:
- 支援 React
- 支援靈活自定義樣式
經過一番調研,選擇用 Recharts[1] 實現上述的圖表。
1. 關於 Recharts
Recharts 是一個處理圖表的類庫,re 的含義除了 "React" 外,還代表 "Redifined",重新定義圖表各元素的組合和配置的方式。它基於 React 和 D3 構建,具有以下特點:
宣告式的標籤,讓寫圖表和寫 HTML 一樣簡單
貼近原生 SVG 的配置項,讓配置項更加自然
介面式的 API,解決各種個性化的需求
下面是一個輸出的例子,Recharts 的程式碼也十分地簡潔明瞭,避免了新學習一套配置和 API 帶來的額外負擔。
<BarChart width={520} height={280} data={data}> <XAxis dataKey="scene" tickLine={false} axisLine={{ stroke: "#5dc1fb" }} tick={{ fill: "#999" }} /> <Bar dataKey="time" isAnimationActive={!isEmpty} fill="#8884d8" barSize={32} shape={<CustomBar />} label={<CustomLabel />} > {data.map((entry, index) => ( <Cell key={index.toString()} /> ))} </Bar></BarChart>複製程式碼
可以說一個個痛點都被它戳中了,更具體的介紹可以參考作者的介紹文章:元件化視覺化圖表 - Recharts[2]。
本文接下來的部分,記錄使用它在實現餅圖與條形圖中,遇到的細節問題和實現的過程。
2. 餅圖的實現
如圖,這裡的餅圖的圓環部分,使用了 PieChart 元件,中間的文字和圖例則直接使用 HTML 渲染,不依賴 Recharts。
這裡簡單地介紹一下 Recharts 實現放大的圓環部分、引導線和 Label 的過程,為你帶來一個對 Recharts 直觀印象。
2.1 實現圓環部分放大
Recharts 提供的 Pie
元件可以實現基本的圓環部分。需要自定義顏色的情況下,通過 Cell
元件把餅圖每一份的顏色傳入。
<PieChart width={480} height={400}> <Pie data={data} dataKey="value" cx={200} cy={200} innerRadius={58} outerRadius={80} paddingAngle={0} fill="#a08bff" stroke="none" > {data.map((entry, index) => ( <Cell key={`cell-${index}`} fill={entry.color} /> ))} </Pie></PieChart>複製程式碼
得到圓環:
接下來需要實現一個滑鼠 Hover 狀態下,放大滑鼠對應的 Sector、再顯示虛線引導線和 label 的效果。
參考 官網例子[3],實現 Hover 狀態下放大的 Sector,<Pie />
提供了一個 ActiveShape
屬性,往裡面傳入一個自定義的 React 元件,重新渲染需要的那一份,然後再傳入一個 activeIndex
指明哪一份需要重新渲染,另外還需要一個 onMouseEnter
函式,更新 activeIndex
。
<Pie activeIndex={this.state.activeIndex} activeShape={renderActiveShape} data={data} dataKey="value" cx={200} cy={200} innerRadius={58} outerRadius={80} paddingAngle={0} fill="#a08bff" stroke="none" onMouseEnter={this.onPieEnter}> {data.map((entry, index) => ( <Cell key={`cell-${index}`} fill={entry.color} /> ))}</Pie>複製程式碼
renderActiveShape
的實現,首先返回一個內徑更小,外徑更大的 Sector 。根據 render 函式返回的資訊填充到 Sector 元件上,cx, cy 為 Sector 所在圓環對應圓心的座標。
function renderActiveShape(props) { const innerOffset = 2; // 內縮 const outerOffset = 4; // 外擴 const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill } = props; return ( <Sector cx={cx} cy={cy} innerRadius={innerRadius - innerOffset} outerRadius={outerRadius + outerOffset} startAngle={startAngle} endAngle={endAngle} fill={fill} /> );}複製程式碼
完成圓環部分放大的效果:
2.2 實現引導線和標籤
找了一圈 Recharts 的文件沒有發現引導線的元件, 官網例子 的引導線是一段巢狀了 svg 元素的程式碼,作者在做這個需求之前還沒仔細研究過 svg 圖形。怎麼辦呢?學!
開始一波網上衝浪,找到了 MDN 的 SVG 教程[4],過了一遍,有了個基礎印象。在引導線的實現上用了 <path>
元素。
2.2.1 關於 元素
<path>
元素提供一個名為 d
屬性,意思是 "Path Data",包含了路徑的所有資料,資料的格式是一系列的命令,和命令所需要的引數序列。命令與引數之間用空白字元分開。
簡單梳理一下文件中涉及的基本命令和接受的引數:
M x y 畫筆移動到 (x, y),作為起點L x y 畫一條直線到 (x, y)H x 水平劃線到橫座標 xV y 水平劃線到縱座標 yZ 閉合路徑回到起點(用於建立一個形狀)複製程式碼
它還可以畫貝塞爾曲線和弧形,用到下方的命令:
C x1 y1, x2 y2, x y 三次貝塞爾曲線Q x1 y1, x y 二次貝塞爾曲線A rx ry x-axis-rotation large-arc-flag sweep-flag x y 繪製弧形複製程式碼
關於 d
屬性,本文涉及到的命令都已經列出來了,這裡不再贅述。
<path>
還提供了 stroke
和 fill
屬性,分別對應著邊框和填充的顏色,path 本質上是一個閉合路徑形成的形狀,我們畫的圖本質上屬於邊框,因此顏色設定上也是需要用 stroke
來做,具體參考 MDN 關於 Stroke 和 Fill 的介紹[5]。
設計同學需要虛線的引導線,SVG 提供了 stroke-dasharray
實現這個需求,它接受一組逗號分隔的數字,這個數字代表著線長和空白的長度的組合。
到這裡,繪製圖形需要的原料基本梳理清楚了。
2.2.2 生成 Path Data
我們的目標是在 renderShapeData
裡輸出一個這樣的 Sector
+ 引導線
+ Label
,需要通過接收原本只交給 Sector 的輸入,自己生成相應的繪圖資料 d
。觀察發現我們需要一個先往外延伸一段,再往水平方向折過去的折線。也就是說我們需要確定一個起點,一箇中間偏折的參考點,還有最後的終點。配合邊框的顏色樣式,我們可以得到如下程式碼。 (這是上述官網的 renderActiveShape
例子的實現思路,我這裡做的也是理解和修改的工作)
<path d={`M${sx},${sy} L${mx},${my} L${ex},${ey}`} stroke={fill} strokeDasharray="1,3" fill="none"/>複製程式碼
確立三個點的座標不難,首先需要確定渲染 activeShape
時的 props
各個屬性在圖形中的含義,這裡用到的有:
const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, midAngle, fill, value, name} = props;複製程式碼
涉及到的圓心座標、角度、半徑等引數的含義如圖:
這不就是初中學過的「直角三角形」嗎?用三角函式可以很快把三個點的座標分別計算出來。
接下來把這一切轉換成程式碼的表達。需要考慮角度弧度轉換、方向等問題。
const RADIAN = Math.PI / 180;const innerOffset = 2; // 內縮const outerOffset = 4; // 外擴const { cx, cy, midAngle, innerRadius, outerRadius, startAngle, endAngle, fill, value, name,} = props;const sin = Math.sin(-RADIAN * midAngle);const cos = Math.cos(-RADIAN * midAngle);const sx = cx + (outerRadius - innerOffset) * cos;const sy = cy + (outerRadius + outerOffset) * sin;const mx = cx + (outerRadius + outerOffset + 30) * cos;const my = cy + (outerRadius + outerOffset + 35) * sin;const ex = mx + (cos >= 0 ? 1 : -1) * 80;const ey = my;複製程式碼
這時我們渲染出了想要的引導線:
2.2.3 label 的生成
這一步比較簡單,用 SVG 的 <text>
元素處理就好,把上一步引導線用的 (ex, ey) 作為文字的起始座標,再考慮一下 textAnchor
保證對齊方向即可。
最終的餅圖效果。
3. 條形圖的實現
如圖,這裡我們需要做這樣的一個條形圖,涉及到的元素有兩塊,X軸、一系列的柱子,各一個 React 元件。
<BarChart width={520} height={280} data={data}> <XAxis dataKey="scene" tickLine={false} axisLine={{ stroke: "#5dc1fb" }} tick={{ fill: "#999" }} /> <Bar dataKey="time" fill="#8884d8" barSize={32} /></BarChart>複製程式碼
得到如下效果:
到了這一步,我們距離最終目標還差條形圖的標籤,漸變和圓角的頂部。
3.1 漸變的實現
首先我們解決漸變的問題,查詢MDN 關於漸變的文件[6],發現實現其實很簡單,只需要往 <defs>
元素插入一個 <linearGradient>
節點,然後再在需要應用漸變的元素的 fill
屬性(填充)設為 url(#漸變節點的id屬性值)
即可。
Recharts 文件沒有說到 <defs>
元素,看 SVG 裡面所有漸變、CSS 等定義都集中在了檔案開頭的 <defs>
裡面。腦洞:我直接在元件裡面寫 <defs>
是否能出現在最終生成的 <svg>
裡面呢?試著寫了下,還真可以!說明這個腦洞是可行的。
看,加入漸變後的 JSX 程式碼,還是那麼簡潔:
<BarChart width={520} height={280} data={data}> <defs> <linearGradient x1="0" x2="0" y1="0" y2="1"> <stop offset="0%" stopColor="#00ddee" /> <stop offset="100%" stopColor="#5dc1fb" /> </linearGradient> </defs> <XAxis dataKey="scene" tickLine={false} axisLine={{ stroke: "#5dc1fb" }} tick={{ fill: "#999" }} /> ...</BarChart>複製程式碼
So easy~
3.2 頂部改為圓角
接下來我們實現圓角的頂部,它本質上是一個封閉的 <path>
,我們只需要畫一個頂部為圓角的矩形就可以了。
這裡我們用到 <Bar>
元件提供的 shape
屬性,傳入一個自定義元件 <CustomBar>
處理。
<Bar dataKey="time" fill="url(#abc-bar-gradient)" barSize={32} shape={<CustomBar />}/>複製程式碼
接下來我們的關注點和精力都放在如何實現這個 <CustomBar />
上,填充 fill
就用上級繼承過來的,核心的問題在於如何計算這個 d
。
實現程式碼如下,搞清楚 x
, y
, width
, height
的含義以後,一切都變得十分簡單。
function CustomBar(props) { const { fill, x, y, width, height } = props; const radius = width / 2; const d = `M${x},${y + height} L${x},${y + radius} A${radius},${radius} 0 0 1 ${x + width},${y + radius} L${x + width},${y + height} Z`; return ( <path d={d} stroke="none" fill={fill} /> );}複製程式碼
(x, y) 指的是柱子左上角的座標。
加上圓角後的效果:
3.3 設定剪下
上面的實現是資料比較均衡的情況,當資料差異懸殊的情況下,便暴露出一個讓人心態炸裂的問題,不多說,看下圖。
我們想實現一個圓角矩形,但 (x, y) 實際上是位於半圓的左邊空白部分的左上角。當這個點太接近座標軸,加上圓角半徑以後,圓角的起點的縱座標便超出範圍,導致了這種詭異的情況。能不能把它隱藏起來呢?
怎麼能不可以!繼續網上衝浪,找到 SVG 的剪下功能[7],恰好 recharts 生成的 SVG 也有 <clipPath>
元素的存在,想必作者有考慮過這一點。
也就是說,我直接在柱子裡面引用這裡帶的 clipPath 就好了,但它的字首帶著一個彷彿是個 id,這個 id 看起來似乎是全域性統一自增的。怎麼獲取到確切的 id 呢?
深入 recharts 原始碼,找到了這裡提到的 clipPath 的 id 的定義[8],原來我們需要在最外層的 <BarChart />
傳入一個固定的 id
屬性。
<BarChart width={520} height={280} data={data} id={uniqueId}> ...</BarChart>複製程式碼
往 <CustomBar />
裡面渲染的 <path>
傳入一個帶著一個我們可控的 id 組合之後得到的 clipPath
,問題解決。
function CustomBar(props) { const { fill, x, y, width, height } = props; const radius = width / 2; const d = `M${x},${y + height} L${x},${y + radius} A${radius},${radius} 0 0 1 ${x + width},${y + radius} L${x + width},${y + height} Z`; return ( <path d={d} stroke="none" fill={fill} clipPath={`url(#${uniqueId}-clip)`} /> );}複製程式碼
3.4 Label 的實現
同樣的思路,我們直接在 <Bar>
元件提供的 label
屬性定義一個 <CustomLabel />
元件。
<Bar isAnimationActive={!isEmpty} dataKey="time" fill="url(#abc-bar-gradient)" barSize={32} shape={<CustomBar />} label={<CustomLabel />}/>複製程式碼
程式碼與修改思路也類似,有問題用 DevTools 跟蹤一波,再給文字自定義格式化一下(這裡抽象成了 getStudyTime
函式)。
function CustomLabel(props) { const { x, y, width, height, value } = props; return ( <text x={x + width / 2 - 1} y={y - 10} width={width} height={height} fill="#999" className="recharts-text recharts-label" textAnchor="middle" > {getStudyTime(value)} </text> );};複製程式碼
3.5 最終效果
總結與感想
關於 SVG 與 React
在做這個需求時也開始直接入門了 SVG,掌握了新的一門控制視覺展示的技術,滿滿的收穫~
React 直接渲染 SVG 也進一步開啟了我的眼界,原來她不僅可以渲染 HTML 元素,也可以直接擼 SVG,在實現了適配層的情況下,我們還可以搞 canvas、Native 渲染,甚至嵌入式裝置的液晶屏也可以用[9]。通過 React 實現一套程式碼在不同的平臺上構造許多複雜的 UI 邏輯,讓我實實在在地感受到了這樣的抽象的威力所在。
“抽象”與圖表框架的選型
假期看了 SICP 課程[10],它討論了許多關於“抽象”的話題。我們為一些複雜的事情建立抽象屏障,避免了我們的精力被各種重複的瑣事給佔據。
抽象的目的在於隱藏背後的複雜,創造抽象屏障的本質上也同時創造出一種新的溝通方式,某種意義上可以說是一種“語言”。
讓人新把握一門“語言”實際會給人帶來負擔,但一般情況下我們察覺不到。當這樣的抽象複雜到了一定程度,這樣的負擔便開始顯現出來。往往我們的需求並不能被一層抽象滿足,而經常去跨越一層層的抽象屏障。
跨越多層抽象屏障,也就意味著需要同時把握更多的“語言”以及它們之間的千絲萬縷關係,導致複雜度大大增加,無形中就帶來了許多的坑。
想以抽象的方式去概括複雜的現實,設計上必然會有所側重。這是個矛盾的問題,類似 ECharts 這樣側重於簡單配置的圖表視覺化元件,如果嘗試去做精細的定製改造,難度將會非常大;Recharts 更側重於定製化,它為我們提供了能直接觸及到最終 UI 展現的方式,藉助於 React,定製的過程也足夠簡單。我們做元件庫選型的時候,得考慮目標在不同維度之下的比較和權衡,根據需求在其中的側重之處,做最合適的選擇。
參考資料
[2] 元件化視覺化圖表 - Recharts: https://zhuanlan.zhihu.com/p/20641029
[3] 官網自定義 ActiveShape 例子: http://recharts.org/en-US/examples/CustomActiveShapePieChart
[4] SVG 教程: https://developer.mozilla.org/zh-CN/docs/Web/SVG/Tutorial
[5] MDN 關於 Stroke 和 Fill 的介紹: https://developer.mozilla.org/zh-CN/docs/Web/SVG/Tutorial/Fills_and_Strokes
[6] MDN 關於漸變的文件: https://developer.mozilla.org/zh-CN/docs/Web/SVG/Tutorial/Gradients
[7] SVG 的剪下功能: https://developer.mozilla.org/zh-CN/docs/Web/SVG/Tutorial/Clipping_and_masking
[8] clipPath 的 id 的定義: https://github.com/recharts/recharts/blob/master/src/chart/generateCategoricalChart.tsx#L172
[9] 將 React 渲染到嵌入式液晶屏: https://juejin.im/post/5dbb729e51882524c101ffe1
[10] Bilibili Learning-SICP 課程: https://www.bilibili.com/video/av8515129/