В уроке 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.targetTypeScript ничего не знает, и угадывать не будем. 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.