跳到主要内容

v0.18:Scalar、类型化文件下载、过滤感知的 Collection

· 阅读需 12 分钟
Nathaniel Tucker
Creator of Reactive Data Client

v0.18 聚焦于三件事:对随请求上下文变化的值进行更丰富的数据建模、更简单的类型化下载,以及更贴近真实 API 过滤条件的集合匹配。

新特性:

其他改进:

Fixtures
▶api/Company
import { Collection, Entity, RestEndpoint, Scalar } from '@data-client/rest';

export class Company extends Entity {
  id = '';
  name = '';
  price = 0;
  pct_equity = 0;
  shares = 0;
}

const PortfolioScalar = new Scalar({
  lens: args => args[0]?.portfolio,
  key: 'portfolio',
  entity: Company,
});
Company.schema = {
  pct_equity: PortfolioScalar,
  shares: PortfolioScalar,
};

export const getCompanies = new RestEndpoint({
  path: '/companies',
  searchParams: {} as { portfolio: string },
  // `portfolio` is a lens, not a filter — the returned Company list is the same
  // regardless of lens. Dropping it from `argsKey` collapses every portfolio to
  // one Collection pk, so `Collection.queryKey()` finds the list on every
  // switch and `useSuspense` reuses it without refetching.
  schema: new Collection([Company], { argsKey: () => ({}) }),
});

export const getPortfolioColumns = new RestEndpoint({
  path: '/companies/columns',
  searchParams: {} as { portfolio: string },
  schema: new Collection([PortfolioScalar], {
    argsKey: ({ portfolio }: { portfolio: string }) => ({ portfolio }),
  }),
});
▶CompanyGrid
▶PortfolioGrid
结果
Store▶

这个示例展示了同一批 Company 实体通过不同的投资组合视角进行渲染。稳定的 Entity 字段会被复用,而随视角变化的值则从独立的 Scalar 单元格中选取。

破坏性变更:

**在使用 AI 智能体?**安装迁移 skill——它会运行 codemod,为依赖参数的 schema 注册 argsKey,并处理 codemod 无法覆盖的情况(类字段、动态方法赋值、SFC <script> 块、自定义辅助函数):

npx skills add reactive/data-client --skill data-client-v0.18-migration

否则,直接运行 codemod——它覆盖了常见情况:

npx jscodeshift -t https://dataclient.io/codemods/v0.18.js --extensions=ts,tsx,js,jsx src/

Scalar​

Scalar 用于处理那些取值依赖运行时“视角”(lens)的 Entity 字段——比如当前选中的投资组合、货币或区域设置。多个组件可以同时通过不同视角渲染同一个实体,各自看到正确的值,而实体本身始终不变。随视角变化的值存放在单独的单元格表中,并在反规范化时根据 endpoint 参数进行关联。

切换投资组合时 name 和 price 的引用保持稳定,因为 Company 实体本身从未改变——改变的只是当前视角所选中的 Scalar 单元格。同一个 Scalar 实例既可以作为 Entity.schema 字段使用(父实体从访问过程中推断),也可以独立用在 Values、[Scalar] 或 Collection([Scalar]) 中,以实现低成本的仅刷新列操作(通过 entity 显式绑定实体)。单元格的 pk 由映射键派生,或通过 Scalar.entityPk() 得到,后者默认使用 Entity.pk(),因此自定义主键和复合主键无需重写即可正常工作。

规范化后的 store 保持实体稳定,并把视角单元格分开存放:

entities['Company']['1'] = {
id: '1',
price: 145.20,
pct_equity: ['1', 'pct_equity', 'Company'],
shares: ['1', 'shares', 'Company'],
}

entities['Scalar(portfolio)']['Company|1|A'] = { pct_equity: 0.50, shares: 10000 }
entities['Scalar(portfolio)']['Company|1|B'] = { pct_equity: 0.30, shares: 6000 }

示例将 getCompanies 与一个低成本的、仅刷新视角数据的 getPortfolioColumns 配对,二者写入同一个单元格表——完整的缓存行为讲解请参阅 Scalar 文档。

Scalar 还实现了 queryKey,因此可以直接参与 useQuery、Controller.get 和 schema.Query—— 即完整的 Queryable 能力——枚举当前视角下的所有单元格。

#3887 - Scalar 文档

RestEndpoint content 属性​

RestEndpoint 现在接受一个 content 属性,用于控制响应体的解析方式。返回类型会根据该值自动推断,并且对于非 JSON 的内容类型,schema 在类型层面被约束为 undefined。

