likes
comments
collection
share

React通用解决方案——筛选与路由联动

作者站长头像
站长
· 阅读数 11

1. 背景介绍

在前后端开发不分离的「远古时代」,页面的筛选器操作筛选一般会同步变更URL,触发浏览器刷新,从而正确呈现「选定筛选值」的筛选器和根据筛选器过滤后的列表数据。

这种渲染模式的流程大致如下:

React通用解决方案——筛选与路由联动

这种渲染流程联动了筛选器与路由,实现了用户「回退页面」时重现「上一个页面」的需求。

这在「远古时代」问题不大,而且刚接触「网上冲浪」的用户并不会有太大的体验需求。

但随着互联网的发展,信息量暴涨,一个具备筛选能力的页面呈现的数据量也日渐丰富起来,用户的体验需求也更为「刁钻」。

在这种变化之下「筛选动作」触发的「浏览器刷新」显然不能满足现状,因为「刷新」动作存在以下弊端:

  • 多次重复请求
  • 响应时间过长
  • 页面闪动问题

在前后端分离的现在,页面更为复杂,更需要避免页面刷新。为实现筛选与路由联动的能力,需要寻求新的方案。

2. 解决方案

HTML5标准赋予了History更为强大的能力,其中一个名为replaceState的History API接口的能力便能够实现修改URL之后避免触发浏览器刷新

关于replaceState的介绍可查阅:developer.mozilla.org/zh-CN/docs/…

通过replaceState改造后的筛选与路由联动的渲染流程如下:

React通用解决方案——筛选与路由联动

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」,效果如下:

React通用解决方案——筛选与路由联动

新项目问题不大,因为我们预留这个「name」用于筛选逻辑。

但在「旧项目」改造中使用「useQuery」中可能就会遇到「query」这个「name」被已有的组件逻辑占用,而重构旧项目除非有较大ROI才建议重构,一般采用迭代的方式进行开发

「useQuery」也恰好支持自定义「name」值,示例代码如下:

const useQuery = buildUseQuery({
  // 自定义查询名称,默认为「query」
  name: 'my_query',
});

这时效果如下:

React通用解决方案——筛选与路由联动

4.2 自定义路由查询条件转换器

筛选值是一个对象类型值,路由查询条件是需要满足URI规范的字符串,这之间必然存在转换逻辑。

「useQuery」默认使用「JSON序列化」加「URI序列化」的方式对其进行转换。这种转换逻辑满足了一般开发的需求。

但「追求极致」的读者们应该注意到,这种转换搭配的转换结果真的「太长」了,例如上面「自定义查询名称」中的效果图,用户只是简单筛选了个时间范围:

React通用解决方案——筛选与路由联动

通过「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通用解决方案——组件生成器」进行讲解说明,读者们稍微等等作者抽空总结下来哈。