React Hooks 你真的用对了吗?

更新日期: 2019-09-07阅读: 2.1k标签: Hooks

react Hooks 正式发布到现在,我一直在项目使用它。但是,在使用 Hooks 的过程中,我也进入了一些误区,导致写出来的代码隐藏 bug 并且难以维护。这篇文章中,我会具体分析这些问题,并总结一些好的实践,以供大家参考。


问题一:我该使用单个 state 变量还是多个 state 变量?

useState 的出现,让我们可以使用多个 state 变量来保存 state,比如:

const [width, setWidth] = useState(100);
const [height, setHeight] = useState(100);
const [left, setLeft] = useState(0);
const [top, setTop] = useState(0);

但同时,我们也可以像 Class 组件中的 this.state 一样,将所有的 state 放到一个 object 中,这样只需一个 state 变量即可:

const [state, setState] = useState({
  width: 100,
  height: 100,
  left: 0,
  top: 0
});

那么问题来了,到底用单个 state 变量还是多个 state 变量呢?

如果使用单个 state 变量,每次更新 state 时需要合并之前的 state。它不像 Class 组件的 this.setState 方法,会把更新的字段合并到 state 对象中。useState 返回的 setState 会替换原来的值:

const handleMouseMove = (e) => {
  setState((prevState) => ({
    ...prevState,
    left: e.pageX,
    top: e.pageY,
  }))
};

使用多个 state 变量可以让 state 的粒度更细,更易于逻辑的拆分和组合。比如,我们可以将关联的逻辑提取到自定义 hook 中:

function usePosition() {
  const [left, setLeft] = useState(0);
  const [top, setTop] = useState(0);

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

  return [left, top, setLeft, setTop];
}

我们发现,每次更新 left 时 top 也会随之更新。因此,把 top 和 left 拆分为两个 state 变量显得有点多余。在使用 state 之前,我们需要考虑状态拆分的「粒度」问题。如果粒度过细,代码就会变得比较冗余。如果粒度过粗,代码的可复用性就会降低。

那么,到底哪些 state 应该合并,哪些 state 应该拆分呢?我总结了下面两点:

  1. 将完全不相关的 state 拆分为多组 state。比如 size 和 position。
  2. 如果某些 state 是相互关联的,或者需要一起发生改变,就可以把它们合并为一组 state。比如 left 和 top。
function Box() {
  const [position, setPosition] = usePosition();
  const [size, setSize] = useState({width: 100, height: 100});
  // ...
}

function usePosition() {
  const [position, setPosition] = useState({left: 0, top: 0});

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

  return [position, setPosition];
}


问题二:deps 依赖过多,导致 Hooks 难以维护?

使用 useEffect hook 时,为了避免每次 render 都去执行它的 callback,我们通常会传入第二个参数「dependency array」。这样,只有当「依赖数组」发生变化时,才会执行 useEffect 的回调函数

function Example({id, name}) {
  useEffect(() => {
    console.log(id, name);
  }, [id, name]); 
}

在上面的例子中,只有当 id 或者 name 发生变化时,才会打印日志。「dependency array」中必须包含在 callback 内部用到的所有参与 React 数据流的值,比如 state、props 以及它们的衍生物。如果有遗漏,可能会造成 bug。这其实就是 JS 闭包问题,对闭包不清楚的同学可以自行 google,这里就不展开了。

function Example({id, name}) {
  useEffect(() => {
    // 由于 dependency array 中不包含 name,所以当 name 发生变化时,无法打印日志
    console.log(id, name); 
  }, [id]);
}

在 React 中,除了 useEffect 外,接收「dependency array」的 hook 还有 useMemo、useCallback 和 useImperativeHandle。大部分情况下,使用「dependency array」确实可以节省一些性能的开销。我们刚刚也提到了,「dependency array」千万不要遗漏回调函数内部依赖的值。但如果 「dependency array」依赖了过多东西,可能导致代码难以维护。我在项目中就看到了这样一段代码:

const refresh = useCallback(() => {
  // ...
}, [name, searchState, address, status, personA, personB, progress, page, size]);

不要说内部逻辑了,光是看到这一堆依赖就令人头大!如果项目中到处都是这样的代码,可想而知维护起来多么痛苦。如何才能避免写出这样的代码呢?

