Pull to refresh

Реализация паттерна декоратор на JS

Reading time 10 min
Views 6.3K
Суть паттерна в том, что есть класс с фактической функциональностью (компонент) и опциональными классами-обертками, которые дополняют основной функционал (декораторы). А фишка в том, что декораторов может быть сколько угодно, совмещаться они могут в произвольном порядке и (поскольку требуют от компонента только интерфейса) — могут работать с разными компонентами.

Безусловно, реализовать что-то похожее можно даже за счет только лишь того, что функции в JS являются объектами первого уровня, но мне бы хотелось поделиться реализацией весьма близкой к ГОСТу GoF'у.

UPD: ссылка на рабочий пример, спасибо Barttos.

Перед хабракатом: в скрипте присутствует инкапсуляция, наследование (по сути) осуществляется через call, jQuery отсутствует — если Ваша идеология не позволяет принять такие ограничения, пожалуйста, не пишите об этом в комментах и, еще лучше, не читайте эту статью. Конструктивная критика и вопросы приветствуются.

Реализовывать будем простую листалку блоков. Не ново, но реализуем мы её так, что сможем перелистывать дивы без анимации или с ней (компоненты) и сможем выбирать, будут ли у нас кнопки переключения «страниц» :) или номер страницы (декораторы), или и то, и другое. Самое интересное, при использовании всего этого «богатства», нам будет без разницы ни как оно листается, ни сколько и каких UI-элементов задействовано.

HTML и CSS у листалки такой:
<html>
	<head>
		<title> </title>
		<style>
			#container { padding-top: 53px; padding-bottom: 3px; border: 1px solid gray; }
			#container, #scroll div { width: 100px; }
			#scroll, #scroll div { height: 50px; }
			#scroll div { float: left; }
			#container { position: relative; overflow: hidden; }
			#scroll { position: absolute; top: 0px; width: 1000px; border-bottom: 1px solid gray; }
		</style>
		<script>
		/*
		* в конце топика
		*/
		</script>
	</head>
	<body>
		<div id="container">
			<div id="scroll" style="left: 0px;">
			<div style="background: #ffc;">страница 1</div>
			<div style="background: #fcf;">страница 2</div>
			<div style="background: #cff;">страница 3</div>
			<div style="background: #fcc;">страница 4</div>
			<div style="background: #ccf;">страница 5</div>
			<div style="background: #cfc;">страница 6</div>
			<div style="background: #ccc;">страница 7</div>
			</div>
		</div>
		<script>
			/*
			* использование
			*/
		</script>
	</body>
</html>


Как накрутить UI сверху

Компонент является самостоятельной частью, готовой перематывать страницы при вызове .nextPage и .prevPage. Чтобы накрутить что-нибудь сверху нам надо:
  1. создать декоратор;
  2. передать декоратору компонент;
  3. сделать у декораторов теже методы, что у компонента;
  4. работать с методами декоратора, а он уже будет делать свою функциональность и вызывать теже методы у компонента.
Ключ ко всему этому — одинаковое именование методов, то бишь интерфейс.

Участники

Все компоненты должны иметь одинаковый интерфейс, и все декораторы должны иметь тот же самый интерфейс, но дополнительно расширенный, чтобы уметь принимать компонент.

Самый-самый интерфейс называется Component (в листинге — Scroll), а его реализации — ConcreteComponent (в листинге: SimpleScroll и AnimScroll). Интерфейс декораторов, Decorator (и в листинге Decorator), тоже основан на интерфейсе Component. А уже реализации Decorator'а, ConcreteDecorator (в листинге: Decorator_SwitchPage и Decorator_PageNum), относятся к Component'у косвенно.

Scroll и Decorator
Рекомендую посмотреть Scroll и Decorator, листинг кода внизу статьи. Как видим, Decorator переписывает (перегружает) все методы Scroll'а:
  • комментировать каждый из них повторно не требуется :)
  • Decorator запускает тот же метод у вложенного (через setComponent) компонента.
И Scroll, и Decorator берут на себя очень весомую часть грязной работы, т.к. они скорее базовые классы, чем интерфейсы.

SimpleScroll и AnimScroll
Благодаря Scroll'у, оба класса умеют работать с локальными переменными container и scroll. Это ноды с position: relative и absolute соответственно. Методы hasNextPage, hasPrevPage, findPages и getCurPage совпадают, но я не стал выносить их в Scroll, чтобы тот хоть немного напоминал интерфейс. Вполне можно вынести эти методы в промежуточный класс.

А вот nextPage и prevPage различаются, но декораторы просто вызывают методы и могут работать с обеими классами.

Decorator_SwitchPage и Decorator_PageNum
SwitchPage добавляет в container кнопки «вперед» и «назад».
PageNum добавляет индикатор текущей страницы, и их общего количества.

После подключения Decorator'а нам надо сохранить ссылки на его методы — в качестве костыля выступает локальная переменная methods. Она используется в перегруженных методах, чтобы запустить соответствующий метод у вложенного компонента. Хороший момент, чтобы оценить разницу в количестве методов у Decorator'а и его реализаций ;)

Использование

При использовании будет создаваться компонент — SimpleScroll или AnimScroll. Затем декораторы: PageNum и SwitchPage. В первый декоратор передается компонент, во второй декоратор — первый декоратор. Работать мы будем с крайним (самым верхним) декоратором, а он будет отправлять вызов методов вниз по цепочке.

SimpleScroll + PageNum + SwitchPage
// создаем компонент - основу для дальнейшей работы
component = new SimpleScroll();
component.setContainer(document.getElementById("container"));
component.setScroll(document.getElementById("scroll"));

// создаем первый декоратор
decorator1 = new Decorator_PageNum();
decorator1.setComponent(component); // заворачиваем компонент в декоратор

// создаем еще один декоратор
decorator2 = new Decorator_SwitchPage();
decorator2.setComponent(decorator1); // заворачиваем первый декоратор во второй

decorator2.init();
decorator2.nextPage();


Без декораторов
component = new SimpleScroll();
component.setContainer(document.getElementById("container"));
component.setScroll(document.getElementById("scroll"));

component.init();
component.nextPage();


AnimScroll + SwitchPage
component = new AnimScroll();
component.setContainer(document.getElementById("container"));
component.setScroll(document.getElementById("scroll"));

decorator1 = new Decorator_SwitchPage(); // или new Decorator_PageNum();
decorator1.setComponent(component);

decorator1.init();
decorator1.nextPage();


JavaScript

// Component (интерфейс)
// из-за scope, в нем так же будут заданы локальные переменные и сеттеры/геттеры к ним - не академично, но и не страшно
// если есть желание использовать только прототипирование, то надо создавать локальные переменные "на месте",
// а все функции, которые их используют в Scroll оставить пустыми
function Scroll() {
	var container, scroll;

	this.setContainer = function(val) {
	container = val;
	};
	this.setScroll = function(val) {
	scroll = val;
	};
	this.getContainer = function() {
	return container;
	};
	this.getScroll = function() {
	return scroll;
	};

	this.init = function() { }; // ручная инициализация после задания container и scroll
	// это несколько упростит задачу, т.к. нам не надо будет переписывать сеттеры

	this.nextPage = function() { }; // перематываем вперед
	this.prevPage = function() { }; // перематываем назад

	this.hasNextPage = function(depth) { }; // наличие следующей страницы, или следующих depth страниц; по-умолчанию depth = 1
	this.hasPrevPage = function(depth) { }; // наличие предыдущей страницы, или следующих depth страниц; по-умолчанию depth = 1
	this.findPages = function() { }; // метод возвр. кол-во страниц
	this.getCurPage = function() { }; // метод возвр. номер текущей страницы
}

// ConcreteComponent (реализация Component'а)
// самый простой скроллинг
function SimpleScroll() {
	var dublicate = this; // постоянная ссылка на инстанцирующийся объект (для работы с любым this)
	Scroll.call(this); // агрегируем интерфейс
	// Scroll уже умеет работать с container и scroll, но ничего более -
	// теперь нам надо реализовать (перегрузить) все пустые методы
	var curPage = 0; // текущая страница (0-4)

	this.init = function() { };

	this.nextPage = function() {
		if (dublicate.hasNextPage()) {
		this.getScroll().style.left = ++curPage * -100 +"px";
		}
	};
	this.prevPage = function() {
		if (dublicate.hasPrevPage()) {
		this.getScroll().style.left = --curPage * -100 +"px";
		}
	};

	this.hasNextPage = function(depth) {
		var depth = depth || 1;
		return curPage + depth < dublicate.findPages();
	};
	this.hasPrevPage = function(depth) {
		var depth = depth || 1;
		return curPage - depth >= 0;
	};
	this.findPages = function() {
		return this.getScroll().getElementsByTagName("div").length;
	};
	this.getCurPage = function() {
		return curPage;
	};
}

