Веб-сайты уже давно перестали напоминать простые листы с текстовой информацией. Сейчас это полнофункциональные приложения, порой с очень сложной и тяжелой логикой. А чем больше логики и чем сложнее она становится, тем сильнее сайт начинает замедляться, что, естественно, не нравится пользователям. Сейчас это решают разными способами, например с помощью ленивой подгрузки, а более рисковые пробуют применять микрофронты или виджетные системы. Однако есть еще один вектор, на который пока никто не обращает внимания: использование многопоточности в браузере.
Практически во всех областях IT-разработки весь мир перешел на использование многопоточности: мобильные приложения, бэкенд, прикладное программирование. И даже процессоры развиваются в сторону увеличения количества ядер. Но на фронте многопоточность используют нечасто, и эта тема до сих пор почему-то не очень популярна, особенно в ру-сообществе.
Сегодня я постараюсь это исправить, открыв для вас фантастический мир многопоточности в браузере.
Многопоточность в браузере
Для начала давайте разберемся, какие у нас есть возможности параллелить работу в браузере. С точки зрения операционной системы, любой из ниженазванных воркеров — это полноценный системный поток, и в нем есть свой отдельный eventloop, изолированный глобальный объект и прочие радости. То есть ресурсные мощности воркеров почти никак не связаны с ресурсами основного потока. И это, несомненно, плюс, особенно если сравнивать языки, где используется GIL.
Однако минусы тоже есть. Из-за того что каждый поток такой обособленный и изолированный, есть дополнительные расходы оперативной памяти, нет шаринга между потоками данных и почти все выполняется через копирование (исключение transferable objects).
Обзор dedicated worker
Начнем с обычного dedicated воркера, который еще иногда называют веб-воркером. Это типичный представитель потоков. Даже в сыром виде работать с этим типом воркеров очень просто. Схему общего механизма можно посмотреть на картинке.
Создаем его с помощью конструктора и передаем ссылку на файл с джаваскрипт-кодом. С полученным воркером можно общаться при помощи постмесседжа, после чего мы должны директивно его завершить, когда он нам будет не нужен. Причем все dedicated воркеры привязаны к вкладке и уничтожаются автоматически, когда мы ее закрываем.
Для работы с веб-воркером написано очень много библиотек и обучающего материала. Одна из самых интересных библиотек, на мой взгляд, это Comlink. Если вкратце, либа позволяет работать с кодом в воркере, будто он в основном потоке. Там есть немного магии прокси, ну и промисы, конечно.
Обзор shared worker
Следующий представитель семейства «потокообразных» — shared воркер.
Он самый непопулярный и самый недооцененный, на мой взгляд. Основная фишка и отличие от dedicated-воркера в том, что он привязан не к странице, а к домену. На разных вкладках dedicated-воркер будет свой, а shared-воркер — общий. Причем он создается в момент, когда первая из страниц домена попробует к нему подключиться, и уничтожается тогда, когда больше никаким страницам не будет нужен.
Естественно, к нему может подключаться и взаимодействовать с ним любая страница этого домена, а он, в свою очередь, может запоминать и отправлять сообщения как всем сразу, так и персонально.
К сожалению, у shared-воркера плохой PR-менеджер, и в интернете очень сложно найти библиотеки или какую-то информацию для работы с ним. Но я все-таки нашел пару реальных кейсов, о которых расскажу позже.
Обзор service worker
Ну и последний из этой троицы — service воркер. Основная возможность этого воркера — перехватывать запросы к веб-ресурсам. Основываясь на этой механике, можно построить интересные решения, например кеширование и поддержку офлайн-режима в приложении.
Он похож на shared-воркер, но живет как бы бесконечно, в том числе и когда нет ни одной открытой вкладки домена. Когда поток очень долго живет, браузеру это не нравится, поэтому браузер примерно раз в минуту пытается «усыпить» service-воркер, ибо нечего ему вхолостую работать.
Из интересного: через service-воркер реализуется поддержка пуш-уведомлений в современном вебе.
Чуть ближе к реальности
Теперь, когда мы вспомнили, какие виды воркеров существуют, попробуем разобраться, зачем их используют.
Если рассматривать типичное современное фронтенд-приложение, то мы увидим такую картину: у нас есть один основной поток, который отвечает сразу за все — за рендеринг, виртуальный дом и его обработку, за реакцию на действия пользователей, а еще за бизнес-логику, обзервинг изменений, общение с сервером и так далее. Если добавить волшебную оптимизацию движка и асинхронность, у нас даже получится жить. Но чем больше и сложнее приложение, тем хуже ему становится.
Самые очевидные примеры — это Bitbucket, GitHub, Gmail, ВК, и у всех них есть определенные проблемы, когда показатель CPU достаточно высок. А ведь это вполне себе хорошие примеры тяжелых приложений с качественной оптимизацией.
Надо отметить, что сейчас проблемы с производительность пытаются решить многие. Кто-то вводит неблокирующий рендеринг, кто-то переезжает на асинхронные модули, микрофронты и виджетную систему. Однако это все еще решения для одного потока, не способного вместить в себя многослойную логику.
Воркеры + фреймворк = «мощь, которая и не снилась моему отцу»
Посмотрел я на все это, и ко мне пришла мысль: что, если объединить фреймворки и воркеры? Впервые я задумался об этом еще в начале 2018 года. Тогда я работал на одном проекте с Angular, и меня не покидала идея вынести всю работу с API в воркер (для этого есть отдельный модуль), чтобы облегчить нормализацию данных. Небольшое исследование показало, что трудоемкость этого действия слишком велика, а количество подводных камней не прогнозируемо. Поэтому эту идею я тогда отложил до лучших времен.
Года через два, в рамках пет-проекта, я делал минималистичный игровой фреймворк, в котором вся логика будет крутиться в воркере, а в основной поток будет прокидываться видеопоток (если что, я про Offscreen Canvas). Эта идея мне очень понравилась тогда, потому что я нигде не видел аналогов. Но как это иногда бывает с пет-проектами, мотивация обратно пропорциональна количеству и сложности кода в проекте.
Еще через некоторое время я открыл для себя фреймворк Neo.mjs, — его пишет один человек, и он весь основан на идее раскидывания логики по разным воркерам. В его репозитории есть вот такая картинка, описывающая архитектуру:
Согласно картинке, фреймворк инкапсулирует почти весь свой код и код приложения внутри shared-воркера. Помимо этого, он создает еще один shared-воркер — для того чтобы хранить и управлять виртуальным деревом рендеринга, и еще один — чтобы общаться с сервером и сериализовать и нормализовывать данные.
В такой схеме роль основного потока состоит лишь в том, чтобы получать информацию об изменениях DOMа и посылать информацию о событиях в shared-воркер.
На самом деле автор этого фреймворка проделал колоссальную работу, у него есть внушительные демо и свой блог, где он рассказывает о фреймворке, подходах, приводит примеры и так далее.
Но у проекта есть большая проблема — вокруг него не образовалось комьюнити. Я думаю, потому, что это абсолютно новый фреймворк, не похожий на популярные React, Angular, Vue. Ну и еще мало средств потрачено на рекламу. Но в любом случае я надеюсь, что этот фреймворк вдохнет новую жизнь в веб-разработку, как это было со Svelte или Snowpack в свое время.
Архитектура фронтенд-приложений будущего
Идея автора Neo.mjs очень интересная, но я решил доработать архитектуру этого фреймворка, чтобы получить пример того, как она потенциально может выглядеть во фронтенд-приложениях.
Моя версия фреймворка будущего выглядит так:
Основной поток все еще отвечает лишь за операции обновления реального дома и подписку на события пользователя. Однако виртуальный дом я вынес в dedicated-воркер, потому что пользователи крайне редко открывают одну и ту же страницу в разных вкладках браузера. Обычно это разные страницы с разными данными, а следовательно, для каждой страницы должен быть свой отдельный виртуальный дом.
Это виртуальное дерево ничем особо не отличается от концепции обычного виртуального дома, за исключением того, что передает данные в DOM через постмесседж в основной поток. А еще может обрабатывать логику событий пользователя и вызывать какие-либо действия из части бизнес-логики.
А вот сама бизнес-логика, как и состояние приложения и веб-сокет, лежат в shared-воркере. Это позволит сократить потребление памяти и ускорит рендеринг новых вкладок приложения. А кроме того, бонусом мы получим синхронизацию всех данных между вкладками, и пользователям не придется директивно обновлять страницу, делая лишние запросы на бэк и перерисовывая все.
Самое интересное, что все запросы в приложении могут идти на специальный локальный URL, потому что тут в игру вступает service-воркер. За счет его особенности перехватывать запросы мы с легкостью можем реализовать в нем адаптер связи с реальным бэком, при этом хранить конфиги приложения, подключать сериализацию и нормализацию данных, подключать интерцепторы и хранить авторизацию (например, OAuth-токен).
Только представьте, как в каком-либо санке или саге мы просто говорим «дай нам такие данные», а все хедеры, метаданные и конкретный URL для запроса подставит service-воркер. При этом волшебная способность service-воркера кешировать данные никуда не исчезает, и в зависимости от политики он может отдавать нам данные практически мгновенно. Особенно если мы открываем сайт после того, как закрыли все вкладки (например, после перезапуска браузера или компьютера).
Но это еще не все преимущества!
Дело в том, что логика в shared-воркере и service-воркере по умолчанию более безопасна, потому что скриптами на странице туда не добраться, а чтобы настроить что-то руками, нужно как минимум знать, как это сделать.
Следовательно, можно не бояться, что кто-то украдет токен авторизации или какие-то другие данные через XSS. К слову, все запросы, происходящие в воркере, также не видны с девтулз основного потока.
А еще это решение прекрасно сочетается с SSR и микрофронтами, где shared-воркер может быть как раз той шиной данных, а service-воркер — кешировать компоненты и виджеты, а виджеты можно будет не бандлить, особенно если использовать HTTP 2 или 3.
А может, мы уже в будущем?
Выше я описал возможное будущее фреймворков для толстых клиентов. Но позже я подумал: а что, если мы уже в этом будущем?
С этой мыслью я пошел искать доказательства и, собственно, нашел.
Как и говорил выше, у shared-воркер есть примеры использования, но все они пока связаны с вынесением сокетного подключения.
Такой механизм реализован в GitHub. И тут все дело в том, что обычно пользователи в одном браузере открывают больше, чем одну вкладку. А каждая новая вкладка — это новое вебсокет-подключение, и, как следствие, сервак страдает от излишних нагрузок, и браузер пользователя тоже перерабатывает.
В данном случае shared-worker помогает нам свести количество подключений до 1.
Демо + код
Но это все теория и потенциальные возможности.
Для тех, кто дочитал до этой части, я приготовил небольшой бонус. Я очень люблю Vue (особенно третью версию) — за то, что он очень гибкий и поддается кастомизации. И во время подготовки этого материала решил попробовать реализовать демо-приложение, которое будет создано по принципам архитектуры приложений будущего.
В итоге у меня получилось вынести состояние приложения в shared-воркер, при этом подружить его с реактивностью Vue, — таким образом, разработчик может и не знать, что вся основная логика выполнятся не в основном потоке.
А чтобы было проще для понимания, я оформил это как плагин для Vue, а по дизайну ориентировался на набирающую популярность библиотеку Pinia.
Disclaimer: это демо-вариант плагина, естественно, там есть искусственные ограничения и места, которые нужно будет еще хорошенько тестировать (как минимум нужны unit-тесты). Если вас заинтересует этот код, можете переходить на Stackblitz или Github форкать / создавать issue / присылать PR ну и ставить звездочки :)
Пример создания стора
import { Ref } from 'vue';
import { defineStore } from '../plugins/ParallelStore';
interface CounterStoreState {
counter: Ref<number>;
}
export const useCounterStore = defineStore(
'CounterStore',
() => ({ counter: 0 }),
{
incrementAction(state: CounterStoreState, value = 1) {
state.counter.value += value;
},
incrementLaterAction(state: CounterStoreState, value = 1) {
setTimeout(() => {
state.counter.value += value;
}, 1000);
},
}
);
Пример использования стора в компоненте
<script setup lang="ts">
import { ref } from 'vue';
import { useCounterStore } from '../store/counter';
const counterStore = useCounterStore();
</script>
<template>
<div class="card">
<span>counterStore.counter is <strong>{{ counterStore.counter }}</strong></span>
<br />
<button type="button" @click="counterStore.counter++">
Update ref by increment
</button>
<button type="button" @click="counterStore.incrementAction()">
update by action
</button>
<button type="button" @click="counterStore.incrementLaterAction(11)">
update by async action (after 1 sec)
</button>
</div>
</template>
<style scoped>
</style>
Заключение
Напоследок хотелось бы поговорить про поддержку, потому как это «бутылочное горлышко». И тут все не так однозначно. Обычные воркеры поддерживаются всеми и давно (даже IE с 10 версии). Service-воркер чуть похуже, но все еще вполне отличный результат. А вот shared-воркер… Сафари вернул его поддержку только в сентябре 2022-го, но все остальные браузеры на мобилках не поддерживают (придется использовать фолбэк на обычный воркер). Т. е. в принципе использовать можно, правда с ограничениями :)
На этом у меня все. Надеюсь, у меня получилось приоткрыть завесу тайны вокруг воркеров и вдохновить на использование воркеров на живых проектах или даже на создание библиотеки и адаптацию фреймворков.
Полезные ссылки
-
Ссылка на Demo — Stackblitz, Github
-
Выступление Surma на Chrome Dev Summit 2019 — The main thread is overworked & underpaid
-
Surma для Smashing Magazine — The State Of Web Workers In 2021
-
Comlink — Github
-
surma.dev — react-redux-comlink
-
Neo.mjs — Github, Blog & examples
-
Ayush Gupta для dev.to — Scaling WebSocket Connections using Shared Workers
-
Stackblitz —Introducing WebContainers: Run Node.js natively in your browser
Автор: Игорь Костяков