https://habr.com/ru/post/443762/- JavaScript
- Программирование
Привет, Хабр!
Я не являюсь фронтенд-разработчиком, но иногда возникают задачи быстрого прототипирования WEB-интерфейса применительно к бизнес-приложениям. Специфика отрасли — множество похожих друг на друга сущностей (а значит и интерактивных форм), где применение ООП, а конкретно наследования — очень облегчает жизнь. Я слышал, что в мире WEB для борьбы со сложностью применяют, в основном, композицию, но мне хотелось использовать именно наследование — оно дает более жесткую, связную структуру приложения (в отличие от слабо-связной компонентной), и хорошо отражает предметную область. Задача звучала так — на сервере есть структуры данных, связанные иерархией наследования, необходимо создать в браузере аналогичную иерархию интерактивных форм (страниц), где наследовались бы разметка, стили и поведение — естественно, с возможностью до-пере-определить любую из сущностей.
Ограничения я себе выставил следущие:
- Cерверную генерацию WEB-интерфейса (с помощью которой легко решалась моя задача) я считаю устаревшей, и придерживаюсь подхода генерации UI строго на клиенте, оставляя серверу лишь хранение данных и тяжелые расчеты (да, я верю в PWA).
- Интерфейс должен верстаться в текстовой форме, на чистом HTML — я до сих пор не могу смириться с объектными обертками над HTML (типа Dart), так как в свое время намучился с различными обертками над SQL, которые то не поддерживали новейшие возможности языка (например хинты), то были намного медленней и прожорливей, чем ожидалось. Этот импринт сидит во мне прочно, и я наверное всегда буду писать SQL, HTML и CSS — текстом, как в 90-х. И даже обработчики событий я предпочитаю вешать в разметке <input onkeydown=''doit(this)''>, а не назначать скриптом. Понимаю, вопрос религиозный, с кем не бывает. С другой стороны, зачем учить новый декларативный язык, если и старый неплох.
Поверхностный поиск готовых решений не дал результатов, времени разбираться с кучей фреймворков не было, и я решил запилить велосипед на чистом JS, тем более, что у него из коробки есть классы с наследованием и модули с инкапсуляцией — почти как у взрослых ЯП. В итоге вырисовывалась такая архитектура:
— Точкой входа в каждую страницу должен стать Javascript, а не HTML. В моем случае страница представлена одним файлом-модулем JS, дефолтно экспортирующем единственный класс, который и определяет разметку, стили и поведение данной страницы.
— Классы страниц могут наследоваться друг-от-друга, и все восходят к одному базовому предку, определяющему содержимое HEAD, базовые стили, базовый контент BODY (колонтитулы, навигацию и т.д.), и базовые функции-обработчики.
— Каждая страница, однажды посещенная, сохраняет в памяти клон дерева DOM вместе с данными, введенными пользователем, и/или полученными с сервера. При повторном заходе на страницу — она восстанавливает DOM (то есть разметку+стили+скрипты+данные). Повторный вход на страницу, заполненную данными, особенно полезен в контексте мобильных устройств, где насыщенные десктопные формы приходится разбивать на множство связанных мелких.
— Все страницы имеют доступ к сохраненнму DOM друг-друга. Таким образом, не требуется иметь общий сессионный объект — каждая форма хранит свои данные сама, лишь добавляя ссылку на себя в объект window.
Я понимаю, что для профессионального «фронтендщика» все перечисленное звучит банально, но я, как джун, был очень обрадован красотой и лаконичностью получившегося решения, и решил оставить эту статью здесь — может еще какому джуну пригодится.
В качестве простого примера —
приложение из 3-х страниц. Первая страница домашняя, на второй пользователь загружает файл с данными, а на третьей — вводит формулу и получает результат расчета над данными второй страницы. Далее, как говорится, «talk is cheap, show me the code».
Точка входа в приложение — index.html. Импортируем класс домашней страницы, инстанцируем и отображаем. Также импортируем глобальную функцию навигации, которая используется в разметке примерно так: <button onclick=''nav('Page_Home')''>
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script type="module">
import Page_Home from './Page_Home.js'
(new Page_Home()).show()
import {nav} from './Nav.js'
window.nav = nav
</script>
</body>
</html>
Базовый предок всех страниц — содержит методы, возвращающие различные блоки разметки, функции-обработчики (если есть), метод первичной инициализации load(), и метод отображения view(), который, собственно, и занимается сохранением/восстановлением DOM при входе/выходе.
// module Page_.js
export default class Page_ {
// возвращает содержимое HEAD
head() { return `
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JS OOP</title>
<style></style>
`}
// возвращает встроенные стили, часто переопределяется
style() { return `
.menubar {background-color: silver; font-weight: bold}
.a {color: darkblue}
.a:hover {color: darkred; cursor: pointer}
.acurr {color: darkred; background-color: white}
`
}
// возвращает содержимое BODY с общим контентом
body() { return `
<div class="menubar">
<span class="a" onclick="nav('Page_Home')"> Home </span>
<span class="a" onclick="nav('Page_Upload')"> Upoad data </span>
<span class="a" onclick="nav('Page_Calculate')"> Calculate </span>
</div>
<div id="content"></div>
`}
// возвращает уникальный контент страницы, всегда переопределяется
content() { return `
`}
// в этих переменных сохраняется DOM (элементы HEAD и BODY)
constructor() {
this.headsave = undefined
this.bodysave = undefined
}
// формирует страницу в первый раз, иногда переопределяется
load() {
document.head.innerHTML = this.head()
document.querySelector('head > style').innerHTML = this.style()
document.body.innerHTML = this.body()
document.querySelector('body > #content').innerHTML = this.content()
}
// вызывается при каждой навигации на страницу
// сохраняет DOM предыдущей страницы, восстанавливает DOM текущей
// сохраняет ссылку на себя в объекте window
// window.page содержит ссылку на текущую отображаемую страницу
// Декорирует ссылку на текущую страницу
show() {
if (window.page !== undefined) {
window.page.headsave = document.head.innerHTML
window.page.bodysave = document.body.cloneNode(true)
}
window.page = this
if (window[this.constructor.name] === undefined) {
window[this.constructor.name] = this
this.load()
} else {
document.head.innerHTML = this.headsave
document.body = this.bodysave
}
let a = document.querySelector('[onclick = "nav(\'' + this.constructor.name + '\')"]');
if (a !== null) {
a.className = 'acurr'
}
}
}
Домашняя страница — переопределяем только метод, возвращающий контент.
// module Page_Home.js
import Page_ from './Page_.js'
export default class Page_Home extends Page_ {
content() { return `
<h3>Hi, geek !</h3>
`}
}
Страница загрузки файла — переопределяем контент, добавляем один стиль, вводим новый обработчик fselect(). Обратите внимание, как в разметке назначается обработчик — через глобальную переменную page, которая всегда содержит ссылку на текущую страницу.
// module Page_Upload.js
import Page_ from './Page_.js'
export default class Page_Upload extends Page_ {
content() { return `
<br>
<input type="file" onchange="page.fselect(this)"/>
<br><br>
<textarea id="fcontent"></textarea>
`}
style() { return super.style() + `
textarea {width: 90vw; height: 15em}
`}
fselect(elem) {
let fr = new FileReader()
fr.readAsText(elem.files[0])
fr.onload = (ev) => {
document.querySelector('#fcontent').value = ev.target.result
}
}
}
Страница расчета — переопределяем контент, меняем заголовок страницы, добавляем обработчик.
// module Page_Calculate.js
import Page_ from './Page_.js'
export default class Page_Calculate extends Page_ {
content() { return `
<br>
<label for="formula">Formula:</label><br>
<textarea id="formula" style="width:90vw; height:5em">data.length</textarea>
<br><br>
<button onclick="page.calc()">Calculate...</button>
<br><br>
<div id = "result"></div>
`}
load() {
super.load()
let t = document.querySelector('head > title')
t.innerHTML = 'Calculation result - ' + t.innerHTML
}
calc() {
let formula = document.querySelector('#formula').value
if (!formula) {
return alert('Formula is empty !')
}
let datapage = window.Page_Upload;
if (datapage === undefined) {
return nodata()
}
let data = datapage.bodysave.querySelector('#fcontent').value
if (!data) {
return nodata()
}
document.querySelector('#result').innerHTML = 'Result: ' + eval(formula)
function nodata() {
alert('Data is not loaded !')
}
}
}
Глобальная функция навигации. Если запрошенной страницы нет в памяти — создаем ее из класса, в противном случае активируем. К сожалению, в модуль навигации приходится статически импортировать классы всех страниц. Когда в браузерах будет окончательно имплементирован
динамический импорт — останется только функция.
// module Nav.js
import Page_Home from './Page_Home.js'
import Page_Upload from './Page_Upload.js'
import Page_Calculate from './Page_Calculate.js'
export function nav(pagename) {
if (window[pagename] === undefined) {
eval('new ' + pagename + '()').show()
} else {
window[pagename].show()
}
}
Собственно это все, что я хотел показать — страницы получились достаточно компактными (чем глубже иерархия наследования тем компактнее потомки), стили и функции строго инкапсулированы, разметка и код расположены рядом, в одном файле. Сохранение контекста позволяет строить многостраничные иерерхические формы, мастера-помощники, и т.д.
Недостатки:
- Наследование в JS реализовано синтаксически немного странно, но привыкнуть можно. Отсутствует множественное, но для данной задачи оно вряд-ли потребуется.
- Трудно объяснить моему редактору, что внутри JS есть куски HTML и CSS, не работают подсказки и автокомплит, но, думаю, это решаемо.
Работающий пример
тут.
P.S.: буду благодарен за информацию — применяется ли наследование в WEB-фреймворках, и вообще во фронтенд-разработке.
Спасибо.