Web dan interop

dart:js_interop untuk web dan pengenalan kompilasi WASM.

Daftar isi

Dart tidak berhenti di terminal dan server, karena bahasa ini juga dapat dikompilasi untuk berjalan di browser. Dua jalurnya adalah dart compile js yang menghasilkan JavaScript dan dart compile wasm yang menghasilkan WebAssembly, dan keduanya menggunakan jembatan yang sama, yaitu dart:js_interop. Topik ini membahas cara menulis kode yang memanggil JavaScript dari Dart, cara mengompilasinya ke kedua target beserta berkas yang dihasilkan, dan batas-batas eksekusinya di sandbox. Satu hal perlu disebutkan sejak awal, yaitu kode browser tidak pernah dieksekusi langsung di terminal, sehingga demo pada topik ini menyajikan hasil kompilasi dan penjelasan perilakunya di browser.

dart:js_interop sebagai jembatan ke JavaScript#

dart:js_interop adalah library bawaan SDK untuk berkomunikasi dengan JavaScript. Seluruh nilai JavaScript dinyatakan melalui kumpulan extension type, misalnya JSAny sebagai induknya, JSObject untuk objek, JSString untuk string, dan JSNumber untuk angka, sehingga tipe Dart dan tipe JavaScript tidak pernah dicampur begitu saja. Jembatan ke fungsi atau objek JavaScript dibuat melalui deklarasi external yang diberi anotasi @JS, dan anotasi itulah yang menyebut nama simbol di sisi JavaScript. Konversi dua arah disediakan sebagai extension, yaitu .toJS pada tipe Dart, .toDart pada JSString, serta .toDartInt dan .toDartDouble pada JSNumber.

Mekanisme ini menggantikan generasi interop lama, seperti package:js dan dart:html, yang kini dikelompokkan sebagai library lama pada dokumentasi dart.dev. Untuk mengakses API browser seperti document dan elemen halaman, dart.dev menyediakan package:web, yaitu kumpulan binding yang dibangun di atas dart:js_interop, sehingga deklarasi external tidak perlu ditulis satu per satu. Satu catatan penting yang menyatukan kedua target kompilasi, yaitu dart:js_interop adalah satu-satunya mekanisme interop yang didukung saat kompilasi ke WebAssembly, sehingga pemahaman atas mekanisme ini berlaku untuk kedua jalur.

Contoh: penghitung kata yang memanggil JavaScript#

Contoh yang digunakan sepanjang topik adalah penghitung kata. Halaman web menyimpan satu fungsi JavaScript bernama hitungKataJS yang menerima string dan mengembalikan jumlah katanya, sedangkan program Dart memanggil fungsi itu dengan satu kalimat sampel dan mencetak hasilnya melalui console.log. Alurnya menyentuh semua bagian interop yang penting, yaitu anotasi @JS, tipe parameter dan kembalian, konversi .toJS dan .toDartInt, serta pemanggilan API JavaScript bawaan seperti console.log.

Berkas Dart-nya adalah sebagai berikut. Baris pertamanya diberi komentar tidak-dijalankan karena kode semacam ini memang tidak dapat dieksekusi di VM sandbox, dan penyebabnya dijelaskan pada bagian Kesalahan umum.

dart main.dart
// tidak-dijalankan: kode dart:js_interop hanya berjalan di browser, bukan di VM.
import 'dart:js_interop';

/// Binding ke fungsi JavaScript hitungKataJS yang didefinisikan halaman.
@JS('hitungKataJS')
external JSNumber hitungKataJS(JSString teks);

/// Binding ke console.log milik JavaScript.
@JS('console.log')
external void log(JSAny? pesan);

void main() {
  final teks = 'Dart dan JavaScript bertukar data melalui dart:js_interop';
  final jumlah = hitungKataJS(teks.toJS).toDartInt;
  log('jumlah kata: $jumlah'.toJS);
}

Dua binding di atas mewakili dua arah kerja interop. Fungsi hitungKataJS menunjuk fungsi JavaScript milik halaman, parameter JSString menerima hasil konversi .toJS dari string Dart, dan nilai kembaliannya bertipe JSNumber yang diubah menjadi int dengan .toDartInt. Binding log menunjuk console.log, argumennya bertipe JSAny? yang luas, dan string Dart diberikan kepadanya setelah dikonversi dengan .toJS. Apabila fungsi main berjalan di browser, konsolnya menampilkan pesan “jumlah kata: 7” sesuai jumlah kata pada kalimat sampel.

Halaman webnya ditulis sebagai berkas HTML biasa yang mendefinisikan fungsi JavaScript tersebut lalu memuat hasil kompilasi Dart melalui tag <script>.

html penghitung.html
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="utf-8">
  <title>Penghitung kata dengan dart:js_interop</title>
</head>
<body>
  <h1>Penghitung kata</h1>
  <script>
    // fungsi JavaScript yang dipanggil dari Dart
    function hitungKataJS(teks) {
      return teks.trim().split(/\s+/).filter(
        function (kata) { return kata.length > 0; }
      ).length;
    }
  </script>
  <script src="main.js" defer></script>
</body>
</html>

Fungsi hitungKataJS memangkas spasi berlebih dengan pola \s+, menyaring potongan kosong, lalu mengembalikan panjang hasilnya. Tag terakhir memuat main.js, yaitu nama keluaran bawaan dari perintah kompilasi pada bagian berikutnya. Eksekusi nyatanya terjadi di browser, dan sandbox tidak menyediakan browser yang dapat diluncurkan, sehingga dua bagian berikutnya menampilkan proses kompilasinya saja.

Kompilasi ke JavaScript#

Kedua berkas di atas disalin ke folder contoh dengan blok berikut, yang menghapus folder lama terlebih dahulu agar seluruh percobaan dapat diulang dari nol kapan saja.

bash
# siapkan folder contoh proyek web
rm -rf ~/contoh/melampaui-mobile/web-dan-interop/web
mkdir -p ~/contoh/melampaui-mobile/web-dan-interop/web
cd ~/contoh/melampaui-mobile/web-dan-interop/web
export PATH=/home/kal/dart-sdk/bin:$PATH
cat > main.dart <<'EOF'
// tidak-dijalankan: kode dart:js_interop hanya berjalan di browser, bukan di VM.
import 'dart:js_interop';

/// Binding ke fungsi JavaScript hitungKataJS yang didefinisikan halaman.
@JS('hitungKataJS')
external JSNumber hitungKataJS(JSString teks);

/// Binding ke console.log milik JavaScript.
@JS('console.log')
external void log(JSAny? pesan);

void main() {
  final teks = 'Dart dan JavaScript bertukar data melalui dart:js_interop';
  final jumlah = hitungKataJS(teks.toJS).toDartInt;
  log('jumlah kata: $jumlah'.toJS);
}
EOF
cat > penghitung.html <<'EOF'
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="utf-8">
  <title>Penghitung kata dengan dart:js_interop</title>
</head>
<body>
  <h1>Penghitung kata</h1>
  <script>
    // fungsi JavaScript yang dipanggil dari Dart
    function hitungKataJS(teks) {
      return teks.trim().split(/\s+/).filter(
        function (kata) { return kata.length > 0; }
      ).length;
    }
  </script>
  <script src="main.js" defer></script>
</body>
</html>
EOF

Perintah dart compile js mengompilasi program Dart menjadi JavaScript. Pesan penutupnya menyebut jumlah byte input, jumlah karakter keluaran, dan durasinya, tetapi angka durasi berubah antarjalanan, sehingga blok berikut membuang pesan itu dan menampilkan isi folder sebagai bukti keluarannya. Pada pengukuran di sandbox, kompilasi program kecil ini berlangsung di bawah satu detik dan menghasilkan sekitar 127 ribu karakter JavaScript.

bash
# kompilasi program Dart menjadi JavaScript
dart compile js main.dart -o main.js > /dev/null
# tampilkan isi folder setelah kompilasi
ls
text Keluaran
main.dart
main.js
main.js.deps
main.js.map
penghitung.html

Berkas main.js adalah seluruh program yang siap dimuat browser. Ukurannya jauh melebihi sumber asalnya, dan hal itu wajar karena runtime Dart untuk JavaScript ikut terkompilasi di dalamnya. Compiler juga melakukan tree shaking, yaitu membuang kelas, fungsi, dan metode yang tidak terpakai, sehingga ukuran akhirnya bergantung pada bagian yang benar-benar dipanggil. Berkas main.js.map adalah source map yang memetakan keluaran terkompilasi kembali ke sumber Dart untuk keperluan debugging, sedangkan main.js.deps mencatat ketergantungan antarbagian program. Untuk menjalankannya, buka penghitung.html di browser, baik langsung dari sistem berkas maupun melalui server pengembangan, dan pesan “jumlah kata: 7” akan terbaca di konsol browser.

Kompilasi ke WebAssembly#

Perintah dart compile wasm mengompilasi program Dart yang sama menjadi modul WebAssembly. Targetnya adalah WasmGC, yaitu standar WebAssembly dengan dukungan garbage collection, ditambah built-in string JavaScript dan penanganan exception yang telah distandarkan. Pesan penutupnya sudah menyebut kedua berkas utamanya, sehingga keluaran berikut dapat ditampilkan apa adanya.

bash
# kompilasi program yang sama menjadi WebAssembly
dart compile wasm main.dart -o main.wasm
# tampilkan pasangan berkas utamanya
ls main.wasm main.mjs
text Keluaran
Generated wasm module 'main.wasm', and JS init file 'main.mjs'.
main.mjs
main.wasm

Berkas main.wasm adalah modul WebAssembly-nya, sedangkan main.mjs adalah modul JavaScript yang memuat dan menginstansiasi modul tersebut di browser. Keduanya menjadi satu paket yang tidak dapat dipisahkan, ditambah berkas pendukung seperti main.support.js dan peta sumber main.wasm.map. Perbedaannya dengan JavaScript terletak pada cara pemuatan, karena modul .mjs adalah modul ES yang mengunduh .wasm melalui fetch, sehingga halaman pengujinya sebaiknya disajikan melalui server HTTP. Persyaratan versi browsernya pun lebih ketat dibandingkan target JavaScript, yaitu Chrome dan Edge 137 ke atas, Firefox 134 ke atas, dan Safari 26.2 ke atas menurut dokumentasi dart.dev.

Pemuatan modul dilakukan dari halaman yang mengimpor main.mjs, dengan bentuk sederhananya seperti berikut.

html Contoh: memuat main.wasm dari halaman
<script type="module">
  import { compileStreaming } from './main.mjs';
  const app = await compileStreaming(fetch('main.wasm'));
  const instantiatedApp = await app.instantiate({}, {});
  instantiatedApp.invokeMain();
</script>

Setelah invokeMain dipanggil, fungsi main Dart berjalan di dalam WebAssembly dan menghasilkan perilaku yang sama dengan versi JavaScript, yaitu satu baris pesan di konsol browser. Perbedaannya berada di bawah permukaan, karena kode berjalan pada mesin WebAssembly dengan garbage collection bawaan dan bukan pada mesin JavaScript. Deklarasi @JS pada berkas Dart tidak perlu diubah sedikit pun untuk target ini, karena dart:js_interop memang dirancang untuk kedua compiler web.

Kesalahan umum: menjalankan kode interop di VM#

Karena dart:js_interop hanya disediakan bagi compiler web, VM yang biasa digunakan melalui dart run tidak memiliki implementasinya. Menjalankan main.dart dengan dart run pun berakhir dengan error kompilasi, dan pesan pertamanya langsung menyebut penyebabnya, yaitu library yang tidak tersedia pada platform tersebut. Pesan lanjutannya panjang karena seluruh tipe JS* dan konversi .toJS ikut menghilang dari pandangan VM, tetapi semuanya hanya turunan dari satu penyebab yang sama. Enam baris pertama pesannya ditampilkan berikut ini, sedangkan perintahnya berakhir dengan kode keluar yang bukan nol.

bash
# pesan error VM yang dibatasi enam baris pertama
dart run main.dart 2>&1 | head -6
text Keluaran
main.dart:2:8: Error: Dart library 'dart:js_interop' is not available on this platform.
import 'dart:js_interop';
       ^
Context: The unavailable library 'dart:js_interop' is imported through these paths:

    main.dart => dart:js_interop

Solusinya bukan memperbaiki kode, melainkan memilih alat yang tepat. Kode interop diuji melalui hasil kompilasinya di browser, sedangkan logika yang ingin diuji dengan cepat di terminal sebaiknya dipisahkan ke berkas Dart biasa tanpa import JavaScript. Pola yang umum adalah menjaga perhitungan tetap murni pada satu berkas, lalu memanggilnya dari berkas interop, sehingga kedua lingkungan mendapat bagian yang sesuai dengan kekuatannya masing-masing.

Ringkasan#

  • dart:js_interop memanggil JavaScript melalui deklarasi external beranotasi @JS dengan tipe extension JS*, dan konversinya dilakukan melalui .toJS, .toDart, serta .toDartInt.
  • dart compile js menghasilkan satu berkas .js yang siap dimuat browser melalui tag <script>, bersama peta sumber dan catatan ketergantungannya.
  • dart compile wasm menghasilkan pasangan .wasm dan .mjs yang disajikan melalui server HTTP dan hanya berjalan pada browser yang mendukung WasmGC.
  • Kode interop tidak dapat dijalankan di VM, sehingga pengujian dilakukan di browser setelah kompilasi, dan logika murni sebaiknya dipisahkan ke berkas tanpa interop.

Referensi#