React 查漏补缺

React 查漏补缺

本章主要用于记录一些 React 相关知识点,因为最近在复习 React 相关内容,发现版本迭代了许多,废弃了很多 API,也添加了一些新的方法(比如生命周期钩子等),所以就简单的在这里汇总整理一下,也算是查漏补缺

回调函数中的 this

通常,我们在 React 当中的事件处理是下面这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Toggle extends React.Component {
constructor(props) {
super(props)
this.state = { isToggleOn: true }

// 为了在回调中使用 `this`,这个绑定是必不可少的
this.handleClick = this.handleClick.bind(this)
}

handleClick() {
this.setState(state => ({
isToggleOn: !state.isToggleOn
}))
}

render() {
return <button onClick={this.handleClick}>{this.state.isToggleOn ? 'ON' : 'OFF'}</button>
}
}

我们之所以要在构造函数当中进行显式的 this 绑定,这是因为在 JavaScript 中,Class 的方法默认不会绑定 this,如果你忘记绑定 this.handleClick 并把它传入了 onClick,当你调用这个函数的时候 this 的值为 undefined,如果不想显式的执行绑定操作的话,我们经常还会采用下面这种方式,即使用箭头函数的方式

1
2
3
4
5
6
7
8
9
10
class LoggingButton extends React.Component {
handleClick() {
console.log('this is:', this)
}

render() {
// 此语法确保 `handleClick` 内的 `this` 已被绑定
return <button onClick={() => this.handleClick()}>Click me</button>
}
}

此语法问题在于每次渲染 LoggingButton 时都会创建不同的回调函数,在大多数情况下,这没什么问题,但如果该回调函数作为 prop 传入子组件时,这些组件可能会进行额外的重新渲染,所以建议使用下面这种方式来进行绑定,即使用 Class fields 语法

1
2
3
4
5
6
7
8
9
10
class LoggingButton extends React.Component {
// 确保 `handleClick` 内的 `this` 已被绑定
handleClick = () => {
console.log('this is:', this)
}

render() {
return <button onClick={this.handleClick}>Click me</button>
}
}

事件处理程序参数传递

在循环中,通常我们会为事件处理函数传递额外的参数,例如 id 是你要删除那一行的 id,以下两种方式都可以向事件处理函数传递参数

1
2
3
4
5
<button onClick={(e) => this.deleteRow(id, e)}>Delete Row</button>

// or

<button onClick={this.deleteRow.bind(this, id)}>Delete Row</button>

在这两种情况下,React 的事件对象 e 会被作为第二个参数传递,如果通过箭头函数的方式,事件对象必须显式的进行传递,而通过 bind 的方式,事件对象以及更多的参数将会被隐式的进行传递

组件通信的几种方式

我们先来看看需要组件之进行通信的几种情况

  • 父组件向子组件通信
  • 子组件向父组件通信
  • 跨级组件通信
  • 没有嵌套关系组件之间的通信

父组件向子组件通信

通常通过 props 向子组件传递需要的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Child
Child.propTypes = {
name: PropTypes.string.isRequired,
}

export default function Child({ name }) {
return <h1>Hello, {name}</h1>
}


// Parent
export default class Parent extends Component {
render() {
return <Child name="zhangsan" />
}
}

子组件向父组件通信

主要是利用回调函数的方式,另外也可以利用自定义事件机制,这个我们会在下面一起来进行介绍,这里我们主要来看回调函数的方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// List
export default class List extends Component {
static propTypes = {
hideConponent: PropTypes.func.isRequired
}
render() {
return (
<div>
List <button onClick={this.props.hideConponent}>隐藏 List</button>
</div>
)
}
}


// App
export default class App extends Component {
constructor(...args) {
super(...args)
this.state = {
isShowList: false
}
}
showConponent = () => {
this.setState({
isShowList: true,
})
}
hideConponent = () => {
this.setState({
isShowList: false,
})
}
render() {
return (
<div>
<button onClick={this.showConponent}>显示 Lists 组件</button>
{ this.state.isShowList ? <List hideConponent={this.hideConponent} /> : null }
</div>
)
}
}

