habrahabr

Применение наследования при генерации WEB-страниц на чистом JavaScript

  • суббота, 16 марта 2019 г. в 00:21:02
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-фреймворках, и вообще во фронтенд-разработке.

Спасибо.