Сигналы в Angular и важность декларативного кода
Вы, скорее всего как и мы, прочитали несколько замечательных постов от очень умных людей о React, о Solid и об Angular. Но мы не являемся любителями крючков.
Мы хотим сосредоточиться на одной важной вещи: декларативном коде.
Эта статья была вдохновлена примером компонента React от Дэна Абрамова:
// React
function VideoList({ videos, emptyHeading }) {
const count = videos.length;
let heading = emptyHeading;
if (count > 0) {
const noun = count > 1 ? 'Videos' : 'Video';
heading = count + ' ' + noun;
}
return <h1>{heading}</h1>
}
Он принимает массив и значение-заполнитель, выполняет некоторые вычисления и возвращает некоторый текст.
No videos here
1 Video
2 Videos
Вот такие комбинации.
Итак: нам нравятся функциональное программирование, и React в некотором роде придерживается функционального мышления.
f(state) = UI
Здесь мы не будем упоминать о hook, так как суть не в этом. Дело в том, что функциональное программирование является подмножеством декларативного программирования. И нам нравится декларативный код. И пример этого конкретного компонента нам не радует глаз.
В данном случае этот компонент был тривиальным примером для демонстрации концепции. Но на самом деле мы находим такой код во многих проектах, поэтому давайте начнем с него, чтобы поговорить о декларативном коде и о том, почему мы в восторге от сигналов в Angular.
Этот пост предполагает базовые знания сигналов (и немного React для сравнений)!
Функционал декларативный
Давайте вернемся к компоненту:
// React
function VideoList({ videos, emptyHeading }) {
const count = videos.length;
let heading = emptyHeading;
if (count > 0) {
const noun = count > 1 ? 'Videos' : 'Video';
heading = count + ' ' + noun;
}
return <h1>{heading}</h1>
}
Причина по которой это нам не нравится заключается в том, что логика этого компонента императивна. Это не проблема для такого маленького компонента, но большинство компонентов в сложных кодовых базах выглядят иначе.
Чтобы понять, каким может быть heading
, мы должны покопаться во всех остальных компонентах.
В чистом функциональном программировании нет переменных, только константы. В JavaScript мы можем изменять переменные, и нам не обязательно строго следовать рекомендациям FP, чтобы писать хороший код, но во многих случаях это все равно хорошая рекомендация.
Это был бы декларативный подход:
// React (declarative)
function VideoList({ videos, emptyHeading }) {
const count = videos.length;
const heading = count > 0
? count + ' ' + (count > 1 ? 'Videos' : 'Video')
: emptyHeading;
return <h1>{heading}</h1>
}
Каждое объявление содержит всю логику для этой конкретной константы. Когда мы читаем const heading = this_thing, в нашем сознании читаем "Это то, о чем идет речь в heading
". И нас не волнует остальная часть компонента.
Недостатком здесь является то, что троичный оператор может быть более трудным для чтения и немного неудобным для записи. Мы также не можем на самом деле использовать промежуточные переменные (такие как noun
в предыдущем примере) без странных синтаксисов, подобных IIFE.
Но для нас это небольшой недостаток по сравнению с тем, что мы получаем. Теперь, с первого взгляда, мы знаем, что такое heading
. Декларация содержит полный рецепт того, как рассчитать это значение. И нам больше не нужно читать остальную часть компонента, чтобы понять это.
Это особенно важно для нас как консультанта: нам нужно иметь возможность посмотреть на какой-то код и сразу понять его логику. Почему? Потому что мы не можем позволить себе понять весь проект, как и клиент! Время - деньги.
И это справедливо и для команд тоже! Если мы можем что-то понять сразу, это означает, что люди, стоящие за этим кодом, смогут хорошо его поддерживать.
Классы
По сравнению с чистыми функциями класс представляет собой другую ментальную модель. Это не потенциальный поток инструкций, а группа свойств и методов.
Это означает, что предыдущий компонент React (императивный) не может быть написан таким же образом, если мы используем класс. Нам пришлось бы поместить логику внутри метода. Это было бы и некрасиво, и странно.
// Angular (kinda, you get the point)
class VideoListComponent {
@Input() videos;
@Input() emptyHeading;
count = 0;
heading = '';
ngOnChanges(changes) {
this.count = changes.videos?.currentValue.length;
this.heading = this.count > 0
? this.count + ' ' + (this.count > 1 ? 'Videos' : 'Video')
: this.emptyHeading;
}
}
Сравните это с тем, насколько чистыми были предыдущие примеры React. Мы не хотим касаться методов жизненного цикла. Мы хотим объявить производные значения: написать руководство.
С классами мы можем использовать getters
для объявления производных состояний.
class VideoListComponent {
@Input() videos = [];
@Input() emptyHeading = '';
get count() {
return this.videos.length;
}
get heading() {
if (this.count > 0) {
const noun = this.count > 1 ? 'Videos' : 'Video';
return this.count + ' ' + noun;
}
return this.emptyHeading;
}
}
Обратите внимание, что, поскольку getters — это функция, легко использовать промежуточные переменные (noun
) и некоторые искры императивного кода (который самодостаточен и не разбросан по всему компоненту).
Конечно, это немного многословно, чтобы написать. И мы должны спросить себя: примет ли фреймворк изменения? Мы полагаемся на обнаружение изменений для работы этих геттеров, и они потенциально могут запускаться много раз, потому что нет реального способа узнать, изменилось ли изменяемое свойство.
Это скоро закончится, как только мы получим сигналы.
Signal
class VideoListComponent {
// I think this is what we'll end up with, reactive
// inputs as signals with default values, or something like that!
videos = input([]);
emptyHeading = input('');
count = computed(() => this.videos().length);
heading = computed(() => {
if (this.count() > 0) {
const noun = this.count() > 1 ? 'Videos' : 'Video';
return this.count() + ' ' + noun;
}
return this.emptyHeading();
});
}
Это все еще больше кода, чем альтернативы, основанные на функциях. На самом деле не так уж много. Но:
- Это очень явно
- Это декларативный
Еще:
- Это производительность по умолчанию (как и любая другая реализация Signal на самом деле), которая, по нашему мнению, является будущим любого фреймворка. Мы не поклонник установки "оптимизируйте это позже, если вам это понадобится", потому что в больших приложениях можем не осознавать, насколько дорогим будет конкретное вычисление через 3 месяца с гораздо большим количеством кода. И будет трудно вернуться к инкриминируемому коду.
- Нам больше не понадобится
zone.js
Signal имеют множество разновидностей, по одной для каждой использующей их среды. Сравните это с подходом Solid:
// Solid
function VideoList(props) {
const count = () => props.videos.length;
const heading = () => {
if (count() > 0) {
const noun = count() > 1 ? "Videos" : "Video";
return count() + " " + noun;
}
return props.emptyHeading;
}
return <h1>{heading()}</h1>
}
Но у него есть несколько небольших предостережений.
- Почему везде есть стрелочные функции? Мы должны знать, что в Solid, чтобы сделать переменную реактивной, она должна быть функцией. Но это немного трудно districate на первый взгляд.
- Видя функцию, мы не знаем, является ли она реактивным производным состоянием или обработчиком событий. Они выглядят одинаково, поэтому нам нужно прочитать код, чтобы узнать, что он делает (или, надеюсь, у переменной отличное имя!).
Важно отметить, что это всего лишь мнение, и вам может понравиться код Solid больше, чем что-либо еще! Это отличный инструмент.
Так почему же нам нравится Angular с сигналами? И почему нам нравится работать с классами таким образом?
Потому что с сигналами Angular мы вынуждены сообщать, что значение является производным состоянием. И мы будем вынуждены сказать, что ввод следует рассматривать как сигнал (если мы этого хотим!). И написание императивного кода, такого как самый первый пример этой статьи, в значительной степени обескураживает природа классов в реактивном контексте. Если ваш код выглядит плохо, вы, вероятно, возненавидите его, может быть, завтра, может быть, через 3 месяца.
С React у нас есть возможность писать императивный код. У нас красивый и короткий синтаксис. Но у нас нет мелкозернистой реактивности, а у Hook есть правила (думаем, будет справедливо сказать, что ментальная модель React великолепна, пока вы не добавите Hook, а они часто используются).
В Solid мы обычно имеем дело с большим количеством анонимных функций, и обращение с props может быть довольно странным (они реактивные, но мы получаем их как обычные значения, деструктурирование — это странно).
С Angular у нас есть четкий синтаксис, и нас подталкивают к декларативному коду. Налог платить немного больше кода, чем альтернативы.
Меньше кода не всегда объективно лучше. Занятия могут быть легко поняты любым старшеклассником, изучившим OOP. О них легко рассуждать, если мы делаем это правильно.
Многие люди критикуют классы. У них есть на это веские причины. Так почему же ценят их, даже если люблю функциональное программирование?
Если мы используем классы с немного FP-мышлением (неизменяемость, свойства являются константами), многие их недостатки исчезают.
Радует, что нам больше никогда не понадобится ngOnChanges
. Или некоторые установщики ввода, чтобы установить некоторые значения BehaviorSubjects
.
PS. Пожалуйста, не воспринимайте это как критику React или Solid.