跳到主要内容

v0.16:并行获取、Collection.move、Lazy

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

新特性:

其他改进:

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

破坏性变更:

使用自动化 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 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

自动化 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移除自定义正则

可选参数​

? 后缀已移除。请把可选片段(包括其前缀)包在 {} 中。

Before
const ep = new RestEndpoint({
path: '/:group/things/:number?',
});
After
const ep = new RestEndpoint({
path: '/:group/things{/:number}',
});

带有不同前缀的多个可选片段:

Before
const ep = new RestEndpoint({
path: '/:attr1?{-:attr2}?{-:attr3}?',
});
After
const ep = new RestEndpoint({
path: '{/:attr1}{-:attr2}{-:attr3}',
});

重复参数​

+(一次或多次)和 *(零次或多次)后缀已移除。请使用 *name 通配符语法,零次或多次的情况需包在 {} 中。

通配符参数的类型现在是 string[](数组)而不是 string | number,因为它们表示多个路径片段。

Before
const ep = new RestEndpoint({
path: '/files/:path+',
});
ep({ path: 'docs/reports/q4' });
const optEp = new RestEndpoint({
path: '/files/:path*',
});
After
const ep = new RestEndpoint({
path: '/files/*path',
});
ep({ path: ['docs', 'reports', 'q4'] });
const optEp = new RestEndpoint({
path: '/files{/*path}',
});

带引号的参数名​

包含特殊字符(连字符、点)的参数名现在必须用双引号括起来:

Before
const ep = new RestEndpoint({
path: '/:with-dash',
});
After
const ep = new RestEndpoint({
path: '/:"with-dash"',
});

移除自定义正则​

不再支持像 /:id(\d+) 这样的内联正则模式。请移除正则——改为在服务端进行校验。

Before
const ep = new RestEndpoint({
path: '/users/:id(\\d+)',
});
After
const ep = new RestEndpoint({
path: '/users/:id',
});

转义特殊字符​

包括 {}()[]+?!:*\\ 在内的字符在路径模式中作为字面量使用时,必须用 \\ 转义。这些字符被 path-to-regexp 保留,未转义地使用会抛出 Unexpected token 错误。模式中的字面量查询字符串仍然需要转义 ?,例如 '/resource\\?per_page=50'。

当查询片段使用命名参数时,优先使用显式分组而不是单个转义的 ?:

Before
const ep = new RestEndpoint({
path: '/search\\?q=:q',
});
After
const ep = new RestEndpoint({
path: '/search?{q=:q}{&page=:page}',
});

查找需要迁移的路径​

在代码库的 path: 值中搜索以下模式:

  • :param 之后的 ?——需要转换的可选参数
  • :param 之后的 + 或 *——需要转换的重复参数
  • :param 之后的 (\——需要移除的自定义正则
  • 包含 - 或 . 的参数名——需要加引号

完整的 v8 语法文档请参阅 RestEndpoint.path,详细解释请参阅 path-to-regexp 错误说明。也可以使用 AI 辅助迁移:

npx skills add reactive/data-client --skill path-to-regexp-v8-migration

useFetch() 返回 UsablePromise​

useFetch() 现在返回一个 UsablePromise thenable,带有反规范化后的数据、错误处理和 .resolved 属性。这使它可以直接与 React.use() 配合使用——use(useFetch(endpoint, args)) 的行为与 useSuspense() 完全相同。

之前,当数据有效时 useFetch() 返回 undefined。通过真值性来判断是否发起了获取的代码,现在必须改用 .resolved:

Before
const promise = useFetch(PostResource.get, { id });
if (promise) {
// fetch was triggered
}
After
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 命名空间。这提供了更简洁的导入语法,同时保持完全的向后兼容。

Before
import { schema } from '@data-client/endpoint';

const myUnion = new schema.Union(
{ users: User, groups: Group },
'type',
);
After
import { Union } from '@data-client/endpoint';

const myUnion = new Union(
{ users: User, groups: Group },
'type',
);

所有 schema 类都可以直接导入:Union、Invalidate、Collection、Query、Values、All 和 Lazy。为了向后兼容,schema 命名空间导出仍然可用。

升级支持​

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