Я не являюсь фронтенд-разработчиком, но иногда возникают задачи быстрого прототипирования 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-фреймворках, и вообще во фронтенд-разработке.
Спасибо.
Комментарии (23)
Sirion
15.03.2019 09:50+1Cерверную генерацию WEB-интерфейса (с помощью которой легко решалась моя задача) я считаю устаревшей
То-то сейчас библиотеки для сервер-сайд рендеринга растут как грибы после дождя)
где наследовались бы разметка, стили и поведение
Вам не видится противоречие между этими двумя пунктами? Если в странице-потомке вам нужно дописать немного разметки в конце, то, конечно, всё хорошо. А если в середине? А если немного поменять разметку в сотне элементов?
Интерфейс должен верстаться в текстовой форме, на чистом HTMLepishman Автор
15.03.2019 10:04К приходу PWA готовлюсь заранее :), да и устал от бездушного серверного энтерпрайза, хочется попробовать что-то легковесное и няшное.
Что касается «дописать в середине» — какие методы в базовом классе вы нарежете, какие дивы расставите — такая и будет степень свободы. Это ж не фреймворк готовый, а только подход — в каждом проекте по сути свой фреймворк пилится, свои базовые классы, своя иерархия.
AndrewMayorov
15.03.2019 15:18+1Направление мыслей правильное, но:
а) Нет смысла делать все «врукопашную». Лучше взять Ангуляр и сосредоточиться на контролерах и шаблонах страниц для вашего приложения, а не на инфраструктуре.
б) Лучше не объединять контроллер и вьюху в одном классе, а разделить. Тогда у вас получится две иерархии: контроллеры и вьюхи. Как правило, одному классу контроллера соответствует одна вьюха, но могут быть исключения.
в) Так лучше не просто выводить HTML строкой, а сделать простой билдер HTML из дерева объектов. Тогда можно будет при необходимости модифицировать ветку элементов, созданных базовым классом.
г) Стили однозначно должны жить снаружи.
Ну и вся эта «философия пуризма», приведенная вначале — только чистые JS, HTML, SQL, whatewer — это не повод для гордости. Лучше переосмыслить.Sirion
15.03.2019 15:29г) Стили однозначно должны жить снаружи.
Ну отчего ж, styled components — вполне себе решение.AndrewMayorov
15.03.2019 15:38Я имею в виду, что CSS лучше держать во внешнем файле.
Sirion
15.03.2019 15:41+1AndrewMayorov
15.03.2019 15:50OK, point taken. Но все равно, мне кажется, что на этом этапе развития обсуждаемого проекта лучше начать с простого.
epishman Автор
15.03.2019 18:45Спасибо, посмотрю Ангуляр, тем более что он для Дарта есть. А не хотелось туда погружаться именно из-за практики разделения сущностей по разным файлам/шаблонам. Страницы (по крайней мере мобильные) обычно весьма маленькие, и читая верстку с установленными прямо в тексте обработчиками, я сразу понимаю что и как работает, и код этих обработчиков тут же рядом, и все это в обычном текстовом редакторе. Как только берешь фреймворк — сразу нужна IDE, которая все свяжет и проконтролирует, и вот я уже раб лампы. А поскольку, как Вы сами говорите — переиспользование контроллеров обычно не происходит, то и непонятно, зачем нам 2 иерархии.
AndrewMayorov
15.03.2019 21:47Visual Studio Code, например, мало чем отличается от обычного текстового редактора. Грузится мгновенно, никаких особых файлов конфигурации не требует. Но код в ней писать гораздо удобнее, чем в «условном ноутпаде». Подсветка синтаксиса, очепяток. Зачем себя искусственно ограничивать?
Насчет разделения вьюх и контроллеров — любой код хорошо читается, пока его мало. Как только появляется скроллинг, становится заметно сложнее. При этом несколько маленьких получаются гораздо удобнее, чем один большой.
AlexZaharow
Ваши потребности в принципе понятны, но для таких решений существуют уже готовые «штучки». Например можно сделать так:
Атрибут handsontable разворачивается в таблицу handsontable.
Это было сделано на angularjs directive. Директивы можно вкладывать в директивы и вот мы получаем наследование. Я делал наследование на 4 уровня (transclude и всё такое). Имеем отдельные html для формы и код JS для обработки формы. Очень удобно. Вроде как то что требовалось?
Видел другие фреймворки, но не изучал. Думаю, что кто-нибудь может меня дополнить.
P.S.
Angular.JS: основы создания директив
AlexZaharow
P.P.S.
уже есть AngularTS 7.x. Он уже на TypeScript. Просто лично я в TS не очень, поэтому и сижу на AngularJS.
epishman Автор
Спасибо, с версткой понятно. А могу я унаследовать обработчики событий, и в каком-то из потомков просто переопределить их — onclick() { super.onclick(); dosome(); }? Ну например, у формы-потомка более сложные проверки, в дополнении к проверкам предка, и т.д.
AlexZaharow
Пусть форма-потомок проверяет только то, что нужно. Зачем ей перехватывать проверки? Вы посмотрите в отладчике браузера сколько событий onclick помимо вашего висит на кнопке — и как вы будете отличать их друг от друга, чтобы вызвать только тот, который нужен? Они все обезличены.
Если вам нужны проверки по бизнеслогике, то лучше все проверки делать на сервере, а клиенту только уведомления слать — верно или нет задан параметр или зависимости между ними.
epishman Автор
Моим первым серьезным языком была Java, и этот ООП из головы теперь не выбить — у вас в WEB можно на событие повесить много разных обработчиков, а я привык все алгоритмы прогонять через бутылочное горлышко иерархии классов — каждый потомок проверяет свое и отдает управление super(). Вообще, вопрос философский — компонентная или там микро-сервисная архитектура конечно гибче, но жесткая иерархия классов более управляема, плата за это — внесение изменений в предка может тянуть на отдельный проект, поэтому и не любят ООП в больших проектах.
PS
Сервера у меня может и не быть — прога должна работать в оффлайне с indexeddb, а если появился интернет — тогда реплика на сервер, и отчеты оттуда же.
AlexZaharow
Sirion
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/super
AlexZaharow
Ух-ты! Я и забыл про это!!! ))) Спасибо, что напомнили. Только вы учли, что это не относится к DOM? Вы пытаетесь «натянуть» DOM на вашу объектную модель и мне кажется, что это не сработает. Нельзя применить к node, например, div, ваш класс.
epishman Автор
Классы не применяются к DOM, а наоборот — пользовательские классы это первичный костяк, а клон DOM просто сохранен в полях объекта. То есть страница ведет себя как полноценный объект — есть данные (DOM), а есть методы (события этого самого DOM, и операции по его изменению.)
AlexZaharow
Вы пытаетесь убедить меня или браузер? ))) Попробуйте применить ваш метод, только потом напишите получилось ли и и если да, то насколько кроссбраузерное решение получилось?
В качестве академического теста мне кажется, что сойдёт, но как для использования в бизнеслогике приложения лично мне идея не нравится. Данные клиента всё равно надо проверять на сервере и смысл отпускать его в offline? Вам тогда придётся синхронизировать код по проверке на клиенте и на сервере (или делать один код по проверке и запускать его, например в nashorn или node) Или задача сильно специфическая, что без функции offline не жить?
epishman Автор
Если на сервере нода — не проблема скрипты синхронизировать, но у меня скорее всего будет гошка, так что тут Вы правы.
epishman Автор
Как же нет супера, у меня в коде super.load() — это ж полноценные классы только без инкапсуляции.