精读useEffect

40 阅读6分钟

每一次渲染都有它自己的 Props and State

看一个简单的计数器组件Counter

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

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

仔细查看例子中的count,count只是一个数字而已,也不是“data binding”,“watcher”,只是一个单纯的count数字。

我们的组件第一次渲染的时候,从useState()拿到count的初始值0。当我们调用setCount(1),React会再次渲染组件,这一次count是1。

在每一次的点击button,React会重新渲染组件,每一次渲染都可以拿到独立的count状态,这个状态值是函数中的一个常量。

每一次渲染都有它自己的事件处理函数

function Counter() {
  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>
  );
}

demo测试

当我们在点击Click me三次后,再去点击Show alert,再点击Click me 2次,alert会捕捉点击按钮时候的count的状态信息,所以alert会弹出‘You clicked on: 3’,而不是5。

但是在类组件中会产生不同的结果详情查看:overreacted.io/zh-hans/how…

函数式组件和类组件的区别

function ProfilePage(props) {
  const showMessage = () => {
    alert('Followed ' + props.user);
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return (
    <button onClick={handleClick}>Follow</button>
  );
}
class ProfilePage extends React.Component {
  showMessage = () => {
    alert('Followed ' + this.props.user);
  };

  handleClick = () => {
    setTimeout(this.showMessage, 3000);
  };

  render() {
    return <button onClick={this.handleClick}>Follow</button>;
  }
}
  1. 点击 其中某一个 Follow 按钮。
  2. 在3秒内 切换 选中的账号。
  3. 查看 弹出的文本。

你将看到一个奇特的区别:

  • 当使用 函数式组件 实现的 ProfilePage, 当前账号是 Dan 时点击 Follow 按钮,然后立马切换当前账号到 Sophie,弹出的文本将依旧是 'Followed Dan'。
  • 当使用 类组件 实现的 ProfilePage, 弹出的文本将是 'Followed Sophie':

但是为什么会有这样的表现呢

class ProfilePage extends React.Component {
  showMessage = () => {
    alert('Followed ' + this.props.user);
  };

这个类方法从 this.props.user 中读取数据。在 React 中 Props 是不可变(immutable)的,所以他们永远不会改变。然而, this 是,而且永远是,可变(mutable)的。

事实上,这就是类组件 this 存在的意义。React本身会随着时间的推移而改变,以便你可以在渲染方法以及生命周期方法中得到最新的实例。

所以如果在请求已经发出的情况下我们的组件进行了重新渲染,this.props将会改变。showMessage方法从一个“更新的”的props中得到了user

所以this不是可靠的,props是不可变的,可以使用删除类来编写。

function ProfilePage(props) {
  const showMessage = () => {
    alert('Followed ' + props.user);
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return (
    <button onClick={handleClick}>Follow</button>
  );
}

在任意一次渲染中,props和state是始终保持不变的。 如果props和state在不同的渲染中是相互独立的,那么使用到它们的任何值也是独立的(包括事件处理函数)。它们都“属于”一次特定的渲染。即便是事件处理中的异步函数调用“看到”的也是这次渲染中的count值。

如何能拿到最新的状态和值就需要使用useRef

function MessageThread() {
  const [message, setMessage] = useState('');

  // 保持追踪最新的值。
  const latestMessage = useRef('');
  useEffect(() => {
    latestMessage.current = message;
  });

  const showMessage = () => {
    alert('You said: ' + latestMessage.current);
  };

demo测试

每次渲染都有它自己的Effects

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>
  );
}

我们已经知道count是某个特定渲染中的常量。事件处理函数“看到”的是属于它那次特定渲染中的count状态值。对于effects也同样如此:

并不是 count 的值在“不变”的effect中发生了改变,而是 effect 函数本身 在每一次渲染中都不相同。

每一个effect版本“看到”的count值都来自于它属于的那次渲染:

为了确保我们已经有了扎实的理解,我们再回顾一下第一次的渲染过程:

  • React: 给我状态为 0时候的UI。
  • 你的组件:
    • 给你需要渲染的内容:

      You clicked 0 times

    • 记得在渲染完了之后调用这个effect: () => { document.title = 'You clicked 0 times' }。
  • React: 没问题。开始更新UI,喂浏览器,我要给DOM添加一些东西。
  • 浏览器: 酷,我已经把它绘制到屏幕上了。
  • React: 好的, 我现在开始运行给我的effect
    • 运行 () => { document.title = 'You clicked 0 times' }。

现在我们回顾一下我们点击之后发生了什么:

  • 你的组件: 喂 React, 把我的状态设置为1。
  • React: 给我状态为 1时候的UI。
  • 你的组件:
    • 给你需要渲染的内容:

      You clicked 1 times

    • 记得在渲染完了之后调用这个effect: () => { document.title = 'You clicked 1 times' }。
  • React: 没问题。开始更新UI,喂浏览器,我修改了DOM。
  • Browser: 酷,我已经将更改绘制到屏幕上了。
  • React: 好的, 我现在开始运行属于这次渲染的effect
    • 运行 () => { document.title = 'You clicked 1 times' }。
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>
  );
}
import React, {Component} from "react";
import ReactDOM from "react-dom";

class Example extends Component {
  state = {
    count: 0
  };
  componentDidMount() {
    setTimeout(() => {
      console.log(`You clicked ${this.state.count} times`);
    }, 3000);
  }
  componentDidUpdate() {
    setTimeout(() => {
      console.log(`You clicked ${this.state.count} times`);
    }, 3000);
  }
  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={() => this.setState({
          count: this.state.count + 1
        })}>
          Click me
        </button>
      </div>
    )
  }
}

const rootElement = document.getElementById("root");
ReactDOM.render(<Example />, rootElement);

函数式组件点击效果:

类组件点击效果:

因为在每一次渲染中拿到的都是每一次的props和state,而在类组件中this.state.count总是指向最新的count值,也就是通过ref自动更改了state.count的值

function MessageThread() {
  const [message, setMessage] = useState('');

  // 保持追踪最新的值。
  const latestMessage = useRef('');
  useEffect(() => {
    latestMessage.current = message;
  });

  const showMessage = () => {
    alert('You said: ' + latestMessage.current);
  };

如果设置了错误的依赖会怎么样

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

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

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

在第一次渲染中,count是0。因此,setCount(count + 1)在第一次渲染中等价于setCount(0 + 1)。既然我们设置了 [] 依赖,effect不会再重新运行,它后面每一秒都会调用 setCount(0 + 1)

修改方式:

第一种策略是在依赖中包含所有effect中用到的组件内的值。 让我们在依赖中包含count:

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

这能解决问题但是我们的定时器会在每一次count改变后清除和重新设定。这应该不是我们想要的结果:

第二种策略是在effect中不需要使用count,当想要更新状态的时候使用seState中函数形式

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

把函数移到Effects里

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

  useEffect(() => {
    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

  // ...
}

通过query更新来重新执行useEffect,但是如果组件中有多个effect使用了相同的函数,就需要把函数放到effects外部中。

有两种解决方案:

第一个, 如果一个函数没有使用组件内的任何值,你应该把它提到组件外面去定义,然后就可以自由地在effects中使用:

// ✅ Not affected by the data flow
function getFetchUrl(query) {
  return 'https://hn.algolia.com/api/v1/search?query=' + query;
}

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

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

  // ...
}

第二个, 将函数包装成useCallback

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

  // ✅ Preserves identity until query changes
  const getFetchUrl = useCallback(() => {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }, [query]);  // ✅ Callback deps are OK

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

  // ...
}

我们要感谢useCallback,因为如果query 保持不变,getFetchUrl也会保持不变,我们的effect也不会重新运行。但是如果query修改了,getFetchUrl也会随之改变,因此会重新请求数据。这就像你在Excel里修改了一个单元格的值,另一个使用它的单元格会自动重新计算一样。

通过useCallback还可以进行父子组件之间的传递函数

function Parent() {
  const [query, setQuery] = useState("react");

  // ✅ Preserves identity until query changes
  const fetchData = useCallback(() => {
    const url = "https://hn.algolia.com/api/v1/search?query=" + query;
    // ... Fetch data and return it ...
  }, [query]); // ✅ Callback deps are OK

  return <Child fetchData={fetchData} />;
}

function Child({ fetchData }) {
  let [data, setData] = useState(null);

  useEffect(() => {
    fetchData().then(setData);
  }, [fetchData]); // ✅ Effect deps are OK

  // ...
}

但是这种模式在class中是行不通的

在 Class Component 的代码里,如果希望参数变化就重新取数,你不能直接比对取数函数的 Diff:

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

要对比的是取数参数是否发生变化

componentDidUpdate(prevProps) {
  if (this.props.query !== prevProps.query) {
    this.props.fetchData();
  }
}
  1. 在组件内部,那些会成为其他useEffect依赖项的方法,建议用 useCallback 包裹,或者直接编写在引用它的useEffect中。
  2. 己所不欲勿施于人,如果你的function会作为props传递给子组件,请一定要使用 useCallback 包裹,对于子组件来说,如果每次render都会导致你传递的函数发生变化,可能会对它造成非常大的困扰。同时也不利于react做渲染优化。