Hey hey! Today's missive continues exploring the space of JavaScriptand mobile application development.
Yesterday we looked into Ionic / Capacitor, giving a briefstructural overview of what Capacitor apps look like under the hood andhow this translates to three aspects of performance: startup latency, jank,and peak performance. Today we'll apply that same approach to anotherpopular development framework, React Native.
I don't know about you, but I find that there is so much marketing smokeand lights around the whole phenomenon that is React and React Nativethat sometimes it's hard to see what's actually there. This iscompounded by the fact that the programming paradigm espoused by React(and its "native" cousin that we are looking at here) is so effective atenabling JavaScript UI programmers to focus on the "what" and not the"how" that the machinery supporting React recedes into the background.
At its most basic, React is what they call a functional reactiveprogramming model. It is functional in the sense that the userinterface elements render as a function of the global applicationstate. The reactive comes into how user input is handled, but I'm notgoing to focus on that here.
React's rendering process starts with a root element tree, describingthe root node of the user interface. An element is a JavaScriptobject with a type property. To render an element tree, if the valueof the type property is a string, then the element is terminal anddoesn't need further lowering, though React will visit any node in thechildren property of the element to render them as needed.
Otherwise if the type property of an element is a function, then theelement node is functional. In that case React invokes the node'srender function (the type property), passing the JavaScript elementobject as the argument. React will then recursively re-render theelement tree produced as a result of rendering the component until allnodes are terminal. (Functional element nodes can instead have a classas their type property, but the concerns are pretty much the same.)
(In the language of ReactNative, a terminal nodeis a React Host Component, and a functional node is a React CompositeComponent, and both are React Elements. There are manyimprecisely-used terms in React and I will continue this tradition byusing the terms I mention above.)
The rendering phase of a React application is thus a function from anelement tree to a terminal element tree. Nodes of element trees can beeither functional or terminal. Terminal element trees are composed onlyof terminal elements. Rendering lowers all functional nodes to terminalnodes. This description applies both to React (targetting the web) andReact Native (which we are reviewing here).
It's probably useful to go deeper into what React does with a terminalelement tree, before building to the more complex pipeline used in ReactNative, so here we go. The basic idea is that React-on-the-web doesimpedance matching between the functional description of what the UIshould have, as described by a terminal element tree, and the statefultree of DOM nodes that a web browser uses to actually paint and displaythe UI. When rendering yields a new terminal element tree, React willcompute the difference between the new and old trees. From thatdifference React then computes the set of imperative actions needed tomutate the DOM tree to correspond to what the new terminal element treedescribes, and finally applies those changes.
In this way, small changes to the leaves of a React element tree shouldcorrespond to small changes in the DOM. Additionally, since renderingis a pure function of the global application state, we can avoidrendering at all when the application state hasn't changed. We'll diveinto performance more deeply later on in the article.
React Native is similar to React-on-the-web in intent but different instructure. Instead of using a WebView on native platforms, as Ionic /Capacitor does, React Native renders the terminal element tree toplatform-native UI widgets.
When a React Native functional element renders to a terminal element, itwill create not just a JS object for the terminal node asReact-on-the-web does, but also a corresponding C++ shadowobject. Thefully lowered tree of terminal elements will thus have a correspondingtree of C++ shadow objects. React Native will then calculate the layoutfor each node in the shadow tree, and then commit the shadow tree: ason the web, React Native computes the set of imperative actions neededto change the current UI so that it corresponds to what the shadow treedescribes. These changes are then applied on the main thread of theapplication.
The description above of React Native's rendering pipeline applies tothe so-called "newarchitecture", which hasbeen in the works for some years and is only now (April 2023) startingto be deployed. The key development that has allowed React Native tomove over to this architecture is tighter integration and control overits JavaScript implementation. Instead of using the platform'sJavaScript engine (JavaScriptCore on iOS or V8 on Android), Facebookwent and made their own whole new JavaScript implementation,Hermes. Let's step back a bit to see if wecan imagine why anyone in their right mind would make a new JSimplementation.
In the last article, I mentioned that the only way to get peak JSperformance on iOS is to use the platform's WkWebView, which enables JITcompilation of JavaScript code. React Native doesn't want a WebView,though. I guess you could create an invisible WebView and just run yourJavaScript in it, but the real issue is that the interface to theJavaScript engine is so narrow as to be insufficiently expressive. Youcan't cheaply synchronously create a shadow tree of layout objects, forexample, because every interaction with JavaScript has to cross aprocess boundary.
So, it may be that JIT is just not worth paying for, if it means havingto keep JavaScript at arm's distance from other parts of theapplication. How do you do JavaScript without a browser on mobile,though? Either you use the platform's JavaScript engine, or you shipyour own. It would be nice to use the same engine on iOS and Android,though. When React Native was first made, V8 wasn't able to operate ina mode that didn't JIT, so React Native went with JavaScriptCore on bothplatforms.
Bundling your own JavaScript engine has the nice effect that you caneasily augment it with native extensions, for example to talk to theSwift or Java app that actually runs the main UI. That's what Idescribe above with the creation of the shadow tree, but that's notquite what the original React Native did; I can only speculate but Isuspect that there was a fear that JavaScript rendering work (or garbagecollection!) could be heavy enough to cause the main UI to drop frames.Phones were less powerful in 2016, and JavaScript engines were lessgood. So the original React Native instead ran JavaScript in a separatethread. When a render would complete, the resulting terminal elementtree would be serialized as JSON and shipped over to the "native" sideof the application, which would actually apply the changes.
This arrangement did work, but it ran into problems whenever the systemneeded synchronous communication between native and JavaScriptsubsystems. As I understand it, this was notably the case when Reactlayout would need the dimensions of a native UI widget; to avoid astall, React would assume something about the dimensions of the nativeUI, and then asynchronously re-layout once the actual dimensions wereknown. This was particularly gnarly with regards to text measurements,which depend on low-level platform-specific rendering details.
To recap: React Native had to interpret its JS on iOS and was using a"foreign" JS engine on Android, so they weren't gaining anything byusing a platform JS interpreter. They would sometimes have someannoying layout jank when measuring native components. And what's more,React Native apps would still experience the same problem as Ionic /Capacitor apps, in that application startup time was dominated byparsing and compiling the JavaScript source files.
The solution to this problem was partly to switch to the so-called "newarchitecture", which doesn't serialize and parse so much data in thecourse of rendering. But the other side of it was to find a way to moveparsing and compiling JavaScript to the build phase, instead of havingto parse and compile JS every time the app was run. On V8, you would dothis by generating asnapshot. OnJavaScriptCore, which React Native used, there was no such facility.Faced with this problem and armed with Facebook's bank account, theReact Native developers decided that the best solution would be to makea new JavaScript implementation optimized for ahead-of-time compilation.
The result is Hermes. If you are familiarwith JavaScript engines, it is what you might expect: a JavaScriptparser, originally built to match the behavior ofEsprima; an SSA-based intermediaterepresentation;a set of basicoptimizations;a custom bytecodeformat;an interpreter to run thatbytecode;a GC to manage JS objects; andso on. Of course, given the presence of eval, Hermes needs to includethe parser and compiler as part of the virtual machine, but the hope isthat most user code will be parsed and compiled ahead-of-time.
If this were it, I would say that Hermes seems to me to be a dead end.V8 is complete; Hermes is not. For example, Hermes doesn't have with,async function implementation has been lagging, and so on. Why Hermeswhen you can V8 (with snapshots), now that V8 doesn't require JIT codegeneration?
I thought about this for a while and in the end, given that V8's maintarget isn't as an embedded library in a mobile app, perhaps the binarysize question is the one differentiating factor (in theory) for Hermes.By focussing on lowering distribution size, perhaps Hermes will be acompelling JS engine in its own right. In any case, Facebook can affordto keep Hermes running for a while, regardless of whether it has acompetitive advantage or not.
It sounds like I'm criticising Hermes here but that's not really thepoint. If you can afford it, it's good to have code you control. Forexample one benefit that I see React Native getting from Hermes is thatthey control the threadingmodel; they canmostly execute JS in its own thread, but interrupt that thread andswitch to synchronous main-thread execution in response to high-priorityevents coming from the user. You might be able to do that with V8 atsome point but the mobile-apps-with-JS domain is still in flux, so it'snice to have a sandbox that React Native developers can use to explorethe system design space.
With that long overview out of the way, let's take a look to what kindsof performance we can expect out of a React Native system.
Because React Native apps have their JavaScript code pre-compiled toHermes bytecode, we can expect that the latency imposed by JavaScriptduring application startup is lower than is the case with Ionic /Capacitor, which needs to parse and compile the JavaScript at run-time.
However, it must be said that as a framework, React tends to result inlarge applicationsizesand incurs significant work at startuptime.One of React's strengths is that it allows development teams inside anorganization to compose well: because rendering is a pure function, it'seasy to break down the task of making an app into subtasks to be handledby separate groups of people. Could this strength lead to a kind ofweakness, in that there is less of a need for overall coordination onthe project management level, such that in the end nobody feelsresponsible for overall application performance? I don't know. I thinkthe concrete differences between React Native and React (the C++ shadowobject tree, the multithreading design, precompilation) could mean thatReact Native is closer to an optimum in the design space than React. Itdoes seem to me though that whether a platform's primary developmenttoolkit shold be React-like remains an open question.
In theory React Native is well-positioned to avoid jank. JavaScriptexecution is mostly off the main UI thread. The threadingmodel changes toallow JavaScript rendering to be pre-empted onto the main thread do makeme wonder, though: what if that work takes too much time, or what ifthere is a GC pause during that pre-emption? I would not be surprisedto see an article in the next year or two from the Hermes team aboutefforts to avoid GC during high-priority event processing.
Another question I would have about jank relates to interactivity. Saythe user is dragging around a UI element on the screen, and the UI needsto re-layout itself. If rendering is slow, then we might expect to seea lag between UI updates and the dragging motion; the app technicallyisn't dropping frames, but the render can't complete in the 16milliseconds needed for a 60 frames-per-second update frequency.
But why might rendering be slow? On the one side, there is the factthat Hermes is not a high-performance JavaScript implementation. Ituses a simple bytecode interpreter, and will never be able to meet theperformance of V8 with JIT compilation.
However the other side of this is the design of the applicationframework. In the limit, React suffers from the O(n) problem: anychange to the application state requires the whole element tree to berecomputed. Rendering and layout work is proportional to the size ofthe application, which may have thousands of nodes.
Of course, React tries to minimize this work, by detecting subtreeswhose layout does not change, by avoiding re-renders when state doesn'tchange, by minimizing the set of mutations to the native widget tree.But the native widgets aren't the problem: the programming model is, orit can be anyway.
Again in theory, React Native can used to write apps that are as good asif they were written directly against platform-native APIs in Kotlin orSwift, because it uses the same platform UI toolkits as nativeapplications. React Native can also do this at the same time as beingcross-platform, targetting iOS and Android with the same code. Inpractice, besides the challenge of designing suitable cross-platformabstractions, React Native has to grapple with potential performance andmemory use overheads of JavaScript, but the result has the potential tobe quite satisfactory.
As I mentioned in the last article, I am a compiler engineer, not a UIspecialist. In the course of my work I do interact with a number ofcolleagues working on graphics and user interfaces, notably in thecontext of browser engines. I was struck when reading about ReactNative's rendering pipeline about how much it resembled what a browseritself willdoas part of the layout, paint, and render pipeline: translate a tree ofobjects to a tree of immutable layout objects, clip those to theviewport, paint the ones that are dirty, and composite the resultingtextures to the screen.
It's funny to think about how many levels we have here: the elementtree, the recursively expanded terminal element tree, the shadow objecttree, the platform-native widget tree, surely a correspondingplatform-native layout tree, and then the GPU backing buffers that areeventually composited together for the user to see. Could we do better?I could certainly imagine any of these mobile application developmentframeworks switching to their own Metal/Vulkan-based renderingarchitecture at some point, to flatten out these layers.
By all accounts, React Native is a real delight to program for; it makesdevelopers happy. The challenge is to make it perform well for users.With its new rendering architecture based on Hermes, React Native maywell be on the path to addressing many of these problems. Bytecodepre-compilation should go a long way towards solving startup latency,provided that React's expands-to-fit-all-available-space tendency iskept in check.
If you were designing a new mobile operating system from the ground up,though, I am not sure that you would necessarily end up with ReactNative as it is. At the very least, you would include Hermes and thebase run-time as part of your standard library, so that every appdoesn't have to incur the space costs of shipping the run-time. Also,in the same way that Android can ahead-of-time and just-in-time compileitsbytecode, Iwould expect that a mobile operating system based on React Native wouldextend its compiler with on-device post-install compilation and possiblyJIT compilation as well. And at that point, why not switch back to V8?
Well, that's food for thought. Next up, NativeScript. Until then,happy hacking!