🌑

Mocha's Blog

目录
  1. 设计思路
  2. 实现
    1. Utils
      1. compressData & decompressData - 数据解压缩
      2. syncStateToUrl - 向 URL 同步状态
      3. getStateFromUrl - 从 URL 中获取指定状态
      4. redirectWithParams - 带状态的路由跳转
    2. useUrlState
    3. DMTable
  3. 其他

基于 URL 的状态保持方案

发布时间:2025年1月3日

页面状态保持及前进后退时回显是前端开发中经常遇见的需求,基于特定场景下的回显在处理起来是比较简单的,比如在 useEffect(,[]) 中从 URL 中读取数据并回显,但如果要把中后台大部分的功能场景覆盖的话还是要做一些通用性质的封装

在业务中,我们设计并实现了一套全覆盖方案

设计思路

在我们的设计中,相较于一些方案中将「状态」按照「最小字段」粒度进行同步,我们将「状态」的概念放宽至「最小功能」粒度进行同步,在页面 URL 体现上的差距如下:

pasted-image-1771768047159.webp

这样可以解决两个问题:

  1. 按照字段进行同步时,URL 中同步的字段过多
  2. 在页面状态恢复时,可以从功能角度直接去消费状态,而无需关注该功能究竟需要维护哪些字段,维护上对业务字段无感

基于上述的设计,具体实现如下

实现

Utils

compressData & decompressData - 数据解压缩

/**
 * 对数据进行压缩
 */
export function compressData(data: any) {
  return btoa(encodeURIComponent(JSON.stringify(data)));
}

/**
 * 对数据进行解压
 */
export function decompressData(data: string) {
  return JSON.parse(decodeURIComponent(atob(data)));
}

压缩和解压的代码是完全相反的,注意几点:

  1. 由于我们将状态的维度放在了功能上,所以状态值可能是各种类型,所以

  2. 在压缩时将状态反序列化,将所有类型统一为字符串

  3. 在解压时序列化,将字符串恢复成原有的类型

  4. 由于状态内容及长度的不可控,所以

  5. 在压缩时,使用 URL 编码将特殊字符转义,此处内容已基本不可读,为了让其彻底失去语义,所以再进行一次 base64 编码

  6. 在解压时,先使用 base64 解压进行还原,在使用 URL 解码将其变为可读的内容

syncStateToUrl - 向 URL 同步状态

import {
  debounce as __debounce,
  isBoolean,
  isEmpty,
  isNumber,
} from '@alipay/bigfish/utils/lodash';

/**
 * 将传入的状态信息同步到 url 上
 * 同步时,需要对每一个状态的值进行 encodeURIComponent 处理
 * @param state 传入的对象信息
 */
export function syncStateToUrl(states: Record<string, any>) {
  const url = new URL(window.location.href);

  const searchParams = url.searchParams;

  for (const key in states) {
    if (!Object.hasOwn(states, key)) {
      continue;
    }

    const state = states[key];

    /**
     * 如果传入的 state 为无效值,删除 url 中的对应参数,判断逻辑如下
     * 1. 当 state 为 boolean 类型时,不做处理
     * 2. 当 state 为 number 类型时,不做处理
     * 3. state 为 null、undefined、空字符串、空数组、空对象时,删除 url 中的对应参数
     */
    if (
      !isBoolean(state) &&
      !isNumber(state) &&
      (state === null || state === undefined || state === '' || isEmpty(state))
    ) {
      searchParams.delete(key);
      continue;
    }

    // 对数据进行压缩并同步到 url 上
    searchParams.set(key, compressData(state));
  }

  window.history.replaceState({}, '', url.toString());
}

/**
 * 添加了防抖功能的同步 url 状态的方法
 */
export const debounceSyncStateToURL = __debounce(syncStateToUrl, 500);

这里逻辑需要注意几点:

  1. 为了实现状态的批量同步,所以入参是一个对象
  2. forin 处理时注意屏蔽掉从原型中继承的非自己所有属性
  3. 当状态值指定为空时,从 URL 中删除对应的状态

getStateFromUrl - 从 URL 中获取指定状态

/**
 * 从 url 中获取指定的状态
 * 需要对获取到的状态进行 decodeURIComponent 处理
 * 如果获取不到指定的状态,将参数同步到 url 后,返回默认值
 * @param fieldKey 指定的状态 key
 * @param defaultValue 默认值
 * @param defaultSync 当 url 中对应值为空时,是否需要同步到 url
 */
export function getStateFromUrl<T>(
  fieldKey: string,
  defaultValue?: T,
  defaultSync?: boolean,
) {
  const url = new URL(window.location.href);

  const searchParams = url.searchParams;

  const value = searchParams.get(fieldKey);

  if (value === null) {
    // 如果需要同步到 url,同步一下
    if (defaultSync) {
      syncStateToUrl({ [fieldKey]: defaultValue });
    }

    return defaultValue;
  }

  try {
    return decompressData(value);
  } catch {
    return defaultValue;
  }
}

此处逻辑比较简单,注意添加默认值及默认值向 URL 同步逻辑即可

redirectWithParams - 带状态的路由跳转

/**
 * 跳转页面,带有指定的参数
 * @param path 跳转的路径
 * @param state 传递的参数
 * @param isNewTab 是否在新标签页打开
 */
export function redirectWithParams(
  path: string,
  states?: Record<string, any>,
  isNewTab?: boolean,
) {
  let searchParams = '';

  for (const key in states) {
    if (!Object.hasOwn(states, key)) {
      continue;
    }

    searchParams += `${key}=${compressData(states[key])}&`;
  }

  if (isNewTab) {
    window.open(`${path}?${searchParams}`, '_blank');
    return;
  }

  history.push(`${path}?${searchParams}`);
}

携带状态的路由跳转函数,用于在页面之间相互跳转时指定新页面的状态

useUrlState

在无需与 URL 进行状态同步的场景中,我们都会用 useState 对组件中的状态进行管理,状态是维护在浏览器的内存中。

那么按照这个思路,在实现与 URL 的同步后,组件的状态会从内存移动到 URL 中进行管理,因此我们封装了一个名为 useUrlStatehook 来实现功能,hook 中状态的值符合单向数据流概念(常规场景下)

  1. 状态的值只从 URL 中获取,依赖 popstate 事件的监听实现状态更新
  2. 当用户调用 setUrlState 时,调用 syncStateToUrl 向 URL 中进行状态同步
import {
  debounceSyncStateToURL,
  getStateFromUrl,
  syncStateToUrl,
} from '@/utils/url';
import { useCallback, useEffect, useState } from '@alipay/bigfish/react';

/**
 * 状态管理,对 useState 进行二次封装
 * 内部通过 useState & getStateFromURL 方法实现状态的初始化
 * 当调用 setState 的时候,调用 syncStateToURL 方法向 url 同步状态
 * 当 location.search 变动时,重新执行初始化逻辑
 * @param fieldKey 字段 ky
 * @param defaultValue 默认值
 * @param defaultSync 是否默认同步到 url
 */
export default function useUrlState<T>(
  fieldKey: string,
  defaultValue?: T,
  defaultSync?: boolean,
): [T, (value: T, debounce?: boolean) => void] {
  // 初始化状态
  const [state, originSetState] = useState(
    getStateFromUrl(fieldKey, defaultValue, defaultSync),
  );

  /**
   * 监听 url 变化,同步状态
   */
  useEffect(() => {
    const onPopState = () => {
      originSetState(getStateFromUrl(fieldKey, defaultValue));
    };

    window.addEventListener('popstate', onPopState);

    return () => {
      window.removeEventListener('popstate', onPopState);
    };
  }, [fieldKey, defaultValue]);

  /**
   * 设置状态
   * 1. 状态不需要延迟同步到 url 时,通过同步到 url 触发 useEffect 的方式实现状态更新
   * 2. 状态需要延迟同步到 url 时,先手动调用原始的 setState,然后调用延迟同步到 url 方法
   * @param value 最新的值
   * @param debounce 是否延迟同步到 url 上
   */
  const setState = useCallback((value: T, debounce?: boolean) => {
    if (debounce) {
      originSetState(value);

      debounceSyncStateToURL({ [fieldKey]: value });

      return;
    }

    syncStateToUrl({ [fieldKey]: value });
  }, []);

  return [state, setState];
}

当然,如果用户不想特别频繁的向 URL 同步状态(比如输入框场景),也可以在调用 setUrlState 时,传入第二个参数 debounce = true,此时的数据流更新会变成类似于「乐观更新」模式,即:先手动更新本地状态,然后在防抖结束后向 URL 中同步,再触发副作用以从 URL 中获取最新状态并更新。

这样我们可以避免在状态高频词更新场景下频繁更新 URL 及触发副作用导致的性能问题,也能保证状态与 URL 中的一致性。

DMTable

中后台场景中最常见的需要状态维持的组件就是表格了,一般都需要对表格的搜索条件及分页进行维持,所以在这里基于前面实现的功能对 ProTable 进行了一次二次封装,额外添加了状态维持能力,在需要接入的场景中,只需要将原有的表格替换为该组件并指定搜索和分页状态的 fieldKey 即可

import useUrlState from '@/hooks/useUrlState';
import { formatSearchParams } from '@/utils/format';
import { getStateFromUrl, syncStateToUrl } from '@/utils/url';
import React, { useState } from '@alipay/bigfish/react';
import {
  isEmpty as __isEmpty,
  omit as __omit,
} from '@alipay/bigfish/utils/lodash';
import { ProTable, ProTableProps } from '@alipay/tech-ui';

export default <
  T extends Record<string, any> = Record<string, any>,
  U extends Record<string, any> = Record<string, any>,
>({
  request,
  urlSearchFields,
  form,
  ...props
}: ProTableProps<T, U> & {
  urlSearchFields: {
    paginationKey: string;
    conditionsKey: string;
  };
}) => {
  // url 中携带的初始化查询条件
  const [initConditions, setInitConditions] = useState<Record<string, any>>(
    getStateFromUrl(urlSearchFields?.conditionsKey || 'conditions', {}),
  );

  // 使用 useUrlState 保存分页信息
  const [pagination, setPagination] = useUrlState(
    urlSearchFields?.paginationKey || 'pagination',
    {
      current: 1,
      pageSize: 20,
    },
  );

  return (
    <ProTable<T, U>
      {...props}
      pagination={{
        current: pagination.current,
        pageSize: pagination.pageSize,
        onChange: (current, pageSize) => {
          setPagination({
            current,
            pageSize,
          });
        },
      }}
      form={{
        ...form,
        // 如果 url 中携带的有初始化查询条件,就使用,否则使用 form 的初始值
        initialValues: __isEmpty(initConditions)
          ? form?.initialValues
          : initConditions,
      }}
      // 如果使用了 request,就劫持,添加同步状态到 url 的逻辑
      {...(request
        ? {
            request: async (params, search, filter) => {
              const filtedParams = formatSearchParams(params);

              // url 中的初始查询条件只用一次,用完就清空
              setInitConditions({});

              // 如果配置了 urlSearchFields,就同步状态到 url
              if (urlSearchFields) {
                const { current, pageSize, ...bizFilter } = __omit(
                  filtedParams,
                  ['pageIndex'],
                );

                syncStateToUrl({
                  [urlSearchFields.paginationKey]: { current, pageSize },
                  [urlSearchFields.conditionsKey]: bizFilter,
                });
              }

              return request(filtedParams, search, filter);
            },
          }
        : {})}
    />
  );
};

注意这里面的「表格初始化条件只使用一次」这个功能,可以根据业务需要动态调整

其他

做完才发现和 nuqs | Type-safe search params state management for React 是同一个路子

Powered By Hexo.js Hexo and Minima. Support By Oracle & Docker-Compose.