В последнее время фронтенд все больше и больше обрастает новыми технологиями, одна из них — WebAssembly. На ней можно написать полноценное web‑приложение и почти все на что способна ваша фантазия. В статье мы рассмотрим, что такое WebAssembly, как работает и с чем её едят. И конечно же я хотел поделиться рецептом приложения. Возьмем наш любимый React и добавим Yew, приправим всё это webpack-ом и добавим щепоточку module‑federation. Статья будет интересна всем, кто хочет познакомиться с WebAssembly и добавить разнообразия в список используемых фреймворков и библиотек для написания пользовательских компонентов.

Что такое Wasm и зачем он был создан

WebAssembly (Wasm) — это новый открытый формат байт-кода, исполняемого современными браузерами. Сейчас он поддерживается во всех браузерах. Подробнее можно почитать тут.

Wasm позволяет переносить код, написанный на языках C, C++, C#, Rust и других (поддерживаемый список можно посмотреть тут), для использования в браузерной среде с производительностью, близкой к нативной. Важно понимать, максимальная производительность WebAssembly равна максимальной производительности JavaScript, но написать оптимизированный по памяти код иногда проще на WebAssembly. Подробнее можно почитать в статье Вячеслава Егорова

Язык WebAssembly предназначен для дополнения и работы вместе с JavaScript, что позволяет веб-разработчикам использовать преимущества обоих языков:

  • JavaScript — это язык высокого уровня, гибкий и достаточно выразительный для написания веб-приложений. У него много преимуществ: он динамически типизирован, не требует компиляции и обладает огромной экосистемой, которая предоставляет мощные фреймворки, библиотеки и другие инструменты.

  • WebAssembly — это низкоуровневый язык, похожий на ассемблер, с компактным двоичным форматом, который работает с почти естественной производительностью и предоставляет низкоуровневые модели памяти для таких языков как C++ и Rust, чтобы компилировать их для работы в интернете. Обратите внимание, что у WebAssembly на будущее есть высокоуровневая цель по поддержке языков со сборщиками мусора в модели памяти.

Как WebAssembly работает в браузерах

Сейчас во всех браузерах уже есть поддержка WebAssembly: его код исполняется в специальной виртуальной машине. Выглядит это так. Код на низкоуровневом языке компилируется с помощью мощных компиляторов (например, LLVM ) или набора инструментов таких как Emscripten и превращается в бинарный формат. Но у него есть и текстовое представление:

(module
  (func import “imports” “imported_func”) (param i32))
  (func (export “exported_func”) 
   i32.const 42 
  call $i)
)

Затем модуль JavaScript вызывает Wasm-модуль. Пример кода:

WebAssembly.instantiateStreaming(
    fetch("simple.wasm"),
    {
   	 imports: {
   		 imported_func: console.log,
   	 },
    },
).then((module) =>
    module.instance.exports.exported_func(),
);

В браузере модуль скачивается, парсится и исполняется. 

Wasm в браузере


Кажется, что всё довольно просто. Благодаря всей этой магии открылась возможность запускать десктопные приложения в браузере или использовать код на низкоуровневых языках.

Не будем сильно углубляться в эту тему, уж очень много статей написано. Подробнее ознакомиться можно тут. 

Достоинства и недостатки WebAssembly

Достоинства

? Стандарт. Сейчас он широко распространяется в отрасли и поддерживается многими языками, в том числе и со сборкой мусора (Garbage Collection). Совсем недавно поддержка стандарта появилась в Google Chrome и Mozilla Firefox. Также Wasm можно использовать не только в браузере, но и на серверах, используя специальный интерфейс WASI.

? Возможность использовать библиотеки, написанные на низкоуровневых языках. Индустрия системных языков программирования накопила большой опыт в написании сложных алгоритмов с преобразованиями данных с оптимальной скоростью. И, о чудо, теперь этот опыт можно использовать при написании приложений в браузере.

? Доступ к DOM и Browser API. WebAssembly напрямую не может обращаться к DOM или браузерному API. Все вызовы API внутри Wasm происходят через JavaScript (компиляторами добавляется специальная прослойка для исполнения JavaScript-кода). Значит, мы можем использовать почти все возможности современных браузеров с помощью специальных полифилов.

? Быстрота и эффективность. Благодаря тому, что Wasm это бинарный формат, длительность его парсинга и компиляции заметно сокращается. Это позволяет ему исполняться в браузере на скорости, близкой к нативной (но тут есть некоторые нюансы, в основном зависящие от размера файла). И можно кешировать распарсенный и скомпилированный Wasm-модуль, используя специальный заголовок Content-Type: application/wasm.

⛓️ Типизация. В Wasm есть система типов. Это помогает корректно выполнять код. Но несмотря на всю привлекательность типизации, она может существенно замедлить работу, потому что каждый тип нужно конвертировать в понятный Wasm. Подробнее о типах в WebAssembly.

? Изоляция. По умолчанию Wasm-модуль запускается в изолированной браузерной среде, и всё, что он может делать, это читать выделенную ему область памяти. Но изоляция не означает безопасность. Для безопасного исполнения лучше использовать последние стандарты в индустрии, к примеру, Content Security Policy (CSP).

Недостатки

? Исполняется в основном потоке. Так как Wasm исполняется внутри основного потока бразера, стоит бережно следить и профилировать используемый код. Основные проблемы, которые могут возникнуть: накладные расходы на копирование данных, общение между потоками, блокировка потока и всё, что относится к работе с основным потоком в JavaScript. Мы тут опускаем взаимодействие с любыми типами Worker'ов.

? Большие размеры файлов. Портируемость в браузер кода, написанного на системном языке программирования, требует добавления своего рода полифилов. Большая часть проблем такого рода решается оптимизацией сборки или прогоном с усиленной оптимизаций через компилятор. Выше я уже говорил, что файлы можно кешировать.

⌛️ Время на изучение технологии. Wasm — молодая, развивающая технология, которая только частично связана с использованием JS. Для написания Wasm-модулей нужно будет погрузиться в новый язык программирования, найти или написать плагины для используемых сборщиков или бандлеров. И, что немаловажно, обучиться грамотно отлаживать и профилировать новую для вас технологию.

Особенности (не смог отнести к достоинствам или недостаткам)

? JS необходим для старта Wasm-модуля. Это связано с тем, что Wasm призван не заменить JS, а стать ещё одним инструментом в руках веб-разработчика. 

? Сложно отлаживать. Сейчас можно это делать с помощью devtools. Но возможностей меньше того, к чему привык JavaScript-разработчик. В то же время уже сейчас можно отлаживать исходники файлов, написанных на низкоуровневых языках.

Пишем web-приложение c использованием WebAssembly

Кого-то удивит, но на WebAssembly можно писать и полноценные фронтенд-приложения. Вот список ингредиентов для приготовления проекта:

  1. Yew. Фреймворк для написания веб-приложений на WebAssembly, использующий язык Rust.

  2. React. Одна из популярных библиотек для создания пользовательских интерфейсов.

  3. Yarn . Менеджер пакетов, который одновременно выполняет функции менеджера проекта.

  4. Webpack. Сборщик приложений.

  5. Typescript. Надстройка над JavaScript c добавлением типизации.

  6. Prettier. Форматер кода.

  7. Eslint. Статический Анализатор кода, помогающий придерживаться единого стиля кода и находить некоторые типы ошибки.

Что за проект мы будем создавать? Небольшое приложение с микрофронтендной архитектурой с использованием Yew и React.

Для начала установим Yarn. Нам потребуется версия 3.6.3. После успешной установки настроим своё рабочее пространство

Затем инициализируем конфигурацию проекта. Скачиваем репозиторий:

git clone https://github.com/easymikey/wasm-example/tree/init-project

…и переключаемся на ветку init-project. Создадим необходимые директории:

mkdir packages && mkdir packages/shell && mkdir packages/react-counter && mkdir packages/yew-counter

Разработка входной точки

Начнем с подготовки окружения:

cd packages/shell && yarn init -y && touch webpack.config.ts && tsconfig.json && mkdir public && touch public/index.html && mkdir src && touch src/App.tsx

Добавим dev-зависимости:

yarn add -D css-loader \
esbuild \
esbuild-loader \
html-webpack-plugin \
style-loader \
typescript \
typescript-plugin-css-modules \
webpack \
webpack-cli \
webpack-dev-server \

Добавим React:

yarn add react react-dom

Получим такой package.json:

{
	"name": "shell",
	"version": "0.1.0",
	"sideEffects": false,
	"scripts": {
		"serve": "webpack serve --color --open"
	},
	"dependencies": {
		"react": "^18.2.0",
		"react-dom": "^18.2.0"
	},
	"devDependencies": {
		"@types/react": "^18.2.20",
		"@types/react-dom": "^18.2.8",
		"@types/webpack": "^5.28.3",
		"@types/webpack-dev-server": "^4.7.2",
		"css-loader": "^6.8.1",
		"esbuild": "^0.19.4",
		"esbuild-loader": "^4.0.2",
		"esbuild-register": "^3.5.0",
		"html-webpack-plugin": "^5.5.3",
		"style-loader": "^3.3.3",
		"typescript": "4.9.5",
		"typescript-plugin-css-modules": "4.1.1",
		"webpack": "^5.88.2",
		"webpack-cli": "^5.1.4",
		"webpack-dev-server": "^4.15.1"
	},
	"packageManager": "yarn@3.6.3",
	"volta": {
		"node": "20.8.1"
	}
}
Следом настроим TypeScript:
{
    "extends": "../../tsconfig.base.json",
    "include": [
   	 "./src/**/*",
    ],
    "compilerOptions": {
   	 "outDir": "dist",
   	 "baseUrl": ".",
   	 "paths": {
         // Дадим возможность IDE найти наши модули
   		 "react_counter/*": [ "../react-counter/dist/@mf-types/*" ],
   		 "yew_counter/*": [ "../yew-counter/core/pkg/index.d.ts" ]
   	 },
   	 "plugins": [ { "name": "typescript-plugin-css-modules" } ],
    },
    "exclude": [
   	 "node_modules",
   	 "./dist/**/*"
    ],
    "references": [ ]
}

Настроим сборку:
import path from 'path';
import webpack from 'webpack';
import 'webpack-dev-server';
import HtmlWebpackPlugin from 'html-webpack-plugin';

const config: webpack.Configuration = {
    entry: './src/index.tsx',
    mode: 'development',
    devServer: {
   	 static: {
   		 directory: path.join(__dirname, 'dist'),
   	 },
   	 headers: {
   		 'Access-Control-Allow-Origin': '*',
   	 },
   	 port: 3001,
    },
    resolve: {
   	 extensions: ['.js', '.ts', '.tsx', '.css'],
    },
    module: {
   	 rules: [
   		 // Use esbuild to compile JavaScript & TypeScript
   		 {
   			 // Match `.js`, `.jsx`, `.ts` or `.tsx` files
   			 test: /\.[jt]sx?$/,
   			 exclude: /yarn/,
   			 loader: 'esbuild-loader',
   			 options: {
   				 // JavaScript version to compile to
   				 target: 'es2015',
   			 },
   		 },
   		 {
   			 test: /\.module.css$/,
   			 use: [
   				 'style-loader',
   				 {
   					 loader: 'css-loader',
   					 options: {
   						 modules: {
   							 localIdentName:
   								 '[name]__[local]--[hash:base64:16]',
   						 },
   					 },
   				 },
   			 ],
   		 },
   	 ],
    },
    plugins: [
   	 new HtmlWebpackPlugin({
   		 template: './public/index.html',
   	 }),
    ],
};

export default config;

Если хочется детально изучить настройки, то почитайте документацию используемых плагинов.

Инициализируем входной HTML-файл в папке public:
<html>
  <head>
	<meta charset="utf-8" />
	<meta name="viewport" content="width=device-width, initial-scale=1" />
	<title>Yew + React </title>
  </head>
  <style>
	body {
  	margin: 0;
  	padding: 0;
	}
  </style>
  <body>
	<div id="react-counter"></div>
  </body>
</html>

И можно приступать к разработке. 

Откроем файл src/App.tsx и напишем оболочку нашего приложения, отображающее счётчик с состоянием по умолчанию, равному нулю. Добавим колбеки для обновления состояния onIncrement и onDecrement.

import {FC, useState} from 'react';
// Стили можно посмотреть https://github.com/easymikey/wasm-example/blob/main/packages/shell/src/App.module.css
import styles from './App.module.css';

const App: FC = () => {
    const [counter, setCounter] = useState(0);

    const onIncrement = () => setCounter((c) => c + 1);

    const onDecrement = () => setCounter((c) => c - 1);

    return (
   	 <div className={styles.root}>
   		 <div className={styles.layout}>
   			 <div className={styles.counter}>{counter}</div>
   		 </div>
   	 </div>
    );
};

export default App;

Затем создадим файлы src/index.tsx и src/bootstrap.tsx со следующим содержанием:

// bootstrap.tsx
import {createRoot} from 'react-dom/client';

import App from './App';

createRoot(document.getElementById('react-counter')!).render(<App />);

index.tsx
import('./bootstrap');

Теперь можно запускать наше приложения из корня командой yarn serve.Можно увидеть текущее состояние приложения, открыв браузер на http://localhost:3001

Начальная версия приложения.

Отлично, с входной точкой закончили, время начать разрабатывать модуль для уменьшения и увеличения счётчика на React

Разработка React-cчетчика

Как и в первом случае начнем с настройки окружения.

cd packages/react-counter && yarn init -y && touch webpack.config.ts && tsconfig.json && mkdir src && touch src/ReactConter.tsx && src/index.tsx

Добавим dev-зависимости:

yarn add -D css-loader \
esbuild \
esbuild-loader \
html-webpack-plugin \
style-loader \
typescript \
typescript-plugin-css-modules \
webpack \
webpack-cli \
webpack-dev-server \
@module-federation/typescript \

И добавим React:

yarn add react react-dom

Итоговый package.json:

{
	"name": "react-counter",
	"version": "0.1.0",
	"sideEffects": false,
	"scripts": {
		"serve": "webpack serve --color"
	},
	"dependencies": {
		"react": "^18.2.0",
		"react-dom": "^18.2.0"
	},
	"devDependencies": {
		"@module-federation/typescript": "^3.0.1",
		"@types/react": "^18.2.21",
		"@types/webpack": "^5.28.3",
		"@types/webpack-dev-server": "^4.7.2",
		"css-loader": "^6.8.1",
		"esbuild": "^0.19.4",
		"esbuild-loader": "^4.0.2",
		"esbuild-register": "^3.5.0",
		"html-webpack-plugin": "^5.5.3",
		"style-loader": "^3.3.3",
		"typescript": "4.9.5",
		"typescript-plugin-css-modules": "4.1.1",
		"webpack": "^5.88.2",
		"webpack-cli": "^5.1.4",
		"webpack-dev-server": "^4.15.1"
	},
	"volta": {
		"node": "20.8.1"
	}
}
Настроим TypeScript.
{
    "extends": "../../tsconfig.base.json",
    "include": [
   	 "src",
    ],
    "compilerOptions": {
   	 "outDir": "dist",
   	 "baseUrl": ".",
   	 "plugins": [ { "name": "typescript-plugin-css-modules" } ],
    },
    "exclude": [
   	 "node_modules",
   	 "dist/**/*"
    ],
    "references": [ ]
}

Настроим сборку.
import path from 'path';
import webpack from 'webpack';
import 'webpack-dev-server';
import {FederatedTypesPlugin} from '@module-federation/typescript';
import {ModuleFederationPluginOptions} from '@module-federation/typescript/src/types';

