先记住这个答案
class 方法定义在原型上,this 的规则和普通函数一样,谁调用就指向谁,class 并不会帮你自动绑定实例。counter.increment() 能拿到实例,是因为调用时点号前面是 counter。一旦把 counter.increment 当回调传给 addEventListener、setTimeout 或者解构出来,调用时就没有点号前面的对象了。和普通对象方法的区别在于,class 体内永远是严格模式,这时 this 是 undefined,读属性直接抛 TypeError;普通对象方法在非严格模式下 this 会退回全局对象,不报错但悄悄算错。常见修法是在构造器里 this.increment = this.increment.bind(this),或者写成箭头函数字段。
- class 方法不会自动绑定实例,
this看调用方式 - class 体是严格模式,丢了
this就是undefined - 构造器
bind和箭头字段都会给每个实例单独建函数 - 要解绑的回调必须存同一个函数引用
同一个方法,class 和普通对象丢 this 的表现不同
先看一段代码,把方法从对象上拿下来单独调用。
class Counter {
count = 0
increment() {
this.count++
return this.count
}
}
const counter = new Counter()
console.log(counter.increment()) // 通过实例调用,this 是 counter
const fn = counter.increment // 只拿到函数本身
try {
fn()
} catch (err) {
console.log(err.name) // class 体是严格模式,this 是 undefined
}
const plain = { count: 0, increment() { this.count++; return this.count } }
const g = plain.increment
console.log(g()) // 非严格模式下 this 退回全局对象查看输出与解释
1
TypeError
NaN第一次调用正常返回 1。fn() 调用时没有接收者,class 里的代码是严格模式,this 就是 undefined,this.count 直接抛错。普通对象的方法在非严格脚本里单独调用,this 变成全局对象,全局上没有 count,undefined 加 1 得到 NaN,还顺手往全局上写了一个 count 属性。
所以两者的绑定规则其实一样,差别只在严格模式。class 的报错反而是好事,问题当场暴露,不会带着错误数据跑下去。
构造器 bind 和箭头字段怎么选
两种修法都能让方法传出去之后 this 还指向实例,但它们放函数的位置不一样,影响内存、继承和测试。
class Button {
label = 'ok'
constructor() {
this.onBind = this.onBind.bind(this) // 实例上放一个绑定版本
}
onBind() { return this.label }
onArrow = () => this.label // 字段初始化时创建,this 固定为实例
}
const a = new Button()
const b = new Button()
console.log(a.onArrow === b.onArrow, a.onBind === b.onBind)
console.log('onArrow' in Button.prototype, 'onBind' in Button.prototype)
const { onBind, onArrow } = a // 解构出来单独调用
console.log(onBind(), onArrow())查看输出与解释
false false
false true
ok ok两种写法下,每个实例都有自己的一份函数,所以 a 和 b 的回调不相等。区别在于 bind 写法原型上还保留着原始的 onBind,子类可以正常覆写,测试时也能在原型上打桩;箭头字段只存在于实例上,原型里根本没有它。
- 实例多的时候要不要在意
- 每个实例多一个函数,几百几千个组件实例基本感觉不到。真正要在意的是列表里成千上万个对象都带着回调,这种情况更好的做法是事件委托,只在父节点挂一个监听器,而不是纠结
bind还是箭头。
容易答错的地方
- 以为 class 会自动绑定 this
- class 只做了两件事:方法放到原型上,方法体进入严格模式。
this仍然看调用方式。React 以前的 class 组件要在构造器里bind,就是因为 JSX 里写onClick={this.handleClick}传出去的是裸函数。 - 注册和解绑时各写一次
.bind(this) bind每次都返回一个新函数。addEventListener('click', this.handle.bind(this))之后再用removeEventListener('click', this.handle.bind(this)),两次拿到的是两个不同的函数,监听器根本移不掉。要在构造器里绑定一次存起来,注册和解绑都用它。- 以为箭头字段在原型上
- 箭头函数字段是实例属性,每次
new都会执行一遍字段初始化。它不在prototype上,所以Button.prototype.onArrow是undefined,想用super.onArrow()调父类的版本也调不到。
面试官还会怎么问?
子类用普通方法覆写父类的箭头字段,会生效吗?
不会。父类构造时先把箭头函数作为实例自己的属性放上去,子类的普通方法在 Sub.prototype 上,属性查找先命中实例属性,子类的版本永远被挡住。父类用箭头字段、子类又想覆写,就要改成父类用原型方法加构造器 bind。
构造器里 this.handle.bind(this),子类覆写了 handle 会绑到哪个?
绑到子类的版本。执行父类构造器时,实例的原型已经是子类的 prototype,this.handle 顺着原型链先找到子类的方法,再把它绑定到实例上。
除了这两种,还有别的办法保住 this 吗?
可以在传回调的地方包一层:button.addEventListener('click', () => counter.increment())。它不改类的定义,但每次渲染都会新建函数,而且没有保存引用就没法解绑,适合一次性、不需要移除的场景。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。