技术咨询、项目合作、广告投放、简历咨询、技术文档下载 点击这里 联系博主

useEffect 完整指南 (opens new window)是一篇很好的文章;读完之后发现自己平时编写代码也有许多未曾注意的点;现简单整理一下 读书笔记;希望对大家有所帮助。

# React 每一次渲染都有自己的 props , state , 事件函数

如下代码,点击三次按钮,点一次 Show alert ;再点两次 按钮,再点 Show alert

最终,第一次 Show alert 会展示 3 第二次会展示 5;也就意味着:

TIP

在任意一次渲染中,propsstate是始终保持不变的。如果propsstate在不同的渲染中是相互独立的,那么使用到它们的任何值也是独立的(包括事件处理函数)。它们都“属于”一次特定的渲染。即便是事件处理中的异步函数调用“看到”的也是这次渲染中的 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>
  );
}

# React 每次渲染都有它自己的 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>
  );
}

我们点击之后发生了什么:

  • 你的组件: 喂 React, 把我的状态设置为1

  • React: 给我状态为 1时候的 UI。

  • 你的组件:

    • 给你需要渲染的内容: <p>You clicked 1 times</p>
    • 记得在渲染完了之后调用这个 effect: () => { document.title = 'You clicked 1 times' }
  • React: 没问题。开始更新 UI,喂浏览器,我修改了 DOM。

  • Browser: 酷,我已经将更改绘制到屏幕上了。

  • React: 好的, 我现在开始运行属于这次渲染的 effect

    • 运行 () => { document.title = 'You clicked 1 times' }

# 那 Effect 中的清理又是怎样的呢?

到目前为止,我们可以明确地喊出下面重要的事实:每一个组件内的函数(包括事件处理函数,effects,定时器或者 API 调用等等)会捕获某次渲染中定义的 props 和 state

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

假设第一次渲染的时候 props 是{id: 10},第二次渲染的时候是{id: 20};

清除的顺序是:

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

造成此执行顺序的原因是:

React 只会在浏览器绘制后运行 effects。这使得你的应用更流畅因为大多数 effects 并不会阻塞屏幕的更新。Effect 的清除同样被延迟了

你可能会好奇:如果清除上一次的 effect 发生在 props 变成{id: 20}之后,那它为什么还能“看到”旧的{id: 10}

引用上一部分的结论: 组件内的每一个函数(包括事件处理函数,effects,定时器或者 API 调用等等)会捕获定义它们的那次渲染中的 props 和 state

# 注意 useEffect 的依赖项

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

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

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

因为 useEffect 中的依赖项没有设置 count,那么 setCount 种的 count 始终为 0,所以不管渲染多少次 count 最终的结果依然为 1;

# 办法一: 添加依赖项

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

现在依赖数组正确了。虽然解决问题,但是依然存在 每次 count 修改都会重新运行 effect 的问题

(依赖发生了变更,所以会重新运行 effect。)

![https://overreacted.io/5734271ddfa94d2d65ac6160515e0069/interval-rightish.gif]

# 办法二: 使用 setState 的函数形式

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

**在 effects 中传递最小的信息会很有帮助。**类似于setCount(c => c + 1)这样的更新形式比setCount(count + 1)传递了更少的信息,因为它不再被当前的 count 值“污染”。它只是表达了一种行为(“递增”)。“Thinking in React”也讨论了如何找到最小状态 (opens new window)。原则是类似的,只不过现在关注的是如何更新。

存在的问题:

**然而,即使是setCount(c => c + 1)也并不完美。**它看起来有点怪,并且非常受限于它能做的事。举个例子,如果我们有两个互相依赖的状态,或者我们想基于一个 prop 来计算下一次的 state,它并不能做到。幸运的是, setCount(c => c + 1)有一个更强大的姐妹模式,它的名字叫useReduce

# 办法三: useReducer

如果存在多个 state 互相依赖,当然你可以使用 useEffect 添加依赖数组的方式处理

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

但是假设如果你不想 useEffect 被执行多次,应该咋办呢?

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

const initialState = {
  count: 0,
  step: 1,
};

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

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

useEffect(() => {
  const id = setInterval(() => {
    dispatch({ type: "tick" }); // Instead of setCount(c => c + step);
  }, 1000);
  return () => clearInterval(id);
}, [dispatch]);

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

# useReducer 的注意事项

# useReducer 的值依赖于 props 咋办?

下面的方式虽说可以运行,下面这种方式会使一些优化失效,所以你应该避免滥用它;

可以运行的原理:

当你dispatch的时候,React 只是记住了 action - 它会在下一次渲染中再次调用 reducer。在那个时候,新的 props 就可以被访问到,而且 reducer 调用也不是在 effect 里。

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

# useEffect 的间接依赖问题

如下的代码是有一定的问题的, useEffect 依赖于 fetchData 函数,fetchData 函数依赖于 query state;也就是说 useEffect 其实是依赖于 query state 的。 也就是 useEffect 的间接依赖

这样会导致的问题是: query 更新了 fetch 的还是 上一次渲染的值。(除非你真的想这样做)

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();
  }, []);

  // ...
}

正确的办法应该是这样的

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

  // ...
}

# 函数复用多处, useEffect 应该如何处理

# Bad Case 🔴

一个常见的误解是,“函数从来不会改变”。实际上,在组件内定义的函数每一次渲染都在变

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

  // ...
}

# 请不要将普通函数作为 useEffect 的依赖项 🔴

在组件内定义的函数每一次渲染都在变, 所以我们的依赖数组会变得无用

function SearchResults() {
  // 🔴 Re-triggers all effects on every render
  function getFetchUrl(query) {
    return "https://hn.algolia.com/api/v1/search?query=" + query;
  }

  useEffect(() => {
    const url = getFetchUrl("react");
    // ... Fetch data and do something ...
  }, [getFetchUrl]); // 🚧 Deps are correct but they change too often

  useEffect(() => {
    const url = getFetchUrl("redux");
    // ... Fetch data and do something ...
  }, [getFetchUrl]); // 🚧 Deps are correct but they change too often

  // ...
}

# 使用 useCallback 解决函数重复变更问题 ✅

我们要感谢 useCallback,因为如果 query 保持不变,getFetchUrl 也会保持不变,我们的 effect 也不会重新运行。但是如果 query 修改了,getFetchUrl 也会随之改变,因此会重新请求数据

function SearchResults() {
  // ✅ Preserves identity when its own deps are the same
  const getFetchUrl = useCallback(
    (query) => {
      return "https://hn.algolia.com/api/v1/search?query=" + query;
    },
    [query]
  ); // ✅ Callback deps are OK

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

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

  // ...
}

对于通过属性从父组件传入的函数这个方法也适用

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

  // ...
}

# 是否应该一直使用 useCallback 包裹函数

例子:

function App() {
  const method1 = () => {
    // ...
  };

  const method2 = useCallback(() => {
    // 这是一个和 method1 功能一样的方法
  }, [props.a, props.b]);

  return (
    <div>
      <div onClick={method1}>button</div>
      <div onClick={method2}>button</div>
    </div>
  );
}

我们的 App 函数在每一次更新的时候都会重新执行,由于这个原因,它内部的函数也都会重新生成一次,也就是说,我们的 method1 每次都会重新执行生成一遍。 而 method2 就不一样了,它是被 useCallback 包裹的返回值,除非依赖变化了,不然它不会重新生成,于是,你可能就会认为 method2 那种写法性能更高。

事实上并不一定如此:

对于 method1: 每次函数执行创建新的函数几乎是可以忽略不计的; 对于 method2: 每次 props 更新 重新执行,需要对比前后的 props 是否相同【增加了对比的成本】;为了能判断 useCallback 要不要更新结果,我们还要在内存保存上一次的依赖。【增加了数据存储】

# useCallback 使用场景 1: 需要将函数传递给子对象

// parent.tsx
const handleClick = () => {};
<Children count={count} onClick={handleClick} />;

如果子组件由于其他值的更改而发生了更新,子组件会重新渲染,由于 handleClick 是一个对象,每次渲染生成的 handleClick 都是新的。

这就会导致,尽管 ChildrenReact.memo 包裹了一层,但是还是会重新渲染,为了解决这个问题,我们就要这样写 handleClick 函数了:

const handleClick = useCallback(() => {
  // 原来的 handleClick...
}, []);

# useCallback 使用场景 2: useEffect 依赖函数变更而变化

这一块上面已经讲到过了,请见【使用 useCallback 解决函数重复变更问题】

# useCallback 使用场景 3: 避免重复的生成函数

获取 DOM 节点的位置或是大小的基本方式是使用 callback ref (opens new window)。每当 ref 被附加到一个另一个节点,React 就会调用 callback。这里有一个 小 demo (opens new window):

function MeasureExample() {
  const [height, setHeight] = useState(0);

  const measuredRef = useCallback((node) => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height);
    }
  }, []);
  return (
    <>
      <h1 ref={measuredRef}>Hello, world</h1>{" "}
      <h2>The above header is {Math.round(height)}px tall</h2>
    </>
  );
}

在这个案例中,我们没有选择使用 useRef,因为当 ref 是一个对象时它并不会把当前 ref 的值的 变化 通知到我们。使用 callback ref 可以确保 即便子组件延迟显示被测量的节点 (opens new window) (比如为了响应一次点击),我们依然能够在父组件接收到相关的信息,以便更新测量结果。

注意到我们传递了 [] 作为 useCallback 的依赖列表。这确保了 ref callback 不会在再次渲染时改变,因此 React 不会在非必要的时候调用它。

在此示例中,当且仅当组件挂载和卸载时,callback ref 才会被调用,因为渲染的 <h1> 组件在整个重新渲染期间始终存在。如果你希望在每次组件调整大小时都收到通知,则可能需要使用 ResizeObserver (opens new window) 或基于其构建的第三方 Hook。

如果你愿意,你可以 把这个逻辑抽取出来作为 (opens new window) 一个可复用的 Hook:

function MeasureExample() {
  const [rect, ref] = useClientRect();
  return (
    <>
      <h1 ref={ref}>Hello, world</h1>
      {rect !== null && (
        <h2>The above header is {Math.round(rect.height)}px tall</h2>
      )}
    </>
  );
}

function useClientRect() {
  const [rect, setRect] = useState(null);
  const ref = useCallback((node) => {
    if (node !== null) {
      setRect(node.getBoundingClientRect());
    }
  }, []);
  return [rect, ref];
}

# useState 依赖于 Props 创建咋办?

第一个常见的使用场景是当创建初始 state 很昂贵时:

function Table(props) {
  // ⚠️ createRows() 每次渲染都会被调用
  const [rows, setRows] = useState(createRows(props.count));
  // ...
}

为避免重新创建被忽略的初始 state,我们可以传一个 函数useState

function Table(props) {
  // ✅ createRows() 只会被调用一次
  const [rows, setRows] = useState(() => createRows(props.count));
  // ...
}

React 只会在首次渲染时调用这个函数。参见 useState API 参考 (opens new window) (opens new window)

**你或许也会偶尔想要避免重新创建 useRef() 的初始值。**举个例子,或许你想确保某些命令式的 class 实例只被创建一次:

function Image(props) {
  // ⚠️ IntersectionObserver 在每次渲染都会被创建
  const ref = useRef(new IntersectionObserver(onIntersect));
  // ...
}

useRef 不会useState 那样接受一个特殊的函数重载。相反,你可以编写你自己的函数来创建并将其设为惰性的:

function Image(props) {
  const ref = useRef(null);

  // ✅ IntersectionObserver 只会被惰性创建一次
  function getObserver() {
    if (ref.current === null) {
      ref.current = new IntersectionObserver(onIntersect);
    }
    return ref.current;
  }

  // 当你需要时,调用 getObserver()
  // ...
}

这避免了我们在一个对象被首次真正需要之前就创建它。如果你使用 Flow 或 TypeScript,你还可以为了方便给 getObserver() 一个不可为 null 的类型

# Hook 会因为在渲染时创建函数而变慢吗?

不会。在现代浏览器中,闭包和类的原始性能只有在极端场景下才会有明显的差别。

除此之外,可以认为 Hook 的设计在某些方面更加高效:

  • Hook 避免了 class 需要的额外开支,像是创建类实例和在构造函数中绑定事件处理器的成本。
  • 符合语言习惯的代码在使用 Hook 时不需要很深的组件树嵌套。这个现象在使用高阶组件、render props、和 context 的代码库中非常普遍。组件树小了,React 的工作量也随之减少。

传统上认为,在 React 中使用内联函数对性能的影响,与每次渲染都传递新的回调会如何破坏子组件的 shouldComponentUpdate 优化有关。Hook 从三个方面解决了这个问题。

// 除非 `a` 或 `b` 改变,否则不会变
const memoizedCallback = useCallback(() => {
  doSomething(a, b);
}, [a, b]);
【未经作者允许禁止转载】 Last Updated: 2/4/2024, 6:06:40 AM