Controller
Controller 是一个单例,提供对 Reactive Data Client flux store 及其生命周期的安全访问。
Controller 会对所有 store 访问进行记忆化,从而保证全局引用相等,并提供最快的渲染和读取性能。
Controller 会提供给:
- Manager:作为 Manager.middleware 的第一个参数
- React:通过 useController()
- hook 的单元测试:通过 renderDataHook()
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 缓存。
- Create
- Update
- Delete
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>
);
}
import { useController } from '@data-client/react';
import { PostResource } from './PostResource';
function UpdatePost({ id }: { id: string }) {
const ctrl = useController();
return (
<form
onSubmit={e =>
ctrl.fetch(PostResource.update, { id }, new FormData(e.currentTarget))
}
>
{/* ... */}
</form>
);
}
import { useController } from '@data-client/react';
import { useCallback } from 'react';
import { useNavigate } from 'react-router';
import { Post, PostResource } from './PostResource';
function PostListItem({ post }: { post: Post }) {
const ctrl = useController();
const navigate = useNavigate();
const handleDelete = useCallback(
async e => {
await ctrl.fetch(PostResource.delete, { id: post.id });
navigate('/');
},
[ctrl, post.id],
);
return (
<div>
<h3>{post.title}</h3>
<button onClick={handleDelete}>X</button>
</div>
);
}
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 & suspend
</button>
</div>
);
}
如果想在刷新的同时继续显示过时数据,请使用 Controller.fetch。
使用 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 & 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(未授权)时清除缓存。
- Web
- React Native
- NextJS
- Expo
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>,
);
import {
DataProvider,
LogoutManager,
getDefaultManagers,
} from '@data-client/react';
import { AppRegistry } from 'react-native';
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(),
];
const Root = () => (
<DataProvider managers={managers}>
<App />
</DataProvider>
);
AppRegistry.registerComponent('MyApp', () => Root);
'use client';
import { LogoutManager, getDefaultManagers } from '@data-client/react';
import { DataProvider } from '@data-client/react/nextjs';
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(),
];
export default function Provider({
children,
}: {
children: React.ReactNode;
}) {
return <DataProvider managers={managers}>{children}</DataProvider>;
}
import Provider from './Provider';
export default function RootLayout({ children }) {
return (
<html>
<body>
<Provider>{children}</Provider>
</body>
</html>
);
}
import { Stack } from 'expo-router';
import {
DataProvider,
LogoutManager,
getDefaultManagers,
} from '@data-client/react';
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(),
];
export default function RootLayout() {
return (
<DataProvider managers={managers}>
<Stack>
<Stack.Screen name="index" />
</Stack>
</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 }]);
当每个成员都把其判别字段声明为字面量(例如 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 />);
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。
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)
{
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 中用它根据给定的状态查找响应。
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;
}
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],
);
}