今天聊一下四个真秀React用法,你值得拥有

不是标题党,本文是我阅读React的一些组件库源码学到的一些比较秀的React语法,先整理了一部分,后续还会将更多源码里面的技巧做整理输出

批量更新多次渲染,你可能需要了解一下 unstable_batchedUpdates

1. 举一个🌰

想象这样一个场景, 一个页面上面同时有一个表单和一个表格,就像下图所示这样

Clipboard_2022-03-09-13-53-17.png

我们希望用户在点击查询按钮的时候, 表格可以将当前页码调整为第一页,同时加载表格的数据,比如像下面代码所示

import React, { useState, useEffect } from "react";

const Test = () => {
  const [page, setPage] = useState(1);
  const [filterParams, setFilterParams] = useState({});
  const [data, setData] = useState([]);
  const form = useRef();

  useEffect(() => {
    // 当page 或 搜索条件发生变化的时候,重新请求表格数据
    fetch("/loadData", {
      method: "get",
      params: {
        page: page,
        ...filterParams,
      },
    }).then((data) => {
      setData(data);
    });
  }, [page, filterParams]);

  function handleSearch() {
    // 通过 validateFields 异步获取表单的值
    form.current.validateFields().then((formData) => {
      // 设置搜索条件
      setFilterParams(formData);
      // 将页码置为1
      setPage(1);
    });
  }

  return (
    <>
      <Form ref={form}>
        <span>表单元素</span>
        <Button onClick={handleSearch}>搜索</Button>
      </Form>
      <Table data={data} pagination={{ page: page }}></Table>
    </>
  );
};

上面的代码,当用户点击搜索按钮的时候,我们做了下面三件事

  1. 调用表单的validateFields方法异步获取表单的数据
  2. 设置搜索条件
  3. 将页码重置为首页

然而,问题出现了,我们发现加载表格数据的请求被发出去了两条,而且第一条请求的页码并不是我们设置的1,这是怎么回事呢?

2. 问题分析

我们知道,在React的事件循环内部,多次setState会被合并成一次来触发更新,所以我们通常写React批量更新状态的时候并不会出现问题,但是这里有一个特例,就是React不会将异步代码里面的多次状态更新进行合并。

比如常见的setTimeout,Promise等等这些异步操作,在这些异步操作中更新多个状态的话,React就不会进行状态合并了,那么有没有什么办法解决这个问题了

3. unstable_batchedUpdates,该你上场了

为了解决异步批量更新状态引起的问题,react提供了一个临时的api unstable_batchedUpdates 进行批量更新,那么这个api应该怎么使用呢?

  function handleSearch() {
    // 通过 validateFields 异步获取表单的值
    form.current.validateFields().then((formData) => {
      unstable_batchedUpdates(() => {
        // 设置搜索条件
        setFilterParams(formData);
        // 将页码置为1
        setPage(1);
      })
    });
  }

如上代码所示,只需要将批量更新状态的代码使用 unstable_batchedUpdates 包裹起来就可以了。

4. 所有异步状态都需要用unstable_batchedUpdates来包裹吗

我认为是不需要的,只有在批量更新状态的时候引起请求重复发送,页面渲染卡顿等影响用户体验的时候,再用这个api也不迟

发布订阅者模式,帮你写一个高性能Hook

1. 举一个🌰

假如有这样一个需求,我们在封装的一些组件里面需要监听如下图红框区域尺寸发生变化时的实际宽度,为了能在多个组件内复用逻辑,所以我们封装了一个useLayoutReiszehook,实现的代码如下所示

Clipboard_2022-03-13-15-30-45.png

2. 常规实现思路

  const useLayoutResize = () => {
  const [size, setSize] = useState({
    width: 0,
    height: 0,
  });

  useEffect(() => {
    function handleResize() {
      const layout = document.querySelector(".layout");
      const bound = layout.getBoundingClientRect();
      setSize({
        width: bound.width,
        height: bound.height,
      });
    }
    window.addEventListener("resize", handleResize);
    return () => {
      window.removeEventListener("resize", handleResize);
    };
  }, []);

  return [size];
};

export default useLayoutResize;

上面的代码我们实现了一个useLayoutResizehook,在组件内我们只需要像下面代码那样使用就可以了

  const [size] = useLayoutResize();
  useEffect(() => {
    console.log("宽度发生了变化", size.width);
  }, [size.width]);

useLayoutResize已经满足了需求,但是假如我们一个页面上面有十个组件都要使用useLayoutResize呢? 仔细看useLayoutResize的实现,我们就需要在十个组件里面监听十次resize事件,然后当浏览器窗口发生变化是,需要调用十次getBoundingClientRect, 而每一次调用getBoundingClientRect多会引起浏览器的重绘,可能会引起性能问题,所以我们需要想办法去解决这个问题

3. 使用发布订阅者模式优化

我们的需求本质上只是去监听layout容器的尺寸变化,监听一次就足够了,所以我们能否可以将监听的逻辑提取出来,当尺寸变化的时候依次去通知每一个useLayoutReisze,这时候就需要使用到了发布订阅者模式

发布订阅者的实现

const subscribes = new Map();
let subId = -1;
let size = {};
let layout = undefined;

const layoutResponsiveObserve = {
  // 触发订阅事件
  dispatch(currentSize) {
    size = currentSize;
    subscribes.forEach((func) => func(size));
    return subscribes.size > 0;
  },
  // 订阅事件
  subscribe(func) {
    // 如果监听事件还没有注册,则注册监听事件
    if (!subscribes.size) {
      this.register();
    }
    subId += 1;
    subscribes.set(subId, func);
    func(size);
    return subId;
  },
  unSubscribe(id) {
    subscribes.delete(id);
    if (!subscribes.size) {
      this.unRegister();
    }
  },
  register() {
    if (!layout) {
      // 初始化layout
      layout = document.querySelector(".layout");
      // addEventListener会导致 handleListener的this指向有问题,所以在这里bind一下
      this.handleListener = this.handleListener.bind(this);
    }
    window.addEventListener("resize", this.handleListener);
    this.handleListener();
  },
  unRegister() {
    window.removeEventListener("resize", this.handleListener);
    subscribes.clear();
  },
  handleListener() {
    const bound = layout.getBoundingClientRect();
    const size = {
      width: bound.width,
      height: bound.height,
    };
    this.dispatch(size);
  },
};

export default layoutResponsiveObserve;

useLayoutResize订阅事件

上面的代码实现了layout resize的发布订阅代码,那么如何在useLayoutResize中使用呢?

import React, { useEffect, useMemo, useState } from "react";
import layoutResponsiveObserve from "./layoutResponsiveObserve";
const useLayoutResize = () => {
  const [size, setSize] = useState({
    width: 0,
    height: 0,
  });
  useEffect(() => {
    // 监听
    const token = layoutResponsiveObserve.subscribe((size) => {
      setSize(size);
    });
    // 组件销毁时取消监听
    return () => {
      layoutResponsiveObserve.unSubscribe(token);
    };
  }, []);

  return [size];
};

export default useLayoutResize;

通过上面的代码,无论我们在多少个组件里面使用useLayoutResize, 对于layout的监听事件来说,我们只做了一次监听,这样我们也就不用担心多次监听多次计算size引起的性能问题了

异常边界

1. 举一个🌰

我们不能保证自己写的代码一定没有bug,所以我们就需要考虑如果我们的组件代码报错了,应该怎么处理呢?比如下面的代码

const Test = () => {
  useEffect(() => {
    const array = "";
    // 这里明显是错的
    array.push(3);
  }, []);

  return <div>这是一个🌰</div>;
};

export default Test;

这里有一个比较明显的bug,就是给字符串调用push方法。执行代码,在开发的时候页面会显示为:

Clipboard_2022-03-14-13-33-39.png

而在生产环境则会导致整个页面崩溃,显示为空白页面,某一个组件报错导致整个页面崩溃,这可是一个严重的bug,那么我们应该如何去降低代码报错带来的影响呢?

2. 看一下异常边界

对于我们来说,我们希望当页面的某一个组件发生报错时,最好不要影响到其他组件的显示,比如像下图所示的这种模式

Clipboard_2022-03-14-13-55-13.png

通过上图可以看到,某一个组件报错了,但是页面的其他内容还是可以正常显示出来的,并没有受到影响。而实现这种效果就需要使用到异常边界了。

什么是异常边界?

异常边界是React 16以后推出的新特性,使用异常组件可以捕获子组件js的错误,并可以展示备用UI的class组件。

异常边界如何实现

下面代码实现了一个简单的异常边界组件,需要注意的是,异常边界组件必须使用class组件,不能使用函数式组件

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      hasError: false,
    };
  }

  static getDerivedStateFromError(error) {
    return {
      hasError: true,
    };
  }

  componentDidCatch(error, errorInfo) {
    // 可以在这里上报错误日志
  }

  render() {
    if (this.state.hasError) {
      return (
        <Result
          status="error"
          title="哇哦,出现了错误"
          subTitle="请联系管理员"
        ></Result>
      );
    }
    return this.props.children;
  }
}

export default ErrorBoundary;

怎么使用异常组件

只需要将组件作为ErrorBoundary的子组件来使用就可以了,如下代码

// 定义一个组件
const Child = () => {
  return <div>子组件</div>
}

// 在父页面使用异常边界组件
const Parent = () => {

  return <>
    <ErrorBoundary><Child/></ErrorBoundary>
    <ErrorBoundary><Child2/></ErrorBoundary>
    <ErrorBoundary><Child3/></ErrorBoundary>
  </>
} 

通过上面的代码,无论哪一个组件发生报错,都不会影响到其他组件的正常显示了。

3. 有哪些限制

虽然异常捕获可以捕获子组件的错误,但是它还是存在一些限制的

  1. 不会捕获异步代码(比如setTimeout,Promise)中的异常
  2. 不能捕获服务端渲染的错误
  3. 假如异常边界组件自身报错了,也不能被捕获
  4. 事件里面的报错

操作子组件,实现秀儿功能

1. 举一个🌰

在页面开发中,使用单选按钮我们一般会像下面这样去写:

<input type="radio" name="colors" id="red">红色<br>
<input type="radio" name="colors" id="blue">蓝色<br>
<input type="radio" name="colors" id="yellow">黄色<br>

为了能让多个单选按钮组成单选按钮组,我们需要给多个单选按钮指定相同的name,但实际上原生的单选按钮样式并不好看,通过我们都是使用封装过的单选按钮组,UI效果类似下图这样的

Clipboard_2022-03-15-21-26-37.png

封装完之后,在页面的使用代码类似下图所示这样

  <Radio.Group>
    <Radio value="red">红色</Radio>
    <Radio value="blue">蓝色</Radio>
    <Radio value="yellow">黄色</Radio>
  </Radio.Group>

这里存在一个问题,怎么给每一个Radio指定name呢?是给每一个Radio都设置一遍name,还是给Radio.Group指定name,然后由Radio.Group分发给每一个Radio呢?肯定是给Radio.Group指定name更方便一些。

2.先来实现一个单选组件组吧

单选按钮代码

import React from "react";

export interface IProps {
  name?: string;
  value: any;
}

const Radio: React.FunctionComponent<React.PropsWithChildren<IProps>> = ({
  name,
  value,
  children,
}) => {
  // 示例代码,未定义样式
  return (
    <label>
      <span>
        <input type="radio" name={name} value={value}></input>
      </span>
      <span>{children}</span>
    </label>
  );
};

export default Radio;

单选按钮组

import React from "react";

export interface IProps {
  name?: string;
}

const Group: React.FunctionComponent<React.PropsWithChildren<IProps>> = ({
  name,
  children,
}) => {
  return <div>{children}</div>;
};

export default Group;

组件出口

import InnerRadio, { IProps as RadioProps } from "./Radio";
import Group from "./Group";
import React from "react";

export interface CompoundedComponent
  extends React.FunctionComponent<RadioProps> {
  Group: typeof Group;
}

const Radio = InnerRadio as CompoundedComponent;
Radio.Group = Group;
export default Radio;

通过上面三段代码,我们就开发了一个单选按钮组,在页面上面使用的话需要像下面这样

 <Radio.Group>
    <Radio name="a" value="red">
      红色
    </Radio>
    <Radio name="a" value="blue">
      蓝色
    </Radio>
  </Radio.Group>

这明显不符合预期想要的将name放到Radio.Group的想法啊,为了满足我们的要求,所以我们引入了React.Children

3. 使用React.Children解决name位置问题

接下来对Radio.Group的代码做一下调整

export interface IProps {
  name: string;
  children: React.ReactNode;
}

const Group: React.FC<IProps> = ({ name, children }) => {
  return (
    <div>
      {React.Children.map(children, (child) => {
        if (React.isValidElement(child)) {
          return React.cloneElement(child, {
            name: name,
          });
        }
        throw new Error("子组件必须是React.ReactElement类型");
      })}
    </div>
  );
};

在上面的代码中,我们引入了React.Children,React.isValidElement,React.cloneElement三个API,将我们想要的功能实现了出来,那么这三个API都是做什么的,都有什么用呢?

4. React.Children 介绍

react官网上面,对React.Children介绍是

React.Children provides utilities for dealing with the this.props.children opaque data structure.

对于reactprops.children,对于开发者来说是一个不透明的数据结构,可能是JSX,数组,函数,字符串,boolean等等,比如我们想知道props.childrenlength是多少,就没办法写成props.children.length,因为像boolean并没有length属性,而字符串比如'react'.length的长度又是5,这时候就需要借助React.Children.count(props.children)来统计个数

React.Children共有五个API,分别是React.Children.map, React.Children.forEach, React.Children.count, React.Children.toArray, React.Children.only

React.Children.map

语法: React.Children.map(children, function[(thisArg)])

React.Children.map用于遍历props.children中的每一个节点,并为每一个节点调用回调函数,在回调函数中返回新的节点。当props.children的值为undefinednull的时候,会直接返回undefined/null,在前面我们实现Radio.Group便使用到了React.Children.map

React.Children.forEach

语法: React.Children.forEach(children, function[(thisArg)])

React.Children.forEach的使用用法与React.Children.map是完全一致的,只是没有返回值

React.Children.count

语法: React.Children.count(children)

因为props.children的数据结构是不透明的,为了能够知道props.children到底有多少个节点,所以就有了React.Children.count

看一个例子,我封装了一个List组件,代码如下

  <List>
    <List.Item key="1">1</Listm.Item>
    <List.Item key="2">2</Listm.Item>
    <List.Item key="3">3</Listm.Item>
    <List.Item key="4">4</Listm.Item>
    <List.Item key="5">5</Listm.Item>
    <List.Item key="6">6</Listm.Item>
  </List>

我现在希望当List.Item的个数超过10个的时候,就只显示10个,然后超过的部分在列表底部加一个查看更多按钮,点击查看更多再显示,为了知道List.Item的个数,我们就需要使用到了React.Children.count

const List: React.FC<IProps> = ({ children }) => {
  const length = useMemo(() => {
    return React.Children.count(children);
  }, [children]);

  // .....
};

现在个数出来了,那么怎么取出来List.Item的前十个呢,这时候就可以使用React.Children.toArray

React.Children.toArray

语法:React.Children.toArray(children)

React.Children.toArray用于将props.children数据结构以扁平的Array结构暴露给我们,通常用于重新排序或过滤部分children的情景。

在前面List.Item获取前十条的场景,我们将children转换为Array,然后就可以使用数组的slice方法获取数组的前十条了

  const list = useMemo(() => {
    return React.Children.toArray(children).slice(0,10)
  },[children])

React.Children.only

语法: React.Children.only(children)

验证子元素是否只有一个子元素(React.ReactElement)并返回它。否则,此方法将抛出错误。注意:React.Children.only不接受React.Children.map的返回值,因为它是一个数组而不是一个React元素。

5. React.isValidElementReact.cloneElement 介绍

语法: React.isValidElement(object)
语法: React.cloneElement(element,[config],[...children])

React.isValidElement用于验证传入的是不是React Element,在前文我们在Radio.Group中有使用到这个API,因为props.children对于我们来说是不透明的,所以当我们需要对组件做某些只有React Element才有的操作的时候,就需要调用这个API来进行验证

React.cloneElement用于克隆一个元素,然后返回一个新的元素,在前文我们在Radio.Group中有使用到这个API。那么什么时候会用到这个API呢?当我们希望修改props.children的属性的时候,就可以使用这个API了.

正文完