ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

urql Graphcache 规范化缓存(Normalized Caching)深入指南

urql Graphcache 规范化缓存(Normalized Caching)深入指南 前端【免费下载链接】urqlThe highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.项目地址https://gitcode.com/gh_mirrors/ur/urql点击查看免费下载GraphQL 查询天然以去规范化的树形 JSON 返回数据而 urql 的 Graphcacheurql/exchange-graphcache通过__typename与可键字段把这些 JSON 重新归一化为内存中的关系型表让多个查询、变更与订阅之间可以共享实体、互相更新。本文将以 docs/graphcache/normalized-caching.md 为主线结合 cacheExchange.ts、store.ts、keys.ts、write.ts 等源码讲透规范化缓存的原理、键生成、自定义键、resolvers/updates配置以及确定性的层layer机制让你既能配置又能理解其内部实现。GraphQL 数据的去规范化与再规范化GraphQL 名称本身暗示了其数据的关系本质我们针对Query根类型编写查询沿着关系类型构成的图一路遍历。与查询规范化的关系型数据不同GraphQL 查询请求的是特定形状的去规范化数据——它是关系数据的一个视图可以被自动重新规范化。当 GraphQL API 沿查询文档执行时它可能从关系型数据库中读取数据把**实体entity**与标量值拷贝到与查询文档匹配的 JSON 文档中。不过实体的类型信息并未丢失查询文档可以通过__typename字段动态自省实体的类型。因此 GraphQL 客户端可以利用__typename字段以及id或_id这类可键字段这在 GraphQL schema 中已是常见惯例在结果返回时自动重新规范化数据。换言之规范化缓存可以在内存中为应用构建一张关系型数据库的表。对应用而言规范化缓存能支撑更复杂的场景不同的 API 请求在应用的其他部分更新数据而我们在查询 GraphQL API 时缓存会自动更新。当跨多个查询、变更或订阅检测到关系数据时规范化缓存本质上可以让 UI 保持最新状态。规范化关系数据从 JSON 到实体键GraphQL schema 构成一棵类型树应用数据总是从Query根类型出发并由来自Mutation或Subscription选择集的数据所修改。从Query类型查询的所有数据都包含实体之间的关系——即层级化的 JSON 对象。规范化缓存的目标是把这份去规范化的 JSON 还原成关系型数据结构所有实体按可直达查找的键存储。由于 GraphQL 文档给了 API 关于如何遍历 schema 的严格规范缓存从 API 接收的 JSON 数据永远与查询它的 GraphQL 查询文档相匹配。这里有一个常见误解规范化缓存并非按查询文档存储数据它唯一关心的是能否用查询文档来遍历收到的 JSON 结构。{ __typename todo(id: 1) { __typename id title author { __typename id name } } }{ __typename: Query, todo: { __typename: Todo, id: 1, title: implement graphcache, author: { __typename: Author, id: 1, name: urql-team } } }以上是一份 GraphQL 查询文档与对应的 API JSON 结果。GraphQL 中我们永远不会丢失数据底层的类型信息——规范化缓存可以自动在选择集中请求__typename字段从而得知每个 JSON 对象对应的类型。面对上述查询文档规范化缓存必须做两件事遍历查询文档与结果的 JSON 数据并缓存数据在过程中规范化存入关系型表之后能够再次遍历查询文档仅通过读取缓存内存关系型表中的数据重建这份 JSON。虽然规范化缓存无法确切知道每个字段的类型但借助 GraphQL 查询语言它可以做几个假设它可以遍历查询文档——没有选择集的字段如上例的title必须是记录record即只能取标量值的字段有选择集的字段必须是另一个实体或实体列表。后者正是实体间的关系如同关系型数据库中的外键。随后规范化缓存读取相关实体上的__typename字段这被称为类型名自省Type Name Introspection。从上文文档我们可以推断出以下关系Query.todo(id: 1)→TodoTodo.author→Author但这还不足以存储关系规范化缓存还必须为每个实体生成主键才能存进表状结构。这正是 Relay 强制要求每个实体必须有id字段的原因——它借此假定每个查询到的实体都有显然的主键。而 urql 的 Graphcache 与 Apollo 则假定给定选择集中可能存在id或_id字段如果 Graphcache 找不到这两个字段会发出警告不过可以通过自定义keys配置为指定类型生成自定义键。基于此逻辑规范化缓存实际会建立如下链接Query.todo(id: 1)→Todo:1Todo:1.author→Author:1可以看到Query根类型本身有恒定键Query——所有关系数据都源于此处因为 GraphQL schema 是一张图而所有查询文档的选择集都像树的根一样从这里出发。内部地规范化缓存按实体的主键存储字段值即Query实体的todo字段参数为{id: 1}指向Todo:1实体Todo:1实体的author字段指向Author:1实体。在 Graphcache 中这些链接按实体存储在嵌套结构中记录则与关系数据分开存放。源码印证键与字段键的生成实体键与字段键的生成逻辑位于 keys.tskeyOfField(fieldName, args)用${fieldName}(${stringifyVariables(args)})拼接字段键例如todo({id:1})——stringifyVariables会对参数对象的键排序保证 JSON 键稳定joinKeys(parentKey, key)生成Todo:1.image这类嵌入式键serializeKeys在持久化时把实体键中的.转义为%2e避免歧义。实体键的判定在 store.ts 的keyOfEntity方法中若类型在keys配置中则使用自定义键函数否则回退到id或_id字段根类型Query/Mutation/Subscription直接以其类型名作为键。非生产环境下还会通过expectValidKeyingConfig等校验见 store.ts。存储规范化数据links 与 records 双表结构规范化的核心是把各个字段分别存入表中。在 Graphcache 中我们把所有字段值存储在一个以主键由 ID 或其他键与类型名生成为键、以字段名非别名与可选参数为条目的字典中| 主键 | 字段 | 值 | | ---- | ---- | -- | | 类型名与 IDKey | 字段名非别名及可选参数 | 标量值或关系 |三块信息被存入表中实体键可由__typename字段与可键字段推导。默认 Graphcache 检查id与_id字段但这是可配置的字段名与可选参数如todo。字段带参数时通过对参数做 JSON 字符串化来规范化并对键排序以保证 JSON 键稳定关系值要么是null要么是指向另一实体的主键要么是主键列表而记录的标量值存放在独立的表中。Graphcache 中 links 表的结构大致如下每个实体拥有一张从字段到其他实体键的映射{ links: Map { Query: Record { todo({id:1}): Todo:1 }, Todo:1: Record { author: Author:1 }, Author:1: Record { }, } }可以看到规范化缓存如何从Query实体出发、沿查询文档遍历并取得其他字段的关系。而为了取回记录所有无选择集的标量字段Graphcache 维护了第二张结构相同的表只存放标量值把非关系数据与链接隔离{ records: Map { Query: Record { __typename: Query }, Todo:1: Record { __typename: Todo, id: 1, title: implement graphcache }, Author:1: Record { __typename: Author, id: 1, name: urql-team }, } }这和我们手动编写状态管理 store 的方式非常相似区别在于Graphcache 能借助 GraphQL 文档自动完成规范化。注意字段键中的参数使用 JSON 字符串化键排序后例如todo({id:1})其实现对应 keys.ts 的keyOfField。源码印证双表与引用计数内存数据结构定义在 data.tsrecords与links均为NodeMap包含base基础层与optimistic乐观层另有refCount引用计数、types类型到实体键集合的映射以及gc垃圾回收集合。writeRecord/writeLinkdata.ts在写入值变化时记录依赖并标记持久化writeLink还会通过updateRCForLink维护引用计数data.ts当某个实体不再被任何链接引用时进入gc集合由 gc() 在延迟任务中清理。规范化带来什么收益我们得到一种既可读又可写的数据结构用于为 GraphQL 查询文档重现 API 结果。任何变更mutation或订阅subscription的结果也能写入该结构——一旦 Graphcache 在其结果中发现可键实体就写入关系表这可能更新应用中的其他查询。同样查询之间也可以共享数据即借助该结构共享实体并互相更新。一旦我们有了Todo:1这样的主键就可能在其他 GraphQL 结果的其他实体中再次找到它。自定义键与不可键实体前面已提到Graphcache 不强制每个实体都有id字段但默认会检查id与_id字段。许多场景下实体要么没有键字段要么使用不同的键。当 Graphcache 遍历 JSON 数据与查询文档写缓存时你可能会看到类似 Invalid key: [...] No key could be generated for the data at this field. 的警告对应错误清单第 15 条Invalid key详见 errors.md。Graphcache 有许多此类警告用于探测异常行为并帮助调整配置或查询。最简单的情况可能只是忘了在查询文档的选择集中加入id字段。但如果字段叫uuid呢{ item { uuid } }上面的选择集中item字段带的是uuid而非idGraphcache 无法自动为该实体生成主键。此时需要传入自定义keys配置帮它生成键cacheExchange({ keys: { Item: data data.uuid, }, });keys配置的每个条目是一个函数属性名Item必须是生成键的实体的 typename函数可以返回任意生成的键。于是对于我们的item字段示例 schema 中返回Item实体可以创建从uuid字段而非id字段生成键的keys条目。不可键数据嵌入式实体的默认行为那么问题来了Graphcache 默认如何处理不可键数据数据没有键时怎么办这个特例称为嵌入式数据embedded data。并非 schema 中的所有类型都有可键字段有些类型只是抽象数据、本身不是关系型的比如边Edge指向其他实体的连接型实体、GeoJson或Image这类数据类型。当规范化缓存遇到不可键类型时它会用父实体的主键与该字段键组合生成嵌入式键。这意味着嵌入式实体只能从父实体的特定字段到达它们全局唯一严格来说不算关系数据。{ __typename todo(id: 1) { id image { url width height } } }上例中我们在Todo上查询Image类型。这个假想的Image类型没有键因为图片是嵌入式数据只与这个Todo关联——API 的 schema 认为该类型不需要主键字段它甚至可能在后端数据库中没有 ID。我们可以给这个类型虚构一个键比如基于url但如果它不是共享数据这样做意义不大。当 Graphcache 尝试存储该实体时会发出前面提到的警告并在内部基于父实体生成嵌入式键若父实体键为Todo:1则Image的嵌入式键变为Todo:1.image这正是 keys.ts 中joinKeys的产物。该实体在内部存储如下{ records: Map { Todo:1.image: Record { __typename: Image, url: ..., width: 1024, height: 768 }, } }但这并不会让 Graphcache 静默那条警告因为它认为我们可能犯了错。警告本身会给出静默建议如果这是有意为之为Image创建一个始终返回 null 的 keys 配置。也就是说可以为不可键类型添加一个显式返回null的keys条目告诉 Graphcache 该实体没有键cacheExchange({ keys: { Image: () null, }, });这一行为的源码依据位于 write.tsKEYLESS_TYPE_RE /^__|PageInfo|(Connection|Edge)$/先豁免了PageInfo、*Connection、*Edge等几乎不可能可键的类型其余类型若keyOfEntity返回null则发出警告 15 并以parentFieldKey父键 字段键作为子键写入。灵活生成键Proxy 动态键函数有时你可能想为键生成建立一种模式。比如想为每个以Node结尾的类型创建特殊键。这种情况下推荐使用一个小型 JSProxy来替你生成键让键函数化cacheExchange({ keys: new Proxy( { Image: () null, }, { get(target, prop, receiver) { if (prop.endsWith(Node)) { return data data.uid; } const fallback data data.uuid; return target[prop] || fallback; }, } ), });上述示例中我们根据 typename 动态改变键生成器当 typename 以Node结尾时返回使用uid字段的键生成器仍然回退到手动键生成函数的对象最后当类型没有预定义键生成器时把默认行为从使用id/_id字段改为使用uuid字段。非自动关系与更新resolvers 与 updatesGraphcache 能够在内存关系型结构中存储并更新实体让同一实体始终位于唯一位置但 GraphQL API 在运行中可能对数据关系做大量隐式变更或存在缓存无需解析的琐碎关系。与keys配置一样还有两个配置项应对此resolvers与updates。手动解析实体resolvers某些字段可以不查询 GraphQL API就解析出关系。resolvers配置允许创建一组客户端解析器在 Graphcache 从缓存数据组装本地 GraphQL 结果时直接读取缓存。{ todo(id: 1) { id } }前面我们用上面的查询演示 API 数据如何写入 Graphcache 的关系结构。但另一个查询可能早已把Todo实体写进缓存。那么如何手动解析一个关系这种情况下Graphcache 可能见过并存储了Todo实体却不知道Query.todo({id:1})与Todo:1实体之间的关系。我们可以为Query.todo字段创建解析器告诉 Graphcache 访问该字段时应查找哪个实体cacheExchange({ resolvers: { Query: { todo(parent, args, cache, info) { return { __typename: Todo, id: args.id }; }, }, }, });解析器是类似 GraphQL.js 服务端解析器。由于能访问查询文档中的字段参数可以返回一个部分Todo实体只要该对象可键它就会告诉 Graphcache 返回实体的键是什么——即我们告诉了它如何从Query.todo字段抵达一个Todo。这一机制远比示例强大解析器还有其他用途作用于记录字段可以更改或转换标量值例如在解析器内部更新字符串或解析Date返回深层嵌套结果结果会叠加在 Graphcache 的内存关系缓存数据之上可以模拟无限分页等复杂行为改变缓存命中/未命中语义返回null表示字段值真的是null不会触发缓存未命中返回undefined表示字段值未缓存返回部分实体或键可以链式调用cache.resolve读取缓存字段即使字段指向另一实体——因为可以直接返回指向该实体的键。关于 Local Resolvers 的更多内容见下页。手动缓存更新updatesresolvers在 Graphcache读取缓存时起作用而updates是写入缓存时生效的配置项。具体来说这些函数用于在Mutation或Subscription的自动更新之上追加更多更新。如前所述发送Mutation或Subscription时schema 数据可能经历大量隐式变更新建的条目可能改动完全不同的条目甚至列表。变更与订阅经常改动其选择集未必看得到的关系。由于变更与订阅作用于不同的根类型而非Query根类型我们常常需要在变更执行后更新其余数据中的链接。query TodosList { todos { id title } } mutation AddTodo($title: String!) { addTodo(title: $title) { id title } }上面是个简单例子查询中有一个 todos 列表通过Mutation.addTodo创建新 todo。变更执行并返回结果后Graphcache 已经把Todo条目写入规范化缓存。但我们还想把新Todo加入Query.todos列表import { gql } from urql/core; cacheExchange({ updates: { Mutation: { addTodo(result, args, cache, info) { const query gql { todos { id } } ; cache.updateQuery({ query }, data { data.todos.push(result.addTodo); return data; }); }, }, }, });这段代码中updates条目的签名与resolvers非常相似但首次见到了cache的用法。cache对象API 文档中的完整说明让我们直接访问 Graphcache 的机制不仅能解析数据还能手动启动子查询或子写入——这些是在其他运行内部执行的完整规范化缓存运行。此处我们在Mutation添加Todo的变更写入缓存的同时对Todo列表调用cache.updateQuery。可见我们可以在updates函数内执行手动更改影响缓存的其他部分如这里的Query.todos这超出了规范化缓存预期的自动更新范围。在更新函数中我们能拿到cache.updateQuery、cache.writeFragment、cache.link等方法——这些在本地解析器中不可用只能在updates条目中使用以改变缓存持有的数据。关于编写缓存更新的更多内容见 Cache Updates 页。源码印证updates 的执行时机在 write.ts 中可以看到每个字段在完成默认的规范化写入后会查找updates[typename][fieldName]并执行更新函数若字段属于Mutation根类型且没有配置 updaterGraphcache 会执行创建变更的兜底逻辑当返回的实体在缓存中找不到引用计数为 0时invalidateType会使同__typename的已缓存实体失效触发相关查询重新请求cache-updates.md 的 Default mutation invalidation 一节 对此有专门说明。一旦为某变更字段定义了 updater该兜底行为即被取代完全由 updater 控制写入后的缓存变化。确定性的缓存更新层Layer与交换性前面在存储规范化数据一节谈到 Graphcache 如何存储规范化数据。然而除了存储许多应用在实现存储时常常忽略、跳过或简化一些注意事项。除了乐观更新与离线支持等特性外Graphcache 还支持多个特性来容忍 API 结果的不可靠。本质上我们不指望 API 结果总是按顺序、准时返回而是期望 Graphcache 防止我们做出不确定的缓存更新——即优雅地处理乱序、延迟返回的 API 结果。就前述的手动缓存更新与乐观更新而言限制起初很简单正常使用 Graphcache 时甚至注意不到做乐观变更时我们定义变更在 API 未来响应时可能呈现的结果并立即应用这个临时结果。临时数据存放在独立的层中真实结果返回后该层被删除真实 API 结果照常应用同时进行多个乐观更新时绝不允许这些层被单独删除。Graphcache 会等待所有变更完成后才删除乐观层并应用真实 API 结果确保某次变更更新不会意外把乐观数据永久提交进缓存乐观更新生效期间Graphcache 会停止重新请求包含该乐观数据的查询避免它闪回到未应用乐观更新的非乐观状态否则 UI 会出现闪烁。这三条原则是 Graphcache 的基本机制总结为Graphcache 将乐观变更分组并暂停查询使乐观更新看起来符合预期——这是使用时可忽略的实现细节。然而有一个实现细节不能忽略即最后一个机制——交换性Commutativity。乐观更新需要把规范化结果存放在独立层中这意味着前面看到的数据结构实际上更像一个列表包含多张 links 与实体表。每一层可能包含乐观结果且具有优先级顺序。但这个顺序同样适用于查询查询按某顺序发起而 API 结果却可能以完全不同的顺序返回。如果在慢速网络连接的应用中结果可能取决于它们返回的时机。Graphcache 实际为收到的任何 API 结果都使用层当 API 结果乱序到达时按优先级——或更准确地说按它们被请求的先后——排序。总体而言我们无需为此操心Graphcache 有机制保证更新安全。源码印证layer 的预占与合并cacheExchange.ts实际位于 cacheExchange.ts的prepareForwardedOperation在转发前为每个查询操作reserveLayer(store.data, operation.key)cacheExchange.ts预先占位结果层乐观变更则把依赖加入blockedDependencies并暂存于mutationResultBuffer直到所有在途变更完成才统一updateCacheWithResult并批量重放cacheExchange.ts。层的创建、reserveLayer预占、squashLayer合并与clearLayer清理都在 data.ts 中实现optimisticOrder数组维护各层的优先级顺序commutativeKeys/dirtyKeys记录哪些层已被写入且可安全合并。查询结果乱序时initDataState会根据是否交换性键决定在哪个层读写data.ts。阅读延伸以上是对 Graphcache 的入门介绍它如何工作、支持什么、以及一些隐藏机制与内部细节。接下来可以进一步了解如何上手使用与更多特性如何编写 Local Resolvers如何设置 Cache Updates 与 Optimistic UpdatesGraphcache 的 Schema Awareness 特性有何用途如何开启 Offline SupportGraphcache 的全部警告与错误解释Graphcache 的 API 参考cache、resolvers、updates 等选项赞分享前端【免费下载链接】urqlThe highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.项目地址https://gitcode.com/gh_mirrors/ur/urql点击查看免费下载相关推荐Graphcache实战urql normalized caching插件使用指南与最佳实践Graphcache实战urql normalized caching插件使用指南与最佳实践 GraphQL应用开发中数据缓存始终是提升性能的关键环节。当你前端urql 规范化缓存实战urql/exchange-graphcache 接入、配置与源码级原理urql 规范化缓存实战urql/exchange graphcache 接入、配置与源码级原理 urql/exchange graphcache 是 u前端urql Graphcache 完全指南规范化缓存、自定义解析器与离线支持urql Graphcache 完全指南规范化缓存、自定义解析器与离线支持 Graphcache urql/exchange graphcache 是前端上一篇Dinky项目在Kubernetes集群中的部署指南下一篇Python性能分析利器line_profiler揭秘代码瓶颈的终极指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表