Заметки в категории «JavaScript»

Разная композиция функций

Часто путаю методы композиции функий compose() и pipe() в Ramda , а потом сижу и разбираюсь почему цепочка функций работает не так как я хочу. 😞

compose() выполняет функции справа налево, pipe() наоборот — слева направо.

Думаю, эта дезориентация пошла от реактовых компонент высшего порядка.

Я привык писать:


const enhance = compose(
  /* подключаю компоненту к стору */
  connect(mapStateToProps, mapDispatchToProps),
  /* добавляю некоторые обработчики событий */
  withHandlers(handlers),
  /* удаляю лишние данные */
  cleanProps()
);

/* оборачиваю компоненту */
export default enhance(MyDumbComponent);

Каждая компонента высшего порядка порождает соответствующую React-компонту в дереве узлов. Их иерархия в точности соответствует порядку их объявления.

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

Например, вот функция, которая выдаёт уникальные символы в строке, отсортированные по алфавиту:


const uniqChars = R.pipe(
  R.split(''),
  R.uniq,
  R.sortBy(R.prop(0)),
  R.join('')
);

uniqChars('Hello world!'); // " !Hdelorw"

Если для композиции использовать метод compose(), то в этом примере сразу будет ошибка:


TypeError: "Hello world!" does not have a method named "join"

Так почему с функциями высшего порядка всё работает правильно?!

Разгадка заключается в том, что HoC не запускаются сразу, а оборачивают переданный им компонент в ещё один компонент. Получается, что MyDumbComponent оборачивается в компонент cleanProps, потом в withHandlers, и, наконец, в connect . А когда React будет рисовать этот “умный” компонент, то начнёт вызывать эту матрёшку с самого верхнего слоя.

Оставте свой комментарий

Размер объекта в памяти

Если вам для каких-то целей потребовалось узнать сколько памяти занимает какой-либо объект в браузере, то инструменты разработки помогут вам это сделать. В Chrome DevTools в разделе Memory можно сделать снимок памяти и посмотреть какие там есть объекты на данный момент времени. Однако, без подготовки найти нужные данные будет совсем не просто.

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

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

Делаем начальный снимок памяти

Теперь переключимся в раздел Console и добавим какой-то тестовый объект.

Создаем объект в консоли

Затем делаем ещё один снимок памяти. Укажем в селекторе, что нужно показать только объекты, созданные между первым и вторым снимком.

Retained size объекта

В списке станет значительно меньше данных. Нас интересует именно Object. И первым же выбранным элементом оказывается наш тестовый объект.

В колонке Retained Size будет указано сколько памяти он занимает. В моём случае это 2488 байт.

Создадим ещё аналогичный объект, но с другими данными.

Создаем другой объект в консоли

Сделаем ещё один снимок экрана и обнаружим, что размер занимаемой памяти теперь значительно уменьшился. Второй объект у меня занимает 736 байт.

Новый объект занимает меньше памяти

Такое поведение браузера обусловлено тем, что для каждого объекты хранятся в различных представлениях в зависимости от их сложности. Когда во время исполнения кода появляются одинаковые по структуре объекты, то браузер значительно оптимизирует расходы памяти.

Грубо оценить эти расходы можно следующим образом:

  • 2 байта на символ строки
  • 8 байт на число
  • 4 байта на булевское значение

Значит мои тестовые объект со случайными значениями полей будут занимать примерно 420-500 байт. Плюс, нужно учесть накладные расходы на хранение структур самих объектов.

Чтобы проверить эту оценку, загрузим 10 тысяч объектов, подобных тем, что я использовал в предыдущих примерах.

Загружаем 10000 объектов

Чтобы значение, обрабатываемое в промисе, не потерялось, так же сохраняем его в глобальную переменную.

Теперь в снимке памяти нас интересует массив.

Массив с тестовыми данными

Итак, загруженные данные (10 тысяч объектов) занимают 4959384 байта, что в пересчете на один объект будет 496 байт. Это значение хорошо вписывается в оценку.

Оставте свой комментарий

Получение текущего состояние коллекции MongoDB в виде набора обновлений

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

Получение состояния коллекции

Нам нужно будет написать еще один оператор (файл state.js).


const { pipe } = require('rxjs');
const { flatMap, map } = require('rxjs/operators');
const fromStream = require('stream-to-observable');

module.exports = function (collection) {
  return pipe(
    // #1
    map(db => (
      db
        .collection(collection)
        .find({})
    )),
    // #2
    flatMap((cursor) => (
      fromStream(cursor)
    )),
    // #3
    map(payload => ({
      type: 'insert',
      payload
    }))
  );
};

Он оказался очень похож на оператор, который генерирует поток изменений. Все так же с помощью функции pipe делаем композицию из трех функций:

  1. Из экземпляра базы данных получаем курсор на все записи, которые хранятся в коллекции.
  2. Преобразуем Stream в Observable и получаем поток данных.
  3. Для упрощения кода предположим, что текущее состояние коллекции формируется из вставки записей в пустой массив. Другими словами, для клиента в браузере записи, соответствующие текущему состоянию, и вновь добавляемые записи имею один и тот же тип — insert.

Внесем изменения в index.js


require('any-observable/register')('rxjs');
const io = require('socket.io')(8081);
const { fromEvent, merge } = require('rxjs');
const { takeUntil } = require('rxjs/operators');
const getDb = require('./db');
const state = require('./state');
const changes = require('./changes');

// создадим оператор для получения начального состояния
const testState = state('test');
const testChanges = changes('test');

io.on('connect', socket => {
  const db$ = getDb();
  const stop$ = fromEvent(socket, 'disconnecting');

  // поток, в котором будет сгенерировано состояние коллекции
  const state$ = db$.pipe(testState);
  const change$ = db$.pipe(testChanges);

  // объединим поток с начальным состоянием и поток изменений
  merge(state$, change$)
    .pipe(takeUntil(stop$))
    .subscribe(data => socket.emit('test', data));
});

Как только браузер подключится к серверу, так в консоли отобразятся все записи, которые были на тот момент в коллекции в виде событий insert .

Скорее всего, такое поведение сервера будет не всегда оправдано. Браузер может на какое-то время потерять соединение с сервером и после восстановления соединения сервер отправит всё состояние коллекции. Но браузеру оно не нужно, так как оно уже находится у него в памяти.

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

Добавим в скрипты index.html следующую строку:

socket.emit('getAllRecords');

А финальная версия index.js будет выглядеть так:


require('any-observable/register')('rxjs');
const io = require('socket.io')(8081);
const { fromEvent, merge } = require('rxjs');
const { flatMap, takeUntil } = require('rxjs/operators');
const getDb = require('./db');
const state = require('./state');
const changes = require('./changes');

const testState = state('test');
const testChanges = changes('test');

io.on('connect', socket => {
  const db$ = getDb();
  const stop$ = fromEvent(socket, 'disconnecting');

  const state$ = db$.pipe(testState);
  const change$ = db$.pipe(testChanges);

  // отдаём состояние только по запросу
  const getAllRecords$ = fromEvent(socket, 'getAllRecords')
    .pipe(flatMap(() => state$));

  // объединяем поток изменений с потоком, в котором состояние
  // коллекции появится только после специального события
  merge(getAllRecords$, change$)
    .pipe(takeUntil(stop$))
    .subscribe(data => socket.emit('test', data));
});

Заключение

Текущее состояние коллекции можно получить как список изменений для пустого массива на клиенте. Его можно отдавать сразу при подключении или по запросу. И на клиенте для этого не потребовалось вносить существенных изменений.

Оставте свой комментарий

Отслеживание обновлений коллекции MongoDB в браузере почти в реальном времени

Я как-то заинтересовался возможностью получать все изменения из определенной таблицы базы данных в браузере, чтобы отображать их в интерфейсе в почти реальном времени. Например, Firebase изначально работает по такому принципу. Но хотелось бы использовать какое-то не облачное решение.

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

Поключение к базе данных

Сервер будет работать на Node.js и зависеть от модулей mongodb, socket.io, rxjs и stream-to-observable.

Начнем с подключения к базе данных (файл db.js):


const { from } = require('rxjs');
const { map } = require('rxjs/operators');
const { MongoClient } = require('mongodb');

// впишите необходимые параметры подключения
const MONGODB_URL = '';
const MONGODB_OPTS = {};
const MONGODB_DATABASE = '';

const client = MongoClient.connect(MONGODB_URL, MONGODB_OPTS);

module.exports = function () {
  return from(client)
    .pipe(map(c => c.db(MONGODB_DATABASE)));
};

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

Поток с подключением к базе данных

Теперь займёмся сервером, принимающим запросы браузеров (файл index.js):


const io = require('socket.io')(8081);
const { fromEvent } = require('rxjs');
const { takeUntil } = require('rxjs/operators');
const getDb = require('./db');

io.on('connect', socket => {
  const db$ = getDb();
  const stop$ = fromEvent(socket, 'disconnecting');

  db$
    .pipe(takeUntil(stop$))
    .subscribe(() => socket.emit('test', 'Hello, world!'));
});

Проверяем как работает сервер с помощью скрипта в браузере (файл index.html)


<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.1.1/socket.io.js"></script>
<script>
  const socket = io.connect('localhost:8081');
  socket.on('test', data => console.log(JSON.stringify(data)));
</script>

Если всё было сделано правильно, то в консоли браузера появится сообщение

> Hello, world!

Фактически, это сообщение появится, когда будет доступно подключение к базе данных. Оно инициализируется один раз при запуске сервера и используется для всех подключений к серверу. На каждое подключение к серверу извне создается Observable с экземпляром базы данных.

Поток завершился раньше, чем подключение к базе данных

Если браузер не дождался пока сервер подключится к базе данных, то этот Observable завершается без генерации данных. Это поведение управляется через другой Observable stop$ . Его значимость я объясню чуть позже.

Получение изменений

Теперь напишем модуль, который будет отдавать изменения из базы данных (файл changes.js)


const { pipe } = require('rxjs');
const { flatMap, map } = require('rxjs/operators');
const fromStream = require('stream-to-observable');

module.exports = function (collection) {
  return pipe(
    // #1
    map(db => (
      db
        .collection(collection)
        .watch({ fullDocument: 'updateLookup' })
    )),
    // #2
    flatMap(cursor => (
      fromStream(cursor, { dataEvent: 'change' })
    )),
    // #3
    map(data => ({
      type: data.operationType,
      payload: data.fullDocument || data.documentKey
    }))
  );
};

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

Функция pipe делает композицию из трех функций:

  1. Из экземпляра базы данных получаем курсор с изменениями. Функция map() заменит в потоке значение db на то значение, которое вернул колбек.
  2. Из курсора получаем фактические данные данные. В этом месте используется преобразование Stream в Observable. Функция flatMap() будет генерировать значения пока не закончится поток данных, порожденный курсором.
  3. Данные конвертируются в нужный формат.

Смотрите в чём отличие map() от flatMap().

Функция map() из одного элемента генерирует один элемент, но с другим здачением.

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

Когда внутри потока появляется еще один поток, то flatMap() начинает объединять родительский и дочерние потоки в один. Так в потоке из одного элемента могут появиться другие элементы.

Появляется вложенный поток Потоки объединяются в один

Внесем изменения в файл index.js


// зарегистрируем библиотеку rxjs как поставщик объектов
// типа Observable для модуля stream-to-observable
require('any-observable/register')('rxjs');
const io = require('socket.io')(8081);
const { fromEvent } = require('rxjs');
const { takeUntil } = require('rxjs/operators');
const getDb = require('./db');
const changes = require('./changes');

// создадим оператор для наблюдения за коллекцией test
const testChanges = changes('test');

io.on('connect', socket => {
  const db$ = getDb();
  const stop$ = fromEvent(socket, 'disconnecting');

  db$
    // добавим оператор testChanges в цепочку
    .pipe(testChanges, takeUntil(stop$))
    // отправляем данные в браузер
    .subscribe(data => socket.emit('test', data));
});

Предполагается, что MongoDB будет генерировать 5 типов изменений: «insert», «update» и «delete», «replace» и «invalidate». Все они будут приходить в браузер через событие test клиента Socket.IO.

Самое время запустить консоль mongo и попробовать добавлять, изменять и удалять данные в таблице.


> db.test.insert({value: 0.1})
> db.test.insert({value: 0.6})
> db.test.insert({value: 0.8})
> db.test.insert({value: 0.3})
> db.test.updateMany({value: {$gt: 0.5}}, {$set: {value: 1}})
> db.test.remove({value: {$lte: 0.5}})

Тем временем в консоли браузера будут тут же отображаться результаты операций.


> {"type":"insert","payload":{"_id":"5b3f29ed2e303b6b6a3b27a3","value":0.1}}
> {"type":"insert","payload":{"_id":"5b3f29ee2e303b6b6a3b27a4","value":0.6}}
> {"type":"insert","payload":{"_id":"5b3f29ee2e303b6b6a3b27a5","value":0.8}}
> {"type":"insert","payload":{"_id":"5b3f29ef2e303b6b6a3b27a6","value":0.3}}
> {"type":"update","payload":{"_id":"5b3f29ee2e303b6b6a3b27a4","value":1}}
> {"type":"update","payload":{"_id":"5b3f29ee2e303b6b6a3b27a5","value":1}}
> {"type":"delete","payload":{"_id":"5b3f29ed2e303b6b6a3b27a3"}}
> {"type":"delete","payload":{"_id":"5b3f29ef2e303b6b6a3b27a6"}}

Остановка потока

Так как поток изменений получился бесконечным, то вот здесь и пригодится Observable stop$ , который я упомянул ранее.

Браурез отключился раньше чем завешрился поток

Данные в stop$ появятся когда браузер будет отключаться от сервера (например, при перезагрузке страницы или закрытии окна). Для потока обновлений это будет служить признаком того, что его нужно завершить (оператор takeUntil). Когда мы корректно завершаем поток, то все подписчики автоматически отписываются и освобождают выделенные ресурсы, если это требуется.

Заключение

Так совсем небольшими усилиями удалось получить доступ к данным практически в реальном времени. Библиотека socket.io скрыла от разработчика всю сложность работы с WebSocket. А использование библиотеки rxjs сделало код лаконичным.

Дальнейшим логичным шагом в развитии этого эксперимента будет получение текущего состояния коллекции при инициализации клиента в браузере.

Комментарии к заметке: 2

Отменяемые промисы

Промисы не имеют встроенного механизма отмены. Из-за этого разработчики придумывают различные хаки, которые порождают некоторые проблемы.

Дополнительный метод .cancel()

Помимо того, что метод .cancel() делает промис нестандартным, нарушается еще один из главных принципов — только функция, которая создала промис, может изменять его состояние.

Отсутствие очистки после отмены

Некоторые используют Promise.race() в качестве механизма отмены. Однако, в этом случае у функции, создающей промис, нет возможности узнать, что произошла отмена и нужно освободить некоторые ресурсы.

speculation

Эрик Элиот написал маленький модуль speculation, который решает перечисленные выше проблемы.

Сигнатура функций:

speculation(fn: PromiseFunction, shouldCancel: Promise) => Promise

По аналогии с конструктором Promise , первым аргументом передается функция «исполнитель», а вторым аргументом передается промис для отмены.


PromiseFunction(resolve: Function, reject: Function, onCancel: Function) => Void

Функция-исполнитель принимает 3 аргумента и реализует логику разрешения, отклонения и отмены. Вот почему у самого промиса не нужен дополнительный метод .cancel() , и при этом вся логика, связанная с отменой, реализована внутри функции, которая создаёт этот промис.


import speculation from 'speculation';

function doWork(cancel = Promise.reject()) {
  speculation(
    (resolve, reject, onCancel) => {
      // делаем вычисления, которые завершаем вызовом resolve()

      onCancel(() => {
        // отменяем вычисления и освобождаем ресурсы
        reject(new Error('Cancelled'));
      });
    },
    cancel
  );
}

Отменённый промис получает статус rejected.

AbortController и AbortSignal

В браузерах начал появляться немного другой механизм для отмены асинхронных операций, возвращающих промисы. Например, fetch() может принимать объект типа AbortSignal и останавливать запрос.


const controller = new AbortController();
const signal = controller.signal;

const request = fetch(url, {signal});

// запрос можно отменить, если необходимо
controller.abort();

Значение signal.aborted меняется только из контроллера. Одновременно с этим испускается событие abort . В спецификации дан пример, как можно применять AbortController и AbortSignal для своих API.

Отменённый промис так же получает статус rejected, а значение устанавливается в new DOMException('Aborted', 'AbortError').

Оставте свой комментарий