FormKit 框架的核心是 @formkit/core
。这个零依赖的包负责 FormKit 几乎所有的底层关键功能,例如:
FormKit 核心的功能不是通过一个集中的实例暴露给你的应用程序,而是通过一组分布式的“节点”(FormKitNode
),其中每个节点代表一个单独的输入。
这反映了 HTML — 实际上 DOM 结构就是一个通用树,FormKit 核心节点反映了这种结构。例如,一个简单的登录表单可以绘制为以下树状图:
在这个图表中,一个 form
节点是三个子节点的父节点 — email
、password
和 submit
。图中的每个输入组件“拥有”一个 FormKit 核心节点,每个节点包含自己的选项、配置、属性、事件、插件、生命周期钩子等。这种架构确保了 FormKit 的主要功能与渲染框架(Vue)解耦 — 这是减少副作用和保持极快性能的关键。
此外,这种去中心化的架构允许极大的灵活性。例如 — 一个表单可以使用与应用程序中其他表单不同的插件,一个组输入可以修改其子输入的配置,甚至可以编写验证规则以使用另一个输入的属性。
每个 <FormKit>
组件拥有一个单独的核心节点,每个节点必须是以下三种类型之一:
核心节点始终是三种类型之一(输入、列表或组)。这些不同于输入类型 — 其中可以有无限的变化。严格来说,所有输入都有2种类型:它们的节点类型(如 input
),以及它们的输入类型(如 checkbox
)。
FormKit 的大多数原生输入都有一个 input
类型的节点 — 它们操作单个值。值本身可以是任何类型,如对象、数组、字符串和数字 — 任何值都是可以接受的。然而,input
类型的节点总是叶子 — 意味着它们不能有子节点。
import { createNode } from '@formkit/core'
const input = createNode({
type: 'input', // 如果未指定,默认为 'input'
value: 'hello node world',
})
console.log(input.value)
// 'hello node world'
列表是一个生成数组值的节点。列表节点的子节点生成列表数组值中的一个值。直接子节点的名称被忽略 —— 每个子节点都被分配一个列表数组中的索引。
import { createNode } from '@formkit/core'
const list = createNode({
type: 'list',
children: [
createNode({ value: 'paprika@example.com' }),
createNode({ value: 'bill@example.com' }),
createNode({ value: 'jenny@example.com' }),
],
})
console.log(list.value)
// ['paprika@example.com', 'bill@example.com', 'jenny@example.com']
组是一个生成对象值的节点。组节点的子节点使用它们的 name
来在组的值对象中生成同名的属性 —— <FormKit type="form">
是组的一个实例。
import { createNode } from '@formkit/core'
const group = createNode({
type: 'group',
children: [
createNode({ name: 'meat', value: 'turkey' }),
createNode({ name: 'greens', value: 'salad' }),
createNode({ name: 'sweets', value: 'pie' }),
],
})
console.log(group.value)
// { meat: 'turkey', greens: 'salad', sweets: 'pie' }
除了在调用 createNode()
时指定节点的 type
,你还可以传递以下任何选项:
选项 | 默认值 | 描述 |
---|---|---|
children | [] | 子 FormKitNode 实例。 |
config | {} | 配置选项。这些成为 props 对象的默认值。 |
name | {type}_{n} | 节点/输入的名称。 |
parent | null | 父 FormKitNode 实例。 |
plugins | [] | 插件函数数组。 |
props | {} | 代表当前节点实例详情的键/值对对象。 |
type | input | 要创建的 FormKitNode 类型(list 、group 或 input )。 |
value | undefined | 输入的初始值。 |
FormKit 使用基于继承的配置系统。在 config
选项中声明的任何值都会自动传递给该节点的子节点(以及所有后代),但不会传递给兄弟节点或父节点。每个节点可以通过提供自己的配置来覆盖其继承的值,这些值将依次被任何更深层的子节点和后代继承。例如:
const parent = createNode({
type: 'group',
config: {
color: 'yellow',
},
children: [
createNode({
type: 'list',
config: { color: 'pink' },
children: [createNode(), createNode()],
}),
createNode(),
],
})
上面的代码将导致每个节点具有以下配置:
最佳实践是从 node.props
而不是 node.config
读取配置值。下一节将详细介绍这个功能。
node.props
和 node.config
对象密切相关。node.config
最好被认为是 node.props
的初始值。props
是一个任意形状的对象,包含了关于节点当前 实例 的详细信息。
最佳实践是始终从 node.props
读取配置和属性数据,即使原始值是使用 node.config
定义的。显式定义的属性优先于配置选项。
const child = createNode({
props: {
flavor: 'cherry',
},
})
const parent = createNode({
type: 'group',
config: {
size: 'large',
flavor: 'grape',
},
children: [child],
})
console.log(child.props.size)
// 输出:'large'
console.log(child.props.flavor)
// 输出:'cherry'
当使用 <FormKit>
组件时,为输入 type
定义的任何属性都会自动设置为 node.props
属性。例如:<FormKit label="Email" />
会导致 node.props.label
变为 Email
。
您可以通过在 createNode()
上提供 value
选项来设置节点的初始值 — 但 FormKit 是关于交互性的,那么我们如何更新已定义节点的值呢?通过使用 node.input(value)
。
import { createNode } from '@formkit/core'
const username = createNode()
username.input('jordan-goat98')
console.log(username.value)
// 未定义 👀 等等 — 什么!?
在上面的例子中,即使设置了 username.value
,它仍然是未定义的,因为 node.input()
是异步的。如果您需要在调用 node.input()
后读取结果值,您可以等待返回的 promise。
import { createNode } from '@formkit/core'
const username = createNode()
username.input('jordan-goat98').then(() => {
console.log(username.value)
// 'jordan-goat98'
})
因为 node.input()
是异步的,我们的表单的其余部分不需要在每次按键时重新计算其依赖关系。它还提供了在值“提交”到表单其余部分之前对未定值进行修改的机会。然而 — 仅供内部节点使用 — 一个 _value
属性也可用,包含输入的未定值。
您不能直接赋值输入 node.value = 'foo'
。相反,您应该始终使用 node.input(value)
现在我们知道了 node.input()
是异步的,让我们探讨一下 FormKit 是如何解决“已结算树”问题的。想象一下,用户快速输入他们的电子邮件地址并迅速按下“回车”键 —— 从而提交表单。由于 node.input()
是异步的,很可能会提交不完整的数据。我们需要一种机制来知道整个表单何时“已结算”。
为了解决这个问题,FormKit 的节点会自动跟踪树、子树和节点的“干扰”。这意味着表单(通常是根节点)始终知道它包含的所有输入的结算状态。
下面的图表说明了这种“干扰计数”。点击任何输入节点(蓝色)来模拟调用 node.input()
,并注意到整个表单始终知道在任何给定时间有多少节点被“干扰”。当根节点的干扰计数为 0
时,表单已结算并且可以安全提交。
import { createNode } from '@formkit/node'
const form = createNode({
type: 'group',
children: [
createNode()
createNode()
createNode()
],
})
// ...
// 用户交互:
async function someEvent () {
await form.settled
// 我们现在知道表单已完全“已结算”
// 并且 form.value 是准确的。
}
<FormKit type="form">
输入已经包含了这种等待行为。它不会调用你的 @submit
处理器,直到你的表单完全已结算。然而,在构建高级输入时,理解这些底层原则可能会很有用。
有时从 Vue <FormKit>
组件中获取节点的底层实例可能会有帮助。获取输入节点的有三种主要方法。
getNode()
(或 Vue 插件的 $formkit.get()
用于 Options API)useFormKitNodeById
@node
事件。ref
。getNode()
当使用 FormKit 时,你可以通过为节点分配一个 id
,然后通过 getNode()
函数按该属性访问它。
你必须为输入分配一个 id
才能使用这种方法。
当使用 Vue 的 Options API 时,你可以通过使用 this.$formkit.get()
来访问相同的 getNode()
行为。
useFormKitNodeById()
useFormKitNodeById
组合函数允许你通过其 id
在 Vue 组件内部访问一个节点,它返回一个 Vue ref
,该 ref
会在 FormKitNode
实例创建好后立即被填充。
有关其他类似的组合函数,请参阅 composables 文档。
你必须为输入项分配一个 id
才能使用此方法。
另一种获取底层 node
的方法是监听 @node
事件,该事件在组件首次初始化节点时只发出一次。
将 <FormKit>
组件分配给 ref
也可以轻松访问节点。
要在组或列表中遍历节点,请使用 node.at(address)
—— 其中 address
是被访问节点的 name
(或相对路径到名称)。例如:
import { createNode } from '@formkit/core'
const group = createNode({
type: 'group',
children: [createNode({ name: 'email' }), createNode({ name: 'password' })],
})
// 返回 email 节点
group.at('email')
如果起始节点有兄弟节点,它将尝试在兄弟节点中找到匹配项(在内部,这是 FormKit 用于像 confirm:address
这样的验证规则的方法)。
import { createNode } from '@formkit/core'
const email = createNode({ name: 'email' })
const password = createNode({ name: 'password' })
const group = createNode({
type: 'group',
children: [email, password],
})
// 访问兄弟节点以返回 password 节点
email.at('password')
你可以使用点语法相对路径来进行超过一级的深度遍历。这里有一个更复杂的例子:
import { createNode } from '@formkit/core'
const group = createNode({
type: 'group',
children: [
createNode({ name: 'team' }),
createNode({
type: 'list',
name: 'users',
children: [
createNode({
type: 'group',
children: [
createNode({ name: 'email' }),
createNode({ name: 'password', value: 'foo' }),
],
}),
createNode({
type: 'group',
children: [
createNode({ name: 'email' }),
createNode({ name: 'password', value: 'fbar' }),
],
}),
],
}),
],
})
// 输出: 'foo'
console.log(group.at('users.0.password').value)
注意,遍历 list
使用数字键,这是因为 list
类型自动使用数组索引。
节点地址也可以表示为数组。例如 node.at('foo.bar')
可以表示为 node.at(['foo', 'bar'])
。
在 node.at()
中也可以使用一些特殊的“令牌”:
令牌 | 描述 |
---|---|
$parent | 当前节点的直接祖先。 |
$root | 树的根节点(没有父节点的第一个节点)。 |
$self | 遍历中的当前节点。 |
find() | 一个执行广度优先搜索以匹配值和属性的函数。例如:node.at('$root.find(555, value)') |
这些令牌就像使用节点名称一样,在点语法地址中使用:
import { createNode } from '@formkit/core'
const secondEmail = createNode({ name: 'email' })
createNode({
type: 'group',
children: [
createNode({ name: 'team', value: 'charlie@factory.com' }),
createNode({
type: 'list',
name: 'users',
children: [
createNode({
type: 'group',
children: [
createNode({ name: 'email', value: 'james@peach.com' }),
createNode({ name: 'password', value: 'foo' }),
],
}),
createNode({
type: 'group',
children: [
secondEmail, // 我们将从这里开始。
createNode({ name: 'password', value: 'fbar' }),
],
}),
],
}),
],
})
// 从第二个电子邮件导航到第一个
console.log(secondEmail.at('$parent.$parent.0.email').value)
// 输出:charlie@factory.com
节点有自己的事件,这些事件在节点的生命周期中发出(与 Vue 的事件无关)。
要观察给定事件,请使用 node.on()
。
// 监听任何属性被设置或更改。
node.on('prop', ({ payload }) => {
console.log(`属性 ${payload.prop} 被设置为 ${payload.value}`)
})
node.props.foo = 'bar'
// 输出:属性 foo 被设置为 bar
事件处理回调都接收一个类型为 FormKitEvent
的单个参数,对象形状是:
{
// 事件的内容 —— 一个字符串、一个对象等。
payload: { cause: 'ice cream', duration: 200 },
// 事件的名称,这与 node.on() 的第一个参数匹配。
name: 'brain-freeze',
// 此事件是否应该冒泡到下一个父节点。
bubble: true,
// 发出事件的原始 FormKitNode。
origin: node,
}
节点事件(默认情况下)会在节点树中向上冒泡,但 node.on()
只会响应由同一个节点发出的事件。然而,如果你想要同时捕获从后代冒泡上来的事件,你可以在事件名称的末尾添加字符串 .deep
:
import { createNode } from '@formkit/core'
const group = createNode({ type: 'group' })
group.on('created.deep', ({ payload: child }) => {
console.log('子节点创建了:', child.name)
})
const child = createNode({ parent: group, name: 'party-town-usa' })
// 输出:'子节点创建了:party-town-usa'
每次使用 node.on()
注册一个观察者时,都会返回一个“收据”——一个随机生成的键——该键稍后可用于停止观察该事件(类似于 setTimeout()
和 clearTimeout()
)使用 node.off(receipt)
。
const receipt = node.on('input', ({ payload }) => {
console.log('received input: ', payload)
})
node.input('foobar')
// 输出:'received input: foobar'
node.off(receipt)
node.input('fizz buzz')
// 没有输出
以下是 @formkit/core
发出的所有事件的综合列表。第三方代码可能会发出此处未包括的额外事件。
名称 | 载荷 | 冒泡 | 描述 |
---|---|---|---|
commit | 任意 | 是 | 当节点的值被提交但在传输到表单的其余部分之前发出。 |
config:{property} | 任意(值) | 是 | 任何时候特定配置选项被设置或更改时发出。 |
count:{property} | 任意(值) | 否 | 任何时候账本的计数器值发生变化时发出。 |
child | FormKitNode | 是 | 当新的子节点被添加、创建或分配给父节点时发出。 |
created | FormKitNode | 是 | 在调用 createNode() 时立即_之前_节点返回时发出(插件和功能已经运行)。 |
defined | FormKitTypeDefinition | 是 | 当节点的“类型”被定义时发出,这通常在 createNode() 期间发生。 |
destroying | FormKitNode | 是 | 在调用 node.destroy() 后,且在它已经从任何父节点分离后发出。 |
dom-input-event | Event | 是 | 当 DOMInput 处理程序被调用时发出,用于在核心中获取原始的HTML输入事件。 |
input | 任意(值) | 是 | 当 node.input() 被调用时发出 — 在 input 钩子运行之后。 |
message-added | FormKitMessage | 是 | 当新的 node.store 消息被添加时发出。 |
message-removed | FormKitMessage | 是 | 当 node.store 消息被移除时发出。 |
message-updated | FormKitMessage | 是 | 当 node.store 消息被更改时发出。 |
mounted | 无 | 是 | 当拥有此节点的 <FormKit> 组件被挂载到dom时发出。 |
prop:{propName} | 任意(值) | 是 | 任何时候特定属性被设置或更改时发出。 |
prop | { prop: string, value: any } | 是 | 任何时候属性被设置或更改时发出。 |
reset | FormKitNode | 是 | 任何时候表单或组被重置时发出。 |
settled | 布尔 | 否 | 任何时候节点的干扰计数安定或不安定时发出。 |
settled:{counterName} | 布尔 | 否 | 任何时候特定账本计数器安定(返回到零)时发出。 |
unsettled:{counterName} | 布尔 | 否 | 任何时候特定账本计数器变得不安定(超过零)时发出。 |
text | 字符串或 FormKitTextFragment | 否 | 在 text 钩子运行之后发出 — 通常在处理可能已翻译的界面文本时。 |
当配置选项发生变化时,任何继承节点(包括原始节点)也会发出 prop
和 prop:{propName}
事件,只要它们没有在自己的 props
或 config
对象中覆盖该属性。
Node 事件通过 node.emit()
触发。你可以利用这个特性从你自己的插件中发出你自己的合成事件。
node.emit('myEvent', payloadGoesHere)
还有一个可选的第三个参数 bubble
。当设置为 false
时,可以防止你的事件在表单树中向上冒泡。
钩子是在预定义的生命周期操作期间触发的中间件分发器。这些钩子允许外部代码扩展 @formkit/core
的内部功能。下表详细说明了所有可用的钩子:
钩子 | 值 | 描述 |
---|---|---|
classes |
| 在所有类操作运行完毕后、最终转换为字符串之前触发。 |
commit | any | 在调用 node.input() 的 input 和防抖之后,设置节点值时触发。 |
commitRaw | any | 在调用 node.input() 的 input 和防抖之后,设置节点值时触发。 |
error | string | 在处理抛出的错误时触发 — 错误通常是输入,最终输出应该是一个字符串。 |
init | FormKitNode | 在节点最初创建但在 createNode() 中返回之前触发。 |
input | any | 在 commit 之前,每个输入事件(每次敲击键盘)同步触发。 |
message | FormKitMessage | 当消息正在设置在 node.store 上时触发。 |
prop |
| 在任何属性被赋值时触发。 |
setErrors | { localErrors: ErrorMessages, childErrors?: ErrorMessages } | 在节点上设置明确的错误时触发(不是 验证错误)。 |
submit | Record<string, any> | 当 FormKit 表单提交并通过验证时触发。这个钩子允许你在它们被传递给提交处理程序之前修改(克隆的)表单值。 |
text | FormKitTextFragment | 当需要显示 FormKit 生成的字符串时触发 — 允许 i18n 或其他插件进行拦截。 |
要使用这些钩子,你必须注册钩子中间件。中间件只是一个接受两个参数的函数 — 钩子的值和 next
— 一个调用堆栈中下一个中间件并返回值的函数。
要注册一个中间件,将它传递给你想要使用的 node.hook
:
import { createNode } from '@formkit/core'
const node = createNode()
// 这会将所有标签转换为 "Different label!"
node.hook.prop((payload, next) => {
if ((payload.prop = 'label')) {
payload.value = 'Different label!'
}
return next(payload)
})
钩子可以在应用程序的任何地方注册,但使用钩子最常见的地方是在插件中。
插件是扩展 FormKit 功能的主要机制。这个概念很简单 — 一个插件只是一个接受节点的函数。这些函数在创建节点时或将插件添加到节点时会自动被调用。插件的工作方式类似于配置选项 — 它们会自动被子节点和后代继承。
import { createNode } from '@formkit/core'
// 一个改变 prop 值的插件。
const myPlugin = (node) => {
if (node.type === 'group') {
node.props.color = 'yellow'
} else {
node.props.color = 'teal'
}
}
const node = createNode([
plugins: [myPlugin],
children: [createNode()]
])
在上面的例子中,插件只在父节点上定义,但子节点也继承了插件。函数 myPlugin
将被调用两次 — 每个节点在图中各一次(在这个例子中只有两个):
除了扩展和修改节点之外,插件还有一个额外的角色 — 暴露输入库。一个“库”是一个分配给插件的 library
属性的函数,它接受一个节点并确定它是否知道如何“定义”该节点。如果可以,它会调用 node.define()
并传入一个输入定义。
例如,如果我们想创建一个插件,暴露一些新的输入:italy
和 france
,我们可以编写一个插件来实现这一点:
经验丰富的开发者会注意到这种插件-库模式的一些令人兴奋的特性:
node.define()
。通常,这只是检查 node.props.type
,但你可以根据其他条件定义不同的输入,比如设置了特定的 prop。每个节点都有自己的数据存储。这些存储中的对象称为“消息”,这些消息对于三个主要用例尤其有价值:
存储中的每个消息(TypeScript 中的 FormKitMessage
)都是具有以下结构的对象:
{
// 此消息是否阻止表单提交(默认值:false)。
blocking: true,
// 必须是唯一的字符串值(默认值:随机字符串)。
key: 'yourkey',
// (可选)关于此消息的元数据对象(默认值:{})。
meta: {
// (可选)如果设置,i18n 将使用此值而不是 key 来查找区域消息。
messageKey: 'i18nKey',
// (可选)如果设置,这些参数将传递给 i18n 区域函数。
i18nArgs: [...any],
// (可选)如果设置为 false,消息将检查本地化。
localize: true,
// (可选)消息区域设置(默认值:node.config.locale)
locale: 'en',
// 任何其他您想要的元数据。
...any
},
// 此消息所属的任意类别(用于过滤目的)。
// 例如:'validation' 或 'success'(默认值:'state')
type: string,
// (可选)应该是字符串、数字或布尔值(默认值:undefined)。
value: '糟糕,我们的服务器坏了!',
// 此消息是否应显示给最终用户?(默认值:true)
visible: true
}
可以从 @formkit/core
导入帮助函数 createMessage({})
,以将您的消息数据与上述默认值合并,以创建一个新的消息对象。
要添加或更新消息,请使用 node.store.set(FormKitMessage)
。然后可以在 node.store.{messageKey}
上获取消息
import { createMessage, createNode } from '@formkit/core'
const node = createNode()
const message = createMessage({
key: 'clickHole',
value: '请点击 100 次。',
})
node.store.set(message)
console.log(node.store.clickHole.value)
// 输出:'请点击 100 次。'
如果安装了 @formkit/i18n
插件并且在活动区域中有可用的匹配键,则消息将自动翻译。 阅读 i18n 文档。
FormKit 性能的关键之一是其能够高效地计数匹配给定条件的消息(在存储中),然后在进行更改时(包括来自子节点的更改)保持这些消息的实时总计。这些计数器是使用 node.ledger
创建的。
假设我们想要计数当前显示的消息数量。我们可以通过计数 visible
属性设置为 true
的消息来实现这一点。
注意 node.ledger.count()
的第二个参数是一个函数。这个函数接受一个消息作为参数,并期望返回值是一个布尔值,指示是否应该计数该消息。这允许你为任何消息类型制定任意计数器。
当在 group
或 list
节点上使用计数器时,计数器会沿树向下传播,对所有通过条件函数的消息进行求和,然后跟踪存储变化的计数。
验证插件已经声明了一个名为 blocking
的计数器,它计数所有消息的 blocking 属性。这就是 FormKit 表单如何知道它们的所有子项是否“有效”的方式。