猿學-用RegularJS開發小程序 — mpregular解析

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 的 onLoadonReady 已經通過 mpregular 與 Regular 的實例生命週期綁定在一起了。頁面 url 的 querystring 也可以通過 this.$mp.options 獲取。 onShowonPageScroll 等小程序特有的生命週期鉤子都同樣綁定到 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。當頁面的其他鉤子函數觸發時,如 onShowonHidePageVM 會通過 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 做了特別的優化,核心方向有兩個:

  1. 降低頻率
  2. 減少數據量

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軟件培訓專家!承接電信、能源、金融、政府、製造業、商貿流通業、醫療衛生、教育與文化、交通、移動互聯網、傳媒、環保等軟件項目合作,歡迎洽談!

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