Vben-Admin的useForm實現思路詳解,以及實現element版的useForm

shellingfordly發表於2022-03-11

前言

Vue3已經發了一年多了,現在也是已經轉正了,Antd和element的Vue3版本也都是可以用了。Vue3剛釋出沒多久的時候我就上車了,當時在Github找了一圈Vue3的Admin架子,最後發現了vue-vben-admin這個專案,感覺這個作者寫得很好,程式碼規範,封裝啥的都很完善,當時Vben還只有1k多star,現在已經10.3k了,雖然很多覺得它太臃腫了,但事實證明它確實是很好,估計後面還會慢慢增加。當時就想做一個Vben的element移植版,奈何過於懶惰,只搭起了架子,沒有做後續,加上Vben確實複雜,它的自定義元件不是太好移植。擱置到了今年,最近撿起來,移植了Form元件包括useForm的用法。

專案

我的這個element-puls版的專案地址vele-admin,目前移植了Model,Dialog,Form元件,用過Vben的應該知道,就是使用useForm的形式,template模版裡面的元件引數可以少傳一些。

OK,開整,先說一下,Vben裡面的Form元件寫得比較複雜,各種util函式封裝的比較多,我這裡寫的時候進行了很多縮減,程式碼簡化了很多,也更容易看懂。

分析

useForm

Vben的很多元件都是對Antd進行二次封裝,使用useFunc的形式對資料進行處理,讓我們在模版中不需要寫過多的引數,也不用寫大量重複的Antd元件。

上程式碼,useForm接受props引數,props就是Form元件的屬性,Vben裡面加了更多自己的屬性,擁有更多自定義的功能,我這裡就不做那麼多了,我的props型別基本上就是element-plus的form屬性,除了schemas,基本上都是直接傳給el-form的,schemas是為了去自動新增Form的內容元件的,後面再詳細說。

  • schemas 表單配置屬性
  • model 表單資料,需要傳入ref或reactive的響應式變數
  • rules 表單驗證規則
export interface FormProps {
  schemas?: FormSchema[];
  // 表單資料物件
  model?: Recordable;
  // 表單驗證規則
  rules: any;
  //     行內表單模式
  inline: boolean;
  // 表單域標籤的位置, 如果值為 left 或者 right 時,則需要設定 label-width
  labelPosition: string;
  // 表單域標籤的寬度,例如 '50px'。 作為 Form 直接子元素的 form-item 會繼承該值。 支援 auto。
  labelWidth: string | number;
  // 表單域標籤的字尾
  labelSuffix: string;
  // 是否顯示必填欄位的標籤旁邊的紅色星號
  hideRequiredAsterisk: boolean;
  // 是否顯示校驗錯誤資訊
  showMessage: boolean;
  // 是否以行內形式展示校驗資訊
  inlineMessage: boolean;
  // 是否在輸入框中顯示校驗結果反饋圖示
  statusIcon: boolean;
  // 是否在 rules 屬性改變後立即觸發一次驗證
  validateOnRuleChange: boolean;
  // 用於控制該表單內元件的尺寸    strin
  size: string;
  // 是否禁用該表單內的所有元件。 若設定為 true,則表單內元件上的 disabled 屬性不再生效
  disabled: boolean;
}

useForm函式主要的功能就是返回一個register和一些Form操作方法,這些方法與element-plus官方方法名一致,實際上也是直接去呼叫el-form的原生方法,Vben也是直接去呼叫antd的方法。當然,它有一些自定義操作表單的方法。

  • setProps 動態設定表單屬性
  • validate 對整個表單作驗證
  • resetFields 對整個表單進行重置,將所有欄位值重置為初始值並移除校驗結果
  • clearValidate 清理指定欄位的表單驗證資訊
  • validateField 對部分表單欄位進行校驗的方法
  • scrollToField 滾動到指定表單欄位

Vben裡面這裡返回的方法會更多,我這裡沒有全部做完,因為我的資料處理和Vben不太一樣,有幾個方法也不需要。比如getFieldsValue獲取表單某個屬性值和setFieldsValue設定表單屬性值,因為我是直接去使用外面宣告的響應式物件,所以外面直接使用/設定formData就可以了,這也和element-plus和antd不同有關係,後面再細說。

useForm其實沒有什麼特別的,返回的register函式需要在使用時傳給VeForm,內部會把VeForm的元件例項傳給register,然後在useForm內就可以使用元件例項去呼叫VeForm內部的方法。

其實準確說來,我覺得useForm拿到的instance也不算是VeForm的元件例項,只是VeForm給useForm提供的一個包含內部方法屬性的物件,因此我把這個變數改成了formAction,而不是Vben裡面的formRef,不過這個倒是無所謂了,無傷大雅。

import { ref, onUnmounted, unref, watch, nextTick } from "vue";
import { FormActionType, FormProps } from "../types";
import { throwError } from "/@/utils/common/log";
import { isProdMode } from "/@/utils/env/env";

export default function useForm(props?: Partial<FormProps>) {
  const formAction = ref<Nullable<FormActionType>>(null);
  const loadedRef = ref<Nullable<boolean>>(false);

  function register(instance: FormActionType) {
    if (isProdMode()) {
      // 開發環境下,元件解除安裝後釋放記憶體
      onUnmounted(() => {
        formAction.value = null;
        loadedRef.value = null;
      });
    }

    // form 元件例項 instance 已存在
    // 實際上 register 拿到的並不是 元件例項, 只是掛載了一些元件內部方法的 物件 formAction
    if (unref(loadedRef) && isProdMode() && instance === unref(formAction)) {
      return;
    }

    formAction.value = instance;
    loadedRef.value = true;

    // 監聽 props, 若props改變了
    // 則使用 form 例項呼叫內部的 setProps 方法將新的props設定到form元件內部
    watch(
      () => props,
      () => {
        if (props) {
          instance.setProps(props);
        }
      },
      { immediate: true, deep: true }
    );
  }

  async function getForm() {
    const form = unref(formAction);
    if (!form) {
      throwError(
        "The form instance has not been obtained, please make sure that the form has been rendered when performing the form operation!"
      );
    }
    await nextTick();
    return form as FormActionType;
  }

  const methods: FormActionType = {
    async setProps(formProps: Partial<FormProps>) {
      const form = await getForm();
      form.setProps(formProps);
    },
    async validate(callback: (valid: any) => void) {
      const form = await getForm();
      form.validate(callback);
    },
    async validateField(
      props: string | string[],
      callback: (err: string) => void
    ) {
      const form = await getForm();
      form.validateField(props, callback);
    },
    async resetFields() {
      const form = await getForm();
      form.resetFields();
    },
    async clearValidate() {
      const form = await getForm();
      form.clearValidate();
    },
    async scrollToField(prop: string) {
      const form = await getForm();
      form.scrollToField(prop);
    },
  };

  return { register, methods };
}

useFormEvents

這個hook函式提供resetFieldsclearValidatevalidatevalidateFieldscrollToField表單操作方法。

export interface FormActionType {
  // 設定表單屬性
  setProps: (props: Partial<FormProps>) => void;
  // 對整個表單作驗證
  validate: (callback: (valid: any) => void) => void;
  // 對整個表單進行重置,將所有欄位值重置為初始值並移除校驗結果
  resetFields: () => void;
  // 清理指定欄位的表單驗證資訊
  clearValidate: (props?: string | string[]) => void;
  // 對部分表單欄位進行校驗的方法
  validateField: (
    props: string | string[],
    callback: (err: string) => void
  ) => void;
  // 滾動到指定表單欄位
  scrollToField: (prop: string) => void;
}

實際上只是在VeForm裡呼叫的時候將el-form的例項formElRef直接傳進來,然後直接去呼叫el-from提供的對應api。 Vben因為方法比較多所以抽出來了,其實直接寫在VeForm裡面也沒關係。

我這裡不同的resetFields方法和Vben不同,Vben是手動去對儲存了表單資料的變數formModel進行修改的,這裡因為el-form的resetFields可以直接重置到初始值,我就把這段刪掉了。

清除檢驗資訊和對資料進行檢驗這兩個方法就直接調el-form的api,檢驗資料這裡有一個大坑,當然如果注意到的人就沒事,我當時沒注意prop這個屬性,導致一直無法觸法檢驗,給我找了好久,最後除錯的時候才發現prop是undefined,氣死我了。

import { nextTick, Ref, unref } from "vue";
import type { FormActionType, FormProps } from "../types";

export interface UseFormActionContext {
  propsRef: Ref<Partial<FormProps>>;
  formElRef: Ref<FormActionType>;
}

export function useFormEvents({ formElRef }: UseFormActionContext) {
  async function resetFields() {
    await unref(formElRef).resetFields();
    nextTick(() => clearValidate());
  }

  async function clearValidate(name?: string | string[]) {
    await unref(formElRef).clearValidate(name);
  }

  async function validate(callback: (valid: any) => void) {
    return await unref(formElRef).validate(callback);
  }

  async function validateField(
    prop: string | string[],
    callback: (err: string) => void
  ) {
    return await unref(formElRef).validateField(prop, callback);
  }

  async function scrollToField(prop: string) {
    return await unref(formElRef).scrollToField(prop);
  }

  return { resetFields, clearValidate, validate, validateField, scrollToField };
}

VeForm

接下來就是最重要的元件VeForm了,獲取el-form元件例項,再將這些表單操作方法物件在onMounted元件掛載之後通過外面使用VeForm訂閱的register事件傳遞到useForm中,useForm再將formAction物件的方法提供給外部元件。

  • getBindValue 收集外部傳入的所有引數,包括接收的,沒接收的,useForm裡傳的,VeForm元件上直接傳的,收集起來通通傳給el-form
  • getSchema 表單配置物件
  • formRef el-form元件例項,傳給useFormEvents去呼叫el-form的提供方法,Vben裡還傳給了useFormValues去做表單資料的處理和其他hook函式,我這裡沒有做
  • setFormModel 給VeFormItem提供的設定表單資料方法
  • setProps 給外部提供動態設定表單屬性的方法
  • formAction 給外部提供的表單操作物件
<script lang="ts" setup>
import { computed, onMounted, ref, unref, useAttrs } from "vue";
import type { Ref } from "vue";
import type { FormActionType, FormProps } from "./types";
import VeFormItem from "./components/VeFormItem.vue";
import { useFormEvents } from "./hooks/useFormEvents";
import { useFormValues } from "./hooks/useFormValues";

const attrs = useAttrs();
const emit = defineEmits(["register"]);
const props = defineProps();
const propsRef = ref<Partial<FormProps>>({});
const formRef = ref<Nullable<FormActionType>>(null);
const defaultValueRef: Recordable = {};
// 合併接收的所有引數
const getBindValue = computed<Recordable>(() => ({
  ...attrs,
  ...props,
  ...propsRef.value,
}));

const getSchema = computed(() => {
  const { schemas } = unref(propsRef);
  return schemas || [];
});

const { validate, resetFields, clearValidate } = useFormEvents({
  propsRef,
  formElRef: formRef as Ref<FormActionType>,
  defaultValueRef,
});

const { initDefault } = useFormValues({
  defaultValueRef,
  getSchema,
  propsRef,
});

function setFormModel(key: string, value: any) {
  if (propsRef.value.model) {
    propsRef.value.model[key] = value;
  }
}

function setProps(formProps: Partial<FormProps>) {
  propsRef.value = { ...propsRef.value, ...formProps };
}

const formAction: Partial<FormActionType> = {
  setProps,
  validate,
  resetFields,
  clearValidate,
};

// 暴露給外面的元件例項使用
defineExpose(formAction);

onMounted(() => {
  emit("register", formAction);
  initDefault();
});
</script>

<template>
  <el-form ref="formRef" v-bind="getBindValue">
    <slot name="formHeader"></slot>
    <template v-for="schema in getSchema" :key="schema.field">
      <VeFormItem
        :schema="schema"
        :formProps="propsRef"
        :setFormModel="setFormModel"
      >
        <template #[item]="data" v-for="item in Object.keys($slots)">
          <slot :name="item" v-bind="data || {}"></slot>
        </template>
      </VeFormItem>
    </template>
    <slot name="formFooter"></slot>
  </el-form>
</template>

VeFormItem

VeForm中通過表單配置物件getSchema迴圈使用VeFormItem,將propsRefsetFormModel以及對應的schema傳入VeFormItem中。Vben的FormItem是用jsx形式寫的,我這裡簡化比較多,感覺沒有必要用,就直接模版寫了。

  • schema

除了沒有特別大必要用jsx之外,還有一個原因就是我jsx不怎麼用,再寫label插槽的時候用jsx我不知道怎麼寫,就用模版了。如果schema的label屬性VNode,就插刀ElFormItem的label插槽中;不是的話就傳給ElFormItem。

  • renderComponent

renderComponent函式返回schema配置的元件,componentMap是一個預先寫好的Map,設定了表單常用元件,當使用者配置了render時,就直接使用render,render必須是一個VNode,沒有則通過component屬性去componentMap上取。

  • getModelValue

getModelValue是動態設定的雙向繫結資料,它是一個可設定的計算屬性,讀取時返回由VeForm傳入的formProps上的model上對應的屬性,設定的時候去呼叫VeForm傳入的setFormModel方法,相當於直接去操作到了使用者在使用useForm時傳入的model物件,因此model物件必須是reactive/ref宣告的可修改的雙向繫結資料。

  • formModel(formData)

資料這裡的做法和Vben不是太一樣,Vben是在內部宣告的formModel物件,通過schema去配置defaultValue,給外部提供setFieldsValue和getFieldsValue的方法。也是因為element-plus的form是直接使用v-model去繫結input等其他實際的內容元件的,並且我也希望在外面自己宣告formData,然後formData也能實時更新,直接去使用formData給介面使用、傳給子元件或是其他操作。因此所有表單資料物件從裡到內一直用的是一個物件,就是外面宣告並傳入useForm的model屬性的變數formData。

  • compAttr 將schema配置物件的componentProps屬性傳給實際的內容元件。
export interface FormSchema {
  // 欄位屬性名
  field: string;
  // 標籤上顯示的自定義內容
  label: string | VNode;
  component: ComponentType;
  // 子元件 屬性
  componentProps?: object;
  // 子元件
  render?: VNode;
}
<script lang="ts" setup>
import { computed, ref, useAttrs } from "vue";
import { componentMap } from "../componentMap";
import { FormSchema } from "../types";
import { ElFormItem } from "element-plus";
import { isString } from "/@/utils/help/is";

const attrs = useAttrs();
const props = defineProps<{
  schema: FormSchema;
  formProps: Recordable;
  setFormModel: (k: string, v: any) => {};
}>();
const { component, field, label } = props.schema;

const labelIsVNode = computed(() => !isString(label));

const compAttr = computed(() => ({
  ...props.schema.componentProps,
}));

// 內容元件的雙向繫結資料
const getModelValue = computed({
  get() {
    return props.formProps.model[field];
  },
  set(value) {
    props.setFormModel(field, value);
  },
});

const getBindValue = computed(() => {
  const value: Recordable = {
    ...attrs,
    prop: field,
  };
  if (isString(label)) {
    value.label = label;
  }
  return value;
});

function renderComponent() {
  if (props.schema.render) {
    return props.schema.render;
  }
  return componentMap.get(component);
}
</script>

<template>
  <ElFormItem v-bind="getBindValue">
    <template v-if="labelIsVNode" #label>
      <component :is="label" />
    </template>
    <component
      v-model="getModelValue"
      v-bind="compAttr"
      :is="renderComponent()"
    />
  </ElFormItem>
</template>

使用

宣告表單配置物件schemas,表單初始資料formData,表單驗證規則rules,傳入useForm;將register給VeForm繫結上即可。

import type { FormSchema } from "/@/components/VeForm/types";
import { reactive } from "vue";

const schemas: FormSchema[] = [
  {
    field: "name",
    label: "姓名",
    component: "Input",
  },
  {
    field: "age",
    label: "年紀",
    component: "InputNumber",
  }
]
const rules = reactive({
  name: [
    {
      required: true,
      message: "Please input name",
      trigger: "blur",
    },
    {
      min: 3,
      max: 5,
      message: "Length should be 3 to 5",
      trigger: "blur",
    },
  ],
  age: [
    {
      required: true,
      message: "Please input age",
      trigger: "blur",
    },
  ]
})

如果把schemas和rules這種一單宣告之後不會太做修改的資料抽出去,那元件就變得更簡潔了,對於我這種喜歡極簡的人來說,這元件看著太舒服了。

import VeForm, { useForm } from "/@/components/VeForm";
import { ref } from "vue";

const formData = reactive({
  name: "shellingfordly",
  age: 24,
});
const { register, methods } = useForm({
  model: formData,
  rules,
  schemas,
});
const submitForm = () => {
  methods.validate((valid: any) => {
    if (valid) {
      console.log("submit!", valid);
    } else {
      console.log("error submit!", valid);
      return false;
    }
  });
};
const resetForm = () => {
  methods.resetFields()
};
const clearValidate = () => {
  methods.clearValidate()
};

<template>
  <VeForm @register="register" />
  <ElButton @click="submitForm">submitForm</ElButton>
</template>

Vben中可以動態設定schema,我這裡沒有做。當然還有一些它自定義的方法我也沒有寫,我前兩天移植的時候在表單校驗那裡卡住了,就因為當時沒注意到prop這個屬性,一直沒有發現,找個半天的bug,最後除錯的時候才發現prop是undefined,對比了一下直接使用ElForm,裡面是有值的,這才看到文件裡面寫著在使用 validate、resetFields 方法的情況下,該屬性是必填的,這個故事告訴我們要好好看文件啊,文件裡寫得清清楚楚。這個bug給我整吐了,導致後面我不想寫了哈哈哈哈,就沒有加動態新增schema,以及提交表單資料的button。不過沒關係,總體來說功能是實現了。有興趣的小夥伴可以去幫我新增哈哈哈哈,當然也可以移植其他元件。專案地址vele-admin

image.png

image.png

相關文章