В поисках идеального 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">
    Hello, <strong> </strong>!
</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>.

Шаблон — часть 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. Некоторые их части тяжело тестировать, потому что они не дробятся на компоненты. Разработчикам следует подумать и в этом направлении. Да, они предоставляют нам умный, изящный и рабочий код. Но код должен быть ещё и тестируемым.

Документация

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

Я бы разделил хорошую документацию на три части:

Заключение

Будущее, конечно, тяжело предугадать. Но зато мы можем о нём помечтать! Важно говорить о том, что мы ожидаем и что мы хотим от фреймворков на JavaScript! Если у вас есть замечания, предложения или вы хотите поделится своими мыслями, пишите в твиттер с хэштегом #jsframeworks.