一、JSON Schema
JSON(JavaScript Object Notation)是一種輕量&常見的數據交換格式,基本的數據的結構就是key-value,具有易於生成和解析的優點,通過JSON可以靈活地表達程序所需要的數據結構。
但JSON本身並沒有特定的規範(本身結構也不支持註釋),所以對於數據本身的描述是缺失的,比如説開發人員或者程序,就無法判斷下面這份數據裏面的age為string是否是符合預期的類型。
{
"name": "John Doe",
"mobile": "1370000001",
"age": "30"
}
JSON Schema定義了一套能夠較為完整地來描述JSON的規範,基於JSON Schema的規範去描述我們所需要的數據結構,或者基於這種規範去開發程序,就能實現預期效果。
使用場景:
1. 數據校驗
可能是JSON Schema最常見的場景,無論是前端還是後台都有校驗數據的需求,表單校驗,CI/CD的自動化測試等等。
以上面的JSON為例,如果要規定age為number,並且必須小於等於20,那麼可以這樣聲明一份JSON Schema
{
"$schema": "http://json-schema.org/schema",
"title": "Person",
"description": "an example",
"type": "object",
"properties": {
"name": {
"type": "string"
},
"mobile": {
"type": "string"
},
"age": {
"type": "number",
"maximum": 20
},
}
}
那麼當age為string,或者為number,但不在範圍內的時候都會提示校驗失敗
簡單的校驗示例:https://www.jsonschemavalidat...
應用
Ajv.js
一個基於JSON Schema的校驗庫,常用於nodejs、瀏覽器、微信小程序等場景的數據校驗,通過聲明一個JSON Schema來快速驗證數據,而無需進行代碼開發。
示例:
const Ajv = require('ajv');
const ajv = new Ajv();
// schema
const schema = {
$schema: 'http://json-schema.org/schema',
...
};
const validate = ajv.compile(schema);
// 驗證的數據
const validData = {
...
age: '30',
};
const validResult = validate(validData);
if (validResult) {
// 驗證通過
console.log('pass');
} else {
// 驗證不通過
console.log(validate.errors);
// [
// {
// keyword: 'type',
// dataPath: '.age',
// schemaPath: '#/properties/age/type',
// params: { type: 'number' },
// message: 'should be number'
// }
// ]
}
不只是JavaScript/Typescript,其他編程語言也有基於JSON Schema實現的校驗器,如Java的Snow 、go的gojsonschema和Python的jschon 等等都是基於此去開發的。所以通過JSON Schema規範,還可以保持前後端校驗的一致。
2. form自動生成
JSON Schem雖然有規範約束,但仍然還是一份描述數據的JSON配置,那麼基於這份配置,邏輯上就能自動渲染出功能完整的表單UI。
應用
- vue-json-schema-form:基於 vue.js 和JSON Schema 渲染form,最新版本已經支持Vue3
- form-render:基於react.js的表單解決方案,最新版本使用Ant Design作為視覺主題
-
formily:跨端能力,邏輯可跨框架,主要模塊(react.js+antd為例):
@formily/core:實現狀態管理、表單校驗等邏輯,和UI無關
@formily/react:實現交互效果,視圖橋接
@formily/antd:擴展組件庫,開箱即用的表單UI
以上表單渲染庫都有提供對應的表單設計器,可以通過拖拽的形式快速生成JSON Schema,整體流程如下:
如何選擇合適的庫?如是基於react.js的low code項目,那其實form-render就已經足夠了。formily雖然支持的場景很多,但有一定的接入成本(從官方文檔就能看出),而且包的體積也相對較大。如form-render,只需要引入form-render,然後正確傳入props就能直接渲染出預期的UI。
常見的在低代碼平台中,都會有表單模塊,但這部分的邏輯通常並不是整個低代碼項目的核心,那就可以交由form-render這類表單渲染庫去做,基於此就能減少開發單獨維護表單映射或者校驗的代碼。當然也有可能需要開發部分定製的widgets,以適配於較為複雜或者更切合業務的情況。
二、form-render
from-render整體可以分為core和widgets。core實現了表單映射、校驗和監聽等等,widgets就是一些UI組件實現了。
core
映射
widgets包含了內置組件和擴展組件,內置的組件已經提供,基本包含在這裏:https://x-render.gitee.io/gen...。同時還支持由開發者自定義一些擴展組件,提供props.widgets傳入自定義的object就能把擴展form組件註冊到widgets映射表內。
// form-render-core/src/index.js
<ConfigProvider locale={zhCN} {...configProvider}>
<FRCore widgets={{ ...defaultWidgets, ...widgets }} {...rest} />
</ConfigProvider>
如果要覆蓋默認組件,可以使用mapping註冊到form映射表內
// form-render-core/src/index.js
const tools = useMemo(
() => ({
widgets,
mapping: { ...defaultMapping, ...mapping },
...
需要注意的是這裏只是form映射表,同時還需要將自定義的widgets註冊到表內。無論是內置或者擴展的組件都會,只要實現了一個基於映射表的getWidgetName方法就能獲取到需要映射的組件名,渲染出對應的UI。
// form-render-core/src/core/RenderField/ExtendedWidget.js
// JSON Schema指定widget
let widgetName = getWidgetName(schema, mapping);
const customName = schema.widget || schema['ui:widget'];
if (customName && widgets[customName]) {
widgetName = customName;
}
const readOnlyName = schema.readOnlyWidget || 'html'; // 指定readOnly模式下的widget,或者使用默認html
if (readOnly && !isObjType(schema) && !isListType(schema)) {
// 基礎組件的readOnly會默認使用readOnlyName
widgetName = readOnlyName;
}
if (!widgetName) {
widgetName = 'input';
return <ErrorSchema schema={schema} />;
}
const Widget = widgets[widgetName];
const extraSchema = extraSchemaList[widgetName];
...
// form-render-core/src/core/RenderField/index.js
// 單屬性UI最基礎的內容
const RenderField = props => {
...
return (
<>
{_showTitle && titleElement}
<div
className={`${contentClass} ${hideTitle ? 'fr-content-no-title' : ''}`}
style={contentStyle}
>
{/* Widget渲染 */}
<ExtendedWidget {...widgetProps} />
{/* 説明信息 */}
<Extra {...widgetProps} />
{/* ErrorMessage,校驗相關 */}
<ErrorMessage {...messageProps} />
</div>
</>
);
}
校驗
需要實現兩個基礎的校驗方法,validateSingle(單屬性校驗)和validateAll(表單校驗),具體的校驗邏輯可以通過一些開源工具去實現,如form-render使用的是async-validator作為校驗工具,async-validator是一個表單異步校驗的工具,Ajv.js也可以異步校驗,只需要初始化的時候帶上schema內帶上{$async: true}。
| Ajv.js | async-validator | |
|---|---|---|
| server | 支持 | 支持 |
| client | 支持 | 支持 |
| 同步校驗 | 支持 | 不支持 |
| 異步校驗 | 支持 | 支持 |
| package size | 119.6 kb | 14.2kb |
多數情況下的表單校驗都會選擇異步執行,所以包括form-render這類表單渲染庫,或者一些開源組件庫(如element)會使用async-validator作為校驗工具。
// form-render-core/src/core/RenderField/index.js
const validateSingle = (data, schema = {}, path, options = {}) => {
...
/**
* getDescriptorSimple會轉換成匹配async-validator的數據結構,如果是其他的校驗工具,可能就是另一種轉換了
* 以path為key,rules為value,和result的[path]: data是對應的
*/
const descriptor = getDescriptorSimple(schema, path);
let validator;
try {
// 校驗
validator = new Validator(descriptor);
} catch (error) {
return Promise.resolve();
}
// 錯誤提示的模板 type number string
let messageFeed = locale === 'en' ? en : cn;
merge(messageFeed, validateMessages);
validator.messages(messageFeed);
return validator
.validate({ [path]: data })
.then(res => {
return [{ field: path, message: null }];
})
.catch(({ errors, fields }) => {
//
return errors;
});
};
validateAll只需要基於validateSingle遍歷完成校驗即可。validateSingle除了作為validateAll的一部分,同時也會在validateField中使用,為單個屬性實時校驗使用。
const onChange = value => {
// 節流、表單方法等
...
validateField({
path: dataPath, // 路徑
formData: formDataRef.current, // 表單數據
flatten, // schema 的轉換結構,[path]: {parent, children, schema}
options: {
locale,
validateMessages,
},
})
...
};
只是有校驗是不夠的,最重要的是同時要提示數據校驗不通過的原因,所以還需要實現message動態模板,以及ErrorMessage組件承載錯誤提示。如form-render,實現了validateMessageCN.js作為message模板,ErrorMessage.js作為錯誤提示組件。
監聽
數據監聽常見於低代碼的場景中,預期是希望用户輸入對應的屬性後,能實時在渲染器響應,同步渲染UI。form-render提供了watch屬性,用於數據的監聽的喚起回調。
// form-render-core/src/Watcher.js
...
/**
* formData當前表單的數據,watchKey被監聽的key
* getValueByPath主要是處理#和普通的key
* 如果是#,返回的就是formData
*/
const value = getValueByPath(formData, watchKey);
// callback
const watchObj = watch[watchKey];
useEffect(() => {
const runWatcher = () => {
if (typeof watchObj === 'function') {
try {
// 執行回調函數,並把value傳遞到外層
watchObj(value);
} catch (error) {
console.log(`${watchKey}對應的watch函數執行報錯:`, error);
}
} else if (watchObj && typeof watchObj.handler === 'function') {
try {
// 適配多個參數的情況,其實目前的話,主要是handler和immediate
watchObj.handler(value);
} catch (error) {
console.log(`${watchKey}對應的watch函數執行報錯:`, error);
}
}
};
if (firstMount) {
const immediate = watchObj && watchObj.immediate;
if (immediate) {
// 如果immediate為true,會在首次加載的時候觸發一次watch
runWatcher();
}
} else {
runWatcher();
}
...
需要注意的是,存在對象或者數組嵌套的情況,getValueByPath也需要有根據path來獲取value的能力。如form-render是通過lodash-es模塊的get方法來實現的。
通過watch映射表構建多個watch實例。
...
{
{/* watchList = Object.keys(watch) */}
watchList.length > 0
? watchList.map((item, idx) => {
{/* null */}
return (
<Watcher
key={idx.toString()}
watchKey={item}
watch={watch}
formData={formData}
firstMount={firstMount}
/>
);
})
: null
}
...
widgets
widgets主要是包含了內置組件,部分組件是直接使用了組件庫提供的組件,如TextArea、InputNumber等,這些組件只需要調整下樣式就能直接用於表單渲染了;但大部分組件都是經過封裝後再使用的,如Slider、Color和Date組件等,不同的組件封裝的邏輯不同,比如Slider包含了組件庫的Slider和InputNumber,並對schema做解構,構建成對應的props。
<img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/dd3dbb93298e432daee11d2b07857266~tplv-k3u1fbpfcp-zoom-1.image" style="zoom:36%;" />
form-render自定義組件:input, checkbox, checkboxes, color, date, time, dateRange, timeRange, imageInput, url, list, map, multiSelect, radio, select, slider, switch, upload, html, rate
form-render組件庫組件:number, textarea, treeSelect