小红书自研小程序:电商体验与效果优化的运行时体系设计
小程序在其诞生后的几年内,凭借其简单、轻量、流畅、无需安装等特点,引来了爆发式的增长。伴随小红书电商业务的发展,我们洞察到越来越多的商家和品牌大客户有自己定制化需求场景,传统的电商和薯店存在下面三大问题:
为了解决上述问题,并快速打通基于小红书体系的支付与账号体系。过去的一年内,我们踏上了自研小程序之路。目前,在小红书店铺主页、 笔记详情、品牌专区、开屏均可唤起小程序。
品牌合作
案例展示
本文将主要介绍小红书进行小程序自研时的一些业务背景及工程化、容器能力的落地方案,以及运行时针对双线程架构 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 来分别获取到各个关键阶段的时间戳信息。
各个线程内所预留的性能点位和其在启动阶段中的位置如下图所示:
以上就是小红书小程序运行时方案的原理解析。
小程序本身是一个依托宿主流量体系衍生出的技术体系,它的价值往往紧贴应用主体的流量,而应用主体本身,又依赖小程序的灵活性及低成本的特点快速完成流量的转换。社交、支付与搜索,这些都是互联网产品提供的服务形态,各大厂商都是结合用户的需求和行为差异进行更开放、安全的技术方案探索,小红书亦是如此。小红书依靠用户产生内容,而内容产生商品,那么结合各类消费场景,如店铺、笔记等都可以通过小程序容器快速进行交易链路闭环。
未来,我们也将在不同的品牌和赛道上,寻找更多的服务商与品牌大客户商家与我们一起,共同丰富小红书的商品服务供给,增加小红书商业收入。技术上,我们则会不断对齐业界,优化技术架构,在提高框架性能的同时,建立完善的服务市场、巡检机制等来帮助小红书服务商与自开发商家细致、高效的开发与管理自己的小程序。
小程序是一个比较庞大的技术体系,如果你觉得本文对你有帮助,欢迎点赞转发。我们后续会根据反馈继续展开介绍更多的技术建设细节。
哈笛
商业技术组 - 小程序团队成员(tailiang@xiaohongshu.com),目前负责小红书运行时相关技术开发工作。
评论