Простое приложение
Давайте разработаем простое приложение, которое покажет, как выполнять большинство основных задач, с которыми вам придется столкнуться при использовании Mithril.
Интерактивный пример итогового результата можно увидеть здесь
Сначала давайте создадим точку входа в приложение. Создайте файл index.html
:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>My Application</title>
</head>
<body>
<script src="bin/app.js"></script>
</body>
</html>
Строка <!doctype html>
указывает, что это HTML5-документ. Первый мета-тег charset
определяет кодировку документа, а мета-тег viewport
указывает, как мобильные браузеры должны масштабировать страницу. Тег title
содержит текст, который будет отображаться на вкладке браузера для этого приложения, а тег script
указывает путь к JavaScript-файлу, который управляет приложением.
Мы могли бы создать всё приложение в одном JavaScript-файле, но это затруднило бы навигацию по кодовой базе в дальнейшем. Вместо этого давайте разделим код на модули и соберем эти модули в пакет bin/app.js
.
Существует множество способов настройки инструментов для сборки, но большинство из них распространяются через npm. Фактически, большинство современных библиотек и инструментов JavaScript распространяются таким образом, включая Mithril. Чтобы загрузить npm, установите Node.js; npm устанавливается автоматически вместе с ним. После установки Node.js и npm откройте командную строку и выполните эту команду:
npm init -y
Если npm установлен правильно, будет создан файл package.json
. Этот файл содержит метаданные для проекта. Вы можете отредактировать информацию о проекте и авторе в этом файле.
Чтобы установить Mithril.js, следуйте инструкциям на странице installation. После установки Mithril.js и создания структуры проекта, мы готовы к разработке приложения.
Начнем с создания модуля для хранения нашего состояния. Создайте файл под названием src/models/User.js
// src/models/User.js
var User = {
list: [],
};
module.exports = User;
Теперь давайте добавим код для загрузки данных с сервера. Для связи с сервером мы можем использовать утилиту XHR Mithril.js, m.request
. Сначала подключим Mithril.js к модулю:
// src/models/User.js
var m = require('mithril');
var User = {
list: [],
};
module.exports = User;
Далее создадим функцию, которая будет выполнять XHR-запрос. Назовем ее loadList
// src/models/User.js
var m = require('mithril');
var User = {
list: [],
loadList: function () {
// TODO: Выполнить XHR-запрос
},
};
module.exports = User;
Затем мы можем добавить вызов m.request
для выполнения XHR-запроса. Для этого руководства мы будем выполнять XHR-вызовы к API REM (DEAD LINK, FIXME: https //rem-rest-api.herokuapp.com/), фиктивному REST API, предназначенному для быстрого прототипирования. Этот API возвращает список пользователей из конечной точки GET https://mithril-rem.fly.dev/api/users
. Давайте используем m.request
для выполнения XHR-запроса и заполнения наших данных ответом этой конечной точки.
Примечание: для работы конечной точки REM может потребоваться включить сторонние файлы cookie.
// src/models/User.js
var m = require('mithril');
var User = {
list: [],
loadList: function () {
return m
.request({
method: 'GET',
url: 'https://mithril-rem.fly.dev/api/users',
withCredentials: true,
})
.then(function (result) {
User.list = result.data;
});
},
};
module.exports = User;
Опция method
- это HTTP-метод. Чтобы получить данные с сервера, не вызывая побочных эффектов на сервере, нам нужно использовать метод GET
. url
- это адрес конечной точки API. Строка withCredentials: true
указывает, что мы используем файлы cookie (что является требованием для API REM).
Вызов m.request
возвращает промис, который разрешается в данные из конечной точки. По умолчанию Mithril.js предполагает, что тело HTTP-ответа находится в формате JSON, и автоматически преобразует его в объект или массив JavaScript. Обратный вызов .then
выполняется при завершении XHR-запроса. В этом случае обратный вызов присваивает массив result.data
переменной User.list
.
Обратите внимание, что у нас также есть оператор return
в loadList
. Это общепринятая практика при работе с промисами, которая позволяет нам регистрировать больше обратных вызовов для выполнения после завершения XHR-запроса.
Эта простая модель предоставляет два члена: User.list
(массив объектов пользователя) и User.loadList
(метод, который заполняет User.list
данными с сервера).
Теперь давайте создадим модуль представления, чтобы мы могли отображать данные из нашего модуля модели User.
Создайте файл под названием src/views/UserList.js
. Сначала подключим Mithril.js и нашу модель, так как нам нужно будет использовать и то, и другое:
// src/views/UserList.js
var m = require('mithril');
var User = require('../models/User');
Далее создадим компонент Mithril.js. Компонент - это объект, содержащий метод view
:
// src/views/UserList.js
var m = require('mithril');
var User = require('../models/User');
module.exports = {
view: function () {
// TODO: Добавить код
},
};
По умолчанию представления Mithril.js описываются с помощью hyperscript. Hyperscript предлагает краткий синтаксис, который можно отступать более естественно, чем HTML, для сложных тегов, и, поскольку его синтаксис - это просто JavaScript, можно использовать множество инструментов экосистемы JavaScript. Например:
- Вы можете использовать Babel для преобразования ES6+ в ES5 для IE и для преобразования JSX (встроенное расширение синтаксиса, похожее на HTML) в соответствующие вызовы hyperscript.
- Вы можете использовать ESLint для линтинга кода без специальных плагинов.
- Вы можете использовать Terser или UglifyJS (только ES5) для сжатия вашего кода.
- Вы можете использовать Istanbul для покрытия кода.
- Вы можете использовать TypeScript для анализа кода. (Существуют определения типов, поддерживаемые сообществом, поэтому вам не нужно создавать свои собственные.)
Начнем с hyperscript и создадим список элементов. Hyperscript - это идиоматический способ использования Mithril.js, но JSX работает довольно похоже.
// src/views/UserList.js
var m = require('mithril');
var User = require('../models/User');
module.exports = {
view: function () {
return m('.user-list');
},
};
Строка ".user-list"
- это CSS-селектор, и, как и следовало ожидать, .user-list
представляет класс. Если тег не указан, div
используется по умолчанию. Таким образом, это представление эквивалентно <div class="user-list"></div>
.
Теперь давайте сошлемся на список пользователей из модели, которую мы создали ранее (User.list
), чтобы динамически перебирать данные:
// src/views/UserList.js
var m = require('mithril');
var User = require('../models/User');
module.exports = {
view: function () {
return m(
'.user-list',
User.list.map(function (user) {
return m('.user-list-item', user.firstName + ' ' + user.lastName);
})
);
},
};
Поскольку User.list
- это массив JavaScript, и поскольку представления hyperscript - это просто JavaScript, мы можем перебирать массив, используя метод .map
. Это создает массив vnode (виртуальных узлов), который представляет список div
s, каждый из которых содержит имя пользователя.
Проблема, конечно, в том, что мы никогда не вызывали функцию User.loadList
. Следовательно, User.list
по-прежнему является пустым массивом, и, следовательно, это представление будет выводить пустую страницу. Поскольку мы хотим, чтобы User.loadList
вызывался при отображении этого компонента, мы можем воспользоваться методами жизненного цикла компонента:
// src/views/UserList.js
var m = require('mithril');
var User = require('../models/User');
module.exports = {
oninit: User.loadList,
view: function () {
return m(
'.user-list',
User.list.map(function (user) {
return m('.user-list-item', user.firstName + ' ' + user.lastName);
})
);
},
};
Обратите внимание, что мы добавили метод oninit
для компонента, который ссылается на User.loadList
. Это означает, что при инициализации компонента будет вызван метод User.loadList, что приведет к запуску XHR-запроса. Когда сервер возвращает ответ, User.list
заполняется.
Также обратите внимание, что мы не сделали oninit: User.loadList()
(со скобками в конце). Разница в том, что oninit: User.loadList()
вызывает функцию один раз и сразу же, но oninit: User.loadList
вызывает эту функцию только при отображении компонента. Это важное отличие и частая ошибка начинающих JavaScript-разработчиков: немедленный вызов функции означает, что XHR-запрос будет запущен, как только будет обработан исходный код, даже если компонент никогда не отображается. Кроме того, если компонент когда-либо будет воссоздан (путем навигации вперед и назад по приложению), функция не будет вызвана снова, как ожидалось.
Давайте отобразим представление из файла точки входа src/index.js
, который мы создали ранее:
// src/index.js
var m = require('mithril');
var UserList = require('./views/UserList');
m.mount(document.body, UserList);
Вызов m.mount
монтирует указанный компонент (UserList
) в элемент DOM (document.body
), удаляя любой DOM, который был там ранее. Открытие HTML-файла в браузере теперь должно отображать список имен людей.
Сейчас список выглядит довольно простым, потому что мы не определили никаких стилей. Итак, давайте добавим несколько из них. Сначала создадим файл под названием styles.css
и подключим его к файлу index.html
:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>My Application</title>
<link href="styles.css" rel="stylesheet" />
</head>
<body>
<script src="bin/app.js"></script>
</body>
</html>
Теперь мы можем стилизовать компонент UserList
:
.user-list {
list-style: none;
margin: 0 0 10px;
padding: 0;
}
.user-list-item {
background: #fafafa;
border: 1px solid #ddd;
color: #333;
display: block;
margin: 0 0 1px;
padding: 8px 15px;
text-decoration: none;
}
.user-list-item:hover {
text-decoration: underline;
}
После перезагрузки страницы в браузере должны появиться стилизованные элементы.
Давайте добавим маршрутизацию в наше приложение.
Маршрутизация означает привязку экрана к уникальному URL, чтобы создать возможность перехода с одной «страницы» на другую. Mithril.js разработан для одностраничных приложений (SPA), поэтому эти «страницы» не обязательно являются разными HTML-файлами в традиционном смысле этого слова. Вместо этого маршрутизация в одностраничных приложениях сохраняет один и тот же HTML-файл на протяжении всего срока его службы, но изменяет состояние приложения с помощью JavaScript. Маршрутизация на стороне клиента имеет то преимущество, что позволяет избежать мерцания экрана между переходами страниц и может уменьшить объем данных, отправляемых с сервера, при использовании в сочетании с архитектурой, ориентированной на веб-сервисы (т. е. приложение, которое загружает данные в формате JSON вместо загрузки предварительно обработанных фрагментов HTML).
Мы можем добавить маршрутизацию, изменив вызов m.mount
на вызов m.route
:
// src/index.js
var m = require('mithril');
var UserList = require('./views/UserList');
m.route(document.body, '/list', {
'/list': UserList,
});
Вызов m.route
указывает, что приложение будет монтироваться в document.body
. Аргумент "/list"
является маршрутом по умолчанию. Это означает, что пользователь будет перенаправлен на этот маршрут, если запрошенный маршрут не существует. Объект {"/list": UserList}
определяет соответствие существующих маршрутов и компонентов, которые обрабатывают каждый маршрут.
Обновление страницы в браузере теперь должно добавить #!/list
к URL-адресу, чтобы указать, что маршрутизация работает. Поскольку этот маршрут отображает UserList, мы по-прежнему должны видеть список людей на экране, как и раньше.
Фрагмент #!
известен как hashbang, и это обычно используемая строка для реализации маршрутизации на стороне клиента. Можно настроить эту строку с помощью m.route.prefix
. Некоторые конфигурации требуют поддержки изменений на стороне сервера, поэтому мы просто продолжим использовать hashbang для остальной части этого руководства.
Давайте добавим еще один маршрут в наше приложение для редактирования пользователей. Сначала создадим модуль под названием views/UserForm.js
// src/views/UserForm.js
module.exports = {
view: function () {
// TODO: Реализовать представление
},
};
Затем мы можем подключить (require
) этот новый модуль из src/index.js
// src/index.js
var m = require('mithril');
var UserList = require('./views/UserList');
var UserForm = require('./views/UserForm');
m.route(document.body, '/list', {
'/list': UserList,
});
И, наконец, мы можем создать маршрут, который ссылается на него:
// src/index.js
var m = require('mithril');
var UserList = require('./views/UserList');
var UserForm = require('./views/UserForm');
m.route(document.body, '/list', {
'/list': UserList,
'/edit/:id': UserForm,
});
Обратите внимание, что новый маршрут содержит :id
. Это параметр маршрута; вы можете рассматривать его как подстановочный знак; маршрут /edit/1
будет разрешаться в UserForm
с id
, равным "1"
. /edit/2
также будет разрешаться в UserForm
, но с id
, равным "2"
. И так далее.
Давайте реализуем компонент UserForm
, чтобы он мог реагировать на эти параметры маршрута:
// src/views/UserForm.js
var m = require('mithril');
module.exports = {
view: function () {
return m('form', [
m('label.label', 'First name'),
m('input.input[type=text][placeholder=First name]'),
m('label.label', 'Last name'),
m('input.input[placeholder=Last name]'),
m('button.button[type=submit]', 'Save'),
]);
},
};
И давайте добавим еще несколько стилей в styles.css
:
/* styles.css */
body,
.input,
.button {
font: normal 16px Verdana;
margin: 0;
}
.user-list {
list-style: none;
margin: 0 0 10px;
padding: 0;
}
.user-list-item {
background: #fafafa;
border: 1px solid #ddd;
color: #333;
display: block;
margin: 0 0 1px;
padding: 8px 15px;
text-decoration: none;
}
.user-list-item:hover {
text-decoration: underline;
}
.label {
display: block;
margin: 0 0 5px;
}
.input {
border: 1px solid #ddd;
border-radius: 3px;
box-sizing: border-box;
display: block;
margin: 0 0 10px;
padding: 10px 15px;
width: 100%;
}
.button {
background: #eee;
border: 1px solid #ddd;
border-radius: 3px;
color: #333;
display: inline-block;
margin: 0 0 10px;
padding: 10px 15px;
text-decoration: none;
}
.button:hover {
background: #e8e8e8;
}
Сейчас этот компонент никак не реагирует на действия пользователя. Давайте добавим немного кода в нашу модель User
в src/models/User.js
. Вот как выглядит код прямо сейчас:
// src/models/User.js
var m = require('mithril');
var User = {
list: [],
loadList: function () {
return m
.request({
method: 'GET',
url: 'https://mithril-rem.fly.dev/api/users',
withCredentials: true,
})
.then(function (result) {
User.list = result.data;
});
},
};
module.exports = User;
Давайте добавим код, позволяющий нам загружать одного пользователя
// src/models/User.js
var m = require('mithril');
var User = {
list: [],
loadList: function () {
return m
.request({
method: 'GET',
url: 'https://mithril-rem.fly.dev/api/users',
withCredentials: true,
})
.then(function (result) {
User.list = result.data;
});
},
current: {},
load: function (id) {
return m
.request({
method: 'GET',
url: 'https://mithril-rem.fly.dev/api/users/' + id,
withCredentials: true,
})
.then(function (result) {
User.current = result;
});
},
};
module.exports = User;
Обратите внимание, что мы добавили свойство User.current
и метод User.load(id)
, который заполняет это свойство. Теперь мы можем заполнить представление UserForm
с помощью этого нового метода:
// src/views/UserForm.js
var m = require('mithril');
var User = require('../models/User');
module.exports = {
oninit: function (vnode) {
User.load(vnode.attrs.id);
},
view: function () {
return m('form', [
m('label.label', 'First name'),
m('input.input[type=text][placeholder=First name]', {
value: User.current.firstName,
}),
m('label.label', 'Last name'),
m('input.input[placeholder=Last name]', { value: User.current.lastName }),
m('button.button[type=submit]', 'Save'),
]);
},
};
Подобно компоненту UserList
, oninit
вызывает User.load()
. Помните, у нас был параметр маршрута под названием :id
в маршруте "/edit/:id": UserForm
? Параметр маршрута становится атрибутом виртуального узла (vnode) компонента UserForm
, поэтому маршрутизация к /edit/1
приведет к тому, что vnode.attrs.id
будет иметь значение "1"
.
Теперь давайте изменим представление UserList
, чтобы мы могли переходить оттуда к UserForm
:
// src/views/UserList.js
var m = require('mithril');
var User = require('../models/User');
module.exports = {
oninit: User.loadList,
view: function () {
return m(
'.user-list',
User.list.map(function (user) {
return m(
m.route.Link,
{
class: 'user-list-item',
href: '/edit/' + user.id,
},
user.firstName + ' ' + user.lastName
);
})
);
},
};
Здесь мы заменили vnode .user-list-item
на m.route.Link
с этим классом и теми же дочерними элементами. Мы добавили href
, который ссылается на нужный нам маршрут. Это означает, что нажатие на ссылку изменит часть URL-адреса, которая идет после hashbang #!
(решётка и восклицательный знак), таким образом, изменив маршрут без перезагрузки текущей HTML-страницы. Внутри для реализации ссылки используется элемент <a>
, что обеспечивает корректную работу.
Если вы обновите страницу в браузере, теперь вы сможете щелкнуть на человеке и перейти к форме. Вы также должны иметь возможность нажать кнопку «Назад» в браузере, чтобы вернуться из формы к списку людей.
Форма все еще не сохраняется при нажатии кнопки "Сохранить". Давайте исправим это и обеспечим сохранение данных формы:
// src/views/UserForm.js
var m = require('mithril');
var User = require('../models/User');
module.exports = {
oninit: function (vnode) {
User.load(vnode.attrs.id);
},
view: function () {
return m(
'form',
{
onsubmit: function (e) {
e.preventDefault();
User.save();
},
},
[
m('label.label', 'First name'),
m('input.input[type=text][placeholder=First name]', {
oninput: function (e) {
User.current.firstName = e.target.value;
},
value: User.current.firstName,
}),
m('label.label', 'Last name'),
m('input.input[placeholder=Last name]', {
oninput: function (e) {
User.current.lastName = e.target.value;
},
value: User.current.lastName,
}),
m('button.button[type=submit]', 'Save'),
]
);
},
};
Мы добавили обработчики событий oninput
для обоих полей ввода. Эти обработчики обновляют свойства User.current.firstName
и User.current.lastName
при каждом изменении текста в полях.
Также, мы указали, что метод User.save
должен вызываться при отправке формы (нажатии кнопки "Сохранить"). Теперь реализуем этот метод:
// src/models/User.js
var m = require('mithril');
var User = {
list: [],
loadList: function () {
return m
.request({
method: 'GET',
url: 'https://mithril-rem.fly.dev/api/users',
withCredentials: true,
})
.then(function (result) {
User.list = result.data;
});
},
current: {},
load: function (id) {
return m
.request({
method: 'GET',
url: 'https://mithril-rem.fly.dev/api/users/' + id,
withCredentials: true,
})
.then(function (result) {
User.current = result;
});
},
save: function () {
return m.request({
method: 'PUT',
url: 'https://mithril-rem.fly.dev/api/users/' + User.current.id,
body: User.current,
withCredentials: true,
});
},
};
module.exports = User;
В методе save
мы используем HTTP-метод PUT
, чтобы указать, что мы обновляем существующие данные на сервере.
Теперь попробуйте изменить имя пользователя в приложении и сохранить изменения. Вы должны увидеть, что изменения отображаются в списке пользователей.
Сейчас вернуться к списку пользователей можно только с помощью кнопки "Назад" в браузере. Было бы удобнее иметь меню, или, в более общем смысле, общую структуру, в которой можно разместить элементы глобального пользовательского интерфейса.
Давайте создадим файл src/views/Layout.js
:
// src/views/Layout.js
var m = require('mithril');
module.exports = {
view: function (vnode) {
return m('main.layout', [
m('nav.menu', [m(m.route.Link, { href: '/list' }, 'Users')]),
m('section', vnode.children),
]);
},
};
Этот компонент достаточно прост: он содержит <nav>
со ссылкой на список пользователей. Как и в случае со ссылками /edit
, эта ссылка использует m.route.Link
для создания маршрутизируемой ссылки.
Обратите внимание на элемент <section>
, его дочерние элементы - vnode.children
. vnode
- это ссылка на виртуальный узел (vnode), представляющий экземпляр компонента Layout (то есть vnode, возвращаемый вызовом m(Layout)
). Таким образом, vnode.children
ссылаются на дочерние элементы этого виртуального узла.
И давайте обновим стили:
/* styles.css */
body,
.input,
.button {
font: normal 16px Verdana;
margin: 0;
}
.layout {
margin: 10px auto;
max-width: 1000px;
}
.menu {
margin: 0 0 30px;
}
.user-list {
list-style: none;
margin: 0 0 10px;
padding: 0;
}
.user-list-item {
background: #fafafa;
border: 1px solid #ddd;
color: #333;
display: block;
margin: 0 0 1px;
padding: 8px 15px;
text-decoration: none;
}
.user-list-item:hover {
text-decoration: underline;
}
.label {
display: block;
margin: 0 0 5px;
}
.input {
border: 1px solid #ddd;
border-radius: 3px;
box-sizing: border-box;
display: block;
margin: 0 0 10px;
padding: 10px 15px;
width: 100%;
}
.button {
background: #eee;
border: 1px solid #ddd;
border-radius: 3px;
color: #333;
display: inline-block;
margin: 0 0 10px;
padding: 10px 15px;
text-decoration: none;
}
.button:hover {
background: #e8e8e8;
}
Давайте изменим маршрутизатор в src/index.js
, чтобы добавить нашу общую структуру (Layout):
// src/index.js
var m = require('mithril');
var UserList = require('./views/UserList');
var UserForm = require('./views/UserForm');
var Layout = require('./views/Layout');
m.route(document.body, '/list', {
'/list': {
render: function () {
return m(Layout, m(UserList));
},
},
'/edit/:id': {
render: function (vnode) {
return m(Layout, m(UserForm, vnode.attrs));
},
},
});
Мы заменили каждый компонент объектом RouteResolver (фактически, это объект с методом render
). Методы render
можно писать так же, как и обычные представления компонентов, используя вложенные вызовы m()
.
Обратите внимание, что компоненты можно использовать в вызове m()
вместо строки селектора. Здесь, в маршруте /list
, у нас есть m(Layout, m(UserList))
. Это означает, что есть корневой виртуальный узел, представляющий экземпляр Layout
, у которого есть виртуальный узел UserList
в качестве единственного дочернего элемента.
Маршрут /edit/:id
также содержит аргумент vnode
, который передает параметры маршрута компоненту UserForm
. Итак, если URL-адрес /edit/1
, то vnode.attrs
в этом случае будет {id: 1}
, и это m(UserForm, vnode.attrs)
эквивалентно m(UserForm, {id: 1})
. Эквивалентный код JSX будет выглядеть так: <UserForm id={vnode.attrs.id} />
.
Обновите страницу в браузере, и теперь вы увидите глобальную навигацию на каждой странице приложения.
На этом руководство завершается.
В этом руководстве мы прошли процесс создания очень простого приложения, в котором мы можем получать список пользователей с сервера и редактировать каждого из них. В качестве дополнительного упражнения попробуйте самостоятельно реализовать создание и удаление пользователей.
Если вы хотите увидеть больше примеров кода Mithril.js, посетите страницу examples. Если у вас есть вопросы, вы можете присоединиться к чату Mithril.js.