初探React中函数组件和类组件的差异

发布于 大漠

自从React Hooks的出来,社区讨论Hooks的越来越多。这并不是说React Hooks就优于类组件,但是使用Hooks来构建组件时有一个巨大的可用性提升,特别是因为这些函数组件可以通过React Hooks中的钩子函数来访问状态和生命周期。

今天我们就来一起聊聊如何将React的类组件转换为函数组件,用React Hooks中的钩子函数替换类组件中的setState和生命周期方法,比如componentWillMountcomponentWillReceiveProps等。

因此,让我们首先使用状态和生命周期方法构建一个基于类的React组件。也是大家最为熟悉的ToDoList组件。该组件具备:

  • 有一个文本输入框(<input type="text" />,用户可以在输入框中输入想要的内容
  • 有一个**“添加列表项”按钮(button)**,点击该按钮之后可以将文本输入框的内容添加到列表中(ToDoList中)
  • 显示每个待办事项的列表清单
  • 每个单独的列表项目都有一个相关联的复选框(<input type="checkbox" />),可以用来将列表项标记为已完成
  • 列表项会存储到浏览器的缓存中(本地存储),并在应用程序启动时从本地存储中再次加载

我们的组件将使用statecomponentDidMountcomponentDidUpdategetDerivedStateFromProps生命周期方法。其中一些生命周期方法(比如getDerivedStateFromProps)将以一种非人为方式使用,以便能够演示有哪些Hooks的钩子函数可以替换这些生命周期的方法。

在开始之前,先来学习关于类和函数相关的知识点。

如何区分类和函数

作为Web开发者,经常和函数打交道。但要真正的理解和掌握他们也不是件易事,特别是对于初学JavaScript的同学更是如此。至少给我自己的感觉是如此。

在这里我们不会深入的去聊函数和类,因为要真正的聊透他们,都可以去写本书了。由于我们今天要聊React的类组件和函数组件,那么在开始之前很有必要的先了解一顶点有关于JavaScript的函数和类。先来看函数吧。

函数在JavaScript中被认为是第一类公民,在JavaScript中明确的创建函数的概念非常重要。

JavaScript语言似乎和其他编程语言不同,我们可以在JavaScript中以不同的方式来创建一个函数,常见的方式主要有:

用几个简单的示例代码来演示他们之间的不同:

// Function Declaration
function Greeting(user) {
    console.log(`Hello, ${user}`)
}

Greeting('@w3cplus') // » Hello, @w3cplus

// Function Expression
const Greeting = function(user) { // 作为对象分配给变量
    console.log(`Hello, ${user}`)
}

const Methods = {
    numbers: [1, 2, 8],
    // Function Expression
    sum: function() { // 在对象上创建一个方法
        return this.numbers.reduce(function(acc, num){ // Function Expression (使用该函数作为回调函数)
            return acc + num
        })
    }
}

// Shorthand Method Definition
const Collection = { // 用于Object Literals和ES6 Class声明中
    items: [],
    // 使用函数名来定义
    // 使用一对圆括号中的参数列表和一对花括号来分隔主体语句
    add(...items) { 
        this.items.push(...items)
    },
    get(index) {
        return this.items[index]
    }
}

// Arrow Function
let empty = () =>{}

let simple = a => a > 15 ? 15 : a

let max = (a, b) => a > b ? a : b

let numbers = [1, 2, 3, 4]
let sum = numbers.reduce((a, b) => a + b)
let even = numbers.filter(v => v % 2 == 0)
let double = numbers.map(v => v * 2)

primise.then( a => {
    // ...
}).then(b => {
    // ...
})

// Generator Function
// JavaScript中的生成器函数返回这个生成器的迭代器对象

function* indexGenerator() {
    var index = 0
    while(true) {
        yield index++
    }
}

const indexGenerator = function* () {
    var index = 0
    while(true) {
        yield index++
    }
}

const obj = {
    *indexGenerator() {
        var index = 0
        while(true) {
            yield index++
        }
    }
}

// Function Constructor
const sum = new Function('a', 'b', 'return a + b')
sum(1, 2) // » 3

类是ES6中开始引入的,实质上是JavaScript现有的基于原型的继承的语法糖。实际上,类是特殊的函数,就像你能够定义的函数表达式和函数声明一样,类语法主要有两个组成部分:类表达式类声明

// 类声明
class Rectangle {
    constructor(height, width) {
        this.height = height
        this.width = width
    }
}

// 类表达式

// 匿名类
let Rectangle = class {
    constructor(height, width) {
        this.height = height
        this.width = width
    }
}

// 命名类
let Rectangle = class Rectangle {
    constructor(height, width) {
        this.height = height
        this.width = width
    }
}

而且还可以使用extends关键字在类声明或类表达式中用于创建一个类作为另一个类的子类:

class Animal {
    constructor(name) {
        this.name = name
    }

    sayHi() {
        console.log(this.name)
    }
}

class Dog extends Animal {
    sayHi() {
        console.log(`${this.name} barks.`)
    }
}
let dog = new Dog('Mitzie')
dog.sayHi() // » Mitzie barks

如果子类中存在构造函数,则需要在使用this之前首先调用super()。也可以扩展传统折基于函数的**“类”**:

function Animal(name) {
    this.name = name
}

Animal.prototype.sayHi = function() {
    console.log(this.name)
}

class Dog extends Animal {
    sayHi() {
        super.sayHi()
        console.log(`${this.name} barks.`) 
    }
}

let dog = new Dog('Mitzie')
dog.sayHi() 

如果你想更深入的了解有关于JavaScript中的函数和类相关的知识的话,可以花点时间阅读下面相关文章:

我们回到React的世界当中来。在React中我们可以以函数形式定义一个组件,比如像下面这样:

function SayHi() {
    return <p>Hello, React</p>
}

也可以将SayHi这个组件以类的形式来定义:

class SayHi extends React.Component {
    render() {
        return <p>Hello, React</p>
    }
}

在当你要使用一个组件时,比如要使用SayHi这个组件,并不会过多的关注它是以什么方式来定义(声明)的组件,只会关心如何使用:

<SayHi />

虽然使用者不会太过关注它是怎么创建的(以哪种方式创建的),但React自身对于怎么创建组件是较为关注也会在意其差别。

如果SayHi是一个函数,React需要调用它:

// 你的代码
function SayHi() {
    return <p>Hello, React</p>
}

// React内部
const result = SayHi(props) // » <p>Hello, React</p>

如果SayHi是一个类,React需要先用new操作符将其实例化,然后调用刚才生成实例的render方法:

// 你的代码
class SayHi extends React.Component {
    render() {
        return <p>Hello, React</p>
    }
}

// React内部
const instance = new SayHi(props) // » SayHi {}
const result = instance.render()  // » <p>Hello, React</p>

无论哪种情况,React的最终目标是去获取渲染后的DOM节点,比如SayHi组件,获取渲染后的DOM节点是:

<p>Hello, React</p>

具体需要取决于SayHi组件是怎么定义的。

从上面的代码中你可能已经发现了,在调用类时,使用了new关键字来调用:

// 如果SayHi是一个函数
const result = SayHi(props); // » <p>Hello, React</p>

// 如果SayHi是一个类
const instance = new SayHi(props) // » SayHi {}
const result = instance.render()  // » <p>Hello, React</p>

那么JavaScript中的new起什么作用呢?在ES6之前,JavaScript是没有类(class)这样的概念。在这种情况之前如果要使用类这样的特性都是使用普通函数来模拟。即,在函数调用前加上new关键字,就可以把任何函数当做一个类的构造函数来用

function Fruit(name) {
    this.name = name
}

const apple = new Fruit('apple') // » Fruit {name: "apple"}
const banana = Fruit('banana')   // » undefined   

JavaScript中的new关键字会进行如下的操作:

  • 创建一个空的对象,即{}
  • 链接该对象(即设置该对象的构造函数)到另一个对象
  • 将创建的对象作为this的上下文
  • 如果该函数没有返回对象,则返回this

正如上面的示例来说:

  • 调用Fruit('apple')时前面添加了new关键字,这个时候JavaScript会知道Fruit只是一个函数,同时也会假装它是一个构造函数。会创建一个**空对象({})**并把Fruit中的this指向那个对象,以便我们可以通过类似this.name的形式去设置一些东西,然后把这个对象返回
  • 调用Fruit('banana')时前面没有添加new关键字,其中的this会指向某个全局且无用的东西,比如windowundefined,因此代码会崩溃或者做一些像设置window.name之类的傻事

也就是说:

// 和Fruit中的this是等效的对象
const apple = new Fruit('apple') // » Fruit {name: "apple"}

new关键字同时也把放在Fruit.prototype上的东西放到了apple对象上:

function Fruit(name) {
    this.name = name
}

Fruit.prototype.SayHi = function () {
    console.log(`Hi,我想吃${this.name}`)
}

const apple = new Fruit('苹果')

apple.SayHi() // » Hi,我想吃苹果

这就是在JavaScript中如何通过new关键字来模拟类的方式。有关于new更多的介绍可以阅读:

JavaScript中的new关键字存在很久了,但ES6的class出来之后,我们能够更按照我们的本意来重构前面的代码:

class Fruit {
    constructor(name) {
        this.name=name
    }
    SayHi() {
        console.log(`Hi,我想吃${this.name}`)
    }
}

let apple = new Fruit('苹果')
apple.SayHi() // » Hi,我想吃苹果

记得在前面,构建函数时,使用 const banana = Fruit('banana')会返回undefined,并不会报错,但在类中,不显式使用new则会报错:

class Fruit {
    constructor(name) {
        this.name=name
    }
    SayHi() {
        console.log(`Hi,我想吃${this.name}`)
    }
}

let apple = Fruit('苹果')
apple.SayHi()

这就意味着,使用React时调用所有类之前必须添加new关键字,而不能把它直接当作是一个普通的函数去调用,因为JavaScript会把它当做一个错误对待。在加不加new关键字之间的大致差别如下:

  new Fruit() Frunt()
class this是一个Fruit实例 报错TypeError
function this是一个Fruit实例 thiswindowundefined

对于普通函数,用new调用会给它们一个this作为对象实例。用于构造函数的函数也是可取的,比如前面的Fruit。但对于函数组件中,对于初学者就会感到困惑:

function SayHi() {
    // 在这里不希望`this`表示任何类型的实例
    return <p>Hello, React</p> 
}

不过,如果函数是一个箭头函数的话,直接使用new来调用已创建的函数则会报错:

const SayHi = () => console.log('Hello, React')
new SayHi()

浏览器报 SayHi不是一个构造函数。这是因为:

箭头函数没有自己的this

这也意味着箭头函数作为构造函数是完全无用的。比如说,下面的示例代码将没有任何意义:

const Fruit = (name) => {
    // 这么写没有任何意义
    this.name = name
}

也就是说,在React中不能对所有东西都使用new,这是因为会破坏箭头函数,不过我们可以借助箭头函数没有prototype的特性来检测箭头函数,不对它们使用new关键字

(() => {}).prototype        // » undefined
(function() {}).prototype   // » {constructor: f}

在React中还有另一个原因不能使用new关键字的原因是因为它会妨碍React支持返回字符串或其它原始类型的组件,比如:

function SayHi() {
    return 'Hello React'
}

SayHi();    // » "Hello React"
new SayHi() // » SayHi {}

阅读到此的话,你可能已经了解到了:

React 在调用类(包括 Babel 输出的)时需要用 new,但在调用常规函数或箭头函数时(包括 Babel 输出的)不需要用 new,并且没有可靠的方法来区分这些情况。

或许你也想到了,在React中使用类来构建一个组件的时候会使用到类的扩展,即扩展React.Component。这样做主要是为了更好获取React中内置的方法,比如this.setState()。简单地说:

React只检测React.Component的后代

也就是说,如果我们想检测一个组件是不是一个React的类组件,那么就可以像下面这样来检测;

class ClassA {}
class ClassB extends ClassA {}

console.log(ClassB.prototype instanceof ClassA) // » true

在这里就会运用到JavaScript中的原型。

在学习JavaScript的时候,总感觉学习其中一个知识点就会涉及到其他的知识点。

当然,原型链是JavaScript中较为重要的基础知识,也是较复杂的一部分知识。同样的,这里对于原型链不做过多的阐述,和大家一起简单的了解一下。

我也是初次接触JavaScript中原型链相关的知识。如果有不对的地方,还希望路过的大神拍正。

在JavaScript中,每个对象都有一个原型。比如Fruit.SayHi(),当Fruit对象没有SayHi()属性时,JavaScript将会尝试在Fruit的原型上去找SayHi()属性。要是找不到的话,就会找原型链的下一个原型,即 Fruit的原型的原型,以此类推

先回到前面的示例中来,下图是函数和类在开发者工具中的一个截图对比:

事实上,一个类或一个函数的prototype属性并不指向那个值的原型,比如:

因此,JavaScript中的原型链更像是__proto__.__proto__.__proto__而不是prototype.prototype.prototype。其实,JavaScript的函数或类的prototype属性是用new调用那个类或函数生成的所有对象的__proto__

比如:

function Fruit(name) {
    this.name = name
}

Fruit.prototype.SayHi = function () {
    console.log(`Hi,我想吃${this.name}`)
}

let apple = new Fruit('苹果')

console.log(apple.__proto__)

这个__proto__链才是JavaScript用来查找属性的:

apple.SayHi()
// » 1: apple自身没有SayHi()属性
// » 2: apple__proto__ 有SayHi()属性

apple.toString()
// » 1: apple自身没有toString属性
// » 2: apple.__proto__也没有toString属性
// » 3: apple.__proto__.__proto__有toString属性

而在撸码的时候,并不会在代码用到__proto__,除非在调试与原型链有关的问题。如果想让某个属性在apple.__proto__上可用,就应该把它放在Fruit.prototype上,至少最初就是这么设计的。

使用类的时候,不同的声明方式,机制也有所差异:

有关于这方面更详细的介绍,可以阅读@kirupa整理的博文:

JavaScript的extends(扩展类)的原理其实就是按这样的原理来实现的。这也是React类实例能够访问setState原因所在:

class Fruit extends React.Component {
    constructor(props) {
        super(props)
        this.name = name
    }

    render() {
        return <p>Hi, 我想吃 {this.props.name}</p>
    }
}

在调用Fruit之前添加new

let apple = new Fruit()

// apple.__proto__ » Fruit.prototype
// apple.__proto__.__proto__ » React.Component.prototype
// apple.__proto__.__proto__.__proto__ » Object.prototype

比如React类组件中的一些方法render()setState()toString()可以通过相应的__proto__prototype)找到:

let apple = new Fruit('苹果')

// apple.render() » apple.__proto__ ( Fruit.prototype )
// apple.setState() » apple.__proto__.__proto__ ( React.Component.prototype )
// apple.toString() » apple.__proto__.__proto__.__proto__ ( Object.prototype )

有关于JavaScript中原型链更多的介绍可以阅读下面相关的文章:

对JavaScript的类和函数有所了解之后,我们开始进来React的世界当中。我们先来看看在React中如何构建类组件。接下来的目标是构建文章开头我们想要的ToDoList组件。

构建一个ToDoList的类组件

这个ToDoList组件可以拆分三个不同的子组件,如下图所示:

让我们先来看一下ToDoContainer组件。该组件在应用程序中可以在任何时候维护应用程序的完整状态。它有两个方法: AddToDoItemToggleItemCompleted ,这两个方法分别作为回调传递到ToDoInputToDoItem组件,并用于添加一个新的ToDoList,并将一个列表项目标记为已完成未完成

此外,ToDoContainer组件使用componentDidMount生命周期方法从浏览器本地存储中(localStorage)加载保存的ToDoList(列表项)。如果没有要做的列表项,则将空列表实例为ToDoContainer组件的状态(state)。ToDoContainer还使用componentDidUpdate生命周期方法将ToDoContainer组件的状态保存到本地存储中。这样,每当ToDoContainer状态(state)发生变化时,它就会被持久化存到本地存储中,并在重新启动应用程序时恢复。

先来看ToDoContainer组件的代码:

class ToDoContainer extends React.Component {
    constructor(props) {
        super(props)
        this.state = {
            toDoItems: [],
            completedItemIds: []
        }
    }

    generateID() {
        return `${Date.now().toString(36)}-${(Math.random()+1).toString(36).substring(7)}` 
    }

    addToDoItem = (text) => {
        const newToDoItem = {
            text,
            id: this.generateID()
        }

        const toDoItems = this.state.toDoItems.concat([newToDoItem])

        this.setState({toDoItems})
    }

    toggleItemCompleted = (toDoItemId) => {
        const toDoItemIndexInCompletedItemIds = this.state.completedItemIds.indexOf(toDoItemId)

        const completedItemIds = toDoItemIndexInCompletedItemIds === -1 ? 
            this.state.completedItemIds.concat([toDoItemId]) : 
            ([
                ...this.state.completedItemIds.slice(0, toDoItemIndexInCompletedItemIds),
                ...this.state.completedItemIds.slice(toDoItemIndexInCompletedItemIds + 1)
            ])

        this.setState({completedItemIds})
    }

    componentDidMount() {
        let saveToDos = localStorage.getItem('todos')

        try {
            saveToDos = JSON.parse(saveToDos)
            this.setState(Object.assign({}, this.state, saveToDos))
        } catch (error) {
            console.log('保存的待办事项不存在')
        }
    }

    componentDidUpdate() {
        localStorage.setItem('todos', JSON.stringify(this.state))
    }

    render() {
        const toDoList = this.state.toDoItems.map(toDoItem => {
            return (
                <ToDoItem
                    key={toDoItem.id}
                    completedItemIds={this.state.completedItemIds}
                    toggleItemCompleted={this.toggleItemCompleted}
                    {...toDoItem}
                />
            )
        })

        const toDoInput = (
            <ToDoInput
                onAdd={this.addToDoItem}
            />
        )

        return (
            <div className="container">
                <h1>待办事项...(^_^)</h1>
                <div className="todo__container">
                    <ul className="todo__lists">
                        {toDoList}
                    </ul>
                    {toDoInput}
                </div>
            </div>
        )
    }
}

接下来再看看ToDoInput组件。这是一个比较简单的组件,由一个inputbutton组成。组件使用自己的state来跟踪文本输入框的值,单击按钮后,将该文本通过ToDoContainer组件的addToDoItem方法将其作为ToDoInputprops传入给onAdd

ToDoInput组件的代码:

class ToDoInput extends React.Component {
    constructor(props){
        super(props)
        this.state={
            text: ''
        }
    }

    handleChange = e => {
        this.setState({text: e.currentTarget.value})
    }

    addToDoItem = () => {
        this.props.onAdd(this.state.text)
        this.setState({text: ''})
    }

    render() {
        return (
            <div className="toto__input">
                <input 
                    type="text"
                    onChange={this.handleChange}
                    value={this.state.text}
                    placeholder="在这里输入待办事项...(^_^)"
                />
                <button onClick={this.addToDoItem}><span>添加</span></button>
            </div>
        )
    }
}

最后是ToDoItem组件。它由一个复选框(<input type="checkbox" />)和一个label组成,其中复选框用来指示ToDoItem是否完成,label是用来显示ToDoItem的文本。为了演示getDerivedStateFromProps的使用,ToDoItem组件将来自ToDoContainer组件的整个completedItemIds作为一个props,并使用它来计算这个特定的ToDoItem是否完成。

ToDoItem组件代码:

class ToDoItem extends React.Component {
    constructor(props) {
        super(props)
        this.state={
            completed: false
        }
    }

    toggleItemCompleted = () => {
        this.props.toggleItemCompleted(this.props.id)
    }

    static getDerivedStateFromProps(props, state) {
        const toDoItemIndexInCompletedItemIds = props.completedItemIds.indexOf(props.id)

        return { completed: toDoItemIndexInCompletedItemIds > -1}
    }

    render() {
        return (
            <li className="todo__item">
                <input
                    id={`completed-${this.props.id}`}
                    type="checkbox"
                    onChange={this.toggleItemCompleted}
                    checked={this.state.completed}
                />
                <label htmlFor={`completed-${this.props.id}`}>{this.props.text}</label>
            </li>
        )
    }
}

整个效果如下:

使用React Hooks重构ToDoList组件

接下来,将使用React Hooks来重构ToDoList组件,会使用React Hooks中的各种钩子将前面的类组件转换为函数组件。当然,我们会先从简单的钩子函数开始,然后慢慢进入到更复杂的钩子函数。但在此之前,要记住Hooks的几个重要规则:

  • 钩子函数只能从React函数组件或另一个React钩子中调用
  • 在渲染单个组件期间,应该以相同的顺序,相同的次数调用相同的钩子。这也意味着钩子函数不能在循环或条件块中调用,而必须始终在函数顶层调用
  • 永远不要从常规函数中调用钩子函数

如果要聊的话,这些规则可以花很大的篇幅来聊,如果你感兴趣的话,可以阅读下面相关文章:

现在,我们知道了使用钩子函数时要遵循的基本规则,那我们就开始将前面的类组件ToDoList转换为函数组件。

构建一个ToDoList的函数组件

React Hooks中最简单、最常用的钩子函数应该是useState了。useState钩子函数基本上为您提供了一个setter和一个getter来操作组件上的单个状态属性。useState钩子一般像下面这样使用:

const [value, setValue] = useState(initialValue)

如果在Codepen这样在线构建Demo平台上使用React Hooks构建组件,需要在钩子函数前添加React前缀,即useState会写成React.useState

