On this post I assume you know the basics of React Native, and will focus on how the internals work when managing the communication between native and JavaScript.
Main Threads
Before anything else, keep in mind that there are 3 “main” threads* in React Native:
- The
shadow queue
: where the layout happens - The
main thread
: where UIKit does its thing - The
JavaScript thread
: where your JS code is actually running
Plus every native module has its own GCD Queue
unless it specifies otherwise (a more detailed explanation is coming).
GCD Queue
rather than a thread, as the name suggests.
Native Modules
If you don’t know how to create a Native Module yet, I’d recommend you check the documentation before.
Here’s an example Person
native module, that both, receives calls from JavaScript and calls into JS.
@interface Person : NSObject <RCTBridgeModule>
@end
@implementation Logger
RCT_EXPORT_MODULE()
RCT_EXPORT_METHOD(greet:(NSString *)name)
{
NSLog(@"Hi, %@!", name);
[_bridge.eventDispatcher sendAppEventWithName:@"greeted"
body:@{ @"name": name }];
}
@end
We are going to focus on these two macros, RCT_EXPORT_MODULE
and RCT_EXPORT_METHOD
, what they expand into, what are their roles and how does it work from there.
RCT_EXPORT_MODULE([js_name])
As the name suggests, it exports your modules, but what does export mean in this specific context? It means making the bridge aware of your module.
Its definition is actually pretty simple:
#define RCT_EXPORT_MODULE(js_name) \
RCT_EXTERN void RCTRegisterModule(Class); \
+ (NSString \*)moduleName { return @#js_name; } \
+ (void)load { RCTRegisterModule(self); }
What does it do:
- It first declares
RCTRegisterModule
as anextern
function, which means that the implementation of the function is not visible to the compiler but will be available at link time, than - declares a method
moduleName
, that returns the optional macro parameterjs_name
, in case you want your module to have a name in JS other than the Objective-C class name, and last - declares a
load
method (When the app is loaded into memory it’ll call theload
method for every class) that calls the above declaredRCTRegisterModule
function to actually make the bridge aware of this module.
RCT_EXPORT_METHOD(method)
This macro is “more interesting”, it doesn’t add anything to your actual method, it actually creates a new method in addition to declaring the one specified.
This new method would look something like that for our example:
+ (NSArray *)__rct_export__120
{
return @[ @"", @"log:(NSString *)message" ];
}
“What the heck is that?” would be a very acceptable first reaction.
It’s generated by concatenating the prefix (__rct_export__
) with an optional
js_name
(empty in this case) with the line number of the declartion (e.g.
12
) with the __COUNTER__
macro.
The purpose of this method is only returning an array that contains the optional
js_name
(again, empty in this case) and the method signature. The hack on the
name is only to avoid method clashing*.
Runtime
This whole setup is only to provide information to the bridge, so it can find everything that was exported, modules and methods, but this all happens at load time, now we’ll look at how it’s used at runtime.
Here’s the bridge initialisation dependency graph:
Initialise Modules
All the RCTRegisterModule
function does is add the class to an array so the bridge can find it later when a new bridge instance is created. It goes through the modules array, create an instance of every module, store a reference to it on the bridge, give it a reference back to the bridge (so we can call both ways), and check if it has specified in which queue
it wants to run, otherwise we give it a new queue, separate from all other modules.
NSMutableDictionary *modulesByName; // = ...
for (Class moduleClass in RCTGetModuleClasses()) {
// ...
module = [moduleClass new];
if ([module respondsToSelector:@selector(setBridge:)]) {
module.bridge = self;
}
modulesByName[moduleName] = module;
// ...
}
Configure Modules
Once we have our modules, in a background thread, we list all the methods for each module, and call the methods that begin with __rct_export__
, so we can have a string representation of the method signature. That’s important so we can have the actual types of the parameters, i.e. at runtime we’d only be able to know that a parameter is an id
, this way we can know that it’s actually an NSString *
in this case.
unsigned int methodCount;
Method *methods = class_copyMethodList(moduleClass, &methodCount);
for (unsigned int i = 0; i < methodCount; i++) {
Method method = methods[i];
SEL selector = method_getName(method);
if ([NSStringFromSelector(selector) hasPrefix:@"__rct_export__"]) {
IMP imp = method_getImplementation(method);
NSArray *entries = ((NSArray *(*)(id, SEL))imp)(_moduleClass, selector);
//...
[moduleMethods addObject:/* Object representing the method */];
}
}
Setup JavaScript Executor
The JavaScript executors have a -setUp
method that allows it to do more expensive work, like initialising JavaScriptCore, on a background thread. It also saves some work, since only the active executor will receive the setUp
call, rather than all the executors available.
JSGlobalContextRef ctx = JSGlobalContextCreate(NULL);
_context = [[RCTJavaScriptContext alloc] initWithJSContext:ctx];
Inject JSON Configuration
The JSON configuration containing only our module would be something like:
{
"remoteModuleConfig": {
"Logger": {
"constants": { /* If we had exported constants... */ },
"moduleID": 1,
"methods": {
"requestPermissions": {
"type": "remote",
"methodID": 1
}
}
}
}
}
This is store in the JavaScript VM as a global variable, so when the JS side of the bridge is initialised it can use this information to create the modules.
Load JavaScript Code
This is pretty intuitive, just load the source code from whatever provider was specified. Usually downloads the source from the packager during development or load it from disk in production.
Execute JavaScript Code
Once everything is ready, we can load the application source code in the JavaScriptCore VM, that will copy the source, parse and execute it. In the initial execution it should register all the CommonJS modules and require the entry point file.
JSValueRef jsError = NULL;
JSStringRef execJSString = JSStringCreateWithCFString((__bridge CFStringRef)script);
JSStringRef jsURL = JSStringCreateWithCFString((__bridge CFStringRef)sourceURL.absoluteString);
JSValueRef result = JSEvaluateScript(strongSelf->_context.ctx, execJSString, NULL, jsURL, 0, &jsError);
JSStringRelease(jsURL);
JSStringRelease(execJSString);
Modules in JavaScript
The modules generated from JSON configuration showed above will be available in JavaScript through the NativeModules
object of react-native
, e.g.:
var { NativeModules } = require('react-native');
var { Person } = NativeModules;
Person.greet('Tadeu');
The way it works is that when you call a method it goes to a queue, containing the module name, the method name and all the arguments used to call it. At the end of the JavaScript execution this queue is given back to native to execute this calls.
Call cycle
Now if we call the module with the code from above, here’s what it looks like:
The calls have to start from native* it calls into JS, and during the execution, as it calls methods on NativeModules
, it enqueues calls that have to be executed on the native side. When the JS finishes, native goes through the enqueued calls, and as it executes them, callbacks and calls through the bridge (using the _bridge
instance available through a native module to call enqueueJSCall:args:
) are used to call back into JS again.
NOTE:
If you’ve been following the project, there used to be a queue of calls from native -> JS as well, that’d be dispatched on every vSYNC, but it’s been removed in order to improve start up time.
Argument types
For calls from native to JS it’s easier, the arguments are passed as an NSArray
that we just encode as JSON, but for the calls from JS we need the native type, for that we check for primitives explicitly (i.e. ints, floats, chars, etc…) but as mentioned above, for any objects (and structs), the runtime doesn’t give us enough information from the NSMethodSignature
, and we save the types as strings.
We use regular expression to extract the types from the method signature, and we use the RCTConvert
utility class to actually transform the objects, it has a method for every type supported by default, and it tries to convert the JSON
input into the desired type.
We use objc_msgSend
to call the method dynamically, unless it’s a struct
, since there’s no version of objc_msgSend_stret
on arm64
, so we fallback to NSInvocation
.
Once we converted all the arguments, we use another NSInvocation
to call the
target module and method, with all the parameters.
Here’s an example:
// If you had the following method in a given module, e.g. `MyModule`
RCT_EXPORT_METHOD(methodWithArray:(NSArray *) size:(CGRect)size) {}
// And called it from JS, like:
require('NativeModules').MyModule.method(['a', 1], {
x: 0,
y: 0,
width: 200,
height: 100
});
// The JS queue sent to native would then look like the following:
// ** Remember that it's a queue of calls, so all the fields are arrays **
@[
@[ @0 ], // module IDs
@[ @1 ], // method IDs
@[ // arguments
@[
@[@"a", @1],
@{ @"x": @0, @"y": @0, @"width": @200, @"height": @100 }
]
]
];
// This would convert into the following calls (pseudo code)
NSInvocation call
call[args][0] = GetModuleForId(@0)
call[args][1] = GetMethodForId(@1)
call[args][2] = obj_msgSend(RCTConvert, NSArray, @[@"a", @1])
call[args][3] = NSInvocation(RCTConvert, CGRect, @{ @"x": @0, ... })
call()
Threading
As mentioned above, every module will have it’s own GCD Queue
by default, unless it specifies the queue it wants to run on, by implementing the -methodQueue
method or synthesizing the methodQueue
property with a valid queue. The exceptions are View Managers
* (That extend RCTViewManager
) that will use the Shadow Queue
by default, and the special target RCTJSThread
, which is just a placeholder, since it’s a thread
rather than a queue
.
View Managers
not a real exception, since the base class explicitly specifies the shadow queue as the target queue.
The current threading “rules” are as following:
-init
and-setBridge:
are guaranteed to be called on the main thread;- All the exported methods are guaranteed to be called on the target queue;
- If you implement the
RCTInvalidating
protocol,invalidate
is also guaranteed to be called on the target queue; - There’s no guarantees for which thread
-dealloc
is going to be called from.
When a batch of calls is received from JS, the calls will be groupped by target queue, and dispatched in parallel:
// group `calls` by `queue` in `buckets`
for (id queue in buckets) {
dispatch_block_t block = ^{
NSOrderedSet *calls = [buckets objectForKey:queue];
for (NSNumber *indexObj in calls) {
// Actually call
}
};
if (queue == RCTJSThread) {
[_javaScriptExecutor executeBlockOnJavaScriptQueue:block];
} else if (queue) {
dispatch_async(queue, block);
}
}
The end
That’s it, that’s a slightly more in-depth overview of how the bridge works. I hope that people that want to build more complex modules and/or contribute to the core framework find it helpful.
Feel free to reach out to me if anything wasn’t clear, or not in-depth enough or if there’s anything you’d like to hear about next!