先给结论:YsEditableSelect的基础用法一个组件标签就能跑,粘进任何 Vue3 + Element Plus 项目都行。一个文件、两处代码:
<!-- 组件:一个 v-model 绑值,一个 options 给候选项 --> <YsEditableSelect v-model="form.role" :options="roleOptions" placeholder="请选择或输入" />// 脚本:引入组件(本组件不注册全局,用哪引哪)importYsEditableSelectfrom'@/components/YsEditableSelect/index.vue';// 候选项:有 value / label 两个字段就够constroleOptions=[{value:'admin',label:'管理员'},{value:'user',label:'普通用户'},];是的,就这么多。它不需要你装插件、改配置、挂全局,一个组件文件导入即可。真正花时间的从来不是"用起来",而是"用对"——尤其是编辑态回显这种使用姿势上的坑。这篇文章从最简用法讲起,一路讲到项目里那个真实的菜单权限设置页,把配置项、事件、以及回显坑的解法一次说透。
一、先把最简单的跑起来
如果你只想看看这控件长什么样,下面这段是一个完整可运行的页面组件,不依赖任何后端接口,直接丢进路由里就能看效果:
<template> <div style="width: 360px; margin: 40px auto;"> <h3>可编辑下拉选择 示例</h3> <!-- 核心就这一行组件 --> <YsEditableSelect v-model="selected" :options="options" placeholder="请选择或输入权限名" allow-custom @change="onChange" @add-custom="onAddCustom" /> <p style="margin-top: 16px; color: #666;"> 当前值:<b>{{ selected || '(空)' }}</b> </p> </div> </template> <script setup> import { ref } from 'vue'; import YsEditableSelect from '@/components/YsEditableSelect/index.vue'; // 选中的值:组件会通过 v-model 回写到这里 const selected = ref(''); // 选项列表:有 value/label 两个字段即可 const options = ref([ { value: '用户管理-新增', label: '用户管理-新增' }, { value: '用户管理-编辑', label: '用户管理-编辑' }, { value: '用户管理-删除', label: '用户管理-删除' }, { value: '用户管理-导出', label: '用户管理-导出' }, ]); // 值变化时回调:拿到完整选项对象 const onChange = (option) => { console.log('选中项:', option); }; // 用户点"添加 xxx"时触发:这里可以决定要不要把新词存起来 const onAddCustom = (val) => { console.log('用户想新增:', val); // 例如把它塞进选项列表,下次就能直接选 options.value.push({ value: val, label: val }); }; </script>跑起来你会看到:点输入框弹出四个选项,敲"编辑"列表只剩一条,敲"新增权限"这种库里没有的词,列表最底下会冒出一个"添加 “新增权限”",点它就把它当作值选中了。allow-custom这行是开关,去掉它就只能选、不能造。
二、真实场景:菜单权限设置页
光看玩具例子不够,我们来看项目里的真实用法。开源版的菜单管理里有个"权限设置"弹窗(ys-vue-open/src/views/system/menu/auth.vue),用来给某个菜单挂按钮级权限。它的表单里有这么一栏——“权限名称”。
为什么权限名称要用可编辑下拉?因为权限命名有一套约定俗成的格式:所属菜单-操作。比如给"用户管理"菜单配权限,规范名就是用户管理-新增、用户管理-编辑这一系列。系统应该把这些规范名当选项推给用户,减少手抖写错;但又不该写死——万一有个特殊权限叫别的名字,也得能填。这正是可编辑下拉的典型场景。
auth.vue 里这段模板长这样:
<el-form-item label="权限名称" prop="title"> <YsEditableSelect v-model="state.ruleForm.title" <!-- 双向绑定表单里的 title 字段 --> ref="editableSelectRef" <!-- 拿组件实例,后面要调 reset --> :options="state.optionTitles" <!-- 选项:按菜单动态生成 --> placeholder="请选择或输入" allow-custom <!-- 允许自定义 --> /> </el-form-item>注意这里的选项不是写死的,而是跟着当前菜单动态生成的:
// 打开弹窗时根据父菜单名生成规范权限选项consttitle=state.ruleForm.parentName+'-';state.optionTitles=[{value:title+'新增',label:title+'新增'},{value:title+'编辑',label:title+'编辑'},{value:title+'删除',label:title+'删除'},{value:title+'查看',label:title+'查看'},{value:title+'导入',label:title+'导入'},{value:title+'导出',label:title+'导出'},];如果当前是给"用户管理"配权限,parentName就是"用户管理",生成的选项就是用户管理-新增到用户管理-导出六条。换个菜单,选项自动跟着变。选项来源可以是任何你能算出来的数组——静态常量、接口返回、或像这样根据上下文拼出来的,组件一视同仁。
三、参数配置速查表
用之前把六个参数过一遍,配置时就不用翻源码了:
| 参数 | 类型 | 默认值 | 什么时候用它 |
|---|---|---|---|
v-model | String / Number / Object | '' | 永远是它,绑表单字段 |
options | Array | [] | 提供候选项,必传 |
placeholder | String | 请选择或输入 | 想改提示文案时 |
allow-custom | Boolean | false | 需要"能填库外新值"时打开 |
value-key | String | value | 选项的"值"字段不叫 value 时 |
label-key | String | label | 选项的"显示"字段不叫 label 时 |
三个高级参数各有一个典型使用场景:
allow-custom:只要你的字段"推荐值 + 允许自由填",就打开它。不开的话,用户碰到库里没有的值就彻底没辙。
value-key/label-key:当你的选项来自后端、字段名不是标准的value/label时用。比如后端返回的是{ dictCode, dictName },你这么配:
<!-- 直接吃后端的原始字段,不用手动 map 一遍 --> <YsEditableSelect v-model="form.status" :options="dictList" value-key="dictCode" label-key="dictName" />// 后端原样返回,无需转换constdictList=awaituseDictApi().getDetailByCode('common_status');// [{ dictCode: '1', dictName: '启用' }, { dictCode: '0', dictName: '禁用' }]这比"先map成{value,label}再传"省一步,也不容易在字段映射上写错。
四、事件与 reset 方法
组件对外抛三个事件、暴露一个方法,用一张表记住它们的分工:
| 事件 / 方法 | 什么时候触发 | 拿到什么 | 用来干什么 |
|---|---|---|---|
@change | 选中或自定义输入 | 选项对象 | 值变化时做联动 |
@add-custom | 点击"添加 xxx" | 输入字符串 | 决定是否落库/入列 |
@update:modelValue | 值变化 | 新值 | 一般由v-model自动处理,不用手写 |
reset()(通过 ref) | 手动调用 | — | 清空组件显示状态 |
change最常用的场景是"选中后联动其它字段"。比如选了某个权限,自动把"权限标识"也填上:
constonPermissionChange=(option)=>{// option 是完整选项对象,可以拿到它的值做推断if(option&&option.value){state.ruleForm.permission=option.value.replace(/-/g,':');}};add-custom则适合"用户造了个新词,顺手存起来"的场景,前面示例里演示过。
五、编辑态回显:那个绕不过去的坑
现在讲这组件使用上最容易踩、也最值得单独拎出来说的一个坑——编辑态打开时,输入框里的值不刷新。
5.1 坑是怎么出现的
先理解组件内部机制:组件里有个内部显示变量searchQuery,它靠watch(modelValue)同步。也就是说,只有modelValue发生变化,显示才会更新。平时这没问题。但在"新增/编辑共用一个弹窗"的模式下,问题就来了:
弹窗被反复打开。这次编辑 A 记录,title显示用户管理-新增;关掉;下次编辑 B 记录,如果 B 的title恰好也是用户管理-新增(或者 B 该字段为空、而组件里残留着上次的值),modelValue没发生"变化",watch就不触发,输入框里显示的可能是上一次的旧内容。表现就是——你明明点了编辑 B,权限名称框里却显示着 A 的值,或者显示空白但其实是脏数据。
这个坑的根源不在组件有 bug,而在"弹窗实例被复用、modelValue未必每次都变"。这不是YsEditableSelect独有的问题,所有"内部缓存显示态"的控件(包括el-select在某些场景下)都会遇到。
5.2 项目里怎么解的
看看 auth.vue 的真实处理方式——打开弹窗时,主动调组件的reset()清干净,再重新赋值:
// 打开弹窗并重置表单constopenDialog=(type,row)=>{// 先重置整个表单数据state.ruleForm={...initialForm};state.dialog.type=type;if(type==='edit'){state.ruleForm={...row};state.ruleForm.parentName=row.pidName;state.ruleForm.pid=row.pid;state.ruleForm.title=row.title;// 先设一次值}elseif(type==='add'){state.ruleForm.parentName=row.title;state.ruleForm.pid=row.id;}// 生成选项(略)state.dialog.isShowDialog=true;// 关键:等 DOM 更新后,重置组件内部状态nextTick(()=>{if(editableSelectRef.value){// 第一步:把组件内部显示清空editableSelectRef.value.reset();// 第二步:编辑态下,强制触发一次 v-model 更新让显示重新跟上if(type==='edit'){state.ruleForm.title='';// 先置空(制造一次"变化")nextTick(()=>{state.ruleForm.title=row.title;// 再赋回真值,watch 必然触发});}}});};这段代码里有三个动作,每个都有它的必然性:
动作一:reset()。先把组件内部的searchQuery清空,把"上一次残留"抹掉。这一步保证无论后面怎样,起始显示都是干净的。
动作二:state.ruleForm.title = ''再赋回真值。这是解决"值没变所以 watch 不触发"的核心技巧:先把它变成空(这是一次真实的modelValue变化,watch会触发、把显示清空),nextTick后再赋回真值(又一次变化,watch再次触发、把显示更新成目标值)。通过这样"抖一下",强行让watch跑起来。虽然绕,但很有效。
动作三:整个流程放在nextTick里。因为弹窗、组件的显示要等 Vue 把 DOM 更新完;在 DOM 更新前操作组件实例,拿到的可能是还没就绪的引用。放进nextTick保证万无一失。
说句实话,这套"先置空再赋回 + reset"的写法读起来不优雅,它是被"弹窗复用 + 值不变"这个现实逼出来的补丁。如果你能接受稍微改一下组件,其实有个更干净的方案,见下一节。
5.3 更干净的解法:用 key 强制重挂载
如果你不想在业务里写那段"抖一下"的代码,可以在组件上加一个:key,让它在弹窗每次打开时重新创建:
<!-- dialogKey 每次打开弹窗自增,组件就会销毁重建,内部状态天然是干净的 --> <YsEditableSelect v-model="state.ruleForm.title" :options="state.optionTitles" :key="dialogKey" allow-custom />constdialogKey=ref(0);constopenDialog=(type,row)=>{dialogKey.value++;// 每次打开都换 key,触发组件重建// ... 其余逻辑};原理很简单:Vue 看到key变了,就销毁旧组件、创建一个全新的。新组件的watch带immediate: true,会用最新的modelValue初始化显示,不存在残留。代价是组件重新挂载的轻微开销,换来的是"永远干净"的初始态。对于这种轻量控件,这点开销可以忽略,比手动reset + 抖一下可读性好得多。两种解法都行,看你团队的口味——要"不改组件、纯业务侧解决",用前一节的;要"代码干净、可读性优先",用key。
六、进阶:选项来自后端接口
前面例子的选项都是本地数组。真实项目里,选项常来自字典或枚举接口。开源版里字典明细的接口是/api/system/dictdetail/code/{dictCode},枚举是/api/enum/getEnumValue/{code}。接进来非常自然:
import{useDictApi}from'@/api/system/dict';constpositionOptions=ref([]);// 页面挂载时拉一次字典选项constloadPositions=async()=>{constres=awaituseDictApi().getDetailByCode('cert_type');// 后端返回 [{ code, name, ... }],用 value-key/label-key 直接对接positionOptions.value=res.data;};<!-- 键名不是 value/label,用 value-key、label-key 声明即可 --> <YsEditableSelect v-model="form.certType" :options="positionOptions" value-key="code" label-key="name" placeholder="请选择或输入证件类型" />这里建议在页面挂载时就把选项拉好、存起来复用,而不是每次打开弹窗都请求一遍——字典、枚举这类配置数据基本不变,多拉几次纯属浪费。如果项目里有 Pinia 缓存字典的做法(前面的字典模块文章聊过),直接读缓存更好。
七、什么时候该用它、什么时候不该
用了半天,也得说清楚边界,免得你用错地方:
该用它:
- 字段"有推荐值,但允许自由填"——权限名、标签、分组名。
- 选项来自后端、字段名非标准,想少做一次数据映射。
- 想要一个比
el-select更轻、更像输入框的视觉表现。 - 需要"用户新增了一个值"这个独立事件来触发落库。
不该用它:
- 纯选择、绝不允许自定义的场景——直接用
el-select更省心,别自研。 - 多选标签(
tags模式)——它是单选控件,做不了。 - 选项成百上千、需要远程搜索——它没有虚拟滚动和远程加载,会卡。
- 要严格的选项约束(比如必须从字典里选)——可编辑反而给了用户"造脏数据"的机会,关掉
allow-custom或用el-select。
八、几个容易忽略的注意事项
一、父容器别设overflow: hidden。下拉浮层是相对组件容器绝对定位的,父容器一裁,浮层就被切掉,表现为"点开但看不见列表"。这是浮层类组件的通用坑。
二、allow-custom打开后,输入内容是实时写进v-model的。如果你的表单对这个字段有watch做联动,要注意它在用户敲字过程中会频繁触发——必要时加防抖。
三、reset()只清显示,不清业务值。它把searchQuery清空,但v-model绑的值不动。所以想彻底重置,得同时重置父组件里的表单字段,别以为调个reset就万事大吉。
四、选项数组变了,显示不会自动重算成"选中项"。因为它只watchmodelValue。如果你动态改了options(比如换了字典),记得确认modelValue也同步更新,否则框里可能显示旧文本。
九、完整表单集成:和 el-form 校验怎么配合
实际项目里,可编辑下拉几乎不会单独存在,它总是嵌在el-form里、参与表单校验、跟着提交按钮走。把这一整块串起来,才算真正"会用"。
先看模板。要点是给它套一层el-form-item并指定prop,校验规则才能挂到它身上:
<el-form :model="form" :rules="rules" ref="formRef" label-width="90px"> <el-form-item label="权限名称" prop="title"> <!-- 组件本身不带校验,交给外层 el-form-item 管 --> <YsEditableSelect v-model="form.title" :options="optionTitles" allow-custom placeholder="请选择或输入" /> </el-form-item> <el-form-item label="权限标识" prop="permission"> <el-input v-model="form.permission" /> </el-form-item> </el-form>// 校验规则:required 挂在 title 上,触发时机用 change 或 blur 都行construles={title:[{required:true,message:'请输入或选择权限名称',trigger:'change'}],permission:[{required:true,message:'请输入权限标识',trigger:'blur'}],};constform=reactive({title:'',permission:''});// 提交前照常 await 校验constsubmit=async()=>{constvalid=awaitformRef.value?.validate();if(!valid)return;// ...调接口提交};这套配合能跑通的关键,在于组件会正常触发update:modelValue,让el-form感知到值的变化。因为组件在用户输入时(allowCustom打开的情况下)就会同步值,所以"敲完不点选项直接提交"也能通过校验;不会出现"输入框里明明有字、校验却说为空"的诡异情况。这也是可编辑下拉比"纯el-select+ 隐藏输入框"这套土办法更省事的根本原因——它天生就和表单的数据流对得上。
还有一个实践建议:如果这个字段的校验比较严(比如必须符合命名规范),把规则写成自定义validator,而不是简单的required。比如要求"必须包含一个短横线",就能在提交时拦下不规范的输入,比事后人工检查靠谱。校验逻辑写在rules里,组件完全不用改,职责边界很清楚。
十、常见问题速查
把使用中最高频的几个疑问集中答一下,省得你一个个试:
Q:为什么我传了options,点开却是空的?先看options是不是ref或reactive且确实有数据;再看选项对象有没有value/label字段(或你声明的value-key/label-key对应的字段)。最常见的原因是选项来自异步接口、但没等数据回来用户就点了——加个 loading 占位即可。
Q:allow-custom打开了,为什么还是不能填新值?检查两点:一是输入内容在过滤后是否命中了某个现有选项(命中了就不显示"添加"项,这是设计如此);二是父组件有没有正确接住v-model回写——如果父组件的表单字段是只读 computed 或没绑v-model,值回不来。
Q:编辑时值对了,但一失焦又变回旧的了?这几乎一定是modelValue在失焦时被别处逻辑覆盖了(比如父组件某个blur事件重新拉取数据覆盖了表单)。组件本身失焦不会改值,往父组件那边查。
Q:能在表格单元格里用吗?能,但它会渲染成输入框形态,表格行高和可编辑表格的交互要一起考虑;如果是大量行的可编辑表格,更推荐用表格自带的编辑单元格能力,而不是每格塞一个这个组件。
Q:想禁用某个选项怎么办?当前组件没有"单项禁用"的能力,它只筛选、不标记禁用。如果确有必要,得改组件(在选项上支持disabled字段并跳过点击),这属于扩展而非配置。
十一、小结
YsEditableSelect的使用门槛很低:v-model加options就能跑,allow-custom一开就能造新值,value-key/label-key一配就能吃后端数据。真正的门道在"编辑态回显"那个坑上——根因是组件靠watch(modelValue)同步显示,而弹窗复用会让modelValue未必每次都变。解法有两条:业务侧reset() + 先置空再赋回(项目现状),或给组件加:key强制重挂载(更干净)。知道问题出在哪,你就能自己选。
开源地址:
Gitee: https://gitee.com/lqclf/ys-lowcode-open
GitHub:https://github.com/lqclf/ys-code-ai-open