Provided Runtime

默认入口的 useSearchValueuseSearchValues 可以直接使用。它们会从最近的 SearchProvider 读取 store;如果没有 SearchProvider,则使用默认 global store。

默认 hooks 也会在 hook consumer 内部读取 React Router 的 locationnavigate。这个零配置行为适合大多数页面,但也意味着消费 search state 的组件会直接订阅 React Router location

@guanriyue/decurl/provided 是一种可选优化入口。它把 store 提供和 React Router 能力获取拆开:

  • SearchProvider 提供 store,并配置 flushDelayflushMode 等 store 行为。
  • SearchRuntimeConnector 负责读取 React Router 的 locationnavigate 或 router instance,并交给当前 store。
  • useProvidedSearchValueuseProvidedSearchValues 只读取最近的 SearchProvider store,不会自己读取 React Router runtime。

基础用法

SearchProvider 不要求位于 Router 内部。使用组件式 Router 时,SearchRuntimeConnector 必须在 Router 内部,并且先于任何 provided hooks consumer 渲染:

import { SearchProvider } from '@guanriyue/decurl';
import {
  SearchRuntimeConnector,
  useProvidedSearchValues,
} from '@guanriyue/decurl/provided';
import { BrowserRouter } from 'react-router';

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

const SearchPanel = () => {
  const [values, setValues] = useProvidedSearchValues(searchFields);

  return null;
};

如果缺少 SearchProvider,provided hooks 会直接抛错。如果缺少 SearchRuntimeConnector,store 还没有获得 React Router 的 location 和 navigate 能力,读取 search state 时也会失败。

Store 配置

SearchProvider 负责创建并提供 store。需要配置 flush 策略时,把配置传给 SearchProvider

<SearchProvider flushDelay={80} flushMode="debounce">
  <BrowserRouter>
    <SearchRuntimeConnector />
    <SearchPanel />
  </BrowserRouter>
</SearchProvider>

业务中一般不需要很长的 flushDelay。它通常用于降低输入、筛选器等高频交互触发 router navigation 的频率。

常见取值可以从几十毫秒开始,按交互类型调整。输入类交互更适合 debounce;点击类交互通常可以使用默认策略或较短 delay。

Data Router

使用 RouterProvider / Data Router 时,可以把 router instance 传给 SearchRuntimeConnector

import { SearchProvider } from '@guanriyue/decurl';
import { SearchRuntimeConnector } from '@guanriyue/decurl/provided';
import { RouterProvider, createBrowserRouter } from 'react-router';

const router = createBrowserRouter(routes);

export const App = () => {
  return (
    <SearchProvider>
      <SearchRuntimeConnector router={router} />
      <RouterProvider router={router} />
    </SearchProvider>
  );
};

这种写法使用 router instance 提供的 state.locationnavigatesubscribe 同步 store 与 React Router。

长延时观测 demo

下面的 demo 故意把 flushDelay 设置得比较长。它不是业务推荐配置,而是为了让你清楚观察两个阶段:

  • 点击后,监听对应 key 的消费者立即响应。
  • URL 确认后,父级 route tree 可能重渲染;未 memo 的子组件会被父级带着渲染,memo 子组件可以挡住这层传导。
parent / React Router location(空)parent renders: 1observation flushDelay: 2000ms
plain countAvalue: 0renders: 1
plain countBvalue: 0renders: 1
memo countAvalue: 0renders: 1
memo countBvalue: 0renders: 1
import { SearchProvider } from '@guanriyue/decurl';
import {
  SearchRuntimeConnector,
  useProvidedSearchValue,
} from '@guanriyue/decurl/provided';
import { defineFields, field } from '@guanriyue/decurl/codec';
import { min, pipe, shape, toNumber } from '@guanriyue/decurl/decode';
import { memo, useRef } from 'react';
import { useLocation } from 'react-router';
import { toSearchText, useDemoI18n } from '@/examples/i18n';

const fields = defineFields({
  countA: field({
    name: 'demo_delay_a',
    decode: pipe(shape.integer, toNumber, min(0)),
    defaultValue: 0,
  }),
  countB: field({
    name: 'demo_delay_b',
    decode: pipe(shape.integer, toNumber, min(0)),
    defaultValue: 0,
  }),
});

const observationDelay = 2000;

const ProvidedRuntimeDelayDemo = () => {
  return (
    <SearchProvider flushDelay={observationDelay} flushMode="debounce">
      <SearchRuntimeConnector />
      <DemoShell />
    </SearchProvider>
  );
};

const DemoShell = () => {
  const t = useDemoI18n();
  const renderCountRef = useRef(0);
  const location = useLocation();

  renderCountRef.current += 1;

  return (
    <div className="decurl-demo">
      <div className="decurl-demo__state">
        <span>parent / React Router location</span>
        <code>{toSearchText(location.search, t)}</code>
        <code>parent renders: {renderCountRef.current}</code>
        <code>observation flushDelay: {observationDelay}ms</code>
      </div>
      <div className="decurl-demo__grid">
        <CountPanel
          fieldKey="countA"
          title="plain countA"
          buttonText={t('demo.provided.updateA')}
        />
        <CountPanel
          fieldKey="countB"
          title="plain countB"
          buttonText={t('demo.provided.updateB')}
        />
      </div>
      <div className="decurl-demo__grid">
        <MemoCountPanel
          fieldKey="countA"
          title="memo countA"
          buttonText={t('demo.provided.updateA')}
        />
        <MemoCountPanel
          fieldKey="countB"
          title="memo countB"
          buttonText={t('demo.provided.updateB')}
        />
      </div>
    </div>
  );
};

type CountPanelProps = {
  fieldKey: 'countA' | 'countB';
  title: string;
  buttonText: string;
};

const CountPanel = ({ fieldKey, title, buttonText }: CountPanelProps) => {
  const renderCountRef = useRef(0);
  const [count, setCount] = useProvidedSearchValue(fields[fieldKey]);

  renderCountRef.current += 1;

  return (
    <section className="decurl-demo__state">
      <span>{title}</span>
      <code>value: {count}</code>
      <code>renders: {renderCountRef.current}</code>
      <div className="decurl-demo__actions">
        <button
          type="button"
          onClick={() => {
            setCount((value) => value + 1);
          }}
        >
          {buttonText}
        </button>
      </div>
    </section>
  );
};

const MemoCountPanel = memo(CountPanel);

export default ProvidedRuntimeDelayDemo;

额外渲染来源 demo

下面的 demo 有两个组件,分别监听 demo_bound_ademo_bound_b

点击“更新 A”时,countA 会先通过 optimistic state 立即更新;React Router location 会在观测用 flushDelay 后才确认 URL 变化。这个 demo 用来强调额外渲染的来源:

  • Decurl store 自身触发的 optimistic 更新。
  • React Router 监听到 location 变化后带来的页面树更新。
observation flushDelay2000ms
countAvalue: 0renders: 1
countBvalue: 0renders: 1
React Router location(空)renders: 1
import { SearchProvider } from '@guanriyue/decurl';
import {
  SearchRuntimeConnector,
  useProvidedSearchValue,
} from '@guanriyue/decurl/provided';
import { defineFields, field } from '@guanriyue/decurl/codec';
import { min, pipe, shape, toNumber } from '@guanriyue/decurl/decode';
import { useRef } from 'react';
import { useLocation } from 'react-router';
import { toSearchText, useDemoI18n } from '@/examples/i18n';

const fields = defineFields({
  countA: field({
    name: 'demo_bound_a',
    decode: pipe(shape.integer, toNumber, min(0)),
    defaultValue: 0,
  }),
  countB: field({
    name: 'demo_bound_b',
    decode: pipe(shape.integer, toNumber, min(0)),
    defaultValue: 0,
  }),
});

const observationDelay = 2000;

const ProvidedRuntimeRendersDemo = () => {
  return (
    <SearchProvider flushDelay={observationDelay} flushMode="debounce">
      <SearchRuntimeConnector />
      <div className="decurl-demo">
        <div className="decurl-demo__state">
          <span>observation flushDelay</span>
          <code>{observationDelay}ms</code>
        </div>
        <div className="decurl-demo__grid">
          <CountA />
          <CountB />
        </div>
        <LocationSearch />
      </div>
    </SearchProvider>
  );
};

const LocationSearch = () => {
  const t = useDemoI18n();
  const renderCountRef = useRef(0);
  const location = useLocation();

  renderCountRef.current += 1;

  return (
    <div className="decurl-demo__state">
      <span>React Router location</span>
      <code>{toSearchText(location.search, t)}</code>
      <code>renders: {renderCountRef.current}</code>
    </div>
  );
};

const CountA = () => {
  const t = useDemoI18n();
  const renderCountRef = useRef(0);
  const [count, setCount] = useProvidedSearchValue(fields.countA);

  renderCountRef.current += 1;

  return (
    <section className="decurl-demo__state">
      <span>countA</span>
      <code>value: {count}</code>
      <code>renders: {renderCountRef.current}</code>
      <div className="decurl-demo__actions">
        <button
          type="button"
          onClick={() => {
            setCount((value) => value + 1);
          }}
        >
          {t('demo.provided.updateA')}
        </button>
      </div>
    </section>
  );
};

const CountB = () => {
  const t = useDemoI18n();
  const renderCountRef = useRef(0);
  const [count, setCount] = useProvidedSearchValue(fields.countB);

  renderCountRef.current += 1;

  return (
    <section className="decurl-demo__state">
      <span>countB</span>
      <code>value: {count}</code>
      <code>renders: {renderCountRef.current}</code>
      <div className="decurl-demo__actions">
        <button
          type="button"
          onClick={() => {
            setCount((value) => value + 1);
          }}
        >
          {t('demo.provided.updateB')}
        </button>
      </div>
    </section>
  );
};

export default ProvidedRuntimeRendersDemo;

这个 demo 里的优化点很窄:

  • provided hooks 消费的是 SearchProvider store 的 snapshot,不会像默认 hooks 那样在 hook 内部直接调用 React Router 的 useLocation
  • React Router location 确认时,provided hooks 不会因为自身订阅了 useLocation 而重新执行。

这不等于页面渲染次数一定减少。React Router location 确认后仍可能让 route tree 重新渲染,并把渲染传导给子组件;如果没有配合 React.memouseMemo 等优化,实际效果可能并不明显,甚至看不到变化。

什么时候使用

推荐按下面顺序决策:

  1. 默认使用主入口 useSearchValue / useSearchValues
  2. 需要配置 store 行为或隔离 store 时,增加可选的 SearchProvider
  3. 只有当页面对 render 次数敏感,并且确认默认 hooks 对 React Router location 的订阅带来额外成本时,再切换到 provided 入口。

provided runtime 只是把 React Router 能力获取集中到 SearchRuntimeConnector 的一种写法,不是基础 hooks 的使用前提,也不保证每个页面都能减少渲染。