React-Hooks

文章目录

React Hooks 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。

使用hook的主要作用是为了让组件内的逻辑更加清晰,并且可以通过自定义hook来达到复用逻辑。使组件内的逻辑复用性强,且功能清晰明了。

React Hooks 特性:

  1. 多个状态不会产生嵌套,写法还是平铺的
  2. 允许函数组件使用 state 和部分生命周期
  3. 更容易将组件的 UI 与状态分离

注意:hook只能使用在函数组件中,class组件不支持,所有hook默认约定名称用use开头

一、useState :组件内状态更新

useState主要用于给组件添加状态变量

useState 接收一个初始值,返回一个数组,数组里面分别是当前值和修改这个值的方法(类似 state 和 setState)。

setState是异步执行。

const [state, setState] = useState(initialState); 
//state为变量,setState 修改 state值的方法, setState也是异步执行。

2.1 useState 函数式更新

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

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

function handleClick() {
    setCount(count + 1)
}
  
function handleClickFn() {
    setCount((prevCount) => {
      return prevCount + 1
    })
}

两种方式的区别:一个是通过=新的 state 值更新,一个是通过函数式更新返回新的 state。

在异步操作中,handleClickFn永远获取最新的值进行修改,handleClick可能丢失更新。

注意:使用useState之后,不能马上获取它的值

setCount(0)
let a = count;  //可能会丢失更新

三、useEffect :副作用钩子

作用:处理函数组件中的副作用,如异步操作、延迟操作等。
可以替代componentDidMountcomponentDidUpdatecomponentWillUnmount等生命周期。

	// 请求菜单数据
    React.useEffect(() => {
        dispatch(queryAppsAction());
    }, []);
  1. useEffect 接收两个参数,分别是要执行的回调函数、依赖数组。
  2. 如果依赖数组为空数组,那么回调函数会在第一次渲染结束后(componentDidMount)执行,返回的函数会在组件卸载时(componentWillUnmount)执行。
  3. 如果不传数组,那么回调函数会在每一次渲染结束后(componentDidMountcomponentDidUpdate)执行,返回的函数会在组件卸载时(componentWillUnmount)执行。
  4. 如果依赖数组不为空数组,那么回调函数会在第一次渲染结束后(componentDidMount)执行,会在依赖值每次更新渲染结束后(componentDidUpdate)执行,这个依赖值一般是 state 或者 props

useEffect callback是在组件被渲染为真实DOM后执行(所以可以用于DOM操作)

可以在useEffect 中发起异步请求,并在接受数据后,调用 state 的更新函数,不会发生爆栈的情况。

3.1 在useEffect中的异步操作

每个async函数都会默认返回一个隐式的promise。但是,useEffect不应该返回任何内容。不能直接在useEffect中使用async函数

因此,我们可以不直接调用async函数,而是像下面这样:

const fetchData = async () => {
   const result = await axios('http://localhost/api/v1/search?query=redux');
   setData(result.data);
};

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

3.2 useEffect清理副作用

useEffect传入的callback返回一个函数,在组件卸载时将会执行这个函数,从而达到清理副作用的效果

应用场景1:清除定时器

useEffect(() => {
    const timer = setInterval(() => {
        console.log('console once per second.');
    }, 1000);
    
    return () => {
        clearInterval(timer);
    };
}, []);

应用场景2:事件监听器

useEffect(() => {
    const handleResize = () => {
        console.log("Window resized!");
    };

    window.addEventListener('resize', handleResize);
    
    return () => {
        window.removeEventListener('resize', handleResize);
    };
}, []);

应用场景3:WebSockets聊天室

useEffect(() => {
    const socket = new WebSocket('ws://weijunext.com/socket');

    socket.onmessage = (event) => {
        console.log(event.data);
    };
    
    return () => {
        socket.close();
    };
}, []);

应用场景4:请求的取消。对于可能在组件卸载后才完成的异步请求(如使用 Axios 发起的 HTTP 请求),你应该在组件卸载前取消它们,以防止设置已卸载组件的状态。

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

    axios.get('/api/some-endpoint', { cancelToken: source.token })
        .then(response => {
            console.log(response.data);
        })
        .catch(error => {
            if (axios.isCancel(error)) {
                console.log('Request cancelled');
            } else {
                console.error(error);
            }
        });
    
    return () => {
        source.cancel('Component unmounted');
    };
}, []);

3.3 跳过初始渲染

在某些情况下,当组件首次渲染时,我们不希望立即执行某些操作。

import React, { useState, useEffect, useRef } from 'react';

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

    useEffect(() => {
        if (mounted.current) {
            alert('Count value changed!');
        } else {
            mounted.current = true;
        }
    }, [count]);

    return (
        <div>
            <button onClick={() => setCount(count + 1)}>Increase</button>
            <p>Count: {count}</p>
        </div>
    );
}

export default Counter;

四、useContext : 跨层级共享状态

跨组件共享数据的钩子函数。

useContext 允许我们以扁平化的形式获取到 Context 数据。即使有多个祖先组件使用多个 Context.Provider 传值,我们也可以扁平化获取到每一个 Context 数据。

  • 使用React.createContext创建Context,由于支持在组件外部调用,因此可以实现状态共享
  • 使用Context.Provider在上层组件挂载状态
  • 调用useContext,传入从React.createContext获取的上下文对象。
  • 解决了 Consumer 难用的问题和contextType 只能使用一个 context 的问题。

使用 Context 的典型方法如下所示:

import React from "react";
import ReactDOM from "react-dom";

// 创建 Context
const NumberContext = React.createContext();
// 它返回一个具有两个值的对象
// { Provider, Consumer }

function App() {
  // 使用 Provider 为所有子孙代提供 value 值 
  return (
    <NumberContext.Provider value={42}>
      <div>
        <Display />
      </div>
    </NumberContext.Provider>
  );
}

function Display() {
  // 使用 Consumer 从上下文中获取 value
  return (
    <NumberContext.Consumer>
      {value => <div>The answer is {value}.</div>}
    </NumberContext.Consumer>
  );
}

ReactDOM.render(<App />, document.querySelector("#root"));

使用 useContext 来重写上面的示例

import React, { useContext } from 'react';

// ...

function Display() {
  const value = useContext(NumberContext);
  return <div>The answer is {value}.</div>;
}

useContext 接收一个context 对象(React.createContext 的返回值)并返回该 context 的当前值。当前的 context 值由上层组件中距离当前组件最近的 <CountContext.Provider>value prop 决定。

当组件上层最近的 <CountContext.Provider> 更新时,该 Hook 会触发重渲染,并使用最新传递给 CountContext provider 的 context value 值。

4.1 嵌套的 Consumers

组件需要从多个父级上下文中接收数据,从而导致这样的代码

function HeaderBar() {
  return (
    <CurrentUser.Consumer>
      {user =>
        <Notifications.Consumer>
          {notifications =>
            <header>
              Welcome back, {user.name}!
              You have {notifications.length} notifications.
            </header>
          }
      }
    </CurrentUser.Consumer>
  );
}

这种大量嵌套只是为了接收两个值。下面是使用useContext时的效果:

function HeaderBar() {
  const user = useContext(CurrentUser);
  const notifications = useContext(Notifications);

  return (
    <header>
      Welcome back, {user.name}!
      You have {notifications.length} notifications.
    </header>
  );
}

五、useReducer:组件复杂状态更新

随着应用逐渐复杂,我们经常发现useState在管理复杂的状态逻辑时显得有些力不从心。这时,React为我们提供的另一个更为强大的hook:useReducer可以帮助我们优雅地处理复杂状态。

useReducer允许我们使用 actionreducer 的方式来组织复杂的状态逻辑,使其变得更加清晰和模块化,弥补了useState的局限性。

const [state, dispatch] = useReducer(reducer, initialArg, init?)

useReducer接收三个参数:

  • reducer 函数:指定如何更新状态的还原函数,它必须是纯函数,以 state 和 dispatch 为参数,并返回下一个状态。
  • initialArg初始状态:初始状态的计算值。
  • init(可选的)初始化参数:用于返回初始状态。如果未指定,初始状态将设置为 initialArg;如果有指定,初始状态将被设置为调用init(initialArg)的结果。

useReducer返回两个参数:

  • state 当前的状态:当前状态。在第一次渲染时,它会被设置为init(initialArg)或 initialArg(如果没有 init 的情况下)。
  • dispatch调度函数:用于调用 reducer 函数,以更新状态并触发重新渲染。

通常情况下,我们只会用到useReducer的前两个参数,如这个计数器组件:

const initialState = { count: 0 };

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'change':
      return { count: action.num};
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'change',num: 10 })}>change10</button>
    </>
  );
}

使用dispatch的注意事项

  • dispatch调用后,状态更新是异步的,因此立刻读取状态可能仍是旧的。
  • dispatch有一个优化机制:如果dispatch触发更新前后的值相等(使用Object.is判断),实际上 React 不会进行重新渲染,这是出于性能考虑。

使用reducer函数的注意事项

  • 在reducer里面更新对象和数组的状态,需要创建一个新的对象或数组,而不是在原对象和数组上修改,这一点和useState是一样的。

6.1 初始化状态:使用init函数

init,也是为了性能优化而来。

function init(initialValue) {
  // 尝试从localStorage中读取值
  const savedCount = localStorage.getItem("count");

  // 如果有值并且可以被解析为数字,则返回它,否则返回initialValue
  return { count: savedCount ? Number(savedCount) : initialValue };
}

function counterReducer(state, action) {
  switch (action.type) {
    case "INCREMENT":
      return { count: state.count + 1 };
    case "DECREMENT":
      return { count: state.count - 1 };
    default:
      return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(counterReducer, 0, init);

  // 使用useEffect来监听状态的变化,并将其保存到localStorage
  useEffect(() => {
    localStorage.setItem("count", state.count);
  }, [state.count]);

  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: "INCREMENT" })}>+1</button>
      <button onClick={() => dispatch({ type: "DECREMENT" })}>-1</button>
    </>
  );
}

在这里插入图片描述

6.2 与useContext一起使用

结合useContextuseReducer可以创建简单的全局状态管理系统。

我们就以此来尝试创建一个完整的主题切换系统:

1.定义状态、reducer 和 context:

const ThemeContext = React.createContext();

const initialState = { theme: 'light' };

function themeReducer(state, action) {
    switch (action.type) {
        case 'TOGGLE_THEME':
            return { theme: state.theme === 'light' ? 'dark' : 'light' };
        default:
            return state;
    }
}

2.接下来,创建一个Provider组件:

function ThemeProvider({ children }) {
    const [state, dispatch] = useReducer(themeReducer, initialState);

    return (
        <ThemeContext.Provider value={{ theme: state.theme, toggleTheme: () => dispatch({ type: 'TOGGLE_THEME' }) }}>
            {children}
        </ThemeContext.Provider>
    );
}

3.在子组件中,你可以轻松切换和读取主题:

function ThemedButton() {
    const { theme, toggleTheme } = useContext(ThemeContext);

    return (
        <button style={{ backgroundColor: theme === 'light' ? '#fff' : '#000' }} onClick={toggleTheme}>
            Toggle Theme
        </button>
    );
}

6.3 useReducer与 Redux 的差异

虽然useReducerRedux 都采用了 actionreducer 的模式来处理状态,但它们在实现和使用上有几个主要的区别:

  • 范围:useReducer通常在组件或小型应用中使用,而Redux被设计为大型应用的全局状态管理工具。
  • 中间件和扩展:Redux支持中间件,这允许开发者插入自定义逻辑,例如日志、异步操作等。而useReducer本身不直接支持,但我们可以模拟中间件的效果。
  • 复杂性:对于简单的状态管理,useReducer通常更简单和直接。但当涉及到复杂的状态逻辑和中间件时,Redux可能更具优势。

六、useRef

// 定义
const inputRef = useRef(null);

// 使用
console.log(inputRef.current)

useRef返回一个可变的 ref 对象,通过.current可以获取保存在useRef的值。

useRef的几个特点

  • 持久性:useRef的返回对象在组件的整个生命周期中都是持久的,而不是每次渲染都重新创建。
  • 不会触发渲染:当useState中的状态改变时,组件会重新渲染。而当useRef.current属性改变时,组件不会重新渲染。

6.1 useRef 获取DOM元素

通过useRef获取input框DOM元素

import React, { useRef} from 'react';
function TextInput(){
    const inputRef= useRef(null)
    const onButtonClick=()=>{
        inputRef.current.value="Hello ,JSPang"
        console.log(inputEl) //输出获取到的DOM节点
    }
     const focusInput=()=>{
        inputRef.current.focus();
    }
    return (
        <>
            {/*保存input的ref到inputRef */}
            <input ref={inputRef} type="text"/>
            <button onClick = {onButtonClick}>在input上展示文字</button>
        </>
    )
}
export default TextInput

6.2 保存状态但不触发渲染

有时,你可能需要在组件中保存某些值,而不希望每次该值更改时都重新渲染组件。在这种情况下,useRef很有用。

function Timer() {
  const count = useRef(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      count.current += 1;
      console.log(`Elapsed time: ${count.current} seconds`);
    }, 1000);

    return () => clearInterval(intervalId);
  }, []);

  return <div>Check the console to see the elapsed time!</div>;
}

6.3 保存上一次的 props 或 state

在某些情况下,你可能需要知道 propsstate 的上一次值。这时可以使用useRef结合useEffect来达到目的。

function DisplayValue({ value }) {
  const [prevValue, setPrevValue] = useState(null); // 初始时,没有前一个值
  const previousValue = useRef(value);

  useEffect(() => {
    setPrevValue(previousValue.current);
    previousValue.current = value;
  }, [value]);

  return (
    <div>
      Current Value: {value} <br />
      Previous Value: {prevValue}
    </div>
  );
}

当组件首次渲染时,previousValue.current会被初始化为value的当前值。随后,每当value发生变化时,useEffect都会运行并更新previousValue.current为新的value

但这里有一个微妙之处:由于useEffect是在组件渲染之后运行的,因此在组件的渲染过程中,previousValue.current的值是从前一次渲染中保持不变的。只有当useEffect被调用并执行完毕后,previousValue.current才会更新为新的value

6.4 useRef 获取子组件属性

当useRef获取子组件的属性时,保证获取到的值总是最新的

    const checkForm = React.useRef<Function>();
    
    <ApiManageApplyDialog getHandleSubmit={(postMyApps: Function) =>
                        checkForm.current = postMyApps
                    }/>
    
    
    checkForm.current && checkForm.current();

6.5 跨越渲染周期存储数据

类组件中,state不能存储跨渲染周期的组件,因为state的参数每一次保存都会触发组件的重渲染。

那么这个时候就可以使用useRef来跨越渲染周期存储数据,而且对它修改也不会引起组件渲染。

import React, { useState, useEffect, useMemo, useRef } from 'react';

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

  const doubleCount = useMemo(() => {
    return 2 * count;
  }, [count]);

  const timerID = useRef();
  
  // 初始化时调用
  useEffect(() => {
    timerID.current = setInterval(()=>{
        setCount(count => count + 1);
    }, 1000); 
  }, []);
  
  // 每次更新渲染后调用
  useEffect(()=>{
      if(count > 10){
          clearInterval(timerID.current);
      }
  });
  
  return (
    <>
      <button ref={couterRef} onClick={() => {setCount(count + 1)}}>Count: {count}, double: {doubleCount}</button>
    </>
  );
}

在上面的例子中,我用ref对象的current属性来存储定时器的ID,这样便可以在多次渲染之后依旧保存定时器ID,从而能正常清除定时器。

七、useMemo 缓存

useMemo 核心目的是通过缓存计算结果,避免在组件渲染时进行不必要的重复计算,从而优化性能。这意味着只有当其依赖项发生变化时,useMemo才会重新计算这个值,否则它将重用之前的结果。

useMemo 计算结果是 return 回来的值, 主要用于缓存计算结果的值 ,应用场景如: 需要计算的状态

useEffect只能在DOM更新后再触发,useMemo是在DOM更新前触发的

const sum = useMemo(() => {
    // 一系列计算
}, [count])

7.1 缓存组件

如果你在渲染某个组件时有昂贵的计算,并且你想在某些依赖未改变时避免这些计算,那么也可以使用useMemo来缓存这个组件。

import React, { useMemo } from 'react';

export default (props = {}) => {
    return useMemo(() => {
        return <div>
            {/* <p>step is : {props.step}</p> */}
            {/* <p>count is : {props.count}</p> */}
            <p>number is : {props.number}</p>
        </div>
    }, [props.number]);
}

除了useMemo能够缓存组件,React 还提供了memo这个高阶组件来完成相似的工作。

const UserList = memo(function UserList({ allUsers, searchTerm }) {
    const filteredUsers = filterUsers(allUsers, searchTerm);
    return (
        <>
          {filteredUsers.map((user) => (
              <div key={user.id}>
                  {user.name}
              </div>
          ))}
        </>
    );
});

使用memo高阶组件包裹后,只有当props发生变化时重新渲染。这种方式和上面使用useMemo缓存组件的作用是一样的,但代码可读性更强,也是React官方更推荐的方式。
考虑到useMemomemo的特点,我们可以这么说:

  • 当你想避免因为数据变化而产生的不必要的计算时,使用useMemo
  • 当你想避免因为props未变而产生的不必要的组件重渲染时,使用memo

7.2 缓存函数

除了缓存数据和组件,useMemo还能够缓存函数。你只需要在useMemoreturn一个函数即可,如下:

const handleUserClick = useMemo(() => {
  return (userName) => {
    alert(`Clicked on: ${userName}`);
  };
}, []);

缓存函数推荐使用useCallback

7.3 useMemo缓存路由

    return useMemo(() => {
        return {
            push: history.push,
            replace: history.replace,
            pathname: location.pathname,
            query: {
                ...queryString.parse(location.search),
                ...params
            },
            match,
            location,
            history
        };
    }, [params, match, location, history]);

