Skip to content

这是一篇适合直接发布在 Hexo 的教程型文章:从“为什么要分层”讲起,到“每层做什么、如何落地、与其它模式的取舍对比”,并给出可复制的目录结构与代码骨架。示例以“数值输入组件(Numeric Input)”为锚点,但套路可复用到任何中等复杂度的交互组件。

1. 为什么分层

  • 复用:把“样式、交互、业务规则、引擎状态”拆开,任意组合,服务多端/多皮肤。
  • 可测试:规则 → 纯函数;引擎 → 少副作用;UI→ 快照测试。层次越“纯”,越好测。
  • 可维护:问题定界更小:是样式错?交互编排错?还是规则错?
  • 可演进:新增步长对齐、异步校验、节流提交……不影响其它层。

2. 六层模型(与业务无关的、可通用命名)

这套命名与具体技术/业务无关,你在任何组件里都能复用。

L1. Presentation(外观层/皮肤层)

  • 职责:纯样式与 DOM。只渲染,不写逻辑。
  • 输入:由上层暴露的状态与动作(props/slot bindings)。
  • 输出:DOM 事件 → 调用上层提供的方法。
  • 不做:数据校验、格式化、状态管理。
  • 好处:一套引擎,多套皮肤(Design System 友好)。

L2. Headless(无样式交互层)

  • 职责:把引擎暴露的“语义动作/状态”通过插槽透出;处理 IME、长按加减、滚轮、焦点等交互细节;向外派发语义事件input/change/error/...)。
  • 输入:引擎 API。
  • 输出:上下文({ formattedValue, isValid, handleInput, blur, ... })+ 自定义事件。
  • 边界:不持久化、不直接修改业务状态(交给引擎)。

L3. Adapter(适配层/桥接层)

  • 职责:把“外部世界(Form 框架、v-model、i18n、平台 API)”与引擎对齐;封装平台差异。
  • 典型实现:Vue composables/*、React hooks、Svelte stores。
  • :把父组件的 v-model 同步到引擎;把 IME 合成期状态传给引擎,屏蔽节流提交。

L4. Engine(引擎/状态编排层)

  • 职责单一可信状态源(raw、formatted、errors、validating);对外提供语义方法(handleInput/blur/commit/increment/...);决定什么时候校验、什么时候提交
  • 约束:最少的副作用;把“规则”外包给 L5。
  • 示例策略:输入期软校验(只报错不改值),提交点显式收敛(精度/步长/边界/非负/格式化),IME 合成期暂停节流提交等。

L5. Pipeline(工序/规则层)

  • 职责纯函数规则,一步一个小函数,可自由编排: trim → parse → hardValidate → precision → snapToStep → clamp → nonNegative → format
  • 特点:可插拔、可复用、易单测;显式/隐式两条管线只是“工序组合不同”。

L6. Extension(插件/钩子层)

  • 职责:对外开放扩展点(parseFn/formatFn/validateFn/beforeCommit/afterCommit);业务可插入自定义校验/日志/埋点。
  • 边界:尽量,异步回调需有防回流(ticket)与取消策略。

3. 目录结构(示例)

text
components/
  numeric-input/
    ui/
      NumericInput.vue           # L1 外观层(可多皮肤)
      NumericInputHeadless.vue   # L2 Headless 层
    composables/
      useNumericInteractions.ts  # L3 适配层(桥接 UI 与引擎)
    core/                        # L4-L6
      numeric-input-core.ts      # L4 引擎(状态编排)
      numeric-pipeline/
        stages.ts                # L5 工序(纯函数集合)
        explicit.ts              # 显式管线(提交点)
        implicit.ts              # 隐式管线(初始化/重置)
      parseFormat.ts             # L6 默认 parse/format,可被覆盖
      types.ts                   # 公共类型
      utils/                     # 十进制、步长、范围等细粒度工具
        decimal.ts
        step.ts
        range.ts
        validate.ts

4. 核心代码骨架(可粘贴后替换细节)

4.1 工序层(L5)最小骨架

ts
// stages.ts
import Decimal from "decimal.js";
import { parseNumber, formatNumber } from "../parseFormat";
import type { NumericInputCoreOptions } from "../types";

export const trimIn = (v: unknown, mode: "start" | "both" = "start") =>
  typeof v === "string" ? (mode === "both" ? v.trim() : v.trimStart()) : v;

export const stageParse = (
  v: unknown,
  opts: NumericInputCoreOptions
): Decimal | null =>
  typeof v === "string"
    ? (opts.parseFn ?? parseNumber)(v, opts)
    : v == null
    ? null
    : new Decimal(v as any);

export const stageHardValidate = (
  d: Decimal,
  opts: NumericInputCoreOptions
): string[] => {
  const errs: string[] = [];
  if (opts.allowNegative === false && d.isNegative()) errs.push("nonNegative");
  const min = opts.min == null ? null : new Decimal(opts.min as any);
  const max = opts.max == null ? null : new Decimal(opts.max as any);
  if (min && d.lessThan(min)) errs.push("min");
  if (max && d.greaterThan(max)) errs.push("max");
  return errs;
};

// 其余:精度/步长对齐/夹取/非负/格式化(略,按你现有实现)
export const stageFormat = (d: Decimal | null, opts: NumericInputCoreOptions) =>
  d == null ? "" : (opts.formatFn ?? formatNumber)(d, opts);

4.2 显式/隐式管线(L5 的两种编排)

ts
// explicit.ts
import Decimal from 'decimal.js'
import type { NumericInputCoreOptions } from '../types'
import type { PipelineResult } from './types'
import {
  trimIn, stageParse, stageHardValidate, /* precision/snap/clamp/nonNeg */, stageFormat
} from './stages'

export function applyExplicitPipelineCore(
  input: unknown,
  opts: Readonly<NumericInputCoreOptions>
): PipelineResult {
  const s = trimIn(input, 'both')
  if (typeof s === 'string' && s === '') {
    return { ok: true, value: null, formatted: '', errors: [] }
  }
  const d = stageParse(s, opts)
  if (!d) return { ok: false, errors: ['nan'] }

  const hard = stageHardValidate(d, opts)
  if (hard.length) return { ok: false, errors: hard }

  let v: Decimal = d
  // v = stagePrecision(v, opts)
  // v = stageSnap(v, opts)
  // v = stageClamp(v, opts)
  // v = stageNonNegative(v, opts)

  return { ok: true, value: v, formatted: stageFormat(v, opts), errors: [] }
}
ts
// implicit.ts
import Decimal from 'decimal.js'
import type { NumericInputCoreOptions } from '../types'
import type { PipelineResult } from './types'
import { trimIn, stageParse, /* precision/snap/clamp/nonNeg */, stageFormat } from './stages'

export function applyImplicitPipelineCore(
  input: unknown,
  opts: Readonly<NumericInputCoreOptions>
): PipelineResult {
  const s = trimIn(input, 'start')
  const d = stageParse(s, opts)
  if (!d) return { ok: true, value: null, formatted: '', errors: [] }

  let v: Decimal = d
  // v = stagePrecision(v, opts)
  // v = stageSnap(v, opts)
  // v = stageClamp(v, opts)
  // v = stageNonNegative(v, opts)

  return { ok: true, value: v, formatted: stageFormat(v, opts), errors: [] }
}

4.3 引擎层(L4)要点(伪代码)

ts
class CoreEngine {
  state = {
    rawValue: null,
    formattedValue: "",
    isValid: true,
    errors: [],
    validating: false,
  };
  opts: Readonly<NumericInputCoreOptions>;
  commitOn: "blur" | "enter" | "manual" | "throttle" =
    this.opts.commitOn ?? "blur";
  throttleMs = this.opts.throttleMs ?? 250;
  private t?: any;

  handleInput(s: string, { composing = false } = {}) {
    // 软校验:parse + 硬约束 + 同步自定义;只更新 errors,不改 raw
    const errs = softValidate(s, this.opts); // 由 stages 组合而成
    this.state.isValid = errs.length === 0;
    this.state.errors = errs;
    this.state.formattedValue = s;

    if (this.commitOn === "throttle" && !composing && errs.length === 0) {
      clearTimeout(this.t);
      this.t = setTimeout(() => this.commit(), this.throttleMs);
    }
  }

  blur() {
    if (this.commitOn === "blur") this.commit();
    else if (this.commitOn === "throttle") {
      clearTimeout(this.t);
      this.commit();
    }
  }

  commit() {
    const ret = applyExplicitPipelineCore(this.state.formattedValue, this.opts);
    if (!ret.ok) {
      this.state.isValid = false;
      this.state.errors = ret.errors;
      return;
    }
    this.state.rawValue = ret.value;
    this.state.formattedValue = ret.formatted;
    this.state.isValid = true;
    this.state.errors = [];
    // 异步校验(如配置)
  }
}

5. 与其它模式对比(怎么选)

模式核心思想优点代价何时用
六层模型(本文)外观/Headless/适配/引擎/工序/扩展清晰可替换、可测、适配广多些文件80% 交互型组件
状态机/状态图状态 + 事件 + 转移异步/并发强、可视化初始学习成本复杂时序/多子状态
Reducer/TEA/MVIIntent→Reducer→State流程清晰、日志/回放友好样板事件多、团队熟悉
Command + Undo每步是命令,可撤销可撤销/重做需要历史栈编辑器/表单历史
Reactive Streams事件流、节流/去抖、可取消节流并发强心智负担多来源合并/可取消异步
六边形/洁净端口适配,内核无关外界可替换性极强初期设计要换平台/第三方

选择建议

  • 一般表单/输入:本文六层模型即可。
  • 异步/并发复杂:加状态机Actor(输入、节流、异步校验分子状态)。
  • 要撤销:在引擎上加 Command 历史栈
  • 团队已经在用 Vuex/Redux:引擎改造为 Reducer 风格。

6. 可测试性与发布质量

  • L5 工序:纯函数表驱动测试(min/max/precision/step/clamp…)。
  • L4 引擎:事件序列测试(input → blur → commit),IME/节流两类路径。
  • L2 Headless:语义事件是否被正确触发(input/change/error)。
  • L1 外观:快照 + 辅助可访问性(a11y)检查。

7. 体验细节 Checklist(直接抄用)

  • [x] 输入期只做软校验(不改 raw,不跳光标)。
  • [x] 支持 IME 合成:合成期不触发节流提交;合成结束再走一次输入处理。
  • [x] 空串 = 清空:视为 null,不当作 NaN 报错。
  • [x] 提交点显式收敛精度 → 步长 → 夹取 → 非负 → 格式化
  • [x] 支持 commitOn = 'blur' | 'enter' | 'manual' | 'throttle'
  • [x] 异步校验使用 ticket 防回流覆盖。
  • [x] parse/format 可注入(国际化/货币符号/千分位)。

8. 结语

  • 这套分层不绑定框架和业务,你可以把它迁移到 React/Svelte/原生 Web Components。
  • 当需求升级(撤销、并发校验、跨字段联动),优先在引擎层加能力,尽量不触动 UI 与工序。
  • 真正的“可复用组件”不是更多的 props,而是结构稳定、边界清晰的层次

有需要,我可以把你现有的 Numeric Input 仓库按本文结构重排,并给出对应的测试样例集和 Storybook 演示页。

本站总访问