
上周处理工单时收到一张客户截图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-classupload-delete-tip /el-upload.upload-delete-tip { display: none; }CSS 方案始终是兜底不推荐作为首选。它只改变视觉不改变语义和属性而且很容易被后续版本升级打破。我的原则是能用 locale 解决的绝不用样式去掩盖。2.3 方案三用 #file 插槽彻底替换默认列表如果项目对文件列表的视觉要求比较高比如要展示缩略图、自定义文件类型图标、进度条样式等与其和默认提示文案周旋不如直接接管文件列表的渲染。el-upload 提供了#file插槽可以让文件列表项完全由你控制el-upload v-model:file-listfileList action/api/upload :on-removehandleRemove template #file{ file } div classcustom-file-item div classfile-name{{ file.name }}/div div classfile-status span v-iffile.status uploading上传中 {{ file.percentage }}%/span span v-else-iffile.status success上传成功/span span v-else上传失败/span /div el-icon classremove-icon clickhandleOwnRemove(file) Close / /el-icon /div /template el-button typeprimary选择文件/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)在部分老版本中即使返回 falsePromise 链上仍会被视为 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 里取消未完成的上传请求所有文件列表项使用:keyfile.uid而不是 index避免删除中间项时状态错乱。这套方案经历了几次小版本升级表现稳定基本没有再收到过“奇怪提示”或“误删”的工单。最后说个实用小技巧以后遇到 Element Plus 里来路不明的文案直接在项目里搜node_modules/element-plus/es/locale/lang/zh-cn.mjs用关键字一搜基本就能定位到语言包对应字段改起来比写样式干净得多。至于“Delect”这个拼写我现在看到都会心一笑客户大概是顺着大写 D 往下念把后面一串看岔了。如果你也遇到有人拿这个拼写来反馈问题先别急着改代码让他按一下键盘上的 Delete 键试试很多“需求”其实是误会。我个人感受是键盘删除这功能留着挺好改一下措辞、加一道确认比直接阉割掉要更稳妥。