前端組件化框架之路

1. 爲什麼組件化這麼難做

Web應用的組件化是一個很複雜的話題。

在大型軟件中,組件化是一種共識,它一方面提高了開發效率,另一方面降低了維護成本。但是在Web前端這個領域,並沒有很通用的組件模式,因爲缺少一個大家都能認同的實現方式,所以很多框架/庫都實現了自己的組件化方式。

前端圈最熱衷於造輪子了,沒有哪個別的領域能出現這麼混亂而欣欣向榮的景象。這一方面說明前端領域的創造力很旺盛,另一方面卻說明了基礎設施是不完善的。

我曾經有過這麼一個類比,說明某種編程技術及其生態發展的幾個階段:

  • 最初的時候人們忙着補全各種API,代表着他們擁有的東西還很匱乏,需要在語言跟基礎設施上繼續完善
  • 然後就開始各種模式,標誌他們做的東西逐漸變大變複雜,需要更好的組織了
  • 然後就是各類分層MVC,MVP,MVVM之類,可視化開發,自動化測試,團隊協同系統等等,說明重視生產效率了,也就是所謂工程化

那麼,對比這三個階段,看看關注這三種東西的人數,覺得Web發展到哪一步了?

細節來說,大概是模塊化和組件化標準即將大規模落地(好壞先不論),各類API也大致齊備了,終於看到起飛的希望了,各種框架幾年內會有非常強力的洗牌,如果不考慮老舊瀏覽器的拖累,這個洗牌過程將大大加速,然後才能釋放Web前端的產能。

但是我們必須注意到,現在這些即將普及的標準,很多都會給之前的工作帶來改變。用工業體系的發展史來對比,前端領域目前正處於蒸汽機發明之前,早期機械(比如《木蘭辭》裏面的機杼,主要是動力與材料比較原始)已經普及的這麼一個階段。

所以,從這個角度看,很多框架/庫是會消亡的(專門做模塊化的AMD和CMD相關庫,專注於標準化DOM選擇器鋪墊的某些庫),一些則必須進行革新,還有一些受的影響會比較小(數據可視化等相關方向),可以有機會沿着自己的方向繼續演進。

2. 標準的變革

對於這類東西來說,能獲得廣泛羣衆基礎的關鍵在於:對將來的標準有怎樣的迎合程度。對前端編程方式可能造成重大影響的標準有這些:

  • module
  • Web Components
  • class
  • observe
  • promise

module的問題很好理解,JavaScript第一次有了語言上的模塊機制,而Web Components則是約定了基於泛HTML體系構建組件庫的方式,class增強了編程體驗,observe提供了數據和展現分離的一種優秀方式,promise則是目前前端最流行的異步編程方式。

這裏面只有兩個東西是繞不過去的,一是module,一是Web Components。前者是模塊化基礎,後者是組件化的基礎。

module的標準化,主要影響的是一些AMD/CMD的加載和相關管理系統,從這個角度來看,正如seajs團隊的@afc163 所說,不管是AMD還是CMD,都過時了。

模塊化相對來說,遷移還比較容易,基本只是純邏輯的包裝,跟AMD或者CMD相比,包裝形式有所變化,但組件化就是個比較棘手的問題了。

Web Components提供了一種組件化的推薦方式,具體來說,就是:

  • 通過shadow DOM封裝組件的內部結構
  • 通過Custom Element對外提供組件的標籤
  • 通過Template Element定義組件的HTML模板
  • 通過HTML imports控制組件的依賴加載

這幾種東西,會對現有的各種前端框架/庫產生很巨大的影響:

  • 由於shadow DOM的出現,組件的內部實現隱藏性更好了,每個組件更加獨立,但是這使得CSS變得很破碎,LESS和SASS這樣的樣式框架面臨重大挑戰。
  • 因爲組件的隔離,每個組件內部的DOM複雜度降低了,所以選擇器大多數情況下可以限制在組件內部了,常規選擇器的複雜度降低,這會導致人們對jQuery的依賴下降。
  • 又因爲組件的隔離性加強,致力於建立前端組件化開發方式的各種框架/庫(除Polymer外),在自己的組件實現方式與標準Web Components的結合,組件之間數據模型的同步等問題上,都遇到了不同尋常的挑戰。
  • HTML imports和新的組件封裝方式的使用,會導致之前常用的以JavaScript爲主體的各類組件定義方式處境尷尬,它們的依賴、加載,都面臨了新的挑戰,而由於全局作用域的弱化,請求的合併變得困難得多。

3. 當下最時髦的前端組件化框架/庫

在2015年初這個時間點看,前端領域有三個框架/庫引領時尚,那就是Angular,Polymer,React(排名按照首字母),在知乎的這篇2014 年末有哪些比較火的 Web 開發技術?裏,我大致回答過一些點,其他幾位朋友的答案也很值得看。關於這三者的細節分析,侯振宇的這篇講得很好:2015前端框架何去何從

我們可以看到,Polymer這個東西在這方面是有先天優勢的,因爲它的核心理念就是基於Web Components的,也就是說,它基本沒有考慮如何解決當前的問題,直接以未來爲發展方向了。

React的編程模式其實不必特別考慮Web標準,它的遷移成本並不算高,甚至由於其實現機制,屏蔽了UI層實現方式,所以大家能看到在native上的使用,canvas上的使用,這都是與基於DOM的編程方式大爲不同的,所以對它來說,處理Web Components的兼容問題要在封裝標籤的時候解決,反正之前也是要封裝。

Angular 1.x的版本,可以說是跟同時代的多數框架/庫一樣,對未來標準的兼容基本沒有考慮,但是重新規劃之後的2.0版本對此有了很多權衡,變成了激進變更,突然就變成一個未來的東西了。

這三個東西各有千秋,在可以預見的幾年內將會鼎足三分,也許還會有新的框架出現,能不能比這幾個流行就難說了。

此外,原Angular 2.0的成員Rob Eisenberg創建了自己的新一代框架aurelia,該框架將成爲Angular 2.0強有力的競爭者。

4. 前端組件的複用性

看過了已有的一些東西之後,我們可以大致來討論一下前端組件化的一些理念。假設我們有了某種底層的組件機制,先不管它是瀏覽器原生的,或者是某種框架/庫實現的約定,現在打算用它來做一個大型的Web應用,應該怎麼做呢?

所謂組件化,核心意義莫過於提取真正有複用價值的東西。那怎樣的東西有複用價值呢?

  • 控件
  • 基礎邏輯功能
  • 公共樣式
  • 穩定的業務邏輯

對於控件的可複用性,基本上是沒有爭議的,因爲這是實實在在的通用功能,並且比較獨立。

基礎邏輯功能主要指的是一些與界面無關的東西,比如underscore這樣的輔助庫,或者一些校驗等等純邏輯功能。

公共樣式的複用性也是比較容易認可的,因此也會有bootstrap,foundation,semantic這些東西的流行,不過它們也不是純粹的樣式庫了,也帶有一些小的邏輯封裝。

最後一塊,也就是業務邏輯。這一塊的複用是存在很多爭議的,一方面是,很多人不認同業務邏輯也需要組件化,另一方面,這塊東西究竟怎樣去組件化,也很需要思考。

除了上面列出的這些之外,還有大量的業務界面,這塊東西很顯然複用價值很低,基本不存在複用性,但仍然有很多方案中把它們“組件化”了,使得它們成爲了“不具有複用性的組件”。爲什麼會出現這種情況呢?

組件化的本質目的並不一定是要爲了可複用,而是提升可維護性。這一點正如面嚮對象語言,Java要比C++純粹,因爲它不允許例外情況的出現,連main函數都必須寫到某個類裏,所以Java是純面嚮對象語言,而C++不是。

在我們這種情況下,也可以把組件化分爲:全組件化,局部組件化。怎麼理解這兩個東西的區別呢,有人問過js框架和庫的區別是什麼,一般來說,有某種較強約定的東西,稱爲框架,而約定比較鬆散的,稱爲庫。框架很多都是有全組件化理念的,比如說,很多年前就出現的ExtJS,它是全組件化框架,而jQuery和它的插件體系,則是局部組件化。所以用ExtJS寫東西,不管寫什麼都是差不多一樣的寫法,而用jQuery的時候,大部分地方是原始HTML,哪裏需要有些不一樣的東西,就只在那個地方調用插件做一下特殊化。

對於一個有一定規模的Web應用來說,把所有東西都“組件化”,在管理上會有較大的便利性。我舉個例子,同樣是編寫代碼,短代碼明顯比長代碼的可讀性更高,所以很多語言裏會建議“一個方法一般不要超過多少行,一個類最好不要超過多少行”之類。在Web前端這個體系裏,JavaScript這塊是做得相對較好的,現在入門水平的人,也已經很少會有把一堆js都寫在一起的了。CSS這塊,最近在SASS,LESS等框架的引領下,也逐步往模塊化方面發展,否則直接編寫bootstrap那種css,會非常痛苦。

這個時候我們再看HTML的部分,如果不考慮模板等技術的使用,某些界面光佈局代碼寫起來就非常多了,像一些表單,都需要一層套一層,很多簡單的表單元素都需要套個三層左右,更不必說一些有複雜佈局的東西了。尤其是整個系統單頁化之後,界面的header,footer,各種nav或者aside,很可能都有一定複雜性。如果這些東西的代碼不作切分,那麼主界面的HTML一定比較難看。

我們先不管用什麼方式切分了,比如用某種模板,用類似Angular中的include,或者Polymer,React中的標籤,或者直接使用原生Web Components,總之是把一塊一塊都拆開了,然後包含進來。從這個角度看,這些拆出去的東西都像組件,但如果從複用性的角度看,很可能多數東西,每一塊都只有一個地方用,壓根沒有複用度。這個拆出去,純粹是爲了使得整個工程易於管理,易於維護。

這時候我們再來關注不同框架/庫對UI層組件化的處理方式,發現有兩個類型,模板和函數。

模板是一種很常見的東西,它用HTML字符串的方式表達界面的原始結構,然後通過代入數據的方式生成真正的界面,有的是生成目標HTML,有的還生成各種事件的自動綁定。前者是靜態模板,後者是動態模板。

另外有一些框架/庫偏愛用函數邏輯來生成界面,早期的ExtJS,現在的React(它內部還是可能使用模板,而且對外提供的是組件創建接口的進一步封裝——jsx)等,這種實現技術的優勢是不同平臺上編程體驗一致,甚至可以給每種平臺封裝相同的組件,調用方輕鬆寫一份代碼,在Web和不同Native平臺上可用。但這種方式也有比較麻煩的地方,那就是界面調整比較繁瑣。

本文前面部分引用侯振宇的那篇文章裏,他提出這些問題:

如何能把組件變得更易重用? 具體一點:
- 我在用某個組件時需要重新調整一下組件裏面元素的順序怎麼辦?
- 我想要去掉組件裏面某一個元素怎麼辦?
如何把組件變得更易擴展? 具體一點:
- 業務方不斷要求給組件加功能怎麼辦?

爲此,還提出了“模板複寫”方案,在這一點上我有不同意見。

我們來看看如何把一個業務界面切割成組件。

有這麼一個簡單場景:一個僱員列表界面包括兩個部分,僱員表格和用於填寫僱員信息的表單。在這個場景下,存在哪些組件?

對於這個問題,主要存在兩種傾向,一種是僅僅把“控件”和比較有通用性的東西封裝成組件,另外一種是整個應用都組件化。

對前一種方式來說,這裏面只存在數據表格這麼一個組件。
對後一種方式來說,這裏面有可能存在:數據表格,僱員表單,甚至還包括僱員列表界面這麼一個更大的組件。

這兩種方式,就是我們之前所說的“局部組件化”,“全組件化”。

我們前面提到,全組件化在管理上是存在優勢的,它可以把不同層面的東西都搞成類似結構,比如剛纔的這個業務場景,很可能最後寫起來是這個樣子:

<Employee-Panel>
<Employee-List></Employee-List>
<Employee-Form></Employee-Form>
</Employee-Panel>

對於UI層,最好的組件化方式是標籤化,比如上面代碼中就是三個標籤表達了整個界面。但我個人堅決反對濫用標籤,並不是把各種東西都儘量封裝就一定好。

全標籤化的問題主要有這些:

第一,語義化代價太大。只要用了標籤,就一定需要給它合適的語義,也就是命名。但實際用的時候,很可能只是爲了把一堆html簡化一下而已,到底簡化出來的那東西應當叫什麼名字,光是起名也費不知多少腦細胞。比如你說僱員管理的表單,這個表單有heading嗎,有footer嗎,能摺疊嗎,等等,很難起一個讓別人一看就知道的名字,要麼就是特別長。這還算簡單的,因爲我們是全組件化,所以很可能會有組合了多種東西的一個較複雜的界面,你想來想去也沒法給它起個名字,於是寫了個:

<Panel-With-Department-Panel-On-The-Left-And-Employee-Panel-On-The-Right>
</Panel-With-Department-Panel-On-The-Left-And-Employee-Panel-On-The-Right>

這尼瑪……可能我誇張了點,但很多時候項目規模夠大,你不起這麼複雜的名字,最後很可能沒法跟功能類似的一個組件區分開,因爲這些該死的組件都存在於同一個命名空間中。如果僅僅是當作一個界面片段來include,就不存在這種心理負擔了。

比如Angular裏面的這種:

<div ng-include="'aaa/bbb/ccc.html'"></div>

就不給它什麼名字,直接include進來,用文件路徑來區分。這個片段的作用可以用其目錄結構描述,也就是通過物理名而非邏輯名來標識,目錄層次充當了一個很好的命名空間。

現在的一些主流MVVM框架,比如knockout,angular,avalon,vue等等,都有一種“界面模板”,但這種模板並不僅僅是模板,而是可以視爲一種配置文件。某一塊界面模板描述了自身與數據模型的關係,當它被解析之後,按照其中的各種設置,與數據建立關聯,並且反過來再更新自身所對應的視圖。

不含業務邏輯的UI(或者是業務邏輯已分離的UI)基本不適合作爲組件來看待,因爲即使在邏輯不變的情況下,界面改版的可能性也太多了。比如即使是換了新的CSS實現方式,從float佈局改成flex佈局,都有可能把DOM結構少套幾層div,因此,在使用模板的方案中,只能把界面層視爲配置文件,不能看成組件,如果這麼做,就會輕鬆很多。

部隊行軍的時候講究“逢山開路,遇水搭橋”,這句話的重點在於只有到某些地形纔開路搭橋,使用MVVM這類模式解決的業務場景,多數時候是一馬平川,橫着走都可以,不必硬要造路。所以從整個方案看的話,UI層實現應該是模板與控件並存,大部分地方是模板,少數地方是需要單獨花時間搞的路和橋。

第二,配置過於複雜。有很多東西其實不太適合封裝,不但封裝的代價大,使用的代價也會很大。有時候會發現,調用代碼的絕大部分都是在寫各種配置。

就像剛纔的僱員表單,既然你不從標籤的命名上去區分,那一定會在組件上加配置。比如你原來想這樣:

<EmployeeForm heading="僱員表單"></EmployeeForm>

然後在組件內部,判斷有沒有設置heading,如果沒有就不顯示,如果有,就顯示。過了兩天,產品問能不能把heading裏面的某幾個字加粗或者換色,然後碼農開始允許這個heading屬性傳入html。沒多久之後,你會驚奇地發現有人用你的組件,沒跟你說,就在heading裏面傳入了摺疊按鈕的html,並且用選擇器給摺疊按鈕加了事件,點一下之後還能摺疊這個表單了……

然後你一想,這個不行,我得給他再加個配置,讓他能很簡單地控制摺疊按鈕的顯示,但是現在這麼寫太不直觀,於是採用對象結構的配置:

<EmployeeForm>
<Option collapsible="true">
<Heading>
<h4><strong>僱員</strong>表單</h4>
</Heading>
</Option>
</EmployeeForm>

然後又有一天,發現有很多面板都可以摺疊,然後特意創建了一個可摺疊面板組件,又創建了一種繼承機制,其他普通業務面板從它繼承,從此一發不可收拾。

我舉這例子的意思是爲了說明什麼呢,我想說,在規模較大的項目中,企圖用全標籤化加配置的方式來描述所有的普通業務界面,是一定事倍功半的,並且這個規模越大就越坑,這也正是ExtJS這類對UI層封裝過度的體系存在的最大問題。

這個問題討論完了,我們來看看另外一個問題:如果UI組件有業務邏輯,應該如何處理。

比如說,性別選擇的下拉框,它是一個非常通用化的功能,照理說是很適合被當做組件來提供的。但是究竟如何封裝它,我們就有些犯難了。這個組件裏除了界面,還有數據,這些數據應當內置在組件裏嗎?理論上從組件的封裝性來說,是都應當在裏面的,於是就這麼造了一個組件:

<GenderSelect></GenderSelect>

這個組件非常美好,只需直接放在任意的界面中,就能顯示帶有性別數據的下拉框了。性別的數據很自然地是放在組件的實現內部,一個寫死的數組中。這個太簡單了,我們改一下,改成商品銷售的國家下拉框。

表面上看,這個沒什麼區別,但我們有個要求,本公司商品銷售的國家的信息是統一配置的,也就是說,這個數據來源於服務端。這時候,你是不是想把一個http請求封裝到這組件裏?

這樣做也不是不可以,但存在至少兩個問題:

  • 如果這類組件在同一個界面中出現多次,就可能存在請求的浪費,因爲有一個組件實例就會產生一個請求。
  • 如果國家信息的配置界面與這個組件同時存在,當我們在配置界面中新增一個國家了,下拉框組件中的數據並不會實時刷新。

第一個問題只是資源的浪費,第二個就是數據的不一致了。曾經在很多系統中,大家都是手動刷新當前頁面來解決這問題的,但到了這個時代,人們都是追求體驗的,在一個全組件化的解決方案中,不應再出現此類問題。

如何解決這樣的問題呢?那就是引入一層Store的概念,每個組件不直接去到服務端請求數據,而是到對應的前端數據緩存中去獲取數據,讓這個緩存自己去跟服務端保持同步。

所以,在實際做方案的過程中,不管是基於Angular,React,Polymer,最後肯定都做出一層Store了,不然會有很多問題。

5. 爲什麼MVVM是一種很好的選擇

我們回顧一下剛纔那個下拉框的組件,發現存在幾個問題:

  • 界面不好調整。剛纔的那個例子相對簡單,如果我們是一個省市縣三級聯動的組件,就比較麻煩了。比如說,我們想要把水平佈局改成垂直的,又或者,想要把中間的label的字改改,都會非常麻煩。按照傳統的做組件的方式,就要加若干配置項,然後組件裏面去分別判斷,修改DOM結構。
  • 如果數據的來源不是靜態json,而是某個動態的服務接口,那用起來就很麻煩。
  • 我們更多地需要業務邏輯的複用和純“控件”的複用,至於那些綁定業務的界面組件,複用性其實很弱。

所以,從這些角度,會盡量期望在HTML界面層與JavaScript業務邏輯之間,存在一種分離。

這時候,再看看絕大多數界面組件存在什麼問題:

有時候我們考慮一下DOM操作的類型,會發現其實是很容易枚舉的:

  • 創建並插入節點
  • 移除節點
  • 節點的交換
  • 屬性的設置

多數界面組件封裝的絕大部分內容不過是這些東西的重複。這些東西,其實是可以通過某些配置描述出來的,比如說,某個數組以什麼形式渲染成一個select或者無序列表之類,當數組變動,這些東西也跟着變動,這些都應當被自動處理,如果某個方案在現在這個時代還手動操作這些,那真的是一種落伍。

所以我們可以看到,以Angular,Knockout,Vue,Avalon爲代表的框架們在這方面做了很多事,儘管理念有所差異,但大方向都非常一致,也就是把大多數命令式的DOM操作過程簡化爲一些配置。

有了這種方式之後,我們可以追求不同層級的複用:

  • 業務模型因爲是純邏輯,所以非常容易複用
  • 視圖模型基本上也是純邏輯,界面層多數是純字符串模板,同一個視圖模型搭配不同的界面模板,可以實現視圖模型的複用
  • 同一個界面模板與不同的視圖模型組合,也能直接組合出完全不同的東西

所以這麼一來,我們的複用粒度就非常靈活了。正因爲這樣,我一直認爲Angular這樣的框架戰略方向是很正確的,雖然有很多戰術失誤。我們在很多場景下,都是需要這樣的高效生產手段的。

6. 組件的長期積累

我們做組件化這件事,一定是一種長期打算,爲了使得當前的很多東西可以作爲一種積累,在將來還能繼續使用,或者僅僅作較小的修改就能使用,所以必須考慮對未來標準的兼容。主要需要考慮的方面有這幾點:

  • 儘可能中立於語言和框架,使用瀏覽器的原生特性
  • 邏輯層的模塊化(ECMAScript module)
  • 界面層的元素化(Web Components)

之前有很多人對Angular 2.0的激進變更很不認同,但它的變更很大程度上是對標準的全面迎合。這不僅僅是它的問題,其實是所有前端框架的問題。不面對這些問題,不管現在多麼好,將來都是死路一條。這個問題的根源是,這幾個已有的規範約束了模塊化和元素化的推薦方式,並且,如果要對當前和未來兩邊做適配的話,基本就沒法幹了,導致以前的都不得不做一定的遷移。

模塊化的遷移成本還比較小,無論是之前AMD還是CMD的,都可以根據一些規則轉換過來,但組件化的遷移成本太大了,幾乎每種框架都會提出自己的理念,然後有不同的組件化理念。

還是從三個典型的東西來說:Polymer,React,Angular。

Polymer中的組件化,其實就是標籤化。這裏的標籤,並不只是界面元素,甚至邏輯組件也可以這樣,比如這個代碼:

<my-panel>
<core-ajax id="ajax" url="http://url" params="{{formdata}}" method="post"></core-ajax>
</my-panel>

注意到這裏的core-ajax標籤,很明顯這已經是純邏輯的了,在大多數前端框架或者庫中,調用ajax肯定不是這樣的,但在瀏覽器端這麼幹也不是它獨創,比如flash裏面的WebService,比如早期IE中基於htc實現的webservice.htc等等,都是這麼幹的。在Polymer中,這類東西稱爲非可見元素(non-visual-element)。

React的組件化,跟Polymer略有不同,它的界面部分是標籤化,但如果有單純的邏輯,還是純JavaScript模塊。

既然大家的實現方式都那麼不一致,那我們怎麼搞出盡量可複用的組件呢?問題到最後還是要繞到Web Components上。

在Web Components與前端組件化框架的關係上,我覺得是這麼個樣子:

各種前端組件化框架應當儘可能以Web Components爲基石,它致力於組織這些Components與數據模型之間的關係,而不去關注某個具體Component的內部實現,比如說,一個列表組件,它究竟內部使用什麼實現,組件化框架其實是不必關心的,它只應當關注這個組件的數據存取接口。

然後,這些組件化框架再去根據自己的理念,進一步對這些標準Web Components進行封裝。換句話說,業務開發人員使用某個組件的時候,他是應當感知不到這個組件內部究竟使用了Web Components,還是直接使用傳統方式。(這一點有些理想化,可能並不是那麼容易做到,因爲我們還要管理像import之類的事情)。

7. 我們需要關注什麼

目前來看,前端框架/庫仍然處於混戰期,可比中國歷史上的春秋戰國,百家齊放,作爲跟隨者來說,這是很痛苦的,因爲無所適從,很可能你作爲一個企業的前端架構師或者技術經理,需要做一些選型工作,但選哪個能保證幾年後不被淘汰呢?基本沒有。

雖然我們不知道將來什麼框架會流行,但我們可以從一些細節方面去關注,某個具體的方面,將來會有什麼,也可以瞭解一下在某個具體領域存在什麼樣的方案。一個完整的框架方案,無非是以下多個方面的綜合。

7.1 模塊化

這塊還是不講了,支付寶seajs還有百度ecomfe這兩個團隊的人應該都能比我講得好得多。

7.2 Web Components

本文前面討論過一些,也不深入了。

7.3 變更檢測

我們知道,現代框架的一個特點是自動化,也就是把原有的一些手動操作提取。在前端編程中,最常見的代碼是在幹什麼呢?讀寫數據和操作DOM。不少現代的框架/庫都對這方面作了處理,比如說通過某種配置的方式,由框架自動添加一些關聯,當數據變更的時候,把DOM進行相應修改,又比如,當DOM發生變動的時候,也更新對應的數據。

