跳到主要内容

Controller

Controller 是一个单例,提供对 Reactive Data Client flux store 及其生命周期的安全访问。 Controller 会对所有 store 访问进行记忆化,从而保证全局引用相等,并提供最快的渲染和读取性能。

Controller 会提供给:

class Controller {
/*************** Action Dispatchers ***************/
fetch(endpoint, ...args): ReturnType<E>;
fetchIfStale(endpoint, ...args): ReturnType<E> | undefined;
expireAll({ testKey }): Promise<void>;
invalidate(endpoint, ...args): Promise<void>;
invalidateAll({ testKey }): Promise<void>;
resetEntireStore(): Promise<void>;
set(queryable, ...args, value): Promise<void>;
set([Entity], rows): Promise<void>;
setResponse(endpoint, ...args, response): Promise<void>;
setError(endpoint, ...args, error): Promise<void>;
resolve(endpoint, { args, response, fetchedAt, error }): Promise<void>;
subscribe(endpoint, ...args): Promise<void>;
unsubscribe(endpoint, ...args): Promise<void>;
/*************** Data Access ***************/
get(queryable, ...args, state): Denormalized<typeof queryable>;
getResponse(endpoint, ...args, state): { data; expiryStatus; expiresAt };
getError(endpoint, ...args, state): ErrorTypes | undefined;
snapshot(state: State<unknown>, fetchedAt?: number): SnapshotInterface;
getState(): State<unknown>;
}

Action 派发方法​

fetch(endpoint, ...args)​

使用给定参数获取 endpoint,并在完成时用响应或错误更新 Reactive Data Client 缓存。

import { useController } from '@data-client/react';
import { PostResource } from './PostResource';

function CreatePost() {
const ctrl = useController();

return (
<form
onSubmit={e =>
ctrl.fetch(PostResource.getList.push, new FormData(e.currentTarget))
}
>
{/* ... */}
</form>
);
}
提示

fetch 的返回值与传给它的 Endpoint 相同。使用 schema 时,返回的是反规范化后的值

const controller = useController();

const post = await controller.fetch(
PostResource.getList.push,
createPayload,
);
post.title;
post.pk();

Endpoint.sideEffect​

sideEffect 会改变其行为

true​
  • 在提交 Reactive Data Client 缓存更新之前 resolve。(React 16、17)
  • 每次调用都一定会发起新的获取。
false | undefined​
  • 在提交 Reactive Data Client 缓存更新之后 resolve。
  • 相同的请求会在全局范围内去重;同一时间只允许一个进行中的请求。
    • 若要确保发起一个新的请求,请务必先中止所有进行中的请求。

fetchIfStale(endpoint, ...args)​

仅当 endpoint 被视为“过时”时才获取。

这在预取数据时很有用,因为它可以避免重复获取仍然新鲜的数据。

下面是一个配合“边渲染边获取”(fetch-as-you-render)路由的示例:

{
name: 'IssueList',
component: lazyPage('IssuesPage'),
title: 'issue list',
resolveData: async (
controller: Controller,
{ owner, repo }: { owner: string; repo: string },
searchParams: URLSearchParams,
) => {
const q = searchParams?.get('q') || 'is:issue is:open';
await controller.fetchIfStale(IssueResource.search, {
owner,
repo,
q,
});
},
},

探索 github-app 示例

更多演示

expireAll({ testKey })​

将所有匹配 testKey 的响应的过期状态设为 Stale。

当缓存中存在许多不同参数组合时,这可以用来只刷新当前正在显示的数据。

import { type Controller, useController } from '@data-client/react';
import { AccountResource, TradeResource, type Trade } from './resources';
import { Form, FormField } from './Form';

const createTradeHandler =
(ctrl: Controller, userId: string) => async (trade: Trade) => {
await ctrl.fetch(TradeResource.getList.push, { user: userId }, trade);
ctrl.expireAll(AccountResource.get);
ctrl.expireAll(AccountResource.getList);
};

function CreateTrade({ userId }: { userId: string }) {
const handleTrade = createTradeHandler(useController(), userId);

return (
<Form onSubmit={handleTrade}>
<FormField name="ticker" />
<FormField name="amount" type="number" />
<FormField name="price" type="number" />
</Form>
);
}
提示

为了减少负载、提升性能并改善状态一致性,通常更好的做法是在变更响应中包含变更的副作用。

invalidate(endpoint, ...args)​

强制使用相同 Endpoint 和参数的 useSuspense 重新获取并触发 suspense。

import { useController, useSuspense } from '@data-client/react';
import { ArticleResource } from './ArticleResource';

function ArticleName({ id }: { id: string }) {
const article = useSuspense(ArticleResource.get, { id });
const ctrl = useController();

return (
<div>
<h1>{article.title}</h1>
<button onClick={() => ctrl.invalidate(ArticleResource.get, { id })}>
Fetch &amp; suspend
</button>
</div>
);
}
提示

如果想在刷新的同时继续显示过时数据,请使用 Controller.fetch。

一次使多个 endpoint 失效

使用 schema.Invalidate 可以使所有包含某个 Entity 的 endpoint 失效。

对于 REST,可以尝试使用 Resource.delete

// deletes MyResource(5)
// this will refetch MyResource.get({id: '5'})
// and remove it from MyResource.getList
controller.setResponse(MyResource.delete, { id: '5' }, { id: '5' });

invalidateAll({ testKey })​

使所有匹配 testKey 的 endpoint key 失效。

import { useController, useSuspense } from '@data-client/react';
import { ArticleResource } from './ArticleResource';

function ArticleName({ id }: { id: string }) {
const article = useSuspense(ArticleResource.get, { id });
const ctrl = useController();

return (
<div>
<h1>{article.title}</h1>
<button onClick={() => ctrl.invalidateAll(ArticleResource.get)}>
Fetch &amp; suspend
</button>
</div>
);
}
提示

如果想在刷新的同时继续显示过时数据,请改用 Controller.expireAll。

这里我们只清除使用 test.com 域名的 GET endpoint。这意味着其他域名的数据仍保留在缓存中。

const myDomain = 'http://test.com';
const testKey = (key: string) => key.startsWith(`GET ${myDomain}`);

function useLogout() {
const ctrl = useController();
return () => ctrl.invalidateAll({ testKey });
}

通常最好也使用 LogoutManager,在遇到 401(未授权)时清除缓存。

index.tsx
import {
DataProvider,
LogoutManager,
getDefaultManagers,
} from '@data-client/react';
import { createRoot } from 'react-dom/client';
import App from './App';
import { unAuth } from '../authentication';

const myDomain = 'http://test.com';
const testKey = (key: string) => key.startsWith(`GET ${myDomain}`);

const managers = [
new LogoutManager({
handleLogout(controller) {
// call custom unAuth function we defined
unAuth();
// still reset the store
controller.invalidateAll({ testKey });
},
}),
...getDefaultManagers(),
];

createRoot(document.body).render(
<DataProvider managers={managers}>
<App />
</DataProvider>,
);

resetEntireStore()​

重置/清空整个 Reactive Data Client 缓存。所有进行中的请求都不会 resolve。

通常在退出登录或切换已认证用户时使用。

import { useController, useSuspense } from '@data-client/react';
import { useCallback } from 'react';
import { CurrentUserResource } from './CurrentUserResource';
import { impersonateUser } from './auth';

const USER_NUMBER_ONE: string = '1111';

function UserName() {
const user = useSuspense(CurrentUserResource.get);
const ctrl = useController();

const becomeAdmin = useCallback(() => {
// Changes the current user
impersonateUser(USER_NUMBER_ONE);
ctrl.resetEntireStore();
}, [ctrl]);
return (
<div>
<h1>{user.name}</h1>
<button onClick={becomeAdmin}>Be Number One</button>
</div>
);
}

set(queryable, ...args, value)​

更新任意 Queryable Schema,或者通过 Array 或 Values schema 一次更新多个 Entity。

ctrl.set(
Todo,
// which Todo to update
{ id: '5' },
// merge this data into the Todo in the store
{ id: '5', title: 'tell me friends how great Data Client is' },
);

value 的类型由 schema 决定:Entity 接收其字段(数字和字符串可以互换),而 Collection 或 All 接收一个行列表。Query 接收其所包裹 schema 的输入,因为 set() 是对该 schema 进行规范化,而不是逆向执行 process()。

ctrl.set(TodoResource.getList.schema, [{ id: '5', completed: true }]);
Unions

当每个成员都把其判别字段声明为字面量(例如 readonly type = 'first')时, Union 的每一行都会按其选中的成员进行检查,因此 { type: 'first', secondField: 1 } 会报错。只接受已声明的字段,因此被 schemaAttribute 函数读取的键必须在每个成员上声明。

当使用派生数据时,value 中可以使用函数。这可以防止竞态条件。

const id = '2';
ctrl.set(Article, { id }, article => ({ id, votes: article.votes + 1 }));

set([Entity], rows)​

传入一个 Array schema([Todo] 或 new schema.Array(Todo))和一个行列表,即可在一次 store 更新中更新多个 Entity。每一行都会与已存储的对应 Entity 合并;不在列表中的 Entity 保持不变。

ctrl.set(
[Todo],
[
{ id: '5', completed: true },
{ id: '6', completed: false },
],
);

行的类型由 Entity 的字段决定;数字和字符串可以互换,而对象、数组和 Date 值不会被检查,因为行是原始输入。

对于混合了多种 Entity 类型的列表,请使用 Union;每一行会按其 type 存储:

const Feed = new schema.Union({ post: Post, comment: Comment }, 'type');

ctrl.set(
[Feed],
[
{ id: '1', type: 'post', title: 'Hello' },
{ id: '7', type: 'comment', body: 'Nice!' },
],
);

要一次删除多个 Entity,请使用 Invalidate;每一行只需包含其 pk 字段:

ctrl.set([new schema.Invalidate(Todo)], [{ id: '5' }, { id: '6' }]);

要删除单个 Entity,传入 Invalidate schema 及其对应的行:

ctrl.set(new schema.Invalidate(Todo), { id: '5' });

Values schema 则接收一个由行组成的对象:

ctrl.set(new schema.Values(Todo), {
'5': { id: '5', completed: true },
'6': { id: '6', completed: false },
});

Array、Values 和 Invalidate schema 不接受 args(因此 Entity.pk() 和 Entity.process() 收到的是 []),也不接受更新函数。pk 相同的行会按列表顺序合并,不会经过 Entity.shouldReorder()。请用它来代替逐行调用 set(),例如在批量处理高频数据流更新时。

试试下面的两个按钮。这个浏览器测试从空的 store 开始,分别计时 500 次 set() 调用的 Promise.all 与一次批量 set()。两种方式都只产生一次 React 提交,并且各自写入 500 个新价格。

import React from 'react';
import { useController, useQuery } from '@data-client/react';
import { Ticker, newPrices } from './Ticker';

function PriceStream() {
  const ctrl = useController();
  const [timing, setTiming] = React.useState('');
  const first = useQuery(Ticker, { product_id: 'COIN-0' });

  const time = async (
    label: string,
    write: (rows: ReturnType<typeof newPrices>) => Promise<unknown>,
  ) => {
    const rows = newPrices();
    const start = performance.now();
    await write(rows);
    setTiming(`${label}: ${(performance.now() - start).toFixed(1)} ms`);
  };
  const perRow = () =>
    time('500 set() calls', rows =>
      Promise.all(
        rows.map(row => ctrl.set(Ticker, { product_id: row.product_id }, row)),
      ),
    );
  // highlight-next-line
  const batch = () => time('1 batch set()', rows => ctrl.set([Ticker], rows));

  return (
    <div>
      <button onClick={perRow}>set() per row</button>{' '}
      <button onClick={batch}>batch set()</button>
      <p>COIN-0: {first ? `$${first.price}` : 'no data yet'}</p>
      <p>{timing}</p>
    </div>
  );
}
render(<PriceStream />);
结果
Store▶

setResponse(endpoint, ...args, response)​

把 response 存入给定 Endpoint 和参数对应的缓存中。

所有因给定 Endpoint 和参数而挂起的组件都会 resolve。

如果给定 Endpoint 和参数已有数据,则会更新它。

import { useController } from '@data-client/react';
import { useEffect } from 'react';
import { EndpointLookup } from './EndpointLookup';

function useWebsocketUpdates(url: string) {
const ctrl = useController();

useEffect(() => {
const websocket = new WebSocket(url);

websocket.onmessage = event => {
const { endpoint, args, data } = JSON.parse(event.data);
ctrl.setResponse(EndpointLookup[endpoint], ...args, data);
};

return () => websocket.close();
}, [ctrl, url]);
}

这里展示的是在 React 中的概念验证;不过基于 Manager 的 websocket 实现会健壮得多。

setError(endpoint, ...args, error)​

把 Endpoint 和参数对应的结果存储为所提供的错误。

resolve(endpoint, { args, response, fetchedAt, error })​

resolve 某次特定的获取,并把 response 存入缓存。

它与 setResponse 类似,区别在于它会触发某个进行中获取的 resolve。这意味着对应的乐观更新将不再生效。

NetworkManager 中使用了它,处理获取请求时也应当使用它。

subscribe(endpoint, ...args)​

标记对给定 Endpoint 的一个新订阅。这应当使订阅计数加一。

useSubscription 和 useLive 会在挂载时调用它。

这对于需要根据其他因素订阅/取消订阅的自定义 hook 可能很有用。

import {
useController,
type EndpointInterface,
type FetchFunction,
type Schema,
} from '@data-client/react';
import { useEffect } from 'react';

function useSubscribe<
E extends EndpointInterface<FetchFunction, Schema | undefined, false | undefined>,
>(endpoint: E, ...args: readonly [...Parameters<E>]) {
const controller = useController();
const key = endpoint.key(...args);

useEffect(() => {
controller.subscribe(endpoint, ...args);
return () => {
controller.unsubscribe(endpoint, ...args);
};
}, [controller, key]);
}

unsubscribe(endpoint, ...args)​

标记对给定 Endpoint 的订阅结束。这应当使订阅计数减一;当计数降到 0 时,将不再自动接收后续更新。

useSubscription 和 useLive 会在卸载时调用它。

数据访问​

get(schema, ...args, state)​

在 state 中查找任意 Queryable Schema。

Example​

useQuery 中使用了它,你也可以在 Manager 中使用它来安全地访问 store。

useQuery.ts
import {
useController,
StateContext,
type Queryable,
type SchemaArgs,
type DenormalizeNullable,
} from '@data-client/react';
import { useContext } from 'react';

/** Oversimplified useQuery */
function useQuery<S extends Queryable>(
schema: S,
...args: SchemaArgs<S>
): DenormalizeNullable<S> | undefined {
const state = useContext(StateContext);
const controller = useController();

return controller.get(schema, ...args, state);
}

getResponse(endpoint, ...args, state)​

returns
{
data: DenormalizeNullable<E['schema']>;
expiryStatus: ExpiryStatus;
expiresAt: number;
}

从给定的状态中获取指定 endpoint/args 组合对应的响应(具有全局引用稳定性)。

data​

反规范化后的响应数据。保证所有成员都具有全局引用稳定性。

expiryStatus​

export enum ExpiryStatus {
Invalid = 1,
InvalidIfStale,
Valid,
}
Valid​
  • 永远不会挂起。
  • 数据过时时可能会获取
InvalidIfStale​
  • 数据过时时会挂起。
  • 数据过时时可能会获取
Invalid​
  • 总是会挂起
  • 总是会获取

expiresAt​

表示过期时间的数字。可与 Date.now() 进行比较。

Example​

useCache 和 useSuspense 中使用了它,你也可以在 Manager 中用它根据给定的状态查找响应。

useCache.ts
import {
useController,
StateContext,
type EndpointInterface,
} from '@data-client/react';
import { useContext } from 'react';

/** Oversimplified useCache */
function useCache<E extends EndpointInterface>(
endpoint: E,
...args: readonly [...Parameters<E>]
) {
const state = useContext(StateContext);
const controller = useController();
return controller.getResponse(endpoint, ...args, state).data;
}
MyManager.ts
import {
type Manager,
type Middleware,
actionTypes,
} from '@data-client/react';

export default class MyManager implements Manager {
declare protected websocket: WebSocket;

middleware: Middleware = controller => {
return next => async action => {
if (action.type === actionTypes.FETCH) {
console.log('The existing response of the requested fetch');
console.log(
controller.getResponse(
action.endpoint,
...action.args,
controller.getState(),
).data,
);
}
next(action);
};
};

cleanup() {
this.websocket.close();
}
}

getError(endpoint, ...args, state)​

获取指定 endpoint 的错误(如果有)。没有错误时返回 undefined。

snapshot(state, fetchedAt)​

返回一个 Snapshot。

getState()​

获取 Reactive Data Client 中已经提交的内部状态。

注意

它只应在事件处理函数或 Manager 中使用。

在 React 的渲染生命周期中使用 getState() 可能导致数据撕裂(tearing)。

import { useController } from '@data-client/react';
import { useCallback } from 'react';
import { MyResource } from './resources/MyResource';
import { redirect } from './routing';

function useUpdateHandler(id: string) {
const controller = useController();

return useCallback(
async updatePayload => {
const response = await controller.fetch(
MyResource.update,
{ id },
updatePayload,
);
// the fetch has completed, but react has not yet re-rendered
// this lets use sequence after the next re-render
// we're working on a better solution to this specific case
setTimeout(() => {
const { data: denormalized } = controller.getResponse(
MyResource.update,
{ id },
updatePayload,
controller.getState(),
);
redirect(denormalized.getterUrl);
}, 40);
},
[id],
);
}