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:jsedart:js_utilsono deprecati (a partire da Dart 3.x) e non funzionano con dart2wasm.package:jscon@JS/@anonymousdinamico è 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:
.toJSsu 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 apackage:web(le API hanno nomi quasi identici a quelle del browser). - Dimenticare
.toDart/.toJS:JSStringeStringnon sono intercambiabili, nemmeno inprint. - 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
.toJScrea una nuova funzione; conserva il riferimento perremoveEventListener. - Testare solo con dart2js: se punti al Wasm, verifica con
flutter build web --wasmfin da subito.
Checklist finale
package:webal posto didart:html,dart:js_interopal posto dipackage:js.- Tipizza le API esterne con extension type, un file per SDK.
- Isola tutto dietro conditional import con uno stub per le altre piattaforme.
- Converti esplicitamente ai confini (
toJS/toDart), mai in mezzo alla logica di business. - 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.