Mpregular 是基于 RegularJS(簡稱 Regular) 的小程式開發架構。開發者可以将直接用 RegularJS 開發小程式,或者将現有的 RegularJS 應用通過較少修改移植到小程式上。Mpregular 為 RegularJS 開發者提供了一套跨 h5 和小程式的前端應用解決方案,讓開發者能在不同平台有一緻的開發體驗和開發效。
0 序
以下是使用 mpregular 前後的效果對比
舊版(原生小程式)
新版(mpregular)
1 為何而生
1.1 原生小程式開發
小程式本身提供的特性相對簡單,在開發複雜應用的時候,用原生小程式進行開發就會顯得比較吃力。為了更好支援複雜應用,小程式也推出自定義元件、wxs 等新特性,但這些新特性無形中又會給開發者帶來一定的學習成本。另外,小程式的開發規範和通常的 web 應用的開發規範有着較大差異,如果需要同時在兩端上開發同樣功能的應用,則要求投入雙倍的人力,無疑大大增加了開發和維護的成本。
1.2 考拉前端業務現狀
目前網易考拉的 wap 前台頁面大部分都是采用 RegularJS 開發的,包括 wap 首頁、詳情頁,是以考拉的前端們都擁有豐富的 RegularJS 開發經驗,RegularJS 可謂是我們最熟悉的前端開發架構之一。相比之下,熟悉小程式的開發就比較少了。微信是一個龐大的流量入口,最近小程式又掀起了一波熱潮,伴随而來的就是小程式相關業務的增加。我們不僅需要把現有前台頁面遷移到小程式,還需要開發和維護跨小程式和 wap 兩端的業務。是以,我們迫切需要一個能夠支撐目前業務的解決方案,保證我們的開發效率,降低開發和維護成本。
1.3 業界的解決方案
業界關于小程式也有許多解決方案。
小程式官方很早就推出了一個元件化解決方案 —— Wepy,它有自己的一套文法規範,建構時将 Wepy 代碼編譯轉換為小程式代碼。它強依賴小程式自身的特性,是以受小程式自身特性所限,開發規範與考拉目前的前端技術棧差異較大,并不适用。
京東的凹凸實驗室推出了新的跨端開發架構 Taro,它是一個 React-like 的開發架構,有完善的配套設施,支援大部分 React 特性。但 Taro 是在 mpregular 開發完成後才出來,而且不符合我們目前的技術棧。
美團今年早些時候推出 mpvue,一個基于 Vue 實作的小程式開發架構,Vue 開發者看到這個架構以後歡天喜地,Github 上 star 數迅速攀升。對此,我們也做了一些調研,它不僅支援了大部分 Vue 的特性,而且有完善的文檔教程、配套設施,可以說是一個非常完善的解決方案。但我們目前存在的大量 RegularJS 頁面到小程式的遷移需求,在這一場景面前,mpvue 顯得無能為力。
縱觀業界的解決方案,都很難滿足我們目前的需求。我們受到了 mpvue 的啟發,并借鑒它的基本設計思想(包括名稱...),決定對 RegularJS 進行了改造,開發 mpregular 這一個基于 RegularJS 的小程式開發架構。
2 架構特性
既然是基于 RegularJS 實作的架構,文法規範必然是與 RegularJS 基本一緻。在開發的的時候,基本上隻要遵循 RegularJS 的開發規範進行即可,大大降低了 RegularJS 開發者的學習成本。
2.1 生命周期
小程式有 App 和 Page 兩個重要的概念,但通常業務代碼是寫在 Page 裡的,這裡就以小程式頁面為例。開發者在開發小程式頁面的時候,基本隻需要了解 Regular 執行個體的生命周期。小程式 Page 的
onLoad
、
onReady
已經通過 mpregular 與 Regular 的執行個體生命周期綁定在一起了。頁面 url 的 querystring 也可以通過
this.$mp.options
擷取。
onShow
、
onPageScroll
等小程式特有的生命周期鈎子都同樣綁定到 Regular 執行個體上。
<template> <div> <ComponentA /> </div></template><script>
import ComponentA from './component-a.rgl';
export default {
mpType: 'page',
config() {
// this.$mp.options 與 onLoad 中的 options 相同 // 用于擷取 options.query console.log('config', this.$mp.options);
},
init() {
console.log('init');
},
onShow() {
console.log('onShow');
}
}
</script>
2.2 文法和特性
mpregular 支援 RegularJS 的文法和大部分特性。例如:
<template> <div> <input r-model="{ input }" on-confirm="{ this.onConfirm($event) }"> <div> {#list toDoList as item}
<div class="item { item.checked ? 'z-checked' : '' }"> <span>{ item.name }</span> <span>{ item.date | dateFormat: 'yyyy-MM-dd' }</span> </div> {/list}
</div> </div></template>
上述模版中的文法可以直接在小程式上執行。除此以外,mpregular 還支援 r-html、r-hide、{#include this.$body }、filter 等特性。這些特性在現有業務代碼中被大量使用,是以在遷移現有代碼時,幾乎可以原封不動地拷貝過來(除非原有代碼中包含大量 DOM 操作...)。
mpreguar 支援的特性:
- RegularJS 基本文法,包括 {#list},{#if}, {#include this.$body }
- filter
- r-model
- r-hide
- r-html
- r-class
- r-style
相比于原生小程式和業界其他架構而言,mpregular 給 RegularJS 開發者提供了他們更熟悉的開發模式,支援更多的特性,對模版的處理能力進一步增強,更适應于我們目前複雜應用的業務場景。
3 基本原理
小程式在結構上主要有 Service(JavascriptCore) 和 View(WebView) 兩部分組成,分别運作在獨立的環境上,之間不具備共享資料通道,二者的通信方式是将資料封裝在 js 腳本後傳遞。Page 執行個體就在 Service 中,通過 setData 方法将資料傳遞到 View。View 則通過事件綁定将視圖層觸發的事件傳遞給 Service。
Regular 是基于 Living Template 實作的,它使用一個内建 DSL 将模版字元串解析成 AST,然後在編譯階段結合資料模型将 AST 進行遞歸周遊,并在這個周遊過程中生成 DOM 節點,同時完成插值、指令等的綁定,實作 DOM 與資料的連結。
Mpregular 要做的就是将 Regular 的視圖層從 DOM 替換成小程式的 View。在小程式中不能直接操作 View 中的 DOM 節點,而是需要通過小程式的 Service 層
setData
方法去更新 View 的資料。
建構時,mpregular 會将 Regular 的模版字元串預先編譯成小程式的模版 .wxml,通過小程式的 Service 與小程式的 View 建立聯系,實作資料更新和事件監聽。由于小程式中無法使用
eval
和
new Function
等操作,是以 mpregular 會在建構階段預先生成 AST ,運作時從源碼中讀取 AST。在執行
this.$update
時把更新資料通知 Service,調用
setData
完成視圖更新。View 觸發的事件會被代理到 Service 的
proxyEvent
方法,這個方法會在 RegularVM 中找到對應的事件處理函數并執行。
Mpregular 要做的,就是在 Regular 執行個體和小程式 Service 之間建立聯系,完成生命周期綁定、資料更新、事件代理等工作。
3.1 生命周期
小程式中通過調用 Page 方法注冊頁面,而頁面加載時建立的頁面執行個體
PageVM
就是 mpregular 與小程式建立連接配接的通道。
Mpregular 在定義頁面入口的 Regular 元件時去調用 Page 方法注冊頁面,并将 Page 的生命周期鈎子與 Regular 的生命周期進行綁定。
page.init = function(config) {
Page({
onLoad(options) {
this.rootVM = initRootVM(this, config);
callHook(this.rootVM,'onLoad');
},
onReady() {
callHook(this.rootVM,'onReady', options);
initDataToMP(this.rootVM);
}
})
}
在 Page 執行個體化(頁面加載)時,會觸發
onLoad
鈎子,此時會對這個頁面對應的 Regular 入口元件進行執行個體化,并将
PageVM
和
RegularVM
綁定在一起。由于每個頁面隻有一個
PageVM
,是以
PageVM
會與
RegularVM.$root
進行綁定,之後 Regular 的邏輯會利用
RegularVM.$root
所綁定的
PageVM
與小程式進行通信。當頁面初次渲染完成後,會觸發
onReady
鈎子,對應于 Regular 的
init
。當頁面的其他鈎子函數觸發時,如
onShow
、
onHide
,
PageVM
會通過
callHook
方法調用
RegularVM
上定義的同名方法。在頁面退出銷毀時,
onUnload
中則會觸發
RegularVM
的
destroy
方法,将頁面綁定對應的 Regular 執行個體銷毀。
3.2 模版轉換
由于 Regular 的模版文法與小程式模版文法不一樣,是以在建構階段,mpregular-loader 會把 Regular 的模版字元串轉換成小程式的 .wxml,不僅會對标簽進行轉換,還會對模版的文法、子元件模版進行處理。所定義的每個 Regular 元件,包括入口元件,都會被轉換成一個個模版片段,存放到對應的 .wxml 檔案中,并用
<template name="${componentName}">
包裹起來,用元件名命名。
<!-- app.rgl --><template> <CustomComponent></CustomComponent> <div> <span>{ title }</span> <input r-model="{ input }" on-confirm="{ this.onConfirm($event) }"> </div></template>
上面這段 Regular 的模版就會被轉換會符合小程式模版文法的模版檔案,如:标簽
<div>
、
<span>
會被轉換為
<view>
、
<label>
,事件監聽的文法則會進行轉換且把是以事件統一代理到
PageVm
上的
evenProxy
方法上。對于外部元件,則會通過
<import>
把元件的模版片段引入。由于所有模版片段都在同一個 Page 的作用域下,即從
PageVm.data
上取資料,是以需要一個規則将Regular 各個元件執行個體的資料映射到對應的模版片段中。
<!-- app.wxml --><import src="./components/custom-component.wxml"><template name="app"> <template is="./CustomComonent" data={{ customComonentData }}> <view> <label>{{ title }}</label> <input bindinput="proxyEvent" bindconfirm="evenProxy" value="{{ input }}"> </view></template>
3.3 資料和視圖的綁定
小程式對于 mpregular 而言隻起到了視圖層的作用,小程式的模版全都會彙集通過
<import>
标簽彙集到頁面的入口 .wxml 中,這些被引入的模版的所有資料都是從
PageVM.data
上擷取的,意味着需要一定的映射規則,才能将 RegularVM 樹上各個子元件的資料綁定到小程式模版對應的節點上。對此,mpregular 借鑒了 mpvue 的資料結構設計,利用子元件在 VM 樹上的路徑生成唯一的 id,将子元件上的資料映射到對應的 View 節點上。
用以下這段簡單的代碼進行說明。
<Page>
是整個頁面的入口模版,包含三個元件,分别是
<Header>
、
<Counter>
、
<Panel>
。
<!-- Counter.rgl --><template> <div> <Panel></Panel> </div></template><!-- page.rgl --><template> <Header></Header> <Counter></Counter></template>
以
<Page>
作為根節點,結構如下圖所示,是一個三層的樹結構。按照元件聲明的順序,每一層級的元件序号從 0 開始遞增。每個元件在樹中的 id 則根據它在樹中的路徑生成,如果
<Header>
則為
0,0
,
<Panel>
的 id 為
0,1,0
,利用
,
進行分隔,根據 id 可以反推出該元件執行個體在樹中的位置。
根據元件的 id,就可以把每個元件要更新到視圖的資料收集起來,并将收集的資料儲存到小程式
PageVM.data.$root
上。
{
$root: {
'0': { ... } // Page '0,0': { ... } // Header '0,1': { ... } // Counter '0,1,0': { ... } // Panel }
}
利用 id 就可以把各個各個元件的資料映射到模版對應的節點上,轉換出來的模版如下所示(為了友善了解,這裡時簡化的執行個體代碼,并不是實際轉換結果)。
<!-- counter.wxml --><template> <view> <template is="./Panel" data={{ ...$root[ '0,1,0' ] }}> </view></template><!-- page.wxml --><template> <template is="./Header" data={{ ...$root[ '0,0' ] }}> <template is="./Counter" data={{ ...$root[ '0,1' ] }}> <Panel></Panel></template>
而
Page.data.$root
上的挂載的各個元件執行個體的資料,與模版的映射關系如下圖所示。
有了這個映射關系之後,通過
PageVM.setData
更新
PageVM.data.$root
上的資料,就完成了資料的更新。
3.4 事件代理
如上所述,所有模版片段的作用域都與該頁面的
PageVM
一緻,事件隻能由
PageVM
進行代理轉發。建構時,mpregular-loader 會為每個包含事件監聽的元素添加上 eventId 和 compId, 用于标記該元素和所屬元件(如下所示)。在注冊頁面的時候,mpregular 會在 Page 上挂載
proxyEvent
方法,所有事件都将代理到這個方法。
<!-- RegularJS 模版 --><div on-click="{ this.onClick($event) }"></div><!-- 轉換後的小程式 .wxml --><div bindtap="proxyEvent" event-id="0" comp-id="0"></div>
Mpregular 在為各個事件注冊處理方的時候,為每個元件建立一個
eventHandlers
對象,根據事件類型和
eventId
記錄各個事件處理函數。
{
componentId: '0',
// ... eventHandlers: {
'0': {
'tap': function() handler{}
}
}
}
當事件觸發時,
PageVM.proxyEvent
方法會根據
compId
找到對應的
RegularVM
,再根據事件類型和
eventId
找到對應的
handler
,最後執行對應的處理函數,完成事件代理。
3.5 性能優化
上面所講述的原理,就是讓 RegularJS 在小程式中運作的關鍵,但是僅僅運作起來還是不夠的,在實際業務場景下,還需要進一步優化才能更好地支撐業務,尤其是對于資料更新的優化。小程式官方文檔中特别強調
setData
在傳遞大資料時會大量占用 WebView JS 線程。同時我們發現,
PageVM
上挂載的資料過大,也會嚴重影響
setData
的性能。為此 mpregular 做了特别的優化,核心方向有兩個:
- 降低頻率
- 減少資料量
3.5.1 緩存資料,定期更新
降低頻率的方法比較簡單,mpregular 會在調用
this.$update
時,先把需要更新的資料會緩存起來,每間隔 50ms 從緩存中取出資料進行批量更新,以減少避免頻繁的
setData
操作。
3.5.2 隻更新 View 需要的資料
通常,在進行原生小程式的開發時,需要通過
setData
把資料更新到
PageVM.data
和 View 上,這也是唯一讓 View 和 Service 線程保持資料一緻的方式。但這樣帶來的一個問題,在調用
setData
時,開發者很少會去區分哪些資料真正是 View 需要的,進而使得有大量的視圖無關資料被傳遞到 View,影響資料更新性能。
舉一個例子,視圖層需要從一個大對象上讀取其中一個值,
largeData.info.countdown.time
。最簡單直接的做法時直接将模版編譯成下面這樣,把
largeData.info.countdown.time
寫到 .wxml 上,mpregular 在運作時把
largeData
更新到 View,由 View 去解析這個對象,取得所需的值。如果隻是一次性傳遞也還好,但如果這個是一個毫秒級的倒計時模版,每次時間更新,就要重新把
largeData
傳給 View,性能變得極為糟糕。當然,開發者可以通過把值提取到
this.data.time
就可以繞過這個問題,但這樣會為開發者帶來許多不便。
<!-- RegularJS template --><span>{ largeData.info.countdown.time }</span><!-- 轉換後的小程式 wxml --><label>{{ largeData.info.countdown.time }}</label>
為此,mpregular 做了深度優化,在建構時 mpregular-loader 會對視圖層用到的插值表達式進行标記,将辨別同步到 AST 上,把模版轉換成如下面代碼那樣。mpregular 在運作時,會根據 AST 上的标志将執行插值表達式的執行結果填入對應的位置上,最後再更新到視圖層。這樣,資料的傳遞由一個大對象變成了一段字元串,大大提升資料更新性能。
<!-- RegularJS template --><span>{ largeData.info.countdown.time }</span><!-- 轉換後的小程式 wxml --><label>{{ __holders[0] }}</label>
有了這一機制,像 filter、r-html 等特性,都可得以實作。在 Regular 裡面,包含 filter 的插值、r-html 指令都會被轉換成插值表達式,用同樣的方法根據插值表達式的标志将執行結果映射到對應模版節點上,就能夠實作原生小程式不支援的各種特性,極大地強化了模版的能力。
此外,mpregular 對清單渲染也進行了優化。在對
source
進行周遊時,視圖層是不需要擷取
source
的實際内容的,mpregular 将
source
重新映射成一個具有同等長度的簡單數組,如
[0, 1, ...]
,再傳遞給視圖層去周遊渲染,而所渲染的清單内容也會采用相同機制,将資料映射到清單中的對應位置。
<!-- RegularJS template -->{#list source as item}
<span>{ item.name }</span>{/list}
<!-- 轉換後的小程式 wxml --><block wx:for="{{ __holders[ 0 ] }}" wx:for-item="item" wx:for-index="item_index"> <label>{{ __holder[ 1 + '-' + item_index ]}}</label></block>
4 實踐
Mpregular 初版完成以後,我們立馬把它投入到生産當中。目前,考拉的小程式商品詳情頁已經用 mpregular 重構完成,頁面性能有明顯提升。
舊版商品詳情頁使用原生小程式進行開發,在處理多 sku 商品時,會存在性能問題。如果在處理下圖中包含 140+ 個 sku 資料的的商品時,點選加入購物車按鈕後,sku 選擇彈層出來有明顯延時,這正是因為在調用
setData
更新大量 sku 資料時引發性能問題。使用 mpregular 重構後,sku 選擇彈層的彈出速度明顯加快。
舊版(原生小程式)
新版(mpregular)
另外還有一個包含 220+ sku 資料的商品,新版詳情頁性能沒有受到大量 sku 資料的影響,而舊版詳情頁因為單次
setData
資料量超出限制,使頁面無法正常渲染(下方加車欄渲染失敗)。
舊版(原生小程式)
新版(mpregular)
除了商品詳情頁以外,小程式的售後、拉新等新老業務都陸續開始使用 mpregular 進行開發。
5 總結與展望
Mpregular 為目前考拉跨 wap 和小程式兩端的老新業務開發和維護提供了有效的跨端解決方案,并能解決部分場景的性能問題。我們将長期維護 mpregular,繼續完善文檔和教程,增加單元測試保障代碼品質,繼續對性能、建構打包方式等進行優化,相關的配套設施也在将進一步完善。
Mpregular 驗證了 RegularJS 在小程式相中運作的可行性,相信 RegularJS 也能與 weex 相結合,成為一個跨端開發架構,也希望 RegularJS 生态能夠活躍起來。
猿學-中國網際網路IT軟體教育訓練專家!承接電信、能源、金融、政府、制造業、商貿流通業、醫療衛生、教育與文化、交通、移動網際網路、傳媒、環保等軟體項目合作,歡迎洽談!