从 PC 时代、移动时代到万物互联的 IoT 时代,伴随终端设备的日趋多样化,跨端复用的种子自此落地,开始生根发芽。从依靠容器能力、各类离线化预装包的 Hybrid 方案,到通过 JSC 连接 JavaScript 生态与原生控件,结合视图框架(React、Vue等)寻找效率、动态性和性能更均衡的 Native 容器方案(React Native、Weex 等),接着由微信牵头的以多进程 WebView、容器标准化的小程序方案出世,各平台小程序随之春笋萌发,随后带来了国内Taro、uni-app、Rax、Remax等多端框架的百家争鸣。

从业务角度出发,跨端技术演进更多是在不同阶段、不同时间段内业务效率上的选择,美团民宿业务就是在大前端融合的浪潮中逐浪前行,不断探索和迭代抉择,为解决业务痛点而孵化出跨端框架技术。本文主要分享美团民宿在跨端复用技术探索层面以及业务实践过程中积累的经验,希望能给大家带来一些帮助或者启发。

1. 背景

1.1 美团民宿业务介绍

美团民宿专注为消费者提供“住得不一样”的旅居体验,提供的服务包括民宿、酒店、公寓、客栈、短租、宾馆、旅行住宿等,同时包括树屋、房车、INS 风等新奇的网红民宿。美团民宿自上线之后,业务发展迅猛,在供给侧,房源类型不断丰富,各类分销、直销、直连、境外陆续推出,房源信息维度不断扩展,筛选、推荐、信息呈现也不断变得复杂。同时伴随着营销方式的丰富、房东管理、经营、服务的不断扩充,民宿的业务也越来越复杂。美团民宿大前端伴随业务的发展不断自我迭代,移动端整体架构也随之不断调整、升级,以寻求匹配业务多样化、复杂化的发展诉求。

1.2 美团民宿移动端现状

业务的发展和跨端复用技术的不断演化,让美团民宿客户端从业务刚起步的单端 Native App,到跨 App(民宿 App、美团 App、点评 App )的 Native 复用和以 SSR 弥补性能差距的 Hybrid 的结合方案,在这场性能和效率的博弈中,客户端最终落脚以 React Native(以下简称 RN)为核心的复用框架。在此同时,民宿小程序端也随着微信小程序的诞生、生态壮大、多平台化的趋势不断成长,逐渐形成多平台复用的小程序架构。

图1 美团民宿移动端原始架构图

上图是美团民宿移动端原始架构图,左侧是客户端的技术架构,iOS 和 Android 系统层之上是独立的 Native 基建层,再往上通过了 RN 打开双端的复用之门,接着以 RN 容器标准化屏蔽了宿主应用间差异,保障了容器化的一致性,进而实现了业务层的复用和跨 App 的复用。右侧是民宿小程序当前简化的架构图,我们在基建层做了多端适配,通过多平台复用构建工具实现了各平台小程序的复用。当前客户端和小程端相关独立,开发维护也相互独立,团队各司其职。

尽管美团民宿 App 已经通过 RN 实现 iOS 和 Android 的跨端复用,但是由于 App 和小程序仍然需要投入双倍的人力成本进行业务迭代,所以我们思考一个问题: 是否可以更进一步,使用一套代码解决多端,把 iOS App、Android App、小程序进行大一统。

2. 美团民宿跨端复用框架设计

2.1 行业现状

近几年,在微信小程序产品牵头下,业界也随之诞生出各种小程序应用,各端技术差异使得开发和维护成本都成倍增加。为了抹平原生开发、小程序开发、Web 开发等技术差异,一些优秀多端框架也就此诞生了。比如 Taro、uni-app、Rax、Remax 等,这些框架都是以自身定义 DSL (一般是 React DSL、Vue DSL)转换成各端应用(微信小程序、RN、H5等),从而实现一套代码,多端运行。

在美团民宿业务中,App 的交易占比较大,从业务角度出发需优先保障 App 的性能体验和需求开发效率,而当前的民宿 App 已迁移至 RN 技术栈。基于这两点,我们希望跨端复用方案的是: RN 转到小程序平台方案,所以上述的多端框架并不能满足我们的 RN-小程序跨端复用的诉求,为此美团民宿参考了业界多端设计方案,实现了基于 RN 转小程序复用的方案。

RN 采用的是 React 语法,因此如何将 RN 转换为小程序,首先要思考如何将 React 代码转换成小程序可运行的代码(简称小程序代码),其次是 RN 基础组件库的适配。随着这几年的发展, React 代码转换成小程序代码在业界实践也是层出不穷,业界方案分为编译时与运行时两类,以下是这两类方案的简单对比:

框架分类 重编译 重运行
典型代表 Taro2.0 / Rax 编译时 Taro Next / Remax
原理 编译时将 React 代码直接转换成小程序代码 运行是通过 React 自定义渲染器完成页面绘制
优势 性能损耗低 无语法限制
劣势 语法限制大 性能损耗大

对比来看,重编译方案有一个严重的问题:语法限制。因为大部分前端开发者们已经对灵活的语法有一定的依赖性,比如会使用高阶组件、在条件判断的时候写很多 return 等等,这种写法很难在编译过程被准确命中。因此,编译时方案就会制定一些语法规则来限制开发者的写法。重运行方案则没有语法限制问题,可以随意使用各种 React 特性。它的实现原理是通过 react-reconciler 实现小程序平台对应的 React 渲染器(以下简称 MP-Renderer),从而来渲染虚拟 DOM 树。不过小程序没有 DOM API 可以更新界面,所以生成的虚拟 DOM 树数据是通过小程序的 setData 触发渲染层的更新,在渲染层里有一个通用模板可以用来渲染这些数据。

因重编译语法限制的问题,我们决定采用重运行时方案来实现 RN 转小程序。但重运行方案存在性能问题,难以满足业务的要求,我们经不断探索后设计了对应的方案极大提升了性能,下文会详细描述如何解决这个问题的。

2.2 整体方案设计

2.2.1 RN 与小程序复用的技术方案

图2 RN与小程序复用技术方案图

整体架构分为两个部分:编译过程、运行过程。它的渲染方式与上文描述重运行时方案类似,都是通过 MP-Renderer 来处理 React 代码。下面我们来简要分析这两个过程:

(1) 编译过程 :该阶段对 RN 源码进行一定的转换处理,用于运行过程,编译后主要产生有以下产物:

  • 编译后的 RN :经过编译后产生 RN 代码,本质上还是 React 代码。
  • 适配组件库 :RN 基础组件的适配库,是使用小程序自定义组件实现的。
  • 通用模板 :由于小程序没有像 Web 有 DOM API 操作节点操作方法,所以这里通过一个通用模板来渲染 React 渲染出来的 TreeData (页面虚拟 DOM 树序列化后的 UI 数据)。
  • 合并模板 :主要用于性能优化的,下文会详细分析这个模板的作用。
  • WXSS :将 RN 代码的 Style 转换为 WXSS,这样可以减少页面的 TreeData 数据量,从而优化性能。

(2) 运行过程 :运行过程分为逻辑层和视图层两部分。

  • 逻辑层 :编译后的 RN 源码包含 RN 业务组件和适配组件库,适配组件库是通过小程序自定义组件来进行适配。这样的方式既可以灵活使用小程序原生代码对齐 RN 组件功能,也可以提升转换后小程序的性能,因为小程序原生代码不会产生 TreeData 数据,从而使性能上得到提升。逻辑层有一个 MP-Renderer ,实现方式和上文讲述的是一样的,RN 代码经过渲染后,便产生对应的虚拟 DOM 树,虚拟 DOM 树数据再经过序列化便产生对应的 TreeData(描述页面的 UI 数据)。
  • 渲染层 :当页面需要更新的时候,逻辑层通过 setData 将 TreeData 传输到渲染层里,TreeData 与通用模板、合并模板和对应样式结合在一起,便可以渲染出对应的 UI。

综上所述,上述整体设计与业界多端框架有点类似,但是也有不同点,主要体现在适配组件库和合并模板。适配组件库上文有解释比较好理解,而合并模板这里可能大家还是比较有疑惑的。其实这个合并模板内容是由编译过程的 “静态编译” 转换生成的,这样的处理方式是为提升转换后的小程序性能,接下来,我们会着重来讲述这个性能解决方案。

2.2.2 性能解决方案

重运行时方案性能损耗原因是什么?正如上文所说,重运行时方案会将所有 React 代码对应的 TreeData,再通过小程序 setData 传输到渲染层,当页面初始化或者大数据更新的话,setData 就需要传递比较大的一个数据,因此也就会造成对应的性能问题。所以要解决这种方案的性能问题,核心就是要减少 TreeData 数据量。

在上述 RN 转小程序方案,有提到适配组件库、样式转换等是可以起到对应性能优化作用的,它的优化原理正是通过减少 TreeData 数据的方式。尽管这些方式可以优化性能,但是在页面比较复杂的时候,TreeData 数据量仍然会保留比较大,因此优化效果并不明显。为此,我们思考一种新的方式来进一步压缩 TreeData 的数据量,也就是前文所提到的结合静态合并树节点方案,在讲述该方案前我们先来看下一个 RN 代码转换为 TreeData 的例子:

图3 RN代码转换TreeData示例图

如上图所示,RN 代码转换后的 TreeData 是一个描述 UI 树的 JSON 数据,等同于右侧的 UI 树,将这颗树的节点进行分类,可以分为静态数据和动态数据,比如 View、Text 节点就是静态数据,而 “Hello”、“World” 则是动态数据。所谓静态数据,就是编译过程可预知的,因此这些数据是不是可以转换另一种形式来描述 UI 呢,从而减少 TreeData 的数据量。答案是肯定的,静态编译合并树节点正是通过这样的原理来实现的,如下流程所示:

图4 静态编译合并树节点原理图-1

这个方案有两个动作,分别是静态编译和合并树节点,静态编译就将 RN 代码的转换成合并模板,如上图序号 2 代码所示,合并模板的名称为 “b1”,内容就是一段与 RN JSX 代码对应的 WXML 结构片段。而合并节点是将已经静态编译的节点进行合并,如上图序号 2 至序号 3 流程所示,原本五个节点被合并到顶层的 View 节点,这个 View 节点称为合并节点,合并节点需要记录合并模板的名称和相关的动态数据,目的是为了渲染时让合并节点可以找到对应的合并模板进行渲染,经过这样合并节点后,最终生成的 TreeData,如上图序号 4 所示。可以看到 TreeData 相比之前的数据量就减少了 60% 左右!

看到这里,是不是有同学就有疑问了,上文不是提到静态编译会有语法限制,那这里是否会有语法限制?确实,如果是完全静态编译,是会有语法限制,而这里所说的结合静态编译是有选择性的编译,即在编译过程,首先会通过 AST 分析节点是否静态数据,如果是的话,再转换成对应的合并模板。如果遇到不可预测的动态节点,则按照运行时方案去处理。因此,最终生成的 UI 树节点即会包含合并节点、也会包含原本的组件节点,如下图所示:

图5 静态编译合并树节点原理图-2

通过这样的方式,既可以保证语法无限制,又能通过编译结合的手段最大化优化性能。当然了这种方案也是有缺点,因为这种方案其实是用空间换性能的方式,生成的合并模板会影响会影响包大小,不过对于一些需要追求性能的页面,这点包大小的增加是值得付出的。

为了更好地衡量解决方案对性能的提升程度,我们参考 Taro 官网的实验( 实验内容 ),对优化前后以及原生和 Taro 3.0 运行后的性能指标进行采集与比较。经过实验,统计出各框架在初始化、加载数据、加载大量数据的操作耗时,如下表所示:

操作耗时 \ 框架 优化前 优化后 原生 Taro 3.0.17
初始化(首屏渲染时间) 897ms 423ms 210ms 675ms
加载普通数据(20条) 1124ms 198ms 110ms 640ms
加载大量数据(400条) 5330ms 1041ms 470ms 3919ms

从上表中可以看出:性能优化后,得益于更少的渲染数据与更精简的节点树,加载数据的操作耗时比优化前减少 80% ,初始化耗时减少了 52%。与同类型的框架 Taro 3.0 相比,也有更好的性能表现。

与原生相比,优化后性能差距明显减少,但是由于运行时方案相对于原生需要更多的 setData 数据开销和更复杂渲染流程,所以从原理上运行时方案和原生性能差距客观存在。尽管如此,业务实践上两者差距并不会那么明显,因为在测评实验中测试数据比较纯粹,setData 数据使用率较高,但在业务实践中原生开发 setData 数据难免冗余且难以优化,而运行时方案会默认优化冗余数据使得两者性能差距更接近,从我们历史业务实践数据上看,性能与原生差距在 10% 左右。

3. 美团民宿跨端复用实践

在跨端复用探索中,我们用创新的方案解决了性能和特性限制的难题,设计了 RN-小程序跨端复用框架。虽然跨端复用属于“利器在手”,但是这是一把“双刃剑”,用得其所则事半功倍,处理不当则隐患丛生。那么,如何在业务实践中驾驭好这把利刃呢?我们先介绍在业务实践中遇到的问题,然后介绍解决这些问题的方案。

3.1 跨端复用场景下的问题

  1. 复用场景下的问题 :小程序产品形态以轻、快、便为旨,用户可快速使用,用完即走,客户端产品相对全、精、稳,可以满足更多的用户需求,以用户留存、用户认知、用户体验为主,两者在产品功能上存在较大的差异,如何恰当地处理产品差异化问题是跨端复用的场景下的一个重要挑战。
  2. 跨端复用质量隐患 :实现了复用便要考虑两端的各种兼容性问题,这就会产生各种质量上的隐患。如何在复用组件不断迭代中,保障组件接口、输入、输出的兼容性问题?如何保障各个复用组件底层依赖的统一、适配层接口的统一?双端复用场景下,如何更好的做测试和监控?双端同学存在各自技术认知的边界,如何在出现问题时快速排查、及时止损?
  3. 跨端复用流程规范问题 :新的技术革命,必然打破旧的秩序,在当前跨端复用场景下,各种包括工程管理、代码规范、分支管理、需求同步的问题也会孕育而生,同需解决。

3.2 跨端复用应用架构

为了解决跨端复用在业务实践中遇到的各种问题,我们重新设计了跨端复用应用架构,从架构分层管理、复用方式设计、流程规范、质量保障方面入手,重点解决跨端差异化、质量隐患、流程规范各种问题,并寻求复用的最大化和性能上的均衡。

3.2.1 跨端复用应用架构演进

在这里,先贴出动态的架构演进过程,让大家有一个宏观的认识。我们先简单地描述下演进过程,后续会基于最终的架构图再做详细的介绍。大致演进过程如下:

图6 跨端复用架构演进动画图

  • 起初,客户端分 Android App 和 iOS App 单独开发,引入 RN 技术实现了 Android 和 iOS 跨端复用,但是小程序端依然需要单独维护迭代。
  • 为了跟进一步实现 RN-小程序跨端复用,我们接入了自研的 RN-小程序跨端复用框架,并基于框架的适配规范,以 RN 的基建为基准,打造出一个和 RN 基建统一接口的小程序适配层。
  • 完成小程序渲染器接入(MP-Render)和小程序适配层后,React-Reconciler 这一层就可以打通到小程序侧,实现了 React 代码复用到小程序的能力。
  • 实现 RN 与小程序间的复用后,就可以对存量的 RN 代码进行抽象、适配、整理,进而抽取出一个组件复用层,这个复用层可直接供上层业务层直接使用。
  • 最后,为了解决跨端复用场景下各种流程、协作和质量隐患,我们配套了相应的流程规范和质量保障措施。

3.2.2 跨端复用应用架构整体介绍

图7 跨端复用应用架构图

整个民宿的 RN-小程序跨端复用架构图如上,我们按照从下到上,从左到右的视角进行解读:

  • 系统层 :最底层是系统服务,除了 iOS 系统和 Android 系统外,我们把小程序视为一个单独的系统模块。
  • 基础服务层 :系统服务之上是基础服务层,这一层主要是集团基于 Native 和小程序建设的基建,全公司通用,覆盖了研发工程中方方面面的基础服务。在此基础上,我们在小程序基建中引入了基于 react-reconciler 实现的小程序运行时渲染器(MP-Render),这个渲染器能在运行时动态更新 vnode 以匹配编译转化的小程序 UI 模板,调用小程序原生 API,最终渲染出小程序组件,有了这个基于 React 的小程序渲染器便使得跨端复用成为可能。
  • 基建层 :基础服务层之上是基建层,这块主要包括 MRN 基建和小程序适配层,我们以 MRN 的基建为标准,适配出一个统一标准和统一接口的小程序适配库,通过这一层适配,上层可以无感知、无差异地以同一标准实现复用组件。其中适配层分为 2 块,下半部分主要适配 RN 基础服务,上层是民宿业务独立封装的基础库和第三方库,这块我们单独引入一个名为 Mapping 的适配库。一个独立的适配库可以让 RN 和小程序在业务迭代和技术变革过程中相互独立,互不干扰,如此就能保障技术的推进完全不会影响业务的迭代。基建层的最上方是 react-reconciler,React 框架本身就是把协调过程和渲染过程分开的,react-reconciler 是实现跨端复用的核心,所以我们把它单独展示出来,它真正打通了客户端和小程序的隔阂,只要有了一个独立的小程序渲染器,就可以全面、无限制的把 React 代码复用到小程序。
  • 复用层 :基建层再往上是复用层,复用层主要以组件维度做复用,复用组件是基于存量 RN 组件做抽象和适配,然后抽取独立出来,复用层的组件以统一的标准和接口供上层业务使用。复用层是很重要的一块,好的复用机制能帮助我们解决前面提到的产品差异化问题和复用最大化问题。这块我们单独放到 3.3 跨端复用方式设计 来详细讲解。
  • 业务层 :复用层之上就是业务层,业务层的各模块主要以页面容器来承接复用组件,基于不同的端和产品差异,可以灵活、动态配置页面的组件来满意业务的差异化需求。

3.3 跨端复用方式设计

差异化问题,一直是跨端复用场景中的一个痛点,双端的产品上、平台上、代码上的差异如何妥善的处理、适配,也是我们一直思考的问题。而好的差异化处理方案可以提升代码的可维护性、降低质量隐患、提升开发效率。我们从复用设计层面出发,探索出页面复用模式、组件复用模式、“组件+逻辑复用”模式等三种复用设计方式,并且根据不同的场景下采用不同的复用模式,可以较好地处理跨端差异化问题,同时能兼顾效率提升、性能体验和可维护性。

3.3.1 差异化下的复用方式

我们自研的复用框架提供两种复用模式,如下图所示:

图8 小程序复用方式原理图

页面复用模式 :页面模式基于页面维度的,可以直接把页面的网络层、逻辑层、数据层以及页面内的组件集全部转换复用,这样可以达到复用的最大化,代码复用率能达到 90% 以上,人效提升明显。 组件复用模式 :组件模式是基于组件维度的,复用以页面中的业务组件为目标,把页面的所有组件抽象、解耦、规范化之后抽取为复用组件。组件模式只能复用组件内代码,对于页面容器的逻辑交互、网络层都需要小程序自己实现,代码复用率相对较低,但是组件复用更灵活、可控,可随意插拔、拼接、定制。

以下是两种复用模式的优劣分析。

页面复用模式

优势

1) 提效明显:整个页面包括所有组件、页面逻辑层网络层一并打包转换复用,代码复用率极高,开发效率提升幅度更大。 2) 接入成本低:整个页面直接转化同步复用,无需小程序同学协助接入,减少双端协助、接口沟通带来的出错风险。

劣势

1) 灵活性低:业务差异和小程序特性不易处理,双端差异适配只能在 RN 上做,代码易出错,维护成本高。 2) 性能劣势:整体页面由 RN 转换复用而来,页面一次性渲染,性能上会略差一些,而且做页面级的性能优化困难。 3) 包大小风险大:整页复用情况下包大小较大,且不能动态调配(比如页面内某一模块需求迭代较少,不想复用,但是页面模式做不到动态移除)。

组件复用模式

优势

1) 轻便灵活:组件如插件般可随意插拔、拼接、定制,可较好解决 App 和小程序双端的差异性问题,针对差异点双端可以独立实现,提高项目的可维护性。 2) 性能较好:页面容器依然是小程序原生组件,如滚动、滑动组件采用原生可减少性能损耗,另外组件分布式 setData 渲染有更好的性能,不会像整页一次性渲染导致 setData 数据量较大影响首屏加载性能。 3) 性能优化空间大:不会影响做页面维度的性能优化(如首屏优先、请求前置)。 4) 包大小可控:组件是否复用可以动态调配,比如把页面中迭代较少的组件不复用以减少包大小。

劣势

1) 提效有限:组件模式只能复用组件内的代码,代码复用率较低,页面容器、逻辑层、网络层小程序依然要自己维护一份代码。
2) 复用组件维护成本高:组件的接口要考虑组件升级迭代的兼容性、可维护性问题,管理不当,容易产生质量隐患。 3) 接入成本较高:小程序需要实现 RN 的页面逻辑,然后按照组件接口进行接入,有更高的接入成本。

两组复用模式各有利弊,页面模式复用率高,但是灵活性低、性能欠佳;组件模式轻便灵活,性能可控,能较好的处理平台差异化问,但是复用率低、维护成本高。我们在想有没有一种方案能保留组件模式的灵活性,又能降低组件维护成本、提高复用程度。在业务实践中,我们探索出一套“组件+逻辑复用”的模式,可以较好地解决上面提到的问题。

3.3.2 差异化下的逻辑复用

“组件+逻辑复用”模式依然保留组件复用的方式,但是在组件复用基础上增加了逻辑层(包括页面逻辑、网络、数据层)的复用,这样保留了组件灵活性,也增加了复用性。具体设计如下图:

图9 组件+逻辑复用模式原理图

整个组件+逻辑复用模式设计图如上,我们按照图片标注的序号进行一一解读:

1) 逻辑复用接口实例 :在小程序的页面容器中,通过注入的方式获取逻辑层复用的接口实例,通过这个实例便可以调用接口实现获取、更改、监听 Redux 的状态,实质上就达到了逻辑复用的效果。 2) 页面复用组件集 :页面可以自由使用复用组件,复用组件可大可小,可以虽然拼装布局,保留了组件模式良好的灵活性。 3) 小程序原生组件 :页面既可以使用复用组件,也可以用小程序原生组件来实现小程序差异化的功能和特性,这样能较好的处理双端差异性。小程序原生组件可以通过 逻辑复用接口实例 来调用逻辑层功能,进而达到逻辑复用的效果。 4) 弹窗复用组件 :弹窗复用组件和页面复用组件同理,这边主要说明可以按照各类维度把复用组件分类,进而更好的做复用组件管理。