← HTML 表单:从控件到提交与校验 / Smart Input 引擎:能力与实现 待审核 22 / 24
smart-input · 引擎剖析

Smart Input 引擎:能力与实现

普通文本框用原生 <input> 足矣。可一旦要「边输入边格式化」或「按规则拦截」,实现者会依次遇到 IME 合成、光标漂移、原生 undo 撕碎格式、粘贴带富文本、拦截时机五个浏览器层问题。@vega/smart-input 把这五点统一封装,让调用方只描述一个 Formatter——允许什么字符、如何规范化、是否合法、怎么渲染。本页沿数据流自底向上拆开这台引擎:先看它提供哪些能力,再看每项能力具体怎么实现。两个落地 demo 见 格式化合集规则输入框

1 · 原生 input 的五个陷阱,与引擎的接管

每一项都是「自己实现 masking 时绕不开、且极易写错」的浏览器细节。引擎把它们收敛进同一条管线,调用方不再感知。

陷阱 引擎的接管
IME 与光标冲突:中文合成期间触发 setSelectionRange 会截断或乱码,必须借 compositionstart / compositionend 守门 composition 守门:合成期间跳过校验与重写,compositionend 后再跑一次防御性 refresh
重写文本后光标漂移:把 123 程序化改写成 1 23 后,浏览器默认把光标甩到末尾 字符偏移重定位:caret 以「第 N 个字符」记录,DOM 重写后按字符偏移放回原处
原生 undo 撕碎格式:浏览器 undo 以 DOM 操作为单位,程序化格式化写入被拆成多步,一次 undo 只回退片段 逻辑层 history:自维护 snapshot 栈,Ctrl+Z 回退到「用户停顿」粒度而非逐字符
paste 引入富文本:contenteditable 默认接受 HTML 粘贴,会带入段落 / 字体 / 颜色 粘贴清洗:截获 paste,只取 text/plain,再经 execCommand('insertText') 走原生路径以保留 IME / undo 兼容
拦截时机难选:只有 beforeinputpreventDefault 能拦字符进 DOM,但 inputType 区分 insertText / insertFromPaste / insertCompositionText,处理不当会破坏 IME 字符级拦截 + 闪红:allowCharbeforeinput 逐字符过滤,非法整笔拒绝并触发 flashReject

定位:本包是「构建 mask 组件的底层库」,不是开箱即用的 mask 实现。同类项目 imask / cleave.js 给的是成品控件;本包给的是把上述五点统一处理后的管线 + Formatter 模型,domain-specific 的规则仍由调用方写。

2 · 两层 API:helper 与 createSmartInput

引擎分两层。底层是五个互不依赖的 helper(自己拼装事件源 / 多元素联动时直接取用);高阶 createSmartInput 把这五个 helper 串成一条管线,只接收一个 Formatter

组成
高阶组合(描述一个 Formatter 即接入) createSmartInput(工厂 + 完整管线)、Formatter(allowChar / isExtendable / normalize / validate / asyncValidate / renderHtml)
底层 helper(内部由它拼装而成,自行装配时可直接取用) history(undo/redo 栈 + bindUndo)、composition(IME 守门 + isImeComposingKey)、caret(字符偏移光标 / 选区读写)、filterbeforeinput 字符级拦截)、flash(reject-flash 视觉反馈)

唯一可信源是 (text, caret),DOM 只是它的视图。各类来源(键入 / 粘贴 / undo / setText / IME 收尾)最终都汇入同一个 apply() 写 DOM。这条「单一可信源 + 单一写入点」是整台引擎的核心设计原则。

3 · Formatter 契约:七个可选字段

Formatter 描述「这个框接受什么、显示什么」,完全不感知 DOM 事件 / 历史 / IME。七个字段全部可选,未提供时落到合理默认。这是调用方唯一要写的东西。

字段 签名 默认 用途
allowChar (ch) => boolean true 字符级白名单。beforeinput 时逐字符过滤,任一不过 → 整笔拒绝 + 闪红
isExtendable (s) => boolean true 仅 strict 启用。整体级:文本须是某合法值的前缀,无法扩展的字符直接吞
normalize (text, caret, prev?) => string | {text, caretAfter, anchorClass} | {text, caret} 透传 归一:大小写 / 别名 / 自动补分隔符 / 截断。返回的 text 即 single source of truth。三种返回形态区分光标处理(见下节):string(保序,引擎推断)/ anchor(插分隔符首选,引擎换算)/ {text, caret}(显式绝对偏移)
validate (text) => FormatterResult 恒 valid 完整校验,供 UI。三态 valid / partial / invalidpending 仅 async 内部用)
asyncValidate (text, signal) => Promise 业务级异步校验(查重等)。仅 sync valid 时触发,自动竞态取消
renderHtml (text) => string textContent=text 富渲染(语法高亮等)。仅 contenteditable 生效;约束 textContent 须严格等于 text
atomicSegments (text) => [start,end][] (选)声明 mention chip / tag 等不可分割的字符区间。查询字段,引擎不接管键盘;上层拿 api.getAtomicSegments() + getSelection / setSelection 自行组装「光标整块跳过」

最小可运行例子(hex 颜色输入框,输入即归一为小写、6 位即把背景刷成该色):

import { createSmartInput } from '@vega/smart-input';

createSmartInput({
  el: document.querySelector('#color') as HTMLInputElement,
  formatter: {
    allowChar: (ch) => /[0-9a-fA-F]/.test(ch),
    normalize: (text, caret) => ({
      text: text.toLowerCase().slice(0, 6),
      caret: Math.min(caret, 6),
    }),
    validate: (text) =>
      text.length === 6
        ? { kind: 'valid', value: '#' + text }
        : { kind: 'partial' },
  },
  onChange: ({ result }) => {
    if (result.kind === 'valid') document.body.style.background = result.value;
  },
});

构造时同步触发一次 onChange(trigger='init')。const api = createSmartInput({ onChange }) 这条语句的 api 此刻仍在 TDZ——onChange 内不能反向访问 api,只用回调参数,或 if (trigger === 'init') return 跳过首次。

4 · 核心管线:refresh → normalize → strict 守门 → apply → commit

每次 DOM 事件都走同一条 refresh 管线。它把「读 DOM → 归一 → 守门 → 写回 → 提交历史」串成五步,其中所有 DOM 写入都集中在 apply() 一处;undo / redo 则共用 apply()提交历史。

阶段 说明
read 读 DOM 当前 raw textcaret<input>.value / selectionStart;contenteditable 走字符偏移)
normalize formatter.normalize(raw, caret, prevText) → 规范文本 + 重映射后的光标
strict guard 仅 strict:若结果非可扩展前缀,rebuildExtendable 重建到最长可扩展前缀(兜 IME 等绕过 beforeinput 的输入)
apply 唯一写入点:writeText(value / innerHTML)→ writeCaret → validate → 派发 onChange
history.commit (text, caret) 提交进 snapshot 栈(500ms 内合并为同一段)。restore 路径不走这步

下面把这台引擎接到一个真实电话号 formatter 上——边输入边看每一步的可观察产物:

试试:打 13800001111 → 实时分组成 138 0000 1111;打字母 → allowChar 拦截(闪红);把光标移到中间删一位 → caret 按字符重映射不漂;Ctrl/Cmd+Z 整段回退;开 strict 后在空框打 2 → 没有 2 字头的合法号,直接被吞;用拼音打中文 → IME 徽标亮起,合成期间不进校验。

5 · normalize:single source of truth 与光标重映射

normalize 返回的 text 是引擎的唯一可信源——history 存它、DOM 写它。当 normalize 往文本里插入字符(空格 / 横线 / 千分位逗号),光标必须同步重算,否则在中间删一位时光标会停在原偏移、有时正好落进分隔符里。这是「边输入边格式化」最容易写错的一块。

你不用自己算光标:返回 anchor 形态 { text, caretAfter, anchorClass }——只声明「哪些字符算有效(anchorClass)」+「光标逻辑上落在第几个有效字符之后(caretAfter)」,引擎负责把它换算到插了分隔符的 formatted 域。你只做两步:抽有效字符并顺带数光标前有几个 → 插分隔符:

import { isDigit } from '@vega/smart-input/helpers';

normalize: (raw, caret) => {
  // ① 抽有效字符,顺带数光标前有几个(这一步的丢弃规则是 formatter 特有的)
  let digits = '';
  let digitsBeforeCaret = 0;
  for (let i = 0; i < raw.length; i++) {
    if (raw[i] >= '0' && raw[i] <= '9') {
      digits += raw[i];
      if (i < caret) digitsBeforeCaret++;
    }
  }
  digits = digits.slice(0, 11);

  // ② 插分隔符
  let out = digits.slice(0, 3);
  if (digits.length >= 4) out += ' ' + digits.slice(3, 7);
  if (digits.length >= 8) out += ' ' + digits.slice(7, 11);

  // ③ 光标交给引擎:落到「第 digitsBeforeCaret 个数字之后」,无需再手写映射
  return { text: out, caretAfter: digitsBeforeCaret, anchorClass: isDigit };
}

引擎内部就是「数 formatted 里第 N 个有效字符、落到它之后」这一步(想手写或做更复杂映射,caretAfterNthInClass / remapCaretByClass 也从 /helpers 导出)。若变换是保序的(仅大小写归一 / 末尾截断,不插字符),更省事:直接返回 string,引擎用公共前缀 / 后缀锚定自动推断光标。三种形态覆盖了从最省心到完全手动的整条谱系。

formatter 仍是纯函数。引擎额外传入第三个参数 prevText(这次编辑前最近提交的文本),让 normalize 能区分「键入 vs 删除」——例如退格删掉自动补的分隔符时,识别这一笔并改删它前面那位数字,否则 normalize 立刻把分隔符补回来就成了删不动的死结。这个「是不是刚删了自动分隔符」的判定已封装成 helper isSeparatorBackspace(raw, caret, prevText, sep)(见 /helpers);对比 prevText 与当前 raw 即可,无需自存状态。

6 · 两道关:allowChar(字符级)与 isExtendable(整体级)

allowChar 限定单个字符的字符集;isExtendable 判断整段文本是否仍可扩展成合法值。启用 strict: true 后,beforeinput 会模拟插入并跑 isExtendable,未通过的按键直接拒、不进 DOM。

createSmartInput({
  el: phoneEl,
  strict: true,
  formatter: {
    allowChar: (ch) => /\d/.test(ch),
    isExtendable: (s) => {
      const digits = s.replace(/ /g, '');
      return digits.length <= 11 && (digits === '' || digits[0] === '1');
    },
  },
});

为什么还要 refresh 阶段再重建一次? IME 合成串会绕过 beforeinputpreventDefault(浏览器在合成期忽略它)。所以引擎在 refresh 里加了 rebuildExtendable 兜底:从头逐字符模拟,只保留让结果保持「可扩展前缀」的字符,并同步换算光标。beforeinput 拦不住的,这里仍能清掉。

7 · 逻辑层 history:为什么不用原生 undo

每次输入后引擎都重写 DOM(覆写 value / innerHTML),浏览器的撤销栈会被砸坏到无法挽救。于是改为自维护一份 snapshot 栈,Ctrl+Z 回退到「用户停顿」粒度。

机制 说明
coalesce 500ms 同一段连续输入(间隔 < coalesceMs)合并到栈顶,不为每个字符建一个 snapshot
hardBreak 在 paste / compositionend / undo・redo / bootstrap 后强制断段,防止被 500ms 窗口合并掉
isRestoring 兜底 restore 会触发 input 事件;refresh 开头 if (history.isRestoring()) return 防止它重新覆盖恢复中的状态
commit ≠ restore refresh 末尾 commit;undo / redo 的 restore 复用 apply 写 DOM 但不 commit,否则 undo 会产生新历史

bindUndo 在 keydown 上拦 Ctrl/Cmd+Z / +Shift+Z / Ctrl+Y,但合成期间一律放行:IME 自己常用 Ctrl+Z 撤销变换 / 退回未转换文本,抢走会破坏输入法操作。这正是「应用快捷键妨碍 IME」的典型无障碍问题。

8 · IME 合成:被忽略的 preventDefault 与 keyCode 229

中文 / 日文经 IME 组字上屏:按键先交给输入法,keydown 全变 keyCode 229isComposing: true,靠 compositionstart / update / end 完成上屏。合成期间 beforeinputpreventDefault 被浏览器忽略,且 input 会随每次候选词更新触发——此时重写 DOM 必然破坏 IME 状态。引擎的统一做法:合成期间跳过校验与 refresh,compositionend 后跑一次防御性 refresh(让 normalize / rebuild 收尾清理)并 hardBreak

import { isImeComposingKey } from '@vega/smart-input';

el.addEventListener('keydown', (e) => {
  if (isImeComposingKey(e)) return;
  if (e.key === 'Enter') submit();
});

function isImeComposingKey(e: KeyboardEvent): boolean {
  return e.isComposing || e.keyCode === 229;
}

为何还要看废弃的 keyCode? 判定写作 isComposing || keyCode === 229:isComposing 是标准信号,但 Safari 在合成末次 keydown 会把它误报为 false(WebKit #165004);而合成期间任何 KeyboardEventkeyCode 都是 229,是目前最可靠的兜底。典型受益场景:聊天框「Enter 发送」在选字阶段按 Enter 只确认候选、不误发(见格式化合集末例)。

9 · caret:跨 DOM 重写保持的字符偏移

<input> 有现成的 selectionStart;但 contenteditable 重写 innerHTML 后,原生 Range 会失效。引擎统一把光标抽象成「root.textContent 里第 N 个字符」,用 TreeWalker 遍历文本节点累加长度来读 / 写偏移。这样无论 renderHtml 把文本拆成多少层 <span>,光标都能稳定落回原字符位置。

function getCaretOffset(root: HTMLElement): number {
  const sel = window.getSelection();
  if (!sel || sel.rangeCount === 0) return 0;
  const range = sel.getRangeAt(0);
  const pre = document.createRange();
  pre.selectNodeContents(root);
  pre.setEnd(range.endContainer, range.endOffset);
  return pre.toString().length;
}

function setCaretOffset(root: HTMLElement, offset: number): void {
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
  let remaining = offset;
  let node = walker.nextNode() as Text | null;
  while (node) {
    if (remaining <= node.data.length) {
      const range = document.createRange();
      range.setStart(node, remaining);
      range.collapse(true);
      const sel = window.getSelection();
      sel.removeAllRanges();
      sel.addRange(range);
      return;
    }
    remaining -= node.data.length;
    node = walker.nextNode() as Text | null;
  }
}

caret 模块还提供 getCharRectAt(root, n)——取第 N 个字符的视觉 DOMRect,用来把浮层 / autocomplete 弹层 / 错误下划线锚定到具体字符(如 mention 输入让弹层跟着 @ 走)。

10 · asyncValidate:pending 态与竞态取消

「用户名是否被占用」这类需要网络的判断走 asyncValidate。它仅在 sync validatevalid 时触发:引擎先把结果替换为 { kind: 'pending' } 派发一次(让 UI 画 loading),再调本函数;结果到达时以 trigger='async' 再派发。

asyncValidate: async (text, signal) => {
  const res = await fetch('/api/check-username?name=' + text, { signal });
  const { taken } = await res.json();
  return taken
    ? { kind: 'invalid', error: '已被占用' }
    : { kind: 'valid', value: text };
}

竞态由引擎管:每次新一轮派发都 abort() 上一次的 AbortController,旧结果到达时因 signal 已 abort 被丢弃,不会覆盖新派发。把 signal 传进 fetch 即可顺带取消请求。Promise reject 视作 bug(映射为 invalid + console.warn);业务失败应主动 resolve({ kind: 'invalid', error })。不内置 debounce——需要就在函数里 await delay(300, { signal })

11 · renderHtml:富渲染与 textContent 约束

el 换成 <div>(引擎自动设 contenteditable)即可启用 renderHtml:文本 → HTML 字符串,写进 innerHTML 做字符级染色。配套的 html tagged template 自动 escape 插值,防止用户输入的 < > & 被当 HTML 解析。

import { createSmartInput, html } from '@vega/smart-input';

createSmartInput({
  el: document.querySelector('#color') as HTMLDivElement,
  formatter: {
    allowChar: (ch) => /[0-9a-fA-F]/.test(ch),
    normalize: (text, caret) => ({
      text: text.toLowerCase().slice(0, 6),
      caret: Math.min(caret, 6),
    }),
    renderHtml: (text) => {
      const r = text.slice(0, 2);
      const g = text.slice(2, 4);
      const b = text.slice(4, 6);
      return (
        (r ? html`<span style="color:#c33">${r}</span>` : '') +
        (g ? html`<span style="color:#3a3">${g}</span>` : '') +
        (b ? html`<span style="color:#36c">${b}</span>` : '')
      );
    },
  },
});

硬约束:输出的 textContent 必须严格等于入参 text。光标按字符偏移定位,长度一旦不一致光标即错。两条推论:HTML 里不得掺 text 之外的字符(装饰用的 # / 空格 / 固定后缀应放兄弟节点);文本里的 < > & 必须转义。dev 模式(debug !== false)下引擎会断言这条并在违反时 console.warn。完整 demo 见规则输入框的实时格式化框。

12 · Trigger 八态与生命周期

每次 onChange 都带一个 trigger,让调用方区分用户键入与程序化操作。运行时还可换 formatter / 配置、或仅重评估当前文本而不动 DOM。

trigger 触发时机
init bootstrap 首次 apply
input 键入 / 粘贴 / IME 收尾
undo Ctrl+Z 后 restore
redo Ctrl+Shift+Z / Ctrl+Y
setText api.setText
update 换 formatter / strict
revalidate 仅重跑 validate
async asyncValidate 结果到达

api.setText(text, caret?) 程序化设值(走完整 refresh、进 history);api.update({ formatter?, strict?, onChange?, debug? }) 运行时换配置后立即对当前文本跑一次完整管线;api.revalidate() 仅重跑 validate 并派发 onChange(外部依赖如 min/max 变了时用),不写 DOM、不进 history。

destroy() 不是可选项。 它反注册所有 DOM 监听器、恢复 contentEditable 原值、abort 在飞的 asyncValidate。SPA 卸载组件时必须调用,否则监听器与 history 闭包泄漏。调用后实例不可再用,后续 getText / setText 会抛错。

13 · 相关链接

  • @vega/smart-input · README · 本仓库——本页的源出处:完整 API、quick start、数据流图、底层 helper 装配示例与开箱即用 formatter 表。
  • MDN · beforeinput event · developer.mozilla.org——对应「拦截」与「IME」两节:inputType 取值、preventDefault 的可取消性,以及合成期间为何被忽略。
  • MDN · compositionstart / compositionend · developer.mozilla.org——对应「IME 合成」节:组字的事件序列,以及 isComposing 信号的边界。
  • imask · cleave.js · npm——同类的开箱即用 mask 控件;本包定位为构建这类组件的底层库,可作对照。
  • 表单输入 · 格式化合集——本引擎的五个落地场景:千分位 number、HH:MM:SS strict 守门、6 位 OTP、tag chip、IME-safe Enter 提交。
  • 表单输入 · 规则输入框——最复杂用例:动作规则 DSL 的 masking / renderHtml 富渲染 / strict 前缀守门三框渐进展示。