StencilJS es un nuevo framework, de los creadores de Ionic, que facilita la creación de Web Components nativos, es decir, componentes que son directamente soportados por el navegador, sin necesidad de frameworks como Angular o React. Una de las ventajas de StencilJS es justamente que los componentes creados con este framework se pueden usar desde cualquier otro framework. ¿No me crees?

Don’t worry, para eso estoy aquí. Voy a crear un par de componentes en StencilJS y luego te mostraré lo fácil que es usarlos desde Angular.

El objetivo

Voy a crear 2 componentes en StencilJS: Una barra de progreso y un slider. Luego, te mostraré cómo usar esos componentes StencilJS desde Angular.

¿Quieres ver el resultado final?

Ahí va

stencilJS components in action

NOTA: Este artículo está detallado paso a paso y la idea es que lo sigas a modo de tutorial, hands on code.

Como instalar StencilJS

Lo primero es lo primero. Empezar a usar StencilJS es muy simple, tal y como indican en su página. Sólo tienes que bajarte un repositorio e instalar sus dependencias.

git clone https://github.com/ionic-team/stencil-app-starter my-app
cd my-app
git remote rm origin
npm install

A partir de ahí, tienes un proyecto llamado my-app, con lo básico para trabajar con StencilJS.

Lo más relevante de la estructura de la carpeta generada es esto:

estructura del proyecto

src/
   components/
       my-name/
           my-name.scss
           my-name.spec.ts
           my-name.tsx
   index.html
package.json
stencil.config.js              

Básicamente, el proyecto tiene un index.html que es el punto de entrada web: carga el JS que se generará durante el proceso de build y tiene un <body> que incluye un único Web Component (creado con Stencil) que se llama <my-name>.

index.html

<!DOCTYPE html>
<html dir="ltr" lang="en">

<head>
  <!-- ...metas, manifest and more stuff... -->
  <script src="/build/app.js"></script>
</head>

<body>

  <my-name first="Stencil" last="JS"></my-name>

</body>
</html>

Como puedes imaginar, este componente <my-name> es el que tienes en el directorio components.

Estructura de un componente StencilJS

Un componente en Stencil se compone de tres elementos, como podías ver en el caso de my-name.

   components/
       my-name/
           my-name.scss -> hoja de estilos SaSS del componente
           my-name.spec.ts -> tests del componente
           my-name.tsx -> Archivo TSX con la implementación del componente

Antes de nada, decir que TSX es la versión Typescript de JSX (un Domain Specific Language donde se mezcla Javascript y HTML) muy popularizado con React.

Voy a centrarme en el archivo TSX. El componente autogenerado tiene esta forma:

my-name.tsx

import { Component, Prop } from '@stencil/core';


@Component({
  tag: 'my-name',
  styleUrl: 'my-name.scss'
})
export class MyName {

  @Prop() first: string;

  @Prop() last: string;

  render() {
    return (
      <div>
        Hello, my name is {this.first} {this.last}
      </div>
    );
  }
} 

Como ves, el componente es una clase decorada con un decorador @Component que recuerda mucho al de Angular.

En su interior, define 2 propiedades (first y last) identificadas con el decorador @Prop.

Además, consta del método render(), que de forma muy similar a React, devuelve el HTML del componente, entremezclado con la evaluación de variables gracias a las llaves, como en {this.first}.

Cómo ejecutar StencilJS

El proyecto está preparado para la compilación en Web Components estándar y la ejecución de un servidor de desarrollo para probar el resultado. Para lanzar ambos procesos, solo tienes que ejecutar en terminal:

npm start

Esto básicamente genera la carpeta www/build, dentro de la cual incluye los archivos javascript generados al compilar los componentes así como las dependencias necesarias, y lanza un servidor que carga el index.html inicial.

Este index.html justamente incluye el script www/build/app.js, por lo que carga todo el JS necesario para reconocer y usar el componente que tiene definido en su interior.

El resultado de ejecutar el servidor de desarrollo es el siguiente.

stencilJS starter

Crear una barra de progreso con StencilJS

Mi idea es crear un componente progress-bar que luego pueda utilizar de la siguiente forma:

