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 wstępie 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!