vue3 表单封装遇到的一个有意思的问题

2,703 阅读3分钟

前言

最近在用 vue3 封装 element 的表单时遇到的一个小问题,这里就简单记录一下过程。话不多说直接上代码!!!

正文

部分核心代码

import { ref, defineComponent, renderSlot, type PropType, type SetupContext } from 'vue';
import { ElForm, ElFormItem, ElRow, ElCol } from 'element-plus';
import type { RowProps, FormItemProps, LabelPosition } from './types';
import formItemRender from './CusomFormItem';
import { pick } from 'lodash-es';

const props = {
  formRef: {
    type: String,
    default: 'customFormRef',
  },
  modelValue: {
    type: Object as PropType<Record<string, unknown>>,
    default: () => ({}),
  },
  rowProps: {
    type: Object as PropType<RowProps>,
    default: () => ({
      gutter: 24,
    }),
  },
  formData: {
    type: Array as PropType<FormItemProps[]>,
    default: () => [],
  },
  labelPosition: {
    type: String as PropType<LabelPosition>,
    default: 'right',
  },
  labelWidth: {
    type: String,
    default: '150px',
  },
};

const elFormItemPropsKeys = [
  'prop',
  'label',
  'labelWidth',
  'required',
  'rules',
  // 'error',
  // 'showMessage',
  // 'inlineMessage',
  // 'size',
  // 'for',
  // 'validateStatus',
];

export default defineComponent({
  name: 'CustomForm',
  props,
  emits: ['update:modelValue'],
  setup(props, { slots, emit, expose }: SetupContext) {
    const customFormRef = ref();

    const mValue = ref({ ...props.modelValue });

    watch(
      mValue,
      (newVal) => {
        emit('update:modelValue', newVal);
      },
      {
        immediate: true,
        deep: true,
      },
    );

    // 表单校验
    const validate = async () => {
      if (!customFormRef.value) return;
      return await customFormRef.value.validate();
    };

    // 表单重置
    const resetFields = () => {
      if (!customFormRef.value) return;
      customFormRef.value.resetFields();
    };

    // 暴漏方法
    expose({ validate, resetFields });

    // col 渲染
    const colRender = () => {
      return props.formData.map((i: FormItemProps) => {
        const formItemProps = { labelWidth: props.labelWidth, ...pick(i, elFormItemPropsKeys) };
        return (
          <ElCol {...i.colProps}>
            <ElFormItem {...formItemProps}>
              {i.formItemType === 'slot'
                ? renderSlot(slots, i.prop, { text: mValue.value[i.prop], props: { ...i } })
                : formItemRender(i, mValue.value)}
            </ElFormItem>
          </ElCol>
        );
      });
    };

    return () => (
      <ElForm ref={customFormRef} model={mValue} labelPosition={props.labelPosition}>
        <ElRow {...props.rowProps}>
          {colRender()}
          <ElCol>
            <ElFormItem labelWidth={props.labelWidth}>{renderSlot(slots, 'action')}</ElFormItem>
          </ElCol>
        </ElRow>
      </ElForm>
    );
  },
});
<script setup lang="ts">
  import CustomerForm from '/@/components/CustomForm';
  const data = ref([
    {
      formItemType: 'input',
      prop: 'name',
      label: 'Activity name',
      placeholder: 'Activity name',
      rules: [
        {
          required: true,
          message: 'Please input Activity name',
          trigger: 'blur',
        },
        { min: 3, max: 5, message: 'Length should be 3 to 5', trigger: 'blur' },
      ],
    },
    {
      formItemType: 'select',
      prop: 'region',
      label: 'Activity zone',
      placeholder: 'Activity zone',
      options: [
        {
          label: 'Zone one',
          value: 'shanghai',
        },
        {
          label: 'Zone two',
          value: 'beijing',
        },
      ],
    },
    {
      formItemType: 'inputNumber',
      prop: 'count',
      label: 'Activity count',
      placeholder: 'Activity count',
    },
    {
      formItemType: 'date',
      prop: 'date',
      label: 'Activity date',
      type: 'datetime',
      placeholder: 'Activity date',
    },
    {
      formItemType: 'radio',
      prop: 'resource',
      label: 'Resources',
      options: [
        { label: 'Sponsorship', value: '1' },
        { label: 'Venue', value: '2' },
      ],
    },
    {
      formItemType: 'checkbox',
      prop: 'type',
      label: 'Activity type',
      options: [
        { label: 'Online activities', value: '1', disabled: true },
        { label: 'Promotion activities', value: '2' },
        { label: 'Offline activities', value: '3' },
        { label: 'Promotion activities', value: '4' },
        { label: 'Simple brand exposure', value: '5' },
      ],
    },
    {
      formItemType: 'input',
      prop: 'desc',
      type: 'textarea',
      label: 'Activity form',
      placeholder: 'Activity form',
    },
    {
      formItemType: 'slot',
      prop: 'test',
      label: 'slot',
    },
  ]);
  const model = reactive({
    name: '',
    region: '',
    count: 0,
    date: '',
    resource: '',
    type: [],
    desc: '',
    test: '1111',
  });
  const formRef = ref();
  const submitForm = () => {
    const valid = formRef.value.validate();
    if (valid) {
      console.log(model);
    } else {
      return false;
    }
  };

  const resetForm = () => {
    formRef.value.resetFields();
  };
</script>

<template>
  <div class="wrap">
    <CustomerForm
      ref="formRef"
      :v-model="model"
      :formData="data"
    >
      <template #test="scope">
        {{ scope.text }}
      </template>
      <template #action>
        <el-button type="primary" @click="submitForm()">Create</el-button>
        <el-button @click="resetForm()">Reset</el-button>
      </template>
    </CustomerForm>
  </div>
</template>

<style scoped>
  .wrap {
    margin: 30px auto;
    width: 600px;
    height: auto;
  }
</style>

问题现象

代码其实非常简单,运行起来也很正常很流畅😀😀😀,但是当我填写完表单后点击提交按钮,打印model的值时,发现值全没给上。

微信截图_20230709120015.png

原因分析

这里经过两年半的尝试,终于发现在定义model时,将const model = reactive({xxx}) 改为 const model = ref({xxx}) 后就正常了。思考了一下 ref 定义的对象,源码上最后通过 toReactive 还是被转化为 reactive,ref 用法上需要 .value, 数据上这两者应该没有什么不同。然后我就去把 reactive、ref 又看了看也没发现问题。在emit('update:modelValue', newVal) 处打印也是正常的。

watch( mValue,
    (newVal) => { 
        console.log('newVal>>>', newVal)
        emit('update:modelValue', newVal);
    },
    { immediate: true, deep: true, }
);

最后有意思的是,我把 const model 改成 let model tmd居然也正常了,这就让我百思不得其解了😕😕😕

解决

其实上面 debugger 后,就确定了方向 肯定是emit('update:modelValue', newVal)这里出问题了,回到使用组件,把v-model 拆解一下,此时还看不出来问题。

1688879457596.jpg

换成:modelValue="model" @update:model-value="update(e)"问题立马出现了,ts已经提示了 model是常量!

微信截图_20230709131250.png

这样问题就非常明了了,这就解释了 let 可以 const 不行,但你好歹报个错啊 😤😤😤 坑死人不偿命,可见即使在 template 里面这样写@update:model-value="model = $event" ts 也无能为力! 回过头再来看看 ref 为啥可行呢?当改成ref时,

  const update = (e) => {
    model.value = e;
  };

update是要.value 的,修改常量对象里面属性是正常的。再想想 ref 的变量在 template 中 vue 已经帮我们解过包了,v-model 语法糖拿着属性直接赋值并不会产生问题。而常量 reactive 则不能修改,也可以在在里面再包裹一层对象,但这样就有点冗余了。

总结

总结起来就是,const 定义的 reactive 对象,v-model 去更新整个对象的时候失败,常量不能更改,也没有给出任何报错或提示!

唉!今年太难了。前端路漫漫其修远兮,还需更加卷地而行!😵😵😵