Controller
Controller 是一个单例,提供对 Reactive Data Client flux store 及其生命周期的安全访问。
Controller 会对所有 store 访问进行记忆化,从而保证全局引用相等,并提供最快的渲染和读取性能。
Controller 会提供给:
- Manager:作为 Manager.middleware 的第一个参数
- Vue:通过 useController()
- composable 的单元测试:通过
@data-client/vue/test中的renderDataCompose()
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
<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>
<script setup lang="ts">
import { useController } from '@data-client/vue';
import { PostResource } from './PostResource';
const props = defineProps<{ id: string }>();
const ctrl = useController();
const handleSubmit = (e: Event) =>
ctrl.fetch(
PostResource.update,
{ id: props.id },
new FormData(e.target as HTMLFormElement),
);
</script>
<template>
<form @submit.prevent="handleSubmit"><!-- ... --></form>
</template>
<script setup lang="ts">
import { useController } from '@data-client/vue';
import { useRouter } from 'vue-router';
import { Post, PostResource } from './PostResource';
const props = defineProps<{ post: Post }>();
const ctrl = useController();
const router = useRouter();
const handleDelete = async () => {
await ctrl.fetch(PostResource.delete, { id: props.post.id });
router.push('/');
};
</script>
<template>
<div>
<h3>{{ post.title }}</h3>
<button @click="handleDelete">X</button>
</div>
</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。
当缓存中存在许多不同参数组合时,这可以用来只刷新当前正在显示的数据。
<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 重新获取。已挂载的组件会继续显示当前数据,直到重新获取完成。
<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>
使用 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 失效。
<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(未授权)时清除缓存。
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。
通常在退出登录或切换已认证用户时使用。
<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 }]);
当每个成员都把其判别字段声明为字面量(例如 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)
{
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() 示例所示。
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() 中读取。