使用JSDoc提高程式碼的可讀性

賈順名發表於2019-04-22
工作了四年多,基本上都在圍繞著 JavaScript 做事情。
寫的程式碼多了,看的程式碼也多了,由衷的覺得,寫出別人看不懂的程式碼並不是什麼能力,寫出所有人都能讀懂的程式碼,才是真的牛X。
眾所周知, JavaScript 是一個弱型別的指令碼語言,這就意味著,從編輯器中並不能直觀的看出這段程式碼的作用是什麼,有些事情只有等到程式碼真正的執行起來才能夠確定。
所以為了解決大型專案中 JavaScript 維護成本高的問題,前段時間我們團隊開始使用 TypeScript,但是由前幾年所積累下來的程式碼,並不是說改立馬都能全部改完的,所以這個重構將是一個漫長的過程。
在重構同時我們還是需要繼續維護原有的 JavaScript 專案的,而 JSDoc 恰好是一箇中間過渡的方案,可以讓我們以註釋的形式來降低 JavaScript 專案的維護難度,提升可讀性。

作用

本人使用的是 vs code 編輯器,內建了對 jsdoc 的各種支援,同時還會根據部分常量,語法來推測出對應的型別
可以很方便的在編輯器中看到效果,所以下面所有示例都是基於 vscode 來做的。

首先,JSDoc 並不會對原始碼產生任何的影響,所有的內容都是寫在註釋裡邊的。
所以並不需要擔心 JSDoc 會對你的程式造成什麼負面影響。

可以先來看一個普通的 JavaScript 檔案在編輯器中的展示效果:

很顯而易見的,編輯器也不能夠確定這個函式究竟是什麼含義,因為任何型別的兩個引數都可以進行相加。
所以編輯器就會使用一個在 TypeScript 中經常出現用來標識任意型別的 any 關鍵字來描述函式的引數以及返回值。

而這種情況下我們可以很簡單的使用 JSDoc 來手動描述這個函式的作用:

實際上有些函式是需要手動指定@return {TYPE}來確定函式返回值型別的,但因為我們函式的作用就是通過兩個引數相加並返回,所以編輯器推算出了函式返回值的型別。

對比上下兩段程式碼,程式碼上並沒有什麼區別,也許有人會嗤之以鼻,認為程式碼已經足夠清晰,並不需要額外的新增註釋來說明。
這種盲目自信一般會在接手了其他人更爛的程式碼後被打破,然後再反思自己究竟做錯了什麼,需要去維護這樣的程式碼。

亦或者我們來放出一個稍微複雜一些的例子:

看似清晰、簡潔的一個示例,完全看不出什麼毛病 _除了兩個非同步await可以合併成一個_。
確實,如果這段程式碼就這麼一直躺在專案中,也不去改需求,那麼這段程式碼可以說是很完美的存在了。
如果這段程式碼一直是寫下這段程式碼的作者在維護,那麼這段程式碼在維護上也不會有什麼風險。

不過如果哪天這段程式碼被交接了出去,換其他的小夥伴來維護。
那麼他可能會有這麼幾個疑問:

  1. getUserInfo的返回值是什麼結構
  2. createOrder的返回值又是什麼結構
  3. notify中傳入的兩個變數又都是用來做什麼的

我們也只能夠從notify函式中找到一些線索,檢視到前兩個函式所返回物件的部分屬性, _但是仍然不能知道這些屬性的型別是什麼_。
而想要維護這樣的一段程式碼,就需要佔用很多腦容量去記憶,這實際上是一個價效比非常低的事情,當這段程式碼再轉給第三個人時,第三個人還需要再經歷完整的流程,一個個函式、一行行程式碼去閱讀,去記憶。
如果你把這個當作是對程式的深入瞭解程度、對業務的嫻熟掌握,那麼我覺得我也幫不了你了。
就像是現在超市結賬時,沒有櫃員會以能夠記憶N多商品價格而感到驕傲,掃碼槍能做到的事情,為什麼要佔用你的大腦呢。

基礎用法

如上文所說的,JSDoc 是寫在註釋中的一些特定格式內容。
在 JavaScript 檔案中大部分的標記都是塊級形式的,也就是使用 /** XXX */ 來進行定義,不過如果你願意的話,也可以寫到程式碼裡邊去。

JSDoc 提供了很多種標記,用於各種場景。
但並不是所有的都是常用的(而且使用了 vscode 以後,很多需要手動指定的標記,編輯器都能夠代替你完成),常用的無外乎以下幾個:

  • @type 標識變數型別
  • @param 標識函式引數型別及描述
  • @return 標識函式返回值型別及描述
完整的列表可以在這裡找到 Block tags

基本上使用以上三種標記以後,已經能夠解決絕大部分的問題。
JSDoc 在寫法上有著特定的要求,比如說行內也必須要是這樣的結構 /** XXX */,如果是 /* XXX */ 則會被忽略。
而多行的寫法是比較常用的,在 vscode 中可以直接在函式上方鍵入 /** 然後回車,編輯器會自動填充很多的內容,包括引數型別、引數描述以及函式描述的預留位置,使用TAB鍵即可快速切換。

實際上@type的使用頻率相較於其他兩個是很低的,因為大多數情況下@type用於標識變數的型別。
而變數的來源基本上只有兩個 1. 基本型別賦值 2. 函式返回值
首先是第一個基本型別的賦值,這個基本上 vscode 就幫你做了,而不需要自己手動的去指定。
而另外一個函式的返回值,如果我們在函式上新增了@return後,那麼呼叫該函式並獲取返回值的變數型別也會被設定為@return對應的型別。

type

不過因為其他兩個標記中都有型別相關的指定,所以就拿 @type 來說明一下

首先,在 JSDoc 中是支援所有的基本型別的,包括數字、字串、布林值之類的。

/** @type {number} */
/** @type {string} */
/** @type {boolean} */
/** @type {RegExp} */

// 或者是一個函式
/** @type {function} */

// 一個包含引數的函式
/** @type {function(number, string)} */

// Object結構的引數
/** @type {function({ arg1: number, arg2: string })} */

// 一個包涵引數和返回值的函式
/** @type {function(number, string): boolean} */
在 vscode 中鍵入以上的註釋,都可以很方便的得到動態提示。
當然了,關於函式的,還是推薦使用 @param 和 @return 來實現,效果更好一些

擴充套件複雜型別

上邊的示例大多是基於基本型別的描述,但實際開發過程中不會說只有這麼些基本型別供你使用的。
必然會存在著大量的複雜結構型別的變數、引數或返回值。

關於函式引數,在 JSDoc 中兩種方式可以描述複雜型別:

不過這個只能應用在@param中,而且複用性並不高,如果有好幾處同樣結構的定義,那我們就需要把這樣的註釋拷貝多份,顯然不是一個優雅的寫法。
又或者我們可以使用另外兩個標記,@typedef@property,格式都與上邊提到的標記類似,可以應用在所有需要指定型別的地方:

使用@typedef定義的型別可以很輕鬆的複用,在需要的地方直接指定我們定義好的型別即可。
同理,這樣的自定義型別可以直接應用在@return中。

param

這個算是比較重要的一個標記了,用來標記函式引數的相關資訊。
具體的格式是這樣的(切換到 TypeScript 後一般會移除型別的定義,改用程式碼中的型別定義):

/**
 * @param {number} param 描述
 */
function test (param) { }

// 或者可以結合著 @type 來寫(雖說很少會這麼寫)

/**
 * @param param 描述
 */
function test (/** @type number */ param) { }

可選引數

如果我們想要表示一個引數為可選的引數,可以的在引數名上包一個[]即可。

/**
 * @param {number} [param] 描述
 */
function test (param) { }

同事在文件中還提到了關於預設值的寫法,實際上如果你的可選引數在引數位已經有了預設值的處理,那麼就不再需要額外的新增[]來表示了,vscode 會幫助你標記。

// 文件中提到的預設值寫法
/**
 * @param {number} [param=123] 描述
 */
function test (param = 123) { }

// 而實際上使用 vscode 以後就可以簡化為
/**
 * @param param 描述
 */
function test (param = 123) { }

兩者效果是一樣的,並且由於我們手動指定了一個基礎型別的值,那麼我們連型別的指定都可以省去了,簡單的定義一下引數的描述即可。

return

該標記就是用來指定函式的返回值,用法與@param型別,並且基本上這兩個都會同時出現,與@param的區別在於,因為@return只會有一個,所以不會像前者一樣還需要指定引數名。

/**
 * @return {number} 描述
 */
function test () { }

Promise 型別的返回值處理

現在這個年代,基本上Promise已經普及開來,所以很多函式的返回值可能並不是結果,而是一個Promise
所以在vscode中,基於Promise去使用@return,有兩種寫法可以使用:

// 函式返回 Promise 例項的情況可以這麼指定型別
/**
 * @return {Promise<number>}
 */
function test () {
  return new Promise((res) => {
    res(1)
  })
}

// 或者使用 async 函式定義的情況下可以省略 @return 的宣告
async function test () {
  return 1
}

  // 如果返回值是一個其他定義了型別的函式 or 變數,那麼效果一樣
async function test () {
  return returnVal()
}

/** @return {string} */
function returnVal () {}

小結

再回到我們最初的那個程式碼片段上,將其修改為新增了 JSDoc 版本的樣子:

/**
 * @typedef   {Object} UserInfo
 * @property  {number} uid  使用者UID
 * @property  {string} name 暱稱
 * 
 * @typedef   {Object} Order
 * @property  {number} orderId 訂單ID
 * @property  {number} price   訂單價格
 */
async function main () {
  const uid = 1

  const orders = await createOrder(uid)

  const userInfo = await getUserInfo(uid)

  await notify(userInfo, orders)
}

/**
 * 獲取使用者資訊
 * @param   {number} uid 使用者UID
 * @return  {Promise<UserInfo>}
 */
async function getUserInfo (uid) { }

/**
 * 建立訂單
 * @param  {number} uid 使用者UID
 * @return {Promise<Order>}
 */
async function createOrder (uid) { }

/**
 * 傳送通知
 * @param {UserInfo} userInfo 
 * @param {Order}    orders 
 */
async function notify (userInfo, orders) { }

實際上並沒有新增幾行文字,在切換到 TypeScript 之前,使用 JSDoc 能夠在一定程度上降低維護成本,尤其是使用 vscode 以後,要手動編寫的註釋實際上是沒有多少的。
但是帶來的好處就是,維護者能夠很清晰的看出函式的作用,變數的型別。程式碼即文件。
並且在進行日常開發時,結合編輯器的自動補全、動態提示功能,想必一定是能夠提高開發體驗的。

上邊介紹的只是 JSDoc 常用的幾個標記,實際上還有更多的功能沒有提到,具體的文件地址:jsdoc

參考資料

相關文章