How Lazy-Loading Web Components Work with Stencil
When you’re building an app that requires a large library of components in a single file download, you’re often forced to sacrifice performance - and specifically startup time - for the sake of easily accessing all those components. The two are often mutually exclusive. Choose to have all the components available, or choose blazing fast startup, but it’s difficult to get both. At least that’s the experience for many.
Stencil, a Web Component compiler used to build custom component libraries and design systems, was originally built for the Ionic Framework. And Ionic’s challenge has always been to easily distribute a large library of UI components, for both Material Design and iOS, while still staying performant and having a fast startup for Progressive Web Apps. In helping to solve the problem, Ionic moved to using Web Components, which can be distributed and consumed in numerous ways, each with their own pros and cons. But just using Web Components alone does not solve the startup and file size problem.
We’re often asked “how” Stencil’s lazy loading works, and how one script can serve as a single entry point to a large library. But even further, why has Ionic migrated to this architecture, and how is it able to easily offer a large UI library without file size and startup penalties. In this article I’d like to describe a little further the reasoning behind each decision.
Disclaimer: This is by no means claiming to be the one-true way to lazy load components. In fact there are many ways to lazy-load. This is how Stencil does it, and how Ionic components, developers and users have benefited using Stencil’s technique.
Script Tags and CDNs
One of the simplest ways to use a web component is by adding its script tag to the webpage it’ll be used in. When the script loads, it automatically defines the custom element(s), and any custom element already on the page and any created thereafter on the page will get hydrated. Too easy right?
For a single component that works great. However, in Ionic’s case, or any large UI library or Design System for that matter, the challenge is that a single script containing every component is often far too large for an acceptable file size or app startup time.
However, do note that Ionic and Stencil components can also be imported into traditional bundlers too. This post is reviewing how lazy-loading works when using the stand-alone script tag way.
Using Native Browser Features
Luckily, browsers already ship with some extremely valuable, and somewhat lesser known, APIs that Ionic (using Stencil) is able to take advantage of. The biggest of those are native ES Module imports and dynamic imports.
With ES Modules now supported natively within browsers, the
import statement no longer needs to be morphed into something different by bundlers, but rather the browser now fully understands
import and can perform the request without the use of any bundler runtime. Module bundling logic can now be limited to build-time, rather than run-time, which reduces file size and puts module loading performance directly in the browser’s hands.
Predefining the Web Component Library
So with a large codebase slowing down an app’s initial startup, naturally the answer is stated with a simple “well just lazy load components then”. However, lazy loading components often enters a great deal of complexity of bundling, package.json and router configurations, custom framework logic built within each component and wrappers, and we’ve quickly lost the abilities of easily using a script tag. Alternatively, other lazy-load solutions require hardcoded HTML attribute values to go alongside even more custom runtime logic in order for the modules to be requested and wired up correctly.
With Stencil, one of its output targets is to define all of the components within the DOM, but the key is it does not require any of the component logic, and the predefined components are simply proxy components with no logic. In Stencil’s terms, an output target is able to take the developers source code and transform it into a highly optimized build. The compiler actually has a handful of output targets, but for now we’re focusing on the lazy-loading default.
With the lazy loading output target, once one of the components is added to DOM, the proxy component then requests and asynchronously wires up the component’s core logic to the host element on-demand. It’s important to note the entire architecture of the runtime allows the web components to be hydrated asynchronously, to include getting and setting properties, catching events before it’s been fully hydrated, and even ensure there is no flicker of the components before they’re loaded.
Further, Stencil performs static analysis of each component at build-time, and from this it already knows which components already work together, and should be bundled together. With this knowledge up front, the components are already built to avoid unnecessary HTTP requests, and each bundle request already comes with the component(s) it already knows it’ll require. On top of that, because components are now using the browser APIs to load modules, we’re able to take advantage of native module preloading without additional runtime or custom configuration.
Browser Driven Lazy-Loading
How the element shows up in the DOM doesn’t actually matter; whether it was already within the HTML, rendered by Angular Ivy,
React.createElement(‘ion-toggle’, null), jQuery’s
$("body").append("<ion-toggle></ion-toggle>"), or even standard web APIs such
Aside from that, one of my favorite things to hear is how students are being taught web development with just Ionic. No framework, no build system. Just writing plain HTML and one script tag to access all of Ionic's 100+ components, and have them lazy-load on-demand. All of this has been made easier due to Stencil’s architecture.
And for browsers that do not understand ES Modules and dynamic imports, Stencil also automatically generates ES5 builds with necessary polyfills that are only downloaded by browsers that require them (such as IE11).
Focus On Component Authoring. Let Robots Do Everything Else.
Authoring, exporting, polyfilling, bundling, packaging, distributing, consuming, etc. are all complicated tasks, especially when trying to stay performant for the end-user. Tooling improves, frameworks update, bundlers change, and new APIs make old APIs obsolete. This especially holds true when your runtime framework decides to change how it works.
All those hundreds of components hardcoded to a specific runtime API must be rewritten yet again. The long list of “you must”, “never”, “don’t” and best practices change daily, when in the end we’re all just trying to happily build components for apps.
For all these reasons, this is one of the driving factors of why Stencil was created to help Ionic move quickly. Ionic core developers focus purely on creating and maintaining great looking components to be used within millions of apps. Stencil lets the core developers put their time into building components, while the robots are focused on optimizing everything else, and adjusting to new standards.
Each framework and bundler has their own way to lazy load modules, and has its own way to be configured, which is great for innovation and new ideas, so we welcome each tool to do it their own way.
But with Ionic using Stencil’s lazy-loading abilities, which is actually just using native browser APIs, Ionic is able to free itself from each framework’s custom build system, tooling and changes. This helps to meet Ionic’s goal of providing a UI library for any framework, and any version of the framework, or no framework at all, all while generating components that sit atop web-standards. And while core developers can focus on component authoring and maintenance, the compiler can focus on the tasks of optimizing and bundling.
Hopefully this helps detail is more about how Stencil is addressing the goal of moving the complexity of easily distributing components into the build-time tooling, and let authoring and sharing components easy for developers, and fast for end-users.