Раздел 46 · Effect: Тихая Академия

Улица: Effect в браузере

middle~25 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В уроке 104 витрина стала компонентом: React рисует то, что лежит в атомах из урока 103. Но атомы получали значения из нашего же кода. Теперь значения приходят из браузера: клики, координаты, сохранённая с прошлого раза башня. Браузер это внешний мир, такой же, как книга башен Магистрали. Он отвечает, когда хочет, может не ответить вовсе и ничего не гарантирует.

Для внешнего мира у Effect уже всё есть. Поток для того, что приходит много раз. Эффект для того, что приходит один раз или не приходит. Слой для того, что в одном браузере есть, а в другом запрещено.

Клики это поток

Горожанин нажимает кнопку башни на витрине. Потом ещё одну. Потом промахивается и попадает в прилавок. Это события: они приходят, когда пришли, и толкают обработчик сами. В уроке 85 такую линию превращали в поток через Stream.callback, а для событий браузера есть готовый Stream.fromEventListener:

import { Predicate, Stream } from 'effect';

const towerOf = (target: unknown): string | undefined => {
  if (!Predicate.hasProperty(target, 'getAttribute') || !Predicate.isFunction(target.getAttribute)) return undefined;
  const code: unknown = target.getAttribute('data-tower');
  return Predicate.isString(code) ? code : undefined;
};

export const towerClicks = (target: Stream.EventListener<{ readonly target: unknown }>): Stream.Stream<string> =>
  Stream.fromEventListener<{ readonly target: unknown }>(target, 'click').pipe(
    Stream.map((event) => towerOf(event.target)),
    Stream.filter(Predicate.isNotUndefined),
  );

Разберём.

  • Stream.EventListener в Effect 4.1 это не EventTarget из браузера, а структурный тип: всё, у чего есть addEventListener и removeEventListener. Подойдёт окно, кнопка, контейнер витрины и тестовая заглушка с этими двумя методами.
  • Stream.fromEventListener(target, 'click') подписывается на событие, когда поток запустили, и снимает слушателя, когда поток кончился, упал или его перестали читать. Ровно то, что в уроке 85 мы делали руками через acquireRelease.
  • Клик по кнопке и клик по прилавку приходят одинаково. towerOf смотрит на data-tower у того, по чему кликнули, и отвечает кодом башни или undefined. Проверки идут через Predicate: про event.target TypeScript ничего не знает, и угадывать не будем.
  • Stream.filter(Predicate.isNotUndefined) пропускает только коды. Предикат с сужением типа превращает Stream<string | undefined> в Stream<string>.

Почему не Stream.filterMap? В 4.1 он принимает не функцию в Option, а Filter, отдельную сущность из модуля Filter. Для «отбрось неподходящие» map и filter с предикатом короче и понятнее.

Что делает витрина с потоком кликов, решает вызывающий: взять первые три (Stream.take), положить каждый в атом selected, считать нажатия. Поток про это не знает. Он только честно отдаёт коды и честно отписывается.

Где тут дыра

type Clickable = { addEventListener(type: 'click', listener: (event: { readonly target: unknown }) => void): void };

let clicks = 0;

const watchClicks = (shop: Clickable) => {
  shop.addEventListener('click', () => {
    clicks += 1;
  });
};

На его примере всё верно: витрину открыли, слушатель повесили, клики считаются. Соседний пример: горожанин переходит со страницы башни на страницу Почтамта и обратно, и каждое открытие витрины зовёт watchClicks ещё раз. Через десять переходов на прилавке висят десять слушателей, и один клик считается за десять. Снять старого нечем: ссылку на функцию никто не сохранил.

Stream.fromEventListener снимает слушателя сам, потому что подписка живёт столько же, сколько чтение потока. Закрыли витрину, перестали читать поток, слушатель ушёл.

Геолокация: отказ это тоже ответ

Чонси хочет первой показать горожанину ближайшую башню. Браузер умеет определить место через navigator.geolocation, но спрашивает разрешения у человека. Человек может согласиться, отказать или не ответить вовсе: окно с вопросом висит, а он ушёл ставить чайник.

API геолокации старое и колбэчное: успех приходит в одну функцию, беда в другую. Для такого API в Effect есть Effect.callback, родственник Stream.callback, только для одного ответа:

import { Effect, Option } from 'effect';

interface Coords {
  readonly latitude: number;
  readonly longitude: number;
}

interface Geo {
  readonly getCurrentPosition: (
    ok: (position: { readonly coords: Coords }) => void,
    fail: (error: { readonly code: number }) => void,
  ) => void;
}

declare const nearest: (coords: Coords) => string;

export const nearestTower = (geo: Geo): Effect.Effect<Option.Option<string>> =>
  Effect.callback<Coords, { readonly code: number }>((resume) =>
    geo.getCurrentPosition(
      (position) => resume(Effect.succeed(position.coords)),
      (error) => resume(Effect.fail(error)),
    ),
  ).pipe(
    Effect.timeout('5 seconds'),
    Effect.map((coords) => Option.some(nearest(coords))),
    Effect.orElseSucceed(() => Option.none()),
  );

Effect.callback даёт функцию resume: её зовут один раз, с эффектом, которым закончится ожидание. Успех геолокации становится Effect.succeed, отказ Effect.fail с кодом беды. Geo описан своей формой, как Stream.EventListener: настоящий navigator.geolocation ей подходит, тестовая заглушка тоже.

Дальше три решения, и каждое про улицу.

  • Effect.timeout('5 seconds'): молчание тоже ответ. Через пять секунд витрина перестаёт ждать. Иначе горожанин, который ушёл ставить чайник, вернётся к пустой витрине.
  • Effect.map превращает координаты в код ближайшей башни, завёрнутый в Option.some.
  • Effect.orElseSucceed(() => Option.none()): отказ, беда геолокации и молчание сходятся в одно, «место неизвестно». Тип ошибки становится never. Витрина покажет все башни списком, и это нормальная витрина, а не сломанная.

Так выглядит мягкая деградация: отказ не превращается в ошибку, которую кто-то должен поймать. Он превращается в другое значение, попроще, но честное. Горожанин, который не дал узнать, где живёт, не должен за это платить сломанной страницей.

Память лавки

Последняя просьба Чонси: витрина должна помнить, какую башню горожанин смотрел в прошлый раз. В браузере для этого есть localStorage, хранилище строк по ключу, которое переживает перезагрузку. В уроке 95 Почтамт хранил номерки в KeyValueStore, и тот же сервис умеет жить поверх localStorage: KeyValueStore.layerStorage(() => localStorage).

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

import { Effect, Layer, Option } from 'effect';
import { KeyValueStore } from 'effect/persistence';

type Storage = Parameters<typeof KeyValueStore.layerStorage>[0] extends () => infer S ? S : never;

export const browserStore = (storage: () => Storage): Layer.Layer<KeyValueStore.KeyValueStore> =>
  Layer.unwrap(
    Effect.sync(() => {
      try {
        const store = storage();
        store.getItem('vitrina.probe');
        return KeyValueStore.layerStorage(() => store);
      } catch {
        return KeyValueStore.layerMemory;
      }
    }),
  );

export const rememberedTower = Effect.flatMap(KeyValueStore.KeyValueStore, (store) => store.get('vitrina.tower')).pipe(
  Effect.map(Option.fromNullishOr),
  Effect.orDie,
);

Layer.unwrap строит слой из эффекта: эффект решает, какой слой отдать. Запрет бывает разным: в одних браузерах исключение бросает уже чтение свойства localStorage, и его ловит вызов storage() внутри try, в других только первое обращение к хранилищу, и его ловит пробное getItem. Получилось, и память лавки живёт в localStorage. Не получилось, и она живёт в памяти вкладки, до закрытия. try здесь на своём месте: это граница с браузерным API, который бросает исключения, а не возвращает ошибки.

Код витрины об этом выборе не знает. rememberedTower просит KeyValueStore и получает Option<string>: башня, которую помнили, или ничего. rememberTower неудачную запись пропускает (Effect.ignore): хранилище, которое читается, но переполнено, витрину не роняет: башня не запомнится, и всё. Тот же код работает в браузере, где можно всё, в браузере, где хранить нельзя, и в тесте на KeyValueStore.layerMemory. Это тот же приём, что у книги башен в уроке 90: граница с миром это сервис, а слой решает, какой он сегодня.

Под капотом

Что даёт @effect/platform-browser

Свои маленькие обёртки в этом уроке нужны, чтобы было видно устройство. В настоящем приложении многое уже обёрнуто пакетом @effect/platform-browser. В версии 4.1.0 в нём есть Geolocation (координаты сервисом и слоем, в том числе слежение за перемещением), Permissions (спросить, разрешён ли доступ, до того как просить), BrowserKeyValueStore (готовые слои KeyValueStore поверх localStorage, sessionStorage и IndexedDB), BrowserStream (события окна и документа потоками), Clipboard и BrowserRuntime с запуском программы в браузере. Устроены они так же, как наш код: сервис, слой, ошибки в типе.

Нестабильно. KeyValueStore из effect/persistence и все модули @effect/platform-browser в 4.1 помечены @stability unstable: имена и слои могут поменяться в минорной версии. Stream.fromEventListener и Effect.callback стабильны. Урок написан на effect@4.1.0 и @effect/platform-browser@4.1.0.

Разминка

Витрина выходит на улицу. В src/window.ts ложатся поток кликов по кнопкам башен, ближайшая башня по геолокации с мягким отказом и память лавки, которая не падает без localStorage. Тесты гоняют всё в happy-dom: кликают по кнопкам, отказывают в доступе, молчат пять секунд по тестовым часам и запрещают хранилище.

Практика

Глубже в архиве

События и делегирование на голом DOM разобраны в уроке про DOM и события, хранилище браузера и формы в уроке про AJAX, хранилище и формы раздела про фронтенд.

Дальше

Чонси вешает на дверь лавки новую табличку: витрина работает у всех троих горожан. Дверь распахивается.

Липвиг ведёт новый раздел, EF14 «Башни без хозяина». Начинаем с урока 106.