WebAssembly ใน TypeScript

WebAssembly ใน TypeScript

บทความนี้อธิบายเกี่ยวกับ WebAssembly ใน TypeScript

เราจะอธิบายวิธีการผสานรวม TypeScript และ WebAssembly ที่เข้าใจง่ายและใช้งานได้จริง

YouTube Video

WebAssembly ใน TypeScript

WebAssembly (Wasm) เป็นรันไทม์ในรูปแบบไบนารีที่ทำงานด้วยความเร็วใกล้เคียง Native ในเบราว์เซอร์ โดยการเรียกใช้ Wasm จาก TypeScript คุณสามารถใช้ประโยชน์จากการประมวลผลที่ใช้ทรัพยากรสูงหรือไลบรารี Native ที่เขียนด้วย C/C++ หรือ Rust ได้อย่างมีประสิทธิภาพ

ขั้นตอนการทำงานพื้นฐาน

ที่นี่ เราจะอธิบายขั้นตอนการทำงานพื้นฐานของ Wasm TypeScript (หรือเบราว์เซอร์) จะดึงไฟล์ .wasm มาสร้างอินสแตนซ์ และเรียกใช้ฟังก์ชันที่ export เอาไว้

    1. สร้างไฟล์ไบนารี .wasm ด้วย AssemblyScript, Rust, C++ หรือเตรียมไฟล์ .wasm ที่มีอยู่แล้ว
    1. ดึงไฟล์ .wasm ใน TypeScript (หรือในเบราว์เซอร์) และสร้างอินสแตนซ์แบบ synchronous หรือ asynchronous
    1. เรียกใช้ฟังก์ชันที่ export และแชร์หน่วยความจำด้วย WebAssembly.Memory หากจำเป็น

WebAssembly.instantiateStreaming

ถัดไปจะมีตัวอย่างพื้นฐานในการโหลดไฟล์ Wasm และเรียกใช้ฟังก์ชันที่ export เบราว์เซอร์จำเป็นต้องรองรับ 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 ไม่ได้รับข้อมูลชนิดข้อมูล (type) คุณจึงต้องใช้ @ts-ignore หรือสร้าง type definitions ของคุณเอง

กระบวนการทำงานโดยใช้ AssemblyScript

AssemblyScript ช่วยให้คุณเขียน Wasm ด้วยไวยากรณ์ที่คล้าย TypeScript เป็นตัวเลือกที่เหมาะสำหรับนักพัฒนา 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 และ build
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 ที่นี่ เราจะใช้ฟังก์ชัน Fibonacci อย่างง่ายเป็นตัวอย่าง เพื่อแสดงวิธีนำเข้ามอดูลที่สร้างขึ้นในรูปแบบ ES module

Export ฟังก์ชันจากฝั่ง 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}
  • เมื่อต้อง build ด้วย wasm-pack หรือ wasm-bindgen CLI จะมีการสร้าง type definitions สำหรับ TypeScript และตัวหุ้ม (wrapper) JS ทำให้สามารถนำเข้าแบบ 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

ฝั่ง TypeScript ให้นำเข้าและใช้งาน ES module จาก 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 (type definition) ทำให้ใช้งานจาก TypeScript ได้ง่าย โปรดทราบว่าเมื่อคุณกำหนด web สำหรับตัวเลือก --target ของคำสั่ง wasm-pack จะต้องใช้การเริ่มต้นแบบอะซิงโครนัส

ตัวอย่างจริงเรื่องการแชร์หน่วยความจำ: การส่งและประมวลผลอาเรย์ (ระดับล่าง)

เมื่อแลกเปลี่ยนข้อมูลขนาดใหญ่กับ Wasm การแชร์ ArrayBuffer เพื่อแลกเปลี่ยนข้อมูลอย่างมีประสิทธิภาพเป็นสิ่งสำคัญ ที่นี่เราจะแสดงตัวอย่างโดยใช้ AssemblyScript แต่อย่างเดียวกันนี้ใช้ได้กับ Rust และ wasm-bindgen

ฝั่ง AssemblyScript ให้เตรียมฟังก์ชันที่ export ออกมาซึ่งใช้เขียนข้อมูลลงหน่วยความจำ ตัวอย่างเช่น ฟังก์ชันสำหรับยกกำลังสองสมาชิกแต่ละตัวในอาเรย์จะมีโค้ดประมาณนี้

 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 และส่ง pointer ไปให้

