Shared State для React. Часть 1
Дисклеймер
Две недели назад я ничего из этого вообще не умел :)
Дано
Что плохо
- Огромное количество boilerplate-кода из-за Redux.
- Архитектурная ошибка: каждое окно хранит в стейте полную копию всей базы. И полагается на то, что стейт — есть истина. Итог: на больших базах (несколько десятков тысяч записей) окна открываются несколько секунд. Пожалуйста, никогда не делайте так! Безопаснее думать о state как о кеше, который нужен вам для синхронной отрисовки компонентов.
- Много слоев абстракции. Сообщения между слоями недостаточно строго стандартизированы. Слушатели могут подключаться в самых разных, порой неожиданных, местах.
- Протокол GRPC довольно сложно расширять. А его подход с подстановкой дефолтных значений вместо не переданных параметров — вообще рассадник трудноуловимых багов.
Что хорошо
- Операции TimeTravel (Redo / Undo) легко делать на Redux: достаточно просто сохранить состояние в стек и путешествовать по нему.
- Код довольно стабильный. Все детские болячки в нем решены. Синхронизация работает хорошо.
- Автотесты, в том числе интеграционные. Имеются. CI работает как надо.
Целевое состояние
- Убрать бойлерплейт-код по-максимуму. Стейт сделать динамическим, с ленивой загрузкой по мере надобности.
- Сообщения между слоями типизированы. Слои абстракции и передача сообщений между слоями максимально сокращены. Вся логика межпроцессного взаимодействия спрятана под капот. Я хочу, чтобы стейт обновлялся сам, если чего-то пришло от сервера, или случилось в другом окне!
- Попробовать заменить GRPC на REST/GrapQL/MessagePack. С одной стороны хочется оставить бинарный протокол. Но мне не нравятся схемы GRPS. С другой — использовать web-сокеты, для реалтайм обновления от сервера через PUSH. С третей — нас очень просят сделать публичный REST-api, и это есть в планах на этот год. Нужно выбрать.
- PWA-приложение: сделать возможность работы в нескольких окнах.
Пинарик
Worker и SharedWorker
SharedWorker работает еще интереснее: несколько экземпляров окон могут разделить между собой один и тот же SharedWorker. Первое, что напрашивается — вынести стейт в SharedWorker и обращаться к нему.
Однако эта идея мне не понравилась. Мы не можем шарить память между процессами браузера (и это правильно). Все что мы можем — это отправлять сообщения в воркер и принимать сообщения из него. Ассинхронно. Либо передать какой-то подготовленный объект из потока worker-а в поток окна. Очевидно, что этот метод нам тоже не подходит, поскольку в момент передачи worker потеряет объект у себя.
Интерфейсы отправки сообщений у Worker и SharedWorker хоть и похожи, но слегка разные. Worker имеет массу ограничений, в частности — не может сам взаимодействовать DOM-деревом (логично, у него нет своего окна).
Более того, SharedWorker не может даже в консоль ничего написать, что делает его отладку особенно утомительной. Поэтому имеет смысл:
- Универсализировать отправку сообщений в Worker или Shared Worker
- Отладить все на Worker
- Переключиться на SharedWorker
Хинт: для отладки SharedWorker в браузере используйте chrome://inspect/#workers. Найдите свой SharedWorker, кликните Inspect. Так можно посмотреть консоль воркера.
Сборка Worker и SharedWorker с TypeScript для Electron. Отладка.
- Сделать отдельную конфигурацию webpack, собирающую воркеры. Этот подход мы используем в боевом приложении — нам там нужно все пожатое и оптимизированное (только не надо пытаться резать worker на чанки — но это и ежу понятно).
- Если используете сборщик Electron Forge — кажется, единственный вариант — сделать отдельную точку входа (Entry Point) в Electron. Удобно для отладки, так как почти адекватно работает hot reload (почти, но на самом деле — нет; если вы в отладке воркера и не хотите чинить фантомы — не полагайтесь на хотрелод: некоторые объекты, вроде WebSocket или EventListner в момент хотрелода могут быть проинициированы повторно, например. А могут и не быть. Лучше вообще исключите файлы воркеров из вотчера webpack. Сложно на словах, на деле — просто добавь watchOptions: { ignored: папка с воркерами} в конфиги вепбака).
Для этого в package.json в секции @electron-forge/plugin-webpack добавляем в entryPoints нужные нам воркеры:
{
name: 'MySuperApp',
html: './assets/index.ejs',
js: './src/index.tsx', preload: {
//Preload используем общий, для всех entryPoint
js: "./src/preload.ts"
}
}, // Worker. Альтернативный вариант: собирать отдельно через webpack
{
js: "./src/workers/worker_sync.ts",
name: "Worker",
},
{
js: "./src/workers/worker_service.ts",
name: "Service Worker",
},
Стандартизация транспорта. Worker, PostMessage, Emitter, Promise, TimeOut
this.worker.postMessage(message); // Веб-воркер
} else if (this.worker instanceof SharedWorker) {
this.worker.port.postMessage(message); // Пошареный воркер
} else if (this.worker instanceof ServiceWorker) {
// Сервис-воркер. Это не сработает для Chrome < 51. Но и нехер:
// https://bugs.chromium.org/p/chromium/issues/detail?id=543198
this.worker.postMessage(message);
}
Объяснение для менеджеров: это как своей коллеге стикер с заданием на экран повесить.
Сделает ли — не факт.
Тут очень хорошо помогает паттерн Emitter. Это объект, который можно попросить слушать события конкретного типа (например по id). Либо постоянно, либо один раз. В случае взаимодействия с воркером в режиме запрос->ответ нам будет достаточно получать событие один раз и отключаться от Emitter.
Звучит сложновато, в коде все это гораздо проще.
* Отправляет в воркер, ждет ответа на него
* @param message.
* @returns Promise обработки сообщения в воркере
*/
public postMessage<TResult = unknown, TInput = unknown>(message: TInput): Promise<TResult> {
// Подписываем сообщения uuidv4. Оборачиваем в формат [uid, message]. Отправляем в воркер.
// Эмиттер ждет разового ответа на сообщение с заданным id..
// Как получает сообщение — реджектит или резовит промис, в зависимости от того, была ли воркере ошибка
// Кроме того, запускаем таймер. Если сработал таймаут и не было ответа от воркера — реджектим промис, кидаем эксэпшин
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
const messageId = uuidv4();
const messageToSend = [messageId, message];
return new Promise((resolve, reject) => {
self.emitter.once(messageId, (error, result) => {
if (error) reject(error); else resolve(result);
return result;
})
self.postMessageToWorker(messageToSend);
setTimeout(() => {
if (hasListeners(self.emitter, messageId)) {
reject(new Error(`Timeout exceed, ${workerExceedTimeout} sec`))
self.emitter.off(messageId, resolve)
}
}, workerExceedTimeout * 1000)
})
}
Инициализация клиента, эмиттера и отправка сообщений в эмиттер:
private emitter: EventEmitter = new EventEmitter();
constructor(worker: Worker | SharedWorker | ServiceWorker | WebSocket) {
this.worker = worker;
// На события от воркеров тригегеррим onMessage, который в свою очередь рергает эмиттер
if (this.worker instanceof SharedWorker) {
this.worker.port.addEventListener("message", this.onMessage.bind(this), false);
this.worker.port.start();
} else {
this.worker.addEventListener('message', this.onMessage.bind(this))
}
}
// Добавляем возможность отработки любых событий внутри класса
onMessageReceive(e: MessageEvent) { return e } // Обработка выходящего события. Для перегрузки в дочерних классах.
onResultReceive(result: IWorkerActionResult, e: MessageEvent) { return e } // Получено событие с результатом
/**
* Слушает все сообщения. Если соответствуют нашему формату [messageId, error, result] — отправляем сообщение в эмиттер
* @param e
* @returns
*/
private onMessage(e: MessageEvent): void {
console.log("I RECEIVE MESSAGE");
e = this.onMessageReceive(e);
const message = e.data
if (!Array.isArray(message) || message.length < 2) {
// Игнорируем. Сообщение не нашего формата
return;
}
const messageId = message[0];
const error = message[1];
const result = message[2];
this.onResultReceive(result as IWorkerActionResult, e);
this.emitter.emit(messageId, error, result);
}
Объяснение для менеджеров
Promise: «Хорошо».
"Ой" и «Отправила» — возможные исходы промиса (Resolve / Reject).
Красный список — это Emitter с номерами событий (поручил-контролирую).
Будильник — это SetTimeout.
А уши у парня большие и красные потому, что у ему еще кучу всего другого слушать приходится.
Тем временем, на стороне Worker
export function postMessageToClient(sourceEvent: any, msg: any) {
if (sourceEvent.source) {
// shared worker -> renderer
sourceEvent.source.postMessage(msg);
} else if (typeof self.postMessage !== 'function') {
// service worker -> renderer
sourceEvent.ports[0].postMessage(msg);
} else {
// web worker -> renderer
self.postMessage(msg);
}
}
this.worker = worker;
// На события от воркеров тригегеррим onMessage, который в свою очередь рергает эмиттер
if (this.worker instanceof SharedWorker) {
this.worker.port.addEventListener("message", this.onMessage.bind(this), false);
this.worker.port.start();
} else {
this.worker.addEventListener('message', this.onMessage.bind(this))
}
}
* Формирует и отправляет message с ошибкой или результатом операции [messageId, error|null, result]
* @param {*} e событие, через которое можно отправлять данные в обратную сторону
* @param {*} messageId
* @param {*} error
* @param {*} result
*/
private postOutgoingMessage(e: MessageEvent, messageId: string, error: Error = null, result: unknown = null) {
console.log("postOutgoingMessage", e, messageId, error, result);
if (error) {
postMessageToClient(e,
[messageId, {
message: error.message
}]);
} else {
postMessageToClient(e, [messageId, null, result]);
}
}
Обработка входящих в worker сообщений:
abstract onMessage(message: IWorkerAction, e: MessageEvent): any;
/**
* Обработка входящих в worker сообщений
*
*/
private onIncomingMessage(e: MessageEvent, transportEvent: MessageEvent = null) {
// В случае Shared Worker, транспорт, через который надо отправлять события обратно,
// хранится событии connect.
if (!transportEvent)
transportEvent = e;
const data = e.data;
if (!Array.isArray(data) || data.length !== 2) {
// Сообщение не нашего типа. Пропускаем.
// todo: тут бы какую-то метку более понятную сделать.
// Иначе можно перебивать сообщения через этот канал, и просто сообщения
return;
}
const messageId = data[0];
const message = data[1];
try {
const result = this.onMessage(message, transportEvent);
// eslint-disable-next-line @typescript-eslint/no-this-alias
const _this = this;
if (!isPromise(result)) {
// Обычный результат, не промис
_this.postOutgoingMessage(transportEvent, messageId, null, result);
} else {
// Колбэк вернул промис.
// Дожидаемся результата, отправляем либо ошибку, либо результат вычислений промиса
result.then(function (finalResult: unknown) {
_this.postOutgoingMessage(transportEvent, messageId, null, finalResult);
}, function (finalError: Error) {
_this.postOutgoingMessage(transportEvent, messageId, finalError);
});
}
} catch (error) {
// Ошибка -- вызов колбэка упал
this.postOutgoingMessage(transportEvent, messageId, error, null);
}
}
Промежуточные итоги
Достаточно:
1. Создать файл, реализующий бизнес-логику Worker
…
onMessage(message: IWorkerAction, e: MessageEvent) {
…
return som value
}
…
}
const worker = new SyncWorker();
// const worker = new Worker(WORKER_SYNC_WEBPACK_ENTRY);
const worker = new SharedWorker(WORKER_SYNC_WEBPACK_ENTRY);
Server = new PromiseWorkerClient(worker);
…
Server.postMessage(query)
Ограничения
- Понятно, что не надо пытаться в качестве данных и результатов передавать колбеки, символы или DOM-деревья. Есть разумное ограничение на формат передаваемых между контекстами данных: Structured Clone Algoritm.
- В данной реализации нет гарантированного способа дождаться на клиенте, что скрип Worker успешно загрузился.
Что дальше
Успехов!