WebAssembly en TypeScript

WebAssembly en TypeScript

Este artículo explica WebAssembly en TypeScript.

Explicaremos métodos prácticos y fáciles de entender para integrar TypeScript y WebAssembly.

YouTube Video

WebAssembly en TypeScript

WebAssembly (Wasm) es un entorno de ejecución de formato binario que se ejecuta a una velocidad casi nativa dentro del navegador. Al llamar a Wasm desde TypeScript, puedes utilizar de manera eficiente procesos intensivos en cálculos y librerías nativas existentes escritas en C/C++ o Rust.

Flujo básico de ejecución

Aquí explicaremos el flujo básico de ejecución de Wasm. TypeScript (o el navegador) obtiene el archivo .wasm, lo instancia y llama a las funciones exportadas.

    1. Crea un binario .wasm usando AssemblyScript, Rust, C++ o prepara uno existente.
    1. Obtén el archivo .wasm en TypeScript (o en el navegador) e instáncialo de forma síncrona o asíncrona.
    1. Llama a las funciones exportadas y comparte memoria usando WebAssembly.Memory si es necesario.

WebAssembly.instantiateStreaming

A continuación, mostraremos un ejemplo básico de cómo cargar un archivo Wasm y llamar a una función exportada. El navegador necesita soportar instantiateStreaming.

El siguiente código es un ejemplo de cómo obtener simple.wasm del servidor y llamar a la función add.

 1// TypeScript: load-and-call.ts
 2// Fetch and instantiate a wasm module and call its exported `add` function.
 3async function run() {
 4  const response = await fetch('http://localhost:3000/simple.wasm');
 5  // Use instantiateStreaming when available for efficiency.
 6  const { instance } = await WebAssembly.instantiateStreaming(response, {});
 7  // @ts-ignore
 8  const result = instance.exports.add(2, 3);
 9  console.log('2 + 3 =', result);
10}
11run().catch(console.error);
  • Las funciones dentro de Wasm se almacenan en instance.exports.
  • Como TypeScript no recibe información de tipos, necesitas usar @ts-ignore o crear tus propias definiciones de tipo.

Flujo de trabajo usando AssemblyScript

AssemblyScript permite escribir Wasm con una sintaxis similar a TypeScript, siendo una opción accesible para los desarrolladores de TypeScript. Aquí preparamos una función simple, la compilamos a .wasm y .d.ts, y la llamamos desde TypeScript.

1// assembly/index.ts (AssemblyScript)
2// npm install --save-dev assemblyscript
3
4// Export a simple function to add two integers.
5export function add(a: i32, b: i32): i32 {
6  return a + b;
7}
  • Al usar asc (el compilador de AssemblyScript), puedes generar un archivo .wasm y, opcionalmente, un archivo de definición de tipos .d.ts. Para probarlo localmente, instala assemblyscript con npm y compílalo.
1# build commands
2# npm install --save-dev assemblyscript
3npx asc assembly/index.ts   -o build/simple.wasm   -t build/simple.wat   --bindings esm   --exportTable   --sourceMap
4
5# optionally generate d.ts with --exportRuntime or use as-bind / loader tools

Aquí tienes un ejemplo de cómo obtener y llamar desde el lado de TypeScript.

1// ts client that loads AssemblyScript-generated wasm
2async function runAssemblyScript() {
3  const res = await fetch('http://localhost:3000/build/simple.wasm');
4  const { instance } = await WebAssembly.instantiateStreaming(res, {});
5  // @ts-ignore
6  console.log('AssemblyScript add:', instance.exports.add(10, 7));
7}
8runAssemblyScript().catch(console.error);
  • AssemblyScript requiere un manejo cuidadoso de los modelos de memoria y cadenas, pero es muy fácil de usar para cálculos numéricos básicos.

Rust + wasm-bindgen (Una opción poderosa y comúnmente utilizada)

