架构

介绍

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

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

架构

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

这反映了 HTML — 实际上 DOM 结构就是一个通用树,FormKit 核心节点反映了这种结构。例如,一个简单的登录表单可以绘制为以下树状图:

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

在这个图表中,一个 form 节点是三个子节点的父节点 — emailpasswordsubmit。图中的每个输入组件“拥有”一个 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}节点/输入的名称。
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 读取配置值。下一节将详细介绍这个功能。

Props

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

当使用 <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 时,表单已结算并且可以安全提交。

点击输入(蓝色)来模拟调用用户输入。
要确保给定的树(表单)、子树(组)或节点(输入)是“已结算”的,你可以等待 `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 插件的 $formkit.get() 用于 Options API)
  • 使用 useFormKitNodeById
  • 使用 @node 事件。
  • 使用模板 ref

使用 getNode()

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

warning

你必须为输入分配一个 id 才能使用这种方法。

加载实时示例
Options API

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

使用 useFormKitNodeById()

useFormKitNodeById 组合函数允许你通过其 id 在 Vue 组件内部访问一个节点,它返回一个 Vue ref,该 ref 会在 FormKitNode 实例创建好后立即被填充。

有关其他类似的组合函数,请参阅 composables 文档。

warning

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

加载实时示例

使用节点事件

另一种获取底层 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: '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}任意(值)任何时候账本的计数器值发生变化时发出。
childFormKitNode当新的子节点被添加、创建或分配给父节点时发出。
createdFormKitNode在调用 createNode() 时立即_之前_节点返回时发出(插件和功能已经运行)。
definedFormKitTypeDefinition当节点的“类型”被定义时发出,这通常在 createNode() 期间发生。
destroyingFormKitNode在调用 node.destroy() 后,且在它已经从任何父节点分离后发出。
dom-input-eventEventDOMInput 处理程序被调用时发出,用于在核心中获取原始的HTML输入事件。
input任意(值)node.input() 被调用时发出 — 在 input 钩子运行之后。
message-addedFormKitMessage当新的 node.store 消息被添加时发出。
message-removedFormKitMessagenode.store 消息被移除时发出。
message-updatedFormKitMessagenode.store 消息被更改时发出。
mounted当拥有此节点的 <FormKit> 组件被挂载到dom时发出。
prop:{propName}任意(值)任何时候特定属性被设置或更改时发出。
prop{ prop: string, value: any }任何时候属性被设置或更改时发出。
resetFormKitNode任何时候表单或组被重置时发出。
settled布尔任何时候节点的干扰计数安定或不安定时发出。
settled:{counterName}布尔任何时候特定账本计数器安定(返回到零)时发出。
unsettled:{counterName}布尔任何时候特定账本计数器变得不安定(超过零)时发出。
text字符串或 FormKitTextFragmenttext 钩子运行之后发出 — 通常在处理可能已翻译的界面文本时。
配置更改时的 Prop 事件

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

触发事件

Node 事件通过 node.emit() 触发。你可以利用这个特性从你自己的插件中发出你自己的合成事件。

node.emit('myEvent', payloadGoesHere)

还有一个可选的第三个参数 bubble。当设置为 false 时,可以防止你的事件在表单树中向上冒泡。

钩子

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

钩子描述
classes
{
property: string,
classes: Record<string, boolean>
}
在所有类操作运行完毕后、最终转换为字符串之前触发。
commitany在调用 node.input()input 和防抖之后,设置节点值时触发。
commitRawany在调用 node.input()input 和防抖之后,设置节点值时触发。
errorstring在处理抛出的错误时触发 — 错误通常是输入,最终输出应该是一个字符串。
initFormKitNode在节点最初创建但在 createNode() 中返回之前触发。
inputanycommit 之前,每个输入事件(每次敲击键盘)同步触发。
messageFormKitMessage当消息正在设置在 node.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'

// 一个改变 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() 并传入一个输入定义

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

加载实时示例

经验丰富的开发者会注意到这种插件-库模式的一些令人兴奋的特性:

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

消息存储

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

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

存储中的每个消息(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() 的第二个参数是一个函数。这个函数接受一个消息作为参数,并期望返回值是一个布尔值,指示是否应该计数该消息。这允许你为任何消息类型制定任意计数器。

当在 grouplist 节点上使用计数器时,计数器会沿树向下传播,对所有通过条件函数的消息进行求和,然后跟踪存储变化的计数。

验证计数器

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