Dart — Web Compilation, JS Interop & Cross-Platform Patterns
Compilation Targets — dart2js, DDC, Wasm
bash
# ── DDC (Dart Development Compiler) — dev mode ──
# Fast compilation, readable JS, hot reload. Used by `webdev serve`.
# Produces per-library modules (good for debugging).
dart run build_runner serve web:8080
# ── dart2js — production ──
# AOT-like, slow compilation, minified + optimized JS. Fast runtime.
# Tree-shaken, dead-code-eliminated. Single JS file + source map.
dart compile js -O2 -o build/web/main.js lib/main.dart
# -O0: no optimization (debugging)
# -O2: recommended for production (small, fast)
# -O4: aggressive (may break some patterns — test thoroughly)
# ── Wasm (experimental) ──
# Compiles to WebAssembly. Better type fidelity (Dart types → Wasm types),
# potentially faster execution. Still maturing.
dart compile wasm -o build/web/main.wasm lib/main.dart
# ── Verify behavior differs between DDC and dart2js ──
# DDC: readable JS, runtime type checks, asserts enabled.
# dart2js: minified, asserts stripped, aggressive optimizations.
# Test production with dart2js, not just DDC in dev.
JS Interop — dart:js_interop & Extension Types (Dart 3.3+)
dart
import 'dart:js_interop';
// ── Declaring JS functions with @JS() ──
@JS('Math.random')
external double random();
@JS('console.log')
external void log(String message);
void main() {
log('Hello from Dart'); // calls JS console.log
print(random()); // calls JS Math.random()
}
// ── Wrapping JS objects with extension types (Dart 3.3+) ──
// Extension types provide a typed Dart interface over a JS object.
// Zero runtime overhead — the type is erased, the JS object is used directly.
extension type JSPromise<T>._(JSObject _) implements JSObject {
external static JSPromise<T> resolve(T value);
external JSPromise<T> then(JSFunction onFulfilled);
external JSPromise<T> catch_(JSFunction onRejected);
}
extension type JSArray<T>._(JSObject _) implements JSObject {
external T operator [](int index);
external int get length;
external void push(T value);
}
// ── Calling a JS library (e.g., a charting library) ──
@JS('Chart')
extension type JSChart._(JSObject _) implements JSObject {
external factory JSChart(String selector, JSChartConfig config);
external void draw();
}
extension type JSChartConfig._(JSObject _) implements JSObject {
external factory JSChartConfig({
String type,
JSArray<JSChartDataset> datasets,
});
}
extension type JSChartDataset._(JSObject _) implements JSObject {
external factory JSChartDataset({
String label,
JSArray<double> data,
});
}
JS Interop — Conversion Between Dart and JS
dart
import 'dart:js_interop';
// Dart ↔ JS type conversions (extension types handle the bridging):
void main() {
// String → JSString and back
JSString jsStr = 'hello'.toJS;
String dartStr = jsStr.toDart;
// List → JSArray and back
JSArray<JSNumber> jsArr = [1, 2, 3].map((n) => n.toJS).toList().toJS;
List<int> dartList = jsArr.toDart.map((js) => js.toDartInt).toList();
// Map → JSObject and back
final jsObj = {'name': 'Alice', 'age': 30}.jsify() as JSObject;
final dartMap = jsObj.dartify() as Map<String, dynamic>;
// Number → JSNumber and back
JSNumber jsNum = 42.toJS;
int dartInt = jsNum.toDartInt;
double dartDouble = jsNum.toDartDouble;
// bool → JSBoolean and back
JSBoolean jsBool = true.toJS;
bool dartBool = jsBool.toDart;
}
// ❌ Anti-pattern: using `dynamic` for JS interop (no type safety).
// dynamic x = someJsObject;
// x.anything(); // compiles, throws at runtime if method doesn't exist
// ✓ Correct: use extension types for typed JS interop.
Conditional Imports — Platform Abstraction
dart
// ── The pattern: a public file that conditionally re-exports ──
// storage.dart (public API)
export 'storage_io.dart' // native
if (dart.library.html) 'storage_web.dart'; // web
// storage_io.dart (native — uses dart:io File)
import 'dart:io';
class Storage {
final File _file;
Storage(this._file);
Future<String> read() => _file.readAsString();
Future<void> write(String data) => _file.writeAsString(data);
}
// storage_web.dart (web — uses localStorage via dart:html)
import 'dart:html';
class Storage {
final String _key;
Storage(this._key);
Future<String> read() async => window.localStorage[_key] ?? '';
Future<void> write(String data) async {
window.localStorage[_key] = data;
}
}
// ── Both implementations must export the SAME public API ──
// (Same class names, method signatures, return types.)
// The compiler picks the right one at compile time — zero runtime cost.
Cross-Platform HTTP
dart
// The `http` package abstracts the platform — uses fetch on web,
// HttpClient on native. No conditional imports needed for basic HTTP.
import 'package:http/http.dart' as http;
Future<Map<String, dynamic>> fetchJson(String url) async {
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) {
throw HttpException('HTTP ${response.statusCode}', uri: Uri.parse(url));
}
return jsonDecode(response.body) as Map<String, dynamic>;
}
// This works on BOTH web and native — the `http` package handles it.
// For advanced features (interceptors, cancellation), use `dio`.
Web Platform Limitations
dart
// ── 1. `int` is a JS double on web ──
// Native: int is 64-bit. Web: int is IEEE 754 double — precision lost above 2^53.
// On native:
print(9007199254740993); // 9007199254740993 (exact)
// On web:
print(9007199254740993); // 9007199254740992 (rounded — can't represent 2^53+1)
// Fix: use BigInt for large integers on web.
final big = BigInt.parse('9007199254740993');
print(big); // exact on all platforms
// ── 2. No `dart:io` on web ──
// File, HttpClient (server), Platform.exit, Process — all native-only.
// import 'dart:io'; // ✗ compile error on web
// Use dart:html, fetch, or the http package instead.
// ── 3. No isolates on web ──
// Dart on web is single-threaded. Isolate.run throws or runs on main thread.
// For CPU-heavy work on web, use Web Workers (via JS interop).
// await Isolate.run(() => heavy()); // ✗ may throw on web
// ── 4. No `dart:mirrors` on web/AOT ──
// Runtime reflection is unsupported in dart2js and AOT.
// import 'dart:mirrors'; // ✗ compile error on web
// Use code generation (json_serializable, freezed) instead of reflection.
DOM Access — dart:html
dart
import 'dart:html';
void main() {
// ── Querying the DOM ──
final button = querySelector('#myButton')!; // null if not found — use `!` or check
final items = querySelectorAll('.item'); // ElementList (iterable)
// ── Creating elements ──
final div = DivElement()
..text = 'Hello, Dart!' // set text
..classes.add('greeting') // add CSS class
..style.color = 'blue'; // inline style
document.body!.append(div);
// ── Events are Streams ──
button.onClick.listen((MouseEvent e) {
print('Button clicked at (${e.clientX}, ${e.clientY})');
});
input.onInput.listen((Event e) {
print('Input changed: ${input.value}');
});
window.onResize.listen((_) {
print('Window resized to ${window.innerWidth}x${window.innerHeight}');
});
// ── Cancel subscriptions to avoid memory leaks ──
final sub = button.onClick.listen((_) => handleClick());
// Later (cleanup):
sub.cancel(); // ← MUST cancel, or the listener leaks
// ── HTTP on web (low-level) ──
final response = await HttpRequest.getString('https://api.example.com/data');
print(response);
}
💡 Tips & Tricks
- Idiom: use the
httppackage (notdart:io'sHttpClient) for cross-platform HTTP —http.getworks on both web (fetch) and native (HttpClient). Avoids platform conditionals for HTTP. - Idiom: use conditional imports for platform-specific code —
export 'web.dart' if (dart.library.io) 'io.dart';chooses the web or native implementation. The exported API must match. Clean for cross-platform packages. - Idiom: use
dart:js_interop+ extension types (Dart 3.3+) for JS interop —@JS()declares JS functions, extension types wrap JS objects with typed interfaces. Safer thandynamicinterop. Use for calling JS libraries. - Idiom: use
jsonDecode/jsonEncode(fromdart:convert) for JSON — returnsdynamic/Map<String, dynamic>. Cast carefully (as Type?then handle null). For typed JSON, usejson_serializable(code generation). - Portability: use
BigIntfor integers above 2^53 that must work on web —inton web is a JS double and loses precision. On native,intis 64-bit and safe up to 2^63-1.
⚠️ Edge Cases & Gotchas
dart:iodoesn't work on web:File,HttpClient(server),Platform.exitare native-only. Use conditional imports (if (dart.library.html)) for cross-platform code.inton web is a JS double:intvalues above 2^53 lose precision on web. For large integers, useBigIntor accept the limitation. On native,intis 64-bit.- No isolates on web:
Isolate.runthrows or runs on the main thread. The web is single-threaded. Use Web Workers (via JS interop) for parallelism. - No
dart:mirrorson web/AOT:dart2jsand AOT don't support runtime reflection. Use code generation (json_serializable,freezed) instead of reflection. - DDC vs dart2js output differs: DDC (dev) produces readable JS with runtime checks; dart2js (prod) produces minified JS with asserts stripped. Test with dart2js, not just DDC.
jsonDecodereturnsdynamic:jsonDecode('{"a":1}')isMap<String, dynamic>but staticallydynamic. Cast:jsonDecode(s) as Map<String, dynamic>, then access.- Event listeners are
Streams:button.onClickisStream<MouseEvent>. Cancel subscriptions (sub.cancel()) to avoid memory leaks, especially in single-page apps. querySelectorreturnsElement?: null if not found. Use!(if certain) or check for null. Forgetting the null check is a common web bug.- Web Workers for parallelism: Dart on web is single-threaded. For CPU-heavy work, use Web Workers (via JS interop) — not Dart isolates (which don't exist on web).
- Flutter Web uses CanvasKit or HTML: CanvasKit (Skia/Wasm) has better fidelity but larger bundle (~2MB). HTML renderer is smaller but less precise. Choose based on needs.
🧠 Spot the Bug
A developer's cross-platform package uses dart:io directly for file storage. It works in dev but crashes in the web build:
dart
import 'dart:io';
class FileStorage {
final String path;
FileStorage(this.path);
Future<String> read() => File(path).readAsString();
Future<void> write(String data) => File(path).writeAsString(data);
}
How to make this cross-platform?
Answer
dart:io is native-only. Importing it in a web project fails at compile time (or the File usage fails at runtime). The code can't run on the web as-is.
The fix — use conditional imports to provide a web-specific implementation:
// storage.dart (public API — conditional export)
export 'storage_io.dart'
if (dart.library.html) 'storage_web.dart';
// storage_io.dart (native — uses dart:io)
import 'dart:io';
class FileStorage {
final String path;
FileStorage(this.path);
Future<String> read() => File(path).readAsString();
Future<void> write(String data) => File(path).writeAsString(data);
}
// storage_web.dart (web — uses localStorage)
import 'dart:html';
class FileStorage {
final String key;
FileStorage(this.key);
Future<String> read() async => window.localStorage[key] ?? '';
Future<void> write(String data) async {
window.localStorage[key] = data;
}
}
The conditional import if (dart.library.html) 'storage_web.dart' chooses the web implementation when dart:html is available, else the native (dart:io) implementation. Both export the same FileStorage class with the same API. The compiler includes only the matching file — zero runtime cost.
Or, use the http package if the "file" is a URL (it abstracts the platform).