Идеальный UI фреймворк

в 8:13, , рубрики: $mol, AngularJS, javascript, jquery, jQuery UI, polymer.js, ReactJS, todomvc, Программирование

Здравствуйте, меня зовут Дмитрий Карловский, и я… архитектор множества широко известных в узких кругах фреймворков. Меня никогда не устраивала необходимость из раза в раз решать одни и те же проблемы, поэтому я всегда стараюсь решать их в корне. Но прежде, чем их решить, нужно их обнаружить и осознать, что довольно сложно находясь в плену привычек, паттернов, стереотипов и "готовых" решений. Каждый раз сталкиваясь с проблемами в реализации задачи, я задумываюсь "что, блин, не так с этим инструментом?" и, конечно же, иду пилить свой инструмент: функцию, модуль, библиотеку, фреймворк, язык программирования, архитектуру ЭВМ… стоп, до последнего я ещё не докатился.

Речь сегодня пойдёт о JS-фреймворках. Нет, я не буду рассказывать про очередное готовое решение, не в том цель поста. Я лишь хочу посеять в ваших головах несколько простых идей, которые вы не встретите в документации ни к одному популярному фреймворку. А в конце мы постараемся сформировать видение идеальной архитектуры построения пользовательского интерфейса.

Взгляд под другим углом

Длинный цикл отладки

Типичный цикл отладки выглядит так:

  1. Редактирование кода.
  2. Запуск приложения.
  3. Проверка и обнаружение проблем.
  4. Исследование их причин.

И цикл этот повторяется для любой опечатки. Чем быстрее разработчик поймёт где и почему ошибся, тем быстрее он всё реализует. Поэтому обратную связь программист должен получать как можно быстрее, прямо в процессе написания кода. Тут помогают средства среды разработки, которые в реальном времени анализируют набранный код и проверяют, будет ли это всё работать. Как следствие, очень важно, чтобы среда разработки могла получить из кода как можно больше как можно более конкретной информации. Чтобы этого добиться, используемый язык должен быть статически типизирован настолько на сколько это возможно. JavaScript же типизирован лишь динамически, от чего IDE пытается угадать типы по косвенным признакам (типичные паттерны, JSDoc-и), но, как показывает практика, даже у наиболее продвинутых сред разработки это получается плохо.

WebStorm не понимает JavaScript

Однако, существует минималистичное расширение JavaScript, добавляющее в него опциональную статическую типизацию — TypeScript. Его отлично понимает даже, какой-нибудь простой текстовый редактор типа GitHub Atom.

Atom понимает TypeScript

На текущий момент TypeScript является наиболее оптимальным языком для разработки веб приложений. Разработчики AngularJS это уже поняли. Не опоздайте на поезд!

Вторым эшелоном в деле ускорения отладочного цикла идут автоматизированные тесты, позволяющие быстро проверить работоспособность и обнаружить место неисправности. К сожалению, многие фреймворки для тестирования сконцентрированы лишь на первой фазе (проверка), но о второй (локализация неисправности) частенько даже и не задумываются, хотя она не менее важна. Например, популярный тестовый фреймворк QUnit оборачивает все тесты в try-catch, чтобы не останавливаться на первом же упавшем тесте, а в конце нарисовать красивый отчёт, который довольно бесполезен в деле поиска причин неисправности. Всё, что он может выдать — название теста и некликабельный стектрейс. Но есть костыль — вы можете добавить в ссылку параметр ?notrycatch, который не всегда работает, и тогда тесты по идее должны свалиться на первой же ошибке, после чего в зависимости от режима отладки вы получите либо остановку отладчика в месте возникновения исключения, либо кликабельный стектрейс одного упавшего теста в консоли. Но идеальным было бы решение без костылей: в режиме останова на исключениях — останавливаться на каждом (а не только на первом), а в режиме логирования — логировать все падения в консоль с кликабельным стектрейсом. Это не так сложно, как может показаться — достаточно запускать тесты в отдельных обработчиках событий, и ни в коем случае не заворачивать их в try-catch.

В свете вышесказанного стоит подчеркнуть, что try-catch лучше не использовать не только в тестовом фреймворке, но и в любом другом, ведь перехватывая исключение вы теряете возможность остановиться отладчиком в месте его возникновения. Единственное разумное применение try-catch в JavaScript — это игнорирование ожидаемых исключений, как например, делает jQuery при старте, проверяя поддержку браузером некоторых фич. И именно поэтому такой костыль, как опция отладчика "останавливаться не только на не перехваченных исключениях" плохо помогает, так как даёт слишком много ложных срабатываний, которые приходится проматывать.

Последним гвоздём в гроб try-catch можно забить тот факт, что как минимум V8 не оптимизирует функции, содержащие эту конструкцию.

Код нужно писать так, чтобы он в любой момент мог спокойно упасть, не разломав всё приложение.

Именно из соображений удобства отладки в последней реализации атомов нет ни одного try-catch, зато есть обработка события error, по которому происходит возобновление синхронизации атомов с учётом упавших.

Где что лежит?

Детективные расследования

Как быстро перейти к объявлению сущности? Как быстро найти все места использования сущности? Как найти объявление сущности по имени? Эти детективные расследования снижают продуктивность разработчика и отвлекают от решаемой задачи. Статическая типизация, как было замечено выше, позволяет среде разработки понимать семантику кода: где какой тип объявлен, какой тип возвращает функция, какие типы я могу использовать в текущем контексте. Но не менее важно, располагать и именовать файлы по простым и универсальным правилам, потому как работа с файлами происходит не только и не столько средствами среды разработки. Например, группировать файлы имеет смысл не по типу, а по функциональности. Модули с родственными функциями — в более крупные модули. А коллекции разнообразных модулей одного автора — в пакеты. Именно эта логика заложена в архитектуре PMS (Package/Module*/Source). В ней, иерархия директорий в точности повторяет иерархию пространств имён в коде. Благодаря этому по имени сущности всегда можно понять где она должна лежать. Это свойство используется pms-сборщиком для построения дерева зависимостей модулей: он анализирует исходники на предмет использования сторонних модулей, потом сериализует полученный граф так, чтобы к моменту исполнения зависимого модуля, все его зависимости тоже были исполнены. И для этого не требудется каких-то специальных объявлений в коде. Никаких AMD, LMD, CommonJS. Никаких import, require, include. Вы просто пишете код, как если бы необходимые модули уже были объявлены в том же файле, что и ваш, а обо всём остальном позаботится сборщик. Это прямой аналог автозагрузки классов из PHP, но работает с любыми модулями, содержащими исходники на самых разнообразных языках.

Благодаря тому, что зависимости между модулями отслеживаются автоматически, становится очень просто добавлять новые модули и переносить существующие. Для создания модуля достаточно создать директорию. Для добавления в него кода на любом языке достаточно просто создать файл. Но самое главное — в релиз уходят только те модули, которые реально используются, а не все подряд. Это позволяет строить код фреймворка и библиотек не из нескольких крупных модулей, 90% функций которых не используется, а из множества микроскопических модулей, не приводя ко километровым портянкам инклудов. Для сравнения: JavaScript код ToDoMVC на AngularJS в общей сложности весит 1.1МБ (60КБ в ужатопережатом виде), а на $mol — 100КБ (8КБ в ужатопережатом виде). Сравните масштабы. Надо ли рассказывать какое приложение быстрее откроется на мобилке через EDGE? И не говорите, что сейчас 4G везде — в Москве даже EDGE во многих местах ловится с перебоями. А что нам предлагает современная индустрия? Подключать библиотеки целиком, а потом хитрой магией вырезать всё лишнее? Пожалейте мой CPU!

Микромодульная архитектура позволяет создавать компактные и быстрые приложения.

У многих сейчас наверняка уже поднялись руки выразить килобайты праведного гнева и возмущения моим непрофессионализмом в комментариях. Но позвольте отмотать время на несколько лет назад и напомнить об одной похожей ситуации. Ещё не так давно в тренде были XML технологии, все активно писали на XHTML, трепетали перед перед XSLT, а данными обменивались исключительно через XML. Если ты не ставил "/" в конце бестелесных тэгов или, упаси боже, не проходил html-валидацию, то на тебя смотрели как на профнепригодного. Но как-то больно много сложностей было с этим стеком технологий. Приходилось читать километровые спецификации, мириться с жёсткими ограничениями, вставлять кучу костылей, а светлое будущее всё не наступало — браузеры так и не довели поддержку XHTML до ума. Недовольство разработчиков росло, энтузиазм угасал, пока внезапно не пришло понимание, что с убогим JSON (по сравнению с мощным XML) работать проще и, главное, быстрее; что строгость и избыточность XML не даёт особого профита; что то, во что верили тысячи разработчиков, оказалось не самой лучшей идеей. Похожая ситуация была и с вендорными префиксами в CSS: сначала их копипастили руками, потом появились специальные утилиты, которые копипастят их автоматически, а теперь от них методично избавляются, так как они не решают никаких проблем, внося лишь излишнюю сложность. Но вернёмся к "модулям". Давайте напишем пару простых модулей на, например, RequireJS:

