React小白进阶之useEffect和ref

112 阅读8分钟

React中函数组件的主体只应该用来返回组件的 HTML 代码,所有的其他操作(副效应)都必须通过钩子引入。由于副效应非常多,所以钩子有许多种。

React 为许多常见的操作(副效应),都提供了专用的钩子。如useState():保存状态,useContext():保存上下文useRef():保存引用。

上面这些钩子,都是引入某种特定的副效应,而 useEffect()是通用的副效应钩子 。找不到对应的钩子时,就可以用它。本文主要就是来说说这个副效应(side effect)。

Effect

Effect 在 React 中是专有定义——由渲染引起的副作用。

// 语法
useEffect(() => {
  // 在这里写你的副作用逻辑
}, [dependencies]); // 依赖项数组

基础使用

每当组件渲染时,React 将更新屏幕,然后运行 useEffect 中的代码。换句话说,useEffect 会把这段代码放到屏幕更新渲染之后执行

import { useEffect } from 'react';
function MyComponent() {
  useEffect(() => {
    // 每次渲染后都会执行此处的代码
  });
  return <div />;
}

一般来说,我们并不需要每次渲染的时候都执行 Effect,所以最好这样写,第二个参数加一个空数组

注意,每次setState都会导致组件重新渲染

useEffect(() => {
  // 这里的代码只会在组件挂载后执行
}, []);

指定依赖

我们可以指定依赖项,是Effect在指定的时候运行,例如,下面这段代码的意思是,当isPlaying改变时,才运行 Effect

useEffect(() => {
    if (isPlaying) { // isPlaying 在此处使用……
      // ...
    } else {
      // ...
    }
  }, [isPlaying]); // ……所以它必须在此处声明!

依赖性可以有多个

useEffect(() => {
  //这里的代码只会在每次渲染后,并且 a 或 b 的值与上次渲染不一致时执行
}, [a, b]);

清理函数

可以在 Effect 中返回一个 清理(cleanup) 函数,每次重新执行 Effect 之前,React 都会调用清理函数;组件被卸载时,也会调用清理函数。

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    function onTick() {
      setCount(c => c + 1);
    }

    const intervalId = setInterval(onTick, 1000);
    return () => { // 如果没有使用清理函数,你可以看到计数器不是每秒递增一次,而是两次。
      clearInterval(intervalId)
    };
  }, []);

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


React 总是在执行下一轮渲染的 Effect 之前清理上一轮渲染的 Effect。

不推荐使用Effect的情况

特定操作

应该由用户处理的一些操作

// ❌ 错误:购买接口不应该放在这里执行,应该要由用户点击购买按钮后执行。
useEffect(() => {
  fetch('/api/buy', { method: 'POST' });
}, []);

// ✅ 购买商品应当在事件中执行,因为这是由特定的操作引起的。
function handleClick() {
  fetch('/api/buy', { method: 'POST' });
}

怎么一些逻辑是放入事件处理函数还是 Effect ?可以这样分析

  • 如果这个逻辑是由某个特定的交互引起的,则放在相应的事件处理函数中。
  • 如果是由用户在屏幕上 看到 组件时引起的,则放在 Effect 中。

计算结果

如果一个值可以基于现有的 props 或 state 计算得出,不要把它作为一个 state,而是在渲染期间直接计算这个值。

例如:

有两个 state 变量,firstName 和 lastName。现在想通过把它们计算出 fullName。每当 firstName 和 lastName 变化时, fullName 都能更新。

// 🔴 Bad
function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');

  // 多余的 state 和不必要的 Effect
  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
  // ...
}

// ✅  Good
function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  // 在渲染期间进行计算
  const fullName = firstName + ' ' + lastName;
  // ...
}

因为每次setFirstName,或setLastName时,Form组件都会重写渲染,所以fullName直接使用普通变量即可

重置全部state

避免当 prop 变化时,在 Effect 中重置 state,例如:

// ❌ Bad
export default function ProfilePage({ userId }) {
  const [comment, setComment] = useState('');

  // 避免这样做,这是非常低效的,因为 ProfilePage 和它的子组件首先会用旧值渲染,然后再用新值重新渲染。
  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}

可以将上面的组件拆分为两个组件,并从外部的组件传递一个 key 属性给内部的组件。

每当 key(这里是 userId)变化时,React 将重新创建 DOM,并 重置 Profile 组件和它的所有子组件的 state。

// ✅  Good
export default function ProfilePage({ userId }) {
  return (
    <Profile
      userId={userId}
      key={userId}
    />
  );
}

function Profile({ userId }) {
  // 当 key 变化时,该组件内的 comment 或其他 state 会自动被重置
  const [comment, setComment] = useState('');
  // ...
}

重置部分state

当 prop 变化时,想重置或调整部分 state ,而不是所有 state。

例如:

List 组件接收一个 items 列表,然后用 state 变量 selection 来保持已选中的项。当 items 接收到一个不同的数组时,想将 selection 重置为 null

// ❌ Bad
function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // 避免当 prop 变化时,在 Effect 中调整 state
  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
}

上面这样写,

  • 每当 items 变化时,List 及其子组件会先使用旧的 selection 值渲染。
  • 然后 React 会更新 DOM 并执行 Effect。
  • 最后,调用 setSelection(null) 将导致 List 及其子组件重新渲染,重新启动整个流程
// ✅  优化1
function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // 在渲染期间调整 state,虽然这种方式比 Effect 更高效,但是会让人不好理解
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
  // ...
}

// ✅  优化2,存储已选中的 item ID 而不是存储(并重置)已选中的 item,然后通过计算实现相同效果
function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  // 在渲染期间计算所需内容
  const selection = items.find(item => item.id === selectedId) ?? null;
  // ...
}

共享逻辑

有的时候,为了减少代码重复,可能会想把共享逻辑放在Effect。

例如:

假设产品页面上有两个按钮(购买和付款),当用户点击这两个按钮时,都想显示一个通知。但是在两个按钮的 click 事件处理函数中都调用 showNotification() 感觉有点重复,所以可能会这样写

// ❌ Bad  这个 Effect 是多余的,而且很可能会导致问题
function ProductPage({ product, addToCart }) {
  // 避免在 Effect 中处理属于事件特定的逻辑
  useEffect(() => {
    if (product.isInCart) {
      showNotification(`已添加 ${product.name} 进购物车!`);
    }
  }, [product]);

  function handleBuyClick() {
    addToCart(product);
  }

  function handleCheckoutClick() {
    addToCart(product);
    navigateTo('/checkout');
  }
  // ...
}

更好的方式应该是把共享的逻辑封装到新的函数中

// ✅  Good
function ProductPage({ product, addToCart }) {
  // 事件特定的逻辑在事件处理函数中处理
  function buyProduct() {
    addToCart(product);
    showNotification(`已添加 ${product.name} 进购物车!`);
  }

  function handleBuyClick() {
    buyProduct();
  }

  function handleCheckoutClick() {
    buyProduct();
    navigateTo('/checkout');
  }
  // ...
}

初始化应用

有些逻辑只需要在应用加载时执行一次。比如,验证登陆状态和加载本地程序数据。

// ❌ Bad
function App() {
  // 避免:把只需要执行一次的逻辑放在 Effect 中
  useEffect(() => {
    loadDataFromLocalStorage();
    checkAuthToken();
  }, []);
  // ...
}

你可以将其放在组件之外,而不是Effect中

// ✅  Good
if (typeof window !== 'undefined') { // 检查是否在浏览器中运行
  checkAuthToken();
  loadDataFromLocalStorage();
}

// 这里面时组件
function App() {
  // ……
}

顶层代码会在组件被导入时执行一次,即使它最终并没有被渲染。所以应用级别的初始化逻辑还是应该保留在像 App.js 这样的根组件模块或你的应用入口中。

注意

这样写会出现死循环

const [count, setCount] = useState(0);
useEffect(() => {
  setCount(count + 1);
});

因为每次渲染结束都会执行 Effect;而更新 state 会触发重新渲染。但是新一轮渲染时又会再次执行 Effect,然后 Effect 再次更新 state……如此周而复始,从而陷入死循环。

使用 ref 操作 DOM

基本使用

示例1):使文本框输入获得焦点

// 1️⃣ 需要访问由 React 管理的 DOM 元素,首先,引入 useRef Hook
import { useRef } from 'react';

export default function Form() {
  const inputRef = useRef(null); // 2️⃣ useRef Hook 返回一个对象

  function handleClick() {
    inputRef.current.focus(); // 3️⃣ useRef Hook 对象有一个名为 current 的属性,可以通过这个属性使用任意浏览器 API
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>
        聚焦输入框
      </button>
    </>
  );
}

最初,inputRef.current 是 null。

当 React 为这个 <input> 创建一个 DOM 节点时,React 会把对该节点的引用放入 inputRef.current。

然后,你可以从 事件处理器 访问此 DOM 节点,并使用在其上定义的内置浏览器 API

多个ref

一个组件中可以有多个 ref,例如:

// 部分代码
...
const firstCatRef = useRef(null);
const secondCatRef = useRef(null);
const thirdCatRef = useRef(null);

function handleScrollToFirstCat() {
  firstCatRef.current.scrollIntoView({
    behavior: 'smooth',
    block: 'nearest',
    inline: 'center'
  });
}

function handleScrollToSecondCat() {
  secondCatRef.current.scrollIntoView({
    behavior: 'smooth',
    block: 'nearest',
    inline: 'center'
  });
}

function handleScrollToThirdCat() {
  thirdCatRef.current.scrollIntoView({
    behavior: 'smooth',
    block: 'nearest',
    inline: 'center'
  });
}

return (
  <>
  ...
  <img
    src="https://placekitten.com/g/200/200"
    alt="Tom"
    ref={firstCatRef}
  />
   <img
    src="https://placekitten.com/g/300/200"
    alt="Maru"
    ref={secondCatRef}
  />
   <img
    src="https://placekitten.com/g/250/200"
    alt="Jellylorum"
    ref={thirdCatRef}
  />
  ...
  </>
);

如果有多个ref,但是不确定数量和名字怎么办?

// ❌.错误,因为 Hook 只能在组件的顶层被调用。所以不能在循环语句、条件语句或 map() 函数中调用 useRef
<ul>
  {items.map((item) => {
    // 行不通!
    const ref = useRef(null);
    return <li ref={ref} />;
  })}
</ul>

解决方案,将函数传递给 ref 属性,完整代码

<li
  key={cat.id}
  ref={node => {
    const map = getMap();
    if (node) {
      // 添加到 Map
      map.set(cat.id, node);
    } else {
      // 从 Map 删除
      map.delete(cat.id);
    }
  }}
>

访问另一个组件的 DOM 节点

如果你试图将 ref 放在 你自己的 组件上,例如 <MyInput />,默认情况下你会得到 null

import { useRef } from 'react';

function MyInput(props) {
  return <input {...props} />;
}

export default function MyForm() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus(); // 无法正常访问,React 不允许组件访问其他组件的 DOM 节点,自己的子组件也不行
  }

  return (
    <>
      <MyInput ref={inputRef} /> 
      <button onClick={handleClick}>
        聚焦输入框
      </button>
    </>
  );
}

改进,需要使用forwardRef将父组件的 ref “转发”给子组件

import { forwardRef, useRef } from 'react';

// MyInput 组件将自己接收到的 ref 传递给它内部的 <input>
const MyInput = forwardRef((props, ref) => {
  return <input {...props} ref={ref} />;
});

···


🎨【点赞】【关注】不迷路,更多前端干货等你解锁

往期推荐

👉 Vue的渲染函数render&h

👉 ES6中一些很好用的数组方法

👉 echarts | 柱状图实用配置

👉 JS设置获取盒模型对应宽高的五种方式详解