React useEffect

React useEffect教程从初学者到高级。useEffect最佳实践。清理、生命周期和渲染问题。

When and how it runs?

import React, { useEffect, useState } from "react";

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

  // 第一次加载 和 每次 点击 按钮 都会执行这个 log
  console.log("component rendered.");

  useEffect(() => {
    console.log("useEffect runs.");
    document.title = `you clicked ${counter} times`;
  });

  return (
    <div>
      <span style={{ display: "block" }}>you clicked {counter} times</span>
      <button type="button" onClick={() => setCounter((prev) => prev + 1)}>
        INC
      </button>
    </div>
  );
}

这段代码执行的顺序是 component => react => browser ,

  • 第一步,渲染 component,<span>You clicked 0 times</span> .
  • 第二步,组件渲染后执行 effact 函数 ()=>document.title=`You clicked 0 times` .
  • 最后,浏览器加载进页面。

How did dependencies work?

import React, { useEffect, useState } from "react";

export default function TestPage() {
  const [counter, setCounter] = useState(0);
  const [name, setName] = useState<string | undefined>(undefined);

  // 第一次加载 和 每次 点击 按钮 都会执行这个 log
  console.log("component rendered.");

  // 只有 counter 改变的时候执行
  useEffect(() => {
    console.log("useEffect runs.");
    document.title = `you clicked ${counter} times`;
  }, [counter]);

  return (
    <div>
      <p>
        <span style={{ paddingRight: "10px" }}>
          you clicked {counter} times
        </span>
        <button type="button" onClick={() => setCounter((prev) => prev + 1)}>
          INC
        </button>
      </p>
      <p>
        <input type="text" onChange={(e) => setName(e.target.value)} /> {name}
      </p>
    </div>
  );
}

假如组建里有两个state,counter 和 name,两个状态改变时都会出发 useEffect, 如果只想 counter 变更时候执行的话,可以给useEffect 加上依赖参数:

  // 只有 counter 改变的时候执行, name 改变时候不会触发。
  useEffect(() => {
    console.log("useEffect runs.");
    document.title = `you clicked ${counter} times`;
  }, [counter]);

Primitive and Non-primitive Dependencies

如果依赖参数是 JS 原始类型, 如:1、number(数字类型);2、string(字符串类型);3、null;4、undefined(未定义);5、boolean(布尔类型), 那是没有问题的。

但是假如我们的依赖参数是一个对象,那问题来, 如

import React, { useEffect, useState } from "react";

export default function TestPage() {
  const [profile, setProfile] = useState<{ name: string; age?: number }>({
    name: "",
    age: 0,
  });

  // 只有 counter 改变的时候执行
  useEffect(() => {
    console.log("useEffect runs.", profile);
  }, [profile]);

  return (
    <div className="w-96 p-5">
      <p>
<!--点击按钮更新 profile-->
        <button
          className="btn mr-5"
          type="button"
          onClick={() => setProfile({ name: "YAO", age: 10 })}
        >
          Updata Profile
        </button>
        {JSON.stringify(profile)}
      </p>
    </div>
  );
}

Console log,即使 profile 对象里的值没有变化,但是任然出发了 useEffect.

这是因为 在 JS 里, 原始类型可以直接比较,但是 非原始类型(如 数组,对象)是不能直接比较的,即使他们看起来是相同的。

let A = {name: "YAO"}
let B = {name: "YAO"}
const C = B
console.log(A===B) // false
console.log(C===B) // true
[] === [] // false
[1] === [1] // false

React useEffect 的依赖是一个数组,react 不是直接比较依赖数组,而且足个比较数组里的那个参数, 如果里面 profile 是一个对象,即使对象值一样,比较的结果依然是 false 而出发 useEffect。

解决的办法有两个

A:使用 useMemo 将依赖对象缓存起来。

  const [profile, setProfile] = useState<{ name: string; age?: number }>({
    name: "",
    age: 0,
  });

  const user = useMemo(
    () => ({
      name: profile.name,
      age: profile.age,
    }),
    [profile.name, profile.age]
  );

  // 只有 counter 改变的时候执行
  useEffect(() => {
    console.log("useEffect runs.", user);
  }, [user]);

B: 将 依赖对象展开成原始类型列进依赖数组

 useEffect(() => {
    console.log("useEffect runs.", profile);
  }, [profile.name, profile.age]);

Clean up functions

import React, { useEffect, useState } from "react";

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

  // 只有 counter 改变的时候执行
  useEffect(() => {
    console.log("useEffect runs.");
    setInterval(() => setCounter((prev) => prev + 1), 1000);
  }, [counter]);

  return (
    <div className="w-96 p-5">
      <p className="text-4xl">{counter}</p>
    </div>
  );
}

上面的代码会进入死循环,让cpu 起飞:

但是,clean up function 可以让我们这真正执行 useEffect 之前 做一些事,如:

import React, { useEffect, useState } from "react";

export default function TestPage() {
  const [counter, setCounter] = useState(0);
  const [toggle, setToggle] = useState(false);

  // 只有 counter 改变的时候执行
  useEffect(() => {
    console.log("useEffect runs.");
    // setInterval(() => setCounter((prev) => prev + 1), 1000);
    // return clean up function
    return () => {
      console.log("1. Before run useEffect");
      console.log("2. Do something before useEffect");
      console.log("3. Done before useEffect");
    };
  }, [toggle]);

  return (
    <div className="w-96 p-5">
      <p className="text-4xl">
        {counter} {toggle ? "true" : "false"}
      </p>
      <button onClick={() => setToggle(!toggle)}>Toggle</button>
    </div>
  );
}

现在我们修改之前的 定时counter 代码为:

import React, { useEffect, useState } from "react";

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

  useEffect(() => {
    console.log("useEffect runs.");
    const inc = setInterval(() => setCounter((prev) => prev + 1), 1000);
    return () => {
      clearInterval(inc);
    };
  }, []);

  return (
    <div className="w-96 p-5">
      <p className="text-4xl">{counter} times</p>
    </div>
  );
}

可以看到,世界清净了。

Best Ways to Make API Requests with useEffect

一个简单的场景,有一组按钮,点击后会调用api 获取数据:

import Link from "next/link";
import React, { useEffect, useState } from "react";
import { NumberParam, useQueryParam, withDefault } from "use-query-params";

export default function TestPage() {
  const [id] = useQueryParam("id", withDefault(NumberParam, null));
  const [user, setUser] = useState({});

  useEffect(() => {
    console.log("useEffect runs.", id);
    if (id) {
      fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
        .then((response) => response.json())
        .then((data) => {
          console.log("setUser:", data);
          setUser(data);
        });
    }

    return () => {
      console.log("before useEffect runs.");
    };
  }, [id]);

  return (
    <div className="w-96 p-5">
      <Link href={"/test?id=1"}>
        <a className="btn">User 1</a>
      </Link>
      <Link href={"/test?id=2"}>
        <a className="btn">User 2</a>
      </Link>
      <Link href={"/test?id=3"}>
        <a className="btn">User 3</a>
      </Link>
      <pre>{JSON.stringify(user)}</pre>
    </div>
  );
}

在慢网速下可以明显的看出,users api 一次完成,并将返回数据依次在按钮下方显示。

而我们正在想要的是,当开始下一个请求时,应该取消之前的请求,避免数据被一次渲染到页面上。

例如:可以对 useEffect 稍加修改, 来取消旧的请求:

  useEffect(() => {
    let unsubscribed = false;
    if (id) {
      fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
        .then((response) => response.json())
        .then((data) => {
          if (!unsubscribed) {
            console.log("setUser:", data);
            setUser(data);
          }
        });
    }

    return () => {
      console.log("cancelled");
      unsubscribed = true;
    };
  }, [id]);

或者使用 AbortController

useEffect(() => {
    const controller = new AbortController();

    if (id) {
      fetch(`https://jsonplaceholder.typicode.com/users/${id}`, {
        signal: controller.signal,
      })
        .then((response) => response.json())
        .then((data) => {
          console.log("setUser:", data);
          setUser(data);
        })
        .catch((err) => {
          if (err.name === "AbortError") {
            console.warn("Abort");
          } else {
            // todo handle error
          }
        });
    }

    return () => {
      controller.abort();
    };
  }, [id]);

如果你使用 Axios

useEffect(() => {
    const cancelToken = axios.CancelToken.source();

    if (id) {
      axios
        .get(`https://jsonplaceholder.typicode.com/users/${id}`, {
          cancelToken: cancelToken.token,
        })
        .then((response) => {
          console.log("setUser:", response.data);
          setUser(response.data);
        })
        .catch((err) => {
          if (axios.isCancel(err)) {
            console.warn("Abort");
          } else {
            // todo handle error
          }
        });
    }

    return () => {
      cancelToken.cancel();
    };
  }, [id]);