React通用解决方案——筛选与路由联动
1. 背景介绍
在前后端开发不分离的「远古时代」,页面的筛选器操作筛选一般会同步变更URL,触发浏览器刷新,从而正确呈现「选定筛选值」的筛选器和根据筛选器过滤后的列表数据。
这种渲染模式的流程大致如下:
这种渲染流程联动了筛选器与路由,实现了用户「回退页面」时重现「上一个页面」的需求。
这在「远古时代」问题不大,而且刚接触「网上冲浪」的用户并不会有太大的体验需求。
但随着互联网的发展,信息量暴涨,一个具备筛选能力的页面呈现的数据量也日渐丰富起来,用户的体验需求也更为「刁钻」。
在这种变化之下「筛选动作」触发的「浏览器刷新」显然不能满足现状,因为「刷新」动作存在以下弊端:
- 多次重复请求
- 响应时间过长
- 页面闪动问题
在前后端分离的现在,页面更为复杂,更需要避免页面刷新。为实现筛选与路由联动的能力,需要寻求新的方案。
2. 解决方案
HTML5标准赋予了History更为强大的能力,其中一个名为replaceState的History API接口的能力便能够实现修改URL之后避免触发浏览器刷新。
关于replaceState的介绍可查阅:developer.mozilla.org/zh-CN/docs/…
通过replaceState改造后的筛选与路由联动的渲染流程如下:
3. 实现细节
为实现筛选与路由联动,基于React的渲染机制(筛选器的筛选值变更会setState,从而触发因此update流程),我们的代码逻辑大致如下:
import React, { useMemo, useCallback } from "react";
const useList = buildUseList({
getData: fetchData(),
});
const Cmpt: React.FC = (props) => {
// 解析URL获取初始化筛选值
const initQuery = useMemo(() => {
const search = new URLSearchParams(location.search);
return JSON.parse(search.get("query"));
}, []);
// 初始化请求
const [listResult, listAction] = useList(props, initQuery);
const handleSyncUrlSearchParam = useCallback(
(newQuery) => {
// 同步新的筛选值到路由查询参数
history.replaceState(null, "", `?query=${JSON.stringify(newQuery)}`);
// 更新筛选值触发重新请求
listAction.setQuery(newQuery);
},
[listAction]
);
return (
<>
{/* 筛选器 */}
<Filter defaultValue={initQuery} onChange={handleSyncUrlSearchParam} />
{/* 数据渲染 */}
<List dataSource={listResult.list} />
</>
);
};
export default Cmpt;
分析上面逻辑,我们可以实现个通用的名为「useQuery」的Hook提供以下能力:
- 解析URL获取初始化筛选值
- 监听依赖值的变更同步到路由查询参数
该Hook的实现如下:
import { useEffect, useMemo, useRef, useState } from 'react';
export type IBuildUseQueryOptions = {
/** 查询名称 */
name?: string;
/** 转换方法 */
transform?: {
/** 编码方法 */
encode: (query: any) => string;
/** 解码方法 */
decode: (str: string) => any;
};
};
/**
* 查询条件Hook工厂函数
* @param options 查询条件Hook配置
* @returns
*/
export function buildUseQuery(options?: IBuildUseQueryOptions) {
const {
name = 'query',
transform = {
encode: JSON.stringify,
decode: JSON.parse,
},
} = options ?? {};
/**
* 查询条件Hook
* @param defaultQuery 默认查询条件
* @param getDept 监听依赖
* @returns
*/
function useQuery(defaultQuery?: any, getDept?: () => any) {
const _defaultQuery = useMemo(() => {
const search = new URLSearchParams(location.search);
let existQuery = {};
const value = search.get(name);
if (value) {
try {
existQuery = transform.decode(value);
} catch {
// DO NOTHING
}
}
return {
...defaultQuery,
...existQuery,
};
}, []);
const [query, setQuery] = useState(_defaultQuery);
const cacheQuery = useRef<any>();
useEffect(() => {
if (!getDept) {
return;
}
const deptQuery = getDept() ?? {};
// 浅比较对象
if (shallowEqual(deptQuery, cacheQuery.current)) {
return;
}
cacheQuery.current = deptQuery;
// 过滤无效查询参数
const newQuery = Object.keys(deptQuery).reduce((p, k) => {
const v = deptQuery[k];
switch (true) {
case ['', null, undefined].includes(v):
// 过滤空数据
break;
case typeof v === 'object' && Object.keys(v).length <= 0:
// 过滤空对象
break;
default:
p[k] = v;
break;
}
return p;
}, {} as Record<string, any>);
const search = new URLSearchParams(location.search);
if (Object.keys(newQuery).length <= 0) {
search.delete(name);
} else {
search.set(name, transform.encode(newQuery));
}
history.replaceState(null, '', `?${search.toString()}`);
});
const ret = [query, setQuery] as const;
return ret;
}
return useQuery;
}
通过该Hook改造后的代码如下:
import React, { useMemo, useCallback } from "react";
const useList = buildUseList({
getData: fetchData(),
});
const useQuery = buildUseQuery();
const Cmpt: React.FC = (props) => {
// 解析URL获取初始化筛选值,并监听依赖值的变更同步到路由查询参数
const [initQuery] = useQuery({}, () => listResult.query);
const [listResult, listAction] = useList(props, initQuery);
return (
<>
{/* 筛选器 */}
<Filter defaultValue={initQuery} onChange={listAction.setQuery} />
{/* 数据渲染 */}
<List dataSource={listResult.list} />
</>
);
};
export default Cmpt;
4. 拓展内容
有读者应该注意到了,「useQuery」使用「build」方式生成,它支持传入一个「options」配置,作者下面用相关场景对其配置能力进行讲解。
4.1 自定义查询名称
「useQuery」默认使用「query」作为路由查询条件的「name」,效果如下:
新项目问题不大,因为我们预留这个「name」用于筛选逻辑。
但在「旧项目」改造中使用「useQuery」中可能就会遇到「query」这个「name」被已有的组件逻辑占用,而重构旧项目除非有较大ROI才建议重构,一般采用迭代的方式进行开发。
「useQuery」也恰好支持自定义「name」值,示例代码如下:
const useQuery = buildUseQuery({
// 自定义查询名称,默认为「query」
name: 'my_query',
});
这时效果如下:
4.2 自定义路由查询条件转换器
筛选值是一个对象类型值,路由查询条件是需要满足URI规范的字符串,这之间必然存在转换逻辑。
「useQuery」默认使用「JSON序列化」加「URI序列化」的方式对其进行转换。这种转换逻辑满足了一般开发的需求。
但「追求极致」的读者们应该注意到,这种转换搭配的转换结果真的「太长」了,例如上面「自定义查询名称」中的效果图,用户只是简单筛选了个时间范围:
通过「URI反序列化」的结果可知,「URI序列化」过程序列化了「括号」、「引号」和「冒号」这些URI编码较长的字符,这显然存在需要优化空间。
「useQuery」同样支持提供对「转换器」进行自定义配置实现优化,示例代码如下:
const useQuery = buildUseQuery({
// 自定义查询名称,默认为「query」
transform: {
encode: (query: any) => {
// TODO 将筛选值转换成路由查询值
return str;
},
decode: (str: string) => {
// TODO 将转路由查询值换成筛选值
return query;
},
},
});
这里的转换优化逻辑本篇就不实现了,留给读者们自行实现哈~
5.后话
阅读过「React通用解决方案」系列其他文章的读者们应该注意到,作者的代码中经常会使用「buildXXX」的函数命名方式。
这种「build」其实是一种十分哇塞的编程思想,作者这里卖个关子,后续另开篇章「React通用解决方案——组件生成器」进行讲解说明,读者们稍微等等作者抽空总结下来哈。