Skip to content

Latest commit

 

History

History
919 lines (663 loc) · 74.6 KB

File metadata and controls

919 lines (663 loc) · 74.6 KB

Вы не знаете JS: this и прототипы объектов

Глава 6: Делегирование поведения

В главе 5 мы подробно изучили механизм [[Prototype]] и показали, почему его нельзя корректно описать в терминах "класс" или "наследование" (несмотря на бесчисленные попытки на протяжении почти двух десятилетий). Нам пришлось продираться не только через многословный синтаксис (.prototype, захламляющий код), но и через всевозможные ловушки (такие как непредвиденное разрешение .constructor или уродливый псевдополиморфный синтаксис). Мы также рассмотрели различные варианты использования "примесей", которые части используются, чтобы сгладить эти острые углы.

Возникает закономерный вопрос: почему так сложно делать такие простые вещи? Теперь, когда мы приоткрыли завесу и увидели, насколько грязно все устроено внутри, неудивительно, что большинство JS разработчиков никогда не погружаются так глубоко, поручая эту работу библиотеке "классов".

Я надеюсь, что вы не собираетесь просто обойти все эти детали, поручив их "черному ящику". Так что давайте разберемся, как мы могли и должны были бы думать о механизме [[Prototype]] в JS, используя гораздо более простой и прямой путь, чем вся эта путаница с классами.

Как вы уже знаете из Главы 5, механизм [[Prototype]] — это внутренняя ссылка, которая существует в одном объекте и ссылается на другой объект.

Эта ссылка используется при обращении к несуществующему свойству/методу первого объекта. В таком случае ссылка [[Prototype]] говорит движку, что свойство/метод нужно искать в связанном объекте. В свою очередь, если поиск в этом объекте завершается неудачно, то происходит переход уже по его ссылке [[Prototype]] и так далее. Эта последовательность ссылок между объектами образует так называемую "цепочку прототипов".

Другими словами, реальный механизм, важнейшая часть функциональности, доступной нам в JavaScript — это по сути объекты, связанные с другими объектами.

Данное наблюдение является фундаментальным и критически важным для понимания мотивов и подходов, описанных далее в этой главе!

Towards Delegation-Oriented Design

Чтобы использовать [[Prototype]] наиболее правильным способом, необходимо осознавать, что этот шаблон проектирования фундаментально отличается от классов (см. главу 4).

Примечание: Некоторые принципы класс-ориентированного проектирования остаются крайне актуальными, так что не отбрасывайте все, что вы знаете (а всего лишь большую часть!). Например, инкапсулирование — весьма мощный инструмент, совместимый с делегированием (хотя такое сочетание встречается редко).

Нам нужно изменить наш способ мышления с шаблона проектирования "класс/наследование" на шаблон проектирования "делегирование поведения". Если большую часть вашего обучения или карьеры в программировании вы имели дело с классами, это может показаться некомфортным или неестественным. Попробуйте проделать это умственное упражнение несколько раз, чтобы привыкнуть к такому совершенно иному способу мышления.

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

Теория классов

Предположим, что у нас есть несколько похожих задач ("XYZ", "ABC", etc), которые мы хотим смоделировать в нашем ПО.

При использовании классов проектирование происходит так: определяем общий родительский (базовый) класс Task, в котором задается поведение всех "похожих" задач. Затем определяем дочерние классы XYZ и ABC, которые наследуют от Task и добавляют уточненное поведение для выполнения собственных задач.

Важно отметить, что шаблон проектирования классов диктует нам для получения максимальной выгоды от наследования использовать переопределение методов (и полиморфизм). В этом случае мы переопределяем определение некоторого общего метода Task в XYZ, возможно даже используя super для вызова базовой версии метода, добавляя к нему новое поведение. Вероятно вы найдете довольно много мест, где можно "абстрагировать" общее поведение в родительский класс, и уточнить (переопределить) его в дочерних классах.

Вот примерный псевдокод для такого сценария:

class Task {
	id;

	// конструктор `Task()`
	Task(ID) { id = ID; }
	outputTask() { output( id ); }
}

class XYZ inherits Task {
	label;

	// конструктор `XYZ()`
	XYZ(ID,Label) { super( ID ); label = Label; }
	outputTask() { super(); output( label ); }
}

class ABC inherits Task {
	// ...
}

Теперь вы можете создать одну или более копий дочернего класса XYZ, и использовать эти экземпляры для выполнения задачи "XYZ". Эти экземпляры копируют как общее поведение из Task, так и уточненное поведение из XYZ. Аналогично и экземпляры класса ABC будут иметь копии поведения Task и уточненного поведения ABC. Обычно после создания вы взаимодействуете только с этими экземплярами (но не с классами), поскольку у каждого экземпляра есть копия всего поведения, которое необходимо для выполнения задачи.

Теория делегирования

А теперь давайте поразмышляем о той же предметной области, но с использованием делегирования поведения вместо классов.

Сначала определяется объект (не класс и не function, что бы ни говорили вам большинство JS разработчиков) по имени Task с конкретным поведением, включающим в себя вспомогательные методы, которыми могут пользоваться различные задачи (читай делегировать!). Затем для каждой задачи ("XYZ", "ABC") вы определяете объект с данными/поведением, специфичными для данной задачи. Вы связываете специфические объекты задач со вспомогательным объектом Task, позволяя им делегировать ему в случае необходимости.

В сущности, для выполнения задачи "XYZ" нам необходимо поведение двух объектов одного уровня (XYZ и Task). Но вместо композиции через копирование классов мы можем оставить их в виде отдельных объектов, и разрешить объекту XYZ делегировать объекту Task, когда это необходимо.

Вот простой пример кода, показывающий как этого добиться:

var Task = {
	setID: function(ID) { this.id = ID; },
	outputID: function() { console.log( this.id ); }
};

// `XYZ` делегирует `Task`
var XYZ = Object.create( Task );

XYZ.prepareTask = function(ID,Label) {
	this.setID( ID );
	this.label = Label;
};

XYZ.outputTaskDetails = function() {
	this.outputID();
	console.log( this.label );
};

// ABC = Object.create( Task );
// ABC ... = ...

В этом примере Task и XYZ не являются классами (или функциями), это просто объекты. С помощью Object.create(..) объект XYZ делегирует объекту Task через ссылку [[Prototype]] (см. главу 5).

По аналогии с класс-ориентированностью (или, OO — объектно-ориентированный), я назвал этот стиль кода "OLOO" (objects-linked-to-other-objects — "объекты, связанные с другими объектами"). Все, что нас действительно интересует — это тот факт, что объект XYZ делегирует объекту Task (как и объект ABC).

В JavaScript механизм [[Prototype]] связывает объекты с другими объектами. Нет никаких абстрактных механизмов наподобие "классов", как бы вы ни пытались убедить себя в обратном. Это как грести на каноэ вверх по реке: вы можете это сделать, но выбираете путь против естественного течения, так что вам очевидно будет труднее добраться в нужное место.

Вот некоторые другие отличия стиля OLOO:

  1. Оба члена данных id и label из предыдущего примера с классами являются здесь свойствами данных непосредственно XYZ (ни одного из них нет в Task). Как правило в случае делегирования через [[Prototype]], вы хотите, чтобы состояние хранилось в делегирующих объектах (XYZ, ABC), а не в делегате (Task).

  2. При использовании шаблона проектирования классов мы специально назвали outputTask одинаково как в родителе (Task), так и в потомке (XYZ), чтобы воспользоваться переопределением (полиморфизм). В случае делегирования поведения мы делаем ровно наоборот: при любой возможности избегаем одинаковых имен на разных уровнях цепочки [[Prototype]] (это называется затенением — см. главу 5), поскольку коллизии имен вынуждают использовать ужасный/хрупкий код для устранения неоднозначности ссылок (см. главу 4), а мы хотим избежать этого.

    Этот шаблон проектирования предписывает отказ от общих, расплывчатых имен методов (предрасположенных к переопределению) в пользу более описательных имен, характерных для поведения каждого конкретного объекта. Это может сделать код проще для понимания/сопровождения, потому что имена методов (не только в месте их определения, но и по всему коду) становятся более очевидными (самодокументируемыми).

  3. this.setID(ID); внутри метода объекта XYZ сначала ищет setID(..) в XYZ, но поскольку метода с таким именем нет в XYZ, делегирование [[Prototype]] означает, что можно пройти по ссылке на Task, чтобы найти там setID(..), что и происходит. Более того, благодаря неявным правилам привязки this (см. главу 2), при выполнении setID(..), хотя этот метод и был найден в Task, this для данного вызова функции — это XYZ, как мы того и желали. То же самое происходит и с this.outputID() чуть дальше в листинге кода.

    Другими словами, методы общего назначения, существующие в Task, доступны нам при взаимодействии с XYZ, потому что XYZ может делегировать Task.

Делегирование поведения означает: пусть у одного объекта (XYZ) будет делегирование (к Task) для обращения к свойству или методу, отсутствующему в объекте (XYZ).

Это чрезвычайно мощный шаблон проектирования, сильно отличающийся от идеи родительских и дочерних классов, наследования, полиморфизма, и т.п. Вместо того чтобы мысленно выстраивать вертикальную иерархию объектов от Родителей к Потомкам, представьте себе равноправные объекты одного уровня, между которыми в любом направлении могут идти делегирующие ссылки.

Примечание: Правильнее использовать делегирование как внутреннюю деталь реализации, а не выставлять его наружу в дизайне API. В нашем дизайне API в примере выше мы не подталкиваем разработчиков использовать XYZ.setID() (хотя могли бы это сделать!). Мы как бы прячем делегирование как внутреннюю деталь нашего API, где XYZ.prepareTask(..) делегирует к Task.setID(..). Подробнее см. главу 5, раздел "Ссылки в роли запасных свойств?".

Взаимное делегирование (запрещено)

Нельзя создавать цикл, где между двумя или более объектами есть взаимное (двунаправленное) делегирование. Если вы создадите B, связанный с A, а затем попытаетесь связать A с B, то получите ошибку.

Жаль, что это запрещено (не то чтобы ужасно, но слегка раздражает). Если бы вы обратились к свойству/методу, которого нет ни у одного из объектов, это привело бы к бесконечной рекурсии в цикле [[Prototype]]. Но если бы все ссылки были на месте, тогда B мог делегировать A и наоборот, и это могло бы сработать. Это позволило бы использовать любой из объектов для делегирования другому. Есть несколько частных случаев, где это было бы полезным.

Но это запрещено, потому что разработчики конкретных реализаций движка обнаружили, что с точки зрения производительности выгоднее проверить (и отклонить!) наличие бесконечных циклических ссылок один раз во время установки, чем выполнять эту проверку каждый раз при обращении к свойству объекта.

Отладка

Рассмотрим вкратце один тонкий момент, который иногда сбивает с толку разработчиков. В целом спецификация JS не контролирует то, в каком виде конкретные значения и структуры отображаются в консоли разработчика в браузере. Поэтому каждый браузер/движок интерпретирует подобные вещи по-своему, и эта интерпретация может различаться. В частности поведение, которое мы сейчас рассмотрим, встречается только в Chrome Developer Tools.

Посмотрите на этот "традиционный" стиль "конструктора классов" в JS коде, и то, что отображается в консоли Chrome Developer Tools:

function Foo() {}

var a1 = new Foo();

a1; // Foo {}

Обратите внимание на последнюю строку кода: в результате вычисления выражения a1 выводится Foo {}. Если запустить этот код в Firefox, то скорее всего мы увидим Object {}. Почему такая разница, и что означают эти значения в консоли?

Chrome по сути говорит нам, что "{} — это пустой объект, который был создан функцией с именем 'Foo'". Firefox же говорит, что "{} — это пустой объект, созданный на основе Object". Маленькое отличие состоит в том, что Chrome отслеживает в виде внутреннего свойства имя реальной функции, создавшей объект, а другие браузеры такую информацию не отслеживают.

Есть соблазн попытаться объяснить это с помощью механизмов JavaScript:

function Foo() {}

var a1 = new Foo();

a1.constructor; // Foo(){}
a1.constructor.name; // "Foo"

Получается, что Chrome выводит "Foo", всего-навсего проверяя свойство .constructor.name объекта? И "да", и "нет".

Рассмотрим код:

function Foo() {}

var a1 = new Foo();

Foo.prototype.constructor = function Gotcha(){};

a1.constructor; // Gotcha(){}
a1.constructor.name; // "Gotcha"

a1; // Foo {}

Несмотря на то, что мы изменили a1.constructor.name на другое значение ("Gotcha"), консоль Chrome по-прежнему использует имя "Foo".

Итак, получается, что ответ на предыдущий вопрос (используется ли .constructor.name?) — нет, информация отслеживается где-то в другом месте, внутри движка.

Но не торопитесь! Давайте посмотрим, как это работает при использовании стиля OLOO:

var Foo = {};

var a1 = Object.create( Foo );

a1; // Object {}

Object.defineProperty( Foo, "constructor", {
	enumerable: false,
	value: function Gotcha(){}
});

a1; // Gotcha {}

Ага! Попались! Здесь консоль Chrome нашла и использует .constructor.name. На самом деле, на момент написания этой книги данное поведение было идентифицировано как баг в Chrome, и сейчас, когда вы её читаете, баг скорее всего исправлен. Поэтому в вашем браузере может выдаваться корректный результат a1; // Object {}.

Если не обращать внимания на этот баг, то внутреннее отслеживание "имени конструктора" (по-видимому, только в целях отладки в консоли) в Chrome является собственным поведением Chrome, выходящим за рамки спецификации.

Если вы не используете "конструктор" для создания объектов, как того и требует OLOO стиль кодирования в этой главе, тогда Chrome не будет отслеживать внутреннее "имя конструктора" для этих объектов, и они будут отображаться в консоли как "Object {}", то есть, "объекты, созданные из Object()".

Не думайте, что это является недостатком OLOO стиля. Когда вы используете шаблон проектирования на основе OLOO и делегирования поведения, совершенно неважно, кто "создал" объект (то есть, какая функция была вызвана с new?). Отслеживание "имени конструктора" внутри Chrome полезно только если вы полностью пишете код "в стиле классов", но совершенно неактуально при использовании OLOO делегирования.

Сравнение мысленных моделей

Теперь, когда вы видите как минимум теоретическую разницу между шаблонами проектирования "класс" и "делегирование", давайте посмотрим на то, как эти шаблоны влияют на мысленные модели, которые мы используем, рассуждая о коде.

Мы рассмотрим абстрактный код ("Foo", "Bar"), и сравним два способа его реализации (OO против OLOO). Первый фрагмент кода использует классический ("прототипный") OO стиль:

function Foo(who) {
	this.me = who;
}
Foo.prototype.identify = function() {
	return "I am " + this.me;
};

function Bar(who) {
	Foo.call( this, who );
}
Bar.prototype = Object.create( Foo.prototype );

Bar.prototype.speak = function() {
	alert( "Hello, " + this.identify() + "." );
};

var b1 = new Bar( "b1" );
var b2 = new Bar( "b2" );

b1.speak();
b2.speak();

Родительский класс Foo наследуется дочерним классом Bar, после чего создаются два экземпляра этого класса b1 и b2. В результате b1 делегирует Bar.prototype, который делегирует Foo.prototype. Все выглядит довольно знакомо, ничего особенного.

Теперь давайте реализуем ту же самую функциональность, используя код в стиле OLOO:

var Foo = {
	init: function(who) {
		this.me = who;
	},
	identify: function() {
		return "I am " + this.me;
	}
};

var Bar = Object.create( Foo );

Bar.speak = function() {
	alert( "Hello, " + this.identify() + "." );
};

var b1 = Object.create( Bar );
b1.init( "b1" );
var b2 = Object.create( Bar );
b2.init( "b2" );

b1.speak();
b2.speak();

Мы используем преимущество делегирования [[Prototype]] от b1 к Bar, и от Bar к Foo, аналогично тому, как сделали это в предыдущем примере с b1, Bar.prototype, и Foo.prototype. У нас по-прежнему есть те же самые 3 объекта, связанные вместе.

Но важно то, что мы значительно упростили все остальное, потому что теперь у нас просто есть объекты, связанные друг с другом, без всех этих ненужных вещей, которые выглядят (но не ведут себя) как классы, с конструкторами, прототипами и вызовами new.

Спросите себя: если я могу получить с OLOO точно такую же функциональность, что и с "классами", но OLOO проще и понятнее, может быть OLOO лучше?

Давайте рассмотрим мысленные модели, связанные с двумя этими примерами.

Пример с классами предполагает следующую мысленную модель сущностей и взаимосвязей между ними:

На самом деле, это немного нечестно, потому что здесь показано множество дополнительных нюансов, которые вы строго говоря не должны постоянно держать в голове (хотя вам надо понимать их!). С одной стороны, это довольно сложная последовательность взаимосвязей. Но с другой стороны, если вы внимательно изучите эти стрелки со связями, то поймете, что механизмы JS обладают потрясающей внутренней целостностью и непротиворечивостью.

Например, функции в JS могут обращаться к call(..), apply(..) и bind(..) (см. главу 2), поскольку сами по себе являются объектами, и у них есть ссылка [[Prototype]] на объект Function.prototype. В этом объекте определены стандартные методы, которым может делегировать любая функция-объект. JS может делать такие вещи, и вы тоже можете!.

Хорошо, давайте теперь посмотрим на слегка упрощенную версию этой диаграммы, чтобы сделать наше сравнение чуть более "честным". Здесь показаны лишь ключевые сущности и взаимосвязи.

По-прежнему довольно сложно, не так ли? Пунктирными линиями обозначены неявные взаимосвязи, когда вы установили "наследование" между Foo.prototype и Bar.prototype, но пока еще не исправили ссылку на отсутствующее свойство .constructor (см. раздел "И снова о конструкторе" в главе 5). Даже без этих пунктирных линий вам придется мысленно проделывать очень много работы каждый раз, когда вы имеете дело с объектными.

А теперь давайте посмотрим на мысленную модель для кода в OLOO-стиле:

Из этого сравнения очевидно, что в OLOO-стиле вам нужно учитывать гораздо меньшее количество нюансов, поскольку в OLOO принимается за аксиому тот факт, что нас интересуют только объекты, связанные с другими объектами.

Весь остальной "классовый" хлам — запутанный и сложный способ для получения такого же конечного результата. Уберите его, и вещи станут гораздо проще (без потери какой-либо функциональности).

Классы против объектов

Мы только что провели теоретические рассуждения и сравнили мысленные модели "классов" и "делегирования поведения". А теперь давайте посмотрим более реальные примеры кода, чтобы увидеть как на деле применять эти идеи.

Сначала мы рассмотрим типичный сценарий фронтенд-разработки: создание UI-виджетов (кнопки, раскрывающиеся списки, и т.п.).

"Классы" виджетов

Если вы привыкли использовать шаблон проектирования OO, то скорее всего сразу же представите себе предметную область в виде родительского класса (например, Widget) с базовым поведением виджета и дочерних производных классов для виджетов конкретного типа (например, Button).

Примечание: Для работы с DOM и CSS мы используем jQuery, поскольку в данном обсуждении нас не интересуют подобные детали. В приведенном ниже коде выбор конкретного JS фреймворка (jQuery, Dojo, YUI, и т.п.) для решения рутинных задач не имеет никакого значения.

Давайте посмотрим, как бы мы могли реализовать архитектуру "классов" на чистом JS, без каких-либо вспомогательных библиотек "классов" или синтаксиса:

// Родительский класс
function Widget(width,height) {
	this.width = width || 50;
	this.height = height || 50;
	this.$elem = null;
}

Widget.prototype.render = function($where){
	if (this.$elem) {
		this.$elem.css( {
			width: this.width + "px",
			height: this.height + "px"
		} ).appendTo( $where );
	}
};

// Дочерний класс
function Button(width,height,label) {
	// вызов конструктора "super"
	Widget.call( this, width, height );
	this.label = label || "Default";

	this.$elem = $( "<button>" ).text( this.label );
}

// `Button` "наследует" от `Widget`
Button.prototype = Object.create( Widget.prototype );

// переопределяем базовый "унаследованный" `render(..)`
Button.prototype.render = function($where) {
	// вызов "super"
	Widget.prototype.render.call( this, $where );
	this.$elem.click( this.onClick.bind( this ) );
};

Button.prototype.onClick = function(evt) {
	console.log( "Button '" + this.label + "' clicked!" );
};

$( document ).ready( function(){
	var $body = $( document.body );
	var btn1 = new Button( 125, 30, "Hello" );
	var btn2 = new Button( 150, 40, "World" );

	btn1.render( $body );
	btn2.render( $body );
} );

Шаблон проектирования OO предписывает нам объявить базовый метод render(..) в родительском классе, и переопределить его в дочернем классе, но не заменять его полностью, а дополнить базовую функциональность поведением, характерным для кнопки.

Обратите внимание на уродливый явный псевдополиморфизм ссылок Widget.call и Widget.prototype.render.call для имитации вызова "super" из методов дочернего "класса". Фу, гадость!

Синтаксический сахар ES6: class

Мы подробно рассмотрим синтаксический сахар class в ES6 в Приложении А. Ну а пока давайте узнаем, как мы могли бы реализовать тот же самый код с помощью class:

class Widget {
	constructor(width,height) {
		this.width = width || 50;
		this.height = height || 50;
		this.$elem = null;
	}
	render($where){
		if (this.$elem) {
			this.$elem.css( {
				width: this.width + "px",
				height: this.height + "px"
			} ).appendTo( $where );
		}
	}
}

class Button extends Widget {
	constructor(width,height,label) {
		super( width, height );
		this.label = label || "Default";
		this.$elem = $( "<button>" ).text( this.label );
	}
	render($where) {
		super.render( $where );
		this.$elem.click( this.onClick.bind( this ) );
	}
	onClick(evt) {
		console.log( "Button '" + this.label + "' clicked!" );
	}
}

$( document ).ready( function(){
	var $body = $( document.body );
	var btn1 = new Button( 125, 30, "Hello" );
	var btn2 = new Button( 150, 40, "World" );

	btn1.render( $body );
	btn2.render( $body );
} );

Несомненно, class в ES6 делает предыдущий классический код менее ужасным. В частности, довольно приятно наличие super(..) (хотя если копнуть поглубже, все не так красиво!).

Несмотря на улучшение синтаксиса, это не настоящие классы, поскольку они по-прежнему работают поверх механизма [[Prototype]]. Им присущи те же самые концептуальные несостыковки, рассмотренные нами в 4 и 5 главах, и в начале этой главы. В Приложении А мы подробно изучим синтаксис class в ES6 и последствия его применения. Мы увидим, почему устранение проблем с синтаксисом не избавляет нас от путаницы с классами в JS, хотя и преподносится как решение.

Неважно, используете ли вы классический прототипный синтаксис или новый синтаксический сахар ES6, вы по-прежнему моделируете предметную область с помощью "классов". И как показано в нескольких предыдущих главах, такой выбор в JavaScript сулит вам дополнительные проблемы и концептуальные трудности.

Делегирование объектов виджетов

Вот более простая реализация нашего примера с Widget / Button, использующая делегирование в стиле OLOO:

var Widget = {
	init: function(width,height){
		this.width = width || 50;
		this.height = height || 50;
		this.$elem = null;
	},
	insert: function($where){
		if (this.$elem) {
			this.$elem.css( {
				width: this.width + "px",
				height: this.height + "px"
			} ).appendTo( $where );
		}
	}
};

var Button = Object.create( Widget );

Button.setup = function(width,height,label){
	// делегированный вызов
	this.init( width, height );
	this.label = label || "Default";

	this.$elem = $( "<button>" ).text( this.label );
};
Button.build = function($where) {
	// делегированный вызов
	this.insert( $where );
	this.$elem.click( this.onClick.bind( this ) );
};
Button.onClick = function(evt) {
	console.log( "Button '" + this.label + "' clicked!" );
};

$( document ).ready( function(){
	var $body = $( document.body );

	var btn1 = Object.create( Button );
	btn1.setup( 125, 30, "Hello" );

	var btn2 = Object.create( Button );
	btn2.setup( 150, 40, "World" );

	btn1.build( $body );
	btn2.build( $body );
} );

Применяя OLOO-стиль, мы не думаем о Widget и Button как о родительском и дочернем классах. Вместо этого, Widgetэто просто объект, некий набор утилит, которым может делегировать любой конкретный тип виджета, а Buttonэто тоже самостоятельный объект (с делегирующей ссылкой на Widget, разумеется!).

Мы не используем в обоих объектах одно и то же имя метода render(..), как то предписывается шаблоном проектирования классов. Вместо этого мы выбрали разные имена (insert(..) и build(..)), которые более точно описывают решаемую каждым классом задачу. Инициализирующие методы названы init(..) и setup(..), соответственно, по тем же причинам.

Этот шаблон проектирования с использованием делегирования не только предлагает различающиеся и более содержательные имена (вместо одинаковых и более общих), но и избавляет нас от некрасивых явных псевдополиморфных вызовов (Widget.call и Widget.prototype.render.call), заменяя их на простые, относительные делегирующие вызовы this.init(..) и this.insert(..).

Из синтаксиса исчезли конструкторы, .prototype и new, поскольку на самом деле для нас они бесполезны.

Если вы были внимательны, то могли заметить, что вместо одного вызова (var btn1 = new Button(..)) у нас теперь два (var btn1 = Object.create(Button) и btn1.setup(..)). Поначалу это может показаться недостатком (больше кода).

Однако даже это является преимуществом кодирования в OLOO-стиле по сравнению с классическим прототипным кодом. Почему?

Конструкторы классов "вынуждают" вас выполнять создание и инициализацию за один шаг (по крайней мере, это настоятельно рекомендуется). Однако во многих случаях нужна большая гибкость и возможность выполнения этих этапов отдельно друг от друга (что и происходит в OLOO!).

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

OLOO обеспечивает лучшую поддержку принципа разделения ответственности, поскольку создание и инициализацию необязательно объединять в одну операцию.

Более простой дизайн

Помимо того, что OLOO обеспечивает нарочито более простой (и гибкий!) код, делегирование поведения может упростить архитектуру кода. Давайте рассмотрим последний пример, показывающий, как OLOO в целом упрощает дизайн.

В нашем примере будут два объекта-контроллера, один из которых обрабатывает форму входа на веб-странице, а другой отвечает за аутентификацию на сервере.

Нам понадобится вспомогательная утилита для Ajax-взаимодействия с сервером. Мы используем jQuery (хотя подойдет любой фреймворк), поскольку она не только выполняет за нас Ajax-запрос, но и возвращает в ответ promise (обещание), так что мы можем прослушивать ответ в вызывающем коде с помощью .then(..).

Примечание: Мы рассмотрим обещания (promises) в одной из будущих книг серии "Вы не знаете JS".

Придерживаясь типичного шаблона проектирования классов, мы разобьем задачу и вынесем базовую функциональность в класс Controller, а затем создадим два дочерних класса, LoginController и AuthController, унаследованных от Controller и уточняющих базовое поведение.

// Родительский класс
function Controller() {
	this.errors = [];
}
Controller.prototype.showDialog = function(title,msg) {
	// показывает пользователю заголовок и сообщение в диалоговом окне
};
Controller.prototype.success = function(msg) {
	this.showDialog( "Success", msg );
};
Controller.prototype.failure = function(err) {
	this.errors.push( err );
	this.showDialog( "Error", err );
};
// Дочерний класс
function LoginController() {
	Controller.call( this );
}
// Привязываем дочерний класс к родительскому
LoginController.prototype = Object.create( Controller.prototype );
LoginController.prototype.getUser = function() {
	return document.getElementById( "login_username" ).value;
};
LoginController.prototype.getPassword = function() {
	return document.getElementById( "login_password" ).value;
};
LoginController.prototype.validateEntry = function(user,pw) {
	user = user || this.getUser();
	pw = pw || this.getPassword();

	if (!(user && pw)) {
		return this.failure( "Please enter a username & password!" );
	}
	else if (pw.length < 5) {
		return this.failure( "Password must be 5+ characters!" );
	}

	// добрались сюда? валидация прошла успешно!
	return true;
};
// Переопределяем для расширения базового `failure()`
LoginController.prototype.failure = function(err) {
	// вызов "super"
	Controller.prototype.failure.call( this, "Login invalid: " + err );
};
// Дочерний класс
function AuthController(login) {
	Controller.call( this );
	// помимо наследования, нам необходима композиция
	this.login = login;
}
// Привязываем дочерний класс к родительскому
AuthController.prototype = Object.create( Controller.prototype );
AuthController.prototype.server = function(url,data) {
	return $.ajax( {
		url: url,
		data: data
	} );
};
AuthController.prototype.checkAuth = function() {
	var user = this.login.getUser();
	var pw = this.login.getPassword();

	if (this.login.validateEntry( user, pw )) {
		this.server( "/check-auth",{
			user: user,
			pw: pw
		} )
		.then( this.success.bind( this ) )
		.fail( this.failure.bind( this ) );
	}
};
//  Переопределяем для расширения базового `success()`
AuthController.prototype.success = function() {
	// вызов "super"
	Controller.prototype.success.call( this, "Authenticated!" );
};
// Переопределяем для расширения базового `failure()`
AuthController.prototype.failure = function(err) {
	// вызов "super"
	Controller.prototype.failure.call( this, "Auth Failed: " + err );
};
var auth = new AuthController(
	// помимо наследования, нам необходима композиция
	new LoginController()
);
auth.checkAuth();

У всех контроллеров есть общее базовое поведение: success(..), failure(..) и showDialog(..). Дочерние классы LoginController и AuthController переопределяют failure(..) и success(..), дополняя стандартное поведение базового класса. Обратите внимание, что AuthController необходим экземпляр LoginController для взаимодействия с формой входа, поэтому он становится свойством данных.

Мы также добавили к наследованию немного композиции. AuthController должен знать о LoginController, поэтому мы создаем экземпляр (new LoginController()) и сохраняем ссылку на него в члене данных класса this.login, так что AuthController может вызывать поведение LoginController.

Примечание: Мы могли бы поддаться легкому искушению и унаследовать AuthController от LoginController, или наоборот, получив виртуальную композицию в цепочке наследования. Но это яркий пример того, какие проблемы порождает наследование классов в качестве модели предметной области. Ведь ни AuthController, ни LoginController не уточняют поведение друг друга, поэтому наследование между ними не имеет смысла, если только классы не являются вашим единственным шаблоном проектирования. Вместо этого мы добавили простую композицию, и теперь оба класса могут взаимодействовать, сохранив при этом преимущества наследования от базового класса Controller.

Если вы разбираетесь в класс-ориентированном (ОО) проектировании, то все это должно выглядеть знакомым и естественным.

Де-класс-ификация

Но действительно ли нам нужно моделировать эту проблему с помощью родительского класса Controller, двух дочерних классов и композиции? Можно ли воспользоваться преимуществами делегирования поведения в стиле OLOO и получить гораздо более простой дизайн? Да!

var LoginController = {
	errors: [],
	getUser: function() {
		return document.getElementById( "login_username" ).value;
	},
	getPassword: function() {
		return document.getElementById( "login_password" ).value;
	},
	validateEntry: function(user,pw) {
		user = user || this.getUser();
		pw = pw || this.getPassword();

		if (!(user && pw)) {
			return this.failure( "Please enter a username & password!" );
		}
		else if (pw.length < 5) {
			return this.failure( "Password must be 5+ characters!" );
		}

		// добрались сюда? валидация прошла успешно!
		return true;
	},
	showDialog: function(title,msg) {
		// показывает пользователю сообщение об успехе в диалоговом окне
	},
	failure: function(err) {
		this.errors.push( err );
		this.showDialog( "Error", "Login invalid: " + err );
	}
};
// Связываем `AuthController` для делегирования к `LoginController`
var AuthController = Object.create( LoginController );

AuthController.errors = [];
AuthController.checkAuth = function() {
	var user = this.getUser();
	var pw = this.getPassword();

	if (this.validateEntry( user, pw )) {
		this.server( "/check-auth",{
			user: user,
			pw: pw
		} )
		.then( this.accepted.bind( this ) )
		.fail( this.rejected.bind( this ) );
	}
};
AuthController.server = function(url,data) {
	return $.ajax( {
		url: url,
		data: data
	} );
};
AuthController.accepted = function() {
	this.showDialog( "Success", "Authenticated!" )
};
AuthController.rejected = function(err) {
	this.failure( "Auth Failed: " + err );
};

Поскольку AuthController теперь просто объект (как и LoginController), нам не нужно создавать экземпляр (new AuthController()) для решения нашей задачи. Все, что надо сделать:

AuthController.checkAuth();

При работе с OLOO вы легко можете добавить один или несколько объектов в цепочку делегирования, и вам не придется создавать экземпляры классов:

var controller1 = Object.create( AuthController );
var controller2 = Object.create( AuthController );

При делегировании поведения AuthController и LoginController являются просто объектами, которые находятся на одном уровне и не выстроены в иерархию, как родительские и дочерние классы в класс-ориентированом подходе. Мы выбрали направление делегирования от AuthController к LoginController произвольно — оно вполне могло быть и противоположным.

Основной результат в том, что во втором листинге кода у нас осталось только две сущности (LoginController и AuthController), а не три как раньше.

Нам не нужен базовый класс Controller с "общим" поведением для двух других, поскольку делегирование поведения — достаточно мощный механизм, предоставляющий нам все требуемую функциональность. Нам также не нужно создавать экземпляры классов, поскольку классов нет, а есть лишь сами объекты. Более того, нет нужды в композиции, потому что делегирование позволяет обоим объектам взаимодействовать как угодно.

Наконец, мы избежали ловушек с полиморфизмом в класс-ориентированном дизайне, отказавшись от одинаковых имен success(..) и failure(..) в обоих классах, ведь иначе нам потребовался бы уродливый явный псевдополиморфизм. Вместо этого, мы назвали их accepted() и rejected(..) в AuthController, и эти имена немного лучше описывают выполняемые задачи.

Подведем итог: мы получили ту же функциональность, но (гораздо) более простой дизайн. В этом и состоит мощь OLOO-стиля и шаблона проектирования делегирования поведения.

Более элегантный синтаксис

Одно из приятных новшеств, которое делает class в ES6 обманчиво притягательным (о том, почему стоит его избегать, см. в Приложении А!), — сокращенный синтаксис для объявления методов класса:

class Foo {
	methodName() { /* .. */ }
}

Мы избавились от ключевого слова function в объявлении, что обрадовало JS-разработчиков по всему миру!

Вы наверное заметили, что в OLOO синтаксисе function встречается на каждом шагу, что немного расходится с нашей целью упростить код. Но мы можем это исправить!

В ES6 мы можем использовать сокращенные объявления методов в любом объектном литерале, поэтому объект в OLOO-стиле можно объявить так (такой же сокращенный синтаксис, что и в теле class):

var LoginController = {
	errors: [],
	getUser() { // Смотри-ка, нет `function`!
		// ...
	},
	getPassword() {
		// ...
	}
	// ...
};

Единственная разница в том, что в объектных литералах по-прежнему надо использовать разделители , между элементами, тогда как синтаксис class этого не требует. Но на фоне общей картины это сущий пустяк.

Более того, в ES6 мы можем заменить неуклюжий синтаксис с отдельным присваиванием каждого свойства (как в определении AuthController) на объектный литерал (с помощью сокращенной формы записи методов), и изменить [[Prototype]] этого объекта на Object.setPrototypeOf(..):

// используем более красивый синтаксис объектного литерала
// с краткими методами!
var AuthController = {
	errors: [],
	checkAuth() {
		// ...
	},
	server(url,data) {
		// ...
	}
	// ...
};

// ТЕПЕРЬ, свяжем `AuthController` через делегирование с `LoginController`
Object.setPrototypeOf( AuthController, LoginController );

С краткими методами ES6 наш OLOO-стиль стал еще более удобным чем раньше (но даже без этого он и так был гораздо проще и симпатичнее чем классический прототипный код). Чтобы получить красивый и чистый объектный синтаксис, вам не нужны классы!

Лексический недостаток

У кратких методов есть небольшой недостаток, о котором нужно знать. Рассмотрим код:

var Foo = {
	bar() { /*..*/ },
	baz: function baz() { /*..*/ }
};

Если убрать синтаксический сахар, то этот код будет работать так:

var Foo = {
	bar: function() { /*..*/ },
	baz: function baz() { /*..*/ }
};

Видите разницу? Сокращенный вариант bar() превратился в анонимное функциональное выражение (function()..), привязанное к свойству bar, поскольку у объекта функции нет имени. Сравните это с указанным вручную именованным функциональным выражением (function baz()..), которое не только привязано к свойству .baz, но и имеет лексический идентификатор baz.

И что из этого? В книге "Область видимости и замыкания" нашей серии "Вы не знаете JS", мы подробно рассмотрели три основных недостатка анонимных функциональных выражений. Перечислим их еще раз и посмотрим, что из этого затрагивает краткие методы.

Отсутствие идентификатора name у анонимной функции:

  1. усложняет отладку стектрейсов (stack traces)
  2. усложняет работу с функциями, ссылающимися на самих себя (рекурсия, подписка/отписка обработчика события, и т.п.)
  3. немного затрудняет понимание кода

Пункты 1 и 3 не относятся к кратким методам.

Несмотря на то, что код без синтаксического сахара превращается в анонимное функциональное выражение, у которого обычно нет name в стектрейсах, краткие методы имеют внутреннее свойство name для объекта функции, поэтому стектрейсы могут его использовать (хотя это зависит от реализации и не гарантируется).

Пункт 2, к сожалению, является недостатком кратких методов. У них нет лексического идентификатора, на который они могли бы ссылаться. Рассмотрим:

var Foo = {
	bar: function(x) {
		if (x < 10) {
			return Foo.bar( x * 2 );
		}
		return x;
	},
	baz: function baz(x) {
		if (x < 10) {
			return baz( x * 2 );
		}
		return x;
	}
};

Явная ссылка Foo.bar(x*2) в этом примере вроде бы решает проблему, но во многих случаях у функции нет такой возможности, например когда функция совместно используется различными объектами через делегирование, когда используется привязка this, и т.п. Вам придется использовать реальную ссылку функции на саму себя, и лучший способ сделать это — идентификатор name объекта функции.

Просто помните об этой особенности кратких методов, и если возникнет проблема со ссылкой функции на саму себя, откажитесь от краткого синтаксиса в данном конкретном объявлении метода в пользу именованного функционального выражения: baz: function baz(){..}.

Интроспекция

Если вы долгое время писали программы в класс-ориентированном стиле (в JS или других языках), то наверняка знаете, что такое интроспекция типа: проверка экземпляра с целью выяснить, какого вида объект перед вами. Основная цель интроспекции типа экземпляра класса — узнать о структуре и функциональных возможностях объекта исходя из того как он был создан.

Рассмотрим пример кода, в котором для интроспекции объекта a1 используется instanceof (см. главу 5):

function Foo() {
	// ...
}
Foo.prototype.something = function(){
	// ...
}

var a1 = new Foo();

// позднее

if (a1 instanceof Foo) {
	a1.something();
}

Благодаря Foo.prototype (не Foo!) в цепочке [[Prototype]] (см. главу 5) объекта a1, оператор instanceof сообщает нам, что a1 будто бы является экземпляром "класса" Foo. Исходя из этого мы предполагаем, что у a1 есть функциональные возможности, описанные в "классе" Foo.

Разумеется, никакого класса Foo не существует, есть всего-навсего обычная функция Foo, у которой есть ссылка на некоторый объект (Foo.prototype), с которым a1 связывается ссылкой делегирования. По идее, оператор instanceof исходя из его названия должен проверять взаимосвязь между a1 и Foo, но на самом деле он лишь сообщает нам, связаны ли a1 и некий объект, на который ссылается Foo.prototype.

Семантическая путаница (и косвенность) синтаксиса instanceof приводит к тому, что для интроспекции объекта a1 с целью выяснить, обладает ли он функциональными возможностями искомого объекта, вам необходима функция, содержащая ссылку на этот объект. То есть, вы не можете напрямую узнать, связаны ли два объекта.

Вспомните абстрактный пример Foo / Bar / b1, который мы рассматривали ранее в этой главе:

function Foo() { /* .. */ }
Foo.prototype...

function Bar() { /* .. */ }
Bar.prototype = Object.create( Foo.prototype );

var b1 = new Bar( "b1" );

Вот список проверок, которые вам придется выполнить для интроспекции типов этих сущностей с помощью семантики instanceof и .prototype:

// устанавливаем связь между `Foo` и `Bar`
Bar.prototype instanceof Foo; // true
Object.getPrototypeOf( Bar.prototype ) === Foo.prototype; // true
Foo.prototype.isPrototypeOf( Bar.prototype ); // true

// устанавливаем связь между `b1` и `Foo` и `Bar`
b1 instanceof Foo; // true
b1 instanceof Bar; // true
Object.getPrototypeOf( b1 ) === Bar.prototype; // true
Foo.prototype.isPrototypeOf( b1 ); // true
Bar.prototype.isPrototypeOf( b1 ); // true

Согласитесь, что это немного отстойно. Например, интуитивно хочется, чтобы была возможность написать что-то вроде Bar instanceof Foo (потому что "instance" — довольно широкое понятие, и можно подумать, что оно включает в себя и "наследование"). Но такое сравнение в JS бессмысленно. Вместо этого приходится использовать Bar.prototype instanceof Foo.

Еще один распространенный, но возможно менее надежный метод интроспекции типов, который многие разработчики предпочитают оператору instanceof, называется "утиная типизация". Этот термин берет свое начало из афоризма "если нечто выглядит как утка и крякает как утка, то возможно это и есть утка".

Пример:

if (a1.something) {
	a1.something();
}

Вместо того, чтобы проверять связь между a1 и объектом, содержащим делегируемую функцию something(), мы предполагаем, что успешная проверка a1.something означает, что a1 позволяет вызывать .something() (неважно, найден ли метод непосредственно в a1 или делегирован какому-то другому объекту). Само по себе это предположение не такое уж и рискованное.

Однако зачастую понятие "утиной типизации" расширяется, и делаются дополнительные предположения о возможностях объекта, выходящие за рамки проверки. Разумеется, это увеличивает риски и делает дизайн более хрупким.

Ярким примером "утиной типизации" являются обещания (Promises) в ES6 (как мы уже говорили, их рассмотрение выходит за рамки этой книги).

В ряде случаев возникает необходимость проверить, является ли ссылка на некий объект обещанием, причем это делается путем проверки наличия у объекта функции then(). Другими словами, если у какого угодно объекта найдется метод then(), то механизм обещаний ES6 будет считать что это "thenable" объект, и будет ожидать от него стандартного поведения Promises.

Если у вас какой-либо не-Promise объект, у которого по какой-то причине есть метод then(), то настоятельно рекомендуется держать его подальше от механизма ES6 Promise, чтобы избежать некорректных предположений.

Этот пример наглядно иллюстрирует риски "утиной типизации". Подобные вещи следует использовать лишь в разумных пределах и в контролируемом окружении.

Возвращаясь к коду в стиле OLOO отметим, что интроспекция типов в данном случае может быть гораздо элегантнее. Давайте вспомним фрагмент OLOO кода Foo / Bar / b1, рассмотренный ранее в этой главе:

var Foo = { /* .. */ };

var Bar = Object.create( Foo );
Bar...

var b1 = Object.create( Bar );

Поскольку в OLOO у нас есть лишь обычные объекты, связанные делегированием [[Prototype]], мы можем использовать гораздо более простую форму интроспекции типов:

// устанавливаем связь между `Foo` и `Bar`
Foo.isPrototypeOf( Bar ); // true
Object.getPrototypeOf( Bar ) === Foo; // true

// устанавливаем связь между `b1` и `Foo` и `Bar`
Foo.isPrototypeOf( b1 ); // true
Bar.isPrototypeOf( b1 ); // true
Object.getPrototypeOf( b1 ) === Bar; // true

Мы больше не используем instanceof, потому что он претендует на то, что каким-то образом связан с классами. Теперь мы просто задаем (неформальный) вопрос "являешься ли ты моим прототипом?" Больше не нужны косвенные обращения, такие как Foo.prototype или ужасно многословное Foo.prototype.isPrototypeOf(..).

Я считаю, что эти проверки гораздо более простые и не такие запутанные, как предыдущий набор интроспектирующих проверок. И снова мы видим, что в JavaScript подход OLOO проще, чем кодирование в стиле классов (и при этом обладает теми же возможностями).

Обзор (TL;DR)

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

Делегирование поведения предполагает, что все объекты находятся на одном уровне и связаны друг с другом делегированием, а не отношениями родитель-потомок. Механизм [[Prototype]] в JavaScript по своему замыслу является механизмом делегирования поведения. Это значит, что мы можем либо всячески пытаться реализовать механику классов поверх JS (см. главы 4 и 5), либо принять истинную сущность [[Prototype]] как механизма делегирования.

Если вы проектируете код, используя только объекты, это не только упрощает синтаксис, но и позволяет добиться более простой архитектуры кода.

OLOO (объекты, связанные с другими объектами) — это стиль кодирования, в котором объекты создаются и связываются друг с другом без абстракции классов. OLOO вполне естественным образом реализует делегирование поведения при помощи [[Prototype]].