[Перевод] Angular на стероидах: наращиваем производительность при помощи WebAssembly

0f98b6b509e1ac547ca538de80062294

В этом посте продемонстрировано, как с лёгкостью использовать WebAssembly внутри приложения, написанного на Angular. Иногда в приложении на Angular требуется выполнить задачу, которая в JavaScript завершается не слишком быстро. Конечно, можно переписать алгоритм на другом языке, например, AssemblyScript и Rust — и код станет эффективнее. Затем можно скомпилировать получившиеся фрагменты кода в файле WASM и потоком передать двоичные данные в приложение, чтобы можно было вызывать из него функции WASM. Бывает и так, что разработчику не удаётся найти в реестре NPM опенсорсные библиотеки, нужные для решения задачи. В таком случае можно написать пакет не на JS, а на каком-нибудь другом языке, затем скомпилировать этот пакет в WASM и опубликовать код WASM в реестре NPM. Angular-разработчики устанавливают новый пакет как зависимость и выполняют WASM-функции внутри приложения.

В следующем демонстрационном примере я напишу на AssemblyScript несколько функций для работы с простыми числами, а затем опубликую файл индекса в формате WASM. Затем скопирую файл WASM в приложение Angular, потоком отправлю двоичные данные через WebAssembly API и, наконец, стану вызывать эти функции, чтобы с их помощью выполнять различные действия над простыми числами.

Что такое WebAssembly?


WebAssembly — это составное слово, которое делится на 2 части: Web (Веб) и Assembly (Ассемблер). Когда пишешь на высокоуровневых языках, например, на AssemblyScript и Rust, получается код, который компилируется в ассемблер при помощи специальных инструментов. После этого разработчик может нативно выполнять ассемблерный код прямо в браузере, то есть, в вебе.

Как этот демо-пример может использоваться на практике


С этим демо-примером связаны 2 github-репозитория. В первом используется язык AssemblyScript, на котором пишется TypeScript-подобный код, компилируемый в Wasm. Во втором репозитории лежит простое приложение на Angular, в котором при помощи функций Wasm исследуются некоторые любопытные свойства простых чисел.

В индексе репозитория с AssemblyScript содержится 3 функции для работы с простыми числами:

  • isPrime — Определить, является ли данное целое число простым
  • findFirstNPrimes — Найти первые N простых чисел, где N — целое число
  • optimizedSieve — Найти все простые числа менее N, где N — целое число


AssemblyScript долбавляет в package.json скрипты, генерирующие, соответственно, debug.wasm и release.wasm.

Я скопировала release.wasm в каталог assets приложения Angular, написала загрузчик WebAssembly, чтобы организовать потоковую передачу двоичного файла и вернуть экземпляр WebAssembly. Главный компонент связывает экземпляр с компонентами, где он выступает в качестве ввода. Эти компоненты используют экземпляр для выполнения Wasm и вспомогательных функций для получения результатов операций над простыми числами.

Пишем WebAssembly на AssemblyScript


AssemblyScript — это TypeScript-подобный язык, на котором можно писать код, в дальнейшем компилируемый в WebAssembly.

Начинаем новый проект

npm init


Устанавливаем зависимость

npm install --save-dev assemblyscript


Выполняем команду для добавления скриптов в package.json и файлов скаффолда

npx asinit .


Наши собственные скрипты для генерации файлов debug.wasm и release.wasm

"scripts": {
    "asbuild:debug": "asc assembly/index.ts --target debug --exportRuntime",
    "asbuild:release": "asc assembly/index.ts --target release --exportRuntime",
    "asbuild": "npm run asbuild:debug && npm run asbuild:release",
    "start": "npx serve ."
}


Реализуем алгоритм для работы над простыми числами на AssemblyScript

// assembly/index.ts

// Запись в файле, в этой записи указан модуль WebAssembly

// импорт модуля
declare function primeNumberLog(primeNumber: i32): void;

export function isPrime(n: i32): bool {
  if (n <= 1) {
    return false;
  } else if (n === 2 || n === 3) {
    return true;
  } else if (n % 2 === 0 || n % 3 === 0) {
    return false;
  }

  for (let i = 5; i <= Math.sqrt(n); i = i + 6) {
    if (n % i === 0 || n % (i + 2) === 0) {
      return false;
    }
  }

  return true;
} 

export function findFirstNPrimes(n: i32): Array {
  let primes = new Array(n);

  for (let i = 0; i < n; i++) {
    primes[i] = 0;
  }
  primes[0] = 2;
  primeNumberLog(primes[0]);

  let num = 3;
  let index = 0;
  while(index < n - 1) {
    let isPrime = true;

    for (let i = 0; i <= index; i++) {
      if (num % primes[i] === 0) {
        isPrime = false;
        break;
      }
    }

    if (isPrime) {
      primeNumberLog(num);
      primes[index + 1] = num;
      index = index + 1;
    }
    num = num + 2;
  }

  return primes;
}

const MAX_SIZE = 1000001;

export function optimizedSieve(n: i32): Array {
  const isPrime = new Array(MAX_SIZE);
  isPrime.fill(true, 0, MAX_SIZE);

  const primes = new Array();
  const smallestPrimeFactors = new Array(MAX_SIZE);
  smallestPrimeFactors.fill(1, 0, MAX_SIZE);

  isPrime[0] = false;
  isPrime[1] = false;

  for (let i = 2; i < n; i++) {
    if (isPrime[i]) {
      primes.push(i);

      smallestPrimeFactors[i] = i;
    }

    for (let j = 0; j < primes.length && i * primes[j] < n && primes[j] <= smallestPrimeFactors[i]; j++) {
      const nonPrime = i * primes[j];
      isPrime[nonPrime] = false;
      smallestPrimeFactors[nonPrime] = primes[j];
    }
  }

  const results = new Array();
  for (let i = 0; i < primes.length && primes[i] <= n; i++) {
    results.push(primes[i]);
  }

  return results;
}


primeNumberLog — это внешняя функция, которая логирует простые числа в findFirstNPrimes. У этой функции нет тела, и именно приложение Angular отвечает за то, чтобы предоставить для неё детали реализации.

После выполнения скрипта npm run asbuild в каталоге builds/ будут содержаться файлы debug.wasm и release.wasm. Та часть работы, которая касается WebAssembly, уже готова, и далее мы будем работать только с приложением Angular.

Комбинируем сильные стороны WebAssembly и Angular


В WebAssembly не переносятся такие высокоуровневые типы данных, как, например, массивы и булевы значения. Поэтому я установила загрузчик assemblyscript и с помощью содержащихся в нём вспомогательных функций привожу к корректным типам значения, возвращаемые функциями Wasm.

Установка зависимости

npm i @assemblyscript/loader


Сборка загрузчика WebAssembly

Далее, методом проб и ошибок я смогла импортировать в приложение Angular функции Wasm, организовав потоковую передачу release.wasm при помощи загрузчика assemblyscript.

src
├── assets
 │   └── release.wasm
├── favicon.ico
├── index.html
├── main.ts
└── styles.scss


Я инкапсулировала загрузчик WebAssembly в загрузочный сервис, тем самым позволив всем компонентам Angular многократно использовать функционал потоковой передачи данных. Если в браузере поддерживается функция instantiateStreaming, то возвращается экземпляр WebAssembly. Если функция instantiateStreaming не поддерживается, то будет вызвана резервная функция. Резервная функция преобразует ответ в массив ArrayBuffer и собирает экземпляр WebAssembly.

DEFAULT_IMPORTS также предоставляет реализацию primeNumberLog. primeNumberLog объявляется в файле index.ts репозитория AssemblyScript. Следовательно, ключом объекта служит его позиция в индексе без файлового расширения.

// web-assembly-loader.service.ts

import { Injectable } from '@angular/core';
import loader, { Imports } from '@assemblyscript/loader';

const DEFAULT_IMPORTS: Imports = { 
  env: {
    abort: function() {
      throw new Error('Abort called from wasm file');
    },
  },
  index: {
    primeNumberLog: function(primeNumber: number) {
      console.log(`primeNumberLog: ${primeNumber}`);
    }
  }
}

@Injectable({
  providedIn: 'root'
})
export class WebAssemblyLoaderService {
  async streamWasm(wasm: string, imports = DEFAULT_IMPORTS): Promise {
    if (!loader.instantiateStreaming) {
      return this.wasmFallback(wasm, imports);
    }

    const instance = await loader.instantiateStreaming(fetch(wasm), imports);
    return instance?.exports;
  }

  async wasmFallback(wasm: string, imports: Imports) {
    console.log('using fallback');
    const response = await fetch(wasm);
    const bytes = await response?.arrayBuffer();
    const { instance } = await loader.instantiate(bytes, imports);

    return instance?.exports;
  }
}

Связываем экземпляр WebAssembly с компонентами Angular

В AppComponent я потоком передала release.wasm, чтобы собрать экземпляр WebAssembly. Затем я связала этот экземпляр с выводом из Angular Components.

// app.config.ts

export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: APP_BASE_HREF,
      useFactory: () => inject(PlatformLocation).getBaseHrefFromDOM(),
    }
  ]
};
// full-asset-path.ts

export const getFullAssetPath = (assetName: string) => {
    const baseHref = inject(APP_BASE_HREF);
    const isEndWithSlash = baseHref.endsWith('/');
    return `${baseHref}${isEndWithSlash ? '' : '/'}assets/${assetName}`;
}
// app.component.ts

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [FormsModule, IsPrimeComponent, FindFirstNPrimesComponent, OptimizedSieveComponent],
  template: `
    

Angular + WebAssembly Demo

`, }) export class AppComponent implements OnInit { instance!: any; releaseWasm = getFullAssetPath('release.wasm'); wasmLoader = inject(WebAssemblyLoaderService); async ngOnInit(): Promise { this.instance = await this.wasmLoader.streamWasm(this.releaseWasm); console.log(this.instance); } }


Применение WebAssembly к Angular Components

IsPrimeComponent вызывает функцию isPrime, определяющую, является ли данное целое число простым. isPrime возвращает 1, если перед нами простое число, в противном случае возвращает 0. Следовательно, оператор === сравнивает целочисленные значения, чтобы в результате вернуть булево.

// is-prime.component.ts

@Component({
  selector: 'app-is-prime',
  standalone: true,
  imports: [FormsModule],
  template: `
    

isPrime({{ primeNumber() }}): {{ isPrimeNumber() }}

`, changeDetection: ChangeDetectionStrategy.OnPush, }) export class IsPrimeComponent { @Input({ required: true }) instance!: any; primeNumber = signal(0); isPrimeNumber = computed(() => { const value = this.primeNumber(); return this.instance ? this.instance.isPrime(value) === 1 : false }); }

FindFirstNPrimesComponent вызывает функцию findFirstNPrimes, чтобы получить первые N простых чисел. Функция findFirstNPrimes не может перенести целочисленный массив, поэтому я пользуюсь вспомогательной функцией __getArray, имеющейся в загрузчике, и с её помощью преобразую целочисленное значение в корректный целочисленный массив.

// find-first-nprimes.component.ts

@Component({
  selector: 'app-find-first-nprimes',
  standalone: true,
  imports: [FormsModule],
  template: `
    

First {{ firstN() }} prime numbers:

@for(primeNumber of firstNPrimeNumbers(); track primeNumber) { {{ primeNumber }} }
`, changeDetection: ChangeDetectionStrategy.OnPush, }) export class FindFirstNPrimesComponent { @Input({ required: true }) instance!: any; firstN = signal(0); firstNPrimeNumbers = computed(() => { const value = this.firstN(); if (this.instance) { const { findFirstNPrimes, __getArray: getArray } = this.instance; return getArray(findFirstNPrimes(value)); } return []; }); }


OptimizedSieveComponent вызывает функцию optimizedSieve, чтобы получить все простые числа, которые меньше to obtain N. Функция optimizedSieve также не может перенести целочисленный массив, и я пользуюсь вспомогательной функцией __getArray, с её помощью преобразую целочисленное значение в корректный целочисленный массив.

// optimized-sieve.component.ts

@Component({
  selector: 'app-optimized-sieve',
  standalone: true,
  imports: [FormsModule],
  template: `
    

Prime numbers less than {{ lessThanNumber() }}

@for(primeNumber of primeNumbers(); track primeNumber) { {{ primeNumber }} }
`, changeDetection: ChangeDetectionStrategy.OnPush, }) export class OptimizedSieveComponent { @Input({ required: true }) instance!: any; lessThanNumber = signal(0); primeNumbers = computed(() => { const value = this.lessThanNumber(); if (this.instance) { const { optimizedSieve, __getArray: getArray } = this.instance; return getArray(optimizedSieve(value)); } return []; }); }


Готовый пример показан на следующей странице:

railsstudent.github.io

Вот и всё. Надеюсь, вам понравился этот пост, и он вдохновит вас на изучение Angular и других технологий.

Ресурсы:

© Habrahabr.ru