如何在 Element UI 表单中使用自定义组件

2,578 阅读2分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

在 Element UI 中使用表单相关组件,比如 <el-input><el-select>,可以通过在 <el-form><el-form-item> 中设置表单属性,实现子组件 sizedisabled 等属性设置,以及表单校验功能。

那么自定义的组件如何实现这些功能?

业务背景

最近产品给我提个一个需求,创建商品的时候,给商品增加标签,标签不仅需要名字,还需要颜色。于是我写了个组件。

▶ 点击查看代码
<template>
    <div class="tag-input">
        <div :class="['tag-list', size, 'disabled']" v-if="disabled">
            <el-tag v-for="(item, index) in tagList"
                    :size="size"
                    class="tag-item"
                    :key="item.name"
                    :color="item.color">
                {{ item.name }}
            </el-tag>
        </div>

        <el-popover placement="bottom-start" @show="onShow" v-model="visible" v-else>
            <div style="width: 100%">
                <div style="display: flex;margin-bottom: 10px;">
                    <el-input v-model="newTag.name"
                              ref="tagInput"
                              style="width: 200px; margin-right: 16px;"
                              placeholder="填写标签名称"
                              size="mini"
                    />
                    <el-color-picker v-model="newTag.color" size="mini" />
                </div>
                <div style="text-align: right">
                    <el-button size="mini" type="primary" @click="addTag">添 加</el-button>
                </div>
            </div>

            <div :class="['tag-list', size]" slot="reference">
                <el-tag v-for="(item, index) in tagList"
                        :size="size"
                        @close="onClose(index)"
                        class="tag-item"
                        :key="item.name"
                        :color="item.color"
                        closable>
                    {{ item.name }}
                </el-tag>
            </div>
        </el-popover>
    </div>
</template>

<script>
    export default {
        name: 'TagInput',
        props: {
            value: {
                type: Array,
            },
            disabled: {
                type: Boolean,
                default: false,
            },
            size: {
                type: String
            }
        },
        data() {
            return {
                newTag: {
                    name: undefined,
                    color: undefined,
                },
                tagList: this.value,
                visible: false,
            };
        },
        watch: {
            value(val) {
                if (val !== this.tagList) {
                    this.tagList = val;
                    this.newTag = {
                        name: undefined,
                        color: undefined,
                    };
                }
            }
        },
        methods: {
            onShow() {
                this.$nextTick(() => {
                    this.$refs.tagInput.focus();
                });
            },
            addTag() {
                if (!this.newTag.name) {
                    return;
                }
                this.tagList.push(this.newTag);
                this.newTag = {
                    name: undefined,
                    color: undefined,
                };
                this.onChange();
                this.visible = false;
            },
            onClose(index) {
                this.tagList.splice(index, 1);
                this.onChange();
            },
            onChange() {
                this.$emit('change', this.tagList);
                this.$emit('input', this.tagList);
            }
        }
    }
</script>

<style scoped>
    .tag-list {
        border: 1px solid #DCDFE6;
        border-radius: 4px;
        padding: 3px 6px;
        cursor: pointer;
        min-height: 36px;
    }
    .tag-list.large {
        min-height: 40px;
    }
    .tag-list.medium {
        min-height: 36px;
    }
    .tag-list.small {
        min-height: 32px;
    }
    .tag-list.mini {
        min-height: 28px;
    }
    .tag-list.disabled {
        background: #F5F7FA;
        cursor: not-allowed;
    }
    .tag-item:not(:last-child) {
        margin-right: 4px;
    }
</style>

展示一下组件的使用效果。

tag1.gif

size 和 disabled 属性的透传

Vue 有个较少用到的功能,provide 和 inject

这对选项需要一起使用,以允许一个祖先组件向其所有子孙后代注入一个依赖,不论组件层次有多深,并在其上下游关系成立的时间里始终生效。

不过注意其不能替代 vuex 使用,provide 和 inject 绑定并不是可响应的

在 ElForm 源码中可以看到其为子组件注入了 elForm 属性,传入了其本身。

export default {
    // ...
    name: 'ElForm',
    // ...
    provide() {
      return {
        elForm: this
      };
    },
}

在 ElFormItem 用同样的方式注入了 elFormItem 属性

