Prima o poi capita a tutti: su Flutter Web serve una libreria JavaScript che non ha un equivalente Dart, oppure un'API del browser non ancora esposta. Fino a qualche tempo fa si usavano dart:html e package:js; oggi entrambi sono in disuso e la strada ufficiale è un'altra: dart:js_interop (static interop) insieme a package:web.

Il motivo non è solo estetico. Il compilatore dart2wasm non supporta dart:html né l'interop dinamico di package:js: se vuoi compilare in WebAssembly, l'unica opzione è l'interop statico. Vediamo come funziona, con esempi che puoi incollare in un progetto reale.

Perché dart:html non basta più

  • dart:html, dart:js e dart:js_util sono deprecati (a partire da Dart 3.x) e non funzionano con dart2wasm.
  • package:js con @JS/@anonymous dinamico è legacy: il sostituto è dart:js_interop, parte dell'SDK.
  • package:web è il wrapper moderno del DOM, generato dalle specifiche WebIDL: stessi nomi delle API del browser, nessuna "traduzione" dartificata.

Requisiti: Dart 3.3+ / Flutter 3.19+. Nel pubspec.yaml:

dependencies:
  web: ^1.1.0

I tipi JS: come ragiona dart:js_interop

La libreria introduce una gerarchia di tipi che rappresentano valori JavaScript: JSAny, JSObject, JSFunction, JSArray, JSPromise, JSString, JSNumber, JSBoolean, JSExportedDartFunction.

Questi non sono valori Dart: sono "handle" verso l'altro mondo. La conversione è esplicita:

import 'dart:js_interop';

final JSString js = 'ciao'.toJS;   // Dart -> JS
final String dart = js.toDart;      // JS -> Dart

final JSArray<JSString> lista = <JSString>['a'.toJS, 'b'.toJS].toJS;
final List<String> back = lista.toDart.map((e) => e.toDart).toList();

Regola pratica: nelle firme external puoi usare direttamente i tipi Dart primitivi (String, int, double, num, bool, void, Null), che vengono convertiti automaticamente. Per tutto il resto servono i tipi JS*.

Primo contatto: chiamare funzioni globali

Una funzione globale JavaScript si dichiara con @JS e external, senza corpo:

import 'dart:js_interop';

@JS('alert')
external void showAlert(String message);

@JS('console.log')
external void consoleLog(JSAny? value);

@JS('encodeURIComponent')
external String encodeUriComponentJs(String value);

Se la funzione vive dentro un namespace, puoi dichiararlo una volta sola a livello di libreria:

@JS('myLib')
library my_lib_interop;

import 'dart:js_interop';

@JS('version')
external String get version; // -> myLib.version

@JS('init')
external void init(); // -> myLib.init()

Per verificare a runtime se un simbolo esiste (utile con SDK caricati via <script>):

import 'dart:js_interop';
import 'dart:js_interop_unsafe';

bool get isLibLoaded => globalContext.has('myLib');

Descrivere oggetti JS con gli extension type

Il cuore dell'interop moderno sono gli extension type: wrapper a costo zero su JSObject che descrivono la "forma" di un oggetto JavaScript.

Immaginiamo una libreria MyPlayer con costruttore, metodi, getter e un campo callback:

import 'dart:js_interop';

@JS('MyPlayer')
extension type MyPlayer._(JSObject _) implements JSObject {
  external MyPlayer(String containerId, JSObject options);

  external void play();
  external void pause();
  external void destroy();

  external String get currentSrc;
  external double get duration;

  external JSPromise<JSBoolean> load(String url);

  external set onEnded(JSFunction callback);
}

Per gli oggetti di configurazione "anonimi" (i classici object literal) si usa un extension type con costruttore external factory e parametri nominati:

extension type PlayerOptions._(JSObject _) implements JSObject {
  external factory PlayerOptions({
    bool autoplay,
    bool muted,
    double volume,
  });

  external bool get autoplay;
}

Uso dal lato Dart:

