Использование Elm в Web Worker

Использование Elm в Web Worker

Язык программирования Elm - это отличный способ моделирования и написания современных веб-приложений. Используя функциональное программирование и сильную систему типов, Elm побуждает разработчиков создавать более надежные и легко поддерживаемые приложения. Однако, будучи языком, компилируемым в Javascript, Elm может предложить не так много по умолчанию. Любые задачи, требующие больших вычислений в Javascript, к сожалению, потребуют таких же вычислений и в Elm. Такие большие задачи могут блокировать основной поток в браузерах, вызывая визуальные проблемы и неотзывчивый пользовательский интерфейс. Очевидно, что это не то, чего мы хотим для наших пользователей, так что же мы можем сделать?

Вводим Web Workers. Из MDN:

Web Workers позволяет выполнять операции сценария в фоновом потоке, отдельном от основного потока выполнения веб-приложения. Преимуществом этого является то, что трудоемкая обработка может быть выполнена в отдельном потоке, позволяя основному потоку (обычно UI) работать без блокировки/замедления.

Web Workers - это способ, с помощью которого браузерные приложения могут перенести определенные задачи из основного потока в свою собственную среду. Web Workers имеют ряд ограничений, например, они не могут получить доступ к DOM, но у них есть возможность делать HTTP-запросы через fetch, а также выполнять стандартный код Javascript. Поскольку Elm является компилируемым в JS языком, это означает, что мы можем смонтировать Elm приложение в Web Worker!

Давайте рассмотрим, как будет выглядеть использование Elm внутри Web Worker. Мы рассмотрим два способа сделать это:

Используя ванильный JS, без каких-либо бандлеров или фреймворков, помимо тех, что предоставляет Elm.Использование этих методов в Vite, который предоставляет полезную обертку вокруг Web Worker API.

Написание модулей Elm

Для начала давайте создадим базовую установку для работы. В новой папке запустите elm init, который создаст наш базовый файл elm.json и папку src. Внутри src создайте два файла: Main.elm и Worker.elm. Мы заполним их в ближайшее время. Также создадим index.html в корне нашего рабочего направления (мы вернемся к нему позже).

Сначала создадим очень простой файл Main.elm. Хотя Web Workers в первую очередь полезны для больших задач, в данном примере мы будем придерживаться простоты. В нашем главном файле мы реализуем базовый пример счетчика:

port module Main exposing (main)

import Browser
import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)


init : (Int, Cmd msg)
init =
    ( 0, Cmd.none )


type Msg
    = Increment
    | Decrement
    | Set Int


update : Msg -> Int -> ( Int, Cmd Msg )
update msg model =
    case msg of
        Increment ->
            ( model, increment model )

        Decrement ->
            ( model, decrement model )

        Set value ->
            ( value, Cmd.none )


view : Int -> Html Msg
view model =
    div []
        [ button [ onClick Decrement ] [ text "-" ]
        , div [] [ text (String.fromInt model) ]
        , button [ onClick Increment ] [ text "+" ]
        ]


subscriptions : Int -> Sub Msg
subscriptions _ =
    receiveCount Set


main : Program () Int Msg
main =
    Browser.element { init = \_ -> init, update = update, view = view, subscriptions = subscriptions }


port increment : Int -> Cmd msg


port decrement : Int -> Cmd msg


port receiveCount : (Int -> msg) -> Sub msg

Это довольно простое приложение Elm, но с одним ключевым отличием: вместо обновления состояния здесь, мы возвращаем команду для передачи текущего состояния в порт. У нас также есть порт для получения числа, которое затем обновляет наше локальное состояние.

Поскольку мы собираемся обрабатывать эти очень сложные вычисления в Web Worker, давайте напишем базовый модуль Elm для запуска из Worker.

port module Worker exposing (main)

import Platform


type Msg
    = Increment Int
    | Decrement Int


init : () -> ( (), Cmd msg )
init _ =
    ( (), Cmd.none )


update : Msg -> () -> ( (), Cmd msg )
update msg _ =
    case msg of
        Increment int ->
            ( (), sendCount (int + 1) )

        Decrement int ->
            ( (), sendCount (int - 1) )


subscriptions : () -> Sub Msg
subscriptions _ =
    Sub.batch
        [ increment Increment
        , decrement Decrement
        ]


main : Program () () Msg
main =
    Platform.worker { init = init, update = update, subscriptions = subscriptions }


port increment : (Int -> msg) -> Sub msg


port decrement : (Int -> msg) -> Sub msg


port sendCount : Int -> Cmd msg

Что здесь происходит? Во-первых, мы импортируем Platform, которая предоставляет нам функцию Platform.worker. В большинстве случаев, когда мы пишем приложения на Elm, мы опираемся на elm/Browser для создания приложений, которые привязываются к DOM. Но в данном случае у нас нет DOM для привязки, поэтому мы используем Platform для создания базового приложения, которое этого не делает. worker принимает три входа: init, update и подписки (это практически то же самое, что и Browser.element из нашего примера Main.elm).

Мы также создаем два порта для увеличения и уменьшения входных данных (невероятно трудоемкое вычисление даже для современного Javascript) и соединяем их с эквивалентными значениями Msg. В функции update мы отправляем результаты в sendCount, которая выводит нас из Elm на дикий запад Javascript.

Концептуально это выглядит следующим образом:

  • Main получает сообщение (Increment)
  • В функции обновления Main мы отправляем текущий счетчик в соответствующий порт (инкремент 0).
  • Это значение отправляется (через Javascript) из Main в Worker и подключается к соответствующему порту (также инкремент 0).
  • Worker посылает результат своего интенсивного подсчета (sendCount 1).
  • Main получает обновленное значение и соответствующим образом обновляет свою модель (receiveCount 1).

Если вы знакомы с архитектурой The Elm, то это практически то же самое, но с большим количеством шагов. Также важно отметить, что поскольку мы полагаемся на порты для связи между приложениями Main и Worker, эти вычисления по своей сути асинхронны. Это действительно идеально подходит только для определенных рабочих нагрузок и, вероятно, не должно использоваться 100% времени (особенно для небольших задач, таких как сложение/вычитание).

Scaffold index.html

Теперь, когда мы рассмотрели код Elm, давайте посмотрим на Javascript. Поскольку мы используем ванильный JS, а не бандлер, нам сначала нужно собрать наш код Elm. Выполните следующую команду:

elm make src/Main.elm --output main.js
elm make src/Worker.elm --output elm-worker.js

Это выведет наши файлы main.js и worker.js, которые мы можем импортировать в наш HTML. Кстати говоря, давайте сделаем это! Вот базовый HTML-файл для начала. Все, что он делает, это монтирует наше главное приложение, а к рабочему мы перейдем через некоторое время.

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Elm Web Workers</title>
  </head>
  <body>
    <div id="app">
      <div></div>
    </div>
    <script src="main.js"></script>
    <script>
      const app = Elm.Main.init({
        node: document.getElementById('app')
      });
    </script>
  </body>
</html>

Если вы откроете HTML-файл в браузере прямо сейчас, он должен правильно отобразить приложение Main, но кнопки, похоже, ничего не делают. Это происходит потому, что вместо обновления нашей модели они отправляют ее в порты. В настоящее время мы ничего не делаем с нашими портами, но прежде чем мы их подключим, давайте добавим нашего Web Worker.

Добавление Web Worker

В этом разделе я буду ссылаться на отличное руководство MDN по использованию Web Worker.

Чтобы создать веб-рабочего, нам нужно иметь внешний JS-файл, который может быть импортирован и выполнен как веб-рабочий. Самой простой реализацией рабочего может быть простой console.log. Давайте сначала сделаем это.

Создайте файл worker.js и поместите в него console.log(“Hello, worker!”). Затем в нашем HTML-файле добавьте этот код в верхнюю часть блока сценария:

const worker = new Worker('worker.js')

const app = Elm.Main.init({
    node: document.getElementById('app')
});

Это указывает браузеру создать рабочего, используя файл Javascript, который находится в указанном месте (в нашем случае worker.js). Если вы откроете свой devtools, то увидите там “Hello, worker!”, сгенерированный из worker.js:1. Отлично!

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

Отправка сообщения

В вашем HTML-файле добавим еще одну строку кода, которая позволит отправить сообщение рабочему. Чтобы отправить сообщение из main в worker, мы используем worker.postMessage().

const worker = new Worker('worker.js')

const app = Elm.Main.init({
    node: document.getElementById('app')
});

worker.postMessage(1)

Чтобы получить сообщение в рабочем, мы задаем onmessage (не переменную) как функцию, которая получает функцию. Удалите содержимое вашего файла worker.js и добавьте следующее:

onmessage = function ({ data }) {
  console.log(data);
}

Как и во всех событиях Javascript, существует ряд других значений, передаваемых в функцию onmessage. В рамках данной статьи мы рассмотрим только ключ data. Если вы запустите этот скрипт, то в консоли вы должны увидеть 1. Поздравляем, теперь мы можем передавать данные рабочему! Но как насчет передачи данных в Elm?

Web Workers предоставляют специальный API для импорта скриптов в них:

