- 后端
【免费下载链接】keystone
The superpowered headless CMS for Node.js — built with GraphQL and React
导读
在 Keystone 6 中,随着项目增长,多个列表(List)往往需要共享同一组"系统字段"——例如"谁在何时创建/更新了这条记录"。直接在每个列表里重复粘贴配置,会导致代码膨胀且难以维护。本文以仓库中的 examples/reuse 示例为蓝本,完整讲解如何把列表、字段、hooks 与权限配置抽象成可复用的 TypeScript 函数,并借助BaseListTypeInfo泛型约束实现类型安全的复用——避免"复用代码时常见类型退化成any"的问题。读完本文,你将掌握一套可立即落地的 Keystone 列表字段复用工程化方案。
示例项目概览:这个示例到底在做什么
examples/reuse示例的目标非常明确:演示如何复用 lists、fields、hooks 以及其他函数。示例作者在 README 中直言:
"Navigating the types of these primitives can be difficult without experience, so hopefully this project helps you understand how you can apply this to your project."
即:这些原语(primitives)的类型体系并不直观,该示例正是为那些希望在自己的项目中应用复用模式的开发者准备的。同时 README 也给出了一个重要提醒:
"Reuse is not encouraged typically, but it can be helpful in projects that have grown beyond having everything inline."
也就是说,Keystone 官方并不鼓励为了复用而复用——当项目还很小时,把所有配置内联(inline)写在schema.ts里反而更清晰;只有当项目已经成长到"全部内联"变得难以维护时,才值得引入本文介绍的抽取与复用模式。
该示例在仓库中的完整文件结构如下(examples/reuse):
| 文件 | 作用 |
|---|---|
schema.ts | 定义全部列表与可复用字段工厂函数(文章核心) |
keystone.ts | Keystone 入口配置:SQLite 数据库与 Prisma 适配器 |
package.json | 示例的脚本与依赖(dev/build/start/check) |
prisma.config.ts | Prisma 独立配置:schema 路径、migrations、datasource URL |
schema.prisma | 由 Keystone 自动生成的 Prisma 数据模型 |
schema.graphql | 由 Keystone 自动生成的 GraphQL Schema |
快速开始:克隆、安装与启动
按照 README 的说明,运行该示例需要先克隆 Keystone 仓库,在仓库根目录执行pnpm install安装依赖,然后进入示例目录启动开发服务:
# 在仓库根目录安装全部 workspace 依赖 pnpm install # 进入示例目录 cd examples/reuse # 启动 Keystone 开发服务器 pnpm devpnpm dev实际执行的命令是keystone dev(见 examples/reuse/package.json)。启动后:
- Admin UI运行在 localhost:3000,你可以直接在界面中向一个空数据库添加数据(例如新建 Invoice、Order、User);
- 当
NODE_ENV不等于production时,默认还可以在 localhost:3000/api/graphql 使用 GraphQL Playground 交互式查询与变更数据; - 数据库文件默认生成在
file:./keystone-example.db,可通过环境变量DATABASE_URL覆盖(见下方配置小节)。
package.json中还提供了其他常用脚本:
{ "scripts": { "dev": "keystone dev", "start": "keystone start", "build": "keystone build", "check": "keystone postinstall" } }其中keystone postinstall(pnpm check)用于生成 Prisma Client 等产物;keystone build会构建 Admin UI 静态资源。
入口配置:SQLite + Prisma BetterSQLite3 适配器
examples/reuse/keystone.ts 展示了现代 Keystone 6(配 Prisma 独立配置)的入口写法:
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3' import { config } from '@keystone-6/core' import { lists } from './schema' export default config({ db: { provider: 'sqlite', prismaClientOptions: () => ({ adapter: new PrismaBetterSqlite3({ url: process.env.DATABASE_URL || 'file:./keystone-example.db', }), }), // WARNING: this is only needed for our monorepo examples, don't do this }, lists, })要点说明:
provider: 'sqlite'声明数据库方言;示例目录没有独立的.env文件,数据库 URL 通过process.env.DATABASE_URL注入,缺省回退到file:./keystone-example.db;prismaClientOptions允许注入 Prisma 客户端适配器;这里使用@prisma/adapter-better-sqlite3提供驱动层实现;- 代码中的
WARNING注释明确提醒:prismaClientOptions这种写法仅仅是为本仓库 monorepo 示例而存在,在真实项目中请勿照搬,应直接使用标准的 Prisma 驱动配置。
与之配套的 examples/reuse/prisma.config.ts 是 Prisma 侧的独立配置文件:
import { defineConfig } from 'prisma/config' export default defineConfig({ schema: 'schema.prisma', migrations: { path: 'migrations', }, datasource: { url: process.env.DATABASE_URL || 'file:./keystone-example.db', }, })它声明了 Prisma schema 文件位置、migrations 目录以及数据源 URL——注意 URL 回退逻辑与keystone.ts保持一致,避免配置漂移。
核心模式:把系统字段抽取为工厂函数
examples/reuse的灵魂在 examples/reuse/schema.ts。示例把一组"审计/追踪字段"(createdBy、createdAt、updatedBy、updatedAt)抽成了一个函数trackingFields,让Invoice、Order两个列表通过展开运算符一次性获得这四个字段:
export const lists = { Invoice: list({ access: allowAll, fields: { title: text(), completed: checkbox(), ...trackingFields<Lists.Invoice.TypeInfo>(), }, }), Order: list({ access: allowAll, fields: { title: text(), completed: checkbox(), name: text(), ...trackingFields<Lists.Order.TypeInfo>(), }, }), // User、Unused 略 } satisfies Lists从生成的 examples/reuse/schema.prisma 可以看到,Invoice与Order两个 model 都平等地拥有了createdBy String、createdAt DateTime?、updatedBy String、updatedAt DateTime?四列——这证明字段工厂函数被正确地"展开"进了每一个列表:
model Invoice { id String @id @default(cuid()) title String @default("") completed Boolean @default(false) createdBy String @default("") createdAt DateTime? updatedBy String @default("") updatedAt DateTime? } model Order { id String @id @default(cuid()) title String @default("") completed Boolean @default(false) name String @default("") createdBy String @default("") createdAt DateTime? updatedBy String @default("") updatedAt DateTime? }同时,在 examples/reuse/schema.graphql 中,Invoice与Order的 GraphQL 输出类型也同步包含createdBy: String、createdAt: DateTime、updatedBy: String、updatedAt: DateTime字段,且这些字段不会出现在 create/update 输入类型中(因为系统字段通常不允许调用方直接写入,见下文systemField的graphql.omit配置)。
类型安全的诀窍:BaseListTypeInfo泛型约束
复用代码最容易踩的坑是类型丢失:一旦把字段配置抽成普通函数,TypeScript 常常把类型推断成宽泛的BaseListTypeInfo,导致resolvedData、item里的字段全部变成"不可知"的类型,写 hooks 时毫无提示、容易写错。
examples/reuse给出了优雅的解法:先定义CompatibleLists接口,把复用代码真正依赖的字段显式声明出来:
type CompatibleLists = BaseListTypeInfo & { item: { completed: boolean } }然后让工厂函数以该约束为下限的泛型参数定义:
function trackingFields<ListTypeInfo extends CompatibleLists>() { ... }这里的关键是extends CompatibleLists:它要求调用方传入的ListTypeInfo至少具备item.completed: boolean,同时保持自身的全部真实类型信息。BaseListTypeInfo的定义位于 packages/core/src/types/type-info.ts,其item字段对应列表项的基本类型。
示例中有一段特意用来"演示类型精确性"的辅助函数:
// we use this function to show that completed is a boolean type // which would be missing if the types were unrefined // a common problem when re-using code function isTrue(b: boolean) { return b === true }它被用在updatedBy字段的resolveInputhook 中:
updatedBy: text<ListTypeInfo>({ ...systemField, hooks: { async resolveInput({ context, operation, resolvedData, item, fieldKey }) { // show we have refined types for compatible item.* fields if (isTrue(item?.completed ?? false) && resolvedData.completed !== false) return undefined ... }, }, }),注意item?.completed和resolvedData.completed之所以能直接传给isTrue(b: boolean),正是因为CompatibleLists约束声明了item.completed: boolean;如果没有这层类型精化,item.completed会是unknown或宽泛类型,isTrue调用处就会报类型错误——这正是"复用代码时常见问题"的活教材。
而User、Unused这类列表没有completed字段,因而不能调用trackingFields(类型上不允许),从类型层面杜绝了把不兼容的字段工厂误用到不匹配的列表上。
从源码注释还可以看到作者标注的 TODO:
FIXME: CommonFieldConfig need not always be generalised,提示字段工厂返回类型的泛化程度仍可进一步优化——这属于示例中的开放边界,读者在实际项目中可按需自行收敛。
systemField:字段级访问控制与 UI 行为的统一抽取
除了 hooks,示例还把字段级的access(访问控制)与UI 表现抽成了公共对象systemField,并通过...systemField展开进每个系统字段:
const systemField = { access: { read: { item: allowAll, filter: denyAll, order: denyAll }, create: denyAll, update: denyAll, }, graphql: { omit: { create: true, update: true, }, }, ui: { createView: { fieldMode: 'hidden' as const }, itemView: { fieldMode: 'read' as const, fieldPosition: 'sidebar' as const, }, listView: { fieldMode: 'read' as const }, }, }逐项解读其语义:
- access:
read拆成三项——item(读具体项)放行(allowAll)、filter(按字段过滤)与order(按字段排序)拒绝(denyAll);create、update全部拒绝(denyAll)。也就是说,调用方无法主动创建或修改这些系统字段,只能通过 hooks 写入; - graphql.omit:在 create/update 的 GraphQL 输入类型中隐藏这些字段(对应 examples/reuse/schema.graphql 中
InvoiceCreateInput只有title、completed的现象); - ui:创建视图中隐藏、详情视图中只读且置于侧栏、列表视图中只读,从而避免在 Admin UI 里误编辑系统字段。
allowAll/denyAll的底层实现非常直接,见 packages/core/src/access.ts:
export function allowAll() { return true } export function denyAll() { return false }它们是返回布尔值的函数,配合 Keystone 的访问控制引擎使用——在 packages/core/src/lib/core/access-control.ts 中,各操作的默认值即为allowAll(如query: allowAll、create: allowAll),而字段断言逻辑会检查field.access.read.item !== allowAll等(见 packages/core/src/lib/core/field-assertions.ts),以保证自定义 access 不会与框架假设冲突。
用 hooks 实现"自动审计":createdBy / createdAt / updatedBy / updatedAt
trackingFields中四个字段的 hooks 组合起来,就构成了一套完整的"谁在何时改了什么"的自动审计逻辑:
createdBy: text<ListTypeInfo>({ ...systemField, hooks: { resolveInput: { async create({ context }) { return `${context.req?.socket.remoteAddress} (${context.req?.headers['user-agent']})` }, async update() { return undefined }, }, }, }),createdBy:仅在create时写入"客户端 IP + User-Agent"字符串(从context.req读取),update时不改变;createdAt:仅在create时写入new Date(),update时返回undefined保持不变;updatedBy:在resolveInput完整回调中做条件判断——若item.completed为 true 且本次更新没有把completed改为 false,则不更新(返回undefined),否则记录 IP + User-Agent;updatedAt:任何写入操作(create/update)都刷新为new Date()。
updatedBy: text<ListTypeInfo>({ ...systemField, hooks: { async resolveInput({ context, operation, resolvedData, item, fieldKey }) { if (isTrue(item?.completed ?? false) && resolvedData.completed !== false) return undefined return `${context.req?.socket.remoteAddress} (${context.req?.headers['user-agent']})` }, }, }),这段updatedBy逻辑同时演示了resolveInput完整形态的四个关键参数:
| 参数 | 含义 |
|---|---|
context | Keystone 请求上下文,可访问req、数据库等 |
operation | 当前操作类型(create / update) |
resolvedData | 本次写入的已解析数据(含本次提交的字段值) |
item | 已存在的数据库记录(update 时有值,create 时通常为空) |
fieldKey | 当前字段的 key |
注意context.req可能为 undefined(例如脚本或测试环境),示例中统一用可选链?.安全访问。
验证复用成果:看生成的 Schema
examples/reuse目录下的schema.prisma与schema.graphql都是由 Keystone 自动生成的产物,可用于验证复用配置是否正确落地:
- 从 examples/reuse/schema.prisma 看,
Invoice、Order都含四个追踪列,而User(只有name)与Unused(只有completed)则没有——说明字段工厂只影响被调用的列表; - 从 examples/reuse/schema.graphql 看,
InvoiceCreateInput/InvoiceUpdateInput只暴露title、completed,追踪字段不出现在输入类型中(graphql.omit生效),但在Invoice输出类型中完整可见;Mutation类型中每个列表都自动获得了create/update/delete及其复数批量形式(如createInvoices、updateInvoices、deleteInvoices); Unused列表在 GraphQL 中同样拥有完整的 CRUD(createUnused、updateUnused、deleteUnused),演示了一个"仅做演示用途"的列表也可以被正常生成。
复用边界:什么时候该用,什么时候不该用
结合 README 的告诫与示例代码,可以总结出几条实践准则:
- 先内联,后抽取:项目初期把所有字段配置写在列表内,等出现"两处以上完全一致的字段+ hooks+权限"且修改成本变高时,再考虑抽取;
- 用泛型约束守住类型边界:工厂函数必须像
trackingFields<ListTypeInfo extends CompatibleLists>这样声明它依赖的字段类型,避免退化为any或宽泛类型,让 hooks 中的item、resolvedData保持可提示、可校验; - 把"行为"和"表现"一起抽:示例把 access、graphql.omit、ui 表现统一放进
systemField并随字段一起展开,确保复用的不仅是字段定义,还有完整的权限与 UI 语义; - 善用生成产物验证:每次调整复用代码后,重新运行
keystone build或pnpm check并检查schema.prisma/schema.graphql,确认字段、权限、GraphQL 输入输出符合预期。
小结
examples/reuse演示的复用模式,本质上是把 Keystone 的字段配置对象当作一等公民进行组合:用展开运算符做字段合并、用泛型参数做类型精化、用systemField做行为封装、用resolveInputhooks 做自动审计。它没有引入任何黑魔法,全部建立在 Keystone 6 公开的类型体系(BaseListTypeInfo、字段工厂的泛型参数)之上,因此可以直接迁移到任何基于@keystone-6/core的项目中。对于已经"长大"、需要系统化组织列表配置的 Keystone 项目,这套模式是一个高性价比的工程化参考。
- 后端
【免费下载链接】keystone
The superpowered headless CMS for Node.js — built with GraphQL and React
相关推荐
Keystone 6 Multiselect 字段完整指南:类型、选项与 GraphQL 集成实战
Keystone 6 Multiselect 字段完整指南:类型、选项与 GraphQL 集成实战 导读 multiselect 是 Keystone 内置的“
后端@langchain/cloudflare 版本演进详解:从原生 streamEvents 转换到 AbortSignal 处理与兼容性修复
@langchain/cloudflare 版本演进详解:从原生 streamEvents 转换到 AbortSignal 处理与兼容性修复 @langchai
后端终极指南:如何用Gopeed全平台高速下载器告别下载烦恼
终极指南:如何用Gopeed全平台高速下载器告别下载烦恼 还在为下载大文件时速度缓慢而烦恼吗?是否经常遇到网络不稳定导致下载中断,不得不重新开始的困扰?Gope
网络CLI后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考