7.4 useMemo存储数据

    const formItemSummary = React.useMemo(() => [
        {
            name: "summary",
            label: formatMessage({id: "content"}), // 内容摘要
            type: "inputRows"
        }
    ], []);

八、useCallback 缓存函数

useCallback是对useMemo的特化,它可以返回一个缓存版本的函数,只有当它的依赖项改变时,函数才会被重新创建。这意味着如果依赖没有改变,函数引用保持不变,从而避免了因函数引用改变导致的不必要的重新渲染。

因为函数式组件每次任何一个 state 的变化 整个组件 都会被重新刷新,一些函数是没有必要被重新刷新的,此时就应该缓存起来,提高性能,和减少资源浪费。

和 useMemo 类似,只不过 useCallback 是用来缓存函数。

  • useCallback的本质是对函数进行依赖分析,依赖变更时才重新执行
  • useCallback 常常配合 React.memo 来一起使用,用于进行性能优化。
    const [coords, setCoords] = useState({x: 0, y: 0});
    
    const handler = useCallback(
        ({clientX, clientY}) => {
            setCoords({x: clientX, y: clientY});
        },
        [setCoords]
    );

useCallback背后的原理是利用闭包和React的调度机制来存储并在必要时重建函数。与直接在组件内创建函数相比,使用useCallback需要付出额外的开销,因为它涉及到存储和检索函数的机制。因此,除非在特定的性能敏感场景中(例如大型列表渲染、频繁的状态更新、与React.memo一同使用等),否则不建议盲目使用它。

8.1 与React.memo一同使用

当将函数作为props传递给子组件时,因为父组件的重新渲染,导致函数方法的内存地址发生变化,所以React.memo会认为props有变化,导致子组件重复渲染。

可以使用React.useCallback来缓存函数方法,避免子组件的重复渲染。

子组件:(子组件仍然用 React.memo() 包裹一层)

import React, { memo } from 'react'

const ChildComp = memo(function ({ name, onClick }) {
  console.log('render child-comp ...')
  return <>
    <div>Child Comp ... {name}</div>
    <button onClick={() => onClick('hello')}>改变 name 值</button>
  </>
})

父组件:

import React, { useCallback } from 'react'

function ParentComp () {
  // ...
  const [ name, setName ] = useState('hi~')
  // 每次父组件渲染,返回的是同一个函数引用
  const changeName = useCallback((newName) => setName(newName), [])  

  return (
    <div>
      <button onClick={increment}>点击次数:{count}</button>
      <ChildComp name={name} onClick={changeName}/>
    </div>
  );
}

useCallback() 起到了缓存的作用,即便父组件渲染了,useCallback() 包裹的函数也不会重新生成,会返回上一次的函数引用。

同理,要避免在子组件的传入参数上直接写匿名函数。

// 不能直接写匿名函数,绑定事件上的匿名函数不能解绑
Child onFnc={() => console.log('这是传入child的方法')} />

8.2 useMemo 和 useCallback 的差异

用途与缓存的内容不同:

  • useMemo: 用于缓存复杂函数的计算结果或者构造的值。它返回缓存的结果。
  • useCallback: 用于缓存函数本身,确保函数的引用在依赖没有改变时保持稳定。

底层关联:
从本质上说,useCallback(fn, deps)就是useMemo(() => fn, deps)的语法糖:

function useCallback(fn, dependencies) {
  return useMemo(() => fn, dependencies);
}

8.3 什么时候使用useCallback

使用useCallback不意味着总是会带来性能提升,这是对useCallback使用场景的简单总结:

使用useCallback

  1. 子组件的性能优化:当你将函数作为 prop 传递给已经通过React.memo进行优化的子组件时,使用useCallback可以确保子组件不会因为父组件中的函数重建而进行不必要的重新渲染。
  2. Hook 依赖:如果你正在传递的函数会被用作其他 Hook(例如useEffect)的依赖时,使用useCallback可确保函数的稳定性,从而避免不必要的副作用的执行。
  3. 复杂计算与频繁的重新渲染:在应用涉及很多细粒度的交互,如绘图应用或其它需要大量操作和反馈的场景,使用useCallback可以避免因频繁的渲染而导致的性能问题。

避免使用useCallback

  1. 过度优化:在大部分情况下,函数组件的重新渲染并不会带来明显的性能问题,过度使用useCallback可能会使代码变得复杂且难以维护。
  2. 简单组件:对于没有经过React.memo优化的子组件或者那些不会因为 prop 变化而重新渲染的组件,使用useCallback是不必要的。
  3. 使代码复杂化:如果引入useCallback仅仅是为了“可能会”有性能提升,而实际上并没有明确的证据表明确实有性能问题,这可能会降低代码的可读性和可维护性。
  4. 不涉及其它 Hooks 的函数:如果一个函数并不被用作其他 Hooks 的依赖,并且也不被传递给任何子组件,那么没有理由使用

九、useLayoutEffect 浏览器绘制之前执行

useEffectuseLayoutEffect 的区别是:

  1. useEffect 不会 block 浏览器渲染,而 useLayoutEffect 会。
  2. useEffect 会在浏览器渲染结束后执行,useLayoutEffect 则是在 DOM 更新完成后,浏览器绘制之前执行。

useEffect 操作dom元素时会看到变化的过程,useLayoutEffect 操作dom元素只会看到变化后的样子。
避免过度使用useLayoutEffect,因为它是同步的,可能会影响应用的性能。只有当你确实需要同步的DOM操作时才使用它。

9.1 何时使用useLayoutEffect

  • 需要同步读取或更改DOM:例如,你需要读取元素的大小或位置并在渲染前进行调整。
  • 防止闪烁:在某些情况下,异步的useEffect可能会导致可见的布局跳动或闪烁。例如,动画的启动或某些可见的快速DOM更改。
  • 模拟生命周期方法:如果你正在将旧的类组件迁移到功能组件,并需要模拟 componentDidMountcomponentDidUpdatecomponentWillUnmount的同步行为。

十、useTransition 进行非阻塞渲染

React的默认渲染行为是同步的,React总是在我一个操作或者状态更新后再执行下一个操作或状态更新。

在密集或资源繁重的更新场景下——如加载大量数据时,这种同步行为可能导致整个应用的UI卡顿甚至没有响应。

React v18开始,官方引入了并发模式(Concurrent Mode)和一些相关的Hooks,其中之一就是useTransition。开发者可以将某些状态更新标记为“可中断”的,从而允许React在必要时打断这些更新,先处理其他更为紧急的任务。我们把这种渲染效果称作“非阻塞UI”

10.1 useTransition 基础

在React的并发模式下,允许我们中断或延后某些状态更新,以便于能够在长时间的计算或数据拉取时保持UI的响应性。

const [isPending, startTransition] = useTransition()
  • isPending:是一个布尔值,当过渡状态仍在进行中时,其值为true;否则为false
  • startTransition:是一个函数,当你希望启动一个新的过渡状态时调用它。

工作原理

  • 并发模式下的状态更新分类: 在React的并发模式中,不是所有的状态更新都被视为等同的。React将更新分为不同的优先级类别,其中某些更新(如输入处理)被认为是更加紧急的,而其他更新(如从服务器获取数据)则可以中断或者延后更新。
  • 使用startTransition进行状态更新: 当你使用startTransition函数进行状态更新时,你实际上告诉 React:这个更新不是非常紧急的,如果有更重要的更新要处理,你可以中断或延后这个次要更新。
  • isPending的用途: isPending 为我们提供了一个标识,告诉我们是否有一个startTransition正在执行。我们可以根据isPending来设置过渡状态的样式。

10.2 使用范围和注意事项

  • 只有当你能访问某个状态的set函数时,你才能将更新包装进useTransition中。
  • 传递给startTransition的函数必须是同步的,而不能是异步的。
startTransition(async () => {
  await someAsyncFunction();
  // ❌ Setting state *after* startTransition call
  setPage('/about');
});

await someAsyncFunction();
startTransition(() => {
  // ✅ Setting state *during* startTransition call
  setPage('/about');
});
  • 如果你想根据某个 prop 或自定义 Hook 的值来启动一个过渡,那么你应该尝试使用useDeferredValue
  • 不能用于控制文本输入。因为输入框是需要实时更新的,如果用useTransition降低了渲染优先级,可能造成输入“卡顿”。
  • 不要在startTransition内部使用setTimeout,如果一定要用setTimeout,你可以在startTransition外层使用
startTransition(() => {
  // ❌ Setting state *after* startTransition call
  setTimeout(() => {
    setPage('/about');
  }, 1000);
});

setTimeout(() => {
  startTransition(() => {
    // ✅ Setting state *during* startTransition call
    setPage('/about');
  });
}, 1000);

useTransition包裹的同一个状态多次更新,只会渲染最后一个,前面的都算中断(仅UI层面,如:长列表多次请求);不同组件触发不同状态的更新,被useTransition包裹的状态优先级较低,被中断后会等高优先级的状态更新完成后继续更新(如:复杂图表渲染被中断,会在高优先级状态更新后,继续处理图表的渲染)。

10.3 切换tabs卡顿时使用

快速点击tab切换内容,但总是会在请求耗时的tab上卡顿一下,我们想用useTransition保持UI的响应,只需要用startTransition包裹切换选项卡的set

const [isPending, startTransition] = React.useTransition();

function selectTab(nextTab) {
  startTransition(() => {
    setTab(nextTab);      
  });
}

这样我们快速切换tab,无论点到哪一个tab都不会卡顿。

10.4 useTransitionSuspense实现路由流畅切换

当与路由结合使用时,React.Suspense允许我们延迟渲染新的路由内容,直到所需的数据被完全加载。而useTransition则允许我们可控地开始这种可能导致UI变化的过渡——导航到新页面。

import React, { useState } from 'react';
import { BrowserRouter, Switch, Route, Link } from 'react-router-dom';

const [location, setLocation] = useState(window.location);
const [isPending, startTransition] = React.useTransition();

// 使用 startTransition 来更新 location 状态,能够延迟显示新页面的内容,直到数据加载完毕
function handleNavigation(newLocation) {
  startTransition(() => {
    setLocation(newLocation);
  });
}

// 主应用组件
function App() {
  return (
    <div>
      <BrowserRouter>
        {/* 导航 */}
        <nav>
          <CustomLink to="/about">About</CustomLink>
          <CustomLink to="/contact">Contact</CustomLink>
        </nav>

        {/* 使用 React.Suspense 来处理组件的懒加载 */}
        <React.Suspense fallback={<LoadingIndicator />}>
          <Switch location={location}>
            <Route path="/about" component={AboutPage} />
            <Route path="/contact" component={ContactPage} />
            {/* ...其他路由... */}
          </Switch>
        </React.Suspense>

        {/* 使用 isPending 显示或隐藏全局加载指示器 */}
        {isPending && <LoadingIndicator />}
      </BrowserRouter>
    </div>
  );
}

export default App;

通过这种方式,我们可以优雅地处理路由切换,并确保在数据加载时为用户提供一个流畅的体验。

十一、useDeferredValue 状态延迟更新

React v18的并发模式下,使用useTransition可以在密集计算的场景下让UI无阻塞。React v18还引入了useDeferredValue,延迟某个值的更新,使高优先级的任务可以先行完成。

明确useTransitionuseDeferredValue的差异:

  • useTransition主要关注点是状态的过渡。它允许开发者控制某个更新的延迟更新,还提供了过渡标识,让开发者能够添加过渡反馈。
  • useDeferredValue主要关注点是单个值的延迟更新。它允许你把特定状态的更新标记为低优先级。

如果你想提供过渡反馈,就用useTransition,如果不需要提供过渡反馈,用useDeferredValue就可以

11.1 useDeferredValue 基础使用

const deferredValue = useDeferredValue(someValue);

其中someValue是你想要延迟的值,它可以是任何类型。
deferredValue的渲染有两种情况:

  • 在初始渲染时deferredValue的值将与someValue的值相同。
  • 在UI更新期间,因为deferredValue的优先级较低,即使并发模式下deferredValue已在后台更新,React也会先使用旧值渲染,当其它高优先级的状态更新完成,才会把deferredValue新值渲染出来。

在大数据量场景下做查询,前端渲染就需要延迟更新列表,还希望只有最后一次查询的数据被保留,这时候useDeferredValue就派上用场了,例如:

import { useState, useDeferredValue, memo } from 'react';

export default function App() {
  const [text, setText] = useState('');
  const deferredText = useDeferredValue(text);
  return (
    <>
      {/* 输入框的值直接与 text 绑定,所以输入框会实时显示用户的输入 */}
      <input value={text} onChange={e => setText(e.target.value)} />
      {/* SlowList 组件接受 deferredText 作为属性,渲染优先级会被降低 */}
      {/* 在 UI 真正更新前,如果 deferredText 被更新多次,也只会保留最后一次的结果 */}
      <SlowList text={deferredText} />
    </>
  );
}

const SlowList = memo(function SlowList({ text }) {
  const arr = [];
  for (let i = 0; i < 200; i++) {
    let startTime = performance.now();
    while (performance.now() - startTime < 1) {}
    if (String(i).includes(text)) {
      arr.push(<li key={i}>{i}</li>);
    }
  }
  return (
    <ul className="items">
      {arr}
    </ul>
  );
});

11.2 使用useDeferredValue注意事项

useDeferredValue仅在开启React并发模式的时候才有效

// React v18以前
ReactDOM.render(<app />, rootNode) // ❌ 无法开启useTransition

// React v18
ReactDOM.createRoot(rootNode).render(<app />) // ✅ 开启useTransition

传递给useDeferredValue的值应该是原始值(如字符串和数字)或在渲染外部创建的对象

为什么对象需要创建在外部创建?这其实和JavaScript的机制有关,即使每次创建相同的对象,JavaScript依然会每次生成新的引用。这意味着每次你在组件的渲染函数中创建一个新的对象并将其传递给useDeferredValue,你都是在传递一个新的、与上一次渲染不同的引用。

function MyComponent() {
  const obj = { name: 'John' }; // 每次 MyComponent 重新渲染时,都会创建一个新的 obj 对象,它们的引用是不同的

	// useDeferredValue 通过 Object.is 来检查值是否有变化,引用不同的对象会被当作不同的值
  const deferredValue = useDeferredValue(obj);

  // ...其他代码...
}
  • 当同一个useDeferredValue在渲染前接收到多次不同的值时,只有最后一个会被渲染。想象一下,前端在做大数据量查询的时候,我们当然希望只有最后一次查询的数据成功渲染。
  • useTransition一样,useDeferredValue只会中断或延迟UI的渲染,不会阻止网络请求。
  • useDeferredValue<Suspense>结合使用,在useDeferredValue更新时,<Suspense>fallback是不会出现的,页面上是继续显示useDeferredValue的旧值。这一点和useTransition不一样。

11.3 和节流、防抖的关系

useDeferredValue在某些场景下是可以替换掉节流和防抖的

防抖与节流的局限性:

  • 这两种方法都是为了控制函数的执行频率,但它们是阻塞的,可能会导致不流畅的用户体验。

useDeferredValue的优势:

  • 它与React深度集成,可以适应用户的设备。如果设备性能好,延迟的重新渲染会很快完成;如果设备性能差,重新渲染会相应地延迟。
    它不需要选择固定的延迟,与防抖和节流不同。
  • useDeferredValue执行的重新渲染是可中断的。这意味着在React重新渲染期间,如果发生了其他更新,React会中断当前的渲染并处理新的更新。

适用场景:

  • 如果要优化的工作不是在渲染期间进行的,例如减少网络请求,那么防抖和节流仍然是有用的。
  • 如果优化的目标是和渲染有关的,建议使用useDeferredValue

十二、useInsertionEffect 样式注入新方式

useInsertionEffect是为CSS-in-JS库提供的一个hook,它让后者可以更合理地注入样式

useInsertionEffect出现以前,无论是使用useEffect注入还是useLayoutEffect注入,都存在重复计算和性能浪费的问题,而像styled-components使用babel插件则又显得不够灵活。

React用useInsertionEffect给CSS-in-JS库作者多一个选择,useInsertionEffect有这样的优点:

  1. 动态性:允许在运行时动态地注入样式,这使得基于组件的状态、道具或上下文的样式变化变得容易。
  2. 及时注入:保证了在任何布局效果触发之前插入样式,减少了样式的重复计算和布局抖动。
useInsertionEffect(setup, dependencies?)

setup方法里可以做我们需要的处理,dependencies则是依赖数组,和useEffectuseLayoutEffect的依赖数组规则一样。

import { useInsertionEffect } from 'react';

function useDynamicStyle(styleObj) {
  const cssString = convertStyleObjToCSS(styleObj); // 将样式对象转换为 CSS 字符串的辅助函数

  useInsertionEffect(() => {
    const styleElement = document.createElement('style');
    styleElement.innerHTML = cssString;
    document.head.appendChild(styleElement);
    return () => {
      document.head.removeChild(styleElement);
    };
  }, [cssString]);
}

useInsertionEffect里面,我们可以动态注入<style>

注意事项

  • useInsertionEffect只在客户端运行,不能在服务器渲染期间运行。
  • 不能从useInsertionEffect中更新状态。这是因为useInsertionEffect专为插入操作设计的,而不是为响应式状态变化设计的。如果在useInsertionEffect里更新状态,会造成组件重新渲染。
  • useInsertionEffect运行时,refs还没有附加。如果你试图在useInsertionEffect中访问ref,你可能会得到null或未定义的值。
  • useInsertionEffect可能在DOM更新之前或之后运行,所以不能依赖于DOM在特定时刻的更新状态。这是因为useInsertionEffect的设计初衷是在任何布局效果触发之前插入元素,但它并不保证在 DOM 的任何特定更新之前或之后运行。因此,依赖于DOM在特定时刻的状态可能导致不可预测的行为。例如:假设你希望在useInsertionEffect中检查某个元素的尺寸。但由于 DOM 可能尚未更新,所以你得到的尺寸可能是旧的或不准确的。

十三、useImperativeHandle

可以让父组件获取并执行子组件内某些自定义函数(方法)。本质上其实是子组件将自己内部的函数(方法)通过useImperativeHandle添加到父组件中useRef定义的对象中。

useImperativeHandle通常与forwardRef一起使用,以便将 ref 传递给函数组件,用法定义如下:

const ForwardRefChildComponents = forwardRef(function ChildComponents(props, ref) {
  useImperativeHandle(ref, () => {
    return {
      // ... your methods ...
    };
  }, []);
})

13.1 基础示例

你有一个自定义输入组件,你想为其提供一个方法来清除输入内容,但不想暴露整个组件或 DOM 节点。

const ForwardedCustomInput = forwardRef(function CustomInput(props, ref) {
  const inputRef = useRef();

  useImperativeHandle(ref, () => ({
    clear: () => {
      inputRef.current.value = '';
    }
  }));

  return <input ref={inputRef} />;
})

现在,当你使用ForwardedCustomInput并为其提供一个 ref 时,你可以直接调用clear方法

function App() {
  const inputRef = useRef();

  return (
    <div>
      <ForwardedCustomInput ref={inputRef} />
      <button onClick={() => inputRef.current.clear()}>Clear Input</button>
    </div>
  );
}

当我们点击 “Clear Input” 按钮时,输入框的内容将被清除,这种实现方式让我们没有操作 DOM 节点也能操作完成操作。

如果使用 props 可以解决的场景都不要使用 refs,也就无需使用useImperativeHandle

你应该只在确实需要自定义暴露给父组件的实例值时使用useImperativeHandle。例如以下场景:

  • 滚动到节点
  • 聚焦节点
  • 触发动画:你可能有一个组件负责管理复杂的动画,你可以使用useImperativeHandle来暴露startstop等控制方法。
  • 选择文本

十四、useSyncExternalStore 获取实时数据

随着 React v18 引入并发模式,React 也支持了在处理多个任务时进行优先级调整,这意味着 React 可以“暂停”一个正在进行的渲染任务,切换到另一个更高优先级的任务,然后再回到原来的任务。

这使得用户界面响应更快,但也带来了新的挑战,尤其是在状态管理方面——状态管理库需要确保它们提供的状态始终是最新的和同步的。

useSyncExternalStore 就是为解决并发模式下的状态同步问题而推出的——它提供了一种方法,确保即使在并发更新的情况下,组件也可以同步地从外部存储中获取数据。

useSyncExternalStore 解决的是并发模式下数据流管理的问题

14.1 useSyncExternalStore的使用

const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)

1.subscribe

  • 类型: ((callback: () => void) => () => void)
  • 这是一个函数,其作用是订阅外部存储的变化。当外部存储发生变化时,它应该调用传入的 callback
  • 这个函数应该返回一个取消订阅的函数。这样,当组件被卸载或订阅被重新创建时,我们可以确保没有内存泄漏或无效的回调调用。

2.getSnapshot

  • 类型: () => Snapshot
  • 这是一个函数,其作用是从外部存储中获取当前的数据快照。
  • 每次组件渲染时,useSyncExternalStore 都会调用此函数来读取当前的数据状态。

3.getServerSnapshot? (可选参数):

  • 类型: () => Snapshot
  • 这个函数的作用与 getSnapshot 类似,但它是为服务端渲染(SSR)或预渲染时使用的。在客户端首次渲染或 hydrate 操作期间,React 会使用此函数而不是 getSnapshot 来读取数据的初始状态。这是为了确保在服务端渲染的内容与客户端的初始内容匹配,从而避免不必要的重新渲染和闪烁。如果你的应用不涉及服务端渲染,那么不需要这个参数。

14.2 从store里获取数据

想象你要做一个博客文章的状态管理功能,你希望所有渲染文章列表的组件都能实时获取最新的数据,那么就可以这样做:

先创建状态store:

    /*
     * articlesStore.js 
     */

    // 初始化文章 ID 计数器
    let nextId = 0;

    // 初始文章列表
    let articles = [{ id: nextId++, title: 'Article #1', content: 'This is the content of Article #1.' }];

    // 用于存储所有订阅文章列表更改的监听器
    let listeners = [];

    export const articlesStore = {
      addArticle(title, content) {
        articles = [...articles, { id: nextId++, title: title, content: content }];
        // 通知所有监听器文章列表已更改
        emitChange();
      },
      // 订阅文章列表更改的方法
      subscribe(listener) {
        // 添加新的监听器
        listeners = [...listeners, listener];
        // 返回一个取消订阅的函数
        return () => {
          // 删除监听器
          listeners = listeners.filter(l => l !== listener);
        };
      },
      // 获取当前文章列表的“快照”
      getSnapshot() {
        return articles;
      }
    };

    // 通知所有监听器的辅助函数,遍历 listeners 数组并调用每个监听器
    function emitChange() {
      for (let listener of listeners) {
        listener();
      }
    }

然后你可以在需要渲染文章列表的组件里实现实时数据渲染了:

    import { useSyncExternalStore } from 'react';
    import { articlesStore } from './articlesStore.js';

    export default function ArticlesApp() {
      // 使用 useSyncExternalStore 订阅文章列表的更改
      const articles = useSyncExternalStore(articlesStore.subscribe, articlesStore.getSnapshot);

      // 当点击按钮时添加新文章的处理函数
      const handleAddArticle = () => {
        // ……
        articlesStore.addArticle(title, content);
      };

      return (
        <>
          <button onClick={handleAddArticle}>Add Article</button>
          <ul>
            {/* 映射文章列表以显示每篇文章的标题和内容 */}
            {articles.map(article => (
              <li key={article.id}>
                {* …… *}
              </li>
            ))}
          </ul>
        </>
      );
    }

这个示例只是为解释 useSyncExternalStore 的用法使用的,实际场景中的逻辑一定比这个复杂得多。但这个简单的示例已经足够让我们看到 useSyncExternalStore 的价值了——如果有多个组件会触发文章列表的更新,那么使用了 useSyncExternalStore 监听数据变化的组件都能实时刷新数据。在使用 useSyncExternalStore 以前,我们通常是在进入页面时获取新数据,或者用定时器定时来更新数据。

14.3 从浏览器API获取数据

现在我们用一个更加直观的示例来看看 useSyncExternalStore 的能力。

我们计划用 useSyncExternalStore 来监听网络状态,并把网络状态显示在页面上。网络状态可以从 navigator 里的 onLine 获取。

在代码设计层面,我们需要知道 useSyncExternalStore 更新的数据通常可能被多个组件引用,那么写一个自定义 hook 是最合理的方式,那么这个示例中我们就写一个自定义 hook 来实现核心逻辑:

    import { useSyncExternalStore } from 'react';

    export function useOnlineStatus() {
      const isOnline = useSyncExternalStore(subscribe, getSnapshot);
      return isOnline;
    }

    function getSnapshot() {
      return navigator.onLine;
    }

    function subscribe(callback) {
      window.addEventListener('online', callback);
      window.addEventListener('offline', callback);
      return () => {
        window.removeEventListener('online', callback);
        window.removeEventListener('offline', callback);
      };
    }

在页面调用useOnlineStatus就可以获取onLine的最新值:

    import { useOnlineStatus } from './useOnlineStatus.js';

    export default function StatusBar() {
      const isOnline = useOnlineStatus();
      return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
    }

这个示例完美地展示了useSyncExternalStore实时获取数据的能力

14.4 注意事项

useSyncExternalStore 依赖 getSnapshot 函数返回的值来决定是否重新渲染。如果每次都返回新的对象,即使对象的内容相同,React 会认为状态已经变化并重新渲染组件。

    function getSnapshot() {
      // 🔴 getSnapshot 不要总是返回不同的对象
      return {
        todos: myStore.todos
      };
    }

正确的返回值应该这样写:

    function getSnapshot() {
      // ✅ 你可以返回不可变数据
      return myStore.todos;
    }

subscribe 不要放在组件内定义

如果 subscribe 函数在组件内部定义,那么每次组件渲染都会创建一个新的 subscribe 函数实例。这是由于 useSyncExternalStore 会在 subscribe 函数改变时重新订阅,这意味着每次重新渲染都会导致重新订阅,可能导致不必要的开销,尤其是当订阅操作涉及复杂的计算或外部资源时。

    function ChatIndicator() {
      const isOnline = useSyncExternalStore(subscribe, getSnapshot);
      
      // 🚩 总是不同的函数,所以 React 每次重新渲染都会重新订阅
      function subscribe() {
        // ...
      }

      // ...
    }

正确的做法是把 subscribe 函数移到组件外部,这样它在组件的整个生命周期中都保持不变;或者使用 useCallback 钩子来缓存 subscribe 函数。

虽然useImperativeHandle对于应用开发者来说不是必要的,但如果你想拓展对 React 生态圈的认识,依然有必要了解一下useImperativeHandle的用法和使用场景,因为它能帮助你未来更好地理解优秀的第三方库的设计。

十五、useId为你生成唯一id

当你看到一个服务端渲染的应用,它的渲染过程会是这样:服务端会先生成 HTML,然后将这个 HTML 发送到客户端,在客户端,React 会进行一个叫做 hydration 的过程,即将服务器端生成的 HTML 和客户端的 DOM 进行匹配,并生成最终的 HTML

而在这个过程中,我们有时候需要给 DOM 生成唯一的 ID。例如:我们需要通过 JavaScriptCSS 选择器来访问 DOM 的时候;或者某些HTML属性(如 aria-labelledby)需要使用唯一的 ID 来关联元素。

const id = useId()

useId 会返回一个唯一的 ID,你可以将这个 ID 用于任何需要唯一 ID 的地方。这是一个使用 useId 的代码示例:

import { useId } from 'react';

function MyComponent() {
  const uniqueId = useId();

  return (
    <div id={uniqueId}>
      Hello, 👉 weijunext.com
    </div>
  );
}

使用场景一:为可访问属性、无障碍属性生成唯一ID

如下面的例子,我们想关联 labelinput,就需要在 <input> 元素上设置一个唯一的 id 属性;再在对应的 <label> 元素上设置 htmlFor 属性,其值与上述 id 相同。为了保证 id 唯一,就可以用 useId 来实现。

import { useId } from "react";

export default function App() {
  const FullName = useId();
  const email = useId();

  return (
    <div className="card">
      <div>
        <label htmlFor={FullName}>Full Name</label>
        <input type="text" id={FullName} name="Full Name" />{" "}
      </div>
      <div>
        <label htmlFor={email}>Enter Email</label>
        <input type="email" id={email} name="email" />
      </div>
    </div>
  );
}

使用场景二:多次调用保证ID不重复

import { useId } from 'react';

function PasswordField() {
  const passwordHintId = useId();
  return (
    <>
      <label>
        密码:
        <input
          type="password"
          aria-describedby={passwordHintId}
        />
      </label>
      <p id={passwordHintId}>
        密码应该包含至少 18 个字符
      </p>
    </>
  );
}

export default function App() {
  return (
    <>
      <PasswordField />
      <PasswordField />
    </>
  );
}

当我们给组件使用 useId 后,即使这个组件被调用多次,也不会出现 id 重复的情况。

使用场景三:为相关元素生成统一前缀或后缀

有时候我们会想给相关元素(如表单项分类、列表元素)设置一个统一的前缀或后缀,那么这个前缀或后缀就可以用 useId 来生成。用法如下:

import { useId } from "react";

export default function Example2() {
  let prefix1 = useId();
  let prefix2 = useId();

  return (
    <div className="card">
      <div>
        <label htmlFor={prefix1 + "-fullName"}>Full Name</label>
        <input type="text" id={prefix1 + "-fullName"} name="Full Name" />
      </div>
      <div>
        <label htmlFor={prefix1 + "-lastName"}>Last Name</label>
        <input type="text" id={prefix1 + "-lastName"} name="Last Name" />
      </div>

      <div>
        <label htmlFor={prefix2 + "-email"}>Enter Email</label>
        <input type="email" id={prefix2 + "-email"} name="email" />
      </div>
		 </div>
  );
}

在简单的场景下,这样做既可以减少 useId 的使用,又可以通过设置局部的唯一字符,实现 id 唯一的需求。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值