Особенности работы или «За что я люблю JavaScript»: Замыкания, Прототипирование и Контекст

в 19:51, , рубрики: javascript, замыкания, контекст, прокси, прототипы, метки: , , , ,

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

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

Думаю, что большинство программистов, писавших код на JavaScript больше пары дней, сталкивались с этими особенностями. Цель данного топика не открыть что-то новое, а попытаться описать эти особенности «на пальцах» и «недостатки» сделать «преимуществами».

В данном топике будут рассматриваться:

  1. Замыкания
  2. Прототипирование
  3. Контекст выполнения

Предисловие

Мне, как автору, конечно же хочется описать все-все-все возможности, которыми богат JavaScript. Однако если я просто попытаюсь сделать это, статья растянется на огромное количество страниц, и многие начинающие разработчики просто не смогут запомнить всю информацию. Поэтому приводимые примеры могут кому-то показаться слишком простыми, а темы раскрытыми не до конца. Но, надеюсь, статья сумеет заинтересовать тех, кто ещё не сильно знаком с данными особенностями, а тем, кто уже знаком, — помочь понять, что на самом-то деле всё элементарно.

Замыкания, или «Эти необычные области видимости»

«Архиполезная штука!» — этими двумя словами можно выразить
моё отношение к замыканиям и их реализации в JavaScript.

Суть замыканий проста: внутри функции можно использовать все пременные, которые доступны в том месте, где функция была объявлена.

Хотя идея замыканий проста, на практике зачастую возникает много непонятных моментов по поведению в том или ином случае. Так что для начала вспомним основы объявления переменной, а именно – "переменные в JavaScript объявляются с помощью ключевого слова var":

var title = "Hello World";
alert(title);

При запуске кода выведет текст "Hello World", как и ожидалось. Суть происходящего проста – создаётся глобальная переменная title со значением "Hello World", которое показывается пользователю с помощью alert-а. В данном примере, даже если мы опустим ключевое слово var, код всё равно сработает правильно из-за глобального контекста. Но об этом позже.

Теперь попробуем объявить ту же переменную, но уже внутри функции:

function example (){
	var title = "Hello World";
}
alert(title);

В результате запуска кода сгенерируется ошибка "'title' is undefined" — "переменная 'title' не была объявлена". Это происходит из-за механизма локальной области видимости переменных: все переменные, объявленные внутри фукнции являются локальными и видны только внутри этой функции. Или проще: если мы объявим какую-то переменную внутри функции, то вне этой функции доступа к этой переменной у нас не будет.

Для того, чтобы вывести надпись "Hello World", необходимо вызвать alert внутри вызываемой функции:

function example(){
	var title = "Hello World";
	alert(title);
}
example();

Либо вернуть значение из функции:

function example(){
	var title = "Hello World";
	return title;
}
alert(example());

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

Ключевое отличие в том, что в JavaScript-е функции можно объявлять внутри других функций, а сами функции в JavaScript являются объектами! Благодаря этому с ними можно производить те же действия, что и с обычными объектами — проверять на существование, присваивать переменным, добавлять свойства, вызывать методы и возвращать объект функции как разультат выполнения другой функции!

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

function A(){
	function B(){
		alert("Hello World");
	}
}
B();

Как и в примере с переменной, при запуске кода сгенерируется ошибка, что переменная B не была объявлена. Если же поместить вызов функции B сразу после объявления внутри функции A, и вызвать саму функцию A — получим заветное сообщение "Hello World"

function A(){
	function B(){
		alert("Hello World");
	}
	B();
}
A();

Теперь приступим к описанию того, обо что спотыкаются большинство начинающих изучать JavaScript – определению того, откуда переменные берут свои значения. Как упоминалось выше, переменные нужно объявлять с помощью ключевого слова var:

var title = 'external';

function example(){
	var title = 'internal';
	alert(title);
}

example();
alert(title);

В данном примере переменная title была объявлена дважды – первый раз глобально, а второй раз – внутри функции. Благодара тому, что внутри функции example переменная title была объявлена с помощью ключевого слова var, она становится локальной и никак не связана с переменной title, объявленной до функции. В результате выполнения кода вначале выведется "internal" (внутренняя переменная), а затем "external" (глобальная переменная).

Если убрать ключевое слово var из строки var title = 'internal', то запустив код, в результате дважды получим сообщение "internal". Это происходит из-за того, что при вызове функции мы объявляли не локальную переменную title, а перезаписывали значение глобальной переменной!

Таким образом можно увидеть, что использование ключевого слова var делает переменную локальной, гарантируя отсутствие конфликтов с внешними переменными (к примеру, в PHP все переменные внутри функции по умолчанию являются локальными; и для того, чтобы обратиться к глобальной переменной необходимо объявить её глобальной с помощью ключевого слова global).

Итак, как же определить какая переменная используется в функции?

Если при объявлении фукнции переменная не была объявлена локально с помощью ключевого слова var, переменная будет искаться в родительской функции. Если она не будет там найдена — поиск будет происходить дальше по цепочке функций-родителей до тех пор, пока интерпретатор не найдёт объявление переменной, либо не дойдёт до глобальной области видимости.

Если объявление переменной не будет найдено ни вверх по цепочке объявлений фукнции, ни в глобальной области видимости, существует два варианта развития:

  1. При попытке использовать (получить значение) переменной сгенерируется ошибка, что переменная не была объявлена
  2. При попытке присвоить переменной значение, переменная будет создана в глобальной области видимости, и ей присвоится значение.
function A(){
	title = 'internal';
	return function B(){
		alert(title);
	}
}
var B = A();
B();
alert(title);

Выполнив код, получим оба раза "internal". Присваивание значения переменной title внутри функции A создаёт глобальную переменную, которую можно использовать и вне функции. Следует иметь в виду, что присвоение значения переменной (а значит и создание глобальной переменной) происходит на этапе вызова функции А, так что попытка вызвать alert(title) до вызыва функции A сгенерирует ошибку.

А теперь вернёмся обратно к теме замыканий в JavaScript.

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

Как известно, все локальные переменные создаются заново при каждом новом вызове функции. Например, у нас есть функция A, внутри которой объявляется переменная title:

function A(){
	var title = 'internal';
	alert(title);
}
A();

После того, как функция A будет выполнена, переменная title перестанет существовать и к ней никак нельзя получить доступ. Попытка как-либо обратиться к переменной вызовет ошибку, что переменная не была объявлена.

Теперь внутри функции A добавим объявлении функции, выводящую значение переменной title, а тажке функцию, которая это значение изменяет на переданное, и вернём эти функции:

function getTitle (){
	var title = "default title";
	var showTitle = function(){
		alert(title);
	};
	var setTitle = function(newTitle){
		title = newTitle;
	};
	return {
		"showTitle": showTitle,
		"setTitle": setTitle
	};
}
var t = getTitle();
t.showTitle();
t.setTitle("Hello World");
t.showTitle();

До того, как запустить этот пример, попробуем рассмотреть логически поведение переменной title: при запуске функции getTitle переменная создаётся, а после окончания вызова – уничтожается. Однако при вызове функции getTitle возвращается объект с двумя динамически-объявленными функциями showTitle и setTitle, которые используют эту переменную. Что же произойдёт, если вызвать эти функции?

И теперь, запустив пример, можно увидеть, что вначале выведется "default title", а затем "Hello World". Таким образом переменная title продолжает существовать, хотя функция getTitle уже давно завершилась. При этом к данной переменной нет другого доступа, кроме как из вышеупомянутых функций showTitle/setTitle. Это и есть простой пример замыкания – переменная title «замкнулась» и стала видимой только для тех функций, которые имели к ней доступ во время своего объявления.

Если запустить функцию getTitle ещё раз, то можно увидеть, что переменная title, как и функции showTitle/setTitle, каждый раз создаются заново, и никак не связаны с предыдущими запусками:

var t1 = getTitle();
t1.setTitle("Hello World 1");

var t2 = getTitle();
t2.setTitle("Hello World 2");

t1.showTitle();
t2.showTitle();

Запустив код (не забыв добавить выше код функции getTitle), будет сгенерировано два сообщения: "Hello World 1" и "Hello World 2" (подобное поведение используется для эмуляции приватных переменных).

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

Первое — это возможность не засорять глобальную область видимости.

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

Чтобы избежать подобной ситуации, необходимо либо каждую функцию/переменную называть уникальным именем, либо использовать замыкания. Извращаться и называть каждую функцию и переменную уникальным именем неудобно, и всё равно не гарантирует полной уникальности. С другой стороны механизм замыканий предоставляет полную свободу в именовании переменных и функций. Для этого достаточно весь код обернуть в анонимную функцию, выполняющуюся сразу после объявления:

(function(){
	/* объявление функций, переменных и выполнение кода */
})();

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

Что же делать, если всё же необходимо чтобы одна или две функции были доступны глобально? И тут всё довольно просто. Самый-самый глобальный объект, обеспечивающий глобальную область видимости — это объект window. Благодаря тому, что функция — это объект, её можно присвоить свойству window, чтобы та стала глобальной. Пример объявления глобальной функции из закрытой области видимости:

(function(){
	var title = "Hello World";
	function showTitle(){
		alert(title);
	}
	window.showSimpleTitle = showTitle;
})();
showSimpleTitle();

В результате выполнения кода сгенерируется сообщение "Hello World" – локальная функция showTitle стала доступна глобально под именем showSimpleTitle, при этом использует «замкнутую» переменную title, недоступную вне нашей анонимной функции.

Т.к. мы всё обернули в анонимную функцию, которая сразу же выполняется, этой функции можно передать параметры, которые внутри этой функции будут доступны под локальными называниями. Пример с jQuery:

(function(){
	$('.hidden').hide();
})();

Вызовет ошибку, если глобальная переменная $ не является jQuery. А такое случается, если помимо jQuery используется другая библиотека, которая использует функцию $, к примеру, Prototype.JS. Решение «в лоб»:

(function(){
	var $ = jQuery;
	$('.hidden').hide();
})();

Будет работать, и будет работать правильно. Но не очень красиво. Есть более красивое решение — объявить локальную переменную $ в виде аргумента функции, передав туда объект jQuery:

(function($){
	/* Код, использующий $ */
})(jQuery);

Если вспомнить, что все аргументы функции по умолчанию являются локальными переменными, то становится ясно, что теперь внутри нашей анонимной функции $, никак не связан с глобальным объектом $, являясь ссылкой на объект jQuery. Для того, чтобы приём с анонимной функций стал понятнее, можно анонимную функцию сделать неанонимной – объявили функцию и сразу же её запустили:

function __run($){
	/* code */
}
__run(jQuery);

Ну, а если всё же понадобится вызвать глобальную функцию $, можно воспользоваться window.$.

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

При использовании событийной модели, часто возникают ситуации, когда нужно повесить одно и то же событие, но на разные элементы. Например, у нас есть 10 div-элементов, по клику на которые нужно вызывать alert(N), где N — какой-либо уникальный номер элемента.

Простейшее решение c использованием замыкания:

for(var counter=1; counter <=10; counter++){
	$('<div>').css({
		"border": "solid 1px blue",
		"height": "50px",
		"margin":  "10px",
		"text-align": "center",
		"width": "100px"
	}).html('<h1>'+ counter +'</h1>')
	.appendTo('body')
	.click(function(){
		alert(counter);
	});
}

Однако выполнение данного кода приводит к «неожиданному» результату — все клики выводят одно и то же число — 11. Догадываетесь почему?

Ответ прост: значение переменной counter берётся в момент клика по элементу. А так как к тому времени значение переменной стало 11 (условие выхода из цикла), то и выводится соответственно число 11 для всех элементов.

Правильное решение — динамически генерировать функцию обработки клика отдельно для каждого элемента:

for(var counter=1; counter <=10; counter ++){
	$('<div>').css({
		"border": "solid 1px blue",
		"height": "50px",
		"margin":  "10px",
		"text-align": "center",
		"width": "100px"
	}).html('<h1>'+ counter +'</h1>')
	.appendTo('body')
	.click((function(iCounter){
		return function(){
			alert(iCounter);
		}
	})(counter));
}

В данном подходе мы используем анонимную функцию, которая принимает значение counter в виде параметра и возвращает динамическую функцию. Внутри анонимной функции локальная переменная iCounter содержит текущее значение counter на момент вызова функции. А так как при каждом вызове любой функции все локальные переменные объявляются заново (создаётся новое замыкание), то при вызове нашей анонимной функции каждый раз будет возращаться новая динамическая функция с уже «замкнутым» номером.

Если говорить проще, то запустив функцию второй (третий, четвёртый...) раз, все локальные переменные будут созданы в памяти заново и не будут иметь никакого отношения к переменным, созданным во время предыдущего вызова функции.

Сложно? Думаю, что с первого раза — да. Зато не нужно иметь кучу глобальных переменных, и делать проверки в функции «откуда же меня вызвали...». А с использованием jQuery.each, который по-умолчанию вызвает переданную функцию, код становится ещё проще и читабельнее:

$('div.handle-click').each(function(counter){
	$(this).click(function(){
		alert(counter);
	});
});

Благодаря замыканиям можно писать красивый, краткий и понятный код, называя переменные и функции так, как хочется; и быть уверенным, что данный код не будет конфликтовать с другими подключаемыми скриптами.

Прототипирование или «Я хочу сделать объект класса»

Про прототипирование в JavaScript написано много хороших статей. Поэтому постараюсь не повторять то, что уже написано, а просто опишу основу механизма прототипов.

В JavaScript-е есть понятие объекта, но нет понятия класса. Из-за слабой типизации, почти все данные в JavaScript-е являются объектами. А в качестве альтернативы классам есть возможность прототипирования — назначать объекту прототип со свойствами и методами «по умолчанию».

Работать с объектами в JavaScript очень просто — нужно всего лишь объявить объект и назначить ему свойства и методы:

var dog = {
	"name": "Rocky",
	"age": "5",
	"talk": function(){
		alert('Name: ' + this.name + ', Age: ' + this.age);
	}
};

Если у нас много объектов, то удобнее будет сделать отдельную функцию, возвращающую объект:

function getDog(name, age){
	return {
		"name": name,
		"age": age,
		"talk": function(){
			alert('Name: ' + this.name + ', Age: ' + this.age);
		}
	};
}
var rocky = getDog('Rocky', 5);
var jerry = getDog('Jerry', 3);

То же самое можно сделать с использованием прототипов:

function Dog(name, age){
	this['name'] = name;
	this.age = age;
}
Dog.prototype = {
	"talk": function(){
		alert('Name: ' + this.name + ', Age: ' + this.age);
	}
};

var rocky = new Dog('Rocky', 5);
var jerry = new Dog('Jerry', 3);

Как упоминалось выше, прототип — это простой объект, который содержит свойства и методы «по умолчанию». Т.е. если у какого-либо объекта попытаться получить свойство или вызвать функцию, которой у объекта нет, то JavaScript интерпретатор, прежде чем сгенерировать ошибку, попытается найти это свойство/функцию в объекте-прототипе и, если оно будет найдено, будет использоваться свойство/функция из прототипа.

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

И небольшой наглядный пример расширения возможностей существующих объектов с помощью прототипов

Необходимо получить название дня недели от какой-либо даты, но встроенный объект Date содержит только метод getDay, возвращающий числовое представление дня недели от 0 до 6: от воскресенья до субботы.

Можно сделать так:

function getDayName(date){
	var days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
	return days[date.getDay()];
}
var today = new Date();
alert(getDayName(today));

Или использовать прототипирование и расширить встроенный объект даты:

Date.prototype.getDayName = function(){
	var days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
	return days[this.getDay()];
}
var today = new Date();
alert(today.getDayName());

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

Контекст выполнения или «Этот загадочный this»

Переходя на JavaScript с других языков программирования, где используется ООП, довольно сложно понять, что же в JavaScript означает объект this. Если попытаться объяснить просто, то this — это ссылка на объект, для которого вызывается функция. Например:

var exampleObject = {
	"title": "Example Title",
	"showTitle": function(){
		alert(this.title);
	}
};
exampleObject.showTitle();

Из примера видно, что при вызове exampleObject.showTitle() функция вызывается как метод объекта, и внутри функции this ссылается на объект exampleObject, вызвавший функцию. Сами по себе функции никак не привязаны к объекту и существуют отдельно. Привязка контекста происходит непосредственно во время вызова функции:

function showTitle(){
	alert(this.title);
}

var objectA = {
	"title": "Title A",
	"showTitle": showTitle
};

var objectB = {
	"title": "Title B",
	"showTitle": showTitle
};

objectA.showTitle();
objectB.showTitle();

В данном примере наглядно показывается, что при вызове objectA.showTitle(), this ссылкается на objectA, а при вызове objectB.showTitle() — на objectB. Сама функция showTitle существует отдельно и просто присваивается объектам как свойство во время создания.

Если при вызове функции она (функция) не ссылается ни на один объект, то this внутри функции будет ссылаться на глобальный объект window. Т.е. если просто вызвать showTitle(), то будет сгенерирована ошибка, что переменная title не объявлена; однако если объявить глобальную переменную title, то функция выведет значение этой переменной:

var title = "Global Title";
function showTitle(){
	alert(this.title);
}
showTitle();

Чтобы продемонстрировать, что контекст функции определяется именно во время вызова, приведу пример, где функция изначально существует только как метод объекта:

var title = "Global Title";
var exampleObject = {
	"title": "Example Title",
	"showTitle": function(){
		alert(this.title);
	}
};
var showTitle = exampleObject.showTitle; // вначале забираем ссылку на функцию
showTitle(); // а тут вызываем функцию без ссылки на объект

В результате выполнения выведется сообщение "Global Title", означающее, что во время вызова функции this указывает на глобальный объект window, а не на объект exampleObject. Это происходит из-за того, что в строке var showTitle = exampleObject.showTitle мы получаем ссылку только функцию, и при вызове showTitle() нет ссылки на исходный объект exampleObject, отчего this начинает ссылаться на объект window.

Упрощая: если функция вызвана как свойство объекта, то this будет ссылаться на этот объект. Если вызывающего объекта нет, this будет ссылаться на глобальный объект window.

Пример частой ошибки:

var title = "Global Title";
var exampleObject = {
	"title": "Example Title",
	"showTitle": function(){
		alert(this.title);
	}
};
jQuery('#exampleDiv').click(exampleObject.showTitle);

При клике на DIV с id "exampleDiv" вместо ожидаемого "Example Title", выведется "Global Title". Это происходит из-за того, что на событие клика мы отдаём функцию, но не отдаём объект; и, в итоге, функция запускается без привязки к объекту. Чтобы запустить функцию, привязанную к объекту, нужно вызывать функцию с ссылкой на объект:

jQuery('#exampleDiv').click(function(){
	exampleObject.showTitle();
});

Чтобы избежать подобного «неуклюжего» объявления, средствами самого JavaScript можно привязать функцию к контексту с помощью bind:

jQuery('#exampleDiv').click(exampleObject.showTitle.bind(exampleObject));

Однако всеми любимый ИЕ до 9 версии не поддерживает данную возможность. Поэтому большинство JS библиотек самостоятельно реализуют данную возможность тем или иным способом. Например, в jQuery это proxy:

jQuery('#exampleDiv').click(jQuery.proxy(exampleObject.showTitle, exampleObject));
// или так:
jQuery('#exampleDiv').click(jQuery.proxy(exampleObject, "showTitle")); 

Суть подхода довольна проста — при вызове jQuery.proxy возвращается анонимная функция, которая с помощью замыканий вызывает исходную функцию в контексте переданного объекта.

По факту, JavaScript может запустить любую функцию в любом контексте. И даже не придётся присваивать функцию объекту. Для этого в JavaScript-е предусмотрено два способа — apply и call:

function showTitle(){
	alert(this.title);
}

var objectA = {
	"title": "Title A",
};

var objectB = {
	"title": "Title B",
};

showTitle.apply(objectA);
showTitle.call(objectA);

Без использования параметров функции, обе работают одинаково — функции apply и call отличаются лишь способом передачи параметров при вызове:

function example(A, B, C){
	/* code */
}
example.apply(context, [A, B, C]);
example.call(context, A, B, C);

Более развернутую информацию по затронутым темам можно прочитать тут:

Автор: Gromo

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js