戰(zhàn):從項目初始化到新架構(gòu)性能優(yōu)化)
早幾天把開發(fā)環(huán)境整體升到了 React Native 0.80 的穩(wěn)定版順手把手頭一個用來做框架驗證的任務(wù)協(xié)作 Demo 翻新了一遍。這個項目不復(fù)雜但五臟俱全登錄鑒權(quán)、任務(wù)列表、篩選搜索、新建編輯、個人設(shè)置從網(wǎng)絡(luò)層到狀態(tài)管理再到導(dǎo)航和列表優(yōu)化都有覆蓋。我把完整的工程結(jié)構(gòu)和關(guān)鍵實(shí)現(xiàn)整理成文給正在學(xué) React Native 或者想從 0.7x 跨到 0.80 的同學(xué)當(dāng)一份學(xué)習(xí)參考。這篇文章會以一個完整可運(yùn)行的實(shí)戰(zhàn)項目為主線從項目設(shè)計、技術(shù)選型、核心頁面實(shí)現(xiàn)、新特性運(yùn)用到常見問題排查按真實(shí)開發(fā)順序展開。每個關(guān)鍵點(diǎn)我都會講清楚為什么這么做、實(shí)際踩過什么坑、怎么調(diào)整而不是光貼代碼。想直接上手跑起來的跟著第二章的初始化步驟走一遍就行想深入了解某個模塊的可以直接跳到對應(yīng)章節(jié)看實(shí)現(xiàn)細(xì)節(jié)。這個項目定位是學(xué)習(xí)參考所以你不需要有完整的后端服務(wù)我會把數(shù)據(jù)請求層做了一層 mock 適配本地模擬接口也能把整個流程跑通。我用的環(huán)境是 macOS Android StudioReact Native 0.80 穩(wěn)定版下面所有路徑和命令都基于這個組合如果你用的是 Windows 或者只跑 iOS差異點(diǎn)我會單獨(dú)標(biāo)出來。1. 項目總體設(shè)計與技術(shù)選型思路1.1 為什么把 0.80 當(dāng)作學(xué)習(xí)基準(zhǔn)很多初學(xué)者上來就跟著網(wǎng)上的舊教程走用的還是 0.6x 甚至 0.5x 的語法等自己新建項目才發(fā)現(xiàn)模板已經(jīng)變了API 也廢棄了一大堆學(xué)得特別痛苦。React Native 從 0.7x 開始新架構(gòu)逐步收斂到 0.80 這個版本新架構(gòu)已經(jīng)在默認(rèn)配置里占據(jù)主導(dǎo)位置。這意味著你用 0.80 寫出來的代碼更貼近當(dāng)前社區(qū)的主流寫法將來維護(hù)老項目或者參與新項目認(rèn)知成本都會低很多。另外 0.80 對 TypeScript 的支持更完整很多類型定義不再需要手動補(bǔ)齊第三方庫的兼容性也穩(wěn)定了不少。這個版本作為學(xué)習(xí)基準(zhǔn)最大的好處是你現(xiàn)在寫的東西半年后還能用而不是教程還沒看完就要學(xué)寫廢棄 API。1.2 Demo 的功能邊界與業(yè)務(wù)場景我給這個項目定的業(yè)務(wù)場景是團(tuán)隊內(nèi)部的任務(wù)協(xié)作工具功能上做了適度裁剪保證學(xué)習(xí)價值的同時不把工程搞得太大手機(jī)號/密碼登錄帶 token 存儲與自動帶參請求任務(wù)列表分頁加載、狀態(tài)篩選、下拉刷新任務(wù)詳情展示任務(wù)信息、更新狀態(tài)新建/編輯任務(wù)表單校驗、提交反饋個人設(shè)置頭像選擇、主題切換、退出登錄這個功能組合覆蓋了移動端開發(fā)的大部分核心場景網(wǎng)絡(luò)通信、列表渲染、導(dǎo)航流轉(zhuǎn)、表單處理、全局狀態(tài)、本地存儲、主題適配。你把這套 Demo 吃透了換皮做其他業(yè)務(wù)思路是一樣的。1.3 技術(shù)選型清單我做了一個表格把這次用到的核心依賴和選擇理由列出來方便你對照著搭環(huán)境。模塊選型為什么選它跨端框架React Native 0.80新架構(gòu)默認(rèn)、TS 支持完善、長期維護(hù)開發(fā)語言TypeScript大型項目可維護(hù)性的基本盤導(dǎo)航React Navigation v7社區(qū)事實(shí)標(biāo)準(zhǔn)文檔全、問題好搜狀態(tài)管理Zustand比 Redux 輕量、上手快、模板代碼少網(wǎng)絡(luò)請求Axios攔截器機(jī)制成熟適合做統(tǒng)一封裝本地存儲AsyncStorage輕量 KV 存儲配合 token/主題使用UI 組件自研基礎(chǔ)組件 部分原生組件學(xué)習(xí)階段不引入重型 UI 庫理解更透Mock 數(shù)據(jù)本地 JSON 延時模擬無后端也能跑通全流程這套組合不是唯一的答案但絕對是一條性價比很高的學(xué)習(xí)路徑。Redux 依然主流但 Zustand 的體驗對新手友好很多等你把狀態(tài)流轉(zhuǎn)的邏輯搞清楚了再回頭理解 Redux 那一套也更容易。2. 環(huán)境準(zhǔn)備與工程初始化2.1 初始化項目的實(shí)際操作我習(xí)慣用官方模板新建項目命令如下npx react-native-community/clilatest init TodoDemo --version 0.80.0這里注意不要用react-native init這種老命令社區(qū) CLI 已經(jīng)把模板能力合并過來了。初始化完成后先跑一次默認(rèn)項目確認(rèn)環(huán)境沒問題再繼續(xù)動代碼。跑 iOS 模擬器cd TodoDemo npm run ios跑 Android先打開 Android Studio 啟動一個模擬器然后npm run android第一次運(yùn)行會自動執(zhí)行 gradle 構(gòu)建耗時幾分鐘很正常不要在這時候中斷。2.2 目錄結(jié)構(gòu)與模塊劃分工程初始化完之后默認(rèn)結(jié)構(gòu)比較散。我一般會按功能域重新組織src目錄規(guī)則是一個頁面一個文件夾公共能力單獨(dú)抽層src/ ├── api/ # 網(wǎng)絡(luò)請求層統(tǒng)一封裝 ├── components/ # 跨頁面復(fù)用的基礎(chǔ)組件 ├── store/ # 全局狀態(tài)管理 ├── navigation/ # 導(dǎo)航棧配置 ├── screens/ # 頁面級組件 ├── theme/ # 主題與樣式常量 ├── types/ # 全局類型定義 └── utils/ # 工具函數(shù)這樣劃分的核心理由是你能清楚地知道一個功能改動會影響哪些文件。比如說接口字段變了你只需要動api和typesUI 風(fēng)格調(diào)整重點(diǎn)看components和theme。學(xué)習(xí)階段養(yǎng)成分層習(xí)慣后面寫大項目才不會亂。2.3 TypeScript 路徑別名配置老項目里經(jīng)??吹揭淮?./../../../utils/format這種相對路徑看著就頭疼。我在項目里配了路徑別名用/指向src目錄干凈利落。先改tsconfig.json{ extends: react-native/typescript-config, compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }然后在項目根目錄建一個babel.config.js加上 babel-plugin-module-resolvermodule.exports { presets: [module:react-native/babel-preset], plugins: [ [ module-resolver, { root: [./], alias: { : ./src } } ] ] };改完配置重啟 Metro別忘記清緩存npm start -- --reset-cache這個配置學(xué)一次能解決以后所有項目的路徑混亂問題。2.4 統(tǒng)一網(wǎng)絡(luò)請求封裝網(wǎng)絡(luò)層是每個項目的地基。我在src/api/client.ts里封裝了一個 axios 實(shí)例統(tǒng)一處理 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) { // 實(shí)際項目在這里跳登錄頁 console.warn(登錄過期); } return Promise.reject(error); } ); export default client;用攔截器的好處是業(yè)務(wù)代碼里不需要關(guān)心 token 和錯誤兜底只管寫請求邏輯就行。比如獲取任務(wù)列表export const fetchTasks (params: FetchTasksParams) client.get(/tasks, { params });fetchTasks返回的已經(jīng)是處理過的數(shù)據(jù)頁面組件里直接setState或者寫入 store代碼可讀性提升一大截。3. 核心頁面實(shí)現(xiàn)與狀態(tài)管理實(shí)戰(zhàn)3.1 用 Zustand 管理全局狀態(tài)項目里的登錄會話、任務(wù)列表的篩選條件、主題模式這些跨頁面共享的數(shù)據(jù)我用 Zustand 管理。Zustand 的寫法比 Redux 簡潔太多看一段實(shí)際代碼就明白了// 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 createAuthState((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);這里有兩個實(shí)踐心得。第一zustand 的 selector 要寫細(xì)粒度useAuthStore(state state.token)這樣能避免不必要的重渲染不要圖省事把整個 store 拿下來。第二異步邏輯我放在 api 層store 里只存狀態(tài)和同步的狀態(tài)變更方法這樣代碼跑起來好定位問題。3.2 導(dǎo)航結(jié)構(gòu)設(shè)計導(dǎo)航是移動應(yīng)用的骨架我用的 React Navigation v7結(jié)構(gòu)上做了登錄棧 主棧的分層const RootStack createNativeStackNavigator(); function RootNavigator() { const token useAuthStore(state state.token); return ( NavigationContainer RootStack.Navigator screenOptions{{ headerShown: false }} {token ? ( RootStack.Screen nameAppTabs component{AppTabs} / ) : ( RootStack.Screen nameLogin component{LoginScreen} / )} /RootStack.Navigator /NavigationContainer ); }用 token 作為條件渲染的標(biāo)識登錄成功自動切換到主界面登出自動回登錄頁不需要手動 navigate邏輯非常直觀。登錄后的主界面用底部 Tab 導(dǎo)航包含任務(wù)、統(tǒng)計、個人中心三個頁簽。其中任務(wù)模塊又嵌套一個 Stack 導(dǎo)航用于處理列表頁 - 詳情頁 - 編輯頁的壓棧跳轉(zhuǎn)。有同學(xué)可能會問為什么不把頁面都放一個 Stack 里我的經(jīng)驗是模塊內(nèi)部用 Stack模塊之間用 Tab這樣每個導(dǎo)航層的職責(zé)單一。任務(wù)模塊內(nèi)部調(diào)整頁面跳轉(zhuǎn)不會影響其他 Tab后續(xù)加新建任務(wù)的全局入口也方便。3.3 任務(wù)列表的完整實(shí)現(xiàn)任務(wù)列表是這個項目里最值得細(xì)看的頁面它集合了數(shù)據(jù)請求、加載狀態(tài)、分頁、下拉刷新、空態(tài)展示這些常見邏輯。我用 FlatList 渲染主要代碼結(jié)構(gòu)如下const [tasks, setTasks] useStateTaskItem[]([]); 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 的關(guān)鍵性能屬性一個都不能少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是新手最容易忽略的優(yōu)化點(diǎn)。它告訴 FlatList 每一項的高度是固定的這樣列表可以跳過動態(tài)測量滾動時會明顯更跟手。我這個任務(wù)卡片高度是固定的 96所以可以直接寫死。如果你的列表項高度不固定那這個屬性沒法用但可以用onLayout緩存動態(tài)高度那是另一個優(yōu)化的故事了。篩選功能我是用狀態(tài)驅(qū)動查詢參數(shù)的方式做的篩選切換時重新從第一頁加載const [status, setStatus] useStateall | done | undone(all); const changeFilter (s: typeof status) { setStatus(s); loadTasks(1, true); };3.4 新建與編輯任務(wù)頁的表單處理表單這塊我沒有引入 Formik 之類的庫直接用手寫受控組件配合一個簡單的校驗函數(shù)。原因是這個表單只有四五個字段上重型表單庫反而增加理解成本學(xué)習(xí)階段把底層邏輯吃透更重要。核心代碼const [form, setForm] useState({ title: , desc: , priority: medium, dueDate: , }); const [errors, setErrors] useStateFormErrors({}); const validate () { const nextErrors: FormErrors {}; if (!form.title.trim()) { nextErrors.title 標(biāo)題不能為空; } else if (form.title.trim().length 2) { nextErrors.title 標(biāo)題至少2個字; } if (form.dueDate new Date(form.dueDate).getTime() Date.now()) { nextErrors.dueDate 截止時間不能早于當(dāng)前時間; } 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在列表頁重新拉數(shù)據(jù)useFocusEffect( useCallback(() { loadTasks(1, true); }, []) );這里說一下為什么用useFocusEffect而不是useEffect。useEffect是組件掛在時觸發(fā)而useFocusEffect是每次頁面獲得焦點(diǎn)時觸發(fā)。從編輯頁返回到列表頁列表組件并沒有重新掛載所以useEffect不會重新執(zhí)行useFocusEffect能做到。這是 React Navigation 里特別常用的生命周期用法。4. 0.80 項目里的新特性實(shí)踐與性能優(yōu)化4.1 新架構(gòu)默認(rèn)開啟后我觀察到的變化升級到 0.80 之后項目默認(rèn)走的就是新架構(gòu)運(yùn)行時。從我實(shí)際開發(fā)和調(diào)試的體驗來看最直觀的變化是啟動速度和交互流暢度。同樣的業(yè)務(wù)代碼在舊架構(gòu)上冷啟動需要白屏挺長時間新架構(gòu)下面啟動到可交互的間隔明顯縮短頁面切換也很少出現(xiàn)掉幀。這個感知在真機(jī)上比模擬器明顯。做學(xué)習(xí)項目的話建議有條件盡量用真機(jī)調(diào)試模擬器更多是用來看布局和驗證邏輯的。新架構(gòu)下如果你引入的第三方原生組件沒有做新架構(gòu)適配可能會出現(xiàn)編譯能過運(yùn)行崩的情況所以依賴庫盡量選社區(qū)活躍的。4.2 暗色模式與主題系統(tǒng)實(shí)現(xiàn)0.80 項目的模板已經(jīng)支持系統(tǒng)暗色模式了但業(yè)務(wù)組件并不會自動跟著適配需要自己把主題體系搭起來。我在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動態(tài)切換const scheme useColorScheme(); const theme scheme dark ? darkTheme : lightTheme;然后在頁面里用useTheme取顏色值而不是直接寫死#FFFFFF。這套邏輯搭好之后組件顏色全部走主題變量切換暗色模式時全應(yīng)用統(tǒng)一變化不用一個頁面一個頁面去改。4.3 列表渲染與圖片加載的性能細(xì)節(jié)任務(wù)列表里卡片包含縮略圖、標(biāo)題、描述、優(yōu)先級標(biāo)簽。圖片這塊我把maxToRenderPerBatch和windowSize調(diào)過一輪這里寫一下我整理后的穩(wěn)定配置FlatList initialNumToRender{10} maxToRenderPerBatch{10} windowSize{7} removeClippedSubviews ... /這幾個參數(shù)的作用分別是initialNumToRender首屏渲染的條目數(shù)不要貪多10 條足夠多了首屏?xí)╩axToRenderPerBatch滾動時每批渲染的條數(shù)減小這個值能讓滾動更平滑但也不能太小否則頻繁渲染更耗性能windowSize可視區(qū)域外預(yù)渲染的緩沖區(qū)大小單位是可視區(qū)域高度的倍數(shù)調(diào)太低會導(dǎo)致快速滑動時白屏圖片優(yōu)化我主要做了兩點(diǎn)縮略圖用固定尺寸裁剪避免大圖加載列表圖片加上resizeMethodresize減少內(nèi)存占用。如果圖片來自網(wǎng)絡(luò)還需要考慮用緩存庫但本地 mock 階段先不過度設(shè)計。4.4 狀態(tài)管理結(jié)合 immutable 更新的注意事項在任務(wù)列表里頻繁操作狀態(tài)更新我給 store 里的列表更新寫了一個統(tǒng)一的updateTaskInList方法。反復(fù)操作之后我意識到一個關(guān)鍵點(diǎn)Zustand 的set雖然是淺合并但嵌套對象里的變更必須手動保證不可變性不然會碰到界面不刷新、數(shù)據(jù)卻變了的詭異問題。const updateTaskInList (taskId: number, patch: PartialTaskItem) set((state) ({ tasks: state.tasks.map((item) item.id taskId ? { ...item, ...patch } : item ), }));這里用map返回新數(shù)組、用展開運(yùn)算符更新目標(biāo)對象每次更新都是全新的引用zustand 才能檢測到變化觸發(fā)重新渲染。如果你直接state.tasks[0].status done再set(state ({ tasks: state.tasks }))引用沒變組件不會重渲染這種 bug 排查起來特別費(fèi)時間。5. 常見問題與排查心得5.1 Unable to resolve module 的幾類原因開發(fā)階段遇到最多的報錯就是Unable to resolve module。我總結(jié)了三類常見原因第一路徑別名配置了但 Metro 不知道。每次改babel.config.js都要重啟 Metro并--reset-cache否則新的模塊解析規(guī)則不生效。第二引用了還沒安裝的包。第三文件大小寫寫錯了。macOS 文件系統(tǒng)默認(rèn)不區(qū)分大小寫iOS 模擬器也是但 Android 構(gòu)建有時對大小寫敏感。我自己就栽過一次import UserCard from /components/userCard文件名是UserCard.tsxiOS 跑得好好的Android 直接 module not found。排查路徑的第一件事永遠(yuǎn)是看完整報錯信息里面會提示具體的文件位置照著把目錄結(jié)構(gòu)翻一遍大部分問題肉眼就能看出來。5.2 新架構(gòu)下 Android Release 構(gòu)建失敗用 0.80 打 Android release 包我碰到過一次構(gòu)建失敗報錯指向Invariant Violation。排查過程是這樣的先看android/gradle.properties里的newArchEnabled配置確認(rèn)新架構(gòu)相關(guān)參數(shù)沒問題然后關(guān)閉代碼混淆再打一次包定位到是hermes字節(jié)碼和某些第三方庫的兼容問題。最終解決辦法是升級了那個第三方庫到支持新架構(gòu)的版本并且重新清理構(gòu)建cd android ./gradlew clean cd .. npm run android -- --mode release經(jīng)驗是遇到新架構(gòu)的 release 問題先區(qū)分是自己業(yè)務(wù)代碼的問題還是第三方庫的問題。把業(yè)務(wù)代碼屏蔽掉逐步加回來是最快的定位方式。5.3 Metro 緩存導(dǎo)致的改了代碼沒效果有時候改了頁面樣式刷新半天還是老樣子。這多半是 Metro 緩存搞得鬼。常規(guī)操作刷新幾次沒用的話按這個順序處理# 停掉 Metro 后 npx react-native start --reset-cache如果還不行清掉原生構(gòu)建緩存cd android ./gradlew clean cd ios rm -rf Pods pod install然后重新運(yùn)行。這種清緩存三板斧能解決 90% 的疑難雜癥。平時開發(fā)盡量保持 Metro 正常生命周期不要頻繁 kill避免緩存頻繁重建。5.4 Android 模擬器請求本地 Mock 服務(wù)連不上我的 mock 服務(wù)跑在宿主機(jī)的 3000 端口Android 模擬器里訪問localhost:3000是連不上的。因為模擬器里的localhost指向的是模擬器自己。解決辦法是用 Android 模擬器的專屬地址10.0.2.2const BASE_URL Platform.select({ ios: http://localhost:3000, android: http://10.0.2.2:3000, });另外 Android 9 之后默認(rèn)禁止明文 HTTP 請求需要在android/app/src/main/AndroidManifest.xml里配置usesCleartextTraffictrue或者用 network security config 只對開發(fā)域名放開明文權(quán)限。這兩個點(diǎn)不處理調(diào)試期就會一直卡在請求直接失敗上。寫在最后把整個項目從舊架構(gòu)切到 0.80 重寫一遍我最深的體會是React Native 現(xiàn)在確實(shí)到了新架構(gòu)紅利期啟動性能、交互流暢度的提升不是玄學(xué)真機(jī)一跑就能感受到。但這個紅利是給那些把基礎(chǔ)工程做扎實(shí)的人的目錄組織、狀態(tài)管理邊界、網(wǎng)絡(luò)層封裝、列表優(yōu)化參數(shù)這些基本功沒到位換什么版本都會寫出磕磕絆絆的代碼。這個任務(wù)協(xié)作 Demo 我還在繼續(xù)擴(kuò)展后續(xù)計劃把服務(wù)端組件和 CodePush 相關(guān)能力加進(jìn)去。你拿到這份參考建議先照著初始化把工程跑通然后改一個自己的頁面比如把任務(wù)列表換成你的藏書清單、隨手記賬改著改著就會順手了。遇到問題歡迎在評論區(qū)把報錯貼出來我看到會回復(fù)。