Marko.js не так популярен, как Angular, React.js, Vue.js или Svelte. Marko.js — это проект ebay.com, который с 2015 года стал достоянием opensource. Собственно, именно на этой библиотеке построен фронтенд ebay.com, что позволяет сделать заключение о её практической ценности для разработчиков.

Ситуация с Marko.js, немного напоминает ситуацию с фреймворком Ember.js, который, несмотря на то что работает в качестве фронтенда нескольких высоко-нагруженных сайтов (например Linkedin) — о нем мало знает среднестатистический разработчик. В случае с Marko.js — можно утверждать, что совсем не знает.

Marko.js неистово быстр, особенно при серверном рендеринге. Что касается серверного рендеринга, то скорость Marko.js, скорее всего, останется недосягаемой для его неторопливых собратьев, и этому есть объективные причины. О них и поговорим в предлагаемом материале.

SSR-first фреймворк


Marko.js может стать основой для классического фронтенда (с серверным рендерингом), для одностраничного приложения (с клиентским рендерингом) и для изоморфного/универсального приложения (пример которого будет рассмотрен далее). Но все же, Marko.js можно считать SSR-first библиотекой, то есть ориентированной, в первую очередь, на серверный рендеринг. От других компонентных фреймворков, Marko.js отличает то, серверный вариант компонента не строит DOM, который потом сериализуется в строку, а реализован как поток вывода. Чтобы было ясно о чем речь, приведу листинг кода, который автогенерируется на сервере для рендеринга простого серверного компонента:

// Compiled using marko@4.23.9 - DO NOT EDIT
"use strict";

var marko_template = module.exports = require("marko/src/html").t(__filename),
    marko_componentType = "/marko-lasso-ex$1.0.0/src/components/notFound/index.marko",
    marko_renderer = require("marko/src/runtime/components/renderer");

function render(input, out, __component, component, state) {
  var data = input;

  out.w("<p>Not found</p>");
}

marko_template._ = marko_renderer(render, {
    ___implicit: true,
    ___type: marko_componentType
  });

marko_template.meta = {
    id: "/marko-lasso-ex$1.0.0/src/components/notFound/index.marko"
  };

Идея, что серверный компонент не должен быть таким же как клиентский компонент, представляется очень естественной. И на этой основе изначально строилась библиотека Marko.js. Я могу предположить, что в случае с другими фреймворками, которые изначально строились как клиент-ориентированные, серверный рендеринг приматывался скотчем к уже существующей весьма сложной кодовой базе. Отсюда возникло это архитектурно неверное решение, когда на стороне сервера воссоздается DOM, чтобы существующий клиентский код мог быть переиспользован без изменений.

Почему это важно


Прогресс в создании одностраничных приложений, который наблюдается с широким распространением Angular, React.js, Vue.js, наряду с положительными моментами, выявил и несколько фатальных просчетов клиент-ориентированной архитектуры. В далеком 2013 году Spike Brehm из Airbnb опубликовал программную статью, в которой соответствующий раздел называется «Ложка дегтя в бочке меда». При этом все отрицательные пункты бьют по бизнесу:

  • увеличивается время загрузки первой страницы;
  • контент не индексируется поисковиками;
  • проблемы с доступностью для людей с ограниченными возможностями.

Как альтернатива, наконец, были созданы фреймворки для разработки изоморфных/универсальных приложений: Next.js и Nust.js. И тут начинает вступать в игру другой фактор — производительность. Всем известно, что node.js не так хорош, если его нагружать сложными расчетами. А в случае, когда мы на сервере создаем DOM, а потом запускаем его сериализацию, node.js очень быстро выдыхается. Да, мы можем поднимать бесконечное количество реплик node.js. Но может быть попробовать сделать все то же но на Marko.js?

Как начать работать с Marko.js


Для первого знакомства рекомендую начать как описано в документации с команды npx @marko/create --template lasso-express.

В результате получим основу для дальнейшей разработки проектов с настроенным сервером Express.js и компоновщиком Lasso (этот компоновщик является разработкой ebay.com и с ним проще всего интегрироваться).

Компоненты в Marko.js, как правило, располагаются в каталогах /components в файлах с расширением .marko. Код компонентов интуитивно понятен. Как сказано в документации, если Вы знаете html, то вы знаете Marko.js.

Компонент рендерится на сервере, и потом воссоздается (hydrate) на клиенте. То есть, на клиенте мы получаем не статический html, а полноценный клиентский компонент, с состоянием и событиями.

При запуске проекта в режиме разработки работает горячая перезагрузка (hot reloading).

Для построения сложного приложения, нам, скорее всего, кроме библиотеки компонентов необходимо иметь еще кое-что, например роутинг, стор, каркас для создания изоморфных/универсальных приложений. И тут, увы, проблемы те же, с которыми первые годы сталкивались разработчики React.js — нет готовых решений и общеизвестных подходов. Поэтому все, что было до этого момента, можно назвать вступлением к беседе о построении приложения на основе Marko.js.

Строим изоморфное/универсальное приложение


Как я уже сказал, о Marko.js не так много статей, поэтому все нижеследующее — плод моих экспериментов, отчасти основанный на работе с другими фреймворками.

Marko.js позволяет задавать и менять имя тэга или компонент динамически (то есть программно) — этим и воспользуемся. Сопоставим роутам — имена компонентов. Поскольку «из коробки» в Marko.js роутинга нет (интересно узнать как это построено на ebay.com) — воспользуемся пакетом, который как раз для таких случаев — universal-router:

const axios = require('axios');
const UniversalRouter = require('universal-router');

module.exports = new UniversalRouter([
  { path: '/home', action: (req) => ({ page: 'home' }) },
  {
    path: '/user-list',
    action: async (req) => {
      const {data: users} = await axios.get('http://localhost:8080/api/users');
      return { page: 'user-list', data: { users } };
    }
  },
  {
    path: '/users/:id',
    action: async (req) => {
      const {data: user} = await axios.get(`http://localhost:8080/api/users/${req.params.id}`);
      return { page: 'user', data: { req, user } };
    }
  },
  { path: '(.*)', action: () => ({ page: 'notFound' }) }
])

Функционал пакета universal-router прост до безобразия. Он разбирает строку url, и вызывает с разобранной строкой асинхронную функцию action(req), внутри которой мы можем, например, получить доступ к разобранным параметрам строки (req.params.id). А поскольку функция action(req) вызывается асинхронно, мы можем прямо здесь инициализировать данные запросами к API.

У нас, как вы помните, в прошлом разделе был создан проект командой npx @marko/create --template lasso-express. Возьмем его как основу для нашего изоморфного/универсального приложения. Для этого немного изменим файл server.js

app.get('/*', async function(req, res) {
    const { page, data } = await router.resolve(req.originalUrl);
    res.marko(indexTemplate, {
            page,
            data,
        });
});

Также изменим шаблон загружаемой страницы:

<lasso-page/>
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8"/>
    <title>Marko | Lasso + Express</title>
    <lasso-head/>
    <style>
      .container{
        margin-left: auto;
        margin-right: auto;
        width: 800px;
    }
    </style>
  </head>
  <body>
    <sample-header title="Lasso + Express"/>
    <div class="container">
      <router page=input.page data=input.data/>
    </div>
    <lasso-body/>
    <!--
    Page will automatically refresh any time a template is modified
    if launched using the browser-refresh Node.js process launcher:
    https://github.com/patrick-steele-idem/browser-refresh
    -->
    <browser-refresh/>
  </body>
</html>

Компонент <router/> — как раз та часть которая будет отвечать за загрузку динамических компонентов, имена которых получаем из роутера в атрибуте page.

<layout page=input.page>
  <${state.component} data=state.data/>
</layout>

import history from '../../history'
import router from '../../router'

class {
  onCreate({ page, data }) {
    this.state = {
      component: require(`../${page}/index.marko`),
      data
    }
    history.listen(this.handle.bind(this))
  }

  async handle({location}) {
    const route = await router.resolve(location);
    this.state.data = route.data;
    this.state.component = require(`../${route.page}/index.marko`);
  }
}

Традиционно, Marko.js имеет состояние this.state, изменение которого вызывает изменение представления компонента, чем мы и пользуемся.

Работу с историей приходится реализовывать также самостоятельно:

const { createBrowserHistory } = require('history')
const parse = require('url-parse')
const deepEqual = require('deep-equal')
const isNode = new Function('try {return !!process.env;}catch(e){return false;}') //eslint-disable-line
let history

if (!isNode()) {
  history = createBrowserHistory()
  history.navigate = function (path, state) {
    const parsedPath = parse(path)
    const location = history.location
    if (parsedPath.pathname === location.pathname &&
      parsedPath.query === location.search &&
      parsedPath.hash === location.hash &&
      deepEqual(state, location.state)) {
      return
    }
    const args = Array.from(arguments)
    args.splice(0, 2)
    return history.push(...[path, state, ...args])
  }
} else {
  history = {}
  history.navigate = function () {}
  history.listen = function () {}
}

module.exports = history

Ну и, наконец, должен быть источник навигации, который перехватывает события клика по ссылке, и вызывает навигацию на странице:

import history from '../../history'

<a on-click("handleClick") href=input.href><${input.renderBody}/></a>

class {
  handleClick(e) {
    e.preventDefault()
    history.navigate(this.input.href)
  }
}

Для удобства изучения материала я представил результаты в репозитарии.

apapacy@gmail.com
22 ноября 2020 года