Skip to content

Модуль Async

Andrey Kobets edited this page Mar 3, 2019 · 37 revisions

Современные веб-приложения - это большой клубок асинхронных вызовов. Например, при изменении свойства компонента отправляется асинхронный запрос на сервер; параллельно компонент отслеживает событие прокрутки экрана и обновляет другие свойства, а также генерирует события, которые в свою очередь слушают другие компоненты и т.д.. А теперь представьте, что пользователь нажимает на ссылку и переходит на другую страницу: т.к. у нас 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 гарантирует, что при регистрации интервала с таким же 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

С параметром label мы уже работали в предыдущем примере: он позволяет помечать поток специальной меткой и если до момента его завершения будет попытка установить новый поток с такой же меткой, то старый поток будет уничтожен (для промисов будет сгенерировано специальное исключение). При этом следует отметить, что различные по типу потоки регистрируются в отдельных песочницах, т.е. одинаковая метка в 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)
  • request (метод request)
  • idleCallback (метод requestIdleCallback, idle)
  • timeout (метод setTimeout, sleep, wait)
  • interval (метод setInterval)
  • immediate (метод setImmediate, nextTick)
  • worker (метод worker)
  • eventListener (метод on, once, promisifyOnce, dnd)
  • animationFrame (метод requestAsimationFrame, animationFrame)

join

Параметр join позволяет изменить стратегию работы label. По умолчанию старый поток уничтожается, но если задать join: true, то старый поток будет сохранен, а новый не будет создан (вместо этого метод вернет ссылку на старый поток). Это особенно полезно при работе с обертками над промисами, т.к. в случае использования меток без 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' работает только для промисов: предыдущий поток будет уничтожен, но вместо генерации исключения промис начнет ссылаться на новый поток:

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

Параметр 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

Когда параметр 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

Метку 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'});
  }
}

Очистка по group

По аналогии с 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
  }
}

Заглушение промисов

Технически заглушить промис мы можем без проблем, однако если он зарезолвится до возобновления, то он так и останется в "висячем" состоянии. В лучшем случае такой указатель будет уничтожен сборщиком мусора, а в худшем - будет утечка памяти, поэтому нужно быть очень осторожным при заглушении промисов.

Жизнь после заглушения

Как и в случае с очисткой, заглушение происходит "здесь и сейчас", т.е. если мы вызвали muteAll, а затем setTimeout, то он будет выполнен, т.к. вызвался уже после заглушения.

Приостановка потоков

Помимо очистки и заглушения существует также приостановка потоков: она очень полезна при работе с промисами и одноразовыми событиями. Принцип такой же как и с заглушением: почти все методы регистрации потоков имеют смежный метод "приостановки" (на данный момент, только worker не имеет такой API), например, setTimeout / suspendTimeout / unsuspendTimeout, suspendAll / unsupendAll. Отличием приостановки от заглушения является тот факт, что в состоянии suspend поток записывает все вызовы в специальную очередь, а затем при вызове unsupend последовательно применяет эти вызовы. Таким образом если промис был зарезолвен в момент приостановки, то при возобновлении он также зарезолвится и это будет выглядеть так, как будто это произошло только что.

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, то он будет выполнен, т.к. вызвался уже после приостановки.

Async и компонент

Как уже говорилось ранее, все наследники iBlock имеют свойство async, которое содержит экземпляр Async. Также при вызове деструктора компонента будет вызван clearAll. На события жизненного цикла компонента deactivated и activated автоматически добавлена логика с заглушением (для всех не промисов, кроме тех, у кого в названии группы есть :suspend) и приостановкой (для всех промисов). Все источники событий 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 делает асинхронное программирование предсказуемым и легко управляемым, поэтому использовать его нужно везде, где есть асинхронные события, даже если они кажутся совсем тривиальными.

Clone this wiki locally