Mithril.js — непопулярный инструмент для создания клиентских веб приложений. На Хабре практически нет публикаций по этой теме. В этой заметке я хочу показать, как можно сделать небольшое приложение на Mithril. Приложение будем делать по мотивам вот этой публикации (перевод)

Что такое Mihtril


Mithril – реактивный фреймворк, предназначенный для создания SPA (одностраничных веб приложений). В общем, это просто javascript и 13 сигнатур функций API. Кроме этого, есть библиотека mithril-stream, не входящая в mithril и используемая отдельно. В ядро mithril входит маршрутизация приложения и работа с XHR запросами. Центральным понятием является абстракция — виртуальный узел (vnode). Виртуальный узел – это просто js объект с некоторым набором атрибутов. Виртуальные узлы создаются специальной функцией m(). Текущее состояние интерфейса хранится в списке виртуальных узлов (virtual DOM). При начальном рендеринге страницы приложения, virtual DOM транслируется в DOM. При запуске обработчика событий DOM API, при завершении промиса m.request() и при смене URL (навигация по маршрутам приложения) генерируется новый массив virtual DOM, сравнивается со старым, и изменившиеся узлы изменяют DOM браузера. Кроме событий DOM и завершения запроса m.request(), перерисовку можно вызвать вручную функцией m.redraw().

В mithril из коробки нет HTML подобных шаблонов, нет поддержки JSX, хотя при желании все это можно использовать с помощью различных плагинов при сборке. Я здесь не буду использовать эти возможности.

Если первым аргументом m() будет строка, (например ‘div’), тогда функция возвращает простой виртуальный узел и в результате в DOM будет выведен HTML тэг

<div></div>

Если первым аргументом m() будет объект или функция возвращающая объект, то такой объект должен иметь метод view(), и такой объект называется компонентом. Метод view() компонента в свою очередь должен всегда возвращать функцию m() (или массив типа: [ m(), ]). Таким образом, мы можем построить иерархию объектов-компонентов. И понятно, что в конечном итоге все компоненты возвращают простые vnode узлы.

И виртуальные узлы, и компоненты имеют методы жизненного цикла, и называются они одинаково oninit(), oncreate(), onbeforeupdate(), и т.д. Каждый из этих методов вызывается во вполне определенный момент времени рендеринга страницы.

Виртуальному узлу или компоненту можно передать параметры в виде объекта, которым должен быть второй аргумент функции m(). Получить ссылку на этот объект внутри узла можно с помощью нотации vnode.attrs. Третий аргумент функции m() – это потомки этого узла и доступ к ним можно получить по ссылке vnode.children. Кроме функции m(), простые узлы возвращает функция m.trust().

Автор mithril не предлагает никаких особых паттернов проектирования приложения, хотя и советует избегать некоторых неудачных решений, например, «слишком толстых» компонентов или манипуляцией потомками дерева компонентов. Автор также не предлагает особых путей или способов управления состоянием приложения в целом или компонентов. Хотя в документации уточняется, что не следует использовать состояние самого узла, манипулировать им.

Все эти особенности mithril кажутся очень неудобными, и фреймворк представляется недоделанным, нет особых рекомендаций, нет состояния/хранилища, нет редуктора/диспетчера событий, нет шаблонов. В общем, делайте, как умеете.

Что нужно для примера


Будем использовать:


Фронтенд сервер здесь не важен, он просто должен отдать клиенту index.html и файлы скриптов и стилей.

Не будем устанавливать mithril в node_modules, и связывать код приложения и фреймворк в один файл. Код приложения и mithril будет загружаться на страницу по отдельности.

Процедуру установки инструментов я описывать не буду, хотя по поводу postgREST могу сказать, просто скачайте бинарный файл, поместите его в отдельную папку, создайте там конфигурационный файл test.conf типа такого:

db-uri = "postgres://postgres:user1@localhost:5432/testbase"
server-port= 5000
# The name of which database schema to expose to REST clients
db-schema= "public"
# The database role to use when no client authentication is provided.
# Can (and probably should) differ from user in db-uri
db-anon-role = "postgres" 

При этом, в вашем кластере postgesql должна быть база testbase и пользователь user1. В этой тестовой базе создайте таблицу:

-- Postgrest sql notes table 
create table if not exists notes (
id serial primary key,
title varchar(127) NOT NULL,
content text NOT NULL,
created timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed timestamp with time zone,
ddel smallint default 0
)

Запуск сервера postgREST производится командой:

postgrest test.conf

После запуска сервера, он выводит информационные сообщения о подключении к базе, и на каком порту будет прослушивать клиентов.

Планируем проект


Итак, если примерно понятно как работает митрил, нужно придумать, как сделать приложение. Вот план:

  1. Данные приложения будем хранить в локальном объекте, назовем его модель
  2. API приложения будем хранить в отдельном файле
  3. Маршруты приложения будем хранить в отдельном файле
  4. Маршрутный файл будет точкой входа для сборки приложения
  5. Каждый отдельный компонент (а я буду использовать компонентную схему рендеринга) и связанные с ним функции будем хранить в отдельном файле
  6. Каждый компонент, который рендерит данные модели, будет иметь доступ к модели
  7. Обработчики событий DOM компонента локализуем в компоненте

Таким образом, нам не нужны пользовательские события, достаточно нативных событий DOM, колбэки по этим событиям локализуем в компонентах. Я буду использовать two way binding. Возможно, такой поход не всем нравится, но и не всем нравится redux или vuex. Тем более что технику one way binding можно изящно реализовать и в митрил, с использованием mithril-sream. Но в данном случае это избыточное решение.

Структура папок приложения




Папка public будет обслуживаться фронт сервером, там есть файл index.html, и каталоги со стилями и скриптами.

Папка src содержит в корне роутер и определение API, и два каталога, для модели и представлений.

В корне проекта есть конфигурационный файл rollup.config, и сборка проекта выполняется командой:

rollup –c

Чтобы не утомлять читателя длинными кусками кода, который доступен на github.com, я прокомментирую только основные элементы реализации, чтобы продемонстрировать идиоматичный для митрил подход.

API и router


Код API:

// used by backend server
export const restApi= {
  notes: { url: 'notes', editable: ['add', 'edit', 'del'] }
}

// used by routers
export const appApi = {
  root: "/",
}
// used by navBar
// here array of arrays though it may be hash eg
export const appMenu = [
  [`${appApi.root}`, 'Home'],
  [`${appApi.root}`, 'About'],
  [`${appApi.root}`, 'Contacts'],
]

Я определил API для REST сервера и для маршрутизатора.

Маршрутизатор:

import { restApi, appApi } from './appApi';
import { moModel } from './model/moModel';
import { vuView, vuApp } from './view/vuApp';
import { vuNavBar } from './view/vuNavBar';

// application router
const appRouter = { [appApi.root]: {
  render() {
    const view = m(vuApp, {
      model: moModel.getModel( restApi.notes ),
    });
    return vuView( {menu: vuNavBar}, view);
  }
}};

// once per app
m.route(document.body, "/", appRouter);

Здесь роутер примонтирован к телу документа. Сам роутер это объект описывающий маршруты и компоненты, которые будут использованы на этом маршруте, функция render() должна вернуть vnode.

Объект appApi определяет все допустимые маршруты приложения, а объект appMenu — все возможные элементы навигации приложения.

Функция render при вызове генерирует модель приложения и передает ее корневому узлу.

Модель приложения


Структуру, хранящую актуальные данные я назвал моделью. Исходный код функции возвращающей модель:

getModel(
    {url=null, method="GET", order_by='id', editable=null} = {}
  ) {
/**
  * url - string of model's REST API url
  * method - string of model's REST method
  * order_by - string "order by" with initially SELECT 
  * editable - array defines is model could changed
*/
    const model = {
      url: url,
      method: method,
      order_by: order_by,
      editable: editable,
      key: order_by, // here single primary key only
      list: null, // main data list 
      item: {}, // note item
      error: null, // Promise error
      save: null, // save status
      editMode: false, // view switch flag
      word: '' // dialog header word
    };  
    model.getItem= id => {
      model.item= {};
      if ( !id ) {
        model.editMode= true;
        return false;
      }
      const key= model.key;
      for ( let it of model.list ) {
        if (it[key] == id) {
          model.item= Object.assign({}, it);
          break;
        }
      }
      return false;
    };
    return model;
  },

Здесь инициализируется, и возвращаем объект модели. Ссылки внутри объекта могут меняться, однако ссылка на сам объект остается постоянной.

Кроме функции getModel в глобальном объекте moModel есть функции обертки для митрил-функции m.request(), это getList(model), и formSubmit(event, model, method). Параметр model, это собственно ссылка на объект модели, event – объект события который генерируется при отправке формы, method- HTTP method, с помощью которого мы хотим сохранить заметку (POST- новая заметка, PATCH, DELETE — старая).

Представления


В папке view лежат функции, которые отвечают за рендеринг отдельных элементов страницы. Я разделил их на 4 части:

  • vuApp – корневой компонент приложения,
  • vuNavBar – панель навигации,
  • vuNotes – список заметок,
  • vuNoteForm – форма редактирования заметки,
  • vuDialog – HTML элемент dialog

В роутере определено, что по единственному маршруту возвращается vuView( menu, view )

Определение этой функции:

export const vuView= (appMenu, view)=> m(vuMain, appMenu, view);

Это просто обертка, которая возвращает компонент vuMain, если объект appMenu будет достаточно сложным, иметь вложенные объекты по структуре сходные с внешним объектом, то такая обертка является подходящим способом возвращать компонент, с разными элементами навигации и дочерними компонентами (просто писать кода нужно будет меньше).

Компонент vuMain:

const vuMain= function(ivnode) {
  // We use ivnode as argument as it is initial vnode
  const { menu }= ivnode.attrs;
  return { view(vnode) {
    // IMPORTANT !
    // If we use vnode inside the view function we MUST provide it for view
    return [
      m(menu),
      m('#layout', vnode.children)
    ];
  }};
};

Здесь просто возвращается vnodes навигации и собственно контента страницы.

Здесь и далее, где это возможно для определения компонента я буду использовать замыкания. Замыкания вызываются единожды при начальном рендеринге страницы, и хранят локально все переданные ссылки на объекты и определения собственных функций.

Замыкание в качестве определения всегда должно возвращать компонент.

И собственно компонент контента приложения:

export const vuApp= function(ivnode) {
  const { model }= ivnode.attrs;
  //initially get notes
  moModel.getList( model );
  
  return { view() { 
    return [
      m(vuNotes, { model }),
      m(vuNoteForm, { model }),
      vuModalDialog(model)
    ];
  }};
}

Поскольку у нас уже есть модель, при вызове замыкания я хочу получить весь список заметок из БД. На станице будут три компонента:

  • vuNotes — список заметок с кнопкой добавить,
  • vuNoteForm – форма редактирования заметки,
  • vuModalDialog – элемент dialog, который мы будем показывать модально, и захлопывать когда нужно.

Поскольку каждому из этих компонентов нужно знать, как себя отрисовывать, мы в каждый передаем ссылку на объект модели.

Компонент список заметок:

//Notes List
export const vuNotes= function(ivnode) {
  const { model }= ivnode.attrs;
  const _display= ()=> model.editMode ? 'display:none': 'display:block';
  const vuNote= noteItem(model); // returns note function
  
  return { view() {
    return model.error ? m('h2', {style: 'color:red'}, model.error) :
    !model.list ? m('h1', '...LOADING' ) :
    m('div', { style: _display() }, [
      m(addButton , { model } ),
      m('.pure-g', m('.pure-u-1-2.pure-u-md-1-1',
        m('.notes', model.list.map( vuNote ) )
      ))
    ]);
  }};
}

В объекте модели хранится bool флаг editMode, если значение флага true, то показываем форму редактирования, в противном случае — список заметок. Можно проверку поднять на уровень выше, но тогда количество виртуальных узлов и собственно узлов DOM менялось бы каждый раз при переключении флага, а это лишняя работа.

Здесь мы идиоматично для митрил, генерируем страницу, проверяя наличие или отсутствие атрибутов в модели с помощью тернарных операторов.

Вот замыкание возвращающее функцию отображения заметки:

const noteItem= model=> {
  // click event handler
  const event= ( msg, word='', time=null)=> e=> {
    model.getItem(e.target.getAttribute('data'));
    if ( !!msg ) {
      model.save= { err: false, msg: msg };
      model.word= word;
      if ( !!time )
        model.item.completed= time;
      vuDialog.open();
    } else {
      model.editMode= true;
    }
  };
  // trash icon's click handler
  const _trash= event('trash', 'Dlelete');
  
  // check icon's click handler
  const _check= event('check', 'Complete',
    // postgre timestamp string
    new Date().toISOString().split('.')[0].replace('T', ' '));
  
  // edit this note
  const _edit= event('');
  
  const _time= ts=> ts.split('.')[0];
  
  // Single Note 
  const _note= note=> m('section.note', {key: note.id}, [
    m('header.note-header', [ m('p.note-meta', [
      // note metadata
      m('span', { style: 'padding: right: 3em' }, `Created: ${_time( note.created )}`),
      note.completed ? m('span', `  Completed: ${_time( note.completed )}`) : '', 
      // note right panel 
      m('a.note-pan', m('i.fas.fa-trash', { data: note.id, onclick: _trash } )),
      note.completed ? '' : [
        m('a.note-pan', m('i.fas.fa-pen', {data: note.id, onclick: _edit } )),
        m('a.note-pan', m('i.fas.fa-check', { data: note.id, onclick: _check} ))
      ]
    ]),
      m('h2.note-title', { style: note.completed ? 'text-decoration: line-through': ''}, note.title)
    ]),
    m('.note-content', m('p', note.content))
  ]);
  return _note;
}

Все обработчики кликов определены локально. Модель не знает, как устроен объект note, я определил только свойство key, с помощью которого мы из массива model.list можем выбрать нужный элемент. Однако компонент должен точно знать, как устроен объект, который он отрисовывает.

Полный текст кода формы редактирования заметки я приводить не буду, просто отдельно посмотрим на обработчик отправки формы:

// form submit handler
  const _submit= e=> {
    e.preventDefault();
    model.item.title= clean(model.item.title);
    model.item.content= clean(model.item.content);
    const check= check_note(model.item);
    if ( !!check ) {
      model.save= { err: true, msg: check }; 
      model.word= 'Edit';
      vuDialog.open();
      return false;
    } 
    return moModel.formSubmit(e, model, _method() ).then(
      ()=> { model.editMode=false; return true;}).catch(
      ()=> { vuDialog.open(); return false; } );
  };

Поскольку определение происходит в замыкании, у нас есть ссылка на модель, и возвращаем мы промис с последующей обработкой результата: запрет на показ формы при нормальном завершении запроса, или при ошибке — открытие диалога с текстом ошибки.

При каждом обращении к бэкенд серверу, перечитывается список заметок. В данном примере нет нужды корректировать список в памяти, хотя это и можно сделать.

Компонент dialog, можно посмотреть в репозитории, единственно, что нужно подчеркнуть — в данном случае я использовал объектный литерал для определения компонента, поскольку хочу, чтобы функции открытия и закрытия окна были доступны другим компонентам.

Заключение


Мы написали небольшое приложение SPA на javascript и mithril.js, стараясь придерживаться идиоматики этого фреймворка. Хочется еще раз обратить внимание, что это просто код на javascript. Возможно не совсем чистый. Подход позволяет инкапсулировать небольшие куски кода, изолировать механику компонента, и использовать общее для всех компонентов состояние.