Перейти к содержанию

Асинхронные задачи

Обзор

Иногда компоненту требуется отобразить данные, которые доступны только асинхронно. Такие данные могут быть получены с сервера, из базы данных или вообще получены или вычислены из асинхронного API.

В то время как жизненный цикл реактивных обновлений Lit является пакетным и асинхронным, шаблоны Lit всегда отображаются синхронно. Данные, используемые в шаблоне, должны быть доступны для чтения в момент рендеринга. Для асинхронного рендеринга данных в компоненте Lit необходимо дождаться готовности данных, сохранить их так, чтобы они были доступны для чтения, а затем запустить новый рендеринг, который может использовать данные синхронно. Часто приходится решать, что рендерить, пока данные находятся в процессе выборки, или когда выборка данных также не удалась.

Пакет @lit/task предоставляет реактивный контроллер Task, который помогает управлять этим асинхронным рабочим процессом с данными.

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

Пример

Это пример использования Task для вызова HTTP API через fetch(). API вызывается каждый раз, когда изменяется параметр productId, а компонент выводит сообщение о загрузке при получении данных.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { Task } from '@lit/task';

class MyElement extends LitElement {
    @property() productId?: string;

    private _productTask = new Task(this, {
        task: async ([productId], { signal }) => {
            const response = await fetch(
                `http://example.com/product/${productId}`,
                { signal },
            );
            if (!response.ok) {
                throw new Error(response.status);
            }
            return response.json() as Product;
        },
        args: () => [this.productId],
    });

