從客戶端角度窺探小程序架構

NO IMAGE

目錄

一、說在前面

二、從微信小程序的發展史說起

三、微信小程序原理分析

  • 快速加載和原生的體驗
  • 渲染層
  • 預加載
  • 基礎庫內部優化
  • 注入小程序WXML結構和WXSS樣式
  • 邏輯層

四、看看JavaScriptCore是怎麼執行JS腳本的

五、再說說支付寶小程序

  • 運行時架構
  • 小程序SDK

六、最後

一、說在前面:

小程序自誕生以來。就以一種百家爭鳴的姿態展現在開發者的面前。繼2017年1月9日微信小程序誕生後,小程序市場又陸續出現了支付寶小程序、頭條小程序、百度智能小程序等等,甚至平安內部,也在發展自己的小程序生態。各家都在微信小程序的基礎上,面向自己的業務,對架構進行逐步優化調整,但是萬變不離其宗,微信小程序終歸為小程序鼻祖,也是得益於微信小程序的思想,才造就瞭如今這百花齊放的業態。說起微信小程序,在體驗上的優化,讓我很長一段時間認為,這是 Native 層渲染。事實並不完全是,至今不敢相信,webView 的渲染竟能帶來如此體驗。本篇主要以一個客戶端開發者的角度,來對微信小程序、支付寶小程序一探究竟。本篇旨在原理分析,我並未有真實的小程序架構設計經驗。

說到小程序,不得不需要指出另外一個問題,蘋果爸爸對於 HTML5 app 的更新的審核問題,目前會有開發者存在這樣的疑問,Hybrid 和 H5 是不是要被蘋果拒審了呢?其實從更新描述來看,不難發現蘋果的主要目的是針對“核心功能未在二進制文件內”的 App ,實際上小程序無論是在設計理念上,還是核心技術上,都不存在這樣的問題,小程序並非App,小程序是以 App 為載體,儘可能的對 web 頁面進行優化而生成的產物。還有一點是馬甲包日益猖獗,馬甲包最後基本都轉化成為了條款內描述的“現金Bocai、彩票抽獎和慈善捐款”類型,所以蘋果想要儘可能的禁止它。而且從微信小程序開發文檔來看,微信小程序是典型的技術推動產品的結果。關於RN類技術,更不存在這樣的問題了,RN本質為 JS 通過 JSCore 調用 Native 組件。實際上它的核心仍然在 Native 端,當然對 code push 我還尚存疑問。關於 RN 的動態更新上,從bang’s的描述也不難發現蘋果爸爸的態度,只要不是為了繞過審核去做動態更新就可以接受

二、從微信小程序的發展史說起

微信小程序是什麼,微信把小程序定義為是一種全新的連接用戶與服務的方式,它可以在微信內被便捷地獲取和傳播,同時具有出色的使用體驗。便捷和出色有何而來?小程序技術最初來源於 H5 和 Native 間的簡單調用,微信構建了一個 WeixinJSBridge 來為H5提供一些 Native 的功能,例如地圖、播放器、定位、拍照、預覽等功能。關於 Bridge 的具體實現可以參考《寫一個易於維護使用方便性能可靠的Hybrid框架》。但是微信逐漸的又遇到了另外一個問題,那就是 H5 頁面的體驗問題,微信團隊為了解決 H5 頁面的白屏問題,他們引入了最近很火的離線包概念,當然微信稱之為微信 Web 資源離線存儲,實際上是一個東西。Web 開發者可藉助微信提供的資源存儲能力,直接從微信本地加載 Web 資源而不需要再從服務端拉取,從而減少網頁的加載時間。關於離線包的概念,不瞭解的話可以參考《h5離線技術原理》
。但是當頁面加載大量 CSS 和 JS 時,依然會有白屏問題,包括 H5 頁面點擊事件的遲鈍感和頁面跳轉的體驗問題。那麼基於此問題,應運而生的,小程序技術就誕生了。

從微信小程序的發展史,不難看出,小程序實際上是近幾年開發者對 H5 體驗優化而來的,這也切合了前面所說的,小程序實際上是典型的技術推動產品的結果

三、微信小程序原理分析

微信小程序自稱能夠解決以下問題:

  • 快速的加載。
  • 更強大的能力。
  • 原生的體驗。
  • 易用且安全的微信數據開發。
  • 高效和簡單的開發。

快速加載和原生的體驗,這其實都是在體驗上的升級,更強大能力實際上源於微信小程序為開發者提供了大量的組件,這些組件有基於web技術,也有基於Native技術,在我看來這和 RN 技術不謀而合。後面我會舉一個模仿 RN 實現的小例子來闡述一下它的原理。

高效和簡單的開發是因為微信小程序開發語言實質上還是基於 web 開發規範,這使得開發前端的人來開發小程序顯得更容易。

還有一點更重要的就是安全,為什麼說小程序是安全的?後面會逐步展開,揭開小程序的神祕面紗。

快速加載和原生的體驗

小程序的架構設計與 web 技術還是有一定的差別,吸取了 web 技術的一些優勢,也摒棄了 web 技術中體驗不好的地方。最主要的特點就是小程序採用雙線程機制,即視圖渲染和業務邏輯分別運行在不同的線程中。在傳統的 web 開發中,網頁開發渲染線程和腳本線程是互斥的,所以 H5 頁面中長時間的腳本運行可能會導致頁面失去響應或者白屏,體驗糟糕。

為了更好的體驗,將頁面渲染線程和腳本線程分開執行:

  • 渲染層:界面渲染相關的邏輯全部 在webView 線程內執行,一個小程序存在多個頁面,一個頁面對應一個 webView,微信小程序限制開發者最多隻能創建五個頁面。
  • 邏輯層:Android採用 JSCore ,iOS採用的 JavaScriptCore 框架運行 JS 腳本。怎麼在 JavaScriptCore 運行腳本文件後面會講。

雙線程模型是小程序框架與大多數前端 web 框架的不同之處,基於這個模型可以更好的管控以及提供更安全的環境。因為邏輯層運行在 JSCore 中,並沒有一個完整瀏覽器對象,因而缺少相關的DOM API和BOM API。客戶端的開發者可能對 DOM 有些陌生,瞭解編譯過程的同學應該知道在編譯器編譯代碼的時候,會有一個語法分析的過程,生成抽象語法樹 AST,編譯器會根據語法樹去檢查表達式是否合法、括號是否匹配等。實際上DOM也是一種樹結構,經過瀏覽器的解析,最終呈現在用戶面前。通過 JavaScript 操縱 DOM 可以隨意改變元素的位置,這對於小程序來說是極為不安全的。所以說邏輯層為小程序帶來的另一個特點,易於管控和安全。線程通信基於前面提到的 WeixinJSBridge :邏輯層把數據變化通知到視圖層,觸發視圖層頁面的更新,視圖層把觸發的事件通知到邏輯層進行業務處理。

從客戶端角度窺探小程序架構

當我們對渲染層進行事件操作後,會通過 WeixinJSBridge 將數據傳遞到 Native 系統層。Native 系統層決定是否要用 Native 處理,然後丟給 邏輯層進行用戶的邏輯代碼處理。邏輯層處理完畢後會將數據通過 WeixinJSBridge 返給 View 層,View 渲染更新視圖。

渲染層

根據《微信小程序開發者文檔》描述,在視圖層內,小程序的每一個頁面都獨立運行在一個頁面層級上。小程序啟動時僅有一個頁面層級,每次調用wx.navigateTo,都會創建一個新的頁面層級;相對地,wx.navigateBack會銷燬一個頁面層級。大概可以理解為,每個 web 頁面都是運行在單獨的 webView 裡面,這樣的好處就是讓每個 webView 單純的處理當前頁面的渲染邏輯,不需要加載其他頁面的邏輯代碼,減輕負擔能夠加速頁面渲染,使其能夠儘可能的接近原生,這與小程序跳轉頁面的體驗上也是一致的。

實際上在小程序源碼內有一個 index.html 文件的存在,這是小程序啟動後的入口文件。初次加載的時候,主入口會加載相應的 webView ,這其中就會包括前面所提到的,視圖層和邏輯層。邏輯層雖然也提供了 webView ,但是並不提供瀏覽器相關接口,而是單純的為了獲取當前的 JSCore ,執行相關的 JS 腳本文件,這也是開發小程序是沒辦法直接操作 DOM 的根本原因。

當我們每打開一個新頁面的時候,調用 navigateTo 都相當於打開了一個新的 webView ,這樣一直打開,內存也會變得吃緊,這也是為什麼小程序對頁面打開數量有限制的原因了。

預加載

根據小程序開發文檔描述:對於每一個新的頁面層級,視圖層都需要進行一些額外的準備工作。在小程序啟動前,微信會提前準備好一個頁面層級用於展示小程序的首頁。除此以外,每當一個頁面層級被用於渲染頁面,微信都會提前開始準備一個新的頁面層級,使得每次調用wx.navigateTo都能夠儘快展示一個新的頁面。這在客戶端的角度來看,相當於打開新頁面之後,對下一個頁面的 webView 提前做了預加載,這個思路與當前比較流行的 webView 緩存池的思路不謀而合,原因是在 iOS 和 Android 系統上,操作系統啟動 webView 都需要一小段時間,預加載會提升頁面打開速度,優化白屏問題。

基礎庫內部優化

再往深層次來看,通過小程序開發工具的源碼,能找到一個 pageframe.html 的模版文件,具體位置在package.nw/html/pageframe.html

從客戶端角度窺探小程序架構

看標題就應該很清楚了,這是渲染層的核心模塊,它的作用就是為小程序準備一個新的頁面,小程序每個視圖層頁面內容都是通過 pageframe.html 模板來生成的,包括小程序啟動的首頁。通過查看源碼,裡面定義了一個屬性var __webviewId__,我猜想這是每個 webView 頁面的頁面 ID ,邏輯層處理多個視圖層間的業務邏輯可能就是通過這個ID來做的映射關係。在首次啟動時,後臺會緩存生成的 pageframe.html 模版,在後面的頁面打開時,直接加載緩存的 pageframe.html 模版,頁面引入的資源文件也可以直接在緩存中加載,包括小程序基礎庫視圖層底層、頁面的模版信息、配置信息以及樣式等內容,這樣避免重複生成,快速打開頁面,提升頁面渲染性能。

注入小程序WXML結構和WXSS樣式

關於 pageframe.html 最後是怎麼生成相應頁面的歸功於一個叫 nw.js 的框架,具體實現這裡就不講了(主要是我不會)。

邏輯層

上面瞭解了渲染層都做了什麼之後,下面在窺探一下,小程序的邏輯層都做了什麼。參考eux.baidu.com/blog/fe/微信小…不難發現,sevice 層的代碼是由 WAService.js 實現的,邏輯層實際上主要提供了 Page, App,GetApp 接口和更為豐富 wx 接口模塊,包括數據綁定、事件分發、生命週期管理、路由管理等等。關於視圖層和邏輯層間的具體交互細節可以看下這張圖:

從客戶端角度窺探小程序架構

我們寫的頁面邏輯最後都被引入到了一個叫 appservice.html 的頁面中,並且分別從 app.js 開始一一執行;小程序代碼調用 Page 構造器的時候,小程序基礎庫會記錄頁面的基礎信息,如初始數據(data)、方法等。需要注意的是,如果一個頁面被多次創建,並不會使得這個頁面所在的JS文件被執行多次,而僅僅是根據初始數據多生成了一個頁面實例(this),在頁面 JS 文件中直接定義的變量,在所有這個頁面的實例間是共享的。對於邏輯層,從客戶端的角度看,我們應該更關注於邏輯層的JS是怎麼注入到JSCore中的。

四、看看JavaScriptCore是怎麼執行JS腳本的

