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


無論你用 React,Vue,還是 Angular,你還是要一遍一遍寫相似的 CRUD 頁面,一遍一遍,一遍一遍,一遍又一遍……

“天下苦秦久矣”~~

前端開發的“痛點”在哪里?

 

 

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

現在的 CRUD 頁面代碼通常:

  1. 太輕的“Model”或着“Service”,大多時候只是一些 API 調用的封裝。
  2. 胖”View“,View 頁面中有展示 UI 邏輯,生命周期邏輯,CRUD 的串聯邏輯,然后還要塞滿業務邏輯代碼。
  3. 不同的項目業務邏輯不同,但是列表頁,表單,搜索這三板斧的樣板代碼,卻要一遍一遍占據着前端工程師的寶貴時間。

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

說好的“數據驅動的前端開發”呢?

對於這個“痛點”——怎么盡可能的少寫模版代碼,就是本文嘗試解決的問題。

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


前端元編程

從 ECMAScript 2015 開始,JavaScript 獲得了  Proxy 和  Reflect 對象的支持,允許你攔截並定義基本語言操作的自定義行為(例如,屬性查找,賦值,枚舉,函數調用等)。借助這兩個對象,你可以在 JavaScript 元級別進行編程。
MDN

在正式開始之前,我們先復習下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 官方的文檔。

Handbook - Decorators

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

下文,我們用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 {};
  };
}

 

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. static getColumns<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 行,我們有了個一個功能完備表單頁

https://ts-model-decorator.stackblitz.io/

元編程減少樣板代碼Demo

https://github.com/yijian166/ts-model-decorator

效果

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

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

元編程 vs. 配置系統

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

簡單來說,就是一個單獨的系統,配置類似上文的元信息,然后使用固定模版生成代碼。

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

總結

上面只是 table,form 頁面的代碼展示,由此我們可以引申到很多類似的地方,甚至 API 的調用代碼都可以在元編程中處理。

 

 

元編程——將元數據轉換成為頁面組件可用的數據,這部分恰恰可以在團隊內非常好共享也需要共同維護的部分,帶來的好處也很明顯:

  • 最大的好處自然就是生產效率的提高了,而且是低成本的實現效率的提升(相比配置系統)。一些簡單單純的 CURD 頁面甚至都不用寫代碼了。
  • 更易維護的代碼:
  • “瘦 View“,專注業務,
  • 更純粹的 Model,你可以和 redux,mobx 配合,甚至,你可以從 React,換成 Angular)
  • 最后更重要的是,元編程是一個低成本,靈活,漸進的方案。它是一個運行時的方案,你不需要一步到羅馬,徐徐圖之 …… - ……
前端元編程,較少你的樣板代碼,加速前端開發

 

最后,本文更多是一次實踐,一種思路,一種元編程在前端開發中的應用場景,最重要的還是拋磚引玉,希望前端小伙伴們能形成自己團隊的的元編程實踐,來解放生產力,更快搬磚~~

 轉自https://zhuanlan.zhihu.com/p/274328551

喜歡這篇文章?歡迎打賞~~

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM