Создание Single Page Application на Marko.js — ZSPA Boilerplate

от автора

В данной статье вы познакомитесь с 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’ам. Например, будет здорово сделать локализации на другие языки, ничего кроме английского и немецкого я не знаю.

Ну и, разумеется, да начнётся холивар в комментах 🙂


ссылка на оригинал статьи https://habr.com/ru/company/deutschetelekomitsolutions/blog/647641/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *