자바스크립트 this 바인딩 정리
생성자 함수로 인스턴스를 만들 때마다 곤란한 지점이 하나 있었다. 생성자 함수를 정의하는 시점에는 아직 인스턴스가 없으니, 만들어질 인스턴스를 가리킬 식별자도 정해지지 않은 상태였다. 인스턴스의 프로퍼티에 값을 넣으려면 "지금 막 만들어지고 있는 그 객체"를 지칭할 무언가가 필요했다. 그게 this였다.
this는 자기 참조 변수(자신이 속한 객체나 자신이 생성할 인스턴스를 가리키는 변수)다. 함수가 호출되면 자바스크립트 엔진이 arguments 객체와 함께 this도 암묵적으로 함수 내부로 넣어준다. 그래서 코드 어디에서든 참조할 수 있고, 지역 변수처럼 쓸 수 있다. 다만 this가 무엇을 가리키느냐(=this 바인딩)는 함수가 어떻게 호출되느냐에 따라 동적으로 정해진다. 바인딩 자체는 식별자와 값을 연결하는 과정인데, this 바인딩은 그중에서도 this라는 식별자에 어떤 객체를 묶어줄지를 결정하는 과정이다.
객체 안의 this는 자신이 속한 객체를 가리켰다
객체 리터럴 안의 메서드에서 this를 찍어보면 그 메서드를 호출한 객체가 나왔다.
const circle = {
radius: 5,
getDiameter() {
return 2 * this.radius;
}
};
console.log(circle.getDiameter()); // 10생성자 함수의 경우는 조금 다르다. 함수를 정의하는 시점에는 아직 인스턴스가 없지만, new와 함께 호출되는 순간 새로 만들어지는 인스턴스가 this로 들어왔다.
function Circle(radius) {
this.radius = radius;
}
Circle.prototype.getDiameter = function () {
return 2 * this.radius;
};
const circle = new Circle(5);
console.log(circle.getDiameter()); // 10전역에서 this는 전역 객체(브라우저에서는 window)였다. 일반 함수로 호출하면 함수 안의 this도 전역 객체가 됐다. 단, strict mode(엄격 모드, ES5에 추가된 더 깐깐한 실행 모드)에서는 일반 함수 내부의 this에 undefined가 들어갔다.
console.log(this); // window
function square(number) {
console.log(this); // window
return number * number;
}
square(2);
const person = {
name: 'Lee',
getName() {
console.log(this); // {name: "Lee", getName: f}
return this.name;
}
};
console.log(person.getName()); // Lee
function Person(name) {
this.name = name;
console.log(this); // Person {name: "Lee"}
}
const me = new Person('Lee');렉시컬 스코프와 this 바인딩은 결정 시점이 달랐다
렉시컬 스코프(함수 정의 위치로 상위 스코프를 정하는 방식)는 함수 정의가 평가되어 함수 객체가 만들어지는 시점에 결정된다. 반면 this 바인딩은 함수를 호출하는 시점에 결정된다. 같은 함수도 어떻게 부르느냐에 따라 this가 달라진다는 뜻이다.
호출 방식은 크게 네 가지였다.
- 일반 함수 호출
- 메서드 호출
- 생성자 함수 호출
Function.prototype.apply/call/bind메서드에 의한 간접 호출
일반 함수로 부르면 무조건 전역 객체였다
전역 함수든, 메서드 안에 정의된 중첩 함수든, 콜백 함수든 일반 함수로 호출되면 this에 전역 객체가 바인딩됐다. strict mode에서는 undefined. 객체를 생성하지 않는 일반 함수에서 this는 사실상 의미가 없었다.
문제는 메서드 안에서 정의한 중첩 함수나, 메서드에 넘긴 콜백 함수가 일반 함수로 호출될 때다. 외부 메서드의 this와 안쪽 함수의 this가 따로 놀게 된다. 메서드를 도와주는 헬퍼 역할이어야 할 함수가 같은 객체를 참조하지 못하니, 의도한 동작이 어그러진다.
이걸 메서드의 this와 일치시키는 방법이 세 가지 있었다.
변수에 this를 담아두는 방법
먼저 this를 외부 메서드 안에서 다른 변수에 담아두는 방식이다. 그러면 안쪽 함수는 스코프 체인을 타고 그 변수를 그대로 쓸 수 있다.
var value = 1;
const obj = {
value: 100,
foo() {
const that = this;
setTimeout(function () {
console.log(that.value); // 100
}, 100);
}
};
obj.foo();bind로 콜백의 this를 묶어두는 방법
콜백 함수 자체에 bind로 this를 명시적으로 묶어 넘기는 방식도 있다.
var value = 1;
const obj = {
value: 100,
foo() {
setTimeout(function () {
console.log(this.value); // 100
}.bind(this), 100);
}
};
obj.foo();화살표 함수로 상위 this를 가져가는 방법
화살표 함수는 자기 자신의 this를 만들지 않고 상위 스코프의 this를 그대로 쓴다. 콜백을 화살표 함수로 바꾸면 자연스럽게 외부 메서드의 this를 가져왔다.
var value = 1;
const obj = {
value: 100,
foo() {
setTimeout(() => console.log(this.value), 100);
}
};
obj.foo();메서드의 this는 마침표 앞의 객체였다
메서드 호출에서 this는 메서드를 소유한 객체가 아니라 메서드를 호출한 객체에 바인딩됐다. 마침표 앞에 적힌 객체가 this가 된다.
const anotherPerson = {
name: 'Kim'
};
anotherPerson.getName = person.getName;
console.log(anotherPerson.getName()); // Kim
const getName = person.getName;
console.log(getName()); // ''person.getName을 anotherPerson에 붙여서 부르면 this는 anotherPerson이 됐다. 같은 메서드를 변수에 담아 일반 함수처럼 부르면 this는 전역 객체로 돌아갔고, 브라우저에서 window.name이 빈 문자열이라 결과도 ''였다. 프로토타입 메서드 내부의 this도 같은 규칙을 따랐다. 호출한 객체가 곧 this다.
생성자 함수의 this는 곧 만들어질 인스턴스였다
new와 함께 호출된 생성자 함수의 this는 그 호출로 새로 만들어지는 인스턴스에 바인딩됐다. 이미 위의 Circle, Person 예제에서 본 동작이다.
apply, call, bind로 this를 직접 지정하기
Function.prototype.apply, call, bind는 Function.prototype의 메서드라서 모든 함수가 상속받아 쓸 수 있다. 인수로 this로 사용할 객체와 함수에 전달할 인수 리스트를 받는다.
apply와 call은 함수를 호출하면서 첫 번째 인수로 받은 객체를 this에 바인딩한다.
function getThisBinding() {
return this;
}
const thisArg = { a: 1 };
console.log(getThisBinding()); // window
console.log(getThisBinding.apply(thisArg)); // {a: 1}
console.log(getThisBinding.call(thisArg)); // {a: 1}둘의 차이는 인수를 넘기는 방식뿐이었다. apply는 두 번째 인수로 인수 배열을 받고, call은 두 번째 이후로 인수를 쉼표로 구분해 받는다. 본질적으로 같은 일을 한다.
자주 쓰는 패턴 하나는 유사 배열 객체(length 프로퍼티가 있고 인덱스로 접근 가능하지만 진짜 배열은 아닌 객체)에 배열 메서드를 빌려 쓰는 경우였다. arguments 객체에는 Array.prototype.slice 같은 배열 메서드가 없지만, call로 this를 arguments로 묶어주면 배열 메서드를 그대로 쓸 수 있다.
function convertArgsToArray() {
console.log(arguments);
const arr = Array.prototype.slice.call(arguments);
console.log(arr);
return arr;
}
convertArgsToArray(1, 2, 3); // [1, 2, 3]bind는 결이 다르다. 함수를 호출하지는 않고, 첫 번째 인수로 받은 객체로 this가 묶인 새 함수를 만들어 돌려준다. 메서드의 this와 메서드 안 콜백 함수의 this가 어긋나는 문제를 풀 때 유용했다.
const person = {
name: 'Lee',
foo(callback) {
setTimeout(callback.bind(this), 100);
}
};
person.foo(function () {
console.log(`Hi! my name is ${this.name}.`); // Hi! my name is Lee.
});호출 방식별 this 바인딩 한눈에 보기
| 함수 호출 방식 | this 바인딩 |
|---|---|
| 일반 함수 호출 | 전역 객체 (strict mode에서는 undefined) |
| 메서드 호출 | 메서드를 호출한 객체 |
| 생성자 함수 호출 | 생성자 함수가 생성할 인스턴스 |
apply/call/bind 간접 호출 | 첫 번째 인수로 전달한 객체 |
this가 헷갈렸던 건 결국 호출 방식을 놓치고 있었기 때문이다. 함수가 어디에 정의되어 있는지를 보고 있는 게 아니라, 어떻게 불리는지를 봐야 했다. 같은 함수라도 obj.method(), method(), method.call(other), new method()가 전부 다른 this로 들어왔다.