React Hooks 实战要点

218 阅读9分钟

useState

昂贵的初始值

initialState 参数只会在组件的初始渲染中起作用,后续渲染时会被忽略。如果初始 state 需要通过复杂计算获得,则可以传入一个函数,在函数中计算并返回初始的 state,此函数只在初始渲染时被调用:

const [state, setState] = useState(() => {
  const initialState = someExpensiveComputation(props);
  return initialState;
});

如果我们不使用函数而使用如下形式,此时 useState 的初始值 someExpensiveComputation(props) 每次渲染都会执行,但除初始渲染外,后续的每次渲染计算得到的值均会被忽略,而造成了资源的浪费。

const [state, setState] = useState(someExpensiveComputation(props));

函数式更新

如果新的 state 需要通过使用先前的 state 计算得出,那么可以将函数传递给 setState。该函数将接收先前的 state,并返回一个更新后的值。

例如下面这个例子,定时器每1秒,count加1。其中 useEffect 的依赖须增加 count 的依赖,否则就会存在闭包问题,count的值一直为 1。但加了 count 依赖后,每次 count 的更新,都会触发定时器的销毁与重新创建,这不是我们所需要的,这个时候我们可以使用函数式更新的方式。

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // 这个 effect 依赖于 `count` state    
    }, 1000);
    return () => clearInterval(id);
  }, [count]);
  
  return <h1>{count}</h1>;
}

更换为函数式更新后,useEffect 则不需要任何依赖,因为 set 函数是稳定的,不会变化。

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count => count + 1); 
    }, 1000);
    return () => clearInterval(id);
  }, []); // 这里没有任何依赖
  
  return <h1>{count}</h1>;
}

批量更新原则

React 内部遵循的是批量更新原则。

所谓异步批量是指在一次页面更新中如果涉及多次 state 修改时,会合并多次 state 修改的结果得到最终结果从而进行一次页面更新。

关于批量更新原则也仅仅在合成事件中通过开启 isBatchUpdating 状态才会开启批量更新,简单来说:

  1. 凡是React可以管控的地方,他就是异步批量更新。比如事件函数,生命周期函数中,组件内部同步代码。
  2. 凡是React不能管控的地方,就是同步批量更新。比如setTimeout,setInterval,原生DOM事件中,包括Promise都是同步批量更新。

在 React 18 中通过 createRoot 中对外部事件处理程序进行批量处理,换句话说最新的 React 中关于 setTimeout、setInterval 等不能管控的地方都变为了批量更新。

声明 State 的一些原则

最小化状态

能用其他状态计算出来就不要单独声明状态。 一个 state 必须不能通过其它 state/props 直接计算出来,否则就不用定义 state。

const SomeComponent = (props) => {
  
  const [source, setSource] = useState([
      {type: 'done', value: 1},
      {type: 'doing', value: 2},
  ])
  
  const [doneSource, setDoneSource] = useState([])  // 可计算得出,不用定义
  const [doingSource, setDoingSource] = useState([]) // 可计算得出,不用定义

  useEffect(() => {
    setDoingSource(source.filter(item => item.type === 'doing'))
    setDoneSource(source.filter(item => item.type === 'done'))
  }, [source])
  
  return (
    <div>
       ..... 
    </div>
  )
}

上面的示例中,变量 doneSource 和 doingSource 可以通过变量 source 计算出来,那就不要定义 doneSource 和 doingSource 了!

保证数据源唯一

在项目中同一个数据,保证只存储在一个地方。

  • 不要既存在 redux 中,又在组件中定义了一个 state 存储;
  • 不要既存在父级组件中,又在当前组件中定义了一个 state 存储;
  • 不要既存在 url query 中,又在组件中定义了一个 state 存储。
function SearchBox({ data }) {
  const [searchKey, setSearchKey] = useState(getQuery('key'));
  
  const handleSearchChange = e => {
    const key = e.target.value;
    setSearchKey(key);
    history.push(`/movie-list?key=${key}`);
  }
  
  return (
      <input
        value={searchKey}
        placeholder="Search..."
        onChange={handleSearchChange}
      />
  );
}

在上面的示例中,searchKey 存储在两个地方,既在 url query 上,又定义了一个 state。完全可以优化成下面这样:

function SearchBox({ data }) {
  const searchKey = parse(localtion.search)?.key;
  
  const handleSearchChange = e => {
    const key = e.target.value;
    history.push(`/movie-list?key=${key}`);
  }
  
  return (
      <input
        value={searchKey}
        placeholder="Search..."
        onChange={handleSearchChange}
      />
  );
}

适当合并

const [firstName, setFirstName] = useState();
const [lastName, setLastName] = useState();
const [school, setSchool] = useState();
const [age, setAge] = useState();
const [address, setAddress] = useState();

const [weather, setWeather] = useState();
const [room, setRoom] = useState();

同样含义的变量可以合并成一个 state,代码可读性会提升很多:

const [userInfo, setUserInfo] = useState({
  firstName,
  lastName,
  school,
  age,
  address
});

const [weather, setWeather] = useState();
const [room, setRoom] = useState();

当然这种方式我们在变更变量时,一定不要忘记带上老的字段,比如我们只想修改 firstName

setUserInfo(s=> ({
  ...s,
  fristName,
}))

React Class 组件,state 是会自动合并的:

this.setState({
  firstName
})

我们可以使用 ahooks 的 useSetState,其封装了类似的逻辑:

const [userInfo, setUserInfo] = useSetState({
  firstName,
  lastName,
  school,
  age,
  address
});

// 自动合并
setUserInfo({
  firstName
})

useRef 的使用

惰性初始值

有时我们想要避免重新创建 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()
  // ...
}

使用 ahooks 中的 useCreation 来实现 useRef 函数式初始值的功能:

const a = useRef(new Subject()); // 每次重渲染,都会执行实例化 Subject 的过程,即便这个实例立刻就被扔掉了
const b = useCreation(() => new Subject(), []); // 通过 factory 函数,可以避免性能隐患

避免 useCallback 的使用

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

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

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

上述代码,自然而然会觉得 method2 的性能要高一点,因为 method2 除非是依赖变了,不然不会重新生成,然而这是不正确的。

首先,每次执行函数,都重新生成一下它内部的变量这件事,开销是可以忽略不计的,这一点,官网的 Hooks FAQ 给出了我们相关的结论:

image.png

另外,useCallback 也一样每次都会生成新的函数(useCallbck 第一个参数对应的函数也需内存),只不过它生成了没有使用罢了。

一个典型的例子

我们拿官网的这个例子来举例:

function ProductPage({ productId }) {
  const [product, setProduct] = useState(null);

  async function fetchProduct() {
    const response = await fetch('http://myapi/product/' + productId); // 使用了 productId prop    
    const json = await response.json();
    setProduct(json);
  }

  useEffect(() => {
    fetchProduct();
  }, []); // 🔴 这样是无效的,因为 `fetchProduct` 使用了 `productId`  
  // ...
}

这里的 useEffect 依赖存在问题,自然而然我们会想到做如下修改:

function ProductPage({ productId }) {
  const [product, setProduct] = useState(null);

  const fetchProduct = useCallback(async () => {
    const response = await fetch('http://myapi/product/' + productId); 
    const json = await response.json();
    setProduct(json);
  }, [productId])

  useEffect(() => {
    fetchProduct();
  }, [fetchProduct]);
}

虽然这样能解决我们的问题,但这种方式 React 官网是不推荐的,最佳的做法是将函数移到 effect 内部

image.png

若函数不能被移到 effect 内部,官网也提供了几点做法,但 useCallback 也是万不得已情况下才使用:

image.png

如果借助 ahooks 的 useMemoizedFn 也可以完美的解决该问题:

在某些场景中,我们需要使用 useCallback 来记住一个函数,但是在第二个参数 deps 变化时,会重新生成函数,导致函数地址变化。

const [state, setState] = useState('');

// 在 state 变化时,func 地址会变化
const func = useCallback(() => {
  console.log(state);
}, [state]);

使用 useMemoizedFn,可以省略第二个参数 deps,同时保证函数地址永远不会变化。

const [state, setState] = useState('');

// func 地址永远不会变化
const func = useMemoizedFn(() => {
  console.log(state);
});

useCallback 使用场景

假设我们有一个叫做 Counter 的子组件,初始化渲染的时候消耗非常大:

<ExpensiveCounter count={count} onClick={handleClick} />

如果我们不做任何优化,父组件有了任何更新,都会重新渲染 Counter。为了避免每次渲染父组件的时候都重新渲染子组件,我们可以使用 React.memo

const ExpensiveCounter = React.memo(function Counter(props) {
    ...
})

使用 React.memo 包裹之后,Counter 组件只有在 props 发生变化的时候才会重新渲染,我们的 Counter 接受两个 props:原始值 count,函数 handleClick

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

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

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

这样,我们每次传递给 Counter 组件的 handleClick 都是同一个,我们的 Counter 组件只有在 count 发生变化的时候才会去渲染,这正是我们想要的,也就起到了很好的优化作用。

使用 useReducer + context 实现深层次的组件通信

参考:官网 Hooks FAQ:如何避免向下传递回调?

const TodosDispatch = React.createContext(null);

function TodosApp() {
  // 提示:`dispatch` 不会在重新渲染之间变化  
  const [todos, dispatch] = useReducer(todosReducer);
  return (
    <TodosDispatch.Provider value={dispatch}>
      <DeepTree todos={todos} />
    </TodosDispatch.Provider>
  );
}

TodosApp 内部组件树里的任何子节点都可以使用 dispatch 函数来向上传递 actions 到 TodosApp

function DeepChild(props) {
  // 如果我们想要执行一个 action,我们可以从 context 中获取 dispatch。  
  const dispatch = useContext(TodosDispatch);
  function handleClick() {
    dispatch({ type: 'add', text: 'hello' });
  }

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

总而言之,从维护的角度来这样看更加方便(不用不断转发回调),同时也避免了回调的问题。像这样向下传递 dispatch 是处理深度更新的推荐模式。

如果你选择使用 context 来向下传递 state,请使用两种不同的 context 类型传递 state 和 dispatch —— 由于 dispatch context 永远不会变,因此读取它的组件不需要重新渲染,除非这些组件也需要用到应用程序的 state。

useLayoutEffect

useLayoutEffect 与 useEffect 使用方式是完全一致的,useLayoutEffect 的区别在于它会在所有的 DOM 变更之后同步调用 effect。

可以使用它来读取 DOM 布局并同步触发重渲染。在浏览器执行绘制之前, useLayoutEffect 内部的更新计划将被同步刷新。

通常对于一些通过 JS 计算的布局,如果你想减少 useEffect 带来的「页面抖动」,你可以考虑使用 useLayoutEffect 来代替它。

如何测量 DOM 节点?

获取 DOM 节点的位置或是大小的基本方式是使用 callback ref。每当 ref 被附加到一个另一个节点,React 就会调用 callback。

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 可以确保即便被测量的节点延迟显示,我们依然能够接收到相关的信息,以便更新测量结果。

在此示例中,当且仅当组件挂载卸载时,callback ref 才会被调用。如果你希望在每次组件调整大小时都收到通知,则可能需要使用 ResizeObserver 或基于其构建的第三方 Hook。

参考

  1. 官网 React Hooks FAQ
  2. 为什么要放弃使用 useCallback
  3. 宝啊~来聊聊 9 种 React Hook
  4. React Hooks 使用误区,驳官方文档