從 Wepy 到 UniApp 變形記

2022-10-31 12:00:52

作者:vivo 網際網路前端團隊-Wan Anwen、Hu Feng、Feng Wei、Xie Tao

進入網際網路「下半場」,靠「人海戰術」的研發模式已經不再具備競爭力,如何通過技術升級提升研發效能?前端通過Babel等編譯技術發展實現了工程化體系升級,如何進一步通過編譯技術賦能前端開發?或許我們 wepy 到uniapp 編譯的轉換實踐,能給你帶來啟發。

一、 背景

隨著小程式的出現,藉助微信的生態體系和海量使用者,使服務以更加便捷方式的觸達使用者需求。基於此背景,團隊很早佈局智慧導購小程式(為 vivo 各個線下門店導購提供服務的使用者運營工具)的開發。

早期的小程式開發工程體系還不夠健全,和現在的前端的工程體系相差較大,表現在對模組化,元件化以及高階JavaScript 語法特性的支撐上。所以團隊在做技術選型時,希望克服原生小程式工程體系上的不足,經過對比最後選擇了騰訊出品的 wepy 作為整體的開發框架。

在專案的從0到1階段,wepy 確實幫助我們實現了快速的業務迭代,滿足線下門店導購的需求。但隨著時間的推移,在技術上,社群逐步沉澱出以 uniapp 為代表的 Vue 棧體系和以 Taro 為代表的 React 棧跨端的體系,wepy 目前的社群活躍度比較低。另外隨著業務進入穩定階段,除少量的 wepy 小程式,H5 專案和新的小程式都是基於 Vue 和 uniapp 來構建,團隊也是希望統一技術棧,實現更好的跨端開發能力,降低開發和維護成本,提升研發效率。

二、思考

隨著團隊決定將智慧導購小程式從 wepy 遷移到 uniapp 的架構體系,我們就需要思考,如何進行專案的平穩的遷移,同時兼顧效率和質量?通過對當前的專案狀態和技術背景進行分析,團隊梳理出2個原則3種遷移思路。

2.1 漸進式遷移

核心出發點,保證專案的平穩過渡,給團隊更多的時間,在迭代中逐步的進行架構遷移。希望以此來降低遷移中的風險和不可控的點。基於此,我們思考兩個方案:

方案一 融合兩套架構體系

在目前的專案中引入和 uniapp 的專案體系,一個專案融合了 wepy 和 uniapp 的程式碼工程化管理,逐步的將 wepy 的程式碼改成 uniapp 的程式碼,待遷移完成刪除 wepy 的目錄。這種方案實現起來不是很複雜,但是缺點是管理起來比較複雜,兩套工程化管理機制,底層的編譯機制,各種入口的組態檔等,管理起來比較麻煩。另外團隊每個人都需要消化 wepy 到 uniapp 的領域知識遷移,不僅僅是專案的遷移也是知識體系的遷移。

方案二 設計 wepy-webpack-loader

以 uniapp 為工程體系基礎,核心思路是將現有 wepy 程式碼融入到 uniapp 的體系中來。我們都知道 uniapp 的底層依賴於 Vue 的 cli 的技術體系,最底層通過 webpack 實現對 Vue 單元件檔案和其他資原始檔的 bundle。

基於此,我們可以開發一個 wepy 的 webpack 的 loader,wepy-loader 類似於 vue-loader 的能力,通過該 loader 對 wepy 檔案進行編譯打包,然後最終輸出小程式程式碼。想法很簡單,但我們想要實現 wepy-loader工作量還是比較大的,需要對 wepy 的底層編譯器進一步進行分析拆解,分析 wepy 的依賴關係,區分是元件編譯還是 page 編譯等,且 wepy 底層編譯器的程式碼比較複雜,實現成本較高。

2.2 整體性遷移

構建一個編譯器實現 wepy 到 uniapp 的自動程式碼轉換。

通過對 wepy 和 uniapp 整體技術方案的梳理,加深了對兩套架構差異性的認知和理解,尤其 wepy 上層語法和 Vue 的元件開發的程式碼上的差異性。基於團隊對編譯的認知,我們認為藉助 babel 等成熟編譯技術是有能力實現這個轉換的過程,另外,通過編譯技術會極大的提升整體的遷移的效率。

2.3 方案對比

圖片

通過團隊對方案的深入討論和技術預研,最終大家達成一致使用編譯轉換的方式(方案三)來進行本次的技術升級。最終,通過實現 wepy 到 uniapp 的編譯轉換器,使原本 25人/天的工作量,6s 完成。

如下動圖所示:

圖片
 
圖片

三、架構設計

3.1 wepy 和 uniapp 單檔案元件轉換

通過對 wepy 和 uniapp 的學習,充分了解兩者之間的差異性和相識點。wepy 的檔案設計和 Vue 的單檔案非常的相似,包含 template 和 script 和 style 的三部分組成。

如下圖所示,

圖片

 

所以我們將檔案拆解為 script,template,style 樣式三個部分,通過 transpiler 分別轉換。同時這個過程主要是對 script 和 template 進行轉換,樣式和 Vue 可以保持一致性最終藉助 Vue 進行轉換即可。

同時 wepy 還有自己的 runtime執行時的依賴,為了確保專案對 wepy 做到最小化的依賴,方便後續完全和 wepy 的依賴進行完全解耦,我們抽取了一個 wepy-adapter 模組,將原先對於 wepy 的依賴轉換為對wepy-adapter 的依賴。

整體轉換設計,如下圖所示:

圖片

3.2 編譯器流水線構建

圖片

如上圖所示,整個編譯過程就是一條流水線的架構設計,在每個階段完成不同的任務。主要流程如下:

3.2.1 專案資源分析

不同的專案依賴資源不同的處理流程,掃描專案中的原始碼和資原始檔進行分類,等待後續的不同的流水線處理。

靜態資原始檔(圖片,樣式檔案等)不需要經過當中流水線的處理,直達目標 uniapp 專案的對應的目錄。

3.2.2 AST抽象語法樹轉換

針對 wepy 的原始檔(app,page,component等)對 script,template 等部分,通過 parse 轉換成相對應的AST抽象語法樹,後續的程式碼轉換都是基於對抽象語法樹的結構改進。

3.2.3 程式碼轉換實現 - Transform code

根據 wepy 和 uniapp 的 Vue 的程式碼實現上的差異,通過對ast進行轉換實現程式碼的轉換。

3.2.4 程式碼生成 - code emitter

根據步驟三轉換之後最終的ast,進行對應的程式碼生成。

四、專案搭建

整體專案結構如下圖所示:

圖片

4.1 單倉庫的管理模式

使用 lerna 進行單倉庫的模組化管理,方便進行模組的拆分和本地模組之間依賴參照。另外單倉庫的好處在於,和專案相關的資訊都可以在一個倉庫中沉澱下來,如檔案,demo,issue 等。不過隨著 lerna 社群不再進行維護,後續會將 lerna 遷移到 pnpm 的 workspace 的方案進行管理。

4.2 核心模組

 

  • wepy-adapter - wepy執行期以來的最小化的polyfill
  • wepy-chameleon-cli - 命令列工具模組
  • wepy-chameleon-transpiler - 核心的編譯器模組,按照one feature,one module方式組織

 

4.3 自動化任務構建等

Makefile - *nix世界的標準方式

4.4 scripts 自動化管理

shipit.ts 模組的自動釋出等自動化能力

4.5 單元測試

 

  • 採用Jest作為基礎的測試框架,使用typescript來作為測試用例的編寫。
  • 使用@swc/jest作為ts的轉換器,提升ts的編譯速度。
  • 現在社群的vitest直接提供了對ts的整合,藉助vite帶來更快的速度,計劃遷移中。

 

五、核心設計實現

5.1 wepy template 模版轉換

5.1.1 差異性梳理

下面我們可以先來大致看一下wepy的模板語法和uniapp的模板語法的區別。

圖片

圖:wepy模板和uni-app模板

 從上圖可以看出,wepy模板使用了原生微信小程式的wxml語法,並且在採用類似Vue的元件引入機制的同時,保留了wxml< import/ >、< include/ >標籤的能力。同時為了和wxml中迴圈渲染dom節點的語法做區別,引入了新的< Repeat/ >標籤來渲染引入的子元件,而uni-app則是完全使用Vue風格的語法來進行開發。

所以總結wepy和uni-app模板語法的主要區別有兩點:

 

  1. wepy使用了一些特定的標籤用來匯入或者複用其他wxml檔案例如< import >和< include >。
  2. wxml使用了xml名稱空間的方式來定義模板指令,並且對指令值的處理更像是使用模板引擎對特定格式的變數進行替換。

下表列舉一些兩者模板指令的對應轉換關係。

此外,還有一些指令的細節需要處理,例如在wepy中wx:key="id"指令會自動解析為wx:key="{{item.id}}",這裡便不再贅述。

5.1.2 核心轉換設計

編譯器對template轉換主要就需要完成以下三個步驟:

 

  1. 處理wepy引入的特殊的標籤例如。
  2. 將wxml中使用的指令、特殊標籤等轉換為Vue模板的語法。
  3. 收集引入的元件資訊傳遞給下游的wepy-page-transform模組。

 

  • wepy特殊標籤轉換