首先,你需要重新思考一下,这些 deps 是否真的都需要?看下面这个例子:

function Example({id}) {
  const requestParams = useRef({});
  requestParams.current = {page: 1, size: 20, id};

  const refresh = useCallback(() => {
    doRefresh(requestParams.current);
  }, []);


  useEffect(() => {
    id && refresh(); 
  }, [id, refresh]); // 思考这里的 deps list 是否合理?
}

虽然 useEffect 的回调函数依赖了 id 和 refresh 方法,但是观察 refresh 方法可以发现,它在首次 render 被创建之后,永远不会发生改变了。因此,把它作为 useEffect 的 deps 是多余的。

其次,如果这些依赖真的都是需要的,那么这些逻辑是否应该放到同一个 hook 中?

function Example({id, name, address, status, personA, personB, progress}) {
  const [page, setPage] = useState();
  const [size, setSize] = useState();

  const doSearch = useCallback(() => {
    // ...
  }, []);

  const doRefresh = useCallback(() => {
    // ...
  }, []);


  useEffect(() => {
    id && doSearch({name, address, status, personA, personB, progress});
    page && doRefresh({name, page, size});
  }, [id, name, address, status, personA, personB, progress, page, size]);
}

可以看出,在 useEffect 中有两段逻辑,这两段逻辑是相互独立的,因此我们可以将这两段逻辑放到不同 useEffect 中:

useEffect(() => {
  id && doSearch({name, address, status, personA, personB, progress});
}, [id, name, address, status, personA, personB, progress]);

useEffect(() => {
  page && doRefresh({name, page, size});
}, [name,  page, size]);

如果逻辑无法继续拆分,但是「dependency array」还是依赖过多东西,该怎么办呢?就比如我们上面的代码:

useEffect(() => {
  id && doSearch({name, address, status, personA, personB, progress});
}, [id, name, address, status, personA, personB, progress]);

这段代码中的 useEffect 依赖了七个值,还是偏多了。仔细观察上面的代码,可以发现这些值都是「过滤条件」的一部分,通过这些条件可以过滤页面上的数据。因此,我们可以将它们看做一个整体,也就是我们前面讲过的合并 state:

const [filters, setFilters] = useState({
  name: "",
  address: "",
  status: "",
  personA: "",
  personB: "",
  progress: ""
});

useEffect(() => {
  id && doSearch(filters);
}, [id, filters]);

如果 state 不能合并,在 callback 内部又使用了 setState 方法,那么可以考虑使用 setState callback 来减少一些依赖。比如:

const useExample = () => {
  const [values, setValues] = useState({
    data: {},
    count: 0
  });

  const [updateData] = useCallback(
      (nextData) => {
        setValues({
          data: nextData,
          count: values.count + 1 // 因为 callback 内部依赖了外部的 values 变量,所以必须在 dependency array 中指定它
        });
      },
      [values], 
  );

  return [values, updateData];
};

上面的代码中,我们必须在 useCallback 的「dependency array」中指定 values,否则我们无法在 callback 中获取到最新的 values 状态。但是,通过 setState 回调函数,我们不用再依赖外部的 values 变量,因此也无需在「dependency array」中指定它。就像下面这样:

const useExample = () => {
  const [values, setValues] = useState({});

  const [updateData] = useCallback((nextData) => {
    setValues((prevValues) => ({
      data: nextData,
      count: prevValues.count + 1, // 通过 setState 回调函数获取最新的 values 状态,这时 callback 不再依赖于外部的 values 变量了,因此 dependency array 中不需要指定任何值
    }));
  }, []); // 这个 callback 永远不会重新创建

  return [values, updateData];
};

最后,还可以通过 ref 来保存可变变量,并对它进行读写。举个例子:

const useExample = () => {
  const [values, setValues] = useState({});
  const latestValues = useRef(values);
  latestValues.current = values;

  const [updateData] = useCallback((nextData) => {
    setValues({
      data: nextData,
      count: latestValues.current.count + 1,
    });
  }, []); 

  return [values, updateData];
};

在使用 ref 时要特别小心,因为它可以随意赋值,所以一定要控制好修改它的方法。特别是一些底层模块,在封装的时候千万不要直接暴露 ref,而是提供一些修改它的方法。

说了这么多,归根到底都是为了写出更加清晰、易于维护的代码。如果发现「dependency array」依赖过多,我们就需要重新审视自己的代码。

  • 「dependency array」依赖的值最好不要超过 3 个,否则会导致代码会难以维护。
  • 如果发现 「dependency array」依赖的值过多,我们应该采取一些方法来减少它。

    • 去掉不必要的「dependency array」。
    • 将 hook 拆分为更小的单元,每个 hook 依赖于各自的「dependency array」。
    • 通过合并相关的 state,将多个 dependency 聚合为一个 dependency。
    • 通过 setState 回调函数获取最新的 state,以减少外部依赖。
    • 通过 ref 来读取可变变量的值,不过需要注意控制修改它的途径。


问题三:该不该使用 useMemo?

该不该使用 useMemo?对于这个问题,有的人从来没有思考过,有的人甚至不觉得这是个问题。不管什么情况,只要用 useMemo 或者 useCallback 「包裹一下」,似乎就能使应用远离性能的问题。但真的是这样吗?有的时候 useMemo 没有任何作用,甚至还会影响应用的性能。

为什么这么说呢?首先,我们需要知道 useMemo 本身也有开销。useMemo 会「记住」一些值,同时在后续 render 时,将「dependency array」中的值取出来和上一次记录的值进行比较,如果不相等才会重新执行回调函数,否则直接返回「记住」的值。这个过程本身就会消耗一定的内存和计算资源。因此,过度使用 useMemo 可能会影响程序的性能。

要想合理使用 useMemo,我们需要搞清楚 useMemo 适用的场景:

  • 有些计算开销很大,我们就需要「记住」它的返回值,避免每次 render 都去重新计算。
  • 由于值的引用发生变化,导致下游组件重新渲染,我们也需要「记住」这个值。

让我们来看个例子:

interface IExampleProps {
  page: number;
  type: string;
}

const Example = ({page, type}: IExampleProps) => {
  const resolvedValue = useMemo(() => {
    getResolvedValue(page, type);
  }, [page, type]);

  return <ExpensiveComponent resolvedValue={resolvedValue}/>;
};

在上面的例子中,渲染 ExpensiveComponent 的开销很大。所以,当 resolvedValue 的引用发生变化时,作者不想重新渲染这个组件。因此,作者使用了 useMemo,避免每次 render 重新计算 resolvedValue,导致它的引用发生改变,从而使下游组件 re-render。

这个担忧是正确的,但是使用 useMemo 之前,我们应该先思考两个问题:

  1. 传递给 useMemo 的函数开销大不大?在上面的例子中,就是考虑 getResolvedValue 函数的开销大不大。JS 中大多数方法都是优化过的,比如 Array.map、Array.forEach 等。如果你执行的操作开销不大,那么就不需要记住返回值。否则,使用 useMemo 本身的开销就可能超过重新计算这个值的开销。因此,对于一些简单的 JS 运算来说,我们不需要使用 useMemo 来「记住」它的返回值。
  2. 当输入相同时,「记忆」值的引用是否会发生改变?在上面的例子中,就是当 page 和 type 相同时,resolvedValue 的引用是否会发生改变?这里我们就需要考虑 resolvedValue 的类型了。如果 resolvedValue 是一个对象,由于我们项目上使用「函数式编程」,所以每次函数调用都会产生一个新的引用。但是,如果 resolvedValue 是一个 JS 基本类型(string, boolean, null, undefined, number, symbol),也就不存在「引用」的概念了,每次计算出来的这个值一定是相等的。也就是说,ExpensiveComponent 组件不会被重新渲染。

因此,如果 getResolvedValue 的开销不大,并且 resolvedValue 返回一个字符串之类的原始值,那我们完全可以去掉 useMemo,就像下面这样:

interface IExampleProps {
  page: number;
  type: string;
}

const Example = ({page, type}: IExampleProps) => {
  const resolvedValue = getResolvedValue(page, type);
  return <ExpensiveComponent resolvedValue={resolvedValue}/>;
};

还有一个误区就是对创建函数开销的评估。有的人觉得在 render 中创建函数可能会开销比较大,为了避免函数多次创建,使用了 useMemo 或者 useCallback。但是对于现代浏览器来说,创建函数的成本微乎其微。因此,我们没有必要使用 useMemo 或者 useCallback 去节省这部分性能开销。当然,如果是为了保证每次 render 时回调的引用相等,你可以放心使用 useMemo 或者 useCallback。

export const Example = () => {
  const onSubmit = useCallback(() => { // 考虑这里的 useCallback 是否必要?
    doSomething();
  }, []);

  return <form onSubmit={onSubmit}></form>;
};

除此之外,很多人还喜欢用 useMemo 来保持引用的相等。比如:

export function Examples() {
  const users = useMemo(() => [1, 2, 3], []);

  return <ExpensiveComponent users={users} />
}

在上面的例子中, 作者用 useMemo 来 「记住」users 数组,不是因为数组本身的开销大,而是因为 users 的引用在每次 render 时都会发生改变,从而导致子组件 ExpensiveComponent 重新渲染。虽然 users 引用的变化会带来较大的开销,但这里使用 useMemo 却并不合适。先仔细看看 「dependency array」:

useMemo(() => [1, 2, 3], []);

在上面的代码中,传递给 useMemo 的依赖数组是一个空数组,也就是说 [1, 2, 3] 仅在组件加载时计算一次。因此,我们可以得出:被记忆值的计算开销并不大,并且在组件挂载之后不会重新计算。

作者只是想在重新渲染时保持值的引用不变,而不是「记住」一个值。所以,更好的方法是使用 useRef,而不是 useMemo。如果你不喜欢 current 属性,你通过解构并重命名的方式来使用:

export function Examples() {
  const {current: users} = useRef([1, 2, 3]);

  return <ExpensiveComponent users={users} />
}

以前我们只把 ref 用作保持 dom 节点引用的工具,可 useRef hook 能做的事情远不止如此。我们可以用它来保存一些值的引用,以便随时取到最新的值。

useMemo 用好了能提高应用的性能,用不好反而会带来更多问题。因此,在使用 useMemo 前,我希望大家能思考下面几个问题:

  1. 要记住的函数开销很大吗?
  2. 返回的值会被其他 hook 或者子组件用到吗?(用到的话就可能会造成较大开销)
  3. 返回的值是原始值吗?
  4. 使用 useMemo 还是 useRef 更合适?(不要仅仅为了保持引用的一致而「记忆」一个值)

回答出上面这几个问题,判断是否应该使用 useMemo 也就不再困难了。


问题四:Hooks 能替代高阶组件和 Render Props 吗?

在 Hooks 出现之前,我们有两种方法可以复用组件逻辑:Render Props 和高阶组件。但是这两种方法都可能会造成 JSX「嵌套地域」的问题。Hooks 的出现,让组件逻辑的复用变得更简单,同时解决了「嵌套地域」的问题。Hooks 之于 React 就像 async / await 之于 Promise 一样。

那 Hooks 能替代高阶组件和 Render Props 吗?官方给出的回答是,在高阶组件或者 Render Props 只渲染一个子组件时,Hook 提供了一种更简单的方式。不过在我看来,Hooks 并不能完全替代 Render Props 和高阶组件。接下来,我们会详细分析这个问题。

高阶组件 HOC

高阶组件是一个函数,它接受一个组件作为参数,返回一个新的组件。

function enhance(Comp) {
  // 增加一些其他的功能
  return class extends Component {
    // ...
    render() {
      return <Comp />;
    }
  };
}

高阶组件采用了装饰器模式,让我们可以增强原有组件的功能,并且不破坏它原有的特性。例如:

const RedButton = withStyles({
  root: {
    background: "red",
  },
})(Button);

在上面的代码中,我们希望保留 Button 组件的逻辑,但同时我们又想使用它原有的样式。因此,我们通过 withStyles 这个高阶组件注入了自定义的样式,并且生成了一个新的组件 RedButton。

Render Props

Render Props 通过父组件将可复用逻辑封装起来,并把数据提供给子组件。至于子组件拿到数据之后要怎么渲染,完全由子组件自己决定,灵活性非常高。而高阶组件中,渲染结果是由父组件决定的。Render Props 不会产生新的组件,而且更加直观的体现了「父子关系」。

<Parent>
  {(data) => {
    // 你父亲已经把江山给你打好了,并给你留下了一堆金币,至于怎么花就看你自己了
    return <Child data={data} />;
  }}
</Parent>

Render Props 作为 JSX 的一部分,可以很方便地利用 React 生命周期和 Props、State 来进行渲染,在渲染上有着非常高的自由度。同时,它不像 Hooks 需要遵守一些规则,你可以放心大胆的在它里面使用 if / else、map 等各类操作。

在大部分情况下,高阶组件和 Render Props 是可以相互转换的,也就是说用高阶组件能实现的,用 Render Props 也能实现。只不过在不同的场景下,哪种方式使用起来简单一点罢了。

将上面 HOC 的例子改成 Render Props,使用起来确实要「麻烦」一点:

<RedButton>
  {(styles)=>(
    <Button styles={styles}/>
  )}
</RedButton>

小结

没有 Hooks 之前,高阶组件和 Render Props 本质上都是将复用逻辑提升到父组件中。而 Hooks 出现之后,我们将复用逻辑提取到组件顶层,而不是强行提升到父组件中。这样就能够避免 HOC 和 Render Props 带来的「嵌套地域」。但是,像 Context 的 <Provider/> 和 <Consumer/> 这样有父子层级关系(树状结构关系)的,还是只能使用 Render Props 或者 HOC。

对于 Hooks、Render Props 和高阶组件来说,它们都有各自的使用场景:

  • Hooks:

    • 替代 Class 的大部分用例,除了 getSnapshotBeforeUpdate 和 componentDidCatch 还不支持。
    • 提取复用逻辑。除了有明确父子关系的,其他场景都可以使用 Hooks。
  • Render Props:在组件渲染上拥有更高的自由度,可以根据父组件提供的数据进行动态渲染。适合有明确父子关系的场景。
  • 高阶组件:适合用来做注入,并且生成一个新的可复用组件。适合用来写插件。

不过,能使用 Hooks 的场景还是应该优先使用 Hooks,其次才是 Render Props 和 HOC。当然,Hooks、Render Props 和 HOC 不是对立的关系。我们既可以用 Hook 来写 Render Props 和 HOC,也可以在 HOC 中使用 Render Props 和 Hooks。


问题五: 使用 Hooks 时还有哪些好的实践?

  1. 若 Hook 类型相同,且 dependency array 一致时,应该合并成一个 Hook。否则会产生更多开销。
const dataA = useMemo(() => {
  return getDataA();
}, [A, B]);

const dataB = useMemo(() => {
  return getDataB();
}, [A, B]);

// 应该合并为
  
const [dataA, dataB] = useMemo(() => {
  return [getDataA(), getDataB()]
}, [A, B]);
  1. 参考原生 Hooks 的设计,自定义 Hooks 的返回值可以使用 Tuple 类型,更易于在外部重命名。但如果返回值的数量超过三个,还是建议返回一个对象。
export const useToggle = (defaultVisible: boolean = false) => {
  const [visible, setVisible] = useState(defaultVisible);
  const show = () => setVisible(true);
  const hide = () => setVisible(false);

  return [visible, show, hide] as [typeof visible, typeof show, typeof hide];
};

const [isOpen, open, close] = useToggle(); // 在外部可以更方便地修改名字
const [visible, show, hide] = useToggle();
  1. ref 不要直接暴露给外部使用,而是提供一个修改值的方法。
  2. 将方法静态化。也就是说,方法只创建一次,不会根据某些值的变化而二次创建。让我们来看一个例子:
export const useValues = () => {
  const [values, setValues] = useState();

  const updateValues = useMemo(
    () => (nextValues: any) => {
      setValues({
        ...values,
        ...nextValues,
      });
    },
    [values],
  );

  return [values, updateValues];
};

在上面的例子中,为了避免每次 render 时都去创建 updateValues 函数,我们使用了 useMemo。只有当 values 发生变化时,才会重新创建 updateValues 函数。我们把 updateValues 函数暴露出去给外部使用。


最后

我们总结了在实践中一些常见的问题,并提出了一些解决方案。最后让我们再来回顾一下:

  1. 将完全不相关的 state 拆分为多组 state。
  2. 如果某些 state 是相互关联的,或者需要一起发生改变,就可以把它们合并为一组 state。
  3. 「dependency array」依赖的值最好不要超过 3 个,否则会导致代码会难以维护。
  4. 如果发现 「dependency array」依赖的值过多,我们应该采取一些方法来减少它。

    • 去掉不必要的「dependency array」。
    • 将 hook 拆分为更小的单元,每个 hook 依赖于各自的「dependency array」。
    • 通过合并相关的 state,将多个 dependency 聚合为一个 dependency。
    • 通过 setState 回调函数获取最新的 state,以减少外部依赖。
    • 通过 ref 来读取可变变量的值,不过需要注意控制修改它的途径。
  5. 不要滥用 useMemo,使用 useMemo 前应该询问自己几个问题:

    • 要记住的函数开销很大吗?
    • 返回的值会被其他 hook 或者子组件用到吗?(用到的话就可能会造成较大开销)
    • 返回的值是原始值吗?
    • 使用 useMemo 还是 useRef 更合适?(不要仅仅为了保持引用的一致而「记忆」一个值)
  6. Hooks、Render Props 和高阶组件都有各自的使用场景,具体使用哪一种要看实际情况。
  7. 若 Hook 类型相同,且 dependency array 一致时,应该合并成一个 Hook。
参考文章:You’re overusing useMemo: Rethinking Hooks memoization
原文连接:https://segmentfault.com/a/1190000020616412

链接: https://www.fly63.com/article/detial/5753

React将引入Hooks,你怎么看?

近日,据 MIT Technology Review 报道,一位名为“Repairnator”的机器人在 GitHub 上“卧底”数月,查找错误并编写和提交修复补丁,结果有多个补丁成功通过并被采纳,这位 Repairnator 到底是如何拯救程序员于水火的呢?

精通React今年最劲爆的新特性——React Hooks

你还在为该使用无状态组件(Function)还是有状态组件(Class)而烦恼吗?你还在为搞不清使用哪个生命周期钩子函数而日夜难眠吗?你在还在为组件中的this指向而晕头转向吗?这样看来,说React Hooks是今年最劲爆的新特性真的毫不夸张。

使用react hooks实现自己的context-redux

我们将userReducer函数返回的原始dispath命名为origin_dispatch,自定义dispatch函数,当action为函数的时候,我们执行action函数,并将origin_dispatch当作参数传进去;action不是函数,直接调用origin_dispatch,不做处理

useEffect Hook 是如何工作的?

使用useEffect 就像瑞士军刀。它可以用于很多事情,从设置订阅到创建和清理计时器,再到更改ref的值。与 componentDidMount、componentDidUpdate 不同的是,在浏览器完成布局与绘制之后,传给 useEffect 的函数会延迟调用。

如何用 Hooks 来实现 React Class Component 写法?

Hooks 的 API 可以参照 React 官网。本文主要是结合 Demo 详细讲解如何用 Hooks 来实现 React Class Component 写法,让大家更深的理解 Hooks 的机制并且更快的入门。 注意:Rax 的写法和 React 是一致的

React-Hooks

以下是上一代标准写法类组件的缺点,也正是hook要解决的问题,型组件很难拆分和重构,也很难测试。业务逻辑分散在组件的各个方法之中,导致重复逻辑或关联逻辑。

React Hooks与setInterval

Hooks出来已经有段时间了,相信大家都用过段时间了,有没有小伙伴们遇到坑呢,我这边就有个 setInterval 的坑,和小伙伴们分享下解决方案。写个 count 每秒自增的定时器,如下写法结果,界面上 count 为 1 ?

React Hooks 底层解析[译]

对于 React 16.7 中新的 hooks 系统在社区中引起的骚动,我们都有所耳闻了。人们纷纷动手尝试,并为之兴奋不已。一想到 hooks 时它们似乎是某种魔法,React 以某种甚至不用暴露其实例

React Hooks实践

9月份开始,使用了React16.8的新特性React Hooks对项目进行了重构,果然,感觉没有被辜负,就像阮一峰老师所说的一样,这个 API 是 React 的未来。

用React hooks实现TDD

由于篇幅所限文章中并没有给出demo的所有代码,大家如果有兴趣可以将代码clone到本地从commit来看整个demo的TDD过程,配合文章来看会比较清晰,从进公司前认识了TDD,到实践TDD

点击更多...

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!