Skip to content
原生模块与平台代码

原生模块与平台代码

这一章解决三个问题:什么时候你真的需要写原生代码、在新架构(TurboModule / Fabric / JSI)下怎么写、以及大多数"跨平台差异"其实只靠 JS 层的平台代码就能抹平。基线是 RN 0.88 + 新架构,Expo 项目会单独讲 Expo Modules API 这条更省事的路。

注意 本手册全篇假设你运行在新架构上(RN 0.82 已移除旧架构)。网上 2023 年以前的原生模块教程(RCTBridgeModule 手写注册、桥接消息队列那一套)现在只用于读懂存量库,不要照着写新代码。


先说清楚:多数时候你不该写原生代码

写原生代码的真实成本不是"实现那 50 行 Kotlin",而是之后每一件事:RN 每次大版本升级你要重验、iOS/Android 两套代码两份心智、崩溃不再被 JS 错误边界兜住、发版要过原生编译矩阵。所以先过一遍止损清单:

  • 自动链接 + 成熟社区库覆盖了约 80% 的需求。相机、定位、推送、蓝牙、支付、健康数据、传感器、文件系统……社区都有多年维护的标准方案,先搜再写。
  • Expo 生态自带一大块。Expo SDK 把常见系统能力做成了带自动配置的模块(见 00-安装与快速开始.md),很多"要写原生"的需求在 Expo 里只是加一个包。

真正需要动原生代码的场景,通常只有四类:

  1. 私有 SDK:公司内部或甲方指定的 SDK(银联、车载、IoT 厂商),社区不可能有。
  2. 硬件与系统底层:定制外设、BLE 特殊 profile、系统级服务,社区库还没出现或不合用。
  3. 极限性能:图像处理、音频帧处理这类每毫秒都要抠的路径,JS 往返都是浪费,需要在原生侧一次做完再回传结果。
  4. 平台新 API 抢首发:iOS/Android 刚发布的新能力(当年 Dynamic Island、实时通知那类),没有库,产品就是要上。

四类之外的"我想自己封装一下",先问一遍自己:封装的库真的存在吗?答案多半是存在。


代际与时间线:从桥到新架构

读懂演进才能读懂存量库的 README 和 changelog。一条线走下来:

RCTBridgeModule 时代(桥):所有 JS 与原生通信都要把参数序列化成 JSON 消息,经消息队列过桥,原生侧再反序列化。异步、有延迟、类型全靠约定,getName/RCT_EXPORT_METHOD 这套手写注册是那个时代的标志。

JSI(JavaScript Interface):C++ 层接口,JS 引擎里的值可以被原生代码直接引用,不再需要序列化中间人。Hermes 引擎配合 JSI 是后来一切的基础——这一点在 01-核心概念与架构.md 里讲过。

TurboModule / Fabric / Bridgeless:新架构的三根支柱。TurboModule 用一份 TypeScript spec 加 Codegen 自动生成三端胶水代码,取代手写注册,还支持按需加载模块;Fabric 给原生视图组件走同一条 codegen 之路;Bridgeless 模式彻底拿掉了 RCTBridge 常驻消息循环。

已核实的时间线锚点:

  • 0.76:新架构成为默认。
  • 0.82:旧架构(Legacy Architecture)被移除。
  • 0.88:桥兼容层仍在,但存在 RCT_REMOVE_LEGACY_MODULE_INTEROP 这类编译开关(当前默认关),官方信号是未来默认移除。

对读者的行动结论只有一句话:存量老库尽快升级或替换,新写的直接按新架构(TurboModule spec 风格)写。没有"先按旧架构写、以后再迁"这个中间地带了。

顺带一提 iOS 工程:RN 核心自 0.84 起预编译(省每次全量编译时间),0.87 起有实验性的 Swift Package Manager 支持,但 0.88 模板的 iOS 路径仍是 CocoaPods——新项目别把宝押在 SPM 上。


平台代码:不碰原生也能跨平台

真正的原生差异之前,先把 JS 层的跨平台三板斧用透。

Platform.select 与 Platform.OS

运行期分叉,适合差异小、不值得拆文件的情况:

import { Platform, StyleSheet } from 'react-native';

const styles = StyleSheet.create({
  header: {
    paddingTop: Platform.select({ ios: 44, android: 24, default: 0 }),
    ...Platform.select({
      ios: { shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 6 },
      android: { elevation: 4 },
      default: {},
    }),
  },
});

