前端元編程——使用註解加速你的前端開發

導語 前端元編程大幅減小CRUD樣板代碼,釋放生產力,極速前端開發html

不管你用React,Vue,仍是Angular,你仍是要一遍一遍寫類似的CRUD 頁面,一遍一遍,一遍一遍,一遍又一遍……前端

「天下苦秦久矣」~~react

前端開發的「痛點」在哪裏?

如今的前端開發,咱們有了世界一流的UI庫React,Vue,Angular,有了樣式豐富的UI組件庫Tea (騰訊雲UI組件庫,相似Antd Design), 有了方便強大的腳手架工具(例如,create react app)。可是咱們在真正業務代碼以前,一般還免不了寫大量的樣板代碼。typescript

如今的CRUD頁面代碼一般:express

  1. 過輕的「Model」或着「Service」,大多時候只是一些API調用的封裝。
  2. 胖」View「,View頁面中有展現UI邏輯,生命週期邏輯,CRUD的串聯邏輯,而後還要塞滿業務邏輯代碼。
  3. 不一樣的項目業務邏輯不一樣,可是列表頁,表單,搜索這三板斧的樣板代碼,卻要一遍一遍佔據着前端工程師的寶貴時間。

特別是CRUD類應用的樣板代碼受限於團隊風格,後端API風格,業務形態等,一般內在邏輯類似書寫上卻略有區別,沒法經過一個通用的庫或者框架來解決(上圖中背景越深,越不容易有一個通用的方案)。npm

說好的「數據驅動的前端開發」呢?編程

對於這個「痛點」——怎麼儘量的少寫模版代碼,就是本文嘗試解決的問題。json

咱們嘗試使用JavaScript新特性 DecoratorReflect元編程來解決這個問題。redux

前端元編程

從ECMAScript 2015 開始,JavaScript 得到了 ProxyReflect 對象的支持,容許你攔截並定義基本語言操做的自定義行爲(例如,屬性查找,賦值,枚舉,函數調用等)。藉助這兩個對象,你能夠在 JavaScript 元級別進行編程。後端

在正式開始以前,咱們先複習下 DecoratorReflect

Decorator

這裏咱們簡單介紹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

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

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減小樣板代碼

回到正題——使用Decorator和Reflect來減小CRUD應用中的樣板代碼。

什麼是CRUD頁面?

CRUD頁面無需多言,列表頁展現,表單頁修改 ……包括API調用, 都是圍繞某個數據結構(圖中 Person)展開,增、刪、改、查。

基本思路

基本思路很簡單,就像上圖,Model是中心,咱們就是藉助 DecoratorReflect將CRUD頁面所需的樣板類方法屬性元編程在Model上。進一步延伸數據驅動UI的思路。

  1. 藉助Reflect Matadata綁定CRUD頁面信息到Model的屬性上
  2. 藉助Decorator加強Model,生成CRUD所需的樣板代碼

Show Me The Code

下文,咱們用TypeScriptReact爲例,組件庫使用騰訊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 {
    }
  }
}
複製代碼

API Model 映射

TypeScript項目中第一步天然是將後端數據安全地轉換爲 typeinterface或者 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];
        })
      }
    }
  }
}
複製代碼

列表頁TablePage

列表頁中通常使用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
複製代碼
  1. getConfigMap<T>(F:any,cachekey:symbol,metaKey:symbol):Map<string,T> 收集元數據到Map

  2. 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。

Form表單頁

表單,天然就是字段的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所需:

  • initialValues
  • 數據校驗的validationSchema
  • 各個表單組件所需的,name,label,required等
  • 提交表單的數據轉換handle函數
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行,咱們有了個一個功能完備表單頁

效果參閱:

stackblitz.com/edit/ts-mod…

元編程減小樣板代碼Demo:

stackblitz.com/edit/ts-mod…

效果

上文包含了很多的代碼,可是大部頭在如何將元數據轉換成爲頁面組件可用的數據,也就是元編程的部分。

而業務頁面,7行的Table頁面,40行的Form頁面,已經很是精簡功能完備了。根據筆者實際項目中估計,能夠節省至少40%的代碼量。

元編程 vs. 配置系統

寫到尾聲,你大概會想到某些配置系統,前端CRUD這個從古就有的需求,天然早就有方案,用的最多的就是配置系統,在這裏不會過多討論。

簡單來講,就是一個單獨的系統,配置相似上文的元信息,而後使用固定模版生成代碼。

思路實際上和本文的元編程相似,只是元編程成本低,你不須要單獨作一個系統,更加輕量靈活,元編程代碼在運行時,想象空間更大……

總結

上面只是table,form頁面的代碼展現,由此咱們能夠引伸到不少相似的地方,甚至API的調用代碼均可以在元編程中處理。

元編程——將元數據轉換成爲頁面組件可用的數據,這部分偏偏能夠在團隊內很是好共享也須要共同維護的部分,帶來的好處也很明顯:

  • 最大的好處天然就是生產效率的提升了,並且是低成本的實現效率的提高(相比配置系統)。一些簡單單純的CURD頁面甚至都不用寫代碼了。
  • 更易維護的代碼:
  1. 「瘦View「,專一業務,
  2. 更純粹的Model,你能夠和redux,mobx配合,甚至,你能夠從React,換成Angular)
  • 最後更重要的是,元編程是一個低成本,靈活,漸進的方案。它是一個運行時的方案,你不須要一步到羅馬,徐徐圖之……
  • ……

最後,本文更可能是一次實踐,一種思路,一種元編程在前端開發中的應用場景,最重要的仍是拋磚引玉,但願前端小夥伴們能造成本身團隊的的元編程實踐,來解放生產力,更快搬磚~~

更多精彩內容,盡請關注騰訊VTeam技術團隊微信公衆號和視頻號

原做者: 王成才

未經贊成,禁止轉載!

相關文章
相關標籤/搜索