前言
之前一直使用Vue,去年年底使用Taro(React语法规范的小程序多端框架)开发小程序,开始正式使用React。相信使用过Vue和React的人都曾下意识地比较过二者的不同,以一个最简单的开关组件为例:
Vue版:
<template>
<button @click="handleClick">{{this.isToggleOn ? 'On' : 'Off'}}</button>
</template>
<script>
export default {
name: "toggle",
data() {
return {
isToggleOn: false
};
},
methods: {
handleClick() {
this.isToggleOn = !this.isToggleOn;
}
}
};
</script>
React版:
import React from 'react'
export default class Toggle extends React.Component {
constructor(props) {
super(props)
this.state = {
isToggleOn: false
}
this.handleCLick = this.handleCLick.bind(this)
}
handleCLick() {
this.setState({
isToggleOn: !this.state.isToggleOn
})
}
render() {
return (
<button onClick={this.handleCLick}>
{this.state.isToggleOn ? 'On' : 'Off'}
</button>
)
}
}
从组件的角度出发,无非这三个方面:
- 输入:Vue的是props,React的也是props
- 中间状态:Vue是用data维护,React的是用state
- 输出:Vue的是template+style,React的则是JSX+样式文件
当时对于二者之间大部分差异都能理解,但始终无法理解的是React中对于事件处理的写法:除了要在元素上指定回调函数,还要在构造函数中对该回调函数进行this绑定。
this.handleCLick = this.handleCLick.bind(this);
我当时在想,为什么要在构造函数中绑定?如果去掉会怎样?于是尝试动手去掉并在代码中打印this,结果在浏览器中会报错:

this是undefined?既然在浏览器中运行,this最不济也应该指向window,是不是应该报window上找不到setState()的错误才对?
带着这些疑问,让我们层层剥茧,去寻找最终的答案。
一、React的事件处理为什么需要绑定this?
对于这个问题,我最开始一直怀疑是React中JSX的绑定导致的,也就是这行代码:
<button onClick={this.handleCLick}>
上面的开关组件中render方法被编译后的代码如下:
{
key: "render",
value: function render() {
return /*#__PURE__*/_react.default.createElement("button", {
onClick: this.handleCLick
}, this.state.isToggleOn ? 'On' : 'Off');
}
}
从上面不难看出,React组件的大概原理:React通过其自身封装的createElement()将render方法中JSX生成虚拟DOM,同时对DOM上的事件(如onClick)以属性的形式传入并进行绑定,然后在浏览器运行时渲染成真实DOM。
在浏览器中渲染出来后的效果,我们用下面的伪代码表示
script:
let toggle = new Toggle()
let handleMyClick = toggle.handleClick
HTML:
<button onClick="handleMyClick">
但是,这又和React中必须进行this绑定有什么关系呢?为了便于理解,我们先看一个普通JavaScript的例子:
class Person {
constructor(name) {
this.name = name
}
sayHello() {
console.log(`My name is ${this.name}`);
}
}
let p = new Person('Jack')
p.sayHello()
let say = p.sayHello
say()
在node.js环境中运行结果如下:

这个错误是不是似曾相识?!没错,和上面的报错如出一辙。
这里的变量say和上面伪代码中的handleMyClick都是原型方法的一个引用,执行的时候会报方法体内部的this是undifined,至于原因,MDN上关于class这一章有明确的说明,并提供了对应的示例:

至此,我们的问题基本上有了答案:React的事件处理之所以需要绑定this,并不是React的原罪,而是因为在JavaScript中class对于原型函数被变相调用时需要指定this。
这和React官方文档给的描述(但没有给出具体的解释)基本一致:

不过,这种在构造函数中绑定this的方式有两个很明显的弊端。 首先,如果需要处理的事件比较多,则会让构造函数变得很丑陋:
export default class MyComponent extends React.Component {
constructor(props) {
super(props)
this.handleCLick1 = this.handleCLick1.bind(this)
this.handleCLick2 = this.handleCLick2.bind(this)
this.handleCLick3 = this.handleCLick3.bind(this)
this.handleCLick4 = this.handleCLick4.bind(this)
this.handleCLick5 = this.handleCLick5.bind(this)
...
}
}
另一个弊端是没办法显式传递额外的参数。类似于这样,无法直接将id传给handleCLick:
import React from 'react'
export default class MyList extends React.Component {
constructor(props) {
super(props)
this.state = {
list: [
{id: 1, value: 'Angular'},
{id: 2, value: 'React'},
{id: 3, value: 'Vue'}
]
}
this.handleCLick = this.handleCLick.bind(this)
}
handleCLick(id) {
console.log('item id:', id);
}
render() {
return (
<div>
{this.state.list.map(item => <div onClick={this.handleCLick}>{item.value}</div>)}
</div>
)
}
}
针对这种情况,有另外一种解决方式:在调用处绑定
<div>
{this.state.list.map(item => <div onClick={this.handleCLick.bind(this, item.id)}>{item.value}</div>)}
</div>
不过,这种方式虽然能解决传参的问题,但是每次重新渲染时,bind方法都会执行一遍,如果多处调用,则执行多次绑定,无疑会造成资源浪费。
那还有没有其他的事件处理方式呢?官方文档给出了另外两种方式
- 箭头函数
- 类成员函数
我们分别看下两者的原理和优缺点。
二、箭头函数:重复渲染的潜在风险
箭头函数在React中的写法,如下所示:
<div>
{
this.state.list.map(item => <div onClick={(e) => this.handleCLick(item.id, e)}>{item.value}</div>)
}
</div>
至于原理,相信大家对箭头函数的特点非常熟悉了,主要就是:箭头函数不同于其他函数,需要在运行时才能确定this的指向,它在声明阶段就已将this锁死。
套用前面伪代码的形式:
script:
let anonymousFunction = function(id, e){
this.handleClick(id, e)
}
HTML:
<button onClick="anonymousFunction">
匿名函数anonymousFunction方法体内的this(即handleClick前面的this)都已在声明时确定,指向的是组件的实例,进而handleClick被执行时,其内部的this指向调用者,即组件的实例。(关于JavaScript中this指向的问题,强烈推荐《你不知道的JavaScript》上卷,里面有很系统的总结)
但在React中使用箭头函数并不是银弹,如下面的例子所示,即便是已经把子组件声明成了PureComponent(而不是React.Component,至于二者之间的区别和本次主题无关,不再展开,详见文档),在将事件回调以箭头函数传递给子组件时依然会引发re-render,示例如下:
MyList:
class MyList extends React.Component {
constructor(props) {
super(props)
this.state = {
list: []
}
this.addListItem = this.addListItem.bind(this)
}
deleteListItem(id) {
console.log('deleteListItem', id);
let list = this.state.list.filter((item) => item != id)
this.setState({
list
})
}
addListItem() {
console.log('addListItem');
this.setState(state => ({
list: state.list.concat(state.list.length + 1)
}))
}
render() {
return (
<div>
<button onClick={this.addListItem}>add item</button>
<ul>
{this.state.list.map(i => {
return <MyListItem key={i} order={i} clickHandler={(id) => this.deleteListItem(id)} />
})}
</ul>
</div>
)
}
}
MyItem:
class MyListItem extends React.PureComponent {
constructor(props) {
super(props)
}
handleDelete(id){
this.props.clickHandler(id)
}
render() {
console.log(`item ${this.props.order} render`);
return (
<li>
item {this.props.order}
<button onClick={this.handleDelete.bind(this, this.props.order)}>delete</button>
</li>
)
}
}
运行结果如下:

可以看到,无论是每次新增还是删除,都会引发已经存在的组件重新渲染。如果使用bind的方式则就不会:
<ul>
{this.state.list.map(i => {
return <MyListItem key={i} order={i} clickHandler={this.deleteListItemBound} />
})}
</ul>
运行结果:

解释下原因:父组件通过props向子组件传递的是一个箭头函数,在父组件每次渲染的时候,箭头函数都会被重新分配(和前面提到的调用处bind一样的道理),所以子组件通过props每次接收到的都是一个新的函数,进而导致子组件会重新渲染。
以上是箭头函数这种方式的原理和存在的弊端,接下来看下最后一种方式:类成员函数
三、类成员函数:实例上的方法
我们用开头的示例进行扩展,代码如下:
import React from 'react'
export default class Toggle extends React.Component {
constructor(props) {
super(props)
this.state = {
isToggleOn: false
}
this.handleCLick = this.handleCLick.bind(this)
}
handleCLick(){
console.log('this:', this);
this.setState({
isToggleOn: !this.state.isToggleOn
})
}
//类成员写法
handleCLickNew = () => {
console.log('this:', this);
this.setState({
isToggleOn: !this.state.isToggleOn
})
}
render() {
return (
<button onClick={this.handleCLickNew}>
{this.state.isToggleOn ? 'On' : 'Off'}
</button>
)
}
}
handleCLickNew就是类成员写法,为了便于理解其原理,我们看下其编译后的代码:

能看到类成员函数handleCLickNew被挂载了指向this的实例上,而不像handleCLick那样在Toggle的原型上。 同样我们也可以用前面那个JavaScript的普通Class例子进行实验,进行扩展如下:
class Person {
constructor(name) {
this.name = name
}
sayHello() {
console.log(`this:${this}`);
console.log(`My name is ${this.name}`);
}
sayHi = () => {
console.log(`this:${this}`);
console.log(`My name is ${this.name}`);
}
}
let p = new Person('Jack')
p.sayHi()
let hi = p.sayHi
hi()
由于类成员函数语法目前出于试验阶段,所以必须借助babel的插件转译,同时为了便于观察,转译的时候不用preset,执行命令:npx babel person.js --out-dir dist --plugins transform-class-properties
最终转译的结果:
class Person {
constructor(name) {
this.sayHi = () => {
console.log(`this:${this}`);
console.log(`My name is ${this.name}`);
};
this.name = name;
}
sayHello() {
console.log(`this:${this}`);
console.log(`My name is ${this.name}`);
}
}
let p = new Person('Jack');
p.sayHi();
let hi = p.sayHi;
hi();
很明显sayHi挂载的位置也是指向this的实例上,执行结果是正常输出,并没有像之前那样报undefined的错误。
所以,我个人理解,正如前面提到的:原型方法在被变相调用时必须指定this,否则函数内部this是undefined。于是,类属性这种提案,要从语法层面解决这个问题:既然挂载在原型上的方法还需要指定this,很麻烦,那就直接将方法挂载在this指向的实例上得了。
总结
以上就是React中对于事件处理的各种写法,以及其背后的原理和可能存在的一些潜在问题。简单做下归纳:
1. 函数绑定(bind)方式
默认参数e会作为最后一个参数传进去,根据绑定所在的位置不同,又可细分为两种方式:
(1)构造函数内绑定: 适用于只有默认参数e的情况;可以支持额外参数,适用于将函数传递给子组件使用。
(2)调用处绑定: 适用于有额外参数的情况;每次重新渲染都会执行绑定,可能存在性能问题。
2. 类属性(class field)语法方式
JavaScript语法层面的支持,目前处于实验阶段,需要转译插件支持。
3. 箭头函数(arrow function)方式
如果函数作为属性传递给子组件,可能会引起额外的重新渲染。
那到底应该选择哪种方式呢?
官方推荐构造函数内绑定和类属性这两种方式。

个人觉得,如果理解了各种方式的原理和优缺点之后,会扩大自己的选择空间,只要能规避问题,无需拘泥于官方推荐。
最后的最后
以上各种事件处理方式都缘起class component中的this,说白了都是围绕着如何解决this的问题展开的,那么有没有终极解决方案?有!React Hooks!
React的Hooks是在v16.8中增加的特性,它的主要目的是能在function component中使用state,而无需再写class component,这样就可以规避class component中的this问题。同时,这也在React这个框架中重新确立了函数作为JavaScript的一等公民的地位。