早几天把开发环境整体升到了 React Native 0.80 的稳定版,顺手把手头一个用来做框架验证的任务协作 Demo 翻新了一遍。这个项目不复杂,但五脏俱全:登录鉴权、任务列表、筛选搜索、新建编辑、个人设置,从网络层到状态管理再到导航和列表优化都有覆盖。我把完整的工程结构和关键实现整理成文,给正在学 React Native 或者想从 0.7x 跨到 0.80 的同学当一份学习参考。
这篇文章会以"一个完整可运行的实战项目"为主线,从项目设计、技术选型、核心页面实现、新特性运用,到常见问题排查,按真实开发顺序展开。每个关键点我都会讲清楚为什么这么做、实际踩过什么坑、怎么调整,而不是光贴代码。想直接上手跑起来的,跟着第二章的初始化步骤走一遍就行;想深入了解某个模块的,可以直接跳到对应章节看实现细节。
这个项目定位是"学习参考",所以你不需要有完整的后端服务,我会把数据请求层做了一层 mock 适配,本地模拟接口也能把整个流程跑通。我用的环境是 macOS + Android Studio,React Native 0.80 稳定版,下面所有路径和命令都基于这个组合,如果你用的是 Windows 或者只跑 iOS,差异点我会单独标出来。
1. 项目总体设计与技术选型思路
1.1 为什么把 0.80 当作学习基准
很多初学者上来就跟着网上的旧教程走,用的还是 0.6x 甚至 0.5x 的语法,等自己新建项目才发现模板已经变了,API 也废弃了一大堆,学得特别痛苦。React Native 从 0.7x 开始,新架构逐步收敛,到 0.80 这个版本,新架构已经在默认配置里占据主导位置。这意味着你用 0.80 写出来的代码,更贴近当前社区的主流写法,将来维护老项目或者参与新项目,认知成本都会低很多。
另外 0.80 对 TypeScript 的支持更完整,很多类型定义不再需要手动补齐,第三方库的兼容性也稳定了不少。这个版本作为学习基准,最大的好处是"你现在写的东西,半年后还能用",而不是教程还没看完就要学写废弃 API。
1.2 Demo 的功能边界与业务场景
我给这个项目定的业务场景是"团队内部的任务协作工具",功能上做了适度裁剪,保证学习价值的同时不把工程搞得太大:
- 手机号/密码登录,带 token 存储与自动带参请求
- 任务列表:分页加载、状态筛选、下拉刷新
- 任务详情:展示任务信息、更新状态
- 新建/编辑任务:表单校验、提交反馈
- 个人设置:头像选择、主题切换、退出登录
这个功能组合覆盖了移动端开发的大部分核心场景:网络通信、列表渲染、导航流转、表单处理、全局状态、本地存储、主题适配。你把这套 Demo 吃透了,换皮做其他业务,思路是一样的。
1.3 技术选型清单
我做了一个表格,把这次用到的核心依赖和选择理由列出来,方便你对照着搭环境。
| 模块 | 选型 | 为什么选它 |
|---|---|---|
| 跨端框架 | React Native 0.80 | 新架构默认、TS 支持完善、长期维护 |
| 开发语言 | TypeScript | 大型项目可维护性的基本盘 |
| 导航 | React Navigation v7 | 社区事实标准,文档全、问题好搜 |
| 状态管理 | Zustand | 比 Redux 轻量、上手快、模板代码少 |
| 网络请求 | Axios | 拦截器机制成熟,适合做统一封装 |
| 本地存储 | AsyncStorage | 轻量 KV 存储,配合 token/主题使用 |
| UI 组件 | 自研基础组件 + 部分原生组件 | 学习阶段不引入重型 UI 库,理解更透 |
| Mock 数据 | 本地 JSON + 延时模拟 | 无后端也能跑通全流程 |
这套组合不是唯一的答案,但绝对是一条性价比很高的学习路径。Redux 依然主流,但 Zustand 的体验对新手友好很多,等你把状态流转的逻辑搞清楚了,再回头理解 Redux 那一套也更容易。
2. 环境准备与工程初始化
2.1 初始化项目的实际操作
我习惯用官方模板新建项目,命令如下:
npx @react-native-community/cli@latest init TodoDemo --version 0.80.0这里注意,不要用react-native init这种老命令,社区 CLI 已经把模板能力合并过来了。初始化完成后,先跑一次默认项目,确认环境没问题再继续动代码。
跑 iOS 模拟器:
cd TodoDemo && npm run ios跑 Android,先打开 Android Studio 启动一个模拟器,然后:
npm run android第一次运行会自动执行 gradle 构建,耗时几分钟很正常,不要在这时候中断。
2.2 目录结构与模块划分
工程初始化完之后,默认结构比较散。我一般会按功能域重新组织src目录,规则是"一个页面一个文件夹,公共能力单独抽层":
src/ ├── api/ # 网络请求层,统一封装 ├── components/ # 跨页面复用的基础组件 ├── store/ # 全局状态管理 ├── navigation/ # 导航栈配置 ├── screens/ # 页面级组件 ├── theme/ # 主题与样式常量 ├── types/ # 全局类型定义 └── utils/ # 工具函数这样划分的核心理由是:你能清楚地知道一个功能改动会影响哪些文件。比如说接口字段变了,你只需要动api和types;UI 风格调整,重点看components和theme。学习阶段养成分层习惯,后面写大项目才不会乱。
2.3 TypeScript 路径别名配置
老项目里经常看到一串../../../../utils/format这种相对路径,看着就头疼。我在项目里配了路径别名,用@/指向src目录,干净利落。
先改tsconfig.json:
{ "extends": "@react-native/typescript-config", "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }然后在项目根目录建一个babel.config.js,加上 babel-plugin-module-resolver:
module.exports = { presets: ['module:@react-native/babel-preset'], plugins: [ [ 'module-resolver', { root: ['./'], alias: { '@': './src' } } ] ] };改完配置重启 Metro,别忘记清缓存:
npm start -- --reset-cache这个配置学一次,能解决以后所有项目的路径混乱问题。
2.4 统一网络请求封装
网络层是每个项目的地基。我在src/api/client.ts里封装了一个 axios 实例,统一处理 token 注入、错误提示和超时:
import axios from 'axios'; import AsyncStorage from '@react-native-async-storage/async-storage'; const client = axios.create({ baseURL: 'http://localhost:3000', timeout: 10000, }); client.interceptors.request.use(async (config: any) => { const token = await AsyncStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); client.interceptors.response.use( response => response.data, error => { if (error.response?.status === 401) { // 实际项目在这里跳登录页 console.warn('登录过期'); } return Promise.reject(error); } ); export default client;用拦截器的好处是:业务代码里不需要关心 token 和错误兜底,只管写请求逻辑就行。比如获取任务列表:
export const fetchTasks = (params: FetchTasksParams) => client.get('/tasks', { params });fetchTasks返回的已经是处理过的数据,页面组件里直接setState或者写入 store,代码可读性提升一大截。
3. 核心页面实现与状态管理实战
3.1 用 Zustand 管理全局状态
项目里的登录会话、任务列表的筛选条件、主题模式,这些跨页面共享的数据,我用 Zustand 管理。Zustand 的写法比 Redux 简洁太多,看一段实际代码就明白了:
// src/store/useAuthStore.ts import { create } from 'zustand'; interface AuthState { token: string | null; user: UserInfo | null; setSession: (token: string, user: UserInfo) => void; clearSession: () => void; } export const useAuthStore = create<AuthState>((set) => ({ token: null, user: null, setSession: (token, user) => set({ token, user }), clearSession: () => set({ token: null, user: null }), }));页面里用的时候:
const token = useAuthStore(state => state.token); const setSession = useAuthStore(state => state.setSession);这里有两个实践心得。第一,zustand 的 selector 要写细粒度,useAuthStore(state => state.token)这样能避免不必要的重渲染,不要图省事把整个 store 拿下来。第二,异步逻辑我放在 api 层,store 里只存"状态"和"同步的状态变更方法",这样代码跑起来好定位问题。
3.2 导航结构设计
导航是移动应用的骨架,我用的 React Navigation v7,结构上做了"登录栈 + 主栈"的分层:
const RootStack = createNativeStackNavigator(); function RootNavigator() { const token = useAuthStore(state => state.token); return ( <NavigationContainer> <RootStack.Navigator screenOptions={{ headerShown: false }}> {token ? ( <RootStack.Screen name="AppTabs" component={AppTabs} /> ) : ( <RootStack.Screen name="Login" component={LoginScreen} /> )} </RootStack.Navigator> </NavigationContainer> ); }用 token 作为条件渲染的标识,登录成功自动切换到主界面,登出自动回登录页,不需要手动 navigate,逻辑非常直观。
登录后的主界面用底部 Tab 导航,包含任务、统计、个人中心三个页签。其中任务模块又嵌套一个 Stack 导航,用于处理"列表页 -> 详情页 -> 编辑页"的压栈跳转。
有同学可能会问,为什么不把页面都放一个 Stack 里?我的经验是,模块内部用 Stack,模块之间用 Tab,这样每个导航层的职责单一。任务模块内部调整页面跳转不会影响其他 Tab,后续加"新建任务"的全局入口也方便。
3.3 任务列表的完整实现
任务列表是这个项目里最值得细看的页面,它集合了数据请求、加载状态、分页、下拉刷新、空态展示这些常见逻辑。
我用 FlatList 渲染,主要代码结构如下:
const [tasks, setTasks] = useState<TaskItem[]>([]); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const [refreshing, setRefreshing] = useState(false); const [loading, setLoading] = useState(false); const loadTasks = async (nextPage: number, replace = false) => { if (loading) return; setLoading(true); try { const data = await fetchTasks({ page: nextPage, pageSize: 20, status }); const newList = replace ? data.list : [...tasks, ...data.list]; setTasks(newList); setHasMore(data.hasMore); setPage(nextPage); } finally { setLoading(false); setRefreshing(false); } };FlatList 的关键性能属性一个都不能少:
<FlatList data={tasks} renderItem={renderItem} keyExtractor={item => String(item.id)} getItemLayout={(_, index) => ({ length: 96, offset: 96 * index, index, })} onEndReached={() => hasMore && loadTasks(page + 1)} onEndReachedThreshold={0.3} refreshing={refreshing} onRefresh={() => loadTasks(1, true)} ListEmptyComponent={<EmptyView />} />getItemLayout是新手最容易忽略的优化点。它告诉 FlatList 每一项的高度是固定的,这样列表可以跳过动态测量,滚动时会明显更跟手。我这个任务卡片高度是固定的 96,所以可以直接写死。如果你的列表项高度不固定,那这个属性没法用,但可以用onLayout缓存动态高度,那是另一个优化的故事了。
筛选功能我是用状态驱动查询参数的方式做的,筛选切换时重新从第一页加载:
const [status, setStatus] = useState<'all' | 'done' | 'undone'>('all'); const changeFilter = (s: typeof status) => { setStatus(s); loadTasks(1, true); };3.4 新建与编辑任务页的表单处理
表单这块我没有引入 Formik 之类的库,直接用手写受控组件,配合一个简单的校验函数。原因是这个表单只有四五个字段,上重型表单库反而增加理解成本,学习阶段把底层逻辑吃透更重要。
核心代码:
const [form, setForm] = useState({ title: '', desc: '', priority: 'medium', dueDate: '', }); const [errors, setErrors] = useState<FormErrors>({}); const validate = () => { const nextErrors: FormErrors = {}; if (!form.title.trim()) { nextErrors.title = '标题不能为空'; } else if (form.title.trim().length < 2) { nextErrors.title = '标题至少2个字'; } if (form.dueDate && new Date(form.dueDate).getTime() < Date.now()) { nextErrors.dueDate = '截止时间不能早于当前时间'; } setErrors(nextErrors); return Object.keys(nextErrors).length === 0; }; const handleSubmit = async () => { if (!validate()) return; if (isEdit) { await updateTask(taskId, form); } else { await createTask(form); } navigation.goBack(); };提交成功后,我通过订阅useFocusEffect在列表页重新拉数据:
useFocusEffect( useCallback(() => { loadTasks(1, true); }, []) );这里说一下为什么用useFocusEffect而不是useEffect。useEffect是组件挂在时触发,而useFocusEffect是每次页面获得焦点时触发。从编辑页返回到列表页,列表组件并没有重新挂载,所以useEffect不会重新执行,useFocusEffect能做到。这是 React Navigation 里特别常用的生命周期用法。
4. 0.80 项目里的新特性实践与性能优化
4.1 新架构默认开启后我观察到的变化
升级到 0.80 之后,项目默认走的就是新架构运行时。从我实际开发和调试的体验来看,最直观的变化是启动速度和交互流畅度。同样的业务代码,在旧架构上冷启动需要白屏挺长时间,新架构下面启动到可交互的间隔明显缩短,页面切换也很少出现掉帧。
这个感知在真机上比模拟器明显。做学习项目的话,建议有条件尽量用真机调试,模拟器更多是用来看布局和验证逻辑的。新架构下,如果你引入的第三方原生组件没有做新架构适配,可能会出现"编译能过,运行崩"的情况,所以依赖库尽量选社区活跃的。
4.2 暗色模式与主题系统实现
0.80 项目的模板已经支持系统暗色模式了,但业务组件并不会自动跟着适配,需要自己把主题体系搭起来。我在theme/index.ts里定义了一套基于 CSS 变量的主题对象:
export const lightTheme = { colors: { background: '#FFFFFF', card: '#F5F6F8', textPrimary: '#1A1A1A', textSecondary: '#8A8A8A', accent: '#3478F6', border: '#E5E5E5', }, }; export const darkTheme: Theme = { colors: { background: '#121212', card: '#1E1E1E', textPrimary: '#F0F0F0', textSecondary: '#A0A0A0', accent: '#4C9EEB', border: '#333333', }, };用useColorScheme动态切换:
const scheme = useColorScheme(); const theme = scheme === 'dark' ? darkTheme : lightTheme;然后在页面里用useTheme取颜色值,而不是直接写死#FFFFFF。这套逻辑搭好之后,组件颜色全部走主题变量,切换暗色模式时全应用统一变化,不用一个页面一个页面去改。
4.3 列表渲染与图片加载的性能细节
任务列表里卡片包含缩略图、标题、描述、优先级标签。图片这块我把maxToRenderPerBatch和windowSize调过一轮,这里写一下我整理后的稳定配置:
<FlatList initialNumToRender={10} maxToRenderPerBatch={10} windowSize={7} removeClippedSubviews ... />这几个参数的作用分别是:
initialNumToRender:首屏渲染的条目数,不要贪多,10 条足够,多了首屏会卡maxToRenderPerBatch:滚动时每批渲染的条数,减小这个值能让滚动更平滑,但也不能太小,否则频繁渲染更耗性能windowSize:可视区域外预渲染的缓冲区大小,单位是可视区域高度的倍数,调太低会导致快速滑动时白屏
图片优化我主要做了两点:缩略图用固定尺寸裁剪,避免大图加载;列表图片加上resizeMethod="resize"减少内存占用。如果图片来自网络,还需要考虑用缓存库,但本地 mock 阶段先不过度设计。
4.4 状态管理结合 immutable 更新的注意事项
在任务列表里频繁操作状态更新,我给 store 里的列表更新写了一个统一的updateTaskInList方法。反复操作之后我意识到一个关键点:Zustand 的set虽然是浅合并,但嵌套对象里的变更必须手动保证不可变性,不然会碰到"界面不刷新、数据却变了"的诡异问题。
const updateTaskInList = (taskId: number, patch: Partial<TaskItem>) => set((state) => ({ tasks: state.tasks.map((item) => item.id === taskId ? { ...item, ...patch } : item ), }));这里用map返回新数组、用展开运算符更新目标对象,每次更新都是全新的引用,zustand 才能检测到变化触发重新渲染。如果你直接state.tasks[0].status = 'done'再set(state => ({ tasks: state.tasks })),引用没变,组件不会重渲染,这种 bug 排查起来特别费时间。
5. 常见问题与排查心得
5.1 "Unable to resolve module" 的几类原因
开发阶段遇到最多的报错就是Unable to resolve module。我总结了三类常见原因:
第一,路径别名配置了但 Metro 不知道。每次改babel.config.js都要重启 Metro,并--reset-cache,否则新的模块解析规则不生效。第二,引用了还没安装的包。第三,文件大小写写错了。macOS 文件系统默认不区分大小写,iOS 模拟器也是,但 Android 构建有时对大小写敏感。我自己就栽过一次,import UserCard from '@/components/userCard',文件名是UserCard.tsx,iOS 跑得好好的,Android 直接 module not found。
排查路径的第一件事永远是看完整报错信息,里面会提示具体的文件位置,照着把目录结构翻一遍,大部分问题肉眼就能看出来。
5.2 新架构下 Android Release 构建失败
用 0.80 打 Android release 包,我碰到过一次构建失败,报错指向Invariant Violation。排查过程是这样的:先看android/gradle.properties里的newArchEnabled配置,确认新架构相关参数没问题;然后关闭代码混淆再打一次包,定位到是hermes字节码和某些第三方库的兼容问题。
最终解决办法是升级了那个第三方库到支持新架构的版本,并且重新清理构建:
cd android && ./gradlew clean && cd .. npm run android -- --mode release经验是:遇到新架构的 release 问题,先区分是"自己业务代码的问题"还是"第三方库的问题"。把业务代码屏蔽掉逐步加回来,是最快的定位方式。
5.3 Metro 缓存导致的"改了代码没效果"
有时候改了页面样式,刷新半天还是老样子。这多半是 Metro 缓存搞得鬼。常规操作刷新几次没用的话,按这个顺序处理:
# 停掉 Metro 后 npx react-native start --reset-cache如果还不行,清掉原生构建缓存:
cd android && ./gradlew clean cd ios && rm -rf Pods && pod install然后重新运行。这种"清缓存三板斧"能解决 90% 的疑难杂症。平时开发尽量保持 Metro 正常生命周期,不要频繁 kill,避免缓存频繁重建。
5.4 Android 模拟器请求本地 Mock 服务连不上
我的 mock 服务跑在宿主机的 3000 端口,Android 模拟器里访问localhost:3000是连不上的。因为模拟器里的localhost指向的是模拟器自己。解决办法是用 Android 模拟器的专属地址10.0.2.2:
const BASE_URL = Platform.select({ ios: 'http://localhost:3000', android: 'http://10.0.2.2:3000', });另外 Android 9 之后默认禁止明文 HTTP 请求,需要在android/app/src/main/AndroidManifest.xml里配置usesCleartextTraffic="true",或者用 network security config 只对开发域名放开明文权限。这两个点不处理,调试期就会一直卡在"请求直接失败"上。
写在最后
把整个项目从旧架构切到 0.80 重写一遍,我最深的体会是:React Native 现在确实到了"新架构红利期",启动性能、交互流畅度的提升不是玄学,真机一跑就能感受到。但这个红利是给那些把基础工程做扎实的人的,目录组织、状态管理边界、网络层封装、列表优化参数,这些基本功没到位,换什么版本都会写出磕磕绊绊的代码。
这个任务协作 Demo 我还在继续扩展,后续计划把服务端组件和 CodePush 相关能力加进去。你拿到这份参考,建议先照着初始化把工程跑通,然后改一个自己的页面,比如把任务列表换成你的藏书清单、随手记账,改着改着就会顺手了。遇到问题欢迎在评论区把报错贴出来,我看到会回复。