Shopee在React Native 架构方面的探索

1. 背景

React Native(下文简称 RN)是混合应用领域流行的跨端开发框架。RN 非常适合灵活多变的电商领域业务,由于 RN 是基于客户端渲染的技术,所以相较于 H5 页面,它在用户体验方面有一定优势。

伴随着 Shopee 业务的飞速发展,我们 App 中的 RN 代码量增长得非常快,出现了构建产物体积过大、部署时间太长、不同团队依赖冲突等问题。为了应对这些痛点,我们探索了去中心化的 RN 架构,并结合该模型自研了系统(Code Push Platform,简称 CPP)和客户端 SDK,覆盖了多团队的开发、构建、发布、运行等一系列 RN 研发周期。经过近三年的迭代,现已接入多款公司级核心 App。

Shopee 商家服务前端团队打造了多款商家端应用,大部分用户是商家服务人员,他们对业务系统高可用和问题及时反馈有着很高的要求,从而也推动我们对 React Native 的架构有了更高的要求。

本文会从发展历史、架构模型、系统设计、迁移方案四个方向逐一介绍我们如何一步步地满足多团队在复杂业务中的开发需求。

2. 发展历程

随着业务高速发展,我们的 RN bundle 个数飞速增加,App 个数也达到近十个。整个 RN 项目在开发模型、部署模型和架构模型三个维度都发生了变化,从单团队发展成多团队,从一个 bundle 发展成多个 bundle,从中心化架构发展成为去中心化,最终发展成为每个团队的业务代码可以独立地开发、部署、运行。

整个发展历史分为 4 个阶段,分别是单 bundle 集中开发模式、单 bundle 多业务组开发模式、多 bundle 中心化发布模式、多 bundle 去中心化发布模式。

2.1 第一阶段:单 bundle 集中开发模式

最初的 RN 整体技术架构相对简单。由于当时业务形态不算复杂,为了满足独立团队在同一个代码仓库当中的开发流程,整个发布流程是基于 CDN 的更新发布,并且使用配置文件记录 RN bundle 文件的版本以及下载地址,以此进行资源管理。整个发布的产物有两个,一个是 RN 资源包,一个是用于资源版本管理的 JSON 配置文件。

每次 RN 资源在完成构建后,这两种构建产物会被放置在静态资源目录下。App 在特定的时间节点(例如 App 重启等)会自动拉取配置文件检查资源更新状态,然后再从 CDN 拉取 RN 静态资源。在下一次打开页面的时候,App 会加载最新的页面内容。

随着业务发展,越来越多业务团队期望使用 RN 技术栈开发业务,这种情况让已有架构发生改变,我们自然地产生了“多个业务组多个代码仓库”的想法。

2.2 第二阶段:单 bundle 多业务组开发模式

针对上述问题,多业务组的研发解决方案是 host-plugin 这种模式。

host 用于管理公共依赖和通用逻辑,它将 React、React Native、Shopee RN SDK 等通过一个独立的仓库管理起来,保证了特殊 RN 依赖的“singleton”(单例模式)条件,避免了部分客户端组件的重叠依赖,而这种重叠依赖是 RN 官方不允许的。

一个 host 对应着多个 plugin 仓库,业务代码仓库则是被看作为一个插件(plugin),以插件的形式接入主应用当中。业务团队可以按自己的编码规范来管理这个仓库。每个插件仓库会被视为 host 项目的 npm 依赖,它的构建是一个集中发布的流程。所有代码都会集成在 host 项目当中执行构建脚本。这种模式满足超级 App 的要求。

image.png

与此同时,host-plugin 的模式也带来了一个“难题”,业务发展使得 RN 产物体积逐渐变大,过大的产物会影响客户端的解压效率和 RN 容器加载 JS 时长。

2.3 第三阶段:多 bundle 中心化架构模式

针对 RN 产物体积过大的问题,我们利用构建工具将打包产物细分成多个 bundle,这一优化是非常有必要的,我们称它为“分包”。host 项目对应的是公共包,plugin 项目对应的是业务包。

整个构建发生在 host 项目,项目的模式还是“集中构建”和“集中发布”。多 bundle 产物将会发布到系统当中,客户端将拉取热更新的内容。客户端会按需加载对应的 bundle,RN 容器单次加载消耗的资源大大减少,解决了效率问题。

但是它的缺点也很明显。随着业务团队的变大和业务内容的扩张,多 bundle 中心化发布模式同样也具备四个弊端:

  • 针对 RN 的运行时,即使分包技术使得产物分离,但是它们还是运行在同一个 JSContext 当中,这种情况可能会导致依赖冲突和环境变量污染;
  • 在开发调试的过程中,项目重依赖于 host 项目,每次存在着代码变更,需要重新加载很多内容,让开发调试不太友好;
  • 在项目构建的过程中,打包速度受到 plugin 个数的影响,特大型应用甚至需要 50 分钟执行一次构建,过长的构建耗时严重影响了发布效率;
  • 在部署发布的过程中,host 项目维护者负责整个 App,每个业务组不能独立发布,发布时间会绑定在一起。当出现 live issue 的情况,开发者需要花费大量的沟通成本,且只能整体回滚。

2.4 第四阶段:多 bundle 去中心化架构模式

去中心化 React Native 架构模式与网页的“微前端”或者客户端的“微应用”的概念类似,满足了多业务团队独立开发部署,能够在同一个 App 各模块独立运行。它涵盖了开发、构建、发布、运行等多个方面。该模型解决了上面所说的四个弊端,并针对整个研发体系有了全面的升级,优点有:RN 运行时的互不干扰,开发调试的高效,构建发布的独立性。

下文会重点介绍项目的去中心化 RN 架构和系统设计,以及我们是怎样做到灵活性和稳定性的平衡的。

3. 去中心的 RN 架构模型

简单来说,去中心化的 RN 发布模型涉及到四个部分:独立的 JS 运行时;独立的开发流程;独立的构建流程;独立的发布流程。在这四个关键环节的帮助下,每个团队按自己的节奏掌控 RN 的研发流程。

3.1 独立 JS 运行时

独立运行时(多 JSContext,执行上下文环境)的出现是去中心化架构的最大特色。独立运行时是对独立发布的完美保证,将 RN 运行代码按照 plugin 维度进行隔离,它可以有效避免不同业务之间的变量冲突以及依赖冲突问题,即“plugin A”的发布绝对不会影响到“plugin B”。

它的设计主要包含以下三点:

  • 提前创建 JSContext 且预加载公共包;
  • 进入 plugin 的页面,SDK 会查看对应的 JSContext 是否已被实例化。如果已经被实例化,就直接使用,否则从 JSContext Pool 选取一个独立的上下文,加载执行业务包,各个 plugin 之间运行时是隔离的;
  • 退出业务页面时,该 JSContext 不会立即销毁,而是放入一个缓存池,使得重复进入该业务可以获得极致体验。

装置 JSContext 的容器可以是线程或者进程。为了避免它频繁创建和回收,我们要维护缓存池且尽可能地复用现有的 JSContext。

这里我们采用 Least Frequently Recently Used(简称 LFRU)的策略。当刚退出的应用被重新打开,该 JSContext 会被重新启用。这样,我们能够节省 85% 的首屏渲染时长。缓存个数管理是可配置的,业务方可以根据应用的规模作为合理的预估。当该 RN 页面还在使用中,即使超出预估数,该上下文也不会立即被回收,该设计有效地保证页面的可用性。

3.2 开发流程

上文提及 RN 项目的调试效率问题,它会随着业务代码的体量增多,代码调试效能也会随之下降。每个开发者的效率问题直接影响到大家的“幸福感”。相比之下,RN 去中心化发布则是针对开发流程做了特定的优化。

随着独立运行时环境的出现,RN 进入调试的时候,客户端可以做到只加载一个 plugin 到对应的 JSContext 中,其他 plugin 则采用内置 cache。

这样做有两个好处:一是保证了服务启动范围的最小化,保证了代码热加载的效率;二是确保开发和构建两种流程的一致性,这样会让一些问题在开发阶段提前暴露出来,比如 babel 插件缺失导致的编译问题。这样的“去中心化”的开发流程提高了 RN 调试效率。

3.3 构建流程

随着业务发展,某 App 的 RN plugin 数有 4 个,旧构建流程受到 plugin 个数的影响,集中构建时长超过 20 分钟。而采用去中心化 RN 架构,构建时长不再随 plugin 个数增长,只和该 plugin 代码量有关,稳定在 5 分钟左右。

新架构也是同样基于 host-plugin 模型,独立仓库的隔离让每个团队有自由的发展空间。考虑到在应用内的基础 Native 依赖是统一的,host 项目仅用来管理统一的公共依赖。项目需要优先将 common bundle 构建完成,系统会记录公共包中的依赖信息。当每个 plugin 项目进行构建的时候,构建工具会剔除掉公共包依赖信息,并完成业务包的构建。每个业务包的构建产物都是独立地存放于系统当中。系统具备独立回滚、独立发布、独立灰度的能力。

这样的好处在于构建任务的最小粒度化,每个 plugin 的构建不会引起整个项目的重新构建,做到真正意义的“按需打包”。

3.4 发布流程

RN 的构建和发布是两个独立的流程。这也意味着 bundle 的构建环节和发布环节完全解耦,发布时间点也可以由每个业务团队发布负责人灵活安排。每个业务组对自己的代码质量负责,灵活地把控自己的发版本节奏,不会影响其他团队线上业务。发布流程里面包含了全量发布、联合发布、灰度发布、回滚等操作,后续章节会详细介绍如何保证发布的稳定性。

4. 系统设计

对于复杂的大型项目来说,简单的热更新流程已无法满足多业务组协同合作,我们需要一个功能完善、性能优越、操作友好的热更新系统来满足复杂业务的发展。Code Push Platform 由 Node.js 编写,搭配系统附属的命令行工具和客户端 SDK。

为了满足该系统在多业务团队的运作,整个系统从功能角度可以划分为三大部分,分别是:

  • 多团队权限管控;
  • bundle 生命周期管理;
  • 系统效能提升。

其中,系统效能提升功能又细分为:

  • 增量差分;
  • 多场景入口体积优化;
  • 一站式多环境整合。

4.1 多团队权限管控

系统除了记录每次构建操作,更重要的是工作流程的去中心化,每个 plugin 的权限是隔离的。每个负责人只能在系统内部操作,plugin 1 的负责人只能触发相关的构建和发布,没法看到 plugin 2 的操作情况。系统通过严格的权限管控来规范所有发布流程,保证了项目的可控性。


React Native 去中心化发布的设计目标是节省不同团队之间的沟通成本。系统会限制他们的构建和发布的动作,各自的发布不会互相干扰。

权限的管理呈树状结构,一个 App 对应着一个项目,项目负责人默认是 App 团队的项目负责人。创建一个全新的插件等系统操作需要项目负责人审批。一个 App 包含有多个 plugin,每个 plugin 负责人默认是相应的业务团队负责人,他有权限分配发布和构建的权限。

image.png

4.2 bundle 生命周期管理

4.2.1 客户端版本控制

RN 有别于网页应用,它对客户端有着紧密的依赖关系。在客户端底层依赖没有变化的情况下,一般情况下开发者可以通过热更新进行 RN 代码的更新。但是遇到重大的更新,例如 React Native 的版本从 59 升级到 63,不仅仅需要 JavaScript 侧改动,客户端也要升级版本且没法继续向下兼容。从技术层面看,它是难以避免的。这种客户端无法向下兼容的情况,被称为“断层”。

系统会提供客户端版本控制的能力。当重大变更出现时,App 负责人应该在系统上新建一个“断层信息”,版本号的范围是从最低 App 兼容版本到最高 App 版本。在这个区间客户端才能拉取到该断层的最新 RN 资源。

如下表所示,大于等于 2.5.0 版本的 App 拉取的是 105 版本 RN 包;在 2.0.0 至 2.5.0 版本拉取到 103 版本 RN 包;在 1.0.0 至 2.0.0 版本拉取到 100 版本 RN 包。

这种措施能够有效避免潜在风险。而最新的需求只会在最新断层上线,旧的断层只做线上问题修复。毕竟是两套代码,代码的维护有成本,随着用户更新至最新版本,应当逐渐淘汰掉旧断层。

4.2.2 灰度和回滚

发布流程里面包含了全量发布、灰度发布、回滚等操作。对于大型需求,全量上线会带来潜在风险。一般来说,优先针对部分用户投放新版本,发布负责人可以根据指定用户和特定范围进行灰度发布,逐步扩大灰度发布范围,直至转到全量。当发现重大 bug 的时候,发布者可以采用“零构建”的方式进行“秒级”回滚。

去中心化 RN 架构支持每个 plugin 独立发布、独立灰度、独立回滚,以最小颗粒度的操作来保证质量规避风险。plugin 维度级别的灰度和回滚能够为不同的业务团队带了灵活性,每个业务团队可以自行发布版本,控制灰度节奏,处理线上问题。

4.3 系统效能提升

4.3.1 差分增量

App 频繁更新 RN 资源包会造成对用户流量的消耗,最有效的方式是利用增量更新来节省流量。RN 资源包涵盖了编译后的 JavaScript 产物、图片、翻译文件等静态资源。它们的前后版本差异即是该版本变更的代码或者其他资源文件。为了让差分粒度深入到资源包内部,系统专门提供独立的“差分服务”,采用二进制差分的方式对构建产物进行差分。

RN 资源包的 diff(差分)操作在服务端完成 ,patch(整合)操作在 App 端完成。在去中心化 RN 架构中,每个 plugin 的差分都是独立的。plugin 的发布会自动触发差分的执行,系统会以 plugin 为维度拉取最近五个版本,Diff Server 则会依次将它们和当前版本进行差分计算。如果计算成功,会将差分结果上传到 CDN 并反馈给系统,否则继续重试。整个差分操作是一个异步的过程,即使出现“差分服务”下线等极端情况,系统会自动降级为全量包,保证系统的可用性。

4.3.2 多场景入口体积优化

由于 React Native 的构建官方依赖于 metro.js,而它并没有具备无用代码剔除(tree-shaking)的能力。随着业务代码的膨胀,包体积的优化是一个很重要的问题。

例如,ShopeePay 为公司多款核心 App 提供支付业务。ShopeePay plugin 在不同地区、不同 App 之间存在一些页面级别差异。同一个仓库含了所有代码和资源,但是构建脚本会将它们都会打包成为一个产物。很明显,这导致 ShopeePay 的发布产物包含大量冗余资源,并非最优,浪费下载流量,同时也影响代码的执行效率。

我们采用自研的多场景插件(babel-plugin-scene),该插件通过注入的环境变量设置一个场景值,babel 可以根据场景值的差异化加载不同的文件,并且以默认文件作为降级兜底。不同场景对应不同的入口文件,利用这种形式可以有效控制包体积。

4.3.3 一站式多环境融合

一个正常的研发流程是从 test 环境,到 uat 环境,再到 live环境。Code Push Platform 对接了 App 的 test/uat/live环境,所以 RN 开发者只需要在该系统就可以进行“一站式”的操作,方可满足一个需求的整个研发周期。

不同环境的包资源流转,是多环境融合的一大亮点。如果某 RN bundle 在 uat 环境构建,它也不需要重新构建,将 bundle 无缝转换到 线上 环境进行发布。它带来的优势在于“零构建时长”以及资源包的稳定性,因为 bundle 没有重新进行构建,所以它的内容已经在 uat 得到了充分的验证,发布风险更小。

5. 旧业务的迁移方案

如何迁移现有业务的 App 是一个非常严肃的问题,特别是历史背景较重的业务,它们可能存在“逻辑耦合”或者“组件耦合”的场景。与此同时,很多相关业务都在需求迭代当中,系统的迁移是不能阻碍需求迭代,所以旧业务“渐进式迁移”方案是非常必要的。