    render() {
        return this._productTask.render({
            pending: () => html`<p>Loading product...</p>`,
            complete: (product) => html`
                <h1>${product.name}</h1>
                <p>${product.price}</p>
            `,
            error: (e) => html`<p>Error: ${e}</p>`,
        });
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { Task } from '@lit/task';

class MyElement extends LitElement {
    static properties = {
        productId: {},
    };

    _productTask = new Task(this, {
        task: async ([productId], { signal }) => {
            const response = await fetch(
                `http://example.com/product/${productId}`,
                { signal },
            );
            if (!response.ok) {
                throw new Error(response.status);
            }
            return response.json();
        },
        args: () => [this.productId],
    });

    render() {
        return this._productTask.render({
            pending: () => html`<p>Loading product...</p>`,
            complete: (product) => html`
                <h1>${product.name}</h1>
                <p>${product.price}</p>
            `,
            error: (e) => html`<p>Error: ${e}</p>`,
        });
    }
}

Особенности

Task заботится о ряде вещей, необходимых для правильного управления асинхронной работой:

  • Собирает аргументы задачи при обновлении хоста
  • Запускает функции задачи при изменении аргументов
  • Отслеживает статус задачи (начальный, ожидающий, завершенный или ошибка)
  • Сохраняет последнее значение завершения или ошибки функции задачи
  • Запускает обновление хоста при изменении статуса задачи
  • Обрабатывает условия гонки, гарантируя, что только последний вызов задачи завершит ее выполнение
  • Выдает правильный шаблон для текущего состояния задачи
  • Позволяет прерывать задачи с помощью AbortController.

Это убирает большую часть шаблонов для правильного использования асинхронных данных из вашего кода и обеспечивает надежную обработку условий гонки и других нештатных ситуаций.

Что такое асинхронные данные?

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

Асинхронные данные обычно возвращаются из асинхронного API, который может быть представлен в нескольких формах:

  • Обещания или асинхронные функции, например fetch().
  • Функции, принимающие обратные вызовы
  • Объекты, которые испускают события, такие как события DOM
  • Библиотеки, такие как observables и signals.

Контроллер Task работает с обещаниями, поэтому независимо от формы вашего async API вы можете адаптировать его к обещаниям для использования с Task.

Что такое задача?

В основе контроллера Task лежит само понятие "задача".

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

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

Установка

1
npm install @lit/task

Использование

Task — это реактивный контроллер, поэтому он может реагировать на обновления и запускать их в рамках жизненного цикла реактивного обновления Lit.

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

1
2
3
4
5
class MyElement extends LitElement {
    private _myTask = new Task(this, {
        /*...*/
    });
}
1
2
3
4
5
class MyElement extends LitElement {
    _myTask = new Task(this, {
        /*...*/
    });
}

Как поле класса, статус и значение задачи легко доступны:

1
2
this._task.status;
this._task.value;

Функция задачи

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

Функция задачи задается в опции task. Контроллер задач будет автоматически вызывать функцию задачи с аргументами, которые передаются отдельным обратным вызовом args. Аргументы проверяются на изменения, и функция задачи вызывается только в том случае, если аргументы изменились.

Функция задачи принимает аргументы задачи в виде массива, передаваемого в качестве первого параметра, и аргумент options в качестве второго параметра:

1
2
3
4
5
6
new Task(this, {
    task: async ([arg1, arg2], { signal }) => {
        // do async work here
    },
    args: () => [this.field1, this.field2],
});

Массив args функции задачи и обратный вызов args должны быть одинаковой длины.

Напишите функции task и args как стрелочные функции, чтобы ссылка this указывала на элемент-хост.

Состояние задачи

Задачи могут находиться в одном из четырех состояний:

  • INITIAL: Задача не была запущена
  • PENDING: Задача запущена и ожидает нового значения
  • COMPLETE: Задача успешно завершена
  • ERROR: Задача завершилась с ошибкой

Статус задачи доступен в поле status контроллера Task, и представлен перечислительным объектом TaskStatus, который имеет свойства INITIAL, PENDING, COMPLETE и ERROR.

1
2
3
4
5
6
import { TaskStatus } from '@lit/task';

// ...
if (this.task.status === TaskStatus.ERROR) {
    // ...
}

Обычно задача переходит от INITIAL к PENDING, затем к одному из COMPLETE или ERROR, а затем возвращается к PENDING, если задача выполняется повторно. Когда задача меняет статус, она запускает обновление хоста, чтобы хост-элемент мог обработать новый статус задачи и выполнить рендеринг, если это необходимо.

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

В контроллере Task есть несколько элементов, которые относятся к состоянию задачи:

  • status: статус задачи.
  • value: текущее значение задачи, если она завершилась.
  • error: текущая ошибка задачи, если она ошиблась.
  • render(): метод, который выбирает обратный вызов для выполнения, основываясь на текущем статусе.

Задачи рендеринга

Самым простым и распространенным API для рендеринга задачи является task.render(), поскольку он сам выбирает нужный код для запуска и предоставляет ему соответствующие данные.

render() принимает объект config с необязательным обратным вызовом для каждого статуса задачи:

  • initial()
  • pending()
  • complete(value)
  • error(err).

Вы можете использовать task.render() внутри метода Lit render() для рендеринга шаблонов в зависимости от статуса задачи:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  render() {
    return html`
      ${this._myTask.render({
        initial: () => html`<p>Waiting to start task</p>`,
        pending: () => html`<p>Running task...</p>`,
        complete: (value) => html`<p>The task completed with: ${value}</p>`,
        error: (error) => html`<p>Oops, something went wrong: ${error}</p>`,
      })}
    `;
  }

Запуск задач

По умолчанию задачи запускаются при любом изменении аргументов. Это контролируется опцией autoRun, которая по умолчанию имеет значение true.

Автозапуск

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

Ручной режим

Если для autoRun установлено значение false, задача будет находиться в ручном режиме. В ручном режиме вы можете запустить задачу, вызвав метод .run(), возможно, из обработчика событий:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MyElement extends LitElement {
    private _getDataTask = new Task(this, {
        task: async () => {
            const response = await fetch(
                `example.com/data/`,
            );
            return response.json();
        },
        args: () => [],
    });

    render() {
        return html`
            <button @click=${this._onClick}>
                Get Data
            </button>
        `;
    }

    private _onClick() {
        this._getDataTask.run();
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MyElement extends LitElement {
    _getDataTask = new Task(this, {
        task: async () => {
            const response = await fetch(
                `example.com/data/`,
            );
            return response.json();
        },
        args: () => [],
    });

    render() {
        return html`
            <button @click=${this._onClick}>
                Get Data
            </button>
        `;
    }

    _onClick() {
        this._getDataTask.run();
    }
}

В ручном режиме вы можете предоставлять новые аргументы непосредственно в run():

1
this._task.run('arg1', 'arg2');

Если аргументы не переданы в run(), они собираются из обратного вызова args.

Прерывание задач

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

Это можно сделать с помощью сигнала AbortSignal, который передается в свойстве signal второго аргумента функции task. Когда ожидающий запуск задачи заменяется новым запуском, сигнал AbortSignal, переданный ожидающему запуску, прерывается, чтобы дать сигнал запуску задачи отменить все ожидающие работы.

AbortSignal не отменяет никакой работы автоматически — это просто сигнал. Чтобы отменить какую-то работу, вы должны либо сделать это самостоятельно, проверив сигнал, либо передать сигнал другому API, принимающему AbortSignal, например fetch() или addEventListener().

Самый простой способ использовать AbortSignal — переслать его в API, который его принимает, например fetch().

1
2
3
4
5
6
private _task = new Task(this, {
    task: async (args, {signal}) => {
    const response = await fetch(someUrl, {signal});
    // ...
    },
});
1
2
3
4
5
6
_task = new Task(this, {
    task: async (args, { signal }) => {
        const response = await fetch(someUrl, { signal });
        // ...
    },
});

Передача сигнала в fetch() приведет к тому, что браузер отменит запрос к сети, если сигнал будет прерван.

Вы также можете проверить, был ли сигнал прерван, в вашей функции задачи. Проверять сигнал следует после возврата в функцию задачи из асинхронного вызова. Удобным способом для этого является throwIfAborted():

1
2
3
4
5
6
7
8
9
private _task = new Task(this, {
    task: async ([arg1], {signal}) => {
    const firstResult = await doSomeWork(arg1);
    signal.throwIfAborted();
    const secondResult = await doMoreWork(firstResult);
    signal.throwIfAborted();
    return secondResult;
    },
});
1
2
3
4
5
6
7
8
9
_task = new Task(this, {
    task: async ([arg1], { signal }) => {
        const firstResult = await doSomeWork(arg1);
        signal.throwIfAborted();
        const secondResult = await doMoreWork(firstResult);
        signal.throwIfAborted();
        return secondResult;
    },
});

Цепочка задач

Иногда требуется запустить одну задачу после завершения другой. Это может быть полезно, если задачи имеют разные аргументы, чтобы связанная задача могла выполняться без повторного запуска первой задачи. В этом случае первая задача будет использоваться как кэш. Для этого вы можете использовать значение задачи в качестве аргумента другой задачи:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class MyElement extends LitElement {
    private _getDataTask = new Task(this, {
        task: ([dataId]) => getData(dataId),
        args: () => [this.dataId],
    });

    private _processDataTask = new Task(this, {
        task: ([data, param]) => processData(data, param),
        args: () => [this._getDataTask.value, this.param],
    });
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class MyElement extends LitElement {
    _getDataTask = new Task(this, {
        task: ([dataId]) => getData(dataId),
        args: () => [this.dataId],
    });

    _processDataTask = new Task(this, {
        task: ([data, param]) => processData(data, param),
        args: () => [this._getDataTask.value, this.param],
    });
}

Вы также можете часто использовать одну функцию задачи и ожидать промежуточных результатов:

1
2
3
4
5
6
7
8
9
class MyElement extends LitElement {
    private _getDataTask = new Task(this, {
        task: ([dataId, param]) => {
            const data = await getData(dataId);
            return processData(data, param);
        },
        args: () => [this.dataId, this.param],
    });
}
1
2
3
4
5
6
7
8
9
class MyElement extends LitElement {
_getDataTask = new Task(this, {
    task: ([dataId, param]) => {
    const data = await getData(dataId);
    return processData(data, param);
    },
    args: () => [this.dataId, this.param],
});
}

Комментарии