// my/worker.js
define( function( require ) {
    var $ = require( 'jQuery' )
    return function () {
        $('#log').text( 'Hello from worker' )
        return 1
    }
} )

// my/app.js
define( function( require ) {
    var jQuery = require( 'jQuery' )
    var worker = require( 'my/worker' )
    var count = 0
    return function () {
        $('#log').text( 'Hello from app' )
        count += worker()
        count += worker()
    }
} )

Что не так с этим кодом:

  1. Один и тот же модуль (который jQuery) в разных файлах имеет разные локальные имена. То есть программисту нужно постоянно держать в голове как jQuery называется в каждом модуле. Зачем нам возможность по разному именовать одну и ту же сущность? Чтобы всех запутать?
  2. У нас нет простого доступа к переменной count. Мы не можете открыть консоль и просто набрать app.count, чтобы узнать какое там сейчас значение. Для этого необходимо изрядно пожонглировать отладчиком.
  3. Каждый раз используя какую-либо сущность, нужно проконтролировать, чтобы она была "импортирована", а переставая её использовать надо удалить и эти "импорты". Существование специального инструмента для автоматической синхронизации списка импортов с кодом, подчёркивает их бессмысленность — это типовой, легко автоматизируемый инфраструктурный код, до которого программисту, вообще говоря, нет никакого дела.
  4. Много лишнего кода, который зачастую просто генерируется по шаблону, так как писать руками одно и то же, никто не любит.

Как решить эти проблемы, не создавая новых? А очень просто, воспользуемся форматом jam.js:

// jq/jq.js

// my/worker.jam.js
var $my_worker = function () {
    $jq('#log').text( 'Hello from worker' )
    return 1
}

// my/app.jam.js
var $my_app = function () {
    $jq('#log').text( 'Hello from app' )
    $my_app.count += $my_worker()
    $my_app.count += $my_worker()
}
$my_app.count = 0

Кода получилось существенно меньше и весь он по делу, при этом для работы с ним нам уже не обязательно нужна мощная среда разработки. Нам не приходится ломать голову в каком модуле как названа jQuery, ведь называется она везде одинаково — в соответствии с путём до неё в файловой системе. При этом мы всегда можем скопировать $my_app.count в консоль, чтобы посмотреть текущее состояние. Вот к чему стоит стремиться, а не к изоляции всего и вся.

Перила помогают не упасть в пропасть, но не стоит ими себя окружать.

А вот что действительно необходимо — это пространства имён, и структура директорий как нельзя лучше позволяет гарантировать отсутствие конфликтов в простанствах имён.

Раздувание кода

Bloat boat

Если раньше были споры стоит ли грузить jQuery на сотню килобайт, то сейчас уже никого не смущает подключение мегабайтного фреймворка. Некоторые даже гордятся тем, что написали миллионы строчек кода для реализации не слишком сложного приложения. И если бы проблема была только в скорости загрузки этих слонов, да скорости их инициализации. Но есть и куда более существенные вытекающие из этого проблемы. Чем больше кода, тем больше в нём ошибок. Более того, чем больше кода, тем больше процент этих ошибок. Так что если вы видите огромный зрелый фреймворк, то можете быть уверенными, что на отладку таких объёмов кода была потрачена уйма человекочасов. И ещё уйму предстоит потратить, как на существующие ещё не замеченные баги, так и на баги, привносимые новыми фичами и рефакторингами. И пусть вас не вводит в заблуждение "100% покрытия тестами" или "команда высококлассных специалистов с десятилетним опытом" — багов точно нет лишь в пустом файле. Сложность поддержки кода растёт нелинейно по мере его разрастания. Развитие фреймворка/библиотеки/приложения замедляется, пока совсем не вырождается в бесконечное латание дыр, без существенных улучшений, но требующее постоянное увеличение штата. Так что, если кто-то предложит вам написать дополнительный код по превращению ВерблюжьихИдентификаторовДиректив в идентификаторы-директив-с-дефисами, только потому, что в JS традиционно используется одна нотация, а в CSS — другая, с ней не совместимая, то плюньте ему в лицо и воспользуйтесь универсальной_нотацией_идентификаторов, не требующей ни дополнительного кода, ни среды разработки со сложными эвристическими алгоритмами поиска соответствий.

Большой объём кода далеко не всегда означает большое число возможностей. Зачастую это следствие использования слишком многословных инструментов, переусложнённой логики и банальной копипасты (в том числе и кодогенерации). Не стоит впадать в крайности, давая всем переменным однобуквенные имена, но стоит насторожиться, видя десятки тысяч строк рисующие простую формочку на экране. Вы можете не обращать на это внимание, полагая, что вам не потребуется в нём разбираться, ограничившись лишь чтением документации; полагая, что публичного API вам хватит для любых хотелок; полагая, что багов либо нет, либо они будут быстро исправляться мейнтейнерами. Но практика показывает, что рано или поздно вам придётся лезть в эту груду кода, если не вносить изменения, то как минимум исследовать его работу. А чем больше кода, тем сложнее в нём разобраться. Но ещё сложнее разобраться в коде, изобилующем множеством абстракций. Небольшое число, грамотных абстракций позволяет значительно упростить код, но мы получаем резко противоположный эффект, когда лепим их без разбора. Фабрики, прокси, адаптеры, реестры, сервисы, провайдеры, директивы, декораторы, примеси, типажи, компоненты, контейнеры, модули, приложения, стратегии, команды, роутеры, генераторы, итераторы, монады, контроллеры, модели, отображения, модели отображения, презентаторы, шаблоны, билдеры, виртуальный дом, грязные проверки, биндинги, события, стримы… Во всём этом многообразии абстракций теряются даже опытные разработчики. А попробуйте объяснить новичку, как на таком фреймворке сделать простой компонент так, чтобы не плакать потом кровавыми слезами, глядя на то, что у него получилось.

Чем проще — тем лучше.

Сложность разработки

Сизифов труд

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

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

Реактивная архитектура значительно упрощает поддержку.

Зачастую при разработке фреймворков основное внимание уделяется таким вещам как "простота", "гибкость" и "скорость", но практически игнорируется такое немаловажное качество как "исследуемость". Предполагается, что взявшийся работать с ним разработчик уже прочитал всю документацию, понял её правильно и вообще очень одарённый человек. Но правда жизни заключается в том, что документация зачастую не полна, плохо структурирована, на не родном языке и требует очень много времени для полного изучения, которого всегда не хватает, да и разработчик зачастую не имеет за плечами десяти лет опыта и досконального знания паттернов. Поэтому лучшая документация — это примеры. Лучшие примеры — это тесты. А лучший способ понять как оно работает — разобрать. Так что важно не изолировать внутреннее состояние, а предоставлять простой и удобный доступ к нему. Через консоль, через отладчик, через логи. Необходимо именовать одни и те же сущности одинаково в разных местах: имена переменных в разных модулях, имена модулей в разных контекстах, имена классов в скриптах, стилях и вёрстке и тд. А также необходимо добавлять информацию о не очевидных связях между сущностями. Например, посмотрите на этот код:

<div class="my-panel">
    <button class="my-button_danger"></button>
</div>

Как тут можно догадаться, что оба этих элемента были добавлены в дерево компонентом my-page? Добавим недостающую информацию:

<div class="my-page_content my-panel">
    <button class="my-page_remove my-button_danger"></button>
</div>

Теперь стало понятно куда копать, и кто виноват в том, что кнопка удаления страницы находится не на той панели. Другой яркий пример связан с парсингом. Когда вы применяете JSON.parse, то теряете информацию о расположении данных в исходном файле. Поэтому, когда при последующей валидации вы обнаруживаете ошибку, то не можете сообщить пользователю "На такой-то строке обнаружен не валидный e-mail", а вынуждены изобретать костыли вида "Невалидный e-mail по пути departaments[2].users[14].mails[0]". Напротив, при использовании формата tree вы всегда можете получить из узла информацию о месте его объявления:

core.exception.RangeError@./jin/tree.d(271): Range violation
./examples/test.jack.tree#87:20 cut-tail
./examples/test.jack.tree#87:11 cut-head
./examples/test.jack.tree#88:7 body
./examples/test.jack.tree#85:6 jack
./examples/test.jack.tree#83:0 test
./examples/test.jack.tree#1:1

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

Компонентная декомпозиция

LEGO

Самым важным аспектом любого фреймворка является реализация единого протокола взаимодействия. Собственно, в случае микромодульного фреймворка, единственное, чем занимается ядро — это организация взаимодействия модулей, как стандартных, так и пользовательских. В случае ui фреймворка, основной его задачей является организация взаимодействия компонент — этаких мини приложений, которые и сами по себе могут работать, но поддерживают и некоторый API для взаимодействия с другими компонентами, позволяя собирать из них как из кубиков лего более сложные компоненты, вплоть до полноценных приложений. Давайте рассмотрим, что нам предлагают современные фреймворки...

AngularJS

AngularJS

Объявление компоненты:

angular.module( 'my' ).component( 'panel' , {
    transclude : {
        myPanelHead : '?head',
        myPanelBody : 'body'
    },
    template: `
        <div class="my-panel">
            <div class="my-panel-header" ng-transclude="head"></div>
            <div class="my-panel-bodier" ng-transclude="body">No data</div>
        </div>
    `
} )

Для объявления компоненты нам потребовалось написать немного скриптов, немного шаблонов и приложить к ним некоторый конфиг, описывающий API. Получилось весьма многословно и запутанно. Необходимо держать в голове, что ng-transclude содержит не просто текст, а имя параметра, значение которого будет вставлено внутрь элемента. Нужно внимательно поддерживать маппинг внешних имён параметров на внутренние. Нужно всем элементам расставить классы, чтобы их можно было стилизовать. А теперь попробуем воспользоваться этой компонентой:

<body ng-app="my">
    <my-panel>
        <my-panel-head>My tasks</my-panel-header>
        <my-panel-body>
            <my-task-list
                assignee="me"
                status="todo"
            />
        </my-panel-body>
    </my-panel>
</body>

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

А теперь типичная ситуация: есть готовая компонента и нам надо её слегка кастомизировать. Например, добавить подвал. Беда многих подобных фреймворков в том, что реализованные на них компоненты слишком жёсткие и изолированные. Вы не можете просто отнаследоваться от компонента и слегка поменять его поведение. Вместо этого, вам необходимо либо вносить изменения в сам исходный компонент, усложняя его и заставляя уметь слишком многое:

angular.module( 'app' ).component( 'myPanelExt' , {
    scope : {
        myPanelShowFooter : '='
    },
    transclude : {
        myPanelHead : '?head',
        myPanelBody : 'body',
        myPanelFoot : '?foot'
    },
    template: `
        <div class="my-panel" my-panel-show-footer="true">
            <div class="my-panel-header" ng-transclude="head"></div>
            <div class="my-panel-bodier" ng-transclude="body">No data</div>
            <div class="my-panel-header" ng-transclude="foot" ng-if="myPanelShowFooter"></div>
        </div>
    `
} )

Либо копипастить, создавая новый компонент очень похожий на старый, но слегка другой:

angular.module( 'app' ).component( 'myPanelExt' , {
    transclude : {
        myPanelHead : '?head',
        myPanelBody : 'body',
        myPanelFoot : '?foot'
    },
    template: `
        <div class="my-panel">
            <div class="my-panel-header" ng-transclude="head"></div>
            <div class="my-panel-bodier" ng-transclude="body">No data</div>
            <div class="my-panel-header" ng-transclude="foot"></div>
        </div>
    `
} )

ReactJS

ReactJS

Объявление компоненты:

class MyPanel extends React.Component {
    render() { return (
        <div class="my-panel">
            <div class="my-panel-header">{this.props.head}</div>
            <div class="my-panel-bodier">{this.props.body}</div>
        </div>
    ) }
}

Уже гораздо лучше, хотя и осталась по прежнему неотделимая завязка на JS. Почему это плохо? Потому, что не везде JS является оптимальным языком программирования. В вебе у вас нет выбора. Но под iOS лучше бы подошёл Swift или ObjectiveC, под Android — Java, а под десктопы выбор языков вообще огромен, но на JS свет клином не сошёлся. По прежнему мы имеем проблему жёсткости и изолированности компоненты, так что с кастомизацией всё почти так же плохо, как и в AngularJS. "Почти", потому, что мы можем расчленить наш шаблон на мелкие кусочки, что позволит в дальнейшем их переопределять:

class MyPanel extends React.Component {
    header() { return <div class="my-panel-header">{this.props.head}</div> }
    bodier() { return <div class="my-panel-bodier">{this.props.body}</div> }
    childs() { return [ this.header() , this.bodier() ] }
    render() { return <div class="my-panel">{this.childs()}</div>
}

class MyPanelExt extends MyPanel {
    footer() { return <div class="my-panel-footer">{this.props.foot}</div> }
    childs() { return [ this.header() , this.bodier() , this.footer() ] }
}

Мы получили неплохую гибкость, но потеряли наглядность иерархии элементов. А использование XML синтаксиса в этом случае становится излишним. Особенно экстравагантно выглядело бы использование компоненты без расчленения на функции:

class MyApp extends MyPanel {
    render() { return (
        <MyPanel
            head="My Tasks"
            body={
                <MyTaskList
                    assignee="me"
                    status="todo"
                />
            }
        />
    ) }
}

Polymer

PolymerJS

Объявление компоненты может целиком и полностью быть в рамках HTML:

<polymer-element name="my-panel">
    <template>
        <div class="header">
            <content select="[my-panel-head]" />
        </div>
        <div class="bodier">
            <content />
        </div>
    </template>
</polymer-element>

Бросается в глаза получение параметров через селекторы, что делает использование компоненты весьма не наглядным:

<link rel="import" href="../../my/panel/my-panel.html">
<polymer-element name="my-app">
    <template>
        <my-panel>
            <div my-panel-head>My tasks</div>
            <my-task-list
                assignee="me"
                status="todo"
            />
        </my-panel>
    </template>
</polymer-element>

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

Наследование реализуется тем же хаком:

<link rel="import" href="../../my/panel/my-panel.html">
<polymer-element name="my-panel-ext">
    <template>
        <shadow select=".header" />
        <shadow select=".bodier" />
        <div class="footer">
            <content select="[my-panel-foot]" />
        </div>
    </template>
</polymer-element>

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

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

Сферический идеальный фреймворк

$mol

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

Как показано выше, XML совсем не удовлетворяет этим требованиям. Вообще, все попытки сделать из XML шаблонизатор выглядят как помесь ужа с ежом. То XML встраивается в JS, то JS встраивается в XML, а то и изобретается свой примитивный язык программирования с XML-синтаксисом.

Давайте воспользуемся tree-синтаксисом для объявления простой компоненты:

$my_header : $mol_view

Тут мы просто говорим, что компонента $my_header — наследник от $mol_view. Нас не волнует как и на каком языке реализована $mol_view, но мы утверждаем, что $mol_header должна быть реализована точно так же. Например, приведённое выше описание, может развернуться в следующее DOM-дерево:

<div id="$my_header.app()" my_header mol_view></div>

Как можно заметить, для элемента были автоматически сгенерированы некоторые атрибуты. Прежде всего это id — глобальный идентификатор компоненты. Он не зря имеет такой странный вид, ведь его можно скопировать и вставить в консоль, получив тем самым прямой доступ к инстансу компоненты, за этот элемент ответственной. Это очень сильно упрощает отладку и исследование чужого кода. Далее идут атрибуты, предназначенные прежде всего для стилизации. Вы можете определить стилизацию для базового компонента, а потом перегрузить её для наследника:

[mol_view] {
    animation : mol_view_showing 1s ease;
}
[my_header] {
    animation : none;
}

Как видите, нам не потребовался даже css препроцессор, чтобы реализовать наследование в CSS. То есть, для декларации наследования у нас есть только одно место в коде — это описание компоненты. По этому описанию также может быть сгенерирован и TypeScript-класс для этой компоненты:

module $mol {
    export class $my_header extends $mol_view {
    }
}

Компонент $mol_view может предоставлять стандартное свойство childs для определения списка дочерних узлов. Поэтому давайте добавим возможность объявления и перегрузки свойств:

$my_panel : $mol_view
    head : null
    body =No data
    header : $mol_view
        childs < head
    bodier : $mol_view
        childs < body
    childs
        < header
        < bodier

Тут мы объявили свойства head и body, представляющие собой содержимое шапки и тела панели. После имени мы указали значение по умолчанию. Чтобы компонента была самодостаточной, значения по умолчанию должны быть всегда. Далее, мы определили свойства header и bodier, значения которых по умолчанию являются инстансами $mol_view с перегруженным свойством child — оно будет замещено соответствующим свойством из определяемой компоненты. Соответственно, при использовании панели мы можем переопределить любое из этих свойств, достигнув тем самым очень высокой гибкости:

$my_app : $my_panel
    head : =My tasks
    body : $my_task_list
        assignee : =me
        status : =todo

Однако, как и в случае с ReactJS, мы потеряли структуру. Но заметим, что после имени свойства при биндинге мы можем тут же указать и значение по умолчанию для него:

$my_panel : $mol_view childs
    < header : $mol_view childs < head : null
    < bodier : $mol_view childs < body =No data

В результате у нас получилось довольно компактное описание компоненты. При этом каждый вложенный элемент получил уникальное имя, которое можно использовать для генерации bem-like атрибутов:

<div id="$my_panel.app()" my_panel mol_view>
    <div id="$my_panel.app().header()" mol_view my_panel_header></div>
    <div id="$my_panel.app().bodier()" mol_view my_panel_bodier></div>
</div>

В итоге мы сохранили иерархию, не потеряв в гибкости. Создадим вариант панельки с подвалом:

$my_panelExt : $my_panel childs
    < header
    < bodier
    < footer : $mol_block childs < foot : null

Но что если нам нужна динамика? Например, показывать подвал в зависимости от какого-либо условия. Нет ничего лучше для описания логики, чем язык программирования. Поэтому воспользуемся TypeScript, чтобы добавить логику к декларативному описанию компоненты:

@ $mol_replace // перегружаем исходный класс
class $my_panel extends $mol.$my_panel {

    footerShowing() { return this.persist<boolean>('footerShowing') }

    childs(){ return this.prop( () => [
        this.header().get() ,
        this.bodier().get() ,
        this.footerShowing.get() ? this.footer().get() : null ,
    ] ) }

}

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

$my_panel_demo : $mol_view childs
    < simple : $mol_panel
        footerShowing : false
        body =I am simple panel
    < footered : $mol_panel
        footerShowing : true
        body =I am panel with footer

А так как это описание в конечном счёте будет транслировано в typescript класс, то мы автоматом получим и статическую типизацию. И хотя IDE без специальных плагинов всё же не сможет вывести контекстные подсказки во view.tree коде, компилятор, тем не менее, проверит, что мы не используем необъявленные свойства, передаём им правильные типы значений и тп.

Производительность

В статьях про каждый второй фреймворк пишут, что он разрабатывается с прицелом на производительность, и что тот чуть ли не самый быстрый из всех. Иногда даже приводят в доказательство какие-нибудь синтетические тесты. Как правило код в таких тестах весьма далёк от того, что можно было бы встретить в реальном приложении. В погоне за попугаями теряется простота, гибкость, компонуемость, настраиваемость. Не редко можно встретить чуть ли не полный отказ от возможностей фреймворка ради скорости. Результат такого надругательства над кодом — производительность, которую можно достигнуть приложив знания, умения и время. Но куда важнее не максимальная скорость, которую можно достичь, а минимальная, которую вы получите написав идиоматичный для данного фреймворка код. Чем выше минимальная скорость, тем реже вам придётся браться за напильник, тем менее квалифицированные (и как следствие — менее дорогие) требуются разработчики, тем быстрее идёт разработка. Поэтому фреймворк, на котором сложно сделать медленно, предпочтительнее фреймворка, на котором можно сделать быстро.

Типичный jQuery рендеринг выглядит так: берём шаблон, генерируем новый HTML подставляя в него данные, вставляем в дерево, навешиваем события. Проблема этого подхода в том, что даже малейшее изменение данных требует повторного рендеринга всего шаблона. Это не заметно на малых объёмах данных, но рано или поздно данных становится много и всё начинает тормозить. А что более существенно, при ререндеринге сбрасываются скроллинги, введённые в поля значения и тому подобные вещи. Представьте, что вы пришли побриться, а цирюльник убивает вас, берёт ДНК и выращивает вашего клона без бороды. Решается это обычно вручную — находим нужные элементы и патчим их, что приводит к большому объёму очень хрупкого кода.

Всё, что делает ReactJS — это ускоряет рендеринг всего шаблона за счёт того, что рендерит его не напрямую в DOM дерево, а в промежуточное дерево JS объектов, которые уже сравниваются с реальным деревом и при наличии расхождения точечно меняет DOM. Уже лучше, конечно, но всё равно куча лишней работы: цирюльник берёт вашу ДНК, выращивает клона без бороды, раздевает вас обоих до гола и, педантично сравнивая волосяной покров, с хирургической точностью делает вас похожим на вашего клона, после чего клона отправляют в биореактор. Похожим образом поступает AngularJS с той лишь разницей, что сравнивает он не ваш клон и вас самих, а фотографии какого-то мужика вчера и сегодня, и если обнаруживает, что у того пропала борода, то сбривает её и вам. А что творится в Polymer после 100500 транспиляций и представить сложно.

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

Но вернёмся к бенчмаркам. Производительность всё же надо контролировать. Причём важна производительность не отдельных подсистем в рафинированных условиях, а общая производительность итогового приложения. Поэтому мы воспользуемся для тестирования широко известной коллекцией одного и того же приложения на разных фреймворках — ToDoMVC. Так как основная задача этого проекта — демонстрация идиоматичного кода на разных фреймворках, то он отлично подходит для интегральной оценки их производительности. По сети разбросано множество версий бенчмарка с ToDoMVC приложениями, поэтому пришлось попотеть, собирая из разных форков один ToDoMVC Benchmark.

Производительность web-ui-фреймворков

Каждое приложение прогоняется через 3 теста:

  1. Создание 100 задач через поле создания задач. Каждая следующая задача создаётся через setTimeout, что не позволяет фреймворку срезать путь и схлопнуть 100 операций рендеринга в одну. Визуально это выглядит как постепенное разрастание списка задач от 0 до 100. Этот шаг показывает насколько дорого фреймворку обходится формирование интерфейса.
  2. Завершение всех задач, нажатием соответствующей кнопки. Этот шаг, наоборот, показывает насколько фреймворк умеет схлопывать множественные операции над моделью в одну операцию рендеринга, что важно, при массовом обновлении данных. Визуально это выглядит как зачёркивание всех задач.
  3. Удаление задач по одной, нажатиями на кнопки удаления задачи. Тут опять используется setTimeout для предотвращения схлопывания 100 операций рендеринга в одну. Этот шаг показывает эффективность фреймворка по уничтожению интерфейса. Визуально это выглядит как постепенное исчезновение задач сверху списка. Такое удаление задач гарантирует, что все строки рано или поздно будут отрендерены, даже если фреймворк рендерит лишь компоненты, попадающие в видимую область.

Также стоит отметить, что каждое приложение прогоняется ровно один раз в отдельном фрейме, что не позволяет ему кешировать какие-либо объекты.

Особенности этой версии бенчмарка:

  1. Защита от схлопывания рендеринга на первом и третьем шаге, но провоцирование на втором.
  2. Вывод гистограммы в конце замеров. Подробности по времени выполнении каждого шага можно найти в консоли.
  3. Автоматический подхват всех добавленных в ToDoMVC приложений. Те, которые с бенчмарком работают не корректно (около половины из всех), занесены в чёрный список, который можно найти в коде страницы. Патчи к этим решениям или к самому бенчмарку для совместимости с ними — приветствуются.
  4. Чекбоксами можно включать и выключать отдельные приложения. Состояние чекбоксов запоминается.

А теперь уберём из тестирования совсем аутсайдеров и увеличим число задач вдвое:

Производительность web-ui-фреймворков

Ожидаемым является увеличение времени работы не более чем в 2 раза. Однако лишь немногим решениям удалось добиться такой пологой динамики. Как правило замедление составило порядка 2.5, а в некоторых случаях даже превысило 3.

Неправильный выбор фреймворка может замедлить ваше приложение более чем в 10 раз.

Призыв к действию

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

Что уже есть

Что ещё предстоит сделать

  • Переписать сборщик. Сейчас он написан на коленке и переусложнён.
  • Покрыть код тестами. Куда уж без них.
  • Заредизайнить существующие компоненты и продумать какие ещё необходимы.
  • Написать документацию.
  • Создать симпатичную домашнюю страничку.

Основные характеристики

  • Минимум абстракций и соглашений.
  • Статическая типизация.
  • Реактивная архитектура.
  • Высокая эффективность.
  • Компактный размер.
  • Простота компоновки.
  • Высокая кастомизируемость.
  • Высокая исследуемость.

Автор: vintage

Источник

Поделиться новостью

* - обязательные к заполнению поля