編寫可讀性程式碼的藝術--萬字總結,看到即學到

發表於2023-09-20

編寫可讀性程式碼的藝術

最近閱讀了《編寫可讀程式碼的藝術》一書,感覺很有收穫,現在結合自己的理解再來總結編寫可讀性程式碼的技巧,會用 js 舉例,並且針對日常開發中常見的程式碼異味給出改我的進建議。

學會該書的大部分技巧並付諸實踐,不能保證保證你寫出完美的程式碼,但是能保證你寫出能讀的程式碼,保證你的碼德下限。

可讀性 = 可測試性 = 設計良好 = 可維護 = 程式碼質量 = ...,衡量程式碼的各種指標,都是正相關的,開發程式的大部分時間是在閱讀程式碼(自己的和他人的),所以保證了可讀性,其他指標也不會差。

衡量程式碼的可讀性

大部分程式設計師,全靠自覺、靈感和經驗編寫程式碼,往往很難一步到位寫出可讀性高的程式碼。

我看過一些前端組長(豬長)、前端架構師(加狗屎)寫的程式碼,簡直慘不忍睹,讓人有罵孃的衝動。

比如這種:

除了手寫 render 函式,毫無可讀性之外,行寬過大,編輯器都出現捲軸了,也會讓人不想讀。

不可讀的程式碼往往都會有這樣或那樣的問題。

軟體的成本由開發成本和維護成本組成,而往往維護成本要遠高於開發成本,維護成本主要花在理解程式碼和修改程式碼上,可讀性高、設計良好的程式碼可大大降理解和修改程式碼的成本。

可見程式碼的可讀性至關重要。

如何衡量程式碼的可讀性呢?

程式碼可讀性和程式碼被他人理解的時間成正比,即他人理解程式碼的時間越少,可讀性越高。

如何定義他人?根據我的經驗,高年級本科生或者研究生或者工作 2 年內的程式設計師,又或者,你的一個普通同事。

同事是和你協作的人,讓和你協作的人能快速地理解你的程式碼,至關重要。

如何定義理解?
理解是一個非常高的標準。真正理解了,就應該能改動它、找出缺陷且明白它與外部程式碼互動的規則

可讀性的標準可以降低嗎?

當可讀性和其他約束條件衝突時,比如效能、程式碼行數,如何取捨?

大部分情況,可讀性優先,那些可能會經常被他人閱讀、改動的程式碼,可讀性再怎麼強調都不為過。

編寫可讀性的程式碼很難嗎?

編寫可讀性高的程式碼很難。如果一個程式設計師放棄了可讀性這一目標,那麼他一定不會成為更好的程式設計師。
編寫可讀性高的程式碼,前人已經總結了諸多技巧和經驗,學習並實踐這些經驗,可以讓程式碼的可讀性不至於很糟糕。

命名的技巧

好的程式碼,從好的命名開始。

把資訊放在名字裡

選擇專業的詞彙,使得意思更加清晰和精確。

比如 fetchDatagetData 好;

單詞更多的選擇
senddeliver、dispatch(派發)、announce(宣告)、distribute(分配、廣播)、route(按照指定路徑投送)
findsearch、extract(提取)、locate(定位)、recover(還原)
startlaunch、create(建立)、begin、open
makecreate、setup(就緒)、build、generate(生成)、compose、add、new
技巧:如果存在兩個相對或者相反的操作,取名使最好能讓他人知道一個,便能直接的猜到有一個操作。

startstop 相對, pauseresume 相對。

技巧:取名符合生活或者數學的習慣。比如 count 一般為正數, num 使可正可負, complexNumber 複數。
避免寬泛的名字,除非有特別的理由

避免使用 tmpretval 這類寬泛的名字。好的名字應該描述變數的目的或者它承載的值tmp_file 比如 tmp 好。

const euclidean_norm = arr => {
  let retval = 0
  for (let i = 0 i < arr.length; i++) {
    retval += arr[i] * arr[i]
  }
  return Math.sqrt(retval)
}

這函式在累加陣列中元素的平方,把 retval 改成 sum_squares 更好。

sum_squares 包含它的目的,如果累加程式碼不小心寫成 retval += arr[i] ,就非常容易發現缺陷。

使用 tmp、it 和 retval 等這些空範的名字時,你需要有足夠好的理由。

使用具體的名字,避免抽象的名字

力求把實體描述得更具體,而非抽象,越具體,會越明確。
serverCanStart() 沒有 canListenOnPort 具體。

技巧:名字裡名字和形容詞時,會比較具體。感覺不夠具體時,不妨加入名字和形容詞。
使用字首或者字尾把重要的資訊附加到名字上

常見的可以附加的資訊:
<br/>
① 單位

函式引數引數帶單位
start(delay)delay -> delay_secs
createCache(size)size -> size_mb
throttleDownload(limit)limit -> max_kbps
rotate(angle)angle -> degrees_cw
angle 角度,單位度。cw(circular_measure),弧度。

在一個專案遇到一個函式的引數物件屬性為 rotate :

someFunction({
  rotate: rotate_value
})

它接收一個從後臺介面返回的值,採用的單位是度,產品經理一直說不對,但是我們也找不到問題,就把這個問題放了很久。產品經理有一天又去找人確認是否正確,給的答覆沒問題。<br/> <br/>
產品又來找我,說那邊反饋資料對的。我才猛然想到採用的單位是不是弧度,於是我把角度轉成了弧度,產品就說對了。如果給屬性加上單位,那麼就一眼看出來了。

我們這個專案還涉及角度的方向,最後幾經測試,需要做兩件事情:角度轉為弧度;三維地圖下,旋轉方向為逆時針。

把單位加入引數。

someFunction({
  route_cw: cw_value // 順時針的弧度 anticlockwise/counterclockwise 逆時針
})
如果把三維地圖下逆時針也加入,引數就長了,選擇不加,可透過函式註釋的方式告知使用者。

② 變數存在危險或者意外

情形變數更好的選擇
純文字的密碼passwordplaintext_password
使用者提供的註釋,需要轉義後才能顯示commentunescaped_comment
安全的 html 程式碼htmlhtml_safe\ html_escaped
變數作用域大小決定名字長短
小作用域,使用段名字,大作用域,使用長名字。
const numList = [1, 2, 3]
let sum = 0
for (let i = 0; i < array.length; i++) {
  sum += array[i];
}
i 的作用域很小,即使取名很短,也一眼能看出它的目的。

再看一例:

for (i = 0; i < clubs.size(); i++) {
  for (j = 0; j < clubs[i].members.size(); j++) {
    for (k = 0; k < users.size(); k++) {
      // do something
    }
  }
}
i j k 的作用域也很小,但是這裡涉及到多層巢狀,當巢狀裡操作複雜時,就很容易混淆它們,此時可以適當讓各自的下標變長,以做區分。
for (club_i = 0; club_i < clubs.size(); club_i++) {
  for (member_i = 0; member_i < clubs[club_i].members.size(); member_i++) {
    for (user_i = 0; user_i < users.size(); user_i++) {
      // do something
    }
  }
}
長名字不好打,而不使用?

有人會因為長名字需要輸入更多字元而不想使用,現在而 IDE、AI 程式設計助手已經能自動補全了,不存在這個問題。

避免隨意縮寫,造單詞。

隨意縮寫很讓人費解。

技巧:開啟編輯器拼寫檢查,可防止寫錯單詞。

眾所周知的縮寫是可以使用的,比如

# 名詞和形容詞
button -> btn
background -> bg
backgroundColor -> bgColor
image -> img
document -> doc
string -> str
number -> num
evaluation -> eval
index -> i
column -> col
hexadecimal -> hex
binary -> bin
octal -> oct # 八進位制
decimal -> dec # 十進位制
to -> 2 # to 在中間可,可縮寫為 2, 比如 bin2dec 二進位制轉為十進位制
address -> addr
application -> app
average -> avg # 平均數
command -> cmd
organization -> org # 組織
original -> orig
destination -> dest/des
resource -> res # 資源
source -> src # 源資料
ascending -> asc # 升序
descending -> desc # 降序
device -> dev # 裝置
different -> diff # 區別
directory -> dir # 目錄
environment -> env # 環境
error -> err
library -> lib # 庫
information -> info # 資訊
message -> msg
number -> num
object -> obj
parameter -> param
parameters -> params # 實參 引數
arguments -> args # 實參 引數
argument -> arg # 形參 引數
package -> pkg # 包 n 打包 v
position -> pos # 位置
configuration -> config # 配置
array -> arr
previous -> pre
next -> next
middle -> mid # 中間
current -> cur # 當前的
password -> pwd
reference -> ref
summation -> sum
system -> sys # 系統
temporary -> temp # 臨時 或者 tmp
text -> txt # 純文字
variable -> var
character -> char
status -> stat # 狀態
standard -> std # 標準
trigger -> trig # 觸發
user -> usr # 使用者
length -> len # 長度
administrator -> adm # 管理員
database -> db # 資料庫
coordinates -> coord # 座標
longitude -> lng # 經度
latitude -> lat # 維度
angle -> ng # 角度
circularMeasure -> cw # 弧度
dictionary -> dic # 字典
link -> lnk # 連結
window -> win/wnd # 視窗
horizontal -> horz # 水平
public -> pub

# 動詞
delete -> del
decrease -> desc # 減少
increase -> inc # 增加
increment -> inc # 增加
execute -> exec # 執行
maximum -> max # 最大
minimum -> min # 最小
calculate -> calc
initialization -> init # 初始化的
initialize -> init # 初始化
generate -> gen # 生成
synchronization -> sync
asynchronization -> async
control -> ctr # 控制
escape -> esc # 退出
insert -> ins # 插入
extend -> ex/ext # 擴充套件
vertical -> vert # 垂直
instance -> ins # 例項
multiply -> mul # 乘
禁止拼音縮寫

拼寫的縮寫意思很多。

# 書本
shuBen -> sb
# 想表示 book,卻變成傻逼
# 價格
jiaGe -> jg
# 想表示 price,卻變成雞哥

使用縮寫的經驗法則:當新成員能理解這個縮寫時,即可使用。

刪除沒用的詞

拿掉某個詞,不會損失資訊,就刪除它。
比如

convertToStr -> toStr # 意思一樣
doStop -> stop #

有多個意思一樣的動詞,往往只需保留一個。

使用格式表示含義

把特定的格式放在變數裡,使得一眼能看出不同變數的區別。
常用的格式有: _-#$大小寫 等。

const YYYYMMDD = '2023-03-23' // 這個變數,就能一眼推斷出表示一個時間

遵循程式語言或者團隊的約定,且保持一致。
對前端來說,建構函式使用大坨峰(PascalCase/UpperCamelCase),普通函式使用小駝峰(lowerCamelCase)。

const person = new Person()
const age = getAge()

jquery 物件使用 $ 開頭,事件處理器使用 onXxx 或者 handleXxx

技巧: onXxx 作為屬性或者引數,而 handleXxx 作為函式,會更好。
const $allImages = $('img')

DOM 的 ID 屬性值使用 _ 連線,類名使用 - 或者 --

<span class="icon-container" id="icon_edit"></span>

vue 和 react 元件中: _ref 表示模板引用:

const div_ref = ref() // 表示引用一個 div
const com_ref = ref() // 表示引用一個 元件

不要使用有歧義的名字

名字容易歧義嗎?當命名時,多問問自己,主動找到可能歧義的詞語。
filter 就是一樣容易有歧義的詞語,可以是挑出一些,也可以是刪除一些,剩下一些。

minmax 表示極限
const CART_TOO_BIG_LIMIT = 10 // 購物車最多10個物品
const MAX_ITEMS_IN_CART = 10 //  ✅  better
firstlast 表示包含末尾
const str = 'abcd' // first = 0  last = 3 包含 d
start/beginend 表示不包含末尾
const str = 'abcd' // start = 0  end = 3 不包含 d
更好地給變數加否定字首

英文中表示否定字首的有很多,比如 undedis ,如何選擇呢?

字首字尾描述例子
non名詞、動詞、形容詞表相反的意思editedRows->nonEditedRows
de動詞表相反的動作encodeURIComponent->decodeURIComponent
unable 結尾的形容詞或者一動詞 founded->unfounded known -> unknown

non 最通用,搞不清楚,一律 non 就行了。

參考: How to Use the Prefixes “Dis” and “Un” Correctly

給布林變數命名

當命名布林變數或者返回布林值的函式命名時,要確保閱讀者一眼明確(返回)值的範圍是 true 或者 false

const read_password = true // bad ❌ 兩種理解:1. 讀取密碼,動作 2. 已經讀取了密碼
const need_password = true // ok ?
const has_password = true // best  ✅
const edit = true // bad ❌
const shouldEdit = true // ok ?
const enableEdit = true // better ?
const canEdit = true // best  ✅
透過使用 iscanshouldenablehas 字首或包含表示布林變數。

這些詞在英語中常常使用來引導疑問句,而疑問句的回答一般是 yes 或者 no ,對應 true 或者 false

字首常見的搭配描述例子補充
isis + 形容詞是否具備某種屬性或者狀態isOk、isHidden單獨的形容詞,也能猜到是布林變數,但是不夠明確,比如 hidden、sortable
isis + 動詞過去式是否完成了某個動作或者某件事isConnected、isFilled、isSorted
isis + 名詞是否是某個東西isAdmin、isVIP、isEditing、isProUser
hashas + 名字是否存在某物hasKey、hasError、hasOnlineUser名字一般使用單數,複數也無傷大雅
cancan + 動詞原型是否啟用某種能力 使其具備某種功能canEdit、canRemove
enableenable + 動詞原型是否啟用某種能力 使其具備某種功能enableEdit、enableLoad還能用於命名函式
shouldshould + 動詞原型是否執行某個操作,一般用於命名函式shouldRemoveBlank、shouldFillTable

不關注數量。全部、所有,使用 every 或者 each, 比如 isEachUserActive,isEveryOrderClosed,存在至少一個,搭配 some、any 或者 has, 比如 isSomeUserLogin、isAnyUserOnLine、hasOnlineUser。

或者直接使用複數,雖然英文語法錯誤,但是無傷大雅,比如 isOrdersClosed。不要使用 are。

// `is` + 形容詞 或者直接形容詞,具有某寫屬性
// `is` + 動詞過去式,表示完成了某個都行 做完xxx
const isGood = true //
const good = true // ok ? 但是不夠明確
const isFinished = true
const sortable = false // ok
const isSorted = true // ok
const hidden = true // ok
// `is` + 名字 表示是不是某個東西
const isSon = true
const isRiver = true
// 名詞 + `is` + 形容詞
const orders_is_closed = true
// 也可以 `is` + 名詞 + 形容詞
const isOrdersClosed = true

// `has` + 名詞 存在某些東西
const hasKey = true // 存在 key
const hasValue = true // 有值

// `should` + 動詞原型 是否需要執行某個動作
const shouldCloseDB = true
// `can` + 動詞 具備某種能力
const canEdit = true // 有編輯許可權
// `enable` + 動詞, 表示是否開啟某種能力 是具備某個能力
const enableEdit = true
const enableRemove = true
應該關注人稱、單複數和時態的變化嗎?

只需要關注動詞時態,動詞和 hasis 搭配,使用過去式就行,和 shouldcanenable 搭配,使用原型。

技巧:should 往往用來命名返回布林值的函式。
function shouldRemoveBlank(remove) {
  //
}
避免使用反義

不使用反義的詞,比如 nonotdisabledneverwon'tdont ,因為含有反義,容易有雙重否定,認知負擔大。

const hasNoValue = arr.length === 0
// bad ❌
if (hasNoValue) {
  // do something
}
if (!hasNoValue) {
  // 不是沒有值,產生雙重否定
  // do something
}

改成這樣,更加容易理解,因為不會產生雙重否定:

// ok ?
const isEmpty = arr.length === 0
if (isEmpty) {
  // do something
}

陣列為空,執行條件,也符合直覺:

// best ✅
const hasValue = arr.length > 0
if (!hasValue) {
  // do something
}

沒有值時,執行條件,非常自然。

全稱變數和存在變數的命名

數學上表示所有的的量詞叫我全稱量詞,表示存在的量詞叫存在量詞。如何命名呢?

舉例說明:

// 全稱變數 對應數學上的全稱量詞
const isUsersActive = users.every(user => user.isActive) // ❌ 英文語法錯誤  is 和複數搭配了, 模糊
const areUsersActive = users.every(user => user.isActive) // ❌ 自定義字首
const isEachUserActive = users.every(user => user.isActive) // ✅ better 語法正確
const isEveryUserActive = users.every(user => user.isActive) // ✅ best 語法正確 every 符合 every 函式的語義
// 存在量詞
const isUsersActive = users.some(user => user.isActive) // ❌ 英文語法錯誤  is 和複數搭配了, 模糊
const isAtLeastOneUserActive = users.some(user => user.isActive) // ❌ 太長
const isOneUserActive = users.some(user => user.isActive) // ❌ 只有一個,和語義不符
const isAnyUserActive = users.some(user => user.isActive) // ✅ better
const isSomeUserActive = users.some(user => user.isActive) // ✅ best 語法正確 some 還反映到 some 語義上

取反的情況

const isAnyUserOffline = users.some(user => !user.isOnline)

if (isAnyUserOffline) {
  // 有人離線
}
// 使用 every
const isEveryUserOnLine = user.every(user => user.isOnline)
if (!isEveryUserOnLine) {
  // 不是每個人都線上
}

第一種更加好理解,而且資料量大的時候,效能更好。

如果可能,布林變數的預設值為 false ,尤其是在函式引數中。

很多 html 的屬性就是這樣的。

autofocus
checked
disabled
readonly
required
selected
draggable
formnovalidate
nowrap
參考

Naming guidelines for boolean variables<br/>

why-am-i-seeing-more-boolean-naming-conventions-where-is-is-used-as-the-first<br/>

Tips on naming boolean variables - Cleaner Code

採用使用者的期望的名字

有些名字之所以會讓人誤解,是因為人們對它們的含義有先入為主的印象,就算你的本意並非如此。此時,你最好放棄這個名字,而採用一個不會被誤解的名字。
<br/>
很多程式設計師都習慣了使用 get* 作為輕量訪問器的用法,它只是簡單的返回一個內部成員變數,如果違背了這個習慣,就會誤導使用者。

class StatisticCollector {
  addSample(x) {
    //
  }
  getMean() {
    // 遍歷所有的 samples 返回平均數
  }
}

上述例子中, getMean 的實現是遍歷所有資料,然後求和,就個數,再求平均。如果有大量資料,就有很大的代價。但是使用者會以為沒有什麼代價,就隨意呼叫。 computeMean 或者 calcMean 就個更加好,它更想有代價的操作。
然而,很多英語單詞是多義的,要做到不讓人誤解或者誤用,很難,很多程式設計師不是英語母語者,更加難了。好在我有一個做法,可以緩解它:

經驗做法:不希望被頻繁使用的變數,採用長一點的名字,難書寫的名字,可以降低這個變數的使用頻率。

比如 vue3 的 getInstance ,就是一個不那麼常見的名字,vue3 的開發者們不希望使用者頻繁使用它。

美觀的格式

好的程式碼在格式上應該具備美觀的排版合理的留白符合認知的順序。這一節探討如何使用留白、對齊和順序,讓程式碼更加易讀。
<br/>
<br/>
排版的三原則:
<br/> 01. 使用一致的佈局,讓讀者快速習慣這種風格;<br/> 02. 讓功能相似的程式碼排版相似;<br/> 03. 程式碼按照一定的邏輯分組,形成程式碼塊。<br/>

為什麼美觀非常重要?

有一個類:

class StackKeeper {
  // A class for keeping track of a series of doubles
  add(d) {
    /*
        and methods for quick statistics about them.
       */
  }
  _minimum = 10
  /* how many so
     for
     */
  average() {}
}

相比一個整潔的版本:

// A class for keeping track of a series of doubles
// and methods for quick statistics about them.
class StackKeeper {
  add(d) {}
  _minimum = 10 // how many so for
  average() {}
}

你需要更多的時間去理解排版混亂的版本。

程式設計的大部分時間花在閱讀程式碼上,閱讀得越快,理解越快,程式碼越容易使用。

讓程式碼變得難以閱讀的手段之一:加入無關的混亂的註釋,讓程式碼排版混亂是常用的手段,希望你不要採用。

讓換行保持一致和緊湊

有一個類 TCPConnectionSimulator ,評估程式在不同網路速度下的行為,建構函式有 4 個引數:

網速 -- kbps
平均時延 -- ms
時延抖動 -- ms
丟包率 -- %

呼叫三個不同的例項:

class PerformanceTester {
  wifi = new TCPConnectionSimulator(
    500, /*kbps*/
    80, /*millisecs latency*/
    200, /*jitter*/
    1 /*packet loss  %*/ )
  t3_fiber =
    new TCPConnectionSimulator(
      45000, /*kbps*/
      10, /*millisecs latency*/
      0, /*jitter*/
      0 /*packet loss  %*/
    )
  cell = new TCPConnectionSimulator(
    100, /*kbps*/
    400, /*millisecs latency*/
    250, /*jitter*/
    5 /*packet loss  %*/
  )
}

t3_fiber 後的換行很突兀,違背了相似的程式碼看上去也要相似,且引數的註釋產生了更多換行,很不美觀。
調整一下:

class PerformanceTester {
  // new TCPConnectionSimulator(throughput,latency,jitter,packet_loss)
  //                               [kbps]    [ms]   [ms]   [percent]
  wifi = new TCPConnectionSimulator(500, 80, 200, 1)
  t3_fiber = new TCPConnectionSimulator(45000, 10, 0, 0)
  cell = new TCPConnectionSimulator(100, 400, 250, 5)
}

把註釋都放在上面了,更加緊湊,引數排成一行,引數像表格一樣,看上去像一個緊湊的表格。

需要時使用列對齊

整齊的變和列可快速瀏覽,而且很容易發現程式碼中的拼寫錯誤,因為排整齊了,就有了對比。

把引數呼叫排整齊。

checkFullName('Doug Adams', 'Mr. Douglas Adams', '')
checkFullName(' Jake Brown', 'Mr. Jake Brown III', '')
checkFullName('No such Guy', '', 'no match found')
checkFullName('John', '', 'more than one results')
因為我的編輯器做不到這一點,可看圖片的而情況:

列對齊

應該使用列對齊嗎?

列對齊給程式碼提供了可見的邊界,也符合讓功能相似的程式碼看起來也相似。
<br/>
<br/>
但是有些程式設計師不喜歡,主要兩個原因:
<br/> 01. 列對齊,有的編輯器不支援; <br/> 02. 列對齊會導致額外的改動,讓程式碼的歷史記錄變得混亂。<br/>

經驗法則:列對齊不必強求,保證專案成員之間一致即可。

讓程式碼的排序有意義

