从客户端角度窥探小程序架构

5,935 阅读20分钟

目录

一、说在前面

二、从微信小程序的发展史说起

三、微信小程序原理分析

  • 快速加载和原生的体验
  • 渲染层
  • 预加载
  • 基础库内部优化
  • 注入小程序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 资源而不需要再从服务端拉取,从而减少网页的加载时间。关于离线包的概念,不了解的话可以参考《web离线技术原理》 。但是当页面加载大量 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,你会怎样设计它们呢?