随着小程式的發展,Web 端和小程式同構的呼聲也越來越大,為此微信官方提供了 Kbone 這一套方案。旨在讓開發者可以用最熟悉的方式來完成一個多端 APP 的開發,降低開發門檻。本文是Kbone作者june在雲加社群微信群中的分享整理總結而成。同時,june将出席11月16日的TWeb騰訊前端技術大會,歡迎現場交流。
大家好,我是來自騰訊微信小程式團隊的前端開發工程師:june。小程式作為一種新興地連結使用者與服務的方式,相信大家都或多或少接觸過。對于開發者來說,它是一種類似 Web 但又不同于 Web 的開發模式,它提供了一套自定義的 API 和檔案組織方式,這無疑帶給開發者一定的學習成本和維護成本,是以我們也在嘗試能否提供一個方案來抹平這個差異。
接下來就進入我今天要分享的話題:Kbone——微信小程式同構方案新思路。本次分享包括四個部分:背景、方案、應用和結語。
一、Kbone誕生背景
首先我們先進入背景部分的介紹。之是以會有 Kbone 這個方案出現,源自于一個需求:微信開放社群當時隻有 Web 端,為了讓資訊可以更友善地傳播、分享和使用,希望實作社群小程式版,互動體驗盡量貼近于 Web 端。
此次同構到小程式端需要考慮幾個因素:多端代碼複用、盡可能支援已有的特性和性能要有保證。其實最主要的就是要在盡量不改動現有代碼的情況下來完成小程式的開發。
二、具體方案實作
接下來就來探讨下具體方案的實作。
社群 Web 端是基于 Vue 實作的,使用了 Vue-router、Vuex 等插件。Vue 想必大家挺熟悉的了,它是市面上一款非常流行的 Web 架構,提供元件化等特性,其原理大緻如下:
Vue 模闆可以認為是一種附加了一些特殊文法的 HTML 片段,一般來說一份 Vue 模闆對應一個元件,在建構階段編譯成調用 Dom 接口的 JS 函數,調用此 JS 函數就會建立出元件對應的 Dom 樹片段進而渲染到浏覽器上。小程式裡是支援運作 JS 的,但是這裡用到的 Dom 接口和渲染到浏覽器上的功能小程式不具備,是以無法直接将 Web 端社群代碼移植到小程式中。原因就在于小程式為了安全和性能而采用了雙線程的架構,運作使用者 JS 代碼的邏輯層是一個純粹的 JSCore,沒有任何浏覽器相關的實作,這裡得想辦法将 Web 端代碼轉成小程式代碼。
業界常見做法:将 Vue 模闆直接轉成小程式的 WXML 模闆
那麼問題來了,如何将 Vue 代碼轉成小程式代碼?這裡先看下業界常見的做法:将 Vue 模闆直接轉成小程式的 WXML 模闆。
使用做法相當于抛棄了浏覽器中建 Dom 樹的過程,而是直接交由小程式來對模闆進行編譯建立出小程式的模闆樹,進而渲染到小程式頁面中。
一般來說這個做法對于普通場景是夠用的,但是對于一些更複雜的場景就很不好處理了,比如社群中的一個簡單例子:社群文章詳情展示富文本内容,點選内容中的圖檔可預覽。
這主要是因為 Vue 模闆和 WXML 模闆的文法并不是直接對等的,Vue 的特性設計也和小程式的設計無法劃等号,這自然就導緻了部分 Vue 特性的丢失。比如像 Vue 中的 v-html 指令、ref 擷取 Dom 節點、過濾器等就通通用不了。當然不止是 Vue 自身的特性,一些原本依賴 Dom/Bom 接口的 Vue 插件也無法使用,比如 Vue-router 等,而這些正是社群高度依賴的,在不對社群代碼做大範圍改造的話是無法使用此方案的。
此路不通,那還有其他的方法麼?
換個思路:做一個适配層
答案是有的,這裡我們就得換一種思路來解決這個問題。回到最初的點上,我們無法将 Web 端代碼移植到小程式中是因為小程式沒有 Dom 接口,那麼我們想辦法做出一個适配層,将這個差異給抹掉不就行了麼?
有了想法就要實施,仿造出 Dom 接口并不難,事實上在 Nodejs 端就有人做過類似的事,比如 jsDom 這個庫的實作,讓我們可以在沒有真實浏覽器環境下可以對一些依賴 Dom 接口的 Web 端代碼進行測試。
仿造了 Dom 接口給 Vue 調用,進而建立出了仿造 Dom 樹。根據前面提到的小程式架構,使用者的 JS 代碼是執行在邏輯層的,也就是說我們建立出的 Dom 樹也是存在與邏輯層的記憶體之中,接下來要解決的難題是如何将這棵 Dom 樹渲染到小程式頁面中。
這裡需要先簡單介紹一下小程式的渲染原理:小程式的雙線程架構,邏輯層會執行使用者的 JS 代碼進而産生一組資料,這組資料會發往視圖層;視圖層接收到資料後,結合使用者的 WXML 模闆建立出元件樹,之後小程式再将元件樹渲染出來。這裡的元件樹和 Dom 樹很類似,隻是它是由官方内置元件或自定義元件拼接而成而不是 Dom 節點。這裡我們能不能将仿造出來的 Dom 樹映射到小程式的元件樹上?
小程式元件樹是根據 WXML 模闆建立出來的,而仿造 Dom 樹結構是不穩定的,我們無法提前預知它會生成什麼樣的結構,也就無法提前準備後可以描述任意 Dom 樹的 WXML 模闆,除非直接将 Vue 模闆轉換成 WXML 模闆,但這樣又繞回前面的問題上了。
小程式元件樹中的元件有兩種:内置元件和自定義元件,内置元件是由官方提供的如 video、map 這樣的元件,而自定義元件是一種支援由使用者利用現有元件自行組裝的元件,能否利用它來做些什麼?
使用 Web 端概念來做個簡單解釋,内置元件就像是 div、span 這些 HTML 标簽,而自定義元件就像是 Web 中的 Vue 元件。Vue 元件可以将 HTML 标簽以及其他的 Vue 元件進行組裝,自定義元件同理,主要用于功能子產品的抽象、封裝和複用。不過自定義元件有個很奇妙的特性,它支援自引用,也就是說它可以自己引用自己來進行組裝。
自定義元件可以自己引用自己,那麼我們就可以利用這個特性來進行遞歸建立元件,進而建立出一棵元件樹:
比如上圖的例子,我們封裝了一個 custom-dom 元件,這個元件裡面也使用了 custom-dom 元件用于渲染子元件。那麼隻要我們執行一下 setData,把 children 資料傳遞過去就可以建立出子元件,子元件本身也是 custom-dom 元件,它同樣可以執行這個邏輯把各自的子元件建立出來,這樣就實作了元件的遞歸建立,隻要我們擁有完整的 Dom 樹結構,就可以建立出相對應的一棵元件樹。
這裡遞歸的終止條件是遇到特定節點、文本節點或者孩子節點為空。然後在建立出元件樹後,将 Dom 節點和自定義元件執行個體進行綁定以便後續的 Dom 更新和操作即可。
如何監聽使用者操作?
接下來,如果使用者在界面上進行了操作,觸發了一些事件的話,那麼代碼中要如何監聽這些事件呢?小程式本身有自己的事件系統,它和 Web 端事件系統類似,但是出于以下幾個原因導緻我們無法直接使用小程式的事件系統:
- 小程式支援的事件表現和 Web 端不一緻,比如 input 事件在小程式中不可冒泡。
- 小程式的捕獲冒泡是在 Webview 端,是以邏輯層在整個捕獲冒泡流程中各個節點接收到的事件不是同一個對象。
- 小程式事件對象和 Web 端事件對象結構不一樣。
- 小程式事件的捕獲冒泡以及阻止冒泡等操作必須在 WXML 模闆中聲明,無法使用接口實作。
- 小程式本身是基于 Web Component 特性來實作的元件體系,其事件來源隻能判定來自于目前 shadow tree 下的哪個節點,而不能跨 shadow tree 判斷。
綜上所述,最好的解決方法就是把事件系統也仿造一份,在仿造 Dom 樹上進行捕獲冒泡。當自定義元件監聽到使用者的操作後,就将事件發往仿造 Dom 樹,後續自定義元件監聽到的同一個事件的冒泡就直接忽略。而 Dom 樹接收到事件後,再進行捕獲和冒泡,讓事件在各個節點觸發,這樣的話整套體系都可以按照 Web 端的方式進行實作,對于使用者來說,隻管按照 Web 端的用法來進行事件監聽即可。
重要細節一:如何将 Dom 樹傳遞給視圖層?
整套方案的大緻思路便是如此,接下來介紹幾個實作過程中比較重要的細節,其一:如何将 Dom 樹傳遞給視圖層?
這其實就是自定義元件要如何做 setData 的問題。我們一開始想到的方式是直接将整棵 Dom 樹傳遞給自定義元件,然後自定義元件在遞歸建立子元件時一步步透傳下去。這個做法的好處是一勞永逸,隻有在最頂層的自定義元件需要管理 Dom 樹和 setData,其他自定義元件隻管接收資料進行渲染即可,但是這樣也帶了問題:每次更新需要做大範圍的 diff,因為 setData 是從根元件發起的;當遇到一些局部更新時可能需要 setData 大量的資料,也就是會傳輸一些不必要的資料。
那麼自然而然的,我們便想到讓每個自定義元件隻 setData 目前節點的資料,每個自定義元件隻考慮目前綁定的 Dom 節點,然後建立出子節點,這樣雖然會增加 setData 的數量,但是帶來的好處便是可以做到最小範圍 diff,同時每次 setData 的資料量也可以降到最小。
細節其二:自定義元件執行個體的建立其實是會有比較大開銷的,有沒有辦法減少一些自定義元件執行個體的建立?
按照先前的構想,一個自定義元件綁定一個 Dom 節點,是以自定義元件執行個體數量等于 Dom 節點數量。
其中一個思路是對 Dom 節點進行删減,這個實作比較簡單,隻要是不展示在頁面上的節點,直接從 Dom 樹上幹掉就可以了,這樣自定義元件數量也會相應減少。
另一個思路是調整映射關系,讓一個自定義元件綁定多個 Dom 節點。我們可以對 Dom 樹按照一定規則進行裁剪,拆分成多棵子樹,然後每個自定義元件管理一棵子樹,這樣的話也可以減少大部分自定義元件的建立。
除此之外,我們可以考慮對葉子節點也進行一些處理。我們使用自定義元件來渲染的初衷就是為了可以動态遞歸建立出子節點,而當一個節點沒有子節點的情況下,我們就不需要使用自定義元件來渲染了,是以葉子節點可以合并到父級棵子樹中(如上圖的藍色節點合并到黃色節點所在的子樹中),直接使用 view 内置元件來渲染即可。
當然還有其他的一些細節,比如 Dom 對象複用、對象延遲建立等等,這裡就不一一展開說明了,有興趣的朋友可以通過源碼來了解。
對于這個方案,性能也需要有一定的保證,我們随機模拟了一些類似社群首頁的 Dom 樹,對其首次渲染耗時進行測算,其對比如下:
可以看到在 500 節點内的兩個方案本身性能差不多,不過因為自定義元件執行個體建立的開銷,在千節點往上的情況下會落後于靜态模闆方案,因為 Kbone 本身是通過犧牲性能來換取更全面的 Web 端相容,而通常一個小程式頁面的節點數在 100-500 這個區間浮動,是以這個表現是符合預期的。
以上就是 Kbone 這個擴充卡方案的大緻設計思路,我們将其歸納為兩個子產品:仿造接口和自定義元件。正因為這個方案是通過提供擴充卡的方式來仿造出 Web 環境,是以使用者代碼不需要做任何魔改,大部分特性都可以繼續使用不需要被删減,比如 vue-router、window.location 操作等。
三、具體應用效果
方案部分以及介紹完畢,接下來說說這個方案要如何應用到我們一開始的背景——微信開放社群上。
前面有簡單提到,原本 Web 端代碼是基于 Vue 來搭建的,其中還用到了諸多插件/庫,如 Vue-router、Vuex、Markdown-it 等,同時還支援了服務端渲染。但是不管 Web 端是怎麼實作的,底層終究是調浏覽器的那些接口,是以對于使用者層面的代碼我們不做任何調整,隻是将浏覽器那一層替換掉即可。
整個建構流程是基于 Webpack 來實作的,使用 Kbone 建構出小程式代碼也是基于 Webpack 來實作,隻需要在原本 Web 端建構流程上實作一個 Webpack 插件,在建構原本 Web 端代碼到小程式端時追加 Kbone 和一些小程式相關的代碼即可。
在整套方案應用的過程中,肯定也會有些定制化的需求,比如希望小程式端頭部和 H5 端不同,不同端使用不同的互動設計:
我們可以建構的時候就注入環境變量,在小程式端将 process.env.isMiniprogram 設為 true,這樣使用者代碼層面可以通過判斷這個變量來判斷不同環境,進而執行不同的邏輯。
除此之外,還希望使用小程式的一些特性,比如小程式端支援使用小程式的分享,那麼除了上述的環境變量外,還需要用到小程式的 button 内置元件來實作分享按鈕。在 Kbone 上可以使用一個特殊的标簽 wx-button 來表示 button 内置元件,在調 Kbone 的仿造 Dom 接口時會将其 wx- 字首的标簽識别成内置元件,進而進行特殊處理。
整個社群小程式的功能完善之後,便要思忖一下代碼體積的問題,因為小程式本身有個 2M 限制。縮減代碼體積的方式大家應該都了解了很多了,如:壓縮混淆、代碼分割和公共代碼複用、tree shaking、使用分包等等。
還有就是考慮到小程式端是直接複用 Web 端代碼,但是并不是所有 Web 端代碼都需要在小程式端做到,那麼在處理子產品依賴時可以做點手腳。因為都使用的 Webpack 建構,是以可以編寫一個 loader,在 import/require 的時候追加上,它可以根據前面注入的環境變量來判斷要不要将代碼進行打包。
這樣就可以很友善地指定哪些代碼不要建構到小程式端。
整體實作出來的效果如下,左邊是 H5 端,右邊是小程式端:
四、總結
這一整套方案的實作和應用大緻如此,其原理并不算複雜,隻是用了另一種思路來實作。目前這一套方案即名為 Kbone,現已整理并開源到 GitHub 上:https://github.com/wechat-miniprogram/Kbone。
考慮到這個方案本身是通過最底層的适配方式來完成同構,那麼除了 Vue 外,它其實也可以很輕松地移植到其他的 Web 架構上,比如 React、Preact、Omi 等,下面是一些基于這些架構的簡單 demo:
在上述 GitHub 倉庫内也可以找到這些架構的 demo,盡管各個 Web 架構的實作、文法都有所不同,但畢竟其本質上是相同的,最終都會轉化為 Dom 接口調用來渲染頁面。
也正因如此,可以看到 Kbone 這套方案最大的優勢:擴充性強、對各個特性的支援全面、對代碼編寫的要求少以及自由度高、不需要魔改 Web 架構的底層實作,這樣對于代碼的維護、更新也都更為簡單友善。