// ConcreteComponent (реализация Component'а)
// другой скроллинг, с анимацией
function AnimScroll() {
	var dublicate = this; // постоянная ссылка на инстанцирующийся объект (для работы с любым this)
	Scroll.call(this); // агрегируем интерфейс
	var curPage = 0; 
	var curOffset = 0; // текущее смещение страницы в пикселях

	this.init = function() { };

	this.nextPage = function() {
		if (dublicate.hasNextPage() && !curOffset) {
			curPage++; // прибавим сразу, чтобы декораторы работали
			curOffset = 0;
			nextPageIterate();
		}
	};
	function nextPageIterate() {
		curOffset -= 10;
		dublicate.getScroll().style.left = curOffset + (curPage-1)* -100 +"px";
		if (curOffset>-100) {
			window.setTimeout(arguments.callee, 20);
		} else {
			curOffset = 0;
		}
	}

	this.prevPage = function() {
		if (dublicate.hasPrevPage() && !curOffset) {
			curPage--;
			curOffset=0;
			prevPageIterate();
		}
	};
	function prevPageIterate() {
		curOffset += 10;
		dublicate.getScroll().style.left = curOffset + (curPage+1)* -100 +"px";
		if (curOffset<100) {
			window.setTimeout(arguments.callee, 20);
		} else {
			curOffset = 0;
		}
	}

	this.hasNextPage = function(depth) {
		var depth = depth || 1;
		return curPage + depth < dublicate.findPages();
	};
	this.hasPrevPage = function(depth) {
		var depth = depth || 1;
		return curPage - depth >= 0;
	};
	this.findPages = function() {
		return this.getScroll().getElementsByTagName("div").length;
	};
	this.getCurPage = function() {
		return curPage;
	};
}


// Decorator (интерфейс)
// умеет инкапсулировать (сохранять в локальную переменную component) компонент или другой декоратор
// и просто передает вызовы методов в component (кроме setComponent и getComponent)
function Decorator() {
	var component;

	this.setComponent = function(val) {
		component = val;
	};
	this.getComponent = function() {
		return component;
	};

	this.setContainer = function(val) {
		return component.setContainer(val);
	};
	this.setScroll = function(val) {
		return component.setScroll(val);
	};
	this.getContainer = function() {
		return component.getContainer();
	};
	this.getScroll = function() {
		return component.getScroll();
	};

	this.init = function() {
		return component.init();
	};

	this.nextPage = function() {
		return component.nextPage();
	};
	this.prevPage = function() {
		return component.prevPage();
	};

	this.hasNextPage = function(depth) {
		return component.hasNextPage(depth);
	};
	this.hasPrevPage = function(depth) {
		return component.hasPrevPage(depth);
	};
	this.findPages = function() {
		return component.findPages();
	};
	this.getCurPage = function() {
		return component.getCurPage();
	};
}
Decorator.prototype = new Scroll();
Decorator.prototype.constructor = Decorator;


// ConcreteDecorator (реализация Decorator'а)
// кнопки для переключения страниц
function Decorator_SwitchPage() {
	var dublicate = this; // постоянная ссылка на инстанцирующийся объект (для работы с любым this)
	// подключаем Decorator и записываем ссылки на методы, которые определенны в "интерфейсе" Decorator'а, и которые будут переопределяться здесь
	Decorator.call(this);
	var methods = {
		nextPage: this.nextPage,
		prevPage: this.prevPage,
		init: this.init
	};
	var buttonNext, buttonPrev;

	this.init = function() {
		dublicate.getContainer().appendChild( buttonPrev = createButton("<", dublicate.prevPage) );
		dublicate.getContainer().appendChild( buttonNext = createButton(">", dublicate.nextPage) );
		buttonNext.disabled = !dublicate.hasNextPage();
		buttonPrev.disabled = !dublicate.hasPrevPage();
		return methods.init();
	};

	this.nextPage = function() {
		buttonNext.disabled = !dublicate.hasNextPage(2);
		buttonPrev.disabled = !dublicate.hasPrevPage(-1);
		return methods.nextPage();
	};
	this.prevPage = function() {
		buttonNext.disabled = !dublicate.hasNextPage(-1);
		buttonPrev.disabled = !dublicate.hasPrevPage(2);
		return methods.prevPage();
	};

	function createButton(text, onclick) {
		var ret = document.createElement("button");
		ret.appendChild( document.createTextNode( text ) );
		ret.onclick = onclick;
		return ret;
	}
}