ตัวอย่างด้านล่างนี้เป็นการใช้ 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 คือหน่วยความจำที่ใช้ร่วมกันแบบลิเนียร์; การลดจำนวนครั้งในการคัดลอกจะช่วยเพิ่มความเร็วในการประมวลผล โปรดทราบว่า pointer ชี้ตำแหน่งตามหน่วยไบต์ ส่วน TypedArray จะจัดการอิงตามจำนวน element ดังนั้นต้องระวังความแตกต่างนี้

การดูแลเรื่องความปลอดภัยของชนิดข้อมูล (Type-safe): เตรียมไฟล์ Type Definitions สำหรับ TypeScript

Wasm export ออกมาเป็นวัตถุ JavaScript ดังนั้นการเตรียม type definitions ให้กับ TypeScript จะช่วยให้งานพัฒนาเป็นระเบียบและง่ายขึ้น นี่คือตัวอย่างไฟล์ type definition แบบง่าย

ข้างล่างคือตัวอย่าง type definition ที่สร้างเองแบบง่ายในไฟล์ 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 จะทำให้ TypeScript สามารถตรวจสอบชนิดข้อมูลได้ wasm-pack สร้างไฟล์ .d.ts ให้โดยอัตโนมัติ จึงควรนำมาใช้ให้เป็นประโยชน์

รูปแบบการ initialize ขณะรันไทม์: แบบ synchronous กับ asynchronous

เนื่องจาก Wasm module ต้องใช้ I/O (fetch) และคอมไพล์จึงนิยม initialize แบบ asynchronous แต่ก็มีอีกแนวทางหนึ่งที่ cache WebAssembly.Module ไว้ล่วงหน้าแล้ว instantiate แบบ synchronous

ด้านล่างคือตัวอย่างโครงสร้างโค้ดสำหรับ initialize WebAssembly แบบ asynchronous ในการใช้งานจริง เราแนะนำให้ใช้แนวทางนี้

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}
  • การ initialize แบบ asynchronous ทำให้สามารถใส่ error handling และ lazy loading ได้ง่ายและยืดหยุ่น เหมาะสมต่อการพัฒนาในเชิงปฏิบัติมากที่สุด นอกจากนี้ โค้ดที่สร้างโดย wasm-pack จะมี API init() สำหรับใช้ initialize ดังนั้นการคุ้นเคยกับ workflow นี้จะช่วยให้การทำงานราบรื่นขึ้น

ข้อควรคำนึงด้านประสิทธิภาพในการใช้งานจริง

นี่คือจุดที่ควรใส่ใจหากต้องการปรับปรุงประสิทธิภาพอย่างมาก โปรดใช้ข้อแนะนำด้านการปรับแต่งเหล่านี้เมื่อใช้งาน TypeScript ร่วมกับ WebAssembly

  • เมื่อมีการเรียกฟังก์ชันบ่อย ๆ การสลับไปมาระหว่าง JavaScript และ Wasm อาจกลายเป็นคอขวด เราขอแนะนำให้รวมข้อมูลและประมวลผลเป็นชุด มากที่สุดเท่าที่จะทำได้
  • การจองหน่วยความจำและการคัดลอกข้อมูลจะเพิ่มภาระในการประมวลผล ใช้ buffer และ pointer ร่วมกันเพื่อลดขั้นตอนเหล่านี้ให้น้อยที่สุด
  • โปรดระมัดระวังเมื่อต้องจัดการกับเลขทศนิยม (floating-point numbers) ใน TypeScript จะถูกแปลงเป็นชนิด number แต่คุณสามารถจัดการให้แม่นยำ โดยกำหนด type ให้ตรงกับฝั่ง Wasm

สรุป

ด้วยการผสานรวม TypeScript และ WebAssembly คุณสามารถได้ประสิทธิภาพที่ใกล้เคียงกับ Native ในเบราว์เซอร์ สิ่งนี้มีประโยชน์โดยเฉพาะอย่างยิ่งสำหรับงานที่ใช้การคำนวณหนัก หรือเมื่อคุณต้องการใช้ประโยชน์จาก assets native ที่มีอยู่ ตัวเลือกนี้ถือเป็นทางเลือกที่ทรงพลังมากหากต้องการเพิ่มประสิทธิภาพให้กับแอปพลิเคชันเว็บของคุณ

คุณสามารถติดตามบทความข้างต้นโดยใช้ Visual Studio Code บนช่อง YouTube ของเรา กรุณาตรวจสอบช่อง YouTube ด้วย

YouTube Video