Применение наследования при генерации WEB-страниц на чистом 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-фреймворках, и вообще во фронтенд-разработке.

    Спасибо.
    Поделиться публикацией

    Комментарии 23

      0
      Ваши потребности в принципе понятны, но для таких решений существуют уже готовые «штучки». Например можно сделать так:
      image

      Атрибут handsontable разворачивается в таблицу handsontable.

      Это было сделано на angularjs directive. Директивы можно вкладывать в директивы и вот мы получаем наследование. Я делал наследование на 4 уровня (transclude и всё такое). Имеем отдельные html для формы и код JS для обработки формы. Очень удобно. Вроде как то что требовалось?

      Видел другие фреймворки, но не изучал. Думаю, что кто-нибудь может меня дополнить.

      P.S.
      Angular.JS: основы создания директив
        –2
        P.P.S.
        уже есть AngularTS 7.x. Он уже на TypeScript. Просто лично я в TS не очень, поэтому и сижу на AngularJS.
          0
          Спасибо, с версткой понятно. А могу я унаследовать обработчики событий, и в каком-то из потомков просто переопределить их — onclick() { super.onclick(); dosome(); }? Ну например, у формы-потомка более сложные проверки, в дополнении к проверкам предка, и т.д.
            0
            Пусть форма-потомок проверяет только то, что нужно. Зачем ей перехватывать проверки? Вы посмотрите в отладчике браузера сколько событий onclick помимо вашего висит на кнопке — и как вы будете отличать их друг от друга, чтобы вызвать только тот, который нужен? Они все обезличены.
            Если вам нужны проверки по бизнеслогике, то лучше все проверки делать на сервере, а клиенту только уведомления слать — верно или нет задан параметр или зависимости между ними.
              0
              Моим первым серьезным языком была Java, и этот ООП из головы теперь не выбить — у вас в WEB можно на событие повесить много разных обработчиков, а я привык все алгоритмы прогонять через бутылочное горлышко иерархии классов — каждый потомок проверяет свое и отдает управление super(). Вообще, вопрос философский — компонентная или там микро-сервисная архитектура конечно гибче, но жесткая иерархия классов более управляема, плата за это — внесение изменений в предка может тянуть на отдельный проект, поэтому и не любят ООП в больших проектах.
              PS
              Сервера у меня может и не быть — прога должна работать в оффлайне с indexeddb, а если появился интернет — тогда реплика на сервер, и отчеты оттуда же.
                0
                но жесткая иерархия классов более управляема
                Всё зависит от контекста. Перед тем, как применять свой опыт убедитесь, что выбранная вами аналогия подходит к контексту задачи. В JS нет такого понятия как 'super'. Следовательно вы не сможете сделать решение через super. С этого момента перестаём натягивать сову на глобус и ищем другое решение. Добавьте в копилку опыта разрешать себе менять свои принципы в зависимости от новых сведений по задаче.
                    0
                    Ух-ты! Я и забыл про это!!! ))) Спасибо, что напомнили. Только вы учли, что это не относится к DOM? Вы пытаетесь «натянуть» DOM на вашу объектную модель и мне кажется, что это не сработает. Нельзя применить к node, например, div, ваш класс.
                      +1
                      Классы не применяются к DOM, а наоборот — пользовательские классы это первичный костяк, а клон DOM просто сохранен в полях объекта. То есть страница ведет себя как полноценный объект — есть данные (DOM), а есть методы (события этого самого DOM, и операции по его изменению.)
                        0
                        Вы пытаетесь убедить меня или браузер? ))) Попробуйте применить ваш метод, только потом напишите получилось ли и и если да, то насколько кроссбраузерное решение получилось?
                        В качестве академического теста мне кажется, что сойдёт, но как для использования в бизнеслогике приложения лично мне идея не нравится. Данные клиента всё равно надо проверять на сервере и смысл отпускать его в offline? Вам тогда придётся синхронизировать код по проверке на клиенте и на сервере (или делать один код по проверке и запускать его, например в nashorn или node) Или задача сильно специфическая, что без функции offline не жить?
                          0
                          Если на сервере нода — не проблема скрипты синхронизировать, но у меня скорее всего будет гошка, так что тут Вы правы.
                    0
                    Как же нет супера, у меня в коде super.load() — это ж полноценные классы только без инкапсуляции.
            +1
            Cерверную генерацию WEB-интерфейса (с помощью которой легко решалась моя задача) я считаю устаревшей
            То-то сейчас библиотеки для сервер-сайд рендеринга растут как грибы после дождя)
            где наследовались бы разметка, стили и поведение

            Интерфейс должен верстаться в текстовой форме, на чистом HTML
            Вам не видится противоречие между этими двумя пунктами? Если в странице-потомке вам нужно дописать немного разметки в конце, то, конечно, всё хорошо. А если в середине? А если немного поменять разметку в сотне элементов?
              0
              К приходу PWA готовлюсь заранее :), да и устал от бездушного серверного энтерпрайза, хочется попробовать что-то легковесное и няшное.

              Что касается «дописать в середине» — какие методы в базовом классе вы нарежете, какие дивы расставите — такая и будет степень свободы. Это ж не фреймворк готовый, а только подход — в каждом проекте по сути свой фреймворк пилится, свои базовые классы, своя иерархия.
                0
                И в итоге придётся прийти к «объектной обёртке», которая вас не устраивала)
                  +1
                  Возможно так и будет, сложность мира непобедима :)
              +1
              Направление мыслей правильное, но:
              а) Нет смысла делать все «врукопашную». Лучше взять Ангуляр и сосредоточиться на контролерах и шаблонах страниц для вашего приложения, а не на инфраструктуре.
              б) Лучше не объединять контроллер и вьюху в одном классе, а разделить. Тогда у вас получится две иерархии: контроллеры и вьюхи. Как правило, одному классу контроллера соответствует одна вьюха, но могут быть исключения.
              в) Так лучше не просто выводить HTML строкой, а сделать простой билдер HTML из дерева объектов. Тогда можно будет при необходимости модифицировать ветку элементов, созданных базовым классом.
              г) Стили однозначно должны жить снаружи.

              Ну и вся эта «философия пуризма», приведенная вначале — только чистые JS, HTML, SQL, whatewer — это не повод для гордости. Лучше переосмыслить.
                0
                г) Стили однозначно должны жить снаружи.
                Ну отчего ж, styled components — вполне себе решение.
                  0
                  Я имею в виду, что CSS лучше держать во внешнем файле.
                    +1
                      0
                      OK, point taken. Но все равно, мне кажется, что на этом этапе развития обсуждаемого проекта лучше начать с простого.
                  0
                  Спасибо, посмотрю Ангуляр, тем более что он для Дарта есть. А не хотелось туда погружаться именно из-за практики разделения сущностей по разным файлам/шаблонам. Страницы (по крайней мере мобильные) обычно весьма маленькие, и читая верстку с установленными прямо в тексте обработчиками, я сразу понимаю что и как работает, и код этих обработчиков тут же рядом, и все это в обычном текстовом редакторе. Как только берешь фреймворк — сразу нужна IDE, которая все свяжет и проконтролирует, и вот я уже раб лампы. А поскольку, как Вы сами говорите — переиспользование контроллеров обычно не происходит, то и непонятно, зачем нам 2 иерархии.
                    0
                    Visual Studio Code, например, мало чем отличается от обычного текстового редактора. Грузится мгновенно, никаких особых файлов конфигурации не требует. Но код в ней писать гораздо удобнее, чем в «условном ноутпаде». Подсветка синтаксиса, очепяток. Зачем себя искусственно ограничивать?

                    Насчет разделения вьюх и контроллеров — любой код хорошо читается, пока его мало. Как только появляется скроллинг, становится заметно сложнее. При этом несколько маленьких получаются гораздо удобнее, чем один большой.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое