添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

@ga23187/vue3-json-schema-form

0.1.0 Public • Published

课程地址: 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

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是有效的

    提取之后,无效了

    解决办法:as const

    原因: vue源码中这一段

    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函数实现

    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的变化

    比单文件组件更灵活

    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

    vue-jss css in js的库

    就可以新增一个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 (
              <MonacoEditor
                code={code}
                onChange={handleCodeChange}
                title="Schema"
                class={classes.editor}
            </div>
    

    简单展示APP

    核心是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,本来想依赖都按照教程中的版本指定安装,所以指定安装[email protected],但是安装的时候提示peer webpack@"^4.5.0" from [email protected],没得法只有安装最新的monaco-editor-webpack-plugin但是安装他又提示了peer monaco-editor@">= 0.31.0" from [email protected],所以只有二个插件都按最新的咯,尝试发现没得问题。

    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())
    

    通过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 (
                <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的内容也打包进来了。

    解决办法,通过.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",
    

    但是测试发现一个神奇的问题,通过上面的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)

    没得法使用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",
    

    通过--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 = 'TextWidget',
      NumberWidget = 'NumberWidget',
    export interface Theme {
      widgets: {
        [SelectionWidgetNames.SelectionWidget]: SelectionWidgetDefine
        [CommonWidgetNames.TextWidget]: CommonWidgetDefine
        [CommonWidgetNames.NumberWidget]: CommonWidgetDefine
    
  • 修改下theme.tsx
    // 提供给子组件使用的方法
    export function getWidget<T extends SelectionWidgetNames | CommonWidgetNames>(
      name: T,
      const context: ComputedRef<Theme> | undefined =
        inject<ComputedRef<Theme>>(THEME_PROVIDER_KEY)
      if (!context) {
        throw new Error('vjsf theme required')
      const widgetRef = computed(() => {
        return context.value.widgets[name]
      return widgetRef
    

    修改下ArrayField.tsx

    import { FiledPropDefine, Schema,SelectionWidgetNames } from '../types'
    // ...
    //const SelectionWidgetRef = getWidget('SelectionWidget')
    const SelectionWidgetRef = getWidget(SelectionWidgetNames.SelectionWidget)

    修复单元测试问题

    再次运行测试,发现关于ArrayField.spec.ts的测试失败了

    test/unit/utils下新建一个测试组件TestComponet.tsx

    import { defineComponent, PropType } from 'vue'
    import JsonSchemaForm, { Schema, ThemeProvider } from '../../../lib'
    import defaultTheme from '../../../lib/theme-default'
    export default defineComponent({
      name: 'TestComponent',
      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,
      setup(props) {
        return () => (
          <ThemeProvider theme={defaultTheme}>
            <JsonSchemaForm {...props} />
          </ThemeProvider>
    

    迁移TextWidget(StringField重写)与mergeProps

    lib/theme-default下新建TextWidget.tsx文件

    import { defineComponent } from 'vue'
    import { CommonWidgetPropsDefine, CommonWidgetDefine } from '../types'
    export default defineComponent({
      name: 'TextWidget',
      props: CommonWidgetPropsDefine,
      setup(props) {
        const handleChange = (e: any) => {
          console.log(e.target.value)
          props.onChange(e.target.value)
        return () => {
          return <input type="text" value={props.value} onInput={handleChange} />
    }) as CommonWidgetDefine

    修改StringField.tsx文件

    import { defineComponent } from 'vue'
    import { getWidget } from '../theme'
    import { FiledPropDefine, CommonWidgetNames } from '../types'
    export default defineComponent({
      name: 'StringField',
      props: FiledPropDefine,
      setup(props) {
        const handleChange = (v: string) => {
          console.log(v)
          // 在添加了onChange={handleChange},但是不props.onChange时
          // 输入框值变化,但是value不会变化
          props.onChange(v)
        const TextWidgetRef = getWidget(CommonWidgetNames.TextWidget)
        return () => {
          // 提取除了schema, rootSchema以外的参数
          // eslint-disable-next-line @typescript-eslint/no-unused-vars
          const { schema, rootSchema, ...rest } = props
          const TextWidget = TextWidgetRef.value
          // 这种写法会合并props 即onChange会得到一个数组[]
          return <TextWidget {...rest} onChange={handleChange}></TextWidget>
    

    处理vue的props合并机制

    修改babel.config.js,添加 { mergeProps: false }

    module.exports = {
      presets: ['@vue/cli-plugin-babel/preset'],
      plugins: [['@vue/babel-plugin-jsx', { mergeProps: false }]],
    

    Controlled-Input功能实现

    解决上面的input 输入框值改变了,但是绑定的value值没有变化问题,也不算问题,毕竟有的时候并不需要页面值变化对应内存的value变化

    // TextWidget.tsx

      setup(props) {
        const handleChange = (e: any) => {
          console.log(e.target.value, 'TextWidget.tsx')
          const value = e.target.value
          e.target.value = props.value
          props.onChange(value)
          //   nextTick(() => {
          //     if (props.value !== e.target.value) {
          //       e.target.value = props.value
          //     }
          //   })
        return () => {
          return <input type="text" value={props.value} onInput={handleChange} />
    

    迁移NumberWidget

    与TextWidget类似

    lib/theme-default下新建NumberWidget.tsx文件

    修改NumberField.tsx文件

    vue3响应式原理解析

    学习版本 v3.0.0

    reactive函数解析

    packages\reactivity\src\reactive.ts

    // 62行
    export function reactive<T extends object>(target: T): UnwrapNestedRefs<T>
    export function reactive(target: object) {
      // if trying to observe a readonly proxy, return the readonly version.
      // 如果target上有readonly属性就不会再被处理,具体就是含有__v_isReadonly
      if (target && (target as Target)[ReactiveFlags.IS_READONLY]) {
        return target
      // 创建一个响应式对象,核心是利用Proxy
      return createReactiveObject(
        target,
        false,
        mutableHandlers,
        mutableCollectionHandlers
    

    createReactiveObject函数

  • 判断是否是对象
  • 判断target是否已经被代理过了,是则直接返回该对象,否则继续
  • target上是否有__v_raw(原始对象),这里会触发get
  • 特殊情况,调用readonly函数并且对象是__v_isReactive是可以继续处理的
  • 从WeakMap中获取对象代理,存在直接返回(一个性能优化点)
  • 获取target的类型(INVALID = 0,COMMON = 1, COLLECTION = 2),无效说明是不可设置对象,直接返回,其他类型继续
  • 调用核心实现方法new Proxy
  • 存储到WeakMap中
  • function createReactiveObject(
      target: Target,
      isReadonly: boolean,
      baseHandlers: ProxyHandler<any>,
      collectionHandlers: ProxyHandler<any>
      if (!isObject(target)) {
        if (__DEV__) {
          console.warn(`value cannot be made reactive: ${String(target)}`)
        return target
      // target is already a Proxy, return it.
      // exception: calling readonly() on a reactive object
      if (
        target[ReactiveFlags.RAW] &&
        !(isReadonly && target[ReactiveFlags.IS_REACTIVE])
        return target
      // target already has corresponding Proxy
      const proxyMap = isReadonly ? readonlyMap : reactiveMap
      const existingProxy = proxyMap.get(target)
      if (existingProxy) {
        return existingProxy
      // only a whitelist of value types can be observed.
      const targetType = getTargetType(target)
      if (targetType === TargetType.INVALID) {
        return target
      const proxy = new Proxy(
        target,
        targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
      proxyMap.set(target, proxy)
      return proxy
    

    proxy的handler解析

    根据不同的api(reactive,shallowReactive,readonly,shallowReadonly)有不同的handler

    这里先看下reactive的handler,也就是mutableHandlers

    packages\reactivity\src\baseHandlers.ts

    // 187行
    export const mutableHandlers: ProxyHandler<object> = {
      get,
      set,
      deleteProperty,
      has,
      ownKeys
    

    get实现

    // 35行
    const get = /*#__PURE__*/ createGetter()
    const shallowGet = /*#__PURE__*/ createGetter(false, true)
    const readonlyGet = /*#__PURE__*/ createGetter(true)
    const shallowReadonlyGet = /*#__PURE__*/ createGetter(true, true)

    key等于reactive,则直接返回!isReadonly,key等于readonly,则直接返回isReadonly,key等于__v_raw并且receiver === (isReadonly ? readonlyMap : reactiveMap).get(target),则直接返回target

    判断target是否是数组

  • 是数组,并且key是['includes', 'indexOf', 'lastIndexOf']中,这些key会特殊处理一下,从Reflect.get(arrayInstrumentations, key, receiver)获取
  • 其他的,直接取const res = Reflect.get(target, key, receiver)

    是,则会判断一下,类似下面这种写法,name的值会直接返回res.value

    const state = reactive({
          name: ref('zzz'),
    
  • 如果target不是数组或key不是int(简单说不是一个数组取值的操作),则返回res.value,否则直接返回res
    // 72行
    function createGetter(isReadonly = false, shallow = false) {
      return function get(target: Target, key: string | symbol, receiver: object) {
        if (key === ReactiveFlags.IS_REACTIVE) {
          return !isReadonly
        } else if (key === ReactiveFlags.IS_READONLY) {
          return isReadonly
        } else if (
          key === ReactiveFlags.RAW &&
          receiver === (isReadonly ? readonlyMap : reactiveMap).get(target)
          // new Proxy 不会修改原对象?? 这里通过闭包直接返回源对象
          return target
        const targetIsArray = isArray(target)
        if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
          return Reflect.get(arrayInstrumentations, key, receiver)
        const res = Reflect.get(target, key, receiver)
        const keyIsSymbol = isSymbol(key)
        if (
          keyIsSymbol
            ? builtInSymbols.has(key as symbol)
            : key === `__proto__` || key === `__v_isRef`
          return res
        if (!isReadonly) {
          track(target, TrackOpTypes.GET, key)
        if (shallow) {
          return res
        if (isRef(res)) {
          // ref unwrapping - does not apply for Array + integer key.
          const shouldUnwrap = !targetIsArray || !isIntegerKey(key)
          return shouldUnwrap ? res.value : res
        if (isObject(res)) {
          // Convert returned value into a proxy as well. we do the isObject check
          // here to avoid invalid value warning. Also need to lazy access readonly
          // and reactive here to avoid circular dependency.
          return isReadonly ? readonly(res) : reactive(res)
        return res
    

    set实现

    packages\reactivity\src\baseHandlers.ts

    在target上取key的值,得到oldValue

    判断shallow

    不是shallow,则调用toRaw方法转化为普通对象,再判断target

    不是数组并且oldValue是ref,当前值不是ref,则会在oldValue.value = value上赋值

    对应情况如下

    const state = reactive({
          name: ref('zzz'),
    state.name = '123'
    那么这里不是name变为123了,而是ref('zzz')变成123了,就是这个ref.value变为123了
    包装与解包装
    // 126行
    const set = /*#__PURE__*/ createSetter()
    const shallowSet = /*#__PURE__*/ createSetter(true)
    // 129行
    function createSetter(shallow = false) {
      return function set(
        target: object,
        key: string | symbol,
        value: unknown,
        receiver: object
      ): boolean {
        const oldValue = (target as any)[key]
        if (!shallow) {
          value = toRaw(value)
          if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
            oldValue.value = value
            return true
        } else {
          // in shallow mode, objects are set as-is regardless of reactive or not
        const hadKey =
          isArray(target) && isIntegerKey(key)
            ? Number(key) < target.length
            : hasOwn(target, key)
        const result = Reflect.set(target, key, value, receiver)
        // don't trigger if target is something up in the prototype chain of original
        if (target === toRaw(receiver)) {
          if (!hadKey) {
            trigger(target, TriggerOpTypes.ADD, key, value)
          } else if (hasChanged(value, oldValue)) {
            trigger(target, TriggerOpTypes.SET, key, value, oldValue)
        return result
    

    delete实现

  • 删除了target上的key,触发trigger一个delete
  • function deleteProperty(target: object, key: string | symbol): boolean {
      const hadKey = hasOwn(target, key)
      const oldValue = (target as any)[key]
      const result = Reflect.deleteProperty(target, key)
      if (result && hadKey) {
        trigger(target, TriggerOpTypes.DELETE, key, undefined, oldValue)
      return result
    

    has实现

    track

    function has(target: object, key: string | symbol): boolean {
      const result = Reflect.has(target, key)
      if (!isSymbol(key) || !builtInSymbols.has(key)) {
        track(target, TrackOpTypes.HAS, key)
      return result
    

    ownKeys实现

    track

    function ownKeys(target: object): (string | number | symbol)[] {
      track(target, TrackOpTypes.ITERATE, ITERATE_KEY)
      return Reflect.ownKeys(target)
    

    proxy的集合类型handler解析( collectionHandler)

    packages\reactivity\src\collectionHandlers.ts

    map.get/map.set本质上都是在获取目标上面的属性,所以只需要一个get

    // 331行
    export const mutableCollectionHandlers: ProxyHandler<CollectionTypes> = {
      get: createInstrumentationGetter(false, false)
    export const shallowCollectionHandlers: ProxyHandler<CollectionTypes> = {
      get: createInstrumentationGetter(false, true)
    export const readonlyCollectionHandlers: ProxyHandler<CollectionTypes> = {
      get: createInstrumentationGetter(true, false)
    
  • 根据传入的isReadOnlyshallow得到instrumentations
  • 返回一个函数
  • 判断key
  • 通过Reflect.get获取值,核心是里面的判断key在target中,则会从instrumentations中获取值,否则就是从target中获取了,这样就到达了拦截代理的目的
  • // 301行
    function createInstrumentationGetter(isReadonly: boolean, shallow: boolean) {
      const instrumentations = shallow
        ? shallowInstrumentations
        : isReadonly
          ? readonlyInstrumentations
          : mutableInstrumentations
      return (
        target: CollectionTypes,
        key: string | symbol,
        receiver: CollectionTypes
      ) => {
        if (key === ReactiveFlags.IS_REACTIVE) {
          return !isReadonly
        } else if (key === ReactiveFlags.IS_READONLY) {
          return isReadonly
        } else if (key === ReactiveFlags.RAW) {
          return target
        return Reflect.get(
          hasOwn(instrumentations, key) && key in target
            ? instrumentations
            : target,
          key,
          receiver
    

    核心在于instrumentations,具体实现如下:

    // 235行
    const mutableInstrumentations: Record<string, Function> = {
      get(this: MapTypes, key: unknown) {
        return get(this, key)
      get size() {
        return size((this as unknown) as IterableCollections)
      has,
      add,
      set,
      delete: deleteEntry,
      clear,
      forEach: createForEach(false, false)
    

    查看get方法的实现

    function get(
      target: MapTypes,
      key: unknown,
      isReadonly = false,
      isShallow = false
      // #1772: readonly(reactive(Map)) should return readonly + reactive version
      // of the value
      target = (target as any)[ReactiveFlags.RAW]
      const rawTarget = toRaw(target)
      const rawKey = toRaw(key)
      if (key !== rawKey) {
        !isReadonly && track(rawTarget, TrackOpTypes.GET, key)
      !isReadonly && track(rawTarget, TrackOpTypes.GET, rawKey)
      const { has } = getProto(rawTarget)
      const wrap = isReadonly ? toReadonly : isShallow ? toShallow : toReactive
      if (has.call(rawTarget, key)) {
        return wrap(target.get(key))
      } else if (has.call(rawTarget, rawKey)) {
        return wrap(target.get(rawKey))
    

    set的实现

    function set(this: MapTypes, key: unknown, value: unknown) {
      value = toRaw(value)
      const target = toRaw(this) // 这里不是上面参数,是函数中的this,ts的特性为了引入this类型
      const { has, get } = getProto(target)
      let hadKey = has.call(target, key)
      if (!hadKey) {
        key = toRaw(key)
        hadKey = has.call(target, key)
      } else if (__DEV__) {
        checkIdentityKeys(target, has, key)
      const oldValue = get.call(target, key)
      const result = target.set(key, value)
      if (!hadKey) {
        trigger(target, TriggerOpTypes.ADD, key, value)
      } else if (hasChanged(value, oldValue)) {
        trigger(target, TriggerOpTypes.SET, key, value, oldValue)
      return result
    

    ref和computed解析

    packages\reactivity\src\ref.ts

    // 29行
    export function ref<T extends object>(
      value: T
    ): T extends Ref ? T : Ref<UnwrapRef<T>>
    export function ref<T>(value: T): Ref<UnwrapRef<T>>
    export function ref<T = any>(): Ref<T | undefined>
    export function ref(value?: unknown) {
      return createRef(value)
    
    // 70行
    function createRef(rawValue: unknown, shallow = false) {
      if (isRef(rawValue)) {
        return rawValue
      return new RefImpl(rawValue, shallow)
    // 24行
    export function isRef<T>(r: Ref<T> | unknown): r is Ref<T>
    export function isRef(r: any): r is Ref {
      return Boolean(r && r.__v_isRef === true)
    

    核心RefImpl

    在构造函数中根据是否_shallow,来转化一下就是会调用reactive函数

    get操作 会track当前实例,之所以toRaw是为了兼容

    const state = reactive({
          name: ref('zzz'),
    

    set操作,判断新老值是否变化,

  • 有变化,则赋值,然后再trigger set
  • // 47行
    class RefImpl<T> {
      private _value: T
      public readonly __v_isRef = true
      constructor(private _rawValue: T, private readonly _shallow = false) {
        this._value = _shallow ? _rawValue : convert(_rawValue)
      get value() {
        track(toRaw(this), TrackOpTypes.GET, 'value')
        return this._value
      set value(newVal) {
        if (hasChanged(toRaw(newVal), this._rawValue)) {
          this._rawValue = newVal
          this._value = this._shallow ? newVal : convert(newVal)
          trigger(toRaw(this), TriggerOpTypes.SET, 'value', newVal)
    // 21行
    const convert = <T extends unknown>(val: T): T =>
      isObject(val) ? reactive(val) : val

    computed

    packages\reactivity\src\computed.ts

    // 64行
    export function computed<T>(getter: ComputedGetter<T>): ComputedRef<T>
    export function computed<T>(
      options: WritableComputedOptions<T>
    ): WritableComputedRef<T>
    export function computed<T>(
      getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>
      let getter: ComputedGetter<T>
      let setter: ComputedSetter<T>
      if (isFunction(getterOrOptions)) {
        getter = getterOrOptions
        setter = __DEV__
          ? () => {
              console.warn('Write operation failed: computed value is readonly')
          : NOOP
      } else {
        getter = getterOrOptions.get
        setter = getterOrOptions.set
      return new ComputedRefImpl(
        getter,
        setter,
        isFunction(getterOrOptions) || !getterOrOptions.set
      ) as any
    

    核心通过ComputedRefImpl

  • effect
  • lazy懒更新,只要在计算属性被使用时才会触发更新。
  • scheduler 调度器
  • // 23行
    class ComputedRefImpl<T> {
      private _value!: T
      private _dirty = true
      public readonly effect: ReactiveEffect<T>
      public readonly __v_isRef = true;
      public readonly [ReactiveFlags.IS_READONLY]: boolean
      constructor(
        getter: ComputedGetter<T>,
        private readonly _setter: ComputedSetter<T>,
        isReadonly: boolean
        this.effect = effect(getter, {
          lazy: true,
          scheduler: () => {
            if (!this._dirty) {
              this._dirty = true
              trigger(toRaw(this), TriggerOpTypes.SET, 'value')
        this[ReactiveFlags.IS_READONLY] = isReadonly
      get value() {
        if (this._dirty) {
          this._value = this.effect()
          this._dirty = false
        track(toRaw(this), TrackOpTypes.GET, 'value')
        return this._value
      set value(newValue: T) {
        this._setter(newValue)
    

    watchEffect的解析

    track / trigger / effect / watch / watchEffect

    const state = reactive({
      count: 0,
    setInterval(() => {
      state.count += 1
    }, 1000)
    debugger
    watchEffect(() => {
      console.log(state.count)
    

    packages\runtime-core\src\apiWatch.ts

    // 133行
    doWatch函数中的核心有个effect函数及getter函数的赋值还有就是会返回一个取消watch的函数(和api吻合上了)
    const runner = effect(getter, {
        lazy: true,
        onTrack,
        onTrigger,
        scheduler
      let getter: () => any
      const isRefSource = isRef(source)
      if (isRefSource) {
        getter = () => (source as Ref).value
      } else if (isReactive(source)) {
        getter = () => source
        deep = true
      } else if (isArray(source)) {
        getter = () =>
          source.map(s => {
            if (isRef(s)) {
              return s.value
            } else if (isReactive(s)) {
              return traverse(s)
            } else if (isFunction(s)) {
              return callWithErrorHandling(s, instance, ErrorCodes.WATCH_GETTER)
            } else {
              __DEV__ && warnInvalidSource(s)
      } else if (isFunction(source)) {
        if (cb) {
          // getter with cb
          getter = () =>
            callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
        } else {
          // no cb -> simple effect
          getter = () => {
            if (instance && instance.isUnmounted) {
              return
            if (cleanup) {
              cleanup()
            return callWithErrorHandling(
              source,
              instance,
              ErrorCodes.WATCH_CALLBACK,
              [onInvalidate]
      } else {
        getter = NOOP
        __DEV__ && warnInvalidSource(source)
    

    里面有个callWithErrorHandling函数,其实就是一个异常捕获,在里面执行函数,报错会触发一个handleError函数,里面的实现类似vue中可以导入的一个组件内部的钩子onErrorCaptured,还有个整个实例的errorHandler

    export function callWithErrorHandling(
      fn: Function,
      instance: ComponentInternalInstance | null,
      type: ErrorTypes,
      args?: unknown[]
      let res
      try {
        res = args ? fn(...args) : fn()
      } catch (err) {
        handleError(err, instance, type)
      return res
    

    其还有一个功能点,就是会从args中获取到参数,也就是

    watchEffect((onInvalidate)=>{
    	console.log(state.count)
        // 异步请求操作
        const token = asyncOperation(id)
        // 这里
        onInvalidate(()=>{
            // 取消之前的请求
            token.cancel()
    

    packages\reactivity\src\effect.ts

    // 54行
    export function effect<T = any>(
      fn: () => T,
      options: ReactiveEffectOptions = EMPTY_OBJ
    ): ReactiveEffect<T> {
      if (isEffect(fn)) {
        fn = fn.raw
      const effect = createReactiveEffect(fn, options)
      if (!options.lazy) {
        effect()
      return effect
    
    // 80行
    function createReactiveEffect<T = any>(
      fn: () => T,
      options: ReactiveEffectOptions
    ): ReactiveEffect<T> {
      // 创建一个effect函数
      const effect = function reactiveEffect(): unknown {
        if (!effect.active) {
          return options.scheduler ? undefined : fn()
        // effectStack数组中不存在effect
        if (!effectStack.includes(effect)) {
          // 清除effect中的deps
          cleanup(effect)
          try {
            enableTracking()
            effectStack.push(effect)
            activeEffect = effect
            return fn() // 执行我们watchEffect传入的函数,其中含有Proxy代理过的对象,就会触发track
          } finally {
            effectStack.pop()
            resetTracking()
            activeEffect = effectStack[effectStack.length - 1]
      } as ReactiveEffect
      // 先上面添加一些属性
      effect.id = uid++
      effect._isEffect = true
      effect.active = true
      effect.raw = fn
      effect.deps = []
      effect.options = options
      return effect
    
    // 121行
    let shouldTrack = true
    const trackStack: boolean[] = []
    export function pauseTracking() {
      trackStack.push(shouldTrack)
      shouldTrack = false
    // 129行
    export function enableTracking() {
      trackStack.push(shouldTrack)
      shouldTrack = true
    export function resetTracking() {
      const last = trackStack.pop()
      shouldTrack = last === undefined ? true : last
    

    核心track函数, get读取时执行

    维护了一个targetMap来存储有关的effect

    对象的某个属性: ['reactEffect1','reactEffect1']
    // 139行
    export function track(target: object, type: TrackOpTypes, key: unknown) {
      if (!shouldTrack || activeEffect === undefined) {
        return
      let depsMap = targetMap.get(target)
      if (!depsMap) {
        targetMap.set(target, (depsMap = new Map()))
      let dep = depsMap.get(key)
      if (!dep) {
        depsMap.set(key, (dep = new Set()))
      if (!dep.has(activeEffect)) {
        dep.add(activeEffect)
        activeEffect.deps.push(dep)
        if (__DEV__ && activeEffect.options.onTrack) {
          activeEffect.options.onTrack({
            effect: activeEffect,
            target,
            type,
    

    核心trigger函数 set赋值触发

    export function trigger(
      target: object,
      type: TriggerOpTypes,
      key?: unknown,
      newValue?: unknown,
      oldValue?: unknown,
      oldTarget?: Map<unknown, unknown> | Set<unknown>
      const depsMap = targetMap.get(target)
      if (!depsMap) {
        // never been tracked
        return
      const effects = new Set<ReactiveEffect>()
      const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
        if (effectsToAdd) {
          effectsToAdd.forEach(effect => {
            // 这里是为了避免在某个watch中又去设置值,再次触发trigger,可以通过allowRecurse开启
            // 一般开发中不推荐这样使用,假如真的需要在watch中设置值时,最好添加一个递归结束条件
            if (effect !== activeEffect || effect.options.allowRecurse) {
              effects.add(effect)
      if (type === TriggerOpTypes.CLEAR) {
        // collection being cleared
        // trigger all effects for target
        depsMap.forEach(add)
      } else if (key === 'length' && isArray(target)) {
        // 对数组长度进行操作,收集新增的dep
        depsMap.forEach((dep, key) => {
          if (key === 'length' || key >= (newValue as number)) {
            add(dep)
      } else {
        // schedule runs for SET | ADD | DELETE
        if (key !== void 0) {
          add(depsMap.get(key))
        // also run for iteration key on ADD | DELETE | Map.SET
        switch (type) {
          case TriggerOpTypes.ADD:
            if (!isArray(target)) {
              add(depsMap.get(ITERATE_KEY))
              if (isMap(target)) {
                add(depsMap.get(MAP_KEY_ITERATE_KEY))
            } else if (isIntegerKey(key)) {
              // new index added to array -> length changes
              add(depsMap.get('length'))
            break
          case TriggerOpTypes.DELETE:
            if (!isArray(target)) {
              add(depsMap.get(ITERATE_KEY))
              if (isMap(target)) {
                add(depsMap.get(MAP_KEY_ITERATE_KEY))
            break
          case TriggerOpTypes.SET:
            if (isMap(target)) {
              add(depsMap.get(ITERATE_KEY))
            break
      const run = (effect: ReactiveEffect) => {
        if (__DEV__ && effect.options.onTrigger) {
          effect.options.onTrigger({
            effect,
            target,
            key,
            type,
            newValue,
            oldValue,
            oldTarget
        // 传入了调度器,那么会执行scheduler
        if (effect.options.scheduler) {
          effect.options.scheduler(effect)
        } else {
          effect()
      effects.forEach(run)
    

    scheduler调度器

    还是在apiWatch.ts文件中的doWatch函数入手

    TODO queuePostRenderEffect instance.suspense

      let scheduler: (job: () => any) => void
      if (flush === 'sync') {
        scheduler = job
      } else if (flush === 'post') {
        scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
      } else {
        // default: 'pre'
        scheduler = () => {
          if (!instance || instance.isMounted) {
            queuePreFlushCb(job)
          } else {
            // with 'pre' option, the first call must happen before
            // the component is mounted so it is called synchronously.
            job()
    

    packages\runtime-core\src\scheduler.ts

    export function queuePreFlushCb(cb: SchedulerCb) {
      queueCb(cb, activePreFlushCbs, pendingPreFlushCbs, preFlushIndex)
    export function queuePostFlushCb(cb: SchedulerCbs) {
      queueCb(cb, activePostFlushCbs, pendingPostFlushCbs, postFlushIndex)
    
    function queueCb(
      cb: SchedulerCbs,
      activeQueue: SchedulerCb[] | null,
      pendingQueue: SchedulerCb[],
      index: number
      if (!isArray(cb)) {
        if (
          !activeQueue ||
          !activeQueue.includes(
            (cb as SchedulerJob).allowRecurse ? index + 1 : index
          pendingQueue.push(cb)
      } else {
        // if cb is an array, it is a component lifecycle hook which can only be
        // triggered by a job, which is already deduped in the main queue, so
        // we can skip duplicate check here to improve perf
        pendingQueue.push(...cb)
      queueFlush()
    function queueFlush() {
      if (!isFlushing && !isFlushPending) {
        isFlushPending = true
        currentFlushPromise = resolvedPromise.then(flushJobs)
    
    function flushJobs(seen?: CountMap) {
      isFlushPending = false
      isFlushing = true
      if (__DEV__) {
        seen = seen || new Map()
      flushPreFlushCbs(seen)
      // Sort queue before flush.
      // This ensures that:
      // 1. Components are updated from parent to child. (because parent is always
      //    created before the child so its render effect will have smaller
      //    priority number)
      // 2. If a component is unmounted during a parent component's update,
      //    its update can be skipped.
      // Jobs can never be null before flush starts, since they are only invalidated
      // during execution of another flushed job.
      queue.sort((a, b) => getId(a!) - getId(b!))
      try {
        for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
          const job = queue[flushIndex]
          if (job) {
            if (__DEV__) {
              checkRecursiveUpdates(seen!, job)
            callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
      } finally {
        flushIndex = 0
        queue.length = 0
        flushPostFlushCbs(seen)
        isFlushing = false
        currentFlushPromise = null
        // some postFlushCb queued jobs!
        // keep flushing until it drains.
        if (queue.length || pendingPostFlushCbs.length) {
          flushJobs(seen)
    

    利用json-schema校验提供的错误信息来,需要注意的是当使用了errorMessage来自定义错误信息时,返回的错误信息数组中keyword是固定为errorMessage了而不是对应属性key。

    父组件调用子组件在setup中的方法

    vue2中一般都是通过ref的方式,直接可以获取到子组件的方法,但是在vue3的setup写法中,通过ref只能取到props

  • 解决办法: 类似react中的事件,子组件定义一个props,并在子组件监听这个props,变化则修改这个props,加上我们子组件的方法(虽然vue不推荐在子组件修改props,但是这样的确方便实现了我们的目的)父组件传递这个props,在初始化完成后就会在父组件的属性上挂载了我们子组件的方法
  • // lib\SchemaForm.tsx

    interface ContextRef {
      doValidate: () => {
        errors: any[]
        valid: boolean
    export default defineComponent({
      props: {
        // ...
        contextRef: {
          type: Object as PropType<Ref<ContextRef | undefined>>,
      name: 'SchemaForm',
      setup(props) {
    // ...
        watch(
          () => props.contextRef,
          () => {
            if (props.contextRef) {
              console.log('-------------', props.contextRef)
              // eslint-disable-next-line vue/no-mutating-props
              props.contextRef.value = {
                doValidate() {
                  console.log('-------doValidate------', props.contextRef)
                  return {
                    valid: true,
                    errors: [],
            immediate: true,
     // ....
    

    //src\App.tsx

    export default defineComponent({
      setup() {
          const contextRef = ref()
          return () => {
              return (
                  //....
              <div class={classes.form}>
                  <ThemeProvider theme={themeDefault}>
                    <SchemaForm
                      schema={demo.schema}
                      value={demo.data}
                      onChange={handleChange}
                      contextRef={contextRef}
                  </ThemeProvider>
                  <button onClick={() => contextRef.value.doValidate()}>
                  </button>
                </div>
    

    实现ajv的校验

    schemaForm.tsx中定义一个ajvOptions的prop,定义一个默认ajv选项defaultAjvOptions,创建一个浅的响应式对象来存储校验对象,监听props.ajvOptions。

    //...
    import Ajv, { Options } from 'ajv'
    const defaultAjvOptions: Options = {
      allErrors: true,
      // jsonPointers:true, //v6.12.6 to v8.0.0已经是默认的了
    export default defineComponent({
      props: {
        schema: {
       //...
        ajvOptions: {
          type: Object as PropType<Options>,
      name: 'SchemaForm',
      setup(props) {
        const validatorRef: Ref<Ajv> = shallowRef() as any
        watchEffect(() => {
          validatorRef.value = new Ajv({
            ...defaultAjvOptions,
            ...props.ajvOptions,
        watch(
          () => props.contextRef,
          () => {
            if (props.contextRef) {	
              props.contextRef.value = {
                doValidate() {
                  // 调用ajv的validate方法
                  const valid = validatorRef.value.validate(
                    props.schema,
                    props.value,
                  return {
                    valid,
                    errors: validatorRef.value.errors || [],
            immediate: true,
      //...
    

    测试一下,修改下App.tsx中的校验按钮绑定的事件,打印下校验信息

    再修改下demos/demo.ts

    点击触发校验

    export default {
      name: 'Demo',
      schema: {
        type: 'string',
        minLength: 10,
      uiSchema: '',
      default: 0,
    

    默认值是0,类型不符合

    输入下1,长度不符合

    转化错误信息到errorSchema

    默认错误信息是一个数组返回的,转化为对象方便处理

    新建一个validator.ts文件,用于校验

    import Ajv, { ErrorObject } from 'ajv'
    import i18n from 'ajv-i18n'
    import toPath from 'lodash.topath'
    import { Schema } from './types'
    interface TransformErrorObject {
      name: string
      property: string
      message?: string
      params: Record<string, any>
      schemaPath: string
    export type ErrorSchema = {
      [level: string]: ErrorSchema
    } & {
      __errors: string[]
    // 数组转对象格式的ErrorSchema
    function toErrorSchema(errors: TransformErrorObject[]) {
      if (!errors.length) {
        return {}
      return errors.reduce((errorSchema, error) => {
        const { property, message } = error
        const path = toPath(property) // 将`/obj/name`转数组[obj,name]
        let parent = errorSchema
        // If the property is at the root (.level1) then toPath creates
        // an empty array element at the first index. Remove it.
        if (path.length > 0 && path[0] === '') {
          path.splice(0, 1)
        //     obj:{
        //         name:{}
        //     }
        for (const segment of path.slice(0)) {
          if (!(segment in parent)) {
            ;(parent as any)[segment] = {}
          parent = parent[segment]
        if (Array.isArray(parent.__errors)) {
          // We store the list of errors for this node in a property named __errors
          // to avoid name collision with a possible sub schema field named
          // "errors" (see `validate.createErrorHandler`).
          parent.__errors = parent.__errors.concat(message || '')
        } else {
          if (message) {
            parent.__errors = [message]
        return errorSchema
      }, {} as ErrorSchema)
    function transformErrors(
      errors: ErrorObject[] | null | undefined,
    ): TransformErrorObject[] {
      if (errors === null || errors === undefined) {
        return []
      return errors.map(
        ({ message, instancePath, keyword, params, schemaPath }) => {
          return {
            name: keyword,
            property: `${instancePath}`, // 老版ajv是dataPath
            message,
            params,
            schemaPath,
    export interface Localize {
      (errors?: null | ErrorObject[]): void
    export function validateFormData(
      validator: Ajv,
      formData: any,
      schema: Schema,
      locale = 'zh',
      let validationError: any = null
      try {
        validator.validate(schema, formData)
      } catch (err) {
        validationError = err
        console.error('validateFormData error', validationError)
      // FIXME: ts类型问题
      i18n[locale as 'zh'](validator.errors)
      // i18n.zh(validator.errors)
      let errors = transformErrors(validator.errors)
      if (validationError) {
        errors = [
          ...errors,
            message: validationError.message,
          } as TransformErrorObject,
      const errorShema = toErrorSchema(errors)
      return {
        errors,
        errorShema,
        valid: errors.length === 0,
    

    安装lodash

    npm i lodash.topath
    npm i -D @types/lodash.topath

    SchemaForm.tsx中使用

    watch(
      () => props.contextRef,
      () => {
        if (props.contextRef) {
          // console.log('-------------', props.contextRef)
          // eslint-disable-next-line vue/no-mutating-props
          props.contextRef.value = {
            doValidate() {
              // console.log('-------doValidate------', props.contextRef)
              const result = validateFormData(
                validatorRef.value,
                props.value,
                props.schema,
                props.locale,
              return result
        immediate: true,
    

    错误信息向下传递

    先再SchemaForm.tsx中声明一个浅响应式对象来存储上面的errorSchema,在doValidate中赋值,并通过props传递到SchemaItem.tsx

    const errorSchemaRef: Ref<ErrorSchema> = shallowRef({})
    errorSchemaRef.value = result.errorShema
    return () => {
          const { schema, value } = props
          // 使用SchemaItem中间处理器
          return (
            <SchemaItem
              errorSchema={errorSchemaRef.value || {}}
              schema={schema}
              rootSchema={schema}
              value={value}
              onChange={handleChange}
    

    需要在SchemaItem.tsx中声明一个errorSchema的props来接收父组件的值,也就是修改下types中的FiledPropDefine

    export const FiledPropDefine = {
      //...
      errorSchema: {
        type: Object as PropType<ErrorSchema>,
        required: true,
    } as const

    接着就是需要在具体的渲染实现中使用errorSchema,也就是fields下的ArrayField.tsx ObjectField.tsx对于NumberField.tsx StringField.tsx 需要的是errors={errorSchema.__errors},就可以了,修改下其types就也是CommonWidgetPropsDefine添加errors属性

    export const CommonWidgetPropsDefine = {
     // ...
      errors: {
        type: Array as PropType<string[]>,
    } as const

    通过FormItem.tsx展示label与错误信息

    上面我们已经完成了,错误信息的转化,现在页面上并没有显示,来展示下

    新建lib/theme-default/FormItem.tsx

    import { defineComponent } from 'vue'
    import { CommonWidgetPropsDefine } from '../types'
    import { createUseStyles } from 'vue-jss'
    const useStyles = createUseStyles({
      container: {},
      label: {
        display: 'block',
        color: '#777',
      errorText: {
        color: 'red',
        fontSize: 12,
        margin: '5px 0',
        padding: 0,
        paddingLeft: 20,
    export default defineComponent({
      name: 'FormItem',
      props: CommonWidgetPropsDefine,
      setup(props, { slots }) {
        const classRef = useStyles()
        return () => {
          const { schema, errors } = props
          const classes = classRef.value
          return (
            <div class={classes.container}>
              <label class={classes.label}>{schema.title}</label>
              {slots.default && slots.default()}
              <ul class={classes.errorText}>
                {errors?.map((err) => (
                  <li>{err}</li>
                ))}
            </div>
    

    要展示label还需要从schema中拿到title,修改下CommonWidgetPropsDefine添加schema属性

    export const CommonWidgetPropsDefine = {
      schema: {
        type: Object as PropType<Schema>,
        required: true,
    } as const

    TextWidget.tsx中先使用一下

    // ...
    import FormItem from './FormItem'
    export default defineComponent({
      name: 'TextWidget',
      props: CommonWidgetPropsDefine,
      setup(props) {
    	// ....
        return () => {
          return (
            <FormItem {...props}>
              <input type="text" value={props.value} onInput={handleChange} />
            </FormItem>
    }) as CommonWidgetDefine

    /src/demo/demos.ts下补充下title

    export default {
      name: 'Demo',
      schema: {
        type: 'string',
        minLength: 10,
        title: 'demo',
      uiSchema: '',
      default: 0,
    

    通过高阶组件来抽离FormItem逻辑

    上面定义了展示错误信息的FormItem组件,但是要在每个Field上使用都需要import在使用会比较麻烦,而且一旦FormItem的使用方式发送变化,每个用到的地方都需要修改。

    FormItem.tsx中添加一个方法

    // HOC Hight Order Component 高阶组件
    export function withFormItem(Widget: any) {
      return defineComponent({
        name: `Wrapped${Widget.name}`,
        props: CommonWidgetPropsDefine,
        setup(props, { attrs, slots }) {
          return () => {
            return (
              // 未定义的props属性就可以从attrs中获取到
              // Widget组件的slot  slots={slots}
              // withFormItem组件的ref
              <FormItem {...props}>
                <Widget {...props} {...attrs}></Widget>
              </FormItem>
      }) as any
    

    TextWidget.tsx SelectionWidget.tsx NumberWidget.tsx中使用即可

    比如TextWidget.tsx

    import { defineComponent } from 'vue'
    import { CommonWidgetPropsDefine, CommonWidgetDefine } from '../types'
    import { withFormItem } from './FormItem'
    const TextWidget: CommonWidgetDefine = withFormItem(
      defineComponent({
        name: 'TextWidget',
        props: CommonWidgetPropsDefine,
        setup(props) {
          const handleChange = (e: any) => {
            console.log(e.target.value, 'TextWidget.tsx')
            const value = e.target.value
            e.target.value = props.value
            props.onChange(value)
          return () => {
            return <input type="text" value={props.value} onInput={handleChange} />
      }),
    export default TextWidget

    实现自定义校验功能

    之前的校验都是利用的ajv提供的校验实现的,现在我们需要提供一种方式来给用户自定义校验规则,

    核心:利用Proxy方法

    SchemaForm.tsx中新增一个属性customValidate,用来传递自定义校验方法

    export default defineComponent({
      props: {
        // ...
        customValidate: {
          type: Function as PropType<(data: any, errors: any) => void>,
      name: 'SchemaForm',
      setup(props) {
    	// ...
        watch(
          () => props.contextRef,
          () => {
            if (props.contextRef) {
              props.contextRef.value = {
                doValidate() {
                  const result = validateFormData(
                    validatorRef.value,
                    props.value,
                    props.schema,
                    props.locale,
                    props.customValidate,
                 // ...
                  return result
       //...
       // ...
        return () => {
      //...
          return (
            <SchemaItem
              errorSchema={errorSchemaRef.value || {}}
             //...
    

    修改下validateFormData方法

    // validator.ts

    export function validateFormData(
      validator: Ajv,
      formData: any,
      schema: Schema,
      locale = 'zh',
      customValidate?: (data: any, errors: any) => void,
      let validationError: any = null
      try {
        validator.validate(schema, formData)
      } catch (err) {
        validationError = err
        console.error('validateFormData error', validationError)
      // FIXME: ts类型问题
      i18n[locale as 'zh'](validator.errors)
      //   i18n.zh(validator.errors)
      let errors = transformErrors(validator.errors)
      if (validationError) {
        errors = [
          ...errors,
            message: validationError.message,
          } as TransformErrorObject,
      const errorShema = toErrorSchema(errors)
      if (!customValidate) {
        return {
          errors,
          errorShema,
          valid: errors.length === 0,
      } else {
        // 执行自定义校验
        const proxy = createErrorHandlerProxy()
        customValidate(formData, proxy)
        // 合并ajv校验与自定义校验
        const newErrorSchema = mergeObjects(errorShema, proxy, true)
        return {
          errors,
          errorShema: newErrorSchema,
          valid: errors.length === 0,
    // 创建一种符合我们指定格式数据的方法
    function createErrorHandlerProxy(raw: object = {}) {
      return new Proxy(raw, {
        get(target, key, receiver) {
          if (key === 'addError') {
            return (msg: string) => {
              const t: any = target
              if (t.__errors) t.__errors.push(msg)
              else t.__errors = [msg]
          const res = Reflect.get(target, key, receiver)
          // 其他属性的读取,也是返回createErrorHandlerProxy,
          // 但是需要判断下该属性是否已经创建过了
          if (res === undefined) {
            const proxy: any = createErrorHandlerProxy({})
            ;(target as any)[key] = proxy
            return proxy
          return res
    export function mergeObjects(obj1: any, obj2: any, concatArrays = false) {
      // Recursively merge deeply nested objects.
      const acc = Object.assign({}, obj1) // Prevent mutation of source object.
      return Object.keys(obj2).reduce((acc, key) => {
        const left = obj1 ? obj1[key] : {},
          right = obj2[key]
        if (obj1 && obj1.hasOwnProperty(key) && isObject(right)) {
          acc[key] = mergeObjects(left, right, concatArrays)
        } else if (concatArrays && Array.isArray(left) && Array.isArray(right)) {
          acc[key] = left.concat(right)
        } else {
          acc[key] = right
        return acc
      }, acc)
    

    同时还需要修改下App.tsx

    export default defineComponent({
      setup() {
        const selectedRef = ref(0)
        const demo: {
         //...
          customValidate: ((d: any, e: any) => void) | undefined
        } = reactive({
        //...
          customValidate: undefined,
        watchEffect(() => {
          const index = selectedRef.value
          const d: any = demos[index]
          demo.schema = d.schema
          demo.data = d.default
          demo.uiSchema = d.uiSchema
          demo.schemaCode = toJson(d.schema)
          demo.dataCode = toJson(d.default)
          demo.uiSchemaCode = toJson(d.uiSchema)
          demo.customValidate = d.customValidate
       //...
        return () => {
          const classes = classesRef.value
          const selected = selectedRef.value
          console.log(nameRef, 'nameRef')
          return (
            <div class={classes.container}>
              //...
                <div class={classes.form}>
                  <ThemeProvider theme={themeDefault}>
                    <SchemaForm
                      customValidate={demo.customValidate}
                      //...
                  </ThemeProvider>
                  //...
                </div>
              </div>
            </div>
    

    demo.ts下测试下

    export default {
      name: 'Demo',
      schema: {
        type: 'object',
        properties: {
          pass1: {
            type: 'string',
            minLength: 10,
            title: 'password',
          pass2: {
            type: 'string',
            minLength: 10,
            title: 'retry password',
      customValidate(data: any, errors: any) {
        if (data.pass1 !== data.pass2) {
          errors.pass2.addError('密码必须相同')
      uiSchema: '',
      default: 0,
    

    异步校验功能

    实际开发过程中是存在输入的数据来源是后台返回的,就好比上面的输入框校验是提交给后台的,也就是对比结果是异步返回的,那么在后端进行校验的过程中,我们再次改变了其中的输入值对于这种情况进行校验要么是在这个过程中禁用掉用户的其他操作(不太友好),要么就需要监听值的修改,最后进行一次校验

    首先 将validateFormData方法改为async,然后就可以await customValidate(formData, proxy)了。

    在测试中就可以使用异步了

      async customValidate(data: any, errors: any) {
        // if (data.pass1 !== data.pass2) {
        //   errors.pass2.addError('密码必须相同')
        return new Promise((resolve) => {
          setTimeout(() => {
            if (data.pass1 !== data.pass2) {
              errors.pass2.addError('密码必须相同')
            resolve('')
          }, 2000)
    

    然后还需要再SchemaForm.tsx中修改下

    watch(
      () => props.contextRef,
      () => {
        if (props.contextRef) {
          props.contextRef.value = {
           async doValidate() {
               const result = await validateFormData(
                 validatorRef.value,
                 props.value,
                 props.schema,
                 props.locale,
                 props.customValidate,
               errorSchemaRef.value = result.errorShema
               return result
        immediate: true,
    

    App.tsx中打印校验结果的地方也需要修改下

     const validateForm = () => {
          contextRef.value.doValidate().then((res: any) => {
            console.log(res)
    

    上面的修改基本实现了异步的功能,但是有一种情况还不能正常处理,就是在先输入第一个输入框123后,点击校验,在等待结果返回的2s间隔中间我们输入第二个输入框也是123,这时候等时间到了后,第二个校验木有任何效果,长度校验也没有触发

    const validateResolveRef = ref()
    const validateIndex = ref(0)
    watch(
      () => props.value,
      () => {
        if (validateResolveRef.value) {
           doValidate()
        deep: true,
    async function doValidate() {
      console.log('start 校验')
      const index = (validateIndex.value += 1)
      const result = await validateFormData(
        validatorRef.value,
        props.value,
        props.schema,
        props.locale,
        props.customValidate,
      if (index !== validateIndex.value) {
        // 索引不相同,说明中间执行过几次doValidate, 之前的结果我们其实是不需要的
        return
      console.log('end 校验')
      errorSchemaRef.value = result.errorShema
      // 执行校验
      validateResolveRef.value(result)
      validateResolveRef.value = undefined
    watch(
      () => props.contextRef,
      () => {
        if (props.contextRef) {
          props.contextRef.value = {
            doValidate() {
              return new Promise((resolve) => {
                validateResolveRef.value = resolve
                doValidate()
        immediate: true,
    

    表单自定义渲染

    定义uiSchema属性

    从父组件依次传递到具体的Field

    SchemaForm.tsx中添加uiSchema的props
  • types.ts中的FiledPropDefine添加uiSchema ArrayField.tsxObjectField.tsx使用uiSchema
  • 最后需要在App.tsx中传递uiSchema

    使用widget字段实现自定义渲染

    上面只是定义了数据的传递,还没有在组件上去使用

    StringField.tsx为例,需要修改下之前的getWidget方法,支持传入uiSchema

    // theme.tsx

    export function getWidget<T extends SelectionWidgetNames | CommonWidgetNames>(
      name: T,
      uiSchema?: UISchema,
      if (uiSchema?.widget && isObject(uiSchema.widget)) {
        return ref(uiSchema.widget as CommonWidgetDefine)
      //...
      return widgetRef
    

    // StringField.tsx

    import { computed, defineComponent } from 'vue'
    import { getWidget } from '../theme'
    import { FiledPropDefine, CommonWidgetNames } from '../types'
    export default defineComponent({
      name: 'StringField',
      props: FiledPropDefine,
      setup(props) {
        //...
        // 改成计算属性,uiSchema一旦变化了就可以再次触发getWidget
        // const TextWidgetRef = getWidget(CommonWidgetNames.TextWidget)
        const TextWidgetRef = computed(() => {
          const widgetRef = getWidget(CommonWidgetNames.TextWidget, props.uiSchema)
          return widgetRef.value
    	//...
        return () => {
         //...
    

    测试在src/components/asswordWidget.tsx组件,给demo下使用

    import PasswordWidget from '@/components/PasswordWidget'
    export default {
      name: 'Demo',
      schema: {
        type: 'object',
        properties: {
          pass1: {
            type: 'string',
            minLength: 10,
            title: 'password',
          pass2: {
            type: 'string',
            minLength: 10,
            title: 'retry password',
      // ....
      uiSchema: {
        properties: {
          pass1: {
            widget: PasswordWidget,
      default: 0,
    

    测试发现,现在第一个输入框就是password类型了,之前是text

    uiSchema的更多使用场景

    通过在CommonWidgetPropsDefine中添加一个options属性来支持更多的自定义属性

    比如在TextWidget.tsx上支持一下颜色的修改

    // theme-default/TextWidget.tsx

    // ...
    const TextWidget: CommonWidgetDefine = withFormItem(
      defineComponent({
        name: 'TextWidget',
        props: CommonWidgetPropsDefine,
        setup(props) {
          // ...
          const styleRef = computed(() => {
            return {
              color: (props.options && props.options.color) || 'black',
          return () => {
            return (
              <input
                type="text"
                value={props.value}
                onInput={handleChange}
                style={styleRef.value}
      }),
    export default TextWidget

    // StringField.tsx

    import { computed, defineComponent } from 'vue'
    import { getWidget } from '../theme'
    import { FiledPropDefine, CommonWidgetNames } from '../types'
    export default defineComponent({
      name: 'StringField',
      props: FiledPropDefine,
      setup(props) {
      	// ...
        // 通过rest的方法取出widget的options
        const widgetOptionsRef = computed(() => {
          // eslint-disable-next-line @typescript-eslint/no-unused-vars
          const { widget, properties, items, ...rest } = props.uiSchema
          return rest
        return () => {
         //...
          return (
            <TextWidget
              //...
              options={widgetOptionsRef.value}
            ></TextWidget>
    

    demo.ts使用

    import PasswordWidget from '@/components/PasswordWidget'
    export default {
      name: 'Demo',
      schema: {
        type: 'object',
        properties: {
          pass1: {
            type: 'string',
            minLength: 10,
            title: 'password',
          pass2: {
            type: 'string',
            minLength: 10,
            title: 'retry password',
      uiSchema: {
        properties: {
          pass1: {
            widget: PasswordWidget,
          pass2: {
            color: 'red',
      default: 0,
    

    自定义format实现自定义渲染

    上面我们已经实现了最为强大的自定义渲染,也就是使用widget字段,随意使用自己定义的组件,

    现在我们来通过扩展jsonschema的format属性来实现自定义渲染。扩展了jsonschema的format后,还会带有jsonschema的校验功能,并且jsonschema还可以方便指定使用的组件

    先定义下自定义format的类型,再修改SchemaForm.tsx,添加customFormats props

    //types.ts

    export interface CustomFormat {
      name: string
      definition: FormatDefinition<string> // FIXME: ajv8版本这里是个泛型
      component: CommonWidgetDefine
    

    // SchemaForm.tsx

    import { CommonWidgetDefine, CustomFormat, Schema, UISchema } from './types'
    //...
    //...
    export default defineComponent({
      props: {
       // ...
        customFormats: {
          type: [Array, Object] as PropType<CustomFormat[] | CustomFormat>,
      name: 'SchemaForm',
      setup(props) {
       //...
        watchEffect(() => {
         //...
          if (props.customFormats) {
            const customFormats = Array.isArray(props.customFormats)
              ? props.customFormats
              : [props.customFormats]
            // 扩展ajv的format
            customFormats.forEach((format) => {
              validatorRef.value.addFormat(format.name, format.definition)
        // ...
        // 将customFormats转成一个对象形式并provide给子组件
        const formatMapRef = computed(() => {
          if (props.customFormats) {
            const customFormats = Array.isArray(props.customFormats)
              ? props.customFormats
              : [props.customFormats]
            return customFormats.reduce((result, format) => {
              result[format.name] = format.component
              return result
            }, {} as { [key: string]: CommonWidgetDefine })
          } else {
            return {}
        const context = {
          SchemaItem,
          // theme: props.theme,
          formatMapRef,
        provide(ShcemaFormContextKey, context)
        return () => {
    

    修改下getWidget方法,处理下支持format

    // theme.tsx

    //...
    import { useVJSFContext } from './context'
    //...
    // 提供给子组件使用的方法
    export function getWidget<T extends SelectionWidgetNames | CommonWidgetNames>(
      name: T,
      props?: ExtractPropTypes<typeof FiledPropDefine>,
      const formContext = useVJSFContext()
      if (props) {
        const { uiSchema, schema } = props
        // 存在uiSchema.widget
        if (uiSchema?.widget && isObject(uiSchema.widget)) {
          console.log('uiSchema.widget', uiSchema.widget)
          return shallowRef(uiSchema.widget as CommonWidgetDefine)
        // 存在format
        if (schema.format) {
          if (formContext.formatMapRef.value[schema.format]) {
            return shallowRef(formContext.formatMapRef.value[schema.format])
    // ...
    export default ThemeProvider

    //context.ts

    添加formatMapRef

    //...
    import { CommonWidgetDefine, ComonFieldType, Theme } from './types'
    //...
    export function useVJSFContext() {
      const context:
            //...
            formatMapRef: Ref<{ [key: string]: CommonWidgetDefine }>
        | undefined = inject(ShcemaFormContextKey)
    //...
    

    // StringField.tsx NumberField.tsx

     const TextWidgetRef = computed(() => {
          const widgetRef = getWidget(CommonWidgetNames.TextWidget, props)
          return widgetRef.value
    

    然后就是在App.tsx中传入customFormats,新建一个src/plugins/customFormat.tsx

    // customFormat.tsx

    import { computed, defineComponent } from 'vue'
    import { CommonWidgetPropsDefine, CustomFormat } from '../../lib/types'
    import { withFormItem } from '../../lib/theme-default/FormItem'
    const format: CustomFormat = {
      name: 'color',
      definition: {
        type: 'string',
        validate: /^#[0-9A-Fa-f]{6}$/,
        // compare: (data1: string, data2: string) => number,
      component: withFormItem(
        defineComponent({
          name: 'ColorWidget',
          props: CommonWidgetPropsDefine,
          setup(props) {
            const handleChange = (e: any) => {
              // console.log(e.target.value, 'TextWidget.tsx')
              const value = e.target.value
              e.target.value = props.value
              props.onChange(value)
            const styleRef = computed(() => {
              return {
                color: (props.options && props.options.color) || 'black',
            return () => {
              return (
                <input
                  type="color"
                  value={props.value}
                  onInput={handleChange}
                  style={styleRef.value}
        }),
    export default format

    // App.tsx

    import customFormat from './plugins/customFormat'
    <ThemeProvider theme={themeDefault}>
      <SchemaForm
        customFormats={customFormat}
      //...
    </ThemeProvider>
    import PasswordWidget from '@/components/PasswordWidget'
    export default {
      name: 'Demo',
      schema: {
        type: 'object',
        properties: {
    //...
          color: {
            type: 'string',
            format: 'color', // 使用自定义格式化
            title: 'Input Color',
    

    自定义keyword实现扩展

    先定义下自定义keyword的类型,SchemaForm.tsx添加属性customKeywords

    // types.ts

    // 我们只需要使用macro关键字的方式来自定义ajv关键字,所以这里提取一下
    interface VjsfKeywordDefinition {
      type?: string | string[]
      async?: boolean
      $data?: boolean
      errors?: boolean | string
      metaSchema?: object
      schema?: boolean
      // statements?: boolean
      dependencies?: Array<string>
      modifying?: boolean
      valid?: boolean
      macro: (
        schema: any,
        parentSchema: AnySchemaObject,
        it: SchemaCxt,
      ) => AnySchema
    export interface CustomKeyword {
      name: string
      definition: VjsfKeywordDefinition
      transformSchema: (originSchema: Schema) => Schema
    
    //...
    import {
      CommonWidgetDefine,
      CustomFormat,
      CustomKeyword,
      Schema,
      UISchema,
    } from './types'
    // ...
    export default defineComponent({
      props: {
        //...
        customKeywords: {
          type: [Array, Object] as PropType<CustomKeyword[] | CustomKeyword>,
      name: 'SchemaForm',
      setup(props) {
       //...
        watchEffect(() => {
         //...
          // 自定义ajv的keyword
          if (props.customKeywords) {
            const customKeywords = Array.isArray(props.customKeywords)
              ? props.customKeywords
              : [props.customKeywords]
            customKeywords.forEach((keyword) => {
              // validatorRef.value.addKeyword(keyword.name,keyword.definition) // ajv6
              validatorRef.value.addKeyword({
                keyword: keyword.name,
                macro: keyword.definition.macro,
        // schema转化方法并provide给子组件
        const transformSchemaRef = computed(() => {
          if (props.customKeywords) {
            const customKeywords = Array.isArray(props.customKeywords)
              ? props.customKeywords
              : [props.customKeywords]
            return (schema: Schema) => {
              let newSchema: any = schema
              customKeywords.forEach((keyword) => {
                if (newSchema[keyword.name]) {
                  newSchema = keyword.transformSchema(schema)
              return newSchema
          } else {
            return (s: Schema) => s
        const context = {
         //...
          transformSchemaRef,
        provide(ShcemaFormContextKey, context)
        return () => {
         //...
    

    基本流程和自定义format类型,都是需要扩展下ajv的方法,不同的地方在于关键字的transformSchemaRef方法需要在SchemaItem.tsx使用

    // SchemaItem.tsx

    //...
    import { useVJSFContext } from './context'
    export default defineComponent({
      name: 'SchemaItem',
      props: FiledPropDefine,
      setup(props) {
        const formContext = useVJSFContext()
        const retrievedSchemaRef = computed(() => {
          const { schema, rootSchema, value } = props
          return formContext.transformSchemaRef.value(
            retrieveSchema(schema, rootSchema, value),
        return () => {
        //...
    

    //context.ts 补充下类型

      const context:
            // theme: Theme;
            SchemaItem: ComonFieldType
            formatMapRef: Ref<{ [key: string]: CommonWidgetDefine }>
            transformSchemaRef: Ref<(schema: Schema) => Schema>
        | undefined = inject(ShcemaFormContextKey)

    然后就是在App.tsx中传入customKeywords,新建一个src/plugins/customKeyword.tsx

    // customKeyWord.tsx

    import { CustomKeyword } from '../../lib/types'
    const keyWord: CustomKeyword = {
      name: 'test',
      definition: {
        macro: () => {
          return {
            minLength: 10,
      transformSchema(schema) {
        return {
          ...schema,
          minLength: 10,
    export default keyWord
    export default {
      name: 'Demo',
      schema: {
        type: 'object',
        properties: {
          pass1: {
            type: 'string',
            // minLength: 10,
            test: true, // 自定义关键字
            title: 'password',
    

    https://shields.io 开源项目的质量元数据徽章

    自动化构建

    常见的CI

  • Travis CI
  • Circle CI
  • Github Action
  • 创建github仓库及配置自动化构建

    github工作流配置

    name: test-coverage
    on: [push]
    jobs:
      build:
        runs-on: ${{ matrix.os }}
        strategy:
          matrix:
            node-version: [10.x, 12.x]
            os: [ubuntu-latest, macos-latest, windows-latest]
        steps:
          - uses: actions/checkout@v2
          - name: Use Node.js ${{ matrix.node-version }}
            uses: actions/setup-node@v1
            with:
              node-version: ${{ matrix.node-version }}
          - run: npm install
          - run: npm run test:cov
              CI: true
    

    配置 codecov

    workflows 添加上次代码覆盖率文件到 codecov 配置

          - name: Upload coverage to Codecov
            uses: codecov/codecov-action@v1
            with:
              flags: unittests
              file: ./coverage/clover.xml
              fail_ci_if_error: true
    

    发布类库到npm

    移除 "private": true,

      "publishConfig": {
        "registry": "https://registry.npmjs.org/" // 指定发布时的npm仓库地址
    

    配合下面的登录npm login --registry=https://registry.npmjs.org/

  •  
    推荐文章
    爱旅游的西红柿  ·  中国新闻网
    2 月前
    千杯不醉的松鼠  ·  冷眸软件库新版app下载-冷眸软件库lmrjk手机版下载v1.3_电视猫
    6 月前
    性感的猴子  ·  Er det på tide å oppdatere dine personlige merkevarebilder? | Ditt Portrett - Bedriftsfoto i Oslo
    8 月前
    坐怀不乱的烈马  ·  HTTP status code overview - Internet Information Services | Microsoft Learn
    12 月前
    飞翔的小刀  ·  还心✞幸福个人主页- 唱吧,玩音乐,就上唱吧!
    12 月前