Llevo unos días hablando de hot topics y no podía dejar pasar uno de los aportes más excitantes de Angular 2: La FÁCIL integración con Web Workers.
Los Web Workers son una herramienta de HTML5 para ejecutar procesos javascript en paralelo, lo que te permite NO bloquear la interfaz de tu web mientras estás haciendo cálculos pesados, por ponerte un ejemplo.
Leyendo el párrafo anterior dirás… parece una idea genial. ¿Por qué no los conocía? ¿Por qué no son algo realmente común?
La respuesta es fácil. Los web workers viven en un mundo aparte. Se cargan de un archivo JS separado. Se ejecutan en otro hilo sin acceso al DOM y la única forma de comunicarse con ellos es a través del paso de mensajes entre ambos hilos. Si los usaras para cada pequeña tarea de tu código, tendrías que gestionar un sinfín de mensajes entre ambos threads y además de poco elegante, sería un incordio.
Sin embargo, en Angular 2 han hecho fácil lo complicado. Con unos pocos cambios en el proceso de bootstrap, puedes conseguir que TODA tu lógica ya existente pase a ejecutarse en un web worker…
…liberando tu UI…
…sin tocar tu código…
…de forma automática…
…sí, se lo que estás pensando…
Yo me siento igual… no lo voy a negar. Visto desde fuera es pura magia. Si levantas un poco la manta, verás que por debajo hay mucho trabajo duro y muy bien hecho. ¡Felicidades chicos!
Caso de ejemplo: calculador de factoriales
Como siempre digo, todo se vuelve más real cuando lo tocas con tus propias manos, así que te enseñaré un ejemplo en su versión original y en su versión web workers para que puedas ver tú mismo las diferencias.
El cálculo de factoriales es un ejemplo perfecto porque para números grandes se necesitan muchos recursos de CPU. La web que ves en el vídeo se dedica a calcular el factorial de 50 números a partir del valor que le indicas.
Como puedes ver, en el caso single thread la interfaz se bloquea a ratos y el scroll no va fluido, a diferencia de la versión con web workers. ¿Lo mejor? Ambos casos comparten el 99.9% del código. ¿Quieres ver como lo he hecho?
Aplicación de factoriales, single thread
Empiezo con la app tradicional (sin web workers) que calcula factoriales. Así verás lo fácil que es actualizar el código para usar web workers. Te voy a explicar los puntos clave, pero puedes encontrar el código y las instrucciones de instalación en este repositorio: https://github.com/kaikcreator/webWorkerFactorialExample.
Estructura de la app
La app es muy simple, consta principalmente de estos elementos:
- main.ts: Donde se hace el proceso de bootstrap
- app.module.ts: Donde se declara el módulo de la app
- app.component: El componente principal con toda la interfaz
- factorial.service: Un servicio que se encarga de realizar los cálculos factoriales.
- webpack.config.js: El archivo de configuración de Webpack, que es lo que uso para compilar la app. Muy estandar.
Calculando factoriales
Un problema que te encuentras al trabajar con números muy grandes es que puedan ocupar más de 4 bytes. En ese caso, las variables numéricas de Javascript no podrían representarlos. Para solucionarlo, utilizo una librería para trabajar con números de mayor resolución: big.js.
Quitando este detalle, el servicio de factoriales es realmente simple. Consta de un método privado factorialize
que es el que se llama de forma recursiva para calcular el factorial de un número. Su método público factorial
es el que llamo desde el componente principal y me devuelve el factorial ya con un formato específico.
// file: src/app/factorial.service.ts
import { Injectable } from '@angular/core';
let Big = require('big.js');
@Injectable()
export class FactorialService {
constructor() {
// use scientific notation if exponent is greater than or equal to 5
Big.E_POS = 5;
}
private factorialize(n: number) {
if (n === 0 || n === 1) {
return 1;
}else {
let bigNum = new Big(n);
return bigNum.mul(this.factorialize(n - 1));
}
}
public factorial(n: number) {
return this.factorialize(n).toPrecision(5);
}
}
Como ves, lo único destacable es el uso de la librería big.js para multiplicar y manejar los números.
Componente principal: La interfaz
La idea es preguntar al usuario a partir de qué número quiere los 50 factoriales. Entonces, calculo los factoriales, los guardo en el array items
y los muestro por pantalla con un bucle *ngFor
. Mientras dura el cálculo, muestro una barra de progreso que se actualiza en función del número de factoriales ya calculados.
Puedes ver el template -muy simple- a continuación.
<!--file: src/app/app.component.html -->
<div [hidden]="computingFactorials == false" class="progress-bar">
<div [style.width.%]="progress" class="progress"></div>
</div>
<div class="header">
<h1>Factorial calculator</h1>
<label>Compute the next {{numberOfFactorials}} factorials, starting on:</label>
<input [(ngModel)]="firstFactorial" type="number" placeholder="Set the start number">
<button (click)="computeFactorials()">Compute factorials</button>
<button (click)="cleanResults()" [disabled]="computingFactorials == true">Clean results</button>
</div>
<div class="items" *ngFor="let item of items">
{{item}}
</div>
Componente principal: La lógica
Del template anterior, deberías quedarte con las siguientes variables:
computingFactorials
: Indica si estamos realizando cálculos o noprogress
: El porcentaje de progreso de los cálculosnumberOfFactorials
: El número de factoriales a calcular.firstFactorial
: A partir de qué cifra empezaremos a calcular factoriales.computeFactorials
: El método que lanza la ejecución de factoriales.cleanResults
: Básicamente limpia la interfaz.
Te copio la estructura de la lógica del componente para dejarlo más claro:
// file: src/app/app.component.ts
import { Component } from '@angular/core';
import { FactorialService } from './factorial.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
items = [];
progress: number = 0;
computingFactorials: boolean = false;
firstFactorial: number = 700;
numberOfFactorials: number = 50;
constructor(private factorialService: FactorialService) { }
public computeFactorials() {/*...*/}
private getFactorialForN(i: number) { /*...*/}
public cleanResults() {
this.items = [];
this.progress = 0;
}
}
Como ves, muy simple. He dejado vacío el método computeFactorials()
y he añadido uno privado getFactorialForN()
(donde se genera cada factorial individualmente) por que es donde está la chicha. Ahora te cuento…
Lanzando el cálculo
El método computeFactorials()
se llama desde la interfaz. Inicializa el array de items, el estado del progreso y la propiedad computingFactorials
. Luego, ejecuta un bucle para calcular el factorial de numberOfFactorials
números partiendo de firstFactorial
.
// file: src/app/app.component.ts ...
public computeFactorials() {
// clear list, reset progress indicator and show progress bar
this.items = [];
this.progress = 0;
this.computingFactorials = true;
// get factorials in async way
for (let i = this.firstFactorial; i < this.firstFactorial + this.numberOfFactorials; i++) {
setTimeout(this.getFactorialForN(i), 0);
}
}
Aunque al usar setTimeout no bloqueamos el hilo de ejecución per se, la pesada tarea de calcular factoriales para números grandes impedirá que haya tiempo de CPU para otras tareas, bloqueando por momentos la interfaz.
NOTA: Es Importante destacar que cada factorial se calcula dentro de un
setTimeout()
. Esto evita bloquear completamente la interfaz y permite que Angular intente actualizarla tras obtener cada resultado.
Cálculo de factorial y actualización de datos
El método getFactorialForN()
devuelve una función que utiliza el servicio que hemos visto para calcular el factorial y actualiza el array de items y el porcentaje de progreso. Además, comprueba si hemos acabado de calcular factoriales y en ese caso actualiza la variable computingFactorials
.
// file: src/app/app.component.ts ...
private getFactorialForN(i: number) {
return () => {
let value = this.factorialService.factorial(i);
this.items = [...this.items, `${i} - ${value}`];
this.progress += 100.0 / this.numberOfFactorials;
console.log('progress: ', this.progress);
// end
if (i === this.firstFactorial + this.numberOfFactorials - 1 ) {
this.computingFactorials = false;
}
};
}
Fíjate en este detalle: getFactorialForN(i)
devuelve una función, por que NO quiero ejecutar el código EN EL MOMENTO que se lo paso a setTimeout()
SINO DESPUES, cuando realmente se ejecuta el timeout.
Resultados
Ya tengo todo el código. ¿Qué puede salir mal? ¡Vamos a ejecutarlo!
El código está montado con webpack, que empaqueta todo dentro de un mismo bunde y lo inyecta dentro del index.html.
Me voy a terminal y ejecuto el servidor de desarrollo de webpack (está configurado en el package.json) con:
$ npm start
Abro la URL http://localhost:8080
en el navegador web y… ¡tachán!
Hasta aquí todo bien. Se ha cargado el template que esperaba, pero falta ejecutarlo.
Pruebo con 100 elementos, cruzo dedos y… ¿ya está? lo ha calculado en un momento. Vaya, voy a tener que exigirle un poco más.
Voy a probar con 700. Te recomiendo que para probarlo aumentes poco a poco la cantidad, no sea que se te quede chrome congelado 😉
Lo dicho, voy con 700. Ahí está, la interfaz se queda clavada por momentos, no se actualiza la barra de progreso ni el listado, solo a veces cuando intento hacer scroll. Puedes verlo en el video.
Usando web workers
Ahora que está más claro el problema, vamos a ver como solucionarlo con Web Workers en Angular 2.
Arquitectura single-thread VS web workers
Lo primero para pasar a usar Web Workers en la app es entender qué cambios representa. A continuación tienes 2 imágenes con las diferencias entre el proceso de bootstrap habitual y el que necesitamos para web workers.
Los recuadros azules son los bundles que genero con webpack. En el caso Web Workers necesito crear otro archivo webWorker.js.
Como ves, la diferencia es que en el thread principal (UI), en lugar de hacer bootstrapModule
, lo que hago es llamar a bootstrapWorkerUI
pasándole el archivo JS que se encarga de lanzar el webWorker.
Por su lado, este archivo JS carga ciertas dependencias de angular y lanza el módulo de una forma similar al caso single thread, pero usando una plataforma de Angular 2 diferente: platform-webworker.
Vale, vale, no me extiendo más con la teoría y te enseño los cambios en código:
Bootstrap desde el web worker
Ya te había dicho que los web workers viven en un mundo aparte. Por eso, para lanzar Angular desde el web worker necesito crear un archivo dedicado a esta tarea.
// file: src/workerLoader.ts
import './polyfills.ts';
import '@angular/core';
import '@angular/common';
import {platformWorkerAppDynamic} from '@angular/platform-webworker-dynamic';
import { AppModule } from './app/';
platformWorkerAppDynamic().bootstrapModule(AppModule);
Cambios en el módulo principal
El archivo app.module.ts tiene que cambiar ligeramente, para usar WorkerAppModule
en lugar de BrowserModule
.
Verás que los cambios están indicados con el comentario //changes -->
// file: src/app/app.module.ts
import { NgModule } from '@angular/core';
//changes -->
import {WorkerAppModule} from '@angular/platform-webworker';
//<-- end changes
import { FormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import { FactorialService } from './factorial.service';
@NgModule({
declarations: [
AppComponent
],
imports: [
//changes -->
WorkerAppModule,
//<-- end changes
FormsModule
],
providers: [FactorialService],
bootstrap: [AppComponent]
})
export class AppModule { }
Cambios en el main
El archivo principal que se carga desde index.html, es decir, main.ts, queda mucho más simple, y su única función es la de llamar al método bootstrapWorkerUI
.
// file: src/main.ts
import {bootstrapWorkerUi} from '@angular/platform-webworker';
// import general styles with webpack
require('./styles.css');
bootstrapWorkerUi('../webworker.js');
Solo faltan los cambios en la configuración de webpack para generar el archivo webworker.js.
Cambios en Webpack
Doy por sentado que ya tienes ciertas nociones de webpack (y si no, deberías XD). Si tienes dudas puedes revisar la introducción a Webpack que escribí hace poco.
Solo te hacen falta 2 pequeños cambios:
El primero es generar un segundo entry point para crear el archivo webworker.js, que importa (y pasa a ES5) el archivo workerLoader.ts que has creado antes.
El otro cambio es excluir este nuevo entry point del plugin HtmlWebpackPlugin para evitar que index.html cargue el Web Worker en el hilo principal.
Te copio el código con los cambios.
// file: webpack.config.js
module.exports = {
entry: {
'app': ['./src/polyfills.ts', './src/vendor.ts', './src/main.ts'],
'webworker': ['./src/workerLoader.ts']
},
/*...*/
plugins: [
new HtmlWebpackPlugin({
template: 'src/index.html',
excludeChunks: ['webworker']
}),
/*...*/
]
};
Cambios en package JSON (launcher)
Si has llegado hasta aquí e intentas ejecutarlo, lo más probable es que topes con un error en tiempo de ejecución. De momento parece que el servidor de desarrollo de webpack tiene un problema con los Web workers así que he optado por compilar webpack por un lado y ejecutar otro servidor de desarrollo (concretamente simplehttpserver).
Para integrarlo en el proyecto y poder lanzar la web ejecutando npm start
, tendrás que hacer este cambio en package.json:
"scripts": {
"start": "simplehttpserver dist/ -p 8080 & webpack --watch & wait"
},
Y por supuesto si no dispones de simplehttpserver, tendrás que instalarlo con npm install -g simplehttpserver
.
Ahora sí debería funcionar, veamos el resultado:
Conclusiones
Como ves, con unos pocos cambios que no afectan al código sino al proceso de carga de la app, puedes pasar a ejecutar toda tu lógica en otro thread, liberando la UI.
Antes de que te pongas a cambiar todos tus proyectos, te aviso de que esta funcionalidad aún está en beta, tampoco nos volvamos locos ahora, pero sinceramente, me parece una de las killer features de Angular 2 y espero que pronto pase a formar parte del paquete estable.
Ojo, no han inventado la pólvora, los Web Workers ya existían, pero poderlos usar así por defecto de una forma tan sencilla… ¡¡wow, se me ponen los pelos de punta!! ¿A ti no?
Excelente articulo Enrique, gracias!
Gran noticia y excelente aporte. Imagino que ionic no tardará en integrar la plataforma worker.
Muy chulo el artículo y el blog, a seguir!
¡Gracias!
Enhorabuena por el ejemplo Enrique, voy a usarlo para hacer mi propio ejemplo calculando decimales de Pi por dos métodos diferentes (para una práctica del master). He conseguido ejecutarlo sin problemas en la versión ‘single thread’; en cambio, en la versión con webWorkers, he logrado ejecutarlo pero después de varios intentos, he llegado a la conclusión que cuando ejecuto npm start de un clon limpio del proyecto, no se genera la carpeta «dist», y las veces que he podido generarla no se bien en que intento lo he logrado. Soy bastante nuevo en Angular, y totalmente novato con webpack. ¿Cómo podría forzar el que se generara dicha carpeta? Muchas gracias de antemano y enhorabuena de nuevo por estos ejemplos tan instruyentes.
¡Gracias!
Cuando haces
npm start
, acabas usando el servidor de desarrollo de webpack, y ese no genera una carpeta dist real, sino que la «carga» en memoria.Si lo que quieres es obtener la carpeta dist, solo tienes que ejecutar desde el directorio del proyecto, por terminal, el comando
webpack
. Luego puedes lanzar la web con cualquier otro servidor que no sea el de desarrollo de webpack. Diría que en el artículo recomiendo usar simpleHttpServer.Saludos
Ah Ok, muchas gracias. Te cuento lo que me pasaba a mi en entorno Windows. Sin la carpeta dist, no me funcionaba en el ejemplo en la rama webWorkers, y tiene sentido lo que comentas. Para que me funcione (no se si habría otra combinación), he hecho lo que me dijiste, ejecutar webpack para que genera la carpeta dist; para ello he instalado webpack de forma global, npm install webpack -g.
Con esto ya funciona correctamente. He hecho un fork con tu ejemplo para subir la práctica en la que pretendo generar aproximaciones del número pi, por dos métodos diferentes: Montecarlo y Series infinitas de Gregory-Leibniz; iterando un número de veces que se le pide al usuario por pantalla.
Si lo consigo pondré por aquí las conclusiones.
Un saludo y gracias de nuevo!
¡Genial!
Hola Enrique, fantástico artículo, creo que es la misma charla que diste en el angular comunity days. Yo soy nuevo en angular 2, vengo de angularjs y una de las nuevas features que más me ha gustado son los reactive forms. Cómo encajan con esta arquitectura usando webworkers? Echame un cable! Saludos
Hola Félix,
Mientras no toques el DOM directamente (usa renderer si lo necesitas), no te debería ocasionar ningún problema la mezcla de reactive forms con webworkers.
Saludos