Présentation

Quatrième article de la série dédiée aux applications micro-frontend. Après VueJS, Angular et ReactJS, c’est au tour de la librairie Lit (anciennement LitElement) de passer le test de la création d’application micro-frontend.

La librairie Lit est spécialisée dans la création de WebComponent. Elle est de ce fait un excellent candidat pour la création d’une application micro-frontend.

Nous allons comme pour les articles, créer une application utilisant un appel API pour générer une liste de vaisseaux issus de l’univers Star Wars.

Contrairement aux autres framework / libraires, Lit ne possède pas de CLI mais un “starter project” qui existe en 2 versions : JavaScript et TypeScript.

Au moment de publier cet article, LitElement sort en version 3 et s’offre un tout nouveau site rien que pour lui (https://lit.dev). Il est renommé en Lit et la librairie en version TypeScript a un peu changé, alors c’est le bon moment pour découvrir la nouvelle version dans ce petit tutoriel.

Pré-requis

Avant de commencer ce tutoriel, retrouvez toutes les informations pour exécuter l’api sur le dépôt GitHub de la série d’articles : https://github.com/ineat/micro-frontend-series/tree/main/swapi

Création du projet

Pour ce projet nous allons utiliser le projet d’exemple de Lit en version TypeScript.

Les starters du dépôt Github de PolymerLabs ne sont pas encore à jour alors nous allons partir sur un template custom. Lit ne possédant pas encore de CLI, nous allons pouvoir nous détourner un peu du tooling classique (Webpack) des projets front-end et utiliser ViteJS. Vite est un petit nouveau dans le tooling JavaScript, mais il a déjà tout d’un grand avec un serveur de développement, un outil de build s’appuyant sur Rollup et surtout il est très léger et très performant.

Initialisation du projet

Dans un premier temps, nous allons initialiser la structure du projet et créer :

  • Un dossier src
  • Un fichier package.json (configuration des packages node)
{
  "name": "lit-sw-starship",
  "version": "0.0.0",
  "description": "Micro-Frontend App using Lit",
  "main": "sw-starship.js",
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "keywords": [
    "web-component",
    "lit",
    "typescript"
  ],
  "license": "ISC",
  "dependencies": {
    "lit": "2.0.0-rc.1"
  },
  "devDependencies": {
    "@rollup/plugin-replace": "^2.3.1",
    "typescript": "4.2.4",
    "rollup-plugin-filesize": "^7.0.0",
    "rollup-plugin-node-resolve": "^5.2.0",
    "rollup-plugin-terser": "^5.3.0",
    "vite": "2.2.1"
  }
}
  • Un fichier tsconfig.json (configuration de TypeScript pour notre projet)
{
  "compilerOptions": {
    "target": "ES5",
    "module": "esnext",
    "lib": ["es2017", "dom", "dom.iterable"],
    "types": ["vite/client"],
    "declaration": true,
    "emitDeclarationOnly": true,
    "outDir": "./types",
    "rootDir": "./src",
    "strict": true,
    "noUnusedLocals": false,
    "noUnusedParameters": false,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "experimentalDecorators": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*.ts"],
  "exclude": []
}
  • Un fichier vite.config.ts
import { defineConfig } from 'vite'

import filesize from 'rollup-plugin-filesize';
import {terser} from 'rollup-plugin-terser';
import resolve from 'rollup-plugin-node-resolve';
import replace from '@rollup/plugin-replace';

export default defineConfig({
  build: {
    rollupOptions: {
      input: './src/sw-starship.ts',
      output: {
        dir: 'dist',
        format: 'esm',
      },
      onwarn(warning) {
        if (warning.code !== 'THIS_IS_UNDEFINED') {
          console.error(`(!) ${warning.message}`);
        }
      },
      plugins: [
        replace({'Reflect.decorate': 'undefined'}),
        resolve(),
        terser({
          module: true,
          warnings: true,
        }),
        filesize({
          showBrotliSize: true,
        })
      ]
    }
  }
})
  • Un fichier index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>StarWars - Lit Micro-Frontend Application</title>
  <script type="module" src="src/sw-starship.ts"></script>
</head>
<body>
  <sw-starship></sw-starship>
</body>
</html>

Attention : Le fichier index.html doit bien se situer à la racine du projet et non dans le dossier src , car la configuration de Vite utilise par défaut ce fichier index.html pour créer le serveur de développement.
De plus, Vite prend directement le fichier TypeScript, l’extension de la source au niveau de la balise <script> est donc bien un fichier .ts

Ensuite, il suffit d’installer les dépendances du projet via la commande npm install. Et notre structure de projet est prête.

Création de notre composant

  • Créez un nouveau fichier nommé sw-starship.ts contenant le code suivant dans le dossier src
import {LitElement, css, html} from 'lit'
import {customElement, property, state} from 'lit/decorators.js'

@customElement('sw-starship')
export class SwStarship extends LitElement {

  static styles = css`
    h1 {
      display: block;
      width: 100%;
      text-align: center;
    }
  `

  @property({type: String}) title = 'Liste des vaisseaux'

  constructor() {
    super()
  }

  render() {
    return html`
      <h1>${this.title}</h1>
    `
  }

  connectedCallback() {
    super.connectedCallback()
  }
}

La base du projet est prête, on démarre le serveur de développement au moyen de la commande npm run dev.

Vous devez obtenir ce résultat en accédant à l’URL suivante : http://localhost:3000

Création des différents composants de l’application

Sur le même principe que dans les épisodes précédents, notre application sera composée de 3 composants (Recherche, Liste des vaisseaux, Pagination) et du composant principal que nous venons de créer.

Le composant de recherche

Dans le dossier src, créez un dossier components, qui contiendra un fichier search.ts.

Comme pour la librairie VueJS, les fichiers .ts de LitElement contiennent à la fois le template HTML, le style CSS et le code JavaScript.

NB: ViteJS permets d’utiliser des fichiers SCSS séparer et de les importer directement dans le fichier ts

// src/components/search.ts
import {LitElement, css, html} from 'lit'
import {customElement, property} from 'lit/decorators.js'

@customElement('search-component')
export class SearchComponent extends LitElement {

  static styles = css`
    .sw-search {
        display: flex;
        flex-direction: row;
        width: 100%;
      }

      .sw-search button {
        min-width: 6rem;
        margin-left: 1rem;
        border: 1px solid rgba(darkred, 0.7);
        background: darkred;
        color: #fff;
      }

      .sw-search button.btn-clear {
        border: 1px solid rgba(grey, 0.7);
        background: lightgrey;
        color: grey;
      }
      
      .sw-search input {
        width: 100%;
        height: 35px;
        padding-left: 1rem;
        border: 1px solid #eee;
        background: #fafafa;
        font-size: 1.125rem;
        
      }
  `

  @property({ type: String, attribute: false }) searchValue = ''

  constructor() {
    super()
  }

  render() {
    return html`
      <div class="sw-search">
        <input type="text"
          .value="${this.searchValue}"
          @change="${this._updateSearchValue}"/>
        ${this.searchValue !== '' ?
          html`
            <button class="btn-clear ${this.searchValue === '' ? 'hide': ''}" @click="${this._handleClear}">
              Clear
            </button>
          ` : ''
        }
        <button @click="${this._handleSearch}">
          Search
        </button>
      </div>
    `
  }

  // Méthode de mise a jour de la prop searchValue
  _updateSearchValue(event: any) {
    this.searchValue = event.target.value
  }

  // Méthode permettant l'émission d'un évènement lors du clic sur le bouton Search
  _handleSearch() {
    let event = new CustomEvent('on-search', {
      detail: {
        searchValue: this.searchValue
      }
    });
    this.dispatchEvent(event);
  }

  // Méthode permettant l'émission d'un évènement lors du clic sur le bouton Clear
  _handleClear() {
    this.searchValue = '';
    let event = new CustomEvent('on-clear', {
      detail: {
        searchValue: this.searchValue
      }
    });
    this.dispatchEvent(event);
  }

}

Ce composant possède une variable searchValue qui est liée à l’input de recherche. Elle sera mise à jour à chaque changement de la valeur de l’input.

Il contient également 3 méthodes privées :

  • _updateSearchValue qui met a jour la variable searchValue à chaque modification de l’input.
  • _handleSearch qui émet un évènement JavaScript lors du clic sur le bouton Search.
  • _handleClear qui émet un évènement JavaScript lors du clic sur le bouton Clear.

Le composant affichant la liste des vaisseaux

Dans le dossier components , créez un fichier starship.ts contenant le code suivant.

// src/components/starship.ts
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";

type Starship = {
  name: String;
}

@customElement('starship-tile-component')
export class StarshipTileComponent extends LitElement {
  
  static styles = css`
    li {
      list-style: none;
      display: flex;
      flex-direction: row;
      align-items: center;
      justify-content: space-between;
      padding: 1rem 2rem;
      border-bottom: 1px solid #efefef;
    }

    li > span {
        font-size: 1.2rem;
        font-weight: 500;
    }

    li > .btn {
      background: firebrick;
      color: white;
      padding: 0.25rem 0.75rem;
      border-radius: 0.5rem;
      border: 1px solid firebrick
    }
  `

  @property({ type: Object })
  starship = { name: ''}

  constructor() {
    super()
  }

  render() {
    return html`
      <li>
        <span>${this.starship.name}</span>
        <a class="btn btn-primary">Details</a>
      </li>
    `
  }
}

Ce composant d’affichage est simple et ne contient qu’une propriété en entrée, l’objet JSON du vaisseau.

Le composant de pagination

Dans le dossier components , créez un fichier pagination.ts .

// pagination.ts
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";

@customElement('pagination-component')
export class PaginationComponent extends LitElement {

  static styles = css`
    .pagination-wrapper {
      display: flex;
      flex-direction: row-reverse;
      padding: 1rem 0
    }
    
    .pagination-wrapper ul {
      display:flex;
      margin: 0;
      padding: 0;
      flex-direction: row;
      align-items: center;
    }
    
    .pagination-wrapper ul li {
      list-style: none;
      padding: 0.25rem .5rem;
      margin: 0 0.125rem;
      border: 1px solid #fff;
      cursor: pointer;
    }
    
    .pagination-wrapper ul li:hover {
      color: #fff;
      background: rgba(255, 34, 0, 0.5);
      border: 1px solid rgba(255, 34, 0, 0.5);
    }
    
    .pagination-wrapper ul li.current {
      border: 1px solid darkred;
      cursor: default;
      color: #fff;
      background: darkred;
    }
  `

  @property({ type: String, attribute: true }) pageNumber = '1'
  @property({ type: Number }) current = 1
  @property({ type: Array }) pages: any[] = []

  constructor() {
    super()
  }

  render() {
    return html`
      <div class="pagination-wrapper">
        <ul>
          ${this.pages.map(item =>
            html`
              <li
                class="pagination-item ${this.current === item.id ? 'current': ''}"
                @click="${() => {this._handleClick(item.id)}}">
                <span>${item.text}</span>
              </li>
            `)
          }
        </ul>
      </div>
    `
  }

  connectedCallback() {
    super.connectedCallback()
    this._createPagination();
  }

  performUpdate() {
    this._createPagination();
    super.performUpdate()
  }

  _createPagination() {
    this.pages = [];
    for(let i = 1; i <= parseInt(this.pageNumber); i++) {
      this.pages = [...this.pages, {id: i, text: i }]
    }
  }

  _handleClick(pageIndex: number) {
    this.current = pageIndex;
    let event = new CustomEvent('on-paginate', {
      detail: {
        pageIndex: pageIndex
      }
    });
    this.dispatchEvent(event);
  }
}

Ce composant possède :

  • 3 propriétés, pageNumber qui définit le nombre de pages de résultats, current qui définit la page courante de la pagination et pages qui correspond à un tableau dans lequel nous allons construire la pagination
  • 2 méthodes, _createPagination pour créer la pagination à partir du nombre de pages et _handleClick qui va émettre un événement lors du clic sur un élément de la pagination.
  • 2 méthodes du cycle de vie, connectedCallback qui est exécutée dès que le WebComponent est chargé dans la page et performUpdate qui sera exécutée lors de la mise à jour du composant suite à un changement de la propriété pageNumber.

Le composant conteneur

Nous avons déjà créé notre composant conteneur sw-starship.ts, il faut le modifier pour inclure les autres composants.

// sw-starship.ts
import {LitElement, css, html} from 'lit'
import {customElement, property, state} from 'lit/decorators.js'

import './components/search'
import './components/starship'
import './components/pagination'

@customElement('sw-starship')
export class SwStarship extends LitElement {

  static styles = css`
    h1 {
      display: block;
      width: 100%;
      text-align: center;
    }
  `

  @property({type: String}) title = 'Liste des vaisseaux'
  @property({type: Array}) starships = []
  @property({type: String}) pageNumber = ''
  @property({type: String}) search = ''
  @state() resultPerPage: number = 10

  constructor() {
    super()
  }

  render() {
    return html`
      <h1>${this.title}</h1>
      <search-component
        @on-search="${this._onSearch}"
        @on-clear="${this._onClear}"
      ></search-component>
      ${this.starships.map(item =>
        html`
          <starship-tile-component .starship=${item}></starship-tile-component>
        `)
      }
      <pagination-component 
        @on-paginate="${this._onPaginate}"
        .pageNumber=${this.pageNumber}
      ></pagination-component>
    `
  }

  connectedCallback() {
    super.connectedCallback()
    this._getStarship()
  }

  async _getStarship(page = 1) {
    const response = await fetch(`http://localhost:3010/starship?_page=${page}&_limit=${this.resultPerPage}`);
    const totalItems: string = response.headers.get('X-Total-Count') as string
    const data = await response.json();
    this.starships = data;
    this.pageNumber = `${Math.round(parseInt(totalItems) / this.resultPerPage)}`;
  }

  async _searchStarship(page = 1) {
    const response = await fetch(`http://localhost:3010/starship?q=${this.search}&_page=${page}&_limit=${this.resultPerPage}`);
    const totalItems: string = response.headers.get('X-Total-Count') as string
    const data = await response.json();
    this.starships = data;
    this.pageNumber = `${Math.round(parseInt(totalItems) / this.resultPerPage)}`;
  }

  _onPaginate(event: any) {
    if(this.search !== null) {
      this._searchStarship(event.detail.pageIndex);
    } else {
      this._getStarship(event.detail.pageIndex)
    }
  }

  _onSearch(event: any) {
    this.search = event.detail.searchValue
    this._searchStarship();
  }

  _onClear() {
    this.search = '';
    this._getStarship();
  }

}

On retrouve dans ce fichier :

  • Les imports des autres composants et leur intégration au niveau de la méthode render.
  • 5 propriétés, title pour le titre de la page, starships pour la liste des vaisseaux, pageNumber pour la pagination, search pour la recherche et resultPerPage pour définir le nombre d’éléments affichés par page de résultat.
  • 2 méthodes pour appeler l’api Star Wars (pour récupérer la liste des vaisseaux ou effectuer une recherche) et 3 autres méthodes pour la gestion de la pagination et de la recherche.
  • Et une méthode du cycle de vie qui au chargement du WebComponent va déclencher l’appel vers l’API pour récupérer la liste des vaisseaux.

Générer le WebComponent

Avant de packager notre application, vérifions que cette dernière fonctionne correctement. En exécutant la commande suivante dans le terminal npm run dev et en accédant à l’URL http://localhost:3000, vous devez obtenir ceci :

Application micro-frontend avec LitElement

L’application tourne correctement, il nous reste à la packager en un seul WebComponent.

Lit étant dédié à la création de WebComponent, il est très simple de packager notre application avec la commande npm run build .

Compilation du composant LitElement en WebComponent

Utiliser le composant micro-frontend

Notre WebComponent est prêt à être intégré dans une page HTML.

Dans votre projet, créez un dossier sample. Dans ce dossier, créez un fichier index.html et copiez les fichiers sw-starship.HASH.js et vendor.HASH.js du WebComponent présents dans le dossier dist/assets.

<!-- sample/index.html -->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>SW Starship Web Component</title>
    <base href="/">
  </head>
  <body>
    <sw-starship></sw-starship>

    <script type="module" src="vendor.b49d7e37.js"></script>
    <script type="module" src="sw-starship.9f87ce39.js"></script>
  </body>
</html>

Dans ce fichier on appelle les scripts de notre WebComponent juste avant la fermeture de la balise </body> et on inclut notre WebComponent dans le corps de notre page HTML aux lignes 13 et 14.

Comme pour Angular, le runtime de Lit qui est très léger est inclus dans le fichier vendor.

Pour voir le résultat, il suffit d’exposer avec un serveur HTTP le dossier sample. (ex: HTTP-Server => https://github.com/http-party/http-server#readme)

Performance

Voilà notre application fonctionne comme voulu. Nous pouvons l’intégrer dans n’importe quel projet web.

Contrairement aux autres frameworks, le runtime de Lit est extrêmement léger, il ne pèse que 16kB en version minifiée, mais non compressée. Pour l’application Lit s’en sort très bien avec un package de moins de 6.5kB non compressé.

Analyse du poids de l’application

Une fois la compression GZip activée, notre WebComponent voit son poids divisé par trois pour atteindre 2.3kB et le runtime ne pèse plus que 6.3kB.

Conclusion

Pour le développement d’application micro-frontend et la création de WebComponent, Lit met une véritable claque aux autres frameworks / libraires. Le poids du composant et du runtime est vraiment léger ce qui est un véritable atout pour la performance web.

Mais attention, Lit ne vient pas avec un tooling complet et demande un peu plus de maitrise pour la réalisation de l’application. La librairie n’intègre ni routeur, ni service http, … mais la performance est au rendez-vous.

Avec une application buildée et compressée de moins de 9kB en incluant le runtime, Lit est vraiment la librairie du moment pour la création de composant Web ou de micro-application.

Est-il possible de combiner plusieurs technologies au sein d’un même projet ? Est-ce pertinent pour les équipes de développement d’avoir le choix de la technologie de développement ? Nous verrons tout ça dans un prochain article.

Liste des articles liés :