Пример SPA «Простые заметки» на Mithril.js

от автора

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. Возможно не совсем чистый. Подход позволяет инкапсулировать небольшие куски кода, изолировать механику компонента, и использовать общее для всех компонентов состояние.

ссылка на оригинал статьи https://habr.com/ru/post/492496/


Комментарии

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

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