9

CSS in Micro Frontends

 1 year ago
source link: https://dev.to/florianrappl/css-in-micro-frontends-4jai
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

CSS in Micro Frontends

Cover by Shubham's Web3 on Unsplash

One of the questions I get asked the most is how to deal with CSS in micro frontends. After all, styling is always something that is needed for any UI fragment, however, it is also something that is globally shared and therefore a potential source of conflict.

In this article I want to go over the different strategies that exist to tame CSS and make it scale for developing micro frontends. If anything in here sounds reasonable to you then consider also looking into "The Art of Micro Frontends".

The code for the article can be found at github.com/piral-samples/css-in-mf. Make sure to check out the sample implementations.

Does the handling of CSS impact every micro frontend solution? Let's check the available types to validate this.

Types of Micro Frontends

In the past I've written a lot about what types of micro frontends exist, why they exist, and when what type of micro frontend architecture should be used. Going for the web approach implies using iframes for using UI fragments from different micro frontends. In this case, there are no constraints as every fragment is fully isolated anyway.

In any other case, independent of your solution uses client or server-side composition (or something in between) you'll end up with styles that are evaluated in the browser. Therefore, in all other cases you'll care about CSS. Let's see what options exist here.

No Special Treatment

Well, the first - and maybe most (or depending on the point of view, least) obvious solution is to not have any special treatment. Instead, each micro frontend can come with additional stylesheets that are then attached when the components from the micro frontend are rendered.

Ideally, each component only loads the required styles upon first rendering, however, since any of these styles might conflict with existing styles we can also pretend that all problematic styles are loaded when any component of a micro frontend renders.

Conflict

The problem with this approach is that when generic selectors such as div or div a are given we'll restyle also other elements, not just the fragments of the originating micro frontend. Even worse, classes and attributes are no failsafe guard either. A class like .foobar might also be used in another micro frontend.

You'll find the example for two conflicting micro frontends in the referenced demo repository at solutions/default.

A good way out of this misery is to isolate the components much more - like web components.

Shadow DOM

In a custom element we can open a shadow root to attach elements to a dedicated mini document, which is actually shielded from its parent document. Overall, this sounds like a great idea, but like all the other solutions presented here there is no hard requirement.

Shadow DOM

Ideally, a micro frontend is free to decide how to implement the components. Therefore, the actual shadow DOM integration has to be done by the micro frontend.

There are some downsides of using the shadow DOM. Most importantly, while the styles inside the shadow DOM stay inside, global styles are also not impacting the shadow DOM. This seems like an advantage at first, however, since the main goal of this whole article is to only isolate the styles of a micro frontend, you might miss out requirements such as applying some global design system (e.g., Bootstrap).

To use the shadow DOM for styling we can either put the styles in the shadow DOM via a link reference or a style tag. Since the shadow DOM is unstyled and no styles from the outside propagate into it we'll actually need that. Besides writing some inline style we can also use the bundler to treat .css (or maybe something like .shadow.css) as raw text. This way, we'll get just some text.

For esbuild we can configure the pre-made configuration of piral-cli-esbuild as follows:

module.exports = function(options) {
  options.loader['.css'] = 'text';
  options.plugins.splice(0, 1);
  return options;
};

This removes the initial CSS processor (SASS) and configures a standard loader for .css files. Now having something in the shadow DOM styled works like:

import css from "./style.css";

customElements.define(name, class extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
  }

  connectedCallback() {
    this.style.display = "contents";
    const style = this.shadowRoot.appendChild(document.createElement('style'));
    style.textContent = css;
  }
});

The code above is a valid custom element that will be transparent from the styling perspective (display: contents), i.e., only its contents will be reflected in the render tree. It hosts a shadow DOM that contains a single style element. The content of the style is set to the text of the style.css file.

You'll find the example for two conflicting micro frontends in the referenced demo repository at solutions/shadow-dom.

Another reason for avoiding shadow DOM for domain components is that not every UI framework is capable of handling elements within the shadow DOM. Therefore, an alternative solution has to be looked for anyway. One way is to fall back to using some CSS conventions instead.

Using a Naming Convention

