React Router 集成

@guanriyue/decurl 的默认 hooks 把 Decurl 的 Search Fields 接入 React Router。它们的目标是让 URL search 像普通 React state 一样被读取和更新,同时保持 URL 是最终状态来源。

hooks

最小使用方式是直接导入 hooks 和 Search Fields 工具:

import { useSearchValue } from '@guanriyue/decurl';
import { defineFields, field } from '@guanriyue/decurl/codec';
import { pipe, shape, trim } from '@guanriyue/decurl/decode';

const fields = defineFields({
  keyword: field({
    name: 'q',
    decode: pipe(trim, shape(/.+/)),
  }),
});

const SearchInput = () => {
  const [keyword, setKeyword] = useSearchValue(fields.keyword);

  return (
    <input
      value={keyword ?? ''}
      onChange={(event) => {
        const value = event.currentTarget.value.trim();
        setKeyword(value === '' ? undefined : value);
      }}
    />
  );
};

这种方式适合单个应用只需要一份默认 search store 的场景。

默认 hooks 不要求你手动创建 Provider。useSearchValueuseSearchValues 会使用默认 search store,并在 hook 内部读取当前 React Router 的 location / navigate

一次操作一个字段

只关心一个字段时,可以使用 useSearchValue。下面用输入框作为常见场景:输入内容会同步到当前页面 URL 的 demo_q 参数。

当前值
当前参数(空)
import { useSearchValue } from '@guanriyue/decurl';
import { defineFields } from '@guanriyue/decurl/codec';
import { trim } from '@guanriyue/decurl/decode';
import { useLocation } from 'react-router';
import { toSearchText, useDemoI18n } from '@/examples/i18n';

const fields = defineFields({
  keyword: {
    name: 'demo_q',
    decode: trim,
  },
});

const QueryInputDemo = () => {
  const t = useDemoI18n();
  const location = useLocation();
  const [keyword, setKeyword] = useSearchValue(fields.keyword);

  return (
    <div className="decurl-demo">
      <label className="decurl-demo__field">
        <span>{t('demo.keyword')}</span>
        <input
          value={keyword ?? ''}
          placeholder={t('demo.queryInput.placeholder')}
          onChange={(event) => {
            const nextValue = event.currentTarget.value.trim();
            setKeyword(nextValue === '' ? undefined : nextValue);
          }}
        />
      </label>
      <div className="decurl-demo__state">
        <span>{t('demo.currentValue')}</span>
        <code>{keyword}</code>
      </div>
      <div className="decurl-demo__state">
        <span>{t('demo.currentSearch')}</span>
        <code>{toSearchText(location.search, t)}</code>
      </div>
    </div>
  );
};

export default QueryInputDemo;

一次操作多个字段

需要同时读取和更新多个字段时,可以使用 useSearchValues。下面用查询表单作为常见场景:URL values 变化时,demo 会把值同步到表单元素;提交时再通过 FormData 读取原始 input,并把结果 patch 到 URL。

当前参数(空)
import { useSearchValues } from '@guanriyue/decurl';
import { defineFields, field } from '@guanriyue/decurl/codec';
import {
  elementOf,
  min,
  pipe,
  shape,
  toBoolean,
  toNumber,
  trim,
} from '@guanriyue/decurl/decode';
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router';
import { toSearchText, useDemoI18n } from '@/examples/i18n';

const fields = defineFields({
  query: field({
    name: 'demo_query',
    decode: trim,
  }),
  category: field({
    name: 'demo_category',
    decode: elementOf(['all', 'docs', 'api']),
    defaultValue: 'all',
  }),
  inStock: field({
    name: 'demo_in_stock',
    decode: pipe(shape.boolish, toBoolean),
    defaultValue: false,
  }),
  page: field({
    name: 'demo_page',
    decode: pipe(shape.integer, toNumber, min(1)),
    defaultValue: 1,
  }),
});

const setFieldValue = (
  form: HTMLFormElement,
  name: string,
  value: string,
): void => {
  const element = form.elements.namedItem(name);

  if (
    element instanceof HTMLInputElement ||
    element instanceof HTMLSelectElement
  ) {
    element.value = value;
  }
};

const setCheckboxValue = (
  form: HTMLFormElement,
  name: string,
  checked: boolean,
): void => {
  const element = form.elements.namedItem(name);

  if (element instanceof HTMLInputElement) {
    element.checked = checked;
  }
};

const normalizeCategory = (value: FormDataEntryValue | undefined) => {
  return value === 'docs' || value === 'api' ? value : 'all';
};

const QueryFormDemo = () => {
  const t = useDemoI18n();
  const location = useLocation();
  const formRef = useRef<HTMLFormElement>(null);
  const [values, setValues] = useSearchValues(fields);

  useEffect(() => {
    const form = formRef.current;

    if (!form) {
      return;
    }

    setFieldValue(form, 'query', values.query ?? '');
    setFieldValue(form, 'category', values.category);
    setCheckboxValue(form, 'inStock', values.inStock);
  }, [values]);

  return (
    <form
      ref={formRef}
      className="decurl-demo"
      onSubmit={(event) => {
        event.preventDefault();

        const formData = new FormData(event.currentTarget);
        const rawValues = Object.fromEntries(formData);
        const nextQuery =
          typeof rawValues.query === 'string' ? rawValues.query.trim() : '';

        setValues({
          query: nextQuery === '' ? undefined : nextQuery,
          category: normalizeCategory(rawValues.category),
          inStock: rawValues.inStock === 'true',
          page: 1,
        });
      }}
    >
      <div className="decurl-demo__grid">
        <label className="decurl-demo__field">
          <span>{t('demo.keyword')}</span>
          <input name="query" placeholder={t('demo.queryForm.placeholder')} />
        </label>
        <label className="decurl-demo__field">
          <span>{t('demo.queryForm.category')}</span>
          <select name="category">
            <option value="all">{t('demo.queryForm.all')}</option>
            <option value="docs">{t('demo.queryForm.guides')}</option>
            <option value="api">{t('demo.queryForm.api')}</option>
          </select>
        </label>
      </div>
      <label className="decurl-demo__check">
        <input name="inStock" type="checkbox" value="true" />
        <span>{t('demo.queryForm.availableOnly')}</span>
      </label>
      <div className="decurl-demo__actions">
        <button type="submit">{t('demo.queryForm.submit')}</button>
        <button
          type="button"
          className="decurl-demo__button-secondary"
          onClick={() => {
            setValues({
              query: undefined,
              category: 'all',
              inStock: false,
              page: 1,
            });
          }}
        >
          {t('demo.reset')}
        </button>
      </div>
      <div className="decurl-demo__state">
        <span>{t('demo.currentSearch')}</span>
        <code>{toSearchText(location.search, t)}</code>
      </div>
    </form>
  );
};

export default QueryFormDemo;

共享 URL key

不同 hook 可以监听同一个 URL key。只要 field codec 映射到同一个 name,它们就会从同一份 URL search state 中读取值。

useSearchValue 读到的值(空)
useSearchValues 读到的值(空)
当前参数(空)
import { useSearchValue, useSearchValues } from '@guanriyue/decurl';
import { defineFields, field } from '@guanriyue/decurl/codec';
import { trim } from '@guanriyue/decurl/decode';
import { useLocation } from 'react-router';
import { toSearchText, useDemoI18n } from '@/examples/i18n';

const singleField = defineFields({
  keyword: field({
    name: 'demo_shared',
    decode: trim,
  }),
});

const valuesFields = defineFields({
  query: field({
    name: 'demo_shared',
    decode: trim,
  }),
});

const SharedKeyHooksDemo = () => {
  const t = useDemoI18n();
  const location = useLocation();
  const [keyword, setKeyword] = useSearchValue(singleField.keyword);
  const [values, setValues] = useSearchValues(valuesFields);

  return (
    <div className="decurl-demo">
      <div className="decurl-demo__grid">
        <label className="decurl-demo__field">
          <span>useSearchValue</span>
          <input
            value={keyword ?? ''}
            placeholder={t('demo.shared.singlePlaceholder')}
            onChange={(event) => {
              const nextValue = event.currentTarget.value.trim();
              setKeyword(nextValue === '' ? undefined : nextValue);
            }}
          />
        </label>
        <label className="decurl-demo__field">
          <span>useSearchValues</span>
          <input
            value={values.query ?? ''}
            placeholder={t('demo.shared.valuesPlaceholder')}
            onChange={(event) => {
              const nextValue = event.currentTarget.value.trim();
              setValues({
                query: nextValue === '' ? undefined : nextValue,
              });
            }}
          />
        </label>
        <div className="decurl-demo__state">
          <span>{t('demo.shared.singleValue')}</span>
          <code>{keyword ?? t('demo.empty')}</code>
        </div>
        <div className="decurl-demo__state">
          <span>{t('demo.shared.valuesValue')}</span>
          <code>{values.query ?? t('demo.empty')}</code>
        </div>
      </div>
      <div className="decurl-demo__state">
        <span>{t('demo.currentSearch')}</span>
        <code>{toSearchText(location.search, t)}</code>
      </div>
    </div>
  );
};

export default SharedKeyHooksDemo;

Equality 与稳定引用

Hooks 会在 decode 后比较前后值。当前后值相等时,hook 会复用上一次的值引用。

稳定引用更适配 React 的优化模型:当 hook 返回值作为 useMemouseEffect 的 deps,或作为 memoized component 的 props 时,相关 search 值不变就不会触发额外的依赖变化。

默认比较规则来自 field codec:

  • single field 默认使用 Object.is
  • multi field 默认按数组顺序做浅比较。
  • 如果 field codec 提供了自定义 eq,会使用自定义比较。

下面的 demo 展示了这个差异:修改监听的 key 时,values 引用会变化;修改无关的 key 时,组件可能因为 React Router location 更新而重新渲染,但 useSearchValues 返回的 values 引用会保持稳定。

组件渲染次数1
values 引用变化次数1
当前参数(空)
import { useSearchValue, useSearchValues } from '@guanriyue/decurl';
import { defineFields, field } from '@guanriyue/decurl/codec';
import { trim } from '@guanriyue/decurl/decode';
import { useRef } from 'react';
import { useLocation } from 'react-router';
import { toSearchText, useDemoI18n } from '@/examples/i18n';

const watchedFields = defineFields({
  keyword: field({
    name: 'demo_equal_q',
    decode: trim,
    defaultValue: '',
  }),
});

const unrelatedFields = defineFields({
  value: field({
    name: 'demo_equal_other',
    decode: trim,
    defaultValue: '',
  }),
});

const EqualityStableReferenceDemo = () => {
  const t = useDemoI18n();
  const location = useLocation();
  const [values, setValues] = useSearchValues(watchedFields);
  const [unrelatedValue, setUnrelatedValue] = useSearchValue(
    unrelatedFields.value,
  );
  const renderCountRef = useRef(0);
  const valuesChangeCountRef = useRef(0);
  const previousValuesRef = useRef<typeof values | undefined>(undefined);

  renderCountRef.current += 1;

  if (previousValuesRef.current !== values) {
    valuesChangeCountRef.current += 1;
    previousValuesRef.current = values;
  }

  return (
    <div className="decurl-demo">
      <div className="decurl-demo__grid">
        <label className="decurl-demo__field">
          <span>{t('demo.equality.watchedKey')}</span>
          <input
            value={values.keyword}
            placeholder={t('demo.equality.watchedPlaceholder')}
            onChange={(event) => {
              setValues({ keyword: event.currentTarget.value });
            }}
          />
        </label>
        <label className="decurl-demo__field">
          <span>{t('demo.equality.unrelatedKey')}</span>
          <input
            value={unrelatedValue}
            placeholder={t('demo.equality.unrelatedPlaceholder')}
            onChange={(event) => {
              setUnrelatedValue(event.currentTarget.value);
            }}
          />
        </label>
      </div>
      <div className="decurl-demo__grid">
        <div className="decurl-demo__state">
          <span>{t('demo.equality.renderCount')}</span>
          <code>{renderCountRef.current}</code>
        </div>
        <div className="decurl-demo__state">
          <span>{t('demo.equality.valuesChangeCount')}</span>
          <code>{valuesChangeCountRef.current}</code>
        </div>
      </div>
      <div className="decurl-demo__state">
        <span>{t('demo.currentSearch')}</span>
        <code>{toSearchText(location.search, t)}</code>
      </div>
    </div>
  );
};

export default EqualityStableReferenceDemo;

应用集成

import { BrowserRouter } from 'react-router';

export const App = () => {
  return (
    <BrowserRouter>
      <SearchPanel />
    </BrowserRouter>
  );
};

只要组件位于 React Router 环境中,就可以直接使用默认 hooks。

如果需要显式提供 store、配置 flush 策略,或把 React Router location / navigate 的读取集中到独立组件,可以阅读 Provided Runtime

和 codec 子入口的关系

React Router hooks 不重新定义 URL 字段。Search Fields 通过 @guanriyue/decurl/codec 定义,再交给 hooks 复用:

import { defineFields, field } from '@guanriyue/decurl/codec';

const searchFields = defineFields({
  page: field({
    decode: decodePage,
    defaultValue: 1,
  }),
});

同一份 Search Fields 可以同时用于:

  • URLSearchParams decode/encode。
  • React Router hooks。
  • 测试和 fixture。

【反例】同 key 不同规则

同一个 key 如果使用不同的 defaultValue,当 URL 中缺失该 key 时,不同 hook 可能读到不同结果。不推荐在同一页面这样使用;应尽量让同一个 search param key 保持一致的 field codec 规则。

useSearchValue defaultValueleft-default
useSearchValues defaultValueright-default
当前参数(空)
import { useSearchValue, useSearchValues } from '@guanriyue/decurl';
import { defineFields, field } from '@guanriyue/decurl/codec';
import { trim } from '@guanriyue/decurl/decode';
import { useLocation } from 'react-router';
import { toSearchText, useDemoI18n } from '@/examples/i18n';

const leftField = defineFields({
  value: field({
    name: 'demo_shared_default',
    decode: trim,
    defaultValue: 'left-default',
  }),
});

const rightFields = defineFields({
  value: field({
    name: 'demo_shared_default',
    decode: trim,
    defaultValue: 'right-default',
  }),
});

const SharedKeyDefaultsDemo = () => {
  const t = useDemoI18n();
  const location = useLocation();
  const [leftValue, setLeftValue] = useSearchValue(leftField.value);
  const [rightValues, setRightValues] = useSearchValues(rightFields);

  return (
    <div className="decurl-demo">
      <div className="decurl-demo__grid">
        <div className="decurl-demo__state">
          <span>useSearchValue defaultValue</span>
          <code>{leftValue}</code>
        </div>
        <div className="decurl-demo__state">
          <span>useSearchValues defaultValue</span>
          <code>{rightValues.value}</code>
        </div>
      </div>
      <div className="decurl-demo__actions">
        <button
          type="button"
          onClick={() => {
            setLeftValue('from-left');
          }}
        >
          {t('demo.sharedDefaults.writeLeft')}
        </button>
        <button
          type="button"
          onClick={() => {
            setRightValues({ value: 'from-right' });
          }}
        >
          {t('demo.sharedDefaults.writeRight')}
        </button>
        <button
          type="button"
          className="decurl-demo__button-secondary"
          onClick={() => {
            setLeftValue(undefined);
          }}
        >
          {t('demo.sharedDefaults.deleteKey')}
        </button>
      </div>
      <div className="decurl-demo__state">
        <span>{t('demo.currentSearch')}</span>
        <code>{toSearchText(location.search, t)}</code>
      </div>
    </div>
  );
};

export default SharedKeyDefaultsDemo;