Strona Główna Blog webmasterski

Nowoczesna aplikacja z użyciem biblioteki Lit

Lit to prosta biblioteka do tworzenia szybkich i lekkich web komponentów z wykorzystaniem natywnych rozwiązań. Rdzeniem Lit jest klasa rozszerzająca HTMLElement, która dostarcza elementom reaktywność i deklaratywny system szablonów, który jest mały, szybki i ekspresywny.

Dokumentacja biblioteki znajduje się pod adresem lit.dev/docs.

Całość kodu z niniejszego artykułu można znaleźć w moim repozytorium lit-boilerplate.

Przygotowanie środowiska

Na początek musimy mieć zainstalowane Node.js i npm. Instrukcję instalacji można znaleźć na stronie Installing Node.js via package manager . Dla ułatwienia zakładam, że instalujemy za pomocą komendy apt.

sudo apt update
sudo apt install nodejs
sudo apt install npm

Teraz można utworzyć nowy projekt:

mkdir LitTutorial && cd LitTutorial
npm init
package name: (littutorial) lit-app
version: (1.0.0)
description:
entry point: (index.js)
test command:
git repository:
keywords:
author:
license: (ISC)

Zostanie utworzony plik package.json, w którym dodajemy "private": true, "node": ">=14" i "type": "module". Będziemy używać ES modules.

{
  "name": "lit-app",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=14"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  }
}

Instalujemy bibliotekę lit:

npm i --save lit

Teraz potrzebny będzie Web Server, żeby możliwe było uruchomienie aplikacji. Dzięki @web/dev-server możliwe jest importowanie modułów z katalogu node_modules bez konieczności podawania pełnej ścieżki. Przykładowo import {LitElement} from 'lit' zamiast import {LitElement} from './node_modules/lit-element/lit-element.js'.

npm i --save-dev @web/dev-server

Do package.json dodajemy nową komendę serve, która będzie służyć do uruchamiania aplikacji. Komenda będzie uruchamiać serwer z argumentem /sites/, który oznacza katalog, w którym umieszczamy pliki .html.

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "serve": "web-dev-server --node-resolve --open /sites/"
}

Tworzymy plik sites/index.html:

<!DOCTYPE html>
<html lang="en" class="no-js">
<head>
    <script>document.querySelector('html').classList.remove('no-js');</script>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Lit App</title>
</head>
<body>

</body>
</html>

Teraz po uruchomieniu komendy npm run serve w przeglądarce otworzy się nasza pusta strona.

> lit-app@1.0.0 serve
> web-dev-server --node-resolve --open /sites/

Web Dev Server started...

  Root dir: /home/rafal/Projects/LitTutorial
  Local:    http://localhost:8000/sites/
  Network:  http://192.168.0.53:8000/sites/

Przygotowanie środowiska testowego

Do testowania aplikacji zostanie użyty Web Test Runner, Mocha i biblioteka Chai. W dużym skrócie Test Runner posłuży do uruchamiania testów w środowisku przeglądarki. Mocha jest popularnym frameworkiem przeznaczonym do testowania, w którym można przeprowadzić serię testów jednostkowych. Natomiast Chai jest biblioteką przeznaczoną do pisania assertów.

npm i --save-dev @web/test-runner @web/test-runner-core \
@web/test-runner-mocha @web/test-runner-playwright \
@open-wc/testing @esm-bundle/chai mocha

Do package.json dodajemy nową komendę test.

"scripts": {
    "test": "web-test-runner",
    "serve": "web-dev-server --node-resolve --open /sites/"
}

Trzeba jeszcze utworzyć plik konfiguracyjny web-test-runner.config.mjs:

import {playwrightLauncher} from '@web/test-runner-playwright';

export default {
    nodeResolve: true,
    coverage: true,
    playwright: true,
    files: 'test/**/*.test.js',
    browsers: [
        playwrightLauncher({product: 'firefox'}),
        playwrightLauncher({product: 'chromium'}),
        playwrightLauncher({product: 'webkit'}),
    ],
    testFramework: {
        config: {
            ui: 'bdd',
            timeout: '2000',
        },
    },
};

W tym momencie można uruchomić testy npm run test. Jednak wyświetli się poniższy komunikat, bo nie napisaliśmy żadnych testów.

> lit-app@1.0.0 test
> web-test-runner

Error: Could not find any test files with pattern(s): test/**/*.test.js

Napiszemy więc dwa proste testy jednostkowe. Jeden test sprawdzi działanie operatora dodawania, natomiast drugi posłuży do weryfikacji działania biblioteki Lit.

test/sum.test.js
import {expect} from '@esm-bundle/chai';

export function sum(a, b) {
    return a + b;
}

describe('Sum Test', () => {
    it('sums up 2 numbers', () => {
        expect(sum(1, 1)).to.equal(2);
        expect(sum(3, 12)).to.equal(15);
    });
});
test/lit-element.test.js
import {expect} from '@esm-bundle/chai';
import {html, LitElement} from 'lit';
import {fixture} from '@open-wc/testing';

describe('Lit Element Test', () => {
    it('Define new element, create and check content', () => {
        return new Promise(async (resolve, reject) => {
            class TestLitElement extends LitElement {
                render() {
                    return html`
                        <p class="paragraph">Lorem Ipsum</p>
                    `;
                }
            }

            customElements.define('test-lit-element', TestLitElement);

            try {
                const el = await fixture(html`<test-lit-element></test-lit-element>`);
                expect(el.shadowRoot.querySelector('.paragraph').textContent)
                    .to.equal('Lorem Ipsum');
                resolve();
            } catch (error) {
                reject(error)
            }
        });
    });
});

Nie ma chyba sensu omawiać pliku test/sum.test.js, więc od razu przejdę do opowiedzenia, co się dzieje w test/lit-element.test.js.

W tym test case wewnątrz Promise jest utworzona klasa nowego elementu <test-lit-element> (TestLitElement). Ten element został zarejestrowany metodą customElements.define. Następnie za pomocą funkcji fixture dostarczonej przez @open-wc/testing zostaje utworzony fragment html, którego użyjemy, aby dostać się do shadowRoot elementu <test-lit-element> i sprawdzić, czy wewnątrz niego znajduje się akapit z tekstem Lorem Ipsum. ShadowRoot jest root node wewnętrznego drzewa elementu. Takie rozwiązanie zapewnia enkapsulacje elementów.

Teraz można uruchomić testy:

npm run test
> lit-app@1.0.0 test
> web-test-runner


Firefox:  |██████████████████████████████| 2/2 test files | 2 passed, 0 failed
Chromium: |██████████████████████████████| 2/2 test files | 2 passed, 0 failed
Webkit:   |██████████████████████████████| 2/2 test files | 2 passed, 0 failed

View full coverage report at coverage/lcov-report/index.html

Finished running tests in 2.3s, all tests passed! 🎉

Dwa proste elementy i komunikacja

Utworzymy dwa elementy. Jeden będzie odpowiedzialny za wyświetlanie nazwy użytkownika, a drugi za wprowadzanie. W elemencie do wprowadzania nazwy użytkownika użyjemy gotowego pola tekstowego z komponentów Material Desing Web - <mwc-textfield>.

Zaczniemy od instalacji pola tekstowego:

npm i --save @material/mwc-textfield

Teraz utwórzmy plik app-username-display.js w katalogu src/components/elements/app-username-display. Będzie zawierać klasę elementu AppUsernameDisplayElement. To jest nasz element do wyświetlania nazwy użytkownika.

import {LitElement, html} from 'lit';

class AppUsernameDisplayElement extends LitElement {
    static properties = {
        username: {
            type: String
        }
    };

    render() {
        return html`
            <span class="app-username-display">
                Hello ${this.username ?? ''}!
            </span>
        `;
    }
}

customElements.define('app-username-display', AppUsernameDisplayElement);

W tym pliku importujemy klasę LitElement, po której będzie dziedziczyć nasz element i funkcję html, której użyjemy do utworzenia szablonu elementu. W elemencie deklarujemy właściwość username, która posłuży do przechowywania nazwy użytkownika. Wyrażenie umieszczone w template string spowoduje wyświetlenie napisu Hello (nazwa użytkownika)!. W ostatniej linii definiujemy nowy element HTML.

W kolejnym pliku app-username-input.js utworzymy element do wprowadzania nazwy użytkownika.

src/components/elements/app-username-input/app-username-input.js:
import {LitElement, html} from 'lit';
import '@material/mwc-textfield/mwc-textfield.js';

class AppUsernameInputElement extends LitElement {
    static properties = {
        username: {
            type: String
        }
    };

    render() {
        return html`
            <div class="app-username-input">
                <mwc-textfield label="Username"
                               .value=${this.username ?? ''}
                               @input=${this.#onInput}>
                </mwc-textfield>
            </div>
        `;
    }

    /**
     * @param {Event} event
     */
    #onInput(event) {
        this.username = event.target.value;
    }
}

customElements.define('app-username-input', AppUsernameInputElement);

Na początku pliku importujemy wspomniany na początku rozdziału element pola tekstowego. Tak jak poprzednim razem tak i teraz element będzie posiadać właściwość username. W kolejnym etapie username zostanie przeniesione w inne miejsce, ale nie uprzedzajmy faktów.

W szablonie elementu do znacznika <mwc-textfield> zostały dodane dwa wyrażenia:

.value=${this.username ?? ''}

Przypisuje nazwę użytkownika do właściwości value pola tekstowego.

@input=${this.#onInput}

Ustawia event listenera do zdarzenia input pola tekstowego. Listner jest referencją do metody #onInput, która aktualizuje właściwość username, kiedy użytkownik wprowadza tekst do pola tekstowego.

Komunikacja elementów

Jak pewnie zauważyłeś, nasze elementy są zamknięte w sobie, nie komunikują się ze sobą w żaden sposób. Wprowadzenie nazwy do pola tekstowego (app-username-input) nie powoduje aktualizacji tekstu wyświetlanego w app-username-display. Aby temu zaradzić, musimy użyć kontrolerów. Kontrolery są to obiekty, które sterują elementami z zewnątrz. Każdy lit element posiada metodę addController(), która umożliwia podłączenie do niego kontrolera. Kontrolery powinny implementować metodę hostConnected(), która uruchamia się, kiedy kontroler został dodany do elementu. Więcej o metodach kontrolera znajdziesz w Reactive Controllers - Lifecycle .

Na początek dodamy kontroler do elementu app-username-display. Utworzymy klasę o nazwie UsernameDisplayController:

src/components/controllers/UsernameDisplayController.js:
export default class UsernameDisplayController {
    #host;
    username;

    constructor(host) {
        (this.#host = host).addController(this);
    }
}

Wprowadzimy też zmiany do klasy AppUsernameDisplayElement. Usuniemy właściwość username i utworzymy obiekt kontrolera. W szablonie elementu wyrażenie this.username zostanie zamienione na this.controller.username.

class AppUsernameDisplayElement extends LitElement {
    controller = new UsernameDisplayController(this);

    render() {
        return html`
            <span class="app-username-display">
                Hello ${this.controller.username ?? ''}!
            </span>
        `;
    }
}

Podobnie postąpimy z AppUsernameInputElement, ale z tą różnicą, że dodatkowo zmodyfikujemy motodę #onInput.

src/components/controllers/UsernameDisplayController.js:
export default class UsernameInputController {
    #host;
    username;

    constructor(host) {
        (this.#host = host).addController(this);
    }
}
class AppUsernameInputElement extends LitElement {
    controller = new UsernameInputController(this);

    render() {
        return html`
            <div class="app-username-input">
                <mwc-textfield label="Username"
                               .value=${this.controller.username ?? ''}
                               @input=${this.#onInput}>
                </mwc-textfield>
            </div>
        `;
    }

    /**
     * @param {Event} event
     */
    #onInput(event) {
        this.dispatchEvent(new CustomEvent('username-change', {
            detail: {value: event.target.value}
        }));
    }
}

Największą zmianą jest to, że w metodzie #onInput zamiast ustawiać username, wysyłamy customowe zdarzenie username-change. Będziemy go nasłuchiwać w kontrolerze. Więcej o zdarzeniach w Creating and triggering events .

Nasze elementy są już kontrolowane na zewnątrz, ale w dalszym ciągu username nie jest współdzielone pomiędzy elementami. Aby to osiągnąć, będziemy musieli utworzyć obiekt przechowujący współdzielone dane i reagujący na zmiany. Wykorzystamy do tego bibliotekę MobX.

npm i --save mobx

W katalogu src/core utworzymy dwa pliki mobx.js i state.js:

src/core/mobx.js
import {
    makeAutoObservable as oMakeAutoObservable,
    autorun as oAutorun,
    intercept as oIntercept
} from 'mobx/dist/mobx.esm.production.min.js';

export const makeAutoObservable = oMakeAutoObservable;
export const autorun = oAutorun;
export const intercept = oIntercept;
src/core/state.js
import {makeAutoObservable} from './mobx';

class State {
    username;
    constructor() {
        this.username = 'Guest';
        makeAutoObservable(this);
    }
}

export default new State();

W pliku mobx.js wyeksportowaliśmy kilka funkcji z biblioteki MobX. W pliku state.js został utworzony obiekt klasy State, w której utworzyliśmy właściwość username i sprawiliśmy, że ten obiekt stał się obserwowalny (makeAutoObservable(this)).

W tym momencie można współdzielić właściwość username. Najpierw zmodyfikujemy klasę UsernameDisplayController.

import state from '../../core/state.js';
import {autorun} from '../../core/mobx.js';

export default class UsernameDisplayController {
    #host;
    username;

    constructor(host) {
        (this.#host = host).addController(this);
    }

    hostConnected() {
        autorun(() => {
            this.username = state.username;
            this.#host.requestUpdate();
        });
    }
}

Dodaliśmy metodę hostConnected(), która aktualizuje tekst wyświetlany w elemencie app-username-display za każdym razem, gdy w obiekcie state zmieni się username.

Teraz można wprowadzić zmiany do UsernameInputController, żeby aktualizować username, w momencie, kiedy użytkownik wprowadza tekst do pola tekstowego.

import state from '../../core/state.js';
import {autorun} from '../../core/mobx.js';

export default class UsernameInputController {
    #host;
    username;

    constructor(host) {
        (this.#host = host).addController(this);
    }

    hostConnected() {
        this.#host.addEventListener('username-change', (event) => {
            state.username = event.detail.value;
        })

        autorun(() => {
            this.username = state.username;
            this.#host.requestUpdate();
        });
    }
}

W metodzie hostConnected przechwytujemy zdarzenie username-change, które wcześniej zdefiniowaliśmy w elemencie app-username-input. W tym listenerze aktualizujemy współdzielony username. Użyliśmy też funkcji autorun, że w polu tekstowym wartość domyślna była pobierana z obiektu state.

Nie pozostało już nic innego jak dodać elementy do pliku index.html:

index.html
<body>
    <div>
        <app-username-display></app-username-display>
    </div>
    <div>
        <app-username-input></app-username-input>
    </div>
    <script type="module" src="../src/index.js"></script>
</body>
index.js
import './components/elements/app-username-display/app-username-display.js';
import './components/elements/app-username-input/app-username-input.js';

Stylowanie elementów

Stylowanie elementów Lit jest proste. Wystarczy do właściwości statycznej styles przypisać template string używając dedykowanej funkcji css. Poniżej umieszczę przykład, w którym ustawię dla elementu app-username-display czerwone wypełnienie.

import {LitElement, html} from 'lit';
import UsernameDisplayController from '../../controllers/UsernameDisplayController';
import {styles} from './styles.js';

class AppUsernameDisplayElement extends LitElement {
    static styles = [styles];
styles.js
import {css} from 'lit';

export const styles = css`
    :host {
        display: block;
    }

    .app-username-display{
        background: red;
    }
`;

SCSS w elementach

Być może niektórzy woleliby używać SCSS zamiast CSS. Jest taka możliwość, ale wymaga instalacji Gulpa i modułu gulp-lit-styles.

npm i --save-dev gulp gulp-lit-styles gulp-sass sass

W pliku gulpfile.js utwórz task modularize-styles i watchera watch:modularize-styles:

import gulp from 'gulp';
import {logError} from './gulp/gulp-sass.js';
import gulpSass from 'gulp-sass';
import sass from 'sass';

gulp.task('modularize-styles', () => {
    return gulp.src('./src/**/*.scss')
        .pipe(gulpSass(sass)({
            outputStyle: 'compressed',
        })
        .on('error', logError))
        .pipe(styleModules())
        .pipe(gulp.dest('./src'));
});

gulp.task('dist', gulp.series('modularize-styles'));

// Watchers

gulp.task('watch:modularize-styles', () => {
    return gulp.watch('./src/**/*.scss', gulp.series('modularize-styles'));
});

gulp.task('watch', gulp.parallel('watch:modularize-styles'));

Teraz jak odpalisz watchera i utworzysz w katalogu elementu plik z rozszrzeniem .scss, to gulp wygeneruje odpowiedni plik .js, który możesz zaimportować w elemencie. Przykładowy gulpfile znajdziesz tutaj .

Powodzenia!