1. 程式人生 > >關於前端開發中“模組”和“元件”概念的思考

關於前端開發中“模組”和“元件”概念的思考

術語的重要性

首要是澄清術語。同事平時交流的時候,有比較多的上下文資訊是雙方已經預先知道的,所以容易推斷對方要表達的意思,一定程度的術語混淆關係不大。但是和其他人交流的時候,如果不明確術語的內涵和外延,經常變成雞同鴨講的狀況。

舉個例子來說,上次看到有位同學老是罵別人的文章裡哪里哪里不對,進而演變為完全否定他人。我後來發現他對某些術語的理解有諸多“與眾不同”,即他自己從概念和定義上就否定了別人。而有這樣問題的人常常不自知,或者被指出其術語運用存在的問題仍堅持認為是別人概念錯誤,跟這樣的同學交流起來就特別令人痛苦和惱火。

所以讓我們先明確定義。

模組(對應英文“module”)

通常所指“模組”是指程式語言所提供的程式碼組織機制,利用此機制可將程式拆解為獨立且通用的程式碼單元。

對於JavaScript來說,在ES6之前,並沒有語言內建的模組機制,但我們用一些方式自制了某種模組機制,像CommonJS / AMD甚至建立了普遍接受的社群標準。雖然它們都是模組機制,但會有一些重大或微妙的差異。故當我們提到JS模組時,如果沒有足夠的上下文,有時需要明確是CommonJS module或AMD module或ES6 module。

對於CSS來說,並沒有普遍接受的“CSS模組”概念。一個CSS樣式表裡可以通過@import來引入其他樣式表,但我們通常並不稱之為“模組”。多份樣式表以cascade機制結合,這和我們一般程式語言中模組互相呼叫的方式相當不同。且CSS的@import

語義基本上就是最簡單的include,也就是將@import語句替換為匯入樣式表的內容。而程式語言中的匯入模組會在當前作用域匯入名稱空間、符號等,比簡單的include要複雜許多。

有關“CSS模組”的問題,我們後面還會討論。

注:在Web標準中,“CSS module”其實指CSS spec本身的模組化。這也是我們應該避免採用“CSS模組”來指代CSS程式碼的組織結構的重要原因。

其實我公司裡對“模組”的用法也比較隨便。比如我們有/static/js/modules/目錄,其實下面就是一些指令碼,並沒有採用任何一種module規範。再如我們有/src/modules/目錄,下面每個子目錄是業務模組,裡面包含了view、controller和相關的各種類。

這裡一個是歷史因素——目錄結構不是我建立的,大家習慣如此,都知道我們講的“module”是指業務模組,跟具體程式語言裡的module沒有直接的關係,只要溝通沒有什麼障礙,那也不必改了。不過當我們完成引入JS module loader和相關設施之後,很可能還是需要重新調整文件和目錄命名,以避免可能的理解錯位。

回到關於“模組”的定義討論上,我建議運用此術語時儘量避免擴張性解釋——即避免在脫離特定機制的general的“模組化”的意義上使用“模組”這個詞。

比如,傳統的JS程式碼組織方法之一,是掛在global上的層級名稱空間。此嚴格上不好稱之為“模組”。原因是namespace只提供邏輯劃分,不解決程式碼本身的劃分。如果沒有其他機制,程式碼劃分仍然是檔案為單位,並由開發者自己指定script載入。同理,我們通常認為C++裡沒有模組(儘管有namespace和include),但是PHP我們認為有模組(因為它有autoloader可以根據namespace對映到目錄去載入檔案)。

當然,即便程式語言沒有模組,我們仍然可以通過一些方式進行“模組化”程式設計,但這種模糊的用法有可能造成誤解。在JS這邊因為我們已經有很成熟的CommonJS / AMD / ES6 module了,更應避免模糊用法。

元件(對應英文“component”)

另一個概念是“元件”。大體上“元件”和“模組”的概念是類似的,只是“元件”通常指更high-level的東西。

我個人體會,“模組”指程式碼單元,其意義偏向靜態的程式碼結構。而“元件”指功能單元,其意義偏向執行時的結構,並有更復雜的控制(如元件例項的生命週期管理)。

舉例來說,在元件系統中,你應該可以比較容易的做到在執行時查詢某種元件並替換為另一種元件(熱插拔)。而這通常並不作為模組系統的需求——即使模組系統支援動態載入,通常也不支援登出舊模組;即使支援登出舊模組,通常也不支援替換所有舊模組的引用(意味著需要重新 例項化/初始化 模組依賴樹上所有直接或間接引用此模組的模組)。

注:的確有某Node.js平臺下的遊戲框架設計以class作為模組單元,通過替換prototype來做到模組的熱插拔。不過這其實要求非常多的程式設計方式約定,實際上可被視為使用的是JS的一個裁剪的特性子集,因而不具有普遍性。

元件與模組的關係

網頁本身匯入指令碼、樣式表、圖片、元件等,繼而元件匯入其自己所需的指令碼、樣式表、圖片、其他元件之類。這樣的元件機制比較符合我們對於網頁構成的一貫認知。Web Components相關規範中的HTML Imports大體就是這麼個東西。

注意,(Web Components的)元件機制跟(JS的)模組機制是正交的。

所謂正交,就是兩者並不互相依賴對方的機制——至少目前是這樣的。HTML Imports匯入的作為元件的HTML檔案裡,引入指令碼(目前)仍然用的是script標籤,並不需要ES6 module。

但是我們有兩個問題。

第一,實踐中的元件方案不止Web Components一種。(現在的情況實際是大多數人還沒有用上Web Components。)

其他元件框架絕大多數基於JS,它們的程式碼本身需要被載入,那就有一個模組機制的問題——因為元件框架通常都足夠複雜,不太可能用裸指令碼方式。既然怎麼樣都需要某種模組載入器,那麼元件框架很可能直接利用模組載入器來載入asset。這樣模組機制就變成了元件機制的基礎了。

另一方面,元件框架如何定義元件呢?無論過去還是現在,看下來大多陣列件框架就以一個class來定義一個元件。最常見的程式碼組織慣例是,一個class對應一個模組,於是元件就變成了符合某種模式(如繼承自某基類)的JS模組。

我們比較一下。原生的Web Components方案,開發者需要在document里加link rel="import",然後引用的元件HTML檔案裡寫script/style/link標籤,script裡宣告自定義標籤和相關元件行為。比起直接document里加載JS模組,然後在JS模組裡import / require其他的JS模組 / HTML template / CSS樣式表的方式,好像後者反而更簡單點?對此我們稍後再討論。

前面討論“模組”定義問題時,我們講過要避免擴張性解釋。“元件”可以被稱為“模組”(通常會加限定詞以區別於普通的JS模組,如“UI模組”)的原因只在於元件本身以JS來表達,因而可以對應到一個具體的JS模組。假如元件本身並不以JS來表達——像Web Components的元件的形式是一個特殊的HTML檔案,則稱之為“XX模組”就是對“模組”的擴張性解釋。就算是前一種情況,為了概念清晰和保持一致性,我仍然會建議用“元件”一詞。

第二,回到Web Components規範,儘管元件機制和模組機制可以是正交的,但是實際情況是資源的依賴、載入、執行(應用)等是兩者共性的問題。當前相關的各項標準在這點上其實還未協調,故而標準社群有討論是否需要統一以及如何統一的問題,而Firefox也因此暫未實現HTML Imports。怎麼樣才對,我現在也還沒想清楚,社群也還沒有一致的意見。

通過JS Module Loader載入CSS等資源

HTML Imports使用和傳統網頁較為一致的模型。與此相對的,從歷史到現在一直有以JS為中心的方案。

之前我們討論過JS的模組。語法上以import "a"require("a")來引入其他模組。但是到底這裡的"a"表示什麼,如何載入,如何執行,是由具體的loader(及其hook/plugin)處理的。這裡就提供了從JS module loader載入其他資源的可能。比如RequireJS、Sea.js、SystemJS均(通過外掛)支援載入CSS。

我們是否可以把被載入的CSS資源叫做“CSS模組”?我覺得是有問題的。現有loader的這些外掛的實現實際上只是簡單的建立link[rel=stylesheet]元素插入到document中。這種行為和通常引入JS模組非常不同。引入另一個JS模組是為了呼叫它所提供的介面,但引入一個CSS卻並不“呼叫”CSS。所以引入CSS本身對於JS程式來說並不存在“模組化”意義,純粹只是表達了一種資源依賴——即本JS模組所要完成的功能還需要某些asset。

loader其實可以載入任何東西。如果看loader的另一些外掛,如允許import "a.png"的圖片資源外掛,它只是起到preload作用。字型外掛亦然。所以沒有人稱其為圖片模組和字型模組,而只是稱之為資源。

CSS介於圖片/字型和JS之間。CSS像JS的地方是在於其複雜性,現代Web應用的CSS的複雜度已經有點接近程式設計了。但是從loader的角度,它更像圖片/字型。

我們進一步仔細分析可以發現,JS模組對其他JS模組的依賴是一種強依賴——在依賴項載入和執行完後才能執行自己,而其對載入的CSS、圖片等的依賴是一種弱依賴——我們只是表達額外需要某種資源,但是載入順序甚至是否載入成功且應用完畢都可能是不重要的。

所以我們或許應該認為存在一個更高階的元件(即使它直接以這個JS模組本身表達),它同時需要這些JS程式碼邏輯和一些CSS資源。另一方面,現有的使用JS module loader來載入CSS、圖片等的實踐也許存在濫用和誤用的狀況。

BTW,hixie有一份草案是通過needs等屬性表示資源的依賴關係和優先順序,其中包含了延遲載入或空閒時才載入等特性(均可視為弱依賴關係)。拋開宣告性和不依賴JS的優勢不說,基於JS module loader的方案能否優雅的支援弱依賴關係,是有很大的疑問的。(當然needs提案也面臨跟HTML Imports和ES6 module一樣的問題,其底層的依賴處理機制需要協調統一。)

注意:loader可以支援import a from "a.png"然後a返回一個HTMLImageElement物件,import b from "b.css"然後b返回一個CSSStyleSheet物件。這樣匯出一些可以被JS操作的物件似乎使其更像JS模組一樣具有強依賴的特徵,這也許是一種合理的用法。不過這時我們可以注意到另一個行為上的差異——image外掛其實並沒有把HTMLImageElement插入到document中,而按照通常CSS外掛的意圖,卻需要把CSSStyleSheet物件插入到document.styleSheets中。這反映了CSS不同尋常之處——它直接是全域性生效的,與“模組化”的要求是正好抵觸的。我們後面還會詳細討論這一點。

此外,loader不會多次載入和執行(應用)相同CSS——這是module loader的要點之一。而CSS自己的@import語義則正好相反,多次引入相同URL的樣式表,都會在匯入位置上應用。使用JS module loader的import的語義和CSS自己的@import語義不一致,這也許是個問題。

CSS的@import也支援media query和supports condition等特性,這是目前的JS module loader外掛不支援的(至少我沒見過支援的)。帶有media query的CSS@import宣告會在執行時根據media query是否匹配而動態應用,也就是除了依賴關係以外,還有其他因素共同決定是否載入,這和前面談到的弱依賴是類似的。

要基於JS module loader實現@import "a.css" (min-width:500px)的效果,可能得這樣寫:

matchMedia('min-width:500px').addEventListener(mediaQueryList => if (mediaQueryList.matches) System.import('a.css').then(() => ...));

或者

import a from 'a.css'
assert(a instanceof CSSStyleSheet)
a.media.appendMedium('min-width:500px')

前者實在難看,且其依賴關係已經不是宣告性的了(從而相當麻煩)。後者則可能在還未加上條件時已經開始下載了(從而不滿足需求)。

總結一下。JS module loader雖然可以被利用來載入各種資源,但本質上就是一個dependencies tree和註冊在其上的一些純粹由依賴來驅動的callback / promise。對於JS模組來說,這樣的設計恰如其分,但是對於其他種類的資源來說——它們可能具有比單純依賴(即強依賴)更復雜的如優先順序、動態條件、可fallback等需求,直接把JS module loader用作元件系統的基礎可能並非合適方案。其實就算是載入JS,對於polyfill / shim,loader系統都可能是要開外掛而不在標準機制內。

回顧之前討論過的“模組”概念,我們可以增加一個認識:“模組”術語暗示了強依賴——因為程式語言的模組都是強依賴的——即使許多人沒有明確意識到這一點。

CSS局域化問題

我們對於CSS當然也有分而治之的需求。但是簡單用“模組化”來表述可能是有問題的。

如前所述,傳統上,CSS被插入文件中,其包含的樣式規則是文件全域性有效的,這和模組化本身是相牴牾的。

當然我們可以通過某種開發規則來達到效果的區域性化。比如以特定id/class限定所有CSS rule的應用範圍。

另一種似乎更常見的方式是:所有rule本身就只包含class選擇器。從某種角度上說,可被視為這個樣式表定義(匯出)了一些可複用的樣式,並以class來命名。是否能稱這樣一個樣式表為一個“CSS模組”?

當我們講“A模組依賴B模組”的時候,其實暗含A要使用B所匯出的介面的意思。假如我們認為“CSS模組”暴露的是class鉤子,可是一個CSS模組依賴其他CSS模組並不存在需要呼叫它的class鉤子的情況;覆寫和擴充套件class鉤子或可類比為某種介面使用,但實際執行時並沒有任何約束,我們也很難進行靜態檢查(比如我們無從判斷A的程式碼中有一個B所不包含的class名字是有意擴充套件還僅僅是拼寫錯誤)。JS依賴CSS的情況也是類似的。

另一方面,這匯出的class及其樣式宣告,也未被限定於只能被宣告依賴者使用,其效果仍然是全域性性的。

所以不建議管這樣的東西叫“CSS模組”,這在溝通中很容易造成誤解。(雖然公司內部溝通的話可能問題不大。)

題外話:這種方式實際上濫用了class屬性。因為CSS沒有複用機制,所以只好拿class屬性來充數,通過class來作為應用樣式的鉤子。這違背了HTML規範和CSS規範的要求。除了對規範的實質性違背之外,這種方式在工程上的一個後果是,將內容和樣式的耦合點從樣式表的selector轉移到了HTML文件的元素屬性上。這對於頁面開發流程、分工協作方式和長期可維護性會有巨大的影響。此外和通常認知的不同,這樣的開發方式其實對頁面效能有負面作用。具體就不展開了,可另行討論。

元件框架在CSS這塊的需求我認為“局域化樣式”(scoped style)是比“CSS模組”更準確的稱呼。目前的具體實現方案除了class樣式鉤子外,更靠譜的方式是:

  1. shadow dom天生樣式就是局域化的
  2. style元素的scoped屬性
  3. 以特定id/class限定單個樣式表中CSS rule的應用範圍,並配合css3增加的all屬性和unset值來確保不被其他樣式表汙染。

前兩者目前都有瀏覽器支援的問題。但第三種方式配合CSS前處理器是完全可行的。

特別是如果講CSS前處理器,因為它們是真的可以以mixin、函式等來進行抽象,因此講“SASS模組”、“Stylus模組”、基於前處理器的“樣式庫/框架”,倒是可以接受的。