וובאסמבלי ב-TypeScript

וובאסמבלי ב-TypeScript

מאמר זה מסביר את WebAssembly ב-TypeScript.

נסביר שיטות מעשיות וקלות להבנה לשילוב בין TypeScript ו-WebAssembly.

YouTube Video

וובאסמבלי ב-TypeScript

WebAssembly (Wasm) הוא זמן ריצה בפורמט בינארי שרץ בדפדפן כמעט במהירות מקורית. בעזרת קריאה ל-Wasm מתוך TypeScript, אפשר לנצל ביעילות תהליכים עתירי חישוב וספריות מקוריות קיימות שנכתבו ב-C/C++ או Rust.

תרשים זרימה בסיסי להרצה

כאן נסביר את תרשים הזרימה הבסיסי של הרצת Wasm. TypeScript (או הדפדפן) מביא את קובץ ‎.wasm, יוצר מופע שלו, וקורא לפונקציות שיוצאו החוצה.

    1. צרו בינארי ‎.wasm בעזרת AssemblyScript, Rust, ‎C++‎ או השתמשו בקובץ קיים.
    1. שלפו את קובץ ה-.wasm ב-TypeScript (או בדפדפן) וצרו מופע (באופן סינכרוני או אסינכרוני).
    1. קראו לפונקציות שיוצאו החוצה ושתפו זיכרון בעזרת WebAssembly.Memory במידת הצורך.

WebAssembly.instantiateStreaming

בהמשך נראה דוגמה בסיסית לטעינת קובץ Wasm וקריאה לפונקציה שיוצאה החוצה. הדפדפן צריך לתמוך ב-instantiateStreaming.

הקוד הבא הוא דוגמה להבאת simple.wasm מהשרת ולהפעלת הפונקציה 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);
  • הפונקציות בתוך Wasm מאוחסנות ב-instance.exports.
  • מכיוון ש-TypeScript לא מקבל מידע טיפוסים, יש להיעזר ב-@ts-ignore או להגדיר טיפוסים בעצמכם.

זרימת עבודה עם AssemblyScript

AssemblyScript מאפשרת לכתוב Wasm בתחביר הדומה ל-TypeScript, מה שהופך זאת לבחירה נגישה למפתחים. כאן אנו מכינים פונקציה פשוטה, בונים אותה לקבצי .wasm ו- .d.ts, וקוראים לה מ-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}
  • באמצעות asc (הקומפיילר של AssemblyScript), אפשר ליצור קובץ .wasm, ובאופן אופציונלי גם קובץ הגדרת טיפוס .d.ts. כדי לנסות זאת מקומית, התקינו את assemblyscript בעזרת npm ובנו אותו.
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

הנה דוגמה לשליפה וקריאה מהצד של 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 דורש טיפול קפדני במודלי זיכרון ומחרוזות, אך מאוד נוח לחישובים נומריים בסיסיים.

Rust + wasm-bindgen (אפשרות עוצמתית ונפוצה מאוד)

סעיף זה מסביר את תהליך העבודה של כתיבת Wasm ב-Rust וחיבורו עם JavaScript או TypeScript באמצעות wasm-bindgen. כאן נשתמש בפונקציה פשוטה של פיבונאצ'י כדוגמה לאיך לייבא את המודול שנוצר כ-ES module.

ייצוא פונקציות בצד של Rust בעזרת 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}
  • כאשר בונים בעזרת wasm-pack או CLI של wasm-bindgen, נוצרים קבצי טיפוסים ל-TypeScript ועוטפי JS, ואפשר לייבא אותם ישירות כ-ES module.
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

בצד ה-TypeScript, ייבאו והשתמשו במודול ES מתוך ספריית 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 יוצר עוטפי JavaScript וקבצי טיפוסים ‎.d.ts, מה שמקל את השימוש מ-TypeScript. לתשומת לבך: כאשר אתה מציין web כאופציה עבור --target בפקודת wasm-pack, יש צורך באתחול אסינכרוני.

דוגמה אמיתית לשיתוף זיכרון: העברת מערכים ועיבודם (רמה נמוכה)

בעת שליחה או קבלה של כמויות נתונים גדולות ל/מ-Wasm, חשוב לשתף ArrayBuffer כדי לייעל את ההעברה. כאן נציג דוגמה עם AssemblyScript, אבל אותו עקרון עובד גם עם wasm-bindgen של Rust.

בצד AssemblyScript, הכינו פונקציה שיוצאה לכתיבה לזיכרון. לדוגמה, פונקציה לריבוע כל איבר במערך תיראה כך.

 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}

כדי להגדיר את הגדרות הזיכרון של AssemblyScript, יש להכין את הקובץ 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
  • כדי לקרוא לפונקציה הזו, צריך להעתיק את ArrayBuffer למרחב הזיכרון של Wasm ולהעביר את המצביע.

להלן דוגמה לשימוש ב-WebAssembly.Memory ב-TypeScript כדי להעתיק נתונים ולקרוא לפונקציה.

 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 הוא הזיכרון הלינארי המשותף; צמצום העתקות משפר את מהירות העיבוד. שימו לב שמצביעים מתייחסים למיקומים בבייטים, בעוד TypedArray מנוהלים לפי כמות איברים — יש להיזהר לא לבלבל ביניהם.

טיפול בטיפוסים בטוחים: הכינו הגדרות טיפוסים ל-TypeScript

היצוא מ-Wasm הם אובייקטים של JavaScript, ולכן הגדרת טיפוסים ב-TypeScript תקל על הפיתוח. הנה דוגמה פשוטה לקובץ הגדרת טיפוסים.

להלן הגדרת הטיפוס המינימלית שניתן ליצור ידנית כ-simple.d.ts.

1// simple.d.ts
2export function add(a: number, b: number): number;
3export const memory: WebAssembly.Memory;
  • הוספת קובץ זה ל-typeRoots ב-tsconfig.json או שימוש ב-declare module תפעיל בדיקות טיפוסים. wasm-pack מייצר אוטומטית קבצי ‎.d.ts, ולכן כדאי להשתמש בהם.

דפוסי אתחול בזמן ריצה: סינכרוני מול אסינכרוני

מכיוון שמודולי Wasm דורשים I/O (שליפה) וקומפילציה, אתחול אסינכרוני הוא הנפוץ. עם זאת, יש גם תבנית בה מאחסנים מראש את WebAssembly.Module ואז יוצרים מופע ממנו בסינכרון.

להלן מבנה קוד בסיסי לאתחול אסינכרוני של WebAssembly. בפרויקטים אמיתיים, מומלץ לעבוד לפי תבנית זו.

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}
  • אתחול אסינכרוני מקנה גמישות בטיפול בשגיאות וטעינה עצלה, ולכן הוא הנוח ביותר בפיתוח מעשי. בנוסף, קוד שמיוצר על ידי wasm-pack כולל API בשם init() לאתחול, כך שהתרגלות לזרימה הזו תייעל את העבודה.

שיקולי ביצועים מעשיים

להלן כמה נקודות לשיפור ביצועים משמעותי. השתמשו בטיפים אלו לאופטימיזציה בעת שילוב TypeScript עם WebAssembly.

  • כאשר קריאות לפונקציות רבות מאוד, התקורה של המעבר בין JavaScript ל-Wasm עלולה להפוך לצוואר בקבוק. מומלץ לאגד נתונים ולעבד אותם בפעם אחת ככל האפשר.
  • הקצאת זיכרון והעתקה מגבירות עומס עיבוד. השתמשו בבאפרים ומשאבים משותפים כדי לצמצם פעולות אלה.
  • יש להיזהר בטיפול במספרים עשרוניים. ב-TypeScript הם הופכים ל-number, אך ניתן לטפל בהם בדיוק רב ע"י התאמת הטיפוסים גם בצד של Wasm.

סיכום

בעזרת שילוב TypeScript ו-WebAssembly ניתן להגיע לביצועים קרובים למקור בדפדפן. זה יעיל במיוחד למשימות חישוביות כבדות או כאשר רוצים להשתמש בביצועי קוד מקוריים קיימים. שילוב זה הוא אפשרות חזקה במיוחד לשיפור ביצועי אפליקציית ווב.

תוכלו לעקוב אחר המאמר שלמעלה באמצעות Visual Studio Code בערוץ היוטיוב שלנו. נא לבדוק גם את ערוץ היוטיוב.

YouTube Video