<progress-bar progress="numberBetween0and100"></progress-bar>

Para crear un nuevo componente en StencilJS, solo tienes que crear una carpeta dentro de src/components con los archivos correspondientes. En este caso, creo la carpeta progress-bar con 2 archivos:

src/
  components/
      progress-bar/
          progress-bar.scss
          progress-bar.tsx

¿Como hago una barra de progreso?

Un componente de tipo barra de progreso, se puede crear con algo tan simple como un DIV (DIVinterno) dentro de otro DIV (DIVexterno). Siguiendo esta idea:

  • El DIVexterno tendrá un width fijo y un borde para que se vea bien.
  • El DIVinterno en cambio necesitará un color que lo diferencie, y su width podrá variar, del 0% al 100% del ancho del padre.

Es decir, el porcentaje del ancho del DIVinterno, indica el porcentaje de progreso de la barra.

1 – Estructura básica

progress-bar.tsx

import { Component, Prop} from '@stencil/core';


@Component({
  tag: 'progress-bar',
  styleUrl: 'progress-bar.scss'
})
export class ProgressBar {

  @Prop() progress: number;

  render() {
    return (
      <div></div>
    );
  }
}

Como puedes ver, he definido un componente con:

  • la etiqueta progress-bar (esto es equivalente al selector de Angular, es decir, el nombre del elemento desde el lado HTML)
  • La propiedad progress, de tipo number
  • Que simplemente renderiza un DIV. Éste sería el DIVinterno.

El propio componente progress-bar actuará como DIVexterno, lo verás enseguida con la hoja de estilos.

2 – Estilos

progress-bar.scss

progress-bar {
  display: block;
  height: 20px;
  width: 100%;
  border: 1px solid black;
  background-color: white;

  div{
     background-color: #333;
     height: 100%;
     transition: width .3s;
  }
}

Como ves en los estilos, la barra de progreso tiene un cierto borde y fondo blanco, mientras que el div que se encuentra en su interior, tiene un color de fondo distinto y de momento su ancho es del 100% del padre.

3 – Actualizar el ancho de forma dinámica

Lo primero que quiero hacer ahora, es crear un método para actualizar el ancho del DIVinterno en función del valor de la propiedad progress.

Para eso necesito coger una referencia al propio componente. Lo puedo hacer con el decorador @Element.

progress-bar.tsx

import { Component, Prop, Element} from '@stencil/core';

//...
export class ProgressBar {
  //...

  @Element() bar: HTMLElement;

  private updateWidth(){
     (this.bar.children[0] as HTMLElement).style.width = this.progress + '%';
  }

  //...
}

4 – Carga del componente y detección de cambios

El método updateWidth() que acabo de crear se llamará al cargar el componente (callback componentDidLoad) y al actualizar la propiedad progress (decorador @PropDidChange). Veamos:

progress-bar.tsx

//...
export class ProgressBar {
  //...

  componentDidLoad(){
    this.updateWidth();
  }

  @PropDidChange('progress')
  didChangeHandler() {
    this.updateWidth();
  }  

  //...
}

5 – Eventos de salida

Ya has visto como gestionar cambios en propiedades de entrada, sería muy similar a los inputs de Angular. Te faltaría ver el caso contrario, los outputs de salida del componente.

Para ilustrarlo, voy a hacer que el componente lance un evento cuando se llegue al 100% del progreso. Para eso, tengo que usar la clase EventEmitter y el decorador Event de Stencil.

progress-bar.tsx

import { Component, Prop, PropDidChange, Element, Event, EventEmitter } from '@stencil/core';
//...
export class ProgressBar {
  //...

  @Event() progressDone: EventEmitter;

  private updateWidth(){
     //...
      if(this.progress == 100)
        this.progressDoneEmitter();
  }

  progressDoneEmitter() {
     this.progressDone.emit();
  }  

  //...
}

Como ves, la generación de eventos de salida en Stencil es análoga a la de Angular.

6 – Código definitivo de la barra de progreso

A continuación te copio el código completo por si te has perdido en algún punto:

progress-bar.tsx

import { Component, Prop, PropDidChange, Element, Event, EventEmitter } from '@stencil/core';


@Component({
  tag: 'progress-bar',
  styleUrl: 'progress-bar.scss'
})
export class ProgressBar {

  @Prop() progress: number;
  @Element() bar: HTMLElement;
  @Event() progressDone: EventEmitter;

  //update the progress bar with the initial value
  componentDidLoad(){
    this.updateWidth();
  }

  //update the progress bar on every property change
  @PropDidChange('progress')
  didChangeHandler() {
    this.updateWidth();
  }

  //internal method to update progress bar width
   private updateWidth(){
     (this.bar.children[0] as HTMLElement).style.width = this.progress + '%';
      if(this.progress == 100)
        this.progressDoneEmitter();
   }

  //call event
   progressDoneEmitter() {
     this.progressDone.emit();
   }

  //render method  
  render() {
    return (
      <div></div>
    );
  }
}

7 – Resultados

Si vas a index.html y añades la barra de progreso dentro del <body>, con un progreso del 30% por ejemplo

<!-- .. some stuff... -->
<body>
   <progress-bar progress="30"></progress-bar>
</body>

Y lanzas el servidor de desarrollo con npm start, podrás ver efectivamente la barra de progreso apareciendo en el navegador.

stencilJS progress bar

Más que eso. Si abres el inspector y actualizas el valor del atributo progress, verás como el progreso de la barra se actualiza de forma correspondiente.

¡Felicidades, has creado tu primer componente en StencilJS.!

Crear un componente de slider con StencilJS

Has visto como crear una barra de progreso. Ahora crearé un slider para que veas como se actualiza la progress-bar de forma dinámica sin tener que toquetear el inspector del navegador.

Mi componente de slider va a tener la forma siguiente:

<my-slider min="minValue" max="maxValue" value="initialValue"></my-slider>

La idea es que el extremo derecho del slider se corresponda con el valor minValue y el extremo izquierdo con el valor maxValue. Por otro lado, con initialValue le daremos un valor inicial a la posición del slider.

DETALLE: fíjate como en lugar de llamarlo slider, he decidido llamarlo my-slider. Los Web Components nativos tienen que tener un guión en su nombre, es uno de los requisitos de la Custom Elements API v1.

1 – Estructura del componente

Lo primero es crear la estructura del nuevo componente. De nuevo, lo haré dentro de la carpeta components del proyecto:

src/
  components/
      slider/
          slider.scss
          slider.tsx

Un slider se puede crear a partir de un input HTML de tipo range. El esqueleto de mi componente tendría esta forma:

import { Component, Prop } from '@stencil/core';

@Component({
  tag: 'my-slider',
  styleUrl: 'slider.scss'
})
export class SliderComponent {

  @Prop() min: number;
  @Prop() max: number;
  @Prop() value: number;

  render() {
    return (
      <div class="slider-container">
        <input type="range" min={this.min} max={this.max} value={this.value} class="slider">
        </input>
      </div>
    );
  }
}

Como ves, defino 3 propiedades, min, max y value, que son las que utilizo dentro de la función render() para inicializar los atributos del input de tipo range.

Esto de momento me genera un slider feo de cojones, pero funcional. El problema es que yo no solo quiero mover el slider, sino enterarme de cómo se ha movido. ¿Te acuerdas de los EventEmitters?

Correcto, los necesitamos de nuevo.

2 – Emitiendo eventos al cambiar la posición de slide

Voy a crear un EventEmitter llamado valueChanged, y en el momento en que detecte un cambio en el input, emitiré el evento correspondiente (nada especial, solo tienes que usar la API onChange del input HTML).

La implementación final del slider es la siguiente:

slider.tsx

import { Component, Prop, Event, EventEmitter } from '@stencil/core';

@Component({
  tag: 'my-slider',
  styleUrl: 'slider.scss'
})
export class SliderComponent {

  @Prop() min: number;
  @Prop() max: number;
  @Prop() value: number;

  @Event() valueChanged: EventEmitter;

  valueChangedHandler(event: any) {
    this.valueChanged.emit(event.target.value);
  }

  render() {
    return (
      <div class="slider-container">
        <input type="range" min={this.min} max={this.max} value={this.value} class="slider" 
               onChange={(event) => this.valueChangedHandler(event)}>
        </input>
      </div>
    );
  }
}

Evidentemente podría sofisticar el código para prevenir un value inicial fuera del rango y cosas similares. Lo dejo simple para no complicar el ejemplo.

3 – Dándole estilo al slider

Antes he dicho que mi slider muy bonito no era. Para eso tienes las hojas de estilo.

En este caso te interesa tocar el estilo del propio input, así como de sus pseudoelementos ::-webkit-slider-thumb y ::-moz-range-thumb, que son los que se aplican a la «bolita» del slider. No entraré en detalle porque todo es cuestión de gustos, pero si quieres tener un resultado como el mío, los estilos que aplico son estos:

my-slider {

    .slider-container {
      width: 100%;

      .slider {
        -webkit-appearance: none;
        appearance: none;   
        width: 100%;
        height: 20px;
        background: lighten(#333, 60%);
        outline: none;
        opacity: 0.8;
        transition: opacity .2s;

        &:hover {
          opacity: 1;
        }

        &::-webkit-slider-thumb {
          -webkit-appearance: none;
          width: 20px;
          height: 20px;
          background: #333;
          cursor: pointer;
        }

        &::-moz-range-thumb {
          width: 20px;
          height: 20px;
          background: #333;
          cursor: pointer;
        }
      }
    }
  }

4 – Resultados

Abre ahora tu index.html y añade este elemento a la etiqueta <body>

<!-- .. some stuff... -->
<body>
   <my-slider min="0" max="100" value="30"></my-slider>
</body>

El resultado que deberías obtener al lanzar el servidor de desarrollo es el siguiente:

stencilJS slider

Como usar los componentes de StencilJS

En este ejemplo, el objetivo es detectar el valor al mover el slider y asignarle ese valor al atributo progress de la barra de progreso.

A diferencia de Angular, donde todo sucede dentro de un componente raíz y por tanto toda tu lógica tiene que estar dentro del contexto de Angular, en el caso de StencilJS esto no es necesario.

StencilJS es una librería para generar Web Components HTML estándar, y esos componentes se pueden usar libremente en cualquier archivo HTML (para navegadores que no implementan aún los Custom Elements, StencilJS incorpora un polyfill).

Por supuesto, podría crear un componente Stencil dentro del cual meter tanto el slider como la barra de progreso y la lógica entre ambos, pero es importante entender que no tienes por que hacerlo así.

Recuerda que puedes usar los componentes de Stencil desde otros frameworks sin problemas o incluso sin framework alguno.

StencilJS desde Vanilla Javascript

Antes de nada, déjame recordarte como vincular estos dos elementos sin ningún framework de por medio.

index.html

<!DOCTYPE html>
<html dir="ltr" lang="en">

<head>
  <!-- ... some stuff ...-->
</head>

<body>
  <progress-bar progress="30"></progress-bar>
  <my-slider min="0" max="100" value="30"></my-slider>
</body>

 <script>
  let progressBar = document.querySelector('progress-bar');
  let slider = document.querySelector('my-slider');

  progressBar.addEventListener("progressDone", ()=>{console.log("progress done!!");});
  slider.addEventListener("valueChanged",(event)=>{
    progressBar.setAttribute("progress", event.detail);
  });
</script>
</html>

Básicamente necesito obtener las referencias a ambos elementos del DOM, para poder escuchar a los eventos que me interesan de esos elementos. En este caso, progressDone de la barra de progreso y valueChanged del slider.

Además, modifico el atributo progress de la barra de progreso en función del valor detectado en el evento del slider.

¿El resultado? Lo que veías al principio del artículo.

stencilJS components in action

Integración con Angular

Quiero utilizar estos componentes creados con StencilJS desde Angular, pero no puedo usar directamente los archivos TSX, Angular no los entendería.

Lo que necesito es obtener el código compilado con los Web Components HTML nativos, junto con los polyfills de StencilJS.

1 – Exportar los componentes nativos

Para eso, edito el archivo stencil.config.js, donde se definen los componentes que quiero exportar y el directorio donde exportarlos.

stencil.config.js

exports.config = {
  bundles: [
    { components: ['progress-bar'] },
    { components: ['my-slider'] }
  ],
  buildDir: 'stencil-components/build',
  collections: [ ]
};

exports.devServer = {
  root: 'www',
  watchGlob: '**/**'
}

En mi caso, exporto los componentes progress-bar y my-slider, y lo hago al directorio stencil-components/build que se generará dentro de la carpeta www del proyecto.

Ahora solo tengo que hacer un build del proyecto para exportar los archivos necesarios:

npm run build

2 – Importar las dependencias en el proyecto Angular

Tengo que meter esos componentes exportados en mi proyecto Angular.

Un buen sitio para hacerlo es la carpeta src/assets que suelen tener los proyectos Angular (por defecto en los proyectos creados con @angular/cli). Así que copio la carpeta stencil-components del directorio www del proyecto Stencil, y la pego en el directorio src/assets del proyecto Angular.

Ahora que tengo los archivos, solo tengo que referenciarlos. La forma más simple, es añadiendo un script tag directamente en mi archivo index.html:

index.html

<script src="assets/stencil-components/build/app.js"></script>

3 – Habilitar Custom components en Angular

Angular por defecto solo entiende elementos HTML estándar y componentes Angular que pertenezcan a sus módulos. Para usar los nuevos Web Components, hay que indicárselo con los metadatos del root module mediante el schema CUSTOM_ELEMENTS_SCHEMA. Así:

app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';

import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule
  ],
  providers: [],
  schemas: [
    CUSTOM_ELEMENTS_SCHEMA
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

Con esto has hecho ya lo más complicado. Solo te queda empezar a usar esos componentes Stencil desde Angular.

4 – ¡Usar los componentes desde Angular!

Voy a modificar el componente principal del proyecto Angular para que muestre ambos componentes y gestione su interacción.

El lado Typescript va a quedar como sigue:

src/app/app.component.ts

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  progress:number = 0;

  updateProgress(progress){
    this.progress = progress;
  }

  progressCompleted(){
    console.log("progress completed");
  }
}

Es decir, mi componente tiene una interfaz muy simple:

  • progress: Una propiedad que va a guardar el progreso (previsiblemente entre 0 y 100)
  • updateProgress(progress): El método para actualizar la propiedad de progreso
  • progressCompleted(): Un método que logueará por consola que el progreso ha completado.

Ahora solo falta meter los componentes en el template y realizar los bindings correspondientes. Veamos:

app.component.html

<h1>
  Using stencil from Angular!!
</h1>

<progress-bar [progress]="progress" (progressDone)="progressCompleted()">
</progress-bar>

<my-slider min="0" max="100" [value]="progress" (valueChanged)="updateProgress($event.detail)">
</my-slider>

Como puedes ver, se usan como cualquier otro componente. Ahí donde quiero hacer un binding de entrada, utilizo los corchetes, como en [progress]="progress".

Donde quiero realizar en cambio un event binding, uso en cambio paréntesis, como en (progressDone)="progressCompleted()".

Así de fácil y de simple.

Conclusiones

Como he comentado en otros artículos, StencilJS es la tecnología que va a adoptar Ionic a partir de su versión 4. Esta decisión levantó cierta inquietud dentro de la comunidad de desarrolladores de Ionic, preocupados de tener que tirar a la basura todo su aprendizaje previo en Angular.

En su momento ya adelanté que no había de qué preocuparse, puesto que Stencil iba a ser compatible no solo con Angular, sino de hecho con cualquier otro framework. Pero entiendo que es difícil despejar esas dudas sin entrar en profundidad en el tema.

Ahora si, espero que este tutorial te haya ayudado a comprender qué es realmente StencilJS y qué lugar ocupa dentro del ecosistema de frameworks JS. Y sobretodo, a desterrar el miedo de que genere una nueva curva de aprendizaje en Ionic.

Si te ha gustado este artículo, compártelo 😉