Pull to refresh

Signals – новая веха развития Angular

Level of difficultyHard
Reading time9 min
Views24K

Angular Signals является частью будущей спецификации 16-й версии Angular. В первую очередь Signals нацелены на решение проблемы с обнаружением изменений в Angular, однако Angular Team позаботилась и о том, чтобы разработчики смогли не только писать более оптимальный код с точки зрения самого фреймворка, но и чтобы самим разработчикам было удобнее и приятнее писать код на Angular. Сигналы предлагают более декларативный подход для написания приложений, позволяя новичкам быстрее вкатываться в Angular и по-новому посмотреть на использование RxJs в своём приложении.

Связь с SolidJS

Начать стоит с того, что сигналы в том виде, в котором их предлагает разработчику Angular, уже существуют в схожем виде, в библиотеке SolidJS. И это неудивительно, ведь Angular Team достаточно близко взаимодействовала с создателем SolidJS – Райаном Карниато (Ryan Carniato) – для разработки своей версии сигналов. Он постулирует о своём детище так:

Сигналы являются краеугольным камнем реактивности в SolidJS. Они содержат значения, которые меняются со временем; когда вы меняете значение сигнала, он автоматически обновляет всё, что его использует.

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

Change Detection без Zone.js.

Как известно, автоматическое обнаружение изменений в Angular базируется на Zone.js – библиотеке, которая занимается манки-патчингом нативного браузерного API и уведомляет фреймворк каждый раз, когда какое-то событие происходит – обработчики событий, промисы, таймауты и др. Поэтому при любом таком событии (даже если мы не хотим обновлять DOM), Angular, несмотря ни на что, проверит дерево компонентов и удостоверится, что ничего обновлять не надо. Это говорит о том, что Angular имеет тенденцию делать ненужную работу без всякой на то необходимости.

А если событий и элементов DOM очень много? Angular должен гарантировать эффективность и работоспособность даже на большом количестве узлов. Для этого Angular запускает Change Detection в порядке DOM и проверяет привязку между моделью и представлением в DOM только 1 раз. А теперь представим, что мы хотим изменить какое-то значение родителя из дочернего элемента. Angular как всегда пройдёт «сверху вниз», отследит изменения, и… его только что проверенное состояние снова меняет какой-то дочерний элемент. Отсюда и возникает ошибка ExpressionChangedAfterItHasBeenCheckedError, которая появляется практически в каждом приложении, а то и несколько раз одновременно.

Хорошо, а что если мы хотим улучшить производительность нашего компонента, изменив текущую стратегию обнаружения изменений на OnPush? И тут не всё так просто. Документация говорит нам:

Используйте стратегию CheckOnce, что означает, что автоматическое обнаружение изменений деактивируется до повторной активации путем установки стратегии по умолчанию (CheckAlways). Обнаружение изменений по-прежнему можно вызвать явно. Эта стратегия применяется ко всем дочерним директивам и не может быть переопределена.

Ключевым здесь является то, что мы не можем переопределить поведение в дочерних элементах, если где-то выше по иерархии находится компонент со стратегией OnPush. Это означает, что все дочерние компоненты должны будут также поддерживать эту стратегию и быть совместимы с ней. Не всегда это имеет смысл, так как теперь каждому разработчику UI-библиотек, например, придётся обеспечивать совместимость своих компонентов с OnPush-стратегией.

Signals убивают сразу нескольких зайцев!

  • Zone.js больше не нужен: нет смысла грузить тяжёлую библиотеку прежде, чем Angular может вступить в свои права => бандл уменьшается, время отклика увеличивается.

  • Не будет загрязнения консоли разработчика огромной ошибкой ExpressionChangedAfterItHasBeenCheckedError, и не нужно будет думать о вложенности DOM, чтобы её исправить.

  • Не нужно будет использовать OnPush-стратегию и думать о другом поведении Change Detection, теперь будет гарантия единообразия и совместимости.

Какие они, Signals?

Для того, чтобы понять, что скрывается за маской сигналов, обратимся к официальному README от Angular:

Angular Signals — это функции без аргументов (() => T). При вызове они возвращают текущее значение сигнала. Вызов сигналов не вызывает побочных эффектов, хотя может лениво пересчитывать промежуточные значения (ленивая мемоизация).

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

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

Итак, сигнал – это функция, которая возвращает некий тип SettableSignal. Тип говорит сам за себя, что он умеет не только возвращать значение сигнала, но и изменять его. SettableSignal предоставляет следующие методы для манипуляции с сигналом:

  • .set(value: T): void – заменяет значение сигнала на новое.

    books.set([{ name: "Унесённые ветром", author: "Маргарет Митчелл", ... }]);
  • .update(updateFn: (value: T) => T) – обновляет значение сигнала на основе текущего значения.

    booksNumber.update(number => number + 1);
  • .mutate(mutatorFn: (value: T) => void) – выполняет внутреннее изменение текущего значения сигнала (Иммутабельность не нужна!).

    books.mutate(list => {
        list.push({ name: "Война и мир", author: "Лев Толстой", ... });
    });

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

  • не обновляет значение сигнала;

  • пропускает применение изменений.

    const counter = signal<number | string>("0", (a, b) => a == b);

    // Не будет обновлено, и изменения не применятся
    counter.set(0);

Сигналы дают возможность строить зависимости и формировать новые значения на основе предыдущих. Функция computed() создаёт мемоизированный сигнал, который рассчитывает своё значение на основе зависимых сигналов, использующихся внутри функции, для обновления текущего значения. При этом мемоизированный сигнал будет вычисляться заново каждый раз при изменении зависимостей.

    const counter = signal(0);

    // Автоматически обновляется при изменении `counter`
    const isEven = computed(() => counter() % 2 === 0);

Ещё одна возможность, которую даёт Angular разработчикам – это effect() – функция, позволяющая выполнять сайд-эффекты на основе сигналов. Те сигналы, которые находятся внутри функции effect(), отслеживаются, и при их изменении сайд-эффект выполняется.

    const counter = signal(0);

    effect(() => {
        console.log("Counter value: ", counter());
    });
    // Counter value: 0

    counter.set(1);
    // Counter value: 1

Однако есть одно важное замечание, которое отличает effect() от его собратьев computed() и signal(). Эффекты не выполняются синхронно, а планируются и разрешаются фреймворком, то есть конкретное время для выполнения эффекта не определено. На первый взгляд это может показаться пугающим, однако предполагается, что для разработчика это не должно вызвать никаких противоречий, зато с точки зрения Angular появится некоторая свобода в выборе, когда лучше выполнять эффекты.

В дополнение к эффектам в API можно найти ещё одну интересную функцию, которая сейчас, к сожалению, не является частью README, - untracked(). Данная функция позволяет сделать сигнал неотслеживаемым внутри определённого контекста выполнения. То есть даже если сигнал меняется, контекст всё равно не будет уведомляться, однако будет иметь доступ к актуальному значению.

    const counter = signal(0);
    const counterUntracked = signal(0);

    // Автоматически вызывается, когда меняется counter, но не counterUntracked
    effect(() => {
        console.log(counter(), untracked(counterUntracked));
    });

    counter.set(1);
    // Пишет в консоль - 1 0

    counterUntracked.set(1);
    // Ничего не пишет в консоль

    counterUntracked.set(2);
    // Ничего не пишет в консоль

    counterUntracked.set(3);
    // Ничего не пишет в консоль

    counter.set(2);
    // Пишет в консоль - 2 3

Signals + RxJs = ?

Может показаться, что сигналы очень похожи на Observables, и скоро Angular-разработчикам придётся распрощаться со всеми прелестями RxJs, однако это не совсем так. Angular во многих кейсах использует RxJs под капотом, поэтому говорить про его исчезновение однозначно рано. Например, HttpClient предоставляет ответ на запрос именно через Observable, FormControl своим свойством valueChanges даёт разработчику Observable, который эмитится каждый раз при изменении значения контрола.

Сигналы действительно реактивны, как и Observables, их можно использовать для несложной реактивности для управления состоянием компонентов, при этом не нужно описывать зависимости и явно подписываться через .subscribe(). Однако по природе своей сигналы синхронны, так как позволяют явно устанавливать значения своего состояния именно синхронно. Поэтому в большинстве своём Angular предлагает комбинированный подход взаимодействия Signals и RxJs: там, где нужно будет просто отслеживать состояние компонентов, взаимодействовать с формами и шаблоном, Angular предлагает использовать Signals, а для взаимодействия с асинхронностью (Http-запросы, браузерные события и др.) RxJs, так как это просто невозможно реализовать с помощью сигналов.

Signals и RxJs будут тесно связаны между собой, и тому подтверждение этот PR от одного из участников Angular Core Team, в котором обсуждается внедрение в Signals двух простых функций для взаимодействия с RxJs: функция fromSignal преобразует сигнал в Observable, а fromObservable делает обратное. Такие идеи могут дать обратную совместимость и ещё более гладкий переход и интеграцию Signals во многие приложения на Angular.

    @Component({
        selector: 'book-component',
        template: `
            <input
                [value]="searchQuery()"
                (input)="changeSearchQuery($event)"
            />

            <ul *ngFor="let book of books$ | async">
                <li>{{ book }}</li>
            </ul>

            <div>First book: {{ firstBook() }}</div>
        `
    })
    export class BookComponent {
        searchQuery = signal('');

        books$ = fromSignal(this.searchQuery).pipe(
            switchMap(query => this.bookService.getBooks(query)),
        );

        // Второй аргумент в данном случае предполагает начальное значение
        firstBook = fromObservable(
            books$.pipe(map(books => books?.[0])),
            ""
        );

        changeSearchQuery(event) {
            this.searchQuery.set(event.target.value);
        }
    }

Рассмотрим код немного подробнее. В компоненте мы имеем searchQuery сигнал, который будет обрабатывать введённую пользователем строку с помощью метода changeSearchQuery. Get-метод HttpClient'а bookService.getBooks ответственен за получение книг по query-параметру. books$ — это Observable, созданный из сигнала searchQuery, поэтому каждый раз при обновлении сигнала он также будет тригерить обновление books$. Также существует сигнал firstBook, который будет выводить на экран первую книгу и изменяться каждый раз, когда Observable books$ будет эмититься. При использовании сигналов код становится ещё более реактивным, к тому же функции fromSignal и fromObservable дают большую гибкость и формируют мощный инструмент по взаимодействию Signals и RxJs.

Почему код станет лучше?

RxJs никуда не исчезнет, однако появление сигналов может ознаменовать новый подход в программировании на Angular и облегчить жизнь только знакомящимся с фреймворком разработчикам. До появления сигналов обучение внутри Angular и построение архитектур приложений могло происходить по-разному. Одним из вариантов является императивный стиль написания кода. Такой подход более интуитивен и прост при старте обучения, когда мы описываем конкретные инструкции к выполнению и реализации приложения. Но когда приходит время познакомиться поближе с декларативным подходом на базе реактивности (так как Angular сам активно предлагает попробовать это), приходится довольно долго и трудно проходить путь смены своей ментальной модели программирования, менять подход в написании кода, вследствие чего делать и изменения в приложении. Получаем две совершенно разных модели обучения в Angular, которые не совместимы по своей натуре. Всё это дорого, неудобно и долго.

Сигналы делают этот путь более гладким, позволяя объединить два, казалось бы, разных подхода воедино. Теперь каждый сумеет изучить концепцию сигналов с учётом их встроенной реактивности (что не должно стать особо сложной задачей) и сможет при желании перейти на более тяжеловесный RxJs в любое время, когда будет к этому готов. Поэтому, идя по такому пути, при понимании базовых концепций в Angular, при желании изучить более декларативный подход к написанию кода, каждый разработчик сможет преступить черту RxJs и начать познавать асинхронную реактивность быстрее и легче.

Итоги

Change Detection, RxJs, кривая обучения, способы написания приложений – это далеко не всё, на что смогут повлиять Signals. Сигналы сделают Angular легче, выбирая путь развития без Zone.js. Теперь компоненты будут узнавать, когда обновлять своё представление напрямую. Сигналы внесут больше реактивности и позволят посмотреть на фреймворк под другим углом, поэтому новым разработчикам станет легче и интереснее изучать его, горизонты использования Angular только расширятся. Однако Angular, вероятно, не будет обязывать использовать сигналы, и менять текущий код в полной мере не придётся, станет возможна комбинация традиционного обнаружения изменений и обнаружения изменений на основе сигналов.

Также следует отметить, что Signals всё ещё находится на ранней стадии и скорее будет представлен с Angular 16 в качестве предварительной версии для разработчиков. Это позволяет заранее опробовать концепцию и дать фидбек, от которого может зависеть дальнейшая судьба Signals. Для Angular Team во многом важна стабильность экосистемы — причина, по которой многие крупные корпоративные проекты полагаются на структуру, написанную Google.

Tags:
Hubs:
Total votes 7: ↑6 and ↓1+6
Comments9

Articles