Blog

Een Design System bouwen met React Webcomponenten

Wat als je een universeel design system zou kunnen bouwen met React en dit kan gebruiken in elke webapplicatie of framework? Het lukte ons door React om te zetten naar webcomponenten. Dit is hoe.

Voor het design system van een klant zochten we een universele oplossing om de componenten eenmaal te maken en ze te gebruiken in verschillende webapplicaties die zijn gebouwd met verschillende web frameworks. Ons idee was simpel: componenten maken in één framework - React - en een combinatie van wrappers, polyfills en tools gebruiken om ze te laten werken in elke context. De implementatie bleek iets lastiger dan we hadden verwacht. Maar we zijn blij met het resultaat en delen graag wat we hebben geleerd tijdens het proces.

Het concept

In ons blog ‘Hoe selecteer je een Framework voor Design System componenten’ beschreven we verschillende concepten om universele componenten te maken, waarvan er één React gebruikte. Kort uitgelegd: we maken componenten in React (.tsx-bestanden) en zetten deze om naar webcomponenten met behulp van wrappers (.wc.ts-bestanden) in combinatie met Preact Custom Element. Hierdoor is het design system beschikbaar als React-componenten voor React-toepassingen en als webcomponenten voor alle andere toepassingen:

React components + Preact Custom Element Wrapper = Web components en React Components
Componenten ontwikkelen met behulp van React en ze omzetten naar Web Components met behulp van een Preact-wrapper

We richten ons op de webcomponenten om het design system universeel beschikbaar te maken. Laten we wat dieper ingaan op hoe dit concept werkt. Allereerst, Preact kan React vervangen en heeft het twee opmerkelijke voordelen: "De kleine omvang van Preact en de op standaarden gebaseerde benadering maken het een uitstekende keuze voor het bouwen van webcomponenten". Omdat een Design System meestal niet de toeters en bellen nodig heeft die React biedt, is Preact een goede optie voor ons.

Het vervangen van React door Preact bereik je met toolconfiguratie zoals in Vite, Rollup of een package.json:

{
  ...
  "alias": {
    "react": "preact/compat",
    "react-dom": "preact/compat",
    "react/jsx-runtime": "preact/jsx-runtime"
  },
  ...
}

Nu Preact is geconfigureerd kunnen we een React-component omzetten in een web component. We bundelen alle gerelateerde bestanden in een directory. Neem bijvoorbeeld een component om een waarschuwingsbericht aan de gebruiker te tonen:

components/alert/
    alert.tsx    ← React component
    alert.css    ← component styles
    alert.wc.ts  ← Web Component wrapper

Opmerking: We gebruiken altijd TypeScript om design systems te maken om de beste ervaring te bieden voor ontwikkelaars die met packages van design systems werken. Om onze voorbeelden eenvoudig en beknopt te houden, hebben we hun typen weggelaten. We noemen ons voorbeeld Design System ACME en gebruiken overal 'acme' als voorvoegsel.

alert.tsx - maak een component met React:

// alert.tsx
import './alert.css’

const Alert = ({ children, type = ‘info’ }) => (
  <div className={`
    alert 
    alert--type-${ type }
  `}>
    { children }
  </div>
)

alert.wc.ts - registreer React-component als webcomponent met behulp van Preact Custom Element:

// alert.wc.ts:
import register from 'preact-custom-element'
import Alert from ‘./Alert.tsx’

register(Alert, 'acme-alert’, [‘type’], { shadow: true })

Gebruik als webcomponent in elke HTML-pagina of ander framework:

<!-- in any HTML page: -->
<acme-alert type=”warning”>My message.</acme-alert>

De werkelijkheid

Toen we begonnen met het bouwen van onze componenten, kwamen we al snel tot de conclusie dat preact-custom-element niet aan onze behoeften voldeed. Het lijkt te zijn ontworpen om op zichzelf staande (web)componenten te bouwen, terwijl wij een systeem van componenten willen bouwen dat stijlen bevat en met elkaar communiceert. Om dit te bereiken hebben we preact-custom-element aangepast en functionaliteit toegevoegd om event handling te ondersteunen en stijlen op te nemen.

Ondersteuning toevoegen voor event handling

Omdat onze componenten fungeren als bouwstenen, willen we dat ze met elkaar communiceren. Attributen worden al omgezet in props door preact-custom-element, maar we moeten ook de andere kant op communiceren:

original preact-custom-element and our adapted preact-custom-element

Bijvoorbeeld, we willen een wegklikknop toevoegen aan ons waarschuwingscomponent:

// alert.tsx
import './alert.css’

const Alert = ({ children, type = ‘info’, onDismiss }) => (
  <div className={`
    alert 
    alert--type-${type}
  `}>
     { children }
	   <button type=”button” onClick={onDismiss}>
        Dismiss
     </button>
  </div>
)

Om dit te laten werken hebben we een configuratieoptie toegevoegd genaamd eventNames:

// alert.wc.ts:
import register from '@acme/register'
import Alert from ‘./Alert.tsx’

register({
  component: Alert,
  tagName: 'acme-alert',
  propNames: [‘type'],
  eventNames: ['onDismiss'],
  shadow: true,
});

Dit werkt als volgt: we maken een proxyfunctie voor deze callbacks, die een aangepast event stuurt wanneer deze functie wordt aangeroepen. De naam van dit aangepaste event wordt gegenereerd uit de waarde die wordt doorgegeven aan eventNames. In ons geval wordt ‘onDismiss’ ‘acme-dismiss’. Dit is een aangepaste eventnaam om conflicten te voorkomen met bubbelende events van het webcomponent, die verschillen van de verzonden events. Met deze wijziging ondersteunen we nu event handling en het kan als volgt worden gebruikt:

// anywhere
<acme-alert type=”warning”>My message.</acme-alert>
<script>
document.querySelector(‘acme-alert’)
  .addEventListener(‘acme-dismiss’, (event) => ...)
</script>

Ondersteuning toevoegen om stijlen op te nemen

De webcomponenten die we bouwen mogen niet worden vervuild door bestaande stijlen op een pagina, omdat we willen dat onze design system componenten overal bruikbaar zijn. Hoewel preact-custom-element het mogelijk maakt om Shadow DOM in te schakelen om ongewenste stijlen buiten te houden, biedt het geen manier om de stijlen op te nemen die we wel willen. Dus hebben we ons preact-custom-element helper verder aangepast:

original preact-custom-element and our adapted preact-custom-element

We staan toe dat een nieuwe webcomponent wordt geregistreerd met een lijst met ingesloten stijlen:

// alert.wc.ts:
import register from '@acme/register'
import Alert from ‘./Alert.tsx’
import alertStyles from './alert.css?inline'

register({
  component: Alert,
  tagName: 'acme-alert',
  propNames: [‘type'],
  eventNames: ['onDismiss'],
  styles: [alertStyles],
  shadow: true,
});

Hoewel ons voorbeeldcomponent slechts één stylesheet heeft, kunnen andere componenten afhankelijk zijn van meerdere stylesheets, zoals een invoercomponent die invoer- en (gedeelde) labelstijlen vereist. Stylesheets die vereist zijn door alle componenten in het design system (zoals resetstijlen) zijn opgenomen in de registerhelper zelf, zodat ze niet in elk afzonderlijk component hoeven te worden geregistreerd. Binnen de registerhelper worden alle stijlen gecombineerd tot een Constructible Stylesheet. Het ziet er ongeveer zo uit:

// handling styles inside @acme/register:
const sheets = [
  resetStyles,
  otherHelperStyles,
  ...this.styles, // passed via register
].map((styles) => {
  const sheet = new CSSStyleSheet();
  sheet.replaceSync(styles);
  return sheet;
});

this.root.adoptedStyleSheets = sheets;

Ondersteuning voor Constructible Stylesheets in Safari is op dit moment experimenteel, daarom gebruiken we voor nu een polyfill:

import 'construct-style-sheets-polyfill'

De optimalisatie

Op dit punt zetten we onze React-componenten met succes om naar webcomponenten, dus we zouden hier kunnen stoppen. Maar omdat we altijd streven naar het leveren van projecten met hoge performance, hebben we nog werk te doen.

An app provider for shared JS dependencies

Met onze werkende setup bundelt elke component zijn eigen Preact-runtime. Hoewel Preact met 4 kB minified en gzipped klein is in vergelijking met React's 45 kB (kern + DOM), telt het toch op wanneer we tientallen componenten hebben.

Het bundelen van Preact in elk component heeft als voordeel dat elk component afzonderlijk kan functioneren. Het betekent ook dat als een team tien van onze componenten implementeert, ze 40 kB (gecomprimeerd) aan hun bundel zouden toevoegen. Deze extra bundelgrootte bestaat alleen uit tien keer dezelfde instantie van Preact. Tijd om dit te optimaliseren!

Om dit op te lossen hebben we een 'app-provider' gemaakt die Preact één keer importeert en beschikbaar maakt voor al zijn child componenten:

Before: each component has its own JS deps and After: app provider exposes shared JS deps

We kunnen het als volgt gebruiken:

// provider loads and exposes Preact
<acme-app-provider>
  <acme-component-a>...</acme-component-a>
  <acme-component-b>...</acme-component-b>
  <acme-component-c>...</acme-component-c>
</acme-app-provider>

De 'app-provider' importeert React en ReactDOM (die worden gealiast naar Preact, zoals hierboven beschreven) en voegt het toe aan het window-object. We voegen er een voorvoegsel aan toe met onze bibliotheeknaam om mogelijke conflicten met bestaande eigenschappen uit te sluiten.

import React from 'react'
import ReactDOM from 'react-dom'
import { register } from '@acme/register'

// we also include other shared dependencies, like:
import 'construct-style-sheets-polyfill'

window.__ACME__React = React
window.__ACME__ReactDOM = ReactDOM

const AppProvider = ({ children }) => {
  return <>{children}</>
}

register({
  component: AppProvider,
  tagName: ‘acme-app-provider'
  options: { shadow: true },
})

Omdat we aliassen hebben geconfigureerd voor React naar Preact, bundelt de app-provider eigenlijk Preact. Ten slotte is de (Rollup) build voor alle andere componenten geconfigureerd om de wereldwijd blootgestelde React(DOM) te gebruiken:

return {
  output: {
  format: 'iife',
  // add an exception for our app-provider, as
  // it provides React instead of consuming it
  ...(!isAppProvider && {
    globals: {
      'react': '__ACME__React',
      'react-dom': '__ACME__ReactDOM',
    },
  }),
  // ...
}

Nu delen alle componenten die in de app-provider zitten dezelfde versie van elke JS-afhankelijkheid.

Een thema-provider voor gedeelde CSS

De Shadow DOM voorkomt dat we gemeenschappelijke CSS delen tussen componenten op dezelfde manier als we doen voor JS-afhankelijkheden. Maar al onze design system tokens - gedefinieerd als aangepaste CSS-eigenschappen - kunnen door de Shadow DOM gaan. We halen daarom deze aangepaste CSS-eigenschappen uit alle componenten en maken ze beschikbaar via een gedeelde thema-provider:

Before: each component includes same CSS variables and After: theme provider injects shared CSS variables

We gebruiken het als volgt:

// provider includes and exposes CSS variables:
<acme-theme-provider>
  <acme-component-a>...</acme-component-a>
  <acme-component-b>...</acme-component-b>
  <acme-component-c>...</acme-component-c>
</acme-theme-provider>

Een extra voordeel van deze thema-provider is dat we in de toekomst gemakkelijker een licht/donker thema-schakelaar kunnen toevoegen.

Optimalisatie van CSS class names

We willen nog één ding optimaliseren: CSS class names. We hechten waarde aan duidelijke benaming van onze componenten en al hun onderdelen, vooral in een design system. Hoewel dit belangrijk is tijdens ontwikkeling en documentatie, geven we prioriteit aan de gebruikerservaring in productie. En voor productie kunnen deze class names alles zijn, omdat ze afgebakend zijn en geen conflicten kunnen veroorzaken. Omdat lange class names de HTML en stylesheets opblazen, hebben we besloten ze te minimaliseren voor productie, wat ongeveer nog eens 5% van de totale bundelgrootte van ons design system bespaart.

Code met lange class names
Tijdens ontwikkeling: lange class names met betekenis
Code met korte class names
In productie: efficiënte class names die 5% bundelgrootte besparen

Afsluitend

Door dit alles samen te voegen, hebben we nu een design system met componenten die overal kunnen worden gebruikt. We hebben een aangepaste omzetter om React-componenten om te zetten naar webcomponenten met ondersteuning voor afgebakende stijlen en event handling. En we hebben onze setup uitgebreid met een gedeelde app- en thema-provider om de bundelgrootte van ons design system te optimaliseren.

Gerelateerde blog posts

← Alle blogposts

Hulp nodig met jouw design system?

Ontdek wat wij voor je kunnen doen voor jouw digitale product.

Lees meer over onze service