首先我們會處理wepy模板中的特殊標籤< import/ >、< include/ >,主要是將wxml的檔案引入模式轉換成Vue模板的元件引入模式,同時還需要收集引入的wxml的檔案地址和展示的模板名稱。由於< include/ >可以引入wxml檔案中除了< template/ >和< wxs/ >的所有程式碼,為了保證轉換後元件的複用性,我們將引入的xx.wxml檔案拆成了xx.vue和xx-incl.vue兩個檔案,使用< import/ >標籤的會匯入xx.vue,而使用< include/ >標籤的會匯入xx-incl.vue,轉換import的核心程式碼實現如下:

transformImport() {
  // 獲取所有import標籤
  const imports = this.$('import')
  for (let i = 0; i < imports.length; i++) {
    const node = imports.eq(i)
    if (!node.is('import')) return
    const importPath = node.attr('src')
    // 收集引入的路徑資訊
    this.importPath.push(importPath)
    // 將檔名統一轉換成短橫線風格
    let compName = TransformTemplate.toLine(
      path.basename(importPath, path.extname(importPath))
    )
    let template = node.next('template')
    while (template.is('template')) {
      const next = template.next('template')
      if (template.attr('is')) {
        const children = template.children()
        // 生成新的元件標籤例如
        // <import src="components/list.wxml" />
        // <template is="subList" />                => <list is="subList" />
        const comp = this.$(`<${compName} />`)
        .attr(template.attr())
        .append(children)
        comp.attr(TransformTemplate.toLine(this.compName), comp.attr('is'))
        comp.removeAttr('is')
        // 將當前標籤替換為新生成的元件標籤
        template.replaceWith(comp)
      }
      template = next
    }
    node.remove()
  }
}

具體的WXML檔案拆分方案請看WXML轉換部分。

  • wepy 屬性轉換

上文中已經介紹了,wepy模板中的屬性使用了名稱空間+模板字串風格的動態屬性,我們需要將他們轉換成Vue風格的屬性。轉換需要操作模板中的節點及其屬性,這裡我們使用了cheerio, 快速、靈活、類jQuery核心實現,可以利用jQuery的語法非常方便的對模板字串進行處理。

上述流程中一個分支中的轉換函數會處理相應的wepy屬性,以保證後續可以很方便的對轉換模組進行完善和修改。由於屬性名稱轉換隻是簡單的做一下相應的對映,我們重點分析一下動態屬性值的轉換過程。

WXML中使用雙中括號來標記動態屬性中的變數及WXS表示式,並且如果變數是WXS物件的話還可以省略物件的大括號例如

< view wx:for="{{list}}" > {{item}} < /view >、< template is="objectCombine" data="{{for: a, bar: b}}" >< /template >

所以當我們取到雙中括號中的值時會有以下兩種情況:

 

  1. 得到WXS的表示式;
  2. 得到一個沒有中括號包裹的WXS物件。此時我們可以先對錶示式嘗試轉換,如果有報錯的話,給表示式包裹一層中括號再進行轉換。考慮到WXS的語法類似於Javascript的子集,我們依然使用babel對其進行解析並處理。

 

核心程式碼實現如下:

/**
 *
 * @param value 需要轉換的屬性值
 */
private transformValue(value: string): string {
  const exp = value.match(TransformTemplate.dbbraceRe)[1]
  try {
    let seq = false
    traverse(parseSync(`(${exp})`), {
      enter(path) {
        // 由於WXS支援物件鍵值相等的縮寫{{a,b,c}},故此處需要額外處理
        if (path.isSequenceExpression()) {
          seq = true
        }
      },
    })
    if (!seq) {
      return exp
    }
    return `{${exp}}`
  } catch (e) {
    return `{${exp}}`
  }
}

到這裡,我們已經能夠處理wepy模板中絕大部分的動態屬性值的轉換。但是,上文也提及到了,wepy採用的是類似模板引擎的方式來處理動態屬性的,即WXML支援這種動態屬性< view id="item-{{index}}" >,如果這個 < view / >標籤使用了wx:for指令的話,id屬性會被編譯成item-0、item-1... 這個問題我們也想了多種方案去解決,例如字串拼接、正則處理等,但是都不能很好的覆蓋全部場景,總會有特殊場景的出現導致轉換失敗。

最終,我們還是想到了模板引擎,Javascript中也有類似於模板引擎的元素,那就是模板字串。使用模板字串,我們僅僅需要把WXML中用來標記變數的雙括號{{}}轉換成Javascript中的${}即可。

5.2 Wepy App 轉換

5.2.1 差異性梳理

wepy 的 App 小程式範例中主要包含小程式生命週期函數、config 設定物件、globalData 全域性資料物件,以及其他自定義方法與屬性。

核心程式碼實現如下:

import wepy from 'wepy'
 
// 在 page 中,通過 this.$parent 來存取 app 範例
export default class MyAPP extends wepy.app {
  customData = {}
 
  customFunction() {}
 
  onLaunch() {}
 
  onShow() {}
 
  // 對應 app.json 檔案
  // build 編譯時會根據 config 屬性自動生成 app.json 檔案
  config = {}
 
  globalData = {}
}

uniapp的 App.vue 可以定義小程式生命週期方法,globalData全域性資料物件,以及一些自定義方法,核心程式碼實現如下:

<script>
    export default {
        globalData: { 
            text: 'text' 
        }
        onLaunch: function() {
            console.log('App Launch,app啟動')
        },
        onShow: function() {
            console.log('App Show,app展現在前臺')
        },
        onHide: function() {
            console.log('App Hide,app不再展現在前臺')
        },
        methods: {
          // .....
        }
    }
<script>

5.2.2 核心轉換設計

圖片

 

如圖, 核心轉換設計流程:

 

  1. 對 app.py 進行 parse,拆分出script和style部分,對script部分使用babel進行parse生成AST。
  2. 通過對 AST 分析出,小程式的生命週期方法,globalData全域性資料,自定義方法等。
  3. 對於AST進行uniapp轉換,生命週期方法和全域性資料轉成物件的方法和屬性,對自定義方法轉換到method內。
  4. 其中對 globalData 的存取,要進行替換通過 getApp()進行存取。
  5. 抽取 ast 中的 config 欄位,輸出到 app.json 組態檔。
  6. 抽取 wepy.config.js 中的 config 欄位,傳入 wepy 的 app 範例。

 

核心程式碼實現:

let APP_EVENT = ['onLaunch', 'onShow', 'onHide', 'onError', 'onPageNotFound']
 
//....
 
// 實現wepy app到uniapp App.vue的轉換
 
t.program([
   ...body.filter((node: t.Node) => !t.isExportDeclaration(node)),
   // 插入appClass
   ...appClass,
   ...body
   .filter((node: t.Node) => t.isExportDeclaration(node))
   .map((node: object) => {
     // 對匯出的app進行處理
     if (t.isExportDeclaration(node)) {
       // 提前config屬性
       const { appEvents, methods, props } = this.clzProperty
       // 重新匯出vue style的物件
       return t.exportDefaultDeclaration(
         t.objectExpression([
           // mixins
           ...mixins,
           // props
           ...Object.keys(props)
           .filter((elem) => elem !== 'config')
           .map((elem) =>
                this.transformClassPropertyToObjectProperty(props[elem])
               ),
 
           // app events
           ...appEvents.map((elem) =>
                            this.transformClassMethodToObjectMethod(elem)
                           ),
 
           // methods
           t.objectProperty(
             t.identifier('methods'),
             t.objectExpression([
               ...methods.map((elem) =>
                              this.transformClassMethodToObjectMethod(elem)
                             ),
             ])
           ),
         ])
       )
     }
     return node
   }),
 ])
 
// ..... 

5.2.3 痛點難點

在執行期,app.wpy 會繼承 wepy.App 類,這樣就會在執行期和 wepy.App 產生依賴關係,怎麼最小化弱化這種關係。抽取wepy的最小化以來的polyfill,隨著業務中程式碼剔除對wepy的api呼叫,最終去除對polyfill的依賴。

5.3 wepy component 轉換

對於wepy component 的轉換主要可以細化到對 component 中 template、script、style 三部分程式碼塊的轉換。

其中, style 部分由於已經相容 Vue 的規範,所以我們無需做額外處理。而 template 模組主要是需要對 wepy template 中特殊的標籤、屬性、事件等內容進行處理,轉化為適配 uni的template,上文做了詳細的說明。

我們只需要專注於處理 script 模組的程式碼轉換即可。從架構設計的思路來看,component script 的轉換主要是是做以下兩件事:

 

  1. 編譯期可確定程式碼塊的轉換。
  2. 執行期動態注入程式碼的相容。

 

wepy-component-transform 就是基於以上這兩個標準設計出來的實現轉換邏輯的模組。

5.3.1 差異性梳理

首先先解釋一下什麼是「編譯期可確定程式碼塊」,我們來看一個 wepy 和 Vue 語法對比範例:

圖片

從直觀上來說,這個 script 的模板的語法大致和 Vue 語法類似,這意味著我們解析出來的 AST 結構和 Vue 檔案對應的 AST 結構上類似,基於這一點來看編譯轉換的工作量大致有底了。

從細節來看, wpy 檔案script 模組中的 API 語法和 Vue 中有宣告及使用上的不同,其中包含:

 

  1. wepy 自身的包依賴注入及執行時依賴
  2. props/data/methods 宣告方式不同
  3. 生命週期勾點不同
  4. 事件釋出/訂閱的註冊和監聽機制不同。
  5. ....等等

 

