Hello, world!
В этой небольшой заметке я хочу поделиться с вами двумя сниппетами, которые показались мне очень интересными. Первый сниппет представляет собой пример реализации простой реактивности (signal), второй — способ предотвращения несогласованности данных в результате состояния гонки (race condition). Первая конструкция используется в SolidJS (с некоторыми дополнительными оптимизациями), вторая — заимствована из одного рабочего проекта.
Интересно? Тогда прошу под кат.
Начнем с сигнала.
Взгляните на следующий код:
let currentListener
function createSignal(initialValue) {
let value = initialValue
const subscribers = new Set()
const read = () => {
if (currentListener) {
subscribers.add(currentListener)
}
return value
}
const write = (newValue) => {
value = newValue
subscribers.forEach((fn) => fn())
}
return [read, write]
}
function createEffect(callback) {
currentListener = callback
callback()
currentListener = null
}
Функция createSignal
создает "реактивное" значение, а функция createEffect
принимает коллбэк, который выполняется при изменении этого значения.
Пример использования данного сниппета:
const [count, setCount] = createSignal(0)
const button = document.querySelector('button')
createEffect(() => {
button.textContent = count()
})
button.addEventListener('click', () => {
setCount(count() + 1)
})
При нажатии кнопки значение счетчика увеличивается на единицу. Это приводит к обновлению текста кнопки.
Таким образом, код работает, как ожидается. Но… почему? Как это работает? ????
Давайте разбираться.
const [count, setCount] = createSignal(0)
count
и setCount
— это, соответственно, функции чтения и записи (read
и write
) значения переменной value
("живущей" в замыкании (closure)), возвращаемые createSignal()
. Значением value
здесь становится 0
.
createEffect(() => {
button.textContent = count()
})
Это, пожалуй, самая хитрая строчка в коде.
-
createEffect()
записывает переданный коллбэк в переменнуюcurrentListener
; -
createEffect()
запускает коллбэк; -
button.textContent = count()
выполняется справа налево; -
count()
(read()
) добавляетcurrentListener
в наборsubscribers
(делает коллбэк подписчиком); -
count()
возвращает значениеvalue
; - значение
value
становится текстом кнопки; - наконец,
createEffect()
очищаетcurrentListener
.
button.addEventListener('click', () => {
setCount(count() + 1)
})
Здесь нас интересует следующая строка:
setCount(count() + 1)
Она также выполняется справа налево:
-
count()
(read()
) возвращает значениеvalue
(на данном этапеcurrentListener === null
, поэтому никаких коллбэков вsubscribers
не добавляется); -
setCount(1 + 1)
(илиwrite(2)
) обновляетvalue
значением2
; -
setCount()
запускает все коллбэки, содержащиеся вsubscribers
(() => { button.textContent = count() }
).
Ловкость рук и никакого мошенничества ????
Теперь поговорим о несогласованности данных в результате состояния гонки.
Начнем с общего описания проблемы.
- На одной странице имеется возможность модификации данных, хранящихся на сервере, несколькими способами;
- после каждой модификации от сервера запрашиваются свежие данные (выполняются одинаковые запросы);
- при получении ответа на каждый запрос обновляется локальное состояние (данные, хранящиеся в памяти на клиенте), которое используется для рендеринга компонентов;
- модификации (и, соответственно, запросы) могут выполняться очень быстро;
- предположим, что выполняется 2 модификации, вторая через секунду после первой;
- на сервер отправляется 2 запроса;
- первый обрабатывается сервером 3 секунды, второй — 1 секунду;
- ответ на второй запрос приходит через 2 (1 + 1) секунды (обновление локального состояния -> повторный рендеринг), а ответ на первый запрос — через 3 (0 + 3) секунды (обновление локального состояния -> повторный рендеринг);
- пользователь видит состояние, актуальное после выполнения первой модификации (sic!);
- данные на клиенте не согласованы (не совпадают) с данными на сервере.
Набросаем абстрактный пример.
Разметка:
<div>
<button>2</button>
<button>4</button>
<button>6</button>
</div>
<p id="counter">0</p>
<p>Last button clicked: <span id="last-btn"></span></p>
Скрипт:
// функция, возвращающая случайное целое число в заданном диапазоне
const randInt = (min, max) => Math.floor(min + Math.random() * (max - min + 1))
// функция, имитирующая обработку запроса сервером
const sleep = (ms) => new Promise((res) => setTimeout(res, ms))
const [count, setCount] = createSignal(0)
const counter = document.getElementById('counter')
const lastBtn = document.getElementById('last-btn')
// текст параграфа обновляется при каждом изменении значения счетчика
createEffect(() => {
counter.textContent = count()
})
// функция, имитирующая получение данных от сервера
// задержка может составлять от 1 до 6 секунд
const getData = async () => await sleep(randInt(1, 6) * 1000)
// функция, имитирующая отправку запроса и
// обновление локального состояния при получении ответа
const update = async (n) => {
// в реальном приложении `n` будет возвращаться `getData()`
await getData()
setCount(n)
}
document.querySelectorAll('button').forEach((btn) => {
// каждая кнопка обновляет значение счетчика своим текстом (2, 4 или 6)
btn.addEventListener('click', () => {
const n = btn.textContent
// отображаем значение последней нажатой кнопки
lastBtn.textContent = n
// обновляем значение счетчика
update(n)
})
})
Демо:
При быстром нажатии нескольких кнопок возникает "состояние гонки", приводящее к тому, что итоговое значение счетчика может быть любым из трех: 2, 4 или 6. Мы не знаем, каким точно будет значение счетчика и не можем полагаться на него при производстве дальнейших вычислений. Кроме того, заметно, что текст параграфа все время обновляется новыми значениями. Это не есть хорошо. Значение счетчика (текст параграфа) должно быть таким же, как текст последней нажатой кнопки (последней модификации/запроса). Как этого достичь? Можно ли сделать это простыми средствами или без библиотеки не обойтись?
Сниппет:
class Query {
// переменная для хранения последнего промиса - запроса
#lastPromise
async last(promise) {
// записываем промис в переменную
this.#lastPromise = promise
// ждем ответа от сервера
const result = await promise
// индикатор того, что разрешенный промис является последним запросом
const isLast = this.#lastPromise === promise
// возвращаем результат и индикатор
return [result, isLast]
}
}
Создаем экземпляр Query
:
const query = new Query()
Оборачиваем вызов getData()
в метод last
и обновляем значение счетчика только в том случае, если индикатор isLast
имеет значение true
, т.е. данные для обновления являются ответом на последний запрос:
const update = async (n) => {
const [result, isLast] = await query.last(getData())
if (isLast) {
// в реальном приложении в `setCount()` будет передаваться `result`
setCount(n)
}
}
Демо:
Теперь значение счетчика всегда будет идентично тексту последней нажатой кнопки (результату обработки последнего запроса), а обновление значения счетчика выполняется однократно.
Таким образом, мы не только обеспечиваем согласованность данных, что хорошо для пользователя*, но также предотвращаем лишний повторный рендеринг, что хорошо для производительности приложения.
* согласованность данных — это хорошо не только для пользователя, но также для сервера, поскольку для последующих модификаций серверных данных вполне могут использоваться данные, хранящиеся на клиенте, и т.п.
Следует отметить, что приведенное решение не является идеальным, поскольку "лишние" запросы все равно выполняются (нагрузка на сеть). Более оптимальным является техника под названием "дедупликация запросов", когда мы отменяем запросы, находящиеся в процессе выполнения, например, с помощью AbortController.signal, и выполняем только последний запрос (понятно, что выполняющийся и новый запросы должны быть идентичными)*. Данный способ намного сложнее, чем рассмотренный. На мой взгляд, для дедупликации запросов лучше использовать готовые решения типа React Query, но там вас ждет одна из самых сложных задач в веб-разработке — правильная работа с кэшем ???? Существуют и другие способы борьбы с состоянием гонки.
* или просто не выполняем запросы в течение определенного времени, когда уверены, что запросы будут множественными (привет, debouncing)
Пожалуй, это все, чем я хотел с вами поделиться.
Надеюсь, вы узнали что-то новое и не зря потратили время.
Happy coding!
Комментарии (14)
vanxant
10.04.2023 10:10+4для последующих модификаций серверных данных вполне могут использоваться данные, хранящиеся на клиенте
Никогда так не делайте. Пользователям доверять нельзя.
Zoolander
10.04.2023 10:10const [count, setCount] = createSignal(0)
Зачем в этих функциях используется возврат массива, а не объекта? Чем это обусловлено, чем это лучше?
Кажется, я догадываюсь - так компактнее всего можно сделать переименование под текущую задачу, чтобы использовать только функции.
Но знаете, если бы createSignal возвращал объект - можно было бы переименовывать его. Этот объект и может содержать функцию отписки, которая жизненно необходима в паттерне Observable-Observer (а именно эта схема используется в данном посте, просто записанная в компактном функциональном стиле).
Zoolander
10.04.2023 10:10+1Вот мой вариант компактного типизированного подписчика-отписчика, который я использую для игр в HTML5. В играх, где объекты часто исчезают, отписка критически важна, поэтому createSignal мне не подошел (хотя он почти идеально имитирует поведение хуков в React, подозреваю, отсюда вытекает и все остальное)
// TState - в данном случае обычный генерик (T), , // позволяющий подставлять любые типы в момент создания type UnSubscriber = () => void; type StateObserver<TState> = (state: TState) => void; /** Это можно использовать и как event, и как state state - это по сути event, изначально имеющий какое-то значение поэтому в этом классе есть методы, присущие обоим концепциям **/ export class StateEvent<TState> { private subscribers = new Set<StateObserver<TState>>(); constructor(protected state?: TState) { } // этот метод необязателен, // но позволяет читать последнее значение - полезно для инициализации getState(): TState | undefined { return this.state; } // подписка, сразу возвращает отписку, которую можно использовать потом on(callback: StateObserver<TState>): UnSubscriber { this.subscribers.add(callback); return () => { this.subscribers.delete(callback); }; } // классический сеттер состояния setState(state: TState) { this.subscribers.forEach((fn) => { fn(state); }) this.state = state; } // это синтаксический сахар для того же, более привычный для event // хотите удалить - удаляйте, emit(data: TState) { this.setState(data); } // это можно вызвать, чтобы гарантированно почистить все подписки // если не хочется возиться с отдельными отписками // там, где это уместно - к примеру, при закрытии экрана основной игры // можно удалить подписчики на все события для этого экрана unSubScribeAll() { this.subscribers.clear(); } }
Использование
// store.ts к примеру, в целом где угодно export const events = { log: new StateEvent<unknown[]>() } //в логике, к примеру, в целом где угодно events.log.emit('Hello', 'World', someMessage); //в создании игрового объекта, к примеру, в целом где угодно const unSub = events.log.on((args: unknown[]) => console.log(...args)); //... где-то при разрушении объекта - отписка unSub();
aleksandr-s-zelenin
10.04.2023 10:10У меня тоже есть интересный сниппет, которым давно пора поделиться. Лежит на память без дела. Код из реального проекта. Обратите внимание на скобки в else, они круглые, но всё работает ;)
vcKomm
10.04.2023 10:10Ff 111.1.1 на android 13 с вами не согласен. В обоих примерах сначала обновляется last button, а через 3-10 секунд — счётчик
fransua
Неприятно, что в createEffect не любой код будет работать:
сonst [countA, setCountA] = createSignal(0);
сonst [countB, setCountB] = createSignal(0);
createEffect(() => {
button.textContent = countA() || countB();
});
Не подпишется на countB
RAX7
Можно сделать
createEffect
как во Vue.js whenDepsChangeтогда подпишется при первом обращении к
countB()
.fransua
Тогда при каждом write будет добавляться listener и подписки надо будет делать Set. Но останется проблема отписок.
RAX7
Так оно уже
const subscribers = new Set()
Да, и как её пофиксить быстро, красиво и в процессе не написать еще один фреймворк я не знаю.
Zoolander
Обычно можно сделать возврат отписчика из подписчика
Но с таким сниппетом, в котором Set тоже спрятанный и недоступный - это проблематично.
Знаете, тут мы дошли до момента, на котором мы либо все заворачиваем в еще одну функцию, либо не паримся и пишем обычный класс
RAX7
Я все же придумал как отписаться от эффекта
Использование
вроде вполне удобный велосипед получился
Alexandroppolus
В идеале эффект должен после каждого вызова пересматривать свои подписки, чтобы следить только за прочитанными (актуальными для себя) значениями, как, например, это сделано в MobX
Здесь после сброса флага не надо следить за count
RAX7
Тогда делаем следующие:
перед запуском колбэка удаляем эффект из всех подписок
вызываем колбэк, что приведет к подписке только у актуальных сигналов (реактивных переменных)
дополнительно: ограничиваем запуск эффекта, если не было изменения ни одной из переменных (внутри эффекта мы все равно можем только читать значения)
fransua
Зачем целый фреймворк, просто библиотеку, cellx например. Или вот тут я переписал cellx для себя с TypeScript.
Это, конечно, не сниппет, но всего 5Кб.