前言
一位計算機前輩曾說過:
1 |
Controlling complexity is the essence of computer programming |
隨著前端開發複雜度的日益提升,元件化開發應運而生,並隨著 FIS、React 等優秀框架的出現遍地開花。這一過程同樣發生在美團,面臨業務規模的快速發展和工程師團隊的不斷擴張,我們歷經引入元件化解決資源整合問題、逐步增強元件功能促進開發效率、重新打造新一代元件化方案適應全棧開發和共享共建等階段,努力“controlling complexity”。本文將介紹我們元件化開發的實踐過程。
元件化 1.0:資源重組
在美團早期,前端資源是按照頁面或者類似業務頁面集合的形式進行組織的。例如 order.js 對應訂單相關頁面的互動,account.css 對應賬戶相關頁面的樣式。這種方式在過去的較長一段時間內,持續支撐了整個專案的正常推進,功勳卓著。
隨著業務規模的增加和開發團隊的擴張,這套機制逐漸顯示出它的一些不足:
- 資源冗餘頁面的逐漸增加,互動的逐漸複雜化,導致對應的 css 和 js 都有大幅度增長,進而出現為了依賴某個 js 中的一個函式,需要載入整個模組,或者為了使用某個 css 中的部分樣式依賴整個 css,冗餘資源較多
- 對應關係不直觀沒有顯而易見的對應規則,導致的一個問題是修改某個業務模組的 css 或者 js 時,幾乎只能依靠 grep。靠人來維護頁面模組 html、css 和 js 之間的依賴關係,容易犯錯,常常出現內容已經刪除但是 css 或 js 還存在的問題
- 難於單元測試以頁面為最小粒度進行資源整合,不同功能的業務模組相互影響,複雜度太高,自動化測試難以推進
2013 年開始,在調研了 FIS、BEM 等方案之後,結合美團開發框架的實際,我們初步實現了一套輕量級的元件化開發方案。主要的改進是:
- 以頁面功能元件為單位聚合前端資源
- 自動載入符合約定的 css、js 資源
- 將業務資料到渲染資料的轉換過程獨立出來
舉例來說,美團頂部的搜尋框就被實現為一個元件。
程式碼構成:
1 2 3 4 5 6 7 8 |
www/component/smart-box/ ├── smart-box.js # 互動 ├── smart-box.php # 渲染資料生產、元件配置 ├── smart-box.scss # 樣式 ├── smart-box.tpl # 內容 └── test ├── default.js # 自動化測試 └── default.php # 單測頁面 |
呼叫元件變得十足簡單:
1 2 3 |
echo View::useComponent('smart-box', [ 'keyword' => $keyword ]); |
對比之前,可以看到元件化的一些特點:
- 按需載入只載入必要的前端資源
- 對應關係非常清晰元件所需要的前端資源都在同一目錄,職責明確且唯一,對應關係顯著
- 易於測試元件是具備獨立展現和互動的最小單元,可利用 Phantom 等工具自動化測試
此外,由於前端資源集中進行排程,元件化也為高階效能優化提供了空間。例如實現元件級別的 BigRender、通過資料分析進行資源的合併載入等等。
元件化 2.0:趨於成熟
元件化 1.0 上線後,由於簡單易用,很快得到工程師的認可,並開始在各項業務中應用起來。新的需求接踵而來,一直持續到 2014 年底,這個階段我們稱之為元件化 2.0。下面介紹下主要的幾個改進。
Lifecycle
元件在高內聚的同時,往往需要暴露一些介面供外界呼叫,從而能夠適應複雜的頁面需求,例如提交訂單頁面需要在支付密碼元件啟動完成後繫結提交時的檢查。Web Components、React 等都選擇了生命週期事件/方法,我們也是一樣。
元件的生命週期:
一個元件的完整生命週期包括:
- init,初始化元件根節點和配置
- fetch,載入 css 和 js 資源
- render,內容渲染,預設的渲染內容方式是 BigRender
- ready,進行資料繫結等操作
- update,資料更新
- destroy,解除所有事件監聽,刪除所有元件節點
元件提供 pause、resume 方法以方便進行生命週期控制。各個階段使用 Promise 序列進行,非同步的管理更清晰。使用自定義語義事件,在修改預設行為、元件間通訊上充分利用了 YUI 強大的自定義事件體系,有效降低了開發維護成本。
舉個例子,頁面初始化時元件的啟動過程實際也是藉助生命週期實現的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
var afterLoadList = []; Y.all('[data-component]').each(function (node) { var component = new Y.mt.Component(node); // 繫結 init 生命週期事件,在 init 預設行為完成後執行回撥 component.after('init', function (e) { // 如果配置了延遲啟動 if (e.config.afterLoad) { // 暫停元件生命週期 e.component.pause(); // 壓入延遲啟動陣列 afterLoadList.push(e.component); } }); // 開始進入生命週期 component.start(); }); Y.on('load', function () { // 在頁面 load 事件發生時恢復元件生命週期 afterLoadList.forEach(function (component) { component.resume(); }); }); |
回過頭來看,引入生命週期除了帶來擴充套件性外,更重要的是理順了元件的各個階段,有助於更好的理解和運用。
Data Binding
資料繫結是我們期盼已久的功能,將 View 和 ViewModel 之間的互動自動化無疑會節省工程師的大量時間。在元件化減少關注點和降低複雜度後,實現資料繫結變得更加可能。
我們最終實現的資料繫結方案主要參考了 Angular,通過在 html 節點上新增特定的屬性宣告繫結邏輯,js 掃描這些內容並進行相應的渲染和事件繫結。當資料發生變化時,對應的內容全部重新渲染。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
<ul class="addressList"> <li mt-bind-repeat="addr in addrList" mt-bind-html="addr.text" > </li> </ul> <script> Y.use(['mt-bind', 'mt-scope'], function () { Y.mt.bind.init(document.body); var scope = Y.one('.addressList').getScope(); // 將 scope.addrList 設定為一個陣列,DOM 上將自動渲染其內容 scope.$set('addrList', [ { text: "first address" }, { text: "second address" } ]); }); </script> |
使用屬性宣告繫結邏輯的好處是可以同時支援後端渲染,這對於美團團購這樣的偏展現型業務是非常必要的,使用者可以很快看到頁面內容。
Flux
實現資料繫結後,我們不得不面對另外一個問題:如何協同多個元件間的資料。因為某個元件的資料變化,很有可能引起其他元件的變化。例如當修改購買數量,總金額會變化,而總金額超過 500 後,還需要展示大額消費提醒。
為了解決這個問題,我們引入了 Flux,使用全域性訊息匯流排的思路進行跨元件互動。
例如因為互動複雜而一直讓我們非常頭疼的專案購買頁,在應用元件 + Flux 重構後,各模組之間的互動更加清晰:
其他方面的改進還有很多,包括引入模板引擎 LightnCandy 約束模板邏輯、支援元件任意巢狀、支援非同步載入並自動初始化等。
隨著元件化 2.0 的逐步完善,基本已經可以從容應對日常開發,在效率和質量方面都上了一個臺階。
元件化 3.0:重啟征程
時間的車輪滾滾前行,2014 年底,我們遇到一些新的機遇和挑戰:
- 基於 Node 的全棧開發模式開始應用,前後端渲染有了更多的可能性
- YUI 停止維護,需要一套新的資源管理方案
- 新業務不斷增加,需要找到一種元件共享的方式,避免重複造輪子
結合之前的實踐,以及在這一過程中逐漸積累的對業內方案的認知,我們提出了新的元件化方案:
- 基於 React 開發頁面元件,使用 NPM 進行分發,方便共建共享
- 基於 Browserify 二次開發,建設資源打包工具 Reduce,方便瀏覽器載入
- 建設適應元件化開發模式的工程化開發方案 Turbo,方便工程師將元件應用於業務開發中
React
在元件化 2.0 的過程中,我們發現很多功能和 React 重合,例如 Data Binding、Lifecycle、前後端渲染,甚至直接借鑑的 Flux。除此之外,React 的函數語言程式設計思想、增量更新、相容性良好的事件體系也讓我們非常向往。藉著前端全棧開發的契機,我們開始考慮基於 React 進行元件化 3.0 的建設。
NPM + Reduce
NPM + Reduce 構成了我們新的資源管理方案,其中:
- NPM 負責元件的釋出和安裝。可以認為是“分”的過程,粒度越小,重用的可能性越大
- Reduce 負責將頁面資源進行打包。可以認為是“合”的過程,讓瀏覽器更快地載入
一個典型的元件包:
1 2 3 4 5 6 |
smart-box/ ├── package.json # 元件包元資訊 ├── smart-box.jsx # React Component ├── smart-box.scss # 樣式 └── test └── main.js # 測試 |
NPM 預設只支援 js 檔案的管理,我們對 NPM 中的 package.json 進行了擴充套件,增加了 style 欄位,以使打包工具 Reduce 也能夠對 css 和 css 中引用的 image、font 進行識別和處理:
1 2 3 |
{ "style": "./smart-box.scss" } |
只要在頁面中 require 了 smart-box,經過 Reduce 打包後,js、css 甚至圖片、字型,都會出現在瀏覽器中。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
var SmartBox = require('@mtfe/smart-box'); // 頁面 var IndexPage = React.createClass({ render: function () { return ( <Header> <SmartBox keyword={ this.props.keyword } /> </Header> ... ); } }); module.exports = IndexPage; |
整體思路和元件化 1.0 如出一轍,卻又那麼不同。
Turbo
單單解決分發和打包的問題還不夠,業務開發過程如果變得繁瑣、難以 Debug、效能低下的話,恐怕不會受到工程師歡迎。
為了解決這些問題,我們在 Node 框架的基礎上,提供了一系列中介軟體和開發工具,逐步構建對元件友好的前端工程化方案 Turbo。主要有:
- 支援前後端同構渲染,讓使用者更早看到內容
- 簡化 Flux 流程,資料流更加清晰易維護
- 引入 ImmutableJS,保證 Store 以外的資料不可變
- 採用 cursor 機制,保證資料修改/獲取同步
- 支援 Hot Module Replacement,改進開發流自動化
通過這些改進,一線工程師可以方便的使用各種元件,專注在業務本身上。開發框架層面的支援也反過來促進了元件化的發展,大家更樂於使用一系列元件來構建頁面功能。
小結
發現痛點、分析調研、應用改進的解決問題思路在元件化開發實踐中不斷運用。歷經三個大版本的演進,元件化開發模式有效緩解了業務發展帶來的複雜度提升的壓力,並培養工程師具備小而美的工程思想,形成共建共享的良好氛圍。毫無疑問,元件化這種“分而治之”的思想將會長久地影響和促進前端開發模式。我們現在已經準備好,迎接新的機遇和挑戰,用技術的不斷革新提升工程師的幸福感。