為了確定這個第5點等等還存在哪些使用場景,我們需要對 wepy 自身的邏輯和玩法有一個詳盡的瞭解和熟悉,通過在團隊內組織的 wepy 原始碼走讀,再結合wepy 實際生產專案中的程式碼相互印鑑,我們最終才將 wepy 語法邏輯與 uni-app Vue 語法邏輯的異同梳理清楚。

5.3.2 核心轉換設計

我們簡單梳理一下 wepy-component-transform 這個模組的結構,可以分為以下三個部分:

 

  • 預處理 wepy component script 程式碼 AST 節點部分
  • 構建 Vue AST
  • 通過 generate 吐出程式碼

 

1.預處理 AST

基於前文轉換設計這一節我們知道, wepy 變色龍的轉換器中對程式碼的 AST 解析主要依賴 babel AST 三板斧(traverse、types、generate)來實現,通過分析各個差異點程式碼語句轉換後的 AST 節點,就可以通過 traverse 中的勾點來進行節點的前置處理,這裡安利一下 https://astexplorer.net/,我們可以通過它快速分析程式碼塊 AST 節點、模擬場景及驗證轉換邏輯:

圖片

 

預處理 AST,目的是提前將 wepy 原始碼中的程式碼塊解析為 AST Node 節點後,按語法進行歸集到預置的 clzProperty 物件中,其中:

  • props 物件用來盛放 ClassProperty 語法的 ast 節點

  • notCompatibleMethods 陣列用來盛放非生命週期函數白名單內的函數 AST 節點。

  • appEvents 陣列用來盛放生命週期函數白名單內的函數 AST 節點。

  • listenEvents 陣列用來盛放 釋出/訂閱事件註冊的函數 AST 節點。

核心程式碼實現如下所示:

import { NodePath, traverse, types } from '@babel/core'
 
this.clzProperty = {
  props: {},
  notCompatibleMethods: [],
  appEvents: [],
  listenEvents: []
}
 
traverse() {
    ClassProperty: (path) => {
        const name = path.node.key.name
        this.clzPropertyprops[name] = path.node
      },
    ClassMethod: (path) => {
      const methodName = path.node.key.name
      // 判斷是否存在於生命週期白名單內
      const isCompEvent = TOTAL_EVENT.includes(methodName)
      if (isCompEvent) {
        this.clzProperty.appEvents.push(path.node)
      } else {
        this.clzProperty.notCompatibleMethods.push(path.node)
      }
    },
    ObjectMethod: (path: any) => {
      if (path.parentPath?.container?.key?.name === 'events') {
        this.clzProperty.listenEvents.push(path.node)
      }
    }
  }

這裡要注意一點,由於對 wepy 來說,實際上 page 也屬於 component 的一種實現,所以兩者的 event 會有一定的重合,而且由於 wepy 中生命週期和 Vue 生命週期的差異性,我們需要對如 attached、detached、ready 等勾點做一些 hack。

2.構建 Vue AST

buildCompVueAst 函數即為 構建 Vue AST 部分。從直觀上來看,這個函數只做了一件事,即用 types.program 重新生成一個 AST 節點結構,然後將原有的 wepy 語法轉換為 vue 語法。但是實際上我們還需要處理許多額外的相容邏輯,簡單羅列一下:

  • created 重疊問題

  • methods 中函數的收集

  • events 中函數的呼叫處理

created 重疊問題主要是為了解決 created/attached/onLoad/onReady 這4個生命週期函數都會轉換為 created 導致的多次重複宣告問題。我們需要針對若存在 created 重疊問題時,將其餘勾點中的程式碼塊取出並 push 到第一個 created 勾點函數內部。程式碼範例如下:

const body = this.ast.program.body
const { appEvents, notCompatibleMethods, props, listenEvents } =
      this.clzProperty
 
// 處理多個 created 生命週期重疊問題
const createIndexs: number[] = []
const sameList = ['created', 'attached', 'onLoad', 'onReady']
appEvents.forEach((node, index) => {
  const name: string = node.key.name
  if (sameList.includes(name)) {
    createIndexs.push(index)
  }
})
 
if (createIndexs.length > 1) {
  // 取出源節點內程式碼塊
  const originIndex = createIndexs[0]
  const originNode = appEvents[originIndex]
  const originBodyNode = originNode.body.body
  // 留下的剩餘節點需要取出其程式碼塊並塞入源節點中
  // 塞入完成後刪除剩餘節點
  createIndexs.splice(0, 1)
  createIndexs.forEach((index) => {
    const targetNode = appEvents[index]
    const targetBodyNode = targetNode.body.body
    // 將源節點內程式碼塊塞入目標節點中
    originBodyNode.push(...targetBodyNode)
    // 刪除源節點
    appEvents.splice(index, 1)
  })
} 

由於 wepy 中非 methods 中函數的特殊性,所以我們需要在轉換時將獨立宣告的函數、events 中的函數都抽離出來再 push 到 methods 中,虛擬碼邏輯如下所示:

buildCompVueAst() {
    const body = this.ast.program.body
 
    return t.program([
      ...body.map((node) => {
          return t.exportDefaultDeclaration(
            t.objectExpression([
              ...Object.keys(props)
                .map((elem) => {
                  if (elem === 'methods') {
                    const node = props[elem]
                    // 1.events 內函數插入 methods 中
                    // 2.與生命週期平級的函數抽離出來插入 methods 中
                    node.value.properties.push(
                      ...listenEvents,
                      ...notCompatibleMethods
                    )
                  }
                  return props[elem]
                })
            ])
          )
        })
    ])
  }

events 中函數的呼叫處理主要是為了抹平 wepy 中釋出訂閱事件呼叫和 Vue 呼叫的差異性。在 wepy 中,事件的註冊通過在 events 中宣告函數,事件的呼叫通過 this.$emit 來觸發。而 vue 中我們採用的是 EventBus 方案來相容 wepy 中的寫法,即手動為 events 中的函數建立 this.$on 形式的呼叫,並將其程式碼塊按順序塞入 created 中來初始化。

首先我們要判斷檔案中是否已有 created 函數,若存在,則獲取其對應的程式碼塊並呼叫 forEachListenEvents 函數將 events 中的監聽都 push 進去。

若不存在,則初始化一個空的 created 容器,並呼叫 forEachListenEvents 函數。核心程式碼實現如下所示:

buildCompVueAst() {
  const obp = [] as types.ObjectMethod[]
  // 獲取class屬性和方法
  const body = node.declaration.body.body
  const targetNodeArray = body.filter(child =>
    child.key.name === 'created'
  )
 
  if (targetNodeArray.length > 0) {
    let createdNode = targetNodeArray[0]
    this.forEachListenEvents(createdNode)
  } else {
    const targetNode = t.objectMethod(
      'method',
      t.identifier('created'),
      [],
      t.blockStatement([])
    )
    this.forEachListenEvents(targetNode)
    if (targetNode.body && targetNode.body.body.length > 0) {
      obp.push(targetNode)
    }
  }
  return obp
}

forEachListenEvents 函數主要是通過 wepy 中 宣告的 events 事件名和入參,藉助 babel types 手動建立對應的 AST Node,最終生成對應的形如 this.eventBus.on("canceldeposit", this.canceldeposit) 形式的監聽,其中,this.canceldeposit 為原有 events 中的事件被移入 methods 後的函數,相關虛擬碼實現如下所示:

// 根據 events 中的 methods 構建事件監聽的呼叫
// 並塞入 created 中
forEachListenEvents(targetNode: types.ObjectMethod) {
  this.clzProperty.listenEvents.forEach((item) => {
    const methodsNode: any = item
    // 形如 this.$on('test', ()=>{})
    if (methodsNode?.key?.name) {
      // 建立 this 表示式
      const thisEx = t.thisExpression()
      // 建立 $on 表示式
      const ide = t.identifier('$eventBus.$on')
      // 合併 this.$on 表示式
      const om = t.memberExpression(thisEx, ide)
       
      // 建立事件名稱引數節點
      const eventNameIde = t.stringLiteral(
        methodsNode.key.name.toString().trim()
      )
       
      // 獲取方法體內程式碼內容節點
      const meNode = t.memberExpression(
        t.thisExpression(),
        t.identifier(methodsNode.key.name.toString().trim())
      )
      const ceNode = t.callExpression(om, [eventNameIde, meNode])
      const esNode = t.expressionStatement(ceNode)
      // 將合成後的程式碼插入到 created 中
      targetNode.body.body.push(esNode)
    }
  })
 }

3.emitter vue 程式碼生成

構建完 Vue AST 之後,我們可以呼叫 generate 函數生成原始碼字串:

transform() {
  const ast = this.buildCompVueAst()
  const compVue = this.genCode(ast)
 
  return { compVue, wxs: this.buildWxs() }
}

5.4 Wepy page 轉換

5.4.1 差異性梳理

上面的章節已經給大家分析了template、component的程式碼轉換邏輯,這一節主要帶大家一起看下如何轉換page檔案。page轉換的邏輯即如何實現wepy 的 page.wpy 模組轉換為 uniapp 的 page.vue 模組。

首先我們來看下wepy 的 page 小程式範例:

<script>
  import wepy from 'wepy';
  import Counter from '../components/counter';
   
  export default class Page extends wepy.page {
    config = {};
    components = {counter1: Counter};
     
    data = {};
    methods = {};
     
    events = {};
    onLoad() {};
    // Other properties
  }
</script>
 
<template lang="wxml">
<view>
  </view>
<counter1></counter1>
</template>
 
<style lang="less">
  /** less **/
</style>

可以看到,wepy的page類也是通過繼承來實現的,頁面檔案 page.wpy 中所宣告的頁面範例繼承自 wepy.page 類,該類的主要屬性介紹如下:

圖片

5.4.2 核心轉換設計

基於page的api特性以及實現方案,具體的轉換設計思路如下:

圖片

5.4.3 痛點難點

1.非阻塞非同步與非同步

在進行批次pages轉換時,需要同時對pages.json進行讀取、修改、再修改的操作,這就涉及到使用阻塞 IO/ 非同步 IO來處理檔案的讀寫,當使用非同步IO時,會發起多個程序同時處理pages.json, 每個讀取完成後單獨處理對應的內容,資料不是序列修改,最終導致最終修改的內容不符合預期,因此在遇到並行處組態檔時,需要使用阻塞式io來讀取檔案,保障最終資料的唯一性,具體程式碼如下:

// merge pageConfig to app config
const rawPagesJson = fs.readFileSync(path.join(dest, 'src/pages.json'))
// 資料操作
fs.writeFileSync(
  path.join(dest, 'src', 'pages.json'),
  prettJson(pagesJson)
)

2.複雜的事件機制

在轉換過程中,我們也碰到一個比較大的痛點:page.wepy 繼承至 wepy.page,wepy.page 程式碼較複雜,需要將明確部分單獨抽離出來。例如說 events 中元件間資料傳遞:$broadcast$emit$invoke$broadcast$invoke需要熟悉其使用場景,轉換為 Vue 中公共方法。

5.5 Wepy WXML 轉換

template轉換章節中提到了wepy模板中可以直接引入wxml檔案,但是uni-app使用的Vue模板不支援直接引入wxml,故我們需要將wxml檔案處理為uniapp可以引入的Vue檔案。我們先來看一下wepy中引入的wxml檔案的大致結構。

<template name="foo">
  <view class="foo-content">
    <text class="text1">{{item.text1}}</text>
    <image class="pic" src="{{pic.url}}" mode="aspectFill"></image>
  </view>
</template>
 
<template name="bar">
  <view class="bar-content">
    <image class="bar" src="{{pic.url}}" mode="aspectFill"></image>
    <text class="text2">{{item.text2}}</text>
  </view>
</template>
 
<view class="footer">
  this is footer
</view>
              
<!-- index.wepy -->
<!-- 引入檔案 -->
<import src="somePath/fooBar.wxml" />
<!-- 確定展示的template及傳入屬性 -->
<script is="foo" data="{{item, pic}}" />
 
<!-- or, 此時僅會展示<template/>以外的內容即footer -->
<include src="somePath/fooBar.wxml">

5.5.1 差異性梳理

從上面的程式碼可以看出,一個WXML檔案中支援多個不同name屬性的< template/ >標籤,並且支援通過在引入設定data來傳入屬性。從上面的範例模板中我們可以分析出,除了需要將wepy使用的WXML語法轉換成vue模板語法外(這裡的轉換交給了template模組來處理),我們還需要處理以下的問題。

  • 確定引入元件時的傳參格式

  • 確定元件中傳入物件的屬性有哪些

  • 處理< import/ >和< include/ >引入的檔案時的情況

5.5.2 核心轉換設計

1.確定引入元件時的傳入屬性方式

首先需要將wepy元件引入形式改成Vue的元件引入方式。以上面的程式碼為例,即將< import/ > 、< script/ >對的引入形式改寫成< component-name / > 引入方式。我們會在轉換開始前對程式碼進行掃描,收集模板中的引入檔案資訊,傳遞給wepy-page-transform模組處理,在轉換後的Vue元件的< script/ >中進行引入。並且將< script is="foo" data="{{item, pic}}" / > 轉換為< FooBar is="foo" :data=(待定) / > 。這裡就需要確定屬性傳遞的方式。

從上面的程式碼中可以看到,在WXML檔案的< template/ >會自動使用傳入的data屬性作為隱式的名稱空間,從而不需要使用data.item來獲取item屬性。這裡很自然的就會想到原來的< script is="foo" data="{{item, pic}}" / >可以轉換成< FooBar compName="foo" :key1="val1" :key2="val2" ... / >。

其中,key1,val1,key2,val2等為原data屬性物件中的鍵值對,compName用來指定展示的部分。這樣處理的好處是,引入的WXML檔案中使用相應的傳入的屬性就不需要做額外的修改,並且比較符合我們一般引入Vue元件時傳入屬性的方式。

雖然這種方案可以較少的改動WXML檔案中的模板,但是由於傳入的物件可能會在執行期間進行修改,我們在編譯期間比較難以確定傳入的data物件中的鍵值對。考慮到實現的時間成本及難易程度,我們沒有選擇這種方案。

