В данной статье вы познакомитесь с 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'ам. Например, будет здорово сделать локализации на другие языки, ничего кроме английского и немецкого я не знаю.
Ну и, разумеется, да начнётся холивар в комментах :)
yroman
Судя по коду, плюрализация в i18n не поддерживается. Как можно использовать фреймворк в современном мире без поддержки плюрализации?
xtremespb Автор
Собственно говоря, это достаточно просто сделать. Это не фреймворк, а boilerplate, который написан за достаточно короткое время, и он готов принять любые pull request'ы ;-)