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