...the previous part was about theoretical aspects behind code splitting, and now it's time to jump into technical details.
Stop! What are you going to tell us about?
React.Lazy
?
Well, React itself provides the only one way to code split - React.lazy
. And it replaces a dozen other OSS solutions, existed before it. Have you ever wonder Why?
What makes Lazy so special?
In the beginning, there was a Component, and the Component has a State. In terms of code splitting it was "Loading"
, "Loaded"
, or "Error"
states. And everything was good, except it was a local state
, this was this.state
.
It might sound link a normal state-full component, but this little implementation detail was creating... 🌊waves🌊
So - once you will nest one loadable inside another - you will see a loading spinner from the top component, and then from the nested one. I could not remember the official name of the problem, but it was something like Death By Thousand Flickering Spinners. A terrible thing, and very popular.
And lazy
was created to fix it, although haven't - but Suspense
did.
Suspense
played the role of a single boundary which shall display something till anything inside is not ready to be displayed. Suspense is no more than a boundary of communication protocol (where the "protocol" is nothing more than throwing Promises).
So, what
lazy
did? - it gave us more stable "boundaries", and removed the Flickering Spinners Threat. Our applications were saved yet again.
What's the problem with Lazy?
Well - the interface. Lazy
is not compatible with SSR, and, well, with tests, by design. And the design is following:
- having `React.lazy(() => import('./something'))
- execute
() => import('./something')
- (throw the promise up to the
Suspense
) - once resolved - render Lazy with resolved information.
To explain why this simple sequence, which works just perfectly in runtime, is not the best option for test/SSR, I have to ask you one question - "Did you ever wonder - why lazy
would not repeat the cycle again and again?". "What" will monitor the fulfilment of a given promise?
Long story short - lazy
itself, inside the object returned by React.lazy
. const Lazy = React.lazy(...)
is not only a Component - it's also a State. Well, typeof Lazy === "object"
, JFYI.
So
lazy
is a local state variable from one point of view. Or component with a static state from another.
And what this point and SSR have in common? Let's call that something - a synchronicity.
How to compact 4 steps above into one? As long as asynchronous rendering is absolutely 100% normal for Client-Side Render - that's (yet) absolutely not acceptable for the Server Side Rendering (or tests).
Basically you have to call "renderToString" multiple times, until all
lazy
components would be resolved prior to the rendering.
Is there any good way to handle lazy
on SSR? Well, of course, they are:
- it's
synchronous thenables
. Ie thenables(a base interface for a Promise, just.then
), which don't have to "wait", and resolves _ synchronously_, giving React ability to instantly use then. (but that's not how Promises were supposed to work) - already resolved ones. Does not matter why, and without any explanation for the "how". Merged in React just a month ago and not yet (16.9) published (and not yet 16.10 documented).
And this is exactly the way "react-loadable" was working all this time.
However - even if these two abilities are making lazy
more or less compatible with (synchronous) testing infrastructure - you may manually "resolve" lazy components before the render (however no interface, like .preload
was exposed), - it still is not compatible with Server Side Rendering. Well, by design.
Server Side Rendering?
The problem with SSR is a hydrate
function - you have to load "everything you need", before rendering on the Client "the same picture" you have just rendered on the Server.
- piece a cake - you have to load everything you need do to it, like all chunks.
- piece a cake - you have to know all the chunks you have to load
- piece a cake - you have to track all the chunks you have used
- piece a cake - you have to track all the components you have used during the render and their connections to the chunks...
- don't forget about styles, by the way
🤷♂️ Not a big deal, probably 😅
And then, having a list of things to load, you have to understand what you actually have loaded them before rendering(hydrating) your App. Like providing onload
callback to all the places... Not a big deal, again, probably 🤔.
So it's all about gathering, tracking, dehydration and hydration of "what is needed to render application in some specific state".
While all "lazy loading" solution has almost the same interface, and doing almost the same job - they are managing this moment quite differently.
So
So let's go thought a few libraries and check how they are doing "that":
React.lazy(() => import('./a'))
React.lazy - the "official" component. Easy to use, and paired with Suspense
and ErrorBoundary
to handle loading or error cases.
reactLoadable(() => import('./a'))
React-Loadable - yet most popular solution. Has integrated Loading and Error states, with a build-in throttling. Does not support Suspense support, but supports Import.Maps
.
loadable(() => import('./a'))
loadable-components - SSR friendly solution currently recommended by React. Comes in a form of 4 packages under @loadable
namespace and has the most detailed information about usage. Supports both Loading/Error components and Suspense.
imported(() => import('./a'))
react-imported-component - solution closer to @loadable
by interface, and react-loadable
by technical implementation. The only one(today) build with hooks, and with hooks API exposed to the client side. And, well, I build this guy 👨🔬.
So, you did XXX(() => import('./a')
. What would happen next?
How lazy
is doing it
Q: Does it do something special?
A: It does not.
Q: It is transforming the code?
A: It does not. lazy
does not require any babel/webpack magic to work.
Q: What would happen if you request not yet known component?
A: It will call an import function
to resolve it. And throw a promise just after to communicate - I am not ready.
Q: What would happen if you request already known component?
A: Lazy
remembers what was loaded, and if something was resolved - it's resolved. So nothing happens - it just renders the Lazy Component.
Q: What would happen on SSR?
A: It will render all "ready" components, and completely fail in all other cases. However, next run it would work for just requested, and just resolved component, and fail for the following, not known ones. So - it might work, especially with "preheating", but unpredictable.
Q: What could be in the importer
function
A: Only something resolved to es6 default
, which is usually a real dynamic import
called for a module with a default import. However - you may "resolve" it in a way you need - it's just a Promise.
How react-loadable
is doing it?
Q: Does it do something special?
A: Jump in!
- SSR tracks all used components
- SSR maps components to
chunks
- SSR sends these
chunks
, as well as theirids
to the client - Browser loads all
script
tags injected into HTML - Every
script
may includeloadable(something)
inside - Once called -
loadable
adds itself into "known loadables" - Once everything is loaded, and
preloadReady
is called,react-loadable
goes thought all "known loadables" and if it seems to be loaded (chunkId is present inwebpack modules
) - callsinit
, effectively preloading (lodable.preload
does the same) your component - once all promises are resolved - you are ready
In other words - it "preloads" all "loadables" which could be preloaded, ie both parts - loadable and what it's going to load - are present.
Q: It is transforming the code?
A: Yeah. It does not work (on SSR) without the babel plugin. Plugin's job is to find import
inside Loadable
and replace it by an object, containing some webpack specific module resolution things, hepling loadable do the job.
Q: What would happen if you request not yet known component?
A: It will call provided import function
to resolve it
Q: What would happen if you request already known component?
A: It remembers what it was loaded, and acts like lazy
- just ready to use.
Q: What would happen on SSR?
A: react-loadable.preloadAll
will preload ALL loadables, so they would be ready when you will handle the first request. Without calling this function everything would be broken. However - with calling it everything also might be broken, as long as not all the code should, and could be executed at the Server (and again - it will load EVERYTHING "loadable")
Q: What could be in importer function
A: dynamic import
with any transformation applied(.then
), as well as Loadable.map
with any async code inside.
Q: What about bundler integration
A: Provides webpack plugin to read module -> chunk mapping from stats
, and uses it to map modules to chunks.
How loadable-components
is doing it?
Q: Does it do something special?
A: Jump in!
- SSR tracks all used components
- SSR maps components to
chunks
- SSR sends these
chunks
, as well as theirids
to the client - Browser loads all
script
tags injected into HTML > absolutely the same asreact-loadable
-
Loadable-components
react on every webpack chunk loaded (via webpack plugin), and checks are all requested chunks are loaded. - Once all are loaded - you are ready.
It does not call the "real"
importer function
, and it is an issue.
Q: It is transforming the code?
A: Yeah. It does not work (on SSR) without the babel plugin. Plugin's job it to find import
inside loadable
(just matching the name) and replace it by an object, containing some webpack specific module resolution things. Plus it hooks into webpack
and changes jsonp
callback for modules, acquiring visibility over and control of modules loading process.
Q: What would happen if you request not yet known component?
A: loadable-component
will check isReady
, which will check the existence of required modules in webpack cache
, and requireAsync
(the import function
) in case it is not.
Q: What would happen if you request already known component?
A: loadable-component
will call isReady
, which will check the existence of required module in the webpack cache, and requireSync
in case it is (call requireAsync
if not).
Q: What would happen on SSR?
A: All components would be always isReady
and always use requireSync
, which is just a common nodejs require
.
Q: What could be in importer function
A: Only dynamic import
and nothing more, as long as only "module name" would be used later.
Q: What about bundler integration?
A: Provides webpack plugin to read chunks to assets mapping from stats
, and uses it to render the right assets during SSR.
How react-imported-component
is doing it?
Q: Does it do something special?
A: Jump in!
- SSR tracks all used components
- SSR maps components to
marks
- acrc32
of the text insideimport
- CLI extracts all
import
s in your code intoasync-requires
, like Gatsby does - SSR sends these
marks
, as well asasync-requires
to the client - Browser loads all
script
tags injected into HTML - Imported finds the similarity all known
marks
inasync-requires
and calls realimporters
- Once all are loaded, and nothing more is pending - you are ready.
Q: It is transforming the code?
A: Yeah. It does not work (on SSR) without babel plugin or babel macros. Plugin job it to find all import
s and inject a mark
- /*imported-XXXX-component*/
inside it. Nothing more.
Q: What would happen if you request not yet known component?
A: It will call an import function
to resolve it
Q: What would happen if you request already known component?
A: It remembers what it was loaded, and acts like lazy
- just ready to use
Q: What would happen on SSR?
A: All imports
, except specially marked ones, would be automatically executed if the server environment is detected. By the time express
would handle the first request - they would be ready. (you should await for a special function in case of Lambda)
Q: What could be in importer function
A: Anything you want, but only requests with a mark
inside would be properly tracked.
Q: What about bundler integration
A: Provides a helper to map mark
to chunk
or module name
. React-imported-component is actually "bundler", and "environment" independent, and support for more tight integration with your bundler is handled by another package.
However, as long as the only thing imported
cares about is a "mark" - it does need any real "bundler" integration, while other SSR friendly solution could not like without it. This make is both CRA compatible(thanks to babel macro), and react-snap (puppeteer based prerendering) compatible.
But I don't need SSR!
The simple proposition, and the wrong one.
Try to get me right - you might not need SSR, but what is SSR in terms of code splitting, and in terms of this article?
Well, nothing more than a guidance, help, instruction and prediction of actions to be made before hydrate
to make your App be able to render the final picture faster.
Fun fact - using code splitting it's really super easy to make things worse, and make everything much slower, not faster - loading waves, network underutilization, chunks waiting for other chunks to be loaded first...
With SSR you might render your app much faster - at SSR side all scripts are already loaded, and there is a zero-latency to the backend - and by rendering something on a server you might get information how to prepare frontend to do the same.
Question for you - do you really need SSR for this? Well, let me be honest - it's much safer and much maintainable to use SSR, but it's not required.
Let's imagine you have a site, which serves almost the same, but still different pages for cats
and dogs
.
you will have two
Routes
, one forcats
and one fordogs
, and you will load bundle behind the route only then that route would be required (that's how code splitting usually works).but then you will have the same page, like
:pet/owner
for the pet-owner-interface, also code split, which would be loaded only when hit, and only then the parentcat
(ordog
) chunk is loaded, and used to render:pet/owner
route.in "normal" application, with dynamically loaded
i18n
and so on you will face many "waves of loading" of this, greatly delaying the final rendering. Load language, then:pet
route, then:pet/owner
route, then something else, there is always something extra else...
Would SSR help here? Of course - it will give an instruction to follow, and remove waving at all.
Do you need SSR to solve it? Well, nothing stops you from predicting and prefetching necessary data
and chunks
outside of Route
, outside of React
, and even outside of your App
.
Putting all code splitting "decisions" into "Components" is a big mistake - it's deferring all "decisions" till the render time. Code spitting only "Components" is also a mistake.
While React.lazy
could load only "Components", loadable-components
provides loadable.lib, which would return a library via renderProps
API, and there is the same helper for react-loadable, plus react-imported-component
provides just an useImported hook, which gives you the ability to load whatever you want, whenever you want.
As a conclusion
Code splitting is a complex, even multidimensional thing - it starts as flexible boundaries between modules, continues with loading orchestration, with actions you have to do sooner(like prefetching), or later (like deferring side effects), with tracking of actions made and must end with something clearly better than then initial unsplit solution.
Look like it's time to move to the next step - optimising JS delivery.
Top comments (1)
Great article! You explained complex things in easy language, a lot of stuff became clear to me. Thank you!