跳到主要内容

v0.11 Query、Queryable 与 useQuery

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

除了规范化状态带来的性能和数据完整性收益之外,我们还获得了一项额外的好处:能够安全地直接访问并查询 store。

在本次发布中,我们围绕 Queryable Schema 的概念统一并简化了这一功能。它们包括 Entity、All、Collection、Query 和 Union

这一变化最大的影响是引入了新的 hook useQuery(),它允许使用 Queryable Schema 直接查找 store。

class User extends Entity {
username = '';
id = '';
groupId = '';
pk() {
return this.id;
}
static index = ['username' as const];
}

const bob = useQuery(User, { username: 'bob' });
const bob = useQuery(User, { id: '5' });

同样地,我们可以通过 controller.get 在 controller 和 snapshot 中查找 Queryable

const bob = snapshot.get(User, { username: 'bob' });

此外,我们还投入了更多的性能改进,使大多数操作的性能提升了约 2 倍,而 Query 则快了 16 倍。

迁移指南

破坏性变更:

其他亮点:

  • useCache() 接受带有 sideEffects 的 Endpoint(例如 Resource.update)
  • 允许 Entity.pk() 返回数字。
  • 2 到 16 倍的性能改进

Queryable​

Queryable schema 需要一个返回非空值的 queryKey() 方法。它们包括 Entity、All、Collection、Query 和 Union。

interface Queryable {
queryKey(
args: readonly any[],
queryKey: (...args: any) => any,
getEntity: GetEntity,
getIndex: GetIndex,
// `{}` means non-void
): {};
}

schema.Query​

Query 之前是作为 Endpoint 实现的。既然我们现在有了直接获取 Queryable schema 的方法,就可以干脆让 Query 本身也成为一个 Queryable schema。它的用法基本保持不变,允许对 store 中的值进行任意计算。#2921

const getUserCount = new schema.Query(
new schema.All(User),
(entries, { isAdmin } = {}) => {
if (isAdmin !== undefined)
return entries.filter(user => user.isAdmin === isAdmin).length;
return entries.length;
},
);

const userCount = useQuery(getUserCount);
const adminCount = useQuery(getUserCount, { isAdmin: true });

schema.Query 现在不再允许使用非 Queryable 的 schema。

const getBob = new schema.Query(
  // Array is not Queryable
  new schema.Array({ bob: '' }),
  thing => {
    return thing;
  },
);

controller.get / snapshot.get​

现在只需一个 Queryable Schema 即可访问 store——不再需要一整个 Endpoint。 #2921

Before
getOptimisticResponse(snapshot, { id }) {
const { data: post } = snapshot.getResponse(Base.get, { id });
if (!post) throw snapshot.abort;
return {
id,
votes: post.votes + 1,
};
}
After
getOptimisticResponse(snapshot, { id }) {
const post = snapshot.get(Post, { id });
if (!post) throw snapshot.abort;
return {
id,
votes: post.votes + 1,
};
}

由于我们不再需要访问其他 Resource 成员来获取 Post,就可以使用简单得多的 Resource.extend() 重载。

Before
export const PostResource = createResource({
path: '/posts/:id',
schema: Post,
}).extend(Base => ({
vote: new RestEndpoint({
path: '/posts/:id/vote',
method: 'POST',
body: undefined,
schema: Post,
getOptimisticResponse(snapshot, { id }) {
const { data: post } = snapshot.getResponse(Base.get, { id });
if (!post) throw snapshot.abort;
return {
id,
votes: post.votes + 1,
};
},
}),
}));
After
export const PostResource = createResource({
path: '/posts/:id',
schema: Post,
}).extend('vote', {
path: '/posts/:id/vote',
method: 'POST',
body: undefined,
schema: Post,
getOptimisticResponse(snapshot, { id }) {
const post = snapshot.get(Post, { id });
if (!post) throw snapshot.abort;
return {
id,
votes: post.votes + 1,
};
},
});

useQuery​

从 store 中渲染任意 Queryable Schema。 Query 是高效渲染聚合计算的绝佳搭档,例如那些使用 groupBy、 map、reduce 和 filter 的计算。#2921

class User extends Entity {
username = '';
id = '';
groupId = '';
pk() {
return this.id;
}
static index = ['username' as const];
}

const bob = useQuery(User, { username: 'bob' });
const getUserCount = new schema.Query(
new schema.All(User),
(entries, { isAdmin } = {}) => {
if (isAdmin !== undefined)
return entries.filter(user => user.isAdmin === isAdmin).length;
return entries.length;
},
);

const userCount = useQuery(getUserCount);
const adminCount = useQuery(getUserCount, { isAdmin: true });
const UserCollection = new schema.Collection([User], {
argsKey: (urlParams: { groupId?: string }) => ({
...urlParams,
}),
});

const usersInGroup = useQuery(UserCollection, { groupId: '5' });

性能​

对缓存系统以及所有关键代码路径的进一步优化,为最常见的操作带来了 2 倍的可观提升。再加上新增的查询缓存能力,我们的 Query 排序基准测试更是取得了令人印象深刻的 16 倍提升。

这些都叠加在原有的(最高 23 倍)相对旧版 normalizr 库的领先优势之上。

历史基准测试 | 查看基准测试

其他改进​

  • useCache() 接受带有 sideEffects 的 Endpoint(例如 Resource.update)#2963
    const lastCreated = useCache(MyResource.getList.push);
  • 允许 pk 返回数字 #2961
    Before
    class MyEntity extends Entity {
    id = 0;
    pk() {
    return `${this.id}`;
    }
    }
    After
    class MyEntity extends Entity {
    id = 0;
    pk() {
    return this.id;
    }
    }
  • 类型
    • 在使用宽松空值检查且没有 body 参数时,改进 .extend() 的类型推断 #2962
      Parameters<typeof new RestEndpoint({ path: '/test' }).extend({ path: '/test2' })> === []
    • Collection 默认的 Args 类型为:8377e
      | []
      | [urlParams: Record<string, any>]
      | [urlParams: Record<string, any>, body: any]
  • 修复
    • schema.All 反规范化的 INVALID 情况在生产构建中进行类名混淆时也应正常工作 #2954
      • 输入为 undefined 时,unvisit() 始终返回 undefined。
      • All 在 queryKey() 中返回 INVALID,以使此前 unvisit() 中的特殊情况(给定实体没有表项时)失效
    • 缺失的嵌套实体在出现后应该能够显示出来 #2956、#2961

迁移指南​

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

npm install --save @data-client/react@^0.11.0 @data-client/rest@^0.11.0 @data-client/test@^0.11.0 @data-client/img@^0.11.0 @data-client/hooks@^0.11.0

移除 Index​

直接在 useQuery 中使用 Entity 即可

Before
const UserIndex = new Index(User);

const bob = useCache(UserIndex, { username: 'bob' });
After
const bob = useQuery(User, { username: 'bob' });

new Query -> new schema.Query​

Query 现在是一个 schema;与 useQuery 搭配使用

Before
const getUserCount = new Query(
new schema.All(User),
(entries, { isAdmin } = {}) => {
if (isAdmin !== undefined)
return entries.filter(user => user.isAdmin === isAdmin).length;
return entries.length;
},
);

const userCount = useCache(getUserCount);
const adminCount = useCache(getUserCount, { isAdmin: true });
After
const getUserCount = new schema.Query(
new schema.All(User),
(entries, { isAdmin } = {}) => {
if (isAdmin !== undefined)
return entries.filter(user => user.isAdmin === isAdmin).length;
return entries.length;
},
);

const userCount = useQuery(getUserCount);
const adminCount = useQuery(getUserCount, { isAdmin: true });

new AbortOptimistic() -> snapshot.abort​

snapshot.abort 让你不再需要导入 AbortOptimistic #2957

Before
getOptimisticResponse(snapshot, { id }) {
const { data } = snapshot.getResponse(Base.get, { id });
if (!data) throw new AbortOptimistic();
return {
id,
votes: data.votes + 1,
};
}
After
getOptimisticResponse(snapshot, { id }) {
const { data } = snapshot.getResponse(Base.get, { id });
if (!data) throw snapshot.abort;
return {
id,
votes: data.votes + 1,
};
}

Entity.useIncoming → Entity.shouldUpdate​

让 Entity.shouldUpdate 的命名与 Entity.shouldReorder 保持一致 #2972

Before
class MyEntity extends Entity {
static useIncoming(
existingMeta: { date: number },
incomingMeta: { date: number },
existing: any,
incoming: any,
) {
return !deepEquals(existing, incoming);
}
}
After
class MyEntity extends Entity {
static shouldUpdate(
existingMeta: { date: number },
incomingMeta: { date: number },
existing: any,
incoming: any,
) {
return !deepEquals(existing, incoming);
}
}

useDLE() 在 React Native 中的焦点处理​

与 useSuspense() 和 useFetch() 一样,useDLE() 现在可能会在焦点事件发生时重新验证数据。

使用 React Navigation 时,如果数据被视为过时,useDLE() 会在获得焦点时触发获取。

这可能导致比以前更多的获取请求。

可能但不太会遇到的破坏性变更​

Schema.infer() -> Schema.queryKey() #2977​

大多数人都没有重写过 infer() 方法。如果你重写了,需要将其重命名为 queryKey(),并同时更新参数列表。

Before
class MyEntity extends Entity {
static infer(
args: readonly any[],
indexes: NormalizedIndex,
recurse: any,
entities: any,
): any {
if (SILLYCONDITION) return undefined;
if (entities[this.key]?.[someId]) return someId;
return super.infer(args, indexes, recurse, entities);
}
}
After
class MyEntity extends Entity {
static queryKey(
args: readonly any[],
queryKey: (...args: any) => any,
getEntity: GetEntity,
getIndex: GetIndex,
): any {
if (SILLYCONDITION) return undefined;
if (getEntity(this.key, someId)) return someId;
return super.queryKey(args, queryKey, getEntity, getIndex);
}
}
getEntity(key, pk?)​

传入一个参数时获取某一类型的全部实体,传入两个参数时获取单个实体

One argument
const entitiesEntry = getEntity(this.schema.key);
if (entitiesEntry === undefined) return INVALID;
return Object.values(entitiesEntry).map(
entity => entity && this.schema.pk(entity),
);
Two arguments
if (getEntity(this.key, id)) return id;
getIndex(key, indexName, value)​

返回索引条目(value->pk 的映射)

const value = args[0][indexName];
return getIndex(schema.key, indexName, value)[value];

pk 始终规范化为字符串类型 #2961​

这是对 store 中内容的一个细微变化。这很可能只在直接使用 @data-client/normalizr 时才有影响。

Before
{
"Article": {
"123": {
"author": 8472,
"id": 123,
"title": "A Great Article"
}
},
"User": {
"8472": {
"id": 8472,
"name": "Paul"
}
}
}
After
{
"Article": {
"123": {
"author": "8472",
"id": 123,
"title": "A Great Article"
}
},
"User": {
"8472": {
"id": 8472,
"name": "Paul"
}
}
}

state.results -> state.endpoints #2971​

这很可能只在直接使用 @data-client/core 时才有影响,因为内部状态的结构对 @data-client/react 来说是不透明的。

Before
{
"entities": {
"Article": {
"5": {
"content": "more things here",
"id": 5,
"title": "hi"
}
}
},
"results": {
"GET http://test.com/articles/5": "5"
}
}
After
{
"entities": {
"Article": {
"5": {
"content": "more things here",
"id": 5,
"title": "hi"
}
}
},
"endpoints": {
"GET http://test.com/articles/5": "5"
}
}

inferResults() -> memo.buildQueryKey() #2977、#2978​

这些方法由 @data-client/core 和 @data-client/normalizr 导出。

denormalizeCached() -> memo.denormalize() #2978​

由 @data-client/normalizr 导出

Before
const endpointCache = new WeakEntityMap();
const entityCache = {};
denormalizeCached(
input,
schema,
state.entities,
entityCache,
endpointCache,
args,
);
After
const memo = new MemoCached();
memo.denormalize(
input,
schema,
state.entities,
args,
);

WeakEntityMap -> WeakDependencyMap #2978​

由 @data-client/normalizr 导出

我们将这一数据类型泛化,使其可以用于其他依赖。

Before
new WeakEntityMap();
After
new WeakDependencyMap<EntityPath>();

升级支持​

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