import { RestEndpoint } from '@data-client/rest';

const downloadFile = new RestEndpoint({
path: '/files/:id/download',
content: 'blob',
dataExpiryLength: 0,
});

// Return type is Blob — inferred from content, no explicit generics needed
const blob: Blob = await ctrl.fetch(downloadFile, { id: '123' });

以前,文件下载需要冗长地重写 parseResponse。现在只需一个属性。

值解析方式返回类型
'json'response.json()any
'blob'response.blob()Blob
'text'response.text()string
'arrayBuffer'response.arrayBuffer()ArrayBuffer
'stream'response.bodyReadableStream<Uint8Array>

将 content 设为非 JSON 的值会在类型层面强制要求 schema: undefined,因为二进制数据无法被规范化。如果不小心使用了可规范化的 schema,运行时检查会给出清晰的错误信息。

可与 extend() 和子类配合使用:

// Extend a JSON endpoint into a blob downloader
const download = jsonEndpoint.extend({
content: 'blob',
schema: undefined,
});

// Subclass for a reusable blob endpoint type
class BlobEndpoint<O extends RestGenerics = any> extends RestEndpoint<O> {
content = 'blob' as const;
}

#3868 - content 文档

resource() nonFilterArgumentKeys​

nonFilterArgumentKeys 让 resource() 可以声明哪些参数键不用于过滤集合结果——通常是排序、分页或显示选项。没有它的话,每一组不同的排序或分页参数都会创建一个独立的 Collection 分桶,新创建的条目也不会出现在使用了不同排序参数的列表中,即便底层数据本应匹配。

import { Entity, resource } from '@data-client/rest';

class Post extends Entity {
id = '';
title = '';
group = '';
author = '';
}

const PostResource = resource({
path: '/:group/posts/:id',
searchParams: {} as { orderBy?: string; author?: string },
schema: Post,
nonFilterArgumentKeys: ['orderBy'],
});

现在 group 和 author 是过滤键(它们划分出各自独立的 Collection),而 orderBy 是共享的——PostResource.getList.push() 会把新帖子添加到给定 { group, author } 分桶的所有 orderBy 变体中。

接受 string[]、RegExp 或谓词函数:

nonFilterArgumentKeys: /^(orderBy|page|cursor)$/,

#3914 - nonFilterArgumentKeys 文档

二进制 Content-Type 自动检测​

当未设置 content 时,parseResponse 现在会自动检测二进制 Content-Type 并返回 response.blob(),而不是通过 .text() 损坏数据。

诸如 image/*、audio/*、video/*、font/*、application/octet-stream、application/pdf 等二进制类型现在都会自动处理。类文本类型(text/*、XML、HTML、JavaScript、CSS 等)继续使用原有的文本 fallback,并附带感知 schema 的错误信息。

这意味着带有正确 Content-Type 头并提供二进制数据的 endpoint 无需任何配置即可工作:

const ep = new RestEndpoint({
path: '/avatars/:id',
});
// Server responds with Content-Type: image/png
// → automatically returns a Blob (previously corrupted via .text())

#3868

迁移指南​

本次升级需要同时更新所有包的版本。

npm install --save @data-client/react@^0.18.0 @data-client/rest@^0.18.0 @data-client/endpoint@^0.18.0 @data-client/core@^0.18.0 @data-client/vue@^0.18.0 @data-client/test@^0.18.0 @data-client/img@^0.18.0

本次发布中的破坏性变更仅影响自定义 Schema 实现。如果你只使用 resource() 和内置 schema(Entity、 Collection、Union、Values、 Array、Object、Query、 Invalidate、Lazy、Scalar),升级可以直接替换完成。

**有自定义 Schema?**如果你使用 AI 智能体,安装迁移 skill 并让它驱动整个流程——codemod、argsKey 注册以及手动清理:

npx skills add reactive/data-client --skill data-client-v0.18-migration

没有智能体的话,先运行 codemod,再阅读下面各节了解它无法处理的情况(尤其是依赖参数的 schema 所需的 argsKey):

npx jscodeshift -t https://dataclient.io/codemods/v0.18.js --extensions=ts,tsx,js,jsx src/

Schema.denormalize() 接收 delegate​

如果你没有实现自定义 Schema 类,可以跳过本节。

Schema.denormalize() 现在的签名是 (input, delegate),取代了以前的 3 参数签名 (input, args, unvisit)。新的 IDenormalizeDelegate 暴露了 unvisit、args 以及新的 argsKey(fn) 辅助方法。 #3887

Before
import { Entity } from '@data-client/rest';

class Wrapper {
denormalize(
input: {},
args: readonly any[],
unvisit: (s: any, v: any) => any,
) {
const value = unvisit(this.schema, input);
return this.process(value, ...args);
}
}
After
import { Entity, type IDenormalizeDelegate } from '@data-client/rest';

class Wrapper {
denormalize(input: {}, delegate: IDenormalizeDelegate) {
const value = delegate.unvisit(this.schema, input);
return this.process(value, ...delegate.args);
}
}

codemod 能处理类方法、对象方法、函数声明、TypeScript 接口签名,以及透传式的 someSchema.denormalize(input, args, unvisit) 调用。它还会把 IDenormalizeDelegate 以内联 type 说明符的形式添加到你现有的 @data-client/* 导入中(找不到时则新建一行 import type)。

依赖参数的输出​

直接读取 delegate.args 不会参与记忆化。如果你的 schema 的输出依赖这些参数(例如一个视角),请通过 delegate.argsKey(fn) 声明这一依赖,以便缓存正确分桶。

codemod 无法推断这个依赖,所以请手动更新这些 schema。
Before
class LensSchema {
denormalize(input, args, unvisit) {
const lens = args[0]?.portfolio;
return this.lookup(input, lens);
}
}
After
class LensSchema {
constructor({ lens }) {
this.lensSelector = lens; // ①
}
denormalize(input, delegate) {
const lens = delegate.argsKey(this.lensSelector); // ②
return this.lookup(input, lens);
}
}
  1. ①The function reference is the cache key, so set it once here (or at module scope). An inline arrow would miss the cache on every call.
  2. ②argsKey returns fn(args) and records the dependency for memoization.

完整示例请参阅 Scalar。

Schema.normalize() 接收 delegate​

如果你没有实现自定义 Schema 类,可以跳过本节。

Schema.normalize() 现在从 INormalizeDelegate 读取 endpoint 参数和递归访问器,与新的 denormalize delegate 的形式保持一致。以前的签名是 (input, parent, key, args, visit, delegate, parentEntity?);新的签名是 (input, parent, key, delegate, parentEntity?)。 #3934

Before
import type { INormalizeDelegate, Visit } from '@data-client/rest';

class Wrapper {
normalize(
input: any,
parent: any,
key: string,
args: readonly any[],
visit: Visit,
delegate: INormalizeDelegate,
) {
const normalized = visit(this.schema, input.value, input, 'value', args);
delegate.mergeEntity(this, this.pk(input, parent, key, args), normalized);
return normalized;
}
}
After
import type { INormalizeDelegate } from '@data-client/rest';

class Wrapper {
normalize(
input: any,
parent: any,
key: string,
delegate: INormalizeDelegate,
) {
const { args, visit } = delegate;
const normalized = visit(this.schema, input.value, input, 'value');
delegate.mergeEntity(this, this.pk(input, parent, key, args), normalized);
return normalized;
}
}

如果你的 schema 接受可选的 parentEntity 参数,请把它保留为最后一个参数:

normalize(input, parent, key, delegate, parentEntity) {
// ...
}

delegate.visit() 在递归规范化时已经携带了同样的参数,因此嵌套的 schema 不需要显式传入参数。

新增:normalize 的 parentEntity​

normalize delegate 的 visit() 回调会在内部跟踪最近的外层类实体 schema,并将其作为可选的末尾参数 parentEntity 转发给 Schema.normalize()。新的 schema 可以选择利用它,在规范化时发现自己所属的实体(Scalar 在内部就是这样使用的)。

可选:合并 Collection 定义​

Collection 现在可以在同一个实例上同时使用 argsKey 和 nestKey。作为一项可选的清理,你可以把成对的顶层和嵌套 Collection 替换为一个共享定义:

Before
export const getTodos = new RestEndpoint({
path: '/todos',
searchParams: {} as { userId?: string },
schema: new Collection([Todo]),
});

class User extends Entity {
static schema = {
todos: new Collection([Todo], {
nestKey: parent => ({ userId: parent.id }),
}),
};
}
After
export const userTodos = new Collection([Todo], {
//argsKey: params => ({ ...params }), - this is default, so it's not needed
nestKey: parent => ({ userId: parent.id }),
});

export const getTodos = new RestEndpoint({
path: '/todos',
searchParams: {} as { userId?: string },
schema: userTodos,
});

class User extends Entity {
static schema = {
todos: userTodos,
};
}

升级支持​

一如既往,如果你遇到任何问题或有任何疑问,欢迎加入我们的 Chat 或提交 bug