ブラウザアプリの作り方

NagiScript を WebAssembly にコンパイルして、ブラウザで動く小さなアプリをゼロから作ってみましょう。ロジックは NagiScript で書き、DOM 操作やイベントは JavaScript が担当する役割分担が基本です。


完成イメージ

このチュートリアルで作るのは「カウンターアプリ」です。ボタンを押すと NagiScript で書いた関数が WASM 上で呼ばれ、カウントが増減します。

┌───────────────────────────┐
│  +──────┐                 │
│  │ = 5  │   [!] [-1] [+1] │
│  └──────┘                 │
│  count in NagiScript      │
└───────────────────────────┘

ステップ 1: ロジックを NagiScript で書く

ブラウザから呼び出す関数は export "C" でエクスポートします。WASM の関数は数値や真偽値、ポインタを引数・戻り値に取れるので、まずは単純なカウンター計算から始めます。

counter.ngs
// counter.ngs — ブラウザから呼び出すビジネスロジック

export "C" fn increment(n: i32) -> i32 {
    return n + 1
}

export "C" fn decrement(n: i32) -> i32 {
    return n - 1
}

export "C" fn is_even(n: i32) -> i32 {
    if n % 2 == 0 { return 1 }
    return 0
}

export "C" fn label_for(n: i32) -> i32 {
    // 0 なら "zero"、10 以上なら "big"、それ以外は "normal"
    if n == 0 { return 0 }
    if n >= 10 { return 1 }
    return 2
}

ステップ 2: WASM にコンパイル

bash
nagiscript wasm counter.ngs -o counter.wasm
# counter.wasm が生成される

ステップ 3: HTML と JavaScript で組み込む

生成した counter.wasm を fetch で読み込み、WebAssembly.instantiate で初期化します。ヘルパー関数を使って DOM に組み込みます。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Counter</title>
</head>
<body>
  <h1>NagiScript Counter</h1>
  <p id="count">0</p>
  <button id="minus">-1</button>
  <button id="plus">+1</button>
  <p id="label"></p>

  <script>
    let count = 0;
    let counter = null;   // WASM exports

    async function loadWasm() {
      const bytes = await fetch('counter.wasm').then(r => r.arrayBuffer());
      const { instance } = await WebAssembly.instantiate(bytes, {
        env: { memory: new WebAssembly.Memory({ initial: 256 }) }
      });
      counter = instance.exports;

      // 初期表示
      render();
    }

    // NagiScript の label_for() は i32 を返すので JS 側で文字列化する
    const LABELS = ['zero', 'big', 'normal'];

    function render() {
      document.getElementById('count').textContent = count;
      const label = LABELS[counter.label_for(count)] ?? '?';
      document.getElementById('label').textContent =
        'is_even = ' + counter.is_even(count) + ' / ' + label;
    }

    document.getElementById('plus').addEventListener('click', () => {
      count = counter.increment(count);   // WASM で計算
      render();
    });
    document.getElementById('minus').addEventListener('click', () => {
      count = counter.decrement(count);
      render();
    });

    loadWasm();
  </script>
</body>
</html>

ブラウザで開けば、「+1」「-1」ボタンでカウントが増減し、NagiScript 側の increment / decrement / is_even / label_for が WASM 上で実行されているのが分かります。


文字列をやり取りする

NagiScript の string はメモリ上で { data, len } のセルとして渡ります。JS 側からは TextEncoder でバイト列を WASM メモリに書き込み、ポインタと長さを渡します。

greet.ngs
export "C" fn char_count(data: i32, len: i32) -> i32 {
    return len
}
main.js
let memory;

async function load() {
  const bytes = await fetch('greet.wasm').then(r => r.arrayBuffer());
  const imports = { env: { memory: new WebAssembly.Memory({ initial: 256 }) } };
  const wasm = await WebAssembly.instantiate(bytes, imports);
  memory = wasm.instance.exports.memory;
  window.ngs = wasm.instance.exports;
}

function writeString(memory, str) {
  const bytes = new TextEncoder().encode(str);
  const dv = new DataView(memory.buffer);
  const HEADER = 16;                        // {data, len} セル
  const ptr = allocate(memory, HEADER + bytes.length);

  // 文字本体をセルの直後にコピー
  const dst = ptr + HEADER;
  new Uint8Array(memory.buffer, dst, bytes.length).set(bytes);

  dv.setUint32(ptr, 0, true);
  dv.setUint32(ptr + 8, bytes.length, true); // len
  return ptr;
}

// 使用例: ngs.char_count(ptr, len) を呼んでから ptr を返す文字列セルを読む
const ptr = writeString(memory, "hello");
console.log(ngs.char_count(ptr, 5)); // => 5

詳細なメモリの扱いは WebAssembly チュートリアル を参照してください。


.ngsx による UI 構文(ロードマップ)

ロードマップ

.ngsx(JSX 風 UI 構文)はランタイムに props 基盤が存在するものの、ドキュメント化は保留されています。以下は予定されている構文です。

NagiScript は JSX 風のマークアップ構文 .ngsx を予定しています。React コンポーネントと統合し、NagiScript だけで UI まで記述できるようになる予定です。

Counter.ngsx
component Counter(ref count, onPlus, onMinus) {
    return (
        <div class="counter">
            <p>{count}</p>
            <button onClick={onMinus}>-1</button>
            <button onClick={onPlus}>+1</button>
        </div>
    )
}

状態管理のパターン

今回のように「計算は NagiScript、状態は JS」が最もシンプルです。規模が大きくなったら、状態も NagiScript 側に置いて export "C" で読み書きする設計にできます。

store.ngs
var counter: i32 = 0

export "C" fn get_counter() -> i32 { return counter }
export "C" fn set_counter(v: i32) { counter = v }

export "C" fn inc() -> i32 {
    counter = counter + 1
    return counter
}
main.js
plusBtn.addEventListener('click', () => {
  const v = exports.inc();          // 状態は WASM 内で保持
  document.getElementById('count').textContent = v;
});

document.getElementById('reset').addEventListener('click', () => {
  exports.set_counter(0);
  document.getElementById('count').textContent = 0;
});

実践のヒント

  1. 最小限のエクスポートを心がける — WASM 境界を越える関数は計算集約処理に絞り、DOM は JS に委譲
  2. 型定義を活用する — エクスポート関数は小さい関数に分割し、nagiscript wasm main.ngs の出力から呼び出し方を確認する
  3. 重い処理は Web Worker に移す — WASM の同期実行はメインスレッドをブロックするため、計算量の高いエクスポートは Web Worker 側で実行して UI を止めない(async / std:async は現行では未実装)
  4. 数値は i32 / f64 がおすすめ — JS の Number と相性が良く、変換コストが小さい