Metro 的文件扩展名解析

Metro 解析 import ... from './Button' 时按平台优先级找文件:Button.ios.tsx(或 Button.android.tsx)优先,其次 Button.native.tsx(RN 平台通用,Web 不会命中),最后才是不带平台后缀的 Button.tsx。差异大时直接把实现拆成两个文件,类型签名保持一份:

components/
  Haptics.ios.ts       — iOS 实现
  Haptics.android.ts   — 安卓实现
  Haptics.ts           — 兜底/类型定义(Web 或漏平台时报错更友好)

Platform.constants:按事实分叉,别猜机型

Platform.OS 之外,还有一组机器可读的系统事实,适合做"版本门槛"类分叉:

import { Platform } from 'react-native';

// 例:Android 12(API 31)起才有某行为差异
if (Platform.OS === 'android' && Platform.constants.Version >= 31) {
  // 走新分支
}

// isTesting 可用于在仪器化测试里关掉高成本逻辑,避免性能采样被干扰

判断系统版本用 Platform.constants.Version(安卓对应 API level 数字),而不是解析机型字符串——后者在灰度和换机上必翻车。

实战:震动/触觉反馈的跨平台 shim

一个很典型的小例子:RN 内置的 Vibration 模块在 iOS 上不生效(iOS 没有开放任意振动 API,只有系统触觉反馈,这是公开稳定的行为差异)。用平台文件把它封成统一接口:

// haptics/Haptics.android.ts
import { Vibration } from 'react-native';

export function tapFeedback(): void {
  // 安卓用短震动近似一次触觉反馈;具体时长依设备马达而定
  Vibration.vibrate(8);
}

export function successFeedback(): void {
  Vibration.vibrate([0, 20, 40, 20]); // pattern: 静-振-静-振
}
// haptics/Haptics.ios.ts
import * as Haptics from 'expo-haptics';

export function tapFeedback(): void {
  // iOS 走系统触觉引擎,观感优于震动;非 Expo 项目可自行封装 UIImpactFeedbackGenerator
  void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}

