В поисках идеального JavaScript-фреймворка
В наши дни в области фронтенд-разработки есть множество фреймворков и библиотек. Какие-то из них хорошие, какие-то нет. Часто нам нравится только определённый принцип или определённый синтаксис. Правда в том, что универсального инструмента нет. Эта статья про будущий фреймворк — фреймворк, которого ещё не существует. Я резюмировал достоинства и недостатки некоторых популярных JavaScript- фреймворков и отважился помечтать об идеальном решении.
Абстракции опасны
Всем нам нравятся простые инструменты. Сложность убивает. Она делает наши жизни сложнее, а кривую обучения — более отвесной. Программистам необходимо знать, как вещи работают. Иначе они чувствуют себя неуверенно. Если мы работаем со сложной системой, появляется большой разрыв между «я этим пользуюсь» и «я знаю, как это работает». К примеру, такой код скрывает сложность:
var page = Framework.createPage({
'type': 'home',
'visible': true
});
Предположим, что это реальный фреймворк. Под капотом createPage
создает
новый класс отображения, который загружает шаблон из home.html
.
В зависимости от значения параметра visible
мы вставляем (или нет) созданный
элемент DOM в дерево. А теперь представьте себя на месте разработчика. Мы
прочитали в документации, что этот метод создаёт новую страницу с заданным
шаблоном. Нам неизвестны конкретные детали, потому что это абстракция.
У некоторых из фреймворков наших дней даже не один, а несколько слоёв абстракций. Иногда чтобы пользоваться фреймворком правильно, нам нужно знать детали. Абстрагирование, вообще говоря, мощный инструмент, это обёртка для функциональности. Оно инкапсулирует конкретные реализации. Но абстрагирование следует использовать с осторожностью, иначе оно может привести к действиям, которые невозможно отследить.
А что если мы перепишем пример выше вот так:
var page = Framework.createPage();
page
.loadTemplate('home.html')
.appendToDOM();
Теперь разработчик знает, что происходит. Создание шаблона и вставка в дерево теперь производятся разными методами API. Так что программист может что-то сделать между этим вызовами и контролирует ситуацию.
Возьмём к примеру Ember.js. Это отличный фреймворк. С его помощью мы можем построить одностраничное приложение всего несколькими строчками кода. Но всё имеет свою цену. Он объявляет за кулисами несколько классов. К примеру:
App.Router.map(function() {
this.resource('posts', function() {
this.route('new');
});
});
Фреймворк создаёт три маршрута, и за каждым закреплён контроллер. Можете использовать эти классы, можете не использовать, но они всё равно есть. Они нужны фреймворку для работы.
Очень часто в наших проектах требуется нестандартный функционал. Не существует фреймворка на все случаи жизни. Так что, мы сталкиваемся с задачами, у которых нет простых решений. Чтобы сделать всё правильно, мы должны понимать, каким образом всё работает. Принятие любого другого решения напоминает взлом фреймворка, а не его использование.
У Backbone.js, например, всего несколько заранее определённых объектов.
В них содержится базовая функциональность, но настоящая реализация остаётся за
программистом. Класс DocumentView
расширяет Backbone.View
. Это всё.
Всего один уровень между нашим кодом и кодом ядра фреймворка.
var DocumentView = Backbone.View.extend({
'tagName': 'li',
'events': {
'mouseover .title .date': 'showTooltip',
'click .open': 'render'
},
'render': function() { ... },
'showTooltip': function() { ... }
});
Лично я предпочитаю фреймворки, в которых нет множества уровней абстракций, фреймворки, которые предоставляют прозрачность.
Недостающий конструктор
Некоторые из фреймворков принимают наши определения классов, но не создают конструкторов. Фреймворк сам решает, где и когда создать экземпляр. Я был бы рад увидеть больше фреймворков, позволяющих нам делать именно так. Вот, к примеру Knockout:
function ViewModel(first, last) {
this.firstName = ko.observable(first);
this.lastName = ko.observable(last);
}
ko.applyBindings(new ViewModel("Planet", "Earth"))
Мы объявляем модель и сами же её инициализируем. А вот в AngularJS чуть по-другому:
function TodoCtrl($scope) {
$scope.todos = [
{ 'text': 'learn angular', 'done': true },
{ 'text': 'build an angular app', 'done': false }
];
}
Опять таки, мы объявляем наш класс, но мы его не запускаем. Мы только говорим, что это наш контроллер, а фреймворк решает, что с ним делать. Нас может сбить это с толку, потому что мы потеряли ключевые точки — те ключевые точки, которые нужны нам для того, чтобы нарисовать схему работы приложения.
Работа с DOM
Что бы мы ни делали, нам нужно взаимодействовать с DOM. То, как мы это делаем, очень важно, обычно каждое изменение узлов дерева на странице влечёт за собой пересчёт размеров или перерисовку, а это могут быть весьма дорогостоящие операции. Давайте в качестве примера разберём такой класс:
var Framework = {
'el': null,
'setElement': function(el) {
this.el = el;
return this;
},
'update': function(list) {
var str = '<ul>';
for (var i = 0; i < list.length; i++) {
var li = document.createElement('li');
li.textContent = list[i];
str += li.outerHTML;
}
str += '</ul>';
this.el.innerHTML = str;
return this;
}
}
Этот крошечный фреймворк генерирует ненумерованный список с нужными данными.
Мы передаём элемент DOM, в котором следует поместить список, и вызываем функцию
update
, которая отображает данные на экране.
Framework
.setElement(document.querySelector('.content'))
.update(['JavaScript', 'is', 'awesome']);
Вот, что у нас из этого вышло:
Чтобы продемонстрировать слабую сторону такого подхода мы добавим на страницу
ссылку и назначим на ней обработчик события click
. Функция снова вызовет метод
update
, но с другими элементами списка:
document.querySelector('a').addEventListener('click', function() {
Framework.update(['Web', 'is', 'awesome']);
});
Мы передаём почти те же самые данные, поменялся только первый элемент массива.
Но из-за того, что мы используем innerHTML
, перерисовка происходит после
каждого щелчка. Браузер не знает, что нам надо поменять только первую строку.
Он перерисовывает весь список. Давайте запустим DevTools браузера Opera и
запустим профилирование. Посмотрите на этом анимированном GIF’е, что происходит:
Заметьте, после каждого щелчка весь контент перерисовывается. Это проблема, особенно, если такая техника применяется во многих местах на странице.
Гораздо лучше запоминать созданные элементы <li>
и менять только их
содержимое. Таким образом, мы меняем не весь список целиком, а только его
дочерние узлы. Первое изменение мы можем сделать в setElement
:
setElement: function(el) {
this.list = document.createElement('ul');
el.appendChild(this.list);
return this;
}
Теперь нам больше не обязательно хранить ссылку на элемент-контейнер. Достаточно
создать элемент <ul>
и один раз его добавить в дерево.
Логика, улучшающая производительность, находится внутри метода update
:
'update': function(list) {
for (var i = 0; i < list.length; i++) {
if (!this.rows[i]) {
var row = document.createElement('LI');
row.textContent = list[i];
this.rows[i] = row;
this.list.appendChild(row);
} else if (this.rows[i].textContent !== list[i]) {
this.rows[i].textContent = list[i];
}
}
if (list.length < this.rows.length) {
for (var i = list.length; i < this.rows.length; i++) {
if (this.rows[i] !== false) {
this.list.removeChild(this.rows[i]);
this.rows[i] = false;
}
}
}
return this;
}
Первый цикл for
проходит по всем переданным строкам и создаёт при
необходимости элементы <li>
.
Ссылки на эти элементы хранятся в массиве this.rows
.
А если там по определённому индексу уже находится элемент, фреймворк лишь
обновляет по возможности его свойство textContent
. Второй цикл удаляет
элементы, если размер массива больше, чем количество переданных строк.
Вот результат:
Браузер перерисовывает только ту часть, которая изменилась.
Хорошая новость: фреймворки вроде React и так уже работают с DOM правильно. Браузеры становятся умнее и применяют хитрости для того, чтобы перерисовывать как можно меньше. Но всё равно, лучше держать это в уме и проверять, как работает выбранный вами фреймворк.
Я надеюсь, в ближайшем будущем мы сможем больше не задумываться о таких вещах, и фреймворки будут заботиться об этом сами.
Обработка событий DOM
Приложения на JavaScript обычно взаимодействуют с пользователем через события DOM. Элементы на странице посылают события, а наш код их обрабатывает. Вот отрывок кода на Backbone.js, который выполняет действие, если пользователь взаимодействует со страницей:
var Navigation = Backbone.View.extend({
'events': {
'click .header.menu': 'toggleMenu'
},
'toggleMenu': function() {
// ...
}
});
Итак, должен быть элемент, соответствующий селектору .header.menu
, и когда
пользователь на нём кликнет, мы должны показать или скрыть меню. Проблема
такого подхода в том, что мы привязываем объект JavaScript к конкретному
элементу DOM. Если мы захотим подредактировать разметку и заменить .menu
на
.main-menu
, нам придётся поправить и JavaScript. Я считаю, что контроллеры
должны быть независимыми, и не следует их жёстко сцеплять с DOM.
Определяя функции, мы делегируем задачи класса JavaScript. Если эти задачи — обработчики событий DOM, есть смысл создавать их из HTML.
Мне нравится, как AngularJS обрабатывает события.
<a href="#" ng-click="go()">click me</a>
go
— это функция, зарегистрированная в нашем контроллере. Если следовать
такому принципу, нам не нужно задумываться о селекторах DOM. Мы просто применяем
поведение непосредственно к узлам HTML. Такой подход хорош тем, что он спасает
от скучной возни с DOM.
В целом, я был бы рад, если бы такая логика была внутри HTML. Интересно, что мы потратили кучу времени на то, чтобы убедить разработчиков разделять содержимое (HTML) и поведение (JavaScript), мы отучили их встраивать стили и скрипты прямо в HTML. Но теперь я вижу, что это может сберечь наше время и сделать наши компоненты более гибкими. Разумеется, я не имею в виду что-то такое:
<div onclick="javascript:App.doSomething(this);">banner text</div>
Я говорю о наглядных атрибутах, которые управляют поведением элемента. Например:
<div data-component="slideshow" data-items="5" data-select="dispatch:selected">
...
</div>
Это не должно выглядеть, как программирование на JavaScript в HTML, скорее это должно быть похоже на установку конфигурации.
Управление зависимостями
Управление зависимостями — важная задача в процессе разработки. Обычно мы зависим от внешних функций, модулей или библиотек. Фактически, мы всё время создаём зависимости. Мы не пишем всё в одном методе. Мы разносим задачи приложения в различные функции, а затем их соединяем. В идеале мы хотим инкапсулировать логику в модули, которые ведут себя как чёрные ящики. Они знают только те детали, которые касаются их работы, и больше ничего.
RequireJS — один из популярных инструментов разрешения зависимостей. Идея состоит в том, что код оборачивается в замыкание, в которое передаются необходимые модули:
require(['ajax', 'router'], function(ajax, router) {
// ...
});
В этом примере функции требуется два модуля: ajax
и router
. Магический
метод require
читает переданный массив и вызывает нашу функцию с нужными
аргументами. Определение router
выглядит примерно так:
// router.js
define(['jquery'], function($) {
return {
'apiMethod': function() {
// ...
}
}
});
Заметьте, тут ещё одна зависимость — jQuery. Ещё важная деталь: мы должны вернуть публичный API нашего модуля. Иначе код, запросивший наш модуль, не смог бы получить доступ к самому функционалу.
AngularJS идёт немного дальше и предоставляет нам нечто под названием фабрика. Мы регистрируем там свои зависимости, и они волшебным образом становятся доступными в контроллерах. Например:
myModule.factory('greeter', function($window) {
return {
'greet': function(text) {
alert(text);
}
};
});
function MyController($scope, greeter) {
$scope.sayHello = function() {
greeter.greet('Hello World');
};
}
Вообще говоря, такой подход облегчает работу. Нам не надо использовать
функций вроде require
для того чтобы добраться до зависимости. Всё, что
требуется,— напечатать правильные слова в списке аргументов.
Ладно, оба эти способа внедрения зависимостей работают, но каждый из них требует своего стиля написания кода. В будущем я хотел бы увидеть фреймворки, в которых это ограничение снято. Было бы значительно изящнее применять метаданные при создании переменных. Сейчас язык не даёт возможности это сделать. Но было бы круто, если бы можно было делать так:
var router:<inject:Router>;
Если зависимость будет находиться рядом с определением переменной, то мы можем быть уверены, что внедрение этой зависимости производится, только если она нужна. RequireJS и AngularJS, к примеру, работают на функциональном уровне. То есть, может случиться так, что вы используете модуль только в определённых случаях, но его инициализация и внедрение будут происходить всегда. К тому же, мы можем определять зависимости только в строго определённом месте. Мы к этому привязаны.
Шаблоны
Мы часто пользуемся шаблонами. И мы делаем это из-за необходимости разделять данные и разметку HTML. Как же современные фреймворки работают с шаблонами? Вот самые распространённые подходы:
Шаблон определён в <script>
<script type="text/x-handlebars"></script>
Такой подход часто используется, потому что шаблоны находятся в HTML. Это
выглядит естественно и не лишено смысла, раз уж в HTML есть теги. Браузер не
отрисовывает содержимое элементов <script>
, и покорёжить внешний вид страницы
это не может.
Шаблон загружается AJAX’ом
Backbone.View.extend({
'template': 'my-view-template',
'render': function() {
$.get('/templates/' + this.template + '.html', function(template) {
var html = $(template).tmpl();
});
}
});
Мы положили свой код во внешние файлы HTML и избежали использования
дополнительных тегов <script>
. Но теперь нам нужно больше запросов HTTP, а
это не всегда уместно (по крайней мере, пока поддержка HTTP2 не станет шире).
- Шаблон является частью разметки — фреймворк получает его из DOM-дерева. Этот
метод опирается на уже сгенерированный HTML. Нам не нужно делать дополнительных
HTTP запросов, создавать новые файлы или дополнительные элементы
<script>
.
Шаблон — часть разметки страницы
Фреймворк считывает шаблон из дерева DOM. Он полагается на заранее
сгенерированный HTML. Не нужно производить дополнительных запросов HTTP,
создавать файлы или добавлять элементы <script>
.
Шаблон — часть JavaScript
var HelloMessage = React.createClass({
render: function() {
// Обратите внимание: следующая строка кода не является корректным JavaScript.
return <div>Hello {this.props.name}</div>;
}
});
Такой подход был введён в React, там используется собственный парсер, который превращает невалидную часть JavaScript в валидный код.
Шаблон — не HTML
Некоторые фреймворки вообще не используют HTML напрямую. Вместо этого шаблоны хранятся в виде JSON или YAML.
Напоследок о шаблонах
Хорошо, а что дальше? Я ожидаю, что с фреймворком будущего мы будем рассматривать данные отдельно, а разметку отдельно. Чтобы они не пересекались. Мы не хотим иметь дело с загрузкой строк в HTML или с передачей данных в специальные функции. Мы хотим присваивать значения переменным, а DOM чтобы обновлялся сам. Распространённое двустороннее связывание не должно быть фичей, это должно быть обязательным базовым функционалом.
Вообще, поведение AngularJS ближе всего к желаемому. Он считывает шаблон из
содержимого предоставленной страницы, и в нём реализовано волшебное двустороннее
связывание. Впрочем, оно ещё не идеально. Иногда наблюдается мерцание. Это
происходит, когда браузер отрисовывает HTML, но загрузочные механизмы AngularJS
ещё не запустились. К тому же, в AngularJS применяется грязная проверка того,
поменялось ли что-нибудь. Такой подход порой очень затратен. Надеюсь, скоро во
всех браузерах будет поддерживаться Object.observe
, и связывание будет
лучше.
Рано или поздно каждый разработчик сталкивается с вопросом динамических шаблонов. Наверняка, в наших приложениях есть части, которые появляются после загрузки. С фреймворком это должно быть просто. Мы не должны задумываться об AJAX-запросах, а API должен быть таким, чтобы процесс выглядел синхронным.
Модульность
Мне нравится, когда фичи можно включать и выключать. A если мы чем-то не пользуемся, зачем держать это в кодовой базе? Было бы хорошо, если у фреймворка был бы сборщик, который генерирует версию с только необходимыми модулями. Как, например YUI, у которого есть конфигуратор. Мы выбираем те модули, которые хотим, и получаем минифицированный и готовый к использованию файл JavaScript.
Даже так, существуют фреймворки, у которых есть нечто, называемое ядром. Вдобавок к нему мы можем использовать пачку плагинов (или модулей). Но мы могли бы это улучшить. Процесс выбора нужных фич не должен включать в себя загрузку файлов. Мы не должны их вручную подключать на странице. Это каким-то образом должно быть частью кода фреймворка.
Помимо адекватных возможностей при установке, идеальная среда должна предусматривать расширяемость. У нас должна быть возможность писать собственные плагины и делиться ими с другими разработчиками. Другими словами, среда должна быть благоприятной для написания модулей. Не получится создать сильное сообщество без существования подходящих условий для разработчиков.
Открытый API
Большая часть фреймворков предоставляют API к своей базовой функциональности. Но при помощи этих API можно добраться только к тем частям, которые поставщики посчитали нужными для нас. И вот тут может понадобиться хакерство. Мы хотим что-то получить, но для этого нет подходящих инструментов. И приходится идти на хитрости и ходить в обход. Рассмотрим такой пример:
var Framework = function() {
var router = new Router();
var factory = new ControllerFactory();
return {
'addRoute': function(path) {
var rData = router.resolve(path);
var controller = factory.get(rData.controllerType);
router.register(path, controller.handler);
return controller;
}
}
};
var AboutCtrl = Framework.addRoute('/about');
У такого фреймворка есть встроенный маршрутизатор. Мы определяем путь, и контроллер
инициализируется автоматически. Когда пользователь посещает определённый URL,
маршрутизатор вызывает у конструктора метод handler
. Это здорово, но что если нам
нужно выполнять небольшую функцию JavaScript при совпадении URL? По какой-то
причине, мы не хотим создавать дополнительный контроллер. С текущим API такое
не получится.
Мы могли бы сделать по-другому, например, вот так:
var Framework = function() {
var router = new Router();
var factory = new ControllerFactory();
return {
'createController': function(path) {
var rData = router.resolve(path);
return factory.get(rData.controllerType);
}
'addRoute': function(path, handler) {
router.register(path, handler);
}
}
}
var AboutCtrl = Framework.createController({ 'type': 'about' });
Framework.addRoute('/about', AboutCtrl.handler);
Заметьте, маршрутизатор не торчит наружу. Его не видно, но теперь мы можем управлять как созданием контроллера, так и регистрацией пути в маршрутизаторе. Разумеется, предложенный вариант подходит для нашей конкретной задачи. Но он может оказаться излишне сложным, потому что контроллеры тут приходится создавать вручную. При разработке API мы руководствуемся принципом единственной обязанности и рассуждением делай что-то одно, и делай это хорошо. Я вижу, как всё больше и больше фреймворков децентрализуют свой функционал. В них сложные методы делятся на более мелкие части. И это хороший признак, я надеюсь, в будущем больше фреймворков будет так делать.
Тестируемость
Нет нужды убеждать вас в необходимости писать тесты для кода. Дело даже не только в том, что надо писать тесты, а в том, что надо писать код, который возможно покрыть тестами. Иногда это невероятно сложно и занимает много времени. Я убеждён, что если мы на что-то не напишем тесты, даже на что-то очень маленькое, то именно в этом месте в приложении начнут плодиться баги. Это в особенности касается JavaScript на клиентской стороне. Несколько браузеров, несколько операционных систем, новые спецификации, новые фичи и их полифилы — да куча причин начать практиковать разработку через тестирование.
Есть ещё кое-что, что мы получим от тестов. Мы не только убеждаемся в том, что наш фреймворк (приложение) работает сегодня. Мы убеждаемся, что он будет работать завтра и послезавтра. Если есть какая-то новая фича, которую мы привносим в код, мы пишем для неё тесты. И очень важно, что мы делаем так, чтобы эти тесты проходились. Но так же важно, чтобы проходились и предыдущие тесты. Именно так мы гарантируем, что ничего не сломалось.
Я с радостью бы увидел больше стандартизованных утилит и методов для тестирования. Мне хотелось бы использовать одну утилиту для тестирования всех фреймворков. Было бы ещё хорошо, если бы тестирование было как-то включено в процесс разработки. Следует обратить больше внимания на сервисы вроде Travis CI. Они работают как индикатор не только для того программиста, который вносит изменения, но также и для других контрибьюторов.
Я всё ещё работаю с PHP. Мне приходилось иметь дело с фреймворками вроде WordPress. И множество людей спрашивало меня, как я тестирую свои приложения: какой фреймворк я использую, как я запускаю тесты, есть ли у меня вообще компоненты. Правда в том, что я ничего не тестирую. И всё потому у меня нет компонентов. То же самое относится и некоторым фреймворкам на JavaScript. Некоторые их части тяжело тестировать, потому что они не дробятся на компоненты. Разработчикам следует подумать и в этом направлении. Да, они предоставляют нам умный, изящный и рабочий код. Но код должен быть ещё и тестируемым.
Документация
Я уверен, что без хорошей документации любой проект рано или поздно загнётся. Каждую неделю выходит куча фреймворков и библиотек. Документация — это первое, что видит разработчик. Никто не хочет тратить часы на то, что узнать, что делает определённая утилита, и какие у неё фичи. Недостаточно простого перечисления основного функционала. Особенно для большого фреймворка.
Я бы разделил хорошую документацию на три части:
-
Что я могу сделать. Документация должна учить пользователя и должна делать это правильно. Неважно, насколько крутой или мощный у нас фреймворк, он нуждается в объяснении. Кто-то предпочитает смотреть видео, кто-то читать статьи. В любом случае, разработчику нужно показать всё, начиная с самых основ и заканчивая сложными частями фреймворка.
-
Документация API. Это обычно везде есть. Полный список всех публичных методов API, того, какие у них параметры и что они возвращают. Может быть, примеры использования.
-
Как это работает. Обычно этого раздела в документациях нет. Хорошо, если бы кто-нибудь разъяснил структуру фреймворка, даже простая схема базового функционала и его взаимосвязей уже бы помогла. Это сделало бы код прозрачным. Это помогло бы тем разработчикам, которые хотят внести свои изменения.
Заключение
Будущее, конечно, тяжело предугадать. Но зато мы можем о нём помечтать! Важно говорить о том, что мы ожидаем и что мы хотим от фреймворков на JavaScript! Если у вас есть замечания, предложения или вы хотите поделится своими мыслями, пишите в твиттер с хэштегом #jsframeworks.