Миграция веб-приложения на Flutter с dart:html на package:web и dart:js_interop
Пошаговая миграция с устаревших dart:html, dart:js_util и package:js на package:web 1.1.1 и dart:js_interop: как найти каждый проблемный импорт с помощью компилятора dart2wasm, что переименовывает dart fix, а что нет, ловушки JSImmutableListWrapper и innerHTML, и как проверить результат через flutter build web --wasm.
Веб-код на Flutter в рамках одного приложения с горсткой вызовов dart:html мигрируется за полдня. Код, в котором dart:html просочился в общие пакеты, в моки или в плагин, который вы сами поддерживаете, займёт неделю, и узким местом почти никогда не оказывается ваш собственный код: это транзитивная зависимость, которая всё ещё импортирует устаревшую библиотеку. Ничего из этого больше не является необязательным. dart:html, dart:js, dart:js_util и package:js были помечены устаревшими в Dart 3.7 (февраль 2025), ни один из них не компилируется под dart2wasm, а замена, package:web 1.1.1 вместе с dart:js_interop, стабильна с июля 2024 года. Это руководство ориентировано на текущий канал stable, Flutter 3.47.2 с Dart 3.13.2 (выпущен 2026-08-27), и на package:web 1.1.1, который требует Dart ^3.4.0. Каждый вывод компилятора ниже получен в реальном запуске на стабильном наборе инструментов Flutter 3.44.8 / Dart 3.12.2 с тем же package:web 1.1.1.
Почему откладывать это больше нельзя
- WebAssembly зависит от этого.
dart2wasmотказывается компилировать программу, которая транзитивно доходит доdart:html. Если вам нужен выигрыш, описанный в статье про сборку веб-приложения на Flutter командойflutter build web --wasm, эта миграция является платой за вход, а не оптимизацией. - Устаревание уже влияет на сборку.
dart analyzeсообщаетdeprecated_member_useпрямо на строке импорта, поэтому любая задача CI с--fatal-infosуже падает или находится в одном изменении конфигурации от падения. package:webверсионируется отдельно от SDK. Новые API браузера приходят как версия пакета, а не ждут релиза SDK, иpackage:webгенерируется напрямую из Web IDL, поэтому имена совпадают с MDN, а не с руководством по стилю Dart образца 2013 года.- Если вы публикуете пакет, ваши пользователи не смогут компилировать в Wasm, пока вы не мигрируете. Один импорт
dart:htmlв конечном пакете блокирует весь граф зависимостей ниже по цепочке.
Что ломается
| Область | Изменение | Серьёзность |
|---|---|---|
| Имена типов | Имена в стиле Dart возвращаются к именам из IDL: HtmlElement становится HTMLElement, InputElement становится HTMLInputElement, AnchorElement становится HTMLAnchorElement | высокая, но почти всё автоматизируется |
| Коллекции | querySelectorAll и children возвращают NodeList / HTMLCollection, которые не реализуют List | высокая |
| Проверки типов | is и as больше не работают на типах браузера, потому что каждый тип package:web стирается до JSObject | высокая |
| Моки | У extension type нет виртуальной диспетчеризации, поэтому мок с implements от класса dart:html не может реализовать тип package:web | высокая |
| Сигнатуры типов | innerHTML имеет тип JSAny, слушатели событий принимают JSFunction, поэтому в местах вызова нужен .toJS | средняя |
| Зоны | Колбэки больше не привязываются к текущей зоне автоматически | средняя |
| Условные импорты | dart.library.html должен стать dart.library.js_interop | средняя |
| Платформенные представления | Фабрики представлений должны возвращать элемент package:web и регистрироваться через dart:ui_web | средняя |
dart:js_util | getProperty / setProperty / callMethod переезжают в dart:js_interop_unsafe с ключами типа JSAny | низкая, механическая |
Подготовительный список
- Flutter 3.47.2 или новее на канале stable. Подойдёт всё начиная с Flutter 3.22 (Dart 3.4), но описанные ниже исправления анализатора работают лучше в свежих SDK.
flutter pub add web, который разрешается вweb: ^1.1.1.- Задача CI, которая запускает
flutter build web --wasm, даже если вы пока не поставляете сборку Wasm. Это единственный надёжный детектор устаревших импортов, спрятанных в зависимостях. - Отдельная ветка, а не серия мелких коммитов в
main. Проход переименования затрагивает сразу много файлов, и по частям его тяжело ревьюить. - Список пакетов, от которых вы зависите и которые последний раз публиковались до середины 2024 года. Это ваши вероятные блокеры.
Шаги миграции
-
Найдите каждый проблемный импорт компилятором, а не через grep.
grep -r "dart:html" lib/находит ваш код и пропускает зависимость тремя уровнями ниже, которая на самом деле вас блокирует.dart2wasmвместо этого печатает полную цепочку импортов. Запуститеflutter build web --wasmи прочитайте первую ошибку:Target dart2wasm failed: ProcessException: Process exited abnormally with exit code 254: lib/legacy_bit.dart:1:8: Error: Dart library 'dart:html' is not available on this platform. import 'dart:html' as html; ^ Context: The unavailable library 'dart:html' is imported through these packages: main.dart => package:fweb => dart:html Detailed import paths for (some of) the these imports: main.dart => package:fweb/main.dart => package:fweb/legacy_bit.dart => dart:htmlБлок “Detailed import paths” и есть самое полезное. Когда цепочка заканчивается на пакете из pub, а не на вашем собственном
lib/, вы нашли зависимость, которую придётся обновить, форкнуть или заменить, прежде чем приложение сможет переехать.Проверка: каждый путь, напечатанный компилятором, записан и отнесён к категории “мой код”, “мой пакет” или “сторонний”. Ничего не остаётся с пометкой “наверное, нормально”.
-
Смените импорт и добавьте зависимость. По файлам
import 'dart:html' as html;превращается вimport 'package:web/web.dart' as web;. Сохраните префикс. Импортpackage:webбез префикса вносит в область видимости несколько сотен имён верхнего уровня и конфликтует с собственнымиElement,ImageиTextиз Flutter.flutter pub add webПроверка:
flutter pub deps | grep webпоказываетweb 1.1.1, а ошибки файла меняются с “deprecated” на список неопределённых имён. Неопределённые имена — это прогресс, они делают работу по переименованию видимой. -
Запустите
dart fixдля переименования типов, остальное доделайте руками. Вpackage:webвходитlib/fix_data.yamlсо 141 преобразованием переименования, поэтому анализатор может переписать большинство устаревших имён типов, как только новый импорт на месте:dart fix --dry-run dart fix --applyВ файле, где есть
InputElement,HtmlElementиCheckboxInputElement,dart fix --applyпереписывает первые два и оставляет третий нетронутым:// After dart fix --apply, package:web 1.1.1 final HTMLInputElement input = HTMLInputElement(); final HTMLElement box = document.querySelector('#box') as HTMLElement; final CheckboxInputElement cb = CheckboxInputElement(); // still undefinedCheckboxInputElement— это не переименование, а удобный тип изdart:htmlбез аналога в IDL. Ручная форма выглядит так:HTMLInputElement()..type = 'checkbox'. Если для имени нет преобразования, посмотрите аннотацию@Nativeу старого классаdart:html: её значение и есть имя вpackage:web.Проверка:
dart analyzeне выдаёт ни одной диагностикиundefined_classиundefined_functionв мигрированных файлах. -
Замените
dart:js_utilиpackage:jsнаdart:js_interop. Старые динамические аксессоры переезжают вdart:js_interop_unsafeи принимают ключиJSAnyвместоString. Объявленный interop переходит от классов с@JS()к extension type надJSObject. Было:// dart:html + dart:js_util, Dart 3.12.2 import 'dart:convert'; import 'dart:html'; import 'dart:js_util' as js_util; void downloadCsv(String csv) { final blob = Blob([csv], 'text/csv'); final url = Url.createObjectUrlFromBlob(blob); AnchorElement(href: url) ..download = 'report.csv' ..click(); Url.revokeObjectUrl(url); } Future<Map<String, dynamic>> loadJson(String path) async { final text = await HttpRequest.getString(path); return jsonDecode(text) as Map<String, dynamic>; } void unsafeAccess() { final maybe = js_util.getProperty(window, 'myLegacyGlobal'); if (maybe != null) { js_util.callMethod(maybe, 'init', ['flutter']); } }Стало:
// package:web 1.1.1 + dart:js_interop, Dart 3.12.2 import 'dart:convert'; import 'dart:js_interop'; import 'dart:js_interop_unsafe'; import 'package:web/web.dart'; void downloadCsv(String csv) { final blob = Blob([csv.toJS].toJS, BlobPropertyBag(type: 'text/csv')); final url = URL.createObjectURL(blob); final anchor = document.createElement('a') as HTMLAnchorElement ..href = url ..download = 'report.csv'; anchor.click(); URL.revokeObjectURL(url); } Future<Map<String, dynamic>> loadJson(String path) async { final response = await window.fetch(path.toJS).toDart; final text = await response.text().toDart; return jsonDecode(text.toDart) as Map<String, dynamic>; } void unsafeAccess() { final maybe = globalContext.getProperty<JSObject?>('myLegacyGlobal'.toJS); if (maybe != null) { maybe.callMethod<JSAny?>('init'.toJS, 'flutter'.toJS); } }Три шаблона, которые стоит запомнить:
allowInterop(fn)превращается вfn.toJS,js_util.promiseToFuture(p)превращается вp.toDart, аJSPromise<T>, ожидаемый через.toDart, даётFuture<T>. УHttpRequestнет прямой замены, которую стоило бы использовать; ответ — этоwindow.fetchилиpackage:http.Проверка:
dart analyzeчист, и ни один файл в репозитории больше не импортируетdart:js,dart:js_utilилиpackage:js. -
Перенесите фабрики платформенных представлений в
dart:ui_web. Любой код, регистрирующий HTML-представление, теперь обязан возвращать элементpackage:web. Реестр живёт вdart:ui_web, аregisterViewFactoryобъявлен какregisterViewFactory(String viewType, Function viewFactory, {bool isVisible = true}):// Flutter 3.44.8, package:web 1.1.1 import 'dart:ui_web' as ui_web; import 'package:flutter/widgets.dart'; import 'package:web/web.dart' as web; const _viewType = 'startdebugging-iframe'; void registerIframeFactory() { ui_web.platformViewRegistry.registerViewFactory(_viewType, (int viewId) { final iframe = web.document.createElement('iframe') as web.HTMLIFrameElement ..src = 'https://startdebugging.net/' ..style.border = 'none' ..style.width = '100%' ..style.height = '100%'; return iframe; }); } class EmbeddedSite extends StatelessWidget { const EmbeddedSite({super.key}); @override Widget build(BuildContext context) => const HtmlElementView(viewType: _viewType); }Проверка: представление отрисовывается в
flutter run -d chrome, аflutter build web --wasmкомпилирует файл без нареканий. -
Перепишите условные импорты на
dart.library.js_interop. Старое написание подdart2wasmмолча выбирает заглушку, потому что тамdart.library.htmlложно, и это даётUnsupportedErrorво время выполнения вместо ошибки компиляции. Это худший режим отказа во всей миграции:// lib/platform_open.dart, Dart 3.12.2 export 'src/open_stub.dart' if (dart.library.io) 'src/open_io.dart' if (dart.library.js_interop) 'src/open_web.dart';// lib/src/open_web.dart import 'package:web/web.dart' as web; void openUrl(String url) => web.window.open(url, '_blank');Проверка: сделайте grep по репозиторию на
dart.library.htmlи убедитесь, что совпадений нет, затем запустите приложение на нативной платформе и в вебе, чтобы убедиться, что каждая ветка по-прежнему разрешается. Тот же приём применим и к более широкой задаче платформенно-зависимого кода без плагина. -
Тесты чините в последнюю очередь, потому что моки ломаются иначе. Типы
package:web— это extension type надJSObject, поэтому подделка сimplements HTMLElementне скомпилируется. Замените фейки на основе классов настоящими узлами DOM, создаваемыми в тесте, или объектом JS, который вы собираете и передаёте тестируемому коду. Всё, что использовалоdynamicдля вызова члена DOM, тоже перестаёт работать, потому что члены extension type разрешаются только статически.Проверка:
flutter testпроходит, и в наборе тестов не осталось ни одной конструкцииimplements, указывающей на типpackage:web.
Проверка
Запустите все четыре команды в этом порядке:
dart analyze --fatal-infos
flutter test
flutter build web
flutter build web --wasm
Последняя команда и есть настоящий барьер. В мигрированном приложении она заканчивается строкой Built build/web и кладёт main.dart.wasm, main.dart.mjs и запасной вариант от dart2js main.dart.js в build/web. Если она всё ещё падает, ошибка называет точную оставшуюся цепочку импортов. После этого загрузите приложение и прокликайте всё, что затрагивает DOM: скачивание файлов, буфер обмена, iframe, localStorage и любой JS SDK, с которым вы общаетесь через interop.
План отката
Откат по одному файлу делается легко, а откат всего репозитория планировать не стоит. package:web и dart:html могут сосуществовать в одной программе, так что вы можете мигрировать один файл, выкатить его и откатить именно этот файл, если что-то сломается. Чего сделать нельзя, так это откатиться после того, как вы удалили ветки кода на dart:html и выкатили сборку Wasm, потому что сборка Wasm их никогда и не поддерживала. Держите сборку dart2js как продакшен-цель, пока не завершите описанный выше ручной прогон; flutter build web --wasm выпускает обе, а загрузчик сам переключается на запасную.
Ловушки, о которых стоит знать заранее
Официальный пример с JSImmutableListWrapper не компилируется. JSImmutableListWrapper<T, U> не может вывести U из аргумента конструктора, поэтому откатывается к границе параметра, JSObject:
for (final a in JSImmutableListWrapper(document.querySelectorAll('a'))) {
a.classList.add('link'); // error: The getter 'classList' isn't defined for the type 'JSObject'
}
Передавайте оба аргумента типа явно:
// package:web 1.1.1
for (final a in JSImmutableListWrapper<NodeList, Element>(
document.querySelectorAll('a'),
)) {
a.classList.add('link');
}
innerHTML имеет тип JSAny в обе стороны. Для записи нужен .toJS, для чтения нужен каст: final String s = el.innerHTML; падает с сообщением “A value of type ‘JSAny’ can’t be assigned to a variable of type ‘String’”. Читайте как (el.innerHTML as JSString).toDart. То же самое относится к outerHTML и к insertAdjacentHTML, у которого второй параметр имеет тип JSAny.
element.text — это сеттер без геттера. package:web сохраняет устаревший сеттер text для удобства миграции, но чтение требует textContent, который имеет тип String?, а не String. Коду, который делал if (el.text.isEmpty), теперь нужна проверка на null.
Колбэки теряют зону. dart:html привязывал колбэки событий к текущей зоне автоматически, package:web этого не делает. Если вы полагаетесь на локальные значения зоны или на то, что обработчик ошибок на основе зон поймает происходящее внутри слушателя, привязывайте вручную перед конвертацией:
element.addEventListener(
'click',
Zone.current.bindUnaryCallback((Event event) {
// zone-local values are preserved here
}).toJS,
);
Проверки типов молча меняют смысл. obj is Window прекрасно компилировался под dart:html; под package:web каждый тип стирается до JSObject, так что проверка бессмысленна. Используйте element.isA<HTMLInputElement>() (Dart 3.4 и новее) или obj.instanceOfString('Window').
Некоторые привычки из dart:html выживают в виде устаревших заглушек. window.localStorage['k'] = 'v' по-прежнему проходит анализ, но с сообщением ”’[]=’ is deprecated and shouldn’t be used. Use Storage.setItem instead”, а querySelector верхнего уровня существует с сообщением “Directly use document.querySelector instead”. Сегодня они компилируются, но конечной точкой не являются. Переводите их в том же проходе, иначе сделаете эту работу дважды.
Потоки событий никуда не делись и остаются самым удобным путём. В package:web есть вспомогательные потоки, поэтому input.onClick.listen(...) работает без изменений и возвращает ElementStream<MouseEvent>. Предпочитайте их сырому addEventListener вместе с .toJS везде, где подписку нужно отменять. Учтите, что вспомогательные потоки доставляют часть событий асинхронно там, где dart:html был синхронным, поэтому код, чувствительный к таймингу, требует второго взгляда.
Похожие материалы
- Ради чего делается вся эта работа, подробно описано в статье сборка веб-приложения на Flutter с WebAssembly, включая причину, по которой Firefox и Safari по-прежнему получают сборку на JavaScript.
- Структурно это такой же широкий механический проход, как и миграция приложения с Flutter 2 на Flutter 3.x: план в два прыжка и компилятор, который сообщает, когда вы закончили.
- Механизм условных импортов из шага 6 лежит в основе платформенно-зависимого кода без плагина.
- Если вы одновременно обновляете Flutter, прочитайте, что Flutter 3.47 изменил в отрисовке на десктопе, прежде чем винить эту миграцию в визуальной регрессии.
- Веб также является той платформой, где изоляты Dart ведут себя иначе, чем везде, и это полезно знать, прежде чем переносить нагруженную процессором работу в том же проходе.
Источники
- Migrate to package:web, dart.dev
- Past JS interop, dart.dev
- JS types and conversions, dart.dev
- Breaking changes and deprecations, dart.dev
- package:web на pub.dev, версия 1.1.1
- Справочник API EventStreamProviders, package:web
- dart:ui_web PlatformViewRegistry, документация API Flutter
- Announcing Dart 3.13, блог Dart
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.