一、引言
Expo 是围绕 React Native 构建的一套开源工具链和服务平台,旨在简化跨平台移动应用的开发流程。作为 React Native 生态系统中最受欢迎的开发框架之一,Expo 提供了从项目初始化、开发调试到构建部署的完整解决方案,使开发者能够使用统一的 JavaScript/TypeScript 代码库同时构建 iOS、Android 和 Web 应用。本文将从架构设计、技术实现细节和工程化实践三个维度,深入剖析 Expo 的核心技术原理,帮助开发者全面理解这一强大的跨平台开发工具。
Expo 的设计理念源于对开发效率的极致追求。传统的 React Native 开发要求开发者具备原生移动开发的知识,能够配置 Xcode、Android Studio 等复杂的开发环境,处理各种原生模块的集成问题。Expo 通过提供预封装的模块集合、托管的构建服务和开箱即用的开发工具链,大大降低了跨平台开发的门槛。同时,Expo 并不牺牲灵活性——开发者可以根据项目需求,在托管工作流和原生工作流之间自由切换,甚至可以渐进式地从托管模式迁移到完全原生模式。
二、Expo 架构设计
2.1 整体架构概述
Expo 的架构设计遵循模块化和分层解耦的原则,整体架构可以分为四个核心层次:应用层、运行时层、构建层和服务层。这种分层设计使得各部分职责明确,便于维护和扩展,同时保证了系统的稳定性和可测试性。
应用层是开发者直接交互的代码层,包括 React 组件、业务逻辑和数据管理。这一层使用标准的 React 和 React Native API,开发者编写的代码具有高度的可移植性,可以在不同的 Expo 项目类型之间共享。应用层通过调用 Expo SDK 提供的 JavaScript API 来访问原生功能,这些 API 在幕后处理了与原生模块通信的所有复杂性。
运行时层是 Expo 架构中最关键的部分,它负责在 JavaScript 引擎和原生平台之间建立桥梁。运行时层的核心组件包括 Expo SDK、Expo Modules 和 JSI(JavaScript Interface)。Expo SDK 提供了一套统一的 JavaScript API,封装了对原生功能的调用;Expo Modules 是原生模块的容器,提供了模块注册、生命周期管理和错误处理等功能;JSI 是 React Native 新架构的核心组件,它允许 JavaScript 代码直接调用原生方法,省去了传统的 Bridge 序列化开销。
构建层负责将应用代码编译成可执行的安装包。这一层主要由 EAS(Expo Application Services)构建服务实现,提供了云端编译、签名管理和多平台打包等功能。EAS Build 支持自定义构建流程,开发者可以通过配置文件定义构建步骤、安装依赖和处理资源文件。对于需要完全原生控制的项目,构建层还提供了 expo prebuild 命令,用于生成完整的原生项目代码。
服务层是 Expo 提供的云端服务集合,包括 EAS Update 热更新服务、EAS Submit 应用商店提交服务和 Expo 应用商店。这些服务为应用的部署和分发提供了端到端的解决方案,使开发者能够专注于业务逻辑的实现,而将运维相关的工作交给 Expo 平台处理。
2.2 托管工作流与原生工作流
Expo 支持两种主要的工作流模式:托管工作流(Managed Workflow)和原生工作流(Bare Workflow)。理解这两种工作流的区别和适用场景,对于选择合适的开发模式至关重要。
托管工作流是 Expo 最推荐的开发模式,特别适合以下场景:快速原型开发、MVP 产品验证、团队缺乏原生开发经验、需要快速迭代的初创项目。在托管工作流模式下,开发者只需要编写 JavaScript/TypeScript 代码,Expo 负责处理所有的原生编译和打包工作。开发者可以通过 Expo Go 应用直接在真机上预览和调试应用,无需配置复杂的原生开发环境。这种模式的核心优势在于极低的使用门槛——开发者只需要安装 Node.js 和一个代码编辑器,就可以开始构建跨平台应用。托管工作流还提供了自动更新功能,用户打开应用时可以自动获取最新版本,确保所有用户都能使用最新功能。
托管工作流的局限性主要体现在定制化方面。由于 Expo 控制了原生项目的生成和管理,开发者无法直接修改原生代码来添加自定义的原生功能。不过,Expo 提供了 Config Plugin 机制来扩展原生配置,一定程度上缓解了这个限制。对于 Expo SDK 未提供的功能,开发者可以通过安装社区维护的 Expo Config Plugins 来集成,或者使用 React Native 社区中兼容 Expo 的第三方库。
原生工作流,也称为裸露工作流或裸机工作流,提供了对原生代码的完全访问权限。当项目通过 expo prebuild 命令生成原生项目后,开发者可以像使用 React Native CLI 创建的项目一样,直接编辑 iOS 和 Android 的原生代码。这种模式适合以下场景:对原生功能有深度定制需求、需要集成 Expo SDK 不支持的第三方原生库、性能敏感的复杂应用、需要在特定平台上进行优化。
原生工作流的使用并不意味着放弃 Expo 提供的工具和服务。生成原生项目后,开发者仍然可以使用 Expo CLI、Expo SDK 和 EAS 服务。主要的区别在于构建过程:原生工作流使用各平台的原生工具链进行编译,而不是依赖 Expo 的托管构建服务。值得注意的是,从托管工作流迁移到原生工作流是一个渐进的过程,开发者可以在项目成熟后逐步增加原生代码的使用,而无需一开始就做出非此即彼的选择。
2.3 运行时架构与 JSI 集成
Expo 的运行时架构经历了从传统 Bridge 到 JSI(JavaScript Interface)的重大演进。React Native 0.68 引入了新架构,JSI 是新架构的核心组件,它彻底改变了 JavaScript 和原生代码之间的通信方式。
传统的 React Native 使用 Bridge 进行 JavaScript 和原生代码之间的通信。Bridge 的工作方式是异步序列化和反序列化:JavaScript 调用原生方法时,参数会被序列化成 JSON 字符串,通过 Bridge 发送到原生端,原生端处理后再将结果序列化返回。这种方式的主要问题在于序列化和反序列化的开销,特别是对于大量数据传输或高频调用的场景,Bridge 往往成为性能瓶颈。
JSI 通过直接方法调用解决了这个问题。在 JSI 架构中,JavaScript 可以持有原生对象的引用,并直接调用这些对象的方法,无需通过 Bridge 进行通信。这种设计带来了几个显著的优势:首先是性能提升,消除了序列化和反序列化的开销,使得同步调用成为可能;其次是简化了原生模块的开发,开发者不需要编写复杂的 Bridge 代码;第三是支持多 JavaScript 引擎,除了 Hermes 之外,还可以集成其他 JavaScript 引擎。
Expo Modules 是在 JSI 基础上构建的模块系统,它为原生模块提供了统一的开发范式。在 Expo Modules 中,原生模块使用 Swift 或 Kotlin 等原生语言编写,通过 Swift/Objective-C 或 Kotlin/Java 的混合方式与 React Native 交互。模块通过 AsyncFunction 装饰器声明异步方法,使用 Promise 或回调处理异步结果。AutoCapabilities 机制自动处理模块的能力声明,如是否需要摄像头权限或位置权限。
// iOS - Swift 示例:创建 Expo 模块
import ExpoModulesCore
public class MyNativeModule: Module {
public func definition() -> ModuleDefinition {
return ModuleDefinition(name: "MyNativeModule") {
// 异步函数
AsyncFunction("getDeviceInfo") { (resolve: @escaping PromiseResolve, reject: @escaping PromiseReject) in
let deviceInfo: [String: Any] = [
"model": UIDevice.current.model,
"systemVersion": UIDevice.current.systemVersion
]
resolve(deviceInfo)
}
// 同步函数(可直接返回)
Function("getConstant") {
return "Hello from Native!"
}
// 监听原生事件
OnViewDidAppear {
// 事件处理逻辑
}
}
}
}
// Android - Kotlin 示例:创建 Expo 模块
package com.myapp.modules
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.AsyncFunction
import expo.modules.kotlin.functions
class MyNativeModule : Module() {
override fun definition() = ModuleDefinition("MyNativeModule") {
// 异步函数
AsyncFunction("getDeviceInfo") { promise ->
val deviceInfo = mapOf(
"model" to android.os.Build.MODEL,
"systemVersion" to android.os.Build.VERSION.SDK_INT
)
promise.resolve(deviceInfo)
}
// 同步函数
Function("getConstant") {
return@Function "Hello from Native!"
}
}
}
三、核心技术细节
3.1 Expo SDK 架构
Expo SDK 是 Expo 平台的核心组件,它提供了一套统一的 JavaScript API,使开发者能够以平台无关的方式访问原生设备功能。SDK 的设计遵循一致性、可预测性和渐进增强的原则,每个模块都经过精心设计,确保在不同平台上提供一致的行为和 API 接口。
Expo SDK 采用 monorepo 结构管理,所有模块都放在同一个代码仓库中,通过 workspaces 机制组织成独立的包。这种结构确保了各模块之间的版本一致性,便于统一发布和版本管理。SDK 包含数十个功能模块,覆盖了移动应用开发的各个方面:基础功能如文件系统、存储、网络请求;设备功能如相机、传感器、地理位置;系统功能如通知、联系人、日历;媒体功能如图像处理、视频播放、音频录制。
每个 Expo SDK 模块都可以独立安装,开发者只需要安装项目所需的具体模块,而不是整个 SDK。例如,如果项目只需要使用相机功能,可以只安装 expo-camera 包。这种按需加载的设计减少了应用的最终包大小,同时保持了代码的模块化和可维护性。模块之间存在依赖关系管理,expo 安装某个模块时,npm/yarn 会自动安装其依赖的模块。
// 安装特定模块(推荐方式)
npx expo install expo-camera
npx expo install expo-location
npx expo install expo-notifications
// 或者使用 npm(需要手动处理版本兼容性)
npm install expo-camera expo-location expo-notifications
// 使用模块的示例代码
import { CameraView, useCameraPermissions } from 'expo-camera';
import * as Location from 'expo-location';
import * as Notifications from 'expo-notifications';
async function setupApp() {
// 请求相机权限
const { status: cameraStatus } = await useCameraPermissions();
// 请求位置权限并获取当前位置
const { status: locationStatus } = await Location.requestForegroundPermissionsAsync();
if (locationStatus === 'granted') {
const location = await Location.getCurrentPositionAsync({});
console.log('当前位置:', location);
}
// 设置推送通知
await Notifications.setNotificationChannelAsync('default', {
name: '默认通知',
importance: Notifications.AndroidImportance.MAX,
});
}
3.2 预构建系统(Prebuild)
Expo prebuild 是 Expo 提供的一个强大功能,它将托管工作流中的项目配置转换为完整的原生项目代码。这个过程解决了托管工作流的灵活性限制问题,使开发者能够在保持 Expo 工具链便利性的同时,获得对原生代码的完全控制。
预构建过程的输入是项目的 app.json/app.config.js 配置文件和 package.json 中的依赖列表。输出是完整的 iOS 和 Android 原生项目目录,包括 Xcode 项目文件(.xcodeproj)、Android Studio 项目文件以及所有原生配置文件。预构建过程会考虑多个因素来生成原生代码:传递给命令的参数(如 --platform 指定目标平台)、app.config.js 中的平台特定配置、项目依赖中声明的 Expo 模块和 Config Plugins。
// app.config.js 配置示例
export default {
expo: {
name: "MyApp",
slug: "my-app",
version: "1.0.0",
orientation: "portrait",
icon: "./assets/icon.png",
userInterfaceStyle: "automatic",
// iOS 配置
ios: {
bundleIdentifier: "com.company.myapp",
supportsTablet: true,
infoPlist: {
NSCameraUsageDescription: "此应用需要访问您的相机",
NSLocationWhenInUseUsageDescription: "此应用需要获取您的位置信息",
},
entitlements: {
"aps-environment": "development",
},
},
// Android 配置
android: {
package: "com.company.myapp",
versionCode: 1,
permissions: [
"CAMERA",
"ACCESS_FINE_LOCATION",
"READ_EXTERNAL_STORAGE",
],
config: {
googleMaps: {
apiKey: "YOUR_GOOGLE_MAPS_API_KEY",
},
},
},
// 插件配置
plugins: [
[
"expo-camera",
{
cameraPermission: "允许应用访问您的相机",
},
],
[
"expo-build-properties",
{
ios: {
deploymentTarget: "13.4",
},
android: {
compileSdkVersion: 34,
targetSdkVersion: 34,
minSdkVersion: 24,
},
},
],
],
// 额外资源
assets: ["./assets/fonts"],
extra: {
eas: {
projectId: "your-project-id",
},
},
},
};
预构建生成的原生项目保持了与 Expo 工具链的兼容性。开发者可以直接使用 EAS Build 进行云端构建,也可以选择使用本地原生工具链进行构建。当需要升级 Expo SDK 版本时,只需更新 package.json 中的版本号,然后重新运行 prebuild 即可。这种设计使得项目的维护和升级变得简单可控。
3.3 开发客户端(Development Client)
Development Client 是 Expo 提供的一个特殊构建版本,它在保留 Expo 工具链便利性的同时,提供了对自定义原生代码的支持。相比 Expo Go,Development Client 是一个可定制的沙箱环境,适合开发阶段需要测试原生功能或 Config Plugins 的场景。
使用 Development Client 的工作流程如下:首先,开发者需要构建一个包含项目特定配置的 Development Client 安装包。这个构建过程可以通过 EAS Build 在线完成,也可以使用本地构建。构建完成后,开发者将生成的安装包安装到设备或模拟器上,然后在应用内启动开发服务器即可开始调试。
# 构建 Development Client
# 方式一:使用 EAS Build 在线构建
eas build --profile development --platform ios
eas build --profile development --platform android
# 方式二:本地构建
npx expo run:ios --configuration Debug
npx expo run:android --variant debug
# 启动开发服务器(带 --dev-client 参数)
npx expo start --dev-client
Development Client 的核心优势在于支持 Config Plugins 的测试。Config Plugins 是 Expo 提供的扩展机制,用于在预构建阶段修改原生项目配置。很多第三方库提供了 Config Plugins 来简化原生代码的集成,但在 Expo Go 环境中无法测试这些插件。Development Client 解决了这个问题,开发者可以在构建时将自定义插件打包到应用中,然后在真实环境中测试插件的效果。
3.4 EAS 构建系统
EAS(Expo Application Services)是 Expo 提供的云端构建和部署服务,它为 React Native 和 Expo 项目提供了完整的 CI/CD 解决方案。EAS 由三个主要服务组成:EAS Build 用于应用构建、EAS Submit 用于应用商店提交、EAS Update 用于热更新。
EAS Build 是整个服务的核心,它提供了托管的构建环境,支持 iOS 和 Android 双平台的构建需求。EAS Build 的构建流程由 eas.json 配置文件定义,开发者可以在配置中指定构建类型、触发条件、凭证管理和自定义构建步骤。
// eas.json 配置文件
{
"cli": {
"version": ">= 5.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"simulator": true
}
},
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": {
"android": {
"buildType": "app-bundle"
},
"ios": {
"simulator": false
}
}
},
"submit": {
"production": {
"android": {
"serviceAccountKeyPath": "./path-to-service-account.json",
"track": "production"
},
"ios": {
"appleId": "your-apple-id@email.com"
}
}
}
}
EAS Build 支持多种构建类型:development 构建生成包含 Metro bundler 的开发版本,适合使用 Development Client 进行调试;preview 构建生成内部测试版本,可以安装到设备上进行真机测试;production 构建生成发布版本,用于应用商店提交。构建过程会自动处理凭证管理,包括 iOS 证书和描述文件、Android 签名密钥等敏感信息。开发者可以通过 EAS Secret 管理敏感的环境变量和 API 密钥,确保这些信息不会泄露到代码仓库中。
构建流程的可定制性是 EAS Build 的一大特色。开发者可以通过 expo-build-properties 插件修改构建参数,也可以在构建配置中指定自定义的 gradle 或 xcodebuild 参数。对于更复杂的构建需求,EAS Build 支持本地构建流程(eas local build),开发者可以在本地机器上使用 Docker 运行完整的云端构建环境,这对于调试构建问题或需要访问本地私有依赖的场景非常有用。
3.5 EAS Update 热更新
EAS Update 是 Expo 提供的热更新服务,它允许开发者在应用发布后无需重新构建和提交应用商店,即可向用户推送 JavaScript 代码和资源的更新。这种能力对于修复紧急 bug、发布新功能和 A/B 测试等场景非常有价值,能够显著缩短迭代周期并提升用户体验。
EAS Update 的工作原理基于 React Native 的代码打包机制。当应用启动时,expo-updates 库会检查是否有可用的更新。如果有更新可用,库会在后台下载新的 JavaScript bundle 和资源文件,下一次应用启动时自动加载新代码。如果没有可用更新或下载失败,应用会使用本地缓存的代码继续运行。这种设计确保了应用的可靠性——即使在网络不可用的情况下,用户仍然可以正常使用应用。
// 配置 EAS Update
import { Updates, useUpdateEffect } from 'expo-updates';
// 在应用启动时检查更新
async function checkForUpdates() {
try {
const update = await Updates.checkForUpdateAsync();
if (update.isAvailable) {
await Updates.fetchUpdateAsync();
// 更新已下载,重启应用以应用更新
await Updates.reloadAsync();
}
} catch (error) {
console.error('检查更新失败:', error);
}
}
// 在 React 组件中使用更新钩子
function App() {
const { isUpdateAvailable, isLoading } = useUpdates();
useUpdateEffect(() => {
// 有可用更新时的处理逻辑
Alert.alert(
'发现新版本',
'新版本已下载,将在下次启动时自动更新。',
[{ text: '确定' }]
);
}, [isUpdateAvailable]);
// ... 应用组件内容
}
// 自定义更新行为
const updateRequest = {
studioCode: {
releaseChannel: 'production',
timeout: 5000,
},
};
// 运行时获取更新
async function fetchUpdatesManually() {
const update = await Updates.checkForUpdateAsync(updateRequest);
if (update.isAvailable) {
const { isNew } = await Updates.fetchUpdateAsync();
if (isNew) {
await Updates.reloadAsync();
}
}
}
EAS Update 引入了分支(Branch)的概念来管理更新发布。每个分支对应一个用户群体或发布阶段,开发者可以将不同的更新推送到不同的分支。例如,可以设置 preview 分支用于内部测试,production 分支用于正式用户。更新发布时使用 eas update 命令:
# 发布更新到预览分支
eas update --branch preview --message "修复登录问题"
# 发布更新到生产分支
eas update --branch production --message "新增用户资料页"
对于需要更精细控制的场景,EAS Update 支持通过代码动态选择分支或更新配置。开发者可以根据用户属性、地区或实验配置来决定加载哪个分支的更新。这种灵活性使得 EAS Update 不仅适用于常规的功能更新,还可以支持灰度发布、功能开关和动态配置等高级场景。
四、工程化实践
4.1 项目结构与代码组织
良好的项目结构是工程化开发的基础。一个典型的 Expo 项目应该遵循清晰的分层架构,将不同职责的代码组织到对应的目录中。下面是一个经过实践验证的项目结构示例:
my-expo-app/
├── src/
│ ├── app/ # 路由和页面组件(使用 Expo Router)
│ │ ├── _layout.tsx # 根布局
│ │ ├── index.tsx # 首页
│ │ ├── (tabs)/ # 标签页路由组
│ │ │ ├── _layout.tsx
│ │ │ ├── home.tsx
│ │ │ └── profile.tsx
│ │ └── settings/
│ │ └── index.tsx
│ ├── components/ # 可复用组件
│ │ ├── ui/ # 基础 UI 组件
│ │ │ ├── Button.tsx
│ │ │ ├── Card.tsx
│ │ │ └── Input.tsx
│ │ ├── forms/ # 表单组件
│ │ │ ├── LoginForm.tsx
│ │ │ └── SignupForm.tsx
│ │ └── business/ # 业务组件
│ │ ├── UserAvatar.tsx
│ │ └── ProductCard.tsx
│ ├── hooks/ # 自定义 Hooks
│ │ ├── useAuth.ts
│ │ ├── useFetch.ts
│ │ └── useTheme.ts
│ ├── services/ # API 和外部服务
│ │ ├── api/
│ │ │ ├── client.ts # API 客户端配置
│ │ │ ├── auth.ts
│ │ │ └── products.ts
│ │ └── storage.ts # 本地存储服务
│ ├── stores/ # 状态管理(Zustand/Jotai)
│ │ ├── authStore.ts
│ │ └── cartStore.ts
│ ├── utils/ # 工具函数
│ │ ├── format.ts
│ │ ├── validation.ts
│ │ └── helpers.ts
│ ├── constants/ # 常量定义
│ │ ├── theme.ts # 主题配置
│ │ ├── colors.ts
│ │ └── config.ts
│ ├── types/ # TypeScript 类型定义
│ │ ├── user.ts
│ │ ├── product.ts
│ │ └── api.ts
│ └── navigation/ # 导航配置(非 Expo Router 项目)
│ └── AppNavigator.tsx
├── app.json # Expo 配置
├── babel.config.js # Babel 配置
├── tsconfig.json # TypeScript 配置
├── metro.config.js # Metro 打包器配置
├── eas.json # EAS 构建配置
└── package.json
对于大型项目,建议进一步采用功能模块化的组织方式。每个功能模块包含其相关的页面、组件、服务和类型定义:
src/
├── features/
│ ├── auth/ # 认证功能模块
│ │ ├── components/
│ │ ├── screens/
│ │ ├── services/
│ │ ├── hooks/
│ │ ├── types/
│ │ └── index.ts # 模块导出
│ ├── products/ # 产品功能模块
│ └── orders/ # 订单功能模块
├── shared/ # 跨模块共享代码
│ ├── components/
│ ├── hooks/
│ └── utils/
└── app/ # 应用入口和路由
4.2 TypeScript 配置与最佳实践
TypeScript 是 Expo 项目的推荐开发语言,它提供了静态类型检查能力,能够在编译阶段发现类型错误,减少运行时 bug。Expo 项目默认使用 TypeScript,无需额外安装或配置。
// tsconfig.json 配置示例
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@hooks/*": ["src/hooks/*"],
"@services/*": ["src/services/*"],
"@stores/*": ["src/stores/*"],
"@utils/*": ["src/utils/*"],
"@types/*": ["src/types/*"]
},
"types": ["jest", "@types/react"],
"skipLibCheck": true,
"esModuleInterop": true,
"moduleResolution": "bundler"
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
],
"exclude": [
"node_modules",
"android",
"ios"
]
}
类型定义文件应该按照功能模块组织,并创建索引文件方便导入:
// src/types/user.ts
export interface User {
id: string;
email: string;
name: string;
avatar?: string;
createdAt: Date;
updatedAt: Date;
}
export interface UserProfile extends User {
bio?: string;
phone?: string;
address?: Address;
}
export interface Address {
street: string;
city: string;
state: string;
country: string;
zipCode: string;
}
export interface LoginCredentials {
email: string;
password: string;
rememberMe?: boolean;
}
export interface AuthResponse {
user: User;
token: string;
refreshToken: string;
expiresIn: number;
}
// src/types/index.ts
export * from './user';
export * from './product';
export * from './api';
// src/types/api.ts
export interface ApiResponse<T> {
data: T;
message?: string;
success: boolean;
}
export interface ApiError {
code: string;
message: string;
details?: Record<string, unknown>;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}
4.3 状态管理方案
对于中大型应用,状态管理是架构设计中的重要环节。Expo 项目可以使用多种状态管理方案,常见的选择包括 Zustand、Jotai、Recoil 和 Redux Toolkit。选择时需要考虑项目的规模、团队的技术背景和特定的功能需求。
Zustand 是一个轻量级的状态管理库,它提供了简洁的 API 和优秀的性能,特别适合中小型应用:
// src/stores/authStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { User, LoginCredentials } from '@/types';
import { authService } from '@/services/api/auth';
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
// Actions
login: (credentials: LoginCredentials) => Promise<void>;
logout: () => Promise<void>;
refreshToken: () => Promise<void>;
updateUser: (user: Partial<User>) => void;
clearError: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
error: null,
login: async (credentials) => {
set({ isLoading: true, error: null });
try {
const response = await authService.login(credentials);
set({
user: response.user,
token: response.token,
isAuthenticated: true,
isLoading: false,
});
} catch (error) {
set({
error: error instanceof Error ? error.message : '登录失败',
isLoading: false,
});
throw error;
}
},
logout: async () => {
set({ isLoading: true });
try {
await authService.logout();
} finally {
set({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
});
}
},
refreshToken: async () => {
const { token } = get();
if (!token) return;
try {
const response = await authService.refreshToken(token);
set({ token: response.token });
} catch (error) {
await get().logout();
}
},
updateUser: (userData) => {
const { user } = get();
if (user) {
set({ user: { ...user, ...userData } });
}
},
clearError: () => set({ error: null }),
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
user: state.user,
token: state.token,
isAuthenticated: state.isAuthenticated,
}),
}
)
);
Jotai 是另一种值得考虑的选择,它基于原子(Atom)的概念,提供了更细粒度的状态管理:
// src/stores/cartStore.ts(使用 Jotai)
import { atom } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Product, CartItem } from '@/types';
// 持久化存储适配器
const storage = createJSONStorage<CartItem[]>(() => AsyncStorage);
// 购物车状态原子
const cartItemsAtom = atomWithStorage<CartItem[]>(
'cart-storage',
[],
storage
);
// 计算属性原子
export const cartCountAtom = atom((get) => {
const items = get(cartItemsAtom);
return items.reduce((total, item) => total + item.quantity, 0);
});
export const cartTotalAtom = atom((get) => {
const items = get(cartItemsAtom);
return items.reduce(
(total, item) => total + item.price * item.quantity,
0
);
});
// 操作原子
export const addToCartAtom = atom(
null,
(get, set, product: Product) => {
const items = get(cartItemsAtom);
const existingIndex = items.findIndex(
(item) => item.id === product.id
);
if (existingIndex >= 0) {
const updatedItems = [...items];
updatedItems[existingIndex].quantity += 1;
set(cartItemsAtom, updatedItems);
} else {
set(cartItemsAtom, [
...items,
{ ...product, quantity: 1 },
]);
}
}
);
// 在组件中使用
// import { useAtom, useSetAtom } from 'jotai';
// import { cartCountAtom, addToCartAtom } from '@/stores/cartStore';
//
// const cartCount = useAtomValue(cartCountAtom);
// const addToCart = useSetAtom(addToCartAtom);
4.4 API 层设计与数据获取
合理设计 API 层对于应用的可维护性和可测试性至关重要。推荐使用 Axios 或 Fetch API 结合自定义的 API 客户端层,封装请求配置、错误处理和响应转换逻辑:
// src/services/api/client.ts
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
import { router } from 'expo-router';
import { useAuthStore } from '@/stores/authStore';
const BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'https://api.example.com';
class ApiClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: BASE_URL,
timeout: 15000,
headers: {
'Content-Type': 'application/json',
},
});
this.setupInterceptors();
}
private setupInterceptors() {
// 请求拦截器:添加认证令牌
this.client.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = useAuthStore.getState().token;
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 响应拦截器:统一错误处理
this.client.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
if (error.response?.status === 401) {
// Token 过期,尝试刷新
const refreshed = await this.handleTokenRefresh();
if (refreshed) {
// 重试原请求
const originalRequest = error.config;
if (originalRequest) {
return this.client(originalRequest);
}
} else {
// 刷新失败,登出
await useAuthStore.getState().logout();
router.replace('/login');
}
}
return Promise.reject(this.normalizeError(error));
}
);
}
private async handleTokenRefresh(): Promise<boolean> {
try {
await useAuthStore.getState().refreshToken();
return true;
} catch {
return false;
}
}
private normalizeError(error: AxiosError): Error {
const message =
error.response?.data &&
typeof error.response.data === 'object' &&
'message' in error.response.data
? (error.response.data as { message: string }).message
: error.message || '网络请求失败';
return new Error(message);
}
async get<T>(url: string, params?: Record<string, unknown>): Promise<T> {
const response = await this.client.get<T>(url, { params });
return response.data;
}
async post<T>(url: string, data?: unknown): Promise<T> {
const response = await this.client.post<T>(url, data);
return response.data;
}
async put<T>(url: string, data?: unknown): Promise<T> {
const response = await this.client.put<T>(url, data);
return response.data;
}
async delete<T>(url: string): Promise<T> {
const response = await this.client.delete<T>(url);
return response.data;
}
async patch<T>(url: string, data?: unknown): Promise<T> {
const response = await this.client.patch<T>(url, data);
return response.data;
}
}
export const apiClient = new ApiClient();
// src/services/api/products.ts
import { apiClient } from './client';
import { ApiResponse, PaginatedResponse, Product, ProductFilter } from '@/types';
export const productService = {
async getProducts(
filter?: ProductFilter,
page = 1,
pageSize = 20
): Promise<PaginatedResponse<Product>> {
return apiClient.get<PaginatedResponse<Product>>('/products', {
...filter,
page,
pageSize,
});
},
async getProductById(id: string): Promise<ApiResponse<Product>> {
return apiClient.get<ApiResponse<Product>>(`/products/${id}`);
},
async createProduct(data: Partial<Product>): Promise<ApiResponse<Product>> {
return apiClient.post<ApiResponse<Product>>('/products', data);
},
async updateProduct(
id: string,
data: Partial<Product>
): Promise<ApiResponse<Product>> {
return apiClient.put<ApiResponse<Product>>(`/products/${id}`, data);
},
async deleteProduct(id: string): Promise<void> {
await apiClient.delete(`/products/${id}`);
},
};
4.5 环境配置与敏感信息管理
敏感信息(如 API 密钥、第三方服务凭证)的安全管理是应用安全的重要环节。Expo 提供了多种机制来处理不同环境的配置需求。
对于公开的配置,可以使用 app.json 中的 extra 字段或环境变量:
// app.config.js
const isDev = process.env.NODE_ENV === 'development';
export default {
expo: {
// ... 其他配置
extra: {
eas: {
projectId: 'your-project-id',
},
// 公开环境变量(会被包含在构建中)
apiUrl: process.env.EXPO_PUBLIC_API_URL || 'https://api.example.com',
enableAnalytics: !isDev,
},
},
};
// 使用公开配置
import Constants from 'expo-constants';
const config = Constants.expoConfig?.extra;
const apiUrl = config.apiUrl as string;
对于敏感信息,应该使用 EAS Secrets 进行管理:
# 添加 EAS Secret
eas secret:create --name STRIPE_SECRET_KEY --value "sk_test_xxx"
eas secret:create --name GOOGLE_MAPS_API_KEY --value "AIzaSyxxx" --scope production
# 查看 Secret 列表
eas secret:list
// 在代码中使用 Secret
// EAS Secrets 会自动注入到环境变量中
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
const googleMapsApiKey = process.env.GOOGLE_MAPS_API_KEY;
4.6 测试策略
完善的测试是保证代码质量的关键。Expo 项目可以使用 Jest 作为测试框架,配合 React Native Testing Library 进行组件测试:
// src/components/__tests__/Button.test.tsx
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import { Button } from '../Button';
describe('Button Component', () => {
it('renders correctly with default props', () => {
const { getByText } = render(<Button>Click Me</Button>);
expect(getByText('Click Me')).toBeTruthy();
});
it('calls onPress when pressed', async () => {
const onPressMock = jest.fn();
const { getByText } = render(
<Button onPress={onPressMock}>Click Me</Button>
);
fireEvent.press(getByText('Click Me'));
await waitFor(() => {
expect(onPressMock).toHaveBeenCalledTimes(1);
});
});
it('is disabled when disabled prop is true', () => {
const onPressMock = jest.fn();
const { getByText } = render(
<Button onPress={onPressMock} disabled>Click Me</Button>
);
fireEvent.press(getByText('Click Me'));
expect(onPressMock).not.toHaveBeenCalled();
});
it('shows loading indicator when loading', () => {
const { queryByText, getByTestId } = render(
<Button loading loadingText="Loading...">Click Me</Button>
);
expect(queryByText('Click Me')).toBeNull();
expect(getByTestId('loading-indicator')).toBeTruthy();
});
});
// src/hooks/__tests__/useAuth.test.ts
import { renderHook, act, waitFor } from '@testing-library/react-native';
import { useAuth } from '../useAuth';
import { MockAuthService } from '@/services/__mocks__/auth';
jest.mock('@/services/api/auth', () => ({
authService: new MockAuthService(),
}));
describe('useAuth Hook', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('initializes with unauthenticated state', () => {
const { result } = renderHook(() => useAuth());
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
});
it('logs in successfully', async () => {
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.login({
email: 'test@example.com',
password: 'password123',
});
});
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user?.email).toBe('test@example.com');
});
});
it('handles login error', async () => {
const { result } = renderHook(() => useAuth());
await act(async () => {
try {
await result.current.login({
email: 'invalid@example.com',
password: 'wrongpassword',
});
} catch (error) {
// Expected error
}
});
expect(result.current.error).toBeTruthy();
});
});
五、实战代码示例
5.1 完整的应用模块实现
以下是一个使用 Expo Router 构建的完整功能模块示例,包含用户认证、列表展示和表单处理:
// src/app/(auth)/login.tsx
import { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert,
} from 'react-native';
import { Link, router } from 'expo-router';
import { useAuthStore } from '@/stores/authStore';
import { useTheme } from '@/hooks/useTheme';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
export default function LoginScreen() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuthStore();
const { colors } = useTheme();
const handleLogin = async () => {
if (!email || !password) {
Alert.alert('错误', '请填写所有必填字段');
return;
}
setIsLoading(true);
try {
await login({ email, password });
router.replace('/(tabs)/home');
} catch (error) {
Alert.alert(
'登录失败',
error instanceof Error ? error.message : '登录时发生错误'
);
} finally {
setIsLoading(false);
}
};
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={[styles.container, { backgroundColor: colors.background }]}
>
<View style={styles.content}>
<Text style={[styles.title, { color: colors.text }]}>欢迎回来</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>
请登录您的账户继续
</Text>
<View style={styles.form}>
<Input
label="邮箱"
placeholder="请输入邮箱地址"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
/>
<Input
label="密码"
placeholder="请输入密码"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity style={styles.forgotPassword}>
<Link href="/(auth)/forgot-password">
<Text style={[styles.forgotPasswordText, { color: colors.primary }]}>
忘记密码?
</Text>
</Link>
</TouchableOpacity>
<Button
onPress={handleLogin}
loading={isLoading}
disabled={isLoading}
style={styles.loginButton}
>
登录
</Button>
</View>
<View style={styles.footer}>
<Text style={[styles.footerText, { color: colors.textSecondary }]}>
还没有账户?
</Text>
<Link href="/(auth)/signup" asChild>
<TouchableOpacity>
<Text style={[styles.signUpLink, { color: colors.primary }]}>
立即注册
</Text>
</TouchableOpacity>
</Link>
</View>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: 24,
},
title: {
fontSize: 28,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
textAlign: 'center',
marginBottom: 32,
},
form: {
gap: 16,
},
forgotPassword: {
alignSelf: 'flex-end',
marginTop: -8,
marginBottom: 8,
},
forgotPasswordText: {
fontSize: 14,
},
loginButton: {
marginTop: 8,
},
footer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
marginTop: 32,
gap: 4,
},
footerText: {
fontSize: 14,
},
signUpLink: {
fontSize: 14,
fontWeight: '600',
},
});
// src/app/(tabs)/products.tsx
import { useCallback, useState } from 'react';
import {
View,
FlatList,
StyleSheet,
RefreshControl,
Text,
} from 'react-native';
import { useFocusEffect } from 'expo-router';
import { productService } from '@/services/api/products';
import { useProductStore } from '@/stores/productStore';
import { Product } from '@/types';
import { ProductCard } from '@/components/business/ProductCard';
import { EmptyState } from '@/components/ui/EmptyState';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
export default function ProductsScreen() {
const [isRefreshing, setIsRefreshing] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const { products, isLoading, fetchProducts, loadMore, hasMore } =
useProductStore();
useFocusEffect(
useCallback(() => {
fetchProducts();
}, [])
);
const handleRefresh = async () => {
setIsRefreshing(true);
await fetchProducts();
setIsRefreshing(false);
};
const handleLoadMore = async () => {
if (!isLoadingMore && hasMore) {
setIsLoadingMore(true);
await loadMore();
setIsLoadingMore(false);
}
};
const renderItem = useCallback(
({ item }: { item: Product }) => (
<ProductCard product={item} />
),
[]
);
const renderFooter = () => {
if (!isLoadingMore) return null;
return <LoadingSpinner />;
};
const renderEmpty = () => {
if (isLoading) return <LoadingSpinner />;
return (
<EmptyState
title="暂无产品"
description="暂时没有可显示的产品"
icon="package"
/>
);
};
return (
<View style={styles.container}>
<FlatList
data={products}
renderItem={renderItem}
keyExtractor={(item) => item.id}
numColumns={2}
columnWrapperStyle={styles.row}
contentContainerStyle={styles.listContent}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
/>
}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.3}
ListFooterComponent={renderFooter}
ListEmptyComponent={renderEmpty}
showsVerticalScrollIndicator={false}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
listContent: {
padding: 16,
flexGrow: 1,
},
row: {
justifyContent: 'space-between',
},
});
5.2 自定义原生模块开发
当 Expo SDK 提供的功能无法满足需求时,可以通过 Expo Modules 开发自定义原生模块:
// src/native/MyCustomModule.ts
import { NativeModules, Platform } from 'react-native';
// 定义 TypeScript 接口
interface MyCustomModuleInterface {
getDeviceId(): Promise<string>;
getSystemInfo(): Promise<{
model: string;
systemVersion: string;
manufacturer: string;
}>;
setClipboard(content: string): Promise<void>;
getClipboard(): Promise<string>;
}
// 获取原生模块
const { MyCustomModule } = NativeModules;
// 包装函数,提供更好的 TypeScript 支持和错误处理
export const MyCustomModuleAPI: MyCustomModuleInterface = {
async getDeviceId(): Promise<string> {
if (Platform.OS === 'web') {
// Web 平台的降级实现
return `web-${Date.now()}`;
}
return MyCustomModule.getDeviceId();
},
async getSystemInfo() {
if (Platform.OS === 'web') {
return {
model: 'Web Browser',
systemVersion: navigator.userAgent,
manufacturer: 'Unknown',
};
}
return MyCustomModule.getSystemInfo();
},
async setClipboard(content: string): Promise<void> {
if (Platform.OS === 'web') {
await navigator.clipboard.writeText(content);
return;
}
return MyCustomModule.setClipboard(content);
},
async getClipboard(): Promise<string> {
if (Platform.OS === 'web') {
return await navigator.clipboard.readText();
}
return MyCustomModule.getClipboard();
},
};
// ios/LocalPods/MyCustomModule/MyCustomModule.swift
import Foundation
import ExpoModulesCore
public class MyCustomModule: Module {
public func definition() -> ModuleDefinition {
return ModuleDefinition(name: "MyCustomModule") {
// 获取设备 ID
AsyncFunction("getDeviceId") { () -> String in
return UIDevice.current.identifierForVendor?.uuidString ?? "unknown"
}
// 获取系统信息
AsyncFunction("getSystemInfo") { () -> [String: String] in
return [
"model": self.getDeviceModel(),
"systemVersion": UIDevice.current.systemVersion,
"manufacturer": "Apple"
]
}
// 设置剪贴板
AsyncFunction("setClipboard") { (content: String) in
UIPasteboard.general.string = content
}
// 获取剪贴板
AsyncFunction("getClipboard") { () -> String? in
return UIPasteboard.general.string
}
}
}
private func getDeviceModel() -> String {
var systemInfo = utsname()
uname(&systemInfo)
let machineMirror = Mirror(reflecting: systemInfo.machine)
let identifier = machineMirror.children.reduce("") { identifier, element in
guard let value = element.value as? Int8, value != 0 else { return identifier }
return identifier + String(UnicodeScalar(UInt8(value)))
}
return mapToDevice(identifier: identifier)
}
private func mapToDevice(identifier: String) -> String {
switch identifier {
case "iPhone14,2": return "iPhone 13 Pro"
case "iPhone14,3": return "iPhone 13 Pro Max"
case "iPhone14,4": return "iPhone 13 mini"
case "iPhone14,5": return "iPhone 13"
default: return identifier
}
}
}
// android/app/src/main/java/com/myapp/modules/MyCustomModule.kt
package com.myapp.modules
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.AsyncFunction
class MyCustomModule : Module() {
override fun definition() = ModuleDefinition("MyCustomModule") {
// 获取设备 ID
AsyncFunction("getDeviceId") {
return@AsyncFunction android.provider.Settings.Secure.getString(
appContext.contentResolver,
android.provider.Settings.Secure.ANDROID_ID
) ?: "unknown"
}
// 获取系统信息
AsyncFunction("getSystemInfo") {
return@AsyncFunction mapOf(
"model" to "${Build.MANUFACTURER} ${Build.MODEL}",
"systemVersion" to Build.VERSION.RELEASE,
"manufacturer" to Build.MANUFACTURER
)
}
// 设置剪贴板
AsyncFunction("setClipboard") { content: String ->
val clipboard = appContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("label", content)
clipboard.setPrimaryClip(clip)
}
// 获取剪贴板
AsyncFunction("getClipboard") {
val clipboard = appContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
return@AsyncFunction clipboard.primaryClip?.getItemAt(0)?.text?.toString() ?: ""
}
}
}
六、总结与展望
Expo 作为 React Native 生态中最成熟的开发平台,通过其精心设计的架构和丰富的功能集,为跨平台移动应用开发提供了高效、可靠的解决方案。从托管工作流的零配置开发体验,到原生工作流的完全自定义能力,Expo 为不同阶段的团队提供了灵活的选择。
在架构层面,Expo 采用的分层设计和 JSI 集成确保了与现代 React Native 新架构的兼容性,同时保证了良好的性能表现。EAS 服务的引入使得构建和部署流程工业化,大大提升了团队的开发效率。热更新能力则赋予了应用快速迭代的能力,让开发者能够在不依赖应用商店审核的情况下即时修复问题。
对于工程化实践,Expo 项目应该遵循良好的代码组织规范,建立完善的状态管理和 API 层设计,实施严格的测试策略,并妥善管理敏感信息。这些实践不仅提升了代码质量,也为项目的长期维护奠定了基础。
展望未来,Expo 正在向更广阔的平台扩展,除了 iOS、Android 和 Web 之外,还在探索 Vision Pro、TV 和车载系统等新平台的支持。随着 AI 技术的快速发展,Expo 也在积极集成智能化开发工具,如基于大语言模型的代码生成、错误诊断和构建优化等功能。可以预见,Expo 将继续引领跨平台开发工具的发展,为开发者创造更大的价值。