final player = MyPlayer(
  'player-container',
  PlayerOptions(autoplay: true, muted: true, volume: 0.8),
);
player.play();

Nota importante: un extension type non aggiunge controlli a runtime. Se l'oggetto JS non ha davvero quel metodo, l'errore arriva solo al momento della chiamata. Gli extension type sono documentazione tipizzata, non una validazione.

Promise e Future

Le Promise JavaScript si convertono in Future con .toDart:

Future<bool> loadTrack(MyPlayer player, String url) async {
  try {
    final JSBoolean result = await player.load(url).toDart;
    return result.toDart;
  } catch (e) {
    // L'errore arriva dal mondo JS: può essere un JSObject (Error), una
    // stringa, qualsiasi cosa. Non assumere un tipo specifico.
    debugPrint('Errore di caricamento: $e');
    return false;
  }
}

Se la Promise risolve undefined, dichiara JSPromise<JSAny?> e ignora il valore. Al contrario, per esporre un Future Dart a JavaScript esiste .toJS su Future, che produce una JSPromise.

Callback: da Dart a JavaScript

Una funzione Dart diventa richiamabile da JS con .toJS:

void attachHandlers(MyPlayer player) {
  player.onEnded = (() {
    debugPrint('Riproduzione terminata');
  }).toJS;
}

Se la callback riceve argomenti, devono essere tipi interop:

extension type ProgressEvent._(JSObject _) implements JSObject {
  external double get percent;
}

void onProgress(ProgressEvent event) {
  debugPrint('Avanzamento: ${event.percent}%');
}

final jsCallback = onProgress.toJS;

Due accortezze:

  • .toJS su una funzione crea ogni volta un nuovo oggetto JS: se devi rimuovere un listener, conserva il riferimento.
  • Le callback che devono sopravvivere al widget vanno "spente" nel dispose(), altrimenti rischi riferimenti pendenti e memory leak.

package:web: il DOM senza sorprese

package:web espone le API del browser con i nomi originali:

import 'package:web/web.dart' as web;

void removeSplash() {
  web.document.querySelector('#loading')?.remove();
}

void saveToken(String token) {
  web.window.localStorage.setItem('token', token);
}

String? readToken() => web.window.localStorage.getItem('token');

bool get isOnline => web.window.navigator.onLine;

Gli eventi si gestiscono con addEventListener e una JSFunction:

import 'dart:js_interop';
import 'package:web/web.dart' as web;

void listenVisibility(void Function(bool visible) onChange) {
  web.document.addEventListener(
    'visibilitychange',
    (web.Event _) {
      onChange(web.document.visibilityState == 'visible');
    }.toJS,
  );
}

Caricare un SDK esterno a runtime

Spesso non vuoi inserire lo <script> in web/index.html (per esempio per caricarlo solo dopo il consenso ai cookie). Puoi iniettarlo dinamicamente:

import 'dart:async';
import 'dart:js_interop';
import 'package:web/web.dart' as web;

Future<void> loadScript(String src) {
  final completer = Completer<void>();

  final script = web.HTMLScriptElement()
    ..src = src
    ..async = true;

  script.addEventListener('load', (web.Event _) {
    if (!completer.isCompleted) completer.complete();
  }.toJS);

  script.addEventListener('error', (web.Event _) {
    if (!completer.isCompleted) {
      completer.completeError(StateError('Impossibile caricare $src'));
    }
  }.toJS);

  web.document.head!.appendChild(script);
  return completer.future;
}

E poi, prima di usare la libreria:

Future<void> ensureSdk() async {
  if (globalContext.has('myLib')) return;
  await loadScript('https://cdn.example.com/mylib.min.js');
}

Passare dati strutturati: jsify e dartify

Per mappe e liste "generiche" esistono due scorciatoie:

import 'dart:js_interop';

final payload = <String, Object?>{
  'userId': 42,
  'tags': ['flutter', 'web'],
  'premium': true,
}.jsify() as JSObject;

final Object? back = payload.dartify(); // Map<Object?, Object?>

jsify()/dartify() fanno una conversione profonda: comode per JSON, costose su strutture grandi e in un loop. Quando la forma dei dati è nota, preferisci un extension type tipizzato: è più veloce e autodocumentante.

Isolare il codice web dal resto dell'app

dart:js_interop e package:web non compilano su Android, iOS o desktop. La soluzione standard sono le conditional import:

// lib/src/web_share/web_share.dart
export 'web_share_stub.dart'
    if (dart.library.js_interop) 'web_share_web.dart';
// lib/src/web_share/web_share_stub.dart
class WebShare {
  static bool get isSupported => false;
  static Future<bool> share({required String text, String? url}) async => false;
}
// lib/src/web_share/web_share_web.dart
import 'dart:js_interop';

@JS('navigator')
external _Navigator get _navigator;

extension type _Navigator._(JSObject _) implements JSObject {
  external JSPromise<JSAny?> share(JSObject data);
  external bool canShare(JSObject data);
}

extension type _ShareData._(JSObject _) implements JSObject {
  external factory _ShareData({String? title, String? text, String? url});
}

class WebShare {
  static bool get isSupported => _navigator.has('share');

  static Future<bool> share({required String text, String? url}) async {
    if (!isSupported) return false;
    try {
      await _navigator.share(_ShareData(text: text, url: url)).toDart;
      return true;
    } catch (_) {
      // L'utente ha annullato (AbortError) o il browser ha rifiutato.
      return false;
    }
  }
}

has arriva da dart:js_interop_unsafe, da importare nel file web. Nel resto dell'app importi solo web_share.dart e scrivi codice che compila ovunque:

if (WebShare.isSupported) {
  await WebShare.share(text: 'Guarda questo articolo', url: articleUrl);
} else {
  await Share.share(articleUrl); // fallback con share_plus
}

Chiamare Dart da JavaScript

Per esporre una funzione Dart al mondo JS (tipico per i callback di SDK di pagamento o analytics inseriti in index.html):

import 'dart:js_interop';
import 'dart:js_interop_unsafe';

void registerBridge() {
  globalContext['onFlutterReady'] = (() {
    debugPrint('Flutter è pronto, chiamato da JS');
  }).toJS;
}

Se devi passare a JavaScript un oggetto Dart opaco (da restituirti intatto più avanti), usa JSBoxedDartObject tramite toJSBox / toDartObject, invece di tentare conversioni manuali.

Errori comuni (e come evitarli)

  • Usare ancora dart:html: blocca la compilazione con dart2wasm. Migra a package:web (le API hanno nomi quasi identici a quelle del browser).
  • Dimenticare .toDart / .toJS: JSString e String non sono intercambiabili, nemmeno in print.
  • Fidarsi degli extension type: non verificano nulla a runtime. Controlla sempre con has(...) che l'SDK sia caricato.
  • Catturare un tipo di eccezione specifico: gli errori JS si presentano diversamente su dart2js e dart2wasm. Usa catch (e) generico.
  • jsify() ovunque: usalo solo per payload dinamici, non nei percorsi caldi.
  • Non rimuovere i listener: ogni .toJS crea una nuova funzione; conserva il riferimento per removeEventListener.
  • Testare solo con dart2js: se punti al Wasm, verifica con flutter build web --wasm fin da subito.

Checklist finale

  1. package:web al posto di dart:html, dart:js_interop al posto di package:js.
  2. Tipizza le API esterne con extension type, un file per SDK.
  3. Isola tutto dietro conditional import con uno stub per le altre piattaforme.
  4. Converti esplicitamente ai confini (toJS/toDart), mai in mezzo alla logica di business.
  5. Avvolgi l'interop in un servizio Dart ordinario, così i widget non vedono mai un JSObject.

Con questo schema l'interoperabilità smette di essere un punto fragile: diventa un semplice adapter, testabile e pronto anche per la compilazione in WebAssembly.