Я попытаюсь просто объяснить, как работают замыкания в Javascript, как работает this, как создавать конструкторы для своих классов и чем различаются различные подходы к их созданию.
Статья не претендует на новаторство, но достаточно доступные объяснения how it works для новичков я не видел, и на мой взгляд — это три самых узких места в Javascript (не привязанному к какому либо контексту, серверу или браузеру, например).
Замыкания
Википедия нам говорит — замыканиями являются функции, определенные в других функциях.
Замыканиями в javascript являются все функции, потому что они неявно лежат в теле «Главной функции».
Что они из себя представляют? Что значит «замыкание»?
За терминологией лежит очень простой смысл, который так же просто можно объяснить.
Функции замыкания имеют возможность обращаться к переменным, созданным не только в контексте самой функции, но и на всех уровнях выше.
Проиллюстрирую кодом:
var a = 1; var b = 2; function closureFirstLevel() { return a + b; } function createrOfSecondLevelClosure() { var c = a + b; return function() { return c + closureFirstLevel() + a + b; } } var c = createrOfSecondLevelClosure(); function runCInAnotherContext() { return c(); } console.log(a,b); console.log('Сумма переменных a & b объявленных вне функции которая считает их сумму:',closureFirstLevel()); console.log('Сумма переменных c (объявленной на уровень выше), возвращаемого значения функции объявленной на два уровня выше, и переменных a и b объявленных так же на два уровня выше:',c());
Теперь немного разберемся, если что-то стало непонятно.
closureFirstLevel обращается к переменным объявленным вне этой функции(внешним переменным) и возвращает их сумму.
createrOfSecondLevelClosure обращается к переменным a и b, сохраняет их сумму в переменной, объявленную в этой функции и возвращает функцию, которая считает сумму c, результата возвращаемого функцией closureFirstLevel и переменных a и b, объявленных на два уровня ниже.
Если запустить runCInAnotherContext он запустит функцию 'c' (ведь createrOfSecondLevelClosure возвращает нам функцию, которую можно сохранить, и переменная 'c', объявленная в глобальной области видимости записывает эту функцию), которая будет работать как и задуманно: возвращать сумму переменных и результата функции, объявленных вне контекста функции runCInAnotherContext, так как при инициализации она замкнула на себя эти переменные.
Замыкания в массовом создании событий.
Обращение к переменной, использующейся в цикле как счетчик, всегда передается как ссылка (хоть обычно и является числом), пока работает цикл. В итоге все созданные функции будут иметь последнее значение этой переменной.
См. пример
var elem = document.getElementsByTagName('a'); for (var n = 0, l = elem.length; n < l; n++ ) { elem[n].onclick = function() { alert(n); return false; } } //Все время будет выдавать порядковый номер последнего эллемента в alert
Можно замкнуть функцию:
var elem = document.getElementsByTagName('a'); for (var n = 0, l = elem.length; n < l; n++ ) { elem[n].onclick = function(x) { return function() { alert(x); return false; } }(n); //Создаем функцию, сразу же её вызываем она возвращает нам порядковый номер элемента в alert при событии click на элементе. }
А так же можно использовать совершенно другой подход. У массивов метод forEach (является частью стандарта EcmaScript5) работает не совсем как цикл for.
Он принимает один аргумент — функцию, которая будет обрабатывать элементы и которая принимает аргументы: elementOfArray, positionInArray, Array. И каждый раз эта функция вызывается, естественно, в своем контексте.
Где-то достаточно принимать только первый аргумент, где-то больше.
Мы можем эту функцию вызвать для нашего NodeList объекта, с помощью подмены контекста исполнения. (Для более полного разъяснения как это работает смотри часть статьи про this и про прототипы).
var elem = document.getElementsByTagName('a'); Array.prototype.forEach.call(elem,function(el,position) { el.onclick = function() { alert(position); return false; } })
Ключевое слово this
Это слово ссылается на текущий объект, вызывающий функцию.
Все функции, объявленные в глобальном контексте, являются методами (в браузере) объекта window, так же все функции, вызванные без контекста в this, ссылаются на window.
Все довольно просто, пока не начинаешь разбираться с асинхронным программированием.
var a = { property1: 1, property2: 2, func: function() { console.log(this.property1 + this.property2, 'test'); return this.property1 + this.property2; } } console.log(a.func()); //this ссылается на объект 'a', которому принадлежит вызываемый метод. setTimeout(function() { console.log(a.func()); //this все так же ссылается на объект 'a', потому что функция, переданная в таймаут, замкнула на себя объект 'a' },100); setTimeout(a.func,101); //результат будет уже другой, NaN (как результат сложения undefined + undefined) //потому, как здесь мы передаем лишь функцию, а сама по себе она не хранит ссылку на объект, к которому принадлежит
Вместо setTimeout можно подставить setInterval или привязку обработчика события (например: elem.onclick или addEventListener), или любой другой способ выполнять отложенные вычисления, все они так или иначе вызывают потерю контекста исполнения. И чтобы сохранить this есть несколько путей.
Можно просто обернуть это в анонимную функцию, можно создать переменную var that = this и использовать that вместо this (переменную создать вне вызываемой функции, естественно), а также воспользоваться самым правильным способом — насильно привязать. Для этого у функций есть встроенный метод bind (стал доступен в стандарте EcmaScript 5, поэтому для старых браузеров нужно реализовывать его поддержку), который возвращает новую функцию, привязанную к нужному контексту и аргументам.
Примеры:
function logCurrentThisPlease() { console.log(this); } logCurrentThisPlease(); //window var a = {} a.logCurrentThisPlease = logCurrentThisPlease; a.logCurrentThisPlease(); //a setTimeout(a.logCurrentThisPlease, 100); //window, так как мы передаем только ссылку на функцию setTimeout(function() { a.logCurrentThisPlease(); }, 200);//a setTimeout(function() { this.logCurrentThisPlease(); }.bind(a), 200);//a var that = a; function logCurrentThatPlease() { console.log(that); } logCurrentThatPlease(); //a setTimeout(logCurrentThatPlease, 200);//a var logCurrentBindedContextPlease = logCurrentThisPlease.bind(a); //первый аргумент — контекст, к которому нужно привязать, остальные аргументы — аргументы функции logCurrentBindedContextPlease(); //a setTimeout(logCurrentBindedContextPlease, 200); //a
Ну и посложнее пример.
Потеря в рекурсивных функциях, работающих через определенные интервалы времени.
var a = { i: 0, infinityIncrementation: function() { console.log( this.i++ ); if (this.i < Infinity) setTimeout(this.infinityIncrementation,500); } } a.infinityIncrementation(); // 0,undefined — не работает, потому что теряется контекст исполнения a.infinityIncrementation = a.infinityIncrementation.bind(a); //не правильный но работающий способ a.infinityIncrementation(); //0,1,2,3,4,5,6,7,8,9,10...Infinity-1 //правильный способ var b = { i: 0, infinityIncrementation: function() { console.log( this.i++ ); if (this.i < Infinity) setTimeout(function() {this.infinityIncrementation}.bind(this),500); } } b.infinityIncrementation(); //0,1,2,3,4,5,6,7,8,9,10...Infinity-1
Почему второй работающий способ правильный, а первый неправильный, смотри в часть статьи про прототипы.
Методы функций, позволяющие менять контекст исполнения — bind,call,apply
Function.bind — метод, принимающий первый аргумент как контекст, в котором он будет исполняться (каким будет this), и остальные как неограниченное количество аргументов, с которыми будет вызываться возвращаемая функция.
Function.apply — метод, вызывающий функцию, первый аргумент – аргумент, который будет являться this в функции, второй — массив аргументов, с которыми будет вызвана функция.
Function.call — то же самое, что и apply, только вместо второго аргумента, неограниченное количество аргументов, которые будут переданы в функцию.
Конструкторы объектов
Многие создают конструкторы так:
function SuperObjectConstructor() { this.a = 1; this.b = 2; this.summ = function() { return this.a + this.b; } }
И это не очень правильно. Что здесь неправильно? В данном примере неправильно только одно то, что функция объявлена в теле конструктора. Чем же это плохо?
Во-первых, переопределить такую функцию через изменение прототипа функции не получится, то есть всем объектам сразу, инициализировавшимся через данный конструктор не получится изменить метод на другой. Пропадает возможность нормально наследоваться.
Во-вторых — лишний расход памяти:
var a = new SuperObjectConstructor(); var b = new SuperObjectConstructor(); console.log(a.summ == b.summ); //false
Так как каждый раз заново создается функция.
По хорошему тону (и для лучшего понимания кода) в конструкторах нужно определять только переменные (точнее поля объекта), которые только для него будут уникальны.
Остальное лучше определять через прототип, в любом случае если только для конкретного объекта нужно переопределить общее свойство или метод, это можно сделать напрямую, не затрагивая прототип.
Как это делается:
function SuperObjectConstructorRightVersion(a,b) { this.a = a || this.constructor.prototype.a; //Берем дефолтное значение из прототипа конструктора this.b = b || this.constructor.prototype.b; } SuperObjectConstructorRightVersion.prototype = { //изменяем прототип полностью constructor: SuperObjectConstructorRightVersion, //так как мы его полностью заменяем нужно переопределить и конструктор a: 1, b: 2, summ: function() { return this.a + this.b; } } /*или такой способ SuperObjectConstructorRightVersion.prototype.a = 1; SuperObjectConstructorRightVersion.prototype.b = 2; SuperObjectConstructorRightVersion.prototype.summ = function() {....}; Но он менее элегантный и занимает больше места. */ var abc = new SuperObjectConstructorRightVersion(); console.log(abc.summ());//3 var bfg = new SuperObjectConstructorRightVersion(5,20); console.log(bfg.summ());//25
Многим не хватает в javascript возможностей, которые и так практически всегда нужны лишь для самоконтроля, таких как приватные методы и функции, к которым сможет напрямую обращаться только сам объект и его методы, и часто реализуют их как переменные и функции, объявленные в теле функции-конструктора. Так же многие говорят, что это — плохой тон, но мало где говорится, почему это плохой тон.
Причина одна, то, что если в этом конструкторе нужно будет что-то изменить, нужно будет лезть в исходник и менять там, а не через прототип.
Так же наследоваться от данного конструктора с целью расширить использование этих «приватных» свойств и методов будет крайне трудно.
Ещё один тонкий момент. Не привязывайте с помощью bind методы к контексту объекта (в конструкторе при инициализации, вне его в принципе можно), если хотите получить возможность переносить этот метод на другие объекты или просто использовать его в другом контексте.
Это нам позволяют делать встроенные объекты.
Например, можно использовать метод массивов forEach для других enumerable(перечисляемых) объектов. Например, для всех видов NodeList (живых и не живых) (как было показано выше).
Вывод
А теперь напишем не большой конструктор, как пример, объединяющий содержимое статьи.
function Monster(name, hp, dmg) { this.name = name || this.constructor.prototype.name(); this.hp = hp || this.constructor.prototype.hp; this.dmg = dmg || this.constructor.prototype.dmg; } Monster.prototype = { constructor: Monster, hp: 10, dmg: 3, name: function() { return 'RandomMonster'+(new Date).getTime(); }, offerFight: function(enemy) { if (!enemy.acceptFight) { alert('this thing cant fight with me :('); return; } enemy.acceptFight(this); this.acceptFight(enemy); }, acceptFight: function(enemy) { var timeout = 50 + this.diceRollForRandom(); this.attack(enemy,timeout); }, diceRollForRandom: function() { return (Math.random() >= 0.5 ? 50 : 20); }, takeDmg: function(dmg) { console.log(this.name,' was damaged (',dmg,'),current HP is ',this.hp-dmg); return this.hp -= dmg; }, attack: function(enemy,timeout) { if (enemy.takeDmg(this.dmg) <= 0) { enemy.die(); this.win(); return; } this.to = setTimeout(function() {this.attack(enemy)}.bind(this),timeout); }, win: function() { alert('My name is ' + this.name + ', and Im a winner'); }, die: function() { alert('I died, ' + this.name); clearTimeout(this.to); } } var ChuckNorris = new Monster('Chuck Norris', 100, 100); var MikhailBoyarsky = new Monster('Misha Boyarsky', 200, 50); MikhailBoyarsky.offerFight(ChuckNorris);
В этом нелепом примере в принципе есть все: сохранение контекста вызова, замыкания, и создание конструктора.