Рабочие потоки имеют доступ к глобальной функции importScripts(), которая позволяет импортировать скрипты. Она принимает ноль или более URI в качестве параметров ресурсов для импорта.

Используя importScripts(), мы можем импортировать наш рабочий модуль Elm, инициализировать его и начать использовать его порты. Давайте обновим наш worker.js следующим образом:

importScripts("elm-worker.js")

const app = Elm.Worker.init();

onmessage = function ({ data }) {
  app.ports.increment.send(data);
};

app.ports.sendCount.subscribe(function(int) {
  console.log(int);
})

Для тех, кто менее знаком с Elm, мы инициализируем наш рабочий Elm без узла DOM (потому что в рабочем нет узлов DOM). Затем, используя его порты, когда мы получаем сообщение от главного потока, мы отправляем его в порт increment. Затем Elm выполняет свои невероятно сложные вычисления и возвращает (через порт sendCount) обновленное целое число (которое мы пока записываем в журнал). Отлично!

Прежде чем мы пойдем дальше, давайте обновим main и worker, чтобы они правильно использовали порты инкремента или декремента. В файле index.html обновите блок сценария следующим образом:

const worker = new Worker('worker.js');
const app = Elm.Main.init({
    node: document.getElementById('app')
});

app.ports.increment.subscribe(int => worker.postMessage({
    type: 'increment',
    value: int
}))

app.ports.decrement.subscribe(int => worker.postMessage({
    type: 'decrement',
    value: int
}))

Затем в нашем рабочем обновите его до следующего:

importScripts("elm-worker.js");

const app = Elm.Worker.init();

onmessage = function ({ data }) {
  const { type, value } = data;

  if (type === "increment") {
    app.ports.increment.send(value);
  }

  if (type === "decrement") {
    app.ports.decrement.send(value);
  }
};

app.ports.sendCount.subscribe(function (int) {
  console.log(int);
});

Если вы обновите страницу, то теперь можете начать нажимать на кнопки и видеть журнал результатов в консоли. Конечно, он покажет только 1 или -1, поэтому давайте передадим данные обратно в основной поток.

У Web Workers есть глобальная функция postMessage, которая позволяет нам передавать данные обратно. Давайте завершим этот код и отправим вычисленный результат в главный поток (и наше приложение Main Elm):

В файле worker.js сделайте следующее:

importScripts("elm-worker.js");

const app = Elm.Worker.init();

onmessage = function ({ data }) {
  const { type, value } = data;

  if (type === "increment") {
    app.ports.increment.send(value);
  }

  if (type === "decrement") {
    app.ports.decrement.send(value);
  }
};

app.ports.sendCount.subscribe(function (int) {
  console.log(int);
  postMessage(int);
});

В файле index.html обновите блок скриптов:

const worker = new Worker('worker.js');
const app = Elm.Main.init({
    node: document.getElementById('app')
});

app.ports.increment.subscribe(int => worker.postMessage({
    type: 'increment',
    value: int
}))

app.ports.decrement.subscribe(int => worker.postMessage({
    type: 'decrement',
    value: int
}))

worker.onmessage = function( { data }) {
    app.ports.receiveCount.send(data);
}

И с этим мы передаем данные! Поздравляем! Если вам нужно передавать какие-либо сложные данные между основным и рабочим потоками, вам, вероятно, придется обратиться к кодированию/декодированию JSON. При необходимости вы также можете передавать объект с пользовательским сообщением, а не использовать несколько портов и полагаться на Javascript в качестве контроллера.

Вот репозиторий с кодом, который мы рассматривали.

Веб-рабочие в Vite

Использование ванильного HTML и JS - это хорошо, но чаще всего на работе или в больших проектах мы используем какие-то инструменты для сборки, чтобы получить более оптимизированный опыт. Лично я являюсь большим поклонником Vite, инструментального решения для фронтенда от создателя Vue. Я поддерживаю шаблон Vite для создания приложений Elm, в котором используется отличный плагин Elm для Vite для достижения горячей перезагрузки модулей и прямого импорта наших файлов .elm в наш Javascript.

В качестве дополнительного преимущества для нашего случая использования, Vite обеспечивает некоторую абстракцию над Web Worker API, который мы рассмотрели выше. В Vite, когда мы импортируем скрипт, который хотим использовать в качестве веб-рабочего, мы можем добавить параметр запроса, который сигнализирует Vite, что это такое, а затем Vite обернет его в функцию, которая сгенерирует правильную команду рабочего.

Давайте перенесем наш вышеприведенный код в Vite и посмотрим, как это работает. Я буду использовать свой шаблон для создания базового приложения. Чтобы сделать это самостоятельно, выполните следующую команду:

npx degit lindsaykwardell/vite-elm-template vite-elm-web-worker
cd vite-elm-web-worker
npm install

Это позволит клонировать шаблон локально (без истории Git) в папку vite-elm-web-worker, ввести его и установить необходимые зависимости. Не стесняйтесь переименовать его в то, что вам больше нравится. Затем удалите содержимое папки src и замените его файлами Main.elm и Worker.elm. На этом этапе у вас должна быть установка, которая выглядит следующим образом:

Дерево файлов в VS Code, показывающее, что папка src содержит два файла: Main.elm и Worker.elm

Далее, давайте перенесем наш worker.js и другие Javascript. Начнем с создания файла worker.js (мы вернемся к нему через некоторое время), а затем обновим наш файл main.js, чтобы включить в него логику рабочего и порта:

import "./style.css";
import { Elm } from "./src/Main.elm";
import ElmWorker from "./worker?worker";

const root = document.querySelector("#app div");

const worker = new ElmWorker();
const app = Elm.Main.init({ node: root });

app.ports.increment.subscribe((int) =>
  worker.postMessage({
    type: "increment",
    value: int,
  })
);

app.ports.decrement.subscribe((int) =>
  worker.postMessage({
    type: "decrement",
    value: int,
  })
);

worker.onmessage = function ({ data }) {
  app.ports.receiveCount.send(data);
};

Это должно выглядеть очень похоже на то, что мы делали, но с некоторым дополнительным синтаксисом импорта в верхней части. Это потому, что мы используем Vite, а Vite поддерживает ES-модули по умолчанию во время разработки. Вместо того чтобы включать несколько тегов скриптов (что все еще возможно), мы можем импортировать один ES-модуль (main.js) и импортировать в него другие файлы.

Для рабочего скрипта будет работать большая часть кода, который мы написали ранее, но Vite предоставляет некоторый дополнительный сахар поверх API:

Рабочий скрипт также может использовать операторы импорта вместо importScripts() - обратите внимание, что во время разработки это зависит от нативной поддержки браузера и в настоящее время работает только в Chrome, но в производственной сборке это скомпилировано.

Таким образом, вместо использования importScripts() Vite требует, чтобы мы использовали стандартный синтаксис импорта ES-модуля. Однако здесь возникает проблема: Elm не компилирует по умолчанию в формат, который хорошо работает с ES-модулями. Кроме того, плагин Vite для Elm предполагает, что вы создаете приложение на основе браузера (разумное предположение), и внедряет некоторые помощники по устранению неполадок на основе DOM, которые не работают в рабочем, поскольку рабочий не имеет доступа к DOM.

Например, предположим, что мы обновили наш worker, чтобы использовать синтаксис импорта ES, как показано ниже:

import { Elm } from './src/Worker.elm'

const app = Elm.Worker.init();

onmessage = function ({ data }) {
  const { type, value } = data;

  if (type === "increment") {
    app.ports.increment.send(value);
  }

  if (type === "decrement") {
    app.ports.decrement.send(value);
  }
};

app.ports.sendCount.subscribe(function (int) {
  console.log(int);
  postMessage(int);
});

Если вы запустите среду разработки сейчас (с помощью npm run dev), вы сразу же увидите ошибку в консоли браузера:

Uncaught ReferenceError: HTMLElement is not defined

Эту ошибку выдает файл overlay.ts. Этот файл добавляет наложение ошибки, когда Elm не может правильно скомпилироваться. Поэтому если вы работаете в файле Main.elm и вносите изменения, которые не компилируются, вы увидите нечто подобное:

Ошибка в браузере, предупреждающая о том, что тип "In" не может быть найден.

Довольно полезно при разработке приложения, но очень раздражает при попытке загрузить Elm в веб-рабочем. Существует параметр, который можно установить в конфигурации Vite (server.hmr.overlay: false), чтобы отключить наложение, но, к сожалению, он не предотвращает ссылки на HTMLElement в Worker.

Второй подход может заключаться в предварительной компиляции нашего файла Worker.elm и импорте его непосредственно в файл worker.js (как мы делали в нашем примере с ванильным JS). Однако это приводит к тихой ошибке; приложение загрузится без каких-либо очевидных сбоев, но рабочий не будет инициализирован. Попробуйте! Запустите elm make src/Worker.elm —output elm-worker.js, затем обновите файл worker.js до следующего вида:

import { Elm } from './elm-worker.js'

console.log("I'm here!")

const app = Elm.Worker.init();

onmessage = function ({ data }) {
  const { type, value } = data;

  if (type === "increment") {
    app.ports.increment.send(value);
  }

  if (type === "decrement") {
    app.ports.decrement.send(value);
  }
};

app.ports.sendCount.subscribe(function (int) {
  console.log(int);
  postMessage(int);
});

Если вы снова запустите приложение, вы заметите, что наш console.log даже не запускается. Это потому, что веб-рабочий так и не был инициализирован, что очень нежелательно для наших сложных вычислений.

Каково же решение? На данный момент лучшее решение, которое я нашел, это создать отдельную точку входа для Vite, импортировать туда Worker.elm и скомпилировать его с Vite. Это выполнит преобразование, необходимое нам в Elm, чтобы позволить импорт в Worker.

В папке src создайте файл elm-worker.js и поместите в него следующее:

import { Elm } from "./Worker.elm";

const app = Elm.Worker.init();

export default app;

Это очень простой файл, все, что он делает, это импортирует наш файл Worker.elm, инициализирует приложение и экспортирует его. Теперь нам нужно скомпилировать этот файл с помощью Vite. На корневом уровне нашего приложения создайте файл worker.config.js. Это будет специальный конфигурационный файл Vite, который мы будем использовать только для компиляции elm-worker.js. Вот хорошая конфигурация для начала:

import { defineConfig } from "vite";
import elmPlugin from "vite-plugin-elm";
const path = require("path");

export default defineConfig({
  publicDir: false,
  plugins: [elmPlugin()],
  build: {
    outDir: "./elm-worker",
    sourcemap: false,
    lib: {
      entry: path.resolve(__dirname, "./src/elm-worker.js"),
      name: "elm-worker",
      fileName: (format) => `elm-worker.${format}.js`,
    },
  },
});

Эта конфигурация указывает, что нас интересует только elm-worker.js, не импортируя никаких других файлов (таких как общая папка), и чтобы эти файлы собирались в папке elm-worker. По умолчанию Vite компилирует как ESM, так и UMD форматы; это, вероятно, не очень полезно для нашего случая, но это не является большой проблемой.

Установив наш конфиг, выполните следующую команду:

npx vite build --config worker.config.js

Это даст команду Vite выполнить команду сборки, используя наш новый файл конфигурации вместо файла по умолчанию. После завершения сборки вы должны увидеть новую папку elm-worker с двумя файлами внутри: elm-worker.es.js и elm-worker.umd.js.

С нашим новым скомпилированным ES-совместимым файлом в руках мы можем теперь, наконец, импортировать наш Elm worker в наш файл web worker, и все будет работать, как ожидалось. Обновите наш файл worker.js (в корне нашего приложения) до следующего:

import app from './elm-worker/elm-worker.es.js'

onmessage = function ({ data }) {
  const { type, value } = data;

  if (type === "increment") {
    app.ports.increment.send(value);
  }

  if (type === "decrement") {
    app.ports.decrement.send(value);
  }
};

app.ports.sendCount.subscribe(function (int) {
  console.log(int);
  postMessage(int);
});

Если теперь вы запустите npm run dev и начнете нажимать на кнопки плюс и минус, вы должны увидеть, как меняется значение, отображаемое на экране. Поздравляем! Теперь у нас есть веб-рабочий, выполняющий Elm внутри Vite!

Это ни в коем случае не простое решение, но оно, по крайней мере, работает, и позволяет нам использовать другие преимущества использования такого инструмента разработки фронтенда, как Vite. Чтобы упростить дальнейшую работу, вы можете добавить в package.json пользовательский скрипт (что-то вроде build:worker) для запуска команды сборки нашего рабочего, и вы даже можете добавить его в наш dev-скрипт, чтобы он запускался каждый раз, поддерживая синхронизацию нашего веб-рабочего с остальным приложением.

Вот репозиторий с нашим рабочим кодом Vite.

Заключение

Очевидно, что базовое сложение и вычитание не стоит дополнительных накладных расходов на использование веб-рабочих. Задачи, требующие больших вычислений (либо сложные вычисления, либо просто разбор большого количества данных), идеально подходят для этой ситуации. В одном из проектов, где я использовал веб-работника, требовалось потенциально обработать более 2 мегабайт данных, что при выполнении в основном потоке приводило к зависанию всего приложения. Перемещение того же вычисления в веб-рабочего не ускорило его, но позволило пользовательскому интерфейсу (и CSS) продолжать работать на полной скорости. Вот веб-работник из побочного проекта, если вам интересно!

Также, если вы беспокоитесь, Web Workers поддерживаются во всех современных браузерах, начиная с IE10, так что не стесняйтесь использовать их в своих новых проектах!

Мне не терпится увидеть, что вы создадите с помощью веб-компонентов!