JavaScript. Язык клиентской части веб-сайтов. Для большинства разработчиков сайтов, основными задачами решаемыми на JavaScript являются: валидация форм, оправка AJAX-запросов и обработка ответов, визуализация элементов интерфейса с помощью библиотечных функций или специальных плагинов. Зачастую, стандартные веб-сайты не требуют большего от клиентской части, основная работа происходит на сервере. Таким образом подключенный на странице JavaScript в среднем насчитывает до десятка файлов (не считая библиотек и плагинов), каждый из которых включает пять-шесть функций. При таком объеме кода задумываться о какой-либо сложной структуре не имеет смысла.



Однако, некоторые сайты, в особенности веб-приложения, требуют большей автономности клиентской части. Встречаются веб-ресурсы, в которых все элементы управления генерируются JavaScript'ом. Иногда объем клиентской бизнес-логики даже превышает объем серверной. В таких случаях, есть смысл использовать объектно-ориентированный подход. В данной статье, мы не будем рассматривать преимущества и недостатки ООП, цель статьи – показать, как применить практики ООП в JavaScript'е. Итак, начнем:

Создание классов


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

1. Создать ассоциативный массив с помощью оператора {}. В фигурных скобках, как известно, можно указывать содержимое хэш-массива в формате ключ: значение. Мы уже знаем, что в качестве значений можно использовать и функции, поскольку в JavaScript это всего лишь тип данных. Результатом будет объект (вообще в JavaScript понятия объект и хэш-массив совпадают). Далее с этим объектом можно работать стандартным образом.

2. Второй метод показан в следующем листинге:

// Конструктор

function MyClass() {

// какой-то код

}

var obj = new MyClass();// инициализация


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

Теперь несколько слов о том, как делать не надо. Периодически я встречаю подобный, С-образный, код для объявления методов класса на JavaScript:

// Конструктор

function MyClass() {

this.myField = 1;

this.myMethod1 = MyClass_myMethod1;

this.myMethod2 = MyClass_myMethod2;

// какой-то код

}

// Методы

function MyClass_myMethod1(x) {

alert("myMethod1("+x+")");

}

function MyClass_myMethod2(x) {

alert("myMethod2("+x+")");

}

var obj = new MyClass ();// инициализация

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

Как же тогда писать? Есть, опять же, несколько вариантов:

Создание методов с помощью прототипов


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

function NewClass () { /* пусто */ }

// Определяем первый метод.

NewClass.prototype.myMethod1 = function(x) {

alert("myMethod1("+x+")");

}

// Определяем второй метод.

NewClass.prototype.myMethod2 = function(x) {

alert("myMethod2("+x+")");

}

var obj = new NewClass();// инициализация

Правда тут же возникает вопрос, а что за prototype такой? Прототип — это хэш, в котором хранятся свойства и методы, присущие классу по умолчанию. Для каждого объекта (хэша) существует хэш-прототип. Когда мы обращаемся к свойству obj.property, вначале свойство property ищется в хэше obj, а если там его нет, то в его obj.prototype.property, в obj.prototype.prototype.property и т. д. То же присуще методам, поскольку, как мы уже не раз упоминали в JavaScript функция это просто один из типов данных.

То что мы называем классом — это функция, с которой ассоциирован некий прототип. Оператор new создает экземпляр объекта, который сохраняется в переменной this, после чего вызывает функцию и делает доступным this внутри нее. При вызове метода obj.method() в локальную переменную this помещается ссылка на объект, метод которого вызван (в данном случаи obj). Если вызывается глобальная функция, переменная this внутри нее будет содержать ссылку на window.

Использование прототипов для создания методов имеет некоторые недостатки, не говоря уже о непривычной записи. Однако практика использования прототипов достаточно востребована, если речь идет о классах, исходный код которых нет возможности или желания менять. Следующая глава иллюстрирует случаи, когда использование прототипов обоснованно.

Изменение стандартных типов с помощью prototype


Рассмотрим стандартные типы JavaScript. Довольно часто бывает так, что для конкретной задачи необходимы некоторые utility-методы. Однако раз уж мы пишем объектно-ориентированный код, то мы не можем использовать функции вне объектов. Значит нужно выяснить, какому классу эти методы наиболее свойственны. Что если это встроенный тип языка? Обычно в таких случаях создают статический класс с utility-методами, поскольку модифицировать встроенные типы невозможно. Но только не в JavaScript. Механизм прототипов позволяет модифицировать любые типы, в том числе и встроенные. Например, нужно добавить метод sqr(), возведения в квадрат, встроенному типу Number (этому типу принадлежат все числа):

var a = 10;

Number.prototype.sqr = function() { return this*this }

alert(a.sqr());

Важным фактором является то, что на момент изменения типа Number, переменная a уже была инициализирована. Тем не менее, после изменения прототипа Number ее интерфейс так же изменился. То есть изменение прототипа влияет на все экземпляры этого типа, которые когда-либо были или будут созданы.

Рассмотрим еще несколько примеров изменения встроенных «классов» (к ним, кстати, относятся Object, Array, String, Function и т. д.). Например, можно добавить метод repeat() которая будет возвращать строку состоящую из количества повторений исходной строки равной переданному параметру:

String.prototype.repeat = function(n) {

var result = "", instance = this.toString();

while (--n >= 0) result += instance;

return result;

}

Теперь мы можем получить строку с необходимым количеством повторений исходной, просто написав: a = b.repeat(10);

Давайте теперь переопределим метод toString() для типов Object и Array. Это может быть полезно для отладки, поскольку базово этот метод, для объекта, выводит что-то вроде [object Object](зависит от браузера).

Array.prototype.toString = 

Object.prototype.toString = function() {

var cont = [];

var addslashes = function(s) {

// В Опере replace приводит к зацикливанию, поскольку вызывается

// Object.toString()

return

s.split('\').join('\\').split('"').join('\"');

}

for (var k in this) {

if (cont.length) cont[cont.length-1] += ",";

var v = this[k];

var vs = '';

if (v.constructor == String)

vs = '"' + addslashes(v) + '"';

else

vs = v.toString();

if (this.constructor == Array)

cont[cont.length]

else

cont[cont.length] = k + ": " + vs;

}

// Здесь тоже нельзя делать replace()!

cont = " " + cont.join("\n").split("\n").join("\n ");

var s = cont;

if (this.constructor == Object) {

s = "{\n"+cont+"\n}";

} else if (this.constructor == Array) {

s = "[\n"+cont+"\n]";

}

return s;

}

Пример использования:

var treeNode = {

color: "green",

children: [

"leaf1",

"leaf2"

],

properties:{

leaf: false,

href: "http://localhost"

},

slashquote: "with \ (slash) and \" (quote)"

}

alert(obj);

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

Создание методов напрямую


Как мы уже упоминали, способ с прототипами все же не является наиболее распространенным. В частности потому, что в каждом объявлении через прототипы присутствует имя типа, а значит, в случаи изменения этого имени, необходимо будет модифицировать все объявления. Это довольно неудобно.

Давайте теперь, рассмотрим менее экстравагантный, но более общепринятый способ объявления методов. Он, мало того, что лишен недостатков способа с прототипами, но еще и позволяет создавать закрытые (private) методы и свойства класса. Вот он:

// Конструктор

function MyClass() {

this.myField = 1;

// Создаем методы класса прямо в конструкторе.

this.myMethod1 = function(x) {

alert("myMethod1("+x+")");

}

// То же самое.

this.method2 = function(x) {

alert("myMethod1("+x+")");

}

}

var obj = new MyClass();// инициализация

Легко заметить, что в объявлении имя типа присутствует лишь один раз. Таким образом, добавлять методы довольно просто — достаточно лишь модифицировать конструктор.

С помощью замыканий можно реализовать private-свойства класса:

// Конструктор.

function MyClass () {

this.public = "public part"; // общедоступное свойство (this)

var private = "private part"; // свойство доступне только внутри данного класса(var)

// Доступ к публичному свойству

this.showPublic = function() {

alert(this.public);

}



// Доступ к приватному свойству

this.showPrivate = function() {

alert(private); // При доступе к приватному свойству this не пишется

}

}

var obj = new MyClass();// инициализация

Это стандартный метод, с помощью которого объявляются приватные переменные.

Классы в JavaScript


Это небольшая, но очень важная глава. Она показывает насколько, все-таки, JavaScript уникальный язык. Возможно, вы уже стали догадываться, о чем здесь пойдет речь. Дело в том, что в JavaScript нет никаких классов! То, что мы ранее пользовались этим термином, имело своей целью некоторую «обратную совместимость». То есть, использование привычных, для прочих объектно-ориентированных языков категорий. На самом же деле, все, чем мы оперируем в JavaScript, является объектами. Функция, строка, массив, число, хэш — объекты, ни больше, ни меньше. У каждого объекта есть прототип, который также является объектом.

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

 (function(n) { alert(n) })(10);

Кроме того, не бывает так же «просто функций»: все функции, объявленные в глобальной области видимости, являются методами объекта window(то же касается глобальных переменных).

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

Наследование


Довольно странно говорить о наследовании в контексте того, что мы обсудили в предыдущей главе. Ведь наследование – отношение между классами, а раз в JavaScript их нет, то о каком наследовании идет речь? Но вот наследование в JavaScript как раз есть, и если уж говорить предметно, можно наследовать объекты. Собственно, все «наследование» сводится к присваиванию нового значения прототипу объекта (свойству prototype). По сути, прототип, в стандартной терминологии, это родительский класс. Рассмотрим это «наследование» на примере:

// Базовый "класс".

function Basic() {}

Basic.prototype.method1 = function() { alert("method1") }

// Производный "класс".

function Derived() {}

Derived.prototype = new Basic(); // использовать new обязательно!

Derived.prototype.method2 = function() { alert("method2") }

var obj = new Derived();

obj.f1(); // вызывается функция базового объекта

Говоря формально, мы наследуем класс Derived от объекта класса Basic. Соответственно, при «наследовании» происходит вызов конструктора наследуемого класса, поскольку создается его экземпляр. В связи с этим в JavaScript конструктор зачастую содержит лишь описание методов и инициализацию свойств. Все остальное принято выносить в методы, дабы избежать выполнения на этапе связывания.

Данная тема достаточно нетривиальна, потому рассмотрим еще один пример:

//**

//** Базовый класс Fruit.

//**

function Fruit() {

console.log("Внутри конструктора Fruit()");

}

// Определяем новый метод для Fruit.

Fruit.prototype.grow = function() {

console.log("Внутри Fruit.grow()");

}

//**

//** Производный класс Plum(как вариант).

//**

function Plum() {

console.log("Внутри конструктора Plum()");

}

// Собственно наследуемся сливой от фрукта.

Plum.prototype = new Fruit();

// В сливе есть косточка, так что можно ее убрать.

Plum.prototype.removeKernel = function() {

console.log("Внутри Plum.removeKernel()");

}

//**

//** Клиентский код.

//**

console.log("Старт выполнения");

// Создаем объект производного "класса" Plum.

var plant = new Plum();

plant.grow(); // вызывается функция базового класса

// Создаем еще одну сливу.

var otherPlant = new Plum();

otherPlant.removeKernel(); // это уже функция производного класса

Результат выполнения данного кода вполне ожидаем с точки зрения написанного, но вот для привычного наследования классов выглядит странновато:

Внутри конструктора Fruit()

Старт выполнения

Внутри конструктора Plum()

Внутри Fruit.grow()

Внутри конструктора Plum()

Внутри Plum.removeKernel()

В большинстве объектно-ориентированных языков(C++, Java, PHP, Perl, Python и т. д.) есть понятие классов, и наследование осуществляется на уровне классов. При вызове конструктора производного класса, прямо перед началом его выполнения вызывается конструктор базового. Как мы уже отмечали, классов в JavaScript нет, а наследование происходит от объекта. Это мы и видим в логе: вначале (еще до запуска основной программы) создается объект, от которого происходит наследование, создается он с помощью function Fruit(), так что тут ничего удивительного. Далее можно заметить, что в отличии от обычного наследования классов, конструктор базового класса запускается лишь один раз, а не при каждом создании экземпляра производного класса. Таким образом, все созданные экземпляры производного класса разделяют один экземпляр базового, это полностью противоречит положению вещей при наследовании классов. С точки зрения ООП классов, это скорее агрегация, поскольку каждый экземпляр «потомка», по сути, содержит ссылку на экземпляр «родителя». Все отличие лишь в том, что при обращении к не определенному в «потомке» методу или свойству, запрос переадресовывается к «родителю», к «родителю родителя», и т. д.

С другой стороны, а чем это мешает? Других механизмов наследования в JavaScript нет, но, после некоторого размышления, становится понятно, что его вполне достаточно. Несколько позже мы покажем, как можно с помощью этого механизма эмулировать стандартный механизм наследования классов. Но его следует рассматривать лишь как формальное доказательство эквивалентности механизмов наследования. На самом деле «наследование» в JavaScript вполне удобно, хотя (тут не поспоришь) к нему нужно немного привыкнуть.

И снова прототипы


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

var object = {

// В самом объекте свойства property нет.

// Зато у него есть прототип...

prototype: {

// ...в котором данное свойство определяется...

property: 101

}

// ...так что в итоге интерпрететор должен считать его.

}

console.log("Значение свойства: " + object.property);

Оказывается, что данный пример работает не так, как мы ожидали, выдавая: "Значение свойства: undefined". То есть, то, что мы присвоили свойству prototype некоторый объект, ничего нам не дает!

Давайте попробуем по-другому:

var object = {

// В самом объекте свойства property нет.

}

// Пробуем обратиться к прототипу по-другому.

object.constructor.prototype.property = 101;

console.log("Значение свойства: " + object.property);

// А это совершенно другой объект, в нем тоже нет свойства property

var newObject = {};

console.log("Ничего: " + newObject.property);

И снова результат оказывается неожиданным. Первая запись "Значение свойства: 101" говорит нам, что нам удалось записать значение в прототип. Правда как мы видим из второй записи "Ничего: 101" свойство попало и в объект newObject, который, на первый взгляд, тут и вовсе не причем. Давайте вспомним, прошлый раздел, в нем мы изменяли прототип функции-конструктора. А теперь, обратим внимание на слово constructor. Как вы, наверное, уже догадались, это поле ссылается на функцию-конструктор данного объекта, чей прототип мы и меняем в приведенном выше коде. Теперь, становится понятно, почему свойство property появилось в объекте newObject. Как известно {} это то же самое, что new Object(), соответственно, object.constructor.prototype ссылается на прототип Object. В результате, свойство property стало доступно во всех объектах, в том числе в newObject.

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

Раз уж мы затронули эту тему, давайте посмотрим, что может выступать в качестве конструктора объекта. Мы уже знаем, что можно сконструировать объект с помощью функции из глобальной области видимости:

// Конструктор

function MyClass() {

}

var obj = new MyClass();// инициализация

Но этим все не ограничивается, ведь функции это те же переменные, а глобальные функции – просто свойства window. Почему бы не написать так:

(function () {

// Конструктор

var MyClass = function () {

}

var obj = new MyClass();// инициализация

return obj;

})();

В этом фрагменте создается экземпляр локальной функции. А можно и так:

(function () {

// Конструктор

var obj = new (function () {})();// инициализация

return obj;

})();

Тут функция-конструктор вообще анонимная, но код рабочий. Получается, что справа от ключевого слова new может стоять любая функция или, формально говоря, экземпляр self.Function. Только для них свойство prototype имеет особый смысл и только на них может ссылаться свойство object.constructor. Кстати, оно всегда равно значению находящемуся справа от ключевого слова new.

var plant = new Fruit(); 

var object = {}; // алиас для new Object()

var array = []; // алиас для new Array()

Теперь вы понимаете, почему не работал код в начале этого раздела. Свойство object.prototype произвольного объекта object не имеет никакого специального значения. Это просто свойство. А вот object.constructor.prototype обращается к свойству prototype функции-объекта (являющейся конструктором object). Таким образом, доступ к прототипам осуществляется по цепочке object.constructor.prototype.constructor.prototype, а не object.prototype.prototype, как можно понять из многих руководств по JavaScript.

«Классо-ориентированный» подход на JavaScript


Итак, теперь зная, что такое прототипы, конструкторы, как и чем они связаны, мы вернемся к нашему фруктовому примеру с наследованием. Наша цель переписать его в «классо-ориентированном» стиле. То есть, по сути, заставить конструкторы базовых классов вызываться прямо перед вызовом конструктора производного класса. Мы вынесем логику «классо-ориентированного» наследования в отдельный плагин classExtender. В таком случаи клиентский код выглядит достаточно просто:

<script src="classExtender.js"></script>

<script>

// Базовый "класс".

Fruit = extendClass(Object, {

constructor: function() {

console.log("Внутри конструктора Fruit()");

},

grow: function() {

console.log("Внутри Fruit.grow()");

}

});

// Производный "класс".

Plum = extendClass(Fruit, {

constructor: function() {

console.log("Внутри конструктора Plum()");

},

grow: function() {

console.log("Внутри Plum.grow()");

},

removeKernel: function() {

console.log("Внутри Plum.removeKernel()");

}

});

console.log("Старт выполнения.");

// Создаем объект производного "класса" Plum.

var plant = new Plum();

plant.grow(); // вызывается функция переопределенная в производном классе

// Создаем еще одну сливу.

var otherPlant = new Plum();

otherPlant.removeKernel(); // это функция производного класса

</script>

Судя по логу, мы добились своей цели:

Старт выполнения.

Внутри конструктора Fruit()

Внутри конструктора Plum()

Вызван Plum.grow()

Внутри конструктора Fruit()

Внутри конструктора Plum()

Вызван Plum.removeKernel()

Теперь, рассмотрим код плагина classExtender чтобы понять, как нам это удалось.

function extendClass(parent, properties) {

var constr = "constructor";

var resultClass = function() {

this.__proto__ = new parent();

if (properties[constr])

properties[constr].apply(this, arguments);

if (properties) {

for (var k in properties) {

if (k != constr) this[k] = properties[k];

}

}

}

return resultClass;

}

Кстати, аналогичный механизм есть в фреймворке Ext JS, используя Ext.extend(parent, properties) в описании класса можно наследовать от класса указанного в поле parent. Как вы могли заметить, плагин classExtender использует служебное поле __proto__. О нем я расскажу в следующей статье. И еще раз хочу напомнить, что цель данного плагина исключительно в том, чтоб показать эквивалентность наследования в JavaScript привычному «классо-ориентированному» наследованию.
1

Комментарии

Для того, чтоб оставлять комментарии или зарегистрируйтесь.