導語 前端元編程大幅減小CRUD樣板代碼,釋放生產力,極速前端開發html
不管你用React,Vue,仍是Angular,你仍是要一遍一遍寫類似的CRUD 頁面,一遍一遍,一遍一遍,一遍又一遍……前端
「天下苦秦久矣」~~react
如今的前端開發,咱們有了世界一流的UI庫React,Vue,Angular,有了樣式豐富的UI組件庫Tea (騰訊雲UI組件庫,相似Antd Design), 有了方便強大的腳手架工具(例如,create react app)。可是咱們在真正業務代碼以前,一般還免不了寫大量的樣板代碼。typescript
如今的CRUD頁面代碼一般:express
特別是CRUD類應用的樣板代碼受限於團隊風格,後端API風格,業務形態等,一般內在邏輯類似書寫上卻略有區別,沒法經過一個通用的庫或者框架來解決(上圖中背景越深,越不容易有一個通用的方案)。npm
說好的「數據驅動的前端開發」呢?編程
對於這個「痛點」——怎麼儘量的少寫模版代碼,就是本文嘗試解決的問題。json
咱們嘗試使用JavaScript新特性 Decorator
和 Reflect
元編程來解決這個問題。redux
從ECMAScript 2015 開始,JavaScript 得到了
Proxy
和Reflect
對象的支持,容許你攔截並定義基本語言操做的自定義行爲(例如,屬性查找,賦值,枚舉,函數調用等)。藉助這兩個對象,你能夠在 JavaScript 元級別進行編程。後端
在正式開始以前,咱們先複習下 Decorator
和 Reflect
。
這裏咱們簡單介紹Typescript的 Decorator
,ECMAScript中 Decorator
還沒有定稿,可是不影響咱們平常的業務開發(Angular同窗就在使用Typescript的 Decorator
)。
簡單來講, Decorator
是能夠標註修改類及其成員的新語言特性,使用 @expression
的形式,能夠附加到,類、方法、訪問符、屬性、參數上。
TypeScript中須要在 tsconfig.json
中增長 experimentalDecorators
來支持:
{
"compilerOptions": {
"target": "ES5",
"experimentalDecorators": true
}
}
複製代碼
好比可使用類修飾器來爲類擴展方法。
// offer type
abstract class Base {
log() {}
}
function EnhanceClass() {
return function(Target) {
return class extends Target {
log() {
console.log('---log---')
}
}
}
}
@EnhanceClass()
class Person extends Base { }
const person = new Person()
person.log()
// ---log---
複製代碼
更多查看 typescript 官方的文檔:www.typescriptlang.org/docs/handbo…
Reflect 是ES6中就有的特性,你們可能對它稍微陌生,Vue3中依賴Reflect和Proxy來重寫它的響應式邏輯。
簡單來講, Reflect
是一我的內置的對象,提供了攔截 JavaScript操做的方法。
const _list = [1,2,3]
const pList = new Proxy(_list,{
get(target, key,receiver) {
console.log('get value reflect:', key)
return Reflect.get(target, key, receiver)
},
set(target,key,value,receiver) {
console.log('set value reflect',key,value)
return Reflect.set(target,key,value,receiver)
}
})
pList.push(4)
// get value reflect:push
// get value reflect:length
// set value reflect 3 4
// set value reflect length 4
複製代碼
Reflect Metadata 是ES7的一個提案,Typescript 1.5+就有了支持。要使用須要:
npm i reflect-metadata--save
tsconfig.json
裏配置 emitDecoratorMetadata
選項簡單來講,Reflect Metadata可以爲對象添加和讀取元數據。
以下可使用內置的 design:key
拿到屬性類型:
function Type(): PropertyDecorator {
return function(target,key) {
const type = Reflect.getMetadata('design:type',target,key)
console.log(`${key} type: ${type.name}`);
}
}
class Person extends Base {
@Type()
name:string = ''
}
// name type: String
複製代碼
回到正題——使用Decorator和Reflect來減小CRUD應用中的樣板代碼。
CRUD頁面無需多言,列表頁展現,表單頁修改 ……包括API調用, 都是圍繞某個數據結構(圖中 Person
)展開,增、刪、改、查。
基本思路很簡單,就像上圖,Model是中心,咱們就是藉助 Decorator
和 Reflect
將CRUD頁面所需的樣板類方法屬性元編程在Model上。進一步延伸數據驅動UI的思路。
下文,咱們用TypeScript和React爲例,組件庫使用騰訊Tea component 解說這個方案。
首先咱們有一個函數來生成不一樣業務的屬性裝飾函數。
function CreateProperDecoratorF<T>() {
const metaKey = Symbol();
function properDecoratorF(config:T): PropertyDecorator {
return function (target, key) {
Reflect.defineMetadata(metaKey, config, target, key);
};
}
return { metaKey, properDecoratorF}
}
複製代碼
一個類裝飾器,處理經過數據裝飾器收集上來的元數據。
export function EnhancedClass(config: ClassConfig) {
return function(Target) {
return class EnhancedClass extends Target {
}
}
}
複製代碼
TypeScript項目中第一步天然是將後端數據安全地轉換爲 type
, interface
或者 Class
,這裏Class能在編譯後在JavaScript存在,咱們選用 Class
。
export interface TypePropertyConfig {
handle?: string | ServerHandle
}
const typeConfig = CreateProperDecoratorF<TypePropertyConfig>()
export const Type = typeConfig.properDecoratorF;
@EnhancedClass({})
export class Person extends Base {
static sexOptions = ['male' , 'female' , 'unknow'];
@Type({
handle: 'ID'
})
id: number = 0
@Type({})
name:string = ''
@Type({
handle(data,key) {
return parseInt(data[key] || '0')
}
})
age:number = 0
@Type({
handle(data,key) {
return Person.sexOptions.includes(data[key]) ? data[key] : 'unknow'
}
})
sex: 'male' | 'female' | 'unknow' = 'unknow'
}
複製代碼
重點在 handle?:string|ServerHandle
函數,在這個函數處理API數據和前端數據的轉換,而後在 constructor
中集中處理。
export function EnhancedClass(config: ClassConfig) {
return function(Target) {
return class EnhancedClass extends Target {
constructor(data) {
super(data)
Object.keys(this).forEach(key => {
const config:TypePropertyConfig = Reflect.getMetadata(typeConfig.metaKey,this,key)
this[key] = config.handle ? typeof config.handle === 'string' ? data[config.handle]:config.handle(data,key): data[key];
})
}
}
}
}
複製代碼
列表頁中通常使用Table組件,不管是Tea Component仍是Antd Design Component中,樣板代碼天然就是寫那一大堆Colum配置了,配置哪些key要展現,表頭是什麼,數據轉化爲顯示數據……
首先咱們收集Tea Table 所需的 TableColumn
類型的column元數據。
import {TableColumn} from 'tea-component/lib/table'
export type EnhancedTableColumn<T> = TableColumn<T>;
export type ColumnPropertyConfig = Partial<EnhancedTableColumn<any>>;
const columnConfig = CreateProperDecoratorF<ColumnPropertyConfig>()
export const Column = columnConfig.properDecoratorF;
@EnhancedClass({})
export class Person extends Base {
static sexOptions = ['male' , 'female' , 'unknow'];
id: number = 0
@Column({
header: 'person name'
})
name:string = ''
@Column({
header: 'person age'
})
age:number = 0
@Column({})
sex: 'male' | 'female' | 'unknow' = 'unknow'
}
複製代碼
而後在EnhancedClass中收集,生成column列表。
function getConfigMap<T>(F: any, cachekey: symbol,metaKey: symbol): Map<string,T> {
if (F[cachekey]) {
return F[cachekey]!;
}
const item = new F({});
F[cachekey] = Object.keys(item).reduce((pre,cur) => {
const config: T = Reflect.getMetadata(
metaKey,
item,
cur
);
if (config) {
pre.set(cur, config);
}
return pre
}, new Map<string, T>());
return F[cachekey];
}
export function EnhancedClass(config: ClassConfig) {
const cacheColumnConfigKey = Symbol('cacheColumnConfigKey');
return function(Target) {
return class EnhancedClass extends Target {
[cacheColumnConfigKey]: Map<string,ColumnPropertyConfig> | null
/** * table column config */
static get columnConfig(): Map<string,ColumnPropertyConfig> {
return getConfigMap<ColumnPropertyConfig>(EnhancedClass, cacheColumnConfigKey,columnConfig.metaKey)
}
/** * get table colums */
static getColumns<T>(): EnhancedTableColumn<T>[] {
const list : EnhancedTableColumn<T>[] = []
EnhancedClass.columnConfig.forEach((config, key) => {
list.push({
key,
header: key,
...config
})
})
return list
}
}
}
}
複製代碼
Table數據通常是分頁,並且調用方式一般很通用,也能夠在EnhancedClass中實現。
export interface PageParams {
pageIndex: number;
pageSize: number;
}
export interface Paginabale<T> {
total: number;
list: T[]
}
export function EnhancedClass(config: ClassConfig) {
return function(Target) {
return class EnhancedClass extends Target {
static async getList<T>(params: PageParams): Promise<Paginabale<T>> {
const result = await getPersonListFromServer(params)
return {
total: result.count,
list: result.data.map(item => new EnhancedClass(item))
}
}
}
}
}
複製代碼
天然咱們封裝一個更簡易的Table 組件。
import { Table as TeaTable } from "tea-component/lib/table";
import React, { FC ,useEffect, useState} from "react";
import { EnhancedTableColumn, Paginabale, PageParams } from './utils'
import { Person } from "./person.service";
function Table<T>(props: { columns: EnhancedTableColumn<T>[]; getListFun: (param:PageParams) => Promise<Paginabale<T>> }) {
const [isLoading,setIsLoading] = useState(false)
const [recordData,setRecordData] = useState<Paginabale<T>>()
const [pageIndex, setPageIndex] = useState(1);
const [pageSize, setPageSize] = useState(20);
useEffect(() => {
(async () => {
setIsLoading(true)
const result = await props.getListFun({
pageIndex,
pageSize
})
setIsLoading(false)
setRecordData(result)
})();
},[pageIndex,pageSize]);
return (
<TeaTable columns={props.columns} records={recordData ? recordData.list : []} addons={[ TeaTable.addons.pageable({ recordCount:recordData ? recordData.total : 0, pageIndex, pageSize, onPagingChange: ({ pageIndex, pageSize }) => { setPageIndex(pageIndex || 0); setPageSize(pageSize || 20); } }), ]} />
)
}
export default Table
複製代碼
getConfigMap<T>(F:any,cachekey:symbol,metaKey:symbol):Map<string,T>
收集元數據到Map
staticgetColumns<T>():EnhancedTableColumn<T>[]
獲得table可用column信息。
const App = () => {
const columns = Person.getColumns<Person>();
const getListFun = useCallback((param: PageParams) => {
return Person.getList<Person>(param)
}, [])
return <Table<Person> columns={columns} getListFun={getListFun}/>
}
複製代碼
👉 效果很明顯,不是嗎?7行寫一個table page。
表單,天然就是字段的name,label,require,validate,以及提交數據的轉換。
Form表單咱們使用Formik + Tea Form Component + yup(數據校驗)。Formik 使用React Context來提供表單控件所需的各類方法數據,而後藉助提供的Field等組件,你能夠很方便的封裝你的業務表單組件。
import React, { FC } from 'react'
import { Field, Form, Formik, FormikProps } from 'formik';
import { Form as TeaForm, FormItemProps } from "tea-component/lib/form";
import { Input, InputProps } from "tea-component/lib/input";
import { Select } from 'tea-component/lib/select';
type CustomInputProps = Partial<InputProps> & Pick<FormItemProps, "label" | "name">;
type CustomSelectProps = Partial<InputProps> & Pick<FormItemProps, "label" | "name"> & {
options: string[]
}
export const CustomInput:FC<CustomInputProps> = props => {
return (
<Field name={props.name}> { ({ field, // { name, value, onChange, onBlur } form: { touched, errors }, // also values, setXXXX, handleXXXX, dirty, isValid, status, etc. meta, }) => { return ( <TeaForm.Item label={props.label} required={props.required} status={meta.touched && meta.error ? 'error': undefined } message={meta.error}> <Input type="text" {...field} onChange={(value,ctx)=> { field.onChange(ctx.event) }} /> </TeaForm.Item> ) } } </Field>
)
}
export const CustomSelect:FC<CustomSelectProps> = props => {
return (
<Field name={props.name}> { ({ field, // { name, value, onChange, onBlur } form: { touched, errors }, // also values, setXXXX, handleXXXX, dirty, isValid, status, etc. meta, }) => { return ( <TeaForm.Item label={props.label} required={props.required} status={meta.touched && meta.error ? 'error': undefined } message={meta.error}> <Select {...field} options={props.options.map(value=>({value}))} onChange={(value,ctx)=> { field.onChange(ctx.event) }} /> </TeaForm.Item> ) } } </Field>
)
}
複製代碼
照貓畫虎,咱們仍是先收集form所需的元數據
import * as Yup from 'yup';
export interface FormPropertyConfig {
validationSchema?: any;
label?: string;
handleSubmitData?: (data:any,key:string) => {[key:string]: any},
required?: boolean;
initValue?: any;
options?: string[]
}
const formConfig = CreateProperDecoratorF<FormPropertyConfig>()
export const Form = formConfig.properDecoratorF;
@EnhancedClass({})
export class Person extends Base {
static sexOptions = ['male' , 'female' , 'unknow'];
@Type({
handle: 'ID'
})
id: number = 0
@Form({
label:"Name",
validationSchema: Yup.string().required('Name is required'),
handleSubmitData(data,key) {
return {
[key]: (data[key] as string).toUpperCase()
}
},
required: true,
initValue:'test name'
})
name:string = ''
@Form({
label:"Age",
validationSchema: Yup.string().required('Age is required'),
handleSubmitData(data,key) {
return {
[key]: parseInt(data[key] || '0')
}
},
required: true,
})
age:number = 0
@Form({
label:"Sex",
options: Person.sexOptions
})
sex: 'male' | 'female' | 'unknow' = 'unknow'
}
複製代碼
有了元數據,咱們能夠在EnhancedClass中生成form所需:
export type FormItemConfigType<T extends any> = {
[key in keyof T]: {
validationSchema?: any;
handleSubmitData?: FormPropertyConfig['handleSubmitData'];
form: {
label: string;
name: string;
required: boolean;
message?: string;
options: string[];
};
};
};
export function EnhancedClass(config: ClassConfig) {
return function(Target) {
return class EnhancedClass extends Target {
[cacheTypeConfigkey]: Map<string,FormPropertyConfig> | null
/** * table column config */
static get formConfig(): Map<string,FormPropertyConfig> {
return getConfigMap<FormPropertyConfig>(EnhancedClass, cacheTypeConfigkey,formConfig.metaKey)
}
/** * get form init value */
static getFormInitValues<T extends EnhancedClass>(item?: T): Partial<T> {
const data:any = {};
const _item = new EnhancedClass({});
EnhancedClass.formConfig.forEach((config,key) => {
if (item && key in item) {
data[key] = item[key]
} else if ('initValue' in config) {
data[key] = config.initValue
} else {
data[key] = _item[key] || ''
}
});
return data as Partial<T>
}
static getFormItemConfig<T extends EnhancedClass>(overwriteConfig?: {
[key: string]: any;
}): FormItemConfigType<T> {
const formConfig: any = {};
EnhancedClass.formConfig.forEach((config,key) => {
formConfig[key] = {
form: {
label: String(config.label || key),
name: String(key),
required: !!config.validationSchema,
options: config.options || [],
...overwriteConfig
}
};
if (config.validationSchema) {
formConfig[key].validationSchema = config.validationSchema;
}
if (config.handleSubmitData) {
formConfig[key].handleSubmitData = config.handleSubmitData;
}
})
return formConfig as FormItemConfigType<T>
}
static handleToFormData<T extends EnhancedClass>(item: T) {
let data = {}
EnhancedClass.formConfig.forEach((config,key)=> {
if (item.hasOwnProperty(key)) {
data = {
...data,
...(EnhancedClass.formConfig
.get(key).handleSubmitData ? EnhancedClass.formConfig
.get(key).handleSubmitData(item, key) : {
[key]: item[key] || ''
})
};
}
})
return data
}
}
}
}
複製代碼
在FormPage中使用
export const PersonForm:FC<{
onClose: () => void
}> = props => {
const initialValues = Person.getFormInitValues<Person>()
const formConfig = Person.getFormItemConfig<Person>();
const schema = Object.entries(formConfig).reduce((pre, [key, value]) => {
if (value.validationSchema) {
pre[key] = value.validationSchema;
}
return pre;
}, {});
const validationSchema = Yup.object().shape(schema);
function onSubmit(values) {
const data = Person.handleToFormData(values);
setTimeout(() => {
console.log('---send to server', data)
props.onClose()
},10000)
}
return (
<Formik
initialValues={initialValues}
onSubmit={onSubmit}
validationSchema={validationSchema}
>
{(formProps:FormikProps<any>) => {
return (
<TeaForm >
<CustomInput {...formConfig.name.form} />
<CustomInput {...formConfig.age.form} />
<CustomSelect {...formConfig.sex.form} />
<Button type="primary" htmlType="submit" onClick={() => {
formProps.submitForm()
}} >Submit</Button>
</TeaForm>
)
}}
</Formik>
)
}
複製代碼
👉 40行,咱們有了個一個功能完備表單頁
效果參閱:
元編程減小樣板代碼Demo:
效果
上文包含了很多的代碼,可是大部頭在如何將元數據轉換成爲頁面組件可用的數據,也就是元編程的部分。
而業務頁面,7行的Table頁面,40行的Form頁面,已經很是精簡功能完備了。根據筆者實際項目中估計,能夠節省至少40%的代碼量。
寫到尾聲,你大概會想到某些配置系統,前端CRUD這個從古就有的需求,天然早就有方案,用的最多的就是配置系統,在這裏不會過多討論。
簡單來講,就是一個單獨的系統,配置相似上文的元信息,而後使用固定模版生成代碼。
思路實際上和本文的元編程相似,只是元編程成本低,你不須要單獨作一個系統,更加輕量靈活,元編程代碼在運行時,想象空間更大……
上面只是table,form頁面的代碼展現,由此咱們能夠引伸到不少相似的地方,甚至API的調用代碼均可以在元編程中處理。
元編程——將元數據轉換成爲頁面組件可用的數據,這部分偏偏能夠在團隊內很是好共享也須要共同維護的部分,帶來的好處也很明顯:
最後,本文更可能是一次實踐,一種思路,一種元編程在前端開發中的應用場景,最重要的仍是拋磚引玉,但願前端小夥伴們能造成本身團隊的的元編程實踐,來解放生產力,更快搬磚~~
更多精彩內容,盡請關注騰訊VTeam技術團隊微信公衆號和視頻號
原做者: 王成才
未經贊成,禁止轉載!