Привет, Хабр! Я участвую в разработке крупного Web приложения и мы с коллегами на этапе проработки и планирования архитектуры пришли к выводу о необходимости выносить всю логику приложения в отдельный поток Web Worker, т.к. предполагается большое число фоновых операций и вычислений. К чему это привело? Сложности? Пути их решения? Обо всем попорядку.

Введение

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

Что имеем

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

Как происходит типовое использование:

  1. Создание потока из файла

  2. Передача данных в виде сообщения в worker

  3. Выполнение

  4. Возврат результата

  5. Уничтожение worker

В нашем случае требуется разделяемая память между вызовами задач в web worker поэтому в этом случае использование выглядит примерно так(код передает идею, не реализацию):

//adapter in main thread
const worker = new Worker('workerfile')

const emitter = new EventEmitter()

worker.onmessage = ({data}) => {
  emitter.emit(data.taskID, data)
}
//Отправляем сообщение в воркер
export async function task(...) {
  return new Promise((res,rej) => {
    const taskID = "..."//generated uniq id
    worker.postMessage({
      taskID,
      type,
      ...
    })
    emitter.once(taskID, () => {
      // resolve or reject в зависимости от вернувшегося статуса
    })
  })
}

// adapter in worker file
import anytask from "..."// import many task
self.onmessage = async ({data} => {
  try {
    //выполняем задачу и возвращаем ответ
    const res = await anytask(data.args)
    postMessage({
      status: "ok",
      res,
      taskID: data.taskID
    })
  } catch(err) {
    postMessage({
      status: "err",
      res: err,
      taskID: data.taskID
    })
  }
})

Выглядит вроде не сложно, но всегда чтото хочется упростить.

Например здесь описан небольшой «лайфхак», который позволяет уменьшить количество кода, необходимого для вызова функций из воркера, если нужно вызывать больше одной функции. Или тут описывается возможность создания webworker передавая функции. Или одна из самых популярных библиотек(4M в неделю) worker-rpc устанавливает связь между потоками, сериализуя сообщения между ними.

Проблемы

А они есть? Думал я долгое время пока количество вызываемых процедур росло, а ts откладывался на будующее

Если проект пишется целиком на javascript проблем особо не замечаешь, создал функцию на одной стороне, зарегистрировал ее в файле worker и вызвал task в основном потоке. А что там с надежностью и удобством разработки? Название вызываемой задачи литерал аргументы также.

Переходим на typescript. Решаешь создать задачу, создаешь функцию, регистрируешь в воркере, только теперь необходимо описать интерфейс входящего и исходящего сообщения, незабудь сделать тоже самое на другой стороне канала, только потом вызывай таску, хочешь изменить аргумент, пробеги по всей цепочке и поправь и это необходимый минимум для работы в ts, в не зависимости от использования вышеописанных решений.

Существует еще ряд больше неудобств, чем проблем, среди которых:

  1. Кругом await task("anytype", ...anyarg) - собствено что это и что делает

  2. А где лежит собственно вызываемый код? Идешь по файлам искать к чему привязан этот литерал(название таски). Тут же невозможность go to definition

  3. И разумеется ошибки в интерфейсах, когда ts говорит что все ok а задача падает с ошибкой

Решение

Собственно к обсуждению, разработанный плагин для Vite (vite-plugin-webworker-service), который позволяет значительно упростить код для взаимодействия с web worker.

Как теперь выглядит код? (не реальный код, на чистоту и логичность не претендую, но суть ясна)

// vite.config.ts
import WebWorkerPlugin from 'vite-plugin-webworker-service';

export default defineConfig({
  plugins: [WebWorkerPlugin()]
})
// any file in main thread
import {getPosts} from "posts.service.ts"
import {getKeywords} from "keywords.service.ts"
// ... any code
button.addEventListner('click', async () => {
  const posts = await getPosts(filter, order, ...etc)
  list.push(...posts)
})
// ... any code
button2.addEventListner('click', async () => {
  const keywords = await getKetwords()
  keys.push(...keywords)
})
// ... any code
// posts.service.ts
import cache from "cache.ts"

export async function getPosts(filter: PostFilter, order: PostOrder, ...etc: any[]) {
  if(cache.has('posts', filter, order)) {
    //тут возможны тяжелые операции по обработке данных кэша, фильтров и тд
    return cache.get(filter, order)
  } else {
    //Получение с бэка
    const res = await fetch('/posts',{filter, order})
    cache.set('posts', filter, order, res)
    return res
  }
}
// cache.ts
const cache = new CustomCache()
// any code
export default cache
// keywords.service.ts
import cache from "cache.ts"

export async function getKeywords() {
  const posts = cache.get('posts')
  return posts.map((post: Post) => {
    // return keywords in post
  })
}

Что имеем. Есть код в основном потоке, который вызывает функции getPosts и getKeywords, для получения постов и ключевых слов соответственно. Предполагается что получение постов из кэша по фильтру или же получение ключевых слов по постам могут быть высоконагруженными операциями, например при получении кл. слов выполняется парсинг строк. Соответственно хочется чтобы эти функции выполнялись в другом потоке.

Что делает плагин

Плагин работает следующим образом во время сборки(или разработки), каждый раз когда встречается import файла оканчивающегося на .service (постфикс в последствии вынесу как настройку) то он подменяется(именно подменяется на этапе сборки а не в runtime) на файл вида, где присутствуют все exportы подменяемого файла:

import adapter from "adapter"

export async function nameExport(...args) {
  return await adapter.task('nameExport', args)
}

код адаптера аналогичен коду в начале статьи, а вот код воркера так же генерируется во время сборки, добавляя в него все .service файлы которые были импортированы, а разрешение остальных импортов в воркер Vite делает как обычно.

Что плагин дает

  1. Автоматический перенос кода из .service файлов в worker на этапе сборки(dev сервер тоже работает)

  2. Чистота кода. В коде вы реально вызываете оригинальную функцию

  3. Из п.2 следует что сохраняется проверка типов typescript напрямую, минуя весь обвес

  4. Из п.3 следует что нет необходимости описывать интерфейсы ts для сообщений

  5. Из п.2 следует возможность go to definition, видеть документацию функции и все остальные преимущества использования языковых серверов и линтеров

  6. Практически полное отсутствие лишнего runtime кода, все делается на этапе сборки, в runtime попадает небольное количество строк адаптера из начала статьи

Заключение

На данный момент (05.09.2023) плагин реализован в виде работающего прототипа, тестирование и доработка продолжается. Буду признателен за любой фидбэк, пожелания, критику.

npm: здесь
github: тут

Комментарии (3)


  1. debagger
    10.09.2023 02:45
    +1

    На первый взгляд выглядит как опасное колдунство. Как обстоят дела с отладкой этого всего через devtools? Что с сорсмапами?

    ps. Возможно тут опечатка в имени файла:

    import {getKeywords} from "posts.service.ts"


    1. rastop123 Автор
      10.09.2023 02:45

      Спасибо за комментарий, да, в имени файла опечатка, отладка в devtools в dev режиме будет точно такой же, т.к. ничего не собирается, а плагин добавляет несколько файлов в основной поток и адаптер в поток воркера, а сами файлы не изменяет вообще, отладку в prod режиме пока не проверял, но она как вы правильно заметили побольшей части зависит от source map, все кейсы пока сильно не проверял, но source map самих файлов должны сохраниться т.к не изменяются, plugin api даёт возможность предоставить source map для изменений, этот вопрос в ближайшее время проверю тщательнее


    1. rastop123 Автор
      10.09.2023 02:45

      ps. По поводу опасного колдунства считаю, что любой код, который не является общепринятым и не используется большим числом проектов и особенно если нет полной ясности как он работает(когда его написал не ты сам или до конца не понял как оно работает), выглядит как опасное колдунство. Возможно лучшее документирование и пояснение принципов работы поможет исправить эту ситуацию