We recently wrapped up development on “unbundle” support for react-native-windows. An unbundle is an optimized packaging mechanism for React Native that supports on-demand, lazy loading of JavaScript modules. Our goal was to reduce launch times and memory usage for React Native Windows apps.
The Problem
We’ve been working with BlueJeans on porting react-native-windows to WPF to complement react-native-windows’ existing support for UWP. A gnificant portion of BlueJeans’ desktop application usage comes from Windows 7 devices, so using react-native-windows only for Universal apps was not an option. One of their key metrics is app package size and app start time. When a BlueJeans user joins a meeting for the first time, they want the download, install, and meeting join experience to be as fast as possible. One of the problems with React Native is that before anything else can occur in the app, the JavaScript bundle has to be loaded into the JavaScript runtime on the device. For large bundles, this can mean hundreds of milliseconds of delay in addition to any operating system overhead. We saw unbundles as an opportunity to reduce some of this startup penalty.
What are “unbundles”?
If you’ve ever developed a React Native app for iOS, Android or Windows, you are probably aware that the collection of JavaScript modules and components you write are bundled up into a single blob called the JavaScript bundle. Bundles are produced either from the packager server or from the React Native CLI.
The packager server is the background process launched using npm start
from your React Native app directory. It is also started automatically when you run react-native run-[ios|android|windows]
. The React Native app has a developer mode component that connects to this packager server to download the JavaScript bundle for your app. It also monitors for potential changes to your app for live and hot reloading.
Bundles produced from the command line have additional options. For example, using the --dev
flag, you can specify whether you want to generate a debug or release bundle. The primary difference is that release bundles have the global __DEV__
variable switched off, skipping over debug-only statements and assertions. Release bundles are also minified.
react-native bundle [options] builds the javascript bundle for offline use Options: -h, --help output usage information --entry-file <path> Path to the root JS file, either absolute or relative to JS root --platform [string] One of "ios", "android", or "windows" --transformer [string] Specify a custom transformer to be used --dev [boolean] If false, warnings are disabled and the bundle is minified --bundle-output <string> File name where to store the resulting bundle, ex. /tmp/groups.bundle --bundle-encoding [string] Encoding the bundle should be written in (https://nodejs.org/api/buffer.html#buffer_buffer). --sourcemap-output [string] File name where to store the sourcemap file for resulting bundle, ex. /tmp/groups.map --sourcemap-sources-root [string] Path to make sourcemap's sources entries relative to, ex. /root/dir --assets-dest [string] Directory name where to store assets referenced in the bundle --verbose Enables logging --reset-cache Removes cached files --read-global-cache Try to fetch transformed JS code from the global cache, if configured. --config [string] Path to the CLI configuration file
There is also the unbundle
command, which produces another variant of JavaScript bundle. There are actually two kinds of unbundles, file-based unbundle and indexed unbundle.
A file-based unbundle has a bundle file for startup code located in the same place as a regular bundle. It also has a js-modules
folder in the same directory as the startup code bundle. Inside the js-modules
folder is a file called UNBUNDLE
with a binary header that matches an expected value in React Native. There is also a file per module in the js-modules
folder that gets loaded on-demand using the nativeRequire
native callback function.
assets/
|--index.bundle
|--js-modules/
|-- UNBUNDLE
|-- 100.js
|-- 101.js
|-- 102.js
|-- ...
The other kind of unbundle is the indexed unbundle. Loading many small files generates many separate I/O requests, as each file is loaded on demand. For some platforms, iOS in particular, the overhead of I/O can outweigh the potential benefits of the unbundle. The indexed unbundle addresses part of the I/O problem by bundling all the JavaScript modules into a single file. The beginning of the file is a binary header that includes a mapping table from module index to the offset and length of the module in the unbundle file.
To generate an unbundle, use the following command:
react-native unbundle --platform [ios|android|windows] --entry-file ./path/to/file --bundle-output ./path/to/output/name.platform.bundle --assets-dest ./path/to/output
You can also use any of the other CLI options from the bundle
command above. To force the indexed unbundle, use the --indexed-unbundle
flag.
Implementing for Windows – The C# Approach
The IJavaScriptExecutor
interface is an abstraction used by the React Native bridge to load JavaScript bundles, call functions, invoke callbacks, and retrieve the batched queue of native methods that should be invoked.
using Newtonsoft.Json.Linq; using System; namespace ReactNative.Bridge { /// <summary> /// Interface for making JavaScript calls from native code. /// </summary> public interface IJavaScriptExecutor : IDisposable { /// <summary> /// Call the JavaScript method from the given module. /// </summary> /// <param name="moduleName">The module name.</param> /// <param name="methodName">The method name.</param> /// <param name="arguments">The arguments.</param> /// <returns>The flushed queue of native operations.</returns> JToken CallFunctionReturnFlushedQueue(string moduleName, string methodName, JArray arguments); /// <summary> /// Invoke the JavaScript callback. /// </summary> /// <param name="callbackId">The callback identifier.</param> /// <param name="arguments">The arguments.</param> /// <returns>The flushed queue of native operations.</returns> JToken InvokeCallbackAndReturnFlushedQueue(int callbackId, JArray arguments); /// <summary> /// Invoke the React 'flushedQueue' function. /// </summary> /// <returns>The flushed queue of native operations.</returns> JToken FlushedQueue(); /// <summary> /// Sets a global variable in the JavaScript runtime. /// </summary> /// <param name="propertyName">The global variable name.</param> /// <param name="value">The value.</param> void SetGlobalVariable(string propertyName, JToken value); /// <summary> /// Runs the JavaScript at the given path. /// </summary> /// <param name="sourcePath">The source path.</param> /// <param name="sourceUrl">The source URL.</param> void RunScript(string sourcePath, string sourceUrl); } }
The first implementation of IJavaScriptExecutor
we created used C# and P/Invoke to call native methods from Chakra.dll
. We use a JSRT wrapper to have a simpler, managed API to the Chakra JavaScript runtime. For more information about using Chakra from C# and .NET, check out this code story on the topic.
In the C++ bridge implementation for React Native on iOS and Android, the JSCExecutor
, which is analogous to IJavaScriptExecutor
, introduced an API to set an unbundle instance. A conditional check to determine if the app JavaScript bundle is an unbundle was also added at an earlier stage in the React Native bridge, outside of the JavaScript executor component.
To check if the app JavaScript bundle is an unbundle, the framework needs to do a bit of file I/O. For a file-based unbundle, the framework must search the bundle directory for the relative path ./js-modules/UNBUNDLE
and confirm that the file includes the correct binary header. Likewise, for an indexed bundle, the framework must also check the bundle file for the correct binary header.
Rather than spread out the logic to perform file I/O operations across different layers of the React Native bridge, we chose to encapsulate the entire unbundle implementation inside IJavaScriptExecutor
. In the RunScript
function, we added the following logic:
public void RunScript(string sourcePath, string sourceUrl) { if (sourcePath == null) throw new ArgumentNullException(nameof(sourcePath)); if (sourceUrl == null) throw new ArgumentNullException(nameof(sourceUrl)); var startupCode = default(string); if (IsUnbundle(sourcePath)) { _unbundle = new FileBasedJavaScriptUnbundle(sourcePath); InstallNativeRequire(); startupCode = _unbundle.GetStartupCode(); } else if (IsIndexedUnbundle(sourcePath)) { _unbundle = new IndexedJavaScriptUnbundle(sourcePath); InstallNativeRequire(); startupCode = _unbundle.GetStartupCode(); } else { startupCode = LoadScript(sourcePath); } EvaluateScript(startupCode, sourceUrl); }
When an unbundle is recognized, we create an instance of the IJavaScriptUnbundle
abstraction that can extract the startup code using GetStartupCode
and can get the source code of a module from a given index using GetModule
.
class JavaScriptUnbundleModule { public JavaScriptUnbundleModule(string source, string sourceUrl) { SourceUrl = sourceUrl; Source = source; } public string SourceUrl { get; } public string Source { get; } } interface IJavaScriptUnbundle : IDisposable { JavaScriptUnbundleModule GetModule(int index); string GetStartupCode(); } class FileBasedJavaScriptUnbundle : IJavaScriptUnbundle { ... } class IndexedJavaScriptUnbundle : IJavaScriptUnbundle { ... }
We also added a global native function to the Chakra runtime instance to implement the nativeRequire
behavior expected by the React Native JavaScript.
private JavaScriptNativeFunction _nativeRequire; private void InstallNativeRequire() { _nativeRequire = NativeRequire; EnsureGlobalObject().SetProperty( JavaScriptPropertyId.FromString("nativeRequire"), JavaScriptValue.CreateFunction(_nativeRequire), true); } private JavaScriptValue NativeRequire( JavaScriptValue callee, bool isConstructCall, JavaScriptValue[] arguments, ushort argumentCount, IntPtr callbackData) { if (argumentCount != 2) { throw new ArgumentOutOfRangeException( nameof(argumentCount), "Expected exactly two arguments (global and moduleId)."); } var moduleId = arguments[1].ToDouble(); if (moduleId <= 0) { throw new ArgumentOutOfRangeException( nameof(arguments), Invariant($"Received invalid module ID '{moduleId}'.")); } var module = _unbundle.GetModule((int)moduleId); EvaluateScript(module.Source, module.SourceUrl); return JavaScriptValue.Invalid; }
When the packager server produces a bundle or an unbundle, it uses a Babel plugin to rewrite require
calls from a module name to a module index. React Native polyfills the require
method to look for modules that have already been loaded given the module index, or, if not available, calls the nativeRequire
method to request that the native framework load the module. This method then calls one of our unbundle interface implementations to either load the module from a file, or from the indexed unbundle.
Initial testing of the C# approach did not meet our expectations. Using the file-based unbundle seemed significantly slower than even the regular bundle approach, and the indexed unbundle did not seem to improve the performance over that of the regular bundle. Our intuition was that the managed file I/O overhead exceeded the performance benefits of the unbundle, so we started looking into a C++/CX approach instead.
Implementing for Windows – The C++/CX Approach
We had previously implemented a C++/CX version of the IJavaScriptExecutor
to investigate its performance benefits. For regular bundles, we didn’t see much of a reason to switch, as the performance characteristics were not drastically different, and the C++/CX implementation was less portable than the C# variant.
However, we felt we could create a much more performant unbundle implementation in C++/CX than in the C# IJavaScriptExecutor
, specifically because we could use memory-mapped files. Memory-mapped files have a strict paging behavior, where parts of a file are read into memory in page increments, and file contents are accessed by byte offset in virtual memory rather than by seek behavior. Depending on the module size, the indexed unbundle can fit multiple module records on a page of memory. For example, the average module size for the UWP React Native Playground app is 1.5KB, but more than 60% of the modules are less than 1KB, more than 40% of the modules are less than 512 bytes, and 25% are less than 256 bytes. That means there are likely quite a few pages that fit four or more modules per 4KB page.
We created a similar abstraction to the IJavaScriptUnbundle
interface above as a base class in C++. We decided to support both file-based and indexed unbundles for C++/CX, even though we were reasonably certain the file-based unbundles would not be performant on Windows.
struct JsModulesUnbundleModule sealed { public: wchar_t* source; wchar_t* sourceUrl; ~JsModulesUnbundleModule() { delete[] source; delete[] sourceUrl; } }; class JsModulesUnbundle { public: JsModulesUnbundle(const wchar_t* szSourcePath); ~JsModulesUnbundle(); virtual JsModulesUnbundleModule* GetModule(uint32_t index); virtual JsErrorCode GetStartupCode(wchar_t** pszScript); protected: JsModulesUnbundle(); private: wchar_t* sourcePath; wchar_t* modulesPath; }; class JsIndexedModulesUnbundle : public JsModulesUnbundle { public: JsIndexedModulesUnbundle(const wchar_t* szSourcePath); ~JsIndexedModulesUnbundle(); virtual JsModulesUnbundleModule* GetModule(uint32_t index) override; virtual JsErrorCode GetStartupCode(wchar_t** pszScript) override; private: ... };
We created a helper method to load the memory-mapped file.
JsErrorCode LoadByteCode(const wchar_t* szPath, BYTE** pData, HANDLE* hFile, HANDLE* hMap) { *pData = nullptr; *hFile = CreateFile2(szPath, GENERIC_READ, FILE_SHARE_READ, OPEN_EXISTING, nullptr); if (*hFile == INVALID_HANDLE_VALUE) { return JsErrorFatal; } *hMap = CreateFileMapping(*hFile, nullptr, PAGE_READONLY | SEC_RESERVE, 0, 0, L"ReactNativeMapping"); if (*hMap == NULL) { CloseHandle(*hFile); return JsErrorFatal; } *pData = (BYTE*)MapViewOfFile(*hMap, FILE_MAP_READ, 0, 0, 0); if (*pData == NULL) { CloseHandle(*hMap); CloseHandle(*hFile); return JsErrorFatal; } return JsNoError; }
From initial testing, we had a significant improvement in the indexed unbundle performance over the C# approach.
Unbundle Load Time Results
We needed a way to measure relative load times of each React Native bundle type. In react-native-windows, native modules are instantiated before any JavaScript is evaluated. We created a new native module that started a timer when the native module static constructor was called, and provided a ReactMethod
to read the current elapsed time from it.
public class StopwatchModule : NativeModuleBase { private static readonly Stopwatch s_stopwatch; static StopwatchModule() { s_stopwatch = Stopwatch.StartNew(); } public override string Name => "StopwatchModule"; [ReactMethod] public void elapsedMilliseconds(ICallback callback) { callback.Invoke(s_stopwatch.ElapsedMilliseconds); } }
We created a dummy app that would call the StopwatchModule
when the main app component mounted.
class Stopwatch extends Component { state = { elapsed: 0 } componentDidMount() { NativeModules.SourceCode.elapsedMilliseconds(value => this.setState({elapsed: value})); } render() { return ( <View> <Text> Elapsed: {this.state.elapsed} ms </Text> </View> ); } }
While this stopwatch approach would not give us a precise measurement of how long it took to load the initial JavaScript, we can compare the relative performance of different JavaScript bundle approaches, assuming all other startup overhead is relatively constant. The difference in the captured stopwatch time would vary only due to differences in the time to load the JavaScript, as all other aspects of the framework were kept constant. We tested the app on a Surface Book running Windows 10 with an Intel Core i7 and 16 GB of RAM.
We found that the C# approach for file-based unbundles were by far the worst performing option, even worse than regular bundles. The C++/CX approach to indexed unbundles produced the fastest load times, about 70 milliseconds faster than the C# approach for regular bundles. The C++/CX file-based unbundle approach also produced a speedup over the C# regular bundle.
Bundle Loader | Average Load Time (ms) |
---|---|
C# Regular Bundle (MB) | 297 |
C# File-Based Unbundle (MU) | 1695 |
C# Indexed Unbundle (MI) | 290 |
C++/CX Regular Bundle (NB) | 249 |
C++/CX File-Based Unbundle (NU) | 257 |
C++/CX Indexed Unbundle (NI) | 229 |
In general, we also found that the file-based unbundles were a bad idea because the minimum file size in Windows is 1KB. For BlueJeans, app package size is equally as important as load times, because they want their first-time use of the app to launch as quickly as possible. Having a larger app package means more time downloading that package. Also, more data would be used to install the app, which is an important metric for their mobile users. Likewise, considering the many files in the file-based unbundle approach, the time to decompress the app package would be longer than a single file.
Instead of using the ad hoc stopwatch module measurement approach, we could have captured ETW traces that measured the load times directly. This would have involved a bit more work to script a deployment mechanism and a tool to read the results from the .etl
files, and ultimately we only cared about the comparative performance. There is more information about capturing ETW traces in react-native-windows on GitHub.
Serialized Bytecode Bundles
We also wanted to compare against an existing experimental feature of react-native-windows that used the built-in capacity of Chakra Core to pre-parse and serialize JavaScript, and run that serialized code directly.
The serialized bytecode is produced by using the JsSerializeScript
API. We write the output of that API call to a specific path in local storage the first time the app is started. On subsequent runs, we simply check if the serialized script already exists and check to make sure the bundle has not been updated since the serialized bytecode bundle was produced. If both of those conditions are satisfied, we use the JsRunSerializedScriptWithCallback
API to run the bytecode bundle.
We found that the bytecode bundle was more performant than any other approach for the test app.
Bundle Loader | Average Load Time (ms) |
---|---|
C# Regular Bundle (MB) | 297 |
C# File-Based Unbundle (MU) | 1695 |
C# Indexed Unbundle (MI) | 290 |
C++/CX Regular Bundle (MB) | 249 |
C++/CX File-Based Unbundle (MU) | 257 |
C++/CX Indexed Unbundle (MI) | 229 |
C++/CX Serialized Bundle (NS) | 143 |
There are a few limitations to the bytecode bundle. First is that the serialized bytecode needs to be generated the first time the app runs, and each time the bundle is updated with tools like CodePush. For apps with intermittent use that push regular updates, this could mean that you’ll be paying the penalty to generate the bytecode bundle each time the app is started, resulting in no net performance benefit to your users. Also, the bytecode bundle approach is not currently compatible with the unbundle approach. More complex apps with large bundle sizes may actually see better performance with the indexed unbundle.
Conclusions and Future Work
By default, react-native-windows still uses the C# IJavaScriptExecutor
, meaning you’ll have to make some changes to the native code in your app to start using the C++/CX executor. If you use the React Native CLI tool to generate your react-native and react-native-windows projects, then you’ll simply need to override the JavaScriptExecutorFactory
property on the MainPage.cs
class that is generated.
class MainPage : ReactPage { public override string MainComponentName { get { return "myapp"; } } ... public override Func<IJavaScriptExecutor> JavaScriptExecutorFactory { get { // To use the C++/CX IJavaScriptExecutor, uncomment: // return () => new NativeJavaScriptExecutor(); // To use the C++/CX IJavaScriptExecutor with byte code bundle support, uncomment: // return () => new NativeJavaScriptExecutor(true); // To use the default IJavaScriptExecutor: return null; // Currently equivalent to return () => new ChakraJavaScriptExecutor(); } } }
You can use this code story as a baseline for your decision on what kind of bundle to use in React Native. Ultimately, for the trivial UWP test app, we found that the serialized bytecode bundle produced the fastest load times. For less trivial apps with much larger bundle sizes, it may be the case that the indexed unbundle would be faster. Similarly, for apps with regular updates and intermittent use, the cost of regenerating the bytecode bundle may exceed its potential benefits. There are many variables to app load times both with and without unbundles. Some apps may require a large number of modules just to render the first component. Some apps may not want to pay an on-demand module loading penalty at a critical point in the app. You will likely need to run an experiment similar to the one described above to make this decision for your app.
We still need to implement the serialized bytecode approach for WPF, as it’s currently only implemented in the C++/CX project, which will only work for WinRT apps. We can do this either by porting the C++/CX project to C++/CLI, or by exposing the JsSerializeScript
and JsRunSerializedScriptWithCallback
APIs via P/Invoke to the C# IJavaScriptExecutor
. We also would like to implement behaviors to serialize the JavaScript bytecode bundle on a background thread, so we don’t pay as much of a penalty for the bytecode serialization each time the bytecode needs to be regenerated.
For further implementation details on either the C# or C++/CX unbundle solution, check out the unbundle pull request on react-native-windows.
If you’re interested in getting started with react-native-windows, check out our code story on how to build UWP apps with react-native-windows.
The post Hacking React Native Unbundles into UWP appeared first on ISE Developer Blog.