Skip to content
On this page

vue3 Form render 实现


vue3 Form render 实现

之前我们提到需要对模板和组件暴露出来的可编辑 props 进行 JSON Schema 的表达,但是 JSON Schema 如何转化成业务可用的表单项进行数据编辑呢?所以我们需要一个 form render 来实现 JSON Schema表单 的转换能力。

调研

业界有没有比较好的方案呢?答案是肯定的,比如以下几个表单渲染工具:

Formliy 是一款比较强大的表单渲染器,目前对 React 支持最友好,Vue 相关的有一个 vue-formly 但也仅仅是 Vue 2.x 的。还有就是 Formliy 过于强大,不仅仅支持 JSON Schema 还支持 JSX Schema 渲染表单。而我们只是单纯需要像 Form Render 这样的 JSON Schema 标准的轻量易用型框架。但是 Form Render 目前也仅支持 React 而我们的技术选型是 vue3,所以话不多说,自己造轮子。

我们先给自己定一个方向,我们的 vue-form-render 一定是非常易用的,需要接受一个 schema 入参用于表单描述,再接受一个 formData 入参作为表单初始值,当表单进行变更时需要提供 change 事件通知前端更新数据,如果表单输入不合法,则需要通过 validate 事件告知前端,所以终态一定是这样的:

html
<formRender
  :schema="schema"
  :formData="formData"
  @on-change="change"
  @on-validate="validate"
/>

接下来我们开始详细介绍如何实现一个 vue3-form-render。先看一下我们实现的表单效果:

formData 处理

还拿之前的 banner组件 举例,对于 banner 组件 来说渲染的 form 代码我们会这么写:

html
<template>
  <div>
    <formRender
      :schema="schema"
      :formData="formData"
      @on-change="change"
      @on-validate="validate"
	/>
  </div>
</template>
<script>
export default {
  name: 'App',
  setup() {
    const state = reactive({
      schema: {
        "type": "object",
        "properties": {
          "src": {
            "title": "图片地址",
            "type": "string",
            "format": "image"
          },
          "link": {
            "title": "跳转链接",
            "type": "string",
            "format": "url"
          }
        },
        "required": [
          "src"
        ]
      },
      formData: {},
    });

    const change = (v) => {
      state.formData = v;
      // console.log(v);
    }
    const onValidate = (v) => {
      console.log(v);
    }

    return {
      ...toRefs(state),
      change,
      onValidate,
    }
  }
}
</script>

注意一下这里的 formData 参数是一个空对象,是没有默认值的一个具体的值,此时对于简单的 string 类型来说没有什么问题,但是如果我们的 schema 是个多级的对象,或者不仅仅是string类型,比如这样:

json
{
  "type": "object",
  "properties": {
    "object": {
      "type": "object",
      "title": "object",
      properties: {
        string: {
          title: '字符串',
          type: 'string'
        },
      }
    },
    "radio": {
      "title": "是否通过",
      "type": "boolean"
    }
  }
}

我们再渲染的时候,就可能面临 formData.object.string 这的操作,或者是 formData.radio 因为他们都是没有默认值的,前者可能会报错,后者可能会出现展示问题。所以我们需要对 formData 按照 schema 的规范对未给定默认值的属性进行初始化:

javascript
function resolve(schema, data, options = {}) {
  const {
    // 类型
    type
  } = schema;
  // 数据未初始化,给定默认值
  const value =
    typeof data === 'undefined' ? getDefaultValue(schema) : clone(data);

  if (type === 'object') {
    // 递归
    Object.keys(subs).forEach(name => {
      // ...
      resolve(subs[name], value[name], options);
    });
  }
  if (type === 'array') {
    // 递归
    value.forEach((item, idx) => {
      // ...
      resolve(subs[idx] || subs[0], item, options);
    });
  }
  return value;
}

然后我们再根据 scheme 类型,制定默认数据:

javascript
function getDefaultValue(schame) {
  const { type } = schema;
  const defaultValue = {
    array: [],
    boolean: false,
    integer: '',
    null: null,
    number: '',
    object: {},
    string: '',
    range: null,
  };
  
  // ...
  
  return defaultValue[type];
}

到这里我们的 formData 会被处理成这样的格式:

{
  "src": "",
  "link": ""
}

表单渲染

表单渲染这块的逻辑,最简单的方式就是写很多很多功能组件,比如 inputnumberrichText 等等,然后再根据 schema 的 type 属性来确定用哪个组件,比如:

javascript
const mapping = {
  default: 'input',
  string: 'input',
  object: 'map',
  array: 'array',
  number: 'number',
  'string:color': 'color',
}

const widgets = {
  input,
  map: index,
  url,
  color,
  array,
  number,
}

然后根据 type 来动态引用:

javascript
export default {
  setup() {
    return () => {
      const Field = widgets[mapping[`${props.schema.type}${props.schema.format ? `:${props.schema.format}` : ''}`]];
      return (
        <div className="vue-form-render">
          <Field
            schema={props.schema}
            formData={data}
            value={data}
            onChange={handleChange}
          />
        </div>
      )
    }
  }
}

注意,我这里使用的是 jsx 语法,因为 jsx 对于一些高度复杂的 ui 逻辑处理还是非常方便的。其次我们可以毫无保留的复用大部分 form render 的能力,因为 form render 是基于 react 来实现的,无非我们就是用 vue3 翻译了一遍而已。 最后再处理一下针对多级前端对象的循环渲染:

javascript
// object
Object.keys(props.value).map((name, index) => {
  const schema = childrenSchemas[index].schema;
  const Field = widgets[mapping[`${schema.type}${schema.format ? `:${schema.format}` : ''}`]];
  if (!Field) return null;
  return (
    <Field
      value={props.value[name]}
      schema={schema}
      name={name}
      onChange={(key, val) => {
        const value = {
          ...props.value,
          [key]: val,
        };
        props.onChange(props.name, value);
      }}
    />
  )
});
// array 类似

校验

校验需要做的一方面是对数据格式的基础校验,一方面需要对用户自定义规则校验。先说一下数据格式的校验,比如对url的格式校验,则需要图片必须是符合域名规范的格式,图片必须是符合图片规范的格式,所以针对这一类的校验我们也可以通过 type 来判断:

javascript
if (format === 'image') {
  const imagePattern =
    '([/|.|w|s|-])*.(?:jpg|gif|png|bmp|apng|webp|jpeg|json)';
  // image 里也可以填写网络链接
  const _isUrl = isUrl(value);
  const _isImg = new RegExp(imagePattern).test(value);
  if (usePattern) {
    // ignore
  } else if (value && !_isUrl && !_isImg) {
    return (message && message.image) || '请输入正确的图片格式';
  }
}

if (format === 'url') {
  if (usePattern) {
    // ignore
  } else if (value && !isUrl(value)) {
    return (message && message.url) || '请输入正确的url格式';
  }
}

如果是用户自定义的格式,我们也需要去支持正则匹配:

javascript
// 正则只对数字和字符串有效果
// value 有值的时候才去算 pattern。从场景反馈还是这样好
if (value && usePattern && !new RegExp(pattern).test(value)) {
  return (message && message.pattern) || '格式不匹配';
}

编辑器使用

编辑器要使用 form render 则可以根据组件配置的 schema 来动态渲染表单:

javascript
import FromRender from 'vue-form-render';
import 'vue-form-render/lib/vue-form-render.css';

import {useStore} from 'vuex';

export default {
  props: {
    // 当前选中的组件
    currentComponent: Object
  },
  setup(props) {
    const {commit} = useStore();
    const changeProps = (payload) => {
      // 触发组件编辑功能
      commit('changeProps', payload);
    }

    return () => {
      if (!props.currentComponent) return null;
      const {component, currentComponentSchema} = props.currentComponent;
      if (!currentComponentSchema) return null;
      return (
        <div class="form-container">
          <FromRender
            schema={currentComponentSchema.schema}
            formData={component.props}
            onOnChange={(e) => changeProps({...e})}
          />
        </div>
      );
    }
  }
}

结语

本章节实现的 vue3-form-render 的架构也可抽象表示成:

实现代码已上传至 Github,更多实现细节可以自行查看