v0.16:并行获取、Collection.move、Lazy
新特性:
- 使用 useFetch() 并行加载数据——通过
use(useFetch())并发获取多个 endpoint,避免串行瀑布流 - Collection.move——用一次操作在多个 Collection 之间移动实体
- Collection.moveWith()——自定义移动行为(例如前置插入而非追加)
- Lazy——延迟关系的反规范化,以提升性能并隔离记忆化
其他改进:
- 直接导入 schema——无需
schema命名空间即可直接导入 schema 类 - 反规范化深度限制——防止大型双向实体图导致栈溢出;可通过
Entity.maxEntityDepth配置(#3822) - DevToolsManager 在开发模式下暴露
globalThis.__DC_CONTROLLERS__,便于从 Chrome DevTools MCP 和 Expo MCP 以编程方式访问 store。使用 data-client-react skill 来启用 AI 辅助调试。 - 移除 Next.js SSR 期间具有误导性的 'Uncaught Suspense' 警告
- 修复 RestEndpoint 中
method: 'POST'导致sideEffect: false类型丢失的问题 - renderDataHook() 在每个测试之后自动清理——无需手动调用
cleanup() - renderDataHook() 返回每次渲染的
cleanup()和allSettled(),让测试的收尾更可靠
Collection.move 让你能够用一次操作轻松地在多个 Collection 之间移动实体:
import { useController } from '@data-client/react'; import { TaskResource, type Task } from './TaskResource'; export default function TaskCard({ task }: { task: Task }) { const handleMove = () => ctrl.fetch( TaskResource.getList.move, { id: task.id }, { id: task.id, status: task.status === 'backlog' ? 'in-progress' : 'backlog' }, ); const ctrl = useController(); return ( <div className="listItem"> <span style={{ flex: 1 }}>{task.title}</span> <button onClick={handleMove}> {task.status === 'backlog' ? '\u25bc' : '\u25b2'} </button> </div> ); }
- path-to-regexp v8——RestEndpoint.path 语法更新:
/:optional?→{/:optional},/:repeat+→/*repeat(类型为string[]) - useFetch() 返回 UsablePromise——返回可供
React.use()使用的 thenable;请检查.resolved而不是真值性
使用自动化 codemod 升级,它涵盖所有破坏性变更:
npx jscodeshift -t https://dataclient.io/codemods/v0.16.js --extensions=ts,tsx,js,jsx src/
Collection.move
Collection.move 和 RestEndpoint.move 让你能够用一次 PATCH 请求轻松地在多个 collection 之间移动实体。实体会自动从与其旧状态匹配的 collection 中移除,并添加到与请求体中新值匹配的 collection 中。
这对看板、分组重新分配、状态变更,或任何条目需要在筛选列表之间移动的工作流都很有用。
const TaskResource = resource({
path: '/tasks/:id',
searchParams: {} as { status: string },
schema: Task,
});
// PATCH /tasks/3 - moves task from 'backlog' to 'in-progress'
await ctrl.fetch(
TaskResource.getList.move,
{ id: '3' },
{ id: '3', status: 'in-progress' },
);
同时适用于路径参数和搜索参数,并支持 Array 和 Values collection。
Collection.moveWith()
Collection.moveWith() 用于构造自定义的移动 schema,类似于 addWith()。merge 函数控制实体如何被添加到目标 collection(例如前置插入而非追加),而移除行为则会自动推导。为方便使用,还提供了 unshift 合并函数。
import { Collection, unshift } from '@data-client/rest';
class MyCollection extends Collection {
constructor(schema, options) {
super(schema, options);
this.move = this.moveWith(unshift);
}
}
并行加载数据
useFetch() 现在返回一个 UsablePromise thenable,使 use(useFetch(endpoint, args)) 成为 useSuspense() 的即插即用等价物——
数据加载时挂起,已缓存时返回反规范化后的数据,出错时抛出,并在失效时重新挂起。
由于 useFetch() 和 use() 是两次独立的调用,多个获取会并行开始。所有 useFetch() 调用都会在任何 use() 挂起之前执行,因此即使第一个 use() 挂起了组件,每个获取也都已经在进行中。这避免了多次调用 useSuspense() 时出现的串行瀑布流,即第二个获取必须等第一个完成后才能开始。
#3755
import { use } from 'react'; import { useFetch } from '@data-client/react'; import { PostResource, CommentResource } from './Resources'; function PostWithComments({ id }: { id: number }) { // Both fetches start in parallel const postPromise = useFetch(PostResource.get, { id }); const commentsPromise = useFetch(CommentResource.getList, { postId: id }); // use() reads the results — if the first suspends, // the second fetch is already in-flight const post = use(postPromise); const comments = use(commentsPromise); return ( <article> <h3>{post.title}</h3> <p>{post.body}</p> <h4>Comments</h4> {comments.map(comment => ( <div key={comment.id} className="listItem"> <strong>{comment.author}</strong>: {comment.text} </div> ))} </article> ); } render(<PostWithComments id={1} />);
反规范化深度限制与 Lazy
当 schema 在不同实体类型之间存在双向关系时(例如 Department → Building → Department → ...),反规范化会遍历连通图中的每一个唯一实体。数据集较小时这并不明显,但在大规模下——成千上万个相互关联的实体——递归会超出 JS 调用栈并抛出
RangeError: Maximum call stack size exceeded。
#3822
两个互补的特性解决了这个问题:作为安全网的深度限制,以及 Lazy,用于按字段精确控制哪些关系被急切解析。
反规范化深度限制
反规范化现在默认强制执行 64 层实体跳转的深度限制。超出限制的实体会以未解析的 id 返回,而不是完全反规范化后的对象。在开发模式下达到该限制时会输出一条 console.error。
可以通过 static maxEntityDepth 为每个 Entity 单独配置该限制:
class Department extends Entity {
static maxEntityDepth = 16;
}
这可以防止崩溃,但它是一把钝刀——会切断所有深层路径,包括合法的非循环路径。若需要细粒度控制,请使用 Lazy。
Lazy
Lazy 包装一个 schema,以跳过对特定关系字段的急切反规范化。在父实体反规范化期间,该字段保留其原始的规范化值(主键)。之后可以通过 useQuery 使用 .query 访问器按需解析该关系。
import { Entity, Lazy } from '@data-client/rest';
class Department extends Entity {
id = '';
name = '';
buildings: string[] = [];
static schema = {
buildings: new Lazy([Building]),
};
}
与深度限制不同,Lazy 是针对单个关系的、有针对性的选择性启用。只有你标记为 lazy 的字段会跳过急切解析——schema 的其余部分照常反规范化。这也通过推迟那些并非总是需要的关系的处理工作来提升性能,并提供了记忆化隔离——
lazy 实体的变化不会让父实体的反规范化结果失效。
完整用法请参阅 Lazy 文档,设计讨论请参阅 #3828。
迁移指南
本次升级需要同时更新所有包的版本。
- NPM
- Yarn
- pnpm
- esm.sh
npm install --save @data-client/react@^0.16.0 @data-client/rest@^0.16.0 @data-client/endpoint@^0.16.0 @data-client/core@^0.16.0 @data-client/vue@^0.16.0 @data-client/test@^0.16.0 @data-client/img@^0.16.0
yarn add @data-client/react@^0.16.0 @data-client/rest@^0.16.0 @data-client/endpoint@^0.16.0 @data-client/core@^0.16.0 @data-client/vue@^0.16.0 @data-client/test@^0.16.0 @data-client/img@^0.16.0
pnpm add @data-client/react@^0.16.0 @data-client/rest@^0.16.0 @data-client/endpoint@^0.16.0 @data-client/core@^0.16.0 @data-client/vue@^0.16.0 @data-client/test@^0.16.0 @data-client/img@^0.16.0
<script type="importmap">
{
"imports": {
"@data-client/react": "https://esm.sh/@data-client/react@^0.16.0",
"@data-client/rest": "https://esm.sh/@data-client/rest@^0.16.0",
"@data-client/endpoint": "https://esm.sh/@data-client/endpoint@^0.16.0",
"@data-client/core": "https://esm.sh/@data-client/core@^0.16.0",
"@data-client/vue": "https://esm.sh/@data-client/vue@^0.16.0",
"@data-client/test": "https://esm.sh/@data-client/test@^0.16.0",
"@data-client/img": "https://esm.sh/@data-client/img@^0.16.0"
}
}
</script>
自动化 codemod 可以处理下面全部三项破坏性变更——路径语法、useFetch() 真值性检查,以及 schema 命名空间导入:
npx jscodeshift -t https://dataclient.io/codemods/v0.16.js --extensions=ts,tsx,js,jsx src/
path-to-regexp v8
RestEndpoint.path 现在使用 path-to-regexp v8,它改变了可选参数、重复参数和特殊字符的语法。
| v6 语法 | v8 语法 | 说明 |
|---|---|---|
/:optional? | {/:optional} | 可选参数 |
/:repeat+ | /*repeat | 重复一次或多次(类型为 string[]) |
/:repeat* | {/*repeat} | 重复零次或多次(类型为 string[]) |
/:with-dash | /:"with-dash" | 带引号的参数名 |
/:id(\d+) | /:id | 移除自定义正则 |
可选参数
? 后缀已移除。请把可选片段(包括其前缀)包在 {} 中。
const ep = new RestEndpoint({
path: '/:group/things/:number?',
});
const ep = new RestEndpoint({
path: '/:group/things{/:number}',
});
带有不同前缀的多个可选片段:
const ep = new RestEndpoint({
path: '/:attr1?{-:attr2}?{-:attr3}?',
});
const ep = new RestEndpoint({
path: '{/:attr1}{-:attr2}{-:attr3}',
});
重复参数
+(一次或多次)和 *(零次或多次)后缀已移除。请使用 *name 通配符语法,零次或多次的情况需包在 {} 中。
通配符参数的类型现在是 string[](数组)而不是 string | number,因为它们表示多个路径片段。
const ep = new RestEndpoint({
path: '/files/:path+',
});
ep({ path: 'docs/reports/q4' });
const optEp = new RestEndpoint({
path: '/files/:path*',
});
const ep = new RestEndpoint({
path: '/files/*path',
});
ep({ path: ['docs', 'reports', 'q4'] });
const optEp = new RestEndpoint({
path: '/files{/*path}',
});
带引号的参数名
包含特殊字符(连字符、点)的参数名现在必须用双引号括起来:
const ep = new RestEndpoint({
path: '/:with-dash',
});
const ep = new RestEndpoint({
path: '/:"with-dash"',
});
移除自定义正则
不再支持像 /:id(\d+) 这样的内联正则模式。请移除正则——改为在服务端进行校验。
const ep = new RestEndpoint({
path: '/users/:id(\\d+)',
});
const ep = new RestEndpoint({
path: '/users/:id',
});
转义特殊字符
包括 {}()[]+?!:*\\ 在内的字符在路径模式中作为字面量使用时,必须用 \\ 转义。这些字符被 path-to-regexp 保留,未转义地使用会抛出 Unexpected token 错误。模式中的字面量查询字符串仍然需要转义 ?,例如 '/resource\\?per_page=50'。
当查询片段使用命名参数时,优先使用显式分组而不是单个转义的 ?:
const ep = new RestEndpoint({
path: '/search\\?q=:q',
});
const ep = new RestEndpoint({
path: '/search?{q=:q}{&page=:page}',
});
查找需要迁移的路径
在代码库的 path: 值中搜索以下模式:
:param之后的?——需要转换的可选参数:param之后的+或*——需要转换的重复参数:param之后的(\——需要移除的自定义正则- 包含
-或.的参数名——需要加引号
完整的 v8 语法文档请参阅 RestEndpoint.path,详细解释请参阅 path-to-regexp 错误说明。也可以使用 AI 辅助迁移:
- Skills
- OpenSkills
- Claude Code
npx skills add reactive/data-client --skill path-to-regexp-v8-migration
npx openskills install reactive/data-client/.agents/skills/path-to-regexp-v8-migration
claude plugin marketplace add reactive/data-client
claude plugin install migrations@data-client
useFetch() 返回 UsablePromise
useFetch() 现在返回一个 UsablePromise thenable,带有反规范化后的数据、错误处理和 .resolved 属性。这使它可以直接与 React.use() 配合使用——use(useFetch(endpoint, args)) 的行为与 useSuspense() 完全相同。
之前,当数据有效时 useFetch() 返回 undefined。通过真值性来判断是否发起了获取的代码,现在必须改用 .resolved:
const promise = useFetch(PostResource.get, { id });
if (promise) {
// fetch was triggered
}
const promise = useFetch(PostResource.get, { id });
if (!promise.resolved) {
// fetch is in-flight
}
use(promise); // suspends or returns denormalized data
关于这如何实现并发获取、避免串行瀑布流,请参阅上文的并行加载数据。 #3752、#3755
直接导入 schema
Schema 类现在可以直接从 @data-client/endpoint(或重新导出它们的 @data-client/rest)导入,而无需通过 schema 命名空间。这提供了更简洁的导入语法,同时保持完全的向后兼容。
import { schema } from '@data-client/endpoint';
const myUnion = new schema.Union(
{ users: User, groups: Group },
'type',
);
import { Union } from '@data-client/endpoint';
const myUnion = new Union(
{ users: User, groups: Group },
'type',
);
所有 schema 类都可以直接导入:Union、Invalidate、Collection、Query、Values、All 和 Lazy。为了向后兼容,schema 命名空间导出仍然可用。
升级支持
一如往常,如果你遇到任何问题或有疑问,欢迎加入我们的 或提交 bug
