课程地址: 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
文件
然后配置vscode保存触发代码格式化
Editor
下的
format on Save
记得重启一下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>
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的文件需要以jsx
或tsx
结尾。
新建一个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数据。不同语言有不同的实现方式。
这里我们使用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 functionconst 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>
schemajson schema对象,用来定义数据,同时也是我们定义表单的依据
value表单的数据结果,你可以从外部改变这个value,在表单被编辑的时候,会通过onChange
透出 value
需要注意的是,因为vue使用的是可变数据,如果每次数据变化我们都去改变value
的对象地址,那么会导致整个表单都需要重新渲染,这会导致性能降低。
从实践中来看,我们传入的对象,在内部修改其 field 的值基本不会有什么副作用,所以我们会使用这种方式来进行实现。也就是说,如果value
是一个对象,那么从JsonSchemaForm
内部修改的值,并不会改变value
对象本身。我们仍然会触发onChange
,因为可能在表单变化之后,使用者需要进行一些操作。
onChange在表单值有任何变化的时候会触发该回调方法,并把新的值进行返回
locale语言,使用ajv-i18n
指定错误信息使用的语言1
contextRef你需要传入一个 vue3的Ref
对象,我们会在这个对象上挂载doValidate
方法,你可以通过
<JsonSchemaForm contextRef={yourRef} />
const yourRef=ref({})
onMounted(()=>{
yourRef.value.doValidate()
uiSchema对表单的展现进行一些定制,其类型如下:
export interface VueJsonSchemaConfig {
title?:string
description?: string
component?: string
additionProps?:{
[key:string]:any
withFormItem?: boolen
widget?:'checkbox'|'textarea'|'select'|'radio'|'range'|string
items:UISchema | UISchema[]
export interface UISchema extends VueJsonSchemaConfig {
properties?:{
[property:string]:UISchema
基本demo移除之前写的一些代码
安装monaco-editor
npm i -D [email protected]
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)
根据传入的isReadOnly
与shallow
得到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
computedpackages\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,这时候等时间到了后,第二个校验木有任何效果,长度校验也没有触发
setup返回一个