Skip to content

ReactHook—useEffect、useLayoutEffect #70

@LightXJ

Description

@LightXJ

基本介绍

你之前可能已经在 React 组件中执行过数据获取、订阅或者手动修改过 DOM。我们统一把这些操作称为“副作用”,或者简称为“作用”。副作用是相对纯函数的概念来说的。一个纯函数的执行过程完全由输出参数决定,如果一个函数的执行受了参数之外的数据的影响,例如使用外界的一个变量加入执行过程中,或者与外界发生了可观察的交互,如发起网络请求、监听用户输入、修改了参数的值,打印log等,我们就称之为发生了副作用。副作用会带来很多意想不到的结果,所以在函数式编程中要极力避免。很多是否副作用又是必不可少的,最为典型的就是网络请求。所以为了尽可能地减少副作用带来的影响,最好把他们找个地方统一管理,useEffect就是拿来干这个的。

例如
在函数式组件中增加副作用,修改网页的标题

 useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

可以把 useEffect Hook 看做 componentDidMount,componentDidUpdate 和 componentWillUnmount 这三个函数的组合。
也就是我们完全可以通过在函数组件里面用useEffect来替代这三个生命钩子函数。

用法

用法

  1. useEffect 接收两个参数,分别是要执行的回调函数、依赖数组。
  2. 如果依赖数组为空数组,那么回调函数会在第一次渲染结束后(componentDidMount)执行,返回的函数会在组件卸载时(componentWillUnmount)执行。
  3. 如果不传依赖数组,那么回调函数会在每一次渲染结束后(componentDidMount 和 componentDidUpdate)执行。
  4. 如果依赖数组不为空数组,那么回调函数会在依赖值每次更新渲染结束后(componentDidUpdate)执行,这个依赖值一般是 state 或者 props。

作用

useEffect 比较重要,它主要有这几个作用:

  1. 代替部分生命周期,如 componentDidMount、componentDidUpdate、componentWillUnmount。
  2. 更加 reactive,类似 mobx 的 reaction 和 vue 的 watch。
  3. 从命令式变成声明式,不需要再关注应该在哪一步做某些操作,只需要关注依赖数据。
  4. 通过 useEffect 和 useState 可以编写一系列自定义的 Hook。

清除副作用

有时候对于一些副作用,我们是需要去清除的,比如我们有个需求需要轮询向服务器请求最新状态,那么我们就需要在卸载的时候,清理掉轮询的操作。

 componentDidMount() {
    this.pollingNewStatus()
  }

  componentWillUnmount() {
    this.unPollingNewStatus()
  }

你会注意到 componentDidMount 和 componentWillUnmount 之间相互对应。使用生命周期函数迫使我们拆分这些逻辑代码,即使这两部分代码都作用于相同的副作用。

我们可以使用Effect来清除这些副作用,只需要在Effect中返回一个函数即可

  useEffect(() => {
    pollingNewStatus()
    //告诉React在每次渲染之前都先执行cleanup()
    return function cleanup() {
      unPollingNewStatus()
    };
  });

有个明显的区别在于useEffect其实是每次渲染之前都会去执行cleanup(),而componentWillUnmount只会执行一次。

使用多个 Effect 实现关注点分离

使用 Hook 其中一个目的就是要解决 class 中生命周期函数经常包含不相关的逻辑,但又把相关逻辑分离到了几个不同方法中的问题。下述代码是将前述示例中的计数器和好友在线状态指示器逻辑组合在一起的组件:

class FriendStatusWithCounter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0, isOnline: null };
    this.handleStatusChange = this.handleStatusChange.bind(this);
  }


  componentDidMount() {
    document.title = `You clicked ${this.state.count} times`;
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }


  componentDidUpdate() {
    document.title = `You clicked ${this.state.count} times`;
  }


  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }


  handleStatusChange(status) {
    this.setState({
      isOnline: status.isOnline
    });
  }
  // ...

可以发现设置 document.title 的逻辑是如何被分割到 componentDidMount 和 componentDidUpdate 中的,订阅逻辑又是如何被分割到 componentDidMount 和 componentWillUnmount 中的。而且 componentDidMount 中同时包含了两个不同功能的代码。

那么 Hook 如何解决这个问题呢?就像你可以使用多个 state 的 Hook 一样,你也可以使用多个 effect。这会将不相关逻辑分离到不同的 effect 中:

function FriendStatusWithCounter(props) {
  const [count, setCount] = useState(0);
  useEffect(() => {    
    document.title = `You clicked ${count} times`;
  });


  const [isOnline, setIsOnline] = useState(null);
  useEffect(() => {    
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }


    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });
  // ...
}

