1. Introducción
Componentes web
Los componentes web son una colección de estándares web que permiten a los desarrolladores extender HTML con elementos personalizados. En este codelab, definirás el elemento <brick-viewer>, que podrá mostrar modelos de ladrillos.
lit-element
Para ayudarnos a definir nuestro elemento personalizado <brick-viewer>, usaremos lit-element. lit-element es una clase base ligera que agrega un poco de azúcar sintáctica al estándar de componentes web. Esto nos facilitará la puesta en marcha de nuestro elemento personalizado.
Comenzar
Codificaremos en un entorno en línea de Stackblitz, así que abre este vínculo en una ventana nueva:
stackblitz.com/edit/brick-viewer
¡Comencemos!
2. Define un elemento personalizado
Definición de clase
Para definir un elemento personalizado, crea una clase que extienda LitElement y decórala con @customElement. El argumento para @customElement será el nombre del elemento personalizado.
En brick-viewer.ts, coloca lo siguiente:
@customElement('brick-viewer')
export class BrickViewer extends LitElement {
}
Ahora, el elemento <brick-viewer></brick-viewer> está listo para usarse en HTML. Sin embargo, si lo intentas, no se renderizará nada. Sin embargo, podemos solucionarlo.
Método de renderización
Para implementar la vista del componente, define un método llamado render. Este método debe mostrar un literal de plantilla etiquetado con la función html. Coloca el HTML que desees en el literal de plantilla etiquetado. Esto se renderizará cuando uses <brick-viewer>.
Agrega el método render:
export class BrickViewer extends LitElement {
render() {
return html`<div>Brick viewer</div>`;
}
}
3. Cómo especificar el archivo LDraw
Define una propiedad
Sería genial si un usuario de <brick-viewer> pudiera especificar qué modelo de ladrillo mostrar con un atributo, como este:
<brick-viewer src="path/to/model.ldraw"></brick-viewer>
Como estamos compilando un elemento HTML, podemos aprovechar la API declarativa y definir un atributo de origen, al igual que una etiqueta <img> o <video>. Con lit-element, es tan fácil como decorar una propiedad de clase con @property. La opción type te permite especificar cómo lit-element analiza la propiedad para usarla como un atributo HTML.
Define la propiedad y el atributo src:
export class BrickViewer extends LitElement {
@property({type: String})
src: string|null = null;
}
<brick-viewer> ahora tiene un atributo src que podemos configurar en HTML. Su valor ya es legible desde nuestra clase BrickViewer gracias a lit-element.
Cómo mostrar valores
Podemos mostrar el valor del atributo src usándolo en el literal de plantilla del método de renderización. Interpola valores en literales de plantilla con la sintaxis ${value}.
export class BrickViewer extends LitElement {
render() {
return html`<div>Brick model: ${this.src}</div>`;
}
}
Ahora, vemos el valor del atributo src en el elemento <brick-viewer> de la ventana. Intenta lo siguiente: abre las herramientas para desarrolladores de tu navegador y cambia manualmente el atributo src. Adelante, pruébalo.
¿Notaste que el texto del elemento se actualiza automáticamente? lit-element observa las propiedades de clase decoradas con @property y vuelve a renderizar la vista por ti. lit-element hace el trabajo pesado para que no tengas que hacerlo.
4. Configura la escena con Three.js
Luces, cámara y renderización
Nuestro elemento personalizado usará three.js para renderizar nuestros modelos de ladrillos 3D. Hay algunas cosas que queremos hacer solo una vez para cada instancia de un elemento <brick-viewer>, como configurar la escena, la cámara y la iluminación de three.js. Los agregaremos al constructor de la clase BrickViewer. Conservaremos algunos objetos como propiedades de clase para poder usarlos más adelante: cámara, escena, controles y renderizador.
Agrega la configuración de la escena de 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);
};
}
El objeto WebGLRenderer proporciona un elemento DOM que muestra la escena renderizada de three.js. Se accede a él a través de la propiedad domElement. Podemos interpolar este valor en el literal de plantilla de renderización con la sintaxis ${value}.
Quita el mensaje src que teníamos en la plantilla y, luego, inserta el elemento DOM del renderizador:
export class BrickViewer extends LitElement {
render() {
return html`
${this._renderer.domElement}
`;
}
}
Para permitir que el elemento DOM del renderizador se muestre en su totalidad, también debemos configurar el <brick-viewer> elemento en display: block. Podemos proporcionar estilos en una propiedad estática llamada styles, establecida en un literal de plantilla css.
Agrega este estilo a la clase:
export class BrickViewer extends LitElement {
static styles = css`
/* The :host selector styles the brick-viewer itself! */
:host {
display: block;
}
`;
}
Ahora, <brick-viewer> muestra una escena renderizada de three.js:

Pero está vacío. Proporcionémosle un modelo.
Cargador de ladrillos
Pasaremos la propiedad src que definimos antes a LDrawLoader, que se envía con three.js.
Los archivos LDraw pueden separar un modelo de ladrillo en pasos de compilación separados. Se puede acceder a la cantidad total de pasos y a la visibilidad de los ladrillos individuales a través de la API de LDrawLoader.
Copia estas propiedades, el nuevo método _loadModel y la línea nueva en el constructor:
@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();
});
}
}
¿Cuándo se debe llamar a _loadModel? Debe invocarse cada vez que cambie el atributo src. Al decorar la propiedad src con @property, la habilitamos en el ciclo de vida de actualización de lit-element. Cada vez que cambia el valor de una de estas propiedades decoradas, se llama a una serie de métodos que pueden acceder a los valores nuevos y antiguos de las propiedades. El método de ciclo de vida que nos interesa se llama update. El método update toma un argumento PropertyValues, que contendrá información sobre las propiedades que acaban de cambiar. Este es el lugar perfecto para llamar a _loadModel.
Agrega el método update:
export class BrickViewer extends LitElement {
update(changedProperties: PropertyValues) {
if (changedProperties.has('src')) {
this._loadModel();
}
super.update(changedProperties);
}
}
Nuestro elemento <brick-viewer> ahora puede mostrar un archivo de ladrillo, especificado con el atributo src.

5. Cómo mostrar modelos parciales
Ahora, hagamos que el paso de construcción actual sea configurable. Nos gustaría poder especificar <brick-viewer step="5"></brick-viewer> y ver cómo se ve el modelo de ladrillo en el quinto paso de construcción. Para ello, hagamos que la propiedad step sea una propiedad observada decorándola con @property.
Decora la propiedad step:
export class BrickViewer extends LitElement {
@property({type: Number})
step?: number;
}
Ahora, agregaremos un método auxiliar que solo hace visibles los ladrillos hasta el paso de compilación actual. Llamaremos al auxiliar en el método de actualización para que se ejecute cada vez que se cambie la propiedad step.
Actualiza el método update y agrega el nuevo método _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);
}
}
Bien, ahora abre las herramientas para desarrolladores de tu navegador y, luego, inspecciona el elemento <brick-viewer>. Agrega un atributo step, como este:

Observa qué sucede con el modelo renderizado. Podemos usar el atributo step para controlar cuánto se muestra del modelo. Así debería verse cuando el atributo step se establece en "10":

6. Navegación por el conjunto de ladrillos
mwc-icon-button
El usuario final de nuestro <brick-viewer> también debería poder navegar por los pasos de compilación a través de la IU. Agreguemos botones para ir al siguiente paso, al paso anterior y al primer paso. Usaremos el componente web de botón de Material Design para facilitar el proceso. Como @material/mwc-icon-button ya está importado, estamos listos para colocar <mwc-icon-button></mwc-icon-button>. Podemos especificar el ícono que nos gustaría usar con el atributo de ícono, como este: <mwc-icon-button icon="thumb_up"></mwc-icon-button>. Puedes encontrar todos los íconos posibles aquí: material.io/resources/icons.
Agreguemos algunos botones de íconos al método de renderización:
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>
`;
}
}
Usar Material Design en nuestra página es así de fácil, gracias a los componentes web.
Vinculaciones de eventos
Estos botones deberían hacer algo. El botón "reply" debe restablecer el paso de construcción a 1. El botón "navigate_before" debe disminuir el paso de construcción, y el botón "navigate_next" debe aumentarlo. lit-element facilita la adición de esta funcionalidad con vinculaciones de eventos. En tu literal de plantilla HTML, usa la sintaxis @eventname=${eventHandler} como un atributo de elemento. eventHandler ahora se ejecutará cuando se detecte un evento eventname en ese elemento. Como ejemplo, agreguemos controladores de eventos de clic a nuestros tres botones de íconos:
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>
`;
}
}
Intenta hacer clic en los botones ahora. ¡Buen trabajo!
Estilos
Los botones funcionan, pero no se ven bien. Están todos agrupados en la parte inferior. Démosles un estilo para superponerlos en la escena.
Para aplicar estilos a estos botones, volvemos a la propiedad static styles. Estos estilos tienen un alcance limitado, lo que significa que solo se aplicarán a los elementos dentro de este componente web. Esa es una de las ventajas de escribir componentes web: los selectores pueden ser más simples, y CSS será más fácil de leer y escribir. Adiós, BEM.
Actualiza los estilos para que se vean así:
export class BrickViewer extends LitElement {
static styles = css`
:host {
display: block;
position: relative;
}
#controls {
position: absolute;
bottom: 0;
width: 100%;
display: flex;
}
`;
}

Botón de restablecimiento de la cámara
Los usuarios finales de nuestro <brick-viewer> pueden rotar la escena con los controles del mouse. Mientras agregamos botones, agreguemos uno para restablecer la cámara a su posición predeterminada. Otro <mwc-icon-button> con una vinculación de eventos de clic hará el trabajo.
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>
`;
}
}
Navegación más veloz
Algunos conjuntos de ladrillos tienen muchos pasos. Es posible que un usuario quiera omitir un paso específico. Agregar un control deslizante con números de pasos puede ayudar con la navegación rápida. Usaremos el elemento <mwc-slider> para esto.
mwc-slider
El elemento deslizante necesita algunos datos importantes, como el valor mínimo y máximo del control deslizante. El valor mínimo del control deslizante siempre puede ser "1". El valor máximo del control deslizante debe ser this._numConstructionSteps, si se cargó el modelo. Podemos indicarle estos valores a <mwc-slider> a través de sus atributos. También podemos usar la directiva ifDefined de lit-html para evitar configurar el atributo max si no se definió la propiedad _numConstructionSteps.
Agrega un <mwc-slider> entre los botones "back" y "forward":
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>
`;
}
}
Datos "arriba"
Cuando un usuario mueve el control deslizante, debe cambiar el paso de construcción actual y la visibilidad del modelo debe actualizarse en consecuencia. El elemento deslizante emitirá un evento de entrada cada vez que se arrastre el control deslizante. Agrega una vinculación de eventos en el control deslizante para detectar este evento y cambiar el paso de construcción.
Agrega la vinculación de eventos:
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>
`;
}
}
¡Sí! Podemos usar el control deslizante para cambiar el paso que se muestra.
Datos "abajo"
Hay una cosa más. Cuando se usan los botones "back" y "next" para cambiar el paso, se debe actualizar el controlador deslizante. Vincula el atributo de valor de <mwc-slider> a this.step.
Agrega la vinculación 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>
`;
}
}
Ya casi terminamos con el control deslizante. Agrega un estilo flexible para que funcione bien con los otros controles:
export class BrickViewer extends LitElement {
static styles = css`
/* ... */
mwc-slider {
flex-grow: 1;
}
`;
}
Además, debemos llamar a layout en el elemento deslizante. Lo haremos en el método de ciclo de vida firstUpdated, que se llama una vez que se diseña el DOM por primera vez. El decorador query puede ayudarnos a obtener una referencia al elemento deslizante en la plantilla.
export class BrickViewer extends LitElement {
@query('mwc-slider')
slider!: Slider|null;
async firstUpdated() {
if (this.slider) {
await this.slider.updateComplete
this.slider.layout();
}
}
}
Aquí se muestran todas las adiciones del control deslizante (con atributos pin y markers adicionales en el control deslizante para que se vea genial):
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>
`;
}
}
Este es el producto final.

7. Conclusión
Aprendimos mucho sobre cómo usar lit-element para compilar nuestro propio elemento HTML. Aprendimos a hacer lo siguiente:
- Definir un elemento personalizado
- Declarar una API de atributos
- Renderizar una vista para un elemento personalizado
- Encapsular estilos
- Usar eventos y propiedades para pasar datos
Si deseas obtener más información sobre lit-element, puedes leer más en su sitio oficial.
Puedes ver un elemento brick-viewer completo en stackblitz.com/edit/brick-viewer-complete.
brick-viewer también se envía en NPM, y puedes ver la fuente aquí: repositorio de GitHub.