If every micro frontend follows a global CSS convention then conflicts can be avoided on the meta level already. The easiest convention is to prefix each class with the name of the micro frontend. So, for instance, if one micro frontend is called shopping and another one is called checkout then both would rename their active class to shopping-active / checkout-active respectively.

Convention

The same can be applied to other potentially conflicting names, too. As an example, instead of having an ID like primary-button we'd call it shopping-primary-button in case of a micro frontend called shopping. If, for some reason, we need to style an element we'd should use descendent selectors such as .shopping img to style the img tag. This now applies to img elements within some element having the shopping class. The problem with this approach is that the shopping micro frontend might also use elements from other micro frontends. What if we would see div.shopping > div.checkout img? Even though img is now hosted / integrated by the component brought through the checkout micro frontend it would be styled by the shopping micro frontend CSS. This is not ideal.

You'll find the example for two conflicting micro frontends in the referenced demo repository at https://github.com/piral-samples/css-in-mf/tree/main/solutions/default.

Even though naming conventions solve the problem up to some degree, they are still prone to errors and cumbersome to use. What if we rename the micro frontend? What if the micro frontend gets a different name in different applications? What if we forget to apply the naming convention at some points? This is where tooling helps us.

CSS Modules

One of the easiest ways to automatically introduce some prefixes and avoid naming conflicts is to use CSS modules. Depending on your choice of bundler this is either possible out of the box or via some config change.

CSS Modules
// Import "default export" from CSS
import styles from './style.modules.css';

// Apply
<div className={styles.active}>Active</div>

The imported module is a generated module holding values mapping their original class names (e.g., active) to a generated one. The generated class name is usually a hash of the CSS rule content mixed with the original class name. This way, the name should be as unique as possible.

As an example, let's consider a micro frontend constructed with esbuild. For esbuild you'd need a plugin (esbuild-css-modules-plugin) and respective config change to include CSS modules.

Using Piral we only need to adjust the config already brought by piral-cli-esbuild. We remove the standard CSS handling (using SASS) and replace it by the plugin:

const cssModulesPlugin = require('esbuild-css-modules-plugin');

module.exports = function(options) {
  options.plugins.splice(0, 1, cssModulesPlugin());
  return options;
};

Now we can use CSS modules in our code as shown above.

You'll find the example for two conflicting micro frontends in the referenced demo repository at solutions/css-modules.

There are a couple of disadvantages that come with CSS modules. First, it comes with a couple of syntax extensions to standard CSS. This is necessary to distinguish between styles that we want to import (and therefore pre-process / hash) and styles that should remain as-is (i.e., to be consumed later on without any import). Another way is to bring the CSS directly in the JS files.

CSS-in-JS

CSS-in-JS has quite a bad reputation lately, however, I think this is a bit of misconception. I also prefer to call it "CSS-in-Components", because it brings the styling to the components itself. Some frameworks (Astro, Svelte, ...) even allow this directly via some other way. The often cited disadvantage is performance - which is usually reasoned by composing the CSS in the browser. This, however, is not always necessary and in the best case the CSS-in-JS library is actually build-time-driven, i.e., without any performance drawback.

CSS-in-JS

Nevertheless, when we talk about CSS-in-JS (or CSS-in-Components for that matter) we need to consider the various options which are out there. For simplicity, I've only included three: Emotion, Styled Components, and Vanilla Extract. Let's see how they can help us to avoid conflicts when bringing together micro frontends in one application.

Emotion

Emotion is very cool library that comes with helpers for frameworks such as React, but without setting these frameworks as a prerequisite. Emotion can be very nicely optimized and pre-computed and allows us to use the full arsenal of available CSS techniques.

Using "pure" Emotion is rather easy; first install the package:

npm i @emotion/css

Now you can use it in the code as follows:

import { css } from '@emotion/css';

const tile = css`
  background: blue;
  color: yellow;
  flex: 1;
  display: flex;
  justify-content: center;
  align-items: center;
`;

// later
<div className={tile}>Hello from Blue!</div>

The css helper allows us to write CSS that is parsed and placed in a stylesheet. The returned value is the name of the generated class.

If we want to work with React in particular we can also use the jsx factory from Emotion (introducing a new standard prop called css) or the styled helper:

npm i @emotion/react @emotion/styled

This now feels a lot like styling is part of React itself. For instance, the styled helper allows us to define new components:

const Output = styled.output`
  border: 1px dashed red;
  padding: 1rem;
  font-weight: bold;
`;

// later
<Output>I am groot (from red)</Output>

In contrast, the css helper prop gives us the ability to shorten the notation a bit:

<div css={`
  background: red;
  color: white;
  flex: 1;
  display: flex;
  justify-content: center;
  align-items: center;
`}>
  Hello from Red!
</div>

All in all this generates class names which will not conflict and provide the robustness of avoiding a mixup of styles. The styled helper in particular was inspired heavily from the popular styled-components library.

You'll find the example for two conflicting micro frontends in the referenced demo repository at solutions/emotion.

Styled Components

The styled-components library is arguably the most popular CSS-in-JS solution and quite often the reason for the bad reputation of such solutions. Historically, it was really all about composing the CSS in the browser, but in the last couple of years they really brought that forward immensely. Today, you can have some very nice server-side composition of the used styles, too.

In contrast to emotion the installation (for React) requires a few less packages. The only downside is that typings are an afterthought - so you need to install two packages for full TypeScript love:

npm i styled-components --save
npm i @types/styled-components --save-dev

Once installed, the library is already fully usable:

import styled from 'styled-components';

const Tile = styled.div`
  background: blue;
  color: yellow;
  flex: 1;
  display: flex;
  justify-content: center;
  align-items: center;
`;

// later
<Tile>Hello from Blue!</Tile>

The principle is the same as for emotion. So let's explore another option that tries to come up with zero-cost from the beginning - not as an afterthought.

You'll find the example for two conflicting micro frontends in the referenced demo repository at solutions/styled-components.

Vanilla Extract

What I wrote beforehand about utilizing types to be closer to the components (and avoiding unnecessary runtime costs) is exactly what is covered by the latest generation of CSS-in-JS libraries. One of the most promising libraries is @vanilla-extract/css.

There are two major ways to use the library:

  • Integrated with your bundler / framework
  • Directly with the CLI

In this example we choose the former - with its integration to esbuild. For the integration to work we need to use the @vanilla-extract/esbuild-plugin package.

Now we integrate it in the build process. Using the piral-cli-esbuild configuration we only need to add it to the plugins of the configuration:

const { vanillaExtractPlugin } = require("@vanilla-extract/esbuild-plugin");

module.exports = function (options) {
  options.plugins.push(vanillaExtractPlugin());
  return options;
};

For Vanilla Extract to work correctly we need to write .css.ts files instead of the plain .css or .sass files. Such a file could look as follows:

import { style } from "@vanilla-extract/css";

export const heading = style({
  color: "blue",
});

This is all valid TypeScript. What we'll get in the end is an export of a class name - just like we got from CSS modules, Emotion, ... - you get the point.

So in the end, the style above would be applied like this:

import { heading } from "./Page.css.ts";

// later
<h2 className={heading}>Blue Title (should be blue)</h2>

This will be fully processed at build-time - not runtime cost whatsoever.

You'll find the example for two conflicting micro frontends in the referenced demo repository at solutions/vanilla-extract.

Another method that you might find interesting is to use a CSS utility library such as Tailwind.

CSS Utilities like Tailwind

This is a category on its own, but I thought since Tailwind is the dominant tool in this one I'll only present Tailwind. The dominance of Tailwind even goes so far that some people are asking questions like "do you write CSS or Tailwind?". This is quite similar to the dominance of jQuery in the DOM manipulation sector ca. 2010, where people asked "is this JavaScript or jQuery?".

Anyhow, using a CSS utility library has the advantage that styles are generated based on usage. These styles will not conflict as they are always defined in the same way by the utility library. So, each micro frontend will just come with the portion of the utility library that is necessary to display the micro frontend as desired.

Tailwind

In case of Tailwind and esbuild we'll also need to install the following packages:

npm i autoprefixer tailwindcss esbuild-style-plugin

The configuration of esbuild is a bit more complicated than beforehand. The esbuild-style-plugin is essentially a PostCSS plugin for esbuild; so it must be configured correctly:

const postCssPlugin = require("esbuild-style-plugin");

module.exports = function (options) {
  const postCss = postCssPlugin({
    postcss: {
      plugins: [require("tailwindcss"), require("autoprefixer")],
    },
  });
  options.plugins.splice(0, 1, postCss);
  return options;
};

Here, we remove the default CSS handling plugin (SASS) and replace it by the PostCSS plugin - using both, the autoprefixer and the tailwindcss extensions for PostCSS.

Now we need to add a valid tailwind.config.js file:

module.exports = {
  content: ["./src/**/*.tsx"],
  theme: {
    extend: {},
  },
  plugins: [],
};

This is essentially the bare minimum for configuring Tailwind. It just mentions that the tsx files should be scanned for usage of Tailwind utility classes. The found classes will then be put into the CSS file.

The CSS file therefore also needs to know where the generated / used declarations should be contained. As bare minimum we'll have only the following CSS:

@tailwind utilities;

There are other @tailwind instructions, too. For instance, Tailwind comes with a reset and a base layer. However, in micro frontends we are usually not concerned with the these layers. This falls into the concern of an app shell or orchestrating application - not in a domain application.

The CSS is then replaced by classes that are already specified from Tailwind:

<div className="bg-red-600 text-white flex flex-1 justify-center items-center">Hello from Red!</div>

You'll find the example for two conflicting micro frontends in the referenced demo repository at solutions/tailwind.

Comparison

Almost every method presented so far is a viable contender for your micro frontend. In general, these solutions can also be mixed. One micro frontend could go for a shadow DOM approach, while another micro frontend is happy with Emotion. A third library might opt-in for Vanilla Extract.

In the end, the only thing that matters is that the chosen solution is collision free and does not come with a (huge) runtime cost. While some methods are more efficient than others, they all provide the desired styling isolation.

Method Migration Effort Readability Robustness Performance Impact
Convention Medium High Low None
CSS Modules Low High Medium None to Low
Shadow DOM Low to Medium High High Low
CSS-in-JS High Medium to High High None to High
Tailwind High Medium High None

The performance impact depends largely on the implementation. For instance, for CSS-in-JS you might get a high impact if parsing and composition if full done at runtime. If the styles are already pre-parsed but only composed at runtime you might have a low impact. In case of a solution like Vanilla Extract you would have essentially no impact at all.

For shadow DOM the main performance impact could be the projection or move of elements inside the shadow DOM (which is essentially zero) combined with the re-evaluation of a style tag. This is, however, quite low and might even yield some performance benefits of the given styles are always to the point and dedicated only for a certain component to be shown in the shadow DOM.

In the example we had the following bundle sizes:

Method Index [kB] Page [kB] Sheets [kB] Overall [kB] Size [%]
Default 1.719 1.203 0.245 3.167 100%
Convention 1.761 1.241 0.269 3.271 103%
CSS Modules 2.149 2.394 0 4.543 143%
Shadow DOM 10.044 1.264 0 11.308 357%
Emotion 1.670 1.632 25.785 29.087 918%
Styled Components 1.618 1.612 63.073 66.303 2093%
Vanilla Extract 1.800 1.257 0.314 3.371 106%
Tailwind 1.853 1.247 0.714 3.814 120%

Take these numbers with a grain of salt, because in case of Emotion and Styled Components the runtimes could (and presumably even should) be shared. Also the given example micro frontends have really been small (overall size being 3kB for all the UI fragments). For a much larger micro frontend the growth would certainly not be as problematic as sketched here.

The size increase of the shadow DOM solution can be explained by the simple utility script we provided for easily wrapping existing React renderings into the shadow DOM (without spawning a new tree). If such a utility is centrally shared then the size would be much closer to the other more lightweight solutions.

Conclusion

Dealing with CSS in a micro frontend solution does not need to be difficult - it just needs to be done in a structured and ordered way from the beginning, otherwise conflicts and problems will arise. In general, choosing solutions such as CSS modules, Tailwind, or a scalable CSS-in-JS implementation are advisable.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK