H5 和小程序架构设计
WebView 是跨端领域比较主流的方式,其本质就是在移动端系统中,内嵌的可以用来展示 Web 应用的组件。这让移动端可以像打开浏览器一样打开页面,被称为 Hybrid (混合)模式。
在 WebView 模式下,主流的技术落地有两种:一种是嵌入 H5 的混合 App,另外一种是小程序。这两种方式在渲染流程和通信流程上有一定区别。
在渲染流程中,WebView H5 方案类似于传统的 Web 应用,先由 Native 打开一个 WebView 容器,WebView 就像浏览器一样,打开 WebView 对应的 URL 地址,然后进行请求资源、加载数据、绘制页面,最终页面呈现在我们眼前。
但是,小程序的 WebView 方案有所不同。小程序采用双线程架构,分为逻辑层和渲染层。首先也是 Native 打开一个 WebView 页面,渲染层加载 WXML 和 WXSS 编译后的文件,同时逻辑层用于逻辑处理,比如触发网络请求、setData 更新等等。接下来是请求资源,请求到数据之后,数据先通过逻辑层传递给 Native,然后通过 Native 把数据传递给渲染层 WebView,再进行渲染。
WebView H5 的通信流程也很简单,由 DOM 触发事件,像 Vue 或者 React 构建的 Web 应用会响应事件,然后通过数据驱动,更新视图。
但是在小程序中,触发的事件首先需要传递给 Native,再传递给逻辑层,逻辑层处理事件,再把处理好的数据传递给 Native,最后 Native 传递给渲染层,由渲染层负责渲染。
整个架构模型如下所示:
H5 模式我们再熟悉不过了,所以接下来我们重点介绍小程序逻辑层和渲染层的架构设计。要声明一点,这里的小程序不限于微信小程序,而是市面上小程序普遍的实现方案,包括支付宝小程序、京东小程序、美团小程序等。
逻辑层处理
逻辑层到底长什么样子?
所谓小程序的逻辑层,指的就是我们在小程序 js 文件中写的业务逻辑。它和单页面应用 SPA 类似,不过有一定的差异。我们先来回顾一下 SPA 应用,以 React 框架为例,JSX 语法能够形象地表述出页面的结构,但其本质仍是 JS。页面即组件,组件本质上是函数,如果不做代码分割,所有的代码都会打包成一个 js 文件。
这一点小程序也比较像,如果想在小程序中开发一个页面,那么首先在 app.json 中注册页面。
{
"pages":[
"pages/index/index",
],
"window":{
"backgroundTextStyle":"light",
"navigationBarBackgroundColor": "#fff",
},
}
如上在 pages 属性下加入 pages/index/index
,就注册了第一个页面,然后我们在项目结构中创建对应的文件,如下所示:
接下来我们在 index.js 中这么写:
Page({
data:{
message:'hello,world',
context:['小程序','React Native']
},
onLoad(){
console.log('===小程序页面 onLoad 执行==>')
console.log(window) // undefined
},
onReady() {
console.log('===小程序页面 onReady 执行==>')
},
handleClick(){
console.log('点击事件')
}
})
在 WXML 中这么写:
<view bind:tap="handleClick" >hello,world</view>
这样就能在页面呈现出hello,world
。那么,一个小程序的页面就创建出来了,但却暴露出了几个问题:
- index 中的 Page 函数又是怎么来的?我们都知道小程序中存在很多特有的函数,比如 Page、Component、Behaivor 等。它们是从哪里来,又做了些什么呢?
- 在 index.js 中的代码是如何执行的?message 属性都是怎么通过 JS 传到了 WXML 中,并渲染到页面上的。
我们带着上面两个问题来分析一下。首先,在浏览器环境下是不存在 Page 等函数的,并且如上在 onLoad 函数中,打印 window 对象为 undefined,这就说明逻辑层的 runtime 运行时并不是浏览器环境提供的。
小程序会有很多页面,一般情况下主包内容会被打包到一起,形成一个 js 文件,我们先称之为 app-service.js
。这样一来,像 WebView 打开 pages/index/index
页面,逻辑层就会执行 app-service.js
中对应的代码,大致如下实现方式:
/* 存放对应的页面 */
self.source_code.pages = [
{
name:'pages/index/index', //对应的页面路径
source:{ // js 逻辑资源
jsCode:function(exports, require, module){
module.exports = function(wx,App,Page,Component,getApp,global){
// 编译后小程序业务代码,这样就可以获取 wx,Page,Component 属性。
// 业务代码
}
},
jsJson:{...}
}
},
{
name:'/app' // 小程序 app 文件
source:{
jsCode:function(exports, require, module){
module.exports = function(wx,App,Page,Component,getApp,global){
// 业务代码
App({})
}
},
}
},
];
/* 存放对应的组件 */
self.source_code.components = []
/* 存放正常的 js 文件 */
self.source_code.modules = []
我们大致实现了小程序逻辑层编译之后的代码,可以看到对于页面(Page)层面的结构存放到了一个数组 pages 里。比如 pages/index/index
:
- 对于 index.js 里面的代码,可以用 jsCode 函数包装;
- 对于组件(Component)层面的文件放在 components 数组里面;
- 对于一些其他的 js,可以用 modules 数组来保存。
在小程序中,像是页面文件 pages/index/index.js
,暴露出一个方法:
exports.a = function(){}
Page({ ... })
如果在另外的一个组件文件中引入 a:
const index = require('./index')
console.log(index.a) // function a
这会让 index 页面渲染不出来,原因是此时的 index.js 是按照 module 维度编译的,而不是按照 page 维度编译的。
我们都知道,在小程序中有 app.js ,可以对整个小程序进行整体处理和监控,比如 onLaunch (在小程序初始化过程中,会执行 onLaunch 生命周期)。这个文件的代码在小程序初始化阶段就会执行。
jsCode 函数有点像 common 规范里面的 nodejs 文件处理,传入 exports、require、module 三个变量。如果我们在 index.js 文件中通过 require 引入其他的文件:
const a = require('/a.js')
如上通过 require 引入 a.js ,那么本质上就是调用 jsCode 函数的第二个参数 require。明白了逻辑层的本质之后,我们来看一下是什么驱动了逻辑层。
什么驱动逻辑层?
逻辑层的运行离不开 js 引擎,以 JavascriptCore 为例,本质上就是执行 js 代码,也就是我们在小程序写的业务代码,因为此时的运行环境并不是浏览器内核提供的,这也就说明为什么我们不能在小程序中使用 DOM 相关的 API 了。
而 js 引擎是通过客户端宿主环境运行的,比如微信小程序,那么运行小程序的宿主环境就是微信客户端。
小程序基础库
整个逻辑层,除了写在业务层的代码,还有小程序在逻辑层注入的基础库,比如在微信中的 wx 对象,以及上面提到的 Page、Component、Behaivor 等方法。那我们来总结一下小程序的基础库做了些什么:
- 小程序基础库负责驱动整个业务逻辑 js 的执行、运行,并维持整个小程序应用;
- 提供小程序运行时所需的各种 API。
多页面架构
小程序模式采用了多页面架构,一个小程序可以存在多个 WebView 页面,可以对比 SPA 单页面应用来理解。
在单页面应用中,可以通过 router 控制路由的状态,如果要改变路由跳转到新页面,本质上还是要通过路由跳转,先找到对应的路由组件,再卸载掉之前组件,然后渲染新的组件,这种体验非常不好。而我们看到,很多原生 App 有很好的用户体验,比如 A 页面跳转到 B 页面,那么在 B 页面可以通过返回手势丝滑地回到 A 页面,这是单页面 SPA 应用难以做到的。
小程序多页面架构这一点的用户体验更趋近于原生,因为小程序存在多个 WebView,页面跳转时就会重新创建一个 WebView。这样一来,两个 WebView 页面会共存到小程应用中,返回时就会有原生应用中的丝滑效果了。
但是如果小程序页面一直跳转,每一个跳转的新页面都创建一个 WebView,会造成内存越来越大,内存达到一定阈值就会造成应用崩溃。为了解决这个问题,小程序加入了一个页面栈的概念,也就是在整个应用中,最多存在一定数量的 WebView 页面。以微信小程序为例子,页面栈最大数量为 10 个,所以此时需要小程序的开发者控制页面栈的层数。
小程序常用的路由跳转方法有:navigateTo、redirectTo、navigateBack,我们一一来看。
- navigateTo:打开新页面,新页面入栈。
- redirectTo:页面重定向,当前页面出栈,而后新页面入栈。
- navigateBack:页面回退,页面一直出栈,到达指定页面停止。
虽然渲染层采用多个 WebView 页面,但是逻辑层还是在同一个线程处理多个页面栈的。在小程序中,用 Navigator 去控制页面状态,用 pageStack 来保存页面栈的信息。模拟代码如下:
class Navigator {
pageStack = [] //保存页面栈信息
maxPageLimit = 10
constructor(appWorker) {
this.appWorker = appWorker
}
/* 跳转下一页 */
_self_navigateTo(query) {
if (this.pageStack.length === this.maxPageLimit) {
// 爆栈了,那么跳转失败
throw new Error('页面栈已满')
}
/* 当前最上层的 WebView 页面 */
const stackTopPage = this.pageStack[this.pageStack.length - 1]
/* 创建一个新的页面 */
const page = this.appWorker.createPage()
if (stackTopPage) {
/* 当前页面需要改变状态,变成未激活状态 */
stackTopPage.unActive()
}
/* 把当前 WebView 页面放入页面栈 */
this.pageStack.push(page)
/* 启动新的 WebView */
page.launch(query)
}
/* 重定向 */
_self_redirectTo() {
/* 清除当前最上面的页面栈 */
const stackTopPage = this.pageStack.pop()
/* 创建一个新的页面 */
const page = this.appWorker.createPage()
/* 把当前 WebView 页面放入页面栈 */
this.pageStack.push(page)
/* 启动新的 WebView */
page.launch(query)
}
/* 页面返回 */
_self_navigateBack() {
if (this.pageStack.length === 0) {
throw new Error('页面栈为空')
}
/* 清除当前最上面的页面栈 */
this.pageStack.pop()
/* 获取上一个页面 */
const stackTopPage = this.pageStack[this.pageStack.length - 1]
/* 上一个页面激活 */
stackTopPage && stackTopPage.active()
}
}
如上代码模拟了整个 WebView 切换的过程,具体细节在后续基于小程序语法的 DSL 应用实战中会详细讲到,现在只要了解即可。
视图层处理
明白了逻辑层是什么,做了哪些事情之后,我们接下来看一下渲染层。
WebView 视图层
首先,我们来说下视图层。小程序的视图层由多个 WebView 构成,每个 WebView 主体部分由 HTML 构成。我们在小程序写的 WXML 结构最后会被转变成 HTML 结构,写的 WXSS 结构,最后被转变成 CSS 结构。
渲染层长什么样?
我们还是拿上面的 pages/index/index
为例子,视图层 WebView 最终的产物是一个 html 文件,如下所示:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>page/index/index</title>
</head>
<body>
<!-- 小程序基础库 -->
<script src="/page-frame/app.js" ></script>
<script>
function bootstrap(){
/* 页面逻辑 */
window._pageName = 'page/index/index'
var script = document.createElement('script')
/* 加载视图层的 js */
script.setAttribute('type','text/javascript')
script.setAttribute('src','/_app/app-view.js')
document.body.append(script)
}
bootstrap()
</script>
</body>
</html>
每当小程序打开一个页面,本质上就是打开了一个新的 HTML 文件,接下来依次加载小程序的基础库 JS 和视图层的 JS 代码。那么,这两个 JS 是做什么的?
小程序基础库负责渲染、通信、底层基建等工作,包括怎么把代码渲染到页面上,怎么和逻辑层做通信。我们在传统 SPA 应用中,如果是用 React 框架构建的应用,那么 React 框架本身就类似于小程序的基础库。
视图层的 JS 就很好理解了,可以理解成我们写的模版结构,比如微信的 WXML。在 WebView 环境下,只能识别 HTML、CSS 和 JS,不能够直接识别 WXML,需要先将 WXML 转成语法树结构,不过这些工作在小程序编译上传阶段就已经完成了。
视图层渲染
语法树(Syntax Tree)是源代码语法结构的一种抽象表示,它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构
上面我们说到,通过 script 加载视图层,在 app-view 中会保留小程序编译后的语法树信息,这样就可以通过基础库来识别语法树,接着就可以绘制页面了。比如我们在 page/index/index 对应的 WXML 这么写:
<view class="container {{ show ? 'in' : 'out' }}" >
<view bind:tap="handleClick" >{{ message }}</view>
</view>
如上是一段小程序模版结构,它最后会变成什么? 在正式回答这个问题之前,我们先来看下 React 中的 jsx 结构,本质上会被编译成 React.createElement 形式。如下:
<div class="container" >hello,world</div>
React.createElement(type = 'div' ,props = { class:'container' } ,...children='hello,world')
React.createElement 在整个 React 应用 render 阶段执行,返回的对象结构会直观描述出元素的结构与层次。而小程序也符合这个流程,在编译阶段把 WXML 转成语法树结构,再在小程序运行时执行语法树,得到页面元素结构并且渲染。现在,我们看一下上面小程序模版会被编译成什么样子:
window.source_code.pages = [{
name:'page/index/index',
source:{
'wxml': {
template:null,
include:null,
render:function(context){
return [
context.renderNode('view',{
props:null,
meta:{
/* 合并 class 属性 */
class: context.mergeClass('container', context.getPropsData('show') ? 'in' : 'out' )
},
},
function (context){
return [
context.renderNode('view',{
props:null,
meta:[
on:[{
name:'tap',
event:'handleClick'
}]
]
},
function (context){
return [
/* 从 data 中获取 message 属性 */
context.getPropsData('message')
]
}
)
]
}
)
]
}
}
}
}]
如上就是 WXML 转成语法树之后的结构,可以把 WXML 结构通过一个 render 函数来承载,在渲染层渲染页面的时候执行 render 函数,同时传入 context,context,那它主要做了些什么呢?
- 通过 view、text、image 等标签渲染对应的元素,可以通过 renderNode 来实现。renderNode 和 createElement 类似,接受多个属性,第一个属性为类型,第二个属性为标签属性,比如绑定的 props 等;
- 如果我们想把逻辑层 data 或者是 props 中的数据渲染到视图层,比如我们在 WXML 中获取 data 中的 message 属性,或者通过 data 中 show 属性判断 class 是 in 还是 out,那么需要 getPropsData 去获取逻辑层的数据;
- 如果要把多个 class 合并,可以通过 mergeClass 来实现。
- ...
我们再看渲染层的流程:首先通过 WebView 加载 html js 等资源,然后加载基础库和视图层的代码,假如是 pages/index/index
页面,那么找到对应 render 函数。接下来通过 render 函数,以及 context 提供的各种方法 就可以把页面结构表现出来,再通过基础库转变成真实的 DOM 结构并渲染出来,这样就完成了渲染流程。
对于 context 提供的各位方法,接下来会有专门的章节介绍。
总结
本章节主要讲了 WebView 模式下的 H5 和小程序的区别、小程序的架构设计,以及小程序逻辑层和渲染层的本质。下一章节,我们将重点介绍客户端与 WebView 的通信原理:JSBridge。
附录
Skyline 渲染引擎
简介
小程序一直以来采用的都是 AppService 和 WebView 的双线程模型,基于 WebView 和原生控件混合渲染的方式,小程序优化扩展了 Web 的基础能力,保证了在移动端上有良好的性能和用户体验。Web 技术至今已有 30 多年历史,作为一款强大的渲染引擎,它有着良好的兼容性和丰富的特性。 尽管各大厂商在不断优化 Web 性能,但由于其繁重的历史包袱和复杂的渲染流程,使得 Web 在移动端的表现与原生应用仍有一定差距。
为了进一步优化小程序性能,提供更为接近原生的用户体验,我们在 WebView 渲染之外新增了一个渲染引擎 Skyline,其使用更精简高效的渲染管线,并带来诸多增强特性,让 Skyline 拥有更接近原生渲染的性能体验。
架构
当小程序基于 WebView 环境下时,WebView 的 JS 逻辑、DOM 树创建、CSS 解析、样式计算、Layout、Paint (Composite) 都发生在同一线程,在 WebView 上执行过多的 JS 逻辑可能阻塞渲染,导致界面卡顿。以此为前提,小程序同时考虑了性能与安全,采用了目前称为「双线程模型」的架构。
在 Skyline 环境下,我们尝试改变这一情况:Skyline 创建了一条渲染线程来负责 Layout, Composite 和 Paint 等渲染任务,并在 AppService 中划出一个独立的上下文,来运行之前 WebView 承担的 JS 逻辑、DOM 树创建等逻辑。这种新的架构相比原有的 WebView 架构,有以下特点:
- 界面更不容易被逻辑阻塞,进一步减少卡顿
- 无需为每个页面新建一个 JS 引擎实例(WebView),减少了内存、时间开销
- 框架可以在页面之间共享更多的资源,进一步减少运行时内存、时间开销
- 框架的代码之间无需再通过 JSBridge 进行数据交换,减少了大量通信时间开销
而与此同时,这个新的架构能很好地保持和原有架构的兼容性,基于 WebView 环境的小程序代码基本上无需任何改动即可直接在新的架构下运行。WXS 由于被移到 AppService 中,虽然逻辑本身无需改动,但询问页面信息等接口会变为异步,效率也可能有所下降;为此,我们同时推出了新的 Worklet 机制,它比原有的 WXS 更靠近渲染流程,用以高性能地构建各种复杂的动画效果。
新的渲染流程如下图所示: