Item 69: Provide a Type for this in Callbacks if It's Part of Their API
要点
- Understand how
thisbinding works. - Provide a type for
thisin callbacks if it's part of your API. - Avoid dynamic
thisbinding in new APIs. - 理解
this绑定是如何工作的。 - 如果
this是你 API 的一部分,在回调中提供this的类型。 - 避免在新 API 中使用动态
this绑定。
正文
JavaScript 的 this 关键字是该语言中最令人困惑的部分之一。与使用 let 或 const 声明的变量(它们是词法作用域的)不同,this 是动态作用域的:它的值不取决于它在代码中出现的位置,而是取决于你如何到达那里。
this 最常用于类中,它通常引用对象的当前实例:
class C {
vals = [1, 2, 3]
logSquares() {
for (const val of this.vals) {
console.log(val ** 2)
}
}
}
const c = new C()
c.logSquares()这会输出:
1
4
9现在看看如果你尝试将 logSquares 放入变量并调用会发生什么:
const c = new C()
const method = c.logSquares
method()这个版本在运行时抛出错误:
for (const val of this.vals) {
^
TypeError: Cannot read properties of undefined (reading 'vals')问题在于 c.logSquares() 实际上做了两件事:它调用 C.prototype.logSquares 并将该函数中的 this 值绑定到 c。通过提取 logSquares 的引用,你已经分离了这些,this 被设置为 undefined。
JavaScript 让你完全控制 this 绑定。你可以使用 call 显式设置 this 并修复问题:
const c = new C()
const method = c.logSquares
method.call(c) // Logs the squares again没有理由说 this 必须绑定到 C 的实例。它可以绑定到任何东西。所以库可以,也确实将 this 的值作为其 API 的一部分。甚至 DOM 在事件处理程序中也会这样做,例如:
document.querySelector('input')?.addEventListener('change', function (e) {
console.log(this) // Logs the input element on which the event fired.
})this 绑定经常出现在像这样的回调上下文中。如果你想在类中定义 onClick 处理程序,例如,你可能会尝试这样做:
class ResetButton {
render() {
return makeButton({ text: 'Reset', onClick: this.onClick })
}
onClick() {
alert(`Reset ${this}`)
}
}当用户点击按钮时,它会弹出 "Reset undefined"。糟糕!通常的罪魁祸首是 this 绑定。一个常见的解决方案是在构造函数中创建方法的绑定版本:
class ResetButton {
constructor() {
this.onClick = this.onClick.bind(this)
}
render() {
return makeButton({ text: 'Reset', onClick: this.onClick })
}
onClick() {
alert(`Reset ${this}`)
}
}onClick() { ... } 定义在 ResetButton.prototype 上定义了一个属性。这由所有 ResetButton 实例共享。当你在构造函数中绑定 this.onClick = ... 时,它会在 ResetButton 实例上创建一个名为 onClick 的属性,并将 this 绑定到该实例。onClick 实例属性在查找序列中位于 onClick 原型属性之前,所以 this.onClick 在 render() 方法中引用绑定的函数。
有一个非常方便的 this 绑定简写:
class ResetButton {
render() {
return makeButton({ text: 'Reset', onClick: this.onClick })
}
onClick = () => {
alert(`Reset ${this}`) // "this" refers to the ResetButton instance.
}
}这里我们用箭头函数替换了 onClick。这将在每次构造 ResetButton 时定义一个新函数,并将 this 设置为适当的值。查看生成的 JavaScript 是有启发性的:
class ResetButton {
constructor() {
this.onClick = () => {
alert(`Reset ${this}`) // "this" refers to the ResetButton instance.
}
}
render() {
return makeButton({ text: 'Reset', onClick: this.onClick })
}
}那么这一切与 TypeScript 有什么关系呢?因为 this 绑定是 JavaScript 的一部分,TypeScript 会建模它。这意味着如果你正在编写(或类型化)一个在回调上设置 this 值的库,那么你也应该建模它。
你可以通过在回调中添加 this 参数来实现:
function addKeyListener(
el: HTMLElement,
listener: (this: HTMLElement, e: KeyboardEvent) => void
) {
el.addEventListener('keydown', (e) => listener.call(el, e))
}this 参数是特殊的:它不仅仅是另一个位置参数。如果你尝试用两个参数调用它,你可以看到这一点:
function addKeyListener(
el: HTMLElement,
listener: (this: HTMLElement, e: KeyboardEvent) => void
) {
el.addEventListener('keydown', (e) => {
listener(el, e)
// ~ Expected 1 arguments, but got 2
})
}更好的是,TypeScript 会强制你用正确的 this 上下文调用函数:
function addKeyListener(
el: HTMLElement,
listener: (this: HTMLElement, e: KeyboardEvent) => void
) {
el.addEventListener('keydown', (e) => {
listener(e)
// ~~~~~~~~ The 'this' context of type 'void' is not assignable
// to method's 'this' of type 'HTMLElement'
})
}作为这个函数的用户,你可以在回调中引用 this 并获得完整的类型安全:
declare let el: HTMLElement
addKeyListener(el, function (e) {
console.log(this.innerHTML)
// ^? this: HTMLElement
})当然,如果你在这里使用箭头函数,你会覆盖 this 的值。TypeScript 会捕获这个问题:
class Foo {
registerHandler(el: HTMLElement) {
addKeyListener(el, (e) => {
console.log(this.innerHTML)
// ~~~~~~~~~ Property 'innerHTML' does not exist on 'Foo'
})
}
}不要忘记 this!如果你在回调中设置 this 的值,那么它就是你的 API 的一部分,你应该在类型声明中包含它。
如果你正在设计一个新的 API,尽量不要使用动态 this 绑定。虽然它在历史上很流行,但它一直是混乱的根源,而箭头函数的普及使得这种 API 在现代 JavaScript 中使用起来更加困难。
要点回顾
- 理解
this绑定是如何工作的。 - 如果
this是你 API 的一部分,在回调中提供this的类型。 - 避免在新 API 中使用动态
this绑定。