上周处理工单时收到一张客户截图:el-upload 组件把附件传上去之后,鼠标挪到文件名上,页面上就弹出一句“按Delect键可删除”。客户以为是我们写的文案,语气里带着疑惑和嫌弃,要求必须去掉。我第一反应是去项目里搜索这串文字,结果整个业务代码里根本不存在。后来跑到 node_modules 里翻 Element Plus 的语言包,才明白这是 el-upload 自带的键盘操作提示,是一个“会主动宣传自己”的无障碍功能,不是 bug,也不是谁偷偷加的文案。
这篇文章就干三件事:第一,揪出提示的来历,顺带纠正一下拼写——那个键叫 Delete,不是 Delect;第二,给出去掉、改写、完整替换提示的三种可行方案;第三,把从按下 Delete 键到 on-remove 回调之间的完整删除链路拆开,讲讲文档里没写的那些坑。不管你是第一次接 el-upload,还是已经被它折腾过几轮,看完应该都能少踩一两个雷。
1. 提示从哪来:el-upload 的键盘删除能力与文案来源
1.1 一次无障碍优化带来的陌生体验
el-upload 在较新的 Element Plus 2.x 版本里加入了键盘操作支持:文件列表中的每一项都可以被聚焦,获得焦点后按 Delete 键就能直接删除当前文件。这个能力对只能靠键盘操作页面的用户来说非常实用,但组件为了让普通鼠标用户也知道“这里藏了一个快捷键”,就在悬停或聚焦文件项时把提示文案显示了出来。
关键在于,这个文案并不是我们业务代码里写的,而是组件内置语言包里的一个字段。当时我在 node_modules 里搜“Delete 键”,路径大致是element-plus/es/locale/lang/zh-cn.mjs,在 upload 对象下面找到了deleteTip,默认值就是“按 Delete 键可删除”。这也是为什么整个项目里搜不到这串字——它被打包在组件库里,而不是在你的业务代码里。
很多人没见过这个提示,其实是版本差异。如果你的项目 Element Plus 版本比较旧,或者升级后样式没有跟上,这个悬停提示可能就不会出现。换句话说,客户截图里的“怪东西”,恰好证明他的项目用的是较新的版本,反而不需要太担心组件自身有 bug。
1.2 “Delect”其实是 Delete:先纠正拼写再排错
标题里写的“按Delect键可删除”,客户截图里的真实文案大概率是“按 Delete 键可删除”。Delect 是 Delete 的常见误拼,读音相近,字形也像,尤其是大写 D 后面跟着一串小写字母,隔着截图确实容易看岔。
这里有个排查心得很重要:当产品或客户用一个看起来不存在的单词来反馈问题时,别急着否定对方的需求,更别直接搜代码。先打开页面实际悬停一次,看清原样文案,确认拼写,再决定下一步。我在开发群里见过因为“Delect 是什么键”吵了一下午,最后发现就是 Delete 键的案例,挺尴尬的。
如果你收到这类工单,最快的确认方式是让客户按一下键盘上的 Delete 键,如果文件真的被删了,那就说明提示描述的功能是真实存在的,剩下的事只是“要不要让用户看到这句话”。
1.3 定位提示的 DOM 形态,再决定用哪种方案
要去掉或改写提示,先得搞清楚它在 DOM 里长什么样。打开 DevTools 的 Elements 面板,点击右上角的:hover模拟悬停图标,然后选中文件列表项,重点看两个地方:
- 目标元素上有没有
title属性。如果title的值就是“按 Delete 键可删除”,说明这是浏览器原生提示。原生 title 提示没法用 CSS 隐藏,只能通过修改属性值或使用插槽消除。 - 页面根部有没有出现
.el-popper浮层。如果提示是被渲染成 Tooltip 浮层的,CSS 可以隐藏,或者用组件配置关闭。
两种形态的处理方式完全不同,这也是我坚持“先定位再动手”的原因。网上很多帖子直接给 CSS 方案,但你的版本里提示可能根本不是 Tooltip 浮层,照抄必然无效。
| 提示形态 | DOM 特征 | 能否用 CSS 隐藏 |
|---|---|---|
| 原生 title | 目标元素上挂着title="按 Delete 键可删除" | 不能,需清空或改写属性 |
| Tooltip 浮层 | body 下出现.el-popper,带箭头样式 | 可以,加限定作用域隐藏 |
| 伪元素 content | ::after的 content 包含文案 | 可以,覆盖 content 或使用 visibility |
这三种形态我在不同小版本里都遇见过,所以排查这一步省不了。花两分钟确认 DOM 结构,比在错误的方向上折腾一小时强得多。
2. 按需求选方案:去掉、改写、替换既有提示的三种做法
2.1 方案一:从 locale 入口改 deleteTip 文案
如果你只是不想让客户看到那句“按 Delete 键可删除”,最干净的办法是覆盖语言包里的deleteTip字段,而不是去改 node_modules 源码或写一堆 CSS hack。
全局引入时,在入口文件做一次覆盖:
import { createApp } from 'vue' import ElementPlus from 'element-plus' import zhCn from 'element-plus/es/locale/lang/zh-cn' zhCn.upload = { ...zhCn.upload, deleteTip: '支持 Delete 键删除文件', } const app = createApp(App) app.use(ElementPlus, { locale: zhCn })按需引入时同样适用,只是挂载方式不同:
import { ElUpload } from 'element-plus' import zhCn from 'element-plus/es/locale/lang/zh-cn' zhCn.upload.deleteTip = '支持 Delete 键删除文件' app.use(ElUpload, { locale: zhCn })这里有一个容易被忽略的点:deleteTip关联的并不只有悬停提示,部分版本里它还作为删除按钮的 aria-label,屏幕阅读器读屏时会用到。直接把它清空成空字符串,视觉上确实看不到提示了,但读屏信息也会跟着缺失,对无障碍体验是负面影响。所以我更建议改成一句引导性的文案,比如“支持 Delete 键删除文件”,而不是彻底清空。我在一次 QA 验收里就因为清空这个字段被 NVDA 读屏漏报功能,差点变成无障碍事故。
2.2 方案二:针对 Tooltip 形态的 CSS 兜底
如果覆盖 locale 后提示还在,说明该版本内部可能不是用语言包渲染title属性,而是单独用 Tooltip 实现。确认 DOM 里出现.el-popper后,可以用样式隐藏:
.el-upload-list__item:hover .el-popper { display: none; }必须强调:.el-popper是全局复用的浮层类,页面里所有基于 ElTooltip 的浮层都会挂这个类。不加限定选择器直接隐藏.el-popper,会导致项目其他 Tooltip 全部消失。上面的写法把范围缩在.el-upload-list__item的 hover 状态下,才能做到只影响上传列表的提示浮层。
如果你的 upload 组件本身挂了 popper-class,也可以单独控制:
<el-upload action="/api/upload" popper-class="upload-delete-tip" > </el-upload>.upload-delete-tip { display: none; }CSS 方案始终是兜底,不推荐作为首选。它只改变视觉,不改变语义和属性,而且很容易被后续版本升级打破。我的原则是:能用 locale 解决的,绝不用样式去掩盖。
2.3 方案三:用 #file 插槽彻底替换默认列表
如果项目对文件列表的视觉要求比较高,比如要展示缩略图、自定义文件类型图标、进度条样式等,与其和默认提示文案周旋,不如直接接管文件列表的渲染。el-upload 提供了#file插槽,可以让文件列表项完全由你控制:
<el-upload v-model:file-list="fileList" action="/api/upload" :on-remove="handleRemove" > <template #file="{ file }"> <div class="custom-file-item"> <div class="file-name">{{ file.name }}</div> <div class="file-status"> <span v-if="file.status === 'uploading'">上传中 {{ file.percentage }}%</span> <span v-else-if="file.status === 'success'">上传成功</span> <span v-else>上传失败</span> </div> <el-icon class="remove-icon" @click="handleOwnRemove(file)"> <Close /> </el-icon> </div> </template> <el-button type="primary">选择文件</el-button> </el-upload>使用#file插槽后,默认列表项的 DOM、title 提示、内置删除按钮全部不会渲染,自然不会再出现“按 Delete 键可删除”。但代价也很明显:组件的内置键盘删除、进度条动画、上传中取消能力一并丢失,这些都需要自己补齐。
两种路线的取舍可以这样看:
| 对比维度 | locale 覆盖 | CSS 隐藏 | #file 插槽 |
|---|---|---|---|
| 改动成本 | 低(几行代码) | 低(几行样式) | 中(需要重写 UI) |
| 提示是否还有 | 改写后的文案 | 视觉消失 | 完全无效 |
| 键盘删除功能 | 保留 | 保留 | 需自己实现 |
| 后续升级风险 | 低 | 中(依赖 DOM 结构) | 低 |
实战中我的建议是:只是想安静点,就用 locale 覆盖;想把删除入口彻底重做,就用#file插槽;CSS 隐藏只当临时应急手段。
3. 删除链路拆解:从 Delete 键按下到 on-remove 回调之间发生了什么
3.1 删除触发方式:点击按钮与键盘 Delete 的差异
删除链路值得单独拆开讲,因为很多看似“组件 bug”的问题,其实都出在对这条链路的理解偏差上。在 el-upload 中,删除一个文件有两条触发路径:
- 鼠标点击文件项上的删除图标,触发 click 事件。
- 文件列表项聚焦后按 Delete 键,组件内部监听 keydown 事件,触发同样的删除流程。
两条路径最终会汇合到同一个处理函数。完整顺序是:先检查 disabled 状态,再执行 before-remove,若返回 false 或 Promise resolve(false) 则中止;否则移除对应 UploadFile 对象,触发 on-remove,最后触发 change。理解这个顺序,能解决很多“我明明在 on-remove 里改数据却不生效”的问题。
值得说明的是,键盘路径同样受 disabled 和 before-remove 控制,不是绕过拦截的后门。所以我会建议团队把二次确认统一放在 before-remove,而不是在删除按钮的 click 里写一层、键盘路径再写一层。前者一次就覆盖所有入口,后者必然漏掉某个入口。
3.2 before-remove:二次确认的正确写法与返回值陷阱
before-remove接收两个参数,分别是即将被删除的文件对象(uploadFile)和当前完整的文件列表(uploadFiles)。它的返回值可以是同步布尔值,也可以是 Promise。最常见的二次确认写法:
import { ElMessageBox } from 'element-plus' const handleBeforeRemove = (file) => { return ElMessageBox.confirm(`确定删除文件「${file.name}」吗?`, '删除确认', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning', }) .then(() => true) .catch(() => false) }这里有一个高频坑:.catch(() => false)在部分老版本中,即使返回 false,Promise 链上仍会被视为 rejected,控制台会报“Uncaught (in promise)”。稳妥的做法是在 catch 中显式return false并避免再次抛出异常。另外,如果弹窗点取消时希望保留文件,catch 里必须返回 false;如果误写成了catch(() => true),点取消也会把文件删掉,这个问题相当隐蔽,上线前一定要自测取消分支。
3.3 on-remove 的参数含义与列表同步细节
on-remove同样收到两个参数:第一个是被删掉的文件对象,第二个是删除后剩余的文件列表。注意,第二个参数不是原数组的引用,而是内部处理后的新数组。在使用v-model:file-list的前提下,组件内部会同步更新绑定的列表,你不需要手动再做fileList.value = uploadFiles。
有一种情况必须手动处理:如果你没有使用v-model:file-list,而是自己管理列表数据,可以这样做:
const handleRemove = (file, uploadFiles) => { fileList.value = uploadFiles.slice() }还有一类隐蔽问题与自定义上传函数http-request有关。当用户在上传过程中就删除文件时,组件列表项虽然移除了,但底层的 XMLHttpRequest 或 fetch 请求还在跑,进度回调回来可能又往列表里塞数据,或者触发重复渲染。处理方法是把请求中断能力挂到文件对象上,在before-remove阶段提前取消:
const handleHttpRequest = (options) => { const controller = new AbortController() options.file._abort = () => controller.abort() return fetch(options.action, { method: 'post', body: options.data, signal: controller.signal, }) .then((res) => options.onSuccess(res)) .catch((err) => { if (err.name === 'AbortError') return options.onError(err) }) }然后在before-remove里调用:
const handleBeforeRemove = (file) => { if (typeof file._abort === 'function') { file._abort() } return ElMessageBox.confirm(...) }这个细节在官方文档里没有展开,但遇到“删除文件后列表又自己冒出来”的经典灵异问题时,往往就是这里没处理干净。
4. 我踩过的三个坑与最终落地的交互方案
4.1 坑一:悬停提示与删除按钮重叠,鼠标一滑就误删
客户反馈的另一个高频问题:鼠标在文件名上停留想看看路径,结果因为删除按钮紧挨着提示文字,稍微一抖就点到了删除。第一次遇到时,我们临时给 before-remove 加了确认弹窗,但产品群里很快又全是“这个文件我没有权限删,为什么还让我确认”的抱怨。
后来我们改成了更克制的交互:删除图标默认不可见,只有悬停到文件项右侧的专门操作区时才出现,并且给出现动画加了一点延迟;同时统一保留二次确认。这段经历的核心心得是:el-upload 默认的“悬停即出现删除按钮”并不适合所有业务场景,尤其是合同、单据这类误删成本高的场景,务必提前加固。
4.2 坑二:外部键盘监听与内置 Delete 删除冲突
为了支持“Enter 预览、Delete 删除”的快捷操作,我们在页面上加过一个全局 keydown 监听。上线后测试发现,焦点停留在文件列表时按一次 Delete,删除逻辑竟然执行了两次,后端接口直接报重复删除。
排查后发现原因:全局 keydown 触发了我们自己的删除逻辑,同时事件继续冒泡到组件内部,又触发了 el-upload 内置的 Delete 删除。两条路径各自执行了一次,自然重复。解决办法是在外部监听里判断事件源是否位于 el-upload-list 内部:
const handleGlobalKeydown = (e) => { if (e.key === 'Delete' && e.target.closest('.el-upload-list__item')) { return } // ... 自己的快捷删除逻辑 }因为 el-upload 自己已经在处理 Delete 键了,外部监听完全没必要重复接管。这个坑让我养成一个习惯:任何第三方组件自带的键盘行为,接入前先查一遍它的能力,再决定是否要自定义。
4.3 坑三:大列表下的删除性能与 file-list 更新时序
最后一个坑出现在有几百条文件记录的后台页面。删除其中一条,整个列表出现明显的卡顿和闪烁。一开始怀疑是组件内部开销,后来用 Performance 面板录制才发现,问题出在我们自己在 on-remove 里又执行了一次fileList.value = uploadFiles,等于删除操作完成之后又强制触发了一次全量列表替换,白白增加了渲染负担。
在已经使用v-model:file-list时,on-remove 里不要再重新给列表赋新数组。如果需要联动其他数据,比如附件数量、表格汇总,放到 nextTick 里处理:
const handleRemove = (file, uploadFiles) => { nextTick(() => { attachmentCount.value = uploadFiles.length }) }如果列表大到上千条,el-upload 本身并没有内置虚拟滚动,越长的列表操作起来越顿。遇到这种场景,建议列表部分不要全部走 el-upload 的文件列表渲染,而是用#file插槽实现简易的表格或卡片列表,再结合分页来展示。
4.4 最终在项目里落地的交互方案
把这些经验沉淀后,我们项目的上传/删除交互最终长这样:
- 提示文案通过 locale 覆盖为“支持 Delete 键删除文件”,保留键盘能力,但不再用命令式语气打扰鼠标用户;
- 删除入口仅在文件项悬停到右侧操作区时出现,且始终经过 before-remove 二次确认;
- 自定义 http-request 中挂载 AbortController,在 before-remove 里取消未完成的上传请求;
- 所有文件列表项使用
:key="file.uid"而不是 index,避免删除中间项时状态错乱。
这套方案经历了几次小版本升级,表现稳定,基本没有再收到过“奇怪提示”或“误删”的工单。
最后说个实用小技巧:以后遇到 Element Plus 里来路不明的文案,直接在项目里搜node_modules/element-plus/es/locale/lang/zh-cn.mjs,用关键字一搜基本就能定位到语言包对应字段,改起来比写样式干净得多。至于“Delect”这个拼写,我现在看到都会心一笑,客户大概是顺着大写 D 往下念,把后面一串看岔了。如果你也遇到有人拿这个拼写来反馈问题,先别急着改代码,让他按一下键盘上的 Delete 键试试,很多“需求”其实是误会。我个人感受是:键盘删除这功能留着挺好,改一下措辞、加一道确认,比直接阉割掉要更稳妥。