export default {
    // ...
    name: 'ElFormItem',
    // ...
    provide() {
      return {
        elFormItem: this
      };
    },
}

于是在子组件可以获取这两个属性

export default {
    // ...
    inject: {
        elForm: {
            default: ''
        },
        elFormItem: {
            default: ''
        }
    },
}

然后在子组件中使用这些属性,把之前 sizedisabled 全部改为 tagInputSizetagInputDisabled 就可以在子组件中引用表单统一设置的属性。

其中 this.$ELEMENT 是引入 Element 时,传入的全局配置对象。

export default {
    // ...
    computed: {
        _elFormItemSize() {
            return (this.elFormItem || {}).elFormItemSize;
        },
        tagInputSize() {
            return this.size || this._elFormItemSize || (this.$ELEMENT || {}).size;
        },
        tagInputDisabled() {
            return this.disabled || (this.elForm || {}).disabled;
        }
    },
}

自定义组件的校验

在配置表单校验规则时,子组件触发指定事件(blurchange)时会触发表单项的校验。

阅读源码,发现 ElFromItem 监听了下面两个事件

this.$on('el.form.blur', this.onFieldBlur);
this.$on('el.form.change', this.onFieldChange);

onFieldBlur()onFieldChange() 中又会去执行校验逻辑。所以我们需要在子组件触发 el.form.blurel.form.change 事件。

我们引入 Element UI 中的 emitter,通过其定义的 dispatch函数触发事件。回到我们的组件,加入如下代码。

import emitter from 'element-ui/src/mixins/emitter';

export default {
    // ...
    mixins: [emitter],
    // ...
    methods: {
        // ...
        onChange() {
            // ...
            this.dispatch('ElFormItem', 'el.form.change', this.tagList);
        }
    }
}

其中 dispatch(componentName, eventName, params) 会向上寻找组件名为 componentName 的组件,并触发其 eventName 事件。

除此之外,还需要加一些对应样式,在校验失败时,组件会有红色边框,我们把组件和其他组件的错误样式统一。

.el-form-item.is-error .tag-input .tag-list {
    border-color: #F56C6C;
}
.el-form-item.is-error .tag-input .tag-list.disabled {
    border-color: #DCDFE6;
}

完整代码

▶ 点击查看代码
<template>
    <div class="tag-input">
        <div :class="['tag-list', tagInputSize, 'disabled']" v-if="tagInputDisabled">
            <el-tag v-for="(item, index) in tagList"
                    :size="tagInputSize"
                    class="tag-item"
                    :key="item.name"
                    :color="item.color">
                {{ item.name }}
            </el-tag>
        </div>

        <el-popover placement="bottom-start" @show="onShow" v-model="visible" v-else>
            <div style="width: 100%">
                <div style="display: flex;margin-bottom: 10px;">
                    <el-input v-model="newTag.name"
                              ref="tagInput"
                              style="width: 200px; margin-right: 16px;"
                              placeholder="填写标签名称"
                              size="mini"
                    />
                    <el-color-picker v-model="newTag.color" size="mini" />
                </div>
                <div style="text-align: right">
                    <el-button size="mini" type="primary" @click="addTag">添 加</el-button>
                </div>
            </div>

            <div :class="['tag-list', tagInputSize]" slot="reference">
                <el-tag v-for="(item, index) in tagList"
                        :size="tagInputSize"
                        @close="onClose(index)"
                        class="tag-item"
                        :key="item.name"
                        :color="item.color"
                        closable>
                    {{ item.name }}
                </el-tag>
            </div>
        </el-popover>
    </div>
</template>

<script>
    import emitter from 'element-ui/src/mixins/emitter';

    export default {
        name: 'TagInput',
        mixins: [emitter],
        inject: {
            elForm: {
                default: ''
            },
            elFormItem: {
                default: ''
            }
        },
        props: {
            value: {
                type: Array,
            },
            disabled: {
                type: Boolean,
                default: false,
            },
            size: {
                type: String
            }
        },
        data() {
            return {
                newTag: {
                    name: undefined,
                    color: undefined,
                },
                tagList: this.value,
                visible: false,
            };
        },
        computed: {
            _elFormItemSize() {
                return (this.elFormItem || {}).elFormItemSize;
            },
            tagInputSize() {
                return this.size || this._elFormItemSize || (this.$ELEMENT || {}).size;
            },
            tagInputDisabled() {
                return this.disabled || (this.elForm || {}).disabled;
            }
        },
        watch: {
            value(val) {
                if (val !== this.tagList) {
                    this.tagList = val;
                    this.newTag = {
                        name: undefined,
                        color: undefined,
                    };
                    this.dispatch('ElFormItem', 'el.form.change', this.tagList);
                }
            }
        },
        methods: {
            onShow() {
                this.$nextTick(() => {
                    this.$refs.tagInput.focus();
                });
            },
            addTag() {
                if (!this.newTag.name) {
                    return;
                }
                this.tagList.push(this.newTag);
                this.newTag = {
                    name: undefined,
                    color: undefined,
                };
                this.onChange();
                this.visible = false;
            },
            onClose(index) {
                this.tagList.splice(index, 1);
                this.onChange();
            },
            onChange() {
                this.$emit('change', this.tagList);
                this.$emit('input', this.tagList);
                this.dispatch('ElFormItem', 'el.form.change', this.tagList);
            }
        }
    }
</script>

<style scoped>
    .tag-list {
        border: 1px solid #DCDFE6;
        border-radius: 4px;
        padding: 3px 6px;
        cursor: pointer;
        min-height: 36px;
    }
    .tag-list.large {
        min-height: 40px;
    }
    .tag-list.medium {
        min-height: 36px;
    }
    .tag-list.small {
        min-height: 32px;
    }
    .tag-list.mini {
        min-height: 28px;
    }
    .tag-list.disabled {
        background: #F5F7FA;
        cursor: not-allowed;
    }
    .tag-item:not(:last-child) {
        margin-right: 4px;
    }
    .el-form-item.is-error .tag-input .tag-list {
        border-color: #F56C6C;
    }
    .el-form-item.is-error .tag-input .tag-list.disabled {
        border-color: #DCDFE6;
    }
</style>

测试效果

taginput.gif

Demo代码:

▶ 点击查看代码
<template>
    <div class="test-page">
        <div class="options-box">
            <el-form label-width="60px" size="mini">
                <el-form-item label="size">
                    <el-select v-model="options.size">
                        <el-option value="large"></el-option>
                        <el-option value="medium"></el-option>
                        <el-option value="small"></el-option>
                        <el-option value="mini"></el-option>
                    </el-select>
                </el-form-item>
                <el-form-item label="disabled">
                    <el-switch v-model="options.disabled"></el-switch>
                </el-form-item>
            </el-form>
        </div>

        <el-form :model="formData"
                 ref="formRef"
                 label-width="80px"
                 :size="options.size"
                 :disabled="options.disabled"
        >
            <el-form-item label="商品名称" prop="name" :rules="{
                  required: true, message: '请填写商品名称', trigger: 'blur'
              }">
                <el-input v-model="formData.name"></el-input>
            </el-form-item>
            <el-form-item label="商品标签" prop="tags" :rules="{
                  required: true, type: 'array', message: '请选择商品标签', trigger: 'change'
              }">
                <TagInput v-model="formData.tags" @change="onChange"></TagInput>
            </el-form-item>
            <el-form-item>
                <el-button @click="reset">重 置</el-button>
                <el-button @click="submit" type="primary">提 交</el-button>
            </el-form-item>
        </el-form>
    </div>
</template>

<script>
    import TagInput from './TagInput';

    export default {
        components: {
            TagInput
        },
        data() {
            return {
                formData: {
                    name: '',
                    tags: []
                },
                options: {
                    size: 'medium',
                    disabled: false,
                }
            };
        },
        methods: {
            reset() {
                this.$refs.formRef.resetFields();
            },
            submit() {
                this.$refs.formRef.validate().then(() => {
                    console.log(this.formData);
                    this.$message.success('创建成功~');
                });
            },
            onChange(v) {
                console.log(v)
            }
        }
    }
</script>

<style scoped>
    .test-page {
        background: #fff;
        padding: 20px;
        border: 1px solid #d7d7d7;
        border-radius: 5px;
        width: 400px;
    }
    .options-box {
        border: 1px solid #e9e9e9;
        margin-bottom: 24px;
        padding: 16px;
        background: #f6f6f6;
    }
</style>