本文档详细对比了Expo项目与原生React Native项目在目录结构、路由机制、Tab导航等方面的核心差异。
📋 目录
📁 目录结构对比
原生React Native项目 (使用 react-native init 创建)
my-app/
├── android/ ⚠️ 原生Android代码目录
│ ├── app/
│ ├── gradle/
│ └── build.gradle
├── ios/ ⚠️ 原生iOS代码目录
│ ├── MyApp/
│ ├── MyApp.xcodeproj
│ └── Podfile
├── src/
│ ├── screens/
│ ├── components/
│ └── navigation/ ⚠️ 需要手动配置路由
├── App.tsx
├── index.js ⚠️ 入口文件
├── metro.config.js
├── babel.config.js
└── package.json
Expo项目 (使用 npx create-expo-app 创建)
my-app/
├── app/ ✨ 基于文件的路由目录(pages)
│ ├── (tabs)/ ✨ 分组路由(Tab导航)
│ │ ├── _layout.tsx ✨ Tab布局配置
│ │ ├── index.tsx → 路由: /
│ │ └── explore.tsx → 路由: /explore
│ ├── _layout.tsx ✨ 根布局
│ ├── modal.tsx → 路由: /modal
│ └── pages/user/
│ └── index.tsx → 路由: /pages/user
│
├── components/ ✨ 可复用UI组件(非路由)
├── constants/
├── hooks/
├── assets/
├── app.json ✨ Expo配置文件
├── babel.config.js
└── package.json
🔑 核心区别详解
1. 原生代码目录
| 特性 | 原生RN | Expo |
|---|---|---|
| android/ | ✅ 包含完整Android原生代码 | ❌ 默认不包含(托管) |
| ios/ | ✅ 包含完整iOS原生代码 | ❌ 默认不包含(托管) |
| 原生模块 | ✅ 完全自由添加 | ⚠️ 受限于Expo SDK |
| 配置复杂度 | 🔴 高(需配置Gradle/Podfile) | 🟢 低(app.json统一管理) |
| 弹出选项 | - | ✅ 可用 expo prebuild 生成原生目录 |
说明:
- Expo默认使用托管工作流(Managed Workflow),无需接触原生代码
- 如需使用Expo不支持的原生模块,运行
npx expo prebuild转为裸工作流(Bare Workflow)
2. 路由系统
原生RN - 代码式路由(React Navigation)
tsx
// src/navigation/AppNavigator.tsx
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
const Stack = createNativeStackNavigator();
export default function AppNavigator() {
return (
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="User" component={UserScreen} />
<Stack.Screen name="Modal" component={ModalScreen} />
</Stack.Navigator>
</NavigationContainer>
);
}
使用路由:
tsx
// 需要手动导航
navigation.navigate('User');
Expo - 文件系统路由(Expo Router)
app/
├── index.tsx → 自动映射路由: /
├── user.tsx → 自动映射路由: /user
└── modal.tsx → 自动映射路由: /modal
使用路由:
tsx
import { useRouter } from 'expo-router';
const router = useRouter();
router.push('/user'); // 直接使用路径
对比表格:
| 特性 | 原生RN | Expo Router |
|---|---|---|
| 路由配置 | 手动在代码中注册 | 文件结构即路由 |
| 新增页面 | 1. 创建组件 2. 注册路由 3. 配置导航 | 1. 创建文件 ✅ |
| 路由跳转 | navigation.navigate('ScreenName') |
router.push('/path') |
| 类型安全 | ⚠️ 需手动配置TypeScript | ✅ 自动生成类型 |
| URL支持 | ❌ 需额外配置 | ✅ 原生支持 |
3. 入口文件
原生RN
js
// index.js
import { AppRegistry } from 'react-native';
import App from './App';
AppRegistry.registerComponent('MyApp', () => App);
Expo
json
// package.json
{
"main": "expo-router/entry"
}
说明:Expo使用统一的入口,自动处理路由初始化。
4. 配置文件
原生RN - 多文件配置
android/app/build.gradle # Android配置
ios/Info.plist # iOS配置
app.json # Metro配置
Expo - 统一配置
json
// app.json
{
"expo": {
"name": "my-app",
"icon": "./assets/icon.png",
"android": { ... },
"ios": { ... },
"web": { ... }
}
}
5. 原生模块添加流程
原生RN
bash
# 1. 安装库
npm install react-native-camera
# 2. 链接原生代码(如需要)
cd ios && pod install
# 3. 手动配置
# - 修改 android/app/build.gradle
# - 修改 AndroidManifest.xml
# - 配置权限等
Expo
bash
# Managed Workflow(仅Expo SDK模块)
npx expo install expo-camera
# Bare Workflow(任何模块)
npx expo prebuild # 生成原生目录
npm install react-native-camera
cd ios && pod install
🎯 路由系统深度对比
核心理念差异
原生RN:组件即路由
- 路由是运行时的导航配置
- 需要显式声明每个Screen
Expo Router:文件即路由
- 路由是编译时的文件结构
- 文件系统映射为URL结构
实际案例:添加用户详情页
原生RN需要做的事
tsx
// 1️⃣ 创建 src/screens/UserDetailScreen.tsx
export default function UserDetailScreen({ route }) {
const { userId } = route.params;
return <Text>User {userId}</Text>;
}
// 2️⃣ 修改 src/navigation/AppNavigator.tsx
<Stack.Screen
name="UserDetail"
component={UserDetailScreen}
options={{ title: '用户详情' }}
/>
// 3️⃣ 使用导航
navigation.navigate('UserDetail', { userId: 123 });
Expo Router需要做的事
tsx
// 1️⃣ 创建 app/user/[id].tsx
import { useLocalSearchParams } from 'expo-router';
export default function UserDetailScreen() {
const { id } = useLocalSearchParams();
return <Text>User {id}</Text>;
}
// 2️⃣ 完成!使用路由
router.push('/user/123');
工作量对比:
- 原生RN:3个步骤,涉及2个文件
- Expo Router:1个步骤,1个文件 ✅
🎨 Tab导航机制详解
原生RN的Tab实现
tsx
// 需要创建 src/navigation/TabNavigator.tsx
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import Icon from 'react-native-vector-icons/Ionicons';
const Tab = createBottomTabNavigator();
export default function TabNavigator() {
return (
<Tab.Navigator
screenOptions={{
tabBarActiveTintColor: '#007AFF',
headerShown: false,
}}>
<Tab.Screen
name="Home"
component={HomeScreen}
options={{
tabBarIcon: ({ color, size }) => (
<Icon name="home" size={size} color={color} />
),
}}
/>
<Tab.Screen
name="Explore"
component={ExploreScreen}
options={{
tabBarIcon: ({ color, size }) => (
<Icon name="compass" size={size} color={color} />
),
}}
/>
</Tab.Navigator>
);
}
// 然后在主导航器中嵌套
<Stack.Navigator>
<Stack.Screen name="MainTabs" component={TabNavigator} />
<Stack.Screen name="Modal" component={ModalScreen} />
</Stack.Navigator>
Expo Router的Tab实现
目录结构
app/
├── (tabs)/ ✨ 括号=分组路由(不影响URL)
│ ├── _layout.tsx ✨ Tab配置文件
│ ├── index.tsx → URL: /
│ └── explore.tsx → URL: /explore
├── _layout.tsx ✨ 根布局
└── modal.tsx → URL: /modal
Tab配置文件
tsx
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { IconSymbol } from '@/components/ui/icon-symbol';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#007AFF',
headerShown: false,
}}>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color }) => (
<IconSymbol size={28} name="house.fill" color={color} />
),
}}
/>
<Tabs.Screen
name="explore"
options={{
title: 'Explore',
tabBarIcon: ({ color }) => (
<IconSymbol size={28} name="paperplane.fill" color={color} />
),
}}
/>
</Tabs>
);
}
分组路由 (tabs) 的魔法
关键特性:
-
括号包裹的目录不会出现在URL中
app/(tabs)/index.tsx → URL: / (不是 /tabs/) app/(tabs)/explore.tsx → URL: /explore (不是 /tabs/explore) -
_layout.tsx决定子页面的渲染方式- 使用
<Tabs>→ 底部Tab导航 - 使用
<Stack>→ 堆栈导航 - 使用
<Drawer>→ 抽屉导航
- 使用
-
自动子页面注册
(tabs)目录下的所有页面自动成为Tab页- 无需手动逐个注册
Tab底层封装链
expo-router 的 <Tabs>
↓ 封装
@react-navigation/bottom-tabs
↓ 基于
React Navigation 7.x
↓ 使用
React Native 核心组件 (View, TouchableOpacity, Animated)
添加新Tab页面对比
原生RN
tsx
// 1️⃣ 创建 src/screens/ProfileScreen.tsx
export default function ProfileScreen() {
return <Text>Profile</Text>;
}
// 2️⃣ 修改 TabNavigator.tsx
<Tab.Screen
name="Profile"
component={ProfileScreen}
options={{
tabBarIcon: ({ color, size }) => (
<Icon name="person" size={size} color={color} />
),
}}
/>
Expo Router
tsx
// 1️⃣ 创建 app/(tabs)/profile.tsx
export default function ProfileScreen() {
return <Text>Profile</Text>;
}
// 2️⃣ 修改 app/(tabs)/_layout.tsx
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color }) => (
<IconSymbol size={28} name="person.fill" color={color} />
),
}}
/>
对比结果:
- 代码量相当
- Expo Router更符合直觉(文件结构即路由)
- URL访问:Expo自动支持
/profile路径
📂 项目结构最佳实践
❌ 错误做法:所有代码放在 app/
app/
├── index.tsx
├── user.tsx
├── api.ts ❌ 会被识别为路由 /api
├── utils.ts ❌ 会被识别为路由 /utils
└── config.ts ❌ 会被识别为路由 /config
✅ 正确做法:app/ 只放页面
my-app/
├── app/ ✅ 纯粹的路由/页面
│ ├── (tabs)/
│ │ ├── _layout.tsx
│ │ ├── index.tsx
│ │ └── explore.tsx
│ ├── _layout.tsx
│ └── pages/user/
│ └── index.tsx
│
├── api/ ✅ API调用层
│ ├── user.ts
│ ├── auth.ts
│ └── request.ts
│
├── utils/ ✅ 工具函数
│ ├── format.ts
│ ├── validate.ts
│ └── storage.ts
│
├── services/ ✅ 业务逻辑服务
│ ├── authService.ts
│ └── userService.ts
│
├── hooks/ ✅ 自定义Hooks
│ ├── useAuth.ts
│ └── useUser.ts
│
├── store/ ✅ 状态管理
│ ├── userStore.ts
│ └── appStore.ts
│
├── types/ ✅ TypeScript类型
│ ├── user.ts
│ └── api.ts
│
├── components/ ✅ UI组件(非路由)
│ ├── ui/
│ └── business/
│
├── constants/
└── assets/
核心原则
app/ 目录应该放的内容:
- ✅ 页面组件(index.tsx, about.tsx等)
- ✅ 布局文件(_layout.tsx)
- ✅ 特殊文件(+html.tsx, +not-found.tsx等)
app/ 目录不应该放的内容:
- ❌ API调用函数
- ❌ 工具函数
- ❌ 业务逻辑
- ❌ 状态管理
- ❌ 类型定义
- ❌ 配置文件
原因 :app/ 目录下的文件/文件夹会被expo-router自动解析为路由!
💼 实战示例:完整的用户登录功能
原生RN实现
src/
├── screens/
│ └── LoginScreen.tsx # 页面
├── navigation/
│ └── AppNavigator.tsx # 路由配置
├── api/
│ └── auth.ts # API调用
├── services/
│ └── AuthService.ts # 业务逻辑
└── hooks/
└── useAuth.ts # Hook
tsx
// 1️⃣ src/api/auth.ts
export const login = async (username: string, password: string) => {
return await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
};
// 2️⃣ src/services/AuthService.ts
export class AuthService {
async login(username: string, password: string) {
const data = await login(username, password);
await AsyncStorage.setItem('token', data.token);
return data;
}
}
// 3️⃣ src/hooks/useAuth.ts
export const useAuth = () => {
const [user, setUser] = useState(null);
const handleLogin = async (username: string, password: string) => {
const authService = new AuthService();
const userData = await authService.login(username, password);
setUser(userData);
};
return { user, login: handleLogin };
};
// 4️⃣ src/screens/LoginScreen.tsx
export default function LoginScreen({ navigation }) {
const { login } = useAuth();
const handleSubmit = async () => {
await login(username, password);
navigation.navigate('Home'); // 手动导航
};
return <View>...</View>;
}
// 5️⃣ src/navigation/AppNavigator.tsx
<Stack.Screen name="Login" component={LoginScreen} />
Expo Router实现
my-app/
├── app/
│ └── login.tsx # 页面(自动路由)
├── api/
│ └── auth.ts # API调用
├── services/
│ └── authService.ts # 业务逻辑
└── hooks/
└── useAuth.ts # Hook
tsx
// 1️⃣ api/auth.ts(同上)
export const login = async (username: string, password: string) => {
return await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
};
// 2️⃣ services/authService.ts(同上)
export class AuthService {
async login(username: string, password: string) {
const data = await login(username, password);
await AsyncStorage.setItem('token', data.token);
return data;
}
}
// 3️⃣ hooks/useAuth.ts(同上)
export const useAuth = () => {
const [user, setUser] = useState(null);
const handleLogin = async (username: string, password: string) => {
const authService = new AuthService();
const userData = await authService.login(username, password);
setUser(userData);
};
return { user, login: handleLogin };
};
// 4️⃣ app/login.tsx
import { useRouter } from 'expo-router';
export default function LoginScreen() {
const { login } = useAuth();
const router = useRouter();
const handleSubmit = async () => {
await login(username, password);
router.replace('/'); // 使用路径导航
};
return <View>...</View>;
}
// ✅ 无需第5步!文件即路由
关键差异
- 路由注册:Expo Router无需手动注册
- 导航方式 :
- 原生RN:
navigation.navigate('ScreenName') - Expo:
router.push('/path')
- 原生RN:
- 类型安全:Expo自动生成路由类型
📊 优劣势总结
原生React Native
优势:
- ✅ 完全掌控原生代码
- ✅ 不受第三方SDK限制
- ✅ 可使用任何原生模块
- ✅ 更成熟的生态系统
劣势:
- ❌ 配置复杂(Gradle、Podfile等)
- ❌ 需要Android Studio/Xcode
- ❌ 路由配置繁琐
- ❌ 学习曲线陡峭
- ❌ 构建时间长
Expo + Expo Router
优势:
- ✅ 开发速度极快
- ✅ 文件系统路由(类似Next.js)
- ✅ 无需接触原生代码
- ✅ 统一配置管理(app.json)
- ✅ 内置OTA更新
- ✅ 云构建支持(EAS Build)
- ✅ Web支持开箱即用
- ✅ 学习曲线平缓
劣势:
- ❌ 原生模块受限(Managed Workflow)
- ⚠️ 应用体积稍大
- ⚠️ 需要Expo SDK支持的功能
弹出选项:
bash
# 需要使用Expo不支持的原生模块时
npx expo prebuild
# 生成 android/ 和 ios/ 目录
# 转为 Bare Workflow,获得原生RN的灵活性
🎓 学习路径建议
新手推荐
-
从Expo开始 ✅
- 快速上手React Native开发
- 理解组件、状态、导航等核心概念
- 体验文件系统路由的便利
-
掌握Expo Router
- 文件结构即路由
- 布局系统(_layout.tsx)
- 分组路由(括号语法)
- 动态路由([id].tsx)
-
深入原生RN(可选)
- 需要特殊原生模块时
- 学习React Navigation
- 理解原生桥接机制
项目选择建议
使用Expo如果:
- 快速原型开发
- 创业公司MVP
- 大部分功能Expo SDK覆盖
- 团队没有原生开发经验
使用原生RN如果:
- 需要大量自定义原生模块
- 对包体积有严格要求
- 需要深度定制原生功能
- 团队有原生开发能力
📚 参考资源
官方文档
关键概念
- 文件系统路由:文件结构映射为URL路由
- 分组路由 :使用括号
(name)组织路由,不影响URL - 布局路由 :
_layout.tsx控制子页面渲染方式 - 动态路由 :
[param].tsx创建动态路径参数
🔄 迁移指南
从原生RN迁移到Expo
-
安装Expo
bashnpx install-expo-modules@latest -
重构路由结构
src/screens/ → app/ src/navigation/ → 删除(使用文件路由) -
更新导航代码
tsx// 之前 navigation.navigate('User', { id: 123 }); // 之后 router.push('/user/123'); -
配置文件迁移
android/ → 可选(使用 expo prebuild 生成) ios/ → 可选(使用 expo prebuild 生成)
💡 最佳实践总结
-
app/ 目录只放页面
- 相当于Next.js的 pages/ 或 app/
- 不要放业务逻辑、工具函数等
-
业务代码同级组织
api/- API调用services/- 业务逻辑utils/- 工具函数hooks/- 自定义Hooksstore/- 状态管理
-
善用分组路由
(tabs)/- Tab导航(auth)/- 认证相关页面(drawer)/- 抽屉导航
-
类型安全
- Expo Router自动生成路由类型
- 使用
Href<>类型获得路径提示
-
渐进增强
- 从Managed Workflow开始
- 需要时使用
expo prebuild转为Bare Workflow
🎉 结语
Expo + Expo Router 代表了React Native开发的现代化方向:
- 📁 文件系统路由 - 告别繁琐的路由配置
- 🚀 快速迭代 - 专注业务逻辑而非基础设施
- 🌐 Web优先 - 天然支持跨平台(含Web)
- 🛠️ 渐进增强 - 需要时可转为原生RN
无论选择哪种方案,理解它们的差异和适用场景才是关键!
文档创建时间:2026-01-15
基于 Expo 54.0 / expo-router 6.0