Obi Madu 的博客
返回所有文章
MobileSystem DesignInfrastructure

React Native 与 Expo:让你顿悟的思维模型

深入了解 React Native 和 Expo 的真实工作原理,从原生构建到 OTA 更新以及开发工作流。

React Native 与 Expo:让你顿悟的思维模型

如果你主要来自 Web 开发背景,React Native 非常容易被误解。你编写标准的 React 组件,使用 JavaScript 或 TypeScript,运行一个感觉很熟悉的开发服务器,并获得快得令人难以置信的刷新能力。因为这种工作流感觉与在 Web 上构建 React 非常接近,所以很容易诱导人们将 React Native 仅仅视为一个巧妙地包裹在移动端外壳中的 Web 应用程序。

这种思维模型是完全错误的。

React Native 构建的是真正的原生移动应用。UI 根本不是渲染到浏览器的 DOM 中。这里没有 divspan 元素,更绝对没有伪装成应用程序的隐藏网页。相反,你的 React 代码只是描述了一个原生界面,而 React Native 充当引擎,将这种描述转化为真正的 iOS 和 Android 组件。然后,Expo 在 React Native 的基础上更进一步,消除了移动开发历史上各种棘手的问题,提供了原生配置、开发构建、基于文件的路由、OTA 更新、云构建以及简化了对常见设备 API 的访问。一旦你深刻理解了原生运行环境和 JavaScript Bundle 之间的边界,整个生态系统就会变得容易理解得多。

React Native 不是 React DOM

面向 Web 的 React 直接渲染到浏览器 DOM 中,而 React Native 渲染到操作系统提供的原生 UI 原语中。

React WebReact Native
divView
spanpText
imgImage
浏览器 DOM原生的 iOS 和 Android 视图

当你编写类似这样的代码时:

<View style={{ backgroundColor: "red" }}>
  <Text>Hello</Text>
</View>

React Native 在底层不会生成 HTML。在 iOS 上,它创建原生的 UIKit 视图,在 Android 上,它创建原生的 Android 视图。这种结构上的区别对性能、样式、无障碍访问、手势、原生模块以及部署都有着极其重要的影响。你发布的不是一个网站;你发布的是一个编译好的原生应用程序,只是它的行为恰好由 JavaScript 引擎驱动。

每个 React Native 应用内部的两个程序

在 React Native 中,最关键的一个概念是:你的应用实际上是两个独立的程序在无缝协作。

你的移动应用

原生运行环境 <-> JavaScript Bundle

原生运行环境就是编译后的应用程序本身。它使用平台原生语言编写,并严格使用原生工具构建。在 iOS 上,这涉及到 Xcode、Swift 和 Objective-C。在 Android 上,这意味着依赖 Gradle、Kotlin 和 Java。相反,JavaScript Bundle 是你实际的 React 应用程序代码,它包含了你的屏幕、组件、导航层级、状态管理以及核心的业务逻辑。

原生运行环境容纳了 JavaScript 引擎、自定义原生模块、权限、应用程序元数据、应用图标、启动屏幕,以及允许 JavaScript 与原生 API 对话的关键桥梁。它是编译后的、签名的、平台专用的,最重要的是,如果没有通过应用商店进行正式的原生更新,它不能在用户的设备上被更改。而 JavaScript Bundle 则灵活得多。在开发期间,它可以通过本地网络直接从你的电脑无缝加载。在生产环境中,它通常被安全地打包到应用二进制文件中,但是借助 OTA 更新,它通常可以即时更新,而无需进行完整的应用商店审核。

这里使用浏览器的比喻通常很有帮助:原生运行环境就像 Chrome 浏览器,而 JavaScript Bundle 就像网站。你可以无休止地向网站推送更新,而无需用户更新 Chrome,但是如果你需要 Chrome 缺乏的全新浏览器功能,那么浏览器本身就必须不可避免地进行更新。

什么情况真正需要原生重新构建

这种基本分离解释了新手最常见的摩擦和困惑来源之一:为什么有些更改可以在屏幕上立即更新,而其他更改却令人沮丧地需要完整的原生重新构建。

更改内容需要原生重新构建吗?支持 OTA 更新吗?
修改 UI 文案
添加新屏幕
修复业务逻辑
添加纯 JavaScript 库
添加原生库
更改应用图标
更改权限

如果更改专门针对 JavaScript,它通常可以立即刷新,并且通常符合 OTA 更新的条件。但是,如果更改影响了原生设备功能、系统权限、二进制元数据或引入了新的原生模块,你就绝对需要一个新的原生构建版本。掌握这一条规则可以解释 React Native 生态系统中几乎每一个开发工作流的决策。

Expo 增加了什么

Expo 是一个专门建立在 React Native 之上的综合平台。它开箱即用地提供了一个更完整的开发环境,并巧妙地隐藏了绝大部分原生构建的复杂性,直到你真正需要与之交互。

在一个由 Expo 管理的项目中,你几乎永远看不到传统的 ios/android/ 文件夹。相反,你可以通过 app.jsonapp.config.js、智能的配置插件以及安装的各种包来优雅地配置原生行为。当最终需要构建应用程序时,Expo 可以通过一个命令为你干净地生成所需的原生项目:

npx expo prebuild

这个强大的命令完全基于你的高级配置和列出的依赖项来创建原生的 ios/android/ 项目。Expo 还提供经过严格测试的原生模块、出色的开发工具、通过 Expo Router 实现的基于文件的路由、用于托管云构建的 EAS Build、用于自动应用商店提交的 EAS Submit,以及用于部署 OTA JavaScript 更新的 EAS Update。最核心的一点不是 Expo 让原生代码不复存在——它只是代表你管理了它的复杂性。

Expo Go 对比 开发构建版本

Expo Go 是一个非常方便的、由 Expo 团队积极维护的预构建应用程序。你可以直接从应用商店安装它,在你的机器上运行 npx expo start,用手机扫描二维码,Expo Go 就会立即加载你的 JavaScript Bundle。这对于学习 React Native 和快速构建想法原型来说是极其棒的,因为在物理设备上看到应用运行之前,你不需要进行任何原生编译。

其主要限制是 Expo Go 仅包含 Expo 团队已经内置的特定原生模块。你基本上无法在其中原生测试每一个可能的原生依赖项、自定义应用图标行为、生产环境启动屏幕机制、通用链接或复杂的远程推送通知设置。

开发构建版本则完全不同。它是你专属的应用程序二进制文件,专门为开发而构建,并且安装了 expo-dev-client 库。你可以简单地将其视为你自己定制版本的 Expo Go。它实际上包含了你的应用程序在生产环境中合法运行所需的特定原生模块。

功能Expo Go开发构建版本
安装设置从应用商店安装构建你自己的应用
原生库仅限于 Expo Go 包含的模块你引入的任何原生库
应用图标与启动屏不是你最终的应用程序你真实的应用程序配置
推送通知受限真实场景测试
最佳用途学习与原型设计正式的应用程序开发

实用的经验法则非常简单:如果 Expo Go 提供了足够的功能来满足你当前的需求,就从它开始;但是,一旦你的应用程序需要 Expo Go 根本无法提供的原生行为或第三方库,就立即切换到开发构建版本。

开发和生产环境加载 JavaScript 的方式不同

在开发环境中,你的物理手机通常会持续从 Metro 加载 JavaScript Bundle,Metro 就是在你电脑上无缝运行的本地开发服务器。

运行 Expo Go 或开发构建版本的手机
        |
        | 请求 JavaScript Bundle
        v
你电脑上的 Metro 开发服务器

这种机制正是为什么你的手机和电脑通常需要连接到完全相同的 Wi-Fi 网络的原因。这也是 Fast Refresh 如此完美运行的根本原因——应用程序不断地从活动的开发服务器轮询代码。

在生产环境中,架构发生了根本性变化。最终确定的 JavaScript Bundle 被安全地打包在应用程序的二进制文件内部。

YourApp.apk 或 YourApp.ipa
  原生代码
  资源文件 (assets)
  main.jsbundle

生产环境的应用显然不需要连接到你的本地电脑。它可以在离线状态下完美启动,并且 JavaScript 已经过高度优化并为性能进行了显式打包。EAS Update 引入了一种引人注目的混合模型。应用最初发布时内部安全地捆绑了一个 JavaScript 版本,但在启动时,它可以悄悄检查较新的 JavaScript 更新并在设备上动态缓存它们。这种能力对于无缝发布错误修复和 UI 更新非常强大,但它带有一个坚硬、不可屈服的边界:OTA 更新绝对不能添加新的原生功能或模块。

Expo 项目的结构

一个受 Expo 管理的项目通常旨在将纯 JavaScript 代码、全局原生配置、静态资源和基本构建配置清晰地分开。

your-project/
  app/              屏幕和路由
  components/       可复用的 UI
  hooks/            自定义 hooks
  constants/        共享常量
  assets/           图片和字体
  app.json          原生应用配置
  package.json      依赖项
  eas.json          EAS 构建与更新配置

app.json 文件全面控制着关键的原生应用程序元数据。

{
  "expo": {
    "name": "My App",
    "slug": "my-app",
    "icon": "./assets/icon.png",
    "android": {
      "package": "com.company.myapp"
    },
    "ios": {
      "bundleIdentifier": "com.company.myapp"
    }
  }
}

package.json 文件至关重要,因为其中列出的依赖项经常可能包含原生模块。安装一个纯 JavaScript 包和安装一个捆绑了原生代码的包是根本不同的操作,对你的构建流水线有着截然不同的影响。

React Native 仍然是前端代码

虽然 React Native 可以让移动应用感觉非常原生,但它绝对不会神奇地将前端代码变成后端代码。React Native 应用绝对不应该直接连接到生产环境的数据库。它不应该包含原始数据库凭据,并且永远不应该在其 Bundle 中存储私有 API 密钥。APK 和 IPA 文件可以被任何人轻易检查,JavaScript Bundle 也很容易被恶意行为者提取。

正确且安全的架构与大多数现代前端系统完全相同:

移动应用 <-> 后端 API <-> 数据库

移动应用应该严格通过安全的 HTTPS 与你的 API 进行通信。然后,你的 API 将安全地处理复杂的身份验证、稳健的授权、彻底的验证、严格的速率限制和私有数据库访问。数据库保持完全私有,并且对移动客户端隐藏。在客户端代码中暴露 API URL 是完全正常的,因为用户已经可以很容易地拦截并查看自己设备的网络流量。真正的安全并不是来自于隐藏 API URL;它完全来自于适当的身份验证、授权和严格的服务器端执行策略。

像 Supabase 和 Firebase 这样的 Backend-as-a-service 工具仍然严格遵守这种模式。客户端安全地与作为中间层的 API 对话,永远不会直接使用具有无限访问权限的原始数据库凭据。例如,Supabase 的公共 anon 密钥明确设计为可以安全地暴露给客户端,但它不可避免地必须与适当的身份验证以及数据库端严格的行级安全相结合。

const supabase = createClient(
  "https://xyz.supabase.co",
  "public-anon-key"
);

const { data } = await supabase
  .from("interviews")
  .select("*");

底层的安全性固有地来自于在 API 背后主动运行的严格策略,而不是假装客户端密钥是一个严密保守的秘密。

应该保持的思维模型

最终,React Native 只是一个运行着功能强大的 JavaScript 应用程序的原生容器。Expo 则充当了健壮的平台,使该容器更易于配置、构建、无缝更新,并最终交付给用户。Expo Go 是一个极其有用的共享预构建容器,而开发构建版本则是你完全定制的专属容器。OTA 更新独特地改变了在容器内部运行的 JavaScript,这与容器本身完全独立。

一旦你清楚地看到并尊重这个边界,整个生态系统就会变得更加合理。你会本能地理解为什么添加新屏幕是瞬时的,为什么添加原生库需要新的构建,为什么 Expo Go 最终不足以满足复杂应用的需求,以及为什么移动应用总是需要一个安全、专属的后端。这就是将整个 React Native 和 Expo 的学习曲线浓缩成一个单一的核心概念:明白什么安全地生存在 JavaScript 中,什么严格地生存在原生代码中,以及确切地什么跨越了它们之间关键的边界。

参考资料