Expo Router + Zustand 的现代化 React Native 应用架构
·
Expo Router + Zustand 的现代化 React Native 应用架构:构建可维护的跨平台应用
前言
随着 React Native 生态的不断发展,开发者们面临着越来越多的技术选择。如何在保持开发效率的同时,构建出高性能、可维护、易扩展的移动应用架构,是每个团队都需要思考的问题。
本文将深入探讨如何基于 Expo Router 和 Zustand 构建现代化的 React Native 应用架构,包括路由管理、状态管理、数据持久化、国际化等核心功能的实现方案。
项目背景
这是一个医疗管理应用,具有以下特点:
- 多平台支持:需要同时支持 iOS 和 Android
- 复杂业务逻辑:包含用户管理、检查记录、治疗计划等多个业务模块
- 数据持久化:需要本地存储用户信息和检查数据
- 国际化支持:需要支持多语言切换
- 热更新:需要支持代码热更新功能
技术架构概览
1. 整体架构设计
2. 目录结构设计
src/
├── app/ # Expo Router 路由文件
│ ├── (tabs)/ # Tab 导航组
│ ├── login/ # 登录相关页面
│ ├── medical/ # 核心业务页面
│ └── _layout.tsx # 根布局
├── components/ # 组件库
│ ├── ui/ # 基础 UI 组件
│ ├── business/ # 业务组件
│ └── icons/ # 图标组件
├── stores/ # Zustand 状态管理
├── services/ # 服务层
├── hooks/ # 自定义 Hooks
├── libs/ # 工具库
├── constants/ # 常量定义
├── types/ # TypeScript 类型
└── languages/ # 国际化资源
核心功能实现
1. Expo Router 文件路由系统
1.1 根布局设计
// app/_layout.tsx
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { Stack, useNavigationContainerRef, usePathname, useRouter, useSegments } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Text, TextInput } from 'react-native';
import 'react-native-reanimated';
import '../global.css';
import { I18nextProvider } from 'react-i18next';
import i18n from '@/libs/i18n';
import GlobalToastGroup from '@/components/ui/Toast/Group';
import { PortalProvider } from '@gorhom/portal';
import GlobalConfirmModal from '@/components/ui/Modal/GlobalConfirmModal';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import React from 'react';
import {
useStatusBarStore,
useSubscription,
online$,
useScreenOrientationLock,
useNavigationBarStore,
useOnline,
currentRoute$,
useAppState,
IS_IOS,
DEVICE_BRAND,
} from '@ife/react-native-common-lib';
import { useToastStore } from '@/stores/toast';
import { useConfirmModalStore } from '@/stores/confirm-modal';
import { useLoginTokenStore } from '@/stores/login-token';
import { usePatientInfoStore } from '@/stores/patient-info';
import { useCheckVersion } from '@/hooks/useCheckVersion';
import { UpdateProvider, Pushy } from 'react-native-update';
// 防止字体缩放
(Text as any).defaultProps = {
...(Text as any).defaultProps,
allowFontScaling: false,
};
(TextInput as any).defaultProps = {
...(TextInput as any).defaultProps,
allowFontScaling: false,
};
// 防止启动屏自动隐藏
SplashScreen.preventAutoHideAsync();
const WHITELIST_ROUTES = ['/login/supplemental-info', '/login/phone-binding'];
function StackNavigator() {
const statusBarStyle = useStatusBarStore(state => state.statusBarStyle);
const statusBarHidden = useStatusBarStore(state => state.statusBarHidden);
const setStatusBarStyle = useStatusBarStore(state => state.setStatusBarStyle);
const setNavigationBarBgColor = useNavigationBarStore(state => state.setNavigationBarBgColor);
const uuid = useLoginTokenStore(state => state.uuid);
const { gestureEnabled } = useScreenGesture();
const pathname = usePathname();
const navigationRef = useNavigationContainerRef();
const appState = useAppState();
const { fetchUnreadMessageCounts } = useMessageStore();
const segments = useSegments();
const router = useRouter();
const accessToken = useLoginTokenStore(state => state.accessToken);
const appInitConsent = useAppInitConsentStore(state => state.hasReadAgreement);
const patientInfo = usePatientInfoStore(state => state.patientInfo);
const prevPathNameRef = useRef<string>('');
useCheckVersion();
// 路由守卫逻辑
useEffect(() => {
if (pathname !== '/') return;
if (!appInitConsent) {
router.replace('/agreement');
return;
}
initClientId();
if (!accessToken) {
router.replace('/login');
return;
}
if (accessToken && !patientInfo.nickname && !WHITELIST_ROUTES.includes(pathname)) {
router.replace('/login/supplemental-info');
return;
}
router.replace('/(tabs)');
}, [accessToken, appInitConsent, pathname, patientInfo.nickname, router]);
// 页面访问统计
useEffect(() => {
if (!navigationRef.current) return;
const params = navigationRef.current?.getCurrentRoute()?.params as Record<string, string>;
currentRoute$.set({
pathname,
params,
});
const prevPathNameCN = prevPathNameRef.current ? routerList.find(item => item.path === prevPathNameRef.current)?.name || '' : '';
Log.pv({
event: 'view',
prevPathName: prevPathNameRef.current,
prevPathNameCN,
});
prevPathNameRef.current = pathname;
}, [navigationRef, pathname]);
return (
<>
<StatusBar style={statusBarStyle} hidden={statusBarHidden} translucent={true} backgroundColor="transparent" />
<Stack
screenOptions={{
headerShown: false,
gestureEnabled,
}}
>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="login" />
<Stack.Screen name="medical" />
<Stack.Screen name="agreement" />
<Stack.Screen name="message-details" />
<Stack.Screen name="demo" />
<Stack.Screen name="camera" initialParams={{ mode: undefined } as CameraScreenParams} />
<Stack.Screen name="webview" initialParams={{ url: '', title: '' } as WebviewScreenParams} />
<Stack.Screen name="changelog" />
<Stack.Screen name="+not-found" />
</Stack>
</>
);
}
export default function RootLayout() {
const { toasts } = useToastStore();
const { confirmModalConfig } = useConfirmModalStore();
const { loadingConfig } = useLoadingStore();
const online = useSubscription(online$);
const network = useNetworkState();
const [isHydrated, setIsHydrated] = useState(false);
// 网络状态监控
useOnline('https://example.com/favicon.svg');
// 屏幕方向锁定
useScreenOrientationLock();
// 热更新配置
const pushyClient = new Pushy({
appKey: (IS_IOS ? process.env.EXPO_PUBLIC_PUSHY_IOS_KEY : process.env.EXPO_PUBLIC_PUSHY_ANDROID_KEY) as string,
checkStrategy: 'onAppStart',
updateStrategy: IS_RELEASE_PACKAGE ? 'silentAndLater' : 'alwaysAlert',
logger({ type, data }) {
Log.hotUpdate({
event: 'hotUpdateType',
value: type,
other: JSON.stringify(data),
});
},
});
// Store 水合状态检查
useEffect(() => {
let isActive = true;
const tokenHydrated = useLoginTokenStore.persist?.hasHydrated?.();
const consentHydrated = useAppInitConsentStore.persist?.hasHydrated?.();
if (tokenHydrated && consentHydrated) {
setIsHydrated(true);
return;
}
const unsubToken = useLoginTokenStore.persist?.onFinishHydration?.(() => {
if (isActive && useAppInitConsentStore.persist?.hasHydrated?.()) {
setIsHydrated(true);
}
});
const unsubConsent = useAppInitConsentStore.persist?.onFinishHydration?.(() => {
if (isActive && useLoginTokenStore.persist?.hasHydrated?.()) {
setIsHydrated(true);
}
});
return () => {
isActive = false;
unsubToken?.();
unsubConsent?.();
};
}, []);
useEffect(() => {
if (isHydrated) {
SplashScreen.hideAsync();
}
}, [isHydrated]);
return (
<UpdateProvider client={pushyClient}>
<SafeAreaProvider>
<I18nextProvider i18n={i18n}>
<PortalProvider>
<ThemeProvider value={DefaultTheme}>
<StackNavigator />
<GlobalToastGroup toasts={toasts} />
<GlobalConfirmModal confirmModalConfig={confirmModalConfig} />
<GlobalLoading loadinglConfig={loadingConfig} visible={loadingConfig.visible} />
<PermissionPrompt />
</ThemeProvider>
</PortalProvider>
</I18nextProvider>
</SafeAreaProvider>
</UpdateProvider>
);
}
1.2 Tab 导航设计
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import React from 'react';
import { Pressable, StyleSheet } from 'react-native';
import COLORS from '@/constants/colors';
import { useTranslation } from 'react-i18next';
import TabBarIcon from '@/components/ui/TabBarIcon';
import TabBarMessage from '@/components/business/TabBarMessage';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function TabLayout() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: COLORS.brand.primary,
tabBarInactiveTintColor: COLORS.text.secondary,
tabBarStyle: styles.tabBarStyle,
tabBarItemStyle: styles.tabBarItemStyle,
tabBarIconStyle: styles.tabBarIconStyle,
tabBarLabelStyle: styles.tabBarLabelStyle,
tabBarButton: props => (
<Pressable
{...props}
android_ripple={{ color: 'transparent' }}
/>
),
}}
safeAreaInsets={{
top: 0,
bottom: Math.max(12, insets.bottom),
}}
>
<Tabs.Screen
name="index"
options={{
title: t('tab_screen_index'),
tabBarIcon: ({ focused }) => <TabBarIcon name={focused ? 'index' : 'index-outline'} />,
}}
/>
<Tabs.Screen
name="message"
options={{
title: t('message.message'),
tabBarBadgeStyle: { backgroundColor: '#FF5954', fontSize: 10, width: 16, height: 16 },
tabBarIcon: props => <TabBarMessage {...props} />,
}}
/>
<Tabs.Screen
name="personal"
options={{
title: t('tab_screen_personal'),
tabBarIcon: ({ focused }) => <TabBarIcon name={focused ? 'personal' : 'personal-outline'} />,
}}
/>
</Tabs>
);
}
2. Zustand 状态管理架构
2.1 登录状态管理
// src/stores/login-token.ts
import { STORAGE_KEY } from '@/constants/async-storage-keys';
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
type State = API.LoginResData;
type Actions = {
setLoginToken: (data: API.LoginResData) => void;
removeToken: () => void;
};
/**
* 用于登录后,存储 token
* @description 使用了 persist 和 createJSONStorage 中间件,数据会持久化存储在 AsyncStorage 中
*/
export const useLoginTokenStore = create<State & Actions>()(
persist(
(set, get) => ({
accessToken: '',
refreshToken: '',
uuid: '',
setLoginToken: (data: API.LoginResData) => {
// 兼容刷新token uuid不返回问题
const uuid = data.uuid ? data.uuid : get().uuid;
set(() => ({ ...data, uuid }));
},
removeToken: () => set(() => ({ accessToken: '', refreshToken: '', uuid: '' })),
}),
{
name: STORAGE_KEY['LOGIN_TOKEN'],
storage: createJSONStorage(() => AsyncStorage),
},
),
);
export const $loginTokenStore = useLoginTokenStore;
2.2 患者信息管理
// src/stores/patient-info.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { STORAGE_KEY } from '@/constants/async-storage-keys';
interface PatientInfo {
nickname: string;
avatar: string;
phone: string;
gender: number;
birthday: string;
// ... 其他患者信息字段
}
interface PatientInfoState {
patientInfo: PatientInfo;
setPatientInfo: (info: Partial<PatientInfo>) => void;
clearPatientInfo: () => void;
}
const defaultPatientInfo: PatientInfo = {
nickname: '',
avatar: '',
phone: '',
gender: 0,
birthday: '',
};
export const usePatientInfoStore = create<PatientInfoState>()(
persist(
(set) => ({
patientInfo: defaultPatientInfo,
setPatientInfo: (info) =>
set((state) => ({
patientInfo: { ...state.patientInfo, ...info }
})),
clearPatientInfo: () =>
set(() => ({ patientInfo: defaultPatientInfo })),
}),
{
name: STORAGE_KEY['PATIENT_INFO'],
storage: createJSONStorage(() => AsyncStorage),
},
),
);
2.3 全局 UI 状态管理
// src/stores/toast.ts
import { create } from 'zustand';
export interface Toast {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
title: string;
message?: string;
duration?: number;
action?: {
label: string;
onPress: () => void;
};
}
interface ToastState {
toasts: Toast[];
addToast: (toast: Omit<Toast, 'id'>) => void;
removeToast: (id: string) => void;
clearToasts: () => void;
}
export const useToastStore = create<ToastState>((set, get) => ({
toasts: [],
addToast: (toast) => {
const id = Math.random().toString(36).substr(2, 9);
const newToast = { ...toast, id };
set((state) => ({
toasts: [...state.toasts, newToast]
}));
// 自动移除
if (toast.duration !== 0) {
setTimeout(() => {
get().removeToast(id);
}, toast.duration || 3000);
}
},
removeToast: (id) =>
set((state) => ({
toasts: state.toasts.filter(toast => toast.id !== id)
})),
clearToasts: () => set(() => ({ toasts: [] })),
}));
// src/stores/loading.ts
interface LoadingState {
loadingConfig: {
visible: boolean;
text?: string;
mask?: boolean;
};
showLoading: (text?: string, mask?: boolean) => void;
hideLoading: () => void;
}
export const useLoadingStore = create<LoadingState>((set) => ({
loadingConfig: {
visible: false,
text: '',
mask: true,
},
showLoading: (text = '', mask = true) =>
set(() => ({
loadingConfig: { visible: true, text, mask }
})),
hideLoading: () =>
set(() => ({
loadingConfig: { visible: false, text: '', mask: true }
})),
}));
3. 自定义 Hooks 设计
3.1 网络状态 Hook
// src/hooks/useNetworkState.ts
import { useEffect, useState } from 'react';
import NetInfo from '@react-native-community/netinfo';
import { useSubscription } from '@ife/react-native-common-lib';
import { online$ } from '@ife/react-native-common-lib';
export const useNetworkState = () => {
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [connectionType, setConnectionType] = useState<string | null>(null);
const online = useSubscription(online$);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener(state => {
setIsConnected(state.isConnected);
setConnectionType(state.type);
});
return unsubscribe;
}, []);
return {
isConnected,
connectionType,
online,
isOffline: isConnected === false,
};
};
3.2 版本检查 Hook
// src/hooks/useCheckVersion.ts
import { useEffect } from 'react';
import { useToastStore } from '@/stores/toast';
import { useLoadingStore } from '@/stores/loading';
import { checkAppVersion } from '@/services/v1/app';
export const useCheckVersion = () => {
const { addToast } = useToastStore();
const { showLoading, hideLoading } = useLoadingStore();
useEffect(() => {
const checkVersion = async () => {
try {
showLoading('检查更新中...');
const result = await checkAppVersion();
if (result.hasUpdate) {
addToast({
type: 'info',
title: '发现新版本',
message: `版本 ${result.version} 已发布,请及时更新`,
duration: 5000,
action: {
label: '立即更新',
onPress: () => {
// 跳转到应用商店或下载页面
}
}
});
}
} catch (error) {
console.error('版本检查失败:', error);
} finally {
hideLoading();
}
};
checkVersion();
}, []);
};
3.3 屏幕手势 Hook
// src/hooks/useScreenGesture.ts
import { useEffect, useState } from 'react';
import { usePathname } from 'expo-router';
interface ScreenGestureConfig {
gestureEnabled: boolean;
swipeBackEnabled: boolean;
}
export const useScreenGesture = (): ScreenGestureConfig => {
const pathname = usePathname();
const [gestureEnabled, setGestureEnabled] = useState(true);
const [swipeBackEnabled, setSwipeBackEnabled] = useState(true);
useEffect(() => {
// 根据路由配置手势行为
const disableGestureRoutes = ['/camera', '/preview'];
const disableSwipeBackRoutes = ['/login', '/agreement'];
setGestureEnabled(!disableGestureRoutes.some(route => pathname.includes(route)));
setSwipeBackEnabled(!disableSwipeBackRoutes.some(route => pathname.includes(route)));
}, [pathname]);
return {
gestureEnabled,
swipeBackEnabled,
};
};
4. 国际化支持
4.1 i18n 配置
// src/libs/i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import * as Localization from 'expo-localization';
import zhCN from '@/languages/zh-CN.json';
const resources = {
'zh-CN': {
translation: zhCN,
},
'en-US': {
translation: {
// 英文翻译
},
},
};
i18n
.use(initReactI18next)
.init({
resources,
lng: Localization.locale,
fallbackLng: 'zh-CN',
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
});
export default i18n;
4.2 语言切换组件
// src/components/business/LanguageSwitcher.tsx
import React from 'react';
import { Pressable, Text } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useToastStore } from '@/stores/toast';
const LanguageSwitcher: React.FC = () => {
const { i18n } = useTranslation();
const { addToast } = useToastStore();
const switchLanguage = (lng: string) => {
i18n.changeLanguage(lng);
addToast({
type: 'success',
title: '语言切换成功',
duration: 2000,
});
};
return (
<Pressable onPress={() => switchLanguage(i18n.language === 'zh-CN' ? 'en-US' : 'zh-CN')}>
<Text>{i18n.language === 'zh-CN' ? 'English' : '中文'}</Text>
</Pressable>
);
};
export default LanguageSwitcher;
5. 错误处理与监控
5.1 全局错误处理
// src/libs/error-handler.ts
import { fundebug } from '@/libs/report/fundebug';
import { useToastStore } from '@/stores/toast';
class ErrorHandler {
private toastStore: any;
init(toastStore: any) {
this.toastStore = toastStore;
}
handleError(error: Error, context?: string) {
console.error('Error occurred:', error);
// 上报错误
fundebug.notifyError(error, {
context,
timestamp: new Date().toISOString(),
});
// 显示用户友好的错误提示
this.toastStore?.addToast({
type: 'error',
title: '操作失败',
message: this.getErrorMessage(error),
});
}
private getErrorMessage(error: Error): string {
if (error.message.includes('Network')) {
return '网络连接异常,请检查网络设置';
}
if (error.message.includes('Timeout')) {
return '请求超时,请稍后重试';
}
return '系统异常,请稍后重试';
}
}
export const errorHandler = new ErrorHandler();
5.2 网络请求拦截器
// src/services/api-interceptor.ts
import axios from 'axios';
import { useLoginTokenStore } from '@/stores/login-token';
import { errorHandler } from '@/libs/error-handler';
const apiClient = axios.create({
baseURL: process.env.EXPO_PUBLIC_API_ROOT,
timeout: 10000,
});
// 请求拦截器
apiClient.interceptors.request.use(
(config) => {
const token = useLoginTokenStore.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
errorHandler.handleError(error, 'Request Interceptor');
return Promise.reject(error);
}
);
// 响应拦截器
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token 过期,清除登录状态
useLoginTokenStore.getState().removeToken();
}
errorHandler.handleError(error, 'Response Interceptor');
return Promise.reject(error);
}
);
export default apiClient;
性能优化策略
1. 组件懒加载
// src/components/LazyComponent.tsx
import React, { Suspense, lazy } from 'react';
import { ActivityIndicator, View } from 'react-native';
const LazyComponent: React.FC<{ componentName: string }> = ({ componentName }) => {
const Component = lazy(() => import(`@/components/${componentName}`));
return (
<Suspense fallback={
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
}>
<Component />
</Suspense>
);
};
export default LazyComponent;
2. 图片预加载
// src/hooks/useImagePreload.ts
import { useEffect, useState } from 'react';
import { Image } from 'expo-image';
export const useImagePreload = (imageUrls: string[]) => {
const [loadedCount, setLoadedCount] = useState(0);
const [isComplete, setIsComplete] = useState(false);
useEffect(() => {
const preloadImages = async () => {
const promises = imageUrls.map(async (url) => {
try {
await Image.prefetch(url);
setLoadedCount(prev => prev + 1);
} catch (error) {
console.warn(`Failed to preload image: ${url}`, error);
}
});
await Promise.allSettled(promises);
setIsComplete(true);
};
preloadImages();
}, [imageUrls]);
return {
loadedCount,
totalCount: imageUrls.length,
progress: imageUrls.length > 0 ? loadedCount / imageUrls.length : 0,
isComplete,
};
};
3. 内存优化
// src/hooks/useMemoryOptimization.ts
import { useEffect, useRef } from 'react';
import { AppState, AppStateStatus } from 'react-native';
export const useMemoryOptimization = () => {
const appState = useRef(AppState.currentState);
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
// 应用从后台回到前台,清理缓存
console.log('App has come to the foreground!');
// 执行内存清理操作
}
appState.current = nextAppState;
};
const subscription = AppState.addEventListener('change', handleAppStateChange);
return () => subscription?.remove();
}, []);
};
最佳实践总结
1. 架构设计原则
- 模块化设计:按功能模块组织代码结构
- 关注点分离:UI、业务逻辑、数据管理分离
- 类型安全:充分利用 TypeScript 类型系统
- 可测试性:组件和逻辑易于单元测试
2. 状态管理最佳实践
- 状态分层:全局状态、页面状态、组件状态合理分层
- 数据持久化:关键数据使用 Zustand persist 中间件
- 状态更新:使用不可变更新模式
- 性能优化:避免不必要的重新渲染
3. 路由管理最佳实践
- 文件路由:利用 Expo Router 的文件路由特性
- 路由守卫:在根布局中实现路由守卫逻辑
- 参数传递:使用类型安全的参数传递
- 深度链接:支持应用内深度链接
4. 性能优化要点
- 懒加载:组件和资源按需加载
- 缓存策略:合理使用缓存减少网络请求
- 内存管理:及时清理不需要的资源
- 渲染优化:使用 React.memo 和 useMemo
5. 错误处理策略
- 全局错误处理:统一的错误处理和上报
- 用户友好提示:错误信息对用户友好
- 降级方案:关键功能异常时的降级处理
- 监控告警:完善的错误监控和告警机制
结语
通过本文的深入探讨,我们构建了一套基于 Expo Router 和 Zustand 的现代化 React Native 应用架构。这套架构不仅解决了当前项目的实际需求,还具备了良好的扩展性和维护性。
在实际应用中,我们需要根据具体的业务场景和团队情况,灵活调整和优化各个模块。同时,随着 React Native 生态的不断发展,我们也需要持续关注新技术的发展,及时升级和优化我们的架构。
希望本文能够为正在构建 React Native 应用的开发者提供有价值的参考。在移动应用开发的道路上,架构设计、性能优化、用户体验的平衡永远是一个需要持续探索的话题。
更多推荐



所有评论(0)