Esta sección explica el flujo de trabajo de escribir Wasm en Rust y conectarlo con JavaScript o TypeScript usando wasm-bindgen. Aquí utilizamos una función de Fibonacci simple como ejemplo para mostrar cómo importar el módulo generado como módulo ES.

Exporta funciones desde el lado de Rust usando wasm-bindgen.

 1// src/lib.rs (Rust)
 2// install wasm-pack from https://drager.github.io/wasm-pack/installer/
 3use wasm_bindgen::prelude::*;
 4
 5// Export a function to JavaScript using wasm-bindgen.
 6#[wasm_bindgen]
 7pub fn fib(n: u32) -> u32 {
 8    if n <= 1 { return n; }
 9    let mut a = 0;
10    let mut b = 1;
11    for _ in 2..=n {
12        let tmp = a + b;
13        a = b;
14        b = tmp;
15    }
16    b
17}
  • Cuando compilas con wasm-pack o la CLI de wasm-bindgen, se generan definiciones de tipo para TypeScript y envoltorios JS, permitiéndote importarlos directamente como ESM.
1# build with wasm-pack
2# install wasm-pack from https://drager.github.io/wasm-pack/installer/
3wasm-pack build --target nodejs --out-dir pkg

En el lado de TypeScript, importa y utiliza el módulo ES desde pkg.

 1// Node.js: import WASM module built with --target web
 2// import init, { fib } from '../pkg/my_wasm_module.js';
 3// Node.js: import WASM module built with --target nodejs
 4import wasm from '../pkg/my_wasm_module.js';
 5
 6async function run() {
 7  //await init();   // --target web
 8  console.log('fib(10)=', wasm.fib(10));
 9}
10
11run().catch(console.error);
  • wasm-pack genera envoltorios de JavaScript y definiciones de tipo .d.ts, lo que facilita su uso desde TypeScript. Tenga en cuenta que cuando se especifica web en la opción --target del comando wasm-pack, se requiere una inicialización asíncrona.

Ejemplo real de compartición de memoria: Pasar y procesar arrays (nivel bajo)

Al intercambiar grandes cantidades de datos con Wasm, es importante compartir ArrayBuffer para un intercambio de datos eficiente. Aquí mostramos un ejemplo usando AssemblyScript, pero el mismo principio aplica con wasm-bindgen de Rust.

En el lado de AssemblyScript, prepara una función exportada para escribir en memoria. Por ejemplo, una función para elevar al cuadrado cada elemento de un array sería así.

 1// assembly/array_ops.ts (AssemblyScript)
 2// Square values in place in the wasm linear memory starting at `ptr` for `len` elements.
 3export function square_in_place(ptr: usize, len: i32): void {
 4  // Treat memory as a pointer to 32-bit integers.
 5  for (let i = 0; i < len; i++) {
 6    let offset = ptr + (i << 2); // i * 4 bytes
 7    let value = load<i32>(offset);
 8    store<i32>(offset, value * value);
 9  }
10}

Para especificar la configuración de memoria utilizada por AssemblyScript, prepara el siguiente archivo asconfig.json.

1{
2  "options": {
3    "memoryBase": 0,
4    "importMemory": false,
5    "initialMemory": 1,
6    "maximumMemory": 10
7  }
8}
1 npx asc assembly/array_ops.ts   -o build/array_ops.wasm   -t build/array_ops.wat   --bindings esm   --exportTable   --sourceMap
  • Para llamar a esta función, debes copiar el ArrayBuffer en el espacio de memoria de Wasm y pasar el puntero.

A continuación se muestra un ejemplo de cómo usar WebAssembly.Memory en TypeScript para copiar datos y llamar a la función.

 1// TypeScript: use memory to pass array to wasm
 2async function runArrayOps() {
 3  const res = await fetch('http://localhost:3000/build/array_ops.wasm');
 4  const { instance } = await WebAssembly.instantiateStreaming(res, {});
 5  // @ts-ignore
 6  const memory: WebAssembly.Memory = instance.exports.memory;
 7  // Create a view into wasm memory.
 8  const i32View = new Int32Array(memory.buffer);
 9
10  // Example data
11  const input = new Int32Array([1, 2, 3, 4]);
12  // Choose an offset (in i32 elements) to copy data to (simple example: at index 0).
13  const offset = 0;
14  i32View.set(input, offset);
15
16  // Call wasm function: ptr in bytes, len in elements
17  // @ts-ignore
18  instance.exports.square_in_place(offset * 4, input.length);
19
20  // Read back result
21  const result = i32View.slice(offset, offset + input.length);
22  console.log('squared:', result);
23}
24runArrayOps().catch(console.error);
  • memory.buffer es la memoria lineal compartida; minimizar las copias mejora la velocidad de procesamiento al máximo. Nota también que los punteros se refieren a posiciones en bytes, mientras que los TypedArray se gestionan por el número de elementos, así que ten cuidado de no confundir estas diferencias.

Manejo seguro de tipos: Preparar definiciones de tipo para TypeScript

Las exportaciones de Wasm son objetos de JavaScript, por lo que proporcionar definiciones de tipo del lado de TypeScript facilitará el desarrollo. Aquí tienes un ejemplo sencillo de un archivo de definición de tipos.

A continuación se muestra la definición de tipo mínima que puedes crear manualmente como simple.d.ts.

1// simple.d.ts
2export function add(a: number, b: number): number;
3export const memory: WebAssembly.Memory;
  • Colocando esto en el typeRoots de tu tsconfig.json o usando declare module habilitará la comprobación de tipos. wasm-pack genera automáticamente archivos .d.ts, por lo que es útil utilizarlos.

Patrones de inicialización en tiempo de ejecución: síncrono vs asíncrono

Como los módulos Wasm requieren entrada/salida (fetching) y compilación, la inicialización asíncrona es común. Sin embargo, también existe un patrón en el que se almacena en caché WebAssembly.Module de antemano y se instancia de forma síncrona.

A continuación se muestra la estructura de código básica para inicializar WebAssembly de forma asíncrona. En proyectos reales, se recomienda este patrón.

1// async init pattern
2async function initWasm(url: string) {
3  const res = await fetch(url);
4  const { instance, module } = await WebAssembly.instantiateStreaming(res, {});
5  return instance;
6}
  • La inicialización asíncrona facilita la incorporación flexible del manejo de errores y la carga diferida, lo que la hace la más conveniente en el desarrollo práctico. Además, el código generado por wasm-pack incluye una API init() para la inicialización, así que acostumbrarte a este flujo hará que tu trabajo sea más fluido.

Consideraciones prácticas de rendimiento

Aquí tienes algunos puntos a tener en cuenta para lograr mejoras de rendimiento significativas. Consulta estos consejos de optimización al combinar TypeScript y WebAssembly.

  • Cuando las llamadas a funciones son muy frecuentes, la sobrecarga de llamadas entre JavaScript y Wasm puede convertirse en un cuello de botella. Recomendamos agrupar los datos y procesarlos de una sola vez tanto como sea posible.
  • La asignación y copia de memoria aumentan la carga de procesamiento. Utiliza buffers compartidos y punteros para minimizar estas operaciones.
  • Ten cuidado al manejar números de punto flotante. En TypeScript, se convierten en el tipo number, pero puedes manejarlos con precisión haciendo coincidir los tipos en el lado de Wasm.

Resumen

Combinando TypeScript y WebAssembly, puedes lograr un rendimiento casi nativo en el navegador. Esto es especialmente efectivo para tareas intensivas en cálculo o cuando quieres aprovechar recursos nativos existentes. Esta combinación es una opción muy poderosa cuando deseas mejorar el rendimiento de tu aplicación web.

Puedes seguir el artículo anterior utilizando Visual Studio Code en nuestro canal de YouTube. Por favor, también revisa nuestro canal de YouTube.

YouTube Video