Appearance
这是一篇适合直接发布在 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.ts4. 核心代码骨架(可粘贴后替换细节)
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/MVI | Intent→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 演示页。