Interazione con il DOM
Wasm e il DOM
WebAssembly non ha accesso diretto al DOM. Ogni interazione con l’albero HTML deve passare attraverso un bridge JavaScript. Questo è un vincolo architetturale: Wasm opera in una sandbox con accesso solo alla propria memoria lineare e alle funzioni importate.
Il Bridge JavaScript
Il pattern fondamentale prevede che Wasm chiami funzioni JavaScript importate, le quali a loro volta manipolano il DOM.
(module
;; Importare funzioni JS per il DOM
(import "dom" "createElement" (func $createElement (param i32 i32) (result i32)))
(import "dom" "setText" (func $setText (param i32 i32 i32)))
(import "dom" "appendChild" (func $appendChild (param i32 i32)))
(memory (export "memory") 1)
(func (export "buildUI")
;; Logica Wasm che chiama le funzioni bridge
;; I parametri sono puntatori alla memoria per le stringhe
;; e handle numerici per i nodi DOM
nop)
)
// Lato JavaScript: implementare il bridge
let nextHandle = 1;
const handleMap = new Map();
function getHandle(element) {
const h = nextHandle++;
handleMap.set(h, element);
return h;
}
function readString(memory, ptr, len) {
return new TextDecoder().decode(new Uint8Array(memory.buffer, ptr, len));
}
const importObject = {
dom: {
createElement(tagPtr, tagLen) {
const tag = readString(instance.exports.memory, tagPtr, tagLen);
const el = document.createElement(tag);
return getHandle(el);
},
setText(handle, textPtr, textLen) {
const el = handleMap.get(handle);
const text = readString(instance.exports.memory, textPtr, textLen);
el.textContent = text;
},
appendChild(parentHandle, childHandle) {
const parent = handleMap.get(parentHandle);
const child = handleMap.get(childHandle);
parent.appendChild(child);
},
},
};
wasm-bindgen e web-sys (Rust)
In Rust, wasm-bindgen e il crate web-sys eliminano la necessità di scrivere il bridge manualmente.
# Cargo.toml
[dependencies]
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = [
"Document", "Element", "HtmlElement",
"Window", "Node", "Event", "HtmlInputElement"
]}
use wasm_bindgen::prelude::*;
use web_sys::{Document, Element, HtmlElement, HtmlInputElement};
#[wasm_bindgen(start)]
pub fn main() -> Result<(), JsValue> {
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
let body = document.body().unwrap();
// Creare un elemento
let heading = document.create_element("h1")?;
heading.set_text_content(Some("Creato da Rust/Wasm!"));
body.append_child(&heading)?;
// Creare un input
let input = document.create_element("input")?;
input.set_attribute("type", "text")?;
input.set_attribute("placeholder", "Scrivi qui...")?;
body.append_child(&input)?;
// Creare un pulsante con event listener
let button = document.create_element("button")?;
button.set_text_content(Some("Saluta"));
let input_clone = input.clone();
let closure = Closure::wrap(Box::new(move |_event: web_sys::Event| {
let input_el: HtmlInputElement = input_clone.clone().dyn_into().unwrap();
let name = input_el.value();
web_sys::window()
.unwrap()
.alert_with_message(&format!("Ciao, {}!", name))
.unwrap();
}) as Box<dyn FnMut(_)>);
button.add_event_listener_with_callback(
"click",
closure.as_ref().unchecked_ref(),
)?;
closure.forget(); // Evitare il drop
body.append_child(&button)?;
Ok(())
}
Pattern di Comunicazione Wasm <-> DOM
1. Render Loop (Canvas/Giochi)
Wasm calcola lo stato, JS disegna sul Canvas:
const { instance } = await WebAssembly.instantiateStreaming(
fetch("game.wasm"), imports
);
const ctx = canvas.getContext("2d");
function gameLoop() {
// Wasm aggiorna lo stato del gioco
instance.exports.update();
// JS legge lo stato dalla memoria condivisa e disegna
const statePtr = instance.exports.getState();
const state = new Float32Array(
instance.exports.memory.buffer, statePtr, 100
);
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < state.length; i += 4) {
ctx.fillRect(state[i], state[i+1], state[i+2], state[i+3]);
}
requestAnimationFrame(gameLoop);
}
gameLoop();
2. Virtual DOM con Patch
Wasm genera un diff, JS applica le modifiche:
// Wasm produce un array di operazioni DOM
instance.exports.computeDiff(oldStatePtr, newStatePtr);
const opsPtr = instance.exports.getDiffOps();
const opsLen = instance.exports.getDiffOpsLen();
const ops = new Uint32Array(
instance.exports.memory.buffer, opsPtr, opsLen
);
// JS interpreta le operazioni
for (let i = 0; i < ops.length; i += 3) {
const op = ops[i]; // 0=create, 1=update, 2=remove
const nodeId = ops[i+1];
const value = ops[i+2];
// Applicare l'operazione al DOM reale
}
3. Message Passing
Comunicazione asincrona tramite coda di messaggi:
// Coda condivisa in SharedArrayBuffer
const queue = new Int32Array(sharedMemory.buffer, 0, 256);
// JS invia un messaggio a Wasm
function sendToWasm(msgType, payload) {
const idx = Atomics.load(queue, 0); // indice di scrittura
queue[idx + 1] = msgType;
queue[idx + 2] = payload;
Atomics.add(queue, 0, 2);
Atomics.notify(queue, 0);
}
Worker Threads
Per evitare di bloccare il thread principale, si può eseguire Wasm in un Web Worker:
// main.js
const worker = new Worker("wasm-worker.js");
worker.postMessage({ type: "compute", data: largeArray });
worker.onmessage = (e) => {
// Aggiornare il DOM con i risultati
document.getElementById("result").textContent = e.data.result;
};
// wasm-worker.js
let instance;
self.onmessage = async (e) => {
if (!instance) {
const { instance: inst } = await WebAssembly.instantiateStreaming(
fetch("heavy_compute.wasm"), {}
);
instance = inst;
}
if (e.data.type === "compute") {
// Scrivere i dati nella memoria Wasm
const ptr = instance.exports.alloc(e.data.data.length * 4);
new Float32Array(instance.exports.memory.buffer, ptr, e.data.data.length)
.set(e.data.data);
// Eseguire il calcolo pesante
const result = instance.exports.process(ptr, e.data.data.length);
// Restituire il risultato al thread principale
self.postMessage({ result });
}
};
Questo pattern separa il calcolo pesante (Wasm nel Worker) dall’aggiornamento dell’interfaccia (DOM nel thread principale), mantenendo l’UI sempre reattiva.