新技術(shù)層出不窮,長江后浪推前浪,而浪潮褪去后能留下來的,是一些經(jīng)典的設(shè)計思想。
在前端界,以前有遠近聞名的 jQuery,近來有聲名鵲起的 Vue.js。這兩者叫好又叫座的原因固然有很多,但是其中有一個共同特質(zhì)不可忽視,那便是它們的 API 設(shè)計 非常優(yōu)雅。
因此這次我想來談個大課題 —— API 設(shè)計之道。
本文并不是《jQuery API 賞析》,當(dāng)我們談?wù)?API 的設(shè)計時,不只局限于討論「某個框架應(yīng)該如何設(shè)計暴露出來的方法」。作為程序世界分治復(fù)雜邏輯的基本協(xié)作手段,廣義的 API 設(shè)計涉及到我們?nèi)粘i_發(fā)中的方方面面。
最常見的 API 暴露途徑是函數(shù)聲明(Function Signiture),以及屬性字段(Attributes);而當(dāng)我們涉及到前后端 IO 時,則需要關(guān)注通信接口的數(shù)據(jù)結(jié)構(gòu)(JSON Schema);如果還有異步的通信,那么事件(Events)或消息(Message)如何設(shè)計也是個問題;甚至,依賴一個包(Package)的時候,包名本身就是接口,你是否也曾碰到過一個奇葩的包名而吐槽半天?
總之,「API 設(shè)計」不只關(guān)乎到框架或庫的設(shè)計者,它和每個開發(fā)者息息相關(guān)。
有一個核心問題是,我們?nèi)绾卧u判一個 API 的設(shè)計算「好」?在我看來,一言以蔽之,易用。
那「易用」又是什么呢?我的理解是,只要能夠足夠接近人類的日常語言和思維,并且不需要引發(fā)額外的大腦思考,那就是易用。
Don’t make me think.
具體地,我根據(jù)這些年來碰到的大量(反面和正面)案例,歸納出以下這些要點。按照要求從低到高的順序如下:
(本文主要以 JavaScript 作為語言示例。)
高級語言和自然語言(英語)其實相差無幾,因此正確地使用(英語的)詞法和語法是程序員最基本的素養(yǎng)。而涉及到 API 這種供用戶調(diào)用的代碼時,則尤其重要。
但事實上,由于亞洲地區(qū)對英語的掌握能力普遍一般……所以現(xiàn)實狀況并不樂觀 —— 如果以正確使用詞法和語法作為達標(biāo)的門檻,很多 API 都沒能達標(biāo)。
正確地拼寫一個單詞是底線,這一點無需贅述。然而 API 中的各種錯別字現(xiàn)象仍屢見不鮮,即使是在我們阿里這樣的大公司內(nèi)。
曾經(jīng)有某個 JSON 接口(mtop)返回這樣一組店鋪數(shù)據(jù),以在前端模板中渲染:
1 | // json |
乍一看平淡無奇,結(jié)果我調(diào)試了小半天都沒能渲染出店鋪的「店鋪等級標(biāo)識圖片」,即 shopLevelImg
字段。問題到底出在了哪里?
眼細的朋友可能已經(jīng)發(fā)現(xiàn),接口給的字段名是 shopLeveImg
,少了一個 l
,而在其后字母 I
的光輝照耀下,肉眼很難分辨出這個細節(jié)問題。
拼錯單詞的問題真的是太普遍了,再比如:
toast
的庫,package.json 中的 name 寫成了 taost
。導(dǎo)致在 npm 中沒能找到這個包。panel
寫成了 pannel
。導(dǎo)致以正確的屬性名初始化時代碼跑不起來。entertainment
寫成了 entainment
……這倒沒什么大影響,只是 URL 發(fā)布后就改不了了,留下了錯別字不好看。注意到,這些拼寫錯誤經(jīng)常出現(xiàn)在 字符串 的場景中。不同于變量名,IDE 無法檢查字符串中的單詞是否科學(xué)、是否和一些變量名一致,因此,我們在對待一些需要公開出去的 API 時,需要尤其注意這方面的問題;另一方面,更認(rèn)真地注意 IDE 的 typo 提示(單詞拼寫錯誤提示),也會對我們產(chǎn)生很大幫助。
我們知道,中英文單詞的含義并非一一對應(yīng),有時一個中文意思可以用不同的英文單詞來解釋,這時我們需要選擇使用恰當(dāng)?shù)臏?zhǔn)確的詞來描述。
比如中文的「消息」可以翻譯為 message、notification、news 等。雖然這幾個不同的單詞都可以有「消息」的意思,但它們在用法和語境場景上存在著細微差異:
postMessage()
和 receiveMessage()
。new NotificationManager()
。getTopNews()
。fetchWeitaoFeeds()
。所以,即使中文意思大體相近,也要準(zhǔn)確地用詞,從而讓讀者更易理解 API 的作用和 上下文場景。
有一個正面案例,是關(guān)于 React 的。(在未使用 ES2015 的)React 中,有兩個方法叫做:
1 | React.createClass({ |
它們的作用都是用來定義初始化的組件信息,返回值的類型也都一樣,但是在方法名上卻分別用了 default
和 initial
來修飾,為什么不統(tǒng)一為一個呢?
原因和 React 的機制有關(guān):
props
是指 Element 的屬性,要么是不存在某個屬性值后來為它賦值,要么是存在屬性的默認(rèn)值后來將其覆蓋。所以這種行為,default
是合理的修飾詞。state
是整個 Component 狀態(tài)機中的某一個特定狀態(tài),既然描述為了狀態(tài)機,那么狀態(tài)和狀態(tài)之間是互相切換的關(guān)系。所以對于初始狀態(tài),用 initial
來修飾。就這么個小小的細節(jié),就可一瞥 React 本身的機制,足以體現(xiàn) API 設(shè)計者的智慧。
另外,最近我還碰到了這樣一組事件 API:
1 | // event name 1 |
這兩個事件顯然是一對正反義的動作,在上述案例中,表示「顯示窗口」時使用了 show
,表示「關(guān)閉窗口」時使用了 close
,這都是非常直覺化的直譯。而事實上,成對出現(xiàn)的詞應(yīng)該是:show & hide
、open & close
。
因此這里必須強調(diào):成對出現(xiàn)的正反義詞不可混用。在程序世界經(jīng)常成對出現(xiàn)的詞還有:
總之,我們可以試著擴充英語的詞匯量,使用合適的詞,這對我們準(zhǔn)確描述 API 有很大的幫助。
所有涉及到諸如數(shù)組(Array)、集合(Collection)、列表(List)這樣的數(shù)據(jù)結(jié)構(gòu),在命名時都要使用復(fù)數(shù)形式:
1 | var shopItems = [ |
現(xiàn)實往往出人意表地糟糕,前不久剛改一個項目,我就碰到了這樣的寫法:
1 | class MarketFloor extends Component { |
這里的 item
實為一個數(shù)組,即使它內(nèi)部只有一個成員。因此應(yīng)該命名為 items
或 itemList
,無論如何,不應(yīng)該是表示單數(shù)的 item
。
同時要注意,在復(fù)數(shù)的風(fēng)格上保持一致,要么所有都是 -s
,要么所有都是 -list
。
反過來,我們在涉及到諸如字典(Dictionary)、表(Map)的時候,不要使用復(fù)數(shù)!
1 | // fail |
雖然這個數(shù)據(jù)結(jié)構(gòu)看上去由很多 key-value 對組成,是個類似于集合的存在,但是「map」本身已經(jīng)包含了這層意思,不需要再用復(fù)數(shù)去修飾它。
另外一個容易犯的低級錯誤是搞錯詞性,即命名時拎不清名詞、動詞、形容詞……
1 | asyncFunc({ |
success
算是一個在程序界出鏡率很高的詞了,但是有些同學(xué)會搞混,把它當(dāng)做動詞來用。在上述案例中,成對出現(xiàn)的單詞其詞性應(yīng)該保持一致,這里應(yīng)該寫作 succeed
和 fail
;當(dāng)然,在這個語境中,最好遵從慣例,使用名詞組合 success
和 failure
。
這一對詞全部的詞性如下:
注意到,如果有些詞沒有對應(yīng)的詞性,則考慮變通地采用其他形式來達到同樣的意思。
所以,即使我們大部分人都知道:方法命名用動詞、屬性命名用名詞、布爾值類型用形容詞(或等價的表語),但由于對某些單詞的詞性不熟悉,也會導(dǎo)致最終的 API 命名有問題,這樣的話就很尷尬了。
關(guān)于詞法最后一個要注意的點是縮寫。有時我們經(jīng)常會糾結(jié),首字母縮寫詞(acronym)如 DOM
、SQL
是用大寫還是小寫,還是僅首字母大寫,在駝峰格式中又該怎么辦……
對于這個問題,簡單不易混淆的做法是,首字母縮寫詞的所有字母均大寫。(如果某個語言環(huán)境有明確的業(yè)界慣例,則遵循慣例。)
1 | // before |
在經(jīng)典前端庫 KISSY 的早期版本中,DOM
在 API 中都命名為 dom
,駝峰下變?yōu)?Dom
;而在后面的版本內(nèi)統(tǒng)一寫定為全大寫的 DOM
。
另外一種縮寫的情況是對長單詞簡寫(shortened word),如 btn (button)
、chk (checkbox)
、tpl (template)
。這要視具體的語言規(guī)范 / 開發(fā)框架規(guī)范而定。如果什么都沒定,也沒業(yè)界慣例,那么把單詞寫全了總是不會錯的。
由于我們在調(diào)用 API 時一般類似于「調(diào)用一條指令」,所以在語法上,一個函數(shù)命名是祈使句式,時態(tài)使用一般現(xiàn)在時。
但在某些情況下,我們需要使用其他時態(tài)(進行時、過去時、將來時)。比如,當(dāng)我們涉及到 生命周期、事件節(jié)點。
在一些組件系統(tǒng)中,必然涉及到生命周期,我們來看一下 React 的 API 是怎么設(shè)計的:
1 | export function componentWillMount() {} |
React 劃分了幾個關(guān)鍵的生命周期節(jié)點(mount, update, unmount, …),以將來時和過去時描述這些節(jié)點片段,暴露 API。注意到一個小細節(jié),React 采用了 componentDidMount
這種過去時風(fēng)格,而沒有使用 componentMounted
,從而跟 componentWillMount
形成對照組,方便記憶。
同樣地,當(dāng)我們設(shè)計事件 API 時,也要考慮使用合適的時態(tài),特別是希望提供精細的事件切面時。或者,引入 before
、after
這樣的介詞來簡化:
1 | // will render |
另一方面是關(guān)于語態(tài),即選用主動語態(tài)和被動語態(tài)的問題。其實最好的原則就是 盡量避免使用被動語態(tài)。因為被動語態(tài)看起來會比較繞,不夠直觀,因此我們要將被動語態(tài)的 API 轉(zhuǎn)換為主動語態(tài)。
寫成代碼即形如:
1 | // passive voice, make me confused |
說了那么多詞法和語法的注意點,不過才是達標(biāo)級別而已。確保 API 的可用性和語義才使 API 真正「可用」。
無論是友好的參數(shù)設(shè)置,還是讓人甜蜜蜜的語法糖,都體現(xiàn)了程序員的人文關(guān)懷。
單一職責(zé)是軟件工程中一條著名的原則,然而知易行難,一是我們對于具體業(yè)務(wù)邏輯中「職責(zé)」的劃分可能存在難度,二是部分同學(xué)仍沒有養(yǎng)成貫徹此原則的習(xí)慣。
小到函數(shù)級別的 API,大到整個包,保持單一核心的職責(zé)都是很重要的一件事。
1 | // fail |
如上,將混雜在一個大坨函數(shù)中的兩件獨立事情拆分出去,保證函數(shù)(function)級別的職責(zé)單一。
更進一步地,(假設(shè))fetchData
本身更適合用另一個類(class)來封裝,則對原來的組件類 Component
再進行拆分,將不屬于它的取數(shù)據(jù)職責(zé)也分離出去:
1 | class DataManager { |
在文件(file)層面同樣如此,一個文件只編寫一個類,保證文件的職責(zé)單一(當(dāng)然這對很多語言來說是天然的規(guī)則)。
最后,視具體的業(yè)務(wù)關(guān)聯(lián)度而決定,是否將一簇文件做成一個包(package),或是拆成多個。
嚴(yán)格「無 副作用 的編程」幾乎只出現(xiàn)在純函數(shù)式程序中,現(xiàn)實中的 OOP 編程場景難免觸及副作用。因此在這里所說的「避免副作用」主要指的是:
對于無副作用的純函數(shù)而言,輸入同樣的參數(shù),執(zhí)行后總能得到同樣的結(jié)果,這種冪等性使得一個函數(shù)無論在什么上下文中運行、運行多少次,最后的結(jié)果總是可預(yù)期的 —— 這讓用戶非常放心,不用關(guān)心函數(shù)邏輯的細節(jié)、考慮是否應(yīng)該在某個特定的時機調(diào)用、記錄調(diào)用的次數(shù)等等。希望我們以后設(shè)計的 API 不會出現(xiàn)這個案例中的情況:
1 | // return x.x.x.1 while call it once |
在這里,getSPM()
用來獲取每個鏈接唯一的 SPM 碼(SPM 是阿里通用的埋點統(tǒng)計方案)。但是用法卻顯得詭異:每調(diào)用一次,就會返回一個不同的 SPM 串,于是當(dāng)我們需要獲得幾個 SPM 時,就會這樣寫:
1 | var spm1 = this.context.getSPM(); |
雖然在實現(xiàn)上可以理解 —— 此函數(shù)內(nèi)部維護了一個計數(shù)器,每次返回一個自增的 SPM D 位,但是 這樣的實現(xiàn)方式與這個命名看似是冪等的 getter 型函數(shù)完全不匹配,換句話說,這使得這個 API 不可預(yù)期。
如何修改之?一種做法是,不改變此函數(shù)內(nèi)部的實現(xiàn),而是將 API 改為 Generator 式的風(fēng)格,通過形如 SPMGenerator.next()
接口來獲取自增的 SPM 碼。
另一種做法是,如果要保留原名稱,可以將函數(shù)簽名改為 getSPM(spmD)
,接受一個自定義的 SPM D 位,然后返回整個 SPM 碼。這樣在調(diào)用時也會更明確。
除了函數(shù)內(nèi)部的運行需可預(yù)期外,它對外部一旦造成不可預(yù)期的污染,那么影響將更大,而且更隱蔽。
對外部造成污染一般是兩種途徑:一是在函數(shù)體內(nèi)部直接修改外部作用域的變量,甚至全局變量;二是通過修改實參間接影響到外部環(huán)境,如果實參是引用類型的數(shù)據(jù)結(jié)構(gòu)。
曾經(jīng)也有發(fā)生因為對全局變量操作而導(dǎo)致整個容器垮掉的情況,這里就不再展開。
如何防止此類副作用發(fā)生?本質(zhì)上說,需要控制讀寫權(quán)限。比如:
對一個函數(shù)來說,「函數(shù)簽名」(Function Signature)比函數(shù)體本身更重要。函數(shù)名、參數(shù)設(shè)置、返回值類型,這三要素構(gòu)成了完整的函數(shù)簽名。而其中,參數(shù)設(shè)置對用戶來說是接觸最頻繁,也最為關(guān)心的部分。
那如何優(yōu)雅地設(shè)計函數(shù)的入口參數(shù)呢?我的理解是這樣幾個要點:
優(yōu)化參數(shù)順序。相關(guān)性越高的參數(shù)越要前置。
這很好理解,相關(guān)性越高的參數(shù)越重要,越要在前面出現(xiàn)。其實這還有兩個隱含的意思,即 可省略的參數(shù)后置,以及 為可省略的參數(shù)設(shè)定缺省值。對某些語言來說(如 C++),調(diào)用的時候如果想省略實參,那么一定要為它定義缺省值,而帶缺省值的參數(shù)必須后置,這是在編譯層面就規(guī)定死的。而對另一部分靈活的語言來說(如 JS),將可省參數(shù)后置同樣是最佳實踐。
1 | // bad |
第二個要點是控制參數(shù)個數(shù)。用戶記不住過多的入口參數(shù),因此,參數(shù)能省略則省略,或更進一步,合并同類型的參數(shù)。
由于可以方便地創(chuàng)建 Object 這種復(fù)合數(shù)據(jù)結(jié)構(gòu),合并參數(shù)的這種做法在 JS 中尤為普遍。常見的情況是將很多配置項都包成一個配置對象:
1 | // traditional |
這樣做的好處是:
當(dāng)然,凡事有利有弊,由于缺乏順序,就無法突出哪些是最核心的參數(shù)信息;另外,在設(shè)定參數(shù)的默認(rèn)值上,會比參數(shù)列表的形式更繁瑣。因此,需要兼顧地使用最優(yōu)的辦法來設(shè)計函數(shù)參數(shù),為了同一個目的:易用。
談到 API 的設(shè)計,尤其是函數(shù)的設(shè)計,總離不開一個機制:重載(overload)。
對于強類型語言來說,重載是個很 cool 的功能,能夠大幅減少函數(shù)名的數(shù)量,避免命名空間的污染。然而對于弱類型語言而言,由于不需要在編譯時做 type-binding,函數(shù)在調(diào)用階段想怎么傳實參都行……所以重載在這里變得非常微妙。以下著重談一下,什么時候該選擇重載,什么時候又不該。
1 | Element getElementById(String: id) |
以上三個函數(shù)是再經(jīng)典不過的 DOM API,而在當(dāng)初學(xué)習(xí)它們的時候(從 Java 思維轉(zhuǎn)到 JS 思維)我就在想這兩個問題:
getSomethingBySomething
這么復(fù)雜結(jié)構(gòu)的名字,而不是使用 getSomething
做重載?getElementById
是單數(shù)形式,為何不設(shè)計為返回 HTMLCollection(即使只返回一個成員也可以包一個 Collection 嘛),以做成復(fù)數(shù)形式的函數(shù)名從而保持一致性?兩個問題中,如果第二個問題能解決,那么這三個函數(shù)的結(jié)構(gòu)將完全一致,從而可以考慮解決第一個問題。
先來看問題二。稍微深入下 DOM 知識后就知道,id 對于整個 DOM 來說必須是唯一的,因此在理論上 getElementsById
(注意有復(fù)數(shù))將永遠返回僅有 0 或 1 個成員的 Collection,這樣一來用戶的調(diào)用方式將始終是 var element = getElementsById(id)[0]
,而這是非常荒謬的。所以 DOM API 設(shè)計得沒問題。
既然問題二無解,那么自然這三個函數(shù)沒法做成一個重載。退一步說,即使問題二能解決,還存在另外一個麻煩:它們的入口參數(shù)都是一樣的,都是 String!對于強類型語言來說,參數(shù)類型和順序、返回值統(tǒng)統(tǒng)一樣的情況下,壓根無法重載。因為編譯器無法通過任何一個有效的特征,來執(zhí)行不同的邏輯!
所以,如果入口參數(shù)無法進行有效區(qū)分,不要選擇重載。
當(dāng)然,有一種奇怪的做法可以繞過去:
1 | // fail |
一種在風(fēng)格上類似重載的,但實際是在運行時走分支邏輯的做法……可以看到,API 的信息總量并沒降低。不過話不能說死,這種風(fēng)格在某些特定場景也有用武之地,只是多數(shù)情況下并不推薦。
與上述風(fēng)格類似的,是這樣一種做法:
1 | // get elements by tag-name by default |
「將 flag 標(biāo)記位作為了重載手段」—— 在早期微軟的一些 API 中經(jīng)常能見到這樣的寫法,可以說一旦離開了文檔就無法編碼,根本不明白某個 Boolean 標(biāo)記位是用來干嘛的,這大大降低了用戶的開發(fā)體驗,以及代碼可讀性。
這樣看起來,可重載的場景真是太少了!也不盡然,在我看來有一種場景很適合用重載:批量處理。
1 | Module handleModules(Module: module) |
當(dāng)用戶經(jīng)常面臨處理一個或多個不確定數(shù)量的對象時,他可能需要思考和判斷,什么時候用單數(shù) handleModule
、什么時候用復(fù)數(shù) handleModules
。將這種類型的操作重載為一個(大抵見于 setter 型操作),同時支持單個和批量的處理,可以降低用戶的認(rèn)知負擔(dān)。
所以,在合適的時機重載,否則寧愿選擇「函數(shù)名結(jié)構(gòu)相同的多個函數(shù)」。原則是一樣的,保證邏輯正確的前提下,盡可能降低用戶負擔(dān)。
對了,關(guān)于 getElements
那三個 API,它們最終的進化版本回到了同一個函數(shù):querySelector(selectors)
。
函數(shù)的易用性體現(xiàn)在兩方面:入口和出口。上面已經(jīng)講述了足夠多關(guān)于入口的設(shè)計事項,這一節(jié)講出口:函數(shù)返回值。
對于 getter 型的函數(shù)來說,調(diào)用的直接目的就是為了獲得返回值。因此我們要讓返回值的類型和函數(shù)名的期望保持一致。
1 | // expect 'a.b.c.d' |
從這一點上來講,要慎用 ES2015 中的新特性「解構(gòu)賦值」。
而對于 setter 型的函數(shù),調(diào)用的期望是它能執(zhí)行一系列的指令,然后去達到一些副作用,比如存文件、改寫變量值等等。因此絕大多數(shù)情況我們都選擇了返回 undefined / void —— 這并不總是最好的選擇。
回想一下,我們在調(diào)用操作系統(tǒng)的命令時,系統(tǒng)總會返回「exit code」,這讓我們能夠獲知系統(tǒng)命令的執(zhí)行結(jié)果如何,而不必通過其他手段去驗證「這個操作到底生效了沒」。因此,創(chuàng)建這樣一種返回值風(fēng)格,或可一定程度增加健壯性。
另外一個選項,是讓 setter 型 API 始終返回 this
。這是 jQuery 為我們帶來的經(jīng)典啟示 —— 通過返回 this
,來產(chǎn)生一種「鏈?zhǔn)秸{(diào)用(chaining)」的風(fēng)格,簡化代碼并且增加可讀性:
1 | $('div') |
最后還有一個異類,就是異步執(zhí)行的函數(shù)。由于異步的特性,對于這種需要一定延時才能得到的返回值,只能使用 callback 來繼續(xù)操作。使用 Promise 來包裝它們尤為必要。對異步操作都返回一個 Promise,使整體的 API 風(fēng)格更可預(yù)期。
在前面的詞法部分中曾經(jīng)提到「準(zhǔn)確用詞」,但即使我們已經(jīng)盡量去用恰當(dāng)?shù)脑~,在有些情況下仍然不免碰到一些難以抉擇的尷尬場景。
比如,我們經(jīng)常會看到 pic 和 image、path 和 url 混用的情況,這兩組詞的意思非常接近(當(dāng)然嚴(yán)格來說 path 和 url 的意義是明確不同的,在此暫且忽略),稍不留神就會產(chǎn)生 4 種組合……
所以,在一開始就要 產(chǎn)出術(shù)語表,包括對縮寫詞的大小寫如何處理、是否有自定義的縮寫詞等等。一個術(shù)語表可以形如:
標(biāo)準(zhǔn)術(shù)語 | 含義 | 禁用的非標(biāo)準(zhǔn)詞 |
---|---|---|
pic | 圖片 | image, picture |
path | 路徑 | URL, url, uri |
on | 綁定事件 | bind, addEventListener |
off | 解綁事件 | unbind, removeEventListener |
emit | 觸發(fā)事件 | fire, trigger |
module | 模塊 | mod |
不僅在公開的 API 中要遵守術(shù)語表規(guī)范,在局部變量甚至字符串中都最好按照術(shù)語表來。
1 | page.emit('pageRenderRow', { |
比如這個我最近碰到的案例,同時寫作了 modList
和 moduleList
,這就有點怪怪的。
另外,對于一些創(chuàng)造出來的、業(yè)務(wù)特色的詞匯,如果不能用英語簡明地翻譯,就直接用拼音:
Taobao
Weitao
Jiyoujia
在這里,千萬不要把「微淘」翻譯為 MicroTaobao
……當(dāng)然,專有詞已經(jīng)有英文名的除外,如 Tmall
。
這一節(jié)算得上是一個復(fù)習(xí)章節(jié)。詞法、語法、語義中的很多節(jié)都指向同一個要點:一致性。
一致性可以最大程度降低信息熵。
好吧,這句話不是什么名人名言,就是我現(xiàn)編的??偠灾?,一致性能大大降低用戶的學(xué)習(xí)成本,并對 API 產(chǎn)生準(zhǔn)確的預(yù)期。
甚至還可以一致得更細節(jié)些,只是舉些例子:
object.onDoSomething = func
或 object.on('doSomething', func)
。this
。一份代碼寫得再怎么爛,把某個單詞都拼成一樣的錯誤,也好過這個單詞只出現(xiàn)一次錯誤。
是的,一致性,再怎么強調(diào)都不為過。
不管是大到發(fā)布至業(yè)界,或小到在公司內(nèi)跨部門使用,一組 API 一旦公開,整體上就是一個產(chǎn)品,而調(diào)用方就是用戶。所謂牽一發(fā)而動全身,一個小細節(jié)可能影響整個產(chǎn)品的面貌,一個小改動也可能引發(fā)整個產(chǎn)品崩壞。因此,我們一定要站在全局的層面,甚至考慮整個技術(shù)環(huán)境,系統(tǒng)性地把握整個體系內(nèi) API 的設(shè)計,體現(xiàn)大局觀。
80% 的項目開發(fā)在版本控制方面做得都很糟糕:隨心所欲的版本命名、空洞詭異的提交信息、毫無規(guī)劃的功能更新……人們顯然需要一段時間來培養(yǎng)規(guī)范化開發(fā)的風(fēng)度,但是至少得先保證一件事情:
在大版本號不變的情況下,API 保證向前兼容。
這里說的「大版本號」即「語義化版本命名」<major>.<minor>.<patch>
中的第一位 <major>
位。
這一位的改動表明 API 整體有大的改動,很可能不兼容,因此用戶對大版本的依賴改動會慎之又慎;反之,如果 API 有不兼容的改動,意味著必須修改大版本號,否則用戶很容易出現(xiàn)在例行更新依賴后整個系統(tǒng)跑不起來的情況,更糟糕的情況則是引發(fā)線上故障。
如果這種情況得不到改善,用戶們就會選擇 永遠不升級依賴,導(dǎo)致更多的潛在問題。久而久之,最終他們便會棄用這些產(chǎn)品(庫、中間件、whatever)。
所以,希望 API 的提供者們以后不會再將大版本鎖定為 0
。更多關(guān)于「語義化版本」的內(nèi)容,請參考我的另一篇文章《論版本號的正確打開方式》。
如果不希望對客戶造成更新升級方面的困擾,我們首先要做好的就是確保 API 向下兼容。
API 發(fā)生改動,要么是需要提供新的功能,要么是為之前的糟糕設(shè)計買單……具體來說,改動無外乎:增加、刪除、修改 三方面。
首先是刪除。不要輕易刪除公開發(fā)布的 API,無論之前寫得多么糟糕。如果一定要刪除,那么確保正確使用了「Deprecated
」:
對于某個不想保留的可憐 API,先不要直接刪除,將其標(biāo)記為 @deprecated
后置入下一個小版本升級(比如從 1.0.2
到 1.1.0
)。
1 | /** |
并且,在 changelog 中明確指出這些 API 即將移除(不推薦使用,但是目前仍然能用)。
之后,在下一個 大版本 中(比如 1.1.0
到 2.0.0
)刪除標(biāo)記為 @deprecated
的部分,同時在 changelog 中指明它們已刪除。
其次是 API 的修改。如果我們僅僅是修復(fù) bug、重構(gòu)實現(xiàn)、或者添加一些小特性,那自然沒什么可說的;但是如果想徹底修改一個 API……比如重做入口參數(shù)、改寫業(yè)務(wù)邏輯等等,建議的做法是:
最后是新增 API。事實上,即使是只加代碼不刪代碼,整體也不一定是向下兼容的。有一個經(jīng)典的正面案例是:
1 | // modern browsers |
瀏覽器新增的一個 API,用以標(biāo)記「當(dāng)前文檔是否可見」。直觀的設(shè)計應(yīng)該是新增 document.visible
這樣的屬性名……問題是,在邏輯上,文檔默認(rèn)是可見的,即 document.visible
默認(rèn)為 true
,而不支持此新屬性的舊瀏覽器返回 document.visible == undefined
,是個 falsy 值。因此,如果用戶在代碼中簡單地以:
1 | if (document.visible) { |
做特征檢測的話,在舊瀏覽器中就會進入錯誤的條件分支……而反之,以 document.hidden
API 來判斷,則是向下兼容的。
毫無疑問,在保證向下兼容的同時,API 需要有一個對應(yīng)的擴展機制以可持續(xù)發(fā)展 —— 一方面便于開發(fā)者自身增加功能,另一方面用戶也能參與進來共建生態(tài)。
技術(shù)上來說,接口的擴展方式有很多,比如:繼承(extend)、組合(mixin)、裝飾(decorate)……選擇沒有對錯,因為不同的擴展方式適用于不同的場景:在邏輯上確實存在派生關(guān)系,并且需要沿用基類行為同時自定義行為的,采用重量級的繼承;僅僅是擴充一些行為功能,但是邏輯上壓根不存在父子關(guān)系的,使用組合;而裝飾手法更多應(yīng)用于給定一個接口,將其包裝成多種適用于不同場景新接口的情況……
另一方面,對于不同的編程語言來說,由于不同的語言特性……靜態(tài)、動態(tài)等,各自更適合用某幾種擴展方式。所以,到底采用什么擴展辦法,還是得視情況而定。
在 JS 界,有一些經(jīng)典的技術(shù)產(chǎn)品,它們的擴展甚至已經(jīng)形成生態(tài),如:
$.fn.customMethod = function() {};
。這種簡單的 mixin 做法已經(jīng)為 jQuery 提供了成千上萬的插件,而 jQuery 自己的大部分 API 本身也是基于這個寫法構(gòu)建起來的。React.Component
來繼承出一個組件類。對于一個 component system 來說,這是一個經(jīng)典的做法。不只是龐大的框架需要考慮擴展性,設(shè)計可擴展的 API 應(yīng)該變成一種基本的思維方式。比如這個活生生的業(yè)務(wù)例子:
1 | // json |
根據(jù)不同的類型渲染一組 feeds 信息:商品模塊、店鋪模塊,或是其他。某天新增了需求說要支持渲染天貓的店鋪模塊(多顯示個天貓標(biāo)等等),于是 JSON 接口直接新增一個 type = 'tmallShop'
—— 這種接口改法很簡單直觀,但是并不好。在不改前端代碼的情況下,tmallShop
類型默認(rèn)進入 default
分支,導(dǎo)致奇奇怪怪的渲染結(jié)果。
考慮到 tmallShop
和 shop
之間是一個繼承的關(guān)系,tmallShop
完全可以當(dāng)一個普通的 shop
來用,執(zhí)行后者的所有邏輯。用 Java 的表達方式來說就是:
1 | // a tmallShop is a shop |
將這個邏輯關(guān)系反映到 JSON 接口中,合理的做法是新增一個 subType
字段,用來標(biāo)記 tmallShop
,而它的 type
仍然保持為 shop
。這樣一來,即使原來的前端代碼完全不修改,仍然可以正常運行,除了無法渲染出一些天貓店鋪的特征。
這里還有一個非常類似的正面案例,是 ABS 搭建系統(tǒng)(淘寶 FED 出品的站點搭建系統(tǒng))設(shè)計的模塊 JSON Schema:
1 | // json |
同樣采用了 type
為主類型,而擴展字段在這里變成了 format
,用來容納一些擴展特性。在實際開發(fā)中,的確也很方便新增各種新的數(shù)據(jù)結(jié)構(gòu)邏輯。
API 能擴展的前提是什么?是接口足夠抽象。這樣才能夠加上各種具體的定語、裝飾更多功能。用日常語言舉個例子:
1 | // abstract |
所以,在設(shè)計 API 時要高抽象,不要陷入具體的實現(xiàn),不要陷入具體的需求,要高屋建瓴。
看個實際的案例:一個類 React Native 的頁面框架想暴露出一個事件「滾動到第二屏」,以便頁面開發(fā)者能監(jiān)聽這個事件,從而更好地控制頁面資源的加載策略(比如首屏默認(rèn)加載渲染、到第二屏之后再去加載剩下的資源)。
但是因為一些實現(xiàn)上的原因,頁面框架還不能通過頁面位移(offset)來精確地通知「滾動到了第二屏」,而只能判斷「第二屏的第一個模塊出現(xiàn)了」。于是這個事件沒有被設(shè)計為 secondScreenReached
,而變成了 secondScreenFirstModuleAppear
……雖然 secondScreenFirstModuleAppear
不能精確定義 secondScreenReached
,但是直接暴露這個具體的 API 實在太糟糕了,問題在于:
secondScreenReached
,那么只需要更改一下這個接口的具體實現(xiàn)即可;反之,我們暴露的是很具體的 secondScreenFirstModuleAppear
,就只能挨個通知用戶:「你現(xiàn)在可以不用依賴這個事件了,改成我們新出的 secondScreenReached
吧!」是的,抽象級別一般來說越高越好,將 API 設(shè)計成業(yè)務(wù)無關(guān)的,更通用,而且方便擴展。但是物極必反,對于像我這樣的抽象控來說,最好能學(xué)會控制接口的抽象級別,將其保持在一個恰到好處的層次上,不要做無休止的抽象。
還是剛才的例子 secondScreenReached
,我們還可以將其抽象成 targetScreenReached
,可以支持到達首屏、到達第二屏、第三屏……的事件,這樣是不是更靈活、更優(yōu)雅呢?并沒有 ——
對于特定的業(yè)務(wù)來說,接口越抽象越通用,而越具體則越能解決特定問題。所以,思考清楚,API 面向的場景范圍,避免懶惰設(shè)計,避免過度設(shè)計。
對于一整個體系的 API 來說,用戶面對的是這個整體集合,而不是其中某幾個單一的 API。我們要保證集合內(nèi)的 API 都在一致的抽象維度上,并且適當(dāng)?shù)睾喜?API,減小整個集合的信息量,酌情做減法。
產(chǎn)品開始做減法,便是對用戶的溫柔。
收斂近似意義的參數(shù)和局部變量。下面這樣的一組 API 好像沒什么不對,但是對強迫癥來說一定產(chǎn)生了不祥的直覺:
1 | export function selectTab(index) {} |
又是 index
又是 tabIndex
的,或許還會有 pageIndex
?誠然,函數(shù)形參和局部變量的命名對最終用戶來說沒有直接影響,但是這些不一致的寫法仍然能反映到 API 文檔中,并且,對開發(fā)者自身也會產(chǎn)生混淆。所以,選一個固定的命名風(fēng)格,然后從一而終!如果忘了的話,回頭看一下前文「固化術(shù)語表」這一節(jié)吧!
收斂近似職責(zé)的函數(shù)。對用戶暴露出太多的接口不是好事,但是一旦要合并不同的函數(shù),是否就會破壞「單一職責(zé)」原則呢?
不,因為「單一職責(zé)」本身也要看具體的抽象層次。以下這個例子和前文「合理運用函數(shù)重載」中的例子有相似之處,但具體又有所不同。
1 | // a complex rendering process |
類似于這樣,避免暴露過多近似的 API,合理利用抽象將其合并,減小對用戶的壓力。
對于一個有清晰繼承樹的場景來說,收斂 API 顯得更加自然且意義重大 —— 利用多態(tài)性(Polymorphism)構(gòu)建 Consistent APIs。(以下例子來源于 Clean Code JS。)
1 | // bad: type-checking here |
有一個將 API 收斂到極致的家伙恐怕大家都不會陌生:jQuery 的 $()
。這個風(fēng)格不正是 jQuery 當(dāng)年的殺手級特性之一嗎?
如果
$()
能讓我搞定這件事,就不要再給我foo()
和bar()
。
收斂近似功能的包。再往上一級,我們甚至可以合并相近的 package。
淘寶 FED 的 Rax 體系(類 RN 框架)中,有基礎(chǔ)的組件標(biāo)簽,如 <Image> (in @ali/rax-components)
、<Link> (in @ali/rax-components)
,也有一些增強功能的 package,如 <Picture> (in @ali/rax-picture)
、<Link> (in @ali/rax-spmlink)
。
在這里,后者包之于前者相當(dāng)于裝飾了更多功能,是前者的增強版。而在實際應(yīng)用中,也是推薦使用諸如 <Picture>
而禁止使用 <Image>
。那么在這種大環(huán)境下,<Image>
等基礎(chǔ) API 的暴露就反而變得很擾民。可以考慮將增強包的功能完全合并入基礎(chǔ)組件,即將 <Picture>
并入 <Image>
,用戶只需面對單一的、標(biāo)準(zhǔn)的組件 API。
這聽上去很荒謬,為什么一個 API 集合又要收斂又要發(fā)散?僅僅是為了大綱上的對稱性嗎?
當(dāng)然不是。存在這個小節(jié)是因為我有一個不得不提的案例,不適合放在其他段落,只能放在這里……不,言歸正傳,我們有時的確需要發(fā)散 API 集,提供幾個看似接近的 API,以引導(dǎo)用戶。因為 —— 雖然這聽起來很荒謬 —— 某些情況下,API 其實不夠用,但是用戶 沒有意識到 API 不夠用,而是選擇了混用、濫用。看下面這個例子:
1 | // the func is used here |
在重構(gòu)一組代碼時,我看到代碼里充斥著 requestAnimationFrame()
,這是一個比較新的全局 API,它會以接近 60 FPS 的速率延時執(zhí)行一個傳入的函數(shù),類似于一個針對特定場景優(yōu)化過的 setTimeout()
,但它的初衷是用來繪制動畫幀的,而不應(yīng)該用在奇奇怪怪的場景中。
在深入地了解了代碼邏輯之后,我認(rèn)識到這里如此調(diào)用是為了「延時一丟丟執(zhí)行一些操作」,避免阻塞主渲染線程。然而這種情況下,還不如直接調(diào)用 setTimeout()
來做延時操作。雖然沒有太明確的語義,但是至少好過把自己偽裝成一次動畫的繪制。更可怕的是,據(jù)我所知 requestAnimationFrame()
的濫用不僅出現(xiàn)在這次重構(gòu)的代碼中,我至少在三個不同的庫見過它的身影 —— 無一例外地,這些庫和動畫并沒有什么關(guān)系。
(一個可能的推斷是,調(diào)用 requestAnimationFrame(callback)
時不用指定 timeout
毫秒數(shù),而 setTimeout(callback, timeout)
是需要的。似乎對很多用戶來說,前者的調(diào)用方式更 cool?)
所以,在市面上有一些 API 好像是「偏方」一般的存在:雖然不知道為什么要這么用,但是……用它就對了!
事實上,對于上面這個場景,最恰當(dāng)?shù)慕夥ㄊ鞘褂靡粋€更加新的 API,叫做 requestIdleCallback(callback)
。這個 API 從名字上看起來就很有語義:在線程空閑的時候再執(zhí)行操作。這完全契合上述場景的需求,而且還自帶底層的優(yōu)化。
當(dāng)然,由于 API 比較新,還不是所有的平臺都能支持。即便如此,我們也可以先面向接口編程,自己做一個 polyfill:
1 | // simple polyfill |
另一個經(jīng)典的濫用例子是 ES2015 中的「Generator / yield」。
原本使用場景非常有限的生成器 Generator 機制被大神匠心獨運地加以改造,包裝成用來異步代碼同步化的解決方案。這種做法自然很有創(chuàng)意,但是從語義用法上來說實在不足稱道,讓代碼變得非常難讀,并且?guī)砭S護隱患。與其如此,還不如僅僅使用 Promise。
令人欣慰的是,隨后新版的 ES 即提出了新的異步代碼關(guān)鍵字「async / await」,真正在語法層面解決了異步代碼同步化的問題,并且,新版的 Node.js 也已經(jīng)支持這種語法。
因此,我們作為 API 的開發(fā)者,一定要提供足夠場景適用的 API,來引導(dǎo)我們的用戶,不要讓他們做出一些出人意料的「妙用」之舉。
我們說,一組公開的 API 是產(chǎn)品。而產(chǎn)品,一定有特定的用戶群,或是全球的開發(fā)者,或僅僅是跨部門的同事;產(chǎn)品同時有保質(zhì)期,或者說,生命周期。
面向目標(biāo)用戶群體,我們要制定 API 的支持策略:
老舊版本很可能還在運行,但維護者已經(jīng)沒時間精力再去管這些歷史遺物,這時明確地指出某些版本不再維護,對開發(fā)者和用戶都好。當(dāng)然,同時別忘了給出升級文檔,指導(dǎo)老用戶如何遷移到新版本。還有一個更好的做法是,在我們開啟一個新版本之際,就確定好上一個版本的壽命終點,提前知會到用戶。
還有一個技術(shù)上的注意事項,那就是:大版本間最好有明確的隔離。對于一個復(fù)雜的技術(shù)產(chǎn)品來說,API 只是最終直接面向用戶的接口,背后還有特定的環(huán)境、工具組、依賴包等各種支撐,互相之間并不能混用。
比如,曾經(jīng)的經(jīng)典前端庫 KISSY。在業(yè)界技術(shù)方案日新月異的大潮下,KISSY 6 版本已經(jīng)強依賴了 TNPM(阿里內(nèi)網(wǎng)的 NPM)、DEF 套件組(淘寶 FED 的前端工具套件),雖然和之前的 1.4 版本相比 API 的變化并不大,但是仍然不能在老環(huán)境下直接使用 6 版本的代碼庫……這一定程度上降低了自由組合的靈活度,但事實上隨著業(yè)務(wù)問題場景的復(fù)雜度提升,解決方案本身會需要更定制化,因此,將環(huán)境、工具等上下游關(guān)聯(lián)物隨代碼一起打包,做成一整個技術(shù)方案,這正是業(yè)界的現(xiàn)狀。
所以,隔離大版本,制定好 API 支持策略,讓我們的產(chǎn)品更專業(yè),讓用戶免去后顧之憂。
以上,便是我從業(yè)以來感悟到的一些「道」,三個進階層次、幾十個細分要點,不知有沒有給讀者您帶來一丁點啟發(fā)。
但實際上,大道至簡。我一直認(rèn)為,程序開發(fā)和平時的說話寫字其實沒有太大區(qū)別,無非三者 ——
寫代碼,就像寫作,而設(shè)計 API 好比列提綱。勤寫、勤思,了解前人的模式、套路,學(xué)習(xí)一些流行庫的設(shè)計方法,掌握英語、提高語感……相信大家都能設(shè)計出卓越的 API。
最后,附上 API 設(shè)計的經(jīng)典原則:
Think about future, design with flexibility, but only implement for production.
花絮:由于文章很長,在編寫過程中我也不由得發(fā)生了「同一個意思卻使用多種表達方式」的情況。某些時候這是必要的 —— 可以豐富文字的多樣性;而有些時候,則顯得全文缺乏一致性。在發(fā)表本文之前,我搜索了這些詞語:「調(diào)用者」、「調(diào)用方」、「引用者」、「使用者」,然后將它們統(tǒng)一修改為我們熟悉的名字:「用戶」。