從對 Vue 中 mixin 的批評,到對模塊間依賴關(guān)系的探討
編程框架日新月異,工具平臺推陳出新。但有意思的是,代碼的壞味道不會因為你使用工具的時髦而自行消散,團隊成員的編程水平也不會隨著工具的進化而水漲船高。
工具從來都不是你寫出好代碼的決定因素,相反它可能是最無關(guān)緊要的條件之一,但偏偏又給大部分人救命稻草一般的錯覺。它更類似于催化劑,能助你的代碼一臂之力,也能加速它的滅亡。
mixin 就是很好的一個例子。
mixin 語法回顧
如果你對 Vue 中的 mixin 語法還不甚了解,我用一句話和一個例子就能將它簡單概括:mixin 是一種組件間的代碼共享機制,允許你將代碼封裝為一個獨立模塊,將其用于在多個組件之間共享。
假設(shè) Toolbar 和 Card 組件在實例化這個組件時都需要傳遞 title、subTitle 屬性,那么你就可以考慮將這兩個屬性封裝到一個公共的例如名為 ComminMixin 的模塊里,然后將這個模塊“插入”到有需求的組件中。
首先定義 CommonMixin 模塊代碼:
- export default {
- props: {
- title: string,
- subTitle: string,
- }
- }
接著在 Toolbar 和 Card 組件中對其進行引用即可:
- import CommonMixin from '../mixins/commin-mixin.js'
- export default {
- mixins: [CommonMixin]
- }
這種方式同你直接在 Toolbar 中定義 title 和 subTitle 的屬性并無不同:
- import CommonMixin from '../mixins/commin-mixin.js'
- export default {
- props: {
- title: string,
- subTitle: string
- }
- }
mixin 機制存在什么樣的問題?早在 2016 年的時候 React 官方就發(fā)布過一篇名為 Mixins Considered Harmful 的文章,其中詳細(xì)敘述了 mixin 機制下會引起的幾類問題,比如命名沖突、比如引起滾雪球般的復(fù)雜性等等。這里我只提及我認(rèn)為危害最大的一點:隱式依賴。并借此引出我們下一節(jié)的話題。
隱式依賴
正如上一節(jié)代碼所示,mixin 模塊內(nèi)的代碼和組件內(nèi)的代碼是等價的,如果你知道 mixin 中存在一個名為 hello 的方法,你完全和可以在組件中以 this.hello() 的形式無差別的對其進行調(diào)用。
并且這種等價還是雙向的,雖然 mixin 模塊在當(dāng)初被定義時并不知道將來會有哪些組件引用它的,但如果當(dāng)下你十分確定某個消費它的組件中注定存在 world 方法,你就可以在 mixin 模塊中調(diào)用 this.world() 。這種關(guān)系還能延申至 mixin 之間,無論是平行關(guān)系還是嵌套關(guān)系下的 mixin 模塊(mixin 模塊可以繼續(xù)引用其他的 mixin 模塊),它們之間都可以互相訪問變量和方法。
在這里你是不是已經(jīng)嗅到了危險的味道?
看上去方便至極了!可一旦需要對代碼進行維護時,問題就暴露了,哪怕你只是想理解某個極小的代碼片段。
假設(shè)某個同事在組件中偶遇了 hello 方法,想給它新增一個參數(shù)來實現(xiàn)更多的功能——這看上去不起眼的小事在實際操作起來卻比登天還難。因為他根本不知道該方法是在哪個 mixin 模塊中被定義的,他所知道的只有方法屬于 this,于是他不得不翻看每一個 mixin 的定義。
退一步說即使他在某個 mixin 中找到了該方法的定義,又會遇到另一個難題:不敢修改這個函數(shù)的簽名。雖然他可以知道有多少個組件文件引用了這個 mixin 模塊,但是他不知道有多少處直接或者間接的調(diào)用了這個方法。也就是說這一次修改會造成的后果和帶來的影響難以評估。
所有這些問題的根源在于組件與 mixin 間,mixin 與 mixin 間的依賴是隱式的。也就是說當(dāng) A 模塊依賴 B 函數(shù)時,這種關(guān)系既不是通過顯示的聲明(比如 import 語句或者依賴注入的方式)取得,也不是通過公共約定(例如 windows 對象上存在 getComputedStyle 方法)確定下來的。
這種關(guān)系也讓 IDE 武功盡廢,我發(fā)現(xiàn)解決這個問題的最后方式竟然是 Ctrl + C(復(fù)制),Ctrl + V(粘貼),Ctrl + Shift + F(全局查找),Ctrl + H(文本替換)。
隱式依賴不僅會對腳本代碼帶來負(fù)面影響,對樣式代碼也會。flex 布局是一個正面的例子:如果你想控制父容器內(nèi)孩子元素的布局,只需要修改父容器上 flex 有關(guān)的屬性即可,你不依賴孩子元素的 DOM 結(jié)構(gòu),更不依賴孩子元素上的樣式;而一個反模式的例子是 text-overflow: ellipsis 屬性,單一的該樣式屬性是不足以自動省略容器內(nèi)的文字,容器還需要滿足 1) 寬度必須是 px 像素為單位 2) 元素必須擁有 overflow:hidden 和 white-space:nowrap 樣式。而 text-overflow 屬性本身并沒有告知我們還需要這些“配套設(shè)施”。
最終帶來的局面剛好符合 Uncle Bob Martin 在他的 The principles of OOD 一些列文章中談到過的糟糕設(shè)計(Bad Design)的幾個特征,比如
- 僵化(Ridigity):代碼難以修改,因為改動會影響到的地方太多
- 脆弱(Fragility):當(dāng)你做出修改時,系統(tǒng)中預(yù)期之外的地方會遭到破壞
- 難以修改(Immobility):代碼很難被復(fù)用,因為它與當(dāng)前系統(tǒng)中的功能耦合在了一起
前兩條在上面解釋過了,很好理解。至于最后一條特征,mixin 不僅似乎沒有違背,還執(zhí)行的非常好不是嗎?這就涉及到我們下一節(jié)要聊的內(nèi)容
Defactoring
這里暫停一下,我們似乎陷入到了一種窘境當(dāng)中:我們都承認(rèn) mixin 是極其強大靈活的,它將代碼的復(fù)用發(fā)揮到了極致。但現(xiàn)在看了恰恰是著這種靈活性給我們的代碼帶來了災(zāi)難。我們應(yīng)該如何理解這種矛盾?
我們要回答的第一個問題是:這種靈活性真的是我們想要的嗎?
Reginald Braithwaite 在13 年寫過一篇很有意思的技術(shù)文章 Defactoring,注意不是重構(gòu)的那個單詞 Refctoring。
什么是 defactoring? 簡而言之如果我們將把大單體代碼拆分為細(xì)粒度碎片代碼過程稱之為 factoring 的話,那么 defactoring 代指的就是相反將代碼碎片拼裝起來的過程。
為什么我們會需要 defactoring? 因為靈活性帶來的并不總是好處,它會給我們帶來認(rèn)知上的困惱,你總是需要將不同的碎片碎片拼湊起來之后才能理解整幅圖的原貌;模棱兩可的代碼總是會讓你摸不清它的意圖;更不要說代碼的復(fù)雜性了。
你可能會問如果萬一呢?有時“靈活性”的背后是我們對于未來的恐懼:我們可能需要支持 A 功能或者支持 B 功能,但事實上你不需要提前實現(xiàn)這些可能性,讓你的代碼有能力應(yīng)對這些可能性即可。
所以說恰當(dāng)?shù)?defactoring 是有必要的。
第二點我們需要考慮到人的因素。我很喜歡 Coding Horror 提出的 Falling Into The Pit of Success 的理論,引用原文中的話說就是:
- a well-designed system makes it easy to do the right things and annoying (but not impossible) to do the wrong things.
在站在項目和團隊的角度上考慮代碼的可維護性時尤其如此。
除此之外代碼應(yīng)該是易于修改,并且是很容易修改正確的。比如 TypeScript 相比 JavaScript 就是,但很明顯 mixin 并不是。
一段代碼從你編寫完畢之日起,它的命運就再也不掌握在你手中了。他人很可能會領(lǐng)悟不到你設(shè)計某個屬性的意圖,你精心設(shè)計的一段優(yōu)化性能的代碼很容易就被破壞掉。所以我們需要適應(yīng)度函數(shù),需要有測試。
在實際的工作中 mixin 大部分被濫用了。你可能會定義一個名為 ComponentCommonMixin 的模塊,初衷是用于存儲和所有組件關(guān)聯(lián)的通用屬性。但后續(xù)的開發(fā)人員并不曉得你的初衷是什么,導(dǎo)致他們在規(guī)劃接下來的公共屬性時會無腦的往這個模塊里添加,讓它變得臃腫不堪——“噢,因為它是公共的”。
表面上看它分離了公共屬性代碼和組件專屬代碼,但實際上在 mixin 模塊內(nèi)部剛好是緊耦合這種反模式的最佳體現(xiàn)。這種狀態(tài)下的 mixin 根本毫無“單一職責(zé)(Single Responsibility )”可言,在一個模塊內(nèi)部既可能包含了和樣式有關(guān)的屬性,也可能包含了和權(quán)限有關(guān)的行為,涉及對任何一塊業(yè)務(wù)的需求變更都會導(dǎo)致模塊被“打開”進行重新修改,這也有違開放封閉原則(Open-closed )。
普適的 mixin 模式
令人欣慰的是這種 mixin 中的隱式依賴問題是 Vue 框架下的特例。
提起代碼的復(fù)用我們首先想到的是繼承,但繼承不是萬能的:繼承打破了父類的封裝;繼承要求子類覆寫方法時與父類兼容;多數(shù)語言不支持多繼承。一言以蔽之繼承機制對類的抽象設(shè)計能力要求很高,劣質(zhì)的抽象比沒有抽象更難維護。
在這些限制之下,組合模式似乎是一類不錯的選擇,而 mixin 就是組合的一種實現(xiàn)方式。這里我們直接參考 TypeScript 2.2 RC 官方技術(shù)博客 中的一個例子,來說明 mixin 是如何實現(xiàn)的。簡單來說分為下面四個步驟:
- takes a constructor
- declares a class that extends that constructor
- adds members to that new class
- and returns the class itself.
這里我們嘗試實現(xiàn)一個 Timestamped mixin,它會在需要拓展的類上添加一個 timestamp 屬性:
- type Constructor<T = {}> = new (...args: any[]) => T;
- function Timestamped<TBase extends Constructor>(Base: TBase) {
- return class extends Base {
- timestamp = Date.now();
- };
- }
首先 Constructor 是一類用于描述構(gòu)造函數(shù)簽名的類型,它支持傳入泛型 T, T 代表著構(gòu)造函數(shù)實例化后返回的結(jié)果類型。它的作用不大,主要為了在下面的方法中承接基類而已。
Timestamped 方法接收一個基類作為參數(shù),這個基類必須要符合上面定義的構(gòu)造函數(shù)簽名,它必須是能“構(gòu)造出點什么東西的”。在函數(shù)的實現(xiàn)中,它用一個匿名類來繼承這個基類,并且在匿名類上新增一個 timestamp 屬性之后返回出去。
使用的效果怎樣呢?我們以一個 Point 類為例,看看如何對它進行拓展
- class Point {
- x: number;
- y: number;
- constructor(x: number, y: number) {
- this.x = x;
- this.y = y;
- }
- }
- const TimestampedPoint = Timestamped(Point);
- const p = new TimestampedPoint(10, 10);
- p.x + p.y;
- p.timestamp.getMilliseconds();
Point 自身并沒有定義 timestamp 屬性,但是通過 Timestamped 方法拓展之后,在依舊保留自身行為的同時,又新增了 timestamp 屬性。
這種模式可以無限的嵌套下去,例如我們還可以添加 draw、color 等 mixin,同時對 Point 類進行拓展:
- const NewPoint = draw(color(Timestamped(Point)))
這種模式看起來是不是非常眼熟?就是 React 中的高階組件,你一定使用過 withRouter 或者是 connect 方法來對組件進行封裝。
但為什么這種模式下似乎就不存在隱式依賴中提及的問題?因為除了移除模塊當(dāng)中的“隱式”元素之外,我們還間接的調(diào)整了模塊間的依賴方向。
在下圖 Vue 的 mixin 模式中,組件 A 和 B 看似在以單向的方式引用 mixin 模塊 B,但實際上因為隱式依賴(上圖中灰色虛線所示)的關(guān)系,模塊和組件間的依賴關(guān)系是并無統(tǒng)一方向可言,甚至可以是循環(huán)依賴的。
而下圖在 TypeScript 的 mixin 模式中,draw 函數(shù)中的匿名類對傳遞給它函數(shù)的類一無所知,它只管往在匿名類中添加自己的屬性和行為即可,并且匿名類都是相互獨立的。這樣就保證了模塊之間的依賴是單向的。注意上述的箭頭雖然表達的是“依賴”關(guān)系,但它并非是 UML 中的依賴,它既沒有調(diào)用依賴模塊的方法,也沒有將依賴模塊作為自己的成員變量
當(dāng)然,如果你“足夠有信心”的話,你還是可以強行調(diào)用傳入的基類上的方法,只不過如果你真的打算這么做的話,你可能需要通過接口或者類型將基類約束起來,給出方法的簽名來保證它是存在的。
模塊間的依賴方向是另一個我們需要關(guān)心但可能會被忽略的一點,因為它會影響到我們的調(diào)整模塊代碼的難度。Uncle Bob Martin 在《整潔架構(gòu)》一書中提出了「組件依賴原則」(Stable Dependencies Principle)。他認(rèn)為在軟件開發(fā)中的軟件設(shè)計不可能是靜態(tài)的,它注定是需要被調(diào)整的,并且不同組件模塊調(diào)整的頻率并不相同。因此,一個注定需要被改動的組件不應(yīng)該依賴那些難以被撼動組件,否則它自己也會變得難以修改。
例如對于下圖中的 Y 模塊而言,它依賴額外的三個模塊。以至于這三個模塊中任意一個模塊的變更都會給它帶來影響,這會導(dǎo)致它變得極不穩(wěn)定。
隱式依賴的其他體現(xiàn)
隱式依賴另一個極富爭議的例子就是服務(wù)定位(Service Locator)模式。
服務(wù)定位模式在大多數(shù)時候被認(rèn)為是反模式。在前端領(lǐng)域中可以實現(xiàn)但很少被用到。
什么是服務(wù)定位模式?假設(shè)你在某個類的方法中需要調(diào)用某個依賴的方法,你可以在方法中通過 Locator “臨時”找到這個依賴:
- class MyClass {
- public void MyMethod() {
- var dep = Locator.resolve(IDep)();
- dep.DoSomething();
- }
- }
它能工作沒有錯,但我們還存在另一種實現(xiàn)方式,我們可以通過創(chuàng)建實例時的構(gòu)造函數(shù)傳入依賴,也可以通過依賴注入傳入依賴:
- class MyClass {
- public MyClass(IDep dep) {}
- public void MyMethod() {
- dep.DoSomething();
- }
- }
在使用服務(wù)定位模式實現(xiàn)的前提下,你想創(chuàng)建一個實例并且調(diào)用它的方法很可能會失?。?/p>
- var myClass = new MyClass();
- myClass.MyMethod();
因為服務(wù)定位模式的問題在于它的依賴被隱藏起來了,你無法一眼看穿它對 IDep 的依賴,所以你也就可能不會在項目中引入對應(yīng)的 Locator 以及 IDep。哪怕你完整收集了它的所有依賴,你還需要額外的引入 Locator 模塊,可它與你真正需要的業(yè)務(wù)功能并無太大關(guān)系。如果能在構(gòu)造函數(shù)中進行顯式的聲明,那這些問題都能夠得到避免。
結(jié)束語
我當(dāng)然同意 mixin 是中性的,所有事故的背后本質(zhì)上都是人的問題。但如果我們承認(rèn)“人”是我們在軟件活動中永遠也無法消除的不穩(wěn)定因素的話,那就要面對 mixin 會比其他機制更讓我們的軟件岌岌可危的這個風(fēng)險。這個時候我們沒有理由視而不見了。