0.1.0 • Published 1 year ago

@ga23187/vue3-json-schema-form v0.1.0

Weekly downloads
-
License
-
Repository
-
Last release
1 year ago

课程地址:https://coding.imooc.com/class/466.html

源码地址::https://github.com/BestVue3/vue3-jsonschema-from

类型开源项目:https://github.com/lljj-x/vue-json-schema-form

项目初始化

开始

借助vue-cli5

image-20230226015636836

初始目录

image-20230226015934257

prettier

一个代码格式化工具

  • 上面项目创建时已经选择了prettier,在node_modules中已经存在依赖了,现在只需要在vscode中也装一下这个插件方便在开发时也格式化就行了。

  • 然后在项目根目录创建prettierrc文件

    {
      "semi": false, // 是否使用分号
      "singleQuote": true, // 是否使用单引号
      "arrowParens": "always", // 匿名函数单个参数是否需要带括号
      "trailingComma": "all",
      "endOfLine": "auto" // 不让prettier检测文件每行结束的格式
    }
  • 然后配置vscode保存触发代码格式化

    • 设置中的Editor下的format on Save
    • 注意vscode配置分用户与工作目录下,二种权限,一般使用工作目录下的权限
  • 记得重启一下vscode,不然eslint可能会提示有问题,估计是配置的prettier配置文件没有读取到eslint下

eslint

补充

vue-cli5生成的.eslintrc.js配置文件中缺少vue3的宏定义相关的api即defineProps这些

需要添加

 env: {
  node: true,
  'vue/setup-compiler-macros': true,// 这个

 },

参考:【Vue3】解决‘defineProps‘ is not defined报错

TS组件定义

Component接口

defineComponent函数

接收4种调用方式

import { defineComponent,ref } from 'vue'

export default defineComponent(function HelloWorld() {
  const msg = ref('Hello world')
  return { msg }
})
import { defineComponent } from 'vue'

export default defineComponent({
  name: 'App',
  components: {
    HelloWorld,
  },
})

定义Props类型

import type { DefineComponent } from 'vue'

type myComponents = DefineComponent<{ a: string }>

提取props定义

提取之前,required是有效的

image-20230226220559655

提取之后,无效了

image-20230226220711331

解决办法:as const

image-20230226220853717

原因: vue源码中这一段

image-20230226221636698

h函数

import { createApp, defineComponent, h } from 'vue'
import HelloWorld from './components/HelloWorld.vue'
// import App from './App.vue'
// import img from './assets/logo.png'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const img = require('./assets/logo.png')

const App = defineComponent({
  render() {
    return h(
      'div',
      {
        id: 'app',
      },
      [
        h('img', {
          alt: 'Vue logo',
          //   src: './assets/logo.png', // 在template中的src引入会经过webpack的loader处理,这里直接写字符串是不出处理的
          src: img,
        }),
        h(HelloWorld, {
          msg: 'Hhhh',
          age: 123,
        }),
      ],
    )
  },
})
createApp(App).mount('#app')

template最终也是被编译为render函数

h函数实现

image-20230226224231274

setup运用和意义

setup返回一个对象

<script lang="ts">
import {
  computed,
  defineComponent,
  reactive,
  ref,
  toRefs,
  watchEffect,
} from 'vue'
import HelloWorld from './components/HelloWorld.vue'

export default defineComponent({
  name: 'App',
  components: {
    HelloWorld,
  },
  // setup(props, { slots, attrs, emit }) {
  //   console.log(props, slots, attrs, emit)
  //   const state = reactive({
  //     name: 'zyq',
  //   })
  //   setInterval(() => {
  //     state.name += 1
  //   }, 1000)
  //   return {
  //     ...toRefs(state), // 解构会丢失响应式,借助toRefs方法重新响应式
  //   }
  // },
  setup(props, { slots, attrs, emit }) {
    console.log(props, slots, attrs, emit)
    const name = ref('zyq')

    // setInterval(() => {
    //   name.value += 1
    // }, 1000)

    const computedName = computed(() => {
      return name.value + 2
    })

    watchEffect(() => {
      console.log(name.value) // 只会监听里面出现过的响应式对象
    })

    return {
      name,
      computedName,
    }
  },
})
</script>

setup返回一个render

import { createApp, defineComponent, h, reactive, ref } from 'vue'
import HelloWorld from './components/HelloWorld.vue'
// import App from './App.vue'
// import img from './assets/logo.png'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const img = require('./assets/logo.png')

const App = defineComponent({
  setup() {
    const state = reactive({
      name: 'zyq',
    })
    const number = ref(1)

    setInterval(() => {
      state.name += 1
      number.value += 1
    }, 1000)

    // 无效(setup只会执行一次,所以在这里只会执行一次,而render函数是会随着响应式数据的更新重新执行的,所以下面的numberX会重新赋值)
    // const numberX = number.value

    // 返回一个函数 闭包 可以访问外层函数定义的变量
    return () => {
      // 有效
      const numberX = number.value
      return h(
        'div',
        {
          id: 'app',
        },
        [
          h('img', {
            alt: 'Vue logo',
            //   src: './assets/logo.png',
            src: img,
          }),
          //   h('p', state.name + '|' + number.value),
          h('p', state.name + '|' + numberX),
        ],
      )
    }
  },
})

使用jsx开发vue组件

JSX支持

安装

npm install @vue/babel-plugin-jsx -D

babel.config.js添加如下配置

{
  "plugins": ["@vue/babel-plugin-jsx"]
}

使用

JSX的文件需要以jsxtsx结尾。

新建一个App.tsx文件

import { defineComponent, reactive, ref } from 'vue'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const img = require('./assets/logo.png')

export default defineComponent({
  setup() {
    const state = reactive({
      name: 'zyq',
    })
    const number = ref(1)

    setInterval(() => {
      state.name += 1
      number.value += 1
    }, 1000)
    // 无效(setup只会执行一次,所以在这里只会执行一次,而render函数是会随着响应式数据的更新重新执行的,所以下面的numberX会重新赋值)
    // const numberX = number.value

    return () => {
      // 有效
      const numberX = number.value
      return (
        <div id="app">
          <img src={img} alt="Vue logo" />
          <p>{state.name + '|' + numberX}</p>
        </div>
      )
    }
  },
})

vue2--->vue3 的jsx变化

拍平了属性

下图就式rfc中关于vue2到vue3的变化

image-20230227000228012

特色

比单文件组件更灵活

import HelloWorld from './components/HelloWorld.vue'

export default defineComponent({
  setup() {
    const renderHellow = (age: number) => {
      return <HelloWorld age={age} />
    }
    return () => {
      return (
        <div id="app">
          {/* jsx可以让eslint知道age是必填的 */}
          {/* <HelloWorld age={12} /> */}
          {renderHellow(12)}
        </div>
      )
    }
  },
})

解决eslint报错了,但是vscode没有提示HelloWorld必填属性为空

原因是因为所有的单文件组件定义都是在shims-vue.d.ts中定义的,ts就检测不出来你使用错误了。

解决办法就是把HelloWorld改为jsx文件

使用指令

export default defineComponent({
  setup() {
    const inputValue = ref('')
    return () => {
      console.log(inputValue.value, 'inputValue.value')
      return (
        <div id="app">
          <input type="text" v-model={inputValue.value} />
        </div>
      )
    }
  },
})

JsonSchema

一种规范,用于定义JSON数据,校验JSON数据。不同语言有不同的实现方式。

官方定义

JSON Schema 规范(中文版)

这里我们使用ajv这个库。注意我学习的课程的版本是6,目前最新的是8

安装ajv

npm install ajv

"ajv": "^8.12.0"

当前这个项目使用的版本不是最新的,所以需要指定下版本,不然后续学习可能会需要调整代码,

需要指定为

"ajv": "6.12.4"

不过下面的简单用法学习记录还是用的最新版本的

简单使用ajv

新建一个文件夹schema-tests,下面创建一个test.js文件

const Ajv = require('ajv')
// 实例化
const ajv = new Ajv() // options can be passed, e.g. {allErrors: true}
// 定义的schema
const schema = {
  type: 'object',
  properties: {
    foo: { type: 'integer' },
    bar: { type: 'string' },
    pets: {
      type: 'array',
      items: {
        type: 'string',
      },
    },
    petss: {
      type: 'array',
      items: [
        {
          type: 'string',
        },
        {
          type: 'number',
        },
      ],
      minItems: 2,// 最新版本ajv 不添加这2个属性
      additionalItems: false,//会出现strict mode: "items" is 2-tuple, but minItems or maxItems/additionalItems are not specified or different at path "#/properties/petss"
    },
  },
  required: ['foo'],
  additionalProperties: true,
}
// 编译
const validate = ajv.compile(schema)
// 需要校验的数据
const data = {
  foo: 1,
  bar: '1',
  pets: ['1', '2'],
  petss: ['1', 2],
}
// 校验
const valid = validate(data)
// 校验不通过 打印下
if (!valid) console.log(validate.errors)

format

最新版本 Ajv does not include any formats, they can be added with ajv-formats (opens new window)plugin.

安装

npm i ajv-formats

"ajv-formats": "^2.1.1"

使用

// ESM/TypeScript import
import Ajv from "ajv"
import addFormats from "ajv-formats"
// Node.js require:
const Ajv = require("ajv")
const addFormats = require("ajv-formats")

const ajv = new Ajv()
addFormats(ajv)

const schema = {
  type: 'object',
  properties: {
    foo: { type: 'integer' },
    bar: { type: 'string', format: 'email' },
  },
}

自定义format

通过ajv的方法来拓展,注意不是JsonSchema的规范

const Ajv = require('ajv')
// 实例化
const ajv = new Ajv()
// 自定义format
ajv.addFormat('test', (data) => {
  console.log(data, '--------')
  return data === 'hh'
})

const schema = {
  type: 'object',
  properties: {
    foo: { type: 'integer' },
    bar: { type: 'string', format: 'test' },
  },
}

自定义关键字

Ajv 允许以下4种方式来定义关键字

  • code generation function (used by all pre-defined keywords)
  • validation function
  • compilation function
  • macro function

validation function

const Ajv = require('ajv')

// 实例化
const ajv = new Ajv() // options can be passed, e.g. {allErrors: true}
// 自定义关键字
ajv.addKeyword({
  keyword: 'test',
  validate: (schema, data) => {
    console.log(schema, data)
    return true
  },
})
// 定义的schema
const schema = {
  type: 'object',
  properties: {
    foo: { type: 'integer' },
    bar: { type: 'string', test: 'schema' },
  },
  required: ['foo'],
  additionalProperties: true,
}
// 编译
const validate = ajv.compile(schema)
// 需要校验的数据
const data = {
  foo: 1,
  bar: 'bar',
}
// 校验
const valid = validate(data)
// 校验不通过 打印下
if (!valid) console.log(validate.errors)

compilation function

编译阶段就会执行

const Ajv = require('ajv')

// 实例化
const ajv = new Ajv() 
// 自定义关键字  
ajv.addKeyword({
  keyword: 'test',
  compile(param, parentSchema) {
    console.log(param, parentSchema)
    return () => true
  },
  metaSchema: {
    // 定义test关键字接收值的定义
    type: 'array',
    items: [{ type: 'number' }, { type: 'number' }],
    minItems: 2,
    additionalItems: false,
  },
})
// 定义的schema
const schema = {
  type: 'object',
  properties: {
    foo: { type: 'integer' },
    bar: { type: 'string', test: 'schema' },
  },
  required: ['foo'],
  additionalProperties: true,
}
// 编译
const validate = ajv.compile(schema)

macro function

相对于起了个别名,实际的校验规则会与return的合并

// ...
ajv.addKeyword({
  keyword: 'test',
  macro: (data) => {
    console.log(data)
    return {
      minLength: 10,
    }
  },
})
// ...

错误信息语言修改

安装ajv-i18n

npm install ajv-i18n

"ajv-i18n": "^4.2.0"

使用

const Ajv = require('ajv')
const addFormats = require('ajv-formats')
const localize = require('ajv-i18n')
// ....
// 校验不通过 打印下
if (!valid) {
  localize.zh(validate.errors) // 只需要这里使用下对应的语言就可以了。
  console.log(validate.errors)
}

老版本的自定义关键字的错误信息语言是不支持的,最新版本的可以了。

自定义错误信息

安装

npm install ajv-errors

"ajv-errors": "^3.0.0"

使用

注意new Ajv({ allErrors: true })中需要开启allErrors

const Ajv = require('ajv')
const addFormats = require('ajv-formats')
const localize = require('ajv-i18n')

// 实例化
const ajv = new Ajv({ allErrors: true })
// Ajv option allErrors is required
require('ajv-errors')(ajv /*, {singleError: true} */)
// 拓展format
addFormats(ajv)
// 自定义关键字
ajv.addKeyword({
  keyword: 'test',
  macro: (data) => {
    console.log(data)
    return {
      minLength: 10,
    }
  },
})
// 定义的schema
const schema = {
  type: 'object',
  properties: {
    foo: { type: 'integer' },
    bar: {
      type: 'string',
      //   test: 'test',
      minLength: 10,
      errorMessage: '会替换当前整个错误信息',
      //   errorMessage: {
      //     type: '必须是字符串',
      //     minLength: '长度不能小于10',
      //   },
    },
  },
  required: ['foo'],
  additionalProperties: true,
}
// 编译
const validate = ajv.compile(schema)
// 需要校验的数据
const data = {
  foo: 1,
  bar: 1,
}
// 校验
const valid = validate(data)
// 校验不通过 打印下
if (!valid) {
  //   localize.zh(validate.errors) //  好像会和自定义error冲突
  console.log(validate.errors)
}

总结

后续需要按照课程来,所以需要指定按照以下的版本来学习,因为使用最新的,需要修改用法,等有空再改,先学思路。

"ajv": "6.12.4"

"ajv-i18n": "3.5.0"

"ajv-errors": "1.0.1"

实现组件库的主流程

  • 确定组件的接口与定义(即props)
    • schema
    • value
    • local
    • onChange
    • uiSchema
    • 等等
  • 开发入口组件的实现
  • 开发基础渲染实现

API设计

<JsonSchemaForm
    schema={schema}
    value={value}
    onChange={onChange}
    locale={locale}
    contextRef={someRef}
    uiSchema={uiSchema}></JsonSchemaForm>

schema

json schema对象,用来定义数据,同时也是我们定义表单的依据

value

表单的数据结果,你可以从外部改变这个value,在表单被编辑的时候,会通过onChange透出 value

需要注意的是,因为vue使用的是可变数据,如果每次数据变化我们都去改变value的对象地址,那么会导致整个表单都需要重新渲染,这会导致性能降低。 从实践中来看,我们传入的对象,在内部修改其 field 的值基本不会有什么副作用,所以我们会使用这种方式来进行实现。也就是说,如果value是一个对象,那么从JsonSchemaForm内部修改的值,并不会改变value对象本身。我们仍然会触发onChange,因为可能在表单变化之后,使用者需要进行一些操作。

onChange

在表单值有任何变化的时候会触发该回调方法,并把新的值进行返回

locale

语言,使用ajv-i18n指定错误信息使用的语言1

contextRef

你需要传入一个 vue3的Ref对象,我们会在这个对象上挂载doValidate方法,你可以通过

<JsonSchemaForm contextRef={yourRef} />
  
const yourRef=ref({})
onMounted(()=>{
    yourRef.value.doValidate()
})

uiSchema

对表单的展现进行一些定制,其类型如下:

export interface VueJsonSchemaConfig {
    title?:string
    description?: string
    component?: string
    additionProps?:{
        [key:string]:any
    }
    withFormItem?: boolen
    widget?:'checkbox'|'textarea'|'select'|'radio'|'range'|string
    items:UISchema | UISchema[]
}
export interface UISchema extends VueJsonSchemaConfig {
    properties?:{
        [property:string]:UISchema
    }
}

基本demo

  • 移除之前写的一些代码

  • 安装monaco-editor

    npm i -D monaco-editor@0.20 
  • vue-jss css in js的库

    npm i vue-jss@0.0.4 jss@10.4.0 jss-preset-default@10.4.0
  • 就可以新增一个MonacoEditor.tsc组件

    import * as Monaco from 'monaco-editor'
    import {
      defineComponent,
      onBeforeMount,
      onMounted,
      PropType,
      ref,
      shallowRef,
      watch,
    } from 'vue'
    import { createUseStyles } from 'vue-jss'
    
    const useStyles = createUseStyles({
      container: {
        border: '1px solid #eee',
        display: 'flex',
        flexDirection: 'column',
        borderRadius: 5,
        // height: 500,
      },
      title: {
        backgroundColor: '#eee',
      },
      code: {
        flexGrow: 1,
      },
    })
    
    export default defineComponent({
      props: {
        code: {
          type: String as PropType<string>,
          required: true,
        },
        onChange: {
          type: Function as PropType<
            (value: string, event: Monaco.editor.IModelContentChangedEvent) => any
          >,
          required: true,
        },
        title: {
          type: String as PropType<string>,
          required: true,
        },
      },
      setup(props) {
        const editorRef = shallowRef()
        const containerRef = ref()
    
        let _subscription: Monaco.IDisposable | undefined,
          __prevent_tigger_change_event = false
    
        onMounted(() => {
          const editor = (editorRef.value = Monaco.editor.create(
            containerRef.value,
            {
              value: props.code,
              language: 'json',
              formatOnPaste: true,
              tabSize: 2,
              minimap: {
                enabled: false,
              },
            },
          ))
          _subscription = editor.onDidChangeModelContent((event) => {
            console.log('>>>>>', __prevent_tigger_change_event)
            if (!__prevent_tigger_change_event) {
              props.onChange(editor.getValue(), event)
            }
          })
        })
    
        onBeforeMount(() => {
          if (_subscription) {
            _subscription.dispose()
          }
        })
        watch(
          () => props.code,
          (v) => {
            const editor = editorRef.value
            const model = editor.getModel()
            if (v !== model.getValue()) {
              editor.pushUndoStop()
              __prevent_tigger_change_event = true
              model.pushEditOperations(
                [],
                [
                  {
                    range: model.getFullModeRange(),
                    text: v,
                  },
                ],
              )
              editor.pushUndoStop()
              __prevent_tigger_change_event = false
              //   if (v !== editorRef.value.getValue()) {
              //     editorRef.value.setValue()
              //   }
            }
          },
        )
    
        const classesRef = useStyles()
    
        return () => {
          const classes = classesRef.value
    
          return (
            <div class={classes.container}>
              <div class={classes.title}>
                <span>{props.title}</span>
              </div>
              <div class={classes.code} ref={containerRef}></div>
            </div>
          )
        }
      },
    })
  • app.tsx中使用下

    import { defineComponent, ref } from 'vue'
    import MonacoEditor from './components/MonacoEditor'
    import { createUseStyles } from 'vue-jss'
    
    function toJson(data: any) {
      return JSON.stringify(data, null, 2)
    }
    
    const schema = {
      type: 'string',
    }
    
    const useStyles = createUseStyles({
      editor: {
        minHeight: 400,
      },
    })
    
    export default defineComponent({
      setup() {
        const schemaRef = ref<any>(schema)
    
        const handleCodeChange = (code: string) => {
          let schema: any
          try {
            schema = JSON.parse(code)
          } catch (err) {
            console.log(err)
          }
          schemaRef.value = schema
        }
    
        const classesRef = useStyles()
    
        return () => {
          const code = toJson(schemaRef.value)
          const classes = classesRef.value
    
          return (
            <div>
              <MonacoEditor
                code={code}
                onChange={handleCodeChange}
                title="Schema"
                class={classes.editor}
              />
            </div>
          )
        }
      },
    })

简单展示APP

image-20230304000755000

核心是lib的实现,待到下节实现。

lib实现之SchemaForm

核心 就是根据用户设置的schema来渲染对应的formItem

// SchemaForm.tsx

import { defineComponent, PropType } from 'vue'
import { Schema, SchemaTypes } from './types'

export default defineComponent({
  props: {
    schema: {
      type: Object as PropType<Schema>,
      required: true,
    },
    value: {
      //   type: Object, 值的类型是不确定的,可能是object也可能是string
      required: true,
    },
    onChange: {
      type: Function as PropType<(v: any) => void>,
      required: true,
    },
  },
  name: 'SchemaForm',
  setup(props, { slots, emit, attrs }) {
    return () => {
      // 解析schema
      const schema = props.schema
      const type = schema?.type
      switch (type) {
        //根据不同的类型渲染不同的表单项,对应string和number这种单一的还好,但是object这种可能自身就是一个完整的表单,所以不太应该把他们的代码放一起,不太合适,可以设计一个中间状态器,通过它来分发。
        case SchemaTypes.STRING: {
          return <input type="text" />
        }
      }
      return <div>This is 123</div>
    }
  },
})

// types.ts

export enum SchemaTypes {
  'NUMBER' = 'number',
  'INTEGER' = 'integer',
  'STRING' = 'string',
  'OBJECT' = 'object',
  'ARRAY' = 'array',
  'BOOLEAN' = 'boolean',
}

type SchemaRef = { $ref: string }
export interface Schema {
  type: SchemaTypes | string
  const?: any
  format?: string
  default?: any
  properties?: {
    [key: string]: Schema | { $ref: string }
  }
  items?: Schema | Schema[] | SchemaRef
  dependencies?: {
    [key: string]: string[] | Schema | SchemaRef
  }
  oneOf?: Schema[]
  //vjsf?: VueJsonSchemaConfig
  required?: string[]
  enum?: any[]
  enumKeyValue?: any[]
  additionalProperties?: any
  additionalItem?: Schema
}

lib实现之SchemaItem

定义一个中间状态器,用于分发不同的表单项。根据不同的类型来把渲染组件的工作交给对应的组件实现。

创建一个fields文件夹存放对应的实现,先实现下Number与String。

import { defineComponent } from 'vue'

export default defineComponent({
  name: 'StringField',
  setup() {
    return () => <div>String field</div>
  },
})
import { defineComponent } from 'vue'

export default defineComponent({
  name: 'NumberField',
  setup() {
    return () => <div>Number field</div>
  },
})

SchemaItem.tsx中使用

import { defineComponent, PropType } from 'vue'
import NumberField from './fields/NumberField'
import StringField from './fields/StringField'
import { Schema, SchemaTypes } from './types'

export default defineComponent({
  name: 'SchemaItem',
  props: {
    schema: {
      type: Object as PropType<Schema>,
      required: true,
    },
    value: {
      required: true,
    },
    onChange: {
      type: Function as PropType<(v: any) => void>,
      required: true,
    },
  },
  setup(props) {
    return () => {
      const { schema } = props
      // TODO: 如果type没有指定,我们需要猜测这个type
      const type = schema.type
      let Component: any
      switch (type) {
        case SchemaTypes.STRING: {
          Component = StringField
          break
        }
        case SchemaTypes.NUMBER: {
          Component = NumberField
          break
        }
        default: {
          console.warn(`${type} is not supported`)
        }
      }
      return <Component {...props} />
    }
  },
})

再修改下SchemaForm.tsx文件,借助SchemaItem.tsx来渲染

//...
  setup(props, { slots, emit, attrs }) {
    // 在这里定义一个处理事件 不直接把props.onChange传递进去,方便后续添加处理逻辑
    const handleChange = (v: any) => {
      props.onChange(v)
    }
    return () => {
      const { schema, value } = props
      // 使用SchemaItem中间处理器
      return (
        <SchemaItem schema={schema} value={value} onChange={handleChange} />
      )
    }
  },
 //...

最后测试一下,在demos文件夹下新增一个demo.ts文件测试下

export default {
  name: 'Demo',
  schema: {
    type: 'number',
  },
  uiSchema: '',
  default: '',
}

使用SFC方式继续实现Field

// StringField.vue

<template>
  <input type="text" :value="value" @input="handleChange" />
</template>
<!-- <script setup="props" lang="ts">
import { FiledPropDefine } from '../types'
// 2020/06月份刚sfc时候的setup写法
export default {
  props:FiledPropDefine
}

export const handleChange = (e: any) => {
  console.log(e.target.value)
  props.onChange(e.target.value)
}
</script> -->
<script setup lang="ts">
import { FiledPropDefine } from '../types'

const props = defineProps(FiledPropDefine)

const handleChange = (e: any) => {
  console.log(e.target.value)
  props.onChange(e.target.value)
}
</script>

使用SFC方式最大的问题就是在vscode中判断不出.vue文件的类型,因为.vue文件类型都是通过shims-vue.d.ts来实现的,ts并不知道props的定义。

解决下monaco-editor的报错问题

因为我使用的是cli5创建的项目,里面webpack版本是5,本来想依赖都按照教程中的版本指定安装,所以指定安装monaco-editor-webpack-plugin@1.9.1,但是安装的时候提示peer webpack@"^4.5.0" from monaco-editor-webpack-plugin@1.9.1,没得法只有安装最新的monaco-editor-webpack-plugin但是安装他又提示了peer monaco-editor@">= 0.31.0" from monaco-editor-webpack-plugin@7.0.1,所以只有二个插件都按最新的咯,尝试发现没得问题。

安装

npm i monaco-editor
npm i -D monaco-editor-webpack-plugin

在vue.config.js中使用

const { defineConfig } = require('@vue/cli-service')
const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin')

module.exports = defineConfig({
  transpileDependencies: true,
  chainWebpack(config) {
    config.plugin('monaco').use(new MonacoWebpackPlugin())
  },
})

复杂节点的渲染

  • 对象
  • 数组

核心就是解析schema,编写一个utlis方法库来处理

  • 安装依赖

    npm i jsonpointer lodash.union json-schema-merge-allof
    npm i @types/jsonpointer @types/lodash.union @types/json-schema-merge-allof -D
  • 文件内容

    // 详情见项目
    import { Schema, SchemaTypes, VueJsonSchemaConfig } from './types'
    
    import jsonpointer from 'jsonpointer'
    import union from 'lodash.union'
    import mergeAllOf from 'json-schema-merge-allof'
    
    export function isObject(thing: any) {
      return typeof thing === 'object' && thing !== null && !Array.isArray(thing)
    }
    export function hasOwnProperty(obj: any, key: string) {
      /**
       * 直接调用`obj.hasOwnProperty`有可能会因为
       * obj 覆盖了 prototype 上的 hasOwnProperty 而产生错误
       */
      return Object.prototype.hasOwnProperty.call(obj, key)
    }
    export function retrieveSchema(
      schema: any,
      rootSchema = {},
      formData: any = {},
    ): Schema {
      if (!isObject(schema)) {
        return {} as Schema
      }
      let resolvedSchema = resolveSchema(schema, rootSchema, formData)
    
      // TODO: allOf and additionalProperties not implemented
      if ('allOf' in schema) {
        try {
          resolvedSchema = mergeAllOf({
            // TODO: Schema type not suitable
            ...resolvedSchema,
            allOf: resolvedSchema.allOf,
          } as any) as Schema
        } catch (e) {
          console.warn('could not merge subschemas in allOf:\n' + e)
          const { allOf, ...resolvedSchemaWithoutAllOf } = resolvedSchema
          return resolvedSchemaWithoutAllOf
        }
      }
      const hasAdditionalProperties =
        resolvedSchema.hasOwnProperty('additionalProperties') &&
        resolvedSchema.additionalProperties !== false
      if (hasAdditionalProperties) {
        // put formData existing additional properties into schema
        return stubExistingAdditionalProperties(
          resolvedSchema,
          rootSchema,
          formData,
        )
      }
      return resolvedSchema
    }
  • 同时还需要扩展下types.ts中的Schema的定义

编写ObjectField渲染

  • 修改下SchemaForm.tsx文件,组件添加rootSchem属性

        return () => {
          const { schema, value } = props
          // 使用SchemaItem中间处理器
          return (
            <SchemaItem
              schema={schema}
              rootSchema={schema}
              value={value}
              onChange={handleChange}
            />
          )
        }
  • 同时需要修改下types.ts中的FiledPropDefine类型,添加rootSchema

    export const FiledPropDefine = {
      schema: {
        type: Object as PropType<Schema>,
        required: true,
      },
      value: {
        required: true,
      },
      onChange: {
        type: Function as PropType<(v: any) => void>,
        required: true,
      },
      rootSchema: {
        type: Object as PropType<Schema>,
        required: true,
      },
    } as const
  • 创建ObjectField.tsx文件

    import { defineComponent, inject } from 'vue'
    import { FiledPropDefine } from '../types'
    import SchemaItem from '../SchemaItem'
    console.log(SchemaItem, '触发下循环引用')
    
    export default defineComponent({
      name: 'ObjectField',
      props: FiledPropDefine,
      setup() {
        return () => {
          return <div>Object</div>
        }
      },
    })
  • 修改下SchemaItem.tsx文件,加入对Object类型的渲染

    核心方法是递归解析retrieveSchema方法,得到需要的schema

    import { computed, defineComponent } from 'vue'
    // ...
    import ObjectField from './fields/ObjectField'
    import { FiledPropDefine, SchemaTypes } from './types'
    import { retrieveSchema } from './utils'
    
    export default defineComponent({
      name: 'SchemaItem',
      props: FiledPropDefine,
      setup(props) {
        const retrievedSchemaRef = computed(() => {
          const { schema, rootSchema, value } = props
          return retrieveSchema(schema, rootSchema, value)
        })
        return () => {
          const { schema } = props
          const retrievedSchema = retrievedSchemaRef.value
          // ...
          let Component: any
          switch (type) {
    		// ...
            case SchemaTypes.OBJECT: {
              Component = ObjectField
              break
            }
            default: {
              console.warn(`${type} is not supported`)
            }
          }
          return <Component {...props} schema={retrievedSchema} />
        }
      },
    })

处理循环依赖

  • 安装circular-dependency-plugin检测循环引用

    npm i circular-dependency-plugin -D

    5.2.0

  • 在vue.config.js中使用

    const CircularDependencyPlugin = require('circular-dependency-plugin')
    
    module.exports = defineConfig({
      chainWebpack(config) {
        config.plugin('circular').use(new CircularDependencyPlugin())
      },
    })

    image-20230308215410232

  • 通过provide来解决ObjectField的循环依赖问题

    • SchemaForm.tsx中注册provide

        setup(props, { slots, emit, attrs }) {
          //...
          // 将SchemaItem组件通过provide提供给子组件
          const context = {
            SchemaItem,
          }
          provide(ShcemaFormContextKey, context)
      
          return () => {
            const { schema, value } = props
            // 使用SchemaItem中间处理器
            return (
              <SchemaItem
                schema={schema}
                rootSchema={schema}
                value={value}
                onChange={handleChange}
              />
            )
          }
        },
    • 然后就可以在ObjectField.tsx中使用inject拿到

      import { defineComponent, inject } from 'vue'
      import { FiledPropDefine } from '../types'
      import { ShcemaFormContextKey } from '../context'
      
      export default defineComponent({
        name: 'ObjectField',
        props: FiledPropDefine,
        setup() {
          const context = inject(ShcemaFormContextKey)
          console.log(context)
      
          return () => {
            return <div>Object</div>
          }
        },
      })

    注意 : provide的数据 如果需要监听变化,那么请声明一个响应式的变量来传递

provide的简单解析

一句话 父组件提供给子组件{key:value},如果中间的子组件又有一个provide,那么最后的子组件中就含有爷父组件的provide,也就是一层层的传递,不会影响兄弟组件。

完善ObjectField渲染

  • 添加inject的类型定义 方便检验其中的SchemaItem属性,借助DefineComponent类型

  • <SchemaItem />添加需要的属性,需要注意value需要判断是否为对象,是对象才取key

    import { defineComponent, inject, DefineComponent, ExtractPropTypes } from 'vue'
    // ..
    import { ShcemaFormContextKey } from '../context'
    import { isObject } from '../utils'
    
    type SchemaItemDefine = DefineComponent<typeof FiledPropDefine>
    export default defineComponent({
     // ...
      setup(props) {
        const context: { SchemaItem: SchemaItemDefine } | undefined =
          inject(ShcemaFormContextKey)
    
        if (!context) {
          throw Error('SchemaForm shoud be used')
        }
    
        const handleObjectFieldChange = (key: string, v: any) => {
          const value: any = isObject(props.value) ? props.value : {}
          if (v === undefined) {
            delete value[key]
          } else {
            value[key] = v
          }
          // 向上传递
          props.onChange(value)
        }
    
        return () => {
          const { schema, rootSchema, value } = props
    
          const { SchemaItem } = context
    
          const properties = schema.properties || {}
    
          const currentValue: any = isObject(value) ? value : {}
    
          return Object.keys(properties).map((key: string, index: number) => (
            <SchemaItem
              schema={properties[key]}
              rootSchema={rootSchema}
              value={currentValue[key]}
              key={index}
              onChange={(v: any) => handleObjectFieldChange(key, v)}
            />
          ))
        }
      },
    })                            

编写ArrayField渲染

首先优化下前面的ObjectField代码,提取类型定义 提取获取context

// ObjectField.tsx

const context = useVJSFContext()

// context.ts

export function useVJSFContext() {
  const context: { SchemaItem: ComonFieldType } | undefined =
    inject(ShcemaFormContextKey)

  if (!context) {
    throw Error('SchemaForm shoud be used')
  }
  return context
}
/**
 * 3种情况
 * {
 *  items:{ type:string}
 * }
 *
 * {
 *  items:[
 *      {  type: string },
 *      {  type: number },
 *  ]
 * }
 *
 * {
 *  items:{ type: string,   enum:['1','2']}
 * }
 */

固定长度数组的渲染

  • 创建ArrayField.tsx文件
import { defineComponent } from 'vue'
import { FiledPropDefine, Schema } from '../types'
import { useVJSFContext } from '../context'

export default defineComponent({
  name: 'ArrayField',
  props: FiledPropDefine,
  setup(props) {
    const context = useVJSFContext()
    const handleMultiTypeChange = (v: any, index: number) => {
      const { value } = props
      const arr = Array.isArray(value) ? value : []
      arr[index] = v
      props.onChange(arr)
    }

    return () => {
      const { schema, rootSchema, value } = props
      const SchemaItem = context.SchemaItem
      const isMultiType = Array.isArray(schema.items)

      if (isMultiType) {
        const items: Schema[] = schema.items as any
        const arr = Array.isArray(value) ? value : []
        return items.map((s: Schema, index: number) => (
          <SchemaItem
            schema={s}
            key={index}
            rootSchema={rootSchema}
            value={arr[index]}
            onChange={(v: any) => handleMultiTypeChange(v, index)}
          />
        ))
      }
      return null
    }
  },
})
  • 修改SchemaItem.tsx文件,添加array类型渲染
// ....
import ArrayField from './fields/ArrayField'
// ...

export default defineComponent({
  name: 'SchemaItem',
  props: FiledPropDefine,
  setup(props) {
   // ...
    return () => {
     // ...
      let Component: any
      switch (type) {
		// ....
        case SchemaTypes.ARRAY: {
          Component = ArrayField
          break
        }
        default: {
          console.warn(`${type} is not supported`)
        }
      }
      return <Component {...props} schema={retrievedSchema} />
    }
  },
})
  • 测试一下,在demo/simple.ts

    schema: {
        description: 'A simple form example.',
        type: 'object',
        required: ['firstName', 'lastName'],
        properties: {
         // ...
          staticArray: {
            type: 'array',
            items: [
              {
                type: 'string',
              },
              {
                type: 'number',
              },
            ],
          },
        },
      },

单类型数组的渲染

  • 修改ArrayField.tsx文件,添加关于单类型数组的解析,添加排序等外层容器

    import { defineComponent } from 'vue'
    import { FiledPropDefine, Schema } from '../types'
    import { useVJSFContext } from '../context'
    
    const ArrayItemWrapr = defineComponent({
      name: 'ArrayItemWrapr',
      //   props: {},
      setup(props, { slots }) {
        return () => {
          return (
            <div>
              <div>
                <button>新增</button>
                <button>删除</button>
                <button>上移</button>
                <button>下移</button>
              </div>
              <div>{slots.default && slots.default()}</div>
            </div>
          )
        }
      },
    })
    
    export default defineComponent({
      name: 'ArrayField',
      props: FiledPropDefine,
      setup(props) {
        const context = useVJSFContext()
        const handleArryItemChange = (v: any, index: number) => {
          const { value } = props
          const arr = Array.isArray(value) ? value : []
          arr[index] = v
          props.onChange(arr)
        }
    
        return () => {
          const { schema, rootSchema, value } = props
          const SchemaItem = context.SchemaItem
    
          const isMultiType = Array.isArray(schema.items)
          const isSelect = schema.items && (schema.items as any).enum
    
          if (isMultiType) {
          // ....
          } else if (!isSelect) {
            const arr = Array.isArray(value) ? value : []
            return arr.map((v: any, index: number) => {
              return (
                <ArrayItemWrapr>
                  <SchemaItem
                    schema={schema.items as Schema}
                    value={v}
                    key={index}
                    rootSchema={rootSchema}
                    onChange={(v: any) => handleArryItemChange(v, index)}
                  />
                </ArrayItemWrapr>
              )
            })
          }
          return null
        }
      },
    })
  • 测试一下,修改下demo/simple.ts,添加单类型数组定义,注意需要添加一下data不然没有输入框显示出现

    export default {
      name: 'Simple',
      schema: {
        description: 'A simple form example.',
        type: 'object',
        required: ['firstName', 'lastName'],
        properties: {
         // ...
          singleTypeArray: {
            type: 'array',
            items: {
              type: 'string',
            },
          },
        },
      },
      uiSchema: {
    	// ...
      },
      default: {
        firstName: 'Chuck',
        lastName: 'Norris',
        age: 75,
        bio: 'Roundhouse kicking asses since 1940',
        password: 'noneed',
        singleTypeArray: ['zyq'],
      },
    }
  • 继续完善,简单添加样式,借助vue-jss

  • 添加新增 删除 上移 下移逻辑

    const ArrayItemWrapr = defineComponent({
      name: 'ArrayItemWrapr',
      props: {
        onAdd: {
          type: Function as PropType<(index: number) => void>,
          required: true,
        },
        onDelete: {
          type: Function as PropType<(index: number) => void>,
          required: true,
        },
        onUp: {
          type: Function as PropType<(index: number) => void>,
          required: true,
        },
        onDown: {
          type: Function as PropType<(index: number) => void>,
          required: true,
        },
        index: {
          type: Number,
          required: true,
        },
      },
      setup(props, { slots }) {
        const classesRef = useStyles()
        const handleAdd = () => props.onAdd(props.index)
        const handleDelete = () => props.onDelete(props.index)
        const handleUp = () => props.onUp(props.index)
        const handleDown = () => props.onDown(props.index)
        return () => {
          const classes = classesRef.value
          return (
            <div class={classes.container}>
              <div class={classes.actions}>
                <button class={classes.action} onClick={handleAdd}>
                  新增
                </button>
                <button class={classes.action} onClick={handleDelete}>
                  删除
                </button>
                <button class={classes.action} onClick={handleUp}>
                  上移
                </button>
                <button class={classes.action} onClick={handleDown}>
                  下移
                </button>
              </div>
              <div class={classes.content}>{slots.default && slots.default()}</div>
            </div>
          )
        }
      },
    })
    
    export default defineComponent({
      name: 'ArrayField',
      props: FiledPropDefine,
      setup(props) {
    	// ...
        const handleAdd = (index: number) => {
          const { value } = props
          const arr = Array.isArray(value) ? value : []
          arr.splice(index + 1, 0, undefined)
    
          props.onChange(arr)
        }
        const handleDelete = (index: number) => {
          const { value } = props
          const arr = Array.isArray(value) ? value : []
          arr.splice(index, 1)
    
          props.onChange(arr)
        }
        const handleUp = (index: number) => {
          if (index === 0) {
            return
          }
          const { value } = props
          const arr = Array.isArray(value) ? value : []
          const item = arr.splice(index, 1)
          arr.splice(index - 1, 0, item[0])
    
          props.onChange(arr)
        }
        const handleDown = (index: number) => {
          const { value } = props
          const arr = Array.isArray(value) ? value : []
          if (index === arr.length - 1) {
            return
          }
          const item = arr.splice(index, 1)
          arr.splice(index + 1, 0, item[0])
    
          props.onChange(arr)
        }
    
        return () => {
        //...
          if (isMultiType) {
            // ...
          } else if (!isSelect) {
            const arr = Array.isArray(value) ? value : []
            return arr.map((v: any, index: number) => {
              return (
                <ArrayItemWrapr
                  index={index}
                  onAdd={handleAdd}
                  onDelete={handleDelete}
                  onUp={handleUp}
                  onDown={handleDown}
                >
                  <SchemaItem
                    schema={schema.items as Schema}
                    value={v}
                    key={index}
                    rootSchema={rootSchema}
                    onChange={(v: any) => handleArryItemChange(v, index)}
                  />
                </ArrayItemWrapr>
              )
            })
          }
          return null
        }
      },
    })

多选数组的渲染

  • lib下新建一个widgets文件夹,并创建一个Selection.tsx组件,用于实现多选

    import { defineComponent, PropType, ref, watch } from 'vue'
    
    export default defineComponent({
      name: 'SelectionWidget',
      props: {
        value: {},
        onChange: {
          type: Function as PropType<(v: any) => void>,
          required: true,
        },
        options: {
          type: Array as PropType<
            {
              key: string
              value: any
            }[]
          >,
          required: true,
        },
      },
      setup(props) {
        const currentValueRef = ref(props.value)
    
        watch(currentValueRef, (newVal) => {
          // currentValueRef被修改时,触发onchange
          if (newVal !== props.value) {
            props.onChange(newVal)
          }
        })
    
        watch(
          () => props.value,
          (v) => {
            // 父组件传递进来的字 赋值给currentValueRef 实现了 value的双向
            if (v !== currentValueRef.value) {
              currentValueRef.value = v
            }
          },
        )
    
        return () => {
          const { options } = props
    
          return (
            <select multiple v-model={currentValueRef.value}>
              {options.map((op) => (
                <options value={op.value}>{op.key}</options>
              ))}
            </select>
          )
        }
      },
    })

    注意: v-model={currentValueRef.value} 这里的.vlaue不能省略,在SFC文件中的模板可以省略是因为vue对模板解析的时候做了处理

  • 修改下ArrayField.tsx文件,引入上面的组件

    // ...
    import SelectionWidget from '../widgets/Selection'
    // ...
    export default defineComponent({
      name: 'ArrayField',
      props: FiledPropDefine,
      setup(props) {
        const context = useVJSFContext()
    
      // ...
    
        return () => {
         //...
    
          if (isMultiType) {
          // ...
          } else if (!isSelect) {
           //...
          } else {
            const enumOptions = (schema as any).items.enum
            const options = enumOptions.map((e: any) => {
              return {
                key: e,
                value: e,
              }
            })
            return (
              <SelectionWidget
                onChange={props.onChange}
                value={props.value}
                options={options}
              />
            )
          }
        }
  • 测试一下,修改下demo/simple.tx文件,添加多选数组schema定义

          multiSelectArray: {
            type: 'array',
            items: {
              type: 'string',
              enum: ['123', '456', '789'],
            },
          },

单元测试

Jest 测试框架

vue-test-utils vue官方提供的关于vue的测试方法

为啥要单测

  • 检测bug
  • 提升回归效率 方便改代码后检测是否有问题
  • 保证代码质量

测试覆盖率

jest单元测试配置

使用vuecli创建项目时,勾选了jest,已经帮我们初始化好了。

查看下jest的配置,项目中的jest.config.js,发现它通过prest预设了好多。

可以从vue-cli的地址上看到

// cli-plugin-unit-jest/presets/typescript-and-babel/jest-preset.js

const deepmerge = require('deepmerge')
const defaultTsPreset = require('../typescript/jest-preset')

module.exports = deepmerge(
  defaultTsPreset,
  {
    globals: {
      'ts-jest': {
        babelConfig: true
      }
    }
  }
)

// cli-plugin-unit-jest/presets/typescript/jest-preset.js

const deepmerge = require('deepmerge')
const defaultPreset = require('../default/jest-preset')

let tsJest = null
try {
  tsJest = require.resolve('ts-jest')
} catch (e) {
  throw new Error('Cannot resolve "ts-jest" module. Typescript preset requires "ts-jest" to be installed.')
}

module.exports = deepmerge(
  defaultPreset,
  {
    moduleFileExtensions: ['ts', 'tsx'],
    transform: {
      '^.+\\.tsx?$': tsJest
    }
  }
)

// cli-plugin-unit-jest/presets/default/jest-preset.js

// eslint-disable-next-line node/no-extraneous-require
const semver = require('semver')

let vueVersion = 2
try {
  // eslint-disable-next-line node/no-extraneous-require
  const Vue = require('vue/package.json')
  vueVersion = semver.major(Vue.version)
} catch (e) {}

let vueJest = null
try {
  vueJest = require.resolve(`@vue/vue${vueVersion}-jest`)
} catch (e) {
  throw new Error(`Cannot resolve "@vue/vue${vueVersion}-jest" module. Please make sure you have installed "@vue/vue${vueVersion}-jest" as a dev dependency.`)
}

module.exports = {
  testEnvironment: 'jsdom',
  moduleFileExtensions: [
    'js',
    'jsx',
    'json',
    // tell Jest to handle *.vue files
    'vue'
  ],
  transform: {
    // process *.vue files with vue-jest
    '^.+\\.vue$': vueJest,
    '.+\\.(css|styl|less|sass|scss|jpg|jpeg|png|svg|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|avif)$':
    require.resolve('jest-transform-stub'),
    '^.+\\.jsx?$': require.resolve('babel-jest')
  },
  transformIgnorePatterns: ['/node_modules/'],
  // support the same @ -> src alias mapping in source code
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1'
  },
  // serializer for snapshots
  snapshotSerializers: [
    'jest-serializer-vue'
  ],
  testMatch: [
    '**/tests/unit/**/*.spec.[jt]s?(x)',
    '**/__tests__/*.[jt]s?(x)'
  ],
  // https://github.com/facebook/jest/issues/6766
  testURL: 'http://localhost/',
  watchPlugins: [
    require.resolve('jest-watch-typeahead/filename'),
    require.resolve('jest-watch-typeahead/testname')
  ]
}

核心知道底层的配置文件是那些,有哪些参数,方便后续自己修改。

使用jest写测试用例

  • describe
  • it/test

expect断言

更多用法看官网

https://jestjs.io/zh-Hans/docs/expect

预设与清理

  • beforeEach / afterEach每个测试之前/之后会执行
  • beforeAll / afterAll所有测试之前/之后执行
  • 有作用域
  • 在before中设置值,在after中清除

异步测试

describe('HelloWorld.vue', () => {
  // 实践是错误滴,但是jest是不知道的
  it('should work', () => {
    setTimeout(() => {
      expect(1 + 1).toBe(3)
    }, 1000)
  })
})

解决办法1: 使用回调中的done方法

it('should work', (done) => {
    setTimeout(() => {
      expect(1 + 1).toBe(3)
      done() // 这里
    }, 1000)
  })

办法2: Promise

  it('should work', () => {
    return new Promise<void>((resolve) => {
      setTimeout(() => {
        expect(1 + 1).toBe(3)
        resolve()
      }, 1000)
    })
  })

办法3: async

it('should work', async () => {
    const x = await new Promise((resolve) => {
      setTimeout(() => {
        resolve(1 + 1)
      }, 1000)
    })
    expect(x).toBe(1)
  })

// 或者测试这个
  it('renders props.msg when passed', async () => {
    const msg = 'new message'
    const wrapper = shallowMount(HelloWorld, {
      props: { msg },
    })
    await wrapper.setProps({
      msg: '123',
    })
    expect(wrapper.text()).toMatch('123')
  })

使用vue-test-utils测试vue组件

根据jest的配置文件,我们得到其省略文件名后缀进行文件导入解析时的默认加载顺序如下

 moduleFileExtensions: [
    'js',
    'jsx',
    'json',
    // tell Jest to handle *.vue files
    'vue',
    'ts', 'tsx'
  ],

所以在省略后缀引入时,import NumberField from './fields/NumberField'实际导入的是.vue的文件,如果你的想和项目运行时的导入逻辑保持一致可能需要调整一下这个字段顺序,或者就不要有同名的vue文件了,保留一个tsx文件就阔以了。

  • 用就完事了

    import { mount } from '@vue/test-utils'
    
    import JsonSchemaForm, { NumberField } from '../../lib'
    
    describe('JsonSchemaForm', () => {
      it('should render correct number field', async () => {
        let value = ''
        const wrapper = mount(JsonSchemaForm, {
          props: {
            schema: {
              type: 'number',
            },
            value: value,
            onChange: (v: any) => {
              value = v
            },
          },
        })
    
        const numberFiled = wrapper.findComponent(NumberField)
        expect(numberFiled.exists()).toBeTruthy()
    
        // await numberFiled.props('onChange')('123')
        const input = numberFiled.find('input')
        input.element.value = '123'
        input.trigger('input')
        expect(value).toBe(123)
      })
    })

代码覆盖率

测试代码覆盖率

npm run test:unit -- --coverage

等价于

script:{
 "test:unit": "vue-cli-service test:unit --coverage",
}

控制台输出,以及会得到一个coverage文件夹,里面有各种文件

Stmts语句覆盖率 Branch分支覆盖率 Funcs函数覆盖率 Lines行覆盖率
------------------|---------|----------|---------|---------|---------------------------------------------------
File              | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s                                 
------------------|---------|----------|---------|---------|---------------------------------------------------
All files         |    33.6 |     8.08 |   17.28 |   34.26 |                                                   
 lib              |   34.09 |     9.58 |      25 |   34.25 |                                                   
  SchemaForm.tsx  |     100 |      100 |     100 |     100 |                                                   
  SchemaItem.tsx  |   74.07 |      100 |     100 |   74.07 | 29-30,37-45                                       
  context.ts      |      50 |      100 |       0 |      50 | 8-13                                              
  index.ts        |     100 |      100 |     100 |     100 |                                                   
  types.ts        |     100 |      100 |     100 |     100 |                                                   
  utils.ts        |   17.68 |     7.04 |   11.11 |    17.5 | ...42,446,453-460,465-467,476-527,533-551,562-584 
 lib/fields       |   34.84 |     3.84 |      10 |    36.5 |                                                   
  ArrayField.tsx  |   11.59 |        0 |       0 |    12.5 | 69-76,103-196                                     
  NumberField.vue |   95.83 |       50 |     100 |   95.83 | 20                                                
  ObjectField.tsx |   26.31 |        0 |       0 |   27.77 | 12-40                                             
  StringField.vue |      50 |      100 |       0 |      50 | 12-27,36                                          
 lib/widgets      |   14.28 |      100 |       0 |   14.28 |                                                   
  Selection.tsx   |   14.28 |      100 |       0 |   14.28 | 22-47                                             
------------------|---------|----------|---------|---------|---------------------------------------------------

ObjectField的单元测试

使用共用变量时,推荐在beforeEach中进行赋值,可以避免在某个测试中改变了这个变量

import { mount } from '@vue/test-utils'

import JsonSchemaForm, { NumberField, StringField } from '../../lib'

describe('ObjectField', () => {
  let schema: any
  beforeEach(() => {
    schema = {
      type: 'object',
      properties: {
        name: {
          type: 'string',
        },
        age: {
          type: 'number',
        },
      },
    }
  })
  it('should render properties to correct fileds', async () => {
    const wrapper = mount(JsonSchemaForm, {
      props: {
        schema,
        value: {},
        onChange: () => {},
      },
    })

    const strField = wrapper.findComponent(StringField)
    const numField = wrapper.findComponent(NumberField)
    expect(strField.exists()).toBeTruthy()
    expect(numField.exists()).toBeTruthy()
  })

  it('should change value when sub fields trigger onChange', async () => {
    let value: any = {}
    const wrapper = mount(JsonSchemaForm, {
      props: {
        schema,
        value: value,
        onChange: (v: any) => {
          value = v
        },
      },
    })

    const strField = wrapper.findComponent(StringField)
    const numField = wrapper.findComponent(NumberField)

    await strField.props('onChange')('1')
    expect(value.name).toBe('1')
    await numField.props('onChange')(1)
    expect(value.age).toBe(1)
  })

  it('should handleObjectFieldChange else', async () => {
    let value: any = {
      name: '123',
    }
    const wrapper = mount(JsonSchemaForm, {
      props: {
        schema,
        value: value,
        onChange: (v: any) => {
          value = v
        },
      },
    })

    const strField = wrapper.findComponent(StringField)

    await strField.props('onChange')(undefined)
    expect(value.name).toBe(undefined)
  })
})

ArrayField的单元测试

只运行特定的测试用例,通过-t的方式来指定运行,会匹配符合it中的字符串的用例

npm run test:unit -- -t multi type
import { mount } from '@vue/test-utils'

import JsonSchemaForm, {
  ArrayField,
  NumberField,
  SelectionWidget,
  StringField,
} from '../../lib'

describe('ArrayField', () => {
  it('should render multi type', async () => {
    const wrapper = mount(JsonSchemaForm, {
      props: {
        schema: {
          type: 'array',
          items: [
            {
              type: 'string',
            },
            {
              type: 'number',
            },
          ],
        },
        value: [],
        onChange: () => {},
      },
    })

    // 先从wrapper中找到array
    const arrayField = wrapper.findComponent(ArrayField)
    // 再从array中找number
    const numberFiled = arrayField.findComponent(NumberField)
    // 再从array中找string
    const stringField = arrayField.findComponent(StringField)

    expect(numberFiled.exists()).toBeTruthy()
    expect(stringField.exists()).toBeTruthy()
  })

  it('should render single type', async () => {
    const wrapper = mount(JsonSchemaForm, {
      props: {
        schema: {
          type: 'array',
          items: {
            type: 'string',
          },
        },
        value: ['1', '2'],
        onChange: () => {},
      },
    })

    // 先从wrapper中找到array
    const arrayField = wrapper.findComponent(ArrayField)
    // 再从array中找所有的string
    const stringFields = arrayField.findAllComponents(StringField)

    expect(stringFields.length).toBe(2)
    expect(stringFields[0].props('value')).toBe('1')
  })

  it('should render select type', async () => {
    const wrapper = mount(JsonSchemaForm, {
      props: {
        schema: {
          type: 'array',
          items: {
            type: 'string',
            enum: ['1', '2', '3'],
          },
        },
        value: [],
        onChange: () => {},
      },
    })

    // 先从wrapper中找到array
    const arrayField = wrapper.findComponent(ArrayField)
    const selectionWidget = arrayField.findComponent(SelectionWidget)

    expect(selectionWidget.exists()).toBeTruthy()
  })
})

主题系统

  • 满足样式和交互的多样性
  • 核心逻辑是不变的 lib,部件 widget
  • 不同于样式主题系统
    • 交互可以变化
    • 组件的产出可以完全不同
    • 统一接口后所有内容都可以自定义
  • 可以基于不同的组件库来实现

拆分主题系统的打包

减少强依赖

  • vue-cli打包配置https://cli.vuejs.org/zh/guide/build-targets.html#%E5%BA%93

  • lib文件夹下新建theme-default文件夹

  • 修改package.json文件

      "scripts": {
        //...
        "build:core": "vue-cli-service build --target lib lib/index.ts",
        "build:theme": "vue-cli-service build --target lib lib/theme-default/index.tsx",
         //...
      },
  • 运行npm run build:theme会发现打出来的文件有点多,是因为在vue.config.js中未区分环境把monaco-editor的内容也打包进来了。

    image-20230313224512883

    • 解决办法,通过.env文件或在运行时添加一个环境变量区分,这里使用简单下环境变量区分就行了

      window下需要通过set TYPE=xx

      mac下直接TYPE=xxx

       "scripts": {
          "build:core": "set TYPE=lib && vue-cli-service build --target lib lib/index.ts",
          "build:theme": "set TYPE=lib && vue-cli-service build --target lib lib/theme-default/index.tsx",
        },

      TODO

      但是测试发现一个神奇的问题,通过上面的set方式,在vue.config.js中打印时值是lib,但是比对却是不相等。。。。

      const isLib = process.env.TYPE === 'lib'
      
      console.log(process.env.TYPE)
      console.log(process.env.TYPE === 'lib')
      console.log(isLib)

      image-20230313230533940

    • 没得法使用cross-env吧,正好也可以解决不同系统下的区别问题

      npm install --save-dev cross-env
        "scripts": {
          "build:core": "cross-env TYPE=lib vue-cli-service build --target lib lib/index.ts",
          "build:theme": "cross-env TYPE=lib vue-cli-service build --target lib lib/theme-default/index.tsx",
        },

      成功后

      image-20230313230845399

  • 修改生成的文件名

    通过--name修改文件名,并把core和theme放在不同的文件夹下

      "scripts": {
        "build:core": "set TYPE=lib && vue-cli-service build --target  lib --name index lib/index.ts",
        "build:theme": "cross-env TYPE=lib vue-cli-service build --target lib --name theme-default/index lib/theme-default/index.tsx",
      },
  • build时会自动清除之前的文件,会导致无法同时存在core包与theme包,所以需要设置下打包时不清除之前的文件。加上--no-clean,但是这样,可能会存在遗留之前的文件。所有可以通过rimraf这个包来选择时机清除。

    npm i -D rimraf
      "scripts": {
        "build:core": "set TYPE=lib && vue-cli-service build --target  lib --name index --no-clean lib/index.ts",
        "build:theme": "cross-env TYPE=lib vue-cli-service build --target lib --name theme-default/index --no-clean lib/theme-default/index.tsx",
        "build": "rimraf dist && npm run build:core && npm run build:theme",
      },

拆分主题并进行定义

  • 提取widget的类型定义,修改下type.ts

    export const CommonWidgetPropsDefine = {
      value: {},
      onChange: {
        type: Function as PropType<(v: any) => void>,
        required: true,
      },
    } as const
    
    export type CommonWidgetDefine = DefineComponent<typeof CommonWidgetPropsDefine>
    
    export const SelectionWidgetPropsDefine = {
      ...CommonWidgetPropsDefine,
      options: {
        type: Array as PropType<
          {
            key: string
            value: any
          }[]
        >,
        required: true,
      },
    } as const
    
    export type CommonWidgetDefine = DefineComponent<typeof CommonWidgetPropsDefine>
    export type SelectionWidgetDefine = DefineComponent<
      typeof SelectionWidgetPropsDefine
    >
    
    export interface Theme {
      widgets: {
        SelectionWidget: SelectionWidgetDefine
        TextWidget: CommonWidgetDefine
        NumberWidget: CommonWidgetDefine
      }
    }
  • 修改SchemaForm.tsx文件,添加theme属性,并新增一个provide属性theme,就可以直接在ArrayField.tsx中通过inject拿到theme下的widget了

    export default defineComponent({
      props: {
      
        theme: {
          type: Object as PropType<Theme>,
          required: true,
        },
      },
      name: 'SchemaForm',
      setup(props, { slots, emit, attrs }) {
       // ...
        const context = {
          SchemaItem,
          theme: props.theme,
        }
        provide(ShcemaFormContextKey, context)
        // ...
    
    })
  • 紧接着就可以修改下context.ts中的theme的定义,支持theme,在ArrayField.tsx中就不需要引入SelectionWidget.tsx

    // context.tx

    import { ComonFieldType, Theme } from './types'
    
    export function useVJSFContext() {
      const context: { theme: Theme; SchemaItem: ComonFieldType } | undefined =
        inject(ShcemaFormContextKey)
     // ...
    }

    // ArrayField.tsx

    const SchemaItem = context.SchemaItem
    const SelectionWidget = context.theme.widgets.SelectionWidget

使用ThemeProvider解耦

之前的代码中我们在schemaForm中定义了theme属性,并通过provide提供给子组件,但是提供的是一个非响应式对象,与我们实际的需求是不符合的,所以就需要提供一个响应式的对象来实现响应式的更新。

  • lib下新建一个theme.tsx文件,用这个组件来提供一个全局的响应式的theme

    import {
      computed,
      ComputedRef,
      defineComponent,
      inject,
      PropType,
      provide,
    } from 'vue'
    import { Theme } from './types'
    
    const THEME_PROVIDER_KEY = Symbol()
    const ThemeProvider = defineComponent({
      name: 'VJSFThemeProvider',
      props: {
        theme: {
          type: Object as PropType<Theme>,
          required: true,
        },
      },
      setup(props, { slots }) {
        const context = computed(() => props.theme)
        provide(THEME_PROVIDER_KEY, context)
        return () => slots.default && slots.default()
      },
    })
    
    // 提供给子组件使用的方法
    export function getWidget(name: string) {
      const context: ComputedRef<Theme> | undefined =
        inject<ComputedRef<Theme>>(THEME_PROVIDER_KEY)
      if (!context) {
        throw new Error('vjsf theme required')
      }
      // 这样写 就成非响应式的了,后续的widget改变不会触发了。
      // const widgetRef = context.value.widgets[name]
      // 这里需要一个响应式的数据
      const widgetRef = computed(() => {
        return (context.value.widgets as any)[name]
      })
      return widgetRef
    }
    export default ThemeProvider
  • 修改下ArrayField.tsx中从context中获取到的SelectionWidget组件,同时也可以去除contenxt.ts中添加的theme类型定义了。

    import { getWidget } from '../theme'
    
    setup(){
    // ...
    const SelectionWidgetRef = getWidget('SelectionWidget') // 注意名字对应
    return ()=>{
    	// const SelectionWidget = context.theme.widgets.SelectionWidget
          const SelectionWidget = SelectionWidgetRef.value
    }
    }
  • 去除SchemeForm.tsx中定义的theme属性,在lib/index.ts中导出ThemeProvider

  • App.tsx中使用ThemeProvider

    <div class={classes.form}>
    <ThemeProvider theme={themeDefault as any}>
      <SchemaForm
        schema={demo.schema}
        value={demo.data}
        onChange={handleChange}
      />
    </ThemeProvider>
    </div>

通过定义一个组件的方式来提供theme,与之前通过SchemaForm.tsx通过props+provide的方式提供,实现了与SchemaForm解耦,好处就是,可以在其他地方导入导出,纯组件化的设计理念,通过组件的拆分组合来增加功能,而不是把功能耦合在一个组件中,但是也不是万能的只是提供了一种方式。

修复TS的类型问题

  • App.tsx<ThemeProvider theme={themeDefault as any}>

    • 修改下theme-default/index.tsx中的内容

      import SelectionWidget from './Selection'
      import { CommonWidgetPropsDefine, CommonWidgetDefine } from '../types'
      import { defineComponent } from 'vue'
      
      const CommonWidget = defineComponent({
        props: CommonWidgetPropsDefine,
        setup() {
          return () => null
        },
      }) as CommonWidgetDefine // 核心是这里 指定下类型 
      
      export default {
        widgets: {
          SelectionWidget,
          TextWidget: CommonWidget,
          NumberWidget: CommonWidget,
        },
      }
    • theme-default/Selection.tsx修改

      import { defineComponent, PropType, ref, watch } from 'vue'
      import { SelectionWidgetDefine, SelectionWidgetPropsDefine } from '../types'
      
      export default defineComponent({
        name: 'SelectionWidget',
        props: SelectionWidgetPropsDefine,
        setup(props) {
         //xxxx
        }
      }) as SelectionWidgetDefine

    主要是defineComponent的类型定义太复杂了

  • lib/theme.tsx中的getWidget方法里面(context.value.widgets as any)[name]

    • types.ts下添加 定义下name
    export enum SelectionWidgetNames {
      SelectionWidget = 'SelectionWidget',
    }
    export enum CommonWidgetNames {
      TextWidget =