이벤트 핸들러 안의 this — 누가 그 함수를 불렀는가
onchange="handleFile(this.files)" 라고 쓰면 this 는 그 input 입니다.
그런데 handleFile 안으로 한 발 들어가는 순간 this 가 사라집니다.
같은 줄에 있던 this 인데 함수 경계 하나를 넘었다고 달라집니다.
자바스크립트에서 this 는 함수가 어디에 쓰였는지가 아니라
어떻게 불렸는지로 정해지기 때문입니다.
원문은 2018년 12월에 쓴 노트입니다. this 의 규칙 자체는 언어의 기본이라
그때와 지금이 같지만, 그 사이에 답이 바뀐 부분이 있습니다 —
화살표 함수가 흔해지면서 새로운 함정이 생겼고, 모던 코드에서는 애초에
this 를 쓰지 않는 쪽이 정답이 됐습니다. 원문에 없던 그 두 가지를 채웠습니다.
1. 인라인 핸들러는 문자열이 아니라 함수 본문이다
파일 선택을 받으려고 이렇게 썼다고 합시다.
<input type="file" onchange="handleFile(this.files)">
function handleFile(files) {
console.log(this) // ← input 일 거라고 기대했다
}
찍히는 것은 input 이 아니라 Window 입니다. 이유를 알려면 브라우저가
저 속성값으로 무엇을 하는지를 봐야 합니다. 속성 안의 문자열은 함수의 본문이 됩니다.
브라우저는 대략 이런 함수를 만들어 onchange 에 답니다.
// 브라우저가 만들어 다는 것
function (event) {
handleFile(this.files) // this = 이벤트가 달린 그 input
}
믿기 어렵다면 속성에 문장을 하나 더 넣어 보면 분명해집니다. 세미콜론으로 여러 문장을 쓸 수 있다는 것 자체가 저기가 함수 본문이라는 증거입니다.
<input type="file" onchange="console.log(this); handleFile(this.files)">
// 첫 줄은 input 을 찍는다. 두 번째 줄 안에서는 아니다
2. 그래서 왜 한 겹 들어가면 사라지는가
handleFile(this.files) 를 보면 this.files 라는 값만 넘어갑니다.
this 자체는 넘어가지 않습니다. 그리고 handleFile(...) 은
아무 객체에도 붙지 않은 맨 호출입니다.
obj.fn()— 점 앞의obj가this가 됩니다.fn()— 점 앞에 아무것도 없으니 기본값이 들어갑니다.
그 기본값이 Window 인데, 여기가 원문에서 한 걸음 더 나갔어야 할 곳입니다.
Window 가 되는 것은 느슨한 모드(sloppy mode)일 때뿐입니다.
맨 호출 fn() 안의 this | 값 |
|---|---|
일반 <script> (느슨한 모드) | Window |
'use strict' | undefined |
ES 모듈 <script type="module"> | undefined — 모듈은 항상 엄격 모드 |
| 클래스 본문 안 | undefined — 클래스도 항상 엄격 모드 |
차이가 중요한 이유는 증상이 다르기 때문입니다. Window 면 this.files 가
조용히 undefined 로 흘러가 한참 뒤에 엉뚱한 곳에서 터지고,
undefined 면 Cannot read properties of undefined 로
그 자리에서 멈춥니다. 요즘 코드는 대부분 모듈이라 후자입니다.
인라인 핸들러의 숨은 스코프
속성 안에서는 this. 를 빼고 onchange="handleFile(files)" 라고 써도
동작합니다. 브라우저가 그 함수를 만들 때 스코프 체인에 요소 자신과 문서를 끼워 넣기
때문입니다. 편해 보이지만 실제로는 함정입니다 — id, name,
title 같은 이름을 쓰면 내가 만든 전역 변수가 아니라 요소의 속성이
잡힙니다. 인라인 핸들러를 쓰지 말아야 할 이유가 하나 더 늘어난 셈입니다.
3. 굳이 넘기려면 — bind · call · apply
호출 방식으로 this 가 정해지므로, 호출 방식을 바꾸면 됩니다.
// 셋 다 handleFile 안의 this 를 input 으로 만든다
onchange="handleFile.call(this, this.files)"
onchange="handleFile.apply(this, [this.files])"
onchange="handleFile.bind(this)(this.files)"
call 과 apply 는 지금 부르면서 this 를 지정하고,
bind 는 this 가 고정된 새 함수를 만들어 돌려줍니다.
인자를 낱개로 넘기면 call, 배열로 넘기면 apply 입니다.
bind 는 매번 다른 함수를 만든다
실무에서 자주 밟는 함정입니다. bind 는 호출할 때마다 새 함수 객체를
만듭니다. 그래서 아래는 절대 떼어지지 않습니다.
el.addEventListener('click', this.onClick.bind(this))
el.removeEventListener('click', this.onClick.bind(this)) // 다른 함수다
const handler = this.onClick.bind(this) // 참조를 붙들어 둔다
el.addEventListener('click', handler)
el.removeEventListener('click', handler)
4. 애초에 감싸지 않으면 된다
addEventListener 로 붙이거나 onchange 프로퍼티에 함수를 직접
대입하면, 내 함수가 곧 핸들러가 됩니다. 브라우저가 다른 함수로 감싸지 않으므로
this 가 바뀔 일이 없습니다.
const input = document.querySelector('input[type=file]')
input.addEventListener('change', function () {
console.log(this) // input — 브라우저가 요소를 this 로 걸어 준다
})
input.onchange = function () {
console.log(this) // input — 마찬가지
}
화살표 함수로 바꾸면 다시 깨진다
원문을 쓰던 2018년 이후 가장 많이 늘어난 실수입니다. 화살표 함수는 자기
this 를 갖지 않습니다. 바깥 스코프의 것을 그대로 씁니다. 그래서
브라우저가 요소를 걸어 줘도 그 값이 무시됩니다.
input.addEventListener('change', () => {
console.log(this) // input 이 아니다 —
// 모듈이면 undefined, 일반 스크립트면 Window
})
bind 도 call 도 화살표 함수에는 듣지 않습니다.
고칠 수 있는 값이 아니라 처음부터 없는 값이기 때문입니다.
요소가 필요하면 다음 절의 방법을 쓰면 됩니다.
5. 지금의 정답 — this 를 쓰지 않는다
여기까지가 원문의 범위였습니다. 그런데 실무에서 권할 결론은 따로 있습니다. 이벤트 객체가 같은 값을 더 정확하게 갖고 있습니다.
input.addEventListener('change', (event) => {
console.log(event.currentTarget) // 핸들러가 달린 요소 = this 와 같다
console.log(event.target) // 실제로 이벤트가 시작된 요소
})
currentTarget
- 핸들러를 붙인 요소
this와 같은 값- 화살표 함수에서도 동작한다
target
- 이벤트가 시작된 요소
- 버블링으로 올라온 경우 자식일 수 있다
- 목록 하나에 핸들러를 달 때 이쪽이 필요하다
<button><span>저장</span></button> 의 글자를 클릭하면
target 은 span, currentTarget 은 button 입니다.
둘을 헷갈려서 생기는 버그가 this 로 생기는 버그보다 많습니다.
currentTarget 은 핸들러가 끝나면 null 이 된다
비동기 안에서 쓰면 사라집니다. 이벤트 전파가 끝나면 브라우저가 값을 비우기 때문입니다. 쓸 값은 동기 구간에서 미리 꺼내 두어야 합니다.
btn.addEventListener('click', async (e) => {
await save()
e.currentTarget.disabled = true // null 이라 터진다
const el = e.currentTarget // 먼저 붙든다
await save()
el.disabled = true
})
6. 클래스 메서드를 그냥 넘기면 안 되는 이유
같은 규칙이 그대로 적용되는 자리인데, 증상이 달라 보여서 따로 헤매게 됩니다.
class Uploader {
constructor(input) {
this.count = 0
input.addEventListener('change', this.onChange) // 함수만 떼어 넘겼다
}
onChange() {
this.count++ // TypeError — this 가 undefined
}
}
this.onChange 라고 쓰긴 했지만 그건 함수를 꺼내는 표현일 뿐입니다.
꺼낸 함수는 인스턴스와의 연결을 갖고 있지 않고, 나중에 브라우저가 부를 때는
요소를 this 로 걸어 줍니다. 그 요소에는 count 가 없습니다.
해결은 셋 중 하나입니다.
// 1) 생성자에서 묶는다 — 예전 React 클래스 컴포넌트의 그 코드
this.onChange = this.onChange.bind(this)
// 2) 클래스 필드에 화살표 함수로 둔다 — 여기서는 화살표가 정답이다
onChange = () => { this.count++ }
// 3) 넘길 때 감싼다
input.addEventListener('change', (e) => this.onChange(e))
4절에서는 화살표 함수가 문제였는데 여기서는 해법입니다. 모순이 아닙니다 —
규칙은 하나입니다. 화살표 함수는 바깥의 this 를 쓴다.
바깥이 인스턴스면 이득이고, 바깥이 모듈 최상위면 손해입니다.
7. 인라인 핸들러를 쓰지 말아야 할 진짜 이유
지금까지는 this 이야기였지만, 실무에서 onclick="..." 을 지우는
이유는 따로 있습니다. 콘텐츠 보안 정책(CSP)에 걸립니다.
Content-Security-Policy: script-src 'self'
// → 인라인 이벤트 속성이 전부 차단된다.
// 오류도 조용해서 "왜 버튼이 안 눌리지"로 한참 헤맨다
'unsafe-inline' 을 넣으면 동작하지만, 그건 XSS 방어를 통째로 푸는 일입니다.
addEventListener 로 옮기면 this 문제도 CSP 문제도 같이 사라집니다.
8. 한 줄로 줄이면
this 는 함수가 어디에 적혀 있는지가 아니라 어떻게 불렸는지로 정해진다.
인라인 속성은 브라우저가 함수로 감싸므로 그 안에서만 요소이고, 한 겹 들어가면 끊깁니다.
| 쓰는 방식 | 핸들러 안의 this |
|---|---|
onclick="fn()" 의 속성 안 | 요소 |
그 fn 내부 | Window 또는 undefined |
addEventListener('click', function () {}) | 요소 |
addEventListener('click', () => {}) | 요소가 아니다 — 바깥 스코프의 것 |
| 클래스 메서드를 떼어 넘김 | 요소 (인스턴스가 아니다) |
event.currentTarget | 언제나 요소 |
마지막 줄이 결론입니다. 이벤트 핸들러에서는 this 를 아예 쓰지 말고
event.currentTarget 을 쓰면, 함수 모양이 무엇이든 위 표를 외울 일이
없어집니다.