引言
React Native 新架构(Fabric + TurboModules + JSI)自 0.76 起成为默认模式,HarmonyOS 生态也在持续扩张。当两者交汇,一个现实问题摆在开发者面前:如何用一套 React Native 代码,同时跑在 iOS、Android 和鸿蒙三个平台上?
本文以一个基于 React Native 0.77(新架构 / C-API)+ RNOH 0.77.50(鸿蒙适配层) 的企业级应用为样本,从工程架构、状态管理、网络层设计、鸿蒙适配策略、到自研工具链,分享跨三端落地过程中的真实经验。所有讨论均聚焦技术实现,不涉及具体业务信息。
一、技术选型:为什么是这套组合
这是一个包含 42+ 业务页面的中型应用,覆盖表单录入、列表查询、文件上传下载、拍照取证等典型企业级场景。
技术栈一览
| 层级 | 技术方案 | 版本 |
|---|---|---|
| 框架 | React Native(新架构 / C-API) | 0.77.1 |
| 鸿蒙适配 | RNOH (React Native OpenHarmony) | 0.77.50 |
| UI 组件库 | Ant Design React Native | 5.4.2 |
| 状态管理 | Redux Toolkit + redux-persist | 2.2.1 |
| 导航 | React Navigation 7(Native Stack + Bottom Tabs) | 7.2.2 |
| 网络 | Axios | 1.6.7 |
| 样式 | NativeWind (Tailwind CSS for RN) | 4.1.24 |
| 动画 | react-native-reanimated + gesture-handler | 3.18.0 |
| 列表 | @shopify/flash-list | 1.8.2 |
| 语言 | TypeScript | 5.0.4 |
选型的核心考量是 "一次编写,三端运行",同时保留对鸿蒙平台差异的细粒度控制能力。Ant Design RN 提供了开箱即用的企业级组件(Form、Picker、Modal 等),NativeWind 让样式开发回归 Tailwind 的高效体验,Redux Toolkit 则以最小样板代码覆盖了复杂的状态管理需求。
二、架构设计:分层清晰,关注点分离
css
App.tsx ← Provider 嵌套层(Gesture / SafeArea / Redux / AntDesign)
├── RootNavigator ← 认证守卫 + 平台适配的导航栈
│ ├── Auth Stack ← 登录页
│ └── Main Stack ← N 个 Tab + 42 个子页面
├── LoadingOverlay ← 全局 Loading(引用计数驱动)
├── Watermark ← 水印遮罩层
└── AppUpdateManager ← 静默版本检查 + 强制更新
src/
├── navigation/ ← 路由定义,含 .harmony.tsx 平台变体
├── screens/ ← 按业务域组织的页面组件
├── components/ ← 可复用 UI 组件(20+)
├── services/ ← API 层(apiClient + 领域 Service)
├── store/ ← Redux Toolkit(5 个 Slice + 持久化)
├── hooks/ ← 自研 Hooks(分页、筛选、详情、主题、更新)
├── theme/ ← 设计令牌(colors / spacing / typography)
├── constants/ ← 配置常量、消息类型定义
├── types/ ← TypeScript 类型定义
├── utils/ ← 纯工具函数(树操作、格式化、文件处理)
└── native/ ← 平台模块重导出(.harmony.ts 变体)
Provider 嵌套顺序
tsx
// App.tsx --- 由外到内的 Provider 链
<GestureHandlerRootView>
<SafeAreaProvider>
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<AntProvider>
<RootNavigator />
<LoadingOverlay />
<AppUpdateManager />
<Watermark /> {/* pointerEvents="none" */}
</AntProvider>
</PersistGate>
</Provider>
</SafeAreaProvider>
</GestureHandlerRootView>
这个顺序不是随意的:GestureHandlerRootView 必须在最外层以确保手势事件正确传播;PersistGate 在 Redux Provider 之内以保证 store 已恢复后再渲染子树;Watermark 作为纯视觉遮罩放在最内层,pointerEvents="none" 确保不拦截任何触摸事件。
三、API 层设计:一个值得细品的 HTTP Client
apiClient.ts 是整个应用的网络中枢,但它不仅仅是 Axios 的简单封装。几个设计亮点:
3.1 依赖注入解耦
typescript
// 由外部 Store 注入,apiClient 本身不依赖任何 Store
setTokenProvider(() => store.getState().user.accessToken);
setGlobalLoadingHandler((isLoading) => store.dispatch(setLoading(isLoading)));
setUnauthorizedHandler(() => {
store.dispatch(logout());
store.dispatch(clearMenu());
});
这使得 apiClient 成为一个纯函数式的网络层,不与 Redux 耦合,可独立测试。三种注入点分别处理:认证令牌获取、全局 Loading 状态驱动、授权失效后的清理逻辑。
3.2 引用计数的全局 Loading
这是最精巧的设计之一。当多个并发请求同时发出时,Loading 的显示/隐藏不能简单地跟随单个请求,否则会出现闪烁。解决方案:
typescript
let requestCount = 0; // 引用计数
let lastShownAt = 0; // 最近一次显示时间戳
const MIN_VISIBLE_MS = 100; // 最小可见时间
function showGlobalLoading() {
if (requestCount === 0) {
// 取消可能残留的延迟关闭定时器
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
_globalLoadingHandler(true);
lastShownAt = Date.now();
}
requestCount += 1;
}
function hideGlobalLoading() {
requestCount -= 1;
if (requestCount <= 0) {
requestCount = 0;
const remaining = Math.max(0, MIN_VISIBLE_MS - (Date.now() - lastShownAt));
// 确保 Loading 至少显示 100ms,避免闪烁
hideTimer = setTimeout(() => _globalLoadingHandler(false), remaining);
}
}
关键细节:
- 引用计数:只有第一个请求触发显示,最后一个请求关闭
- 最小可见时间 :
MIN_VISIBLE_MS = 100ms确保即使请求极快返回,Loading 也不会"闪一下就消失" - 竞态保护 :
hideTimer在新一轮请求到来时被取消,防止误关 Loading - 二次检查 :延迟关闭时再次确认
requestCount === 0,避免并发场景下的误关
此外,支持通过 noLoading 配置或 x-no-loading 请求头跳过全局 Loading,适用于静默刷新、轮询等场景。
3.3 安全的开发日志
typescript
const SENSITIVE_KEYS = ['authorization', 'password', 'token', 'accesstoken', 'refreshtoken'];
function redactSensitiveData(value: unknown): unknown {
if (Array.isArray(value)) return value.map(redactSensitiveData);
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(
Object.entries(value).map(([key, val]) => [
key,
SENSITIVE_KEYS.includes(key.toLowerCase()) ? '[REDACTED]' : redactSensitiveData(val),
]),
);
}
递归地将敏感字段脱敏后输出到控制台,开发调试安全两不误。在企业级应用中,日志泄露 Token 或密码是常见的安全隐患,这个方案以极低的成本堵住了这个口子。
3.4 错误类型体系
typescript
export class ApiError extends Error {
constructor(public readonly statusCode: number, message: string, public readonly data?: unknown) { ... }
}
export class NetworkError extends Error { ... }
export class TimeoutError extends Error { ... }
三种错误类型覆盖了所有失败场景:业务错误(API 返回错误码)、网络不可用、请求超时。UI 层可以根据错误类型展示不同的提示信息,而不是一个笼统的"请求失败"。
四、状态管理:Redux Toolkit 的务实应用
4.1 五个 Slice 的职责划分
| Slice | 持久化? | 职责 |
|---|---|---|
userSlice |
✅ | 用户信息、Token、登录状态 |
appSlice |
✅ | 主题配置、站点信息、系统信息、更新信息 |
menuSlice |
✅ | 菜单树数据(从后端动态获取) |
globalSlice |
❌ | 全局 Loading 状态 |
refDataSlice |
❌ | 字典数据、组织机构选项(带缓存策略) |
user、app、menu 三个 Slice 通过 redux-persist 持久化到 AsyncStorage,确保应用重启后无需重新登录或重新拉取菜单。global 和 refData 是瞬态数据,重启后重新获取即可。
4.2 字典数据的内存缓存
refDataSlice 值得一提------它的每个异步 Thunk 都实现了 内存级缓存:
typescript
export const fetchDictData = createAsyncThunk(
'refData/fetchDictData',
async (modelCode, { getState }) => {
const cached = getState().refData.dictModel[modelCode];
if (cached) return cached; // 命中缓存,直接返回
const res = await getDictList(modelCode);
return res.data;
}
);
字典数据在企业应用中极为常见(行业分类、许可类型、检查项目等),且变更频率低。内存缓存避免了同一生命周期内的重复请求,同时不引入额外的缓存库。
4.3 与 API 层的联动
Store 初始化时完成了三件事:
typescript
setTokenProvider(() => store.getState().user.accessToken);
setGlobalLoadingHandler((isLoading) => store.dispatch(setLoading(isLoading)));
setUnauthorizedHandler(() => {
Toast.info('登录已过期,请重新登录');
store.dispatch(logout());
store.dispatch(clearMenu());
store.dispatch(clearOrgData());
});
401/403 统一处理,自动清空状态并引导重新登录。所有 Service 层无需关心认证失效逻辑------这是 apiClient 依赖注入架构的直接收益。
五、导航系统:认证守卫与平台适配
5.1 认证守卫模式
typescript
function RootStackNavigator() {
const isLoggedIn = useAppSelector(selectIsLoggedIn);
return (
<Stack.Navigator>
{!isLoggedIn ? (
<Stack.Screen name="Auth" component={LoginScreen} />
) : (
<>
<Stack.Screen name="Main" component={MainTabs} />
{/* 42 个子页面在此注册 */}
</>
)}
</Stack.Navigator>
);
}
简洁而有效:未登录时整个导航栈只有 Login 一个页面,用户无法通过深链接绕过认证。
5.2 鸿蒙的 Stack 适配
这是跨平台适配的典型场景。iOS/Android 使用原生 Stack(react-native-screens),但鸿蒙的 RNOH 不支持原生 screens,必须降级为 JS Stack:
typescript
// rootStack.tsx (Android/iOS)
import { createNativeStackNavigator } from '@react-navigation/native-stack';
const Stack = createNativeStackNavigator();
// rootStack.harmony.tsx (HarmonyOS)
import { createStackNavigator } from '@react-navigation/stack';
const Stack = createStackNavigator();
React Native 的 .harmony.tsx 平台后缀机制让 Metro 在打包时自动选择正确的文件,业务代码完全不需要 Platform.OS 判断 。这是整个项目中最优雅的平台适配策略------差异被隔离在文件层面,而非散落在 if-else 分支中。
同样的模式还应用在:
devDemoStack.tsx/devDemoStack.harmony.tsxasyncStorage.ts/asyncStorage.harmony.tsPlatformButton/index.tsx/PlatformButton/index.harmony.tsx
5.3 动态菜单驱动的路由映射
应用的菜单不是前端硬编码,而是从后端 API 动态获取。前端维护一个路由映射表:
typescript
const ROUTE_MAP: Record<string, keyof RootStackParamList> = {
'/pages/check/dailyInspection': '/Market/DailyInspection',
'/pages/complaint/allot': '/Market/ComplaintAllot',
// ... 更多映射
};
后端返回的菜单树包含路径标识,前端通过映射表转换为 React Navigation 的路由名。这种设计使得后端可以灵活调整菜单结构,前端只需维护映射关系。
六、自研 Hooks:用组合式思维解决通用问题
6.1 usePaginatedList --- 泛型分页列表
这是整个 App 中复用率最高的 Hook,几乎所有列表页都在用它。
typescript
export function usePaginatedList<T>({
fetcher,
pageSize = 10,
autoLoad = true,
}: UsePaginatedListOptions<T>) {
const isFetchingRef = useRef(false); // 同步锁,防并发
const fetchData = useCallback(async (page: number, type: FetchType) => {
if (isFetchingRef.current) return; // 忽略并发请求
isFetchingRef.current = true;
// ... 请求逻辑
finally { isFetchingRef.current = false; }
}, [pageSize]);
const loadMore = useCallback(() => {
if (hasMore && !isFetchingRef.current) {
fetchData(pageNumRef.current + 1, 'loadMore'); // 用 ref 避免闭包过期
}
}, [hasMore, fetchData]);
return {
data, refreshing, loadingMore, hasMore, error,
listRef, refresh, loadMore, scrollToTop,
};
}
三个关键设计决策:
1. 同步锁 isFetchingRef :用 useRef 而非 useState 实现。setState 是异步的,无法在高频触发场景(如快速滑动触发多次 loadMore)中可靠地防止并发。useRef 是同步赋值,锁的获取和释放在同一事件循环内完成。
2. pageNumRef 解决闭包陷阱 :loadMore 的回调中需要最新页码,但 useCallback 的依赖数组会导致频繁重建。用 useRef 同步跟踪页码,既保证数据新鲜又避免不必要的重渲染。
typescript
const pageNumRef = useRef(1);
// fetchData 成功后:
pageNumRef.current = page;
setPageNum(page); // 触发 UI 更新
3. 区分业务错误和异常错误:
typescript
export type PaginatedError<T> =
| { type: 'exception'; error: unknown } // 网络异常
| { type: 'business'; response: FetcherResponse<T> }; // 业务错误码
UI 层可以分别处理"接口返回错误码"和"网络异常"两种场景------前者可能显示"暂无数据",后者显示"网络连接失败,请重试"。
6.2 useFilters --- 筛选状态与重置信号
typescript
const { filters, updateFilter, resetFilters, resetSignal } = useFilters(initialFilters);
resetSignal 是一个递增的数字,每次重置时 +1。列表组件通过监听 resetSignal 变化来触发重新请求------这比直接在 resetFilters 中触发请求更解耦,因为筛选组件和列表组件不需要直接通信。
typescript
// 列表组件中
useEffect(() => {
if (resetSignal > 0) refresh(); // 重置信号变化时刷新列表
}, [resetSignal]);
这种"信号量"模式在需要跨组件协调状态变化时非常实用,避免了 prop drilling 或全局事件总线。
6.3 useDetailFetch --- 三种详情页变体
typescript
// 从路由参数获取 ID 并请求详情
export function useParamsDetailFetch<T>(fetcher: (id: string) => Promise<T>);
// 直接传入 ID
export function useDetailFetch<T>(id: string, fetcher: (id: string) => Promise<T>);
// 带工单号的详情(如审批流)
export function useOrderDetailFetch<T>(orderNo: string, fetcher: (orderNo: string) => Promise<T>);
三种变体覆盖了详情页的常见场景,统一处理加载状态、错误状态和数据缓存。
6.4 useAppUpdate --- 版本更新的完整生命周期
typescript
function compareVersions(a: string, b: string): number {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < 3; i++) {
if (pa[i] !== pb[i]) return pa[i] - pb[i];
}
return 0;
}
配合 Redux 的 updateInfo 和 ignoredVersion,实现了"检查更新 → 强制/建议更新 → 忽略版本"的完整流程。版本比较使用语义化版本号,而非字符串比较------"1.9.0" < "1.10.0" 在字符串比较中是错误的。
七、组件设计哲学:务实而非炫技
7.1 Watermark --- 全局水印
tsx
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
{/* 重复渲染用户名 + 组织部门,倾斜排列 */}
</View>
pointerEvents="none" 确保水印不拦截任何触摸事件,同时覆盖在所有内容之上。在企业级应用中,水印是防止截图泄露信息的常见手段,这个实现简洁而有效。
7.2 LoadingOverlay --- Redux 驱动的全局遮罩
不需要在每个页面手动管理 Loading 状态。任何 API 请求(默认)都会自动触发全局 Loading,由引用计数机制控制显示/隐藏。对于需要静默执行的请求,传入 noLoading: true 即可。
7.3 PlatformButton --- 平台感知的 UI
typescript
// PlatformButton/index.tsx (默认)
<Button style={{ backgroundColor: '#028d71' }}>...</Button>
// PlatformButton/index.harmony.tsx (鸿蒙)
<Button style={{ backgroundColor: '#CF0A2C' }}>...</Button>
同一个组件,鸿蒙上自动使用特定品牌色。不需要运行时判断,Metro 打包时自动选择正确的文件。
7.4 DaDropdown --- 复合筛选下拉
自定义的下拉组件,支持日期范围、枚举选择、多级联动等复合筛选场景。在企业应用中,列表页的筛选条件往往很复杂,一个通用的筛选组件可以大幅减少重复代码。
八、鸿蒙适配:七个 Patch 背后的真实故事
跨平台开发中,最耗时的往往不是业务逻辑,而是 第三方库在新平台上的兼容性问题 。项目使用 patch-package 管理了 7 个补丁,每一个都对应一个真实的踩坑经历。
8.1 axios+1.6.7.patch --- 空响应头
问题 :在 RN/鸿蒙环境下,XHR 的 getAllResponseHeaders() 返回空字符串,导致 Axios 无法解析响应头。
影响:所有基于 Axios 的请求都可能失败,因为响应拦截器依赖 header 解析。
修复:在响应拦截器中添加兜底逻辑,当 header 为空时构造最小可用 header。这是一个跨平台的通用问题,不仅影响鸿蒙。
8.2 react-native-file-viewer --- 近乎重写
这是最复杂的一个 patch,涉及四个子问题:
/storage路径映射 :鸿蒙的沙箱路径与 Android 不同,直接使用/storage前缀会导致文件找不到.txt文件预览:鸿蒙沙箱内的文本文件无法被外部应用直接打开,需要先复制到可访问路径- MIME 类型推断:鸿蒙的文件类型推断不完整,需要手动补充
- PreviewKit 降级:当 FileViewer 无法打开时,尝试使用 PreviewKit 作为兜底方案
8.3 react-native-document-picker --- 三个问题合一
- 相册文件在鸿蒙上是只读的,不能直接用
copyTo复制 - 中文文件名需要 UTF-8 编码处理,否则出现乱码
copyTo目标路径的权限问题,需要使用正确的沙箱路径
8.4 构建级适配:repack-ohos-hars.sh
鸿蒙的包管理器 ohpm 使用 .har 格式的本地包。当我们 patch 了第三方库的源码后,需要将修改重新打包进 .har 文件:
bash
#!/bin/bash
# scripts/repack-ohos-hars.sh
# 解压 .har → 替换修补的 ETS 源码 → 重新打包
这在 postinstall 阶段自动执行,保证每次 npm install 后鸿蒙的本地包都是最新的。这个脚本的存在说明了一个现实:鸿蒙生态的第三方库质量参差不齐,很多时候需要自己动手修补。
8.5 其他 Patch
| Patch | 平台 | 问题 |
|---|---|---|
react-native-fs |
Android | AGP 8 兼容性(namespace、react-android 依赖) |
react-native-spinkit |
Android | AGP 兼容性(jcenter → mavenCentral) |
react-native-splash-screen |
Android | AndroidX 迁移 |
@react-native-ohos/react-native-fs |
鸿蒙 | file://media/ 前缀被错误截断 |
这些 patch 揭示了一个事实:跨平台开发的隐性成本不在业务代码,而在依赖管理。 每个 patch 都需要理解上游库的实现细节、目标平台的差异、以及修复的副作用。
九、文件操作的鸿蒙适配:一个深度案例
企业级应用离不开文件操作------上传照片、下载文档、预览 PDF 等。在鸿蒙上,文件系统的沙箱机制与 Android/iOS 有显著差异。
9.1 ensureUploadablePath --- 确保文件可上传
typescript
export async function ensureUploadablePath(uri: string): Promise<string> {
// 1. 如果已经是可访问的本地路径,直接返回
// 2. 如果是 content:// 或 file://media/ 前缀
// → 鸿蒙:先 copyFile 到临时目录,再返回新路径
// → Android:通过 ContentResolver 读取
// 3. 兜底:base64 读取 → 写入临时文件
}
这个函数的存在说明:跨平台文件操作没有银弹。 每个平台的文件系统都有自己的语义差异,必须针对具体场景做适配。
9.2 openPickedFilePreview --- 跨平台文件预览
typescript
export async function openPickedFilePreview(file: DocumentPickerResponse) {
if (Platform.OS === 'harmony') {
// 鸿蒙策略:优先用 FileViewer,失败则用 PreviewKit 降级
// .txt 文件需要先复制到沙箱外的可访问路径
} else {
// Android/iOS:直接调用 FileViewer.open()
}
}
9.3 buildUploadFormDataFromPickedFile --- 文件上传的 FormData 构造
typescript
export function buildUploadFormDataFromPickedFile(file: DocumentPickerResponse): FormData {
const formData = new FormData();
formData.append('file', {
uri: file.uri,
name: file.name,
type: file.type ?? 'application/octet-stream',
} as any);
return formData;
}
注意 FormData 的 Content-Type 处理------在 API 层的请求拦截器中,如果检测到 FormData,会调用 headers.setContentType(false) 让 RN 原生层自动生成 multipart boundary。这是另一个容易踩坑的地方:Axios 1.x 默认的 application/json 会导致 FormData 被序列化为 JSON 字符串,在 Android 上表现为 Network Error。
十、性能优化:从列表到动画
10.1 FlashList 替代 FlatList
对于长列表场景,使用 @shopify/flash-list 替代 RN 内置 FlatList。FlashList 通过回收不可见区域的视图来减少内存占用,在大数据量场景下性能优势明显。
10.2 Reanimated 的 UI 线程动画
跑马灯文本、悬浮按钮等动画组件使用 react-native-reanimated,动画逻辑运行在 UI 线程,不阻塞 JS 线程。
typescript
const scrollY = useSharedValue(0);
const fabVisible = useDerivedValue(() => scrollY.value > threshold);
使用 useSharedValue + useDerivedValue,FAB 的显示/隐藏完全在 UI 线程完成,零 JS 线程通信开销。
10.3 enableScreens 的鸿蒙降级
typescript
// index.js
if (Platform.OS !== 'harmony') {
enableScreens(true); // iOS/Android 启用原生 screens
} else {
enableScreens(false); // 鸿蒙禁用,使用 JS Stack
}
react-native-screens 的原生实现在鸿蒙上不可用,必须禁用。这是一个在入口文件级别的平台适配,所有导航相关代码都受此影响。
十一、鸿蒙 RNOH 深度集成
11.1 CAPI 架构模式
typescript
// Index.ets
RNApp({
rnInstanceConfig: {
enableCAPIArchitecture: true, // 启用 C-API 架构
enableNDKTextMeasuring: true, // NDK 文本测量
createRNPackages,
fontResourceByFontFamily: {
'antoutline': $rawfile("fonts/antoutline.ttf"),
'antfill': $rawfile("fonts/antfill.ttf"),
},
},
jsBundleProvider: new AnyJSBundleProvider([
new MetroJSBundleProvider(), // 开发:热更新
new FileJSBundleProvider('/data/.../bundle.harmony.js'), // 真机调试
new ResourceJSBundleProvider(..., 'bundle.harmony.js'), // 生产
]),
})
三个 JS Bundle Provider 的优先级设计:
- Metro:开发时热更新,连接 Metro DevServer
- File:真机调试时从文件系统加载
- Resource:生产环境从 App 资源中加载
enableCAPIArchitecture: true 启用 C-API 架构,这是 RNOH 的核心特性------通过 C++ 直接桥接 ArkUI 和 React Native 的渲染树,避免了 TypeScript ↔ ArkTS 的序列化开销。
11.2 原生模块注册
typescript
export function createRNPackages(ctx: RNPackageContext): RNPackage[] {
return [
new SafeAreaViewPackage(ctx),
new GestureHandlerPackage(ctx),
new AsyncStoragePackage(ctx),
new PermissionsPackage(ctx),
new DocumentPickerPackage(ctx),
new SpinKitPackage(ctx),
new SplashScreenPackage(ctx),
new FileSystemPackage(ctx),
new ReanimatedPackage(ctx),
new FileViewerPackage(ctx),
];
}
10 个原生模块全部通过 RNOH 的 Package 机制注册,使用 C-API 架构直接调用鸿蒙原生 API。
11.3 自定义原生组件
typescript
@Builder
export function buildCustomRNComponent(ctx: ComponentBuilderContext) {
if (ctx.componentName === SpinKitView.NAME) {
SpinKitView({ ctx: ctx.rnComponentContext, tag: ctx.tag })
}
}
对于 RNOH 未内置的原生组件(如 SpinKit 加载动画),需要通过 buildCustomRNComponent 手动注册。这体现了 RNOH 当前阶段的一个现实:生态还在建设中,很多组件需要自己适配。
十二、工程规范:代码质量的自动化守护
12.1 Git Hooks 链
sql
git commit
→ Husky pre-commit → lint-staged
→ ESLint --fix(自动修复可修复的问题)
→ Prettier --write(统一格式化)
→ Husky commit-msg → commitlint
→ 校验 Conventional Commits 格式
12.2 Commit 规范
采用 @commitlint/config-conventional,强制要求:
makefile
feat: 新增功能
fix: 修复 Bug
refactor: 重构(不改变行为)
docs: 文档更新
chore: 构建/工具变更
配合 git log 可以自动生成 CHANGELOG,也可以用 git log --grep 快速定位特定类型的变更。
12.3 路径别名
json
// tsconfig.json
"paths": { "@/*": ["./src/*"] }
javascript
// babel.config.js
['module-resolver', { alias: { '@': './src' } }]
javascript
// metro.config.js
extraNodeModules: { '@': path.resolve(__dirname, 'src') }
TypeScript、Babel、Metro 三处统一配置 @/ 别名,消除 ../../../ 地狱。这是现代 RN 项目的标配,但三处配置必须同步,否则会出现编译通过但运行时找不到模块的问题。
十三、文档驱动开发:docs/ 目录的价值
项目维护了 40+ 篇中文技术文档,覆盖:
- 使用指南(18 篇):API 调用、Redux 使用、列表优化、文件上传等
- Hook 文档(3 篇):每个自研 Hook 的 API、用法、注意事项
- 踩坑记录(8 篇):上游库 Bug、平台差异、异步竞态等
- 迁移指南(4 篇):从旧架构迁移到新架构的分阶段记录
- 技术博客(2 篇):Axios 响应头问题、rn-fs 路径问题的深度分析
这不是"写完就扔"的文档,而是 与代码同步演进的活文档。每一个 patch 背后都有一篇 issue 文档记录问题现象、根因分析和修复方案。
为什么文档很重要? 因为跨平台开发中的坑往往是"一次性"的------你踩过一次,解决了,三个月后换个人来维护,同样的问题可能再踩一遍。文档把这些"一次性知识"变成了可复用的团队资产。
十四、总结
这个项目的技术实践揭示了几个跨三端开发的核心命题:
1. React Native 新架构 + RNOH 是可行的生产方案。 通过 .harmony.tsx 平台后缀和 patch-package,可以在不 fork 上游库的情况下完成鸿蒙适配。但需要接受一个现实:鸿蒙生态仍在建设中,第三方库的适配质量参差不齐。
2. 企业级应用对工程质量的要求远高于一般 App。 认证守卫、数据持久化、水印、强制更新、敏感数据脱敏------每一个细节都关乎合规和安全。这些不是"锦上添花",而是"没有就不上线"。
3. 自研 Hooks 是复用逻辑的最佳载体。 usePaginatedList、useFilters、useDetailFetch 三个 Hook 覆盖了 90% 的列表页场景,新页面的开发效率极高。Hooks 的设计应该面向"组合"而非"继承",每个 Hook 解决一个具体问题。
4. 文档是工程的一部分。 40+ 篇文档不是负担,而是团队协作和知识传承的基础设施。尤其在跨平台开发中,平台差异带来的"隐性知识"远多于单平台项目。
5. 跨平台的隐性成本在依赖管理。 7 个 patch 意味着 7 次"上游库在新平台上不工作"的经历。每个 patch 都需要理解上游实现、平台差异、以及修复的副作用。在评估跨平台方案时,这部分成本往往被严重低估。
6. 平台差异应该隔离在文件层面,而非散落在 if-else 中。 .harmony.tsx 后缀机制是 React Native 提供的最优雅的平台适配方式------差异被封装在独立文件中,业务代码保持纯粹。
本文所有技术分析基于 React Native 0.77.1(新架构 / C-API)+ RNOH 0.77.50 实际项目代码。