#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。useSearchValue 和 useSearchValues 会使用默认 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 中读取值。
(空)(空)(空)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 返回值作为 useMemo、useEffect 的 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 引用会保持稳定。
11(空)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 规则。
left-defaultright-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;