export function successFeedback(): void {
  void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
// haptics/index.ts —— 调用方只 import 这里,Metro 自动挑平台实现
export { tapFeedback, successFeedback } from './Haptics';

调用点完全平台无关:

import { tapFeedback } from './haptics';

<Pressable onPress={tapFeedback}>...</Pressable>

注意 expo-haptics 与 Vibration 在触感强度、静音开关、系统设置联动上的具体行为各有差异,以各自文档与真机体验为准;这里要示范的是"接口一份、实现两份"的组织方式。


TurboModule 全链路:写一个电池信息模块

这是本章核心。选"读电量/充电状态"是因为它够小、两端都有公开 API、又完整覆盖 spec 到注册的全流程。全链路五步:JS spec 定义接口,Codegen 生成胶水,两端实现逻辑,注册进应用,JS 调用。

Warning 下面各端实现是结构骨架。codegen 生成物的类名、模板里注册的确切挂法会随 RN 小版本微调,动手时以你项目 npx react-native init 模板与官方新架构文档为准;但"spec 驱动生成 + 两端按生成物实现"的结构是稳定的。

第一步:JS spec

spec 文件放在项目里约定目录(下文 src/native/),命名必须是 Native<名字>.ts,codegen 按这个模式扫描:

// src/native/NativeBattery.ts
import { TurboModuleRegistry, type TurboModule } from 'react-native';

export interface BatteryInfo {
  level: number;    // 0.0 到 1.0;-1 表示不可读
  plugged: boolean; // 是否在充电/插电
  health: string;   // 'good' | 'unknown' 等
}

export interface Spec extends TurboModule {
  getBatteryInfo(): Promise<BatteryInfo>;
}

// 'NativeBattery' 这个模块名字符串必须与原生侧注册名完全一致
export default TurboModuleRegistry.getEnforcing<Spec>('NativeBattery');

getEnforcing 找不到模块直接抛异常;对"可能没装原生实现"的场景(比如 Web 调试),可用 TurboModuleRegistry.get<Spec>(...) 返回 null 再兜底。

第二步:codegenConfig

在库(或应用)的 package.json 里声明,告诉 codegen 去哪扫 spec、生成物前缀叫什么:

{
  "codegenConfig": {
    "name": "RNBatterySpec",
    "type": "modules",
    "jsSrcsDir": "src/native"
  }
}

type 取 modules(TurboModule)或 components(Fabric 组件)。之后每次构建,codegen 会从 TS spec 生成 C++ 与 Kotlin/Swift 的抽象基类/协议——这是"类型即接口契约"在 RN 里的落地:spec 写错类型,原生侧编译直接报错,而不是运行时对不上。

主要类型在 codegen 两端的映射(常用面,确切命名以构建产物为准):

TS spec 写法 Android 生成(Kotlin) iOS 生成(ObjC 桥接)
number Double double
boolean Boolean BOOL
string String NSString *
对象字面量类型 ReadableMap NSDictionary *
数组 T[] ReadableArray NSArray *
Promise<T> 尾参 Promise 尾参 RCTPromiseResolveBlock / RCTPromiseRejectBlock
void(同步无返回) Unit void

字符串字面量联合(枚举形态)还有 string/int 两种 codegen 变体可选,用到时查当期文档,不要凭旧记忆写。

第三步:Android 实现(Kotlin)

codegen 会生成一个 Kotlin 抽象类(通常以 spec 文件名加 Spec 后缀,本项目语境下叫 NativeBatterySpec,确切生成名以构建产物为准),实现它:

// android/src/main/java/com/example/battery/BatteryModule.kt
package com.example.battery

import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.annotations.ReactModule

@ReactModule(name = BatteryModule.NAME)
class BatteryModule(reactContext: ReactApplicationContext) :
  NativeBatterySpec(reactContext) {

  companion object {
    const val NAME = "NativeBattery" // 与 spec 的模块名一致
  }

  override fun getName(): String = NAME

  // codegen 把 TS 的 Promise<BatteryInfo> 生成为尾参 Promise 的方法
  override fun getBatteryInfo(promise: Promise) {
    // ACTION_BATTERY_CHANGED 是 sticky 广播:传 null receiver 只取当前值,无需注册
    val info = reactApplicationContext.registerReceiver(
      null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)
    ) ?: return promise.reject("UNAVAILABLE", "battery info unavailable")

    val level = info.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
    val scale = info.getIntExtra(BatteryManager.EXTRA_SCALE, 100)
    val status = info.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
    val health = info.getIntExtra(
      BatteryManager.EXTRA_HEALTH, BatteryManager.BATTERY_HEALTH_UNKNOWN
    )

    val map = Arguments.createMap().apply {
      putDouble("level", if (level >= 0 && scale > 0) level.toDouble() / scale else -1.0)
      putBoolean("plugged", status == BatteryManager.BATTERY_STATUS_CHARGING ||
        status == BatteryManager.BATTERY_STATUS_FULL)
      putString("health", if (health == BatteryManager.BATTERY_HEALTH_GOOD) "good" else "unknown")
    }
    promise.resolve(map)
  }
}

注册进应用——本地模块要手写进 MainApplication,三方库由自动链接处理:

// MainApplication.kt 中(以官方模板为准)
override fun getPackages(): List<ReactPackage> =
  PackageList(this).packages.apply {
    add(BatteryPackage()) // PackageList 是自动链接生成的清单
  }
// BatteryPackage.kt —— 新架构用 TurboReactPackage 按需创建模块
package com.example.battery

import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager

class BatteryPackage : TurboReactPackage() {
  override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
    if (name == BatteryModule.NAME) BatteryModule(reactContext) else null

  override fun getReactPartyType(): com.facebook.react.bridge.ReactNative =
    com.facebook.react.bridge.ReactNative.NATIVE
}

TurboReactPackage 按模块名惰性创建,这正是 TurboModule “用到才加载"的落点。

第四步:iOS 实现(Swift)

Swift 类实现 codegen 生成的协议(同样以 NativeBatterySpec 称呼生成物,确切名称以构建产物为准),再用一个 Objective-C 桥接文件把 OC 可见的模块名和方法签名导出去——RCT_EXTERN_MODULE / RCT_EXTERN_METHOD 这套桥接模式是当前 iOS 原生模块公开稳定的写法:

// ios/Battery/NativeBattery.swift
import Foundation
import React
import UIKit

@objc(NativeBattery)
class NativeBattery: NSObject, NativeBatterySpec {

  @objc
  func getBatteryInfo(_ resolve: @escaping RCTPromiseResolveBlock,
                      rejecter reject: @escaping RCTPromiseRejectBlock) {
    DispatchQueue.main.async {
      UIDevice.current.isBatteryMonitoringEnabled = true
      let level = UIDevice.current.batteryLevel // 0.0–1.0;模拟器返回 -1
      resolve([
        "level": level,
        "plugged": UIDevice.current.batteryState != .unplugged,
        "health": "unknown", // iOS 不暴露健康度,固定兜底
      ])
    }
  }
}
// ios/Battery/NativeBattery.m —— 桥接文件,导出模块名与方法签名
#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(NativeBattery, NSObject)

RCT_EXTERN_METHOD(getBatteryInfo:(RCTPromiseResolveBlock)resolve
                  rejecter:(RCTPromiseRejectBlock)reject)

@end

注意 方法签名要和 spec 逐参数对齐:TS 里返回 Promise 的方法,原生侧就是"尾参收 resolve/reject"的形态;参数类型按 codegen 的类型表映射(number 在 OC 桥接里对应 double/NSNumber,对象用 NSDictionary)。新架构下应用级本地模块的注册由构建时的 codegen 产物(如 RCTAppDependencyProvider)自动接线,具体挂法以官方模板为准,不要手写桥接时代的注册代码。

持续型数据(比如电量变化订阅)走事件通道,还是同一套 spec 驱动的路子。spec 里把事件契约声明出来:

export interface Spec extends TurboModule {
  getBatteryInfo(): Promise<BatteryInfo>;
  addListener(eventType: string): void;   // 三个方法名是事件模块的约定契约
  removeListeners(count: number): void;
}

JS 侧用 NativeEventEmitter 接:

import { NativeEventEmitter, TurboModuleRegistry } from 'react-native';

const emitter = new NativeEventEmitter(
  TurboModuleRegistry.getEnforcing<Spec>('NativeBattery'),
);
const subscription = emitter.addListener('batteryChanged', (info) => setInfo(info));
// 组件卸载时务必 subscription.remove(),否则监听器泄漏

原生侧经 codegen 生成的 emitter 基建发送事件(Android 走生成的 DeviceEventEmitter 访问器、iOS 走 sendEventNamed 形态,确切方法名以官方模板为准)。写之前把官方文档的事件一节读完整——事件名拼错没有任何编译期保护,只会静默失灵。

第五步:调用点

import { useEffect, useState } from 'react';
import { Text } from 'react-native';
import NativeBattery, { type BatteryInfo } from './src/native/NativeBattery';

export function BatteryText() {
  const [info, setInfo] = useState<BatteryInfo | null>(null);

  useEffect(() => {
    NativeBattery.getBatteryInfo().then(setInfo).catch(() => setInfo(null));
  }, []);

  if (!info) return <Text>电量不可用</Text>;
  return <Text>{Math.round(info.level * 100)}%,{info.plugged ? '充电中' : '电池供电'}</Text>;
}

注意调用方拿到的类型完全来自 spec——把 spec 接口维护好,文档和类型提示就白送。

getEnforcing 之外,再包一层业务可容错的调用层是好实践:原生缺席(Expo Web、单测环境)或模块报错时降级而不是崩溃:

// battery/index.ts —— 调用层的兜底封装
import { TurboModuleRegistry } from 'react-native';
import type { Spec, BatteryInfo } from '../native/NativeBattery';

const Native = TurboModuleRegistry.get<Spec>('NativeBattery'); // get 不抛异常

export async function readBattery(): Promise<BatteryInfo | null> {
  if (!Native) return null;
  try {
    return await Native.getBatteryInfo();
  } catch {
    return null;
  }
}

业务组件只依赖 readBattery(),将来这个模块被换实现(比如迁到 Expo Modules),改动被封在这一层。


Fabric 原生组件概览(高级,点到为止)

选路先想清楚:暴露"函数/能力"用 TurboModule;暴露一个"可渲染的视图"才用 Fabric 组件。绝大多数需求是前者。

Fabric 组件同样是 spec 驱动。JS 侧声明 props schema:

// src/specs/NativeCoolView.tsx
import { codegenNativeComponent, type ViewProps } from 'react-native';

interface Props extends ViewProps {
  uri: string;
  fadeDuration?: number;
}

export default codegenNativeComponent<Props>('CoolView');

配合 codegenConfig 的 type: "components",构建时生成两端的 ViewConfig 与抽象基类:Android 侧实现 ViewManager(普通视图继承 SimpleViewManager,复杂场景走 FabricView),iOS 侧继承 RCTViewComponentView;props 经由 C++ 共享层下发,省掉了桥时代的序列化开销,主线程以外也能算布局。

但成本也要认账:原生视图组件涉及生命周期、事件冒泡(bubbling config)、measure 等一整套约定,坑比 TurboModule 多一个数量级。能用 JS 组合 + Reanimated 实现的效果不要上原生视图;真要写,动手前把官方 “Fabric components” 文档读完整。


Expo Modules API:Expo 项目里的更优解

如果你在做 Expo 项目,写原生扩展优先用 Expo Modules API,而不是手搓 TurboModule——它用一个 Swift DSL(安卓侧也有对应 Kotlin DSL)把"模块声明 + 参数类型转换 + promise 包装"全部吃掉,样板代码少得肉眼可见:

// ios/Battery/BatteryModule.swift —— 结构示意,具体宏名与参数包装以 create-expo-module 生成模板为准
Module("Battery") {
  Function("ping") { () -> String in
    "pong"
  }

  AsyncFunction("getInfoAsync") { promise in
    DispatchQueue.main.async {
      UIDevice.current.isBatteryMonitoringEnabled = true
      promise.resolve([
        "level": Double(UIDevice.current.batteryLevel),
        "plugged": UIDevice.current.batteryState != .unplugged,
      ])
    }
  }
}

DSL 的稳定形状就是这几个构件:Module(模块)、Function / AsyncFunction(同步/异步函数)、Object(原生对象句柄传回 JS)、View(原生视图组件,对标 Fabric)。JS 侧 import 脚手架生成的带类型导出即可。

工程路径上:

  • npx create-expo-module@latest 起新模块,本地模块用 --local,脚手架会生成 Swift/Kotlin 两侧骨架和 podspec;具体产物以工具版本为准。
  • SDK 56 起 expo-modules-core 完成 Swift 化重写(官方说法 “Swift all the way down”);SDK 58 的 changelog 里已出现 Expo Modules 2.0 beta——跟进前读对应 release notes。

选择规则很简单:Expo 项目(含 prebuild/managed)写原生扩展,选 Modules API;bare 项目、或库要发布给全 RN 社区用,选 TurboModule spec。跨 Expo 边界发的库也有"用 TurboModule 写、内部可选适配"的路子,别混用两套心智。


自动链接机制:原生库是怎么被"发现"的

装好一个带原生代码的 npm 包后,链接是构建时自动发生的,原理值得知道,因为排查问题全靠它:

  • iOS:ios/Podfile 里的 use_native_modules! 扫描 node_modules,凡是带合法 .podspec 的依赖自动进 Pods。
  • Android:RN 的 Gradle 插件在配置阶段扫描依赖的 android/ 目录与 react-native.config.js 声明,生成 PackageList 注册清单。
  • react-native.config.js:项目/库级的覆盖点。典型用法是关掉某库在某平台的链接(老平台实现损坏时)、声明非标准目录、或指定库的原生工程参数:
// react-native.config.js —— 项目级覆盖示例:禁用某库的 iOS 自动链接
module.exports = {
  dependencies: {
    'some-broken-lib': {
      platforms: { ios: null }, // 该平台改由自己封装或暂时不接入
    },
  },
};

库作者的 podspec 长这样(发布 iOS 原生库必需):

# RNBattery.podspec
Pod::Spec.new do |s|
  s.name         = 'RNBattery'
  s.version      = '1.0.0'
  s.summary      = 'Battery info TurboModule'
  s.author       = 'You'
  s.homepage     = 'https://github.com/you/rn-battery'
  s.license      = 'MIT'
  s.source       = { git: 'https://github.com/you/rn-battery', tag: s.version.to_s }
  s.platforms    = { :ios => '15.1' } # 最低 iOS 版本跟紧你支持的 RN 模板要求
  s.source_files = 'ios/**/*.{h,m,mm,swift}'
  s.dependency 'React-Core'
end

仍然要手动链接的遗留场景:monorepo 里依赖路径被提升得让扫描器找不到、库没写 podspec/gradle 配置、特殊 flavor/variant 构建、纯 C++ 库需要手写编译单元。

诊断第一步永远是:

npx react-native config

输出的 JSON 就是链接器眼中的依赖清单——库不在里面,问题就出在包结构或 react-native.config.js;在里面但没装上,再去看 Gradle / CocoaPods 日志。


调试原生代码

  • iOS:用 Xcode 打开 ios/*.xcworkspace,直接在 Swift/OC 源码打断点;真机 attach 也走 Xcode(设备选好再 Run)。
  • Android:Android Studio 打开 android/ 目录,Run/Debug 选择 app 进程 attach;配合 logcat 看原生日志。
  • 改完原生代码必须重新构建原生工程(npx expo run:ios / run:android,或 bare 的 gradle/xcodebuild);Fast Refresh 只刷 JS,原生改动不会热进去——这是新手"我明明改了代码"的头号原因。
  • 先验证"模块到底注册上没有”,再怀疑逻辑。JS 侧一行就能探测:
import { TurboModuleRegistry } from 'react-native';
console.log('NativeBattery 可见性:', !!TurboModuleRegistry.get('NativeBattery'));

打出 false 说明链接/注册环节断了(回到上节用 npx react-native config 查),打出 true 才轮到断点进原生。

  • JS 断点(Hermes debugger)和原生断点可以同时挂,两边命中互不干扰;混合栈的 trace 怎么读,回指 10-调试测试与性能.md。
  • 线上崩溃的符号化:iOS 靠 dSYM 上传映射、安卓靠 mapping 文件还原混淆栈,完整流程在 10-调试测试与性能.md 的崩溃分析一节;发版侧配置见 11-构建与发布.md。

升级存量项目的现实路径

旧架构已经从 0.82 移除,所以"要不要升新架构"不再是问题,“存量依赖能不能陪你在 0.88 上活下来"才是。按这个顺序体检:

  1. 拉依赖表:npx react-native config 加 package.json 里所有带原生代码的包,逐个对照 New Architecture Directory(reactnative.directory) 查兼容状态与最低支持版本。
  2. 分类处理:已兼容的锁到安全小版本;停止维护的老库,要么找同功能替代品,要么评估自己 fork 改造(桥模块改 TurboModule spec 通常是"包一层"的工作量,但回归测试是整个原生面)。
  3. 认清兼容开关的定位:0.88 仍保留桥兼容层(RCT_REMOVE_LEGACY_MODULE_INTEROP 默认关闭,官方信号是未来默认移除)。它是过渡期的减震器,不是可以一直依赖的"旧架构续命按钮”——排期要按"没有它"来做。
  4. 成本评估口径:一个中等项目、3~5 个自研原生模块、十几个三方原生库,迁移加回归通常按周计而不是按天计;预算时把两端真机回归(12-常见陷阱与最佳实践.md 里的发布检查清单走一遍)算进去。
  5. Expo 项目:SDK 与 RN 版本是锁配的(SDK 58 对应本手册基线),升级时以 Expo changelog 为主线走,见 00-安装与快速开始.md。

实战建议

  • 先搜后写:动原生之前花 20 分钟搜社区方案;真要封装,封在"接口层 + 平台实现"里,将来换底层不动业务代码(本章的 Haptics shim 就是这个思路的最小形态)。
  • 新写一律新架构:TurboModule spec 驱动、Expo 项目走 Modules API;不要给桥接时代语法写新代码,那是在给未来埋债。
  • 原生版本与 JS 版本锁配套:RN 主版本、原生模板、每个原生三方库的版本矩阵要一起定,CI 里固定住;升级时整矩阵一起动,不要"只升 RN 不动库"。
  • 发库前跑编译矩阵:至少最新两个 RN 稳定模板,iOS 与 Android 双端各过一遍编译与最小冒烟——原生库的兼容承诺只有编译验证过才算数。
  • spec 即文档:把 TurboModule 的 TS spec 注释写好、类型收窄,JS 侧调用方拿到的是编译期契约,这是新架构送给工程效率的礼物。
  • 别把性能问题错交给原生:先确认瓶颈在 JS 逻辑还是桥通信(见 10-调试测试与性能.md),多数"想写原生提性能"的案子最后发现是渲染浪费或状态风暴。