Правила выбора JS-фреймворка

в 14:04, , рубрики: effcetor, html, isomorphic, javascript, ReactJS, RiotJS, Universal, universal-router, Клиентская оптимизация, Разработка веб-сайтов

TL;DR

  • В статье не рассматриваются JS-фреймвёрки из списка TOP-3
  • При разработке на JS-фреймвёрке не из списка TOP-3 приходится решать на порядок больше технических вопросов, чем это ожидается в начале разработки
  • История основана на реальных событиях


История началось с одного мини-проекта, который изначально разрабатывался на основе библиотек backbone.js и marionette.js. Это, конечно, великие библиотеки, с которых начиналась история разработки одностраничных приложений. Но уже в то время они представляли, скорее, историческую, чем практическую ценность. Упомяну лишь тот факт, что для отображения простой таблицы нужно было создать: 1) модуль с описанием модели, 2) модуль с описанием коллекции, 3) модуль с определением вью модели, 4) модуль с определением вью коллекции, 4) шаблон строки таблицы, 5) шаблон таблицы, 6) модуль контроллера. Имея в небольшом приложении около 10 сущностей — у Вас уже на самом начальном этапе было более полусотни мелких модулей. И это только начало. Но сейчас не об этом.

В какой-то момент, после полугода работы приложения, оно все еще не появилось в поисковой выдаче. Добавление в проект prerender.io (который тогда использовал движок phantom.js) помогло, но не так существенно, как ожидалось. И над приложением, и надо мной начали сгущаться тучи, после чего я понял что нужно сделать что-то очень быстро и эффективно, желательно сегодня. Цель я поставил такую: перейти на серверный рендеринг. C backbone.js и marionettejs это сделать практически невозможно. Во всяком случае, проект rendr на backbone.js, который разрабатывался под руководством Spike Brehm (автора идеи изоморфных/универсальных приложений), собрав 58 контрибьюторов и 4184 лайков на github.com, был остановлен в 2015 году, и явно не предназначался для однодневного блица. Я начал искать альтернативу. TOP-3 JS-фреймворка я исключил из рассмотрения сразу, так как не имел запаса времени на их освоение. После недолгих поисков я нашел бурно в то время развивающийся JS-фреймворк riot.js github.com/riot/riot, который на сегодняшний день имеет 13704 лайков на github.com, и, как я надеялся, вполне мог бы со временем выйти на первые позиции (чего, однако, не произошло).

И да, вопрос я закрыл (правда не в тот же день, а за два последующих дня дня) переводом приложения на серверный рендеринг с применением riot.js. И в тот же день, когда это произошло, сайт переместился в первый десяток страниц поисковой выдачи. И в течение недели вышел на первую страницу, откуда не уходит уже несколько лет.

На этом история успеха заканчивается и начинается история поражений. Следующий проект был существенно сложнее. Правда был положительный момент в том что 99% экранов приложения находились в личном кабинете пользователя, поэтому не было необходимости в серверном рендеринге. Окрыленный первым успешным опытом применения riot.js, я начал продвигать идею закрепить успех и применить на фронтенде riot.js. Тогда мне казалось, что, наконец, найдено решение которое совмещало простоту и функциональность, как это и обещала документация riot.js. Как же я ошибался!

С первой проблемой я столкнулся, когда нужно было обеспечить верстальщика HTML-документов всеми необходимыми инструментами для разработки. В частности, нужны были плагины для редактора кода, движок, в котором можно было бы размещать компоненты и сразу наблюдать полученный результат, в том числе, с горячей перегрузкой компонентов (hot-reload). Все это нужно было в готовом к промышленной эксплуатации виде отдать в ближайшее время, и всего этого не было. В результате, верстка приложения началась на одном из традиционных шаблонизаторов, что в результате привело к неблагодарному этапу перевода HTML-документов в компоненты riot.js.

Однако, главной проблемой, которая выявилась на этом этапе, связана даже не с переводом верстки из формата шаблонов в компоненты riot.js. Неожиданно выяснилось, что riot.js выдает совершенно неинформативные сообщения об ошибках компиляции шаблонов, равно как и об ошибках времени выполнения (это справедливо для всех версий riot.js вплоть до версии 4.0, которая была полностью переработана). Не было информации не только о строке в которой произошла ошибка, а даже об имени файла или компонента, в котором произошла ошибка. Искать ошибку можно было много часов подряд, и все равно ее не найти. И тогда приходилось откатывать все изменения до последнего рабочего состояния.

Следующей подкатила проблема с роутингом. Роутинг в riot.js идет почти из коробки github.com/riot/route — во всяком случая от того же самого разработчика. Это позволяло надеяться на беспроблемную его работу. Но в какой-то момент я обратил внимание, что некоторые страницы непредсказуемо перегружаются. То есть один раз переход на новый роут может произойти в режиме одностраничного приложения, а в другой раз тот же самый переход перегружал полностью HTML-документ, как при работе с классическим веб-приложением. При этом, естественно, терялось внутреннее состояние, если оно еще не было сохранено на сервере. (В настоящее разработка этой библиотеки остановлена и с версией riot.js 4.0 оне не используется).

Единственный компонент системы который работал, как ожидалось, был минималистичный flux-подобный менеджер состояний github.com/jimsparkman/RiotControl. Правда для работы с этим компонентом приходилось назначать и отменять слушателей изменения состояние гораздо чаще, чем этого хотелось бы.

Первоначальный замысел этой статьи был такой: показать на примере собственного опыта работы с фреймворком riot.js задачи, которые придется решать разарботчику, решившему (решившимуся) разрабатывать приложение на JS-фреймворке не из списка TOP-3. Однако, в процессе подготовки я решил освежить в памяти некоторые страницы из документации riot.js, и так узнал, что вышла новая версия riot.js 4.0, которая полностью (с чистого листа) была переработана, о чем можно прочитать в статье разработчика riot.js на medium.com: medium.com/@gianluca.guarini/every-revolution-begins-with-a-riot-js-first-6c6a4b090ee. Из этой статьи я узнал что все основные проблемы, которые меня волновали, и о которых я собирался рассказать в этой статье, были устранены. В частности, в riot.js версии 4.0:

  • полностью переписан компилятор (вернее впервые написан т.к. раньше движок работал на регулярных выражениях) — это повлияло, в частности на информативность сообщений об ошибках
  • в дополнение в серверному рендерингу была добавлена гидрация (hydrate) на клиенте — это позволило, наконец-то, начать писать универсальные приложения без двойного рендеринга (первый раз на сервере и тут же на клиенте из-за отсутствия в старых версиях функции hydrate())
  • добавлен плагин для горячей перегрузки компонентов github.com/riot/hot-reload
  • и много других полезных изменений

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

К сожалению, проделанная разработчиками riot.js работа еще не была должным образом оценена сообществом. Например, библиотека серверного рендеринга github.com/riot/ssr за полгода прошедшие с начала ее разработки собрала трех котрибьюторов и три лайка на github.com (не все лайки сделаны контрибьюторами, хотя один таки контриьбютором).

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

Итак, начинаем. Для примера было сделана реализация приложения github.com/gothinkster/realworld. Это проект уже не раз обсуждался на Хабре. Для тех кто еще не знаком с ним, коротко опишу его идею. Разработчики этого проекта при помощи разных языков программирования и фреймворков (или без них) решают одну и ту же задачу: разработка движка блогов, по функциональности похожего на упрощенную версию medium.com. Это компромисс между между сложностью реальных приложений, которые нам приходится ежедневно разрабатывать, и todo.app, которое не всегда позволяет реально оценить работу с библиотекой или фреймворком. Это проект пользуется уважением среди разработчиков. В подтверждение сказанного могу сказать, что есть даже одна реализация от Rich Harris (мажорного разработчика sveltejs) github.com/sveltejs/realworld.

Среда разработки

Вы конечно готовы ринуться в бой, но подумайте об окружающих Вас разработчиках. В данном случае сконцентрируйтесь на вопросе, в какой среде разработки работают Ваши коллеги. Если для фреймворка, с которым Вы собираетесь работать, нет плагинов для основных сред разработки и редакторов программного кода — то Вас вряд ли поддержат. Я, например, для разработки использую редактор Atom. Для него есть плагин riot-tag github.com/riot/syntax-highlight/tree/legacy, который не обновлялся последние три года. И в этом же репозитарии есть плагин для sublime github.com/riot/syntax-highlight — он актуальный и поддерживает актуальную версию riot.js 4.0.

Впрочем, компонент riot.js это валидный фрагмент HTML-документа, в котором JS-код содержится в теле элемента script. Так что все просто работает, если Вы добавите для расширения *.riot тип документа html. Разумеется, это вынужденное решение, так как иначе здесь продолжать дальше было бы просто невозможно.

Подсветку синтаксиса в текстовом редакторе мы получили, и теперь нам нужен более продвинутый функционал, то что мы привыкли получать от eslint. В нашем случае JS-код компонентов содержится в теле элемента script, я надеялся найти и нашел плагин для извлечение JS-кода из HTML-документа — github.com/BenoitZugmeyer/eslint-plugin-html. После этого моя конфигурация eslint стала выглядеть так:

{
  "parser": "babel-eslint",
    "plugins": [
      "html"
    ],
  "settings": {
    "html/html-extensions": [".html", ".riot"]
  },
  "env": {
    "browser": true,
    "node": true,
    "es6": true
  },
  "extends": "standard",
  "globals": {
    "Atomics": "readonly",
    "SharedArrayBuffer": "readonly"
  },
  "parserOptions": {
    "ecmaVersion": 2018,
    "sourceType": "module"
  },
  "rules": {
  }
}

Наличие плагинов для подсветки синтаксиса и eslint — наверное, не самое первое, о чем начинает думать разработчик выбирая JS-фреймворк. Между тем, без этих инструментов Вы можете столкнуться с противодействием коллег и с их массовым бегством по «уважительным» причинам с проекта. Хотя единственной и действительно уважительной причиной является, то что им некомфортно работать, не имея полного арсенала разработчика. В случае с riot.js проблема была решена способом Колумба. В том смысле, что на самом деле плагинов для riot.js нет, но благодаря особенностям синтаксиса шаблонов riot.js, который выглядит как фрагмент обычного HTML-документа, мы покрываем 99% нужной функциональности, задействуя инструменты для работы с HTML-документом.

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

Сборка проекта

К большинству фич, которые нужны при сборке проекта мы уже успели привыкнуть, и даже перестаем задумываться о том, что может быть иначе. Но иначе быть может. И, если Вы выбрали новый JS-фреймворк, то желательно предварительно убедиться, что все работет как Вы и ожидаете. Например, как я уже упоминал, наибольшая проблемы при разработке на старых версиях riot.js была связана с отсутствием в сообщениях об ошибках компиляции и времени выполнения информации о компоненте, в котором эта ошибка произошла. Также важной является скорость компиляции. Как правило, для ускорения скорости компиляции, в правильно построенных фреймворках перекомпилируется только изменившаяся часть, в результате время реакции на изменения текста компонентов минимальны. Ну и совсем хорошо, если поддерживается горячая перезагрузка компонентов без полной перезагрузки страницы в веб-браузере.

Поэтому постараюсь перечислить чек-лист, на что нужно особо обратить внимание при анализе средств сборки проекта:

1. Наличие режима разработки и рабочего приложения
В режиме разработчика:
2. Информативные сообщения об ошибках компиляции проекта (имя исходного файла, номер строки в исходном файле, описание ошибки)
3. Информативные сообщения об ошибках времени выполнения (имя исходного файла, номер строки в исходном файле, описание ошибки)
4. Быстрая пересборка измененных модулей
5. Горячая перегрузка компонентов в браузере
В рабочем режиме:
6. Наличие версионности в имени файлов (например 4a8ee185040ac59496a2.main.js)
7. Компоновка мелких модулей в один или несколько модулей (чанков)
8. Разбиение кода на чанки с использованием динамического импорта

В riot.js версии 4.0 полявился модуль github.com/riot/webpack-loader, котрый полностью соответсвует приведенному чеклисту. Я не буду перечислять все особенности конфигурации сборки. Единственное на что обращу внимание, что в рассматриваемом проекте я применяю модули для express.js: webpack-dev-middleware и webpack-hot-middleware, которые позволяют сразу, с момента верстки, работать на полнофункциональном сервере. Это, в частности, позволяет разрабатывать универсальные/изоморфные веб-приложения. Обращу Ваше внимание, что модуль горячей перегрузки компонентов действует только для веб-браузера. В то же время, компонент, отрендеренный на стороне сервера, остается неизменным. Поэтому необходимо прослушивать его изменения, и в нужный момент удалить весь кэшированный сервером код и загрузить код измененный модулей. Как это сделать долго описывать, поэтому только приведу ссылку на реализацию: github.com/apapacy/realworld-riotjs-effector-universal-hot/blob/master/src/dev_server.js

Роутинг

Немного перефразируя Льва Николаевича Толстого, можно сказать, что все движки JS-фреймворков похожи друг на друга, в то время, как все роутинги, которые к ним прилагаются работают по-своему. Я часто встречаю условную классификацию роутеров на два типа: декларативные и императивные. Сейчас я попробую разобраться насколько такая классификация обоснована.

Проведем небольшой экскурс в историю. На заре интернета URL/URI соответствовали имени файла, который хостится на сервере. Перевернем сразу несколько страниц истории и мы узнаем о появлении Model 2 (MVC) Architecture. В этой архитектуре появляется фронт-контроллер, который выполняет функцию роутинга. Я задался вопросом, кто первый решил из фронт-контроллера выделить функцию роутинга в отдельный блок, который дальше отправляет запрос на один из множетсва контроллеров и пока не нашел ответа. Такое впечатление что это начали делать все и сразу.

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

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

… позволяет легко создавать SPA-приложения. Включает следующие возможности

  • Вложенные маршруты/представления
  • Модульная конфигурация маршрутизатора
  • Доступ к параметрам маршрута, query, wildcards
  • Анимация переходов представлений на основе Vue.js
  • Удобный контроль навигации
  • Автоматическое проставление активного CSS класса для ссылок
  • Режимы работы HTML5 history или хэш, с автопереключением в IE9
  • Настраиваемое поведение прокрутки страницы

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

  1. должен одинаково работать как на стороне веб-клиента, так и на стороне веб-сервера для универсальных/изоморфных веб-приложений;
  2. должен работать с любым (в том числе и выбранным мною) фреймворком или без него.

И такую библиотеку я нашел, это github.com/kriasoft/universal-router. Если в двух словах описать идею этой библиотеки, то она конфигурирует роуты, которые на входе принимают строку URL, а на выходе вызывают асинхронную функцию, которой передают разобранный URL в виде фактического параметра. Честно говоря, хотелось спросить: и это все? И как дальше с этим всем нужно работать? И тут я нашел статью на medium.com medium.com/@ippei.tanaka/universal-router-history-react-97ec79464573, в которой был предложен достаточно хороший вариант, за исключением пожалуй переписывания метода push() history, который бал совершенно не нужен и который я из своего кода удалил. В результате работа роутера на стороне клиента определяется примерно так:

const routes = new UniversalRouter([
  { path: '/sign-in', action: () => ({ page: 'login', data: { action: 'sign-in' } }) },
  { path: '/sign-up', action: () => ({ page: 'login', data: { action: 'sign-up' } }) },
  { path: '/', action: (req) => ({ page: 'home', data: { req, action: 'home' } }) },
  { path: '/page/:page', action: (req) => ({ page: 'home', data: { req, action: 'home' } }) },
  { path: '/feed', action: (req) => ({ page: 'home', data: { req, action: 'feed' } }) },
  { path: '/feed/page/:page', action: (req) => ({ page: 'home', data: { req, action: 'feed' } }) },
  ...
  { path: '(.*)', action: () => ({ page: 'notFound', data: { action: 'not-found' } }) }
])

const root = getRootComponent()

const history = createBrowserHistory()

const render = async (location) => {
   const route = await router.resolve(location)
   const component = await import(`./riot/pages/${route.page}.riot`)
   riot.register(route.page, component.default || component)
   root.update(route, root)
}

history.listen(render)

Теперь при любом вызове history.push() будет инициироваться роутинг. Для навигации внутри приложения также нужно создать компонент, оборачивающий стандартный HTML-элемент a (anchor), не забывая отменить его поведение по умолчанию:

<navigation-link href={ props.href } onclick={ action }>
  <slot/>
  <script>
    import history from '../history'
    export default {
      action (e) {
        e.preventDefault()
        history.push(this.props.href)
        if (this.props.onclick) {
          this.props.onclick.call(this, e)
        }
        e.stopPropagation()
      }
    }
  </script>
</navigation-link>

Управление состоянием приложения

Изначально, я включил в проект библиотеку mobx. Все работало как и ожидалось. За исключением того, что не вполне соответствовала задаче — исследование, которую я поставил в начале статьи. Поэтому я переключился на github.com/zerobias/effector. Это очень мощный проект. Он дает 100% от функциональности redux (только без больших накладных расходов) и 100% функциональности mobx (хотя в этом случае кодировать нужно будет немного больше, впрочем все же меньше, если сравнивать с mobx без декораторов)

Выглядит описание стора примерно так:

import { createStore, createEvent } from 'effector'
import { request } from '../agent'
import { parseError } from '../utils'

export default class ProfileStore {
  get store () {
    return this.profileStore.getState()
  }

  constructor () {
    this.success = createEvent()
    this.error = createEvent()
    this.updateError = createEvent()
    this.init = createEvent()
    this.profileStore = createStore(null)
      .on(this.init, (state, store) => ({ ...store }))
      .on(this.success, (state, data) => ({ data }))
      .on(this.error, (state, error) => ({ error }))
      .on(this.updateError, (state, error) => ({ ...state, error }))
  }

  getProfile ({ req, author }) {
    return request(req, {
      method: 'get',
      url: `/profiles/${decodeURIComponent(author)}`
    }).then(
      response => this.success(response.data.profile),
      error => this.error(parseError(error))
    )
  }

  follow ({ author, method }) {
    return request(undefined, {
      method,
      url: `/profiles/${author}/follow`
    }).then(
      response => this.success(response.data.profile),
      error => this.error(parseError(error))
    )
  }
}

В этой библиотеке используются полноценные редьюсеры (они так в документации effector.js и называются), которых многим не хватает в mobx, но с разительно меньшими усилиями по кодированию, по сравнению с redux. Но главное даже не это. Получив 100% от функциональности redux и mobx, я использовал только десятую часть того функционала, который заложен в effector.js. Из чего можно сделать вывод, что его применение в сложных проектах может существенно обогатить средства разработчиков.

Тестирование

TODO

Выводы

Итак, работа завершена. Результат представлен в репозитарии github.com/apapacy/realworld-riotjs-effector-universal-hot и в этой статье на Хабре.
Демо сайт на now realworld-riot-effector-universal-hot-pnujtmugam.now.sh

И в завершении поделюсь своими впечатлениями отразработки. Разрабатывать на riot.js версии 4.0 достаточно удобно. Многие конструкции записываются проще чем в том же React. На разработку ушло ровно две недели без фанатизма в послерабочее время и в выходные дни. Но… Одно маленькое но… Чудо опять не произошло. Серверный рендеринг в React работает в 20-30 раз быстрее. Корпорации опять побеждают. Впрочем опробованы в работе две интересные библиотеки роутинга и менеджера состояний.

apapacy@gmail.com
17 июня 2019г.

Автор: apapacy

Источник


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


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js