說到JavaScriptCore,我們先來討論下Hybrid App 的構建思路,Hybird App是指混合模式移動應用,即其中既包含原生的結構又有內嵌有 Web 的組件。這種 App 不僅性能和用戶體驗可以達到和原生所差無幾的程度,更大的優勢在於 bug 修復快,版本迭代無需發版。Hybird App的實質並沒有修改原生 Native 的行為,而是將下發的資源進行加載和界面渲染,類似 WebView。下面通過一個例子來模擬一下 JavaScriptCore 執行 JS 腳本來讓 Native 和 JS 之間的通信。關於 JavaScriptCore 的具體使用可以參考下戴銘的《深入剖析 JavaScriptCore》

我們打算實現這樣的功能:通過下發JS腳本創建原生的 UILabel 和 UIButton 控件並響應事件,首先編寫 JS 代碼如下:

(function(){
console.log("ProgectInit");
//JS腳本加載完成後 自動render界面
return render();
})();
//JS標籤類
function Label(rect,text,color){
this.rect = rect;
this.text = text;
this.color = color;
this.typeName = "Label";
}
//JS按鈕類
function Button(rect,text,callFunc){
this.rect = rect;
this.text = text;
this.callFunc = callFunc;
this.typeName = "Button";
}
//JS Rect類
function Rect(x,y,width,height){
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
//JS顏色類
function Color(r,g,b,a){
this.r = r;
this.g = g;
this.b = b;
this.a = a;
}
//渲染方法 界面的渲染寫在這裡面
function render(){
var rect = new Rect(20,100,280,30);
var color = new Color(1,0,0,1);
var label = new Label(rect,"這是一個原生的Label",color);
var rect4 = new Rect(20,150,280,30);
var button = new Button(rect4,"這是一個原生的Button",function(){
var randColor = new Color(Math.random(),Math.random(),Math.random(),1);
TestBridge.changeBackgroundColor(randColor);
});
//將控件以數組形式返回
return [label,button];
}

創建一個 OC 類 TestBridge 綁定到 JavaScriptCore 全局對象上:

@protocol TestBridgeProtocol <JSExport>
- (void)changeBackgroundColor:(JSValue *)value;
@end
@interface TestBridge : NSObject<TestBridgeProtocol>
@property(nonatomic, weak) UIViewController *ownerController;
@end
#import "TestBridge.h"
@implementation TestBridge
- (void)changeBackgroundColor:(JSValue *)value{
self.ownerController.view.backgroundColor = [UIColor colorWithRed:value[@"r"].toDouble green:value[@"g"].toDouble blue:value[@"b"].toDouble alpha:value[@"a"].toDouble];
}
@end

在 ViewController 中實現一個界面渲染的 render 解釋方法:

#import "ViewController.h"
#import <JavaScriptCore/JavaScriptCore.h>
#import "TestBridge.h"
@interface ViewController ()
@property(nonatomic, strong)JSContext *jsContext;
@property(nonatomic, strong)NSMutableArray *actionArray;
@property(nonatomic, strong)TestBridge *bridge;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
//創建JS運行環境
self.jsContext = [JSContext new];
//綁定橋接器
self.bridge =  [TestBridge new];
self.bridge.ownerController = self;
self.jsContext[@"TestBridge"] = self.bridge;
self.actionArray = [NSMutableArray array];
[self render];
}
-(void)render{
NSString * path = [[NSBundle mainBundle] pathForResource:@"main" ofType:@"js"];
NSData * jsData = [[NSData alloc]initWithContentsOfFile:path];
NSString * jsCode = [[NSString alloc]initWithData:jsData encoding:NSUTF8StringEncoding];
JSValue * jsVlaue = [self.jsContext evaluateScript:jsCode];
for (int i=0; i<jsVlaue.toArray.count; i++) {
JSValue * subValue = [jsVlaue objectAtIndexedSubscript:i];
if ([[subValue objectForKeyedSubscript:@"typeName"].toString isEqualToString:@"Label"]) {
UILabel * label = [UILabel new];
label.frame = CGRectMake(subValue[@"rect"][@"x"].toDouble, subValue[@"rect"][@"y"].toDouble, subValue[@"rect"][@"width"].toDouble, subValue[@"rect"][@"height"].toDouble);
label.text = subValue[@"text"].toString;
label.textColor = [UIColor colorWithRed:subValue[@"color"][@"r"].toDouble green:subValue[@"color"][@"g"].toDouble blue:subValue[@"color"][@"b"].toDouble alpha:subValue[@"color"][@"a"].toDouble];
label.textAlignment = NSTextAlignmentCenter;
[self.view addSubview:label];
}else if ([[subValue objectForKeyedSubscript:@"typeName"].toString isEqualToString:@"Button"]){
UIButton * button = [UIButton buttonWithType:UIButtonTypeSystem];
button.frame = CGRectMake(subValue[@"rect"][@"x"].toDouble, subValue[@"rect"][@"y"].toDouble, subValue[@"rect"][@"width"].toDouble, subValue[@"rect"][@"height"].toDouble);
[button setTitle:subValue[@"text"].toString forState:UIControlStateNormal];
button.tag = self.actionArray.count;
[button addTarget:self action:@selector(buttonAction:) forControlEvents:UIControlEventTouchUpInside];
[self.actionArray addObject:subValue[@"callFunc"]];
[self.view addSubview:button];
}
}
}
-(void)buttonAction:(UIButton *)btn{
JSValue * action  = self.actionArray[btn.tag];
[action callWithArguments:nil];
}
@end

