Pull to refresh

Попытка просто объяснить сложные, для новичков, вещи в javascript

Reading time 8 min
Views 23K
Я попытаюсь просто объяснить, как работают замыкания в 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);

В этом нелепом примере в принципе есть все: сохранение контекста вызова, замыкания, и создание конструктора.
Надеюсь на критику и исправления (так как второпях мог, что-то забыть дописать, или же просто ошибаюсь).
p.s. боярский выигрывает иногда.
Tags:
Hubs:
+64
Comments 34
Comments Comments 34

Articles