盐焗乳鸽还要香锅

V1

2022/12/24阅读:23主题:默认主题

2023年了,useEffect你真的会用嘛?

2023年了~useEffect你真的会用嘛

前言

这篇文章是我根据a-complete-guide-to-useeffect自己总结出来的一些重点~

按照顺序依次如下:

  • React单向数据流的渲染
  • Effect的执行时机
  • 不要对Effect撒谎:依赖数组要怎么设置
  • 使用setStateuseReducer将上报行为和状态更新解耦
  • 函数是否可以作为Effect的依赖呢?

同时我们可以在阅读的时候时刻问自己以下问题:

  • 🤔 如何用useEffect模拟componentDidMount生命周期?
  • 🤔 如何正确地在useEffect里请求数据?[]又是什么?
  • 🤔 我应该把函数当做effect的依赖吗?
  • 🤔 为什么有时候会出现无限重复请求的问题?
  • 🤔 为什么有时候在effect里拿到的是旧的state或prop?

每一次渲染都有它自己的props和state

function Counter({
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>You clicked {count} times</p>
+      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>

  );
}

上述例子中,对于<p>You clicked {count} times</p>中的count,可能react的初学者会认为其是响应式的,也就是会自动监听并且更新渲染。

但实际上,它只是一个普通的变量count,没有任何的data bounding。我们在每一次调用setCount的时候,实际上是React使用新的count值来反复渲染组件。第一次是0,第二次是1,以此类推。。。

除了变量,那么事件处理函数呢?

function Example({
  const [count, setCount] = useState(0);

  function handleAlertClick({
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
      <button onClick={handleAlertClick}>
        Show alert
      </button>
    </div>

  );
}

我们想象一下:

  • 首先连续点击 Click me 两次
  • 再点击一次 Show alert
  • 继续点击 Click me

在点击alert的3秒过后,其显示的值会是什么呢?

答案是2!!,这也证明了我们上述的结论。即事件处理函数跟变量一样,也是独立属于当前的渲染。

每次渲染都有它独立的Effect

我们来看看官网上的例子:

function Counter({
  const [count, setCount] = useState(0);
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>

  );
}

这里根据之前的学习我们知道,并不是「不变的Effect中count的值发现了变化,而是Effect每一次渲染都不相同,每一次Effect都捕获了当前渲染的count

「React会记住你提供的effect函数,并且会在每次更改作用于DOM并让浏览器绘制屏幕后去调用它。」

useEffect

每一次渲染都有它自己的......所有

function Counter({
  const [count, setCount] = useState(0);

  useEffect(() => {
    setTimeout(() => {
      console.log(`You clicked ${count} times`);
    }, 3000);
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>

  );
}

我们来思考一下上述代码,我们给每一次渲染都添加一个延时函数。当我们疯狂点击Click me的时候,会发生什么呢?

答案是:「顺序打印0,1,2,3...」

当然,你可能会想,如果我就是想要获取最新的值要怎么办的?使用useRef

function Example({
  const [count, setCount] = useState(0);
  const latestCount = useRef(count);

  useEffect(() => {
    // Set the mutable latest value
    latestCount.current = count;
    setTimeout(() => {
      // Read the mutable latest value
      console.log(`You clicked ${latestCount.current} times`);
    }, 3000);
  });
  // ...

此时当我们疯狂点击后,最终打印出来的全都是最新的count

Effect中的清理又是怎么样的?

思考官方的案例:

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

假设第一次渲染的时候props是{id: 10},第二次渲染的时候是{id: 20}。你可能会认为发生了下面的这些事:

  • React 清除了 {id: 10} 的effect。
  • React 渲染 {id: 20} 的UI。
  • React 运行 {id: 20} 的effect。

事实上并不是这样的。React只会在浏览器渲染完毕后才会去执行Effect,这并不会阻塞渲染使得你的应用更加流畅。

Effect的清理实际上也被延后了。上一次Effecet的清理会在UI渲染结束后被执行。

  • React 渲染 {id: 20} 的UI。
  • 浏览器绘制 我们在屏幕上看到 {id: 20} 的UI。
  • React 清除 {id: 10} 的effect。
  • React 运行 {id: 20} 的effect。

告诉react如何去比对useEffect

简单来说,你的Effect可能会因为其他状态变化了而造成了不必要的调用。 为了解决这个问题,可以通过依赖数组来告诉React

如果当前渲染中的这些依赖项和上一次运行这个effect的时候值一样,因为没有什么需要同步,React会自动跳过这次effect。

如果依赖项设置错误会怎么样呢?

举个例子,我们来写一个每秒递增的计数器。在Class组件中,我们的直觉是:“开启一次定时器,清除也是一次”

当我们理所当然地把它用useEffect的方式翻译,直觉上我们会设置依赖为[]。因为“我只想运行一次effect“。

function Counter({
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}

在这个例子中,·count的值永远都不会超过1因为定时器只会设置一次,而此时的count永远都是0,即永远都在执行setCount(0+1)

我们必须改变我们的想法。所谓「依赖数组」是我们用来告诉React该Effect用到了什么状态。在这个例子中,我们对React撒谎了,告诉React说没有用到任何组件内的值。但实际上依赖了count

要诚实的告诉React我们正确的依赖的两种办法

1. 将所有Effect用到的依赖都告诉给React

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
+  }, [count]);

现在依赖正确了,但是会有一个问题,就是每一次执行setCount的时候,都会导致计数器的清除和新建。这并不符合我们的初衷。

2. 修改effect内部的代码以确保它包含的值只会在需要的时候发生变更。

我们不想告知错误的依赖 - 我们只是修改effect使得依赖更少。

继续看会上面的示例(将count传入依赖数组)。我们需要思考,我们为什么需要用到count?我们想要通过setCount来更新count的值。

但其实在这个场景,我们并不需要获取count的值,我们只需要获取上一次的状态进行处理即可。因此可以写成

useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(id);
}, []);

函数式更新

对于Effect来说,我们可以用「同步」的思想去理解。

以上面这个例子来说,同步的一个有趣的地方就是将同步信息和状态解耦。类似于setCount(c => c + 1)这样的更新形式比setCount(count + 1)传递了更少的信息,因为它不再被当前的count值“污染”。它只是表达了一种行为(“递增”)。

但是,setCount(c => c + 1)并不完美,例如我们有两个状态互相依赖,即下一次状态不仅仅依赖上一次状态,它就不能做到了。

这时候,useReducer大哥就要出场了。

useReducer

我们对上面例子做一些修改:

function Counter({
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(1);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + step);
    }, 1000);
    return () => clearInterval(id);
  }, [step]);

  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => setStep(Number(e.target.value))} />
    </>

  );
}

现在count的值不仅仅依赖自己上一次的值,还依赖step状态。

相同的,目前我们并没有对React说谎,因此它可以正确运行。但问题在于每一次改变step后,计时器都会被销毁重建。

当你想更新一个状态,并且这个状态更新依赖于另一个状态的值时,你可能需要用useReducer去替换它们。

当你写类似setSomething(something => ...)这种代码的时候,也许就是考虑使用reducer的契机。reducer可以让你把组件内发生了什么(actions)和状态如何响应并更新分开表述。

function Counter({
  const [state, dispatch] = useReducer(reducer, initialState);
  const { count, step } = state;

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type'tick' }); // 更新count
    }, 1000);
    return () => clearInterval(id);
  }, [dispatch]);

  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => {
      // 更新step
        dispatch({
          type: 'step',
          step: Number(e.target.value)
        });
      }} />
    </>

  );
}

function reducer(state, action{
  const { count, step } = state;
  if (action.type === 'tick') {
    return { count: count + step, step };
  } else if (action.type === 'step') {
    return { count, step: action.step };
  } else {
    throw new Error();
  }
}

使用useReducer可以完美解决上述问题。这是为什么呢?

答案在于「React会保证dispatch在组件的声明周期内保持不变」。所以上面例子中不再需要重新订阅定时器。

(你可以从依赖中去除dispatch, setState, 和useRef包裹的值因为React会确保它们是静态的。不过你设置了它们作为依赖也没什么问题。)

与其在Effect中去获取状态,不如只是dispatch一个action来描述行为,这使得Effect与状态解耦,Effect再也不用关心具体的状态了~

使用useRuducer真的是在作弊

当我们的step是props传进来的又会怎样呢?我们还是可以使用useReducer,但这种情况下我们的reducer函数就得写进组件中,因为要获取props。

function Counter({ step }{
  const [count, dispatch] = useReducer(reducer, 0);

  function reducer(state, action{
    if (action.type === 'tick') {
      return state + step;
    } else {
      throw new Error();
    }
  }

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type'tick' });
    }, 1000);
    return () => clearInterval(id);
  }, [dispatch]);

  return <h1>{count}</h1>;
}

有的小伙伴可能会问:上一次渲染的reducer怎么能获取到最新的props呢?

答案是:Effect只是dispatch了行为 - 它会在下一次渲染中去调用reducer,这是就可以获取到最新的props了。注意:「reducer不是在Effect中调用的」

永远要记住useEffect的执行时机

useEffect的执行时机在Dom渲染完毕后。而且它是异步执行的,不会阻塞的。因此在下面例子中,会出现页面闪动的效果。(0 -> 12 -> 2)


export default function FuncCom ({
    const [counter, setCounter] = useState(0);

    useEffect(() => {
        if (counter === 12) {
            // 为了演示,这里同步设置一个延时函数 500ms
            delay()
            setCounter(2)
        }
    });
    return (
        <div style={{
            fontSize: '100px'
        }}>

            <div onClick={() => setCounter(12)}>{counter}</div>
        </div>

    )
}

换成了 useLayoutEffect 后,屏幕上只会出现 0 和 2,这是因为 useLayoutEffect同步特性,会在浏览器渲染之前同步更新react DOM 数据,哪怕是多次的操作,也会在渲染前一次性处理完,再交给浏览器绘制。这样不会导致闪屏现象发生。

将函数放进Effect中

首先抛出问题:函数真的不应该成为依赖项嘛?

我们来看一个许多程序员都会写的一个案例:

function SearchResults({
  const [data, setData] = useState({ hits: [] });

  async function fetchData({
    const result = await axios(
      'https://hn.algolia.com/api/v1/search?query=react',
    );
    setData(result.data);
  }

  useEffect(() => {
    fetchData();
  }, []); // Is this okay?

  // ...
}

该代码是可以正常运行的,但是它的拓展性非常差。试想一下,在项目代码不断增长下,我们可能会分离出很多个很长的函数,可能会有其他的依赖:

function SearchResults({
  const [query, setQuery] = useState('react');

  // Imagine this function is also long
  function getFetchUrl({
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }

  // Imagine this function is also long
  async function fetchData({
    const result = await axios(getFetchUrl());
    setData(result.data);
  }

  useEffect(() => {
    fetchData();
  }, []);

  // ...
}

如果我们忘记更新Effect的依赖数组的话,这不仅仅欺骗了React,而且导致Effect不会同步所依赖的state和props。

一个最简单的解决方案是,如果某些函数仅在effect中调用,你可以把它们的定义移到effect中:。这样的话程序员不需要考虑这些“间接依赖”,而且在添加query状态的时候,意识到Effect依赖于它。

function SearchResults({
  const [query, setQuery] = useState('react');

  useEffect(() => {
  // ❇️ get together
    function getFetchUrl({
      return 'https://hn.algolia.com/api/v1/search?query=' + query;
    }

    async function fetchData({
      const result = await axios(getFetchUrl());
      setData(result.data);
    }

    fetchData();
  }, [query]); // ✅ Deps are OK

  // ...
}

我们必须意识到:·useEffect设计意图就是要强迫你去关注数据流的变化,然后决定怎么去让Effect同步!!

但我不能将函数放进Effect中

举个例子,比如某函数被多个Effect使用到了。

首先我们要明确一点,在组件中定义的函数每一次渲染都是变化的,但这个事实也给我们带来了问题。比如:

function SearchResults({
  function getFetchUrl(query{
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }

  useEffect(() => {
    const url = getFetchUrl('react');
    // ... Fetch data and do something ...
  }, []); // 🔴 Missing dep: getFetchUrl

  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... Fetch data and do something ...
  }, []); // 🔴 Missing dep: getFetchUrl

  // ...
}

一方面我们不想在每一个Effect中都复制相同的代码,但这又是对React的不诚实~,另一方面假设我们将getFetchUrl当作依赖项,但它每一次渲染都在变,我们的依赖数组就没有发挥用处了。

这里有两个解决办法:

  1. 如果函数并没有用到组件内的值,我们可以大胆地将其移到组件外部!

  2. 使用useCallBackhook包裹。

useCallBack本质上只是加了一层依赖检查,使得函数本身只有在需要时才改变,这样我们也不需要去掉函数依赖。

当函数用到了组件内的值,比如query可以通过输入框由用户去改变,我们可以这样做:

function SearchResults({
  const [query, setQuery] = useState('');
  const getFetchUrl = useCallback(() => {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }, [query]); // ✅ Deps are OK

  useEffect(() => {
    const url = getFetchUrl();
    // ... Fetch data and do something ...
  }, [getFetchUrl]); // ✅ Deps are OK

  useEffect(() => {
    const url = getFetchUrl();
    // ... Fetch data and do something ...
  }, [getFetchUrl]); // ✅ Deps are OK

  // ...
}

这才是拥抱React数据流和同步思维的最终结果。query改变 -> getFetchUrl改变 -> Effect重新执行。反之,如果query不变,则Effect将不会重新执行

函数是数据流的一部分吗?

有趣的是,这种模式在class组件中是行不通的。

class Parent extends Component {
  state = {
    query'react'
  };
  fetchData = () => {
    const url = 'https://hn.algolia.com/api/v1/search?query=' + this.state.query;
    // ... Fetch data and do something ...
  };
  render() {
    return <Child fetchData={this.fetchData} />;
  }
}

class Child extends Component {
  state = {
    datanull
  };
  componentDidMount() {
    this.props.fetchData();
  }
  componentDidUpdate(prevProps) {
    // 🔴 This condition will never be true
    if (this.props.fetchData !== prevProps.fetchData) {
      this.props.fetchData();
    }
  }
  render() {
    // ...
  }
}

在class组件中,类方法this.fetchData是永远不会变的

为了解决这个问题,我们必须将query参数传给Child组件即使它并没有直接使用到query

class Child extends Component {
  componentDidUpdate(prevProps) {
    // 🔴 This condition will never be true
    if (this.props.query !== prevProps.query) {
      this.props.fetchData();
    }
}

在class组件中,函数属性本身并不是数据流的一部分。组件的方法中包含了可变的this变量导致我们不能确定无疑地认为它是不变的。因此,即使我们只需要一个函数,我们也必须把一堆数据传递下去仅仅是为了做“diff”。我们无法知道传入的this.props.fetchData 是否依赖状态,并且不知道它依赖的状态是否改变了。

但是感谢useCallback,它让函数本身也参与进了React数据流,我们可以说函数的输入变了,那么函数本身就变了。(函数的输入需要我们来定义)

🌟需要强调的是:当我们需要将函数传递给子组件并且在子组件的Effect中运用的话,最好使用useCallback将其包裹。

关于竞态

下面是一个class组件发请求的案例

class Article extends Component {
  state = {
    articlenull
  };
  componentDidMount() {
    this.fetchData(this.props.id);
  }
  async fetchData(id) {
    const article = await API.fetchArticle(id);
    this.setState({ article });
  }
  // ...
}

心细的小伙伴可能发现了,上面代码并没有考虑到id更新的情况,于是:

 componentDidUpdate(prevProps) {
    if (prevProps.id !== this.props.id) {
      this.fetchData(this.props.id);
    }
  }

但是这真的可以完美解决所有问题嘛?

我们可以试想一下,比如我先请求 {id: 10},然后更新到{id: 20},但{id: 20}的请求更先返回。请求更早但返回更晚的情况会错误地覆盖状态值。这就是「竞态」

最好的权宜之计就是利用一个boolean来去记录当前请求状态。

更多相关知识可以看这篇文章

结尾

我是「盐焗乳鸽还要香锅」,喜欢我的文章欢迎关注噢

  • github 链接https://github.com/1360151219
  • 博客链接是 strk2.cn
  • 掘金账号、知乎账号、简书《盐焗乳鸽还要香锅》
  • 思否账号《天天摸鱼真的爽》

分类:

前端

标签:

React.js

作者介绍

盐焗乳鸽还要香锅
V1