Pull to refresh

Ключевое отличие AngularJS от Knockout

Reading time 6 min
Views 48K
imageЗа последнее время я несколько раз успел поучаствовать в дискуссиях о том, чем Angular лучше или хуже Knockout и других JS-фреймворков. И очень часто я сталкивался с тем, что есть некоторое непонимание сути различий в подходах, заложенных в эти продукты. Иногда дело доходило даже до того, что в качестве преимущества Knockout приводились валидные по умолчанию префиксы «data-», что ну просто совсем смешно (не говоря уж о том, что их можно использовать и в Angular).

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

  1. Модульная организация кода, тестируемость и жестокая война с любыми глобальными данными.
  2. Пропаганда декларативного подхода через создание собственных HTML-директив.
  3. Механизм проверки изменения данных в дата-биндинге без использования коллбэков.

И третий пункт мне здесь видится наиболее сложным для понимания. Поговорим именно о нем.

Что такое дата-биндинг? Грубо говоря, это отображение данных в шаблоне, выполненное так, чтобы изменение данных изменяло их представление. Имея объект:
var myViewModel = {
    personName: 'Bob',
    personAge: 123
};
… и шаблон:
<span>personName</span>

… мы хотим, чтобы span при изменении myViewModel обновлялся до актуального состояния с минимальным нашим участием. И наоборот, если это, например, поле ввода.

И для решения этой задачи есть два принципиально различных подхода.

Change listeners

Для механизма дата-биндинга в таких фреймворках как Knockout и Backbone была разработана система отслеживания изменений (change listeners). Вы работаете с данными не напрямую, а через специальный слой (например, observables в KO), призванный вовремя менять представление данных при их изменении. Любая переменная превращается в объект фреймворка, который следит за состоянием.

var myViewModel = {
    personName: ko.observable('Bob'),
    personAge: ko.observable(123)
};
...
myViewModel.personName('Mary');
myViewModel.personAge(50);

<span data-bind="text: personName"></span>


Умное и очевидное решение. Казалось бы, функциональное и событийно-ориентированное программирование в javascript подходит как нельзя лучше для подобных задач, да и вообще работа с коллбэками — наш сильный конек.

Однако, тут возникают проблемы, которые приходится решать подобным фреймворкам.

Во-первых, что, если одна часть данных каким-то способом зависит от другой части? Изменив одну переменную, мы автоматически сообщаем об этом, но изменившаяся при этом вторая переменная останется незамеченной. Для разрешения подобных зависимостей в KO существует механизм dependency tracking, который работает хорошо, но само наличие решения говорит о существовании проблемы.

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

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

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

Dirty checking

По этому принципу работает AngularJS. Dirty checking — это проверка на изменененность данных, простая как два рубля. Раньше переменная myVar была 1, теперь она 2 — значит данные изменились и надо их в шаблоне перерисовать. Для простых переменных это оператор !=, для сложных — соответствующие легковесные процедуры. Это простейший подход, который избавляет нас как от необходимости работать с данными через специальный «слушающий» слой, так и от всех проблем, связанных с зависимостями данных.

var myViewModel = {
    personName: 'Bob',
    personAge: 123
};
...
myViewModel.personName = 'Mary';
myViewModel.personAge = 50;

<span>{{personName}}</span>


Весь вопрос в том, когда производить эту проверку? Непрерывно, по таймеру? Учитывая, что модель данных может быть довольно сложной, то непрерывно производящиеся проверки могут сильно ухудшить UX. В Angular этот вопрос решается путем автоматического вызова функции $digest после каждого участка кода, предположительно могущего изменить данные. Это ключевой момент — проверка выполняется тогда и только тогда, когда данные могли быть изменены (например, при действии пользователя), и никогда не выполняется в других случаях. Если вы ожидаете изменения данных в какой-то другой момент времени (например, при поступлении события от сервера или завершении какого-либо процесса), вы должны явно указать Angular, что стоит выполнить проверку, вызвав функцию $apply.

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

Очевидный минус этого подхода — производительность. Хотя и здесь есть небольшое исключение: например, при пакетном обновлении сразу большого количества данных проверка выполняется всего один раз в конце, а не при каждом изменении каждого из отслеживаемых объектов, как это происходит в первом случае. Но в целом, это тем не менее минус, так как при изменении всего одной переменной выполняется dirty check всех данных.

Нужно лишь понять, насколько сильны потери производительности.

Тут стоит отметить, что Angular во время выполнении dirty check никогда не работает с DOM. Все данные — это нативные объекты js, с которыми все современные движки браузеров молниеносно выполняют большинство основных операций. Хотя вы и можете сами вставлять процедуры проверки в процесс dirty check, документация Angular настоятельно рекомендует не работать с DOM внутри них, так как это может сильно замедлить весь процесс.

Учитывая это, можно сказать, что потеря производительности в сегодняшних условиях работы веб-приложений на практике не ощущается. Раньше я немалое время занимался разработкой игр под мобильные платформы, и там (особенно на старых вроде Palm OS) на счету обычно был каждый лишний такт процессора. Но даже при такой нехватке ресурсов основным принципом работы «дата-биндинга» был именно простейший dirty check. Что такое дата-биндинг в случае игры? Это отображение картинок на игровом экране в зависимости от того, что происходит в состоянии данных игры. Иногда, действительно, использовался подход, близкий к подходу слушающих коллбэков — например, обновление экрана только лишь в тех местах, где картинка поменялась. Но в основном экран просто перерисовался каждый раз заново целиком, замещая текущий кадр новым актуальным состоянием графики. И единственным критерием правомерности такого подхода был и остается показатель FPS — как часто мы можем менять таким образом кадры и насколько плавным будет соответствующий UX. До тех пор, пока FPS находится в районе максимально возможного для восприятия человеком (в районе 30 кадров в секунду), о потерях быстродействия просто нет смысла думать, так как они не приводят ни к чему, что можно назвать ухудшением UX.

Факт заключается в том, что простой dirty checking, применяемый в AngularJS, позволяет работать с данными практически любой сложности, и при этом выполнять проверку и менять отображение менее, чем за 50мс, а это хоть и дольше, чем если бы мы проверяли лишь часть данных, но тем не менее мгновенно для пользователя. И при этом такой подход избавляет от множества различных головных болей, вызываемых сложным механизмом change listeners, да и просто упрощает работу, ведь мы продолжаем обращаться с данными как с обычными переменными и POJO-объектами, не задействуя сложный синтаксис «слушающего» слоя.
Tags:
Hubs:
+64
Comments 64
Comments Comments 64

Articles