在第一次调用钩子时,将使用initialValue初始化状态项的值。在后续调用useState时,将返回先前设置的值,以及setter方法,该方法可用于为特定的状态属性设置新值。

我先从简单的组件开始,使用useState钩子将ToDoInput类组件转换为函数组件。

// ToDoInput Function Components

const ToDoInput = ({onAdd}) => {
    const [text, setText] = React.useState('')

    const onTextChange = e => setText(e.currentTarget.value)

    const addToDoItem = () => {
        onAdd(text);
        setText('')
    }

    return (
        <div className="todo__input">
            <input 
                type="text"
                onChange={onTextChange}
                value={text}
                placeholder="在这里输入待办事项...(^_^)"
            />
            <button onClick={addToDoItem}><span>添加</span></button>
        </div>
    )
}

效果如下:

在文章中我们不会花时间聊CSS,如果你对效果中的样式感兴趣的话,可以查阅具体演示的Demo。

正如你所看到,在ToDoInput函数组件中使用了useState钩子函数来获取text状态属性值和setText函数(相当组件中的setter),它们是从useState方法获取的。onTextChangeaddToDoItem方法都已更改为使用setText()方法,从而替代了setState

你可能已经注意到了,分别在input上添加了onChange事件和在button上添加了onClick事件。事实上这样做在性能上不是太好,自引用渲染函数渲染时,只要发生了变化,就会重新渲染inputbutton

为了避免这些不必要的重新渲染,我们需要保持引用相同的函数。为了达到此目的,可以使用Hooks中的另一个钩子函数useCallbackuseCallback的用法大致如下:

const memoizedFunction = useCallback(inlineFunctionDefinition, memoizationArguments);

// 相当于
const memoizedFunction = useMemo(() => inlineFunctionDefinition, memoizationArguments)

简单的解释一下:

  • inlineFunctionDefinition:是你希望在渲染之间维护引用的函数。该函数可以是内联匿名函数,也可以是从其他地方导入的函数。但是,在大多数情况下希望引用组件的状态变量,所以我们将把它定义为一个内联函数,这将允许我们使用闭包访问状态变量
  • memoizationArguments:是inlineFunctionDefinition函数引用的参数数组。第一次调用useCallback钩子函数时,memoizationArgumentsinlineFunctionDefinition一起保存。后续调用,每个元素在新memoizationArguments数组元素的值相比,在相同的索引之前保存memoizationArguments数组;如果没有改变,那么以前保存的inlineFunctionDefinition返回,因此保留参考,防止不必要的重新渲染。如果任何参数发生了更改,则保存并使用inlineFunctionDefinition和新的memoizationArguments,从而更改对函数的引用,并确保重新渲染

下面的代码是使用了useStateuseCallback两个钩子函数重构的ToDoInput组件:

// ToDoInput Component
const ToDoInput = ({onAdd}) => {
    const [text, setText] = React.useState('')
    const onTextChange = React.useCallback((e) => setText(e.currentTarget.value), [setText])

    const addToDoItem = React.useCallback(() => {
        onAdd(text)
        setText('')
    }, [onAdd, text, setText])

    return (
        <div className="todo__input">
            <input 
                type="text"
                onChange={onTextChange}
                value={text}
                placeholder="在这里输入待办事项...(^_^)"
            />
            <button onClick={addToDoItem}><span>添加</span></button>
        </div>
    )
}

现在已经使用React Hooks重构了ToDoInput组件,接下来以同样的方式,使用useStateuseCallback来重构ToDoItem组件:

// ToDoItem Component
const ToDoItem = ({id, text, toggleItemCompleted, completedItemIds}) => {
    const [completed, setCompleted] = React.useState(false)

    const onToggle = React.useCallback(() => {
        toggleItemCompleted(id)
    },[toggleItemCompleted, id])

    return (
        <li className="toto__item">
            <input 
                id={`completed-${id}`}
                type="checkbox"
                onChange={onToggle}
                checked={completed}
            />
            <label htmlFor={`completed-${id}`}>{text}</label>
        </li>
    )
}

如果把它和类组件ToDoItem进行比较的话,你会发现在函数组件版本中看不到getDerivedStateFromProps

我们需要使用getDrivedStateFromProps来确定特定的ToDoItem是否已经完成。而在Hooks中没有特定的钩子函数来实现这个。我们必须将其在render()函数中实现。一旦实现了它,ToDoItem组件看起来会像下面这样:

// ToDoItem Component with Function Components
const ToDoItem = ({id, text, toggleItemCompleted, completedItemIds}) => {
    const [completed, setCompleted] = React.useState(false)

    const toDoItemIndexInCompletedItemIds = completedItemIds.indexOf(id)
    
    const isCompleted = toDoItemIndexInCompletedItemIds > -1

    if (isCompleted != completed) {
        setCompleted(isCompleted)
    }

    const onToggle = React.useCallback(() => {
        toggleItemCompleted(id)
    },[toggleItemCompleted, id])

    return (
        <li className="todo__item">
            <input 
                type="checkbox"
                id={`completed-${id}`}
                onChange={onToggle}
                checked={completed}
            />
            <label htmlFor={`completed-${id}`}>{text}</label>
        </li>
    )
}

在组件渲染期间调用了setCompleted方法设置状态。在编写类组件时,从来没有在render()方法中调用setState,那么为什么函数组件可以接受这个方法呢?

在函数组件中是允许的,特别是允许我们执行getDerivedStateFromProps类型的操作。需要记住的是,确保在函数组件中,我们总是在条件块中调用状态设置器(setter)方法。否则就会陷入一个无限循环。

**注意:**在这里实现isCompleted检查的方法有点做作,这里目的主要是用来演示如何在函数组件中设置状态。理想情况下,不使用completed状态,而计算的isCompleted值将用于设置复选框的选中状态(checked)。

接下来我们需要转换ToDoContainer组件。我们需要实现state以及componentDidMountcomponentDidUpdate生命周期对应的功能。

通过前面的学习,我们已经了解了useState,不过接下来将向大家演示useReducer。该钩子函数的使用方式如下:

const [state, dispatch] = useReducer(reducerFunction, initialState, stateInitializerFunction);

简单地解释一下各个参数的含义:

  • reducerFunction:将现有stateaction作为输入并返回新state作为输出的函数
  • initialState:如果没有提供stateInitializerFunction,那么就是组件的初始state对象;如果提供了stateInitializerFunction,则将其作为参数传递给该函数
  • stateInitializerFunction:一个允许你执行组件状态的延迟初始化的函数(initialState)。initialState参数将作为参数传递给这个函数

使用useReducer钩子函数重构的ToDoContainer组件大致像下面这样:

// ToDoContainer Component with Function Component
const generateID = () => {
    return `${Date.now().toString(36)}-${(Math.random() + 1).toString(36).substring(7)}`
}

const reducer = (state, action) => {
    if (action.type === 'toggleItemCompleted') {
        const {toDoItemId} = action
        const toDoItemIndexInCompletedItemIds = state.completedItemIds.indexOf(toDoItemId)

        const completedItemIds = toDoItemIndexInCompletedItemIds === -1 ? state.completedItemIds.concat([toDoItemId]) : ([...state.completedItemIds.slice(0, toDoItemIndexInCompletedItemIds), ...state.completedItemIds.slice(toToItemIndexInCompletedItemIds + 1)])

        return {...state, completedItemIds}
    }

    if (action.type === 'addToDoItem') {
        const newToDoItem = {
            text: action.text,
            id: generateID()
        }

        const toDoItems = state.toDoItems.concat([newToDoItem])

        return {...state, toDoItems}
    }

    return state
}

const initialState = {
    toDoItems: [],
    completedItemIds: []
}

const ToDoContainer = () => {
    const [state, dispatch] = React.useReducer(reducer, initialState)

    const toggleItemCompleted = React.useCallback((toDoItemId) => {
        dispatch({type: 'toggleItemCompleted', toDoItemId})
    }, [dispatch])

    const toDoList = state.toDoItems.map(toDoItem => {
        return (
            <ToDoItem 
                key={toDoItem.id}
                completedItemIds={state.completedItemIds}
                toggleItemCompleted={toggleItemCompleted}
                {...toDoItem}
            />
        )
    })

    const addToDoItem = React.useCallback((text) => {
        dispatch({type: 'addToDoItem', text})
    },[dispatch])

    const toDoInput = (
        <ToDoInput 
            onAdd={addToDoItem}
        />
    )

    return (
        <div className="container">
            <h1>待办事项...(^_^)</h1>
            <div className="todo__container">
                <ul className="todo__lists">
                    {ToDoList}
                </ul>
                {toDoInput}
            </div>
        </div>
    )    
}

ToDoList的类组件中的componentDidUpdate生命周期中使用了localStorage来做缓存。为此,我们将使用useEffect钩子函数在组件的每个渲染完成后对要执行的某个操作进行排队。useEffect钩子的使用:

useEffect(enqueuedActionFunction);

只要遵守Hooks的使用规则,就可以在函数组件的任何位置插入useEffect。如果你有多个useEffect时,它们将要按顺序执行。useEffect的思想是执行不直接影响useEffect块中的组件的任何操作,比如API的调用,DOM操作等。

使用useEffectToDoContainer组件添加浏览器缓存的功能:

// ToDoContainer Component with Function Components
const generateID = () => {
    return `${Date.now().toString(36)}-${(Math.random() + 1).toString(36).substring(7)}`
}

const reducer = (state, action) => {

    if (action.type === 'toggleItemCompleted') {
        const {toDoItemId} = action

        const toDoItemIndexInCompletedItemIds = state.completedItemIds.indexOf(toDoItemId)

        const completedItemIds = toDoItemIndexInCompletedItemIds === -1 ? state.completedItemIds.concat([toDoItemId]) : ([...state.completedItemIds.slice(0, toDoItemIndexInCompletedItemIds), ...state.completedItemIds.slice(toDoItemIndexInCompletedItemIds + 1)])

        return {...state, completedItemIds}
    }

    if (action.type === 'addToDoItem') {
        const newToDoItem = {
            text: action.text,
            id: generateID()
        }

        const toDoItems = state.toDoItems.concat([newToDoItem])

        return {...state, toDoItems}
    }

    return state
}

const initialState = {
    toDoItems: [],
    completedItemIds: []
}

const ToDoContainer = () => {
    const [state, dispatch] = React.useReducer(reducer, initialState)

    React.useEffect(() => {
        localStorage.setItem('todos', JSON.stringify(state))
    })

    const toggleItemCompleted = React.useCallback((toDoItem) => {
        dispatch({type:'toggleItemCompleted', toDoItemId})
    }, [dispatch])

    const ToDoList = state.toDoItems.map(toDoItem => {
        return (
            <ToDoItem 
                kty={toDoItem.id}
                completedItemIds={state.completedItemIds}
                toggleItemCompleted={toggleItemCompleted}
                {...toDoItem}
            />
        )
    })

    const addToDoItem = React.useCallback((text) => {
        dispatch({type: 'addToDoItem', text})
    }, [dispatch])

    const toDoInput = (
        <ToDoInput onAdd={addToDoItem} />
    )

    return (
        <div className="container">
            <h1>待办事项...(^_^)</h1>
            <div className="todo__container">
                <ul className="todo__lists">
                    {ToDoList}
                </ul>
                {toDoInput}
            </div>
        </div>
    )
}

接下来要实现组件挂载时从本地存储中恢复状态,就目前为止还没有特定的钩子来执行这个操作,但我们可以使用useReducer钩子函数的延迟初始化函数(仅在第一次渲染时调用)来实现(实现类似类组件中的componentDidMount生命周期对应的功能):

// ToDoContainer Component with Function Components
const generateID = () => {
    return `${Date.now().toString(36)}-${(Math.random() + 1).toString(36).substring(7)}`
}

const reducer = (state, action) => {
    if (action.type === 'toggleItemCompleted') {
        const {toDoItemId} = action

        const toDoItemIndexInCompletedItemIds = state.completedItemIds.indexOf(toDoItemId)

        const completedItemIds = toDoItemIndexInCompletedItemIds === -1 ? state.completedItemIds.concat([toDoItemId]) : ([...state.completedItemIds.slice(0, toDoItemIndexInCompletedItemIds), ...state.completedItemIds.slice(toDoItemIndexInCompletedItemIds + 1)])

        return {...state, completedItemIds}
    }

    if (action.type === 'addToDoItem') {
        const newToDoItem = {
            text: action.text,
            id: generateID()
        }

        const toDoItems = state.toDoItems.concat([newToDoItem])

        return {...state, toDoItems}
    }

    return state
}

const initialState = {
    toDoItems: [],
    completedItemIds: []
}

const initState = (state) => {
    let savedToDos = localStorage.getItem('todos')

    try {
        savedToDos = JSON.parse(savedToDos)
        return Object.assign({}, state, savedToDos)
    } catch (err) {
        console.log(`Saved todos non-existent or corrupt. Trashing saved todos.`)
        return state
    }
}

const ToDoContainer = () => {
    const [state, dispatch] = React.useReducer(reducer, initialState, initState)

    React.useEffect(() => {
        localStorage.setItem('todos', JSON.stringify(state))
    })

    const toggleItemCompleted = React.useCallback((toDoItemId) => {
        dispatch({type: 'toggleItemCompleted', toDoItemId})
    }, [dispatch])

    const toDoList = state.toDoItems.map( toDoItem => {
        return (
            <ToDoItem 
                key={toDoItem.id}
                completedItemIds={state.completedItemIds}
                toggleItemCompleted={toggleItemCompleted}
                {...toDoItem}
            />
        )
    })

    const addToDoItem = React.useCallback((text) => {
        dispatch({type: 'addToDoItem', text})
    }, [dispatch])

    const toDoInput = (
        <ToDoInput onAdd={addToDoItem} />
    )

    return (
        <div className="container">
            <h1>待办事项...(^_^)</h1>
            <div className="todo__container">
                <ul className="todo__lists">
                    {toDoList}
                </ul>
                {toDoInput}
            </div>
        </div>
    )
}

正如React官网所说,并不是以前类组件都必须重新改造为函数组件。我们应该根据自己的需要进行改造。

函数组件和类组件的差异

通过前面两小节的学习,我们了解了如何分别通过类和函数的方式来构建ToDoList组件。在这一节中,我们来探讨一下函数组件和类组件之间的差异。

在社区中有这么一种观点:使用函数(React Hooks)构建的组件性能要更优于类构建的组件。但事实上,Web性能主要取决于你的代码在做什么,而不是你选择的是函数组件还是类组件。或者说,我们对于性能可以忽略不计,在实际生产中可以有很多种方式来对性能进行优化。不过,我们不在这里探讨这方面。

我们还是回到这一节中来,React函数和类之间到底有什么区别?

早在2015年,React就引入了函数组件,但经常是被忽视:

函数组件捕获渲染的值

为什么会这么说呢?先来看一个这样的组件:

const UserProfile = (props) => {
    const showMessage = () => {
        alert(`关注${props.user}`)
    }

    const handleClick = () => {
        setTimeout(showMessage, 3000)
    }

    return <button onClick={handleClick}><span>Follow</span></button>
}

UserProfile组件很简单,就一个Follow按钮,该按钮使用了setTimeout模拟网络请求。用户点击这个按钮之后会弹出一个警告框。如果props.user传的值是大漠3s后显示“关注大漠”。

如果我们把它换成类组件,可能是这样的:

class UserProfile extends React.Component {
    showMessage = () => {
        alert(`关注${this.props.user}`)
    }

    handleClick = () => {
        setTimeout(this.showMessage, 3000)
    }

    render() {
        return <button onClick={this.handleClick}><span>Follow</span></button>
    }
}

简单地看,他们看上去一样的(最起码操作效果是一样的)。

可能大部分人没有注意到它们的含义,只注重到模式(类组件和函数组件)之间的切换。甚至可以说,这两个示例之间的差异与React Hooks本身无关,也可以不使用Hooks的任何钩子函数。

把上面的两个示例稍作修改,用来阐述他们之间的差异:

仔细看Demo效果哟!

分别按下面的顺序来操作Follow按钮:

  • 先点击Follow按钮
  • 3s之前更改下拉选择项的选项
  • 阅读弹出的警告框内容

你会发现函数组件和类组件是有区别的:

  • 函数组件:按上面所列的三个步骤操作时,当用户在3s前更改下拉选择框的选项时,h1的用户名会立马改变,而3s后弹出的警告框中的用户名并不会改变
  • 类组件:按上面所列的三个步骤操作时,当用户在3s前更改下拉选择框的选项时,h1中的用户名会立马改变,而3s后弹出的警告框中的用户名也会改变

仔细看下面的录屏效果:

从显示的效果上来看,函数组件是更符合我们的实际场景。比如说,我们关注一个人,不应该随着选择项更改之后,我们所关注的人也变了。所以混淆我们的关注对象。类组件就有这样混淆视听的现象。

为什么类组件会如此呢?我们先来看一下类组件的代码:

class UserProfileClass extends React.Component {
    showMessage = () => {
        alert(`关注${this.props.user}`)
    }

    handleClick = () => {
        setTimeout(this.showMessage, 3000)
    }

    render() {
        return <button onClick={this.handleClick}><span>Follow</span></button>
    }
}

仔细看看代码中的showMessage方法:

class UserProfileClass extends React.Component {
    showMessage = () => {
        alert(`关注${this.props.user}`)
    }
    // ...
}

showMessage方法中读取了this.props.user(也是我们要输出的用户名称)。而React中的props是不可变的,但是this是可变的,而且是一直是可变的。这也是类组件中this的目的。React自身会随着时间的推移对this进行修改,以便你可以在render函数或生命周期中读取新的版本。

因此,如果组件在请求重新渲染时,this.props将会改变。showMessage方法会从新的props中读取user。你所看到的效果也正是因为这个原因。

在React中的组件,UI在概念上可以理解是程序当前状态的函数,那么事件处理就是让UI的渲染结果一部分一部分可视化输出。我们的事件处理程序属于具有特定propsstate的特定渲染。但是,当回调超时的话,this.props就会打破这种联系。示例中的showMessage方法在回调时没有绑定到任何特定的渲染,因此它会丢失真正的props

那么我们有没有一种较好的方式可以使用正确的props来修复rendershowMessage回调之间的联系。我们可以在事件发生的早期,将this.props传递给超时完成的处理程序来尝试着解决这个问题。

class UserProfileClass extends React.Component {
    showMessage = (user) => {
        alert(`关注${user}`)
    }

    handleClick = () => {
        const {user} = this.props
        setTimeout(()=>this.showMessage(user), 3000)
    }

    render() {
        return <button onClick={this.handleClick}><span>Follow</span></button>
    }
}

这种方法虽然解决我们前面所提到的问题,但是这种方法代码会随着props的个数增加,代码也会变得更加冗余也易于出错。如果我们也需要访问state。如果showMessage调用另一个方法,该方法会读取this.props.somethingthis.state.something。我们又会碰到同样的问题。所以我们必须通过this.props作为showMessage的参数来修复它们之间存在的问题。

但这么做会破坏类提供的特性。也令人难于记住或执行。另外,在handleClick中内联alert中的代码并不能解决更大的问题。我们希望以一种允许代码分解成更多方法的方式来构造代码,同时还可以读取与其相关的render所对应的propsstate

或许,我们可以在类的构造函数中绑定这些方法:

class UserProfileClass extends React.Component {

    render() {
        // 获取props
        const props = this.props

        // 在render内部,这些不是类方法
        const showMessage = () => {
            alert(`关注${props.user}`)
        }

        const handleClick = () => {
            setTimeout(showMessage, 3000)
        }
        
        return <button onClick={handleClick}><span>Follow</span></button>
    }
}

这样一来,函数组件和类组件所达到的效果都一样了。在类组件中可以捕获渲染时的props。效果上看上去是一样了,但看起来怪怪的。如果在类组件中的render中定义函数而不是使用类方法,那么还有使用类的必要性?

通个这个简单的示例,我们了解了React中函数组件和类组件之间的差异:

函数组件捕获渲染的值

事实上,在Reack Hooks中,同样的原则也适用于状态。比如上面示例中的App组件代码:

const App = (props) => {
    const [name, setName] = React.useState('大漠')
    
    const handleChange = (e) => {
        setName(e.target.value)
    }

    return (
        <>
            <div className="control">
                <label htmlFor="name">选择用户名称:</label>
                <select id="name" value={name} onChange={handleChange}>
                    <option value="大漠">大漠</option>
                    <option value="@w3cplus">@w3cplus</option>
                    <option value="大漠@w3cplus">大漠@w3cplus</option>
                </select>
            </div>
            <h1>欢迎来到<span>{name}</span>的个人主页</h1>
            <div className="wrapper">
                <div className="control">
                    <UserProfileFn user={name} /> (Function Component)
                </div>
                <div className="control">
                    <UserProfileClass user={name} /> (Class Component)
                </div>  
            </div>
            <p>你能看出函数组件和类组件之间的差异吗?</p>
        </>
    )
}

通过这个小节中的小示例,是否对于React的类组件和函数组件之间的差异有一点了解了吧。如果你还想深入的了解他们之间的差异,还可以阅读下面这些文章:

小结

在《React中创建组件的方式》一文中我们有说到,在React中有关于组件的构建方式有多种。比如无状态组件、有状态组件、类组件和函数组件中。在这篇文章中,我们主要初步探讨了类组件和函数组件之间的差异,并且通过一些简单的实例演示了类组件如何转换为函数组件。在接下来,我们将继续围绕着React的组件、Hooks以及涉及到的一些JavaScript基础展开学习,要是你这方面的知识点也感兴趣的话,欢迎继续关注相关更新。