DEV Community

loading...
Cover image for ⚛️ Inside the Code Split

⚛️ Inside the Code Split

Anton Korzunov
Reinventing the wheels.
・11 min read

...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 their ids to the client
  • Browser loads all script tags injected into HTML
  • Every script may include loadable(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 in webpack modules) - calls init, 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 their ids to the client
  • Browser loads all script tags injected into HTML > absolutely the same as react-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 - a crc32 of the text inside import
  • CLI extracts all imports in your code into async-requires, like Gatsby does
  • SSR sends these marks, as well as async-requires to the client
  • Browser loads all script tags injected into HTML
  • Imported finds the similarity all known marks in async-requires and calls real importers
  • 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 imports 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 for cats and one for dogs, 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 parent cat(or dog) 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.

Discussion (0)