跳到主要内容

乐观更新

乐观更新通过避免等待网络,实现响应迅速、速度飞快的界面。所谓乐观,是指更新时假定网络请求会成功。

这样做会放大已有的竞态条件并带来新的竞态条件;幸运的是,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,
});
结果
Store▶

这会使用一些能处理大多数情况的合理默认实现,让所有变更都变成乐观的。

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,
    };
  },
});
结果
Store▶

Reactive Data Client 会自动处理所有由网络时序引起的竞态条件。Reactive Data Client 既会跟踪请求的时序,也会将响应与对应的乐观更新配对,并在请求 resolve 或 reject/失败时回滚。

你可以看到,即使没有乐观更新,这对其他库来说也是个问题;而乐观更新会让情况更糟。

竞态条件示例​

下面是一个竞态条件的例子。我们请求了两次递增;但第一个响应比第二个响应更晚回到客户端。

使用其他库且没有乐观更新时,界面会先显示 0,然后是 2,最后是 1。

如果其他库支持乐观更新,界面会依次显示 0、1、2、2,最后是 1。

两种情况下,我们最终都显示了错误的状态,而且过程中还会看到古怪、卡顿的状态更新。

补偿服务器时序差异​

在异步变更中,有三种时序可能发生变化。

  1. 请求时序
  2. 服务器时序
  3. 响应时序

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,
    };
  },
});
结果
Store▶