const federationConfig: ModuleFederationPluginOptions = {
	name: 'react_counter',
	filename: 'remoteEntry.js',
	exposes: {
		'./ReactCounter': './src/ReactCounter.tsx',
	},
	shared: {
		react: {singleton: true},
		'react-dom': {singleton: true},
	},
};
const config: webpack.Configuration = {
	entry: './src/index.tsx',
	mode: 'development',
	devServer: {
		static: {
			directory: path.join(__dirname, 'dist'),
		},
		hot: true,
		port: 3002,
		headers: {
			'Access-Control-Allow-Origin': '*',
		},
	},
	output: {
		publicPath: 'auto',
	},
	resolve: {
		extensions: ['.js', '.ts', '.tsx', '.css'],
	},
	module: {
		rules: [
			// Use esbuild to compile JavaScript & TypeScript
			{
				// Match `.js`, `.jsx`, `.ts` or `.tsx` files
				test: /\.[jt]sx?$/,
				exclude: /yarn/,
				loader: 'esbuild-loader',
				options: {
					// JavaScript version to compile to
					target: 'es2015',
				},
			},
			{
				test: /\.module.css$/,
				use: [
					'style-loader',
					{
						loader: 'css-loader',
						options: {
							modules: {
								localIdentName:
									'[name]__[local]--[hash:base64:16]',
							},
						},
					},
				],
			},
		],
	},
	plugins: [
		new FederatedTypesPlugin({
			federationConfig,
		}),
		new webpack.container.ModuleFederationPlugin(federationConfig),
	],
};

export default config;

Приступаем к написанию нашего микрофронтенда на React.

Откроем файл src/ReactCounter.tsx и добавим возможность обновлять счётчик. Это очень простой компонент, думаю вы писали сотни или даже тысячи таких. Код можно увидеть ниже.

import {FC} from 'react';
// Стили можно посмотреть https://github.com/easymikey/wasm-example/blob/main/packages/react-counter/src/ReactCounter.module.css
import styles from './ReactCounter.module.css';

const ReactCounter: FC<{
    onIncrement: () => void;
    onDecrement: () => void;
}> = ({onDecrement, onIncrement}) => {
    return (
     	 <div className={styles.layout}>
     		 <button className={styles.button} onClick={onIncrement}>
     			 +
     		 </button>
     		 <button className={styles.button} onClick={onDecrement}>
     			 -
     		 </button>
     	 </div>
    );
};

export default ReactCounter;

Теперь нам нужно добавить новоиспеченный микрофонтенд.

Переходим к файлу packages/shell/webpack.config.ts и обновляем его.

...

const config: webpack.Configuration = {
	...
	plugins: [
		new webpack.container.ModuleFederationPlugin({
			name: 'shell',
			remotes: {
				// Used modules
				react_counter:
					'react_counter@http://localhost:3002/remoteEntry.js',
			},
			shared: {
				react: {singleton: true},
				'react-dom': {singleton: true},
			},
		}),
		new HtmlWebpackPlugin({
			template: './public/index.html',
		}),
	],
    ...
};

export default config;

Теперь у нас есть возможность загружать модуль счётчик на React. Давайте это используем и добавим обработку в нашу оболочку. Код изменится следующим образом:

import {FC, Suspense, lazy, useState} from 'react';
import styles from './App.module.css';

const ReactCounter = lazy(() => import('react_counter/ReactCounter'));

const App: FC = () => {
    const [counter, setCounter] = useState(0);

    const onIncrement = () => setCounter((c) => c + 1);

    const onDecrement = () => setCounter((c) => c —  - 1);

    return (
     	 <div className={styles.root}>
     		 <div className={styles.layout}>
     			 <Suspense fallback="Loading ReactCounter">
     				 <ReactCounter
     					 onIncrement={onIncrement}
     					 onDecrement={onDecrement}
     				 />
     			 </Suspense>
     			 <div className={styles.counter}>{counter}</div>
     		 </div>
     	 </div>
    );
};

export default App;

Снова запустим проект из корня командой yarn serve. Проект изменился, теперь он выглядит так:

Версия приложения с добавлением ReactCounter.

Отличной, второй кусочек нашего приложения готов. Теперь мы можем добавить модуль на Wasm. 

Разработка Yew-счётчика

Как и в первых двух случаях начнем с подготовки окружения.

cd packages/yew-counter && yarn init -y && touch webpack.config.ts

Для разработки Wasm-модуля нам потребуется установить Rust и wasm-pack.
Сначала установим Rust, следуя официальной документации. Затем установим wasm-pack снова следуя официальной документации.

Установим dev-зависимости:

yarn add -D webpack \
webpack-cli \
webpack-dev-server \
esbuild \
esbuild-register \
@wasm-tool/wasm-pack-plugin \

Итоговый package.json:

{
	"name": "yew-counter",
	"version": "0.1.0",
	"sideEffects": false,
	"scripts": {
		"serve": "webpack serve --color"
	},
	"devDependencies": {
		"@types/webpack": "^5.28.3",
		"@types/webpack-dev-server": "^4.7.2",
		"@wasm-tool/wasm-pack-plugin": "^1.7.0",
		"esbuild": "^0.19.4",
		"esbuild-register": "^3.5.0",
		"webpack": "^5.88.2",
		"webpack-cli": "^5.1.4",
		"webpack-dev-server": "^4.15.1"
	},
	"packageManager": "yarn@3.6.3",
	"volta": {
		"node": "20.8.1"
	}
}
Настроим сборку.
import path from 'path';
import webpack from 'webpack';
import 'webpack-dev-server';
import WasmPackPlugin from '@wasm-tool/wasm-pack-plugin';

const config: webpack.Configuration = {
	entry: './core/pkg/index.js',
	output: {
		path: path.resolve(__dirname, 'dist'),
		filename: '[name].js',
	},
	mode: 'development',
	experiments: {asyncWebAssembly: true},
	devServer: {
		static: {
			directory: path.join(__dirname, 'dist'),
		},
		headers: {
			'Access-Control-Allow-Origin': '*',
		},
		port: 3003,
	},
	resolve: {
		extensions: ['.js', '.ts'],
	},
	plugins: [
		new WasmPackPlugin({
			crateDirectory: path.resolve(__dirname, 'core'),
			outDir: path.join(__dirname, 'core/pkg'),
		}),
		new webpack.container.ModuleFederationPlugin({
			name: 'yew_counter',
			filename: 'remoteEntry.js',
			exposes: {
				'./yew': './core/pkg',
			},
		}),
	],
};

export default config;

Инициализируем Wasm-репозиторий c Yew:

wasm-pack new core --template https://github.com/yewstack/yew-wasm-pack-minimal

У нас появляется создатся папкаcore со всеми необходимыми нам файлами.

Для начала обновим файл Cargo.toml. Это аналог package.json и в нём сосредоточны все наши зависимости.

[package]
name = "core"
version = "0.1.0"
edition = "2021"
readme = "README.md"

[lib]
crate-type = ["cdylib"]


# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
yew = { version = "0.21", features = ["csr"] }
serde-wasm-bindgen = "0.6.1"
wasm-bindgen = "0.2.78"
web-sys = "0.3.64"
gloo = "0.10.0"
js-sys = "0.3.64"
stylist = { version = "0.13.0", features = ["yew_integration"] }

Начнем с файла src/app.rs и переименуем его в src/yew_counter.rs.  

Начнём реализовывать счётчик. Для начала импортируем необходимые зависимости.

// Добавляем возможность использовать css
use stylist::css;
// Добавляем возможность стилизации компонентов
use stylist::yew::styled_component;
// Своего рода полифил для wasm
use wasm_bindgen::prelude::*;
// Инициализация фреймворка 
use yew::prelude::*;

Затем опишем типы для нашего компонента.  Вы скорее всего заметите, что это напоминает описание интерфейсов в TypeScript, за исключением использования декоратора derive. Хотя кого я обманываю, большинство из нас так или иначе пробовали использовать декораторы в TypeScript. 

#[derive(Properties, Clone, PartialEq)]
pub struct Props {
	pub on_increment: js_sys::Function,
	pub on_decrement: js_sys::Function,
}
// Если это перенести на Typescript
export interface Props {
    onIncrement: (...args: any[]) => any,
    onDecrement: (...args: any[]) => any,
};

Следует отметить, что функции и типы данных не равны в Rust и JavaScript/TypeScript. В частности потому, что это разные языки.  Если немного уточнить, то для перевода типов нужно сначала замапить типы из JS/TS в Wasm, а только потому уже в Rust. По этой причине мы видим тут тип any.  Поэтому любые функции, передаваемые в модуль на Rust мы будем вызывать не таким образом как все привыкли.

С типами разобрались, давайте начнем писать компонент счётчика. И он удивительным образом похож на React.

#[styled_component]
pub fn YewCounter(props: &Props) -> Html {
	html! {
    	<div>
        	<button>{"+"}</button>
        	<button>{"-"}</button>
    	</div>
	}
}

Стилизуем наш компонент.  Получилось что-то очень похожее на styled-components, за исключением того, что библиотека на Rust только генерирует стили без обертки в компонент через шаблонные строки. Обратим особое внимание на функцию clone. Если простыми словами, то это концепция в Rust, которая явным образом позволяет создавать копию переменной. 

#[styled_component]
pub fn YewCounter(props: &Props) -> Html {
    let on_increment = {
        let on_increment = props.on_increment.clone();
        Callback::from(move |_| {
            let _ = on_increment.call0(&JsValue::NULL);
        })
    };
    let on_decrement = {
        let on_decrement = props.on_decrement.clone();
        Callback::from(move |_| {
            let _ = on_decrement.call0(&JsValue::NULL);
        })
    };

    let layout = css!(
        r#"
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 16px;"#
    );

    let button = css!(
        r#"
		height: 65px;
		width: 65px;
		padding: 0;
		border: none;
		background-color: #aee18b;
		color: #e16541;
		font-size: 40px;
		text-align: center;
		box-shadow:
			rgb(50 50 93 / 25%) 0px 2px 5px -1px,
			rgb(0 0 0 / 30%) 0px 1px 3px -1px;
		cursor: pointer;

		& + & {
			margin-left: 8px;
		}"#
    );

    html! {
        <div class={layout}>
            <button class={button.clone()} onclick={on_increment}>{"+"}</button>
            <button class={button} onclick={on_decrement}>{"-"}</button>
        </div>
    }
}

У нас нет никаких обработчиков, сейчас есть просто статический модуль, который загружает кнопки. Добавим немного магии в YewCounter:


#[styled_component]
pub fn YewCounter(props: &Props) -> Html {
    ...
	let on_increment = {
    	let on_increment = props.on_increment.clone();
    	    Callback::from(move |_| {
        	     let _ = on_increment.call0(&JsValue::NULL);
    	    })
	};
	let on_decrement = {
    	let on_decrement = props.on_decrement.clone();
    	    Callback::from(move |_| {
        	    let _ = on_decrement.call0(&JsValue::NULL);
       	})
	};
    ...
}

Согласитесь же, снова очень похоже на React? Да есть некоторые отличия, но всё же похоже.

Давайте немного разберём создание функции. В Rust нет функций с переменным количеством параметров, поэтому для связи с JavaScript используется обертка над функцией call с передачей контекста и аргументов.


// on_decrement.call0(&JsValue::NULL) В Rust === on_decrement() в JavaScript


let on_decrement = {
    let on_decrement = props.on_decrement.clone();
        // Тут мы используем анонимную функцию и ключевое слово move.
        // Подробнее про move https://doc.rust-lang.org/book/ch13-01-closures.html?highlight=move#capturing-references-or-moving-ownership
        Callback::from(move |_| {
            // Присваиваем к неиспользуемой переменной, чтобы вызвать панику. Подробнее про unwrap https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html?highlight=unwrap#shortcuts-for-panic-on-error-unwrap-and-expect
            let _ = on_decrement.call0(&JsValue::NULL);
    	})
};

Компонент нашего приложения мы написали, теперь нужно добавить для него точку монтирования, и это будет lib.rs.

// Указываем имя модуля. Подробнее про модули в Rust. https://doc.rust-lang.org/book/ch07-00-managing-growing-projects-with-packages-crates-and-modules.html
mod yew_counter;

// Добавляем возможность использовать Document API
use gloo::utils::document;
// И снова добавляем полифил для Wasm
use wasm_bindgen::prelude::*;
// Импортируем компонент и тип как JS
use yew_counter::{YewCounter, Props};

// Указываем, входную точку модуля
#[wasm_bindgen]
pub fn run_app(
	root_id: String,
	on_increment: js_sys::Function,
	on_decrement: js_sys::Function,
// Небольшой след алгебраических типов данных
) -> Result<(), JsValue> {
     // Добавляем проверку, что элемент с нужным id существует.
	let root = match document().get_element_by_id(&root_id) {
    	    Some(v) => v,
       	None => return Ok(()),
	};
    // Указываем, куда отрисовывать элемент и какие пропсы для это нужны
	yew::Renderer::<App>::with_root_and_props(
    	root,
    	Props {
        	on_increment,
        	on_decrement,
    	},
	)
	.render();
    // Указываем, что инициализация модуля произошла успешно
	Ok(())
}

Очень похоже на вызов функции ReactDOM.createRoot, хоть и более многословно.

Дальше нам нужно добавить возможность вызвать наш новоиспеченный модуль. Обновим поле plugins в shell/webpack.config.ts:

...

const config: webpack.Configuration = {
	...
	plugins: [
		new webpack.container.ModuleFederationPlugin({
			name: 'shell',
			remotes: {
				// Used modules
				react_counter:
					'react_counter@http://localhost:3002/remoteEntry.js',
                yew_counter: 'yew_counter@http://localhost:3003/remoteEntry.js',
			},
			shared: {
				react: {singleton: true},
				'react-dom': {singleton: true},
			},
		}),
		new HtmlWebpackPlugin({
			template: './public/index.html',
		}),
	],
    ...
};

export default config;

Добавим ленивую инициализацию модуля YewCounter в shell/src/App.tsx и возможность обрабатывать ошибку загрузку модуля:

Код
import {FC, Suspense, lazy, useEffect, useState} from 'react';
import styles from './App.module.css';

/**
 * Общие пропсы для любого счётчика
 */
type CounterProps = {
    onIncrement: () => void;
    onDecrement: () => void;
};
// Обработчик ошибки при импорте модуля
const onRejectedComponentHandler = (
    error: unknown,
): {
    default: FC<CounterProps>;
} => {
    console.error('Component Failed Loading:', error);

    return {
   	 default: () => <div>Failed to Load</div>,
    };
};

// Добавим возможность обрабатывать ошибку импорта модуля
const ReactCounter = lazy(() =>
    import('react_counter/ReactCounter').catch(onRejectedComponentHandler),
);

const YEW_COUNTER = 'yew-counter';

// Добавим возможность ленивой загрузки YewCounter и обработку ошибки
const YewCounter = lazy(() =>
    // импортируем модуль и инициализируем модуль
    import('yew_counter/yew')
   	 .then((module) => {
   		 const YewCounter: FC<CounterProps> = ({
   			 onDecrement,
   			 onIncrement,
   		 }) => {
   			 useEffect(() => {
                 // Используя доступное из lib.rs API монтируем компонент
   				 module.run_app(YEW_COUNTER, onIncrement, onDecrement);
   			 }, []);

   			 return <div id={YEW_COUNTER} />;
   		 };
   		 return {default: YewCounter};
   	 })
   	 .catch(onRejectedComponentHandler),
);


const App: FC = () => {
    const [counter, setCounter] = useState(0);

    const onIncrement = () => setCounter((c) => c + 1);

    const onDecrement = () => setCounter((c) => c - 1);

    return (
     	 <div className={styles.root}>
     		 <div className={styles.layout}>
     			 <Suspense fallback="Loading ReactCounter">
     				 <ReactCounter
     					 onIncrement={onIncrement}
     					 onDecrement={onDecrement}
     				 />
     			 </Suspense>

     			 <div className={styles.counter}>{counter}</div>

     			 <Suspense fallback="Loading YewCounter">
     				 <YewCounter
     					 onIncrement={onIncrement}
     					 onDecrement={onDecrement}
     				 />
     			 </Suspense>
     		 </div>
     	 </div>
    );
};

export default App;

Снова запускаем из корня yarn serve

Версия приложения с ReactCounter и YewCounter.

Ихууу. Теперь мы можем менять состояние своего приложения и через React, и через Yew. Мы попробовали новую технологию, и, кажется, у нас получилось построить простое, но в то же время необычное приложения c использование Wasm. Полная версия кода.

Заключение

Как я уже говорил, в последнее время WebAssembly стремительно развивается и позволяет творить настоящую магию. Мы с вами разработали небольшое web-приложение, используя WebAssembly. Надеюсь, этот опыт поможет вам в дальнейшем.

«Всё, что должно быть сказано, уже сказано. Но поскольку этого никто не услышал, можно всё что угодно сказать ещё раз.» — Андре Жид