Start Debugging

Как собрать веб-приложение Flutter с WebAssembly через flutter build web --wasm

Полное руководство по публикации веб-приложения Flutter, скомпилированного в WebAssembly, на Flutter 3.44: как выглядят две выпускаемые сборки, почему Firefox и Safari по-прежнему получают JavaScript из-за wasmAllowList в загрузчике, миграция с dart:html для dart2wasm, заголовки COOP/COEP, определяющие, работает ли skwasm в несколько потоков, и как в среде выполнения доказать, какую сборку реально загрузил браузер.

Чтобы собрать веб-приложение Flutter с WebAssembly, добавьте флаг --wasm: flutter build web --wasm. Этот единственный флаг заставляет инструмент выпустить в build/web две сборки: сборку WasmGC, скомпилированную dart2wasm и использующую рендерер skwasm, и обычную сборку dart2js, использующую canvaskit как резервную. Сгенерированный flutter_bootstrap.js выбирает одну из них при загрузке страницы. Дальше то, получат ли реальные пользователи сборку Wasm, определяют две вещи: ничто в графе зависимостей не должно импортировать dart:html, dart:js, dart:js_util или package:js, а сервер обязан отдавать Cross-Origin-Opener-Policy: same-origin вместе с Cross-Origin-Embedder-Policy: credentialless, иначе skwasm молча переходит на один поток. Статья ориентирована на Flutter 3.44 stable (выпущен 2026-05-18, включает Dart 3.10), и каждая деталь ниже проверена по ветке stable репозитория flutter/flutter. Важная оговорка сразу: начиная с 3.44 загрузчик включает сборку Wasm только в браузерах на Blink, поэтому Firefox, Safari и любой браузер на iOS получают сборку JavaScript независимо от того, что вы скомпилировали.

Что --wasm на самом деле кладёт в build/web

Мысленная модель, которая есть у большинства, неверна довольно полезным образом. --wasm не переключает сборку с JavaScript на WebAssembly. Он добавляет сборку WebAssembly рядом с JavaScript-сборкой. В packages/flutter_tools/lib/src/commands/build_web.dart передача флага создаёт список из двух конфигураций компилятора, WasmCompilerConfig и JsCompilerConfig, и инструмент запускает оба компилятора. Без флага вы получаете настоящий JsCompilerConfig плюс WasmCompilerConfig, помеченный dryRun: true, который компилирует, но выбрасывает результат (об этом чуть ниже).

Каждая скомпилированная цель добавляет описание сборки в сгенерированный flutter_bootstrap.js. После flutter build web --wasm на Flutter 3.44 дескриптор выглядит так:

// Excerpt from build/web/flutter_bootstrap.js, Flutter 3.44 stable
if (!window._flutter) {
  window._flutter = {};
}
_flutter.buildConfig = {
  "engineRevision": "...",
  "builds": [
    {
      "compileTarget": "dart2wasm",
      "renderer": "skwasm",
      "mainWasmPath": "main.dart.wasm",
      "jsSupportRuntimePath": "main.dart.mjs"
    },
    {
      "compileTarget": "dart2js",
      "renderer": "canvaskit",
      "mainJsPath": "main.dart.js"
    }
  ]
};

Порядок важен: FlutterLoader.load() вызывает buildConfig.builds.find(buildIsCompatible) и берёт первую совместимую запись, поэтому сборка Wasm выигрывает всегда, когда это позволяет окружение. Привязка рендерера не настраивается. WebRendererMode.defaultForWasm равно skwasm, а defaultForJs равно canvaskit, и инструмент не позволяет их смешивать, что даёт первый подводный камень из списка ниже.

На диске вы получаете main.dart.wasm (сам модуль), main.dart.mjs (JS-среда поддержки, которая его инстанцирует) и main.dart.js (резервный вариант), а также полезную нагрузку рендереров: skwasm.js и skwasm.wasm для пути Wasm и бандл CanvasKit для резервного пути.

Пять шагов, которые действительно важны

  1. Перейдите на Flutter 3.24 или новее. Компиляция в Wasm попала в stable в 3.24; здесь я проверял на 3.44. Если вы жонглируете версиями SDK по проектам, мои заметки о запуске одного проекта Flutter на нескольких версиях SDK в CI применимы к сборкам Wasm без изменений.
  2. Пересоздайте web/index.html, если он старше Flutter 3.22. Путь Wasm полностью опирается на загрузчик flutter_bootstrap.js, поэтому старый bootstrap с serviceWorkerVersion работать не будет. flutter create . --platforms web после удаления web/ даёт актуальный шаблон.
  3. Уберите из графа зависимостей несовместимости с dart2wasm. Сначала соберите через flutter build web без --wasm и прочитайте выводы dry run.
  4. Соберите: flutter build web --wasm.
  5. Раздавайте с заголовками кросс-доменной изоляции. Без них приложение всё равно работает, но в один поток, что обесценивает большую часть смысла Wasm.

Почему приложение всё ещё выполняет JavaScript в Firefox и Safari

Именно это удивляет разработчиков, а официальная страница поддержки Wasm устарела достаточно (её frontmatter last-update указывает Nov 6, 2024), чтобы её чтение не объясняло текущее поведение. WasmGC уже не является ограничением: он достиг Baseline в Chrome 119, Firefox 120 и Safari 18.2. Ограничение находится в жёстко заданном списке разрешений внутри загрузчика движка.

Файл engine/src/flutter/lib/web_ui/flutter_js/src/browser_environment.js в ветке stable содержит ровно это:

// engine/src/flutter/lib/web_ui/flutter_js/src/browser_environment.js
export const defaultWasmSupport = {
  "blink": true,
  "gecko": false,
  "webkit": false,
  "unknown": false,
}

А loader.js ставит сборку skwasm в зависимость от этого списка:

// engine/src/flutter/lib/web_ui/flutter_js/src/loader.js
const supportsDart2Wasm = browserEnvironment.supportsWasmGC;
const supportsSkwasm = supportsDart2Wasm && browserEnvironment.webGLVersion > 0;

const enableWasm = config.wasmAllowList?.[browserEnvironment.browserEngine]
  ?? defaultWasmSupport[browserEnvironment.browserEngine];

Поэтому в Firefox supportsWasmGC() возвращает true (детектор валидирует крошечный модуль WasmGC, и Firefox его проходит), но enableWasm из записи gecko даёт false, сборка skwasm отклоняется как несовместимая, и загрузчик переходит к dart2js + canvaskit. С Safari через webkit та же история. Причина не в WasmGC, а в рендерере: многопоточный skwasm во Flutter опирается на OffscreenCanvas.transferToImageBitmap, и баг Firefox (Bugzilla 1788206), и баг WebKit (267291), отслеживающие его стоимость, на момент проверки в июле 2026 года оставались открытыми.

Список разрешений можно переопределить самому, и это стоит делать за параметром запроса, если нужны реальные цифры, а не мнения:

// web/flutter_bootstrap.js, Flutter 3.44
{{flutter_js}}
{{flutter_build_config}}

const params = new URLSearchParams(window.location.search);
_flutter.loader.load({
  config: {
    // Only opt gecko/webkit in deliberately. Expect rendering artifacts.
    wasmAllowList: params.has('force_wasm')
      ? { blink: true, gecko: true, webkit: true, unknown: false }
      : undefined,
  },
});

Не отправляйте это в production по интуиции. Сначала измерьте по методике из статьи о профилировании jank во Flutter-приложении с помощью DevTools, потому что на затронутых движках отказ проявляется как деградация времени кадра, а не как аккуратная ошибка.

Одно ограничение не переопределяется вообще: все браузеры на iOS обязаны использовать WebKit, поэтому скомпилированное в Wasm приложение Flutter не может работать в iOS Safari, iOS Chrome и ни в чём другом на этой платформе.

Как заставить зависимости компилироваться

dart2wasm поддерживает только статический JS-интероп Dart. Любой транзитивный импорт dart:html, dart:js, dart:js_util или package:js рушит компиляцию с сообщениями вроде этих:

Dart library 'dart:html' is not available on this platform.
JS interop library 'dart:js_util' can't be imported when compiling to Wasm.
Try using 'dart:js_interop' or 'dart:js_interop_unsafe' instead.

Хорошая новость: выяснять это методом проб не нужно. --wasm-dry-run по умолчанию равен true, поэтому обычный flutter build web уже запускает dart2wasm в режиме dry run и сообщает найденное:

Wasm dry run findings:
...
Consider addressing these issues to enable wasm builds. See docs for more info:
https://docs.flutter.dev/platform-integration/web/wasm

Если приложение уже чистое, тот же механизм подталкивает в обратную сторону сообщением Wasm dry run succeeded. Consider building and testing your application with the --wasm flag. В любом случае flutter build web --no-wasm-dry-run отключает вывод, когда решение принято.

Для своего кода миграция состоит в переходе на package:web вместо dart:html и на dart:js_interop вместо package:js:

// Dart 3.10, Flutter 3.44 -- wasm-compatible
import 'dart:js_interop';
import 'package:web/web.dart' as web;

@JS('navigator.clipboard.writeText')
external JSPromise<JSAny?> _writeText(String text);

Future<void> copy(String text) async {
  await _writeText(text).toDart;
  web.document.querySelector('#status')?.textContent = 'Copied';
}

При миграции больно бьют три различия. Имена следуют браузерному IDL, поэтому HtmlElement становится HTMLElement, а innerHtml становится innerHTML. querySelectorAll возвращает итерируемый объект, который не является List. И поскольку типы интеропа являются extension types, is и as работают не так, как вы ожидаете; используйте вместо них isA<T>(). Условные импорты тоже меняются: теперь условием служит dart.library.js_interop, а не dart.library.html. Если вы пишете интероп руками, а не подключаете плагин, приёмы из статьи о добавлении платформозависимого кода во Flutter без плагинов переносятся напрямую.

Для чужого кода фильтруйте pub.dev по is:wasm-ready. Когда блокирует зависимость, её обновление часто и есть всё решение, и добавляется обычная боль с разрешением ограничений; если вы попали в ад резолвера, выход описан в статье Исправление: Version solving failed в pubspec.yaml.

COOP и COEP определяют, получите ли вы потоки

Flutter компилирует skwasm с разделяемой памятью. Это видно в вызове компилятора в build_system/targets/web.dart, который для рендерера skwasm добавляет --import-shared-memory и --shared-memory-max-pages=32768. Разделяемая память в браузере требует кросс-доменной изоляции, а та требует двух заголовков ответа. Инструмент жёстко задаёт нужную пару:

// packages/flutter_tools/lib/src/web/web_constants.dart, Flutter 3.44
const kCrossOriginIsolationHeaders = <String, String>{
  'Cross-Origin-Opener-Policy': 'same-origin',
  'Cross-Origin-Embedder-Policy': 'credentialless',
};

flutter run -d chrome --wasm выставляет их на своём сервере разработки, и именно поэтому проблема никогда не проявляется локально, а потом проявляется в production. При их отсутствии ошибки не будет. skwasm_loader.js вычисляет skwasmSingleThreaded: ... || !browserEnvironment.crossOriginIsolated || ... и молча запускает однопоточный движок.

Для nginx:

# nginx, serving build/web
location / {
    add_header Cross-Origin-Opener-Policy   same-origin   always;
    add_header Cross-Origin-Embedder-Policy credentialless always;
    try_files $uri $uri/ /index.html;
}

Для Firebase Hosting:

{
  "hosting": {
    "public": "build/web",
    "headers": [
      {
        "source": "**",
        "headers": [
          { "key": "Cross-Origin-Opener-Policy",   "value": "same-origin" },
          { "key": "Cross-Origin-Embedder-Policy", "value": "credentialless" }
        ]
      }
    ]
  }
}

Проверьте в консоли браузера window.crossOriginIsolated: значение должно быть true. Учтите, что GitHub Pages вообще не умеет отдавать пользовательские заголовки, поэтому размещённая там сборка Wasm всегда будет работать в один поток.

Кросс-доменная изоляция не бесплатна. require-corp ломает любой кросс-доменный подресурс, который не согласился через Cross-Origin-Resource-Policy, а на практике это сторонние изображения, шрифты, аналитические beacon-запросы и встроенные iframe. credentialless мягче: он загружает кросс-доменные подресурсы без учётных данных, а не блокирует их. Начните с credentialless, а затем просмотрите панель сети в поисках запросов, потерявших свои cookie.

Как доказать, какую сборку загрузил браузер

Не выводите это по секундомеру. Компилятор задаёт переменную окружения, которую можно прочитать:

// Flutter 3.44, Dart 3.10
const isRunningWithWasm = bool.fromEnvironment('dart.tool.dart2wasm');

Есть и поведенческая проверка, работающая без пересборки и основанная на том, что Wasm использует нативное представление чисел:

final isRunningWithWasm = identical(double.nan, double.nan);

Панель сети служит третьей проверкой: запрос main.dart.wasm означает сборку Wasm, main.dart.js означает резервную.

Подводные камни, о которых стоит знать до публикации

Указание рендерера вместе с --wasm является жёсткой ошибкой. build_web.dart вызывает throwToolExit('Do not attempt to set a web renderer when using "--wasm"'), когда вычисленный рендерер не равен skwasm. Поэтому --wasm в комбинации с --dart-define=FLUTTER_WEB_USE_SKIA=true падает на уровне CLI, и так задумано.

config.renderer: 'canvaskit' в сборке Wasm падает в среде выполнения. buildIsCompatible отклоняет любую сборку, чей renderer не равен настроенному значению, а сборка --wasm не содержит записи dart2wasm + canvaskit. Все кандидаты отфильтровываются, и загрузчик выбрасывает FlutterLoader could not find a build compatible with configuration and environment. Это отслеживается как flutter/flutter#183265. Уберите ключ renderer либо установите его в skwasm.

Движки не на Chromium загружают более тяжёлую полезную нагрузку рендерера. loadSkwasm выбирает skwasm_heavy вместо skwasm, когда в браузере нет ImageDecoder или разрывателей строк Chromium, поэтому при принудительном открытии списка разрешений вы дополнительно платите увеличенной загрузкой.

Расширения Chrome принудительно переводятся в один поток. Загрузчик обнаруживает chrome.runtime.id и отключает потоки, потому что CSP расширений блокирует динамическую загрузку скриптов, нужную воркерам.

Имена символов по умолчанию удаляются. --strip-wasm по умолчанию равен true. Передавайте --no-strip-wasm, когда нужна читаемая трассировка стека из профилировочной сборки, и --source-maps, чтобы получить main.dart.wasm.map.

Wasm не решает проблему SEO. Обе сборки рисуют на canvas, поэтому краулеры по-прежнему видят почти полное отсутствие семантического HTML. Wasm делает веб-приложение Flutter быстрее; документом оно от этого не становится.

Инструмент всё ещё называет это новым. flutter build web --wasm печатает рамку с текстом WebAssembly compilation is new. Understand the details before deploying to production. Считайте это точным утверждением, а не формальностью: фиксируйте версию Flutter и держите резервный путь на JavaScript в матрице тестирования, потому что при нынешнем списке разрешений именно на этом пути находится большинство ваших пользователей.

Похожие статьи

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад