初探React中函数组件和类组件的差异
自从React Hooks的出来,社区讨论Hooks的越来越多。这并不是说React Hooks就优于类组件,但是使用Hooks来构建组件时有一个巨大的可用性提升,特别是因为这些函数组件可以通过React Hooks中的钩子函数来访问状态和生命周期。
今天我们就来一起聊聊如何将React的类组件转换为函数组件,用React Hooks中的钩子函数替换类组件中的setState
和生命周期方法,比如componentWillMount
、componentWillReceiveProps
等。
因此,让我们首先使用状态和生命周期方法构建一个基于类的React组件。也是大家最为熟悉的ToDoList
组件。该组件具备:
- 有一个文本输入框(
<input type="text" />
),用户可以在输入框中输入想要的内容 - 有一个**“添加列表项”按钮(
button
)**,点击该按钮之后可以将文本输入框的内容添加到列表中(ToDoList
中) - 显示每个待办事项的列表清单
- 每个单独的列表项目都有一个相关联的复选框(
<input type="checkbox" />
),可以用来将列表项标记为已完成 - 列表项会存储到浏览器的缓存中(本地存储),并在应用程序启动时从本地存储中再次加载
我们的组件将使用state
、componentDidMount
、componentDidUpdate
和getDerivedStateFromProps
生命周期方法。其中一些生命周期方法(比如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中的函数和类相关的知识的话,可以花点时间阅读下面相关文章:
- 6 Ways to Declare JavaScript Functions
- Understanding JavaScript Functions
- How To Define Functions in JavaScript
- Curry and Function Composition
- Understanding JavaScript Callbacks and best practices
- Understanding Classes in JavaScript
- Understanding Prototypes and Inheritance in JavaScript
- A Deep Dive into Classes
- A Guide To Prototype-Based Class Inheritance In JavaScript
- Understanding Public and Private Fields in JavaScript Class
- 3 ways to define a JavaScript class
- Object-oriented JavaScript: A Deep Dive into ES6 Classes
- Demystifying Class in JavaScript
- Javascript Classes — Under The Hood
- JavaScript engine fundamentals: Shapes and Inline Caches
- Understanding "Prototypes" in JavaScript
- Advanced TypeScript Concepts: Classes and Types
- A Beginner's Guide to JavaScript's Prototype
我们回到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
会指向某个全局且无用的东西,比如window
或undefined
,因此代码会崩溃或者做一些像设置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’s new way to make objects
- How Does A JavaScript Function Define A Type And Create Object Instances?
- JavaScript Factory functions vs Constructor functions
- Let’s demystify JavaScript’s ‘new’ keyword
- Constructors: building objects with functions
- JavaScript For Beginners: the ‘new’ operator
- JavaScript “New” Operator
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 实例 |
this 是window 或undefined |
对于普通函数,用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整理的博文:
- Introduction to Objects in JavaScript
- Extending Built-in Objects in JavaScript
- A Deeper Look at Objects in JavaScript
- Using Classes in JavaScript
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中原型链更多的介绍可以阅读下面相关的文章:
- Prototypes in JavaScript
- Understanding Prototypes in JavaScript
- Prototypal inheritance
- All you need to know to understand JavaScript’s Prototype
- Prototype-based Inheritance and Prototype chain in JavaScript
- Properly using .bind() in React and understanding the prototype chain
- JavaScript Prototypes & Inheritance
- Diving deeper into Javascript Prototypes
- JavaScript Inheritance and the Prototype Chain
- JavaScript Prototype and Prototype Chain explained
- Inheritance & Prototype Chain in Javascript
- Fast properties in V8
- Native prototypes
对JavaScript的类和函数有所了解之后,我们开始进来React的世界当中。我们先来看看在React中如何构建类组件。接下来的目标是构建文章开头我们想要的ToDoList
组件。
构建一个ToDoList
的类组件
这个ToDoList
组件可以拆分三个不同的子组件,如下图所示:
让我们先来看一下ToDoContainer
组件。该组件在应用程序中可以在任何时候维护应用程序的完整状态。它有两个方法: AddToDoItem
和 ToggleItemCompleted
,这两个方法分别作为回调传递到ToDoInput
和ToDoItem
组件,并用于添加一个新的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
组件。这是一个比较简单的组件,由一个input
和button
组成。组件使用自己的state
来跟踪文本输入框的值,单击按钮后,将该文本通过ToDoContainer
组件的addToDoItem
方法将其作为ToDoInput
的props
传入给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钩子中调用
- 在渲染单个组件期间,应该以相同的顺序,相同的次数调用相同的钩子。这也意味着钩子函数不能在循环或条件块中调用,而必须始终在函数顶层调用
- 永远不要从常规函数中调用钩子函数
如果要聊的话,这些规则可以花很大的篇幅来聊,如果你感兴趣的话,可以阅读下面相关文章:
- Rules of Hooks
- Use React Hooks Correctly with These Two Rules
- 5 Tips to Help You Avoid React Hooks Pitfalls
- HOW TO BREAK THE RULES OF REACT HOOKS
现在,我们知道了使用钩子函数时要遵循的基本规则,那我们就开始将前面的类组件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
方法获取的。onTextChange
和addToDoItem
方法都已更改为使用setText()
方法,从而替代了setState
。
你可能已经注意到了,分别在input
上添加了onChange
事件和在button
上添加了onClick
事件。事实上这样做在性能上不是太好,自引用渲染函数渲染时,只要发生了变化,就会重新渲染input
和button
。
为了避免这些不必要的重新渲染,我们需要保持引用相同的函数。为了达到此目的,可以使用Hooks中的另一个钩子函数useCallback
。useCallback
的用法大致如下:
const memoizedFunction = useCallback(inlineFunctionDefinition, memoizationArguments);
// 相当于
const memoizedFunction = useMemo(() => inlineFunctionDefinition, memoizationArguments)
简单的解释一下:
inlineFunctionDefinition
:是你希望在渲染之间维护引用的函数。该函数可以是内联匿名函数,也可以是从其他地方导入的函数。但是,在大多数情况下希望引用组件的状态变量,所以我们将把它定义为一个内联函数,这将允许我们使用闭包访问状态变量memoizationArguments
:是inlineFunctionDefinition
函数引用的参数数组。第一次调用useCallback
钩子函数时,memoizationArguments
和inlineFunctionDefinition
一起保存。后续调用,每个元素在新memoizationArguments
数组元素的值相比,在相同的索引之前保存memoizationArguments
数组;如果没有改变,那么以前保存的inlineFunctionDefinition
返回,因此保留参考,防止不必要的重新渲染。如果任何参数发生了更改,则保存并使用inlineFunctionDefinition
和新的memoizationArguments
,从而更改对函数的引用,并确保重新渲染
下面的代码是使用了useState
和useCallback
两个钩子函数重构的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
组件,接下来以同样的方式,使用useState
和useCallback
来重构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
以及componentDidMount
和componentDidUpdate
生命周期对应的功能。
通过前面的学习,我们已经了解了useState
,不过接下来将向大家演示useReducer
。该钩子函数的使用方式如下:
const [state, dispatch] = useReducer(reducerFunction, initialState, stateInitializerFunction);
简单地解释一下各个参数的含义:
reducerFunction
:将现有state
和action
作为输入并返回新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操作等。
使用useEffect
给ToDoContainer
组件添加浏览器缓存的功能:
// 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的渲染结果一部分一部分可视化输出。我们的事件处理程序属于具有特定props
和state
的特定渲染。但是,当回调超时的话,this.props
就会打破这种联系。示例中的showMessage
方法在回调时没有绑定到任何特定的渲染,因此它会丢失真正的props
。
那么我们有没有一种较好的方式可以使用正确的props
来修复render
和showMessage
回调之间的联系。我们可以在事件发生的早期,将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.something
或this.state.something
。我们又会碰到同样的问题。所以我们必须通过this.props
作为showMessage
的参数来修复它们之间存在的问题。
但这么做会破坏类提供的特性。也令人难于记住或执行。另外,在handleClick
中内联alert
中的代码并不能解决更大的问题。我们希望以一种允许代码分解成更多方法的方式来构造代码,同时还可以读取与其相关的render
所对应的props
和state
。
或许,我们可以在类的构造函数中绑定这些方法:
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 Hooks: Migration from Class to Function Components
- Convert a React Class-Based Component to a Functional One Using a State Hook
- How Are Function Components Different from Classes?
小结
在《React中创建组件的方式》一文中我们有说到,在React中有关于组件的构建方式有多种。比如无状态组件、有状态组件、类组件和函数组件中。在这篇文章中,我们主要初步探讨了类组件和函数组件之间的差异,并且通过一些简单的实例演示了类组件如何转换为函数组件。在接下来,我们将继续围绕着React的组件、Hooks以及涉及到的一些JavaScript基础展开学习,要是你这方面的知识点也感兴趣的话,欢迎继续关注相关更新。