1. Wprowadzenie
Komponenty sieciowe
Komponenty internetowe to zbiór standardów internetowych, które umożliwiają twórcom rozszerzanie HTML o elementy niestandardowe. W tym ćwiczeniu zdefiniujesz element <brick-viewer>, który będzie mógł wyświetlać modele z klocków.
lit-element
Aby zdefiniować nasz element niestandardowy <brick-viewer>, użyjemy lit-element. lit-element to lekka klasa bazowa, która dodaje do standardu komponentów internetowych pewne ułatwienia składniowe. Ułatwi nam to rozpoczęcie korzystania z elementu niestandardowego.
Rozpocznij
Będziemy pisać kod w środowisku online Stackblitz, więc otwórz ten link w nowym oknie:
stackblitz.com/edit/brick-viewer
Zaczynajmy!
2. Definiowanie elementu niestandardowego
Definicja klasy
Aby zdefiniować element niestandardowy, utwórz klasę, która rozszerza LitElement, i oznacz ją dekoratorem @customElement. Argumentem funkcji @customElement będzie nazwa elementu niestandardowego.
W pliku brick-viewer.ts wpisz:
@customElement('brick-viewer')
export class BrickViewer extends LitElement {
}
Element <brick-viewer></brick-viewer> jest teraz gotowy do użycia w HTML. Jeśli jednak spróbujesz, nic się nie wyświetli. Zajmijmy się tym.
Metoda renderowania
Aby zaimplementować widok komponentu, zdefiniuj metodę o nazwie render. Ta metoda powinna zwracać literał szablonu oznaczony funkcją html. Wstaw dowolny kod HTML do oznaczonego literału szablonu. Będzie się wyświetlać, gdy użyjesz <brick-viewer>.
Dodaj metodę render:
export class BrickViewer extends LitElement {
render() {
return html`<div>Brick viewer</div>`;
}
}
3. Określanie pliku LDraw
Definiowanie usługi
Dobrze byłoby, gdyby użytkownik <brick-viewer> mógł określić, który model klocka ma być wyświetlany, za pomocą atrybutu, np. w ten sposób:
<brick-viewer src="path/to/model.ldraw"></brick-viewer>
Ponieważ tworzymy element HTML, możemy skorzystać z deklaratywnego interfejsu API i określić atrybut źródła, tak jak w przypadku tagu <img> lub <video>. W lit-element wystarczy udekorować właściwość klasy za pomocą @property. Opcja type pozwala określić, w jaki sposób lit-element analizuje właściwość do użycia jako atrybut HTML.
Zdefiniuj właściwość i atrybut src:
export class BrickViewer extends LitElement {
@property({type: String})
src: string|null = null;
}
Element <brick-viewer> ma teraz atrybut src, który możemy ustawić w HTML-u. Dzięki lit-element wartość jest już odczytywana z klasy BrickViewer.
Wyświetlanie wartości
Wartość atrybutu src możemy wyświetlić, używając jej w literałach szablonu metody renderowania. Interpoluj wartości w literałach szablonu za pomocą składni ${value}.
export class BrickViewer extends LitElement {
render() {
return html`<div>Brick model: ${this.src}</div>`;
}
}
W oknie widzimy teraz wartość atrybutu src w elemencie <brick-viewer>. Spróbuj otworzyć narzędzia programisty w przeglądarce i ręcznie zmienić atrybut src. Śmiało, spróbuj...
...Czy zauważyłeś, że tekst w elemencie aktualizuje się automatycznie? lit-element obserwuje właściwości klasy oznaczone dekoratorem @property i ponownie renderuje widok. lit-element wykonuje za Ciebie całą pracę.
4. Tworzenie sceny za pomocą Three.js
Światła, kamera, renderowanie!
Nasz element niestandardowy będzie używać biblioteki three.js do renderowania modeli klocków 3D. Niektóre czynności chcemy wykonać tylko raz dla każdej instancji elementu <brick-viewer>, np. skonfigurować scenę, kamerę i oświetlenie w three.js. Dodamy je do konstruktora klasy BrickViewer. Niektóre obiekty zachowamy jako właściwości klasy, aby móc ich później użyć: camera, scene, controls i renderer.
Dodaj konfigurację sceny three.js:
export class BrickViewer extends LitElement {
private _camera: THREE.PerspectiveCamera;
private _scene: THREE.Scene;
private _controls: OrbitControls;
private _renderer: THREE.WebGLRenderer;
constructor() {
super();
this._camera = new THREE.PerspectiveCamera(45, this.clientWidth/this.clientHeight, 1, 10000);
this._camera.position.set(150, 200, 250);
this._scene = new THREE.Scene();
this._scene.background = new THREE.Color(0xdeebed);
const ambientLight = new THREE.AmbientLight(0xdeebed, 0.4);
this._scene.add( ambientLight );
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(-1000, 1200, 1500);
this._scene.add(directionalLight);
this._renderer = new THREE.WebGLRenderer({antialias: true});
this._renderer.setPixelRatio(window.devicePixelRatio);
this._renderer.setSize(this.offsetWidth, this.offsetHeight);
this._controls = new OrbitControls(this._camera, this._renderer.domElement);
this._controls.addEventListener("change", () =>
requestAnimationFrame(this._animate)
);
this._animate();
const resizeObserver = new ResizeObserver(this._onResize);
resizeObserver.observe(this);
}
private _onResize = (entries: ResizeObserverEntry[]) => {
const { width, height } = entries[0].contentRect;
this._renderer.setSize(width, height);
this._camera.aspect = width / height;
this._camera.updateProjectionMatrix();
requestAnimationFrame(this._animate);
};
private _animate = () => {
this._renderer.render(this._scene, this._camera);
};
}
Obiekt WebGLRenderer udostępnia element DOM, który wyświetla wyrenderowaną scenę three.js. Dostęp do niej można uzyskać za pomocą usługi domElement. Możemy wstawić tę wartość do literału szablonu renderowania za pomocą składni ${value}.
Usuń z szablonu komunikat src i wstaw element DOM renderera:
export class BrickViewer extends LitElement {
render() {
return html`
${this._renderer.domElement}
`;
}
}
Aby umożliwić wyświetlanie elementu DOM renderera w całości, musimy też ustawić sam element <brick-viewer> na display: block. Możemy udostępnić style w statycznej właściwości o nazwie styles, ustawionej na literał szablonu css.
Dodaj do klasy ten styl:
export class BrickViewer extends LitElement {
static styles = css`
/* The :host selector styles the brick-viewer itself! */
:host {
display: block;
}
`;
}
Teraz <brick-viewer> wyświetla renderowaną scenę three.js:

Ale… jest pusta. Podajmy model.
Moduł wczytujący klocki
Zdefiniowaną wcześniej właściwość src przekażemy do klasy LDrawLoader, która jest dostarczana z biblioteką three.js.
Pliki LDraw mogą dzielić model klocka na osobne etapy budowy. Łączna liczba kroków i widoczność poszczególnych klocków są dostępne za pomocą interfejsu LDrawLoader API.
Skopiuj te właściwości, nową metodę _loadModel i nowy wiersz w konstruktorze:
@customElement('brick-viewer')
export class BrickViewer extends LitElement {
private _loader = new LDrawLoader();
private _model: any;
private _numConstructionSteps?: number;
step?: number;
constructor() {
// ...
// Add this line right before this._animate();
(this._loader as any).separateObjects = true;
this._animate();
}
private _loadModel() {
if (this.src === null) {
return;
}
this._loader
.setPath('')
// Using our src property!
.load(this.src, (newModel) => {
if (this._model !== undefined) {
this._scene.remove(this._model);
this._model = undefined;
}
this._model = newModel;
// Convert from LDraw coordinates: rotate 180 degrees around OX
this._model.rotation.x = Math.PI;
this._scene.add(this._model);
this._numConstructionSteps = this._model.userData.numConstructionSteps;
this.step = this._numConstructionSteps!;
const bbox = new THREE.Box3().setFromObject(this._model);
this._controls.target.copy(bbox.getCenter(new THREE.Vector3()));
this._controls.update();
this._controls.saveState();
});
}
}
Kiedy należy wywołać funkcję _loadModel? Musi być wywoływana za każdym razem, gdy zmienia się atrybut src. Dekorując właściwość src za pomocą @property, włączyliśmy ją do cyklu życia aktualizacji lit-element. Gdy wartość jednej z tych właściwości ulegnie zmianie, wywoływana jest seria metod, które mogą uzyskać dostęp do nowych i starych wartości właściwości. Metoda cyklu życia, która nas interesuje, to update. Metoda update przyjmuje argument PropertyValues, który zawiera informacje o właściwościach, które właśnie uległy zmianie. To idealne miejsce na _loadModel.
Dodaj metodę update:
export class BrickViewer extends LitElement {
update(changedProperties: PropertyValues) {
if (changedProperties.has('src')) {
this._loadModel();
}
super.update(changedProperties);
}
}
Element <brick-viewer> może teraz wyświetlać plik cegiełki określony za pomocą atrybutu src.

5. Wyświetlanie częściowych modeli
Teraz skonfigurujmy bieżący etap budowy. Chcemy móc określić <brick-viewer step="5"></brick-viewer> i zobaczyć, jak wygląda model z klocków na 5 etapie budowy. Aby to zrobić, przekształćmy właściwość step we właściwość obserwowaną, dodając do niej dekorator @property.
Dodaj parametry do właściwości step:
export class BrickViewer extends LitElement {
@property({type: Number})
step?: number;
}
Teraz dodamy metodę pomocniczą, która sprawi, że widoczne będą tylko klocki do bieżącego etapu budowy. Wywołamy funkcję pomocniczą w metodzie aktualizacji, aby była uruchamiana za każdym razem, gdy zmieni się właściwość step.
Zaktualizuj metodę update i dodaj nową metodę _updateBricksVisibility:
export class BrickViewer extends LitElement {
update(changedProperties: PropertyValues) {
if (changedProperties.has('src')) {
this._loadModel();
}
if (changedProperties.has('step')) {
this._updateBricksVisibility();
}
super.update(changedProperties);
}
private _updateBricksVisibility() {
this._model && this._model.traverse((c: any) => {
if (c.isGroup && this.step) {
c.visible = c.userData.constructionStep <= this.step;
}
});
requestAnimationFrame(this._animate);
}
}
Otwórz teraz narzędzia dla deweloperów w przeglądarce i sprawdź element <brick-viewer>. Dodaj do niego atrybut step, np. tak:

Zobacz, co się stanie z wyrenderowanym modelem. Za pomocą atrybutu step możemy kontrolować, jaka część modelu jest wyświetlana. Gdy atrybut step ma wartość "10", powinno to wyglądać tak:

6. Nawigacja po zestawie klocków
mwc-icon-button
Użytkownik końcowy naszego <brick-viewer> powinien też mieć możliwość poruszania się po krokach kompilacji za pomocą interfejsu. Dodajmy przyciski umożliwiające przejście do następnego kroku, poprzedniego kroku i pierwszego kroku. Aby ułatwić to zadanie, użyjemy komponentu internetowego przycisku Material Design. @material/mwc-icon-button jest już zaimportowany, więc możemy dodać <mwc-icon-button></mwc-icon-button>. Ikona, której chcemy użyć, może być określona za pomocą atrybutu icon, np. <mwc-icon-button icon="thumb_up"></mwc-icon-button>. Wszystkie możliwe ikony znajdziesz tutaj: material.io/resources/icons.
Dodajmy do metody renderowania kilka przycisków z ikonami:
export class BrickViewer extends LitElement {
render() {
return html`
${this._renderer.domElement}
<div id="controls">
<mwc-icon-button icon="replay"></mwc-icon-button>
<mwc-icon-button icon="navigate_before"></mwc-icon-button>
<mwc-icon-button icon="navigate_next"></mwc-icon-button>
</div>
`;
}
}
Dzięki komponentom internetowym korzystanie z Material Design na naszej stronie jest bardzo proste.
Powiązania zdarzeń
Te przyciski powinny faktycznie coś robić. Przycisk „Odpowiedz” powinien zresetować krok konstrukcyjny do 1. Przycisk „navigate_before” powinien zmniejszać numer kroku konstrukcji, a przycisk „navigate_next” powinien go zwiększać. lit-element ułatwia dodawanie tej funkcji za pomocą powiązań zdarzeń. W literałach szablonu HTML użyj składni @eventname=${eventHandler} jako atrybutu elementu. eventHandler będzie teraz uruchamiana, gdy na tym elemencie zostanie wykryte zdarzenie eventname. Dodajmy na przykład obsługę zdarzeń kliknięcia do 3 przycisków z ikonami:
export class BrickViewer extends LitElement {
private _restart() {
this.step! = 1;
}
private _stepBack() {
this.step! -= 1;
}
private _stepForward() {
this.step! += 1;
}
render() {
return html`
${this._renderer.domElement}
<div id="controls">
<mwc-icon-button @click=${this._restart} icon="replay"></mwc-icon-button>
<mwc-icon-button @click=${this._stepBack} icon="navigate_before"></mwc-icon-button>
<mwc-icon-button @click=${this._stepForward} icon="navigate_next"></mwc-icon-button>
</div>
`;
}
}
Spróbuj teraz kliknąć przyciski. Świetnie!
Style
Przyciski działają, ale nie wyglądają dobrze. Wszystkie są stłoczone na dole. Nadajmy im styl, aby nałożyć je na scenę.
Aby zastosować style do tych przycisków, wracamy do właściwości static styles. Te style mają ograniczony zakres, co oznacza, że będą stosowane tylko do elementów w tym komponencie internetowym. To jedna z zalet pisania komponentów internetowych: selektory mogą być prostsze, a kod CSS będzie łatwiejszy do odczytania i napisania. Pa, BEM!
Zaktualizuj style, aby wyglądały tak:
export class BrickViewer extends LitElement {
static styles = css`
:host {
display: block;
position: relative;
}
#controls {
position: absolute;
bottom: 0;
width: 100%;
display: flex;
}
`;
}

Przycisk resetowania kamery
Użytkownicy naszych <brick-viewer> mogą obracać scenę za pomocą myszy. Dodajmy też przycisk resetowania kamery do pozycji domyślnej. Inny element <mwc-icon-button> z powiązaniem zdarzenia kliknięcia wystarczy.
export class BrickViewer extends LitElement {
private _resetCamera() {
this._controls.reset();
}
render() {
return html`
<div id="controls">
<!-- ... -->
<!-- Add this button: -->
<mwc-icon-button @click=${this._resetCamera} icon="center_focus_strong"></mwc-icon-button>
</div>
`;
}
}
Szybsza nawigacja
Niektóre zestawy klocków mają wiele kroków. Użytkownik może chcieć przejść do określonego kroku. Dodanie suwaka z numerami kroków może ułatwić szybkie poruszanie się po instrukcjach. W tym celu użyjemy elementu <mwc-slider>.
mwc-slider
Komponent suwaka wymaga kilku ważnych danych, takich jak minimalna i maksymalna wartość suwaka. Minimalna wartość suwaka może zawsze wynosić „1”. Maksymalna wartość suwaka powinna wynosić this._numConstructionSteps, jeśli model został wczytany. <mwc-slider> te wartości na podstawie atrybutów. Możemy też użyć ifDefined dyrektywy lit-html, aby uniknąć ustawiania atrybutu max, jeśli właściwość _numConstructionSteps nie została zdefiniowana.
Dodaj znak <mwc-slider> między przyciskami „Wstecz” i „Dalej”:
export class BrickViewer extends LitElement {
render() {
return html`
<div id="controls">
<!-- ... backwards button -->
<!-- Add this slider: -->
<mwc-slider
step="1"
pin
markers
min="1"
max=${ifDefined(this._numConstructionSteps)}
></mwc-slider>
<!-- ... forwards button -->
</div>
`;
}
}
Dane „w górę”
Gdy użytkownik przesunie suwak, bieżący etap budowy powinien się zmienić, a widoczność modelu powinna zostać odpowiednio zaktualizowana. Element suwaka będzie emitować zdarzenie input za każdym razem, gdy suwak zostanie przeciągnięty. Dodaj powiązanie zdarzenia na samym suwaku, aby przechwycić to zdarzenie i zmienić krok konstrukcji.
Dodaj powiązanie wydarzenia:
export class BrickViewer extends LitElement {
render() {
return html`
<div id="controls">
<!-- ... -->
<!-- Add the @input event binding: -->
<mwc-slider
...
@input=${(e: CustomEvent) => this.step = e.detail.value}
></mwc-slider>
<!-- ... -->
</div>
`;
}
}
Super! Za pomocą suwaka możemy zmienić wyświetlany krok.
Dane „w dół”
Jest jeszcze jedna sprawa. Gdy do zmiany kroku używane są przyciski „Wstecz” i „Dalej”, należy zaktualizować uchwyt suwaka. Powiąż atrybut wartości <mwc-slider> z atrybutem this.step.
Dodaj powiązanie value:
export class BrickViewer extends LitElement {
render() {
return html`
<div id="controls">
<!-- ... -->
<!-- Add the value property binding: -->
<mwc-slider
...
value=${ifDefined(this.step)}
></mwc-slider>
<!-- ... -->
</div>
`;
}
}
Suwak jest już prawie gotowy. Dodaj styl elastyczny, aby dobrze współpracował z innymi elementami sterującymi:
export class BrickViewer extends LitElement {
static styles = css`
/* ... */
mwc-slider {
flex-grow: 1;
}
`;
}
Musimy też wywołać metodę layout w samym elemencie suwaka. Zrobimy to w metodzie cyklu życia firstUpdated, która jest wywoływana po pierwszym ułożeniu DOM. Dekorator query może pomóc nam uzyskać odniesienie do elementu suwaka w szablonie.
export class BrickViewer extends LitElement {
@query('mwc-slider')
slider!: Slider|null;
async firstUpdated() {
if (this.slider) {
await this.slider.updateComplete
this.slider.layout();
}
}
}
Oto wszystkie dodatki do suwaka (z dodatkowymi atrybutami pin i markers, które sprawiają, że suwak wygląda atrakcyjnie):
export class BrickViewer extends LitElement {
@query('mwc-slider')
slider!: Slider|null;
static styles = css`
/* ... */
mwc-slider {
flex-grow: 1;
}
`;
async firstUpdated() {
if (this.slider) {
await this.slider.updateComplete
this.slider.layout();
}
}
render() {
return html`
${this._renderer.domElement}
<div id="controls">
<mwc-icon-button @click=${this._restart} icon="replay"></mwc-icon-button>
<mwc-icon-button @click=${this._stepBack} icon="navigate_before"></mwc-icon-button>
<mwc-slider
step="1"
pin
markers
min="1"
max=${ifDefined(this._numConstructionSteps)}
?disabled=${this._numConstructionSteps === undefined}
value=${ifDefined(this.step)}
@input=${(e: CustomEvent) => this.constructionStep = e.detail.value}
></mwc-slider>
<mwc-icon-button @click=${this._stepForward} icon="navigate_next"></mwc-icon-button>
<mwc-icon-button @click=${this._resetCamera} icon="center_focus_strong"></mwc-icon-button>
</div>
`;
}
}
Oto gotowy produkt.

7. Podsumowanie
Dowiedzieliśmy się wiele o tym, jak używać lit-element do tworzenia własnych elementów HTML. Dowiedzieliśmy się, jak:
- Definiowanie elementu niestandardowego
- Deklarowanie interfejsu API atrybutu
- Renderowanie widoku elementu niestandardowego
- Hermetyzacja stylów
- Przekazywanie danych za pomocą zdarzeń i właściwości
Jeśli chcesz dowiedzieć się więcej o lit-element, odwiedź jego oficjalną stronę.
Gotowy element brick-viewer możesz zobaczyć na stronie stackblitz.com/edit/brick-viewer-complete.
brick-viewer jest też dostępny w NPM. Kod źródłowy możesz wyświetlić w repozytorium GitHub.