ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JSON Schema驱动HTML表单生成器:从数据到界面的自动化实践

JSON Schema驱动HTML表单生成器:从数据到界面的自动化实践 1. 别急着写 HTML先想清楚为什么要让 JSON Schema 驱动表单1.1 表单不只是几个 input 拼在一起做过后台管理系统的人都知道表单是整个系统中看起来最简单、实际迭代最痛苦的部分。今天加一个字段明天改一下下拉选项后天又要调整校验规则。如果每个表单都是手写 HTML 手写校验逻辑改动一个字段往往要同时动模板、动脚本、动提交流程漏改一处就是线上问题。我手搓这个 HTML 表单生成器的初衷很简单不想要那种“表单和数据结构各写各的”状态。既然接口的入参就是 JSON为什么不能直接拿一份 JSON Schema 当唯一数据源让表单界面自动生成这样字段变了界面跟着变校验也跟着变维护成本会低很多。1.2 JSON Schema 解决的是“表单与数据脱节”问题JSON Schema 本身是一套描述 JSON 数据结构的规范光从名字看像是在做接口文档校验但它天然适合用来描述表单它规定了字段类型、是否必填、默认值、枚举选项、格式约束。这些信息和表单的需求几乎一一对应。举个例子接口需要接收一个userName字段要求字符串、长度 2 到 20、必填。用 JSON Schema 写就是{ $schema: http://json-schema.org/draft-07/schema#, type: object, properties: { userName: { type: string, title: 用户名, minLength: 2, maxLength: 20 } }, required: [userName] }这段描述里面已经包含了三个信息字段类型是文本输入框、界面上的标签叫“用户名”、提交前要校验长度和必填。传统的开发方式是前端拿到这个需求后再单独写一遍 HTML再单独写一遍 JS 校验等于把同一份信息重复维护了两次。用 Schema 驱动之后界面和校验都是从这份 JSON 推导出来的不会出现两边不一致的情况。1.3 哪些项目适合手搓哪些该用现成方案市面上已经有很多成熟的表单生成方案比如基于 JSON Schema 的 react-jsonschema-form、Formily或者各类低代码平台里的表单设计器。既然有现成的为什么还要手搓我的判断标准很简单如果项目里表单数量巨大、自定义组件封装要求高而且团队有能力维护底层渲染逻辑手搓一个轻量生成器反而比接框架更灵活。框架封装得太重想改一个控件渲染方式经常需要写扩展插件文档还不一定讲得清楚。但如果你只是内部系统里有三五个表单那直接用现成组件库里的 Schema 表单组件就够了没必要重复造轮子。手搓还有一个额外好处你对整个渲染链路有完全掌控权。出问题时不会黑盒加功能时不会受框架约束。这个项目我拿原生 HTML CSS JavaScript 实现不依赖任何框架跑在浏览器里就是一个独立的工具函数。2. 先拆规矩JSON Schema 字段类型到 HTML 控件的映射表2.1 类型映射string / number / integer / boolean / array / object要做生成器第一件事就是把 JSON Schema 里出现的类型和 HTML 表单控件建立映射关系。这一步看起来简单但细节很容易翻车。我的基础映射表如下JSON Schema 类型默认渲染控件可追加控件说明stringinput typetexttextarea、select、radio、date字符串承载能力最强需要结合 format 关键字判断numberinput typenumberrange浮点数注意 step 设置integerinput typenumberrange整数step 固定为 1booleaninput typecheckboxselect单选布尔值array动态列表多选 checkbox、tags数组的渲染选项比较多我单独做了一套容器object内部嵌套渲染fieldset 分组递归渲染子属性这里有一个容易踩的点JSON Schema 的number类型在 HTML 的input typenumber里value 拿到的其实是一个字符串需要自己转成数字。我一般是提交取值时统一转换而不是在渲染时做。string类型不能一概而论。同一个字符串字段加上了format: email就该渲染成input typeemail加上format: date就该渲染成input typedate。我做了两层判断第一层看type第二层看format两者组合决定最终控件。2.2 常用关键字处理title、description、default、enum、constJSON Schema 里有一套描述性关键字在渲染表单界面时非常重要很多人只关注type和required把title和description忽略了结果生成的表单标签很丑全显示字段名。title当作表单 label 文本。description当作输入框下方的帮助提示。default初始化时给输入框赋值也承担表单纯前端预览时的默认数据填充。enum有枚举值时字符串字段渲染成select下拉框每个枚举项对应一个 option。const表示字段值是固定的可以直接渲染成只读文本。readOnly渲染成带 readonly 或 disabled 属性的输入框。我的处理顺序是先判断readOnly和const如果字段不可编辑就不用渲染复杂控件直接一个文本框加 disabled 属性就行。然后判断enum优先渲染成下拉框。最后才根据type和format选择普通输入控件。这个顺序一旦反了会出现明明有枚举值却渲染成自由文本的情况。2.3 UI 扩展约定x- 前缀与额外参数JSON Schema 标准里允许扩展自定义关键字只要不跟标准关键字冲突就行。为了不污染标准字段我约定 UI 相关的扩展参数统一用x-开头比如{ type: object, x-layout: grid, properties: { gender: { type: string, enum: [male, female], x-component: select, x-placeholder: 请选择性别 } } }这里x-component用来强制指定控件类型x-placeholder覆盖默认 placeholder。这些参数在渲染时被读取但不会被当成表单字段提交。我建议在项目一开始就固定一套x-扩展规范否则各种团队各写各的就乱套。之前我用过ui:widget这类写法纯 UI 配置和 Schema 混在一起拆分困难后来统一改成x-前缀才消停。3. 核心代码从 Schema 到表单界面的渲染器3.1 递归渲染的入口与思路既然object类型里还能嵌套objectarray类型里还能嵌套object那么渲染器必须是一个递归函数。我的核心渲染函数叫做renderSchema(schema, value)它接收当前子 Schema 和当前默认值返回一段 HTML 字符串或 DOM 节点。这里有个设计取舍用字符串拼接还是用 DOM API。字符串拼接很简单调试时一眼就能看出生成的结构但遇到事件绑定会比较麻烦。DOM API 可读性差一些但事件管理更自然。我选择的是字符串拼接 事件委托。整个表单渲染到一个容器里不关心具体某个输入框是什么时候生成的所有事件都委托到顶层容器上。这样不管表单怎么嵌套事件处理都只需要注册一次。渲染入口大概是这样的逻辑function renderSchema(schema, name, value) { const type schema.type || inferType(schema); if (type object) return renderObject(schema, name, value); if (type array) return renderArray(schema, name, value); return renderField(schema, name, value); }inferType用来处理没有显式type的字段比如只有一个enum那它本质上就是字符串。这种容错处理在实际 Schema 里经常用到因为不是所有后端同事都会规范地写全type。3.2 各类字段渲染实现以最常用的renderField为例它的职责是输出一个完整的表单行包含 label、控件、帮助文案和校验提示容器。function renderField(schema, name, value) { const label schema.title || name; const required schema.required ? span classrequired*/span : ; const desc schema.description ? div classfield-desc${schema.description}/div : ; const control renderControl(schema, name, value); return div classform-item>function renderControl(schema, name, value) { const val value ?? schema.default ?? ; if (schema.readOnly) { return input typetext value${escapeHtml(val)} disabled /; } if (schema.const ! undefined) { return span classconst-value${escapeHtml(schema.const)}/span; } if (schema.enum) { return renderSelect(schema, name, val); } if (schema.type boolean) { return input typecheckbox ${val ? checked : } /; } const inputType mapType(schema); const placeholder schema[x-placeholder] ?? ; const step schema.type integer ? step1 : ; return input type${inputType} value${escapeHtml(val)} placeholder${placeholder} ${step} /; }这个函数看起来短但实际我在不同版本里加了大量边界判断。比如value 0的时候如果用||去取默认值0 会丢失必须用??来判断空值。再比如 value 是对象时不能直接拼到字符串里必须先 JSON.stringify 再 escapeHtml。3.3 布局封装如何让默认生成的表单不乱表单生成的最终界面不能是一堆纵向堆叠的输入框那样长表单根本没法看。我在渲染外层加一个布局配置默认支持grid和inline两种模式{ x-layout: grid, x-layout-props: { columns: 2 } }渲染object字段时读取x-layout如果是 grid就按列数把子字段分到网格里。实现方式不复杂就是把子字段的 HTML 拼好之后放到一个带grid-template-columns的容器里。布局这块有个原则我一直记着布局是样式层的东西不应该写死在渲染函数里。所以布局的生成我单拆了一个renderLayout函数它只负责怎么摆放子字段不管子字段的渲染细节。这样 Schema 负责内容布局配置负责外观互不干扰。4. 数据回填、校验与值收集让表单真正“活”起来4.1 从表单取值并对照 Schema 校验渲染器只是把界面画出来真正有价值的表单生成器还必须能从界面上反向收集数据并且按 Schema 规则校验。我的取值逻辑也是递归的从最外层容器开始遍历每一个[data-field]节点function collectData(container) { const result {}; const items container.querySelectorAll([data-field]); items.forEach((item) { const name item.dataset.field; const schema item.dataset.schema ? JSON.parse(item.dataset.schema) : null; const control item.querySelector(input, select, textarea); if (!control) return; result[name] getControlValue(control, schema); }); return result; }这个版本只处理一层字段如果遇到嵌套 object需要递归遍历。我在实现时给每个 form-item 都挂了一个>{ hasCompany: { type: boolean, title: 是否为企业用户 }, companyName: { type: string, title: 企业名称, x-visible-if: { field: hasCompany, equals: true } } }渲染时不管显隐条件是否满足我先把所有字段渲染出来然后用[x-visible-if]存储规则监听关联字段的 change 事件动态判断当前值是否满足条件不满足就隐藏整个 form-item。这个方案的优点是不用重新渲染整个表单字段内部状态不会丢失。代价是隐藏只是 CSS 层级的控制字段 DOM 还在取值时要注意别把隐藏字段的值收集进去。我在collectData里会过滤掉父容器被隐藏的字段。联动另一个常见场景是选项筛选比如两个 select 级联。做法是给 select 加一个x-options-from配置指向另一个字段的值作为过滤条件。核心思路是选项数据不写死在 Schema 里而是写一个函数动态生成。我定义了一套dataSource机制通过x-data-source指定选项来源。5. 实操中的坑与解决方案5.1 enum 和 boolean 的默认值容易被忽略这算是我踩得最深的坑之一。很多 JSON Schema 字段定义了enum但没有写default结果 select 下拉框渲染出来第一项是空白的用户不选也能通过校验提交的值是 null后端直接报错。解决思路是对于 enum 字段如果 Schema 里没有default且没有允许空值的x-allow-empty渲染时自动把枚举项的第一项作为值。boolean 字段也一样checkbox 在未勾选时不会出现在 FormData 里初始状态必须明确处理成false而不是 null。我在渲染函数里加了一个resolveDefault(schema)function resolveDefault(schema) { if (schema.default ! undefined) return schema.default; if (schema.enum schema.enum.length 0) return schema.enum[0]; if (schema.type boolean) return false; if (schema.type array) return []; if (schema.type object) return {}; return ; }这个函数必须早于渲染执行否则每次打开表单默认值都不一致。5.2 HTML 转义没做好页面会被 Schema 内容搞崩JSON Schema 是外部传入的数据里面的title、description、枚举值都可能是用户输入的。如果我直接把它们拼到 HTML 字符串里遇到包含script或者img onerror的内容页面就会出问题。我写了一个escapeHtml函数对 做替换function escapeHtml(str) { return String(str) .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;) .replace(//g, #39;); }所有从 Schema 中取出来拼到 HTML 里的字符串都必须经过escapeHtml。这不是小题大做Schema 一旦来自后端平台就成了不可信输入。5.3 number 类型输入框的取值字符串问题我在前面提过一次这里具体展开。input typenumber的value属性是字符串哪怕输入的是数字。如果直接提交给接口后端严格校验类型时会报 400。我的处理是给控件加一个>function getControlValue(control, schema) { const type schema.type; if (type integer || type number) { return control.value ? null : Number(control.value); } if (type boolean) return control.checked; if (type array) return gatherArrayValue(control); return control.value; }这里还要注意Number(abc)会得到 NaN所以取值前我会先做一次control.validity.valid判断确保输入框自带的类型校验已经通过。5.4 事件绑定的时机与委托冲突字符串拼接渲染最大的麻烦是渲染出来的输入框没有绑定事件。最开始我在每个字段渲染完后手动绑事件后来发现动态增删字段后新字段没有事件老字段还在整个绑定逻辑会越来越乱。最终方案是事件委托。我把 change、input、blur 三个事件绑定在表单根容器上formContainer.addEventListener(change, handleFormChange);handleFormChange里通过event.target.closest([data-field])找到当前字段的容器再根据容器上的>if (item.offsetParent null) return;这个判断比较直接如果字段被display: none隐藏offsetParent就是 null。不过要注意这个判断依赖 CSS 样式是 display 层级的隐藏如果用的是visibility: hidden还得额外加判断。6. 继续扩展从数组嵌套到自定义组件再到接入接口6.1 数组字段的增删改实现数组类型是表单生成器里最复杂的部分比如一个用户需要维护多个联系方式数据结构是对象数组界面要提供新增、删除、排序操作。我的渲染思路是渲染一个容器容器里显示已有项。每一项都生成一个独立的子表单子表单内部递归调用renderSchema。提供“添加一项”按钮点击后按子 Schema 的默认值生成一条新记录并追加到容器内。数组项操作的事件也走委托。我给按钮加上>button typebutton>schemaForm.registerComponent(dept-select, { init(element, schema, value) { // 初始化自定义控件生成 DOM }, getValue(element) { // 从自定义控件中取值 }, setValue(element, value) { // 回填值 } });Schema 里通过x-component: dept-select指定控件类型渲染器遇到registerComponent里注册过的名称时不调用默认渲染逻辑而是调用自定义组件的 init 方法。这个设计让生成器有了扩展点不会被内置控件限制住。6.3 与接口联调及错误展示表单生成器真正落地时要和接口联调的部分包括初始化时从接口拉取详情数据回填表单、提交时把表单数据 POST 回接口、接口返回错误后把错误信息显示到对应字段上。回填数据我单独实现了setFormData(data)function setFormData(schemaFormData) { // 遍历 data 的 key Object.keys(schemaFormData).forEach((key) { const field container.querySelector([data-field${key}]); if (field) { const control field.querySelector(input, select, textarea); setControlValue(control, schemaFormData[key]); } }); }注意setControlValue和getControlValue一样要处理类型转换checkbox 对应checkednumber 对应value赋值为字符串。接口返回错误展示这块我的做法是保持前后端字段名一致后端在错误对象里返回 field 和 message前端遍历错误数组逐个找到对应字段并显示.field-error。这样表单提交失败时错误会直接出现在具体输入框旁边体验比弹一个全局提示好很多。6.4 让生成器支持非标准 Schema 的兜底策略最后聊一个比较少被提到但实际非常重要的问题现实世界的 JSON Schema 往往不完全符合规范。有些后端同事会漏写type有些会把required直接写成布尔值有些枚举值不是字符串而是混合类型。我的兜底策略是每到一个字段就先做一次 schema 归一化function normalizeSchema(schema) { if (schema.type string schema.enum) { schema.enum schema.enum.map(String); } if (!schema.type schema.properties) { schema.type object; } if (!schema.type Array.isArray(schema.items)) { schema.type array; } if (typeof schema.required boolean) { schema.required schema.required ? [] : schema.required; } return schema; }这段代码能解决大部分不规范的 Schema。处理原则是能不报错就不报错能按最合理的解释渲染就按最合理的解释渲染。如果实在无法判断就在控制台输出一个 warning而不是让整个页面白屏。做工具类的东西容错比报错重要得多。我在实际项目里跑这套渲染逻辑跑了小半年踩的坑基本都摆在上面了。手搓表单生成器这件事难点不在写几个 input而在于你想清楚数据结构和界面之间的关系。JSON Schema 的优势是它已经把数据结构描述这套事标准化了你要做的只是把映射规则稳定下来让它变成一套可复用、可扩展的组件。等项目跑起来之后你会发现后续新增表单字段的工作量远比从前手写表单的时代要小。
返回列表