Effect

PPG007 ... 2023-10-19 About 4 min

# Effect

函数式组件不能使用类组件中的生命周期,但是生命周期的效果可以通过 Effect 实现(但是 Effect 不是生命周期),Effect 的定义就是“渲染引起的副作用”。

# 编写 Effect

编写 Effect 的步骤:

  • 声明 Effect。默认情况下,Effect 会在每次渲染后都执行。
  • 指定 Effect 依赖。大多数 Effect 应该按需执行,而不是每次渲染后都被执行。
  • 必要时添加清理函数。有时 Effect 需要指定如何停止、撤销或者清除它的效果。

# 声明 Effect

function MyVideo(props) {
  const ref = React.useRef(null);
  if (props.isPlaying) {
    ref.current.play();
  } else {
    ref.current.pause();
  }
    return (
      <>
        <video src={props.src} ref={ref}/>
      </>
    )
}
function Player(props) {
  const [isPlaying, setIsPlaying] = React.useState(false);
  return (
    <div>
    <MyVideo isPlaying={isPlaying} src={props.src}/>
    <br/>
    <button onClick={() => {setIsPlaying(!isPlaying)}}>{isPlaying ? 'pause' : 'play'}</button>
    </div>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

在上面的代码中,定义了两个组件,现在希望通过点击按钮来控制 video 的播放和暂停,但是不能直接在函数中通过 ref 操作 video 标签的 play()pause() 方法,因为渲染过程中不能操作 DOM,而且在第一次渲染时还没有 DOM。

可以使用 Effect 将控制 video 的方法分离到渲染逻辑之外,让 React 先渲染好界面再调用 Effect 的内容:

function MyVideo(props) {
  const ref = React.useRef(null);
  React.useEffect(() => {
    if (props.isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  })
    return (
      <>
        <video src={props.src} ref={ref} style={{height: '900px', width: '500px'}}/>
      </>
    )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

当这个组件被渲染时,React 会首先刷新屏幕,确保元素出现在了 DOM 中,然后 React 执行 Effect,Effect 中根据 isPlaying 的值进行操作。

Note

由于渲染会触发 Effect,所以 Effect 中再调用 setState 会导致死循环。

# 指定 Effect 依赖

一般情况下,Effect 将在每次渲染时都被调用,但是很多时候并不需要执行。例如上面的组件中,如果给 Player 增加一个 input 并绑定一个 state,那么每次修改这个 input 的内容都会导致 effect 被执行。

function Player(props) {
  const [isPlaying, setIsPlaying] = React.useState(false);
  const [value, setValue] = React.useState('');
  return (
    <div>
      <MyVideo isPlaying={isPlaying} src={props.src}/>
      <br/>
      <button onClick={() => {setIsPlaying(!isPlaying)}}>{isPlaying ? 'pause' : 'play'}</button>
      <br/>
      <input value={value} onChange={(event) => {setValue(event.target.value)}}/>
    </div>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13

为了解决这个问题,useEffect 接收第二个参数,这个参数是一个数组,内容是在 Effect 中依赖的变量,每次渲染时进行判断,如果这个数组内的变量的值没有发生改变,那么将不会执行 Effect:

function MyVideo(props) {
  const ref = React.useRef(null);
  React.useEffect(() => {
    console.log('effect')
    if (props.isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  }, [props.isPlaying])
    return (
      <>
        <video src={props.src} ref={ref} style={{height: '900px', width: '500px'}}/>
      </>
    )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

Tips

React 是通过 Object.is() 方法进行比较的。

Note

不传依赖数组和传递空依赖数组是不一样的:

useEffect(() => {
  // 这里的代码会在每次渲染后执行
});

useEffect(() => {
  // 这里的代码只会在组件挂载后执行
}, []);
1
2
3
4
5
6
7

# 添加清理函数

在一些场景中,例如在线聊天,需要在组件挂载时连接服务器并在卸载时断开连接。Effect 可以返回一个函数,每次重新执行 Effect 之前 React 都会调用清理函数,组件被卸载时也会调用清理函数。

React.useEffect(() => {
  console.log('effect')
  return () => {
    console.log('GG')
  }
})
1
2
3
4
5
6

关于如何限制清理函数只在组件卸载时被调用见后文。

# 从 Effect 中读取 props 和 state 但是不响应

假设 Effect 中需要上报一个客户事件,这个事件需要上报一些事件属性,其中有的事件属性是 props 或者 state 中的内容,因此 Effect 的依赖项里必须要声明这些字段,但是事件是否上报可能只取决于其中一个字段,也就是说需要只对部分字段做出响应,可以通过 useEffectEvent 来声明 Effect 事件。

TODO: 非正式特性。

# 处理开发环境两次调用 Effect 的问题

在开发环境中,React 重复挂载组件会调用两次 Effect,Effect 中可能会有不能连续调用两次的 API,例如 modal 标签的 showModal 方法,这种情况下,可以通过返回一个清理函数解决,这个清理函数将状态重置回调用前从而允许下次的调用:

class MockAPI {
  isClosed = true
  call() {
    if (this.isClosed) {
      this.isClosed = false;
      return
    } else {
      throw new Error('error status');
    }
  }
  close() {
    this.isClosed = true;
  }
}
const api = new MockAPI();
function MyVideo(props) {
  const ref = React.useRef(null);
  React.useEffect(() => {
    console.log('effect')
    if (props.isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
    api.call();
    return () => api.close()
  }, [props.isPlaying])
    return (
      <>
        <video src={props.src} ref={ref} style={{height: '900px', width: '500px'}}/>
      </>
    )
}
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

Tips

与上面的例子类似,为了解决开发环境中 Effect 执行两次的问题,应当遵循下面的规范:

  • 如果 Effect 中订阅了事件(例如 addEventListener),那么清理函数中应该退订事件。
  • 如果 Effect 中获取了数据,那么清理函数要么终止上次的获取操作,要么忽略第二次的结果。终止操作可以通过第三方库 api 实现(如 axios);忽略结果可以定义一个局部变量,在清理函数中修改此变量的值,并在发出请求前确认此变量。
  • …………

总之,Effect 中的逻辑需要考虑重复调用产生的影响和处理。

Last update: October 19, 2023 07:43
Contributors: Koston Zhuang