Lição 7 - Interface web
Nesta lição, você vai aprender o seguinte:
- Desenvolver interface web com Lit e Shoelace
- Implementar um componente de tela
- Implementar um componente de barra de zoom
Interface web com Lit e Shoelace
Ao escolher uma biblioteca de componentes, eu não queria que ela ficasse presa à implementação de um framework específico; veja Web Components are not Framework Components — and That’s Okay. Web components são uma boa escolha, e o Lit oferece estado reativo, estilos com escopo e um sistema de templates declarativo para facilitar o processo de desenvolvimento. O Shoelace é uma biblioteca de UI baseada em Lit, e Web Awesome é o nome novo e pomposo dela. Com eles, podemos tornar nossos componentes de tela independentes de framework, com suporte a React, Vue e Angular ao mesmo tempo. Vale observar que a biblioteca de componentes spectrum-web-components, usada pelo Photoshop Web, também se baseia em Lit; veja: Photoshop is now on the web!
O Photoshop faz parte do ecossistema mais amplo Creative Cloud da Adobe. Usar uma estratégia padronizada de Web Components construída sobre o Lit permite consistência de UI entre os aplicativos.
Como o site estático deste tutorial é escrito com VitePress, Vue instructions é usado nas páginas de exemplo, mas nenhuma sintaxe de Vue será usada nos componentes.
Em seguida, vamos pegar a funcionalidade simples que já temos e envolvê-la em componentes de UI.
Componente de tela
Um componente de tela é declarado com Lit e, para evitar possíveis conflitos de nome, usamos ic como prefixo do componente:
import { LitElement } from 'lit';
@customElement('ic-canvas')
export class InfiniteCanvas extends LitElement {}Definimos a propriedade renderer com um decorator, para poder usá-la com esta sintaxe: <ic-canvas renderer="webgl" />.
import { property } from 'lit/decorators.js';
export class InfiniteCanvas extends LitElement {
@property()
renderer = 'webgl';
}Queremos que a tela acompanhe a largura e a altura da página, e o Shoelace oferece um Resize Observer pronto para uso, que dispara um evento personalizado sl-resize quando a largura ou a altura do elemento muda. Declaramos o HTML no ciclo de vida render fornecido pelo Lit, e o HTMLCanvasElement real pode ser consultado rapidamente por meio de query:
export class InfiniteCanvas extends LitElement {
@query('canvas', true)
$canvas: HTMLCanvasElement;
render() {
return html`
<sl-resize-observer>
<canvas></canvas>
</sl-resize-observer>
`;
}
}Escuta mudanças de tamanho durante o ciclo de vida connectedCallback e deixa de escutar durante o ciclo de vida disconnectedCallback:
export class InfiniteCanvas extends LitElement {
connectedCallback() {
this.addEventListener('sl-resize', this.resize);
}
disconnectedCallback() {
this.removeEventListener('sl-resize', this.resize);
}
}Ciclo de vida da inicialização da tela
A questão de quando criar a tela tem me incomodado há um tempo. Tentar obter <canvas> no ciclo de vida connectedCallback retornava undefined, porque o CustomElement ainda não tinha sido adicionado ao documento, então naturalmente eu não conseguia consultá-lo pela API do DOM. No fim, descobri que firstUpdated era um bom momento para disparar o evento personalizado ic-ready depois de criar a tela e incluir a instância da tela no objeto de evento, e para disparar o evento personalizado ic-frame a cada tick:
export class InfiniteCanvas extends LitElement {
async firstUpdated() {
this.#canvas = await new Canvas({
canvas: this.$canvas,
renderer: this.renderer as 'webgl' | 'webgpu',
}).initialized;
this.dispatchEvent(
new CustomEvent('ic-ready', { detail: this.#canvas }),
);
const animate = (time?: DOMHighResTimeStamp) => {
this.dispatchEvent(new CustomEvent('ic-frame', { detail: time }));
this.#canvas.render();
this.#rafHandle = window.requestAnimationFrame(animate);
};
animate();
}
}Existe uma solução melhor do que essa gambiarra?
Usar tarefa assíncrona
Para esse tipo de cenário, em que uma tarefa assíncrona é executada e depois renderizada, o React oferece <Suspense>:
<Suspense fallback={<Loading />}>
<SomeComponent />
</Suspense>O Lit também oferece Async Tasks parecidas, para podermos exibir os estados de carregamento e de erro antes de a tarefa assíncrona terminar e quando ela falha. Ao criar uma tarefa assíncrona com Task, você precisa especificar os parâmetros e incluir o <canvas> criado como valor de retorno, para que ele possa ser recuperado e renderizado no hook complete da função de renderização.
private initCanvas = new Task(this, {
task: async ([renderer]) => {
return canvas.getDOM();
},
args: () => [this.renderer as 'webgl' | 'webgpu'] as const,
});
render() {
return this.initCanvas.render({
pending: () => html`<sl-spinner></sl-spinner>`,
complete: ($canvas) => html`
<sl-resize-observer>
${$canvas}
<ic-zoom-toolbar-lesson7 zoom=${this.zoom}></ic-zoom-toolbar-lesson7>
</sl-resize-observer>
`,
error: (e) => html`<sl-alert variant="danger" open>${e}</sl-alert>`,
});
}Por exemplo, no Safari, que não oferece suporte a WebGPU, a mensagem de erro a seguir será exibida:
O WebGPU não é suportado pelo navegador.
Assim, nosso componente de tela está escrito. A natureza independente de framework dos web components permite usá-los de forma consistente. Veja Vue e React, por exemplo:
<template>
<ic-canvas renderer="webgl"></ic-canvas>
</template><div>
<ic-canvas renderer="webgl"></ic-canvas>
</div>Obtenha a instância da tela escutando o evento personalizado ic-ready quando a tela terminar de carregar:
const $canvas = document.querySelector('ic-canvas');
$canvas.addEventListener('ic-ready', (e) => {
const canvas = e.detail;
// 创建场景图
canvas.appendChild(circle);
});Aqui vamos implementar o componente de zoom da câmera.
Componente de zoom
A figura a seguir mostra o componente de zoom do Miro:

Vamos criar primeiro um ic-zoom-toolbar:
@customElement('ic-zoom-toolbar')
export class ZoomToolbar extends LitElement {}Adicione-o ao componente de tela:
<sl-resize-observer>
<canvas></canvas>
<ic-zoom-toolbar zoom="${this.zoom}"></ic-zoom-toolbar> // [!code ++]
</sl-resize-observer>A estrutura interna desse componente é assim, e você pode ver que o Lit usa uma sintaxe de template muito próxima da do Vue:
<sl-button-group label="Zoom toolbar">
<sl-tooltip content="Zoom out">
<sl-icon-button
name="dash-lg"
label="Zoom out"
@click="${this.zoomOut}"
></sl-icon-button>
</sl-tooltip>
<span>${this.zoom}%</span>
<sl-tooltip content="Zoom in">
<sl-icon-button
name="plus-lg"
label="Zoom in"
@click="${this.zoomIn}"
></sl-icon-button>
</sl-tooltip>
</sl-button-group>Para passar a instância da tela do componente de tela para os filhos, usamos Lit Context, que é salvo no contexto depois da instanciação:
const canvasContext = createContext<Canvas>(Symbol('canvas'));
export class InfiniteCanvas extends LitElement {
#provider = new ContextProvider(this, { context: canvasContext });
private initCanvas = new Task(this, {
task: async ([renderer]) => {
// ...省略实例化画布过程
this.#provider.setValue(this.#canvas);
}
});
}Ele pode então ser consumido no componente filho por meio do contexto e, como a criação da tela é assíncrona, é preciso usar subscribe para acompanharmos o contexto em tempo real:
export class ZoomToolbar extends LitElement {
@consume({ context: canvasContext, subscribe: true })
canvas: Canvas;
}Adicione uma função de callback à câmera, disparada toda vez que a câmera ou a matriz de projeção mudar:
export class Camera {
onchange: () => void;
private updateViewProjectionMatrix() {
if (this.onchange) {
this.onchange();
}
}
}A variável reativa zoom é modificada quando o callback é disparado.
this.#canvas.camera.onchange = () => {
this.zoom = Math.round(this.#canvas.camera.zoom * 100);
};Tema
O tema precisa ser aplicado aos componentes de UI e à tela. Vamos começar pelos primeiros.
Componentes de UI
O Shoelace oferece a funcionalidade de Themes, que pode ser alternada com facilidade. Primeiro, importe as folhas de estilo dos dois temas:
import '@shoelace-style/shoelace/dist/themes/light.css';
import '@shoelace-style/shoelace/dist/themes/dark.css';Ao alternar para o tema escuro, basta adicionar uma classe identificadora ao elemento raiz:
this.classList.toggle('sl-theme-dark', this.theme === 'dark');Por fim, ao escrever os estilos do componente, use variáveis CSS em vez de valores fixos, para que sejam aplicadas automaticamente ao alternar os temas:
:host {
background: var(--sl-panel-background-color);
}Mais adiante, na Lição 18 - Refatorar com ECS, substituímos a implementação de Web Components do Shoelace pelo Spectrum, que também oferece a alternância de tema claro/escuro:

Fundo e grade da tela
O fundo e a Grade da tela também precisam estar associados à cor do tema. Com base em Theme colors in DGM.js, oferecemos suporte a passar os valores de cor dos temas claro e escuro ao criar a tela:
enum Theme {
LIGHT,
DARK,
}
interface ThemeColors {
background: string;
grid: string;
}
interface CanvasConfig {
themeColors?: Partial<{
[Theme.LIGHT]: Partial<ThemeColors>;
[Theme.DARK]: Partial<ThemeColors>;
}>;
}Há suporte à troca em tempo de execução:
canvas.theme = Theme.DARK;Não vamos entrar nos detalhes da implementação de UI depois.
Internacionalização
O Lit oferece uma solução de Localization, que pode ser automatizada com as ferramentas certas.
Compilar o template localizado
Primeiro, você precisa envolver as strings que exigem internacionalização usando o método msg. Tomando o menu principal como exemplo:
import { msg, str } from '@lit/localize';
<sp-tooltip slot="tooltip" self-managed placement="bottom">
${msg(str`Main menu`)}
</sp-tooltip>;Em seguida, execute o comando a seguir para extrair as strings de idioma usadas no código-fonte:
lit-localize extractNesse ponto, você obtém uma série de arquivos xlf. Envie esses arquivos xlf a um serviço de tradução (ou traduza-os manualmente, é claro) e você receberá resultados parecidos com os seguintes:
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file target-language="zh-Hans" source-language="en" original="lit-localize-inputs" datatype="plaintext">
<body>
<trans-unit id="sa10f310edb35fa9f">
<source>Main menu</source>
<target>主菜单</target>
</trans-unit>
</body>
</file>
</xliff>Em seguida, execute o comando a seguir para compilar as strings traduzidas em componentes e colocá-los na pasta do código-fonte:
lit-localize buildTrocar o locale
Este site é construído com VitePress. Você pode encontrar um componente de troca de idioma no canto superior direito.
import { localized } from '@lit/localize';
@customElement('ic-spectrum-top-navbar')
@localized()
export class TopNavbar extends LitElement {}Pode ser carregado sob demanda em tempo de execução:
import { configureLocalization } from '@lit/localize';
// Generated via output.localeCodesModule
import { sourceLocale, targetLocales } from './generated/locale-codes.js';
export const { getLocale, setLocale } = configureLocalization({
sourceLocale,
targetLocales,
loadLocale: (locale) => import(`/locales/${locale}.js`),
});Para fins de demonstração, optamos por usar static imports. É claro que essa abordagem carrega todos os arquivos de idioma, o que impacta o tempo até o primeiro conteúdo.
Compor com slot
Mais adiante, na Lição 18 - Refatorar com ECS, implementamos uma barra de ferramentas no lado esquerdo da tela. Se quisermos adicionar uma nova ferramenta à barra de ferramentas, podemos fazer isso com facilidade usando o elemento <slot> dos Web Components.
Em muitos plugins extensíveis (como o Eraser plugin), implementamos um uso parecido:
<ic-spectrum-canvas>
<ic-spectrum-penbar-eraser slot="penbar-item"><ic-spectrum-penbar-eraser />
</ic-spectrum-canvas>Reserve posições expansíveis dentro dos componentes Lit usando <slot>:
export class Penbar extends LitElement {
render() {
return html`<sp-action-group class="penbar">
<slot name="penbar-item"></slot>
</sp-action-group>`;
}
}Vale observar que, ao passar um <slot> pela árvore de componentes, um slot nomeado precisa ser usado no componente pai.
<!-- ic-spectrum-canvas -->
<ic-spectrum-penbar>
<slot name="penbar-item" slot="penbar-item"></slot>
</ic-spectrum-penbar>