React Native 新架构落地鸿蒙:跨三端政务级应用的工程实践与深度复盘

引言

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 字典数据、组织机构选项(带缓存策略)

userappmenu 三个 Slice 通过 redux-persist 持久化到 AsyncStorage,确保应用重启后无需重新登录或重新拉取菜单。globalrefData 是瞬态数据,重启后重新获取即可。

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.tsx
  • asyncStorage.ts / asyncStorage.harmony.ts
  • PlatformButton/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 的 updateInfoignoredVersion,实现了"检查更新 → 强制/建议更新 → 忽略版本"的完整流程。版本比较使用语义化版本号,而非字符串比较------"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,涉及四个子问题:

  1. /storage 路径映射 :鸿蒙的沙箱路径与 Android 不同,直接使用 /storage 前缀会导致文件找不到
  2. .txt 文件预览:鸿蒙沙箱内的文本文件无法被外部应用直接打开,需要先复制到可访问路径
  3. MIME 类型推断:鸿蒙的文件类型推断不完整,需要手动补充
  4. 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;
}

注意 FormDataContent-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 的优先级设计:

  1. Metro:开发时热更新,连接 Metro DevServer
  2. File:真机调试时从文件系统加载
  3. 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 是复用逻辑的最佳载体。 usePaginatedListuseFiltersuseDetailFetch 三个 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 实际项目代码。

相关推荐
lqj_本人4 小时前
鸿蒙electron框架PC适配:ExifCleaner 适配鸿蒙全过程:一次从“能启动”到“能处理文件”的完整复盘
华为·electron·harmonyos
excel4 小时前
为什么我推荐使用 Termius:现代 SSH 工具的完整体验
前端·后端
ZC跨境爬虫4 小时前
模块化烹饪小程序开发日记 Day7:(菜谱详情接口开发与JSON数据读取全流程)
前端·javascript·css·ui·微信小程序·json
এ慕ོ冬℘゜5 小时前
JS 前端基础面试题
开发语言·前端·javascript
LaughingZhu5 小时前
Product Hunt 每日热榜 | 2026-05-25
前端·人工智能·经验分享·chatgpt·html
IT_陈寒6 小时前
Java的Optional差点让我掉坑里,这几个坑你别踩
前端·人工智能·后端
粉嘟小飞妹儿6 小时前
JavaScript对象创建的几种灵活方法
前端
前端小万6 小时前
2026年了,为什么我突然开始做GZH?
前端
子兮曰6 小时前
Harness 驾驭工程深度教程:从 AGENTS.md 到全链路 AI 编码基础设施
前端·后端·ai编程