目前我們所採用的方案是不去改變原有的屬性傳入方式,即將元件引入標籤轉換為< FooBar compName="foo" :data="{item, pic}" / >。從而省去分析傳入物件在執行時的變動。這裡就引出了第二個問題,如何確定元件中傳入的引數有哪些。

 

2.確定元件中的傳入的物件屬性

由於Vue的模板中不會自動使用傳入的物件作為名稱空間,我們需要手動的找到當前待轉換的模板中所使用到的所有的變數。相應的程式碼如下:

searchVars() {
    const self = this
    const domList = this.$('template *')
    // 獲取wxml檔案中template節點下的所有text節點
    const text = domList.text()
    const dbbraceRe = new RegExp(TransformTemplate.dbbraceRe, 'g')
    let ivar
    // 拿到所有被{{}}包裹的動態表示式
    while ((ivar = dbbraceRe.exec(text))) {
      addVar(ivar[1])
    }
    // 遍歷所有節點的屬性,獲取所有的動態屬性
    for (let i = 0; i < domList.length; i++) {
      const dom = domList.eq(i)
      const attrs = Object.keys(dom.attr())
      for (let attr of attrs) {
        const value = dom.attr(attr)
        if (!TransformTemplate.dbbraceRe.test(value)) continue
        const exp = value.match(TransformTemplate.dbbraceRe)[1]
        try {
          addVar(exp)
        } catch (e) {
          addVar(`{${exp}}`)
        }
      }
    }
 
    function addVar(exp: string) {
      traverse(parseSync(`(${exp})`), { // 利用babel分析表示式中的所有變數
        Identifier(path) {
          if (
            path.parentPath.isMemberExpression() &&
            !path.parentPath.node.computed &&
            path.parentPath.node.property === path.node
          )
            return
          self.vars.add(path.node.name) // 收集變數
        },
      })
    }
  }

收集到所有的變數資訊後,模板中的所有變數前面需要加上傳入的物件名稱,例如item.hp_title需要轉換成data.item.hp_title。考慮到模板的簡潔性和後續的易維護性,我們把轉換統一放到< script/ >的computed欄位中統一處理即可:

<template>
  <!--...-->
</template>
<script>
  export default {
    props: ['data', 'compName'],
    computed: {
      item() {
        return data.item
      },
      pic() {
        return data.pic
      }
    }
  }
</script>

3.處理 < import/ >和< include/ >兩種引入方式

wepy模板有兩種引入元件的方式,一種是使用< import/ >< script/ >標籤對進行引入,還有一種是使用< include/ > 進行引入,< include/ > 會引入WXML檔案中除了< template/ >和< wxs/ >的其他標籤。這裡的處理方式就比較簡單,我們把< include/ > 會引入的部分單獨抽取出來,生成TItem-incl.vue檔案,這樣即保證了生成程式碼的可複用性,也降低< import/ >標籤引入的部分生成的TItem.vue檔案中的邏輯複雜度。生成的兩個檔案的結構如下:

<!--TItem.vue-->
<template>
  <view>
    <template v-if="compName == 'foo'">
      <view class="foo">
        <!--...-->
      </view>
    </template>
   
    <template v-if="compName == 'bar'">
      <view class="bar">
        <!--...-->
      </view>
    </template>
  </view>
</template>
 
<script>
export default {
  props: ['compName', 'data'],
  computed: {
     item() {
       return this.data.item
     },
     pic() {
       return this.data.pic
     }
  }
}
</script>
 
<!--TItem-incl.vue-->
<template>
  <view>
    <view class="footer">
      this is footer
    </view>
  </view>
</template>

六、階段性成果

截止到目前,司內的企微導購小程式專案通過接入變色龍編譯器已經順利的從 wepy 遷移到了 uniApp 架構,原本預計需要 25人/天 的遷移工作量在使用了編譯器轉換後縮短到了 10s。這不僅僅只是提高了遷移的效率,也降低了遷移中的知識遷移成本,給後續業務上的快速迭代奠定的紮實的基礎。

遷移後的企微導購小程式專案經測試階段驗證業務功能 0 bug,目前已經順利上線。後續我們也會持續收集其他類似的業務訴求,幫助業務兄弟們低成本完成遷移。

七、總結

研發能效的提升是個永恆的話題,此次我們從編譯這個角度出發,和大家分享了從wepy到uniapp的架構升級探索的過程,通過構建程式碼轉換的編譯器來提升整體的架構升級效率,通過編譯器消化底層的領域和知識的差異性,取得了不錯的效果。

當然,我們目前也有還不夠完善的地方,如:編譯器腳手架缺乏對於部分特性顆粒度更細的控制、程式碼編譯轉換過程中紀錄檔的輸出更友好等等。後續我們也有計劃將 wepy 變色龍編譯器在社群開源共建,屆時歡迎大家一起參與進來。

現階段編譯在前端的使用場景越來越多,或許我們真的進入了Compiler is our framework的時代。