版权所有,转载请注明出处。
最近在前端圈大名鼎鼎的 Airbnb(爱彼迎)团队宣布放弃 React Native。他们在自己的博客的中写了一系列文章详细介绍了使用 React Native 的开发经历,解释了放弃使用 RN 的原因。本文就是对这些博文的整理和翻译。
由于篇幅较长,我拆分成了上下两部分,以下是上篇:
序
在 Airbnb 正式推出的十年前,智能手机还处于发展的初级阶段。从那时起,它逐渐成为我们日常生活中的重要工具。作为一家为数百万人提供旅行服务的社区,开发强大稳定的移动端应用程序对我们来说非常重要,因为客户在外大多只通过手机来使用我们的服务。
从2008年开始,我们的移动端用户数量快速增长到了数百万级别。我们的应用让客户可以方便地在旅途中管理他们的住宿和行程,只需动动手指,就可以获得完美的旅行体验!
Airbnb 拥有一百人规模的移动开发团队,这使我们能跟上最新的技术潮流,不断寻找和评估新的技术方向,来实现代码的快速迭代,提升开发体验,打造更好的产品。
选择 React Native
2016年,我们意识到移动端对我们业务的重要性,但是当时没有足够的人手来开发 App,所以我们开始探索替代方案,因为我们的网站是由 React 来构建的,它一直是 Airbnb 内部非常受欢迎的 Web 开发框架。因此,我们选择了 React Native,来帮助前端工程师快速地上手移动端的编码,
当开始投入到 RN 的开发中时,我们清楚地知道其中的风险:它是一个未经验证,尚未稳定的平台,它会割裂我们的代码,带来很多副作用。
但我们还是满怀信心,期望能做到最好,当时我们的目标是:
- 实现代码的快速迭代。
- 方便进行不同平台的质量管理。
- 跨平台开发,只需写一次代码,而不是为不同的平台单独开发程序。
- 提升开发体验。
两年多来,我们慢慢积累了大量的开发经验,打造了强大的 React Native 生态,实现很多复杂的功能,比如:共享元素变换、视差效果、定位,还有对接类似网络、测试、本地化等现有原生基础框架的桥接组件。
我们使用 React Native 开发了许多重要的产品,比如 Experiences,这是 Airbnb 推出的全新 App,包含了评论、礼品卡等等十几项功能。而这些都是在我们没有足够的移动开发人手时构建的。
两年后的今天,我们认为 React Native 确实是一项革命性的技术,它彻底改变了移动开发,我们从中获益良多,但是这并不意味着它没有缺点。
React Native 的优点
跨平台
React Native 的最重要的优点是它的跨平台性,你编写的代码可以同时在 iOS 和 Android 上运行。大多数模块可以实现95%-100%的代码共享,只有0.2%的代码需要针对平台单独实现(*.android.js *.ios.js)。
统一的设计语言(DLS)
我们开发了一套名为DLS的跨平台设计语言。每一个组件都可能有 Android、iOS、React Native 和 web 版本的实现。拥有统一的设计语言意味着我们可以编写跨平台的功能,因为设计、组件名称、屏幕适配在各个平台上都是统一的。当然我们仍然会根据不同平台的特性进行一些微调,比如在实现导航栏时,我们在 iOS 端使用 Navigation Bar,而在 Android 端使用 Toolbar 这类的原生组件,而且 Android 端的返回按钮会被隐藏,因为这不符合 Android 的设计规范。
相对于包装原生组件,我们更倾向于(使用JS)重写组件,因为为每个平台分别编写平台适应的 api 会更加可靠,也方便 Android 和 iOS 工程师去测试。但是这样的做法也会导致碎片化的问题。
React
React是最受欢迎的 Web 开发框架是有原因的,它简单但是功能强大,适合编写大型项目。有几个特性是我们非常喜欢的:
组件化(Components):React 通过良好的设计,使用 props 和 state 管理组件的状态,使得我们将功能拆分为独立的组件。组件化是 React 灵活可扩展性最重要的原因。
简化的生命周期(Lifecycles):Android 和 iOS 组件的生命周期是非常复杂的(特别是 Android),而 React 在这方面做的非常好,使得它易于学习上手。
声明式渲染(Declarative):声明式编程告诉机器你想要什么(what),让机器想出如何去做(how)。React 这个特性帮助我们实现 UI 的实时更新(通过 setState)。
开发效率
开发 React Native 应用时,我们可以通过 Hot Reloading 来实时部署对代码的改动。尽管我们特别去优化了 iOS 和 Android 应用程序的编译速度(达到测试编译15s左右,完整编译最长20分钟),和它们相比,React Native 仍然快如闪电。
基础框架搭建
我们搭建了大量的基础框架,定义统一的接口来实现和原生端的桥接。所有的核心组件,比如:登录信息、网络层、测试、分享、设备信息等等,都被包含在了一个统一的 React Native API 中。我们想要为 React 端提供标准和权威的通信接口,并且通过快速迭代,来更新这些接口,增加新的内容。这些工作使得前端的开发更加轻松。
如果没有前期大量的投入,我们的开发体验和效率会变得非常低。所以,如果你想要在现有原生端嵌入 React Native,这些工作是非常有必要的。
性能
React Native 的问题之一是它的性能。但是在实践中,我们发现完全在可以接受的范围内。大多数使用 React Native 开发的页面就和原生页面一样流畅。性能包含很多个维度,通过做业务逻辑的调整,或者将 layout 相关计算移出主线程,可以解决大部分的性能问题。
当然如果确实遇到了性能瓶颈(通常是由于大量组件的重复渲染造成的),我们可以通过 shouldComponentUpdate
,removeClippedSubviews
等方法来进行优化,当然最好是使用 Redux
来管理状态。
另外一个性能的缺点是 React Native 的首次加载时间太长了,这使得使用 React Native 开发诸如启动页、全局跳转、导航等功能时响应缓慢。这个问题目前也没有很好的解决方案,因为 Yoga 将 js 『翻译』成原生组件需要大量的计算。
Redux
我们使用 Redux 来管理状态,它非常高效,可以防止 UI 和状态不同步,并且轻松实现垮屏幕的数据共享。
Redux 的缺点是过于死板不灵活,并且学习曲线陡峭。我们为一些常见模板编写了生成器(generators),但是 Redux 仍然是 React Native 编码中最为令人困惑的部分。当然,Redux 框架也不是 React Native 特有的。
原生模块支持
React Native 中所有的接口模块都可以通过原生代码来桥接,得益于此,我们最终实现了许多一开始并不确定能否实现的功能:
共享元素变换(Shared Element Transition):通过桥接 Android 和 iOS 我们创建了一个 <SharedElement> 组件来实现共享元素变换效果。它甚至可以在原生和 RN 页面之间渲染过场动画!
Lottie:桥接在原生端的现有库,我们成功地让 Lottie 在 React Native 中运行。(注:Lottie 是 Airbnb 开发的炫酷动画框架)
原生网络堆栈:我们在 React Native 端使用各自平台原生的网络层框架和缓存。
其它核心内容:和网络层类似,我们将其它现有的原生端基础架构(测试、i18n等)封装起来,以便能在 js 端无缝地调用。
静态分析
前端做静态分析的经典工具是 eslint,不过我们在 Airbnb 的 React Native 端使用 prettier 工具。它的效率非常高,是我们的前端基础架构团队重点关注的工具之一。
我们也使用分析工具来测试页面的渲染次数和事件,来找出应用中性能瓶颈。
相较于我们的 Web 端工程,React Native 项目比较新,也比较小巧,所以它成为我们实验新想法新技术的『试验场』,很多我们在 React Native 端开发的工具现在都应用到了 Web 端。
动画
React Native 提供了强大的动画库,我们可以实现流畅的动画效果,甚至是事件驱动的动画,比如视差滚动。
开源的 React/JS 代码
React Native 基于 React 和 JS,这意味着我们可以使用海量好用的 js 框架,比如 Redux、reselect,jest 等等。
Flexbox
React Native 使用 Yoga 来实现 UI 布局,它是一个基于C语言开发的跨平台布局框架,使用 flexbox API。当然 Yoga 在早期也有很多不足的地方,比如对比例布局没有很好的支持,不过在后续版本中他们会逐渐改进。
另外,例如 flexbox froggy 这类的小游戏也让学习 Flexbox 变得更有趣。
和 Web 端合作
深入使用 React Native 后,我们开始同时构建服务的 iOS,Android 和 Web 版本。由于我们的 Web 端也使用 Redux,所以 RN 端和 Web 端可以共享大量的代码。
React Native 的缺点
还不够成熟
相比于 Android 和 iOS 两个原生平台,React Native 还不够成熟。它更新,更有野心,并且还在不断地迭代中。虽然 React Natvie 在大多数情况下都能很好地工作,但有时候,它不成熟的一面会让一些看起来微不足道的事情变得非常困难让你抓狂。不幸的是,这些情况很难预测,而且可能需要几小时到几天的时间才能解决。
维护 React Native 的本地分支
由于 React Native 的不稳定性,我们有时候会需要去修改它的源码。除了在 github 上贡献源码外,我们还必须维护一个本地分支,以便能将这些改动快速部署到我们的产品中,来解决 bug。在过去的两年中,我们在 React Native 上提交了50个本地 commit,这让每次升级 React Native 版本都变得非常痛苦。
JavaScript
JS 是一种弱类型的语言,缺乏类型安全让那些习惯了使用类型安全语言(例如 Swift)的工程师抗拒学习 React Natvie。我们尝试使用 flow,但是那些晦涩难懂的错误提示真的让人非常沮丧。我们又调研了 TypeScript ,但是很难将它整合到现有的生态中去,它和 babel,metro bundler 这类框架有严重的兼容问题。 不过我们仍然在持续关注着 TypeScript 在 Web 端的发展。
重构
JavaScript 一个时常被忽略的缺点是重构代码非常困难,而且容易出错。重命名属性(props),特别是像 onClick 这样常见名称的属性,简直就是一场噩梦。更糟糕的是,这样的重构很容易导致在运行时崩溃,而且很难在编译过程中发现这类错误,也很难为这类错误添加适当的静态分析。
JavaScriptCore 的不一致性
React Native 一个重要特性是它在 JavaScriptCore 环境中执行,这有时候会导致一些很棘手的麻烦:
iOS 自带了 JavaScriptCore 环境,所以 React Native 在 iOS 上的表现相对稳定一致。
Android 没有默认的 JS Core 引擎,React Native 针对 Android 自己打包了一个,但是这个打包的引擎版本比较老。所以我们自己想办法打包了一个较新版本的 Core。
在 debug 调试时,React Native 会连接到 Chrome 的 Developer Tools,这是一个很强大的工具。这样一来,debug 时所有的 js 代码就会默认在 Chrome 的 V8 引擎中执行,99%的情况下不会有什么异常,但是我们因此遇到过一个诡异的错误:
toLocaleString
接口在 iOS 上能正常工作,在 Android debug 环境也 OK, 但是在打包的 Android App 中却会导致崩溃,因为 Android JSC 并不包含这个接口!如果工程师不了解这些细节,那么调试这样的错误很有可能会花费数天的时间,而且非常痛苦。
React Native 开源库
学习一个全新的平台总是费时费力的。大部分工程师都只对一两个平台比较了解。许多 React Natvie 模块要用到原生端的桥接,比如地图、视频等等,这就要求工程师对三个平台都比较熟悉。但是我们发现,大部分 React Native 开源库的作者只对一两个平台有经验,这就会导致这些库在某个平台上出现诡异的问题。
在 Android 端,许多 React Natvie 库还在采用到 node_modules 的相对路径来链接工程,而不是社区更加推荐的 maven 构建方式。
重新搭建基础架构
我们已经在 iOS 和 Android 平台上积累了大量成熟的基础架构。但是在 React Native 中,一切都是全新的,我们从零开始慢慢桥接或者重新编写这些框架。这意味着,我们的业务工程师常常发现他们需要的接口、功能还没有被实现,只能阻塞手头的工作,去自己并不熟悉的平台编写需要的功能。
崩溃监测
我们在 iOS 和 Android 平台上使用 Bugsnag 来做线上崩溃监测。我们花了很多精力使它能够工作在 React Natvie 环境中,但仍然不够稳定。因为整个社区在这方面的经验太少了,我们不得不自己动手编写了很多监测相关的功能,甚至和 Bugsnag 合作,来使它能正确过滤只在 React Native 中发生的异常。
得益于大量相关的工作,我们终于可以监测到以前无法捕获的错误和闪退了。
还有一点,就是调试同时涉及到原生和 React Native 代码的闪退是非常困难的,因为你无法跨平台来追踪调用栈。
原生桥接
React Native 提供了联系原生和 JavaScript 的桥接API。尽管它完全可以正常工作,但是编写起来是在是太『笨重』了。首先,它要求三个平台实现的桥接 API 都完全一致,我们经常遇到的一个问题是从 JavaScript 端传递过来的类型发生错误。举个例子:整型数据会以字符串的格式被包装传递,然后在运行时导致错误。更糟糕的是,在 iOS 平台上这类错误往往会导致静默的异常,这让它们更加难以捕捉。我们在2017年底的时候尝试使用 TypeScript 编写更为安全的桥接代码,但是成效甚微,也太晚了。
初始化时间
在 React Native 初次加载之前,你必须先初始化它的运行时 runtime。不幸的是:像我们这种体量的 App,即使是在性能比较好的手机上,这个初始化时间往往需要耗费几秒钟之多!所以基于 React Native 做启动闪屏页面这种功能几乎是不可能的。不过我们还是让 Runtime 在 App 启动过程中提前加载,来尽可能地缩减这段时间。
页面渲染时间
和渲染原生页面不同,渲染一个 React Native 页面至少要走一个:主线程 -> js线程 -> yoga layout 解析线程 -> 主线程 的完整工作流,来保证拿到渲染 UI 所需要的所有上下文。我们测试发现渲染一个普通的页面在 iOS 端平均需要280毫秒,在 Android 端则是440毫秒。
在 Android 端,我们使用 postponeEnterTransition Api 来做页面的延迟显示。在 iOS 端,我们不得不为所有的 React Native 页面添加一个50毫秒的固定延迟,来解决配置 Navigation Bar 的闪烁问题(注:Airbnb 在 RN 项目中使用了原生的 Navi bar,如果使用 js 重写的 Navi bar 就可以避免这个异常)。
应用的体积
引入 React Natvie 对应用的体积有非常大的副作用。Android 端每个 React Native 包的体积可以达到8M(Java + JS + 依赖原生实现的库比如 Yoga,JS runtime等等),如果在一个 APK 里同时集成 x86 和 arm 平台库的话,可以达到将近12M之多。
64位
因为某个已知的issue,我们目前在 Android 端仍然无法打包64位的 APK。
手势
我们尽量避免使用 React Native 来编写含有复杂手势的页面。因为 iOS 和 Android 的手势系统有很大的区别,所以在 RN 端提供一个统一的手势 API 对整个社区来说都是非常大的挑战。不过相关的工作仍然在不断推进中,目前 react-native-gesture-handler 已经发布了1.0版本。
长列表
React Native 已经在改进长列表性能方面取得了很多进步,它们推出了 FlatList,但是还远不够成熟,和 Android 端的 RecyclerView,iOS 端的 UICollectionView 相比还差很远。因为渲染线程的问题,很多性能瓶颈暂时还很难解决。无法同步获取到数据会导致快速滑动的时候页面闪烁,因为列表内的元素在这种情况下会被异步渲染。Text 组件的高度不能被同步地计算,因为iOS端无法通过提前计算 cell 高度来进行性能优化。
升级 React Native
尽管大部分 React Native 的版本升级改动都不大,但有时候也会让人抓狂。特别是当你从 React Native 0.43 版本(2017年四月)升级到 0.49 版本(2017年十月)的时候,后者使用了 React 16 的 alpha 和 beta 版本,这会导致很多难以解决的问题,因为大部分为 Web 设计的 React 框架不支持 pre-release 的 React 版本。2017年中的一段时间,我们遇到的大部分兼容问题都来自于这个蛋疼的升级过程。
辅助访问
2017年,为了帮助残障人士能正常使用 Airbnb 的服务,我们制定了辅助访问规范。但是现有的 React Native 框架难以帮助我们达到这些规范哪怕最低的标准。因此我们不得不维护一个我们自己的 React Native 代码分支,在上面实现我们需要的功能。这就导致我们花费大量的精力来考虑怎么将我们的改动 merge 到 React Navite 仓库中,然后在 github 上提 merge issue,再花时间去追踪这些 issue 的进度。
诡异的闪退
React Native 经常有一些非常诡异的崩溃。比如说,最近就遇到一个头疼的 bug,即使在和检测到崩溃手机软硬件完全一致的环境下我们怎么也复现不了……
Android 的进程状态保存机制
Android 会经常清理后台进程,但是会给进程同步保存上下文到 bundle 中的机会。但是在 React Native 端,所有状态只能在 js 线程中获取到,所以它们无法被同步访问到。不仅仅是这个问题,当我们使用 Redux 来做保存数据状态的 store 时,它同时包含了那些需要被序列化的状态,和不需要被序列化的状态,这就导致了往往会保存大量多余的数据到 bundle 中去,容量超出导致线上的异常崩溃。