8 Composition and universal rendering

This chapter covers:

  • Employing universal rendering in a micro frontends architecture
  • Applying server- and client-side composition in tandem to combine their benefits
  • Discovering how to leverage the server-side rendering (SSR) capabilities of modern JavaScript frameworks in a micro frontends context

In the last few chapters, we focused on various integration techniques and discussed their strengths and weaknesses. We grouped them into two categories: server-side and client-side. Integration on the server makes it possible to ship a page that loads quickly and adheres to the principles of progressive enhancement. Client-side integration enables building rich user interfaces where the page can react to user input instantly.

Broad framework support for universal rendering made building applications that run server- and client-side a lot easier for developers. But what do we need to do to integrate multiple universal applications into a big one?

Terminology: Universal, isomorphic, and SSR

The terms universal rendering,a Isomorphic JavaScript,b and server-side rendering (SSR) essentially refer to the same concept: Having a single code-base that makes it possible to render and update markup on the server and in the browser. Their meaning or perspective varies in detail. However, in this book, we’ll go with the term universal rendering.

a See Michael Jackson, “Universal JavaScript,” componentDidBlog, http://mng.bz/GVvR

b See Spike Brehm, “Isomorphic JavaScript: The Future of Web Apps,” Medium, http://mng.bz/zj7X.

You’ve already acquired the necessary building blocks. We can combine the client- and server-side composition and routing techniques from the last few chapters to make this happen. Figure 8.1 illustrates how our puzzle pieces fit together.

Figure 8.1 Universal composition is the combination of a server- and a client-side composition technique. For the first request, a technique like SSI, ESI, or Podium assembles the markup of all micro frontends server-side. The complete HTML document gets sent to the browser . In the browser, each micro frontend hydrates itself and becomes interactive . From there on, all user interactions can happen fully client-side. The micro frontends update the markup directly in the browser .

Note In this chapter, we assume that you’re already familiar with the concept of universal rendering and know what hydration is. If not, I recommend reading this blog post for a quick introduction. 1 If you want do dive deeper, you can also check out the book Isomorphic Web Applications. 2

In this chapter, we’ll upgrade our product detail page. We’ll implement universal rendering for all micro frontends and than apply the required integration techniques to make the site work as a whole.

8.1 Combining server- and client-side composition

Since Team Decide added Team Checkout’s Buy button to the product page, tractor sales skyrocketed. Now hundreds of orders from all over the world arrive every hour. The team behind The Tractor Store was pretty overwhelmed by this success. They had to ramp up their production and logistics capabilities to keep up with the demand. But not everything has been rosy since then. Over recent weeks, the development teams struggled with some serious issues. One day Team Checkout shipped a release of their software that triggered a JavaScript error in all Microsoft Edge browsers. Due to this bug, the Buy button was missing on the page. Sales for that day were down by 34%. This incident showed a significant quality issue, and the team took measures so that this kind of problem wouldn’t strike again.

But this is not the only problem. The product page integrates the Buy button micro frontend using client-side composition via Web Components. The Buy button is not part of the initial markup. Client-side JavaScript renders it. While it loads, the user sees an empty spot where the Buy button will appear after a delay. In local development, this delay is not noticeable. But in the real world, on lower-end smartphones and non-optimal network conditions, it takes a considerable amount of time. Adding new features to the Buy button made this effect even worse. Figure 8.2 shows how the product page looks when JavaScript fails or hasn’t finished loading yet.

The teams decide to switch to a hybrid integration model. Using SSI for server-side composition and also keeping the Web Components composition. This way, the first-page load can be fast, and client-side updating and communication is still possible. Let’s look at this combination.

Figure 8.2 Client-side composition requires JavaScript to work. If it fails or takes a long time to load, the included micro frontends are not shown. For the product page, this means that the user can’t buy a tractor. Universal composition makes it possible to use progressive enhancement in a micro frontends context. That way, the Buy button can render instantly, and you can make it function even without JavaScript.

8.1.1 SSI and Web Components

In chapter 5, Team Checkout wrapped its Buy button micro frontend into a Custom Element. The browser receives the following HTML markup.

Listing 8.1 team-decide/product/fendt.html

...
<checkout-buy sku="fendt"></checkout-buy>
...

Since checkout-buy is a custom HTML tag, the browser treats it as an empty inline element. At first, the user sees nothing. Client-side JavaScript creates the actual content (a button with a price) and renders it as a child. Then the final DOM structure in the browser looks like this:

...
<checkout-buy sku="fendt">
  <button type="button">buy for $54</button>
</checkout-buy>
...

It would be great if we could ship the button content already with the initial markup. Sadly, Web Components don’t have a standard way to render server-side. 3

Figure 8.3 Nginx (webserver/) acts as the shared frontend proxy and handles the markup composition on the server side. Note that this is an excerpt of the complete folder structure.

TIP You can find the sample code for this task in the 16_universal folder. It essentially combines the example code from 05_ssi with 08_web_components.

Since there is no standard way of doing it, we need to be creative. In this example, we will use the SSI technique you learned in chapter 4 for adding server-side composition to the Web Components approach. This way, we prepopulate the Web Components’ internal markup. Figure 8.3 shows our folder structure. Team Decide adds an SSI directive as the child to the Buy button’s Custom Element.

Listing 8.2 team-decide/product/fendt.html

...
<checkout-buy sku="fendt">                                    
  <!--#include virtual="/checkout/fragment/buy/fendt" -->     
</checkout-buy>
...

Client-side Custom Element definition owned by Team Checkout. The associated code runs in the browser and renders/hydrates the micro frontend.

Nginx replaces this SSI directive with the content that’s returned by the endpoint specified in virtual. Team Checkout owns this endpoint.

The preceding code of Team Decide’s product page now combines client- and server-side composition. The Nginx web server replaces the SSI directive with the <button> markup, which Team Checkout generates when calling the /checkout/fragment/buy/fendt endpoint. Our example simulates this by serving a static HTML file.

Listing 8.3 team-checkout/fragment/buy/fendt.html

<button type="button">buy for $54</button>

In practice, you’d use a library with server-rendering capabilities to dynamically generate a response in a Node.js environment. For a React-based application, you’d call ReactDOMServer.renderToString (<CheckoutBuy />) and return its result. Here <CheckoutBuy /> would be the React-based micro frontend application. The assembled product page markup that reaches the browser looks like this:

...
<checkout-buy sku="fendt">
  <button type="button">buy for $54</button>      
</checkout-buy>
...

Nginx replaced the SSI directive with the actual content.

The browser is now able to show the button instantly. The associated Custom Element code runs when the JavaScript finishes loading. It hydrates the micro frontend--making sure that the markup is correct and attaching events for further interaction.

Team Checkout’s client-side code for the Buy button looks like this.

Listing 8.4 team-checkout/checkout/static/fragment.js

const prices = {
  porsche: 66,
  fendt: 54,
  eicher: 58
};
 
class CheckoutBuy extends HTMLElement {
  connectedCallback() {
    const sku = this.getAttribute("sku");
    this.innerHTML = `                                               
      <button type="button">buy for $${prices[sku]}</button>         
    `;                                                               
    this.querySelector("button").addEventListener("click", () => {   
      ...                                                            
    });                                                              
  }
  ...
}
window.customElements.define("checkout-buy", CheckoutBuy);
...

Renders the markup client-side. This is a “dumb” implementation which replaces all existing markup even if it might already be correct. In a real application, you’d use something more clever and performant like DOM-diffing.

Adding event listeners to be able to react to user input

The code is identical to the examples we used in chapter 5. The component renders its internal markup inside itself and attaches all required event handlers. We again use a simplified implementation here. No client-server code reuse, no DOM diffing. But you get the picture.

When you use something like React, this is the place where you’d call ReactDOM.hydrate (<CheckoutBuy />, this), where <CheckoutBuy /> is the React application for the button and this is the reference to the Custom Element. The call instructs the framework to pick up the existing server-generated markup and hydrate it.

Figure 8.4 shows the complete process we went through, starting with the server-side markup generation at the bottom and ending with the initialization of the Buy button’s Custom Element in the DOM.

Figure 8.4 Prerendering the contents of a Web Component based micro frontend using SSI. The markup of Team Decide’s product page contains a Custom Element for Team Checkout’s Buy button. It has an SSI include directive as its content . Nginx replaces the include directive with the internal Buy-button markup generated by Team Checkout . The browser receives the assembled markup and displays it to the user . The browser loads Team Checkout’s JavaScript containing the Custom Element definition for the Buy button . The Custom Element’s initialization code (constructor, connectedCallback) runs. It hydrates the server-generated markup and can react to user input from this point on .

The integration works. Run the example with npm run 16_universal on your machine and open http://localhost:3000/product/fendt in your browser to see it working.

  • Notice Team Checkout’s mini-cart and Team Inspire’s recommendation fragment. The integration for these fragments works the same way as for the Buy button.

  • Have a look at the server-logs in the console. You can see how Nginx requests the individual SSI fragments needed for the page.

  • See how the price on the Buy button updates client-side when you select the platinum edition.

  • Clicking the button triggers the checkmark animation and updates the mini-cart.

  • Disable JavaScript in your browser to simulate how the page looks when the client-side code fails or isn’t loaded yet.

Progressive enhancement

You’ve noticed that the Buy button now appears even with JavaScript disabled. But clicking it does not perform any action. This is because we are attaching the actual add-to-cart mechanics via JavaScript. But it’s straightforward to make it work without JavaScript by wrapping the button inside an HTML form element like this:

<form action="/checkout/add-to-cart" method="POST">
  <input type="hidden" name="sku" value="fendt">
  <button type="submit">buy for $54</button>
</form>

In the case of failed or pending JavaScript, the browser performs a standard POST to the specified endpoint provided by Team Checkout. After that, Team Checkout redirects the user back to the product page. On that page, the updated mini-cart presents the newly added item.

Building an application with progressive enhancement principles in mind requires a little more thinking and testing than relying on the fact that JavaScript always works. But in practice, it boils down to a handful of patterns you can reuse throughout your application. This way of architecting creates a more robust and failsafe product. It’s good to work with the paradigms of the web and not reinvent your ones on top of it.

8.1.2 Contract between the teams

Let’s take a quick look at the contract for including a fragment from another team. Here is the definition Team Checkout provides:

  • Buy button

    Custom Element: <checkout-buy sku=[sku]></...>

    HTML endpoint: /checkout/fragment/buy/[sku]

Since we are combining two integration techniques, the team offering the micro frontend needs to provide both: the Custom Element definition and the SSI endpoint, which delivers the server-side markup. The team using the micro frontend also needs to specify both. In our example Team Decide uses this code:

<checkout-buy sku="fendt">
  <!--#include virtual="/checkout/fragment/buy/fendt" -->
</checkout-buy>

These three lines include a lot of redundancy. To reduce friction, it’s a good idea to establish a project-wide naming schema. This way, tag names and endpoints all look alike, and teams can use a generic template for including a fragment. Figure 8.5 shows how a schema might look.

Figure 8.5 This schema shows how you could generate the universal integration markup in a standardized way. When offering or integrating a fragment, teams need to know three properties: the name of the team that owns it, the name of the micro frontend itself, and the parameters it takes.

8.1.3 Other solutions

This is, of course, not the only way to build a universal integration. Instead of SSI and Web Components, you can also combine other techniques. Integrating server-side with ESI or Podium and adding your client-side initialization on top would also work.

Are you looking for a batteries-included solution? Then you could try the Ara Framework. 4 Ara is a relatively young micro frontends framework, but it’s built with universal rendering in mind. It brings its own SSI-like server-side assembly engine written in Go. Client-side hydration works through custom initialization events. Examples for running a universal React, Vue.js, Angular, or Svelte application exist.

8.2 When does universal composition make sense?

Does your application need to have a fast first-page load? Your user interface should be highly interactive, and your use case requires communication between the different micro frontends. Then there is no way around a universal composition technique like you’ve seen in this chapter.

8.2.1 Universal rendering with pure server-side composition

But the fact that one team wants to use universal rendering does not mean that you need a client-side composition technique. Let me give you an example.

Team Decide owns the product page and includes a header micro frontend (fragment), which Team Inspire owns. The two applications (product page and header) do not need to communicate with each other. Here a simple server-side composition is sufficient. Both teams can adopt universal rendering inside of their micro frontends if it helps their goal. But they don’t have to. If the header has no interactive elements, a pure server rendering is sufficient. They can add client-side rendering later on if their use case changes. The other team does not have to know about it. From an architectural perspective, universal rendering inside a team is a team-internal implementation detail.

8.2.2 Increased complexity

Universal composition combines the benefits of server- and client rendering. But it also comes with a cost. Setting up, running, and debugging a universal application is more complicated than having a pure client- or server-side solution. Applying this concept on an architecture level with universal composition doesn’t make it easier. Every developer needs to understand how integration on the server and hydration on the client works. Modern web frameworks make building universal applications easier. Adding a new feature is usually not more complicated. But the initial setup of the system and onboarding of new developers takes extra time.

8.2.3 Universal unified single-page app?

Is it possible to combine the application shell model from chapter 7 with universal rendering? Yes, in this chapter, we combined client- and server-side composition techniques to run multiple universal applications in one view. You could also combine client- and server-side routing mechanisms to create a universal application shell. However, this is not a trivial undertaking, and I haven’t seen production projects that are doing this right now.

The single-spa project plans to add server-side rendering support. But at the time of writing this book, this feature hasn’t been implemented yet. 5

Let’s take a look at our beloved comparison chart in figure 8.6 for the last time. As stated before, running a universal composition setup is not trivial and introduces extra complexity. Since it builds on the existing client- and server-side composition techniques, it also does not introduce extra technical isolation. But this approach shines when it comes to user experience. It’s possible to achieve the page-load speeds of server-rendered solutions, and it also enables building highly interactive features that directly render in the browser.

Figure 8.6 To run a micro frontends integration that supports universal rendering for all teams, we need to combine server- and client-side composition techniques. Both have to work together in harmony. This makes this approach quite complex. Regarding user experience, it’s the gold standard, since it delivers a fast first-page load while also providing a high amount of interactivity. It also enables developers to build their features using progressive enhancement principals.

To keep this chart readable, I’ve omitted the theoretical universal unified SPA option. It’s by far the most complicated approach, but it would rank even higher on the interactivity scale since it eliminates all hard page transitions.

Summary

  • Universal rendering combines the benefits of server and client rendering: fast first-page load and quick response to user input. To leverage this potential in a micro frontends project, you need to have a server- and client-side composition solution.

  • You can use SSI together with Web Components as a composition pattern.

  • Each team must be able to render its micro frontend via an HTTP endpoint on the server and also make it available via JavaScript in the browser. Most modern JavaScript frameworks support this.

  • On the first page load, a service like Nginx assembles the markup for all micro frontends and sends it to the browser. In the browser, all micro frontends initialize themselves via JavaScript. From that point on, they can react to user input entirely client-side.

  • Currently, there’s no web standard to server-render a Web Component. But there are custom solutions to define ShadowDOM declaratively. In our example, we use the regular DOM to prepopulate the Web Components content on the server.

  • It’s possible to implement a universal application shell to enable client- and server-side routing. However, this approach comes with a lot of complexity.


1.See Kevin Nguyen, “Universal Javascript in Production--Server/Client Rendering,” Caffeine Coding, http://mng.bz/04jl.

2.Elyse Kolker Gordon, Isomorphic Web Applications--Universal Development with React, http://mng.bz/K2yZ.

3.There are custom solutions available in projects like Skate.js or Andrea Giammarchi’s project Heresy. But since the W3C spec defines Shadow DOM as a pure client-side concept, we don’t have a web standard to build upon for proper hydration.

4.See https://github.com/ara-framework.

5.See https://github.com/CanopyTax/single-spa/issues/103.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.190.217.134