В данной статье вы познакомитесь с Marko.js актуальной на данный момент пятой версии. Пару лет назад на Хабре уже была отличная статья (за авторством apapacy) о том, как работает этот замечательный реактивный фреймворк, разработанный где-то в недрах eBay.
Что такое Marko.js?
Marko.js - это реактивный веб-фреймворк, который позволяет заниматься современной разработкой фронтенд-части вашего сайта или приложения, используя Javascript или TypeScript. По возможностям его в какой-то мере можно сравнить с React. Если кратко, то среди преимуществ Marko - быстрота, простота, легковесность, возможность создания SPA (Single Page Application), SSR (Server-Side Rendering) или изоморфных приложений (объединяющих оба подхода), и многое другое. Недостатков не так много; основным можно считать то, что он не настолько популярен и распространен, как React, Angular или Vue.
В своем комментарии (а это был далекий 2020 год) я предложил написать на Хабр статью, посвященную моему опыту работы с Marko, и вот - как с тем самым котом - время наконец пришло :-)
С 2019 года я использовал Marko.js:
как основу для большого веб-фреймворка ZOIA, на котором уже работает достаточно большое количество сайтов и сервисов;
как UI для десктопных приложений на Electron;
и, наконец, не так давно сделал простой, но функциональный boilerplate для удобного создания SPA, названный ZSPA (ZOIA Single Page Application) - о ��ем и пойдет речь в этой статье.
Почему статья не о "большой" ZOIA?
Проект ZOIA активно развивается, и с 2019 года там сделано уже очень много. Но до того, чтобы написать полноценную документацию и допилить тесты, руки все никак не дойдут. Плюс на ZOIA в основном работают "закрытые" или интранет-проекты, а времени на то, чтобы довести до ума сайт и сконфигурировать регулярно обновляемую демку не находится, поэтому для для знакомства с Marko лучше подойдет описание намного более простого boilerplate'а ZSPA.
Итак, прежде всего - зачем все это нужно? Все чаще и чаще стала появляться необходимость делать простые сайты, где не требуется серверная логика - такие, как "одностраничники", лендинги и прочие "сайты-визитки". Есть тысяча и один способ сделать что-то подобное, почему бы не появиться ещё одному? При этом хотелось бы, чтобы новоиспеченный "велосипед" удовлетворял следующим требованиям:
разработка с использованием компонентов (чтобы можно было их переиспользовать);
высокая скорость загрузки и рендеринга (максимально разбивать всё на чанки и загружать только то, что нужно, используя как возможности современного HTTP протокола, так и старый добрый gzip);
минимальный размер файлов, никаких лишних мегабайтов ненужных библиотек;
на выходе должна быть только и исключительно статика, т.е. возможность хостить получившийся сайт буквально в утюге или одноплатнике;
встроенная интернационализация и роутинг.
Как мне кажется, получилась достаточно красивая реализация перечисленных выше запросов. Для желающих посмотреть на демку - велком, ну а дальше я расскажу, как воспользоваться всем эти великолепием и что для этого потребуется.
Первым делом вам потребуется клонировать репозиторий с GitHub:
git clone https://github.com/xtremespb/zspa.git
Дальше все стандартно, устанавливаем пакеты NPM, которые необходимы для сборки:
cd zspa && npm i
После чего вы можете запустить процесс сборки, для чего существуют два варианта - режим разработчика (build-dev), который работает быстрее, и режим продакшна (build-production), который максимально оптимизирует все ресурсы:
npm run build-production
В директории dist вы получите готовый сайт, который можно открыть через index.html.
Для того, чтобы кастомизировать содержимое, потребуется забраться "под капот" ZSPA, и далее я подробно расскажу, как это сделать, заодно выполню обещание, данное в начале статьи, и познакомлю вас с Marko ;-)
Конфигурация
Сборка ZSPA осуществляется при помощи Webpack 5, а все исходники находятся в директориях etc и src. Начнем с файлов конфигурации, находящихся в etc, там их несколько:
routes.json
Здесь необходимо разместить роуты, которые используются для навигации по страницам. Под капотом используется библиотека router5, соответственно, подсмотреть синтаксис можно в документации. Но в целом, все понятно интуитивно, и для двух страниц из "демки" используется следующая конфигурация:
[{ "name": "home", "path": ":language<([a-z]{2}-[a-z]{2})?>/", "defaultParams": { "language": "" } }, { "name": "license", "path": ":language<([a-z]{2}-[a-z]{2})?>/license", "defaultParams": { "language": "" } }]
Важным элементом пути (path) является параметр :language, который используется для корректной работы интернационализации, поэтому не следует забывать о нем.
navigation.json
В этим файле размещается конфигурация, которая используется при рендеринге navbar'а - верхней "менюшке", которая используется для перехода между страницами. Формат этот файла также интуитивно понятен:
{ "defaultRoute": "home", "routes": ["home", "license"] }
В массиве routes перечислены все роуты, которые должны отображаться в меню навигации, а в defaultRoute - роут по-умолчанию.
languages.json
Файл необходим для корректной работы интернационализации и представляет собой перечисление доступных для переключения языков:
{ "en-us": "English", "ru-ru": "Русский" }
Каждый идентификатор представлен в формате xx-xx для возможности работы с различными языковыми вариантами. Первый язык в этом списке является также языком "по-умолчанию".
translations
В данной директории содержатся файлы с языковыми константами, используемыми для перевода. Например, локаль русского языка (ru-ru.json) выглядит следующим образом:
{ "title": "ZSPA", "home": "Главная", "license": "Лицензия" }
Каждый раз, когда вы создаете новую страницу и новый роут для нее, вам необходимо соответствующим образом добавлять в файлы интернационализации ключи для роутов. Допустим, вы добавили новую страницу, создали роут habr, в этом случае в файлы ru-ru.json, en-us.json и т.д. необходимо добавить новый ключ:
"habr": "Хабрхабр"
Директория translations/core содержит файлы перевода, используемые системой, и трогать их не обязательно.
i18n-loader.js
Данный скрипт используется для динамической загрузки файлов интернационализации. Оператор switch используется для выбора между языками и импорта необходимых языков по запросу. Чтобы Webpack смог правильно разбить код на чанки, необходимо при импорте указать соответствующий комментарий:
translationCore = await import(/* webpackChunkName: "lang-core-en-us" */ `./translations/core/en-us.json`); translationUser = await import(/* webpackChunkName: "lang-en-us" */ `./translations/en-us.json`);
Редактировать этот файл нужно только в том случае, если потребуется добавить новую или удалить одну из ��уществующих локалей.
pages-loader.js
Данный скрипт используется для динамической загрузки компонентов при открытии тех или иных страниц, и он так же, как и i18nloader.js, необходим для корректной разбивки сайта на чанки. Файл необходимо редактировать при добавлении новых страниц, он имеет следующий формат:
/* eslint-disable import/no-unresolved */ module.exports = { loadComponent: async route => { switch (route) { case "home": return import(/* webpackChunkName: "page.home" */ "../src/zoia/pages/home"); case "license": return import(/* webpackChunkName: "page.license" */ "../src/zoia/pages/license"); default: return import(/* webpackChunkName: "page.404" */ "../src/zoia/errors/404"); } }, };
Для корректной обработки ситуации, когда пользователь обращается к несуществующему роуту, в default прописывается импорт компонента errors/404.
bulma.scss
В качестве CSS-фреймворка используется Bulma. Его просто кастомизировать (при помощи SASS переменных), он обладает большим количеством возможностей, и, самое главное, Bulma - модульный фреймворк, т.е. вы сможете загружать только те компоненты, которые вам потребуются. То, какие компоненты будут использоваться на вашем сайте, вы и можете указать в данном конфигурационном файле. По умолчанию импортируется всё:
@import "../node_modules/bulma/sass/elements/_all.sass"; @import "../node_modules/bulma/sass/components/_all.sass"; @import "../node_modules/bulma/sass/form/_all.sass"; @import "../node_modules/bulma/sass/grid/_all.sass"; @import "../node_modules/bulma/sass/helpers/_all.sass"; @import "../node_modules/bulma/sass/layout/_all.sass";
Всегда можно закомментировать этот блок и убрать комментарии там, где это действительно нужно.
Исходники
На этом конфигурация завершена, и можно переходить к редактированию исходников, т.е. к директории src, имеющей достаточно понятную структуру:
в директории favicon размещаются файлы favicon'ов, тех самых пиктограмм (иконок) сайта, которые отображаются в левой части перед названием страницы (если вы хотите уточнить, что именно будет копироваться из этого перечня - посмотрите на плагин CopyWebpackPlugin, используемый в webpack.config.js - там перечислены все копируемые в dist файлы);
директория images содержит изображения, которые будут использоваться на сайте (по умолчанию там лежит лого ZOIA);
в директории misc располагаются вспомогательные файлы (на данный момент там только robots.txt, но в следующих версиях может появиться что-то ещё);
файл variables.scss содержит значения переменных для Bulma (цвета, отступы, шрифты и т.д.), и именно здесь можно начать кастомизацию дизайна;
в директории zoia находятся "исходники" вашего сайта.
Точкой входа в приложение является файл index.js. Все, что там происходит - это загрузка файла index.marko и его рендеринг:
import template from "./index.marko"; (async () => { template.render({}).then(data => data.appendTo(document.body)); })();
Сам файл index.marko содержит один-единственный тег:
<zoia/>
Особенностью Marko является то, что в точке входа нельзя напрямую размещать какую-либо логику, иначе на странице не будут подгружаться стили. Поэтому подобный workaround с подключением "корневого компонента" является наиболее простым решением проблемы.
Компонент zoia находится в директории src. Для того, чтобы Marko "знал", где искать компоненты, существуют специальные файлы - marko.json, в которых можно перечислить пути для поиска:
{ "tags-dir": ["./"] }
Компоненты Marko могут состоять как из одного файла, так из нескольких, что достаточно подробно описано в документации. Я рекомендую использовать "однофайловые" компоненты только в случае крайней и осознанной необходимости, а во всех остальных случаях разбивать их на три файла - index.marko (собственно, Marko-код компонента), component.js (логика компонента, написанная на Javascript) и style.css (файл стилей, можно также использовать и формат .scss). Все файлы, кроме index.marko, являются опциональными, т.е. компонент может не иметь стилей или логики.
Синтаксис Marko ничем не отличается от обычно HTML, и это является основной "фишкой" этого фреймворка. Т.е. все, что вам потребуется знать для того, чтобы начать делать свои страницы или компоненты - это обычный HTML. Но, в случае необходимости, вы сможете использовать все возможности, которые предоставляет Marko, такие, как условные операторы и списки:
<if(user.loggedOut)> <a href="/login">Log in</a> </if> <else-if(!user.trappedForever)> <a href="/logout">Log out</a> </else-if> <else> Hey ${user.name}! </else> <ul> <for|color, index| of=colors> <li>${index}: ${color}</li> </for> </ul>
Файлы component.js экспортируют класс, который может содержать несколько используемых Marko методов, таких, как onCreate и onMount:
module.exports = class { async onCreate() { const state = { iconWrapOpacity: 0, }; this.state = state; await import(/* webpackChunkName: "error500" */ "./error500.scss"); } onMount() { setTimeout(() => this.setState("iconWrapOpacity", 1), 100); } };
Подробнее о классах, используемых Marko, можно почитать в документации.
Компонент zoia, используемый как точка входа, также является мультифайловым. Файл zoia/index.marko используется как основной шаблон страницы, и именно этот файл требуется редактировать для кастомизации дизайна страницы. В свою очередь, файл zoia/component.js содержит всю логику, связанную с обработкой событий (переключение языков, нажатие на "бургер" в "мобильной" версии и т.д.).
В директории компонента zoia также содержится несколько "вложенных" компонентов, которые используются для рендеринга:
navbar - навигационная панель, отображаемая сверху;
core - системные компоненты, реализующие функционал интернационализации, роутинга и т.д.;
errors - компоненты, отвечающие за ситуации, связанные с возникновением различных ошибок ("страница не найдена" или "фатальная ошибка");
pages - компоненты, соответствующие роутам, используемым на сайте: именно здесь необходимо размещать страницы с контентом, которые технически будут представлять собой обычные компоненты Marko.
Поскольку страницы представляют собой обычные компоненты, то их структура в простейшем виде может быть представлена в виде обычного HTML (Marko) файла. Но для реализации полноценной многоязычности требуется немного более сложная структура, которую мы рассмотрим на примере главной страницы (компонент home).
Итак, компонент home имеет следующую структуру:
index.marko
$ const { t } = out.global.i18n; <div> <h1 class="title">${t("home")}</h1> <${state.currentComponent}/> </div>
В начале мы импортируем метод t, который, в свою очередь, экспортирует библиотека интернационализации (src/zoia/core/i18n). Данный метод необходим для того, чтобы обращаться к загруженным файлам перевода по ключу. Обратите внимание, что непосредственно в коде Marko вы можете использовать Javascript, указав для этого оператор $ в начале строки.
Для обращения к переменным или функциям в Marko используется синтаксическая конструкция ${...}, как ${t("home")} в коде выше - вызов функции t для перевода соответствующей строки.
В свою очередь, конструкция <${state.currentComponent}/> является т.н. динамическим тегом, который подгружает соответствующий компонент в зависимости от значения переменной. Переменная state ссылается на состояние компонента, определенное в методе onCreate (файл component.js):
/* eslint-disable import/no-unresolved */ module.exports = class { onCreate(input, out) { const state = { language: out.global.i18n.getLanguage(), currentComponent: null, }; this.state = state; this.i18n = out.global.i18n; this.parentComponent = input.parentComponent; } async loadComponent(language = this.i18n.getLanguage()) { let component = null; const timer = this.parentComponent.getAnimationTimer(); try { switch (language) { case "ru-ru": component = await import(/* webpackChunkName: "page.home.ru-ru" */ "./home-ru-ru"); break; default: component = await import(/* webpackChunkName: "page.home.en-us" */ "./home-en-us"); } this.parentComponent.clearAnimationTimer(timer); } catch { this.parentComponent.clearAnimationTimer(timer); this.parentComponent.setState("500", true); } this.setState("currentComponent", component); } onMount() { this.loadComponent(); } async updateLanguage(language) { if (language !== this.state.language) { setTimeout(() => { this.setState("language", language); }); } this.loadComponent(language); } };
Метод loadComponent необходим для того, чтобы при смене языка был загружен соответствующий дочерний компонент (в данном случае, это либо home-ru-ru, либо home-en-us). Используя динамический импорт, мы добиваемся загрузки соответствующего чанка только в том случае, если он в явном виде запрашивается пользователем. Подобный подход позволяет загружать не весь компонент целиком, что экономит трафик, особенно для объемных страниц.
При помощи this.parentComponent мы можем обратиться к "родительскому" компоненту и вызвать ряд необходимых методов оттуда:
в случае долгой загрузки страницы (более 500 мс) на странице отображается анимация загрузки (спиннер);
в случае ошибки во время загрузки чанка (либо других исключений) отображается содержимое компонента errors/500, по умолчанию там иконка робота на темно-сером фоне.
Вызов метода loadComponent происходит во время рендеринга (монтирования) страницы в onMount и при смене локали (в методе updateLanguage, который компонент zoia вызывает для каждой страницы).
Таким образом, добавление новой страницы сводится к созданию нового компонента в src/zoia/pages и редактированию настроек в etc.
Что дальше
А дальше вы можете использовать boilerplate ZSPA так, как посчитаете нужным - например, чтобы сделать свой сайт, или форкнуть в качестве основы для своего проекта. Делайте все, что позволяет лицензия MIT.
Также буду рад любой конструктивной критике, особенно в виде Issues, а также вашим Pull Request'ам. Например, будет здорово сделать локализации на другие языки, ничего кроме английского и немецкого я не знаю.
Ну и, разумеется, да начнётся холивар в комментах :)