Hook 允许我们按照代码的用途分离他们, 而不是像生命周期函数那样。React 将按照 effect 声明的顺序依次调用组件中的每一个 effect。

通过跳过 Effect 进行性能优化

useEffect其实是每次更新都会执行,在某些情况下会导致性能问题。那么我们可以通过跳过 Effect 进行性能优化。在class组件中,我们可以通过在 componentDidUpdate 中添加对 prevProps 或 prevState 的比较逻辑解决。

componentDidUpdate(prevProps, prevState) {
  if (prevState.count !== this.state.count) {
    document.title = `You clicked ${this.state.count} times`;
  }
}

在Effect中,我们可以通过增加Effect的第二个参数即可,如果没有变化,则跳过更新

useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]); // 仅在 count 更改时更新

上面这个示例中,我们传入 [count] 作为第二个参数。这个参数是什么作用呢?如果 count 的值是 5,而且我们的组件重渲染的时候 count 还是等于 5,React 将对前一次渲染的 [5] 和后一次渲染的 [5] 进行比较。因为数组中的所有元素都是相等的(5 === 5),React 会跳过这个 effect,这就实现了性能的优化。

当渲染时,如果 count 的值更新成了 6,React 将会把前一次渲染时的数组 [5] 和这次渲染的数组 [6] 中的元素进行对比。这次因为 5 !== 6,React 就会再次调用 effect。如果数组中有多个元素,即使只有一个元素发生变化,React 也会执行 effect。

对于有清除操作的 effect 同样适用:

useEffect(() => {
  function handleStatusChange(status) {
    setIsOnline(status.isOnline);
  }


  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  };
}, [props.friend.id]); // 仅在 props.friend.id 发生变化时,重新订阅

注意:
如果你要使用此优化方式,请确保数组中包含了所有外部作用域中会随时间变化并且在 effect 中使用的变量,否则你的代码会引用到先前渲染中的旧变量。参阅文档,了解更多关于如何处理函数以及数组频繁变化时的措施内容。
如果想执行只运行一次的 effect(仅在组件挂载和卸载时执行),可以传递一个空数组([])作为第二个参数。这就告诉 React 你的 effect 不依赖于 props 或 state 中的任何值,所以它永远都不需要重复执行。这并不属于特殊情况 —— 它依然遵循依赖数组的工作方式。
如果你传入了一个空数组([]),effect 内部的 props 和 state 就会一直拥有其初始值。尽管传入 [] 作为第二个参数更接近大家更熟悉的 componentDidMount 和 componentWillUnmount 思维模式,但我们有更好的方式来避免过于频繁的重复调用 effect。除此之外,请记得 React 会等待浏览器完成画面渲染之后才会延迟调用 useEffect,因此会使得额外操作很方便。
我们推荐启用 eslint-plugin-react-hooks 中的 exhaustive-deps 规则。此规则会在添加错误依赖时发出警告并给出修复建议

正确使用,防止死循环

我们知道每次state或者props改变都会导致组件的re-render,所以useEffect在没有任何依赖项时每次都会执行一遍。这时如果在它当中改变了state,那么就会导致死循环。过程就是执行useEffect改变了state,而改变state又导致了重复执行useEffect。
错误的写法:

const [count, updateCount] = useState(0);

useEffect(() => {
  updateCount(prevCount => prevCount++);   
})

正确的写法一:

const [count, updateCount] = useState(0);

// 增加前置条件,满足时才执行更新状态
useEffect(() => {
  if (count < 1) {
    updateCount(prevCount => prevCount++);  
  }
})

正确的写法二:

const [count, updateCount] = useState(0);
const [num, updateNum] = useState(1)

// 依赖num,每次num改变后才会执行Effect
useEffect(() => {
  updateCount(prevCount => prevCount+num);  
}, [num])

依赖函数

除了使用state,props作为依赖项,函数也是可以直接作为依赖项使用的。不过由于函数每次在渲染时都会重新执行,所以Effect会没必要的重复执行。
不过可以使用useCallback方法避免这种情况
每次渲染都重复执行Effect

const doSomething = () => {}

useEffect(() => {
  // do somethings 
}, [doSomething])

使用useCallback

const doSomething = () => useCallback(() => {}, [])

useEffect(() => {
  // do somethings once
}, [doSomething])

如果函数依赖任何其他的状态执行,则可以将依赖加入到useCallback的依赖数组项中,这样在依赖项改变后函数都会重新执行,Effect由于依赖了函数,所以Effect也会执行。

useEffect vs useLayoutEffect

useLayoutEffect 也是一个 Hook 方法,从名字上看和 useEffect 差不多,他俩用法也比较像。在90%的场景下我们都会用 useEffect,然而在某些场景下却不得不用 useLayoutEffect。useEffect 和 useLayoutEffect 的区别是:

  1. useEffect 不会 block 浏览器渲染,而 useLayoutEffect 会。
  2. useEffect 会在浏览器渲染结束后执行,useLayoutEffect 则是在 DOM 更新完成后,浏览器绘制之前执行。
    这两句话该怎么来理解呢?我们以一个移动的方块为例子:
function App() {
  const ref = createRef(null);
  const style = {
    width: '100px',
    height: '100px',
    background: 'yellow',
    position: 'relative',
    left: 0,
  };

  useEffect(() => {
    ref.current.style.left = '600px';
    ref.current.style.transition = 'left 2s';
  }, []);

  return (
    <div style={style} ref={ref}>方块</div>
  );
}

使用useEffect效果:可以看到方块从左侧经过2s向右移动到600px,能看到移动过程
使用useLayoutEffect效果:方块直接出现在600px处,看不到移动过程

原因是
useEffect 是在浏览器绘制之后执行的,所以方块一开始就在最左边,于是我们看到了方块移动的动画。然而 useLayoutEffect 是在绘制之前执行的,会阻塞页面的绘制,所以页面会在 useLayoutEffect 里面的代码执行结束后才去继续绘制,于是方块就直接出现在了右边。那么这里的代码是怎么实现的呢?以 preact 为例,useEffect 在 options.commit 阶段执行,而 useLayoutEffect 在 options.diffed 阶段执行。然而在实现 useEffect 的时候使用了 requestAnimationFrame,requestAnimationFrame 可以控制 useEffect 里面的函数在浏览器重绘结束,下次绘制之前执行。

useEffect实现原理

let lastDependencies;
function useEffect(callback,dependencies){
  if(lastDependencies){
    //看看新的依赖数组是不是每一项都跟老的依赖数组中的每一项都相同
    let changed = !dependencies.every((item,index)=>{
      return item == lastDependencies[index];
    });
    if(changed){
      **setTimeout(callback);**
      lastDependencies = dependencies;
    }
  }else{//没有渲染过
    **setTimeout(callback);**
    lastDependencies = dependencies;
  }
}

useLayoutEffect实现原理

let lastLayoutDependencies;
function useLayoutEffect(callback,dependencies){
  if(lastLayoutDependencies){
    //看看新的依赖数组是不是每一项都跟老的依赖数组中的每一项都相同
    let changed = !dependencies.every((item,index)=>{
      return item == lastLayoutDependencies[index];
    });
    if(changed){
      //Promise.resolve().then(callback);
      queueMicrotask(callback);//把callback放到微任务队列中
      lastLayoutDependencies = dependencies;
    }
  }else{//没有渲染过
    //Promise.resolve().then(callback);
    queueMicrotask(callback);//把callback放到微任务队列中
    lastLayoutDependencies = dependencies;
  }
}

其中queueMicrotask来执行微任务,微任务会在页面绘制前执行

以上提到的useEffect和useLayoutEffect的运行时机涉及到了浏览器渲染和js引擎的执行过程,我们这里也顺便再复习一下

浏览器进程

进程:浏览器一个页面就是新的一个进程,进程是CPU资源分配的最小单位(系统会给它分配内存);

Browser进程 (http通信)
第三方插件进程
GPU进程(加速,3D渲染,一次)
Renderer进程(新开页面渲染进程)

Render进程(浏览器渲染进程)

线程:线程包含在每个进程内,线程是CPU调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程);

  • GUI 渲染线程
  • JavaScript引擎线程
  • 定时触发器线程(宏任务(异步任务))
  • 事件触发线程(宏任务(异步任务))
  • 异步http请求线程(宏任务(异步任务))

1. GUI 渲染线程

    1. 解析HTML生成DOM树 - 渲染引擎首先解析HTML文档,生成DOM树
    1. 构建Render树 - 接下来不管是内联式,外联式还是嵌入式引入的CSS样式会被解析生成CSSOM树,根据DOM树与CSSOM树生成另外一棵用于渲染的树-渲染树(Render tree),
    1. 布局Render树 - 然后对渲染树的每个节点进行布局处理,确定其在屏幕上的显示位置
    1. 绘制Render树 - 最后遍历渲染树并用UI后端层将每一个节点绘制出来

2. JavaScript引擎线程(主线程执行栈)
永远只有JS引擎(JS内核)线程在执行JS脚本程序,
负责解析执行Javascript脚本程序的主线程(例如V8引擎)
js引擎执行顺序
宏任务(同步任务)直接执行,其他线程先进入任务队列等待执行
然后任务队列中先执行微任务(只有异步任务)
再执行宏任务(异步任务)(如果有任务内还包含宏任务(同步任务),继续依此执行1)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions