Тяжёлые вычисления в Next.js

Тяжёлые вычисления в Next.js

Next.js
WebWorker
WebAssembly
React
computations

Данная статья описывает способ внедрения WebAssembly, выполняющегося в веб-воркере, в Next.js приложение.

Настройка Next.js

worker-loader - позволяет подключать в приложение файл с кодом веб-воркера при помощи стандартного импорта.

Следующие моменты при настройке worker-loader являются ключевыми:

  • publicPath — должен быть правильным URL-ом, т.е. вида https://mysite/_next/, потому как import в веб-воркере трансформируется в importScript, требующий корректный URL.
  • chunkFilename и filename должны иметь корректный префикс, чтобы комилируемые файлы сохранялись в static директорию Next.js.
  • нужно включить в цепочку лоадеров стандартный Next.js лоадер для JavaScript/TypeScript.

Пример next.config.js с учётом вышеперечисленных моментов:

// next.config.js
const workerLoader = require('worker-loader');
module.exports = {
webpack(config = {}, context) {
const { isServer, defaultLoaders, config: nextConfig } = context;
const publicPrefix = process.env.NEXT_PUBLIC_URL || nextConfig.assetPrefix;
const publicPath = (publicPrefix ? `${publicPrefix}/` : '') + '_next/';
const outputPrefix = (isServer ? '../' : '') + 'static/worker/';
const workerLoaderOptions = {
publicPath,
chunkFilename: outputPrefix + 'chunks/[id].[contenthash].worker.js',
filename: outputPrefix + '[name].[contenthash].worker.js',
};
config.modules.push({
test: /\.worker\.(js|ts)$/,
use: [
{
loader: 'worker-loader',
options: workerLoaderOptions,
},
(defaultLoader: defaultLoaders.babel),
],
});
return config;
},
};

В конфигурации используется NEXT_PUBLIC_URL переменная окружения, которая должна содержать URL текущего деплоя.

Имена веб-воркер файлов должны оканчиваться на .worker.ts или .worker.js.

Внутри веб-воркера

// heavy-calculation.worker.ts
self.addEventLinstener('message', async ({ data }) => {
const { payload, command } = data;
if (command !== 'calculate') {
return;
}
// (1)
const { calculate } = await import('./wasm/heavy-calculation');
const result = calculate(payload);
self.postMessage({
result,
});
});

Веб-воркер подписывается на сообщения, и в случае, если данные сообщения содержат поле command со значением calculate, вызывает WebAssembly функцию и отправляет сообщение с результатом вычисления.

Важным моментов является динамический импорт (1) wasm-файла. Стандартным решением было бы использовать статический импорт в начале файла, но данный подход приводит к ошибке — Next.js не может включить wasm-файл при серверном рендеринге или при генерации статической страницы. В то время как динамический импорт будет выполнен только в браузере.

Подключение веб-воркера

Создадим React компонент, задачами которого будут запуск вычисления в веб-воркере и отображение результата.

// HeavyCalculator.tsx
type Calculate = (n: number) => number;
type WorkerMessageListener = (ev: WorkerEventMap['message']) => void;
function HeavyCalculator() {
const [result, setResult] = useState<number | null>(null);
const [inputValue, setInputValue] = useState<stirng>();
const worker = useRef<Worker | null>(null);
const calculate = useRef<Calculate | null>(null);
const listener = useRef<Listener | null>(null);
useEffect(() => {
if (calculate.current) {
return;
}
// (1)
import('./heavy-calculation.worker').then(({ default: HeavyCalculationWorker }) => {
// (2)
worker.current = new HeavyCalculationWorker();
// (3)
calculate.current = n => {
worker.postMessage({
command: 'calculate',
payload: inputValue,
});
};
// (4)
listener.current = message => {
if (mesage.data && typeof mesage.data.result !== 'undefined') {
setResult(message.data.result);
}
};
worker.current.addEventListener('message', listener);
});
() => {
if (worker.current) {
worker.current.removeEventListener('message', listener);
}
};
}, []);
function onCalculateClick() {
if (calculate.current) {
calculate.current(parseInt(inputValue, 10));
}
}
return (
<div>
<label htmlFor="heavy-calculator-input">Input data for heavy calculation</label>
<input
id="heavy-calculator-input"
value={inputValue}
onChange={e => {
setInputValue(e.target.value);
}}
></input>
<button onClick={onCalculateClick}>calculate</button>
<div>{result}</div>
</div>
);
}

(1) — динамически импортируем веб-воркер на клиенте (попытка использовать статический импорт в начале файла приведёт к ошибке при серверном рендеринге).

(2) — инстанциируем веб-воркер и сохраняем его в ref-переменную, чтобы он был доступен вне колбэка импорта.

(3) — создаём функцию calculate, которая отправляет сообщение c необходимыми данными веб-воркеру, созданному на этапе (2).

(4) — подписываемся на события, приходящие от веб-воркера, у которых есть поле data.result.

Заключение

Данная реализация позволяет подключать на любую Next.js страницу React компонент, взаимодействующий с WebAssembly кодом, выполняющимся в веб-воркере. Как бонус — при создании нескольких экземпляров компонента для каждого из них будет инстанциирован свой веб-воркер, что позволяет, например, запустить несколько параллельных потоков вычислений.