@ga23187/vue3-json-schema-form v0.1.0
课程地址:https://coding.imooc.com/class/466.html
项目初始化
开始
借助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,// 这个 },
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源码中这一段
h
函数
import { createApp, defineComponent, h } from 'vue'
import HelloWorld from './components/HelloWorld.vue'
// import App from './App.vue'
// import img from './assets/logo.png'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const img = require('./assets/logo.png')
const App = defineComponent({
render() {
return h(
'div',
{
id: 'app',
},
[
h('img', {
alt: 'Vue logo',
// src: './assets/logo.png', // 在template中的src引入会经过webpack的loader处理,这里直接写字符串是不出处理的
src: img,
}),
h(HelloWorld, {
msg: 'Hhhh',
age: 123,
}),
],
)
},
})
createApp(App).mount('#app')
template
最终也是被编译为render
函数
h
函数实现
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的文件需要以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 function
const Ajv = require('ajv')
// 实例化
const ajv = new Ajv() // options can be passed, e.g. {allErrors: true}
// 自定义关键字
ajv.addKeyword({
keyword: 'test',
validate: (schema, data) => {
console.log(schema, data)
return true
},
})
// 定义的schema
const schema = {
type: 'object',
properties: {
foo: { type: 'integer' },
bar: { type: 'string', test: 'schema' },
},
required: ['foo'],
additionalProperties: true,
}
// 编译
const validate = ajv.compile(schema)
// 需要校验的数据
const data = {
foo: 1,
bar: 'bar',
}
// 校验
const valid = validate(data)
// 校验不通过 打印下
if (!valid) console.log(validate.errors)
compilation function
编译阶段就会执行
const Ajv = require('ajv')
// 实例化
const ajv = new Ajv()
// 自定义关键字
ajv.addKeyword({
keyword: 'test',
compile(param, parentSchema) {
console.log(param, parentSchema)
return () => true
},
metaSchema: {
// 定义test关键字接收值的定义
type: 'array',
items: [{ type: 'number' }, { type: 'number' }],
minItems: 2,
additionalItems: false,
},
})
// 定义的schema
const schema = {
type: 'object',
properties: {
foo: { type: 'integer' },
bar: { type: 'string', test: 'schema' },
},
required: ['foo'],
additionalProperties: true,
}
// 编译
const validate = ajv.compile(schema)
macro function
相对于起了个别名,实际的校验规则会与return的合并
// ...
ajv.addKeyword({
keyword: 'test',
macro: (data) => {
console.log(data)
return {
minLength: 10,
}
},
})
// ...
错误信息语言修改
安装ajv-i18n
npm install ajv-i18n
"ajv-i18n": "^4.2.0"
使用
const Ajv = require('ajv')
const addFormats = require('ajv-formats')
const localize = require('ajv-i18n')
// ....
// 校验不通过 打印下
if (!valid) {
localize.zh(validate.errors) // 只需要这里使用下对应的语言就可以了。
console.log(validate.errors)
}
老版本的自定义关键字的错误信息语言是不支持的,最新版本的可以了。
自定义错误信息
安装
npm install ajv-errors
"ajv-errors": "^3.0.0"
使用
注意new Ajv({ allErrors: true })中需要开启allErrors
const Ajv = require('ajv')
const addFormats = require('ajv-formats')
const localize = require('ajv-i18n')
// 实例化
const ajv = new Ajv({ allErrors: true })
// Ajv option allErrors is required
require('ajv-errors')(ajv /*, {singleError: true} */)
// 拓展format
addFormats(ajv)
// 自定义关键字
ajv.addKeyword({
keyword: 'test',
macro: (data) => {
console.log(data)
return {
minLength: 10,
}
},
})
// 定义的schema
const schema = {
type: 'object',
properties: {
foo: { type: 'integer' },
bar: {
type: 'string',
// test: 'test',
minLength: 10,
errorMessage: '会替换当前整个错误信息',
// errorMessage: {
// type: '必须是字符串',
// minLength: '长度不能小于10',
// },
},
},
required: ['foo'],
additionalProperties: true,
}
// 编译
const validate = ajv.compile(schema)
// 需要校验的数据
const data = {
foo: 1,
bar: 1,
}
// 校验
const valid = validate(data)
// 校验不通过 打印下
if (!valid) {
// localize.zh(validate.errors) // 好像会和自定义error冲突
console.log(validate.errors)
}
总结
后续需要按照课程来,所以需要指定按照以下的版本来学习,因为使用最新的,需要修改用法,等有空再改,先学思路。
"ajv": "6.12.4"
"ajv-i18n": "3.5.0"
"ajv-errors": "1.0.1"
实现组件库的主流程
- 确定组件的接口与定义(即props)
- schema
- value
- local
- onChange
- uiSchema
- 等等
- 开发入口组件的实现
- 开发基础渲染实现
API设计
<JsonSchemaForm
schema={schema}
value={value}
onChange={onChange}
locale={locale}
contextRef={someRef}
uiSchema={uiSchema}></JsonSchemaForm>
schema
json schema对象,用来定义数据,同时也是我们定义表单的依据
value
表单的数据结果,你可以从外部改变这个value,在表单被编辑的时候,会通过onChange
透出 value
需要注意的是,因为vue使用的是可变数据,如果每次数据变化我们都去改变value
的对象地址,那么会导致整个表单都需要重新渲染,这会导致性能降低。
从实践中来看,我们传入的对象,在内部修改其 field 的值基本不会有什么副作用,所以我们会使用这种方式来进行实现。也就是说,如果value
是一个对象,那么从JsonSchemaForm
内部修改的值,并不会改变value
对象本身。我们仍然会触发onChange
,因为可能在表单变化之后,使用者需要进行一些操作。
onChange
在表单值有任何变化的时候会触发该回调方法,并把新的值进行返回
locale
语言,使用ajv-i18n
指定错误信息使用的语言1
contextRef
你需要传入一个 vue3的Ref
对象,我们会在这个对象上挂载doValidate
方法,你可以通过
<JsonSchemaForm contextRef={yourRef} />
const yourRef=ref({})
onMounted(()=>{
yourRef.value.doValidate()
})
uiSchema
对表单的展现进行一些定制,其类型如下:
export interface VueJsonSchemaConfig {
title?:string
description?: string
component?: string
additionProps?:{
[key:string]:any
}
withFormItem?: boolen
widget?:'checkbox'|'textarea'|'select'|'radio'|'range'|string
items:UISchema | UISchema[]
}
export interface UISchema extends VueJsonSchemaConfig {
properties?:{
[property:string]:UISchema
}
}
基本demo
移除之前写的一些代码
安装
monaco-editor
npm i -D monaco-editor@0.20
vue-jss
css in js的库npm i vue-jss@0.0.4 jss@10.4.0 jss-preset-default@10.4.0
就可以新增一个
MonacoEditor.tsc
组件import * as Monaco from 'monaco-editor' import { defineComponent, onBeforeMount, onMounted, PropType, ref, shallowRef, watch, } from 'vue' import { createUseStyles } from 'vue-jss' const useStyles = createUseStyles({ container: { border: '1px solid #eee', display: 'flex', flexDirection: 'column', borderRadius: 5, // height: 500, }, title: { backgroundColor: '#eee', }, code: { flexGrow: 1, }, }) export default defineComponent({ props: { code: { type: String as PropType<string>, required: true, }, onChange: { type: Function as PropType< (value: string, event: Monaco.editor.IModelContentChangedEvent) => any >, required: true, }, title: { type: String as PropType<string>, required: true, }, }, setup(props) { const editorRef = shallowRef() const containerRef = ref() let _subscription: Monaco.IDisposable | undefined, __prevent_tigger_change_event = false onMounted(() => { const editor = (editorRef.value = Monaco.editor.create( containerRef.value, { value: props.code, language: 'json', formatOnPaste: true, tabSize: 2, minimap: { enabled: false, }, }, )) _subscription = editor.onDidChangeModelContent((event) => { console.log('>>>>>', __prevent_tigger_change_event) if (!__prevent_tigger_change_event) { props.onChange(editor.getValue(), event) } }) }) onBeforeMount(() => { if (_subscription) { _subscription.dispose() } }) watch( () => props.code, (v) => { const editor = editorRef.value const model = editor.getModel() if (v !== model.getValue()) { editor.pushUndoStop() __prevent_tigger_change_event = true model.pushEditOperations( [], [ { range: model.getFullModeRange(), text: v, }, ], ) editor.pushUndoStop() __prevent_tigger_change_event = false // if (v !== editorRef.value.getValue()) { // editorRef.value.setValue() // } } }, ) const classesRef = useStyles() return () => { const classes = classesRef.value return ( <div class={classes.container}> <div class={classes.title}> <span>{props.title}</span> </div> <div class={classes.code} ref={containerRef}></div> </div> ) } }, })
在
app.tsx
中使用下import { defineComponent, ref } from 'vue' import MonacoEditor from './components/MonacoEditor' import { createUseStyles } from 'vue-jss' function toJson(data: any) { return JSON.stringify(data, null, 2) } const schema = { type: 'string', } const useStyles = createUseStyles({ editor: { minHeight: 400, }, }) export default defineComponent({ setup() { const schemaRef = ref<any>(schema) const handleCodeChange = (code: string) => { let schema: any try { schema = JSON.parse(code) } catch (err) { console.log(err) } schemaRef.value = schema } const classesRef = useStyles() return () => { const code = toJson(schemaRef.value) const classes = classesRef.value return ( <div> <MonacoEditor code={code} onChange={handleCodeChange} title="Schema" class={classes.editor} /> </div> ) } }, })
简单展示APP
核心是lib
的实现,待到下节实现。
lib实现之SchemaForm
核心 就是根据用户设置的schema来渲染对应的formItem
// SchemaForm.tsx
import { defineComponent, PropType } from 'vue'
import { Schema, SchemaTypes } from './types'
export default defineComponent({
props: {
schema: {
type: Object as PropType<Schema>,
required: true,
},
value: {
// type: Object, 值的类型是不确定的,可能是object也可能是string
required: true,
},
onChange: {
type: Function as PropType<(v: any) => void>,
required: true,
},
},
name: 'SchemaForm',
setup(props, { slots, emit, attrs }) {
return () => {
// 解析schema
const schema = props.schema
const type = schema?.type
switch (type) {
//根据不同的类型渲染不同的表单项,对应string和number这种单一的还好,但是object这种可能自身就是一个完整的表单,所以不太应该把他们的代码放一起,不太合适,可以设计一个中间状态器,通过它来分发。
case SchemaTypes.STRING: {
return <input type="text" />
}
}
return <div>This is 123</div>
}
},
})
// types.ts
export enum SchemaTypes {
'NUMBER' = 'number',
'INTEGER' = 'integer',
'STRING' = 'string',
'OBJECT' = 'object',
'ARRAY' = 'array',
'BOOLEAN' = 'boolean',
}
type SchemaRef = { $ref: string }
export interface Schema {
type: SchemaTypes | string
const?: any
format?: string
default?: any
properties?: {
[key: string]: Schema | { $ref: string }
}
items?: Schema | Schema[] | SchemaRef
dependencies?: {
[key: string]: string[] | Schema | SchemaRef
}
oneOf?: Schema[]
//vjsf?: VueJsonSchemaConfig
required?: string[]
enum?: any[]
enumKeyValue?: any[]
additionalProperties?: any
additionalItem?: Schema
}
lib实现之SchemaItem
定义一个中间状态器,用于分发不同的表单项。根据不同的类型来把渲染组件的工作交给对应的组件实现。
创建一个fields文件夹存放对应的实现,先实现下Number与String。
import { defineComponent } from 'vue'
export default defineComponent({
name: 'StringField',
setup() {
return () => <div>String field</div>
},
})
import { defineComponent } from 'vue'
export default defineComponent({
name: 'NumberField',
setup() {
return () => <div>Number field</div>
},
})
在SchemaItem.tsx
中使用
import { defineComponent, PropType } from 'vue'
import NumberField from './fields/NumberField'
import StringField from './fields/StringField'
import { Schema, SchemaTypes } from './types'
export default defineComponent({
name: 'SchemaItem',
props: {
schema: {
type: Object as PropType<Schema>,
required: true,
},
value: {
required: true,
},
onChange: {
type: Function as PropType<(v: any) => void>,
required: true,
},
},
setup(props) {
return () => {
const { schema } = props
// TODO: 如果type没有指定,我们需要猜测这个type
const type = schema.type
let Component: any
switch (type) {
case SchemaTypes.STRING: {
Component = StringField
break
}
case SchemaTypes.NUMBER: {
Component = NumberField
break
}
default: {
console.warn(`${type} is not supported`)
}
}
return <Component {...props} />
}
},
})
再修改下SchemaForm.tsx
文件,借助SchemaItem.tsx
来渲染
//...
setup(props, { slots, emit, attrs }) {
// 在这里定义一个处理事件 不直接把props.onChange传递进去,方便后续添加处理逻辑
const handleChange = (v: any) => {
props.onChange(v)
}
return () => {
const { schema, value } = props
// 使用SchemaItem中间处理器
return (
<SchemaItem schema={schema} value={value} onChange={handleChange} />
)
}
},
//...
最后测试一下,在demos文件夹下新增一个demo.ts文件测试下
export default {
name: 'Demo',
schema: {
type: 'number',
},
uiSchema: '',
default: '',
}
使用SFC方式继续实现Field
// StringField.vue
<template>
<input type="text" :value="value" @input="handleChange" />
</template>
<!-- <script setup="props" lang="ts">
import { FiledPropDefine } from '../types'
// 2020/06月份刚sfc时候的setup写法
export default {
props:FiledPropDefine
}
export const handleChange = (e: any) => {
console.log(e.target.value)
props.onChange(e.target.value)
}
</script> -->
<script setup lang="ts">
import { FiledPropDefine } from '../types'
const props = defineProps(FiledPropDefine)
const handleChange = (e: any) => {
console.log(e.target.value)
props.onChange(e.target.value)
}
</script>
使用SFC方式最大的问题就是在vscode中判断不出.vue文件的类型,因为.vue文件类型都是通过shims-vue.d.ts
来实现的,ts并不知道props的定义。
解决下monaco-editor
的报错问题
因为我使用的是cli5创建的项目,里面webpack版本是5,本来想依赖都按照教程中的版本指定安装,所以指定安装monaco-editor-webpack-plugin@1.9.1
,但是安装的时候提示peer webpack@"^4.5.0" from monaco-editor-webpack-plugin@1.9.1
,没得法只有安装最新的monaco-editor-webpack-plugin
但是安装他又提示了peer monaco-editor@">= 0.31.0" from monaco-editor-webpack-plugin@7.0.1
,所以只有二个插件都按最新的咯,尝试发现没得问题。
安装
npm i monaco-editor
npm i -D monaco-editor-webpack-plugin
在vue.config.js中使用
const { defineConfig } = require('@vue/cli-service')
const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin')
module.exports = defineConfig({
transpileDependencies: true,
chainWebpack(config) {
config.plugin('monaco').use(new MonacoWebpackPlugin())
},
})
复杂节点的渲染
- 对象
- 数组
核心就是解析schema,编写一个utlis方法库来处理
安装依赖
npm i jsonpointer lodash.union json-schema-merge-allof npm i @types/jsonpointer @types/lodash.union @types/json-schema-merge-allof -D
文件内容
// 详情见项目 import { Schema, SchemaTypes, VueJsonSchemaConfig } from './types' import jsonpointer from 'jsonpointer' import union from 'lodash.union' import mergeAllOf from 'json-schema-merge-allof' export function isObject(thing: any) { return typeof thing === 'object' && thing !== null && !Array.isArray(thing) } export function hasOwnProperty(obj: any, key: string) { /** * 直接调用`obj.hasOwnProperty`有可能会因为 * obj 覆盖了 prototype 上的 hasOwnProperty 而产生错误 */ return Object.prototype.hasOwnProperty.call(obj, key) } export function retrieveSchema( schema: any, rootSchema = {}, formData: any = {}, ): Schema { if (!isObject(schema)) { return {} as Schema } let resolvedSchema = resolveSchema(schema, rootSchema, formData) // TODO: allOf and additionalProperties not implemented if ('allOf' in schema) { try { resolvedSchema = mergeAllOf({ // TODO: Schema type not suitable ...resolvedSchema, allOf: resolvedSchema.allOf, } as any) as Schema } catch (e) { console.warn('could not merge subschemas in allOf:\n' + e) const { allOf, ...resolvedSchemaWithoutAllOf } = resolvedSchema return resolvedSchemaWithoutAllOf } } const hasAdditionalProperties = resolvedSchema.hasOwnProperty('additionalProperties') && resolvedSchema.additionalProperties !== false if (hasAdditionalProperties) { // put formData existing additional properties into schema return stubExistingAdditionalProperties( resolvedSchema, rootSchema, formData, ) } return resolvedSchema }
同时还需要扩展下
types.ts
中的Schema
的定义
编写ObjectField渲染
修改下
SchemaForm.tsx
文件,组件添加rootSchem
属性return () => { const { schema, value } = props // 使用SchemaItem中间处理器 return ( <SchemaItem schema={schema} rootSchema={schema} value={value} onChange={handleChange} /> ) }
同时需要修改下
types.ts
中的FiledPropDefine
类型,添加rootSchema
export const FiledPropDefine = { schema: { type: Object as PropType<Schema>, required: true, }, value: { required: true, }, onChange: { type: Function as PropType<(v: any) => void>, required: true, }, rootSchema: { type: Object as PropType<Schema>, required: true, }, } as const
创建
ObjectField.tsx
文件import { defineComponent, inject } from 'vue' import { FiledPropDefine } from '../types' import SchemaItem from '../SchemaItem' console.log(SchemaItem, '触发下循环引用') export default defineComponent({ name: 'ObjectField', props: FiledPropDefine, setup() { return () => { return <div>Object</div> } }, })
修改下
SchemaItem.tsx
文件,加入对Object类型的渲染核心方法是递归解析
retrieveSchema
方法,得到需要的schemaimport { 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
需要判断是否为对象,是对象才取keyimport { defineComponent, inject, DefineComponent, ExtractPropTypes } from 'vue' // .. import { ShcemaFormContextKey } from '../context' import { isObject } from '../utils' type SchemaItemDefine = DefineComponent<typeof FiledPropDefine> export default defineComponent({ // ... setup(props) { const context: { SchemaItem: SchemaItemDefine } | undefined = inject(ShcemaFormContextKey) if (!context) { throw Error('SchemaForm shoud be used') } const handleObjectFieldChange = (key: string, v: any) => { const value: any = isObject(props.value) ? props.value : {} if (v === undefined) { delete value[key] } else { value[key] = v } // 向上传递 props.onChange(value) } return () => { const { schema, rootSchema, value } = props const { SchemaItem } = context const properties = schema.properties || {} const currentValue: any = isObject(value) ? value : {} return Object.keys(properties).map((key: string, index: number) => ( <SchemaItem schema={properties[key]} rootSchema={rootSchema} value={currentValue[key]} key={index} onChange={(v: any) => handleObjectFieldChange(key, v)} /> )) } }, })
编写ArrayField渲染
首先优化下前面的ObjectField代码,提取类型定义 提取获取context
// ObjectField.tsx
const context = useVJSFContext()
// context.ts
export function useVJSFContext() { const context: { SchemaItem: ComonFieldType } | undefined = inject(ShcemaFormContextKey) if (!context) { throw Error('SchemaForm shoud be used') } return context }
/**
* 3种情况
* {
* items:{ type:string}
* }
*
* {
* items:[
* { type: string },
* { type: number },
* ]
* }
*
* {
* items:{ type: string, enum:['1','2']}
* }
*/
固定长度数组的渲染
- 创建
ArrayField.tsx
文件
import { defineComponent } from 'vue'
import { FiledPropDefine, Schema } from '../types'
import { useVJSFContext } from '../context'
export default defineComponent({
name: 'ArrayField',
props: FiledPropDefine,
setup(props) {
const context = useVJSFContext()
const handleMultiTypeChange = (v: any, index: number) => {
const { value } = props
const arr = Array.isArray(value) ? value : []
arr[index] = v
props.onChange(arr)
}
return () => {
const { schema, rootSchema, value } = props
const SchemaItem = context.SchemaItem
const isMultiType = Array.isArray(schema.items)
if (isMultiType) {
const items: Schema[] = schema.items as any
const arr = Array.isArray(value) ? value : []
return items.map((s: Schema, index: number) => (
<SchemaItem
schema={s}
key={index}
rootSchema={rootSchema}
value={arr[index]}
onChange={(v: any) => handleMultiTypeChange(v, index)}
/>
))
}
return null
}
},
})
- 修改
SchemaItem.tsx
文件,添加array类型渲染
// ....
import ArrayField from './fields/ArrayField'
// ...
export default defineComponent({
name: 'SchemaItem',
props: FiledPropDefine,
setup(props) {
// ...
return () => {
// ...
let Component: any
switch (type) {
// ....
case SchemaTypes.ARRAY: {
Component = ArrayField
break
}
default: {
console.warn(`${type} is not supported`)
}
}
return <Component {...props} schema={retrievedSchema} />
}
},
})
测试一下,在
demo/simple.ts
下schema: { description: 'A simple form example.', type: 'object', required: ['firstName', 'lastName'], properties: { // ... staticArray: { type: 'array', items: [ { type: 'string', }, { type: 'number', }, ], }, }, },
单类型数组的渲染
修改
ArrayField.tsx
文件,添加关于单类型数组的解析,添加排序等外层容器import { defineComponent } from 'vue' import { FiledPropDefine, Schema } from '../types' import { useVJSFContext } from '../context' const ArrayItemWrapr = defineComponent({ name: 'ArrayItemWrapr', // props: {}, setup(props, { slots }) { return () => { return ( <div> <div> <button>新增</button> <button>删除</button> <button>上移</button> <button>下移</button> </div> <div>{slots.default && slots.default()}</div> </div> ) } }, }) export default defineComponent({ name: 'ArrayField', props: FiledPropDefine, setup(props) { const context = useVJSFContext() const handleArryItemChange = (v: any, index: number) => { const { value } = props const arr = Array.isArray(value) ? value : [] arr[index] = v props.onChange(arr) } return () => { const { schema, rootSchema, value } = props const SchemaItem = context.SchemaItem const isMultiType = Array.isArray(schema.items) const isSelect = schema.items && (schema.items as any).enum if (isMultiType) { // .... } else if (!isSelect) { const arr = Array.isArray(value) ? value : [] return arr.map((v: any, index: number) => { return ( <ArrayItemWrapr> <SchemaItem schema={schema.items as Schema} value={v} key={index} rootSchema={rootSchema} onChange={(v: any) => handleArryItemChange(v, index)} /> </ArrayItemWrapr> ) }) } return null } }, })
测试一下,修改下
demo/simple.ts
,添加单类型数组定义,注意需要添加一下data不然没有输入框显示出现export default { name: 'Simple', schema: { description: 'A simple form example.', type: 'object', required: ['firstName', 'lastName'], properties: { // ... singleTypeArray: { type: 'array', items: { type: 'string', }, }, }, }, uiSchema: { // ... }, default: { firstName: 'Chuck', lastName: 'Norris', age: 75, bio: 'Roundhouse kicking asses since 1940', password: 'noneed', singleTypeArray: ['zyq'], }, }
继续完善,简单添加样式,借助
vue-jss
添加新增 删除 上移 下移逻辑
const ArrayItemWrapr = defineComponent({ name: 'ArrayItemWrapr', props: { onAdd: { type: Function as PropType<(index: number) => void>, required: true, }, onDelete: { type: Function as PropType<(index: number) => void>, required: true, }, onUp: { type: Function as PropType<(index: number) => void>, required: true, }, onDown: { type: Function as PropType<(index: number) => void>, required: true, }, index: { type: Number, required: true, }, }, setup(props, { slots }) { const classesRef = useStyles() const handleAdd = () => props.onAdd(props.index) const handleDelete = () => props.onDelete(props.index) const handleUp = () => props.onUp(props.index) const handleDown = () => props.onDown(props.index) return () => { const classes = classesRef.value return ( <div class={classes.container}> <div class={classes.actions}> <button class={classes.action} onClick={handleAdd}> 新增 </button> <button class={classes.action} onClick={handleDelete}> 删除 </button> <button class={classes.action} onClick={handleUp}> 上移 </button> <button class={classes.action} onClick={handleDown}> 下移 </button> </div> <div class={classes.content}>{slots.default && slots.default()}</div> </div> ) } }, }) export default defineComponent({ name: 'ArrayField', props: FiledPropDefine, setup(props) { // ... const handleAdd = (index: number) => { const { value } = props const arr = Array.isArray(value) ? value : [] arr.splice(index + 1, 0, undefined) props.onChange(arr) } const handleDelete = (index: number) => { const { value } = props const arr = Array.isArray(value) ? value : [] arr.splice(index, 1) props.onChange(arr) } const handleUp = (index: number) => { if (index === 0) { return } const { value } = props const arr = Array.isArray(value) ? value : [] const item = arr.splice(index, 1) arr.splice(index - 1, 0, item[0]) props.onChange(arr) } const handleDown = (index: number) => { const { value } = props const arr = Array.isArray(value) ? value : [] if (index === arr.length - 1) { return } const item = arr.splice(index, 1) arr.splice(index + 1, 0, item[0]) props.onChange(arr) } return () => { //... if (isMultiType) { // ... } else if (!isSelect) { const arr = Array.isArray(value) ? value : [] return arr.map((v: any, index: number) => { return ( <ArrayItemWrapr index={index} onAdd={handleAdd} onDelete={handleDelete} onUp={handleUp} onDown={handleDown} > <SchemaItem schema={schema.items as Schema} value={v} key={index} rootSchema={rootSchema} onChange={(v: any) => handleArryItemChange(v, index)} /> </ArrayItemWrapr> ) }) } return null } }, })
多选数组的渲染
在
lib
下新建一个widgets
文件夹,并创建一个Selection.tsx
组件,用于实现多选import { defineComponent, PropType, ref, watch } from 'vue' export default defineComponent({ name: 'SelectionWidget', props: { value: {}, onChange: { type: Function as PropType<(v: any) => void>, required: true, }, options: { type: Array as PropType< { key: string value: any }[] >, required: true, }, }, setup(props) { const currentValueRef = ref(props.value) watch(currentValueRef, (newVal) => { // currentValueRef被修改时,触发onchange if (newVal !== props.value) { props.onChange(newVal) } }) watch( () => props.value, (v) => { // 父组件传递进来的字 赋值给currentValueRef 实现了 value的双向 if (v !== currentValueRef.value) { currentValueRef.value = v } }, ) return () => { const { options } = props return ( <select multiple v-model={currentValueRef.value}> {options.map((op) => ( <options value={op.value}>{op.key}</options> ))} </select> ) } }, })
注意: v-model={currentValueRef.value} 这里的.vlaue不能省略,在SFC文件中的模板可以省略是因为vue对模板解析的时候做了处理
修改下ArrayField.tsx文件,引入上面的组件
// ... import SelectionWidget from '../widgets/Selection' // ... export default defineComponent({ name: 'ArrayField', props: FiledPropDefine, setup(props) { const context = useVJSFContext() // ... return () => { //... if (isMultiType) { // ... } else if (!isSelect) { //... } else { const enumOptions = (schema as any).items.enum const options = enumOptions.map((e: any) => { return { key: e, value: e, } }) return ( <SelectionWidget onChange={props.onChange} value={props.value} options={options} /> ) } }
测试一下,修改下
demo/simple.tx
文件,添加多选数组schema定义multiSelectArray: { type: 'array', items: { type: 'string', enum: ['123', '456', '789'], }, },
单元测试
Jest 测试框架
vue-test-utils vue官方提供的关于vue的测试方法
为啥要单测
- 检测bug
- 提升回归效率 方便改代码后检测是否有问题
- 保证代码质量
测试覆盖率
jest单元测试配置
使用vuecli创建项目时,勾选了jest,已经帮我们初始化好了。
查看下jest的配置,项目中的jest.config.js
,发现它通过prest预设了好多。
可以从vue-cli的地址上看到
// cli-plugin-unit-jest/presets/typescript-and-babel/jest-preset.js
const deepmerge = require('deepmerge')
const defaultTsPreset = require('../typescript/jest-preset')
module.exports = deepmerge(
defaultTsPreset,
{
globals: {
'ts-jest': {
babelConfig: true
}
}
}
)
// cli-plugin-unit-jest/presets/typescript/jest-preset.js
const deepmerge = require('deepmerge')
const defaultPreset = require('../default/jest-preset')
let tsJest = null
try {
tsJest = require.resolve('ts-jest')
} catch (e) {
throw new Error('Cannot resolve "ts-jest" module. Typescript preset requires "ts-jest" to be installed.')
}
module.exports = deepmerge(
defaultPreset,
{
moduleFileExtensions: ['ts', 'tsx'],
transform: {
'^.+\\.tsx?$': tsJest
}
}
)
// cli-plugin-unit-jest/presets/default/jest-preset.js
// eslint-disable-next-line node/no-extraneous-require
const semver = require('semver')
let vueVersion = 2
try {
// eslint-disable-next-line node/no-extraneous-require
const Vue = require('vue/package.json')
vueVersion = semver.major(Vue.version)
} catch (e) {}
let vueJest = null
try {
vueJest = require.resolve(`@vue/vue${vueVersion}-jest`)
} catch (e) {
throw new Error(`Cannot resolve "@vue/vue${vueVersion}-jest" module. Please make sure you have installed "@vue/vue${vueVersion}-jest" as a dev dependency.`)
}
module.exports = {
testEnvironment: 'jsdom',
moduleFileExtensions: [
'js',
'jsx',
'json',
// tell Jest to handle *.vue files
'vue'
],
transform: {
// process *.vue files with vue-jest
'^.+\\.vue$': vueJest,
'.+\\.(css|styl|less|sass|scss|jpg|jpeg|png|svg|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|avif)$':
require.resolve('jest-transform-stub'),
'^.+\\.jsx?$': require.resolve('babel-jest')
},
transformIgnorePatterns: ['/node_modules/'],
// support the same @ -> src alias mapping in source code
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
// serializer for snapshots
snapshotSerializers: [
'jest-serializer-vue'
],
testMatch: [
'**/tests/unit/**/*.spec.[jt]s?(x)',
'**/__tests__/*.[jt]s?(x)'
],
// https://github.com/facebook/jest/issues/6766
testURL: 'http://localhost/',
watchPlugins: [
require.resolve('jest-watch-typeahead/filename'),
require.resolve('jest-watch-typeahead/testname')
]
}
核心知道底层的配置文件是那些,有哪些参数,方便后续自己修改。
使用jest写测试用例
- describe
- it/test
expect断言
更多用法看官网
https://jestjs.io/zh-Hans/docs/expect
预设与清理
beforeEach
/afterEach
每个测试之前/之后会执行beforeAll
/afterAll
所有测试之前/之后执行- 有作用域
- 在before中设置值,在after中清除
异步测试
describe('HelloWorld.vue', () => {
// 实践是错误滴,但是jest是不知道的
it('should work', () => {
setTimeout(() => {
expect(1 + 1).toBe(3)
}, 1000)
})
})
解决办法1: 使用回调中的done方法
it('should work', (done) => {
setTimeout(() => {
expect(1 + 1).toBe(3)
done() // 这里
}, 1000)
})
办法2: Promise
it('should work', () => {
return new Promise<void>((resolve) => {
setTimeout(() => {
expect(1 + 1).toBe(3)
resolve()
}, 1000)
})
})
办法3: async
it('should work', async () => {
const x = await new Promise((resolve) => {
setTimeout(() => {
resolve(1 + 1)
}, 1000)
})
expect(x).toBe(1)
})
// 或者测试这个
it('renders props.msg when passed', async () => {
const msg = 'new message'
const wrapper = shallowMount(HelloWorld, {
props: { msg },
})
await wrapper.setProps({
msg: '123',
})
expect(wrapper.text()).toMatch('123')
})
使用vue-test-utils测试vue组件
根据jest的配置文件,我们得到其省略文件名后缀进行文件导入解析时的默认加载顺序如下
moduleFileExtensions: [ 'js', 'jsx', 'json', // tell Jest to handle *.vue files 'vue', 'ts', 'tsx' ],
所以在省略后缀引入时,
import NumberField from './fields/NumberField'
实际导入的是.vue的文件,如果你的想和项目运行时的导入逻辑保持一致可能需要调整一下这个字段顺序,或者就不要有同名的vue文件了,保留一个tsx文件就阔以了。
用就完事了
import { mount } from '@vue/test-utils' import JsonSchemaForm, { NumberField } from '../../lib' describe('JsonSchemaForm', () => { it('should render correct number field', async () => { let value = '' const wrapper = mount(JsonSchemaForm, { props: { schema: { type: 'number', }, value: value, onChange: (v: any) => { value = v }, }, }) const numberFiled = wrapper.findComponent(NumberField) expect(numberFiled.exists()).toBeTruthy() // await numberFiled.props('onChange')('123') const input = numberFiled.find('input') input.element.value = '123' input.trigger('input') expect(value).toBe(123) }) })
代码覆盖率
测试代码覆盖率
npm run test:unit -- --coverage
等价于
script:{
"test:unit": "vue-cli-service test:unit --coverage",
}
控制台输出,以及会得到一个coverage
文件夹,里面有各种文件
Stmts语句覆盖率 Branch分支覆盖率 Funcs函数覆盖率 Lines行覆盖率
------------------|---------|----------|---------|---------|---------------------------------------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
------------------|---------|----------|---------|---------|---------------------------------------------------
All files | 33.6 | 8.08 | 17.28 | 34.26 |
lib | 34.09 | 9.58 | 25 | 34.25 |
SchemaForm.tsx | 100 | 100 | 100 | 100 |
SchemaItem.tsx | 74.07 | 100 | 100 | 74.07 | 29-30,37-45
context.ts | 50 | 100 | 0 | 50 | 8-13
index.ts | 100 | 100 | 100 | 100 |
types.ts | 100 | 100 | 100 | 100 |
utils.ts | 17.68 | 7.04 | 11.11 | 17.5 | ...42,446,453-460,465-467,476-527,533-551,562-584
lib/fields | 34.84 | 3.84 | 10 | 36.5 |
ArrayField.tsx | 11.59 | 0 | 0 | 12.5 | 69-76,103-196
NumberField.vue | 95.83 | 50 | 100 | 95.83 | 20
ObjectField.tsx | 26.31 | 0 | 0 | 27.77 | 12-40
StringField.vue | 50 | 100 | 0 | 50 | 12-27,36
lib/widgets | 14.28 | 100 | 0 | 14.28 |
Selection.tsx | 14.28 | 100 | 0 | 14.28 | 22-47
------------------|---------|----------|---------|---------|---------------------------------------------------
ObjectField的单元测试
使用共用变量时,推荐在beforeEach中进行赋值,可以避免在某个测试中改变了这个变量
import { mount } from '@vue/test-utils'
import JsonSchemaForm, { NumberField, StringField } from '../../lib'
describe('ObjectField', () => {
let schema: any
beforeEach(() => {
schema = {
type: 'object',
properties: {
name: {
type: 'string',
},
age: {
type: 'number',
},
},
}
})
it('should render properties to correct fileds', async () => {
const wrapper = mount(JsonSchemaForm, {
props: {
schema,
value: {},
onChange: () => {},
},
})
const strField = wrapper.findComponent(StringField)
const numField = wrapper.findComponent(NumberField)
expect(strField.exists()).toBeTruthy()
expect(numField.exists()).toBeTruthy()
})
it('should change value when sub fields trigger onChange', async () => {
let value: any = {}
const wrapper = mount(JsonSchemaForm, {
props: {
schema,
value: value,
onChange: (v: any) => {
value = v
},
},
})
const strField = wrapper.findComponent(StringField)
const numField = wrapper.findComponent(NumberField)
await strField.props('onChange')('1')
expect(value.name).toBe('1')
await numField.props('onChange')(1)
expect(value.age).toBe(1)
})
it('should handleObjectFieldChange else', async () => {
let value: any = {
name: '123',
}
const wrapper = mount(JsonSchemaForm, {
props: {
schema,
value: value,
onChange: (v: any) => {
value = v
},
},
})
const strField = wrapper.findComponent(StringField)
await strField.props('onChange')(undefined)
expect(value.name).toBe(undefined)
})
})
ArrayField的单元测试
只运行特定的测试用例,通过
-t
的方式来指定运行,会匹配符合it中的字符串的用例npm run test:unit -- -t multi type
import { mount } from '@vue/test-utils'
import JsonSchemaForm, {
ArrayField,
NumberField,
SelectionWidget,
StringField,
} from '../../lib'
describe('ArrayField', () => {
it('should render multi type', async () => {
const wrapper = mount(JsonSchemaForm, {
props: {
schema: {
type: 'array',
items: [
{
type: 'string',
},
{
type: 'number',
},
],
},
value: [],
onChange: () => {},
},
})
// 先从wrapper中找到array
const arrayField = wrapper.findComponent(ArrayField)
// 再从array中找number
const numberFiled = arrayField.findComponent(NumberField)
// 再从array中找string
const stringField = arrayField.findComponent(StringField)
expect(numberFiled.exists()).toBeTruthy()
expect(stringField.exists()).toBeTruthy()
})
it('should render single type', async () => {
const wrapper = mount(JsonSchemaForm, {
props: {
schema: {
type: 'array',
items: {
type: 'string',
},
},
value: ['1', '2'],
onChange: () => {},
},
})
// 先从wrapper中找到array
const arrayField = wrapper.findComponent(ArrayField)
// 再从array中找所有的string
const stringFields = arrayField.findAllComponents(StringField)
expect(stringFields.length).toBe(2)
expect(stringFields[0].props('value')).toBe('1')
})
it('should render select type', async () => {
const wrapper = mount(JsonSchemaForm, {
props: {
schema: {
type: 'array',
items: {
type: 'string',
enum: ['1', '2', '3'],
},
},
value: [],
onChange: () => {},
},
})
// 先从wrapper中找到array
const arrayField = wrapper.findComponent(ArrayField)
const selectionWidget = arrayField.findComponent(SelectionWidget)
expect(selectionWidget.exists()).toBeTruthy()
})
})
主题系统
- 满足样式和交互的多样性
- 核心逻辑是不变的 lib,部件 widget
- 不同于样式主题系统
- 交互可以变化
- 组件的产出可以完全不同
- 统一接口后所有内容都可以自定义
- 可以基于不同的组件库来实现
拆分主题系统的打包
减少强依赖
vue-cli打包配置https://cli.vuejs.org/zh/guide/build-targets.html#%E5%BA%93
在
lib
文件夹下新建theme-default
文件夹修改package.json文件
"scripts": { //... "build:core": "vue-cli-service build --target lib lib/index.ts", "build:theme": "vue-cli-service build --target lib lib/theme-default/index.tsx", //... },
运行
npm run build:theme
会发现打出来的文件有点多,是因为在vue.config.js
中未区分环境把monaco-editor
的内容也打包进来了。解决办法,通过.env文件或在运行时添加一个环境变量区分,这里使用简单下环境变量区分就行了
window下需要通过set TYPE=xx
mac下直接TYPE=xxx
"scripts": { "build:core": "set TYPE=lib && vue-cli-service build --target lib lib/index.ts", "build:theme": "set TYPE=lib && vue-cli-service build --target lib lib/theme-default/index.tsx", },
TODO
但是测试发现一个神奇的问题,通过上面的set方式,在vue.config.js中打印时值是lib,但是比对却是不相等。。。。
const isLib = process.env.TYPE === 'lib' console.log(process.env.TYPE) console.log(process.env.TYPE === 'lib') console.log(isLib)
没得法使用
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
文件,用这个组件来提供一个全局的响应式的themeimport { 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 =
- 在
1 year ago