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)


  1. fransua
    10.04.2023 10:10
    +4

    Неприятно, что в createEffect не любой код будет работать:
    сonst [countA, setCountA] = createSignal(0);
    сonst [countB, setCountB] = createSignal(0);

    createEffect(() => {
    button.textContent = countA() || countB();
    });

    Не подпишется на countB


    1. RAX7
      10.04.2023 10:10

      Можно сделать createEffect как во Vue.js whenDepsChange

      function createEffect(callback) {
        const effect = () => {
          currentListener = effect
          callback()
          currentListener = undefined
        }
        effect()
      }
      

      тогда подпишется при первом обращении к countB().


      1. fransua
        10.04.2023 10:10

        Тогда при каждом write будет добавляться listener и подписки надо будет делать Set. Но останется проблема отписок.


        1. RAX7
          10.04.2023 10:10
          +1

          надо будет делать Set

          Так оно уже const subscribers = new Set()

          Но останется проблема отписок.

          Да, и как её пофиксить быстро, красиво и в процессе не написать еще один фреймворк я не знаю.


          1. Zoolander
            10.04.2023 10:10

            проблема отписок.

            Обычно можно сделать возврат отписчика из подписчика

            Но с таким сниппетом, в котором Set тоже спрятанный и недоступный - это проблематично.

            Знаете, тут мы дошли до момента, на котором мы либо все заворачиваем в еще одну функцию, либо не паримся и пишем обычный класс


            1. RAX7
              10.04.2023 10:10
              +1

              Я все же придумал как отписаться от эффекта

              let currentListener
              const effectsMap = new Map()
              
              function createSignal(initialValue) {
                let value = initialValue
                const subscribers = new Set()
              
                const read = () => {
                  if (currentListener) {
                    subscribers.add(currentListener)
                    
                    let effectsList = effectsMap.get(currentListener)
                    if (!effectsList) {
                      effectsList = new Set()
                      effectsMap.set(currentListener, effectsList)
                    }
              
                    effectsList.add(subscribers)
                  }
                  return value
                }
              
                const write = (newValue) => {
                  value = newValue
                  subscribers.forEach((fn) => fn())
                }
              
                return [read, write]
              }
              
              function createEffect(callback) {
                const effect = () => {
                  currentListener = effect
                  callback()
                  currentListener = undefined
                }
                effect()
                
                return () => {
                  const effectsList = effectsMap.get(effect)
                  
                  if (effectsList) {
                    for (const subscribers of effectsList) {
                      subscribers.delete(effect)
                    }
                    effectsMap.delete(effect)
                  }
                }
              }
              

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

              const revomeEffect = createEffect(() => {
                counter.textContent = count()
              })
              
              // later
              revomeEffect()
              

              вроде вполне удобный велосипед получился


              1. Alexandroppolus
                10.04.2023 10:10

                В идеале эффект должен после каждого вызова пересматривать свои подписки, чтобы следить только за прочитанными (актуальными для себя) значениями, как, например, это сделано в MobX

                const [flag, setFlag] = createSignal(true);
                const [count, setCount] = createSignal(1);
                
                createEffect(() => {
                    console.log(flag() ? count() : 0);
                });

                Здесь после сброса флага не надо следить за count


                1. RAX7
                  10.04.2023 10:10
                  +1

                  Тогда делаем следующие:

                  • перед запуском колбэка удаляем эффект из всех подписок

                  • вызываем колбэк, что приведет к подписке только у актуальных сигналов (реактивных переменных)

                  • дополнительно: ограничиваем запуск эффекта, если не было изменения ни одной из переменных (внутри эффекта мы все равно можем только читать значения)

                  let currentEffect = null
                  let globalWriteCount = 1
                  const effectsMap = new Map()
                  
                  function createSignal(initialValue) {
                    let value = initialValue
                    const subscribers = new Set()
                  
                    const read = () => {
                      if (currentEffect) {
                        subscribers.add(currentEffect)
                  
                        let effectsList = effectsMap.get(currentEffect)
                        if (!effectsList) {
                          effectsList = new Set()
                          effectsMap.set(currentEffect, effectsList)
                        }
                  
                        effectsList.add(subscribers)
                      }
                      return value
                    }
                  
                    const write = (newValue) => {
                      if (newValue === value) return
                  
                      value = newValue
                      globalWriteCount += 1
                      subscribers.forEach((fn) => fn())
                    }
                  
                    return [read, write]
                  }
                  
                  function createEffect(callback) {
                    let writeCount = 0
                  
                    const effect = () => {
                      if (writeCount === globalWriteCount) return
                  
                      writeCount = globalWriteCount
                      currentEffect = effect
                      const effectsList = effectsMap.get(effect)
                  
                      if (effectsList) {
                        for (const subscribers of effectsList) {
                          subscribers.delete(effect)
                        }
                      }
                  
                      callback()
                      currentEffect = null
                    }
                  
                    effect()
                  
                    return () => {
                      const effectsList = effectsMap.get(effect)
                  
                      if (effectsList) {
                        for (const subscribers of effectsList) {
                          subscribers.delete(effect)
                        }
                        effectsMap.delete(effect)
                      }
                    }
                  }
                  


          1. fransua
            10.04.2023 10:10

            Зачем целый фреймворк, просто библиотеку, cellx например. Или вот тут я переписал cellx для себя с TypeScript.
            Это, конечно, не сниппет, но всего 5Кб.


  1. vanxant
    10.04.2023 10:10
    +4

    для последующих модификаций серверных данных вполне могут использоваться данные, хранящиеся на клиенте

    Никогда так не делайте. Пользователям доверять нельзя.


  1. Zoolander
    10.04.2023 10:10

    const [count, setCount] = createSignal(0)

    Зачем в этих функциях используется возврат массива, а не объекта? Чем это обусловлено, чем это лучше?

    Кажется, я догадываюсь - так компактнее всего можно сделать переименование под текущую задачу, чтобы использовать только функции.

    Но знаете, если бы createSignal возвращал объект - можно было бы переименовывать его. Этот объект и может содержать функцию отписки, которая жизненно необходима в паттерне Observable-Observer (а именно эта схема используется в данном посте, просто записанная в компактном функциональном стиле).


  1. 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();
    


  1. aleksandr-s-zelenin
    10.04.2023 10:10

    У меня тоже есть интересный сниппет, которым давно пора поделиться. Лежит на память без дела. Код из реального проекта. Обратите внимание на скобки в else, они круглые, но всё работает ;)


  1. vcKomm
    10.04.2023 10:10

    Ff 111.1.1 на android 13 с вами не согласен. В обоих примерах сначала обновляется last button, а через 3-10 секунд — счётчик