kd-cloud-web / Blog

一群人, 关于前端, 做一些有趣的事儿
13 stars 1 forks source link

重新认识 react 常见内置 hooks #77

Open zzkkui opened 11 months ago

zzkkui commented 11 months ago

在 vue 转 react 的时候,团队小伙伴分享过一片关于 react hooks 使用总结,很详细,在最开始使用 react 的时候,开发起来还是很有帮助的。但是在使用 react hooks 这么多时间之后,发现我们在使用 react 上存在一些误区。

我们回过头再去看下 react 的官方文档,对比我们之前的分享内容,让我们重新认识 react 内置的 hooks。

首先我们应该明白,react hooks 是 react 函数组件提供的钩子函数,是和类组件完全不同的存在,我们在理解 hooks 的时候,一定要区别类组件的使用方法。

很重要的一点:函数组件是没有所谓的生命周期钩子,有的只是 mount(挂载)、update 和 unmount(卸载),而且没有 this。

useState

useState 是我们的添加状态变量的 hook,这个大家都很熟悉,之前也有分享关于 react 批处理的 文章,关于是否“异步”以及批处理特性已经讲得很清楚。这里还有几点补充:

1. 因为批处理的原因,获取不到最新的state值,我们可以通过更新函数来获取最新值

// 错误,后面的 number 不是最新值
setNumber(number + 1);
setNumber(number + 1);

// 正常工作
setNumber(n => n + 1);
setNumber(n => n + 1);

事实上批处理就是调用 setState 时,将其加入队列,在下次渲染之前进行统一执行,而更新函数可以拿到最新的 state 值。

2. react 会对新旧 state 进行 Object.is 比较

3. 一键重置状态 很多时候我们组件需要重置状态,我们可以为组件添加一个 key,需要重置状态的时候改变这个key,充分利用 react diff 的规则,销毁重建组件,就不需要我们单独重置整个组件的状态了

4. 状态是一个函数时

// 这样其实状态是 someFunction 的返回值,而 setFn 后的状态也将是 someOtherFunction 的返回值
const [fn, setFn] = useState(someFunction);

function handleClick() {
  setFn(someOtherFunction);
}

// 正确的写法
const [fn, setFn] = useState(() => someFunction);

function handleClick() {
  setFn(() => someOtherFunction);
}

useCallback

useCallback 可以缓存函数本身。用于性能优化,如果代码没有 useCallback 就不能正常运行,可能存在潜在的 bug

事实上 useCallback 就是 useMemo 的语法糖

用法:

  1. 传递函数给子组件时,useCallback 搭配 memo 可以跳过子组件的重新渲染
import { memo } from 'react';

const ChildMemo = memo(function Child({ onSubmit }) {
  // ...
});

function App() {
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      orderDetails,
    });
  }, []);

  return (
    <div className={theme}>
      <ChildMemo onSubmit={handleSubmit} />
    </div>
  );
}

默认情况下,当一个组件重新渲染时,React 会重选渲染它的所有子组件。

  1. 函数作为某个 hooks 的依赖项时,例如作为 useEffect 的依赖项。不过,官方建议是最好不要依赖函数,将函数写在 useEffect 中,官方的说法适用于不复杂的组件。

其实我们在碰到 useEffect 依赖某一个方法时,可能是我们碰到了闭包,是因为 useEffect 里调用的方法(不在依赖中)里面拿不到最新的 state。我们可以尝试通过解决闭包的问题去移除 useEffect 对方法的依赖。

使用原则

useCallback 是可以进行性能优化,但是这是相对而言,因为 useCallback(即缓存) 本身也是需要性能,需要对比依赖项,需要缓存。同时相对而言 useCallback 会使得代码阅读起来没有那么直观。所以我们应该遵循一些原则去使用

  1. 传递函数给子组件时,需要跳过子组件的重新渲染,但是这里是要搭配 memo 一起使用,而 memo 就是会对 props 进行一次浅比较。不过事实上需要确认是否需要 memo,在状态提升和状态下移的选择上需要考虑更多,同时提升组件拆分的能力。

但是上面情况并不强制,首先多次渲染并不一定会造成性能问题(有性能问题来优化不迟,而且绝大多数性能问题并不是 react 引起的),useCallback 引起的代码可读性和 deps 带来额外维护问题也需要考虑。

同样不推荐所有的组件都缓存(memo),首先缓存是需要性能代价,其次缓存会影响初次渲染时间,成千上万个组件都在初始渲染的时候缓存,同样体验不佳

useMemo

useMemo 可以缓存函数返回值(计算)。同样也是用来性能优化。

用法

  1. 缓存昂贵的计算,依赖项没有更新时跳过计算取缓存值

通常,大多数的计算都非常快。但是如果操作大型数组或者一些昂贵的计算时,才会用到 useMemo

计算是否昂贵?如果计算时间大于 1 ms ,利用 useMemo 缓存是有意义的

  1. 同 useCallback 一样,缓存对象,可以跳过组件的渲染(搭配 memo)
import { memo } from 'react';

const List = memo(function List({ items }) {
  // ...
});

export default function TodoList({ todos, tab, theme }) {
  const visibleTodos = useMemo(
    () => filterTodos(todos, tab),
    [todos, tab]
  );
  return (
    <div className={theme}>
      <List items={visibleTodos} />
    </div>
  );
}

甚至是缓存整个 react node

import { memo } from 'react';

const List = memo(function List({ items }) {
  // ...
});

export default function TodoList({ todos, tab, theme }) {
  const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
  const children = useMemo(() => <List items={visibleTodos} />, [visibleTodos]);
  return (
    <div className={theme}>
      {children}
    </div>
  );
}

其实作用同 React.memo

  1. 作为某个 hooks 的依赖项时

使用原则

同 useCallback 一样(毕竟 useCallback 只是 useMemo 的语法糖),useMemo 同样是性能优化的手段。除上述 useCallback 使用规则外,useMemo 还有缓存函数返回值的功能,这使得在更新阶段可以跳过依赖没有变更的 useMemo 的值的生成。

  1. 强调一点,单纯利用 useMemo 来缓存计算值时,需要确认这个计算是否昂贵(大于等于 1ms,当然并不一定,机器原因也很大)。

  2. 同 useCallback,传递对象类型给子组件时,跳过子组件的重新渲染。

react 团队正在研究 doing granular memoization automatically (自动缓存)

useRef

useRef 可以创建一个改变不触发渲染的值

注意事项

  1. 不要在渲染期间写入 或者读取 ref.current,可能会破坏纯函数的特性
function MyComponent() {
  // ...
  // 🚩 不要在渲染期间写入 ref
  myRef.current = 123;
  // ...
  // 🚩 不要在渲染期间读取 ref
  // 其实如果跟渲染有关,那它可能是一个state
  return <h1>{myOtherRef.current}</h1>;
}
function MyComponent() {
  // ...
  useEffect(() => {
    // ✅ 你可以在 effects 中读取和写入 ref
    myRef.current = 123;
  });
  // ...
  function handleClick() {
    // ✅ 你可以在事件处理程序中读取和写入 ref
    doSomething(myOtherRef.current);
  }
  // ...
}
  1. 避免重复创建 ref
function Video() {
// new VideoPlayer() 的结果只会在首次渲染时使用,但是每次渲染时都在调用这个方法。
  const playerRef = useRef(new VideoPlayer());
  // ...
 }

function Video() {
  const playerRef = useRef(null);
  if (playerRef.current === null) {
    playerRef.current = new VideoPlayer();
  }
  // ...
 }

我们在新建一个状态(state)的时候,也需要想清楚它是否是一个状态,还是说可以使用 ref 进行一个替代

useEffect

旧文档:Effect Hook 可以在函数组件中执行副作用操作

新文档:useEffect 可将组件与外部系统同步。 比如非 React 组件、网络和浏览器 DOM 等。

常见用法

1. 请求数据

import { useState, useEffect } from 'react';
import { fetchBio } from './api.js';

export default function Page() {
  const [person, setPerson] = useState('Alice');
  const [bio, setBio] = useState(null);

  useEffect(() => {
    let ignore = false;
    setBio(null);
    fetchBio(person).then(result => {
      if (!ignore) {
        setBio(result);
      }
    });
    return () => {
      ignore = true;
    };
  }, [person]);

 return  // ....

}

这里设置的 ignore 可以确保请求数据不受 竞态请求 影响,之前做过处理竞态是通过取消请求,这里是请求完成但是取消渲染。

但是在 useEffect 中请求数据也存在一些问题:

React 推荐的方案:

2. 控制非 React 小部件:例如 echact 3. 绑定DOM事件

去掉不必要的 useEffect

如果没有连接外部系统,可能并不需要 useEffect。

实际上,在大多数情况下,我们是将 useEffect 当成一个 watcher 来使用。 但是这样的watcher是低效的。

function Form() {
  const [count, setCount] = useState(0);
  const [data, setData] = useState('');

  const fetchSome = () => {
      //...
      setData(someData)
      //...
  }

  useEffect(() => {
    fetchSome()
  }, [count]);

  return <>
    <div>{data}</div>
    <div>{count}</div>
    <button 
        onClick={() => setCount(prev => {
            return prev++
        })}
    >
    </button>
  </>
}

上述代码点击button后的逻辑是:更新 state -> render -> 触发 useEffect -> 开始获取数据 -> 更新state -> render

但是我们在点击 button 的时候直接去获取数据的话: 更新 state -> 开始获取数据 -> render -> 更新state -> render

现在官网罗列了一些场景来帮助我们移除掉不必要的 useEffect

这里总结几点常见的场景:

1. 缓存计算

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');

  const [visibleTodos, setVisibleTodos] = useState([]);
  useEffect(() => {
    setVisibleTodos(getFilteredTodos(todos, filter));
  }, [todos, filter]);

  // ...
}

// 这里如果计算很简单的话,每次渲染计算并没有关系
function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  const visibleTodos = getFilteredTodos(todos, filter);
  // ...
}

// 计算复杂的话,使用 useMemo
function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  const visibleTodos = useMemo(() => {
    return getFilteredTodos(todos, filter);
  }, [todos, filter]);
  // ...
}

2. props变化时需要 state 重置

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

  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}

// 可以在使用 ProfilePage 组件的时候传一个 key,利用 react diff 来进行组件的重置

3. 根据 props 或 state 来更新 state

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

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

  // 直接进行计算
  const fullName = firstName + ' ' + lastName;
}

还有一种是根据 props 初始化 state。

let isInit = true

function Form({text}) {
    const [fullName, setFullName] = useState('');
    if(isInit && text) {
        setFullName(text)
        isInit = false
    }
}

function Form({text}) {
    const [fullName, setFullName] = useState('');
    useEffect(() => {
        if(text) {
            setFullName(text)
        }
    }, [text])
}

4. 当 prop 变化时调整部分 state

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

  // 当 prop 变化时,在 Effect 中调整 state
  // 这样的渲染流程是 items 变化 -> render -> 触发useEffect -> state 更新 -> render
  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
}

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

  // 在渲染期间调整 state
  // const [prevItems, setPrevItems] = useState(items);
  // 或者更好
  const prevItems = useRef(items)
  if (items !== prevItems.current) {
    prevItems.current = item
    setSelection(null);
  }
  // ...
}

5. 作为一个 watcher 共享业务逻辑

function ProductPage({ product, addToCart }) {
  // 避免:在 Effect 中处理属于事件特定的逻辑
  // 区别于 watcher,useEffect 会在初次渲染时执行
  // 所以每次 ProductPage 初始化的时候都会跳出提示信息
  useEffect(() => {
    if (product.isInCart) {
      message(`已添加 ${product.name} 进购物车!`);
    }
  }, [product]);

  function handleBuyClick() {
    addToCart(product);
  }

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

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

  function handleBuyClick() {
    buyProduct();
  }

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

6. 发送 POST 请求

function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // const [jsonToSubmit, setJsonToSubmit] = useState(null);
  // useEffect(() => {
  //   if (jsonToSubmit !== null) {
  //     post('/api/register', jsonToSubmit);
  //   }
  // }, [jsonToSubmit]);

  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  function handleSubmit(e) {
    e.preventDefault();
    post('/api/register', { firstName, lastName });
  }
  // ...
}

7. 初始化应用

react 18 的开发模式会执行两次渲染

function App() {
  // 避免:把只需要执行一次的逻辑放在 Effect 中
  // 在react 18 的开发环境中会执行两次,
  useEffect(() => {
    loadDataFromLocalStorage();
    checkAuthToken();
  }, []);
  // ...
}

let didInit = false;

function App() {
  useEffect(() => {
    if (!didInit) {
      didInit = true;
      loadDataFromLocalStorage();
      checkAuthToken();
    }
  }, []);
  // ...
}

// 或者 loadDataFromLocalStorage、checkAuthToken 不依赖 dom 相关
let didInit = false;
function App() {
  if (!didInit) {
      didInit = true;
      loadDataFromLocalStorage();
      checkAuthToken();
  }
}

7. 子组件状态变化通知父组件,或者子组件传递数据给父组件

function Parent() {
  const [data, setData] = useState(null);

  const onChange = (val) => {
      setData(val)
  }
  // ...
  return <Child onFetched={setData} onChange={onChange} />;
}

function Child({ onFetched, onChange }) {
  const [isOn, setIsOn] = useState(false);
  const data = useSomeAPI();

  // 避免:onChange 处理函数执行的时间太晚了
  useEffect(() => {
    onChange(isOn);
  }, [isOn, onChange])

  // 避免:在 Effect 中传递数据给父组件
  useEffect(() => {
    if (data) {
      onFetched(data);
    }
  }, [onFetched, data]);
  // ...
}

// 灵活使用状态提升
function Parent() {
const [isOn, setIsOn] = useState(false);
  const data = useSomeAPI();

  const onChange = (val) => {
      setData(val)
  }
  // ...
  return <Child data={data} isOn={isOn} onChange={onChange} />;
}

function Child({ data, isOn, onChange }) {

  function handleClick() {
    onChange(!isOn);
  }

}

useEffect 注意点

1. 指定依赖 useEffect 会需要我们指定响应式依赖,但是我们会碰到 react-hooks/exhaustive-deps 代码检查工具的提示, 如果出现这个提示就需要考虑是否这个依赖项是必要的

2. 关于对象、方法依赖 因为 useEffect 是通过 Object.is 来对比依赖是否更新。如果是普通方法的话,每次渲染方法都会重新赋值,就会触发 useEffect。 需要区别 Object.is 与 '===' 的区别:

console.log(NaN === NaN) // false
console.log(Object.is(NaN, NaN)) // true

console.log(+0 === -0) // true
console.log(Object.is(+0, -0)) // false