¿Cómo escribes un bridge de módulo nativo en React Native, y cuáles son las implicaciones de rendimiento?
Los TurboModules reemplazaron las llamadas asíncronas del bridge serializadas en JSON por bindings directos vía JSI — más rápidos, pero solo si sigues respetando los límites entre hilos.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo escribes un bridge de módulo nativo en React Native, y cuáles son las implicaciones de rendimiento?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Por qué existen los módulos nativos
React Native te da de fábrica una superficie enorme de APIs multiplataforma, pero tarde o temprano un producto necesita algo que la capa de JS no puede hacer sola: Bluetooth LE, un SDK propietario sin wrapper de RN, un sensor de hardware, o un mecanismo de persistencia que quieras respaldado directamente por UserDefaults/SharedPreferences en lugar de AsyncStorage. Un módulo nativo es la vía de escape — una pieza de código Swift/Objective-C o Kotlin/Java expuesta a JavaScript como si fuera un import normal.
#Bridge antiguo vs. TurboModules
Antes de la New Architecture, cada llamada a un módulo nativo se serializaba a JSON, se empujaba a una cola de mensajes y se procesaba de forma asíncrona en ambos lados — incluso para algo tan simple como leer un booleano. Esto tenía dos costos: la propia serialización/deserialización, y el hecho de que todos los módulos se instanciaban de forma anticipada al arrancar la app, se usaran o no, lo cual se reflejaba directamente en el tiempo de arranque en frío.
Los TurboModules resuelven ambos problemas. Un módulo se describe mediante una especificación de Codegen —una interfaz de TypeScript plana— a partir de la cual React Native genera el código de interfaz nativa en tiempo de compilación, forzando seguridad de tipos entre el límite JS/nativo antes de que siquiera ejecutes la app. En tiempo de ejecución, JSI (JavaScript Interface) expone el módulo nativo a JS como un HostObject: JS puede llamar a sus métodos de forma mayormente síncrona a través de bindings directos en C++, sin pasar por JSON ni por la antigua cola asíncrona. Los módulos también se instancian de forma perezosa —se crean la primera vez que JS realmente los referencia, no al arrancar— lo cual es una mejora directa y medible en el tiempo de arranque en apps con muchos módulos nativos.
#Definiendo la especificación
Todo TurboModule empieza con un contrato de TypeScript que Codegen lee para generar el código de conexión de cada plataforma:
// specs/NativeLocalStorage.tsimport type { TurboModule } from "react-native";import { TurboModuleRegistry } from "react-native";export interface Spec extends TurboModule {// Síncrono — seguro solo para trabajo rápido y que no bloquee.getItem(key: string): string | null;setItem(key: string, value: string): void;// Asíncrono — delega el trabajo real a una cola en segundo plano.multiGet(keys: Array<string>): Promise<Object>;}export default TurboModuleRegistry.getEnforcing<Spec>("NativeLocalStorage");
#Implementándolo en iOS (Swift)
@objc(NativeLocalStorage)class NativeLocalStorage: NSObject {private let defaults = UserDefaults.standard@objc func getItem(_ key: String) -> String? {return defaults.string(forKey: key)}@objc func setItem(_ key: String, value: String) {defaults.set(value, forKey: key)}@objc(multiGet:resolver:rejecter:)func multiGet(_ keys: [String],resolver: @escaping RCTPromiseResolveBlock,rejecter: @escaping RCTPromiseRejectBlock) {// El trabajo real ocurre fuera del hilo principal aunque JSI soporte llamadas síncronas.DispatchQueue.global(qos: .userInitiated).async {var result: [String: String?] = [:]for key in keys {result[key] = self.defaults.string(forKey: key)}DispatchQueue.main.async {resolver(result)}}}}
#Implementándolo en Android (Kotlin)
class NativeLocalStorageModule(reactContext: ReactApplicationContext) :NativeLocalStorageSpec(reactContext) {private val prefs = reactContext.getSharedPreferences("app_storage", Context.MODE_PRIVATE)override fun getItem(key: String): String? = prefs.getString(key, null)override fun setItem(key: String, value: String) {prefs.edit().putString(key, value).apply()}override fun multiGet(keys: ReadableArray, promise: Promise) {// Se delega a un hilo worker — leer SharedPreferences es barato, pero// este es el patrón para cualquier cosa más pesada (I/O de disco, decodificación, cripto).Thread {val result = Arguments.createMap()for (i in 0 until keys.size()) {val key = keys.getString(i)result.putString(key, prefs.getString(key, null))}promise.resolve(result)}.start()}override fun getName() = "NativeLocalStorage"}
#Las implicaciones de rendimiento que realmente importan
Síncrono no significa gratis. JSI te permite llamar a un método nativo y recibir un valor de retorno sin un viaje de ida y vuelta por una promesa, pero esa llamada sigue ejecutando código nativo real en el hilo que la invocó. Un método JSI síncrono que hace trabajo significativo (parsear un archivo grande, calcular un hash criptográfico) bloquea ese hilo durante toda su duración — llámalo desde el hilo de JS y habrás congelado las actualizaciones de UI impulsadas por JS hasta que retorne. La solución es la de siempre: mantén los métodos síncronos triviales y rápidos, y empuja el trabajo real detrás de una Promise/callback que salte a un hilo en segundo plano, exactamente como hace multiGet arriba.
La carga perezosa cambia lo que cuesta "agregar un módulo". Bajo el bridge antiguo, cada módulo nativo registrado se instanciaba al arrancar, así que una base de código con 40 módulos nativos pagaba ese costo en cada arranque en frío sin importar qué pantallas visitara realmente el usuario. Los TurboModules se instancian al primer uso, así que agregar un módulo poco usado (digamos, una función de exportar a PDF) ya no penaliza cada lanzamiento de la app — solo cuesta algo la primera vez que un usuario llega a la pantalla que lo necesita.
Codegen detecta el desalineamiento del contrato en tiempo de compilación, no en producción. Antes de Codegen, una firma de método desalineada entre la especificación JS y la implementación nativa era un crash en tiempo de ejecución o, peor, un bug silencioso de tipo incorrecto. Ahora es un fallo de compilación, lo cual es una ganancia de rendimiento real (aunque menos vistosa): elimina toda una clase de incidentes de producción causados por el desalineamiento entre plataformas.
Los HostObjects necesitan una disciplina explícita de ciclo de vida. Bajo el bridge antiguo, todo lo que cruzaba el límite era un valor JSON, copiado y recolectado como basura normalmente. Un HostObject expuesto vía JSI es un objeto real de C++ con semántica de retain/release; mantener una referencia obsoleta a uno, o exponer memoria nativa sin una ruta de liberación correspondiente, puede filtrar memoria o provocar crashes de formas que la serialización JSON nunca permitía. Este es el costo de eliminar el impuesto de serialización — obtienes acceso directo a memoria, y el acceso directo a memoria exige disciplina.
🧠 Comprueba tu comprensión
0/2 · 0/2 answered1. Bajo el bridge antiguo de React Native, ¿por qué se instanciaban todos los módulos nativos al arrancar la app, se usaran o no?
2. Un TurboModule expone un método síncrono que calcula un hash criptográfico pesado directamente en el hilo que lo invoca. ¿Cuál es el riesgo principal?