前端如何优雅地管理url查询参数

610 阅读4分钟

1. 背景

在中后台页面的前端开发中,我们可能会用表单来实现数据的过滤功能

如果我们的页面能够支持url查询参数与表单值之间的联动,无疑能够大大提升用户的使用体验

出于提高代码复用性的考虑,我们可以封装这么一个React Hooks,专门用于管理url查询参数

  • 入参:由n个字符串(key)组成

  • 返回值:元组,包含一个js对象 和 一个更新这个对象的setter方法

  • 生命周期:

    • 初始化:在页面初始化时,读取url查询参数作为js对象的初始值

    • 更新:通过setter更新js对象后,同步更新到url查询参数,但不引起页面刷新

2. 方案设计

管理url查询参数的过程,实质就是url查询参数与js对象相互转换的过程 :

  • 初始化:需要将url查询参数转换为js对象
  • 更新:需要将js对象转换为url查询参数

2.1 将url查询参数转换为js对象

第1️⃣步:将url查询参数转换为URLSearchParams对象

这一步非常简单,我们只需要拿到当前页面的url,并调用URLSearchParams的构造函数即可

const urlSearchParams = new URLSearchParams(window.location.search)

第2️⃣步:将URLSearchParams对象转换为js对象

在实际转换之前,定义js对象的类型为Params

  • 为了让函数以及Hooks的返回值有类型提示,我们用泛型Key来标声明key的类型
  • 若单个key对应单个值,则js对象中的对应值为字符串
  • 若单个key对应多个值,则js对象中的对应值为字符串数组
type Params<Key extends string> = Record<Key, string | string[] | undefined>;

接下来,我们就可以利用 URLSearchParams.entries() 这个迭代器去遍历所有的键值对:

  • 若params[key]为空:说明这是第一个与key匹配的值,直接写入params[key]即可
  • 若params[key]为字符串:说明这是第二个与key匹配的值,需要把params[key]改写为字符串数组
  • 若params[key]为数组:说明这是第n个(n>2)与key匹配的值,需要把它放进数组里

封装函数transformURLSearchParams2Params如下:

function transformURLSearchParams2Params(urlSearchParams: URLSearchParams) {
  const params: Params<string> = {};

  for (const array of urlSearchParams.entries()) {
    const key = array[0];
    const value = array[1];

    if (!params[key]) {
      params[key] = value;
    } else if (Array.isArray(params[key])) {
      (params![key] as string[]).push(value);
    } else {
      params[key] = [params[key] as string, value];
    }
  }

  return params;
}

2.2 将js对象转换为url查询参数

由于URLSearchParams无法正确地识别字符串数组,我们不再使用URLSearchParams作为这一类转换的媒介

const params = {
  a: '1',
  b: ['2', '3']
}

// 预期:URLSearchParams { 'a' => '1', 'b' => '2', 'b' => '3' }
// 现状:URLSearchParams { 'a' => '1', 'b' => '2,3' }
console.log(new URLSearchParams(params))

因此我们需要封装一个函数,将js对象转换为 形如{key1}={value1}&{key2}={value2} 的字符串

基于reduce遍历js对象

  • 若params[key]为空:直接跳过
  • 若params[key]为字符串:说明这个key对应单个值,正常拼接
  • 若params[key]为数组:说明这个key对应多个值,需要利用reduce遍历数组进行拼接

封装函数transformParams2String如下:

function transformParams2String<Key extends string>(params: Params<Key>) {
  return Object.keys(params).reduce((str, key) => {
    const value = params[key] as string | string[] | undefined;

    if (typeof value === 'string' && value) {
      return str + `${key}=${value}&`;
    }

    if (Array.isArray(value) && value.length > 0) {
      return str + value.reduce((prev, v) => prev + `${key}=${v}&`, '');
    }
    
    return str;
  }, '');
}

2.3 逻辑封装

接下来我们需要将上述的转换过程封装到React Hook里面

  • 初始化:

    • 通过transformURLSearchParams2Params方法得到全量的js对象
    • 利用lodash的pick方法从全量的js对象中选出所需的键值对,作为js对象(命名为params)的初始值
  • 更新:监听params值的变化

    • 将当前的全量js对象、与当前params进行合并
    • 通过transformParams2String转换成字符串
    • 通过history.pushState更新url查询参数
import { pick } from 'lodash'
import { useUpdateEffect } from 'ahooks'

function useUrlQueryParams<Key extends string>(
  ...keys: Key[]
): [Params<Key>, React.Dispatch<React.SetStateAction<Params<Key>>>] {
  const urlSearchParams = new URLSearchParams(window.location.search)
  const allParams = transformURLSearchParams2Params(urlSearchParams)
  
  const [params,setParams] = useState<Params<Key>>(() => {
    return pick(allParams, keys)
  })
  
  useUpdateEffect(() => {
    const str = transformParams2String({
     ...allParams,
     ...params
    })
    
    window.history.pushState({}, '', `${window.location.pathname}?${str}`);
  }, [params])
  
  return [params, setParams]
}

完整代码如下:

import { pick } from 'lodash'
import { useUpdateEffect } from 'ahooks'

type Params<Key extends string> = Record<Key, string | string[] | undefined>;

function transformURLSearchParams2Params(urlSearchParams: URLSearchParams) {
  const params: Params<string> = {};

  for (const array of urlSearchParams.entries()) {
    const key = array[0];
    const value = array[1];

    if (!params[key]) {
      params[key] = value;
    } else if (Array.isArray(params[key])) {
      (params![key] as string[]).push(value);
    } else {
      params[key] = [params[key] as string, value];
    }
  }

  return params;
}

function transformParams2String<Key extends string>(params: Params<Key>) {
  let result = '';

  for (const key in params) {
    const value = params[key];

    if (!value?.length) {
      continue;
    }

    if (typeof value === 'string') {
      result += `&${key}=${value}`;
    } else {
      result += value.reduce((pre, cur) => {
        return pre + `&${key}=${cur}`;
      }, '');
    }
  }

  return result;
}

function useUrlQueryParams<Key extends string>(
  ...keys: Key[]
): [Params<Key>, React.Dispatch<React.SetStateAction<Params<Key>>>] {
  const history = useHistory()
  const urlSearchParams = new URLSearchParams(window.location.search)
  const allParams = transformURLSearchParams2Params(urlSearchParams)
  
  const [params,setParams] = useState<Params<Key>>(() => {
    return pick(allParams, keys)
  })
  
  useUpdateEffect(() => {
    const str = transformParams2String({
     ...allParams,
     ...params
    })
    
    window.history.pushState({}, '', `${window.location.pathname}?${str}`);
  }, [params])
  
  return [params, setParams]
}

2.4 使用示例

以arco-design为例:

  • 把params传递给Form组件的initialValues属性,作为表单的初始值
  • 通过Form组件的onValuesChange监听到表单值变化时,调用setParams把更新同步到url查询参数
import React from 'react'
import { Form, Select } from '@arco-design/web-react'
import { useUpdateEffect } from 'ahooks'

export function App() {
  const [form] = Form.useForm<{city: string}>()
  const [params, setParams] = useUrlQueryParams('city')
  
  useUpdateEffect(() => {
    // 表单值变化时,重新请求数据
    featchData()
  }, [params])
  
  return (
    <div>
      <Form
        form={form}
        initialValues={{
          city: Array.isArray(params.city) ? params.city?.[0] : params.city
        }}
        onValuesChange={(_, values) => {
          setParams(values)
        }}
      >
        <Form.Item label='城市' field='city'>
          <Select options={['Beijing', 'Shanghai']} />
        </Form.Item>
      </Form>
      
      <Table data={[]} />
    </div>
  )
}

参考资料