我重構了一遍第一份工作寫的程式碼

NewFrontendWeekly發表於2019-06-24

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))
複製程式碼

儘管這一行程式碼效果不錯,但我不建議在我們的例子中使用它,原因有兩個:

  1. 使用者每次選擇篩選條件都會執行該方法。取決於資料集的大小(別忘了資料集將隨著年份增長),這可能影響效能。現代瀏覽器很高效,效能損失也許極小,但如果我們明知使用者每次只能選擇一種型別和一個年份,我們可以在一開始就分組資料,主動提升效能。
  2. 這一選項不會提供可供選擇的型別、年份、區域列表。有了這些列表,我們可以使用它們動態生成使用者介面的骨架,無需手動建立並更新。

硬編碼的下拉選單

沒錯,我當年硬編碼了選項。每次新增一個年份,我需要記住同時更新 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 McCrearyBaseCode,編寫更簡單、更可讀程式碼的指南,其中包含很多真實世界的例子。

最重要的是,認可這一事實,有時你會寫下糟糕的程式碼,重構(讓它變得更好)是成長的標誌,值得慶祝。

相關文章