Jacque Schrag 原作,授權 New Frontend 翻譯。
在 #DevDiscuss 聊“開發者懺悔”這個話題時,我承認 3 年前開始我的第一份開發工作時,根本不知道自己在做什麼。題圖中的程式碼是一個例子,展示了我當時是如何寫程式碼的。
我收到太多分享類似經歷的回應。我們中的大多數人都寫過讓自己羞愧的糟糕程式碼(硬寫一些愚蠢程式碼,雖然可以完成任務所需,但本可以寫得更高效)。但是當我們回顧過去的程式碼時,如果能意識到我們可以如何寫得更好,乃至覺得當初做的選擇很可笑,那麼這是一個成長的標誌。秉承持續學習的精神,我想要分享一些現在的我會怎麼來寫這段程式碼的方式。
上下文和目標
在重構任何陳年程式碼之前,評估當初寫程式碼時的上下文是極為關鍵的一步。開發者當初做的某個瘋狂決策背後可能有一個重要的原因,源自你不瞭解的上下文(或者源自你不記得的上下文,如果程式碼是你寫的)。我的這個例子,則單純是因為缺乏經驗,所以我可以安全地重構程式碼。
這段程式碼是為兩張資料視覺化圖表寫的。它們的資料和功能相似,主要目標是讓使用者可以根據型別、年份、區域過濾檢視資料集。
我們假定改變過濾器會返回以下數值:
let currentType = 'in' // 或 'out'
let currentYear = 2017
let currentRegions = ['Africa', 'Americas', 'Asia', 'Europe', 'Oceania']
複製程式碼
最後,下面是一個從 CSV 載入資料的簡化例子:
const data = [
{ country: "Name", type: "in", value: 100, region: "Asia", year: 2000 },
{ country: "Name", type: "out", value: 200, region: "Asia", year: 2000 },
...
]
// 陣列中總共約有 2400 條資料
複製程式碼
選項一:初始化空物件
除了硬編碼之外,我原本的程式碼完全違反了 DRY 原則。當然有些情況下重複是有意義的,但在這個不斷重複同樣屬性的情況下,動態建立物件是更明智的選擇。這還可以降低資料集新增年份的工作量,同時降低輸入錯誤的風險。
這裡有好幾種選擇:for
、.forEach
、.reduce
。我將使用 .reduce
方法處理陣列,將陣列轉化為其他東西(在我們的例子中是物件)。我們使用三次 .reduce
,每個類別一次。
我們首先宣告類別常量。這樣未來我們只需在 years
陣列中加上新的年份,我們將要編寫的程式碼會處理剩下的部分。
const types = ['in', 'out']
const years = [2000, 2005, 2010, 2015, 2016, 2017]
const regions = ['Africa', 'Americas', 'Asia', 'Europe', 'Oceania']
複製程式碼
我們想要逆轉 types → years → regions 的順序,從 regions 開始。一旦 regions
轉換為物件,就可以將它賦值給 years 屬性。years 和 types 同理。儘管我們可以少寫幾行程式碼,但我選擇更清晰而不是更聰明的寫法。
const types = ['in', 'out']
const years = [2000, 2005, 2010, 2015, 2016, 2017]
const regions = ['Africa', 'Americas', 'Asia', 'Europe', 'Oceania']
// 將 regions 轉換為物件,每個 region 是一個屬性,值為一個空陣列。
const regionsObj = regions.reduce((acc, region) => {
acc[region] = []
return acc
}, {}) // 累加器(`acc`)的初始值設為 `{}`
console.log(regionsObj)
// {Africa: [], Americas: [], Asia: [], Europe: [], Oceania: []}
複製程式碼
既然已經有了區域物件,年份和型別也可以照此處理。只不過它們的值不是像區域一樣設為空陣列,而是之前說的類別物件。
function copyObj(obj) {
return JSON.parse(JSON.stringify(obj))
}
// 和 regions 一樣處理 years,但將每個年份的值設為 region 物件。
const yearsObj = years.reduce((acc, year) => {
acc[year] = copyObj(regionsObj)
return acc
}, {})
// type 也一樣。返回最終物件。
const dataset = types.reduce((acc, type) => {
acc[type] = copyObj(yearsObj)
return acc
}, {}
console.log(dataset)
// {
// in: {2000: {Africa: [], Americas: [],...}, ...},
// out: {2000: {Africa: [], Americas: [], ...}, ...}
// }
複製程式碼
我們現在得到的效果和我最初的程式碼是一致的,然而我們成功地將它重構成可讀性更強、更容易重構的程式碼!需要在資料集中新增年份時再也不需要複製貼上了!
不過還有一個問題:我們仍然需要手工更新年份列表。而且既然我們將在物件中載入資料,沒理由單獨初始化一個空物件。下面的兩個重構選項完全脫離了我最早的程式碼,展示瞭如何直接使用資料。
附註:老實說,如果我在 3 年前嘗試重構,我大概會用 3 層巢狀的 for
迴圈,並對此表示滿意。就像 Stephen Holdaway 在評論中給出的寫法:
const types = ['in', 'out'];
const years = [2000, 2005, 2010, 2015, 2016, 2017];
const regions = ['Africa', 'Americas', 'Asia', 'Europe', 'Oceania'];
var dataset = {};
for (let typ of types) {
dataset[typ] = {};
for (let year of years) {
dataset[typ][year] = {};
for (let region of regions) {
dataset[typ][year][region] = [];
}
}
}
複製程式碼
我之前使用 reduce
的寫法避免了過深的巢狀。
選項二:直接過濾
有些讀者大概想知道為什麼我們要把資料按型別分組。我們本可以使用 .filter
根據 currentType
(當前型別)、currentYear
(當前年份)、currentRegion
(當前區域) 返回所需資料,就像這樣:
/*
`.filter` 會建立一個新陣列,其中所有的成員均匹配 `currentType` 和 `currentYear`。
`includes` 根據 `currentRegions` 是否包含條目的 region 返回真假。
*/
let currentData = data.filter(d => d.type === currentType && d.year === currentYear && currentRegion.includes(d.region))
複製程式碼
儘管這一行程式碼效果不錯,但我不建議在我們的例子中使用它,原因有兩個:
- 使用者每次選擇篩選條件都會執行該方法。取決於資料集的大小(別忘了資料集將隨著年份增長),這可能影響效能。現代瀏覽器很高效,效能損失也許極小,但如果我們明知使用者每次只能選擇一種型別和一個年份,我們可以在一開始就分組資料,主動提升效能。
- 這一選項不會提供可供選擇的型別、年份、區域列表。有了這些列表,我們可以使用它們動態生成使用者介面的骨架,無需手動建立並更新。
沒錯,我當年硬編碼了選項。每次新增一個年份,我需要記住同時更新 JS 和 HTML。
選項三:由資料驅動的物件
我們可以將前兩個選項組合一下,得到第三種重構方式。這種方式的目標是在更新資料集時完全不需要修改程式碼,直接根據資料確定類別。
同樣,要做到這一點,技術上有多種方法。不過,我將繼續使用 .reduce
。
const dataset = data.reduce((acc, curr) => {
// 如果累加器的屬性中已存在當前型別,將其設為自身,否則初始化為空物件。
acc[curr.type] = acc[curr.type] || {}
// 年份同理
acc[curr.type][curr.year] = acc[curr.type][curr.year] || []
acc[curr.type][curr.year].push(curr)
return acc
}, {})
複製程式碼
注意上面的程式碼中不包括區域。這是因為,和型別、年份不同,可以同時選中多個區域。這使得預先根據區域分組毫無作用,要是這麼做了,我們還得合併它們。
考慮到這一點,下面是新版的根據選定型別、年份、區域獲取 currentData
的一行程式碼。由於我們將資料的查詢範圍限定於當前型別和當前年份,我們知道陣列中資料項數目的最大值等於國家數(小於 200),這就比選項二中的 .filter
實現要高效很多。
let currentData = dataset[currentType][currentYear].filter(d => currentRegions.includes(d.region))
複製程式碼
最後一步是獲取不同型別、年份、區域的陣列。為此我將使用 .map
和集合。下面是一個例子,獲取一個陣列,包含資料中所有不同區域。
// `.map` 將提取特定物件屬性值(例如,區域)到新陣列
let regions = data.map(d => d.region)
// 根據定義,集合中的值是唯一的。重複值將被剔除。
regions = new Set(regions)
// Array.from 根據集合建立陣列。
regions = Array.from(regions)
// 單行版本
regions = Array.from(new Set(data.map(d => d.region)))
// 或者使用 ... 操作符
regions = [...new Set(data.map(d => d.region))]
複製程式碼
使用同樣的方法處理型別和年份。接著就可以根據陣列的值動態建立過濾介面。
最終的重構程式碼
最終我們得到了如下的重構程式碼,未來資料集新增年份無需手工改動。
// 型別、年份、區域
const types = Array.from(new Set(data.map(d => d.type)))
const years = Array.from(new Set(data.map(d => d.year)))
const regions = Array.from(new Set(data.map(d => d.region)))
// 根據型別和年份分組資料
const dataset = data.reduce((acc, curr) => {
acc[curr.type] = acc[curr.type] || {}
acc[curr.type][curr.year] = acc[curr.type][curr.year] || []
acc[curr.type][curr.year].push(curr)
return acc
}, {})
// 根據選中內容獲取資料
let currentData = dataset[currentType][currentYear].filter(d => currentRegions.includes(d.region))
複製程式碼
結語
調整格式僅僅是重構的一小部分,“重構程式碼”常常意味著重新構想實現和不同部分之間的關係。解決問題有多種方式,所以重構不容易。一旦找到有效的解決方案,可能不太容易去考慮不同做法。確定哪種解決方案更好並不總是顯而易見的,可能很大程度上取決於程式碼的上下文,甚至,個人偏好。
想要更好地重構程式碼,我有一條簡單的建議:閱讀更多程式碼。如果你在團隊裡,積極參與程式碼審閱。如果有人讓你重構程式碼,問下為什麼並且嘗試去理解其他人處理問題的方式。如果你單獨工作(就像我剛開始工作時一樣),留意同一問題的不同解決方案,同時搜尋最佳實踐指南。我強烈推薦閱讀 Jason McCreary 的 BaseCode,編寫更簡單、更可讀程式碼的指南,其中包含很多真實世界的例子。
最重要的是,認可這一事實,有時你會寫下糟糕的程式碼,重構(讓它變得更好)是成長的標誌,值得慶祝。