這樣就完成了一個簡單的 JS 腳本注入,實際上執行後的樣子是這樣的:

從客戶端角度窺探小程序架構

這就是一個簡單的執行 JS 腳本的邏輯,實際上 ReactNative 的原理也是基於此,小程序邏輯層與微信客戶端的交互邏輯也是基於此。

到這裡,關於微信小程序渲染層與邏輯層做了什麼、怎麼做的、優化了什麼以及為什麼要採用這樣的架構來設計,基本都梳理完畢了。小程序這樣的分層設計顯然是有意為之的,它的中間層完全控制了程序對於界面進行的操作, 同時對於傳遞的數據和響應時間也做到的監控。一方面程序的行為受到了極大限制, 另一方面微信可以確保他們對於小程序內容和體驗有絕對的控制。我們在小程序的 JS 代碼裡面是不能直接使用瀏覽器提供的 DOM 和 BOM 接口的,這一方面是因為 JS 代碼外層使用了局部變量進行屏蔽,另一方面即便我們可以操作 DOM 和 BOM 接口,它們對應的也是邏輯層模塊,並不會對頁面產生影響。這樣的結構也說明了小程序的動畫和繪圖 API 被設計成生成一個最終對象而不是一步一步執行的樣子, 原因就是 json 格式的數據傳遞和解析相比與原生 API 都是損耗不菲的,如果頻繁調用很可能損耗 過多性能,進而影響用戶體驗。

總結一句話就是webView渲染,JSCore處理邏輯,JSBridge做線程通信。後面再簡要的分析下支付寶小程序,支付寶小程序屬於後起之秀,支付寶小程序在微信小程序的基礎上,做了一些優化,單從技術角度來看,有點後來者居上的意思。目前支付寶技術通過官方的媒體賬號對外暴漏的一些實現細節也在逐步增多。

六、再說說支付寶小程序

前端框架下面是小程序 native 引擎,包括了小程序容器、渲染引擎和 JavaScript 引擎,這塊主要是把客戶端 native 的能力和前端框架結合起來,給開發者提供系統底層能力的接口。在渲染引擎上面,支付寶小程序不僅提供 JavaScript+Webview 的方式,還提供 JavaScript+Native 的方式,在對性能要求較高的場景,可以選擇 Native 的渲染模式,給用戶更好的體驗。

這段文字來源於支付寶對外開放的技術博客的描述,從這段描述中,我們能夠發現支付寶小程序在架構設計上同樣採用的渲染引擎加 JavaScript 引擎兩部分,包括頁面間的切換實際上和微信小程序邏輯基本一致。下面這張是支付寶小程序應用框架的架構圖:

運行時架構

從客戶端角度窺探小程序架構

單從這個運行時架構來看,它與微信小程序不同的地方是,webView 頁面也就是渲染層通過消息服務直接與邏輯層進行通訊,而不需要像微信的 JSBridge 那樣作為中間層,消息服務具體實現細節目前尚不得知。支付寶的JSBridge只會與邏輯層進行通訊,來給小程序提供一些 Native 能力。支付寶的這種架構主要目的是解決渲染層與邏輯層交互的對象較複雜、數據量較大時,交互的性能比較差的問題。支付寶小程序的設計思路比較值得借鑑,微信小程序線程間的通訊是通過 JSBridge ,序列化 json 進行傳遞的。支付寶小程序重新設計了V8虛擬機,讓邏輯和渲染都有自己的 Local Runtime,存放私有的模塊和數據。在渲染層和邏輯層交互時,setData 的 對象會直接創建在 Shared Heap 裡面,因此渲染層的 Local Runtime 可以直接讀到該對象,並且用於 render 層的渲染,保證了邏輯和渲染的隔離,又減少了序列化和傳輸成本。當然支付寶還有些其他的優化,包括首頁離線緩存,緩存時機的處理以及閃屏處理等等問題,這裡就不再延伸討論了(因為很多細節我也不知道😂)。

小程序SDK

根據支付寶小程序對外開放的技術文章來看,架構設計還是非常巧妙的,也很值得我們學習,先看圖:

從客戶端角度窺探小程序架構

參考:《獨家!支付寶小程序技術架構全解析》

小程序SDK在架構設計上把它分為了兩部分,一部分是核心庫基礎引擎,一部分是基於基礎庫開發的插件功能。從上往下看:

  • 第一層小程序層,這是小程序開發者使用小程序 DSL 及各種組件開發的代碼層。
  • 第二層和第三層架應該是小程序內部封裝的一些組件和對外提供的相關API等。
  • 第四層和第五層是基於 React 框架,構建的小程序運行基礎框架,這是小程序的核心層,主要包含小程序的邏輯處理引擎及渲染層。支付寶基於 ReactNative 增加了 Native 引擎,可以用原生來渲染 UI 。根據支付寶 mPaaS 的介紹來看,目前支付寶的小程序使用的是 React 版,螞蟻內部的其他 App 有在使用 React Native 版的小程序。
  • 基礎組件部分和擴展能力部分更像是基於 Bridge 調用的原生能力。擴展能力應該是支付寶內部的一些基礎組件,一樣通過JSBridge給小程序進行賦能。

支付寶小程序架構設計上採用分層的設計,邏輯非常清晰。在管控上,和微信小程序基本一致,使用自己的一套 DSL 來保證它的管控能力,編寫小程序只能使用框架提供的自定義的模板樣式,既保證了安全性,又解決了H5開發質量參差不齊的問題。

六、最後

差不多半年多沒有寫文章了,這一年幾乎把所有的精力都撲在了公司的業務上,趁著公司年會時間稍顯充裕,對當前的小程序架構進行了下分析和總結,順便參加下掘金的徵文活動。當然,真正的小程序應該比這還要複雜的多,小程序實際上是多年來大前端融合的一個結果,是一套非常成體系的技術方案,是技術推動產品而產生的概念。看了這麼多我想你對小程序也有了初步認識,小程序的核心實際上還是渲染層邏輯層的構建,那麼如果讓你開發一套小程序SDK,你會怎樣設計它們呢?

掘金年度徵文 | 2019 與我的技術之路 徵文活動正在進行中……

相關文章

開源|AabResGuard:AAB資源混淆工具

SpringBoot系列JPA錯誤姿勢之Entity映射

Python植物大戰殭屍代碼實現:圖片加載和顯示切換

《碼了4個小時》SonarQube+Jenkins代碼質量檢查工具攻略大全