乐观更新
乐观更新通过避免等待网络,实现响应迅速、速度飞快的界面。所谓乐观,是指更新时假定网络请求会成功。
这样做会放大已有的竞态条件并带来新的竞态条件;幸运的是,Reactive Data Client 会自动为你处理它们。
Resources
可以通过设置 optimistic: true 来配置 resource()。
import { Entity, resource } from '@data-client/rest'; export class Todo extends Entity { id = 0; userId = 0; title = ''; completed = false; static key = 'Todo'; } export const TodoResource = resource({ urlPrefix: 'https://jsonplaceholder.typicode.com', path: '/todos/:id', searchParams: {} as { userId?: string | number } | undefined, schema: Todo, optimistic: true, });
这会使用一些能处理大多数情况的合理默认实现,让所有变更都变成乐观的。
update/getList.push/getList.unshift
function optimisticUpdate(
snap: SnapshotInterface,
params: any,
body: any,
) {
return {
...params,
...ensureBodyPojo(body),
};
}
function ensureBodyPojo(body: any) {
return body instanceof FormData
? Object.fromEntries((body as any).entries())
: body;
}
对于创建操作(push/unshift),响应中通常没有可用于计算 pk 的 id。
Data Client 会创建一个随机的 pk 来解决这个问题。
在对象真正创建之前,对它进行变更通常是行不通的。因此在这种情况下,比较稳妥的做法是在实际的
POST 完成之前禁止进一步的变更。一种判断方法是直接检查该 Entity 中是否存在真实的 id。
partialUpdate
function optimisticPartial(schema: Queryable) {
return function (snap: SnapshotInterface, params: any, body: any) {
const data = snap.get(schema, params);
if (!data) throw snap.abort;
return {
...params,
...data,
// even tho we don't always have two arguments, the extra one will simply be undefined which spreads fine
...ensurePojo(body),
};
};
}
部分更新不会发送完整的 body,因此我们可以使用 store 中的 Entity 来计算预期的响应。Snapshots 让我们能够安全地访问 store 中的现有值,并且不受任何竞态条件的影响。
delete
function optimisticDelete(snap: SnapshotInterface, params: any) {
return params;
}
如果你不希望所有 endpoint 都是乐观的,或者你的 API 设计比较特殊,可以通过 Resource.extend() 设置 getOptimisticResponse()
乐观变换
有时用户操作引起的数据变换依赖于数据之前的状态。最简单的例子是切换一个布尔值或递增一个计数器;但同样的原则也适用于更复杂的变换。为了更直观,这里我们使用一个简单的计数器。
import { RestEndpoint } from '@data-client/rest'; import { CountEntity, getCount } from './count'; export const increment = new RestEndpoint({ path: '/api/count/increment', method: 'POST', body: undefined, name: 'increment', schema: CountEntity, getOptimisticResponse(snap) { const data = snap.get(CountEntity, {}); if (!data) throw snap.abort; return { count: data.count + 1, }; }, });
Reactive Data Client 会自动处理所有由网络时序引起的竞态条件。Reactive Data Client 既会跟踪请求的时序,也会将响应与对应的乐观更新配对,并在请求 resolve 或 reject/失败时回滚。
你可以看到,即使没有乐观更新,这对其他库来说也是个问题;而乐观更新会让情况更糟。
竞态条件示例
下面是一个竞态条件的例子。我们请求了两次递增;但第一个响应比第二个响应更晚回到客户端。
使用其他库且没有乐观更新时,界面会先显示 0,然后是 2,最后是 1。
如果其他库支持乐观更新,界面会依次显示 0、1、2、2,最后是 1。
两种情况下,我们最终都显示了错误的状态,而且过程中还会看到古怪、卡顿的状态更新。
补偿服务器时序差异
在异步变更中,有三种时序可能发生变化。
- 请求时序
- 服务器时序
- 响应时序
Reactive Data Client 能够自动处理网络时序,也就是请求时序和响应时序。通常这已经足够,因为服务器往往会先处理先收到的请求。然而,如果服务器中的持久化顺序与请求顺序不同,就可能引发另一种竞态条件。
这可以通过维护一个全序来解决。由于服务器和客户端的时间可能不同,我们需要从一个一致的视角来记录时间。既然我们执行的是乐观更新,就必须使用客户端的时钟。也就是说,我们会通过 getRequestInit() 在 updatedAt header 中把请求时序发送给服务器。服务器随后应确保按照该顺序进行处理,并把这个 updatedAt 存储在 Entity 中,以便在任何请求中返回。
通过覆盖 shouldReorder,我们可以根据服务器时间戳对乱序的响应重新排序。
我们在 getOptimisticResponse 中使用了 snap.fetchedAt。它表示触发获取的时刻,与计算 updatedAt header 的时间相同。
import { RestEndpoint } from '@data-client/rest'; import { CountEntity } from './count'; export const increment = new RestEndpoint({ path: '/api/count/increment', method: 'POST', body: undefined, name: 'increment', schema: CountEntity, getRequestInit() { // this is a substitute for super.getRequestInit() // since we aren't in a class context return RestEndpoint.prototype.getRequestInit.call(this, { updatedAt: Date.now(), }); }, getOptimisticResponse(snap) { const data = snap.get(CountEntity, {}); if (!data) throw snap.abort; return { count: data.count + 1, updatedAt: snap.fetchedAt, }; }, });