一、背景介紹
【資料圖】
小程序在其誕生后的幾年內,憑借其簡單、輕量、流暢、無需安裝等特點,引來了爆發式的增長。伴隨小紅書電商業務的發展,我們洞察到越來越多的商家和品牌大客戶有自己定制化需求場景,傳統的電商和薯店存在下面三大問題:
為了解決上述問題,并快速打通基于小紅書體系的支付與賬號體系。過去的一年內,我們踏上了自研小程序之路。目前,在小紅書店鋪主頁、 筆記詳情、品牌專區、開屏均可喚起小程序。
本文將主要介紹小紅書進行小程序自研時的一些業務背景及工程化、容器能力的落地方案,以及運行時針對雙線程架構 bridge,framework 能力的設計。
二、運行時工程能力建設
2.1 小程序 "運行時" 定義
運行時在不同語言中含義有所不同,但基本可以概括為「運行在代碼執行階段的代碼」,類似 vue-runtime, 提供了對于頁面狀態的劫持,生命周期的解析,api 的調用能;nodejs 提供了 JS 運行時執行能力等。小程序 “運行時” 則提供了在不同線程內,借助 Bridge 消息通道,進行邏輯調度的能力。
那么可以基本概括為:運行在小程序代碼執行階段、用于提供在獨立線程中操作其他線程的頁面(或視圖),正確響應用戶交互行為、并調度用戶業務邏輯能力的代碼。
2.2 小程序基礎架構
小紅書小程序也是對齊業界經典架構進行建設:
經典雙線程架構
經典架構下,運行時主要分為 渲染層 - Render、邏輯層 - Service。Service 用于與系統能力進行交互,在安全的 JS 線程內調度用戶業務邏輯。而 Render 則負責接受渲染指令、進行視圖的繪制與用戶交互的響應。邏輯層與渲染層則通過 js-bridge 進行消息的通訊,容器則負責接受 api 指令進行端能力的調用。
之所以需要一個獨立的線程來執行 JS,其主要目的是為了限制 JS 靈活性。為了提供一個可用的 JS 環境,其實也有比較多的方案。比如,我們可以使用瀏覽器內核提供的 Service Worker ,來單獨運行 service 層 JS 代碼。或者我們可以使用多個 webview 實例來分別承載雙端 js 的執行環境。
2.3 容器架構實現
按照經典架構的設計,我們需要在三端 (iOS、android、小程序開發者工具) 提供面向雙線程的容器方案。在不同的容器環境下,渲染層和邏輯層選用的方案會存在一定差異,小紅書三端選用容器的分布如下:
雖然運行環境存在一定差異,但容器對于基礎庫和業務腳本的加載順序是基本一致的。我們可以將整個啟動階段拆解為下面幾個階段:
首先,當用戶點擊時,會經歷一個基本的啟動過程。
在這個啟動流程的背后,會對應著上面提到渲染層 webview 容器被加載出來。于此同時,在用戶看不到的地方,容器會進行邏輯層 v8/JsCore 的初始化, 同時會加載小程序的基礎庫代碼。
腳本注入結束后,容器會立即通知「運行時邏輯層框架」進行依賴分析、并準備初渲染數據。
渲染層接受到 initialData 消息后,會進行后續渲染操作,用戶即刻看到了頁面的內容 至此,初渲染的流程基本結束。
當然,實際的容器的啟動過程中的流程會更加復雜,整個啟動流程可以用下面的這張圖來表示:
黑色、藍色、橙色分別代表了 端側、邏輯層、渲染層三個線程
實際場景中,容器還面臨更多的挑戰,比如如何確保雙線程的是否 ready,再進行消息的推送等。核心在于,我們通過不同線程的容器,完成了頁面渲染行為的控制。
可以看到,上述啟動流程中容器側分別在 Render 和 Service 分別注入了 page.render.js 和 service.js 的業務代碼。那么如何進行業務代碼構建,來分別在雙線程下執行呢?這就需要依靠前端工程化的能力來實現了。
2.4 實現基礎架構的工程化能力
通過前端工程化能力,我們可以對資源進行分類構建,小紅書小程序使用 webpack 作為工程化構建工具。通常,小程序的構建分兩塊,一塊是針對基礎庫的打包,一塊是針對業務組件的構建。
基礎庫的打包需要構建基礎庫代碼,產出分別用于提供運行時框架能力的render.base.js及service.base.js
而業務組件的構建,則相對復雜。一個原生小程序組件或頁面通常包含下面四個文件:
通過拆分多個文件,我們可以在構建時指定入口依賴,將對應的依賴打入所需要的模塊內,在工程構建時,需要對文件進行分類打包:
我們使用 loader 作為 webpack 的 entry 入口進行構建,每個頁面都會作為一個 entry 獨立打包。這使得從行為上來說小程序更像一個 MPA(多頁應用)。入口側會進行 app.json 的校驗,對配置以頁面維度來進行解析,針對小程序業務代碼,會分別構建出 page.render.js 和 service.js 分別交給不同的線程進行加載(如上圖)。
構建會將代碼打包成 UMD 格式文件,當在不同線程內執行基礎庫腳本時,部分腳本會自動執行,端側只需要關注容器加載 Js腳本的時機及消息發送的順序即可。
運行時基礎能力與框架
容器和工程化能力是小程序運行的基石,但小程序之所以可以做到高效開發、并擁有極強的跨平臺能力和優秀的體驗,這也得益于在框架底層提供了完善的組件及模塊化能力,更有豐富的 api 來滿足原生場景下各種系統能力調用的述求。
3.1 運行時總架構
·這張圖主要將運行時架構分為了渲染層、邏輯層和jsBridge:
·渲染層面向業務提供了組件、沙箱、性能收集等框架能力,這一層業務是無法接觸到的
·邏輯層則在 JsContext 內提供了 invoke 層來與端側進行數據交互
·邏輯層通過適配層,完成導航、Render和頁面實例的管理
·邏輯層內核主要用于向業務代碼的執行環境,提供 Page、Component、behavior ·這類能力,并預置 JS Polyfill 來確保業務的 js 正常運行。
·JSON schema 則用于定義 api 標準結構和定義,并通過 js-bridge 層完成端能力調用與通訊
3.2 基礎能力分布
為了豐富小程序的基礎能力,初期我們盤點了業界的功能矩陣,盡可能豐富小紅書小程序的基礎能力,目前運行時的基礎能力分布如下:
灰色部分為暫未支持的能力
其中包含了:
·App, Compnent, Page 等基礎能力
·網絡、文件系統,設備等 API 能力
·xhs-view, xhs-button 等面向業務的組件能力
·目前,矩陣列出的功能,在小紅書小程序基礎庫 ≥ v3.32.x 版本上已經得到支持。
3.3 雙線程框架能力建設
熟悉小程序語法的同學都知道,小程序可以通過 Page、Component 來進行非常靈活的組件化開發。通過 selectComponent 、triggerEvent這類功能可以非常方便的進行 子 → 父 或 父 → 子 實例的追溯,這就要求框架側需要維護組件之間的依賴關系。
實現這種架構有多種思路,不同廠商的做法也不同。譬如微信在 Page 體系和 Component 自定義組件的實現上就采用了不同的設計。微信在渲染側通過Exparser模塊完成小程序內的所有組件,包括內置組件和自定義組件組織管理。
小紅書側在渲染層則是 Fork Vue 框架,通過定制 Vue 的一些能力來完成頁面渲染工作。借助 Vue優秀的組件化能力的來完成 Page, Component 的渲染工作。在邏輯層,則通過消息維護一棵類 vdom 樹 , 來完成視圖 ←→ 邏輯的映射與綁定關系,整個關系大概如下圖所示:
3.4 事件系統
有了上述基礎能力和雙線程架構,運行時還需要實現一套事件系統,讓 UI 界面與用戶產生互動。事件通常分為兩塊,一塊是服務于用戶的手勢交互,比如用戶的點擊 tap, 長按 longtap 等事件,另一塊則是渲染層交互組件的回調時間,譬如 swiper 組件的 onChange 等回調。
在小程序的事件系統下,我們把這些用戶的手勢操作和組件回調,進行攔截與收集,全部轉入消息隊列轉發到邏輯線程。每條消息攜帶自己的實例 ID,找到邏輯層實例進行對應函數的觸發。
3.5 bridge 能力設計
框架側借助 bridge 通道可以非常方便進行消息的轉發。但實際上,一條消息需要經過多次序列化和反序列化,才可以到達“目的地”。小紅書小程序的 bridge 側是如何實現的呢?
我們以渲染層事件消息舉例,當渲染層收到一條點擊 消息,會經過如下幾個階段:
不同容器下,對 webview 內核消息的攔截機制不同,ios 使用 messageHandler, android 則使用 console 通道攔截消息,但內核底層對消息的處理流程基本一致。
這個過程可以簡單描述為以下幾個環節:
·Render 側發送 postMessage 消息,此時消息需要經過一次序列化轉成字符串
·瀏覽器攔截到消息,反序列化成 JSONObject 并發送到 Naive 容器側
·容器開始進行跨線程事件分發,并轉發消息到 service
·Service 運行環境將消息反序列化成 string,并轉成 JS 數據類型,傳到 Service 所在的 JsContext 中
·JsContext 中 invokeCallback 函數被調用
·至此,render 消息已成功轉發至 service 層
可以看到,這個過程非常復雜,不僅要完成消息的轉發,還要完成 jsonObject 和 js 數據類型的互轉。為了在兩個線程內方便的完成這種互調,并保證 bridge 的安全線,我們分別在雙端分別實現了 handleMessage 和 postMessage 的封裝,通過 schema 來定義 bridge 和 api 標準協議,來完成線程消息的轉發和消息類型的校驗工作。
一個標準的 api schema 定義大概是這樣:
消息會在 JsContext 內完成校驗,并在校驗通過后以序列化的方式完成上述流程的傳遞。
3.6 數據編譯能力與 JS 沙箱
為什么這里要提下數據編譯能力和 js 沙箱呢。因為小程序雙線程的框架下,邏輯層通過setData 發起頁面更新請求,攜帶的數組字段在被渲染層對應的組件解析時,需要配合小程序的一些語法特性進行特殊轉換。
在運行時側,我們將字段的解析能力與數據字段的處理,都收攏到沙箱 環境中進行字段編譯。通過沙箱,我們可以攔截業務代碼對于變量的訪問,從而實現變量的劫持,并配合完成 sjs 這類能力的實現。同時,沙箱可以有效防止業務動態注入一些變量或函數,帶來的變量訪問逃逸的安全問題。因此,沙箱在小程序語法和變量計算的過程中起到了至關重要的作用。
例如,下面這段代碼片段:
在編譯側,我們會將 loader上面代碼通過 ast 進行轉換:
通過沙箱,我們可以攔截到業務對 sjs 模塊訪問,將訪問屬性替換為 sjs 的模塊導出,從而實現類似 sjs 這樣的腳本拓展能力。
性能優化與監控
雙線程在線程隔離方案上,將原本在同一線程內執行的腳本、渲染等工作分散到多個線程內執行,帶來了更好的性能。但如果單個 webview 線程的渲染負擔過重或對設備內存占用過大一樣會影響到整體的體驗。
于此同時,線程隔離也帶來了通訊的損耗,對于一次消息需要經過多次序列化和反序列化,消息序列化的損耗與轉發也對性能有著直接的影響。
因此,小程序的性能優化不同于傳統的 web,需要從框架、通道、容器三方面來考慮。
4.1 bridge 消息調度機制
bridge 消息通道的繁忙程度,會在很大程度上影響小程序的性能表現。通過上面對于 bridge 消息轉發機制的介紹也可以看出,頻繁的借助 bridge 進行消息轉發,意味著消息要不斷進行序列化和反序列化的操作。
實際場景中,數據量小于64KB時,時長基本在10 - 40ms內。傳輸時間與數據量上呈現正相關關系,傳輸過大的數據將使這一時間顯著增加,因此減少傳輸數據量是降低數據傳輸時間的有效方式。
但是如果數據量較小,確在短時間內多次使用 bridge ,也會導致通道過于繁忙。小紅書在 bridge 側,通過一定消息調度能力,將特定場景下的消息進行聚合,確保一次序列化盡可能在不影響序列化性能的情況下,多攜帶一些消息到對應的線程內。
4.2 渲染層任務調度與優先級隊列
前面我們曾經提到,雙線程背景下,小程序的更新機制與事件系統全部都是通過消息進行處理的,但消息本身的收發都存在一定的延時性,這就注定了小程序是一個異步通訊的世界。那么在一個異步多線程的場景下,線程之間“生產“和“消費“的消息的速度會因性能、穩定性等因素而不一致,這時,我們便要借助消息隊列的思想來管理我們的消息:
有了消息隊列,我們可以更好的管理框架層拋出的消息體,但小程序框架內,除了更新消息和事件消息外,還有不同的消息體會與這些框架消息搶占消息通道。比如,框架收集不同的 render 線程 webview 內的性能指標,這些性能消息會與事件消息共享同一隊列。但有些場景下,事件消息的優先級要遠高于性能指標消息。
此外,不同的渲染層 render 實例的消息所擁有的優先級也不同,比如 A、B 頁面在同一時間段內,因其“棧頂的地位”會因用戶操作而不斷變化,此時棧頂頁面的框架消息優先級高于 B 頁面的框架消息優先級,在底層。我們使用 二叉堆 結構來維護優先級隊列。
4.3 容器預加載
小程序的啟動分為冷啟動和熱啟動, 從用戶的角度看:
冷啟動:如果用戶首次打開,或小程序銷毀后被用戶再次打開,此時小程序需要重新加載啟動,即冷啟動。
熱啟動:如果用戶已經打開過某小程序,然后在一定時間內再次打開該小程序,此時小程序并未被銷毀,只是從后臺狀態進入前臺狀態,這個過程就是熱啟動。
通常在容器側的優化,就是針對冷啟動來進行。那么容器的預載,顧名思義,就是在合適的時間提前預載小程序容器,預載的同時,會提前進行基礎庫的下載和渲染容器(webview)的加載。
通過前置容器的初始化時機,來達到快速換起小程序,提高首屏的優化效果。這是小程序這類容器技術方案常用的優化策略。
4.4 性能監控與告警
性能優化的同時,框架側需要對業務代碼的性能和行為有一定感知能力。在底層,我們通過 aop 的方式,建設了一套監控和插件機制。在開發階段,可以感知到業務各項指標的健康狀況,業務可以接收到底層框架給出的性能告警信息,并通過告警信息中的修復建議,針對性的進行優化。
業務側,則可以通過 performance api 拿到這些性能指標,來進行基礎性能數據的收集與上報。
性能告警會結合性能標準閾值來給出提示和修復建議,未來在審核階段也會結合這些指標進行小程序健康度的洞察。
4.5 ServiceTiming 與 RenderTiming
除了卡頓、渲染指標外,為了滿足高級開發者洞察平臺的性能信息的需要,我們對容器和框架在啟動階段的關鍵節點,都預留了性能點位。開發者可以通過 performance.serviceTiming和performance.renderTiming 來分別獲取到各個關鍵階段的時間戳信息。
各個線程內所預留的性能點位和其在啟動階段中的位置如下圖所示:
五、總結
以上就是小紅書小程序運行時方案的原理解析。
小程序本身是一個依托宿主流量體系衍生出的技術體系,它的價值往往緊貼應用主體的流量,而應用主體本身,又依賴小程序的靈活性及低成本的特點快速完成流量的轉換。社交、支付與搜索,這些都是互聯網產品提供的服務形態,各大廠商都是結合用戶的需求和行為差異進行更開放、安全的技術方案探索,小紅書亦是如此。小紅書依靠用戶產生內容,而內容產生商品,那么結合各類消費場景,如店鋪、筆記等都可以通過小程序容器快速進行交易鏈路閉環。
未來,我們也將在不同的品牌和賽道上,尋找更多的服務商與品牌大客戶商家與我們一起,共同豐富小紅書的商品服務供給,增加小紅書商業收入。技術上,我們則會不斷對齊業界,優化技術架構,在提高框架性能的同時,建立完善的服務市場、巡檢機制等來幫助小紅書服務商與自開發商家細致、高效的開發與管理自己的小程序。
小程序是一個比較龐大的技術體系,如果你覺得本文對你有幫助,歡迎點贊轉發。我們后續會根據反饋繼續展開介紹更多的技術建設細節。也歡迎訪問我們的小程序官方網站與我們交流:
·小紅書小程序介紹:https://miniapp.xiaohongshu.com/docs/guide/miniIntroduce
·小紅書社區專業號:https://pro.xiaohongshu.com/
·你也可以通過 https://github.com/redengineer/redmini 與我們進行交流
六、作者信息
哈笛
商業技術組 - 小程序團隊成員(tailiang@xiaohongshu.com),目前負責小紅書運行時相關技術開發工作。