观察一下实现方法,可以发现它与传统回调函数的实现方法一样,而且 setState 一般与回调函数均会成对出现,因为回调函数即是转换内部状态的函数传统

另外我们也可以使用 onRef,原理与上面的方式是一致的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Child
export default class Child extends React.Component {
state = {
name: '初始值'
}

componentDidMount() {
this.props.onRef(this, this.state.name)
}

click = () => {
this.setState({
name: '改变后的值'
})
};

render() {
return (
<div>
<div>
<div>{this.state.name}</div>
</div>
</div>
)
}
}


// App
class App extends Component {
handleParentClick = (ref) => {
console.log(ref.state.name)
ref.click()
}
render() {
return <Child onRef={this.handleParentClick} />
}
}

跨级组件通信

有两种实现方式

  • 层层组件传递 props
    • 例如 A 组件和 B 组件之间要进行通信,先找到 AB 公共的父组件,A 先向 C 组件通信,C 组件通过 propsB 组件通信,此时 C 组件起的就是中间件的作用
  • 使用 Context
    • Context是一个全局变量,像是一个大容器,在任何地方都可以访问到,我们可以把要通信的信息放在 Context 上,然后在其他组件中可以随意取到
    • 但是 React 官方不建议使用大量 Context,尽管他可以减少逐层传递,但是当组件结构复杂的时候,我们并不知道 Context是从哪里传过来的
    • 而且 Context是一个全局变量,全局变量正是导致应用走向混乱的罪魁祸首

这里我们主要来看看 Context 的使用方式,比如下面这个示例,其中 ListItemList 的子组件,ListApp 的子组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// ListItem
export default class ListItem extends Component {
// 子组件声明自己要使用 context,并且 contextTypes 是必写的
static contextTypes = {
color: PropTypes.string,
}
static propTypes = {
value: PropTypes.string,
}
render() {
const { value } = this.props
return (
<li style={{ background: this.context.color }}>
<span>{value}</span>
</li>
)
}
}


// List
export default class List extends Component {
// 父组件声明自己支持 context
static childContextTypes = {
color: PropTypes.string,
}
static propTypes = {
list: PropTypes.array,
}
// 提供一个函数,用来返回相应的 context 对象
getChildContext() {
return {
color: 'red',
}
}
render() {
const { list } = this.props
return (
<div>
<ul>
{ list.map((entry, index) => <ListItem key={`list-${index}`} value={entry.text} />) }
</ul>
</div>
)
}
}
1
2
3
4
5
6
7
8
9
10
11
// App
const list = [
{ text: '题目一', },
{ text: '题目二', },
]

export default class App extends Component {
render() {
return <List list={list} />
}
}

没有嵌套关系的组件通信

如果在业务逻辑不是十分复杂的情况下推荐使用自定义事件机制,这里以常用的 发布/订阅 模式举例,借用 Node.js 当中的 Events 模块的浏览器版实现,比如我们要实现这样一个功能,点击 List2 中的一个按钮,改变 List1 中的信息显示,其中 List1List2 没有任何嵌套关系,App 是他们的父组件

我们首先需要安装 events

1
$ npm install events --save

然后我们新建一个 events.js

1
2
3
4
// events.js
import { EventEmitter } from 'events'

export default new EventEmitter()

接下来进行使用即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// List1
export default class List extends Component {
constructor(props) {
super(props)
this.state = {
message: 'List1',
}
}
componentDidMount() {
// 组件装载完成以后声明一个自定义事件
this.eventEmitter = emitter.addListener('changeMessage', (message) => {
this.setState({
message,
})
})
}
componentWillUnmount() {
// 组件卸载的时候取消订阅
emitter.removeListener(this.eventEmitter)
}
render() {
return <div>{this.state.message}</div>
}
}


// List2
export default class List2 extends Component {
handleClick = (message) => {
emitter.emit('changeMessage', message)
}
render() {
return <div><button onClick={this.handleClick.bind(this, 'List2')}>点击我改变 List1 组件中显示信息</button></div>
}
}


// APP
export default class App extends Component {
render() {
return (
<div>
<List1 />
<List2 />
</div>
)
}
}

自定义事件是典型的发布订阅模式,通过向事件对象上添加监听器和触发事件来实现组件之间的通信

总结

  • 父组件向子组件通信使用 props
  • 子组件向父组件通信使用回调函数或者自定义事件
  • 跨级组件通信使用层层组件传递 props 的方式或是 Context
  • 没有嵌套关系组件之间的通信推荐使用自定义事件

但是在进行组件通信的时候,主要还是看业务的具体需求来选择最为合适的,当业务逻辑复杂到一定程度,可以考虑引入 MobxRedux 等状态管理工具

对比

我们在上面介绍了一些比较常见的处安置方式,但是 React 当中的传参方式并不只有这些,下表列举了一些在 React 当中可以使用的传参方式,我们可以对比一下它们之间的优缺点,然后根据实际场景选择使用

方法 优点 缺点
props 不需要引入外部插件 兄弟组件通讯需要建立共同父级组件,较为麻烦
ProviderConsumerContext 不需要引入外部插件,跨多级组件或者兄弟组件通讯利器 状态数据状态追踪麻烦
EventEmitter 可支持兄弟,父子组件通讯 要引入外部插件
路由传参 可支持兄弟组件传值,页面简单数据传递非常方便 父子组件通讯无能为力
onRef 可以在获取整个子组件实例,使用简单 兄弟组件通讯麻烦,官方不建议使用
ref onRef onRef
Redux 建立了全局的状态管理器,兄弟父子通讯都可解决 引入了外部插件
Mobx 建立了全局的状态管理器,兄弟父子通讯都可解决 引入了外部插件
Flux 建立了全局的状态管理器,兄弟父子通讯都可解决 引入了外部插件
Hook 16.x 新的属性,可支持兄弟,父子组件通讯 需要结合 Context 一起使用
slot 支持父向子传标签

下面我们来看看 ReduxMobxFlux 三者之间简单的对比,详细内容可以参考 Redux、Flux 和 React-Redux 三者之间的区别

  • Redux
    • 核心模块 actionreducerstore
    • store 和更改逻辑是分开的,并且只有一个 store
    • 没有调度器的概念,而且容器组件是有联系的
    • 状态是不可改变的,更多的是遵循函数式编程思想
  • Mobx
    • 核心模块 actionreducerDerivation
    • 有多个 store
    • 设计更多偏向于面向对象编程和响应式编程,通常将状态包装成可观察对象,一旦状态对象变更,就能自动获得更新
  • Flux
    • 核心模块 storeReduceContainer
    • 有多个 store

React.lazy

React.lazy 函数能让你像渲染常规组件一样处理动态引入(的组件),在使用之前

1
import OtherComponent from './OtherComponent'

而使用之后

1
const OtherComponent = React.lazy(() => import('./OtherComponent'))

不过更为常见的方式是搭配 React.Suspense 使用

1
2
3
4
5
6
7
8
9
10
11
12
13
import React, { Suspense } from 'react'

const OtherComponent = React.lazy(() => import('./OtherComponent'))

function MyComponent() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
</Suspense>
</div>
)
}

React.lazy() 接收一个函数作为参数,该函数需要返回一个 Promise 对象,reslove 后返回一个模块,模块的默认导出对象作为渲染的 React 组件,例如

1
2
3
4
5
6
7
import React from 'react'

function OtherComponent() {
return <h1>Hello World</h1>
}

export default OtherComponent

如何支持有名导出的模块

使用 React.lazy() 加载的模块,如果其中的 React 组件不是默认导出话,可能会报以下错误

1
2
3
Warning: React.createElement: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: undefined. 

You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports.

这是因为 React.lazy() 目前只支持默认导出(Default Export),不支持有名导出(Named Exports),假如在 OtherComponent 中导出了多个组件,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
import React, { Component } from 'react'

export class AComponent extends Component {
render() {
return <div>Hello</div>
}
}

export class BComponent extends Component {
render() {
return <div>World</div>
}
}

我们在 OtherComponent 组件当中分别导出了 AComponentBComponent 两个组件,在不修改 OtherComponent 的前提下,可以这样写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const OtherComponent = React.lazy(() => {
return new Promise((resolve, reject) => {
import('./OtherComponent').then(module => {
resolve({
// 这里可以根据需求加载 `AComponent` 或是 `BComponent`
default: module.AComponent
})
}).catch(err => {
reject(err)
})
})
})

export default class Test extends Component {
render() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
</Suspense>
</div>
)
}
}

搭配 Webpack 实现代码分割

借助 WebpackCode Splitting 功能,使用动态 import() 引入的模块会被自动拆分为异步加载的 chunk,如果希望自定义 chunk 的文件名,可以在 import() 中加入 Webpack 特定的注释,如下

1
2
3
const OtherComponent = React.lazy(
() => import(/* webpackChunkName: 'Other-Component' */ './OtherComponent')
)

例如如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const AComponent = React.lazy(() => import(/* webpackChunkName: 'A-component' */ './AComponent'))
const BComponent = React.lazy(() => import(/* webpackChunkName: 'B-component' */ './BComponent'))

export default class Test extends Component {
render() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<AComponent />
<BComponent />
</Suspense>
</div>
)
}
}

运行后可以在控制台的 Network 选项当中发现 A-component.chunk.jsB-component.chunk.js

setState() 是同步还是异步

我们从一个简单的例子开始看起,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class App extends Component {

state = { val: 0 }

increment = () => {
this.setState({ val: this.state.val + 1 })
// 需要注意的是这里的值仍为 0
console.log(this.state.val)
}

render() {
return <div onClick={this.increment}>{`Counter is ${this.state.val}`}</div>
}
}

运行以后我们可以发现,输出的值仍为 0,所以在本节当中,我们就来简单的探讨一下这个问题,其实在 React 中,setState() 的使用场景一般有以下这些

  • 合成事件中 setState()
  • 生命周期函数中的 setState()
  • 原生事件中的 setState()
  • setTimeout 中的 setState()
  • setState() 中的批量更新

所以下面我们就分别来看看这些不同情况下的结果

合成事件中 setState()

JSX 中常见的 onClick()onChange() 这些其实本质上都是合成事件,也就是属于 React 来进行管辖的范围,就比如上面的例子,我们可以知道它的结果为 0

生命周期函数中的 setState()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class App extends Component {

state = { val: 0 }

componentDidMount() {
this.setState({ val: this.state.val + 1 })
// 需要注意的是这里的值仍为 0
console.log(this.state.val)
}

render() {
return <div>{`Counter is ${this.state.val}`}</div>
}
}

其实还是和合成事件一样,当 componentDidmount() 执行的时候,React 内部并没有更新,这就导致在 componentDidmount() 中调用完 setState() 以后去 console.log() 拿到的结果还是更新之前的值

原生事件中的 setState()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class App extends Component {

state = { val: 0 }

changeValue = () => {
this.setState({ val: this.state.val + 1 })
// 需要注意的是这里的值为 1
console.log(this.state.val)
}

componentDidMount() {
document.body.addEventListener('click', this.changeValue, false)
}

render() {
return <div>{`Counter is ${this.state.val}`}</div>
}
}

原生事件是指非 React 合成事件,比如上面的 addEventListener(),它相较于合成事件,会直接触发点击事件,所以当你在原生事件中 setState() 后,能同步拿到更新后的 state

setTimeout 中的 setState()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class App extends Component {

state = { val: 0 }

componentDidMount() {
setTimeout(_ => {
this.setState({ val: this.state.val + 1 })
// 需要注意的是这里的值为 1
console.log(this.state.val)
}, 0)
}

render() {
return <div>{`Counter is ${this.state.val}`}</div>
}
}

setTimeout() 中去使用 setState() 并不算是一个单独的场景,它是随着外层所决定的,因为你可以在合成事件中使用 setTimeout(),可以在钩子函数中使用 setTimeout(),也可以在原生事件中使用 setTimeout(),但是不管是哪个场景下,基于 EventLoop 的模型下, 在 setTimeout() 当中去 setState() 总能拿到最新的 state

setState 中的批量更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class App extends Component {

state = { val: 0 }

batchUpdates = () => {
this.setState({ val: this.state.val + 1 })
this.setState({ val: this.state.val + 1 })
this.setState({ val: this.state.val + 1 })
// 需要注意的是这里的值仍为 1
}

render() {
return <div onClick={this.batchUpdates}>{`Counter is ${this.state.val}`}</div>
}
}

在调用 setState() 的时候 React 内部会创建一个更新队列,通过 firstUpdate/lastUpdate/lastUpdate.next 等方式去维护一个更新队列,在最终的 performWork 当中,相同的 key 会被覆盖,所以只会对最后一次的 setState() 进行更新,而如果我们使用一些别的方式,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class App extends React.Component {
state = { val: 0 }

componentDidMount() {
this.setState({ val: this.state.val + 1 })
console.log(this.state.val)

this.setState({ val: this.state.val + 1 })
console.log(this.state.val)

setTimeout(_ => {
this.setState({ val: this.state.val + 1 })
console.log(this.state.val);

this.setState({ val: this.state.val + 1 })
console.log(this.state.val)
}, 0)
}

render() {
return <div>{this.state.val}</div>
}
}

结合上面分析的,钩子函数中的 setState() 无法立即拿到更新后的值,所以前两次都是输出 0,当执行到 setTimeout() 里的时候,前面两个 state 的值已经被更新,由于 setState() 批量更新的策略, this.state.val 只对最后一次的生效,为 1,而在 setTimeout()setState() 是可以同步拿到更新结果,所以 setTimeout() 中的两次输出 23,最终结果就为 0,0,2,3

总结

简单来说,有时表现出异步,有时表现出同步

  1. setState() 只在合成事件和钩子函数当中可以理解为异步的,在原生事件和 setTimeout() 中都是同步的
  2. setState() 的异步并不是说内部由异步代码实现,其实本身执行的过程和代码都是同步的,只是合成事件和钩子函数的调用顺序在更新之前,导致在合成事件和钩子函数中没法立马拿到更新后的值,形式了所谓的异步,当然可以通过第二个参数 setState(partialState, callback) 中的 callback 拿到更新后的结果(这个可以参考官方文档当中的 State 的更新可能是异步的
  3. setState() 的批量更新优化也是建立在异步(合成事件、钩子函数)之上的,在原生事件和 setTimeout() 中不会批量更新,在异步中如果对同一个值进行多次 setState()setState() 的批量更新策略会对其进行覆盖,取最后一次的执行,如果是同时 setState() 多个不同的值,在更新时会对其进行合并批量更新

如果使用一种比较好的记忆方式来进行概括的话,就是

React 管得到的就是异步,管不到的就是同步

最后我们在简单的提及一个使用 setState() 的小技巧,那就是它还可以接收第二个参数,作用是在 state 值改变后进行调用

1
2
3
4
5
this.setState(
{ count: 3 }, () => {
// ...
}
)

监听数据变化

React16.x 之前的版本我们可以使用 componentWillReceiveProps()(当然现在还暂未移除,不过调整成了 UNSAFE_componentWillReceiveProps()

1
2
3
4
5
componentWillReceiveProps(nextProps){
if (this.props.visible !== nextProps.visible) {
// props 值改变做的事
}
}

但是有一点需要注意的就是,有些时候 componentWillReceiveProps()props 值未变化也会触发,因为在生命周期的第一次 render() 后不会被调用,但是会在之后的每次 render() 中被调用(当父组件再次传送 props

React16.x 以后的版本当中我们可以使用 getDerivedStateFromProps() 这个静态方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
export default class App extends React.Component {
state = {
countOne: 1,
changeFlag: ''
}
clickOne = () => {
let { countOne } = this.state
this.setState({ countOne: countOne + 1 })
}
static getDerivedStateFromProps(nextProps) {
console.log(`变化执行`)
return {
changeFlag: 'state 值变化执行'
}
}
render() {
const { countOne, changeFlag } = this.state
return (
<div>
<div>
<Button type="primary" onClick={this.clickOne}>点击加 1</Button><span>countOne 值为{countOne}</span>
<div>{changeFlag}</div>
</div>
</div>
)
}
}

使用 React Hook 来检查网络连接状态

我们都知道,在 JavaScript 当中有一个 Navigator 对象,它包含当前浏览器的状态和特性,比如定位、userAgent 和一些其他的属性,其中就包括当前是否处于网络连接状态,这里我们需要获取的就是 onLine 这个属性(这里需要注意它是驼峰命名的形式),下面我们就来看看如何在 Hook 中来使用

显然我们的首要任务是需要一些状态来跟踪记录我们是否在线的状态以及把它从我们的自定义 Hookreturn 出来,如下

1
2
3
4
function useNetwork() {
const [isOnline, setOnline] = useState(window.navigator.onLine)
return isOnline
}

当组件正常挂载时这样做没有问题,但是如果当用户在渲染完成之后掉线我们该怎么做呢?所幸的是,我们可以监听两个事件,触发时以更新状态,为了达到这个效果我们需要使用 useEffect Hook

1
2
3
4
5
6
7
8
function useNetwork() {
const [isOnline, setNetwork] = useState(window.navigator.onLine)
useEffect(() => {
window.addEventListener('offline', _ => setNetwork(window.navigator.onLine))
window.addEventListener('online', _ => setNetwork(window.navigator.onLine))
})
return isOnline
}

如你所见我们监听了两个事件,offlineonline(这里就不是驼峰命名的形式了),当事件触发的时候会随之更新状态,但是我们都知道在处理 useEffect 的时候应该 return 一个清理函数,这样 React 就可以帮助我们移除事件的监听,所以我们就不能直接在 addEventListener 当中使用箭头函数的方式了,而是应该传递同一个函数,这样 React 才能明确是哪一个监听器应该被移除,下面是我们最终版本的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function useNetwork() {
const [isOnline, setNetwork] = useState(window.navigator.onLine)
const updateNetwork = () => {
setNetwork(window.navigator.onLine)
}
useEffect(() => {
window.addEventListener('offline', updateNetwork)
window.addEventListener('online', updateNetwork)
return () => {
window.removeEventListener('offline', updateNetwork)
window.removeEventListener('online', updateNetwork)
}
})
return isOnline
}

如何确保一个对象仅被创建一次

比如一个常见的使用场景就是创建初始 state 需要花费大量的计算的时候,比如下面这个示例

1
2
3
4
5
function Table(props) {
// ⚠️ createRows() 每次渲染都会被调用
const [rows, setRows] = useState(createRows(props.count))
// ...
}

为了避免重新创建被忽略的初始 state,我们可以传一个函数给 useState

1
2
3
4
5
function Table(props) {
// ✅ createRows() 只会被调用一次
const [rows, setRows] = useState(() => createRows(props.count))
// ...
}

这样一来 React 只会在首次渲染时调用这个函数,但是有时候我们也可能想要避免重新创建 useRef() 的初始值,比如下面这个例子

1
2
3
4
5
function Image(props) {
// ⚠️ IntersectionObserver 在每次渲染都会被创建
const ref = useRef(new IntersectionObserver(onIntersect))
// ...
}

但是 useRef 不会像 useState 那样可以接受一个特殊的函数重载,所以针对这种情况,我们可以编写自己的函数来创建并将其设为惰性的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Image(props) {
const ref = useRef(null)

// ✅ IntersectionObserver 只会被惰性创建一次
function getObserver() {
if (ref.current === null) {
ref.current = new IntersectionObserver(onIntersect)
}
return ref.current
}

// 当需要时,调用 getObserver()
// ...
}

这样一来我们就避免了在一个对象被首次真正需要之前就先创建了它

路由传参的几种方式

params

1
2
3
4
5
<Route path='/path/:name' component={Search} />

<link to="/path/2"></Link>

this.props.history.push({pathname: '/path/' + name})

读取参数使用 this.props.match.params.name

query

1
2
3
4
5
<Route path='/query' component={Search} />

<Link to={{pathname: '/query', query: { name: 'zhangsan' }}}>

this.props.history.push({pathname: '/query', query: { name: 'zhangsan' }})

读取参数使用 this.props.location.query.name

state

1
2
3
4
5
<Route path='/sort ' component={Search} />

<Link to={{pathname: '/sort', state: { name: 'zhangsan' }}}>

this.props.history.push({pathname: '/sort', state: { name: 'zhangsan' }})

读取参数使用 this.props.location.query.state

1
2
3
4
5
<Route path='/web/search' component={Search} />

<link to="web/search?id=123"></Link>

this.props.history.push({ pathname: `/web/search?id=${row.id}` })

读取参数使用 this.props.location.search,但是需要注意这种方式在 react-router-dom^4.2.2 下存在一些问题,即传参跳转页面会空白,刷新才会加载出来

优缺点

  1. paramsHashRouterBrowserRouter 路由中刷新页面参数都不会丢失
  2. stateBrowserRouter 中刷新页面参数不会丢失,在 HashRouter 路由中刷新页面会丢失
  3. queryHashRouterBrowserRouter 路由中刷新页面参数都会丢失
  4. querystate 都可以传递对象

require.context()

这一个是 Webpack 当中的 API,但是因为 React 工程是基于 Webpack 打包的,所以在 React 当中也可以使用,它的功能是创建我们自己的 context,该函数接受三个参数,一个要搜索的目录,一个标记表示是否还搜索其子目录,以及一个匹配文件的正则表达式,语法如下

1
require.context(directory, useSubdirectories = true, regExp = /^\.\/.*$/, mode = 'sync')

两个简单的示例

1
2
3
4
5
// 创建一个 context,其中文件来自 test 目录,request 以 `.test.js` 结尾
require.context('./test', false, /\.test\.js$/)

// 创建一个 context,其中所有文件都来自父文件夹及其所有子级文件夹,request 以 `.stories.js` 结尾
require.context('../', true, /\.stories\.js$/)

此外,一个 context module 会导出一个(require)函数,此函数可以接收一个参数 request,另外导出的函数也有三个属性 resolvekeysid

  • resolve 是一个函数,它返回 request 被解析后得到的模块 id
  • keys 也是一个函数,它返回一个数组,由所有可能被此 context module 处理的请求组成

如果我们想引入一个文件夹下面的所有文件,或者引入能匹配一个正则表达式的所有文件,这个功能就会很有帮助

1
2
3
4
5
6
function importAll(r) {
r.keys().forEach(r)
}

importAll(require.context('../components/', true, /\.js$/))
const cache = {}
1
2
3
4
5
6
function importAll(r) {
r.keys().forEach(key => cache[key] = r(key))
}

// 在构建时,所有被 `require` 的模块都会被填充到 `cache` 对象中
importAll(require.context('../components/', true, /\.js$/))

ReactDOM.createPortal()

ReactDOM.createPortal() 提供了一种将子节点渲染到存在于父组件以外的 DOM 节点的方案,也就是说组件的 render() 函数返回的元素会被挂载在它的父级组件上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import React from 'react'
import ReactDOM from 'react-dom'
import { Button } from 'antd'

const modalRoot = document.body

class Modal extends React.Component {
constructor(props) {
super(props)
this.el = document.createElement('div')
this.el.style.width = '200px'
this.el.style.height = '200px'
this.el.style.backgroundColor = 'green'
this.el.style.position = 'absolute'
this.el.style.top = '200px'
this.el.style.left = '400px'
}

componentDidMount() {
modalRoot.appendChild(this.el)
}

componentWillUnmount() {
modalRoot.removeChild(this.el)
}

render() {
return ReactDOM.createPortal(this.props.children, this.el)
}
}

function Child() {
return <div className="modal">这个是通过 ReactDOM.createPortal 创建的内容</div>
}

export default class App extends React.Component {
constructor(props) {
super(props)
this.state = { clicks: 0 }
this.handleClick = this.handleClick.bind(this)
}

handleClick() {
this.setState(prevState => ({
clicks: prevState.clicks + 1
}))
}

render() {
return (
<div>
<Button onClick={this.handleClick}>点击</Button>
<p>点击次数为 {this.state.clicks}</p>
<Modal>
<Child />
</Modal>
</div>
)
}
}

取消请求

React 中如果当前正在发出请求的组件从页面上卸载了,理想情况下这个请求也应该取消掉,那么如何把请求的取消和页面的卸载关联在一起呢?在这种情况下可以考虑利用 useEffect 的清理函数搭配 Ajax 或是 Fetch 的取消请求 API(使用方式可以参考我们之前整理过的 终止请求

所以有了这两个方式以后,我们就可以结合 React 来封装一个 useFetch 的自定义 Hook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export function useFetch = (config, deps) => {
const abortController = new AbortController()
const [loading, setLoading] = useState(false)
const [result, setResult] = useState()

useEffect(() => {
setLoading(true)
fetch({
...config,
signal: abortController.signal
}).then(res => setResult(res))
.finally(_ => setLoading(false))
}, deps)

useEffect(() => {
return () => abortController.abort()
}, [])

return { result, loading }
}

如上,如果在此时路由发生切换或是 Tab 发生切换等场景下,被卸载掉的组件发出的请求也会被中断

深比较依赖

我们通常在使用 useEffect 的时候需要传入所依赖的 Hook,最理想的状况是所有依赖都在真正发生变化的时候才去改变自身的引用地址,但是有些依赖可能在每次渲染都会重新生成一个引用,但是内部的值却没变,这可能会让 useEffect 对于依赖的『浅比较』没法正常工作,也就是我们有时候会遇到的无限循环问题

1
2
3
4
5
6
7
8
9
const getDep = () => {
return {
foo: 'bar',
}
}

useEffect(() => {
// ❌ 无限循环
}, [getDep()])

在上面的示例当中,由于 getDeps 函数返回的对象每次执行都是一个全新的引用,所以会导致触发无限更新的问题,这里有一个比较取巧的解决方式,那就是把依赖转为字符串

1
2
3
4
5
6
7
8
9
10
11
const getDep = () => {
return {
foo: 'bar',
}
}

const dep = JSON.stringify(getDeps())

useEffect(() => {
// ✅
}, [dep])

这样一来对比的就是字符串 { foo: 'bar' } 的值,而不再是对象的引用,所以只有在值真正发生变化时才会触发更新,当然最好还是采用社区提供的解决方案 useDeepCompareEffect,它选用深比较策略,对于对象依赖来说,它会逐个对比 keyvalue,但是在性能上会有所牺牲,useDeepCompareEffect 大致原理如下

1
2
3
4
5
6
7
8
9
10
11
import { isEqual } from 'lodash'

export function useDeepCompareEffect(fn, deps) {
const trigger = useRef(0)
const prevDeps = useRef(deps)
if (!isEqual(prevDeps.current, deps)) {
trigger.current++
}
prevDeps.current = deps
return useEffect(fn, [trigger.current])
}

真正传入 useEffect 用以更新的是 trigger 这个数字值,使用 useRef 来保留上一次传入的依赖,每次都利用 lodashisEqual 方法对本次依赖和旧依赖进行深比较,如果发生变化,则让 trigger 的值增加

另外我们也可以采用 fast-deep-equal 这个库,根据官方的 benchmark 对比,它比 lodash 的效率高七倍左右

# React

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×