不是标题党,本文是我阅读React的一些组件库源码学到的一些比较秀的React语法,先整理了一部分,后续还会将更多源码里面的技巧做整理输出
批量更新多次渲染,你可能需要了解一下 unstable_batchedUpdates
1. 举一个🌰
想象这样一个场景, 一个页面上面同时有一个表单和一个表格,就像下图所示这样
我们希望用户在点击查询按钮的时候, 表格可以将当前页码调整为第一页,同时加载表格的数据,比如像下面代码所示
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>
</>
);
};
上面的代码,当用户点击搜索按钮的时候,我们做了下面三件事
- 调用表单的validateFields方法异步获取表单的数据
- 设置搜索条件
- 将页码重置为首页
然而,问题出现了,我们发现加载表格数据的请求被发出去了两条,而且第一条请求的页码并不是我们设置的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. 举一个🌰
假如有这样一个需求,我们在封装的一些组件里面需要监听如下图红框区域尺寸发生变化时的实际宽度,为了能在多个组件内复用逻辑,所以我们封装了一个useLayoutReisze
的hook
,实现的代码如下所示
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;
上面的代码我们实现了一个useLayoutResize
的hook
,在组件内我们只需要像下面代码那样使用就可以了
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
方法。执行代码,在开发的时候页面会显示为:
而在生产环境则会导致整个页面崩溃,显示为空白页面,某一个组件报错导致整个页面崩溃,这可是一个严重的bug
,那么我们应该如何去降低代码报错带来的影响呢?
2. 看一下异常边界
对于我们来说,我们希望当页面的某一个组件发生报错时,最好不要影响到其他组件的显示,比如像下图所示的这种模式
通过上图可以看到,某一个组件报错了,但是页面的其他内容还是可以正常显示出来的,并没有受到影响。而实现这种效果就需要使用到异常边界了。
什么是异常边界?
异常边界是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. 有哪些限制
虽然异常捕获可以捕获子组件的错误,但是它还是存在一些限制的
- 不会捕获异步代码(比如
setTimeout
,Promise
)中的异常 - 不能捕获服务端渲染的错误
- 假如异常边界组件自身报错了,也不能被捕获
- 事件里面的报错
操作子组件,实现秀儿功能
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
效果类似下图这样的
封装完之后,在页面的使用代码类似下图所示这样
<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.
对于react
的props.children
,对于开发者来说是一个不透明的数据结构,可能是JSX
,数组,函数,字符串,boolean
等等,比如我们想知道props.children
的length
是多少,就没办法写成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
的值为undefined
或null
的时候,会直接返回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.isValidElement
与 React.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
了.