邁向可更新的 D3.js 圖表
已發表: 2022-03-11介紹
D3.js 是由 Mike Bostock 開發的用於數據可視化的開源庫。 D3 代表數據驅動文檔,顧名思義,該庫允許開發人員輕鬆生成和操作基於數據的 DOM 元素。 儘管不受庫功能的限制,D3.js 通常與 SVG 元素一起使用,並提供強大的工具來從頭開始開發矢量數據可視化。
讓我們從一個簡單的例子開始。 假設您正在為一場 5k 比賽進行訓練,並且您想製作一個水平條形圖,顯示您上週每天跑的英里數:
var milesRun = [2, 5, 4, 1, 2, 6, 5]; d3.select('body').append('svg') .attr('height', 300) .attr('width', 800) .selectAll('rect') .data(milesRun) .enter() .append('rect') .attr('y', function (d, i) { return i * 40 }) .attr('height', 35) .attr('x', 0) .attr('width', function (d) { return d*100}) .style('fill', 'steelblue');
要查看它的實際效果,請在 bl.ocks.org 上查看。
如果這段代碼看起來很熟悉,那就太好了。 如果沒有,我發現 Scott Murray 的教程是開始使用 D3.js 的絕佳資源。
作為一名使用 D3.js 開發數百小時的自由職業者,我的開發模式經歷了一次演變,始終以創造最全面的客戶端和用戶體驗為最終目標。 正如我稍後將更詳細討論的那樣,Mike Bostock 的可重複使用圖表模式提供了一種經過驗證的真實方法,可以在任意數量的選擇中實現相同的圖表。 但是,一旦圖表初始化,它的局限性就會顯現。 如果我想通過這種方法使用 D3 的轉換和更新模式,則必須在生成圖表的同一範圍內完全處理對數據的更改。 實際上,這意味著在同一功能範圍內實現過濾器、下拉選擇、滑塊和調整大小選項。
在多次親身體驗這些限制之後,我想創建一種方法來充分利用 D3.js 的全部功能。 例如,在一個完全獨立的組件的下拉列表中監聽變化,並無縫觸發從舊數據到新數據的圖表更新。 我希望能夠交出具有完整功能的圖表控件,並以合乎邏輯和模塊化的方式進行。 結果是一個可更新的圖表模式,我將逐步完成創建此模式的完整過程。
D3.js 圖表模式進展
第 1 步:配置變量
當我開始使用 D3.js 開發可視化時,使用配置變量來快速定義和更改圖表的規格變得非常方便。 這使我的圖表能夠處理所有不同長度和值的數據。 顯示里程數的同一段代碼現在可以顯示更長的溫度列表而不會出現任何問題:
var highTemperatures = [77, 71, 82, 87, 84, 78, 80, 84, 86, 72, 71, 68]; var height = 300; var width = 800; var barPadding = 1; var barSpacing = height / highTemperatures.length; var barHeight = barSpacing - barPadding; var maxValue = d3.max(highTemperatures); var widthScale = width / maxValue; d3.select('body').append('svg') .attr('height', height) .attr('width', width) .selectAll('rect') .data(highTemperatures) .enter() .append('rect') .attr('y', function (d, i) { return i * barSpacing }) .attr('height', barHeight) .attr('x', 0) .attr('width', function (d) { return d*widthScale}) .style('fill', 'steelblue');
要查看它的實際效果,請在 bl.ocks.org 上查看。
請注意條形的高度和寬度如何根據數據的大小和值進行縮放。 一個變量被改變,其餘的被處理。
第 2 步:通過函數輕鬆重複
通過抽像出一些業務邏輯,我們能夠創建更通用的代碼,準備好處理通用的數據模板。 下一步是將此代碼包裝到生成函數中,從而將初始化減少到一行。 該函數接受三個參數:數據、DOM 目標和可用於覆蓋默認配置變量的選項對象。 看看如何做到這一點:
var milesRun = [2, 5, 4, 1, 2, 6, 5]; var highTemperatures = [77, 71, 82, 87, 84, 78, 80, 84, 86, 72, 71, 68, 75, 73, 80, 85, 86, 80]; function drawChart(dom, data, options) { var width = options.width || 800; var height = options.height || 200; var barPadding = options.barPadding || 1; var fillColor = options.fillColor || 'steelblue'; var barSpacing = height / data.length; var barHeight = barSpacing - barPadding; var maxValue = d3.max(data); var widthScale = width / maxValue; d3.select(dom).append('svg') .attr('height', height) .attr('width', width) .selectAll('rect') .data(data) .enter() .append('rect') .attr('y', function (d, i) { return i * barSpacing }) .attr('height', barHeight) .attr('x', 0) .attr('width', function (d) { return d*widthScale}) .style('fill', fillColor); } var weatherOptions = {fillColor: 'coral'}; drawChart('#weatherHistory', highTemperatures, weatherOptions); var runningOptions = {barPadding: 2}; drawChart('#runningHistory', milesRun, runningOptions);
要查看它的實際效果,請在 bl.ocks.org 上查看。
在此上下文中記錄 D3.js 選擇也很重要。 應始終避免像d3.selectAll('rect')
這樣的一般選擇。 如果 SVG 出現在頁面的其他位置,頁面上的所有rect
都將成為選擇的一部分。 相反,使用傳入的 DOM 引用,創建一個svg
對象,您可以在添加和更新元素時引用該對象。 這種技術還可以提高圖表生成的運行時間,因為使用條形圖之類的參考也可以避免再次進行 D3.js 選擇。
第 3 步:方法鏈接和選擇
雖然之前使用配置對象的框架在 JavaScript 庫中非常普遍,但 D3.js 的創建者 Mike Bostock 推薦了另一種創建可重用圖表的模式。 簡而言之,Mike Bostock 建議使用 getter-setter 方法將圖表實現為閉包。 雖然增加了圖表實現的一些複雜性,但調用者只需使用方法鏈接,設置配置選項就變得非常簡單:
// Using Mike Bostock's Towards Reusable Charts Pattern function barChart() { // All options that should be accessible to caller var width = 900; var height = 200; var barPadding = 1; var fillColor = 'steelblue'; function chart(selection){ selection.each(function (data) { var barSpacing = height / data.length; var barHeight = barSpacing - barPadding; var maxValue = d3.max(data); var widthScale = width / maxValue; d3.select(this).append('svg') .attr('height', height) .attr('width', width) .selectAll('rect') .data(data) .enter() .append('rect') .attr('y', function (d, i) { return i * barSpacing }) .attr('height', barHeight) .attr('x', 0) .attr('width', function (d) { return d*widthScale}) .style('fill', fillColor); }); } chart.width = function(value) { if (!arguments.length) return margin; width = value; return chart; }; chart.height = function(value) { if (!arguments.length) return height; height = value; return chart; }; chart.barPadding = function(value) { if (!arguments.length) return barPadding; barPadding = value; return chart; }; chart.fillColor = function(value) { if (!arguments.length) return fillColor; fillColor = value; return chart; }; return chart; } var milesRun = [2, 5, 4, 1, 2, 6, 5]; var highTemperatures = [77, 71, 82, 87, 84, 78, 80, 84, 86, 72, 71, 68, 75, 73, 80, 85, 86, 80]; var runningChart = barChart().barPadding(2); d3.select('#runningHistory') .datum(milesRun) .call(runningChart); var weatherChart = barChart().fillColor('coral'); d3.select('#weatherHistory') .datum(highTemperatures) .call(weatherChart);
要查看它的實際效果,請在 bl.ocks.org 上查看。

圖表初始化使用 D3.js 選擇,綁定相關數據並將 DOM 選擇作為this
上下文傳遞給生成器函數。 生成器函數將默認變量包裝在一個閉包中,並允許調用者通過方法鏈接與返回圖表對象的配置函數來更改這些變量。 通過這樣做,調用者可以一次將相同的圖表呈現給多個選擇,或者使用一個圖表將相同的圖表呈現給具有不同數據的不同選擇,同時避免傳遞龐大的選項對象。
第 4 步:可更新圖表的新模式
Mike Bostock 建議的先前模式為我們作為圖表開發人員提供了生成器功能的強大功能。 給定一組數據和傳入的任何鏈接配置,我們從那裡控制一切。 如果需要從內部更改數據,我們可以使用適當的轉換,而不是從頭開始重繪。 甚至可以優雅地處理窗口大小調整之類的事情,創建響應式功能,例如使用縮寫文本或更改軸標籤。
但是如果數據是從生成器函數的範圍之外修改的呢? 或者如果圖表需要以編程方式調整大小怎麼辦? 我們可以再次調用圖表函數,使用新數據和新大小配置。 一切都會重新繪製,瞧。 問題解決了。
不幸的是,這種解決方案存在許多問題。
首先,我們幾乎不可避免地會執行不必要的初始化計算。 當我們所要做的就是縮放寬度時,為什麼還要進行複雜的數據操作? 這些計算可能在第一次初始化圖表時是必需的,但肯定不是在我們需要進行的每次更新時。 每個編程請求都需要進行一些修改,作為開發人員,我們確切地知道這些更改是什麼。 不多也不少。 此外,在圖表範圍內,我們已經可以訪問許多我們需要的東西(SVG 對象、當前數據狀態等),從而使更改變得簡單易行。
以上面的條形圖示例為例。 如果我們想更新寬度,並通過重繪整個圖表來實現,我們會觸發很多不必要的計算:找到最大數據值、計算條形高度以及渲染所有這些 SVG 元素。 實際上,一旦將width
分配給它的新值,我們需要做的唯一更改是:
width = newWidth; widthScale = width / maxValue; bars.attr('width', function(d) { return d*widthScale}); svg.attr('width', width);
但它變得更好。 由於我們現在有一些圖表的歷史,我們可以使用 D3 的內置轉換來更新我們的圖表並輕鬆地為它們製作動畫。 繼續上面的例子,在width
上添加一個過渡就像改變一樣簡單
bars.attr('width', function(d) { return d*widthScale});
到
bars.transition().duration(1000).attr('width', function(d) { return d*widthScale});
更好的是,如果我們允許用戶傳入一個新數據集,我們可以使用 D3 的更新選擇(進入、更新和退出)也將轉換應用於新數據。 但是我們如何允許新數據呢? 如果你還記得,我們之前的實現創建了一個像這樣的新圖表:
d3.select('#weatherHistory') .datum(highTemperatures) .call(weatherChart);
我們將數據綁定到 D3.js 選擇,並調用我們的可重用圖表。 對數據的任何更改都必須通過將新數據綁定到相同的選擇來完成。 從理論上講,我們可以使用舊模式並探索現有數據的選擇,然後用新數據更新我們的發現。 這不僅實現起來混亂且複雜,而且還需要假設現有圖表具有相同的類型和形式。
相反,通過對 JavaScript 生成器函數的結構進行一些更改,我們可以創建一個圖表,允許調用者通過方法鏈輕鬆地在外部提示更改。 而在配置和數據被設置然後保持不變之前,調用者現在可以做這樣的事情,即使在圖表初始化之後:
weatherChart.width(420);
結果是從現有圖表平滑過渡到新寬度。 沒有不必要的計算和流暢的過渡,結果是一個快樂的客戶。
這種額外的功能會稍微增加開發人員的工作量。 然而,我發現從歷史上看,這是一項非常值得花時間的努力。 這是可更新圖表的骨架:
function barChart() { // All options that should be accessible to caller var data = []; var width = 800; //... the rest var updateData; var updateWidth; //... the rest function chart(selection){ selection.each(function () { // //draw the chart here using data, width // updateWidth = function() { // use width to make any changes }; updateData = function() { // use D3 update pattern with data } }); } chart.data = function(value) { if (!arguments.length) return data; data = value; if (typeof updateData === 'function') updateData(); return chart; }; chart.width = function(value) { if (!arguments.length) return width; width = value; if (typeof updateWidth === 'function') updateWidth(); return chart; }; //... the rest return chart; }
要查看完全實施,請在 bl.ocks.org 上查看。
讓我們回顧一下新的結構。 與之前的閉包實現最大的變化是添加了更新函數。 如前所述,這些函數利用 D3.js 轉換和更新模式,根據新數據或圖表配置平滑地進行任何必要的更改。 為了使調用者可以訪問這些函數,將函數作為屬性添加到圖表中。 為了使它更容易,初始配置和更新都通過相同的函數處理:
chart.width = function(value) { if (!arguments.length) return width; width = value; if (typeof updateWidth === 'function') updateWidth(); return chart; };
請注意,在圖表初始化之前不會定義updateWidth
。 如果它是undefined
,那麼配置變量將被全局設置並在圖表閉包中使用。 如果圖表函數已被調用,則所有轉換都將移交給updateWidth
函數,該函數使用更改後的width
變量進行任何需要的更改。 像這樣的東西:
updateWidth = function() { widthScale = width / maxValue; bars.transition().duration(1000).attr('width', function(d) { return d*widthScale}); svg.transition().duration(1000).attr('width', width); };
使用這種新結構,圖表的數據就像任何其他配置變量一樣通過方法鏈接傳遞,而不是將其綁定到 D3.js 選擇。 區別:
var weatherChart = barChart(); d3.select('#weatherHistory') .datum(highTemperatures) .call(weatherChart);
變成:
var weatherChart = barChart().data(highTemperatures); d3.select('#weatherHistory') .call(weatherChart);
所以我們做了一些改變並增加了一些開發人員的努力,讓我們看看好處。
假設您有一個新功能請求:“添加一個下拉列表,以便用戶可以在高溫和低溫之間切換。 並且在你使用的時候也可以改變顏色。” 無需清除當前圖表、綁定新數據、重新繪製,現在可以在選擇低溫時進行簡單調用:
weatherChart.data(lowTemperatures).fillColor('blue');
並享受魔法。 我們不僅節省了計算,而且我們在可視化更新時增加了一個新的理解水平,這在以前是不可能的。
這裡需要注意關於轉換的重要提示。 在同一元素上安排多個轉換時要小心。 開始一個新的過渡將隱式取消任何以前運行的過渡。 當然,可以在一個 D3.js 啟動的過渡中更改元素的多個屬性或樣式,但我遇到過一些同時觸發多個過渡的情況。 在這些情況下,請考慮在創建更新函數時對父元素和子元素使用並發轉換。
哲學的改變
Mike Bostock 引入閉包作為封裝圖表生成的一種方式。 他的模式針對在許多地方創建具有不同數據的相同圖表進行了優化。 然而,在我使用 D3.js 的這些年裡,我發現優先級略有不同。 我引入的新模式不是使用圖表的一個實例來創建具有不同數據的相同可視化,而是允許調用者輕鬆創建圖表的多個實例,即使在初始化之後,每個實例都可以完全修改。 此外,這些更新中的每一個都可以完全訪問圖表的當前狀態來處理,從而允許開發人員消除不必要的計算並利用 D3.js 的強大功能來創建更無縫的用戶和客戶端體驗。