首页 / 文章

为何 Codegen 使 React Native TurboModules 必须以规范优先为原则

了解 React Native Codegen 如何将 TurboModule TypeScript 规范转换为构建时的契约,省略该步骤会导致什么问题,以及相关规则在何处不再适用。

发布者:REACTAPP.TOP阅读约 8 分钟1819 词

来源: Spec First Isn’t a House Rule — the New Architecture Toolchain Enforces It

许多维护 React Native 库的团队在贡献指南中都规定了一条规则:在修改任何原生代码之前必须先更改 TurboModule 的规范。这看起来像是一种风格偏好,是人们希望贡献者在时间压力下仍能遵守的惯例。但在新架构中并非如此。Codegen 会根据该规范生成原生桥接代码,因此构建过程本身决定了工作的顺序。本文将解释这一约定是如何得到执行的,当有人试图走捷径时在 iOS 和 Android 上实际会发生什么,以及哪些类型的模块不在其适用范围内。

本为惯例却变成约束

设想有一位维护者想节省时间:先添加原生方法,然后再回去更新规范。在由 Codegen 支持的模块中,这种计划还没起到任何作用就失败了。无需任何代码检查工具或审核人员提出异议,编译器和生成的代码会自动处理。接下来要明白的关键点是:这条规则从来不是工具链恰好容忍的团队习惯,而是团队特意记录下来的工具链特性。

两者的区别不仅仅是用词不同。偏好可以为了满足截止日期而做出妥协,但构建时的契约却无法如此;明白自己面对的是哪一种,会改变你对库进行修改的规划方式。

TurboModule 的核心——规范文件

每个新的架构 TurboModule 都有一个 TypeScript 文件,通常命名为 src/Native<Name>>.ts,用于声明所有在 JavaScript 与原生代码之间交互的方法。用于社交登录 SDK 的封装文件可能命名为 src/NativeNaverLogin.ts,而用于应用内商店评价的封装文件则可能使用 src/NativeInAppReview.ts。在包的 package.json 中的 codegenConfig 配置项会告知 Codegen 该文件的位置以及应基于它生成何种输出文件。

按照规则,其顺序十分简单:所有 JavaScript 应该能够调用的方法都需先在规范中声明,之后才用原生代码实现。必须是先有规范,再有原生代码,绝不能反其道而行之。而让这种顺序成为不可更改原则的,正是用于解析该规范的工具机制。

Codegen 如何将规范转化为原生实现

Codegen 是在构建时运行的转换工具。它会解析 TypeScript 规范,并为每个平台生成相应的桥接接口:

  • Android: 一个抽象类,例如 NativeNaverLoginSpec。您的 Kotlin 或 Java 模块需要继承这个生成的类,而非直接继承 ReactContextBaseJavaModule,且规范中的每个方法都会变成必须实现的抽象方法。
  • iOS: Objective-C++ JSI 桥接代码,包括 NativeNaverLoginSpecJSI 转换单元以及您的 .mm 实现文件必须遵循的协议。

关键在于依赖方向。原生代码并不描述桥接机制,而是继承自根据规范生成的桥接结构。而 JavaScript 只能通过该生成的接口才能访问原生代码。正是这一事实决定了当双方出现分歧时会出现何种情况。

规范中未声明的原生方法

如果在原生代码侧添加了方法但未在规范中体现,Codegen 将不会为此生成任何内容。Android 上没有可重写的抽象方法,iOS 上也没有对应的协议条目。

在 Android 上,编写此类方法最常见的方式是使用 override 关键字,但由于生成的父类中没有对应的成员,这种方式会导致编译失败。即便去掉该修饰符使文件能够编译,该方法也不属于生成的接口部分,因此 JavaScript 无法访问它。

在 iOS 上,这种问题表现得更隐蔽。该方法存在于你的 .mm 文件中且能正常编译,但由于它不在运行时用于暴露方法的协议中,因此 JavaScript 永远无法访问到它。这种无声无息的无效方法甚至比编译错误更糟糕:在有人从 JS 中调用它并发现其不存在之前,根本无法察觉到问题。

仅有规范定义而无原生实现的方法

相反的情况也会出现问题。如果规范中声明了某种方法,但原生层并未实现它,Codegen 仍然会生成对应的桥接代码,而 JavaScript 代理也会正常暴露该方法。

在 Android 上,如果某个类继承了自动生成的抽象规范却未实现其中的任何抽象方法,Kotlin 或 Java 编译器会直接拒绝该类,这算是最好的情况,因为错误能在早期被发现。而在 iOS 上,缺失的必需协议方法通常只会引发编译器警告;真正的错误会在运行时出现,即当 JavaScript 调用该方法时导致“未识别的选择器”崩溃。

无论哪种情况,在代码生成时这两部分都是绑定在一起的。你无法仅通过修改其中一方来为 JavaScript 添加新功能。阻碍你的并非审查意见,而是类型系统以及无法匹配的生成代码。

为何该规则适用于所有 Codegen 库

如果“以规范为先”仅仅是一种开发风格,那它只会适用于那些维护者选择采用它的代码库。但实际上这种规范是由 Codegen 强制执行的,而 Codegen 属于 React Native。所有在“新架构”模式下通过 codegenConfig 块启用 Codegen 的库,无论其开发者是否曾考虑过“以规范为先”的理念,都必须遵循相同的规则。

React Native的官方文档在表述中也体现了这一点。在描述Codegen生成的输出文件夹时,它将所生成的抽象类描述为Turbo Native Module必须实现的内容。“必须”是关键词:这意味着在代码编译时会进行验证,属于强制要求而非建议。社区中的示例也从外部角度描述了相同的行为:一旦配置好codegenConfig,就会生成一个名为Native...Spec的文件,其名称由package.json中codegenConfig指定的内容决定。不同的作者、不同的仓库,却有着完全一致的规范。

还有一个从相反方向得到的有用验证方法。如果在package.json中完全不包含codegenConfig字段,那么在iOS上运行CocoaPods安装时就会显示出这一缺失:

[Codegen] The "codegenConfig" field is not defined in package.json

Codegen会完全跳过该包。整个处理流程的设计是直接停止处理,而非尝试寻找替代方案。规范并非可有可无、容易过时的文档,而是整个生成流程所围绕的输入依据。

总而言之,“以规范为先”的理念是处理流程读取输入方式所导致的结构性结果。各类库之所以遵循这一原则,是因为它们都遵循Codegen的架构设计。那些看似忽视该规范的库,要么仍在使用旧版架构,要么根本未配置codegenConfig。

契约适用范围的界限

将此称为工具链契约确实很准确,但这种表述容易被过度延伸。其边界应当明确说明:

  • 旧版桥接模块。这些模块基于JSI出现之前的桥接机制构建,iOS端使用RCTBridgeModule,Android端使用ReactContextBaseJavaModule,且没有相应的规范,属于Codegen出现之前的版本。由于不存在自动生成的基准代码,因此没有任何机制来强制维护模块的执行顺序,完全依赖开发者自身的自律。
  • 未包含codegenConfig的包。如果没有该配置块,就不会生成任何输出文件,从规范到原生代码的转换流程也就无从开始。
  • 组件视图部分。那些使用 type: "components" 声明的组件也会经过 Codegen 处理,因此可以预期会有类似的规范要求。不过上述论述是基于 Turbo Native Modules(type: "modules")而言的,而组件生成的产物有所不同,所以这种规范更偏向于可能存在的状态而非既定事实,需参照你所使用的 React Native 版本的当前文档进行验证。
  • 唯一能够明确成立的观点是:对于通过 JavaScript 或 TypeScript 规范由 Codegen 生成的 New Architecture TurboModules——这正是官方文档中规定的编写方式——构建过程会强制要求先遵循规范。

    同样值得注意的是,基于 Codegen 的 TurboModules 并非当今编写原生模块的唯一方式。诸如 Nitro Modules 这样的替代方案采用自己的代码生成与绑定机制;文章 探讨了 Nitro 的静态绑定与 TurboModules 的对比及其中的权衡。

    构建过程实际遵循的工作流程

    一旦明确了相关机制,操作顺序就不再需要主观判断。向 TurboModule 添加方法遵循固定的步骤:

    1. 在 src/Native<Name>>.ts 中修改规范,声明新方法及其类型。
    2. 运行 Codegen:pod install 会重新生成 iOS 相关代码,而 Gradle 的同步或构建操作则会重新生成 Android 相关文件。
  • 在每个平台上应用新生成的签名。Android和iOS的步骤是独立的,可以并行进行,但两者都依赖于第2步。
  • 从库的公共JavaScript API中重新导出该方法。
  • 由此可形成一些实用的习惯:

    • 将规范变更视为API变更来处理。规范实际上就是库的公共原生契约,因此对其的修改应与对导出的JavaScript类型的修改受到同等严格的审查。
    • 在调试前重新生成代码。当某个原生方法似乎不存在时,通常是由于跳过了pod install或Gradle同步而导致生成的代码过时所致。
    • 在CI环境中同时运行两个平台的构建。由于iOS可能出现无声失败,而Android则会给出明显错误,因此仅有Android的构建通过并不能证明桥接功能已经完整实现。

    总结

    弄清楚谁“制定”了以规范优先的规则并非吹毛求疵。一旦明白这只是机械性的要求,就无需再将其视为需要与截止日期权衡的建议。不存在“就这一次”先实现原生方法、后再修改规范的方案,因为这种捷径无法生成可运行的代码。

    • Codegen会根据TypeScript规范生成原生桥接代码,因此是原生代码实现了规范,而非规范在定义桥接逻辑。
    • 无论朝哪个方向偏离都会出问题:有时会在编译时出现错误,有时在iOS上会悄无声息地出错,还有时会引发运行时崩溃。
    • 该规范适用于由Codegen支持的TurboModules;没有codegenConfig的旧版桥接模块和包则不在其覆盖范围内,而Fabric组件则需要单独进行验证。
  • 在贡献指南中保留“先遵循规范”的要求,但应将其表述为对构建流程的描述,而非对良好行为的强制要求。无论文档中如何说明,构建过程都会强制执行这一规范。