Как стать автором
Обновить
Deutsche Telekom IT Solutions
Немецкая IT-компания с российским сердцем

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

Время на прочтение10 мин
Количество просмотров3K

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

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

Теги:
Хабы:
+5
Комментарии2

Публикации

Изменить настройки темы

Информация

Сайт
deutschetelekomitsolutions.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия

Истории