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

от автора

В этом посте продемонстрировано, как с лёгкостью использовать 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<i32> {   let primes = new Array<i32>(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<i32> {   const isPrime = new Array<bool>(MAX_SIZE);   isPrime.fill(true, 0, MAX_SIZE);    const primes = new Array<i32>();   const smallestPrimeFactors = new Array<i32>(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<i32>();   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<any> {     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: `     <div class="container outer" style="margin: 0.5rem;">       <h2>Angular + WebAssembly Demo</h2>       <app-is-prime [instance]="instance" />       <app-find-first-nprimes [instance]="instance" />       <app-optimized-sieve [instance]="instance" />     </div>   `, }) export class AppComponent implements OnInit {   instance!: any;   releaseWasm = getFullAssetPath('release.wasm');   wasmLoader = inject(WebAssemblyLoaderService);    async ngOnInit(): Promise<void> {     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: `     <form>       <label for="primeNumber">         <span>Input an positive integer: </span>         <input id="primeNumber" name="primeNumber" type="number"           [ngModel]="primeNumber()" (ngModelChange)="primeNumber.set($event)" />       </label>     </form>     <p class="bottom-margin">isPrime({{ primeNumber() }}): {{ isPrimeNumber() }}</p>   `,   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: `     <form>       <label for="firstNPrimeNumbers">         <span>Find first N prime numbers: </span>         <input id="firstNPrimeNumbers" name="firstNPrimeNumbers" type="number"           [ngModel]="firstN()" (ngModelChange)="firstN.set($event)" />       </label>     </form>      <p class="bottom-margin">First {{ firstN() }} prime numbers:</p>     <div class="container first-n-prime-numbers bottom-margin">       @for(primeNumber of firstNPrimeNumbers(); track primeNumber) {         <span style="padding: 0.25rem;">{{ primeNumber }}</span>       }     <div>   `,   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: `     <form>       <label for="primeNumber">         <span>Input an positive integer: </span>         <input id="primeNumber" name="primeNumber" type="number"           [ngModel]="lessThanNumber()" (ngModelChange)="lessThanNumber.set($event)" />       </label>     </form>      <p class="bottom-margin">Prime numbers less than {{ lessThanNumber() }}</p>     <div class="container prime-numbers-less-than-n bottom-margin">       @for(primeNumber of primeNumbers(); track primeNumber) {         <span style="padding: 0.25rem;">{{ primeNumber }}</span>       }     <div>   `,   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 и других технологий.

Ресурсы:


ссылка на оригинал статьи https://habr.com/ru/articles/796329/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *