-
Notifications
You must be signed in to change notification settings - Fork 15
Модуль Async
Современные веб-приложения — это большой клубок асинхронных вызовов. Например, при изменении свойства компонента отправляется асинхронный запрос на сервер; параллельно компонент отслеживает событие прокрутки экрана и обновляет другие свойства, а также генерирует события, которые в свою очередь слушают другие компоненты и т. д. А теперь представьте, что пользователь нажимает на ссылку и переходит на другую страницу: т. к. у нас SPA, то браузер не делает реальный переход, а вместо этого кидает сообщение нашему приложению, которое уже начинает загрузку новых модулей, но ему также нужно отменить/остановить все текущие асинхронные действия, иначе это может привести к очень сложно отлаживаемым ошибкам.
Вопрос в том, как именно реализовать «очистку» асинхронных событий. В самом худшем случае мы в ручном режиме контролируем такие события и самостоятельно чистим их в деструкторе компонента, например,
import iBlock, { component, field, system } from 'super/i-block/i-block';
export * from 'super/i-block/i-block';
@component()
export default class bExample extends iBlock {
@field()
i: number = 0;
@system()
intervalID?: number;
onScroll() {
console.log('Scroll…');
}
mounted() {
// mounted у компонента может вызваться несколько раз,
// поэтому на всякий случай чистим предыдущий таймер
clearInterval(this.intervalID);
this.intervalID = setInterval(() => { this.i++; }, 1e3);
document.removeEventListener('scroll', this.onScroll);
document.addEventListener('scroll', this.onScroll);
}
beforeDestroy() {
clearInterval(this.intervalID);
document.removeEventListener('scroll', this.onScroll);
}
}
Как видите, ручной контроль асинхронных событий очень быстро приводит к «захламлению» кода и самое главное — очень легко забыть очистить тот или иной ресурс. Для решения этой проблемы в V4 есть специальный модуль Async, который предоставляет набор методов для полного контроля любых асинхронных действий (далее потоков). И хотя сам модуль никак не связан с компонентами (т.е. его можно использовать и в других местах), но все наследники базового блока iBlock имеют свойство .async
с экземпляром класса Async.
Давайте перепишем пример выше с использованием Async:
import iBlock, { component, field, system } from 'super/i-block/i-block';
export * from 'super/i-block/i-block';
@component()
export default class bExample extends iBlock {
@field()
i: number = 0;
onScroll() {
console.log('Scroll…');
}
mounted() {
// label гарантирует, что при регистрации интервала с такой же меткой
// старый будет автоматически отменен
this.async.setInterval(() => { this.i++; }, 1e3, {
label: 'inc i'
});
// Аналогичная логика с label, но для события
this.async.on(document, 'scroll', this.onScroll, {
label: 'onScroll'
});
}
}
Как видите использование Async сводится к вызову одной из готовых оберток под нужное действие: setTimeout
, setInterval
, request
и т. д. Нам также не нужно думать про очистку ресурсов при уничтожении компонента — это сделает сам Async в деструкторе компонента (эта логика реализована в iBlock). Помимо основных параметров специфичных для каждого из методов, любой из Async методов обладает рядом необязательных параметров, которые дают более полный контроль за происходящим.
{
label?: string | symbol;
group?: string;
join?: boolean | 'replace';
}
С параметром label
мы уже работали в предыдущем примере: он позволяет помечать поток специальной меткой и если до момента его завершения будет попытка установить новый поток с такой же меткой, то старый поток будет уничтожен (для Promise объектов будет выброшено специальное исключение). При этом следует отметить, что различные по типу потоки регистрируются в отдельных песочницах, т. е. одинаковая метка в setTimeout
не может отменить обработчик события. Хорошей практикой считается использование символов для задания меток, т. к. это помогает избежать ситуации, когда мы случайно используем одну метку в дочернем и родительском компоненте для разных по смыслу, но одинаковых по типу потоков. В V4 есть специальной модуль для удобной генерации символов:
import symbolGenerator from 'core/symbol';
import iBlock, { component, field, system } from 'super/i-block/i-block';
export * from 'super/i-block/i-block';
// На всякий случай экспортируем объект символов,
// чтобы иметь возможность использования в дочернем компоненте
// (хотя это нужно крайне редко)
export const
$$ = symbolGenerator ();
@component()
export default class bExample extends iBlock {
@field()
i: number = 0;
onScroll() {
console.log('Scroll…');
}
mounted() {
this.async.setInterval(() => { this.i++; }, 1e3, {
// При первом обращении к свойству будет создан символ,
// который будет возвращаться для всех последующих обращений
label: $$.incI
});
this.async.on(document, 'scroll', this.onScroll, {
label: $$.onScroll
});
}
}
- proxy (метод
proxy
) - promise (методы
promise
,sleep
,wait
,nextTick
,idle
,animationFrame
,promisifyOnce
) - request (метод
request
) - idleCallback (метод
requestIdleCallback
) - timeout (метод
setTimeout
) - interval (метод
setInterval
) - immediate (метод
setImmediate
) - worker (метод
worker
) - eventListener (методы
on
,once
,dnd
) - animationFrame (метод
requestAsimationFrame
)
Параметр join
позволяет изменить стратегию работы label
. По умолчанию старый поток уничтожается, но если задать join: true
, то старый поток будет сохранен, а новый не будет создан: вместо этого метод вернет ссылку на старый поток. Это особенно полезно при работе с обертками над Promise, т. к. в случае использования меток без join
, будет выбрасываться исключение:
import symbolGenerator from 'core/symbol';
import iBlock, { component, field, system } from 'super/i-block/i-block';
export * from 'super/i-block/i-block';
export const
$$ = symbolGenerator ();
@component()
export default class bExample extends iBlock {
created() {
this.async.promise(Promise.resolve(1), {label: $$.promise})
.catch(() => console.log('Abort! '));
this.async.promise(Promise.resolve(2), {label: $$.promise})
.then((r) => console.log(r));
// Abort!
// 2
}
}
При использовании с join: true
:
import symbolGenerator from 'core/symbol';
import iBlock, { component, field, system } from 'super/i-block/i-block';
export * from 'super/i-block/i-block';
export const
$$ = symbolGenerator ();
@component()
export default class bExample extends iBlock {
created() {
this.async.promise(Promise.resolve(1), {label: $$.promise})
.then((r) => console.log(r));
this.async.promise(Promise.resolve(2), {label: $$.promise, join: true})
.then((r) => console.log(r));
// 1
// 1
}
}
Последнее значение join: 'replace'
работает только для Promise объектов: предыдущий поток будет уничтожен, но вместо генерации исключения обещание начнет ссылаться на новый поток, например,
import symbolGenerator from 'core/symbol';
import iBlock, { component, field, system } from 'super/i-block/i-block';
export * from 'super/i-block/i-block';
export const
$$ = symbolGenerator ();
@component()
export default class bExample extends iBlock {
created() {
this.async.promise(Promise.resolve(1), {label: $$.promise})
.then((r) => console.log(r));
this.async.promise(Promise.resolve(2), {label: $$.promise, join: 'replace'})
.then((r) => console.log(r));
// 2
// 2
}
}
Параметр group
позволяет задать строковую метку для любых потоков (не обязательного одного типа), а затем использовать методы очистки или приостановки по заданной группе. Это удобно для логического связывания потоков, например, логика Drag&Drop навешивает на элемент события: mousedown
, mouseup
, mousemove
, touchstart
, touchend
, touchmove
— их можно объединить в группу, и потом использовать для очистки.
import iBlock, { component, field, system } from 'super/i-block/i-block';
export * from 'super/i-block/i-block';
@component()
export default class bExample extends iBlock {
created() {
this.async.setTimeout(() => console.log(1), 500, {group: 'foo'});
this.async.setImmediate(() => console.log(2), {group: 'foo'});
this.async.clearAll({group: 'foo'});
}
}
Когда параметр label
используется вместе с group
, то его действие применяется только для потоков одного типа и одной группы.
import symbolGenerator from 'core/symbol';
import iBlock, { component, field, system } from 'super/i-block/i-block';
export * from 'super/i-block/i-block';
export const
$$ = symbolGenerator ();
@component()
export default class bExample extends iBlock {
created() {
this.async.promise(Promise.resolve(1), {label: $$.promise, group: '1'})
.then((r) => console.log(r));
this.async.promise(Promise.resolve(2), {label: $$.promise, group: '2'})
.then((r) => console.log(r));
// 1
// 2
}
}
У каждого метода регистрации потока есть метод для очистки, например, setTimeout
/ clearTimeout
и т. д. Также есть универсальный метод clearAll
, который позволяет делать очистку сразу нескольких типов.
Если вызвать метод очистки (например, clearTimeout
) и не указать никаких параметров, то будут очищены все потоки заданного типа. Если вызвать clearAll
без параметров — будут очищены все зарегистрированные потоки всех типов.
Все методы регистрации возвращают значение, которое можно использовать для точечной очистки, например,
import iBlock, { component, field, system } from 'super/i-block/i-block';
export * from 'super/i-block/i-block';
@component()
export default class bExample extends iBlock {
created() {
const id1 = this.async.setTimeout(() => console.log(1), 500);
this.async.clearTimeout(id);
const id2 = this.async.setImmediate(() => console.log(2));
this.async.clearAll(id2);
}
}
Метку label
также можно использовать для очистки событий: при этом если использовать clearAll
и label
, то удалятся потоки всех типов с заданной меткой. Также можно использовать group
для сужения поиска.
import iBlock, { component, field, system } from 'super/i-block/i-block';
export * from 'super/i-block/i-block';
@component()
export default class bExample extends iBlock {
created() {
this.async.setTimeout(() => console.log(1), 500, {label: 'foo'});
this.async.setImmediate(() => console.log(2), {label: 'foo'});
this.async.clearAll({label: 'foo'});
}
}
По аналогии с label
группы могут также использоваться для удаления сразу множества потоков. Также при указании группы можно использовать регулярное выражение и удалять сразу несколько групп, подходящих под условие. Также можно использовать label
для сужения поиска.
import iBlock, { component, field, system } from 'super/i-block/i-block';
export * from 'super/i-block/i-block';
@component()
export default class bExample extends iBlock {
created() {
this.async.setTimeout(() => console.log(1), 500, {group: 'foo: timer'});
this.async.setImmediate(() => console.log(2), {group: 'foo: immediate'});
this.async.clearAll({group: /foo/});
}
}
Следует понимать, что очистка потоков работает по принципу «здесь и сейчас», т. е. если мы вызвали clearAll
, а затем setTimeout
, то он будет выполнен, т. к. вызвался уже после очистки.
В реальной разработке бывают случаи, когда обработку потоков нужно временно приостановить, а потом возобновить (например, деактивация компонента с сохранением в кеше). Конечно, для решения этой задачи мы можем создать специальный метод регистрации потоков, а затем просто вызывать очистку и регистрацию когда нам нужно. Решение рабочее, но у него есть 2 больших минуса:
- Нам нужно локализировать регистрацию потоков, т. е. уже нельзя просто написать где-то в коде
setTimeout
, т. к. наша программа должна уметь восстаналивать эти отмененные потоки; - Очистка и навешивание обработчиков — это дополнительная логика и процессорное время.
К счастью в Async есть более удобный механизм решения этой проблемы: почти все методы регистрации потоков имеют смежный метод «заглушения» (на данный момент, только worker
не имеет такой API), например, setTimeout
/ muteTimeout
. Также по аналогии с clearAll
есть muteAll
. Интерфейс этих методов полностью совпадает с методами очистки.
Как же работают эти методы? При заглушении потока не происходит его уничтожения, т. е. он по прежнему существует, может делать свою работу или ждать, но это никак не доходит до принимающей стороны, например,
import iBlock, { component, field, system } from 'super/i-block/i-block';
export * from 'super/i-block/i-block';
@component()
export default class bExample extends iBlock {
created() {
// Переданная функция никогда не выполниться,
//, но сам timeout физически сработает
this.async.setTimeout(() => console.log(1), 500, {group: 'timer'});
this.async.muteTimeout({group: 'timer'});
}
}
Для компонента все также, как если бы мы вызвали очистку. Однако, для каждого метода заглушения существует метод возобновления, например, muteTimeout
/ unmuteTimeout
, muteAll
/ unmuteAll
. Интерфейсы этих методов полностью совпадают. Таким образом, когда наш компонент будет готов «ожить», то нам просто нужно вызвать соотвествующие методы unmute
.
import iBlock, { component, field, system } from 'super/i-block/i-block';
export * from 'super/i-block/i-block';
@component()
export default class bExample extends iBlock {
created() {
this.async.setTimeout(() => console.log(1), 500, {group: 'timer'});
this.async.muteTimeout({group: 'timer'});
this.async.unmuteTimeout({group: 'timer'});
// 1
}
}
Технически заглушить Promise мы можем без проблем, однако если он зарезолвится до возобновления, то он так и останется в «висячем» состоянии. В лучшем случае такой указатель будет уничтожен сборщиком мусора, а в худшем — будет утечка памяти, поэтому нужно быть очень осторожным при заглушении промисов.
Как и в случае с очисткой, заглушение происходит «здесь и сейчас», т. е. если мы вызвали muteAll
, а затем setTimeout
, то он будет выполнен, т к. вызвался уже после заглушения.
Помимо очистки и заглушения существует также приостановка потоков: она очень полезна при работе с обещаниями и одноразовыми событиями. Принцип такой же как и с заглушением: почти все методы регистрации потоков имеют смежный метод «приостановки» (на данный момент, только worker
не имеет такой API), например, setTimeout
/ suspendTimeout
/ unsuspendTimeout
, suspendAll
/ unsupendAll
. Отличием приостановки от заглушения является тот факт, что в состоянии suspend
поток записывает все вызовы в специальную очередь, а затем при вызове unsupend
последовательно применяет эти вызовы. Таким образом если Promise был зарезолвен в момент приостановки, то при возобновлении он также зарезолвится и это будет выглядеть так, как будто это произошло только что.
import iBlock, { component, field, system } from 'super/i-block/i-block';
export * from 'super/i-block/i-block';
@component()
export default class bExample extends iBlock {
created() {
// Это обещание зарезолвится только после 500мс
this.async.promise(Promise.resolve(1), {group: 'promise'})
.then((r) => console.log(r));
this.async.suspendPromise({group: 'promise'});
this.async.setTimeout(() => this.async.unsuspendPromise({group: 'promise'}), 500);
}
}
Как и в случае с очисткой, приостановка происходит «здесь и сейчас», т. е. если мы вызвали suspendAll
, а затем setTimeout
, то он будет выполнен, т. к. вызвался уже после приостановки.
Как уже говорилось ранее, все наследники iBlock имеют свойство async
, которое содержит экземпляр Async. Также при вызове деструктора компонента будет вызван clearAll
. На события жизненного цикла компонента deactivated и activated автоматически добавлена логика с заглушением (для всех не Promise потоков, кроме тех, у кого в названии группы есть : suspend
) и приостановкой (для всех Promise). Все источники событий parentEvent
, rootEvent
, localEvent
и т. д. упакованы в Async обертки и могут принимать параметры group
, label
и т. д. Кроме того, в Async упакованы и собственные события компонента (on
, off
, once
). Многие методы и декораторы также имеют интеграцию, например, декоратор @watch
:
import iBlock, { component, field, watch } from 'super/i-block/i-block';
export * from 'super/i-block/i-block';
@component()
export default class bExample extends iBlock {
@watch({field: 'document: click', group: 'domEvents'})
onClick() {
this.emit('Click! ');
}
}
Использование Async делает асинхронное программирование предсказуемым и легко управляемым, поэтому использовать его нужно везде, где есть асинхронные события, даже если они кажутся совсем тривиальными.
В Async существуют обертки над всеми видами таймеров JS: setTimeout
, setInterval
, setImmediate
, requestIdleCallback
и requestAnimationFrame
. Все они имеют приблеженный к нативному API, например, значение интервала прокидывается вторым параметром, как и в обычном setInterval
, а функция в requestIdleCallback
получит специальный объект с информацией о затраченом времени. Контекстом функций (this
) передаваемых в обертки будет свойство Async.context
(по умолчанию оно ссылается на сам экземпляр Async, но для компонентов — это ссылка на экземпляр компонента). Также дополнительным параметром все эти методы могу принимать объект настроек Async, который мы уже рассматривали выше. Однако, для этих функций есть 2 дополнительных параметра:
export interface AsyncCb<T extends object = Async> {
(this: T, ctx: AsyncCtx<T>): void;
}
{
onClear?: AsyncCb | AsyncCb[];
onMerge?: AsyncCb | AsyncCb[];
}
Эти функции вызываются при очистке потока или при его слиянии (join: true
). Контекст у них такой же, как и у других функций, а в качестве параметра они принимают специальный мета-объект операции. Можно задавать несколько обработчиков, если передавать их как список.
Для некоторых оберток есть специальные promisify версии:
-
setTimeout
/sleep
; -
setImmediate
/nextTick
; -
requestIdleCallback
/idle
; -
requestAnimationFrame
/animationFrame
.
Обратите внимание, что тип этих оберток — promise
, поэтому для очистки или приостановки необходимо использовать методы обещаний: cancelPromise
, mutePromise
и т. д.
Обертка wait
предоставляет promisify версию setInterval
, которая зарезолвится когда переданная функция вернет true
. По умолчанию интервал проверки равен 15 миллисекунд, но его можно задать с помощью параметра delay
. Wait удобно использовать, когда нужно дождаться некоторого события, но у него нет собственного событийного API.
import iBlock, { component, field, watch } from 'super/i-block/i-block';
export * from 'super/i-block/i-block';
@component()
export default class bExample extends iBlock {
async mounted () {
// Promise зарезолвится, когда в DOM дереве появится элемент foo:
// интервал проверки 50 мс
await this.async.wait(() => this.block.element('foo'), {
delay: 50,
label: 'waitFoo'
});
}
}
В Async есть универсальная обертка proxy
, которая подходит для упаковывания любых функций, например,
import iBlock, { component, field, watch } from 'super/i-block/i-block';
export * from 'super/i-block/i-block';
@component()
export default class bExample extends iBlock {
mounted() {
const img = new Image ();
img.onload = this.async.proxy(() => console.log('Image loaded! '));
img.src = 'foo.jpg';
}
}
Если вызаваемая функция принимает некоторые аргументы, то они будут переданы в проксируемую функцию. Контекст вызываемой функции идентичен с API таймеров.
Обертка может принимать расширенный набор настроек Async (как и API таймеров), а также еще один дополнительный параметр: single
. Если указать его в false
, то проксируемая функция сможет вызываться несколько раз (по умолчанию функция может вызываться только один раз).
import iBlock, { component, field, watch } from 'super/i-block/i-block';
export * from 'super/i-block/i-block';
@component()
export default class bExample extends iBlock {
mounted() {
window.onmessage = this.async.proxy((e) => console.log('Message: ', e), {
single: false
});
}
}