要是程式碼的順序不影響功能,不要隨機的排序,而是選一個有意義的順序,讓認知負擔更小。
<br/>
常見的有意義的排序: <br/>

  1. 物件中的 key 的順序和 html 中表單的順序一致;<br/>
  2. 使用者最關心的在前,不太關心的在後,比如 css 屬性;<br/>
  3. 必需的在前,可選的在後,比如後端介面驗證前端提交的資料;<br/>
  4. 和程式碼的生命週期一致,有些程式碼有生命週期,比如 vue 元件,呼叫這些宣告週期鉤子函式時,最好按照生命週期的順序呼叫;<br/>
  5. 按照字母排序。

    <br/>
    <br/>
    以第二條排序建議為例,看看順序是如何影響可讀性的:
    
.page {
  flex-direction: column;
  box-shadow: 0 1px 5px 0 rgb(0 0 0 / 0.2), 0 2px 2px 0 rgb(0 0 0 / 0.14),
    0 3px 1px -2px rgb(0 0 0 / 0.12);
  flex: 1;
  display: flex;
  align-items: center;
}

這一個段 css 程式碼,有一點前端開發經驗的人,就會發現屬性的出現順序非常奇怪: displayflex-direction 相關的屬性,中間卻多了一個 box-shadow ,display 的值為 flex 時,flex 相關的屬性才會生效,但是 flex-direction 卻在最前面,很費解。

在閱讀 css 程式碼時,我們關注的順序是:定位 -> 尺寸 -> 動畫 -> 字型 -> 背景 -> 其他,因為這些屬性的順序對佈局影響依次減弱。

按照這個順序,重新排版上面的程式碼:

.page {
  display: flex;
  flex: 1;
  align-items: center;
  flex-direction: column;
  box-shadow: 0 1px 5px 0 rgb(0 0 0 / 0.2), 0 2px 2px 0 rgb(0 0 0 / 0.14),
    0 3px 1px -2px rgb(0 0 0 / 0.12);
}

這樣就好理解多了,可以快速知道哪些屬性影響了頁面的佈局。

關於 css 屬性順序的最佳實踐:使用 stylelintstylelint-order外掛讓屬性的順序按照對佈局的影響大小排列。

在看一個 js 例子:

import hoverIntent from 'hoverintent'
import {
  ref
} from 'vue'
type InAndOut = {
  in ? : (target: HTMLElement) => void
  out ? : (target: HTMLElement) => void
}
const options = {
  in: target => undefined,
  out: target => undefined,
}
/**
 * 滑鼠移入移出 hook,可設定滑鼠停留時間。
 * 為何不使用 hover 事件:hover 事件瞬間觸發,不能設定停留時間
 * @param target 目標元素
 * @param inAndOut 移入移除回撥
 * @param inAndOut.in 移入回撥
 * @param inAndOut.out 移出回撥
 * @param updateTarget 是否更新 hover 的目標元素
 * @param opts hoverIntent配置
 * @link https://www.npmjs.com/package/hoverintent
 */
function useHover(inAndOut: InAndOut = options, updateTarget = false, opts = undefined) {
  const isHover = ref(false)
  const target = ref(null)
  watch(
    target,
    (target, oldTarget) => {
      if (target && target !== oldTarget) {
        detectHover(target)
      }
    }, {
      flush: 'post'
    }
  )

  function detectHover(target) {
    const _target = isRef(target) ? target.value : target
    if (!_target) return
    const {
      in: inTarget, out
    } = inAndOut
    opts &&
      hoverIntent(
        _target,
        () => {
          inTarget?.(_target)
          isHover.value = true
        },
        () => {
          out?.(_target)
          isHover.value = false
        }
      ).options(opts) !opts &&
      hoverIntent(
        _target,
        () => {
          inTarget?.(_target)
          isHover.value = true
        },
        () => {
          out?.(_target)
          isHover.value = false
        }
      )
  }
  return {
    isHover,
    setHoverTarget: ele => {
      if (!updateTarget && target.value) return
      target.value = ele
    },
  }
}
export {
  useHover
}

閱讀一個函式時,我們更加關注函式的名字引數返回值,因為這三個要素決定了函式的用法。useHover 函式沒有把返回值放在前面,需要滾動到後面才知道返回值,把返回值提前,讓閱讀者快速知道它的存在。

function useHover(inAndOut: InAndOut = options, updateTarget = false, opts = undefined) {
  const isHover = ref(false)
  const target = ref(null)
  watch(
    target,
    (target, oldTarget) => {
      if (target && target !== oldTarget) {
        detectHover(target)
      }
    }, {
      flush: 'post'
    }
  )
  return {
    isHover,
    setHoverTarget: ele => {
      if (!updateTarget && target.value) return
      target.value = ele
    },
  }

  function detectHover(target) {
    const _target = isRef(target) ? target.value : target
    if (!_target) return
    const {
      in: inTarget, out
    } = inAndOut
    opts &&
      hoverIntent(
        _target,
        () => {
          inTarget?.(_target)
          isHover.value = true
        },
        () => {
          out?.(_target)
          isHover.value = false
        }
      ).options(opts) !opts &&
      hoverIntent(
        _target,
        () => {
          inTarget?.(_target)
          isHover.value = true
        },
        () => {
          out?.(_target)
          isHover.value = false
        }
      )
  }
}

這個例子中,return 語句之前的程式碼只是一個函式,程式碼行輸不是很多,把 return 語句放在最後,可讀性提高不大,但是當 return 語句之前有很多程式碼時,這種改變,可讓閱讀快速瞭解到返回值,而不用滾動到下面。

js 使用 function 定義函式,定義可在後面,呼叫在前面,使用 const let 和函式宣告定義函式,不支援呼叫在前,宣告在後。

經驗法則:使用 function 定義函式,因為它的宣告可以在呼叫之後,function 也一眼能讓讀者知道它是一個函式,而不是其他型別的變數。

經驗法則:一個 js 檔案有多於3 個以上的匯出,統一放在檔案底部匯出。

經驗法則:相似的程式碼,選擇一個有意義的順序,並保持一致。一段程式碼中 ABC ,在另一段程式碼中 CAB 會降低可讀性。這樣的做法,也符合讓功能相似的程式碼看起來也相似的原則。

把程式碼拆分成段落

把文字分段的原因: 01. 分段是一種把相似的想法放在一起,與其他想法分開的方式,方便組織寫作思路; 02. 分段提供了可見的邊界,沒有邊界,讀者很容易不知道讀到哪兒了; 03. 分段便於段落之間導航。
類似的原因,程式碼也應該分段。

個人風格和一致性

相當一部分程式碼格式,是程式設計師的個人偏好,比如類的大括號是否應換行,縮排是兩個 2 個 tab,還是 4 個空格等,但是兩種偏好不應該同時出現在程式碼中,會降低可讀性。

一致風格比正確的風格更加重要。

關於格式的最佳實踐

專案開始之前,和團隊成員討論,指定編碼規範,不花精力制定規範的,採用社群的最佳實踐即可,並藉助 eslint + prettier + stylelint + gitHook 等工具在程式碼提交前,檢查程式碼質量,統一格式。
當有新成員加入時,先讓他熟悉規範。

言簡意賅的註釋

當你根據直覺寫下一段程式碼,幾周甚至幾天後在閱讀它時,不會有你寫下程式碼時的直覺。註釋可以在程式碼中記錄寫程式碼時的想法,讓閱讀程式碼的人快速理解你的想法。

註釋的目的是幫助讀者快速瞭解作者寫程式碼時的想法。

何時不需要註釋

註釋會佔用閱讀時間和螢幕空間,應該編寫必要的註釋,錯誤的或者不必要的註釋,不能給出有用的資訊,反而可能誤導他人。

經驗法則:能從程式碼本身快速推斷的資訊,不需要註釋。

不要給不好的名字新增註釋

註釋不應掩蓋不好的命名,而是應該把名字改好。

好程式碼 > 壞程式碼 + 好註釋 >> 壞程式碼 + 壞註釋。

需要怎樣的註釋

好的程式碼往往能反映你寫下程式碼時的想法,以幫助他人理解你的想法

加入程式碼評論

在註釋中,加入一些有價值的見解,可以幫助他人理解為何這麼寫。
一些例子:

// 出乎意料的是,對於這些資料使用二叉樹比雜湊錶快 40%
// 哈比運算的程式碼比左/右比較大得多
這個註釋告訴了讀者,為何使用使用二叉樹,防止它們為謂的最佳化而浪費時間。
// 作為整體,可能會丟掉幾個詞。目前沒發現bug,想要 100% 解決,太難了。
沒有這段註釋,你的隊友可能會過於負責,嘗試修復它。
// 這個函式很混亂
// 也許需要另外一種方案,不如提取部分程式碼到新的函式中
這個註釋,給出程式碼的問題和改進建議。

為程式碼中的瑕疵寫註釋

專案一直在迭代,程式碼一直在演進,難以存在瑕疵。應該把這些瑕疵記錄下來,方便他人或者自己改進。

標記意義
TODO還沒完成的事件
BUG存在缺陷
FIXME需要改進的程式碼
NOTE讀者需要特別留意
經驗法則:以上標記可藉助編輯器,可註釋顯示不同的顏色,方便快速閱讀和統計,比如 vscode 的Todo Tree擴充套件。

todo-tree使用效果

在編輯器左側顯示不同的 icon,狀態列也有統計資訊。

給常量新增註釋

// as long as it is >= 2 * num_processors, its enough.
const MUN_THREADS = 8

有些常量名字本身足夠見名知義,可不加註釋。

const SECONDS_1DAY = 24 * 60 * 60;

站在讀者的角度給出註釋

意料之中的提問

他人閱讀你的程式碼時,可能會產生各種疑問:為何這樣?為何不使用簡便的寫法?
當你預料到讀者的問題時,最好在註釋中給出回答。

公佈可能的陷阱

在註釋中說出可能的陷阱,可以防止使用者誤用。

全域性的註釋

全域性的註釋,可以幫助專案新成員快速瞭解專案或者檔案。

// 這個檔案包含了一些輔助函式,為我們的檔案系統提供了邊界的介面
// 它處理了檔案許可權以及其他基本細節
經驗法則:檔案級別的註釋,告訴這個檔案的目的。vscode 可使用koroFileHeader 外掛快速插入檔案頭註釋。

使用效果:

檔案頭註釋

總結性註釋

在大塊程式碼的之間寫一些總結性註釋,可幫助讀者快速瞭解程式碼之間的聯絡。

如何寫好註釋

你可能聽過這種說法: 註釋應該回答為什麼,而不是做什麼。這種總結過於粗暴。

能幫助讀者快速理解程式碼的註釋,都可以寫。

寫好註釋需要的技巧

讓註釋精簡
// This int is the CategoryType
// The first float in the inner pair is the 'score',
// the second is the weight.
typedef hash_map<int,pair<float,float> > ScoreMap

更加精簡的註釋

// CategoryType -> (score,weight)
typedef hash_map<int,pair<float,float> > ScoreMap
避免指代不明
說明特殊的輸入和輸出

在函式註釋中說明特殊的例子。

簡化控制流程

好的命名、美觀的排版和精簡的註釋,只是程式碼表面上的改進,程式碼的控制結構對可讀性也有巨大影響,最佳化程式碼程式碼結構,讓程式碼具備更好的設計,可以有效提高可讀性。

條件結構中的引數順序

下面的條件語句哪個更加易讀?

if (length > 10) {
  //
}

還是

if (10 < length) {
  //
}

第一個更加易讀,因為它和英文和中文用法一樣,"長度大於 10"。
通用的指導原則:

比較的左側比較的右側
變數,不變的變化的用來做比較的表示式,它的值更加傾向於常量

最佳化 if else 的順序

下面兩種等價程式碼,哪兒更加易讀?

if (a === b) {
  //  do a
} else {
  // do b
}

還是

if (a !== b) {
  // do b
} else {
  // do a
}

第一種更加可讀,因為它先處理正邏輯。
if else 順序問題,經驗法則:

  • 首先處理正邏輯;
  • 先處理簡單情況;
  • 先處理有趣的或者意外的情況。
    三種法則衝突時,只能靠你的判斷了。

避免複雜的三目運算子

?:if-else 簡寫形式,複雜的三目運算子會降低可讀性。

避免使用 do {} while 迴圈

do while 的奇怪之處,是先執行程式碼塊,再判斷繼續的條件。通常來說,先判斷條件,再執行程式碼塊,更加符合直覺。
C++ 的建立者 Bjarne Stroustrup 說:

我的經驗是, do 語句是困惑的來源...... 我傾向於把條件放在前面我能看到的地方。其結果是,我傾向於避免使用 do 語句。

善用提前返回

經驗法則:把判斷簡單條件,提交返回,複雜操作放在後面,避免頭重腳輕。
function doSomeThing(value) {
  if (!value.includes()) return null
  // do something
  // ...
  return someValue
}

減少 try...catch 語句

很多程式語言都有 try...catch ,用來處理丟擲的錯誤,應該要減少使用它,它有諸多缺點:

  1. 讓函式變得不純;
  2. 中斷程式控制流,從而會中斷理解思路,可讀性不高,難以推理程式執行流程;
  3. 語法冗餘,模板程式碼多;
  4. 程式中斷後無法恢復。
那應該如何處理錯誤?

程式錯誤時,不丟擲錯誤,而是給變數設定一個 可理解的 提示資訊,或是設定預設值。比如 undefinedInvalid user input

拋錯的例子:

const greet = ({
  firstName,
  lastName
}) => {
  if (firstName === undefined || lastName === undefined) {
    throw new Error("Invalid user");
  }
  return `Hello, ${firstName} ${lastName}`
}

不拋錯的改進:

// 設定預設值
const greet = ({
    firstName = "Guest",
    lastName = "User"
  }) =>
  `Hello, ${firstName} ${lastName}`
使用空值合併,設定預設值

拋錯的例子:

const getUserName = user => {
  if (
    user !== undefined &&
    user.profile !== undefined &&
    user.profile.name !== undefined
  ) {
    return user.profile.name
  }
  throw new Error("Invalid user")
}

不拋錯的例子:

const getUserName = user => user?.profile?.name ?? "Guest"
// 或者
const getUserName = user => user?.profile?.name ?? "user no name"

程式錯誤時,給一個友好的可理解的預設值,比拋錯好得多。

參考:

We don't need Throw

使用策略模式改善消除多個語句分支或者 switch case

有一個函式如下:

function checkOneVar(greet) {
  if (greet === 'hello') {
    console.log('字串 hello', 'zqj log')
  } else if (typeof greet === 'number' && greet === 1) {
    console.log('數值 1', 'zqj log')
  } else if (typeof greet === 'boolean' && greet) {
    console.log('布林值 true', 'zqj log')
  } else {
    console.log('其他值', 'zqj log')
  }
}

這段程式碼,對同一個變數進行多個條件判斷,可使用策略模式改善。

function checkOneVar(greet) {
  function whenHello() {
    console.log('字串 hello', 'zqj log')
  }

  function whenUndefined() {
    console.log('undefined undefined', 'zqj log')
  }

  function whenNull() {
    console.log('null null', 'zqj log')
  }

  function when1() {
    console.log('數值 1', 'zqj log')
  }

  function whenTrue() {
    console.log('數值 1', 'zqj log')
  }

  const obj = {
    hello: whenHello,
    1: when1,
    true: whenTrue,
    undefined: whenUndefined,
    null: whenNull,
  }
  obj[greet]?.()
}
策略模式簡化對同一個變數不同值的檢查,尤其是對列舉值的檢查。

使用 map 的的策略模式:

function checkOneVar(greet) {
  function whenHello() {
    console.log('字串 hello', 'zqj log')
  }

  function whenUndefined() {
    console.log('undefined undefined', 'zqj log')
  }

  function whenNull() {
    console.log('null null', 'zqj log')
  }

  function when1() {
    console.log('數值 1', 'zqj log')
  }

  function whenTrue() {
    console.log('數值 1', 'zqj log')
  }
  const map = new Map()
  map.set('hello', whenHello)
  map.set(1, when1)
  map.set(undefined, whenUndefined)
  map.set(null, whenNull)
  map.set(true, whenTrue)

  map.get(greet)?.()
}
使用 || 過濾假值,簡化多個條件語句

有一個從物件中提取地址的函式:

function calcPlace(location_info) {
  const {
    country,
    state,
    city,
    local
  } = location_info

  let first_part = 'middle-of-nowhere'
  let second_part = 'planet earth'
  if (country === 'USA') {
    if (city) {
      first_part = city
    }
    if (local) {
      first_part = local
    }
    second_part = 'USA'
    if (state) {
      second_part = state
    }
  } else {
    second_part = 'planet earth'
    if (country) {
      second_part = country
    }
    if (state) first_part = state
    if (city) first_part = city
    if (local) first_part = local
  }

  return `${first_part},${second_part}`
}

兩層條件語句巢狀,理解時還要考慮條件語句的優先情況,認知負擔大,改用 || 可改善:

function calcPlace(location_info) {
  const {
    country,
    state,
    city,
    local
  } = location_info

  let first_part
  let second_part
  if (country === 'USA') {
    // 先處理正邏輯
    first_part = local || city || 'middle-of-nowhere'
    second_part = state || 'USA'
  } else {
    first_part = local || city || state || 'middle-of-nowhere'
    second_part = country || 'planet earth'
  }

  return `${first_part},${second_part}`
}
思考: ||?? 有何不同?該如何選擇?

相同點:

都可用於多個變數的存在性檢查,獲取第一個存在的變數,可簡化多個 if 語句。都需要注意短路效應或者說變數取值的優先順序。

不同點:
?? 用於過濾空值,獲取第一個非空值 undefinednull , 常用來獲取非空值,特別小心 NaN

|| 用於過濾假值( undefinednull0false''NaN ),獲取第一個真值。
常用來獲取 非空字串非零數值true ,當 0 和 false 有意義時,要特別小心。

最小化巢狀

巢狀很深的程式碼難以理解,因為每個巢狀都會讓讀者思考巢狀結束的地方。

if (e.rainMetrics && JSON.stringify(e.rainMetrics) !== '{}') {
  let arr = Object.getOwnPropertyNames(e.rainMetrics)
  if (arr.length !== 0) {
    if (e.rainPeriod && e.rainPeriod.length > 0) {
      e.rainPeriod.map(field => {
        if (e.rainMetrics[field].length > 0) {
          let oneObj, twoObj
          e.rainMetrics[field].forEach(b => {
            if (b.warnName === '準備轉移') {
              twoObj = assign({}, oneObj, {
                intv: field,
                warnName: b.warnName,
                warnGradeId: b.warnGradeId,
                period: b.period,
                crp: b.crp,
              })
              tetList.push(twoObj)
            }
            if (b.warnName === '立即轉移') {
              oneObj = assign({}, b, {
                intv: field,
                warnName: b.warnName,
                warnGradeId: b.warnGradeId,
                period: b.period,
                crp: b.crp,
              })
              tetList.push(oneObj)
            }
          })
        }
      })
    }
    tetList.forEach(v => {
      if (v.warnName === '準備轉移') {
        threeObj['prepareTime' + v.period] = v.crp
      }
      if (v.warnName === '立即轉移') {
        threeObj['immediatelyTime' + v.period] = v.crp
      }
    })
  }
}

基本無可讀性可言。

深層巢狀往往都是逐漸累積的,當修改修改一個已經有巢狀的程式碼時,整體考慮這個程式碼片段,當巢狀比較深時,就要思考有沒有可改進的方法。

衡量巢狀的複雜度,圈複雜度,巢狀越深,圈複雜度越高,程式碼可讀性越低。

避免過深的巢狀的方式有哪些呢?

提前返回或者提前拋錯
if (user_result === 'SUCCESS') {
  if (permission_result !== 'SUCCESS') {
    reply.WriteErrors('error reading permission.')
    reply.Done()
    return
  }
  reply.WriteErrors('')
} else {
  reply.WriteErrors(user_result)
}
reply.Done()

使用提前返回改進它:

if (user_result !== 'SUCCESS') {
  reply.WriteErrors(user_result)
  reply.Done()
  return
}
if (permission_result !== 'SUCCESS') {
  reply.WriteErrors('error reading permission.')
  reply.Done()
  return
}
reply.WriteErrors('')
reply.Done()

透過改進,可讀性顯著提高了。

再看一個提前拋錯的例子:

async function getBook(params) {
  const {
    id
  } = params;
  if (id) { // not an empty string
    const idAsInt = parseInt(id);
    if (!isNaN(idAsInt)) { // is it a number?
      const book = await findBook(idAsInt);
      return Response.ok(JSON.stringify(book));
    } else {
      throw Error("Id must be numeric");
    }
  } else {
    throw Error("Id must be present");
  }
}

兩個拋錯的條件,可以提前:

async function getBook(params) {
  const {
    id
  } = params;
  if (!id) {
    throw Error("Id must be present");
  }

  const idAsInt = parseInt(id);
  if (Number.isNaN(idAsInt)) {
    throw Error("Id must be numeric");
  }

  const book = await findBook(idAsInt);
  return Response.ok(JSON.stringify(book));
}
這樣處理,拋錯的兩個條件語句提前了。

程式碼來源 -- Invariant - a helpful JavaScript pattern

不要濫用拋錯,因為它有諸多缺點,比如處理錯誤的可讀性差、排錯困難、程式被中斷、理解程式碼的思考被中斷、使得函式不純等。
巢狀的條件語句,只有一個操作,可合併

有一段這樣的程式碼:

data.value?.resources.forEach(itemOne => {
  itemOne?.subs.forEach(itemTwo => {
    // 只在三維下執行:行政區劃,行政駐地
    const is3DList = ['listen_id_371', 'listen_id_372']
    if (is3DList.includes(itemTwo.name)) {
      if (isCesium()) {
        if (itemTwo.checked === 1) {
          // 兩個條件下,只有一個操作,可把條件合併
          cacheChecksData[itemTwo.id] = itemTwo
          onLayerCheck(itemTwo, true)
        }
      }
    } else {
      if (itemTwo.checked === 1) {
        cacheChecksData[itemTwo.id] = itemTwo
        onLayerCheck(itemTwo, true)
      }
    }
  })
})

經過觀察, itemTwo.checked === 1isCesium() 可合併,減少巢狀:

data.value?.resources.forEach(itemOne => {
  itemOne?.subs.forEach(itemTwo => {
    // 只在三維下執行:行政區劃,行政駐地
    const is3DList = ['listen_id_371', 'listen_id_372']
    if (is3DList.includes(itemTwo.name)) {
      if (isCesium() && itemTwo.checked === 1) {
        cacheChecksData[itemTwo.id] = itemTwo
        onLayerCheck(itemTwo, true)
      }
    } else {
      if (itemTwo.checked === 1) {
        cacheChecksData[itemTwo.id] = itemTwo
        onLayerCheck(itemTwo, true)
      }
    }
  })
})

經過合併條件,巢狀少了一層。

調整條件語句的順序

當條件語句順序不影響程式碼執行時,可調整順序,減少巢狀。

上面的例子,經過觀察,有兩個條件語句 itemTwo.checked === 1 ,可把它提到外層。

// 只在三維下執行: 行政區劃, 行政駐地
const is3DList = ['listen_id_371', 'listen_id_372']
data.value?.resources.forEach(itemOne => {
  itemOne?.subs.forEach(itemTwo => {
    if (itemTwo.checked === 1) {
      if (is3DList.includes(itemTwo.name) && isCesium()) {
        cacheChecksData[itemTwo.id] = itemTwo
        onLayerCheck(itemTwo, true)
      } else if (!is3DList.includes(itemTwo.name)) {
        cacheChecksData[itemTwo.id] = itemTwo
        onLayerCheck(itemTwo, true)
      }
    }
  })
})

這樣調整以後,巢狀雖然沒有減少,但是條件語句不再重複,更加容易理解。

下一個辦法,繼續改進它。
減少迴圈或者迭代中的巢狀

使用 break 或者 continue 可減少迴圈內的巢狀。

for (let i = 0; i < result.length; i++) {
  if (result[i]) {
    count++
    if (result[i].name !== '') {
      // do something
    }
  }
}

改進:

for (let i = 0; i < result.length; i++) {
  if (!result[i]) continue
  count++
  if (result[i].name === '') continue
  // do something
}

continue 和 break,還能用於 for of 迭代。

如何跳出 forEach 迴圈?

return 跳出本輪迴圈。

const arr = [
  [1, 2, 3],
  ['1', '2', '3'],
]

arr.forEach(ele => {
  ele.forEach(item => {
    if (item === '2') return /*跳出本次迴圈*/
    console.log(item)
  })
})

輸出 1 2 3 '1' '3'

forEach 不能使用 continuebreak ,希望跳出整個迴圈,使用 try...catch ,在內部丟擲錯誤。但是不推薦這麼使用。

使用 for of 改寫上面的例子:

const arr = [
  [1, 2, 3],
  ['1', '2', '3'],
]

for (const value of arr) {
  for (const _value of value) {
    if (_value === '2') continue /*跳出本次迭代*/
    console.log(_value)
  }
}
注意:在 for of 中不能使用 return ,可以使用 continuebreak

使用 return 再次改進前面的例子:

const is3DList = ['listen_id_371', 'listen_id_372']
data.value?.resources.forEach(itemOne => {
  itemOne?.subs.forEach(itemTwo => {
    // 跳出本輪迴圈
    if (itemTwo.checked !== 1) continue

    if (is3DList.includes(itemTwo.name) && isCesium()) {
      cacheChecksData[itemTwo.id] = itemTwo
      onLayerCheck(itemTwo, true)
    } else if (!is3DList.includes(itemTwo.name)) {
      cacheChecksData[itemTwo.id] = itemTwo
      onLayerCheck(itemTwo, true)
    }
  })
})

最佳化以後,內層迴圈的巢狀,只有一層了。?

避免回撥地獄

使用 await 或者 promise.then 避免回撥地獄問題。

讓執行流程更容易理解

除了把迴圈、條件和其他跳轉語句寫得簡單,更應該從高層次來考慮執行流程,讓執行流程更容易理解。在實踐中,有些程式碼結構會讓流程難以了理解和難以除錯,應該避免濫用它們。

程式碼結構對程式的影響
異常會從多個函式呼叫中向上冒泡地執行
執行緒、非同步回撥不清楚何時執行程式碼
訊號量、中斷處理可能隨時執行

拆分超長表示式

表示式越長,越難以理解,且容易出現 bug,要把超長表示式拆分成小塊。

將表示式存入描述性變數

if (line.split(':')[0].strip() === 'root') {
  // do something
}
// 引入描述性變數
const usrName = line.split(':')[0].strip()
if (usrName === 'root') {
  //
}

多次使用的表示式存在描述性變數:

if (request.user.id === document.owner_id) {
  //
}
if (request.user.id !== document.owner_id) {
  // do
}

即使表示式很短,把它存入描述性變數,會更加容易理解。

const user_own_doc = request.user.id === document.owner_id
if (user_own_doc) {
  //
}
if (!user_own_doc) {
  // do
}

使用可選鏈 ?. 簡化存在性檢查

value?.key ,當 valuenull 或者 undefined 時,表示式返回 undefined 。善用它,簡化物件深層屬性的判斷。

const objDeep = {
  a: {
    b: {
      c: 'hello',
      d: 'a'
    }
  }
}
if (objDeep.a && objDeep.a.b && objDeep.a.b.c) {
  // 當 c 的值為真
  // do something
}
// 使用可選鏈簡化
if (objDeep.a?.b?.c) {
  // 當 c 的值為真
  // do something
}
技巧: ?. 還可以用於函式和陣列。
const ele = arr?.[4] // arr 存在,才獲取下標為 4 元素
obj.methodName?.() // obj.methodName 方法,才執行

善用德摩根定律

const result = !(a && b)
const result2 = !a || !b
// 這兩個表示式等價
轉換技巧:分別取反, &|| 互換。

可以使用這個定律使得表示式更加可讀。

if (!(file_exist && !is_protected)) {
  // 不易讀
}
if (!file_exist || is_protected) {
  // 更加可讀
}

避免賦值語句再包含其他操作

if (!(bucket = findBucket(key))) {
  //
}
// 包含兩個操作
// 1. 取出 key 對應的 bucket
// 2. 判斷 bucket 是否不存在

一個語句或者表單式具有多個操作,雖然程式碼量少了,顯得很智慧,但是容易讓人困惑。
分成兩步寫,可讀性更高。

const bucket = findBucket(key)
if (!bucket) {
  //
}

簡寫的箭頭函式只有一條語句也容易出現這種情況:

const myFn = (value) => result = doSomeThing(value)
返回值和賦值語句混合了,難以確定寫程式碼的人是刻意為之還是不小心寫錯了。
const myFn = (value) => {
  return doSomeThing(value)
}

這樣更加可讀。

避免任何智慧的程式碼,它會讓人困惑。

拆分巨大語句

一個語句包含兩個以上操作,也容易讓人困惑。

function update_highlight(message_num) {
  if ($("#vote_value" + message_num).htm1() === "Up") {
    $("#thumbs_up" + message_num).addClass("highlighted")
    $("#thumbs_down" + message_num).removeClass("highlighted")
  } else if ($("#vote_value" + message_num).htm1() === "Down") {
    $("#thumbs_up" + message_num).removeClass("highlighted")
    $("#thumbs_down" + message_num).addClass("highlighted")
  } else {
    $("#thumbs_up" + message_num).removeClass("highighted")
    $("#thumbs_down" + message_num).removeClass("highlighted")
  }
}

程式碼中每個語句都包含了兩個操作,不好理解,把它們拆分成描述性變數:

function update_highlight(message_num) {
  const $thumbs_up = $("#thumbs_up" + message_num)
  const $thumbs_down = $("#thumbs_down" + message_num)
  const vote_value_html = $("#vote_value" + message_num).htm1()
  const hi = 'highlighted'
  if (vote_value_html === "Up") {
    $thumbs_up.addClass(hi)
    $thumbs_down.removeClass(hi)
  } else if (vote_value_html === "Down") {
    $thumbs_down.removeClass(hi)
    $thumbs_down.addClass(hi)
  } else {
    $thumbs_up.removeClass(hi)
    $thumbs_down.removeClass(hi)
  }
}

這樣一改進,可讀性有明顯的提高。
const hi = 'highlighted' 不是必需的,但是鑑於這個變數使用了多次,提取成單獨的變數,有諸多好處: 01. 避免輸入錯誤。第一個版本有一個單詞寫錯了(highighted 少了一個字母 l) 02. 當名字需要修改,只改一處。 03. 降低了行寬。

經驗法則:當一個值使用超過 2 次,就應該把它提取成變數。

複雜邏輯反向操作

有一個表示區間的類:

class Range {
  begin
  end
  isOverlapsWith(otherRange) {
    // 判斷是否和range 重疊 [0,5) 和 [3,8) 有重疊
  }
}

正向思考,需要判斷當前的區間端點是否是否在另一個端點的範圍內

const isOverlap = (begin >= otherRange.begin && begin <= otherRange.end) || (end >= otherRange.begin && end <= otherRange.end)

這個表示式就太複雜了,不易理解,且容易錯誤,它忽略了 begin 和 end 完全包含的情況。反向思考,更加簡單:AB 無重疊,A 在 B 開始之前結束,或者 A 在 B 結束後開始,從而達到簡化判斷的目的。

isOverlapsWith(otherRange) {
  if (otherRange.end <= begin) return false
  if (otherRange.begin >= end) return false
  return true
}

減少變數和收縮作用域

變數的隨意使用會讓程式變的難以理解: 01. 變數越多,就越難以跟蹤它們的動向; 02. 變數的作用域越大,就需要跟蹤它們的動向越久; 03. 變數改變得越頻繁,就越難以跟蹤它當前的值。

減少變數

前面我們增加描述性變數來儲存複雜表示式,並且它們可作為某種形式的文件。但是我們需要減少不能改進可讀性的變數,從而讓程式碼更加精簡和容易理解

刪除沒有價值的零時變數
const now = datetime.datetime.now()
const root_msg_last_view_time = now

now 是值得保留的變數嗎?不是,因為它: <br/> 01. 沒有拆分複雜表示式; <br/> 02. 沒有做更多的澄清 -- datetime.datetime.now() 已經很清楚了;<br/> 03. 只用了一次-- 沒有壓縮冗餘程式碼。
<br/>
<br/>
now ,程式碼更容易理解。

減少中間結果

一個例子:

function remove_one(array, value_to_remove) {
  let index = -1
  for (let i = 1; i < array.length; i++) {
    if (array[i] === value_to_remove) {
      index = i
      break
    }
  }
  if (index > -1) {
    array.splice(index, 1)
  }
}

index 只是儲存中間的臨時結果,這種變數可以透過得到後立即處理它而被刪除。

function remove_one(array, value_to_remove) {
  for (let i = 1; i < array.length; i++) {
    if (array[i] === value_to_remove) {
      array.splice(i, 1)
      return
    }
  }
}

不再用 index ,程式碼精簡多了,可讀性也提高了。

減少控制流變數

常見到迴圈中有如下模式:

let done = false
while (!done) {
  // do something
  if (someCondition) {
    done = true
    continue
  }
}

這種變數,稱為控制流變數,它不包含任何程式資料,僅僅用於控制程式流程變化,它們可以透過良好的設計而被消除。

具體的例子後面會有。

使用宣告式程式碼是消除控制流變數的重要方式

有一段求和的程式碼:

const arr = [1, 2, 3, 4]
let sum = 0
for (let i; i < arr.length; i++) {
  sum += arr[i]
}

這段程式碼每次累加,都要維護下標 i ,非常容易出錯。使用宣告式的程式碼可消除 i :

const arr = [1, 2, 3, 4]
const sum = arr.reduce((pre, next) => {
  return pre + next
}, 0)

或者使用 forEach 或者 for of :

const arr = [1, 2, 3, 4]
const sum = 0
arr.forEach(item => {
  sum += item
})
宣告式程式碼告訴你做什麼,往往不需要知道太多細節,命令式程式碼告訴你怎麼做,會包含很多細節。

再看一個例子,找到第一個大於3的元素:

命令式程式碼:

const arr = [1, 2, 5, 4, 3, 5, 5, 6, 0]
let greatThan3
let len = arr.length
let i = 0
while (i < len) {
  if (arr[i] > 3) {
    greatThan3 = arr[i]
    break
  }
  i++
}

需要計算陣列下標、陣列長度,然後跳出迴圈,需要控制的變數很多,認知負擔大。

宣告式程式碼:

const arr = [1, 2, 5, 4, 3, 5, 5, 6, 0]
const greatThan3 = arr.find(ele => ele > 3)

使用宣告式程式碼不僅消除了兩個變數,可讀性也提高了。

js 中有哪些宣告式的程式碼呢?

陣列方法: forEachfiltereverysomemapfindfindIndex 等。

縮小變數的作用域

縮寫變數作用域是提高可讀性的重要手段,常說的減少全域性變數的使用,就是縮小變數作用域的常見手段。

讓變數的作用域越小越好,作用域越大,越容易出現命名衝突,越難以跟蹤變化。

js 中常見的作用域: 01. 全域性作用域; 02. 區域性作用域:模組作用域、函式作用域、塊作用域。

最佳實踐:使用 let 和 const 宣告變數,它們有塊級作用域。

禁止使用 var 或者不使用任何關鍵字宣告變數 ,因為它不會產生塊級作用域。

最佳實踐:在即將使用的地方宣告,能有效縮寫變數的作用域。

經驗法則:使用閉包,可縮小變數作用域在某個函式中,也實現了防止命名衝突。

使用常量或者不變性變數

不斷變化的變數會導致難以跟蹤它的值,難以推理程式的狀態,非常容易出 bug。
一個例子;

const numbers = [1, 2, 3, 4, 5, 6]
numbers.splice(0, 3) // [1,2,3]
// numbers 被修改成 [4,5,6]
numbers.splice(0, 3) // [4,5,6]
// numbers 被修改成 []
numbers.splice(0, 3) // []
// numbers 被修改成 []

splice 的三次呼叫引數相同,得到的結果卻不同,而且還修改了 numbers,就非常難以推理 splice 的返回值和當前的 numbers 的值。
使用 slice 就沒有這種問題

const numbers = [1, 2, 3, 4, 5, 6]
// 純的,多次呼叫,返回值相同,且不會修改 numbers
numbers.slice(0, 3) // [1, 2, 3]
numbers.slice(0, 3) // [1, 2, 3]
numbers.slice(0, 3) // [1, 2, 3]

三次呼叫,引數相同,結果相同,且不會修改 numbers。

副作用(side effect):除了程式碼單元的返回值對錶達式產生影響外,還有其他影響,比如上面的例子中,splice 修改了 numbers。

副作用往往是 bug 的來源, 可變資料和賦值操作是非常常見的副作用。

幾種方案可使變數不可變:

  1. 使用immutable.js等不可變的資料結構;
  2. 使用 js 庫(比如 lodash)來執行不可變的操作;
  3. 使用 es6 中執行不可變操作;
  4. 賦值一次,不再賦值的變數,使用const宣告;
  5. 編寫無副作用的函式:① 函式不改變引數;② 不拋錯;③ 必需有返回值;
  6. 涉及到傳遞物件和陣列時,傳入深度複製後的資料(嚴格來說,不是不可變)。

前兩種不在此介紹,主要看看後面三種是如何避免可變的。

ES6 中的不可變操作
const a = {
  name: 'js',
  age: 20
}
const b = Object.assign({}, a) // assign 合併淺層屬性
b.name = 'python' // 修改 b,不影響 a

const c = {
  ...a
} // 擴充套件運算子 ... 也是不可變操作
使用 const 宣告不再變化的變數

比如,上面的例子中, a 初始化後不再賦值,使用了 const ,當不小心賦值時,編輯器會報錯。

編寫無副作用的函式

副作用常常是 bug 的來源,編寫函式時,不要讓函式有副作用。

function remove_one(array, value_to_remove) {
  for (let i = 1; i < array.length; i++) {
    if (array[i] === value_to_remove) {
      array.splice(i, 1)
      return
    }
  }
}

remove_one 就是一個有副作用的函式,它改變了引數。改成無副作用的版本:

function remove_one(array, value_to_remove) {
  return array.filter(item => item !== value_to_remove)
}

技巧:1. 不改變引數和全域性變數,2. 保證函式有返回值,遵循這兩個原則就能寫出無副作用的函式。

js 中有副作用的函式,要謹慎使用。

這些陣列函式有副作用

const array = [1, 2, 3, 4]
array.splice(0, 1) // array [1,2,3]
array.reverse() // array [3,2,1]
array.sort()

多使用無副作用的函式,它們都返回一個新的陣列:

const array = []
array.reduce()
array.reduceRight()
array.filter()
array.map()
array.some()
array.every()
array.slice()
array.toReversed()
array.toSorted()
array.toSpliced()
array.flat()
array.flatMap()
array.with() // 修改某個位置的元素
深度複製避免意外改變變數

這個不多闡述,有開發經驗的人都理解。

最後的一個例子

有如下的 html 程式碼:

<input type="text" id="input1" value="Dustin">
<input type="text" id="input2" value="Trevor">
<input type="text" id="input3" value="">
<input type="text" id="input4" value="Melissa">
<!-- ...還有很多 input -->

編寫一個函式 setFirstEmptyInput(valueStr) ,給第一個 value 值為空的 input 設定值,並返回修改後的 input,沒有為空的 input, 返回 null。

我的實現:

function setFirstEmptyInput(valueStr) {
  const inputs = document.querySelectorAll('input')
  const firstEmptyInput = Array.from(inputs).find(input => input.value === '')
  if (!firstEmptyInput) return null
  firstEmptyInput.value = valueStr
  return firstEmptyInput
}

其他實現:

function setFirstEmptyInput(valueStr) {
  let found = false
  let i = 1
  let ele = document.getElementById('input' + i)
  while (ele !== null) {
    if (ele.value === '') {
      found = true
      break
    }
    i++
    ele = document.getElementById('input' + i)
  }
  if (found) ele.value = valueStr
  return ele
}

found 是迴圈控制變數,可消除。

function setFirstEmptyInput(valueStr) {
  let i = 1
  let ele = document.getElementById('input' + i)
  while (elem !== null) {
    if (elem.value === '') {
      elem.value = valueStr
      return ele
    }
    i++
    ele = document.getElementById('input' + i)
  }
  return null
}

這個迴圈類似 do...while 迴圈,且 document.getElementById 呼叫了兩次,希望避免使用 do...while ,消除 document.getElementById 的重複:

function setFirstEmptyInput(valueStr) {
  let i = 1
  while (true) {
    let ele = document.getElementById('input' + i)
    if (ele === null) {
      return null
    }
    if (ele.value === '') {
      ele.value = valueStr
      return ele
    }
    i++
  }
}

這個版本的,更加好了,相比之下,還是我的實現最好,它沒有涉及到迴圈,即沒有程式碼巢狀,也沒有冗餘的變數。

一次只做一件事

簡單來說,就是保持程式碼單元(函式、類、程式碼段落)的職責單一。

工程學的思想:把大問題拆分成小問題,再把小問題的解決方案組合成大問題的解決方案。

保持職責單一的技巧:拆分任務,相同的任務聚集到同一個程式碼快中。

寫程式碼也一樣,在解決一個複雜問題時,可把它拆分成解決簡單問題的組合。

提取可複用的操作

一個例子:

找到距離給定點最近的點

// Return which element of 'array'
// is closest to the given latitude / longitude.
// Models the Earth as a perfect sphere.
function findClosestLocation(lat, lng, array) {
  let closest
  let closest_dist = Number.MAX_VALUE
  for (let i = 0; i < array.length; i = 1) {
    // 計算球面距離 start
    // Convert both points to radians.
    const lat_rad = radians(lat)
    const lng_rad = radians(lng)
    const lat2_rad = radians(array[i].latitude)
    const lng2_rad = radians(array[i].longitude)
    // use the "Spherical law of Cosines" formula.
    const dist = Math.acos(
      Math.sin(lat_rad) * Math.sin(lat2_rad) +
      Math.cos(lat_rad) * Math.cos(lat2_rad) * Math.cos(lng2_rad - lng_rad)
    )
    // 計算球面距離 end
    if (dist < closest_dist) {
      closest = array[i]
      closest_dist = dist
    }
  }
  return closest
}

迴圈中計算球面距離的程式碼可提取成獨立的函式:

function findClosestLocation(lat, lng, array) {
  let closest
  let closest_dist = Number.MAX_VALUE
  for (let i = 0; i < array.length; i = 1) {
    // 計算球面距離
    const dist = sphericalDistance(lat, lng, array[i].lat, array[i].lng)

    if (dist < closest_dist) {
      closest = array[i]
      closest_dist = dist
    }
  }
  return closest
}

function sphericalDistance(lat1, lng1, lat2, lng2) {
  const lat_rad = radians(lat1)
  const lng_rad = radians(lng1)
  const lat2_rad = radians(lat2)
  const lng2_rad = radians(lng2)
  // use the "Spherical law of Cosines" formula.
  const dist = Math.acos(
    Math.sin(lat_rad) * Math.sin(lat2_rad) +
    Math.cos(lat_rad) * Math.cos(lat2_rad) * Math.cos(lng2_rad - lng_rad)
  )
  return dist
}

提取之後,可讀性提高, sphericalDistance 還就可以單獨測試,複用更加容易了。

保持函式短小,職責單一,有諸多好處。

再看一個例子:

從物件中抽取值:

const locationInfo = {
  country: 'USA',
  state: 'California',
  city: 'Los Angeles',
  local: 'Santa Monica'
}

從這個物件中提取友好的地址字串,形成 city,country ,比如 Santa Monica, USA ,找到每個屬性都可能缺失。提取方案:

  • 選擇 city 的值時,先取 local,沒有,再取 city, city 還是沒有,再取 state,還是沒有,取預設值middle-of-nowhere;
  • 選擇 country 的值,country 不存在,取預設值planet earth

第一個實現了版本:

const locationInfo = {
  country: 'USA',
  state: 'California',
  city: 'Los Angeles',
  local: 'Santa Monica',
}

function calcPlace(location_info) {
  // 取值 + 更新第一部分
  let place = location_info['local'] //  e.g. "Santa Monica"
  if (!place) {
    // 取值 + 更新第一部分
    place = location_info['city'] //  e.g. "Los Angeles"
  }
  if (!place) {
    // 取值 + 更新第一部分
    place = location_info['state'] //  e.g. "California"
  }
  if (!place) {
    // 取值 + 更新第一部分
    place = 'middle-of-nowhere'
  }

  if (location_info['country']) {
    // 取值 + 更新第二部分
    place += ', ' + location_info['CountryName'] //  e.g. "USA"
  } else {
    // 取值 + 更新第二部分
    place += ',planet earth'
  }

  return place
}

程式碼有點亂,但是能工作。過幾天,新的需求又來了:美國之內的地點,要顯示州,而不是國家名。

第二個版本:

function calcPlace(location_info) {
  // 1. 取值
  const {
    country,
    state,
    city,
    local
  } = location_info

  // 2. 計算第一部分
  let first_part = 'middle-of-nowhere'
  if (state && country !== 'USA') {
    first_part = state
  }
  if (city) {
    first_part = city
  }
  if (local) {
    first_part = local
  }
  // 3. 計算第二部分
  let second_part = 'planet earth'
  if (country) {
    second_part = country
  }
  if (state && country === 'USA') {
    second_part = state
  }
  // 4. 組合成新地址
  return `${first_part},${second_part}`
}

第一個版本的不同操作,分散在不同的程式碼區域,而第二個版本,相同的操作,更加聚集,4 個不同的任務,聚合在 4 個程式碼塊中,可讀性和可維護性,第二個版本更加好。

第二個版本中,對 country 的判斷分散在兩個程式碼塊中,和其他邏輯交織在一起,可讀性還是不夠理想,希望對 country 的判斷更加聚集,可以提高可讀性。

function calcPlace(location_info) {
  const {
    country,
    state,
    city,
    local
  } = location_info

  let first_part
  let second_part
  if (country === 'USA') {
    // 先處理正邏輯
    first_part = local || city || 'middle-of-nowhere'
    second_part = state || 'USA'
  } else {
    first_part = local || city || state || 'middle-of-nowhere'
    second_part = country || 'planet earth'
  }

  return `${first_part},${second_part}`
}

第三個版本的可讀性又有很大的提升。

技巧:從多個值中獲取第一個真值,使用 const result = a||b||c||'default value' ,可簡寫多個 if 語句。

提取工具函式程式碼

每個專案都有一些可複用的工具程式碼,可提取出來。

常見的工具函式:

  1. 表單驗證函式;
  2. 日期格式化函式;
  3. 深度複製;
  4. 陣列去重;
  5. 下載檔案

簡化已有介面

引數少,不需要很多設定就能使用的庫,總是讓人愛不釋手。如果你嫌正在使用的庫不夠簡潔,就可以再封裝一下,讓它更加優雅易用。

比如 jQuery 就封裝了複雜難用的 DOM 操作,堪稱封裝的典範,即使十多年過去,它依然被很多網站採用。

js 處理 cookie,瀏覽器只提供了 document.cookie

document.cookie = 'key1=value1'
document.cookie = 'key2=value2' // NOTE 追加 cookie,而不是覆蓋

刪除 cookie 更加奇怪,需要設定一個過去的時間。

符合直覺的用法:

removeCookie(key)
// 或者
cookie.remove(key)

封裝一個優雅的 cookie 操作函式:
希望的介面:

const {
  get,
  set,
  remove
} = cookie()
// 還可以這樣使用
cookie.get()
cookie.set()
cookie.remove()
function cookie() {
  const enable = window.navigator.cookieEnabled

  cookie.get = get
  cookie.set = set
  cookie.remove = remove
  cookie.enable = enable

  return {
    remove,
    set,
    get,
    enable,
  }

  function set(name, value, {
    age_in_mins = 7,
    ...rest
  } = {}) {
    const options = {
      path: '/',
      domain: '*.' + window.location.hostname,
      'max-age': age_in_mins * 60,
      ...rest,
    }

    if (options.expires instanceof Date) {
      options.expires = options.expires.toUTCString()
    }

    let updatedCookie = encodeURIComponent(name) + '=' + encodeURIComponent(value)

    for (let optionKey in options) {
      updatedCookie += '; ' + optionKey
      let optionValue = options[optionKey]
      if (optionValue !== true) {
        updatedCookie += '=' + optionValue
      }
    }

    document.cookie = updatedCookie
  }

  function get(name) {
    let cookie = {}
    const decodeCookie = decodeURIComponent(document.cookie)
    decodeCookie.split(';').forEach(function(el) {
      let [k, v] = el.split('=')
      cookie[k.trim()] = v
    })

    return cookie[name]
  }

  function remove(name) {
    if (!name) return false
    set(name, '', {
      age_in_days: -1,
    })
    return true
  }
}

少寫程式碼

最好讀的程式碼是沒有程式碼。多寫多錯,不寫不錯,寫下的每一行程式碼,沒充分的測試,都可能引入 bug。

如何少寫程式碼呢?

轉化你的需求

產品經理說需要一個 google, 經過分析,他其實只是需要一個能讓使用者搜尋的功能。

當產品理解提出難以實現或者離譜的需求時,積極瞭解他的目的,實現困難時,就換一種方式滿足使用者。

簡化函式的引數

引數越多,易用性越差,可讀性也越差。

  1. 位置引數超過4個,使用物件代替
函式引數不應該超過4個,超過4個,就難以理解和使用。超過4個,使用物件代替。
// 位置引數太對,難以使用和理解
function person(name, age, city, salary, job){}
// 使用物件代替 5個引數被放在一個物件裡
function person2({name, age, city, salary, job}){}
  1. 多使用預設引數和剩餘引數

提供預設引數和剩餘引數,可提高函式的易用性。

function testFn(name,age=18,job='coder'){
// 
}
testFn('Jack')
testFn('Tom',20)
testFn('Tim',34,'PM')

物件的預設引數,善用解構

function person({ name = 'Tim', age = 28, ...restProps } = {}) {
  console.log(restProps)
}
person()
person({
  name: 'Tim', 
  age: 30, 
  salary: '$30000', 
  addr: 'ShangHai, China', 
  job: 'rust dev', 
})
當知道了物件會具備的屬性時,使用物件預設引數是非常好的方法。

比如需要給一個div設定style樣式:

function setDivStyle({width='200px',height='100px',display='flex',...restProps}={}){}
  1. 剩餘引數
function testFn(name, age, city, ...restParams) {}
避免濫用剩餘引數,因為剩餘引數也是位置引數的一種。函式不能同時具備剩餘引數和預設引數。
  1. 柯里化實現減少形參和複用實參

柯里化的基本形式:函式A返回另函式B,B使用A的引數參與計算。

function outerFn(greet) {
  return function innerFn(name) {
    console.log(`${greet},${name}`)
  }
}

再看一個例子

function sum(a, b) {
  return a + b
}
// 引數 10 重複 3 引數
sum(10, 1)
sum(10, 10)
sum(10, 100)

使用柯里化複用引數:

function currySum(a) {
  return function add(b) {
    return a + b
  }
}

const tenAdd = currySum(10)
tenAdd(1) // 複用之前的引數 10
tenAdd(10) // 同上
tenAdd(100) // 同上

柯里化不僅可以返回函式,還能返回包含函式的物件,有時候這種方式會更加實用。

function counter(initValue) {
  return {
    add,
  }

  function add(n) {
    return initValue + n
  }
}
const {
  add
} = counter(10)
add(1)

const {
  add: add100and
} = counter(100)
add100and(1000)
// 像不像 react 的 useState ? ?
透過柯里化,把兩個形參拆分到了兩個函式中,從而實現了實參複用。 通用的柯里化函式,讀者可自行實現或者谷歌。

透過柯里化拆分形參,實現了實際引數的複用,函式功能不僅得到加強,易用性和可讀性也提高了。

看一個綜合的例子,封裝一個 vue3 的useHttp:

type Method = 'post' | 'get'
type MaybeRef<T> = Ref<T> | T

type Options = {
  method?: Method
  enableWatch?: boolean
  immediate?: boolean
  autoAbort?: boolean
}

function useHttp(
  url: string,
  params: MaybeRef<Record<string, any>> = ref({}),
  { enableWatch = true, immediate = true, autoAbort = true, method = 'get' }: Options = {}
) {
  const _params = unref(params)
  const data = ref()
  const loading = ref(false)

  enableWatch &&
    watch(
      params,
      newParams => {
        sendHttp(newParams)
      },
      {
        deep: true,
      }
    )

  onMounted(() => {
    immediate && sendHttp(unref(params))
  })

  let abortController = null // new AbortController()
  onBeforeUnmount(() => {
    autoAbort && abortHttp()
  })

  type SendHttp = (params?: Record<string, any>) => Promise<any>

  return [data, loading, sendHttp] as [Ref<any>, Ref<boolean>, SendHttp]

  function sendHttp(params: Record<string, any> = _params) {
    let path = url
    let body = undefined
    if (method === 'get') {
      let query = Object.keys(params)
        .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
        .join('&')
      path += `?${query}`
    } else if (method === 'post') {
      body = JSON.stringify(params)
    }

    abortController = new AbortController()
    const options = { body, signal: abortController.signal }

    loading.value = true

    return fetch(path, options)
      .then(res => {
        // 請求不成功,不拋錯
        if (!res.ok) {
          return Promise.resolve({
            success: false,
            msg: ` httpCode is ${res.status}`,
          })
        }
        return res.json()
      })
      .then(res => {
        data.value = res
        return res
      })
      .finally(() => {
        loading.value = false
        abortController = null
      })
  }

  function abortHttp() {
    abortController?.abort()
  }
}

export { useHttp }

使用方式:

// 立即請求 todo
const [todo] = useHttp('/todos/120')

const params = ref({
  date: '2023-10-10'
})
// 立即請求訂單,當 params 改變,會自動再次請求
const [orderList, loading] = useHttp('/orders', params)

// 自動請求,需要再次更新 userList 時,
// 手動呼叫,fetchUsers({job:'pm'}),會複用 url 和 method
const [userList, loadIngUsers, fetchUsers] = useHttp('/users', {
  job: 'coder'
})
還可以給 useHttp 新增泛型,提供設定請求頭等功能。

善用周邊庫

很多時候,程式設計師不知道現有的庫能解決他們的問題,就自己造輪子,但很可能造出一個方的輪子 -- 問題輪子。又或者,他們不熟悉周邊庫的使用技巧,寫出難以閱讀的程式碼。

瞭解常用的周邊庫,掌握使用技巧,使很有必要的。使用廣泛的庫,經過社群千錘百煉,不太可能出現大問題,能幫你又快又好的解決問題。

如何選擇你使用的庫?或者如何做技術選型?這個話題可單獨寫一篇文章了。

刪除無用的和重複的程式碼,保持專案輕量

程式碼量越大,維護成本就越高,保持程式碼庫輕量,可以讓專案更加容易維護。

刪除無用的程式碼,不要過度設計等,可讓專案輕量。

團隊成員水平各異,隨著人員流動,沒有專案負責人統籌規劃公共複用的程式碼時,極易出現各自重複造輪子的情況,相同的功能,不管是元件還是函式,都會重複存在。反正保持程式碼庫輕量,關鍵在於專案的管理上。

讓測試用例更加可讀

測試程式碼應該具有可讀性,方便他人舒服地改變或者新增測試用例。

站在使用者的角度編寫用例

隱去具體細節,只給出測試的輸入和輸出。

有 vue 元件 Counter.vue :

<template>
  <button role="increment" @click="increment" />
  <button role="submit" @click="submit" />
</template>

<script>
  import {
    ref
  } from 'vue';
  export function submitValidator(value) {
    if (typeof value !== 'number') {
      throw Error(`Count should be a number. Got: ${count}`)
    }
    return true
  }
  export default {
    emits: {
      submit: submitValidator
    },
    setup(props, ctx) {
      const count = ref(0)
      // NOTE 觸發自定義事件的方法命名
      // 1. onCustomEvent 和 觸發的事件保持一致
      // 2. handleCustomEvent
      function submit() {
        ctx.emit('submit', this.count)
      }

      function increment() {
        count.value++
      }
      return {
        count,
        submit,
        increment
      }
    },
  }
</script>

測試用例:

// Counter.test.jsx
import {
  render
} from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import Counter, {
  submitValidator
} from './Counter.vue'

describe('Counter.vue', () => {
  it('emit with current count', async () => {
    // Arrange
    const {
      getByRole,
      user,
      emitted
    } = setup(<Counter/> )

    // Action
    await user.click(getByRole('increment'))
    await user.click(getByRole('submit'))

    // Assert
    expect(emitted('submit')[0]).toEqual([1])
  })
})

function setup(component) {
  const result = render(component)
  return {
    user: userEvent.setup(),
    ...result,
  }
}

元件掛載過程,封裝了 setup ,隱去了掛載細節。

更好的組織測試程式碼

  1. 上面的元件測試,遵循了3A法則編寫用例,可讀性更高。

① 準備測試環境(Arrange),比如掛載元件、模擬定時器、測試資料等。<br/>
② 執行相關操作(Action),比如點選按鈕、輸入表單等。<br/>
③ 斷言結果(Assert)。<br/>
④ 以上程式碼,使用空行分割,保證可讀性。<br/>

  1. 為特殊的輸入,常見的 bug,編寫用例,讓使用者瞭解意外情況。

比如測試封裝的瀏覽器儲存:

// storage.ts
type StorageType = 'local' | 'session'

function set<V = unknown>(key: string, value: V, type: StorageType = 'session') {
  if (!key || typeof key !== 'string') throw new Error('必須有一個字串引數 key')
  const jsonValue = JSON.stringify(value)
  if (type === 'local') {
    localStorage.setItem(key, jsonValue)
  } else if (type === 'session') {
    sessionStorage.setItem(key, jsonValue)
  } else {
    throw new Error('不支援的儲存型別')
  }
  // NOTE  stringify 支援的值
  // 1, 物件 {...}
  // 2, 陣列 [...]
  // 3, 字串
  // 4, 數字
  // 5, 布林值
  // 6, null

  // 被忽略的屬性值
  // 1, undefined
  // 2, Symbol
  // 3, 函式
}

function get<V = string | null | unknown>(key: string, type: StorageType = 'session'): V {
  if (!key || typeof key !== 'string') throw new Error('必須有一個字串引數 key')

  if (type === 'local') {
    try {
      let value = JSON.parse(localStorage.getItem(key)!)
      return value
    } catch (error) {
      return localStorage.getItem(key) as any
    }
  } else if (type === 'session') {
    try {
      let value = JSON.parse(sessionStorage.getItem(key)!)
      return value
    } catch (error) {
      return sessionStorage.getItem(key) as any
    }
  } else {
    throw new Error('不支援的儲存型別')
  }
}

function clear(type: StorageType = 'session') {
  if (type === 'local') {
    localStorage.clear()
  } else if (type === 'session') {
    sessionStorage.clear()
  } else {
    throw new Error('不支援的儲存型別')
  }
}

function remove(key: string, type: StorageType = 'session') {
  if (!key || typeof key !== 'string') throw new Error('必須有一個字串引數 key')
  if (type === 'local') {
    localStorage.removeItem(key)
  } else if (type === 'session') {
    sessionStorage.removeItem(key)
  } else {
    throw new Error('不支援的儲存型別')
  }
}

const storage = {
  get,
  set,
  clear,
  remove,
}

export { storage }

測試用例:

// storage.test.ts
import { storage } from './storage'
describe('storage', () => {
  describe('預設是 sessionStorage', () => {
    beforeEach(() => {
      sessionStorage.clear()
    })
    it('storage.set', () => {
      const value = 'hello'
      const key = 'sessionKey'
      storage.set(key, value)
      expect(sessionStorage.getItem(key)).toEqual(JSON.stringify(value))

      const key2 = 'sessionKey2'
      const value2 = {
        name: 'zqj',
      }
      storage.set(key2, value2)

      expect(storage.get(key2)).toEqual(value2)
    })

    it('storage.get', () => {
      const value = JSON.stringify('hello')
      const key = 'sessionKey'
      sessionStorage.setItem(key, value)

      expect(sessionStorage.getItem(key)).toEqual(value)
      expect(storage.get(key)).toEqual(JSON.parse(value))
    })
    it('storage.remove', () => {
      const key = 'sessionKey'
      const value = ['hello']
      storage.set(key, value)

      expect(storage.get(key)).toEqual(value)

      storage.remove(key)

      expect(storage.get(key)).toBeNull()
    })
    it('storage.clear', () => {
      const key = 'sessionKey'
      const value = ['hello']
      storage.set(key, value)
      const key2 = 'sessionKey2'
      const value2 = {}
      storage.set(key2, value2)

      expect(storage.get(key)).toEqual(value)
      expect(storage.get(key2)).toEqual(value2)

      storage.clear()

      expect(storage.get(key)).toBeNull()
      expect(storage.get(key2)).toBeNull()
    })
  })
  describe('設定 localStorage', () => {
    beforeEach(() => {
      localStorage.clear()
    })
    it('storage.set', () => {
      const value = 'hello'
      const key = 'sessionKey'
      storage.set(key, value, 'local')
      expect(localStorage.getItem(key)).toEqual(JSON.stringify(value))

      const key2 = 'sessionKey2'
      const value2 = {
        name: 'zqj',
      }
      storage.set(key2, value2, 'local')

      expect(storage.get(key2, 'local')).toEqual(value2)
    })

    it('storage.get', () => {
      const value = JSON.stringify('hello')
      const key = 'sessionKey'
      localStorage.setItem(key, value)

      expect(localStorage.getItem(key)).toEqual(value)
      expect(storage.get(key, 'local')).toEqual(JSON.parse(value))
    })
    it('storage.remove', () => {
      const key = 'sessionKey'
      const value = ['hello']
      storage.set(key, value, 'local')

      expect(storage.get(key, 'local')).toEqual(value)

      storage.remove(key, 'local')

      expect(storage.get(key, 'local')).toBeNull()
    })
    it('storage.clear', () => {
      const key = 'sessionKey'
      const value = ['hello']
      storage.set(key, value, 'local')
      const key2 = 'sessionKey2'
      const value2 = {}
      storage.set(key2, value2, 'local')

      expect(storage.get(key, 'local')).toEqual(value)
      expect(storage.get(key2, 'local')).toEqual(value2)

      storage.clear('local')

      expect(storage.get(key, 'local')).toBeNull()
      expect(storage.get(key2, 'local')).toBeNull()
    })
  })
  describe('設定錯誤的 type', () => {
    it('storage.set throw', () => {
      expect(() => storage.set('key', 'error', 'errorType' as any)).toThrowError()
    })
    it('storage.get throw', () => {
      storage.set('key', 'error')

      expect(() => storage.get('key', 'errorType' as any)).toThrowError()

      const value = 'error'
      sessionStorage.setItem('key2', value)
      expect(storage.get('key2')).toBe(value)

      // 不是一個合法的 json 字串
      const valueObj = '{name: "zqj"}}'
      localStorage.setItem('key2', valueObj)
      // 部署合法的 JSON 字串,返回原字串,不進行 JSON.parse
      expect(storage.get('key2', 'local')).toBe(valueObj)
    })
    it('storage.remove throw', () => {
      storage.set('key', 'error')

      expect(() => storage.remove('key', 'errorType' as any)).toThrowError()
    })
    it('storage.clear throw', () => {
      expect(() => storage.clear('errorType' as any)).toThrowError()
    })
  })
  describe('沒有提供 key', () => {
    it('storage.set() throw', () => {
      expect(() => storage.set('', 'value')).toThrowError()
      expect(() => storage.set(undefined as any, 'value')).toThrowError()
      expect(() => storage.set(null as any, 'value')).toThrowError()
    })
    it('storage.get() throw', () => {
      expect(() => storage.get('', 'session')).toThrowError()
      expect(() => storage.get(undefined as any, 'session')).toThrowError()
      expect(() => storage.get(null as any, 'session')).toThrowError()
      expect(() => storage.get(1 as any, 'session')).toThrowError()
    })
    it('storage.remove() throw', () => {
      expect(() => storage.remove('', 'session')).toThrowError()
      expect(() => storage.remove(undefined as any, 'session')).toThrowError()
      expect(() => storage.remove(null as any, 'session')).toThrowError()
      expect(() => storage.remove(1 as any, 'session')).toThrowError()
    })
  })
})

對沒有 key 和錯誤的 type ,都是編寫了用例。

給用例取一個好名字

如下的例子,都給 describe 和 it,取了一個好名字。

describe('submitValidator', () => {
  it('throw error when count is not number', function() {
    const actual = () => submitValidator('1')
    expect(actual).toThrowError()
  })

  it('return true when count is number', function() {
    const actual = () => submitValidator(1)
    expect(actual).not.toThrowError()
    expect(actual()).toBe(true)
  })
})

構造良好的測試輸入

最好的輸入,能覆蓋所有的邊界情況,同時保持輸入最小。

上面的測試例子,兩個用例就覆蓋了所有情況。

讓錯誤訊息更加可讀

程式碼的錯誤訊息越具體,越可讀,除錯越容易,越容易修復。

美化錯訊息的輸出格式

讓程式碼可測試

程式碼設計良好,依賴越少,越容易測試。

可測試性差的程式碼特徵:

特徵可測試性問題設計問題
使用全域性變數每個用例都要重置全域性變數,否則用例之間有影響難以理解副作用
大作用域變數和全域性變數的類似和全域性變數的類似
外不依賴多需要很多模擬程式碼系統可能因為某個依賴失敗而失敗
程式碼有不確定性行為(隨機數、時間戳)測試不可靠程式難以推理,難以驗證,難以除錯

可測試性好的程式碼特徵:

特徵對測試的好處對設計的好處
類中只有少量和沒有狀態容易編寫測試容易理解
類的介面簡單正交,定義明確有明確的行為可測試,用例少易用
程式碼塊只做一件事用例更加少耦合低,容易理解和組合
外部依賴少模擬少,測試穩定容易修改
函式少,引數正交容易測試容易使用

正交:數學上的概念,指兩個向量垂直,不管它們如何變化,內積總是為零。

軟體設計上的正交:兩種變化不會相互影響。比如引數 a 的變化,不會影響引數 b 的效果。

正交系統的好處:系統設計達到了正交,說明達到了高內聚,低耦合,它是高內聚和低耦合原則的具體度量。系統的裡依賴,可任意替換。

N 個函式和 M 個函式的功能正交,組合起來就能做 N*M 件事情,可見正交的設計,程式碼重用容易。

正交的設計,還可以有效避免被誤用。好的設計應該正確使用很容易,錯誤使用很困難。

正交的設計易於測試和除錯,因為很容易把問題定位到區域性範圍。

正交性是最重要的屬性之一,可以幫助使複雜的設計緊湊。在純正交設計中,操作沒有副作用; 每個操作(無論是 API 呼叫、宏呼叫還是語言操作)只更改一件事,而不會影響其他操作。只有一種方法可以更改您正在控制的任何系統的每個屬性。---《UNIX 程式設計藝術》

設計正交的系統非常難,不追求整體正交,但是一定要做到區域性正交,比如函式的引數儘量要正交。

正交設計的四原則:

  1. 消除重複

不推薦徹底消除重複,實際上無法做到。適當重複,可保證程式碼可讀性,重複是值得的。

  1. 分離關注點
  2. 縮小依賴範圍

依賴範圍小,意味著依賴容易替換,越容易修改。

全域性變數,就是一種全域性性的依賴,儘量避免。

純函式的依賴只有引數,因此非常容易測試、推理。

  1. 向穩定的方向依賴

耦合點的變化,會導致依賴方跟著變化。耦合點穩定,依賴方受到耦合點變化的影響越小。

如何讓依賴更趨於穩定:

站在需求的角度,而不是實現的角度定義依賴點(API),會讓 API 更加穩定。

需求是不斷變化的,必須對需求進行抽象和建模,找出其中本質的東西,才能使 API 更加穩定。

依賴的版本要穩定,屬於這個原則嗎?屬於。

更多參考 -- 正交設計之正交四原則

總結

從表面上提高程式碼的可讀性
  1. 見名知義的名字
  2. 美觀的排版
  3. 精簡的註釋
從程式碼結構上提高可讀性
  1. 最佳化條件語句
  2. 減少巢狀
  3. 拆分超長表示式和語句
  4. 減少變數和縮小變數作用域
組織好程式碼
  1. 提取可複用的操作
  2. 保持程式碼塊(函式、類、程式碼段落)的職責單一
  3. 善用周邊庫少造輪子

相關文章