// ConcreteDecorator (реализация Decorator'а)
// индикатор текущей страницы
function Decorator_PageNum() {
	var dublicate = this; // постоянная ссылка на инстанцирующийся объект (для работы с любым this)
	Decorator.call(this);
	var methods = {
	nextPage: this.nextPage,
	prevPage: this.prevPage,
	init: this.init
	};
	var text;

	this.init = function() {
		dublicate.getContainer().appendChild( text = document.createTextNode( "..." ) );
		var ret = methods.init();
		chText();
		return ret;
	};

	this.nextPage = function() {
		var ret = methods.nextPage();
		chText();
		return ret;
	};
	this.prevPage = function() {
		var ret = methods.prevPage();
		chText();
		return ret;
	};

	function chText() {
		text.nodeValue = " "+ (dublicate.getCurPage()+1) +" / "+ dublicate.findPages();
	}
}


Пряники
Когда компонентов или декораторов будет много, можно написать удобную функцию:
function cr() {
		var ret, last;
		for (var i=0, l=arguments.length; i<l; i++) {
			ret = new arguments[i]();
			if (!i) {
				ret.setContainer(document.getElementById("container"));
				ret.setScroll(document.getElementById("scroll"));
			} else (
				ret.setComponent(last);
			}
		last = ret;
		}
		return ret;
	}


Демка
К верстке добавляем:
<table cellpadding="5"><tr>
	<th>workWith</th>
	<td>=</td>
	<td>
		<div>
			<input type="radio" name="component" value="simple" id="component-simple" checked="checked" />
			<label for="component-simple">SimpleScroll</label>
		</div>
		<input type="radio" name="component" value="simple" id="component-anim" />
		<label for="component-anim">AnimScroll</label>
	</td>
	<td>+</td>
	<td>
		<input type="checkbox" id="pageNum" checked="checked" />
		<label for="pageNum">pageNum</label>
	</td>
	<td>+</td>
	<td>
		<input type="checkbox" id="switchPage" checked="checked" />
		<label for="switchPage">switchPage</label>
	</td>
</tr></table>
<button onclick="create();">Пересоздать компонент с декораторами</button>
<br />
<button onclick="workWith.prevPage();">workWith.prevPage()</button>
<button onclick="workWith.nextPage();">workWith.nextPage()</button>
<br />


И вот такой script:
var component;
var decorator1;
var decorator2;
var workWith;

function reset() {
	component = decorator1 = decorator2 = workWith = null;
	var node = document.getElementById("scroll");
	node.style.left = "0px";
	while(node.nextSibling) {
		node.parentNode.removeChild(node.nextSibling);
	}
}

function create() {
	reset(); // сбрасываем предыдущие настройки (если были)
	
	// создаем компонент - основу для дальнейшей работы
	if (document.getElementById("component-simple").checked) {
		component = new SimpleScroll();
	} else {
		component = new AnimScroll();
	}
	component.setContainer(document.getElementById("container"));
	component.setScroll(document.getElementById("scroll"));
	workWith = component;
	
	if (document.getElementById("pageNum").checked) {
		// создаем первый декоратор
		decorator1 = new Decorator_PageNum();
		decorator1.setComponent(component); // заворачиваем компонент в декоратор
		workWith = decorator1;
	}
	
	if (document.getElementById("switchPage").checked) {
		// создаем еще один декоратор
		decorator2 = new Decorator_SwitchPage();
		if (decorator1) {
			// заворачиваем первый декоратор во второй
			decorator2.setComponent(decorator1); 
		} else {
			// заворачиваем компонент в декоратор
			decorator2.setComponent(component); 
		}
		workWith = decorator2;
	}
	
	workWith.init();
}

create();
Tags:
Hubs:
+32
Comments 97
Comments Comments 97

Articles