架构

介绍

FormKit框架的核心是@formkit/core。这个零依赖的包负责几乎所有FormKit的低级关键功能,包括:

  • 配置
  • 值的输入/输出
  • 事件冒泡
  • 插件管理
  • 树状态跟踪
  • 消息管理
  • 生命周期钩子

架构

FormKit核心的功能不是通过一个集中的实例暴露给应用程序,而是通过一组分布式的“节点”(FormKitNode)来表示,其中每个节点代表一个单独的输入。

这与HTML相似 - 实际上DOM结构实际上是一棵一般树,而FormKit核心节点反映了这种结构。例如,一个简单的登录表单可以绘制为以下树图:

将鼠标悬停在每个节点上以查看其初始选项。

在这个图表中,form节点是三个子节点(emailpasswordsubmit)的父节点。图表中的每个输入组件都“拥有”一个FormKit核心节点,每个节点都包含自己的选项,配置,props,事件,插件,生命周期钩子等。这种架构确保了FormKit的主要功能与渲染框架(Vue)解耦 - 这是减少副作用和保持极快性能的关键。

此外,这种分散式架构还允许极大的灵活性。例如 - 一个表单可以使用不同的插件,而其他表单可以使用相同的应用程序,一个组输入可以修改其子输入的配置,甚至可以编写使用另一个输入的props的验证规则。

节点

每个<FormKit>组件拥有一个单独的核心节点,每个节点必须是以下三种类型之一:

输入与节点类型

核心节点始终是三种类型之一(输入,列表或组)。这与输入类型不同 - 输入类型可以有无限的变化。严格来说,所有输入都有两种类型:它们的节点类型(如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}节点/输入的名称。
parentnullFormKitNode实例。
plugins[]插件函数的数组。
props{}代表当前节点实例详细信息的键/值对对象。
typeinput要创建的FormKitNode的类型(listgroupinput)。
valueundefined输入的初始值。

配置和属性

FormKit 使用基于继承的配置系统。在 config 选项中声明的任何值都会自动传递给该节点的子节点(以及所有后代节点),但不会传递给兄弟节点或父节点。每个节点都可以通过提供自己的配置来覆盖其继承的值,这些值将进一步被任何更深层次的子节点和后代节点继承。例如:

const parent = createNode({
  type: 'group',
  config: {
    color: 'yellow',
  },
  children: [
    createNode({
      type: 'list',
      config: { color: 'pink' },
      children: [createNode(), createNode()],
    }),
    createNode(),
  ],
})

上面的代码将导致每个节点具有以下配置:

注意列表子树是粉色的。
使用 props 读取配置

最佳实践是从 node.props 而不是 node.config 中读取配置值。下一节详细介绍了这个特性。

属性

node.propsnode.config 对象密切相关。最好将 node.config 视为 node.props 的初始值。props 是一个任意形状的对象,包含有关节点当前 实例 的详细信息。

最佳实践是始终从 node.props 中读取配置和属性数据,即使原始值是使用 node.config 定义的。显式定义的 props 优先于配置选项。

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 组件属性

当使用 <FormKit> 组件时,为输入的 type 定义的任何属性都会自动设置为 node.props 的属性。例如:<FormKit label="Email" /> 会导致 node.props.labelEmail

设置值

您可以通过在 createNode() 上提供 value 选项来设置节点的初始值。但是,FormKit 是关于交互性的,那么如何更新已定义节点的值呢?通过使用 node.input(value)

import { createNode } from '@formkit/core'

const username = createNode()
username.input('jordan-goat98')
console.log(username.value)
// undefined  👀 等等 — 什么!?

在上面的示例中,username.value 在设置后仍然是 undefined,因为 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 时,表单已解决,可以安全提交。

单击输入(蓝色)以模拟调用用户输入。

为了确保给定的树(表单)、子树(组)或节点(输入)“已解决”,您可以等待 node.settled 属性:

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插件的Options API中的$formkit.get()
  • 使用@node事件。
  • 使用模板ref

使用getNode()

在使用FormKit时,您可以通过为其分配一个id,然后通过getNode()函数通过该属性访问它来访问节点。

warning

您必须为输入分配一个id才能使用此方法。

加载实时示例
Options API

在使用Vue的Options API时,您可以使用this.$formkit.get()来访问相同的getNode()行为。

使用节点事件

获取底层node的另一种方法是监听@node事件,该事件仅在组件首次初始化节点时触发。

加载实时示例

使用模板ref

<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类型会自动使用数组索引。

以红色显示的group.at('users.0.password')的遍历路径。
数组路径

节点地址也可以表示为数组。例如,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
以红色显示的secondEmail.at('$parent.$parent.0.email')的遍历路径。

事件

节点具有其自己的事件,在节点的生命周期中会发出这些事件(与Vue的事件无关)。

添加监听器

要观察给定的事件,请使用 node.on()

// 监听任何属性的设置或更改。
node.on('prop', ({ payload }) => {
  console.log(`属性 ${payload.prop} 被设置为 ${payload.value}`)
})

node.props.foo = 'bar'
// 输出:属性 foo 被设置为 bar

事件处理程序回调都接收一个类型为 FormKitEvent 的参数,对象的形状如下:

{
  // 事件的内容 - 字符串、对象等。
  payload: { cause: '冰淇淋', duration: 200 },
  // 事件的名称,与 node.on() 的第一个参数匹配。
  name: '脑冻',
  // 是否将此事件冒泡到下一个父节点。
  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: '派对之城' })
// 输出:'子节点已创建:派对之城'

移除监听器

使用 node.on() 注册观察者的每次调用都会返回一个“收据” - 一个随机生成的键 - 以后可以使用 node.off(receipt) 停止观察该事件(类似于 setTimeout()clearTimeout())。

const receipt = node.on('input', ({ payload }) => {
  console.log('接收到输入:', payload)
})
node.input('foobar')
// 输出:'接收到输入: foobar'
node.off(receipt)
node.input('fizz buzz')
// 没有输出

核心事件

以下是 @formkit/core 发出的所有事件的综合列表。第三方代码可能会发出此处未包含的其他事件。

名称PayloadBubbles描述
commit任意当节点的值被提交但尚未传输到表单的其余部分时发出。
config:{property}任意(值)每次设置或更改特定配置选项时发出。
count:{property}任意(值)每当分类帐的计数器值发生更改时发出。
childFormKitNode当添加、创建或分配给父节点的新子节点时发出。
createdFormKitNode在调用 createNode() 时立即发出 - 插件和功能已经运行。
definedFormKitTypeDefinition当节点的 "type" 被定义时发出,通常在 createNode() 过程中发生。
destroyingFormKitNode在调用 node.destroy() 之后发出,此时节点已从任何父节点中分离。
dom-input-eventEvent在调用 DOMInput 处理程序时发出,用于获取核心中的原始 HTML 输入事件。
input任意(值)在调用 node.input() 之后发出 - 在运行 input 钩子之后。
message-addedFormKitMessage当添加新的 node.store 消息时发出。
message-removedFormKitMessage当删除 node.store 消息时发出。
message-updatedFormKitMessage当更改 node.store 消息时发出。
prop:{propName}任意(值)每次设置或更改特定 prop 时发出。
prop{ prop: string, value: any }每次设置或更改 prop 时发出。
resetFormKitNode每当表单或组重置时发出。
settled布尔值每当节点的扰动计数稳定或不稳定时发出。
settled:{counterName}布尔值每当特定分类帐计数器稳定(返回零)时发出。
unsettled:{counterName}布尔值每当特定分类帐计数器不稳定(大于零)时发出。
text字符串或 FormKitTextFragment在运行 text 钩子之后发出 - 通常在处理可能已被翻译的界面文本时。
在配置更改时的属性事件

当配置选项更改时,任何继承的节点(包括原始节点)也会发出 propprop:{propName} 事件,只要它们在自己的 propsconfig 对象中没有覆盖该属性。

发出事件

节点事件使用 node.emit() 发出。您可以利用此功能从自己的插件中发出自己的合成事件。

node.emit('myEvent', payloadGoesHere)

还可以使用可选的第三个参数 bubble。当设置为 false 时,它会阻止事件通过表单树冒泡。

钩子

钩子是在预定义的生命周期操作期间触发的中间件调度程序。这些钩子允许外部代码扩展 @formkit/core 的内部功能。下表详细介绍了所有可用的钩子:

钩子描述
classes
{
property: string,
classes: Record<string, boolean>
}
在所有类操作运行完成之后,在最终转换为字符串之前调度。
commitany在设置节点的值之后,调度 inputnode.input() 的防抖之后调用。
commitRawany在设置节点的值之后,调度 inputnode.input() 的防抖之后调用。
errorstring在处理抛出的错误时调度 — 错误通常是输入,最终输出应为字符串。
initFormKitNode在节点初始创建后但在返回 createNode() 之前调度。
inputany在每个输入事件(每个按键)之前同步调度 commit
messageFormKitMessagenode.store 上设置消息时调度。
prop
{
prop: string,
value: any
}
在分配任何属性时调度。
setErrors{ localErrors: ErrorMessages, childErrors?: ErrorMessages }在节点上设置显式错误(而不是验证错误)时调度。
submitRecord<string, any>在提交 FormKit 表单并通过验证时调度。此钩子允许您在将克隆的表单值传递给提交处理程序之前修改它们。
textFormKitTextFragment在需要显示 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'

// 一个改变属性值的插件。
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() 并传入一个输入定义

例如,如果我们想创建一个插件来公开一些新的输入:italyfrance,我们可以编写一个插件来实现:

加载实时示例

有经验的开发人员会注意到插件库模式的一些有趣属性:

  1. 可以在同一个项目上安装多个输入库。
  2. 插件(和库)可以在本地、每个表单、组或全局范围内公开。
  3. 插件可以将新的输入与插件逻辑捆绑在一起,使终端用户的安装变得简单。
  4. 库函数完全控制哪些条件会导致调用 node.define()。通常,这只是检查 node.props.type,但您可以根据其他条件定义不同的输入,比如是否设置了特定的 prop。
学习如何创建自定义输入自定义输入文档

消息存储

每个节点都有自己的数据存储。存储中的对象称为“消息”,这些消息对于三个主要用例特别有价值:

  • 用 i18n 支持向用户显示有关节点的信息(验证插件使用它)。
  • “阻止”表单提交。
  • 插件作者的通用数据存储。

存储中的每个消息(在 TypeScript 中为 FormKitMessage)都是一个具有以下结构的对象:

{
  // 是否阻止表单提交(默认值:false)。
  blocking: true,
  // 必须是唯一的字符串值(默认值:随机字符串)。
  key: 'yourkey',
  // (可选)关于此消息的元数据对象(默认值:{})。
  meta: {
    // (可选)如果设置,i18n 将使用它而不是键来查找区域消息。
    messageKey: 'i18nKey',
    // (可选)如果设置,这些参数将传递给 i18n 区域函数。
    i18nArgs: [...any],
    // (可选)消息将检查本地化,如果设置为 false,则不会检查本地化。
    localize: true,
    // (可选)消息区域设置(默认值:node.config.locale)
    locale: 'en',
    // 您心中所需的任何其他元数据。
    ...any
  },
  // 此消息所属的任意类别(用于过滤目的)。
  // 例如:'validation' 或 'success'(默认值:'state')
  type: string,
  // (可选)应该是字符串、数字或布尔值(默认值:undefined)。
  value: 'Woops, our server is broken!',
  // 是否向终端用户显示此消息?(默认值: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: 'Please click 100 times.',
})

node.store.set(message)

console.log(node.store.clickHole.value)
// 输出:'Please click 100 times.'
消息区域设置

如果安装了 @formkit/i18n 插件并且活动区域设置中有匹配的键,则消息将自动被翻译。 阅读 i18n 文档

分类账簿

FormKit 的性能关键之一是它能够高效地计算与给定条件匹配的消息数量(在存储中),并在进行更改时保持这些消息的累计(包括来自子节点的消息)。这些计数器是使用 node.ledger 创建的。

创建一个计数器

假设我们想要计算当前显示的消息数量。我们可以通过计算visible属性设置为true的消息来实现。

加载实时示例

请注意,node.ledger.count()的第二个参数是一个函数。该函数接受一个消息作为参数,并期望返回一个布尔值,指示是否应该计算该消息。这使您可以为任何消息类型创建任意计数器。

当在grouplist节点上使用计数器时,计数器将向下传播,将通过条件函数的所有消息的值相加,然后跟踪该计数以进行存储更改。

验证计数器

验证插件已经声明了一个名为blocking的计数器,它计算所有消息的阻止属性。这是FormKit表单知道它们的所有子元素是否“有效”的方式。