跳到主要内容

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 缓存。

CreatePost.vue
<script setup lang="ts">
import { useController } from '@data-client/vue';
import { PostResource } from './PostResource';

const ctrl = useController();

const handleSubmit = (e: Event) =>
ctrl.fetch(
PostResource.getList.push,
new FormData(e.target as HTMLFormElement),
);
</script>

<template>
<form @submit.prevent="handleSubmit"><!-- ... --></form>
</template>
提示

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,
});
},
},

expireAll({ testKey })​

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

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

CreateTrade.vue
<script setup lang="ts">
import { useController } from '@data-client/vue';
import { AccountResource, TradeResource, type Trade } from './resources';
import TradeForm from './TradeForm.vue';

const props = defineProps<{ userId: string }>();
const ctrl = useController();

const handleTrade = async (trade: Trade) => {
await ctrl.fetch(
TradeResource.getList.push,
{ user: props.userId },
trade,
);
ctrl.expireAll(AccountResource.get);
ctrl.expireAll(AccountResource.getList);
};
</script>

<template>
<TradeForm @submit="handleTrade" />
</template>
提示

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

invalidate(endpoint, ...args)​

强制使用相同 Endpoint 和参数的 useSuspense 重新获取。已挂载的组件会继续显示当前数据,直到重新获取完成。

ArticleName.vue
<script setup lang="ts">
import { useController, useSuspense } from '@data-client/vue';
import { ArticleResource } from './ArticleResource';

const props = defineProps<{ id: string }>();
const ctrl = useController();
const article = await useSuspense(ArticleResource.get, () => ({
id: props.id,
}));
</script>

<template>
<div>
<h1>{{ article.title }}</h1>
<button @click="ctrl.invalidate(ArticleResource.get, { id })">
Refetch
</button>
</div>
</template>
一次使多个 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 失效。

ArticleName.vue
<script setup lang="ts">
import { useController, useSuspense } from '@data-client/vue';
import { ArticleResource } from './ArticleResource';

const props = defineProps<{ id: string }>();
const ctrl = useController();
const article = await useSuspense(ArticleResource.get, () => ({
id: props.id,
}));
</script>

<template>
<div>
<h1>{{ article.title }}</h1>
<button @click="ctrl.invalidateAll(ArticleResource.get)">
Refetch
</button>
</div>
</template>

这里我们只清除使用 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(未授权)时清除缓存。

main.ts
import { createApp } from 'vue';
import {
DataClientPlugin,
LogoutManager,
getDefaultManagers,
} from '@data-client/vue';
import App from './App.vue';
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 app = createApp(App);
app.use(DataClientPlugin, { managers });
app.mount('#app');

resetEntireStore()​

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

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

UserName.vue
<script setup lang="ts">
import { useController, useSuspense } from '@data-client/vue';
import { CurrentUserResource } from './CurrentUserResource';
import { impersonateUser } from './auth';

const USER_NUMBER_ONE: string = '1111';

const user = await useSuspense(CurrentUserResource.get);
const ctrl = useController();

const becomeAdmin = () => {
// Changes the current user
impersonateUser(USER_NUMBER_ONE);
ctrl.resetEntireStore();
};
</script>

<template>
<div>
<h1>{{ user.name }}</h1>
<button @click="becomeAdmin">Be Number One</button>
</div>
</template>

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(),例如在批量处理高频数据流更新时。

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

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

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

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

const ctrl = useController();
let websocket: WebSocket;

onMounted(() => {
websocket = new WebSocket(url);

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

onUnmounted(() => websocket.close());

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

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

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

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

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

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

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

subscribe(endpoint, ...args)​

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

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

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

const controller = useController();

// args can be a ref, computed or getter; this re-runs when it changes
watchEffect(onCleanup => {
const currentArgs = toValue(args);
controller.subscribe(endpoint, ...currentArgs);
onCleanup(() => controller.unsubscribe(endpoint, ...currentArgs));
});

unsubscribe(endpoint, ...args)​

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

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

数据访问​

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

在 state 中查找任意 Queryable Schema。

Example​

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

在组件中,useQuery() 会让结果保持响应式。在事件处理函数中,传入 getState() 来读取最新的 store:

const ctrl = useController();

const toggle = (id: string) => {
const todo = ctrl.get(Todo, { id }, ctrl.getState());
if (todo) ctrl.set(Todo, { id }, { id, completed: !todo.completed });
};

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 中用它根据给定的状态查找响应。

在事件处理函数中,传入 getState() 来读取最新的 store,如 getState() 示例所示。

MyManager.ts
import {
type Manager,
type Middleware,
actionTypes,
} from '@data-client/vue';

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 中使用。

在 computed() 或模板中使用 getState(),store 变化时不会更新。在这些地方请改用 useQuery() 或 useCache()。

const controller = useController();

const handleShare = () => {
// reads the latest store without making this handler reactive
const { data: article } = controller.getResponse(
ArticleResource.get,
{ id: props.id },
controller.getState(),
);
if (article) navigator.share({ title: article.title, url: article.url });
};

变更会在 store 更新之前 resolve,因此请从 fetch() resolve 的值中读取结果,而不是从 getState() 中读取。