也说React事件处理:官方文档没说的事儿

968 阅读5分钟

前言

之前一直使用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,结果在浏览器中会报错:

thisisundefined.jpg

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环境中运行结果如下: esrunerr.jpg

这个错误是不是似曾相识?!没错,和上面的报错如出一辙。

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

classthisisundefined.jpg

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

不过,这种在构造函数中绑定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>
        )
    }
}

运行结果如下: repeatrerender.jpg

可以看到,无论是每次新增还是删除,都会引发已经存在的组件重新渲染。如果使用bind的方式则就不会:

<ul>
    {this.state.list.map(i => {
        return <MyListItem key={i} order={i} clickHandler={this.deleteListItemBound} />
    })}
</ul>

运行结果: norepeatrerender.jpg

解释下原因:父组件通过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就是类成员写法,为了便于理解其原理,我们看下其编译后的代码: calssfieldbuilded.png

能看到类成员函数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)方式

如果函数作为属性传递给子组件,可能会引起额外的重新渲染。

那到底应该选择哪种方式呢?

官方推荐构造函数内绑定类属性这两种方式。 officialrecommend.jpg

个人觉得,如果理解了各种方式的原理和优缺点之后,会扩大自己的选择空间,只要能规避问题,无需拘泥于官方推荐。

最后的最后

以上各种事件处理方式都缘起class component中的this,说白了都是围绕着如何解决this的问题展开的,那么有没有终极解决方案?有!React Hooks

React的Hooks是在v16.8中增加的特性,它的主要目的是能在function component中使用state,而无需再写class component,这样就可以规避class component中的this问题。同时,这也在React这个框架中重新确立了函数作为JavaScript的一等公民的地位。