Lazy Bundle Loading in React Native 🔥
A deep dive into Metro internals and the React Native iOS codebase 🐬
This post is a more detailed explanation of a tweet I did earlier this week:
It involves things that I needed to do to get this experiment to work, mainly:
Code Splitting using Metro
Metro doesn’t have an out of the box way to split your bundle based on dynamic imports like webpack has using the CommonsChunkPlugin. There is a webpack based bundler for react native called Haul which could help but for the purposes of this experiment I stuck with Metro.
react-native bundle \
--platform ios \
--entry-file index.js \
Next, I diff’ed them in a file diff tool, like DiffMerge.
The first diff looked like this:
This is how the transpiled portion of the index.js file looks like. It has 3 distinct parts:
- First part of the diff refers to the extra ScreenB import. Note that _dependencyMap is the array of module ID’s received by this function. So:
_dependencyMap = react-native
_dependencyMap = ScreenA
_dependencyMap = ScreenB
- Second part refers to the AppRegistry call to register ScreenB with react native
- Third part refers to Metro providing the ScreenB module ID, 354, to the function so that it can consume it
Each of __d(…) functions refers to a file/module used within your react native app. The second argument of this, 0(zero) in this case, refers to the ID given to our index.js file by Metro.
The second part of the diff looks like this:
This shows the transpiled ScreenB.js module included in the final bundle with a module ID of 354.
So to create our separate bundle for ScreenB we have to do two things:
1. Update main.jsbundle
- Removed ScreenB import and registration
- Removed the module ID for ScreenB, 354, from the dependency map passed to __d(…) in main.jsbundle
- Removed the transpiled ScreenB.js module as shown in the second part of the diff
2. Move code to new file — ScreenB.jsbundle
- I created a new __d(…) function call which is how a module is registered. I needed to give it a unique ID. I chose 355, as 354 was the last module ID used in main.jsbundle
- ScreenB is the second element in the dependencyMap array now, instead of third, so I changed the require to use _dependencyMap instead of _dependencyMap
Now we have an “almost” complete ScreenB.jsbundle. It registers the two modules with Metro, but now we want it to “consume” it as well. This can be done by checking the relevant code in main.jsbundle to understand what’s happening:
In main.jsbundle, the actual starting point is the require(0) at the end of the file. This triggers the application start by executing the code for module ID 0(zero), which is our react native application entry file, index.js.
Since we moved ScreenB registration to a separate module, we need to trigger its execution so that it gets registered with react native and is available when we try to load ScreenB from the native side. We do this by adding a require(355) at the end of ScreenB.jsbundle.
Now, our code splitting task is complete 🌟
Lazily load and execute a partial bundle inside react native
Here’s the chain of events that happens when we call initWithBundleURL:
- An instance of class RCTBridge is created and _bundleURL is set to the one we provide it — [Code]
- The setUp method of this object is called which initialises RCTCxxBridge (aka batched bridge)— [Code]
- The start method of the batched bridge is called — [Code]
- This is where the actual bundle loading and execution takes place — [Code]
So to get out custom loading setup working, we need to have two things in place:
1. Setup the bridge to load a custom bundle
Here we need to set the _bundleURL to point to the partial bundle file we have: ScreenB.jsbundle. So we create a custom method inside RCTBridge.m and expose it in RCTBridge.h. This is the only method we need to call from our userland code. Everything is else will be hidden inside the react native code.
Don’t worry about the lazyStart method that is being invoke inside loadCustomBundle. It is still unimplemented and will be done in the next step 🙂
2. Load and execute the new bundle
- Initialise the bridge object which makes the native to JS communication possible — [Code]
Based on this, our new method, lazyStart, will look like this:
In this, we don’t need to initialise the bridge object since that is already done when the first bundle was loaded. So we just need to do two things here:
Next, we need to expose this method inside RCTCxxBridge.mm so that we can invoke it from the loadCustomBundle method in RCTBridge.m. We can do this by adding it to the interface in RCTBridge+Private.h.
An overview of the changes can be seen in this github diff: https://github.com/karanjthakkar/react-native/compare/v0.56.0...rn_lazy_bundle_loading 🤓
Hooking up lazy loading code with user land code
Now that we have a partial bundle and the code to load it ready, we need two things to wrap it all up.
1. Add ScreenB.jsbundle to bundle resources in XCode
The default react native project generated by the react-native-cli comes with main.jsbundle added to bundle resources. These are resources that XCode bundles with your app. Since ScreenB.jsbundle is a non-standard file that we created, we need to add it to this configuration.
2. Use the react native bridge instance to trigger lazy load
By default, the react native project created by react-native-cli internally creates an RCTBridge instance when we call initWithBundleURL on the the RCTRootView:
In our case, the loadCustomBundle method is available on the RCTBridge. So we need an instance of that class. To do that, we need to break down the above implementation into two parts like so:
This way we have the bridge instance and now you can use it to load your custom bundle wherever you want:
You can save the bridge to a static class property and use it as a singleton wherever you want. Or you can use any dependency injection technique of your choice.
A more elaborate and complete example of the concepts covered in this blog is available on Github.
That’s it folks! 👋🏻 If you found this interesting/useful, please 💚 it. If you would like to know more, please leave a comment or find me on twitter, @geekykaran.