每天都在写业务代码中度过,但是呢,经常在写业务代码的时候,会感觉自己写的某些代码有点别扭,但是又不知道是哪里别扭,今天这篇文章我整理了一些在项目中使用的一些小的技巧点。
状态逻辑复用
在使用React Hooks之前,我们一般复用的都是组件,对组件内部的状态是没办法复用的,而React Hooks的推出很好的解决了状态逻辑的复用,而在我们日常开发中能做到哪些状态逻辑的复用呢?下面我罗列了几个当前我在项目中用到的通用状态复用。
useRequest
为什么要封装这个hook呢?在数据加载的时候,有这么几点是可以提取成共用逻辑的
loading状态复用- 异常统一处理
const useRequest = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const run = useCallback(async (...fns) => {
setLoading(true);
try {
await Promise.all(
fns.map((fn) => {
if (typeof fn === 'function') {
return fn();
}
return fn;
})
);
} catch (error) {
setError(error);
} finally {
setLoading(false);
}
}, []);
return { loading, error, run };
};
function App() {
const { loading, error, run } = useRequest();
useEffect(() => {
run(
new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 2000);
})
);
}, []);
return (
<div className="App">
<Spin spinning={loading}>
<Table columns={columns} dataSource={data}></Table>
</Spin>
</div>
);
}
usePagination
我们用表格的时候,一般都会用到分页,通过将分页封装成hook,一是可以介绍前端代码量,二是统一了前后端分页的参数,也是对后端接口的一个约束。
const usePagination = (
initPage = {
total: 0,
current: 1,
pageSize: 10,
}
) => {
const [pagination, setPagination] = useState(initPage);
// 用于接口查询数据时的请求参数
const queryPagination = useMemo(
() => ({ limit: pagination.pageSize, offset: pagination.current - 1 }),
[pagination.current, pagination.pageSize]
);
const tablePagination = useMemo(() => {
return {
...pagination,
onChange: (page, pageSize) => {
setPagination({
...pagination,
current: page,
pageSize,
});
},
};
}, [pagination]);
const setTotal = useCallback((total) => {
setPagination((prev) => ({
...prev,
total,
}));
}, []);
const setCurrent = useCallback((current) => {
setPagination((prev) => ({
...prev,
current,
}));
}, []);
return {
// 用于antd 表格使用
pagination: tablePagination,
// 用于接口查询数据使用
queryPagination,
setTotal,
setCurrent,
};
};
除了上面示例的两个hook,其实自定义hook可以无处不在,只要有公共的逻辑可以被复用,都可以被定义为独立的hook,然后在多个页面或组件中使用,我们在使用redux,react-router的时候,也会用到它们提供的hook。
在合适场景给useState传入函数
我们在使用useState的setState的时候,大部分时候都会给setState传入一个值,但实际上setState不但可以传入普通的数据,而且还可以传入一个函数。下面极端代码分别描述了几个传入函数的例子。
下面的代码3秒后输出什么?
如下代码所示,也有有两个按钮,一个按钮会在点击后延迟三秒然后给count + 1, 第二个按钮会在点击的时候,直接给count + 1,那么假如我先点击延迟的按钮,然后多次点击不延迟的按钮,三秒钟之后,count的值是多少?
import { useState, useEffect } from 'react';
function App() {
const [count, setCount] = useState(0);
function handleClick() {
setTimeout(() => {
setCount(count + 1);
}, 3000);
}
function handleClickSync() {
setCount(count + 1);
}
return (
<div className="App">
<div>count:{count}</div>
<button onClick={handleClick}>延迟加一</button>
<button onClick={handleClickSync}>加一</button>
</div>
);
}
export default App;
我们知道,React的函数式组件会在自己内部的状态或外部传入的props发生变化时,做重新渲染的动作。实际上这个重新渲染也就是重新执行这个函数式组件。
当我们点击延迟按钮的时候,因为count的值需要三秒后才会改变,这时候并不会重新渲染。然后再点击直接加一按钮,count值由1变成了2, 需要重新渲染。这里需要注意的是,虽然组件重新渲染了,但是setTimeout是在上一次渲染中被调用的,这也意味着setTimeout里面的count值是组件第一次渲染的值。
所以即使第二个按钮加一多次,三秒之后,setTimeout回调执行的时候因为引用的count的值还是初始化的0, 所以三秒后count + 1的值就是1
如何让上面的代码延迟三秒后输出正确的值?
这时候就需要使用到setState传入函数的方式了,如下代码:
import { useState, useEffect } from 'react';
function App() {
const [count, setCount] = useState(0);
function handleClick() {
setTimeout(() => {
setCount((prevCount) => prevCount + 1);
}, 3000);
}
function handleClickSync() {
setCount(count + 1);
}
return (
<div className="App">
<div>count:{count}</div>
<button onClick={handleClick}>延迟加一</button>
<button onClick={handleClickSync}>加一</button>
</div>
);
}
export default App;
从上面代码可以看到,setCount(count + 1)被改为了 setCount((prevCount) => prevCount + 1)。我们给setCount传入一个函数,setCount会调用这个函数,并且将前一个状态值作为参数传入到函数中,这时候我们就可以在setTimeout里面拿到正确的值了。
还可以在useState初始化的时候传入函数
看下面这个例子,我们有一个getColumns函数,会返回一个表格的所以列,同时有一个count状态,每一秒加一一次。
function App() {
const columns = getColumns();
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
setCount((prevCount) => prevCount + 1);
}, 1000);
}, []);
useEffect(() => {
console.log('columns发生了变化');
}, [columns]);
return (
<div className="App">
<div>count: {count}</div>
<Table columns={columns}></Table>
</div>
);
}
上面的代码执行之后,会发现每次count发生变化的时候,都会打印出columns发生了变化,而columns发生变化便意味着表格的属性发生变化,表格会重新渲染,这时候如果表格数据量不大,没有复杂处理逻辑还好,但如果表格有性能问题,就会导致整个页面的体验变得很差?其实这时候解决方案有很多,我们看一下如何用useState来解决呢?
// 将columns改为如下代码
const [columns] = useState(() => getColumns());
这时候columns的值在初始化之后就不会再发生变化了。有人提出我也可以这样写 useState(getColumns()), 实际这样写虽然也可以,但是假如getColumns函数自身存在复杂的计算,那么实际上虽然useState自身只会初始化一次,但是getColumn还是会在每次组件重新渲染的时候被执行。
上面的代码也可以简化为
const [columns] = useState(getColumns);
了解hook比较算法的原理
const useColumns = (options) => {
const { isEdit, isDelete } = options;
return useMemo(() => {
return [
{
title: '标题',
dataIndex: 'title',
key: 'title',
},
{
title: '操作',
dataIndex: 'action',
key: 'action',
render() {
return (
<>
{isEdit && <Button>编辑</Button>}
{isDelete && <Button>删除</Button>}
</>
);
},
},
];
}, [options]);
};
function App() {
const columns = useColumns({ isEdit: true, isDelete: false });
const [count, setCount] = useState(1);
useEffect(() => {
console.log('columns变了');
}, [columns]);
return (
<div className="App">
<div>
<Button onClick={() => setCount(count + 1)}>修改count:{count}</Button>
</div>
<Table columns={columns} dataSource={[]}></Table>
</div>
);
}
如上面的代码,当我们点击按钮修改count的时候,我们期待只有count的值会发生变化,但是实际上columns的值也发生了变化。想了解为什么columns会发生变化,我们先了解一下react比较算法的原理。
react比较算法底层是使用的Object.is来比较传入的state的.
语法: Object.is(value1, value2);
如下代码是Object.is比较不同数据类型的数据时的返回值:
Object.is('foo', 'foo'); // true
Object.is(window, window); // true
Object.is('foo', 'bar'); // false
Object.is([], []); // false
var foo = { a: 1 };
var bar = { a: 1 };
Object.is(foo, foo); // true
Object.is(foo, bar); // false
Object.is(null, null); // true
// 特例
Object.is(0, -0); // false
Object.is(0, +0); // true
Object.is(-0, -0); // true
Object.is(NaN, 0/0); // true
通过上面的代码可以看到,Object.is对于对象的比较是比较引用地址的,而不是比较值的,所以Object.is([], []), Object.is({},{})的结果都是false。而对于基础类型来说,大家需要注意的是最末尾的四个特列,这是与===所不同的。
再回到上面代码的例子中,useColumns将传入的options作为useMemo的第二个参数,而options是一个对象。当组件的count状态发生变化的时候,会重新执行整个函数组件,这时候useColumns会被调用然后传入{ isEdit: true, isDelete: false },这是一个新创建的对象,与上一次渲染所创建的options的内容虽然一致,但是Object.is比较结果依然是false,所以columns的结果会被重新创建返回。
通过二次封装标准化组件
我们在项目中使用antd作为组件库,虽然antd可以满足大部分的开发需要,但是有些地方通过对antd进行二次封装,不仅可以减少开发代码量,而且对于页面的交互起到了标准化作用。
看一下下面这个场景, 在我们开发一个数据表格的时候,一般会用到哪些功能呢?
- 表格可以分页
- 表格最后一列会有操作按钮
- 表格顶部会有搜索区域
- 表格顶部可能会有操作按钮
还有其他等等一系列的功能,这些功能在系统中会大量使用,而且其实现方式基本是一致的,这时候如果能把这些功能集成到一起封装成一个标准的组件,那么既能减少代码量,而且也会让页面展现上更加统一。
以封装表格操作列为例,一般用操作列我们会像下面这样封装
const columns = [{
title: '操作',
dataIndex: 'action',
key: 'action',
width: '10%',
align: 'center',
render: (_, row) => {
return (
<>
<Button type="link" onClick={() => handleEdit(row)}>
编辑
</Button>
<Popconfirm title="确认要删除?" onConfirm={() => handleDelete(row)}>
<Button type="link">删除</Button>
</Popconfirm>
</>
);
}
}]
我们期望的是操作列也可以像表格的columns一样通过配置来生成,而不是写jsx。看一下如何封装呢?
// 定义操作按钮
export interface IAction extends Omit<ButtonProps, 'onClick'> {
// 自定义按钮渲染
render?: (row: any, index: number) => React.ReactNode;
onClick?: (row: any, index: number) => void;
// 是否有确认提示
confirm?: boolean;
// 提示文字
confirmText?: boolean;
// 按钮显示文字
text: string;
}
// 定义表格列
export interface IColumn<T = any> extends ColumnType<T> {
actions?: IAction[];
}
// 然后我们可以定义一个hooks,专门用来修改表格的columns,添加操作列
const useActionButtons = (
columns: IColumn[],
actions: IAction[] | undefined
): IColumn[] => {
return useMemo(() => {
if (!actions || actions.length === 0) {
return columns;
}
return [
...columns,
{
align: 'center',
title: '操作',
key: '__action',
dataIndex: '__action',
width: Math.max(120, actions.length * 85),
render(value: any, row: any, index: number) {
return actions.map((item) => {
if (item.render) {
return item.render(row, index);
}
if(item.confirm) {
return <Popconfirm title={item.confirmText || '确认要删除?'}
onConfirm={() => item.onClick?.(row, index)}>
<Button type="link">{item.text}</Button>
</Popconfirm>
}
return (
<Button
{...item}
type="link"
key={item.text}
onClick={() => item.onClick?.(row, index)}
>
{item.text}
</Button>
);
});
}
}
];
}, [columns, actions, actionFixed]);
};
// 最后我们对表格再做一个封装
const CustomTable: React.FC<ITableProps> = ({
actions,
columns,
...props
}) => {
const actionColumns = useActionColumns(columns,actions)
// 渲染表格
}
通过上面的封装,我们再使用表格的时候,就可以这样去写
const actions: IAction[] = [
{
text: '编辑',
onClick: handleModifyRecord,
},
];
return <CustomTable actions={actions} columns={columns}></CustomTable>
避免重复渲染
重复渲染,包含重复计算,重复发请求等等,这个在开发中很容易遇到。比如某一个页面代码的时候,某个接口被调用了两次,对于这种情况,我们还是需要去尽量避免的。
先看一下下面几个示例代码
示例一
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(1);
setTimeout(() => {
setCount(0);
}, 1000);
}, [count]);
return <div>{count}</div>;
}
示例二
//组件
import React, { useEffect } from 'react';
const Test = () => {
useEffect(() => {
console.log('此处发送请求');
}, []);
return <div></div>;
};
export default Test;
// 页面
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
setTimeout(() => {
setCount(1)
},0)
},[])
return <>
<Route exact key="test" path="/" component={() => <Test></Test>} />
</>
}
示例三
function App() {
const [pageSize, setPageSize] = useState(10);
const [currentPage, setCurrentPage] = useState(1);
const [update, setUpdate] = useState(0);
const [appCode, setAppCode] = useState('');
useEffect(() => {
console.log('发送请求');
}, [pageSize, currentPage, update]);
// 当 appCode 值发生变化时,修改 update 从而重新请求数据
useEffect(() => {
setUpdate(update + 1);
}, [appCode]);
return <div></div>;
}
请问,上面三个示例存在什么问题呢?
第一个:会导致死循环
第二个:进入页面会发送两次请求
第三个:进入页面会发送两次请求
接下来我们来逐一分析原因
分析示例一
useEffect(() => {
setCount(1);
setTimeout(() => {
setCount(0);
}, 1000);
}, [count]);
上面代码为示例一中的useEffect,可以看到useEffect监听的是count的变化,而且里面有一个setTimeout会每一秒钟修改一次count的值,而count的变化又会导致useEffect重新被执行,然后就进入了死循环。那么应该如何解决呢?方法就是useEffect不要去监听count的变化。即改为
useEffect(() => {
setCount(1);
setTimeout(() => {
setCount(0);
}, 1000);
}, []);
分析示例二
示例二关键问题在于下面这段代码
<Route exact key="test" path="/" component={() => <Test></Test>} />
在代码中,count的值初始化为1,然后一秒钟后被修改为0, 这会导致App组件产生两次渲染,注意上面的代码component传入的参数是一个箭头函数,而两次渲染会导致初始化两个箭头函数,这就导致两次给Route传入的component是不一样的,从而产生两次渲染,Test组件也就被渲染了两次,从而内部 的请求发送了两次。如何去修改呢?
<Route exact key="test" path="/" component={Test} />
分析示例三
示例三中为了在appCode发生变化时重新请求数据,然后加了一个update属性,通过调整这个属性来触发useEffect执行,但是问题就在于下面这段代码
// 当 appCode 值发生变化时,修改 update 从而重新请求数据
useEffect(() => {
setUpdate(update + 1);
}, [appCode]);
初始化页面的时候,useEffect会被默认执行一遍,所以初始化的时候会发送一个请求,同时上面的useEffect也会被执行,这时候update发生变化了,所以又会导致请求再发送一次,如何调整呢?
其实完全不需要update,直接在下面代码监听appCode就好了
useEffect(() => {
console.log('发送请求');
}, [pageSize, currentPage, appCode]);