這個關聯過程可能會用到幾種技術。首先我們看怎麼知道數據在變化,這裏面有三種途徑:

一、存取器的封裝。這個的意思也就是對數據進行一層包裝,比如:

var data = {
name: "aaa",
getName: function() {
return this.name;
},
setName: function(value) {
this.name = value;
}
}

這樣,不允許用戶直接調用data.name,而是調用對應的兩個函數。Backbone就是通過這樣的機制實現數據變動觀測的,這種方式適用於幾乎所有瀏覽器,缺點就是比較麻煩,要對每個數據進行包裝。

這個機制在稍微新一點的瀏覽器中,也有另外一種實現方式,那就是defineProperty相關的一些方法,使用更優雅的存取器,這樣外界可以不用調用函數,而是直接用data.name這樣進行屬性的讀寫。

國產框架avalon使用了這個機制,低版本IE中沒有defineProperty,但在低版本IE中不止有JavaScript,還存在VBScript,那裏面有存取器,所以他巧妙地使用了VBS做了這麼一個兼容封裝。

基於存取器的機制還有個麻煩,就是每次動態添加屬性,都必須再添加對應的存取器,否則這個屬性的變更就無法獲取。

二、髒檢測。

以Angular 1.x爲代表的框架使用了髒檢測來獲知數據變更,這個機制的大致原理是:

保存數據的新舊值,每當有一些DOM或者網絡、定時器之類的事件產生,用這個事件之後的數據去跟之前保存的數據進行比對,如果相同,就不觸發界面刷新,否則就刷新。

這個方式的理念是,控制所有可能導致數據變更的來源(也就是各種事件),在他們可能對數據進行操作之後,判斷新舊數據是否有變化,忽略所有中間變更,也就是說,如果你在同一個事件中,把某個數據任意修改了很多次,但最後改回來了,框架會認爲你什麼都沒幹,也就不會通知界面去刷新了。

不可否認的是,髒檢測的效率是比較低的,主要是不能精確獲知數據變更的影響,所以當數據量更大的情況下,浪費更嚴重,需要手動作一些優化。比如說一個很大的數組,生成了一個界面上的列表,當某個項選中的時候,改變顏色。在這種機制下,每次改變這個項的數據狀態,就需要把所有的項都跟原來比較一遍,然後,還要再全部比較一次發現沒有關聯引起的變化了,才能對應刷新界面。

三、觀察機制。

在ES7裏面,引入了Object的observe方法,可以用於監控對象或數組的變動。

這是目前爲止最合理的觀測方案。這個機制很精確高效,比如說,連長跟士兵說,你去觀察對面那個碉堡裏面的動靜。這個含義很複雜,包括什麼呢?

  • 是不是加人了
  • 是不是有人離開了
  • 誰跟誰換崗了
  • 上面的旗子從太陽旗換成青天白日了

所謂觀察機制,也就是觀測對象屬性的變更,數組元素的新增,移除,位置變更等等。我們先思考一下界面和數據的綁定,這本來就應當是一個外部的觀察,你是數據,我是界面,你點頭我微笑,你伸手我打人。這種綁定本來就應當是個鬆散關係,不應當因爲要綁定,需要破壞原有的一些東西,所以很明顯更合理。

除了數據的變動可以被觀察,DOM也是可以的。但是目前絕大多數雙向同步框架都是通過事件的方式把DOM變更同步到數據上。比如說,某個文本框綁定了一個對象的屬性,那很可能,框架內部是監控了這個文本框的鍵盤輸入、粘貼等相關事件,然後取值去往對象裏寫。

這麼做可以解決大部分問題,但是如果你直接myInput.value="111",這個變更就沒法獲取了。這個不算大問題,因爲在一個雙向綁定框架中,一個既被監控,又手工賦值的東西,本身也比較怪,不過也有一些框架會嘗試從HTMLInputELement的原型上去覆蓋value賦值,嘗試把這種東西也納入框架管轄範圍。

另外一個問題,那就是我們只考慮了特定元素的特定屬性,可以通過事件獲取變更,如何獲得更廣泛意義上的DOM變更?比如說,一般屬性的變更,或者甚至子節點的增刪?

DOM4引入了MutationObserver,用於實現這種變更的觀測。在DOM和數據之間,是否需要這麼複雜的觀測與同步機制,目前尚無定論,但在整個前端開發逐步自動化的大趨勢下,這也是一種值得嘗試的東西。

複雜的關聯監控容易導致預期之外的結果:

  • 慕容復要復國,每天讀書練武,各種謀劃
  • 王語嫣觀察到了這種現象,認爲表哥不愛自己了
  • 段譽看到神仙姐姐悶悶不樂,每天也茶飯不思
  • 鎮南王妃心疼愛子,到處調查這件事的原委,意外發現段正淳還跟舊愛有聯繫
  • ……

總之這麼下來,最後影響到哪裏了都不知道,誰讓丘處機路過牛家村呢?

所以,變更的關聯監控是很複雜的一個體系,尤其是其中產生了閉環的時候。搭建整個這麼一套東西,需要極其精密的設計,否則熟悉整套機制的人只要用特定場景輕輕一推就倒了。靈智上人雖然武功過人,接連碰到歐陽鋒,周伯通,黃藥師,全部都是上來就直接被抓了後頸要害,大致就是這意思。

polymer實現了一個observe-js,用於觀測數組、對象和路徑的變更,有興趣的可以關注。

在有些框架,比如aurelia中,是混合使用了存取器和觀察模式,把存取器作爲觀察模式的降級方案,在瀏覽器不支持observe的情況下使用。值得一提的是,在髒檢測方式中,變更是合併後批量提交的,這一點常常被另外兩種方案的使用者忽視。其實,即使用另外兩種方式,也還是需要一個合併與批量提交過程。

怎麼理解這個事情呢?數據的綁定,最終都是要體現到界面上的,對於界面來說,其實只關注你每一次操作所帶來的數據變更的始終,並不需要關心中間過程。比如說,你寫了這麼一個循環,放在某個按鈕的點擊中:

for (var i=0; i<10000; i++) {
obj.a += 1;
}

界面有一個東西綁定到這個a,對框架來說,絕對不應當把中間過程直接應用到界面上,以剛纔這個例子來說,合理的情況只應當存在一次對界面DOM的賦值,這個值就是對obj.a進行了10000次賦值之後的值。儘管用存取器或者觀察模式,發現了對obj上a屬性的這10000次賦值過程,這些賦值還是都必須被捨棄,否則就是很可怕的浪費。

React使用虛擬DOM來減少中間的DOM操作浪費,本質跟這個是一樣的,界面只應當響應邏輯變更的結束狀態,不應當響應中間狀態。這樣,如果有一個ul,其中的li綁定到一個1000元素的數組,當首次把這個數組綁定到這個ul上的時候,框架內部也是可以優化成一次DOM寫入的,類似之前常用的那種DocumentFragment,或者是innerHTML一次寫入整個字符串。在這個方面,所有優化良好的框架,內部實現機制都應當類似,在這種方案下,是否使用虛擬DOM,對性能的影響都是很小的。

7.4 Immutable Data

Immutable Data是函數式編程中的一個概念,在前端組件化框架中能起到一些很獨特的作用。

它的大致理念是,任何一種賦值,都應當被轉化成複製,不存在指向同一個地方的引用。比如說:

var a = 1;
var b = a;
b = 2;

console.log(a==b);

這個我們都知道,b跟a的內存地址是不一致的,簡單類型的賦值會進行復制,所以a跟b不相等。但是:

var a = {
counter : 1
};
var b = a;

b.counter++;
console.log(a.counter==b.counter);

這時候因爲a和b指向相同的內存地址,所以只要修改了b的counter,a裏面的counter也會跟着變。

Immutable Data的理念是,我能不能在這種賦值情況下,直接把原來的a完全複製一份給b,然後以後大家各自變各自的,互相不影響。光憑這麼一句話,看不出它的用處,看例子:

對於全組件化的體系,不可避免會出現很多嵌套的組件。嵌套組件是一個很棘手的問題,在很多時候,是不太好處理的。嵌套組件所存在的問題主要在於生命週期的管理和數據的共享,很多已有方案的上下級組件之間都是存在數據共享的,但如果內外層存在共享數據,那麼就會破壞組件的獨立性,比如下面的一個列表控件:

<my-list list-data="{arr}">
<my-listitem></my-listitem>
<my-listitem></my-listitem>
<my-listitem></my-listitem>
</my-list>

我們在賦值的時候,一般是在外層整體賦值一個類似數組的數據,而不是自己挨個在每個列表項上賦值,不然就很麻煩。但是如果內外層持有相同的引用,對組件的封裝性很不利。

比如在剛纔這個例子裏,假設數據源如下:

var arr = [
{name: "Item1"}, 
{name: "Item2"}, 
{name: "Item3"}
];

通過類似這樣的方式賦值給界面組件,並且由它在內部給每個子組件分別進行數據項的賦值:

list.data = arr;

賦值之後會有怎樣的結果呢?

console.log(list.data == arr);
console.log(listitem0.data == arr[0]);
console.log(listitem1.data == arr[1]);
console.log(listitem2.data == arr[2]);

這種方案裏面,後面那幾個log輸出的結果都會是true,意思就是內層組件與外層共享數據,一旦內層組件對數據進行改變,外層中的也就改變了,這明顯是違背組件的封裝性的。

所以,有一些方案會引入Immutable Data的概念。在這些方案裏,內外層組件的數據是不共享的,它們的引用不同,每個組件實際上是持有了自己的數據,然後引入了自動的賦值機制。

這時候再看看剛纔那個例子,就會發現兩層的職責很清晰:

  • 外層持有一個類似數組的東西arr,用於形成整個列表,但並不關注每條記錄的細節
  • 內層持有某條記錄,用於渲染列表項的界面
  • 在整個列表的形成過程中,list組件根據arr的數據長度,實例化若干個listitem,並且把arr中的各條數據賦值給對應的listitem,而這個賦值,就是immutable data起作用的地方,其實是把這條數據複製了一份給裏面,而不是把外層這條記錄的引用賦值進去。內層組件發現自己的數據改變之後,就去進行對應的渲染
  • 如果arr的條數變更了,外層監控這個數據,並且根據變更類型,添加或者刪除某個列表項
  • 如果從外界改變了arr中某一條記錄的內容,外層組件並不直接處理,而是給對應的內層進行了一次賦值
  • 如果列表項中的某個操作,改變了自身的值,它首先是把自己持有的數據進行改變,然後,再通過immutable data把數據往外同步一份,這樣,外層組件中的數據也就更新了。

所以我們再看這個過程,真是非常清晰明瞭,而且內外層各司其職,互不干涉。這是非常有利於我們打造一個全組件化的大型Web應用的。各級組件之間存在比較鬆散的聯繫,而每個組件的內部則是封閉的,這正是我們所需要的結果。

說到這裏,需要再提一個容易混淆的東西,比如下面這個例子:

<outer-component>
<inner-component></inner-component>
</outer-component>

如果我們爲了給inner-component做一些樣式定位之類的事情,很可能在內外層組件之間再加一些額外的佈局元素,比如變成這樣:

<outer-component>
<div>
<inner-component></inner-component>
</div>
</outer-component>

這裏中間多了一級div,也可能是若干級元素。如果有用過Angular 1.x的,可能會知道,假如這裏面硬造一級作用域,搞個ng-if之類,就可能存在多級作用域的賦值問題。在上面這個例子裏,如果在最外層賦值,數據就會是outer -> div -> inner這樣,那麼,從框架設計的角度,這兩次賦值都應當是immutable的嗎?

不是,第一次賦值是非immutable,第二次才需要是,immutable賦值應當僅存在於組件邊界上,在組件內部不是特別有必要使用。剛纔的例子裏,依附於div的那層變量應當還是跟outer組件在同一層面,都屬於outer組件的人民內部矛盾。

這裏是facebook實現的immutable-js庫

7.5 Promise與異步

前端一般都習慣於用事件的方式處理異步,但很多時候純邏輯的“串行化”場景下,這種方式會讓邏輯很難閱讀。在新的ES規範裏,也有yield爲代表的各種原生異步處理方案,但是這些方案仍然有很大的理解障礙,流行度有限,很大程度上會一直停留在基礎較好的開發人員手中。尤其是在瀏覽器端,它的受衆應該會比node裏面還要狹窄。

前端裏面,處理連續異步消息的最能被廣泛接受的方案是promise,我這裏並不討論它的原理,也不討論它在業務中的使用,而是要提一下它在組件化框架內部所能起到的作用。

現在已經沒有哪個前端組件化框架可以不考慮異步加載問題了,因爲,在前端這個領域,加載就是一個繞不過去的坎,必須有了加載,纔能有執行過程。每個組件化框架都不能阻止自己的使用者規模膨脹,因此也應當在框架層面提出解決方案。

我們可能會動態配置路由,也可能在動態加載的路由中又引入新的組件,如何控制這些東西的生命週期,值得仔細斟酌,如果在框架層面全異步化,對於編程體驗的一致性是有好處的。將各類接口都promise化,能夠在可維護性和可擴展性上提供較多便利。

我們之前可能熟知XMLHTTP這樣的通信接口,這個東西雖然被廣爲使用,但是在優雅性等方面,存在一些問題,所以最近出來了替代方案,那就是fetch。

細節可以參見月影翻譯的這篇【翻譯】這個API很“迷人”——(新的Fetch API)

在不支持的瀏覽器上,也有github實現的一個polyfill,雖然不全,但可以湊合用window.fetch polyfill

大家可以看到,fetch的接口就是基於promise的,這應當是前端開發人員最容易接受的方案了。

7.6 Isomorphic JavaScript

這個東西的意思是前後端同構的JavaScript,也就是說,比如一塊界面,可以選擇在前端渲染,也可以選擇在後端渲染,值得關注,可以解決像seo之類的問題,但現在還不能處理很複雜的狀況,持續關注吧。

8. 小結

很感謝能看到這裏,以上這些是我近一年的一些思考總結。從技術選型的角度看,做大型Web應用的人會很痛苦,因爲這是一個青黃不接的年代,目前已有的所有框架/庫都存在不同程度的缺陷。當你向未來看去,發現它們都是需要被拋棄,或者被改造的,人最痛苦的是在知道很多東西不好,卻又要從中選取一個來用。@嚴清 跟@寸志 @題葉討論過這個問題,認爲現在這個階段的技術選型難做,不如等一陣,我完全贊同他們的觀點。

選型是難,但是從學習的角度,可真的是挺好的時代,能學的東西太多了,我每天路上都在努力看有可能值得看的東西,可還是看不完,只能努力去跟上時代的步伐。


原文地址:http://div.io/topic/908

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章