写点什么

微信小程序底层框架实现原理|万字长文

作者:虎妞先生
  • 2023-02-10
    北京
  • 本文字数:12819 字

    阅读完需:约 42 分钟

前言

最近在掘金上学习了一本小册——《微信小程序底层框架实现原理》,加上以前做微信小程序的经验,结合自己的工作经历,深有感触,借此机会和大家分享一下学习工作心得。


2017 年 1 月微信小程序正式发布


我从 2018 年接触学习前端时,曾仿写过一个性格评测类小程序 demo,后来实习期间,完成了部门首个真正意义上小程序。做毕业设计时,结合微信小程序云开发能力,做了一个问答小程序(类似百度知道,360 问答)。后来,做过一个大学信息资讯类小程序。正式工作之后,做过的小程序就很多了,借款类小程序,购物类小程序,消费类小程序,导流类小程序。

为什么要掌握小程序

有招聘需求,现在部分团队会有专门招聘小程序开发工程师,toC 的产品招聘前端一般也会要求掌握微信小程序,有相关小程序开发经验。

于开发者

  • 目前开发的项目是小程序

  • 想自己独立开发一个小程序

  • 多掌握一门技术是好的

于企业

  • app 开发迭代成本较高,对于新业务小程序可以快速试错,探索渠道

  • web 端的生态不完整,收益小(游戏,抖音小程序,虎牙小程序,支付宝小程序)

双线程架构


小程序与传统 web 单线程架构相比,是双线程架构


渲染层和逻辑层由两个线程管理,逻辑层采用 JSCore 运行 js 代码,渲染层使用 webview 进行渲染。小程序有多个页面,所以渲染层存在多个 webview。


两个线程之间由 Native 层之间统一处理,无论是线程之间的通信,还是数据的传递,网络请求都是由 Native 层做转发。


此处提到的小程序都特指微信小程序

渲染一个 hello world 页面

// index.wxml<view>{{ msg }}</view>
// index.jsPage({ onLoad: function () { this.setData({ msg: 'Hello World' }) }})
复制代码


  • 渲染层和数据相关。

  • 逻辑层负责产生、处理数据。

  • 逻辑层通过 Page 实例的 setData 方法传递数据到渲染层。

数据驱动

WXML 可以先转成 JS 对象,然后再渲染出真正的 Dom 树,回到“Hello World”那个例子,我们可以看到转换的过程



通过 setData 把 msg 数据从“Hello World”变成“Goodbye”,产生的 JS 对象对应的节点就会发生变化,此时可以对比前后两个 JS 对象得到变化的部分,然后把这个差异应用到原来的 Dom 树上,从而达到更新 UI 的目的,这就是“数据驱动”。


这一点和 vue 其实是一致的



既然小程序是基于双线程模型,那就意味着任何数据传递都是线程间的通信,也就是都会有一定的延时。


一切都是异步。

快速渲染设计原理

小程序采用多个 webview 渲染,更加接近原生 App 的用户体验。


如果为单页面应用,单独打开一个页面,需要先卸载当前页面结构,并重新渲染。


多页面应用,新页面直接滑动出来并且覆盖在旧页面上即可。这样用户体验非常好。

数量限制

页面得载入是通过创建并插入 webview 来实现的。


微信小程序做了限制,在微信小程序中打开的页面不能超过 10 个,达到 10 个页面后,就不能再打开新的页面。


所以我们在开发中,要避免路由嵌套太深。

PageFrame

我们在写小程序页面时,并不关心 webview,只需要写页面 ui 和逻辑即可。


我们通过调试微信开发工具,可以看到,有两个 webview。


一个加载的的是当前页面,加载地址和当前页面路径一致。


一个是 instanceframe.html。



微信小程序在初始化的时候,除了渲染首页之后,会帮我们提前额外的预加载一个 webview,微信起名为 instanceframe.html,用来新渲染 webview 的模板。


我们通过微信开发者工具打开调试,打开这个 instanceframe.html


document.getElementsByTagName('webview')[1].showDevTools(true, null)
复制代码


下图是 pageframe/instanceframe.html 的模板


pageFrame 的 html 结构中注入的 js 资源

  • ./dev/wxconfig.js


小程序默认总配置项,包括用户自定义与系统默认的整合结果。在控制台输入__wxConfig 可以看出打印结果



  • ./dev/devtoolsconfig.js


小程序开发者配置,包括 navigationBarHeight,标题栏的高度,状态栏高度,等等,控制台输入__devtoolsconfig 可以看到其对应的信息



  • ./dev/deviceinfo.js


设备信息,包含尺寸/像素点 pixelRatio


  • dev/jsdebug.js


debug 工具


  • ./dev/WAWebview.js


渲染层底层基础库


  • ./dev/hls.js


优秀的视频流处理工具


  • ./dev/WARemoteDebug.js


底层基础库调试工具


  • <!-- wxappcode -->


注释占位符, 整个页面的 json wxss wxml 编译之后都存储在这里,当前是一个预设的 html 模版,所以是空的

wxappcode.js

我们按同样的调试方法,去找到首页的 wxappcode.js 结构,简单说明下


var decodeJsonPathName = decodeURI("pages/index/index")__wxAppCode__[decodeJsonPathName + ".json"]={"usingComponents":{}}var decodeWxmlPathName = decodeURI("pages/index/index")__wxAppCode__[decodeWxmlPathName + ".wxml"]=$gwx("./" + decodeWxmlPathName + ".wxml")var decodeWxssPathName = decodeURI("pages/index/index")__wxAppCode__[decodeWxssPathName + ".wxss"]=((window.eval || __global.__hackEval)('setCssToHead([\x22.\x22,[1],\x22test{ height: calc(\x22,[0,100],\x22-2px); ;wxcs_style_height : calc(100rpx-2px); width: \x22,[0,200],\x22; ;wxcs_style_width : 200rpx; ;wxcs_originclass: .test;;wxcs_fileinfo: ./pages/index/index.wxss 2 1; }\n\x22,],undefined,{path:\x22./pages/index/index.wxss\x22})'));window.__mainPageFrameReady__ && window.__mainPageFrameReady__()
复制代码


文件包含了所有文件的编译路径


主要几个重要的函数和属性有


  • decodeJsonPathName

  • .json 配置

  • .wxml 编译后的 $gwx 函数。

  • .wxss 编译后的 eval 函数。


后两个函数我们会在后文展开分析。


当小程序需要打开某个页面的时候,只需要提取页面的者几个属性,注入到预加载的 html 模版中就可以快速生成一个新的 webview

快速启动

在视图层内,每个页面都是一个 webiew,当小程序启动时只有首页一个 webview


执行 wx.navigateTo 新开一个页面的时候,就会创建一个新的 webview 并插入到视图层


wx.navigateBack 则为销毁 webview


小程序每个视图层页面内容都是通过 pageframe.html 模板来生成的。


  • 首页启动时,即第一次通过 pageframe.html 生成内容后,后台服务会缓存 pageframe.html 模板首次生成的 html 内容

  • 非首次新打开页面时,页面请求的 pageframe.html 内容直接走后台缓存

  • 非首次新打开页面时,pageframe.html 页面引入的外链 js 资源走本地缓存


这样在后续新打开页面时,都会走缓存的 pageframe 的内容,避免重复生成,快速打开一个新页面。

首次打开新页面

  • 启动一个 webview,src 为空地址 http://127.0.0.1:{c}

  • webview 初始化完毕后,设置地址 src 为 pageframe.html,开始加载注入的预设样式和预设 js 代码

  • pageframe.html 在 dom ready 之后,触发注入并执行具体页面的相关代码


下图代码中可以看到 dom 加载完毕之后,触发 alert 通知



  • 此时通过 history.pushState 方法修改 webview 的 src 但是 webview 并不会发送页面请求。

  • 因此 webview 路径变化为

  • http://127.0.0.1:{c}

  • http://127.0.0.1::63444/pageframe/instanceframe.html

  • http://127.0.0.1:63444/pageframe/pages/index/index

  • 正好对应 webview 加载过程


wxml 设计思路

网页编程一般采用的是 HTML + CSS + JS 的组合,其中 HTML 是用来描述当前这个页面的结构,CSS 用来描述页面的样子,JS 通常是用来处理这个页面和用户的交互。


同样道理,在小程序中也有同样的角色,其中 WXML 充当的就是类似 HTML 的角色。


小程序自行搭建了组件组织框架 Exparser 框架


Exparser 的组件模型与 WebComponents 标准中的 ShadowDOM 高度相似


如下代码,我们定义在 wxml 中


<!--index.wxml--><view class="container">  Weixin  <text style="position:relative;">文本</text></view><button bindtap="test">按钮</button>
复制代码


Exparser 框架会将上述结构转换为下面这个样子


<wx-view exparser:info-class-prefix="" exparser:info-component-id="2" class="container">  Weixin  <wx-text exparser:info-class-prefix="" exparser:info-component-id="3" style="position:relative;">    <span style="display:none;">文本</span>    <span>文本</span></wx-text></wx-view><wx-button exparser:info-class-prefix="" exparser:info-component-id="4" exparser:info-attr-bindtap="test" role="button" aria-disabled="false">  按钮</wx-button>
复制代码


这样看的话是不是和 WebComponents 一样了,但是小程序并没有直接使用 WebComponents,而是自行搭建了组件框架 Exparser。

WebComponents

Web Components 是一个浏览器原生支持的组件化方案,允许你创建新的自定义、可封装、可重用的 HTML 标记。不用加载任何外部模块,直接就可以在浏览器中跑。


如下代码,<custom-component>标签就是自定义组件的标签了,它不属于 html 语义化标签中的任何一个,是自定义的。


<html><head></head><body><user-card></user-card><template id='userCardId'>  <!--组件的样式与代码封装在一起,只对自定义元素生效,不会影响外部的全局样式。-->  <style>    .name{        color:red;        font-size: 50px;    }    button{        width:200px;    }    </style>  <p class='name'>21312</p>  <button>test</button></template><script>  class UserCard extends HTMLElement {    constructor() {      super()      var shadow = this.attachShadow({ mode:'closed'});            var templateElem = document.getElementById('userCardId')      var content = templateElem.content.cloneNode(true)                  // this.appendChild(content)      shadow.appendChild(content)    }  }
window.customElements.define('user-card', UserCard)</script></body></html>
复制代码


WebComponent 主要就是三个规范:


  • Custom Elements 规范


可以创建一个自定义标签。根据规范,自定义元素的名称必须包含连词线”-“,用与区别原生的 HTML 元素。


可以指定多个不同的回调函数,它们将会在元素的不同生命时期被调用。


  • templates 规范


提供了<template>标签,可以在它里面使用 HTML 定义 DOM 结构。


  • Shadow DOM 规范


下图中,看一下右侧的 HTML 结构,我们可以展开<user-card>标记看到里面的结构。是不是有种白封装了的感觉。如果只有这样的效果的话,跟模板引擎渲染组件的效果是一样的。所以我们不希望用户能够看到<user-card>的内部代码,WebComponent 允许内部代码隐藏起来,这叫做 Shadow DOM,即这部分 DOM 默认与外部 DOM 隔离,内部任何代码都无法影响外部。


ShadowDOM

首先实例化一个根节点,挂载到宿主上,这里的宿主是 this。上面说过,this 指向 user-card。


然后我们把创建的 DOM 结构,或者<template>结构挂载到影子根上即可。看一下 HTML 结构展示。


var shadow = this.attachShadow({ mode:'closed'});shadow.appendChild(content)
复制代码



内置的控件元素不能成为宿主,比如:img、button、input、textarea、select、radio、checkbox,video 等等,因为他们已经是 #shadow-root


如果愿意的话,我们可以调试他们的 shadow,看看这些标签的真实结构


Exparser 框架原理

Exparser 是微信小程序的组件组织框架,内置在小程序基础库中,为小程序提供各种各样的组件支撑。


内置组件和自定义组件都有 Exparser 组织管理。


Exparser 的组件模型与 WebComponents 标准中的 Shadow DOM 高度相似。


Exparser 会维护整个页面的节点树相关信息,包括节点的属性、事件绑定等,相当于一个简化版的 Shadow DOM 实现。Exparser 的主要特点包括以下几点:


  • 基于 Shadow DOM 模型:模型上与 WebComponents 的 ShadowDOM 高度相似,但不依赖浏览器的原生支持,也没有其他依赖库;实现时,还针对性地增加了其他 API 以支持小程序组件编程。

  • 可在纯 JS 环境中运行:这意味着逻辑层也具有一定的组件树组织能力。

  • 高效轻量:性能表现好,在组件实例极多的环境下表现尤其优异,同时代码尺寸也较小。


小程序中,所有节点树相关的操作都依赖于 Exparser,包括 WXML 到页面最终节点树的构建和自定义组件特性等。

原生组件

小程序中的部分组件是由客户端创建的原生组件,并不完全在 Exparser 的渲染体系下,这些组件有:



引入原生组件主要有 3 个好处:


  • 扩展 Web 的能力。比如像输入框组件(input, textarea)有更好地控制键盘的能力。

  • 体验更好,同时也减轻 WebView 的渲染工作。比如像地图组件(map)这类较复杂的组件,其渲染工作不占用 WebView 线程,而交给更高效的客户端原生处理。

  • 绕过 setData、数据通信和重渲染流程,使渲染性能更好。比如像画布组件(canvas)可直接用一套丰富的绘图接口进行绘制。

特殊场景

如果业务场景为手势识别之类的,监听事件不断的触发,数据不断的改变。


这样的业务场景中,我们可以想像,如果坐标值不断改变的话,在逻辑与视图分开的双线程架构中,线程与线程之间的通讯是非常频繁的,会有很大的性能问题。


所以我们可以看到微信开放了一个标记<WXS>,可以在渲染层写部分 js 逻辑。这样话就可以在渲染层单独处理频繁改变的数据,就避免了线程与线程之间频繁通讯导致的性能和延时问题。

优势

WXML 模版语法经过转换之后,会已自定义元素的形式来渲染。这里会有个疑问🤔️,为什么不用 HTML 语法和 WebComponents 来实现渲染,而是选择自定义?


  • 管控与安全:web 技术可以通过脚本获取修改页面敏感内容或者随意跳转其它页面

  • 能力有限:会限制小程序的表现形式

  • 标签众多:增加理解成本

wxss 设计思路

WXSS 具有 CSS 的大部分特性。同时为了更适合开发微信小程序,WXSS 对 CSS 进行了扩充以及修改。通俗的可以理解成基于 CSS 改了点东西,又加了点东西。


与 CSS 相比,WXSS 扩展的特性有:


  • 尺寸单位


rpx(responsive pixel) : 可以根据屏幕宽度进行自适应。规定屏幕宽为 750rpx。如在 iPhone6 上,屏幕宽度为 375px,共有 750 个物理像素,则 750rpx = 375px = 750 物理像素,1rpx = 0.5px = 1 物理像素。



  • 样式导入

  • 使用 @import 语句可以导入外联样式表, @import 后跟需要导入的外联样式表的相对路径,用;表示语句结束。

编译

/**index.wxss**/.test{  height: calc(100rpx-2px);  width: 200rpx;}
复制代码


如上我们定义的 index.wxss,会被编译成 js,注入 webview


我们把编译后的 js 分成三部分,展开分析。


第一部分用于获取一套基本设备信息,包含设备高度、设备宽度、物理像素与 CSS 像素比例、设备方向。


/*********//*第一部分*//*设备信息*//*********/var BASE_DEVICE_WIDTH = 750;// 基础设备宽度750var isIOS=navigator.userAgent.match("iPhone"); // 是否ipheone 机型var deviceWidth = window.screen.width || 375; // 设备宽度 默认375var deviceDPR = window.devicePixelRatio || 2; // 获取物理像素与css像素比例 默认2var checkDeviceWidth = window.__checkDeviceWidth__ || function() {  var newDeviceWidth = window.screen.width || 375  // 初始化设备宽度  var newDeviceDPR = window.devicePixelRatio || 2 // 初始化设备 像素比例  var newDeviceHeight = window.screen.height || 375 // 初始化设备高度  // 判断屏幕方向 landscape 为横向,如果是横向 高度值给宽度  if (window.screen.orientation && /^landscape/.test(window.screen.orientation.type || '')) newDeviceWidth = newDeviceHeight  // 更新设备信息  if (newDeviceWidth !== deviceWidth || newDeviceDPR !== deviceDPR) {    deviceWidth = newDeviceWidth    deviceDPR = newDeviceDPR  }}// 检查设备信息checkDeviceWidth()
复制代码


第二部分:转化 rpx


核心就是:下面两句,做了一个精度收拢


number = number / BASE_DEVICE_WIDTH *** (newDeviceWidth || deviceWidth);**


number = Math . floor (number + eps);


/*********//*第二部分*//*转化rpx*//*********/var eps = 1e-4;//0.0001var transformRPX = window.__transformRpx__ || function(number, newDeviceWidth) {  // 如果0 返回 0  0rpx = 0px  if ( number === 0 ) return 0;  // px = rpx值 / 基础设备宽度750 * 设备宽度  number = number / BASE_DEVICE_WIDTH * ( newDeviceWidth || deviceWidth );  // 返回小于等于 number + 0.0001的大整数,用户收拢精度  number = Math.floor(number + eps);  if (number === 0) {// 如果number == 0,说明输入为1rpx    if (deviceDPR === 1 || !isIOS) {// 非IOS 或者 像素比为1,返回1      return 1;    } else {      return 0.5;     }  }  return number;}
复制代码


第三部分主要是 setCssToHead 顾名思义


/*********//*第三部分*//*setCssToHead*//*********/window.__rpxRecalculatingFuncs__ = window.__rpxRecalculatingFuncs__ || [];var __COMMON_STYLESHEETS__ = __COMMON_STYLESHEETS__ || {} % svar setCssToHead = function(file, _xcInvalid, info) {  var Ca = {};  var css_id;  var info = info || {};  var _C = __COMMON_STYLESHEETS__  function makeup(file, opt) {    var _n = typeof(file) === "string";    if (_n && Ca.hasOwnProperty(file)) return "";    if (_n) Ca[file] = 1;    var ex = _n ? _C[file] : file;    var res = "";    for (var i = ex.length - 1; i >= 0; i--) {      var content = ex[i];      if (typeof(content) === "object") {        var op = content[0];        if (op == 0) res = transformRPX(content[1], opt.deviceWidth) + "px" + res;        else if (op == 1) res = opt.suffix + res;        else if (op == 2) res = makeup(content[1], opt) + res;      } else res = content + res    }    return res;  }  var styleSheetManager = window.__styleSheetManager2__  var rewritor = function(suffix, opt, style) {    opt = opt || {};    suffix = suffix || "";    opt.suffix = suffix;    if (opt.allowIllegalSelector != undefined && _xcInvalid != undefined) {      if (opt.allowIllegalSelector) console.warn("For developer:" + _xcInvalid);      else {        console.error(_xcInvalid);      }    }    Ca = {};    css = makeup(file, opt);    if (styleSheetManager) {      var key = (info.path || Math.random()) + ':' + suffix      if (!style) {        styleSheetManager.addItem(key, info.path);        window.__rpxRecalculatingFuncs__.push(function(size) {          opt.deviceWidth = size.width;          rewritor(suffix, opt, true);        });      }      styleSheetManager.setCss(key, css);      return;    }    if (!style) {      var head = document.head || document.getElementsByTagName('head')[0];      style = document.createElement('style');      style.type = 'text/css';      style.setAttribute("wxss:path", info.path);      head.appendChild(style);      window.__rpxRecalculatingFuncs__.push(function(size) {        opt.deviceWidth = size.width;        rewritor(suffix, opt, style);      });    }    if (style.styleSheet) {      style.styleSheet.cssText = css;    } else {      if (style.childNodes.length == 0) style.appendChild(document.createTextNode(css));      else style.childNodes[0].nodeValue = css;    }  }  return rewritor;}setCssToHead([".", [1], "test{ height: calc(", [0, 100], "-2px); width: ", [0, 200], "; }\n", ])(typeof __wxAppSuffixCode__ == "undefined" ? undefined: __wxAppSuffixCode__);
复制代码


setCssToHead 传的参数 是我们定义的 wxcss,变成了结构化数据,方便遍历处理


index.wxss 中写 rpx 单位的属性都变成了区间的样子[0, 100]、[0, 200]。其他单位并没有转换。这样的话就可以方便的识别哪里写了 rpx 单位


[".", [1], "test{ height: calc(", [0, 100], "-2px); width: ", [0, 200], "; }\n", ]

注入

在渲染层的一个的<script>标签中,有很长的一串字符串,并且用 eval 方法执行。如果你仔细看的话,还是可以勉强分辨出,这个字符串正是我们前面编译出来的 js 转换成的。


这样就可以得知,编译后的代码是通过 eval 方法注入执行的。这样的话完成了 WXSS 的一整套流程。


同时我们也可以看到,是在修改 pageFrame 的路径之后,初始化小程序样式配置文件之后,才开始注入样式文件


Virtual Dom 渲染流程

微信开发者工具和微信客户端都无法直接运行小程序的源码,因此我们需要对小程序的源码进行编译。


代码编译过程包括本地预处理、本地编译和服务器编译。


为了快速预览,微信开发者工具模拟器运行的代码只经过本地预处理、本地编译,没有服务器编译过程,而微信客户端运行的代码是额外经过服务器编译的。

编译

<!--index.wxml--><view class="container">  Weixin  <text style="position:relative;" >文本</text></view><button bindtap="test">按钮</button>
复制代码


如上面这段简单的 wxml 文件,经过编译之后,被编译成了 1500 多行


全部代码都被包裹在 $gwx 函数中,编译后的 WXML 文件,以 js 的形式插入到了渲染层的<script>标签中



但是在这个 script 标签中插入了 $gwx 函数之后并没有立即执行这个函数。


在渲染层的一个的<script>标签中,我们可以看到这段代码


var decodeName = decodeURI("./pages/index/index.wxml")var generateFunc = $gwx(decodeName)
复制代码


我们在控制抬手动执行 $gwx()的返回值 generateFunc()函数


返回的树形结构,就是该页面 wxml 对应的 js 对象形式表示的 dom 树



这是一个类似 Virtual Dom 的对象,交给了 WAWebview.js 来渲染成真实 DOM

事件系统设计

核心在于,wxml 和 js 文件在两个线程渲染,解析。事件如何绑定?


我们最开始在 wxml 文件中定义的事件绑定,其实转化成虚拟 dom 树结构之后,其实只是一个键值对,表明了某个 dom 上有绑定某个事件,并没有完成事件绑定。



WAWebview.js 处理虚拟 dom 树时,会去循环遍历 attr 属性,判断 attr 中的属性名是否为事件属性


if (n = e.match(/^(capture-)?(mut-)?(bind|catch):?(.+)$/))


如果是,通过 addListener 方法进行了事件绑定。


可以理解成,通过 addListner 方法监听 tap 事件,就相当于 window.addEventListener 对 mouseup 方法的监听。


回调函数中对函数的 event 信息进行组装,并触发 sendData 方法。



sendData 方法就是向逻辑线程发送 event 数据的方法。


下图是我们在逻辑层接收到的数据和准备发送的数据结构



可以看到数据结构是一样的,


目前在触发 sendData 方法之前这些逻辑的解析包括 event 参数的组装都是在渲染层的底层基础库 WAWebview.js 中完成的,也就是说还在渲染线程中。

事件

微信小程序中主要事件绑定:bind catch


bind /catch 后可以紧跟一个冒号,其含义不变,如 bind:tap catch:tap


catch 会阻止事件向上冒泡。


mut-bind 来绑定事件。一个 mut-bind 触发后,如果事件冒泡到其他节点上,其他节点上的 mut-bind 绑定函数不会被触发,但 bind 绑定函数和 catch 绑定函数依旧会被触发。


需要在捕获阶段监听事件时,可以采用 capture-bind、capture-catch 关键字,后者将中断捕获阶段和取消冒泡阶段。


通讯系统设计

最上面提到,视图层和逻辑层通讯是通过 Native 层。


具体的手段就是


  • ios 利用 WKWebView 的提供 messageHandlers 特性

  • android 是往 webview 的 window 对象注入一个原生方法


这两种会统一封装成 weixinJSBridge,这和正常 h5 与客户端通讯手段一致


初始化过程中 Native 层理论上是微信客户端,分别在视图层和业务逻辑层注入了 WeixinJSBridge


生命周期设计

data

逻辑层的 data 与 view 是相互绑定的,data 是页面第一次渲染使用的初始数据。页面加载的时候,data 将会以 JSON 字符串形式由逻辑层传至渲染层。因此 data 中的数据必须是可以转成 JSON 的类型:字符串,数字,布尔值,对象,数组。



图中,渲染层和逻辑层都从 start 开始出发,自身分别进行初始化操作,都初始化完毕后要怎么做呢?两条线程互相并不知道对方初始化怎么样了。


所以这个时候由渲染层发出信号,发出一个我已经初始化完毕的信号发给逻辑层,并且自身状态进入等待。


逻辑层收到这个信号的时候有两种情况。


  • 第一种就是自身还没初始化完,那么收到此信号后只需要初始化完毕后发送初始数据 Data 到渲染层即可。

  • 第二种情况就是逻辑层早已经进入等待状态,那么收到信号后立即发送初始数据 Data 到渲染层即可。

生命周期

  • onLoad(Object query) 页面加载时触发,一个页面只会调用一次,可以在 onLoad 的参数中获取打开当前页面路径中的参数。

  • onShow() 页面显示/切入前台时触发

  • onHide() 页面隐藏/切入后台时触发。 如 wx.navigateTo 或底部 tab 切换到其他页面,小程序切入后台等。

  • onReady() 页面初次渲染完成时触发。一个页面只会调用一次,代表页面已经准备妥当,可以和视图层进行交互。

  • onUnload() 页面卸载时触发。如wx.redirectTo或wx.navigateBack到其他页面时。


我们结合路由跳转和 webview 设计去理解


  • wx.navigateTo 是创建了新的 webview,当前 webview 进入 Hide()

  • wx.redirectTo 以及 wx.navigateBack 是通过更新自身 webview 进行页面转换的,所以当前页面会进行卸载操作,并且重新生成新页面。所以两个页面都会进入完整生命周期序列。


配合整体架构图来看一下生命周期。


路由设计

路由栈

小程序中不像单页面应用,采用多个 webview 类似多页。


触发路由的行为可以是逻辑层触发,也可以从视图层触发。在视图层中用户可以通过点击回退按钮,或者回退上一页的手势等机制触发。在逻辑层中发出的信号有打开新页面 navigateTo、重定向 redirectTo、页面返回 navigateBack 等,开发者通过官网提供的 API 触发。


无论逻辑层还是视图层,这个行为都会被发送到 Native 层,有 Native 层统一控制路由。对于 webview 的添加或删除都会有一个载体来维护,这就是路由栈。



上图中,逻辑层中发出打开页面行为到 Native 层,Native 层收到行为后通过 pageFrame 快速创建 webview,并且推入路由栈。页面创建完后,底层基础库会立刻执行初始化操作,初始化完毕后会发送一个信号通知 Native 页面已经创建并初始化完毕,随后 Native 层发送信号到逻辑层中。


通知的目的有两个:


需要通知开发者页面已经创建成功。


在沙箱中创建新页面的“根组件”,并正式开启新页面的生命周期与渲染的流程。

性能优化

程序的性能又可以分为「启动性能」和「运行时性能」两个主题。「启动性能」让用户能够更快的打开并看到小程序的内容,「运行时性能」保障用户能够流畅的使用小程序的功能。

小程序启动流程

1.资源准备

1.1 小程序相关信息准备

微信客户端需要从微信后台获取小程序的头像、昵称、版本、配置、权限等基本信息,这些信息会在本地缓存,并通过一定的机制进行更新。

1.1 环境预加载

为了尽可能的降低运行环境准备对启动耗时的影响,微信客户端会根据用户的使用场景和设备资源的使用情况,依照一定策略在小程序启动前对运行环境进行部分地预加载,以降低启动耗时。


但不一定命中


1.2 代码包准备

从微信后台获取代码包地址,从 CDN 下载小程序代码包


小程序代码包会在本地缓存,并通过更新机制进行更新。


同步下载/异步下载 强制更新/静默更新


为例降低代码包下载的耗时,微信做的一些优化


  • 代码包压缩

  • 增量更新

  • 优先使用 QUIC 和 HTTP/2

  • 预先建立连接:在下载发生前,提前和 CDN 建立连接,降低下载过程中 DNS 请求和连接建立的耗时

  • 代码包复用:对每个代码包都会计算 MD5 签名。即使发生了版本更新,如果代码包的 MD5 没有发生变化,则不需要重新进行下载。

2.代码注入

小程序启动时需要从代码包内读取小程序的配置和代码,并注入到 JavaScript 引擎中。


微信客户端会使用 V8 引擎的 Code Caching 技术对代码编译结果进行缓存,降低非首次注入时的编译耗时


code cache

V8 会把编译和解析的结果缓存下来,等到下次遇到相同的文件,直接跳过这个过程,把直接缓存好的数据拿来使用


启动时性能优化

控制代码包体积

  • 推荐所有小程序使用分包加载

  • 避免非必要使用全局自定义组件和插件

  • 会影响按需注入的效果和小程序代码注入的耗时

  • 控制资源文件

  • 建议开发者在代码包内的图片一般应只包含一些体积较小的图标,避免在代码包中包含或在 WXSS 中使用 base64 内联过多、过大的图片等资源文件。

  • 这类文件应尽可能部署到 CDN,并使用 URL 引入。

代码注入优化

  • 推荐所有小程序使用按需注入

  • 用时注入

  • 为自定义组件配置 占位组件,组件就会自动被视为用时注入组件

  • 启动过程中减少同步 API 的调用

  • 建议优先使用拆分后的 getSystemSetting/getAppAuthorizeSetting/getDeviceInfo/getWindowInfo/getAppBaseInfo 按需获取信息,或使用使用异步版本 getSystemInfoAsync

  • getStorageSync/setStorageSync 应只用来进行数据的持久化存储,不应用于运行时的数据传递或全局状态管理。

首屏渲染优化

  • 启用「初始渲染缓存

  • 启用初始渲染缓存,可以使视图层不需要等待逻辑层初始化完毕,而直接提前将页面初始 data 的渲染结果展示给用户,这可以使得页面对用户可见的时间大大提前

  • 提前首屏数据请求

  • 预拉取能够在小程序冷启动的时候通过微信后台提前向第三方服务器拉取业务数据,当代码包加载完时可以更快地渲染页面,减少用户等待时间,从而提升小程序的打开速度

  • 周期性更新能够在用户未打开小程序的情况下,也能从服务器提前拉取数据,当用户打开小程序时可以更快地渲染页面,减少用户等待时间,增强在弱网条件下的可用性。

  • 缓存请求数据

  • 骨架屏

运行时性能优化

合理使用 setData

控制频率,范围,内容

页面渲染优化

  • 适当监听 scroll 事件

  • 控制 WXML 节点数量和层级

  • 源码中一个页面 dom 数目超过 16000,肯定会报错

  • data 层级不要过深,因为需要深度遍历

  • 使用 IntersectionObserver 监听元素曝光

页面切换优化

  • 避免在 onHide/onUnload 执行耗时操作

  • 页面切换时,会先调用前一个页面的 onHide 或 onUnload 生命周期,然后再进行新页面的创建和渲染

  • 提前发起数据请求

  • 进行页面跳转时(例如 wx.navigateTo),可以提前为下一个页面做一些准备工作。页面之间可以通过 EventChannel 进行通信。类似 postMessage

  • 例如,在页面跳转时,可以同时发起下一个页面的数据请求,而不需要等到页面 onLoad 时再进行,从而可以让用户更早的看到页面内容。

  • 控制预加载下个页面的时机

  • 程序页面加载完成后,会预加载下一个页面。默认情况下,小程序框架会在当前页面 onReady 触发 200ms 后触发预加载。

  • 预加载会阻塞当前页面 setData,我们可以对单个页面的配置增加, handleWebviewPreload 选项,来控制预加载下个页面的时机。


资源加载优化

控制图片大小

内存优化

  • 合理分包,既能减少耗时,也能降低内存占用

  • 事件监听,定时器记得清除

三方库框架设计




小程序为什么快(与普通 h5 相比)

我们在对小程序的架构设计时的要求只有一个,就是要快,包括要渲染快、加载快等。当用户点开某个小程序时,我们期望体验到的是只有很短暂的加载界面,在一个过渡动画之后可以马上看到小程序的主界面。


  • 双线程,渲染层和逻辑层并行不阻塞

  • 多个 webview,页面切换更流畅

  • webview 预加载

  • 安装包缓存

  • 以及微信做了大量的优化和看不见的操作

总结与展望

  • 小程序拥有接近原生 App 的体验。

  • 小程序并不是真正的 “无需下载”,只是小程序的体积很小,在当今高速的网络环境下能够快速下载,用户感知不到,更确切的来说是 “无感下载”。

  • 基于移动端布局的局限性,可以高效且简单的开发,迭代快速。

  • 小程序是双线程模型,逻辑层和渲染层分别运行在不同的线程中,通过 JSBridge 进行通信。


现在已知的小程序有 微信,支付宝,抖音,qq,虎牙,斗鱼,饿了么,百度,京东等等



已知的这些都是超级 app,对用户来说,一般手机只按照常用和必要的 app,对于一般的 app 其实很难推广。开发 h5 又天生需要借助平台给予流量,体验无论怎么优化,仍旧比不上原生。但是小程序在可以借助平台流量的同时,有较好的用户体验。


目前已经出了三方框架,FinClip 把小程序搬进 app。


参考链接

https://developers.weixin.qq.com/ebook?action=get_post_info&docid=0000286f908988db00866b85f5640a

发布于: 2023-02-10阅读数: 21
用户头像

虎妞先生

关注

微信公众号:天涯碧草话斜阳 2017-12-22 加入

我很丰富,无法简介 如果愿意的话,我们能成为朋友

评论

发布
暂无评论
微信小程序底层框架实现原理|万字长文_微信小程序_虎妞先生_InfoQ写作社区