5.1 逻辑耦合

如果两个以上 plugin 存在逻辑依赖关系,用户必须同时加载到最新的 plugin。考虑到热更新失败的可能性,逻辑耦合就是多个 plugin 隐藏着一种约束关系。例如,订单业务和购买业务存在一定的逻辑耦合关系,发布负责人针对流量极大的超级 App,不可能逐个发布 plugin。在极端的状态下,用户可能会先加载到 plugin A,新版本的 plugin A 和旧版本的 plugin B 是不兼容的,这样会带来严重后果。遇到这种情况,有两种解决方案:

  • 方案一:plugin 间逻辑解耦,保证每个 plugin 的独立性。
  • 方案二:系统提供了联合发布,在 Native 侧保证多个 plugin 能够同时加载到最新。

方案一是最理想化的状态,但是在业务场景细分的情况下,项目结构很难做到绝对独立。

针对老业务可以考虑方案二,系统提供了 module 的概念,一个 module 对应着两个以上的 plugin。它们存在着一个绑定的关系。在同一个下载任务里面,客户端 SDK 以“事务”形式,保证多个 plugin 能够同时下载完成并投入使用。联合发布这个能力在系统层面,有效规避这种错误的可能性。

5.2 组件耦合

如果说联合发布是针对在 plugin 维度的“逻辑耦合”兼容方案,“组件耦合”则是更细粒度的组件级别的耦合关系。也就是说,一个页面中存在多个组件来自不同的团队,例如商品详情页等页面有评价功能组件。这种“一个页面存在着 JSContext 相互嵌套”的情景存在于电商业务当中。

针对这种“组件耦合”情况,有两种解决方案:

  • 方案一:嵌套组件抽离成为一个独立仓库,供第三方 plugin 使用。
  • 方案二:使用“同屏渲染”的能力实现“多 Context 嵌套”。

方案一是最理想的解决方案。但是考虑到迁移成本,我们也提供了方案二(一种“同屏渲染”嵌套组件)来支持这种场景,它类似一种 Native 组件。在多个 JSContext 的情况下,通过 plugin 名和页面名将所需要的内容嵌套到另一个页面当中。

如下图所示,plugin A 会嵌套 plugin B 的内容,A 和 B 也可以实现在同一个屏幕进行渲染。从 Web 的方向理解,这种情况有点像 “iframe” 的场景,支持多个页面的嵌套。它非常易于 RN 开发者的理解,客户端 SDK 能够动态加载目标 bundle 并将它渲染在合适的位置。

5.3 渐进式迁移

对于现有的 App,因为业务没法暂停迭代,我们难以一次性完成整体迁移。因此,我们提供了“渐进式迁移”方案。考虑到历史背景,该方案不会一次性把所有 plugin 都迁移,而是逐步拆分,再迁移接入到新发布系统。

迁移的步骤如下图所示:

  • 优先将独立的业务迁移到 Code Push Platform,它们享用一个独立的 JSContext;
  • 所有“待拆分代码”共用一个独立的JSContext;
  • 将“待拆分代码”继续拆分成几个独立 plugin,独立使用 JSContext,其他内容则保持步骤二的状态。

随着版本迭代,重复第二和第三步骤,直至历史业务全部拆分完毕。这样我们可以达到一个最优的目标,即是真正意义的“独立构建”和“独立发布”。

6. 总结

该系统的目标在于满足所有 App 的多团队研发协作效率问题,去中心化 RN 发布模型考虑到“独立运行时”、“独立开发”、“独立构建”、“独立发布”四大方面,保障了每个 plugin 运行的独立性。最终目标在于支撑 Shopee 的多个 RN 团队在不同 App 平台根据自己节奏自由发布且高效运作。

系统设计涉及到“多团队权限管控”、“客户端版本控制”、“灰度和回滚”、“增量差分”、“多入口包体积优化”、 “一站式多环境融合”,加速了整个研发流程,真正做到了“灵活性”和“稳定性”的兼得。