Привет, Хабр!

Я не являюсь фронтенд-разработчиком, но иногда возникают задачи быстрого прототипирования 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)


  1. AlexZaharow
    15.03.2019 08:58

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

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

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

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

    P.S.
    Angular.JS: основы создания директив


    1. AlexZaharow
      15.03.2019 09:35
      -2

      P.P.S.
      уже есть AngularTS 7.x. Он уже на TypeScript. Просто лично я в TS не очень, поэтому и сижу на AngularJS.


    1. epishman Автор
      15.03.2019 09:55

      Спасибо, с версткой понятно. А могу я унаследовать обработчики событий, и в каком-то из потомков просто переопределить их — onclick() { super.onclick(); dosome(); }? Ну например, у формы-потомка более сложные проверки, в дополнении к проверкам предка, и т.д.


      1. AlexZaharow
        15.03.2019 10:41

        Пусть форма-потомок проверяет только то, что нужно. Зачем ей перехватывать проверки? Вы посмотрите в отладчике браузера сколько событий onclick помимо вашего висит на кнопке — и как вы будете отличать их друг от друга, чтобы вызвать только тот, который нужен? Они все обезличены.
        Если вам нужны проверки по бизнеслогике, то лучше все проверки делать на сервере, а клиенту только уведомления слать — верно или нет задан параметр или зависимости между ними.


        1. epishman Автор
          15.03.2019 11:21

          Моим первым серьезным языком была Java, и этот ООП из головы теперь не выбить — у вас в WEB можно на событие повесить много разных обработчиков, а я привык все алгоритмы прогонять через бутылочное горлышко иерархии классов — каждый потомок проверяет свое и отдает управление super(). Вообще, вопрос философский — компонентная или там микро-сервисная архитектура конечно гибче, но жесткая иерархия классов более управляема, плата за это — внесение изменений в предка может тянуть на отдельный проект, поэтому и не любят ООП в больших проектах.
          PS
          Сервера у меня может и не быть — прога должна работать в оффлайне с indexeddb, а если появился интернет — тогда реплика на сервер, и отчеты оттуда же.


          1. AlexZaharow
            15.03.2019 15:38

            но жесткая иерархия классов более управляема
            Всё зависит от контекста. Перед тем, как применять свой опыт убедитесь, что выбранная вами аналогия подходит к контексту задачи. В JS нет такого понятия как 'super'. Следовательно вы не сможете сделать решение через super. С этого момента перестаём натягивать сову на глобус и ищем другое решение. Добавьте в копилку опыта разрешать себе менять свои принципы в зависимости от новых сведений по задаче.


            1. Sirion
              15.03.2019 15:40

              1. AlexZaharow
                15.03.2019 17:02

                Ух-ты! Я и забыл про это!!! ))) Спасибо, что напомнили. Только вы учли, что это не относится к DOM? Вы пытаетесь «натянуть» DOM на вашу объектную модель и мне кажется, что это не сработает. Нельзя применить к node, например, div, ваш класс.


                1. epishman Автор
                  15.03.2019 18:26
                  +1

                  Классы не применяются к DOM, а наоборот — пользовательские классы это первичный костяк, а клон DOM просто сохранен в полях объекта. То есть страница ведет себя как полноценный объект — есть данные (DOM), а есть методы (события этого самого DOM, и операции по его изменению.)


                  1. AlexZaharow
                    15.03.2019 18:49

                    Вы пытаетесь убедить меня или браузер? ))) Попробуйте применить ваш метод, только потом напишите получилось ли и и если да, то насколько кроссбраузерное решение получилось?
                    В качестве академического теста мне кажется, что сойдёт, но как для использования в бизнеслогике приложения лично мне идея не нравится. Данные клиента всё равно надо проверять на сервере и смысл отпускать его в offline? Вам тогда придётся синхронизировать код по проверке на клиенте и на сервере (или делать один код по проверке и запускать его, например в nashorn или node) Или задача сильно специфическая, что без функции offline не жить?


                    1. epishman Автор
                      15.03.2019 19:00

                      Если на сервере нода — не проблема скрипты синхронизировать, но у меня скорее всего будет гошка, так что тут Вы правы.


            1. epishman Автор
              15.03.2019 18:23

              Как же нет супера, у меня в коде super.load() — это ж полноценные классы только без инкапсуляции.


  1. Sirion
    15.03.2019 09:50
    +1

    Cерверную генерацию WEB-интерфейса (с помощью которой легко решалась моя задача) я считаю устаревшей
    То-то сейчас библиотеки для сервер-сайд рендеринга растут как грибы после дождя)
    где наследовались бы разметка, стили и поведение

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


    1. epishman Автор
      15.03.2019 10:04

      К приходу PWA готовлюсь заранее :), да и устал от бездушного серверного энтерпрайза, хочется попробовать что-то легковесное и няшное.

      Что касается «дописать в середине» — какие методы в базовом классе вы нарежете, какие дивы расставите — такая и будет степень свободы. Это ж не фреймворк готовый, а только подход — в каждом проекте по сути свой фреймворк пилится, свои базовые классы, своя иерархия.


      1. Sirion
        15.03.2019 10:07

        И в итоге придётся прийти к «объектной обёртке», которая вас не устраивала)


        1. epishman Автор
          15.03.2019 10:11
          +1

          Возможно так и будет, сложность мира непобедима :)


  1. AndrewMayorov
    15.03.2019 15:18
    +1

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

    Ну и вся эта «философия пуризма», приведенная вначале — только чистые JS, HTML, SQL, whatewer — это не повод для гордости. Лучше переосмыслить.


    1. Sirion
      15.03.2019 15:29

      г) Стили однозначно должны жить снаружи.
      Ну отчего ж, styled components — вполне себе решение.


      1. AndrewMayorov
        15.03.2019 15:38

        Я имею в виду, что CSS лучше держать во внешнем файле.


        1. Sirion
          15.03.2019 15:41
          +1

          1. AndrewMayorov
            15.03.2019 15:50

            OK, point taken. Но все равно, мне кажется, что на этом этапе развития обсуждаемого проекта лучше начать с простого.


    1. epishman Автор
      15.03.2019 18:45

      Спасибо, посмотрю Ангуляр, тем более что он для Дарта есть. А не хотелось туда погружаться именно из-за практики разделения сущностей по разным файлам/шаблонам. Страницы (по крайней мере мобильные) обычно весьма маленькие, и читая верстку с установленными прямо в тексте обработчиками, я сразу понимаю что и как работает, и код этих обработчиков тут же рядом, и все это в обычном текстовом редакторе. Как только берешь фреймворк — сразу нужна IDE, которая все свяжет и проконтролирует, и вот я уже раб лампы. А поскольку, как Вы сами говорите — переиспользование контроллеров обычно не происходит, то и непонятно, зачем нам 2 иерархии.


      1. AndrewMayorov
        15.03.2019 21:47

        Visual Studio Code, например, мало чем отличается от обычного текстового редактора. Грузится мгновенно, никаких особых файлов конфигурации не требует. Но код в ней писать гораздо удобнее, чем в «условном ноутпаде». Подсветка синтаксиса, очепяток. Зачем себя искусственно ограничивать?

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