v0.18:Scalar、类型化文件下载、过滤感知的 Collection
v0.18 聚焦于三件事:对随请求上下文变化的值进行更丰富的数据建模、更简单的类型化下载,以及更贴近真实 API 过滤条件的集合匹配。
新特性:
- Scalar schema - 随视角(lens)变化的实体字段(例如特定投资组合的数值),且永不修改底层实体
- RestEndpoint
content属性 - 只用一个属性即可实现类型化文件下载、文本响应和流式传输 - resource()
nonFilterArgumentKeys- 排序/分页参数不再把你的 Collection 拆散
其他改进:
- 二进制 Content-Type 自动检测 - 图片、PDF 及其他二进制响应无需任何配置即可自动处理(#3868)
- Collection 扩展方法的 body 类型与 HTTP 方法语义一致 - PATCH 扩展方法(
.move、.remove)接受部分 body;独立的 RestEndpoint 会从 Collection 的实体 schema 推导出类型化的 body(#3910) - 从
@data-client/endpoint和@data-client/rest导出CollectionOptions,用于类型化地构造 Collection(#3904)
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 }), }), });
这个示例展示了同一批 Company 实体通过不同的投资组合视角进行渲染。稳定的 Entity 字段会被复用,而随视角变化的值则从独立的 Scalar 单元格中选取。
- Schema.denormalize() 接收 delegate -
denormalize(input, args, unvisit)→denormalize(input, delegate)。仅影响自定义 Schema 实现。 - Schema.normalize() 接收 delegate -
normalize(input, parent, key, args, visit, delegate)→normalize(input, parent, key, delegate)。仅影响自定义 Schema 实现。
**在使用 AI 智能体?**安装迁移 skill——它会运行 codemod,为依赖参数的 schema 注册
argsKey,并处理 codemod 无法覆盖的情况(类字段、动态方法赋值、SFC <script> 块、自定义辅助函数):
- Skills
- OpenSkills
- Claude Code
npx skills add reactive/data-client --skill data-client-v0.18-migration
npx openskills install reactive/data-client/.agents/skills/data-client-v0.18-migration
claude plugin marketplace add reactive/data-client
claude plugin install migrations@data-client
否则,直接运行 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 能力——枚举当前视角下的所有单元格。
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.body | ReadableStream<Uint8Array> |
将 content 设为非 JSON 的值会在类型层面强制要求 schema: undefined,因为二进制数据无法被规范化。如果不小心使用了可规范化的 schema,运行时检查会给出清晰的错误信息。
// 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;
}
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())
迁移指南
本次升级需要同时更新所有包的版本。
- NPM
- Yarn
- pnpm
- esm.sh
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
yarn add @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
pnpm add @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
<script type="importmap">
{
"imports": {
"@data-client/react": "https://esm.sh/@data-client/react@^0.18.0",
"@data-client/rest": "https://esm.sh/@data-client/rest@^0.18.0",
"@data-client/endpoint": "https://esm.sh/@data-client/endpoint@^0.18.0",
"@data-client/core": "https://esm.sh/@data-client/core@^0.18.0",
"@data-client/vue": "https://esm.sh/@data-client/vue@^0.18.0",
"@data-client/test": "https://esm.sh/@data-client/test@^0.18.0",
"@data-client/img": "https://esm.sh/@data-client/img@^0.18.0"
}
}
</script>
本次发布中的破坏性变更仅影响自定义 Schema 实现。如果你只使用 resource() 和内置 schema(Entity、
Collection、Union、Values、
Array、Object、Query、
Invalidate、Lazy、Scalar),升级可以直接替换完成。
**有自定义 Schema?**如果你使用 AI 智能体,安装迁移 skill 并让它驱动整个流程——codemod、argsKey 注册以及手动清理:
- Skills
- OpenSkills
- Claude Code
npx skills add reactive/data-client --skill data-client-v0.18-migration
npx openskills install reactive/data-client/.agents/skills/data-client-v0.18-migration
claude plugin marketplace add reactive/data-client
claude plugin install migrations@data-client
没有智能体的话,先运行 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
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);
}
}
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) 声明这一依赖,以便缓存正确分桶。
class LensSchema {
denormalize(input, args, unvisit) {
const lens = args[0]?.portfolio;
return this.lookup(input, lens);
}
}
class LensSchema {
constructor({ lens }) {
this.lensSelector = lens; // ①
}
denormalize(input, delegate) {
const lens = delegate.argsKey(this.lensSelector); // ②
return this.lookup(input, lens);
}
}
- ①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.
- ②
argsKeyreturnsfn(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
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;
}
}
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 替换为一个共享定义:
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 }),
}),
};
}
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,
};
}
升级支持
一如既往,如果你遇到任何问题或有任何疑问,欢迎加入我们的 或提交 bug
