Mithril.js — непопулярный инструмент для создания клиентских веб приложений. На Хабре практически нет публикаций по этой теме. В этой заметке я хочу показать, как можно сделать небольшое приложение на Mithril. Приложение будем делать по мотивам вот этой публикации (перевод)
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 тэг
Если первым аргументом 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 типа такого:
При этом, в вашем кластере postgesql должна быть база testbase и пользователь user1. В этой тестовой базе создайте таблицу:
Запуск сервера postgREST производится командой:
После запуска сервера, он выводит информационные сообщения о подключении к базе, и на каком порту будет прослушивать клиентов.
Итак, если примерно понятно как работает митрил, нужно придумать, как сделать приложение. Вот план:
Таким образом, нам не нужны пользовательские события, достаточно нативных событий DOM, колбэки по этим событиям локализуем в компонентах. Я буду использовать two way binding. Возможно, такой поход не всем нравится, но и не всем нравится redux или vuex. Тем более что технику one way binding можно изящно реализовать и в митрил, с использованием mithril-sream. Но в данном случае это избыточное решение.
Папка public будет обслуживаться фронт сервером, там есть файл index.html, и каталоги со стилями и скриптами.
Папка src содержит в корне роутер и определение API, и два каталога, для модели и представлений.
В корне проекта есть конфигурационный файл rollup.config, и сборка проекта выполняется командой:
Чтобы не утомлять читателя длинными кусками кода, который доступен на github.com, я прокомментирую только основные элементы реализации, чтобы продемонстрировать идиоматичный для митрил подход.
Код API:
Я определил API для REST сервера и для маршрутизатора.
Маршрутизатор:
Здесь роутер примонтирован к телу документа. Сам роутер это объект описывающий маршруты и компоненты, которые будут использованы на этом маршруте, функция render() должна вернуть vnode.
Объект appApi определяет все допустимые маршруты приложения, а объект appMenu — все возможные элементы навигации приложения.
Функция render при вызове генерирует модель приложения и передает ее корневому узлу.
Структуру, хранящую актуальные данные я назвал моделью. Исходный код функции возвращающей модель:
Здесь инициализируется, и возвращаем объект модели. Ссылки внутри объекта могут меняться, однако ссылка на сам объект остается постоянной.
Кроме функции getModel в глобальном объекте moModel есть функции обертки для митрил-функции m.request(), это getList(model), и formSubmit(event, model, method). Параметр model, это собственно ссылка на объект модели, event – объект события который генерируется при отправке формы, method- HTTP method, с помощью которого мы хотим сохранить заметку (POST- новая заметка, PATCH, DELETE — старая).
В папке view лежат функции, которые отвечают за рендеринг отдельных элементов страницы. Я разделил их на 4 части:
В роутере определено, что по единственному маршруту возвращается vuView( menu, view )
Определение этой функции:
Это просто обертка, которая возвращает компонент vuMain, если объект appMenu будет достаточно сложным, иметь вложенные объекты по структуре сходные с внешним объектом, то такая обертка является подходящим способом возвращать компонент, с разными элементами навигации и дочерними компонентами (просто писать кода нужно будет меньше).
Компонент vuMain:
Здесь просто возвращается vnodes навигации и собственно контента страницы.
Здесь и далее, где это возможно для определения компонента я буду использовать замыкания. Замыкания вызываются единожды при начальном рендеринге страницы, и хранят локально все переданные ссылки на объекты и определения собственных функций.
Замыкание в качестве определения всегда должно возвращать компонент.
И собственно компонент контента приложения:
Поскольку у нас уже есть модель, при вызове замыкания я хочу получить весь список заметок из БД. На станице будут три компонента:
Поскольку каждому из этих компонентов нужно знать, как себя отрисовывать, мы в каждый передаем ссылку на объект модели.
Компонент список заметок:
В объекте модели хранится bool флаг editMode, если значение флага true, то показываем форму редактирования, в противном случае — список заметок. Можно проверку поднять на уровень выше, но тогда количество виртуальных узлов и собственно узлов DOM менялось бы каждый раз при переключении флага, а это лишняя работа.
Здесь мы идиоматично для митрил, генерируем страницу, проверяя наличие или отсутствие атрибутов в модели с помощью тернарных операторов.
Вот замыкание возвращающее функцию отображения заметки:
Все обработчики кликов определены локально. Модель не знает, как устроен объект note, я определил только свойство key, с помощью которого мы из массива model.list можем выбрать нужный элемент. Однако компонент должен точно знать, как устроен объект, который он отрисовывает.
Полный текст кода формы редактирования заметки я приводить не буду, просто отдельно посмотрим на обработчик отправки формы:
Поскольку определение происходит в замыкании, у нас есть ссылка на модель, и возвращаем мы промис с последующей обработкой результата: запрет на показ формы при нормальном завершении запроса, или при ошибке — открытие диалога с текстом ошибки.
При каждом обращении к бэкенд серверу, перечитывается список заметок. В данном примере нет нужды корректировать список в памяти, хотя это и можно сделать.
Компонент dialog, можно посмотреть в репозитории, единственно, что нужно подчеркнуть — в данном случае я использовал объектный литерал для определения компонента, поскольку хочу, чтобы функции открытия и закрытия окна были доступны другим компонентам.
Мы написали небольшое приложение SPA на javascript и mithril.js, стараясь придерживаться идиоматики этого фреймворка. Хочется еще раз обратить внимание, что это просто код на javascript. Возможно не совсем чистый. Подход позволяет инкапсулировать небольшие куски кода, изолировать механику компонента, и использовать общее для всех компонентов состояние.
Что такое 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 кажутся очень неудобными, и фреймворк представляется недоделанным, нет особых рекомендаций, нет состояния/хранилища, нет редуктора/диспетчера событий, нет шаблонов. В общем, делайте, как умеете.
Что нужно для примера
Будем использовать:
- Mithril.js версии 2.0.4
- css библиотеку pure.css версии 1.0.1
- fontawesome v5.12 для иконок
- rollup.js версии 1.32 для сборки приложения
- postgREST версии 5.2.0, в качестве backend REST сервера
- postgresql версии 9.6 или выше как база данных.
Фронтенд сервер здесь не важен, он просто должен отдать клиенту 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
После запуска сервера, он выводит информационные сообщения о подключении к базе, и на каком порту будет прослушивать клиентов.
Планируем проект
Итак, если примерно понятно как работает митрил, нужно придумать, как сделать приложение. Вот план:
- Данные приложения будем хранить в локальном объекте, назовем его модель
- API приложения будем хранить в отдельном файле
- Маршруты приложения будем хранить в отдельном файле
- Маршрутный файл будет точкой входа для сборки приложения
- Каждый отдельный компонент (а я буду использовать компонентную схему рендеринга) и связанные с ним функции будем хранить в отдельном файле
- Каждый компонент, который рендерит данные модели, будет иметь доступ к модели
- Обработчики событий 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. Возможно не совсем чистый. Подход позволяет инкапсулировать небольшие куски кода, изолировать механику компонента, и использовать общее для всех компонентов состояние.