zl程序教程

您现在的位置是:首页 >  前端

当前栏目

React-Hooks(附demo)

React Demo Hooks
2023-09-27 14:28:54 时间

文章首发于3月29日。

hooks诞生的原因

react的今天和明天系列文章中,react开发人员介绍了class组件存在的三个问题。

  • 逻辑复用
  • 庞大的组件
  • 难以理解的class

在class组件中通过HOC和render props中来实现组件的逻辑复用。这会带来一个问题,当我们拆分出很多细小的组件再将它们组合到一起,如果在chrome打开react的扩展,会发现组件层级非常深,增加了react的计算负担。这个问题称为“包裹地狱(wrapper hell)”。

当我们试图解决第一个问题的时候,我们会将更多的逻辑放在单个的组件中,导致组件日益庞大。这就是第二个问题。

js中的class实际上是function的语法糖。在使用过程中,它隐藏了function的实现细节(static, prototype等)。并且当我们写一个函数组件的时候,如果要添加状态,那么就必须将它转换为class组件,这会写很多样板代码。不仅如此,对于机器来说,压缩后的class的组件中的所有的方法名都是没有经过压缩的。并且也无法tree shaking。这是class组件的第三个问题。

Dan Abramov认为这不是三个独立的问题,而是一个问题的三个部分。为了解决这个问题,于是出现了hooks。

hooks简介

hooks是另一种书写组件的方式,在hooks中只有函数而没有class。通过hooks提供的一系列API几乎可以完全覆盖class组件中的情况(为什么说是几乎?在下面差异部分会提到)。hooks是更加简洁优雅的,逻辑分离的。

  • v16.8。hooks是react16.8版本新添加的功能。如果要使用hooks,需要确保react版本升级到16.8.0及以上,同时保证react-dom和react的版本保持一致。

  • 百分之百向后兼容

  • react没有计划移除class组件。hooks是一种可选的写法,你依然可以选择不用而使用class(用过了你会喜欢上它)。

  • hooks中使用的依旧是class中的概念。

hooks API

hooks有下列API。所有的示例demo都在这里。codesandbox.io/s/o968n1q62… ,可以打开并直接看到效果。

Basic Hooks

  • useState
  • useEffect
  • useContext

Additional Hooks

  • useReducer
  • useCallback
  • useMemo
  • useRef
  • useImperativeHandle
  • useLayoutEffect
  • useDebugValue

接下来会解释这些API并附有详细的demo。

1.useState

useState是hooks中最基础的一个API。其使用方式类似于class中的this.setState,不过又有所不同。下面是demo中的示例。

function Counter() {
  const [count, setCount] = useState(4);
  return (
    <>
      <p>
        count: {count}, random: {Math.random()}
      </p>
      <button onClick={() => setCount(count < 5 ? count + 1 : count)}>
        点击这里加1
      </button>
      <button onClick={() => setCount(count - 1)}>点击这里减1</button>
    </>
  );
}
复制代码

useState(4)用于创建一个state变量,并传入初始值。返回值是一个数组,通过解构写法拿到返回的值。count是一个可变的值(只能通过setCount改变),它的初始值是useState传入的参数4。单击按钮的时候,通过setCount去改变它的值。从而重新渲染该组件。

它与class组件中的setState的差异有以下几点:

  • 不会进行状态的合并,而是进行状态的替换。这在简单类型没有什么问题,对于复杂类型如对象使用的时候需要注意(可以在demo中渲染Count2组件)。
  • 当使用setCount改变状态时,使用Object.is算法比较状态的前后值。它和 === 的区别在于对于0和-0的判断,以及NaN的判断。
  • 如果前后状态相同,那么组件不会进行渲染。在class组件中使用setState,即使某个状态前后是相同的值,仍会进行渲染(demo中点击按钮让count的值改变到5可以看到)。

另外,如果useState的参数值需要复杂的计算才能得到,那么可以传入给useState一个函数,它仅在首次渲染时执行该复杂的计算函数。useState(() => expensiveCalc())

2.useEffect/useLayoutEffect

由于useEffect和useLayoutEffect具有相同的使用方式,就放到一起来介绍。 正如其名,useEffect用来处理副作用。副作用包括DOM的改变、订阅、定时器、日志等。副作用不允许放到函数体中。

useEffect/useLayoutEffect可以取代componentDidMount、componentDidUpdate、componentWillUnmount三个声明周期。所以它是非常强大一个hook,在使用方面很灵活,也不是那么容易理解。

useEffect接受两个参数,其中第二个参数是可选的,不过一般情况下都需要传入第二个参数。

useEffect(() => {
  // some side effect
  // ...

  // return a clean up function
  return () => {

  }
}, [])
复制代码

第一个参数是一个函数,当组件首次渲染或者其依赖的状态改变时它会执行。该函数的返回值是可选的,可以不写,如果要写的话,必须是一个函数,用于清除上一个状态。

第二个参数是可选的,它是一个数组。数组中可以传入状态值(通过useState产生的值),当状态值改变的时候首先会执行return函数,用于清理上一个状态,然后useEffect中的函数就会再次执行。

  • 如果不传入第二个参数,代表组件中任何状态的改变该effect都会执行一次,这通常不是我们想要的行为。
  • 如果第二个参数传递一个空数组,代表该effect仅会执行一次,相当于componentDidMount。return函数也只会在组件卸载的时候执行一次,相当于componentWillUnmount。
  • 如果第二个参数数组中有一个或多个状态(demo中的useLayEffect),那么只要有任意一个状态值发生变化,该effect都会再次执行。相当于componentDidUpdate。
// demo--useLayoutEffect
useEffect(() => {
  if (value.length > 10) {
    setValue(value.substring(0, 10));
  }
  setLengths(value.length);
},[value]);
复制代码

当value发生变化的时候,effect再次执行。改变length状态。

useLayoutEffect的语法和useEffect一样。不同点在于:

  • useEffect是在组件状态改变后,并且在组件layout和paint之后,也就是说组件出现在页面后再进行调用。useLayoutEffect是在组件状态改变后,但是在组件layout和paint之前,也就是在组件出现在页面之前进行调用。
  • useEffect是异步的,useLayoutEffect是同步的。可以看这篇文章:juejin.im/post/5c8f43…

在useLayoutEffect的demo中,尝试将useEffect改变成useLayoutEffect,然后在输入框输入第十一个字符,可以看到明显差别。在大多数情况下你应该使用useEffect。因为useEffect是异步的,不会堵塞主线程渲染。

3.useRef

在16.3版本中,引入React.createRef,来代替字符串ref。同样,在hooks中,useRef也可以取代createRef。可以查看demo中的useRef。

function xxx() {
  const inputRef = useRef(null);
  useEffect(() => {
    inputRef.current.value = 'hello';
  }, [])
  return (
    <input type="text" ref={inputRef} />
  )
}
复制代码

useRef接受一个初始值,返回一个可变的ref对象,ref.current指向初始化的值。它可以指向别的值。

另外,由于是函数组件,this不再指向这个组件,所以如果要达到class组件中实例变量的效果,也可以通过useRef来实现。

const timerRef = useRef(null);

useEffect(() => {
  timerRef.current = setInterval(() => {
    inputRef.current.value = 'hello';
  }, 1000);
  return () => {
    clearInterval(timerRef.current);
  };
}, []);
复制代码

这里的timerRef.current相当于class中的实例变量。

4.useContext

在React中,如果要将上层的属性传递到下层,一般来说需要一层一层的传递。比如A->B-C->D。Context是一种数据传递机制,用于跨层级传递数据。比如在D组件可以直接使用A组件的数据。可以查看demo中的useContext。

useContext是Context.Consumer(16.3)以及static contextType(16.6)的一种简写。

上层组件定义Context.Provider,并传入value属性。在子组件中通过useContext(Context)可以获取value属性。

// parent.js
const parentContext = createContext();
function Parent(props) {
  const countArr = useState({
    count1: 0,
    count2: 1
  });
  return (
    <parentContext.Provider value={countArr}>
      {props.children}
    </parentContext.Provider>
  );
}

function Child() {
  const countArr = useContext(parentContext);
  const [countObj, setCountObj] = countArr;

  return (
    <>
      <div>
        count1: {countObj.count1} count2: {countObj.count2}
      </div>
    </>
  );
}

<Parent><Child /></Parent>
复制代码

可以看到,这里传递属性不是通过props的,而是通过Context的。需要注意的是,在父组件定义了parentContext,需要将其导出,因为子组件使用useContext的参数就是parentContext。

另外,因为没有static的限制,同一个组件中可以使用多个useContext。也就是说,可以使用多个上层组件传递来的数据。

5.useReducer

提到reducer,首先想到的应该是redux中的reducer。useReducer这个hook与redux中的reducer有所相似又有所不同。可以查看demo中的useReducer。

定义一个reducer的方式和redux中是一样的:

const initialState = {
  count: 0
};
const reducer = (state = initialState, action) => {
  switch (action.type) {
    case 'ADD': {
      return {
        count: state.count + 1
      };
    }
    case 'MINUS': {
      return {
        count: state.count - 1
      };
    }
  }
};


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

  return (
    <>
      <div>count: {state.count}</div>
      <button onClick={() => { dispatch({ type: 'ADD' }); }}>+</button>
      <button onClick={() => { dispatch({ type: 'MINUS' }); }}>-</button>
    </>
  );
}
复制代码

useReducer接受连个参数,一个是reducer,一个是initialState。返回一组值,分别是state和dispatch。通过dispatch触发一个动作,进而去更新状态。如果你使用过redux,那么理解起来没有任何困难。

另外,与redux中的reducer有所不同的是,useReducer中的reducer是独立的。如果有多个组件使用到了同一个reducer,那么它们之间的状态是独立的。相较于redux的全局共享状态,它还依赖于react-redux提供的Provider组件。

所以是不是突然想到了第四点中的Context,它提供了Provider。如果能配合useReducer,就可以实现全局状态共享了?确实如此!具体的代码可以查看demo中的context-reducer。

还有一点必须需要提到的是,由于性能原因,react-redux没有推出官方的useRedux。 具体原因可以看这篇文章:juejin.im/post/5c7c8d… 。在最近的7.0的beta版本的发布说明中,react-redux团队宣布将在7.x版本中推出useRedux API。

6.useImperativeHandle

在class组件中,如果父组件需要改变子组件的状态,有两种方式。一种是就是通过改变父组件state,该state作为props传给子组件,从而改变子组件的状态。另一种就是通过操作子组件的ref了。传递ref的方式主要有两种,createRef和forwardRef,具体的就不再细说。useImperativeHandle这个hook就是ref的另一种写法。以前是在父组件中拿到子组件元素的ref,直接操作ref代表的元素节点,相当于是直接操作子元素的dom元素。现在通过这个hook可以在子组件中暴露一些API供父组件调用,而父组件是不能直接操作子组件的dom元素的。迪米特法则就是这样描述的:一个类对它所调用的类的细节知道的越少越好。具体代码可以查看demo中的useImperativeHandle。

function Child(props) {
  const inputRef = useRef(null);
  useImperativeHandle(props.myref, () => ({
    focus() {
      inputRef.current.focus();
    },
    setValue(value) {
      inputRef.current.value = value;
    }
  }));
  return (
    <>
      <input type="text" ref={inputRef} />
    </>
  );
}
复制代码

不过在实际开发中,你应该尽可能通过传递props来改变子组件,通过ref来改变子组件是一种不推荐的方案。

7.useCallback

useCallback用来缓存一个函数。在函数式组件中可能有这样一种情况,父组件调用子组件,并将一个函数传递给子组件,假设子组件是使用了memo的(如果属性值没有变化,那么将不会重新渲染)。具体代码可以查看demo中的useCallback。

function Parent() {
  //...
  const handleChange = () => { // ... }
  return (
    <>
      <p>count: {count}</p>
      <Child onChange={handleChange}/>
    </>
  )
}
复制代码

当父组件中的count状态改变时,这时候父组件重新渲染,子组件尽管没有任何改变,但由于onChange这个属性是一个新的函数,它还是重新渲染了。这是我们不期望的行为。在class组件中我们往往通过onChange={this.handleChange}将函数传递给子组件。那么下次父组件渲染时,this.handleChange是没有变化的。如果子组件是memo的,那么子组件将不会重新渲染。

所以uesCallback就是为了解决这样一个问题,它缓存一个函数,并接受一系列依赖项,返回一个函数。如果依赖项没有变化,那么返回的函数不会变化。

function Parent() {
  const [count, setCount] = useState(0);
  const [count2, setCount2] = useState(0);
  const [result, setResult] = useState(count);
  useEffect(() => {
    setInterval(() => {
      // setCount(prevCount => prevCount + 1);
      setCount2(prevCount => prevCount + 1);
    }, 1000);
  }, []);

  const handleChange = useCallback(() => {
    setResult(count + 1);
  }, [count]);
  // const handleChange = () => { setResult(count + 1) };

  return (
    <>
      <Counter count={count} />
      <Counter count={count2} />
      <Child onChange={handleChange} />
      <p>result: {result}</p>
    </>
  );
}
复制代码

这里的handleChange函数是被缓存了的,除非count发生变化,它才会发生变化。通过demo打开控制台,发现随着count2的改变,子组件是不会重新渲染的。

8.useMemo

useMemo用来缓存一个复杂的计算值。 useCallback(fn, deps) 等价于 useMemo(() => fn, deps)。如果通过一个输入得到一个值需要经过复杂的计算,那么下次同样的输入再进行一遍同样复杂的计算是没有必要的。这正是useMemo存在的意义。具体代码可以查看demo中的useMemo。

function Parent() {
  const [count, setCount] = useState(10);
  const [count2, setCount2] = useState(10);

  console.time('calc');
  const result = useMemo(() => computeExpensiveValue(40), [count]);
  // const result = computeExpensiveValue(count);
  console.timeEnd('calc');

  return (
    <>
      <p>result: {result}</p>
      <div>
        <input type="number" disabled value={count} />
        <button onClick={() => setCount(count + 1)}>+</button>
        <button onClick={() => setCount(count - 1)}>-</button>
        <br />
        <input type="number" disabled value={count2} />
        <button onClick={() => setCount2(count2 + 1)}>+</button>
        <button onClick={() => setCount2(count2 - 1)}>-</button>
      </div>
    </>
  );
}
复制代码

useMemo接受一个函数,该函数涉及到复杂的计算,并接受一系列依赖项,返回一个计算后的值。如果依赖项没有变化,那么返回的值不会变化。这里useMemo依赖于count的变化。在页面中分别尝试改变count和count2的值,观察控制台输出的时间。改变count的时候,函数进行了重进计算,打印出的值比较大。改变count2的时候,直接使用缓存的值,打印出的值很小。

需要注意的是,react文档中说明,useMemo只是作为一种暗示,当依赖值变化时,并不一定能保证每一次都不计算。

9.useDebugValue

通常来说你不需要它。它只会存在于自定义的hooks中用来标志一个自定义的hooks。当在chrome中打开react扩展的时候,如果一个组件使用到了自定义的hooks,并且该hooks使用到了useDebugValue,那么该组件下方会显示useDebugValue传入的参数。

function useUserInfo() {
  // ...

  useDebugValue('use-user-info');
  return userInfo;
}
复制代码

自定义hooks

除了官方提供的hooks以外,我们也可以定义自己的hooks。编写自定义的hooks是非常简单的。你可以像和编写一个正常的组件一样,区别在于它返回的是数据(或者不返回),而不是jsx。使用自定义hooks需要遵循两点。demo中编写了一个自定义的hooks(/components/custom-hooks/use-user-info.js)。

  1. 自定义hooks以use开头,驼峰命名。
  2. 自定义的hooks只能被其他hooks或者函数组件使用。
const useUserInfo = (username = 'yuwanlin') => {
  const fetchRef = useRef(null);
  const [userInfo, setUserInfo] = useState({});
  const handleData = data => {
    setUserInfo(data);
  };
  useEffect(() => {
    const fetchData = username =>
      fetch(`${prefix}${username}`)
        .then(res => res.json())
        .then(data => {
          console.log('fetch success');
          handleData(data);
        });
    fetchRef.current = debounce(fetchData, 1000);
  }, []);

  useEffect(
    () => {
      fetchRef.current(username);
    },
    [username]
  );
  // useDebugValue('use-user-info');
  return userInfo;
};
复制代码

打开use-user-info demo可以看到页面中出现了用户的信息。

如何测试hooks

enzyme目前尚不支持测试hooksreact官方推出了测试hooks的方案。打开demo,在右边选项卡中。从Browser切换到Tests,可以看到通过了测试。所有位于__test__文件夹下的.test.js结尾的文件都会被当成测试文件。

hooks缺少的部分

hooks目前不支持getSnapshotBeforeUpdate和componentDidCatch/getDerivedStateFromError生命周期,以后会加上。

一些常见的问题

由于每个函数就是一个组件,那么整个函数体就相当于class组件中的render函数。每次状态改变,都要重新执行函数。下面是一些常见的问题:

  • 如何保存上一个状态?

在class组件中我们通过实例变量来保存上一个状态。在函数组件中,可以通过ref来取代实例变量。usePrevious的实现如下:

function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}
复制代码
  • 如何实现shouldComponentUpdate?

通过React.memo。

  • 在渲染的时候,由于需要创建函数,hooks是否更缓慢?

不,在现代浏览器中,与类相比,闭包的原始性能没有显著差异,除了在极端情况下。 此外,考虑到Hooks的设计在以下几个方面更有效:

  1. 钩子避免了类所需的大量开销,例如在构造函数中创建类实例和绑定事件处理程序的成本。
  2. 使用Hooks的惯用代码不需要深层组件树嵌套,这在使用高阶组件,渲染道具和上下文的代码库中很常见。 使用较小的组件树,React的工作量较少。

更多常见的问题,可以参照:reactjs.org/docs/hooks-…

hooks需要遵循的规范

  • hooks只能出现在函数作用域的顶级,不能出现在条件语句、循环语句中、嵌套函数中。
  • 只能从react的函数式组件以及自定义hooks中使用hooks。

代码中使用hooks的时候,最好配合eslint插件eslint-plugin-react-hooks。

// Your ESLint configuration
{
  "plugins": [
    // ...
    "react-hooks"
  ],
  "rules": {
    // ...
    "react-hooks/rules-of-hooks": "error", // Checks rules of Hooks
    "react-hooks/exhaustive-deps": "warn" // Checks effect dependencies
  }
}
复制代码

总结

class组件存在三个问题,逻辑复用、组件庞大、难以理解的class。hooks的存在就是为了解决这三个问题的。并且在一个函数组件中,你可以多次使用相同的hooks。比如:

function Example() {
  const [count, setCount] = useState(0);
  const [count2, setCount2] = useState(0);

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

  return (
    // ...
  )
}
复制代码

将不同的逻辑放到不同的hooks,逻辑更加清晰。hooks是class的另一种写法,在react16.8引入,react并不打算放弃class。


作者:Real
链接:https://juejin.im/post/5ce15f70e51d4510881389aa
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。