1. 从需求说起为什么非要去动文件列表的样式做后台管理系统用到 ant design vue 的 Upload 组件再常见不过。默认的a-upload会自动生成一份文件列表样式是 antd 设计语言的标准风格——左侧一个图标中间文件名右侧删除按钮下面再顶一条上传进度。这套东西在大多数内部管理系统里够用但一旦遇到对外展示、品牌定制、或者在移动端 H5 里复用问题就全来了。我在项目里最常见的几个场景第一产品要求文件封面用缩略图展示图片文件要直接渲染预览图第二上传成功后文件名左侧要显示“成功”状态徽标失败的要红色高亮并且允许重新上传第三文件列表要排成卡片网格而不是纵向一行行堆第四文件信息要展示自定义字段比如文件大小、上传时间、操作人。这些需求用默认的a-upload-list基本都做不到或者说做到的成本很高。这时候就需要我们去“自定义文件列表的样式”。ant design vue 的 Upload 组件其实给了一个相当核心的扩展点叫itemRender用它可以完全接管文件列表里每一项的渲染逻辑。CSS 覆盖算最轻量的方案插槽受限于组件内部实现真正能实现“随心所欲”的就是itemRender。这篇内容就把我实际做过的几种自定义方案挨个拆开讲明白从原理到代码再到踩坑记录一次说透。2. 先把 Upload 组件的默认渲染结构搞清楚既然要自定义第一件事就是弄明白默认的文件列表到底是哪些元素拼出来的。很多人一上来就写覆盖样式结果页面上一堆!important还不生效就是因为不清楚 antd 的 DOM 层级。2.1 默认列表的 DOM 结构ant design vue 的 Upload 组件在listType默认是text时文件列表是一个ul包着若干li每个li内部大致长这样li classant-upload-list-item div classant-upload-list-item-info span classant-upload-list-item-name !-- 图标部分 -- span classant-upload-list-item-thumbnail.../span !-- 文件名 -- span classant-upload-list-item-title文件名称.pdf/span /span /div !-- 操作按钮 -- span classant-upload-list-item-actions a classant-upload-list-item-action.../a a classant-upload-list-item-action.../a /span !-- 进度条 -- div classant-upload-list-item-progress.../div /li注意这个结构会根据listType变化。picture-card模式下li会多出图片预览的遮罩层picture模式会显示缩略图text模式下缩略图区域基本是空的。还有一个容易被忽略的细节上传中的条目li上会加.ant-upload-list-item-uploading类上传失败的会加.ant-upload-list-item-error完成的会加.ant-upload-list-item-done。这些状态类名是 CSS 定制的抓手。2.2 为什么直接写 CSS 往往不如意我的经验是能用 CSS 少量调整就不要轻易上组件重构但很多调整是 CSS 做不到的。原因有三点antd 的样式用了 CSS-in-JS 的动态主题方案普通项目里如果全局样式优先级不够很容易被组件内部样式覆盖。文件列表的交互逻辑删除、预览、进度、错误重试是组件内部维护的单纯改样式无法改变 DOM 结构。如果要做“文件卡片网格”默认的li是纵向流式排布的改 CSS 实现网格也可以但每一项内部的布局、操作按钮的位置、悬停效果写起来不比用itemRender少。所以接下来的重点是itemRender这是官方设计好的、用来替换默认文件列表渲染的扩展点。2.3 itemRender 到底是什么看文档的话Upload 组件里有一个属性叫itemRender它的签名是itemRender?: (originNode, file, fileList, actions) VNodeoriginNode默认渲染好的那个 DOM 节点VNode你可以用它做基础也可以完全丢弃。file当前条目的文件对象包含name、size、status、url、percent等字段。fileList当前完整的文件列表数组。actions组件内部提供的一系列操作方法字段如下{ remove: () void, // 删除当前文件 preview: () void, // 触发预览如果有配置 download: () void // 触发下载 }这个扩展点的核心价值在于它把“渲染”真正交还给你而“上传逻辑、状态管理、列表数据”仍由 Upload 内部维护。你只需要写一份模板定义每个文件长什么样完全不用自己去管理fileList的增删改查也不用自己写上传逻辑。这是我们做高度自定义列表最推荐的入口。3. 方案一用 itemRender 彻底接管列表渲染3.1 基础用法每一行完全自定义我们先从最常用的“纵向列表行”开始。需求是列表项左侧显示缩略图或类型图标中间是文件名和文件大小右侧是自定义操作按钮区。这是很多后台系统里“文件库”的常见形态。先看完整代码template a-upload v-model:file-listfileList multiple :before-uploadbeforeUpload :item-renderrenderFileItem a-button typeprimary选择文件/a-button /a-upload /template script setup import { ref } from vue const fileList ref([]) const beforeUpload (file) { // 需要 return false 阻止自动上传fileList 才会保留该文件 return false } const formatSize (size) { if (!size) return 0 KB if (size 1024) return size B if (size 1024 * 1024) return (size / 1024).toFixed(1) KB return (size / (1024 * 1024)).toFixed(2) MB } const getFileIcon (fileName) { const ext fileName.split(.).pop().toLowerCase() if ([png, jpg, jpeg, gif, webp, svg].includes(ext)) return picture if (ext pdf) return file-pdf if ([doc, docx].includes(ext)) return file-word if ([xls, xlsx].includes(ext)) return file-excel return file } const renderFileItem ({ originNode, file, fileList, actions }) { // 注意这里必须返回一个 Vue VNode return ( div classcustom-file-item div classcustom-file-item__icon file-icon :typegetFileIcon(file.name) / /div div classcustom-file-item__info div classcustom-file-item__name{{ file.name }}/div div classcustom-file-item__meta {{ formatSize(file.size) }} · {{ file.status }} /div /div div classcustom-file-item__actions a-button sizesmall typelink onClick{actions.preview}预览/a-button a-button sizesmall typelink danger onClick{actions.remove}删除/a-button /div /div ) } /script这段代码里有几个关键的层级关系需要注意itemRender是写在a-upload属性上的不是写在插槽里的。函数返回的是一个 VNode在 Vue 3 的 JSX 语法中可以直接写模板结构。actions.preview只有在你配置了onPreview时才有实际效果否则点击后无反应。建议在a-upload上同时绑定previewhandlePreview。3.2 在模板语法中使用 itemRender如果你的项目里没有启用 JSXitemRender也可以配合h函数来写。Vue 3 的 setup 中可以用h创建节点script setup import { h } from vue const renderFileItem ({ originNode, file, fileList, actions }) { return h(div, { class: custom-file-item }, [ h(div, { class: custom-file-item__icon }, [h(FileIcon, { type: getFileIcon(file.name) })]), h(div, { class: custom-file-item__info }, [ h(div, { class: custom-file-item__name }, file.name), h(div, { class: custom-file-item__meta }, formatSize(file.size) · file.status) ]), h(div, { class: custom-file-item__actions }, [ h(a-button, { size: small, type: link, onClick: actions.preview }, 预览), h(a-button, { size: small, type: link, danger: true, onClick: actions.remove }, 删除) ]) ]) } /script这种写法可读性差一些但胜在不需要额外安装 JSX 插件。实际项目里我更推荐 JSX 写法因为当数据结构复杂时h嵌套实在容易出错。Vue 3 项目使用vitejs/plugin-vue-jsx插件即可支持 JSX配置非常简单。3.3 自己写列表容器完全抛弃默认 ulitemRender能控制每一项的渲染但外层的ul、li结构仍然由 Upload 内部生成。如果你需要完全自定义的容器比如排成网格卡片、瀑布流、表格等直接使用itemRender还不够还需要配合一个“隐藏默认列表容器”的技巧。实际操作中我通常用 CSS 把默认的ul.ant-upload-list容器改掉.ant-upload-list { display: grid !important; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)) !important; gap: 12px; list-style: none; padding: 0; } .ant-upload-list-item-container { /* 取消默认内边距和边框避免和自定义结构重复 */ padding: 0 !important; border: none !important; margin: 0 !important; }然后itemRender内部实现卡片结构。需要注意Upload 组件的li元素上有很多默认样式直接用!important覆盖时要保证不影响上传交互区域。最稳妥的方式是让自定义内容的根节点占满整个li再在根节点里做出卡片效果。3.4 严格注意响应式状态itemRender函数的file参数在上传过程中会变化文件从uploading到donepercent从 0 到 100这些是响应式的。但在自定义渲染时如果你在 JSX 中直接读取file.status、file.percentVue 的响应式系统是能追踪到的因为这本来就是组件内部维护的响应式对象。我给一个建议不要在itemRender外部用浅拷贝的方式把file对象存起来再使用否则会丢失响应式更新。比如有人会这样写const snapshot { name: file.name, status: file.status }然后在 JSX 中读取snapshot.status结果上传状态永远停留在uploading。这就是响应式断裂。直接用file对象的属性即可它们是深层响应式的。4. 方案二轻量定制CSS 覆盖默认样式itemRender适合大改但有些时候我们只想要默认列表“看起来更顺眼一点”没必要重写 DOM。CSS 覆盖方案在这种场景下效率很高。4.1 覆盖的核心选择器ant design vue 的 Upload 列表样式遵循 antd 的命名规范以.ant-upload-list开头。重点掌握以下几个/* 整个列表容器 */ .ant-upload-list {} /* 每一项容器注意 antd 版本不同结构有差异新版是 .ant-upload-list-item-container */ .ant-upload-list-item-container {} /* 文件项主体 */ .ant-upload-list-item {} /* 文件图标和名称区域 */ .ant-upload-list-item-info {} /* 文件名文字 */ .ant-upload-list-item-name {} /* 操作按钮区域 */ .ant-upload-list-item-actions {} /* 进度条区域 */ .ant-upload-list-item-progress {}在 4.x 版本中upload列表的每一项多了一层.ant-upload-list-item-container包裹专门用来定位 hover 操作按钮。5.x 版本ant design vue 4.x结构又有微调。所以写 CSS 覆盖前建议先打开浏览器开发者工具实际看一下当前版本的 DOM。4.2 几个高性价比的定制点我在项目里最常做的几个轻量定制把默认的文字列表变成“文件图标 上下两行信息”的结构这一步单靠 CSS 不够但如果你只是想调整间距、对齐右对齐的删除按钮、限制列表最大高度等可以很轻松.ant-upload-list { max-height: 300px; overflow-y: auto; } .ant-upload-list-item { border-bottom: 1px solid #f0f0f0; padding: 8px 4px; border-radius: 4px; transition: background 0.2s; } .ant-upload-list-item:hover { background: #fafafa; } /* 自定义状态颜色 */ .ant-upload-list-item-error .ant-upload-list-item-name { color: #ff4d4f; }可以通过listType属性预设两种特殊模式listTypepicture显示文件缩略图图片文件会渲染预览非图片文件使用通用图标。listTypepicture-card网格卡片式每一项是一个固定尺寸的卡片上传按钮也参与网格排布。这两种模式配合 CSS 覆盖可以做出很多漂亮的效果比如调整卡片尺寸.ant-upload-list-picture-card .ant-upload-list-item-container, .ant-upload-list-picture-card .ant-upload-select { width: 120px !important; height: 120px !important; } .ant-upload-list-picture-card .ant-upload-list-item { padding: 8px !important; border-radius: 8px !important; }4.3 CSS 方案的边界CSS 方案解决不了的问题也很明显你无法改变 DOM 结构无法新增“上传时间”“文件大小”这类原本不存在的字段无法把操作按钮从默认的“hover 才显示”改为“常驻显示”也无法在文件卡片上叠加自定义角标。遇到这些需求还是要回到itemRender。还有一个兼容性问题antd 的 CSS 变量体系和深浅主题模式。如果项目开启了深色模式纯覆盖很可能出现“列表浅色背景深色”的割裂感。这种情况建议使用 antd 提供的ConfigProvider主题变量或通过cssVar模式去调整而不是写死颜色值。5. 方案三基于 showUploadList 和插槽的局部定制有一些需求介于“纯 CSS”和“完全重写”之间可以用官方保留的辅助属性来做局部定制。5.1 showUploadList 的精细开关showUploadList可以传对象控制哪些元素显示a-upload v-model:file-listfileList :show-upload-list{ showPreviewIcon: true, showRemoveIcon: true, showDownloadIcon: false } 这几个开关控制的是默认操作图标是否渲染。如果你想把系统默认的图标换成自己的图标组件可以配合itemRender把对应行为映射到自己的按钮上这在第 3 节已经展示过。5.2 局部扩展操作入口ant design vue 的 Upload 并没有暴露“在列表末尾追加操作”的插槽但itemRender加上fileList参数可以灵活实现。比如在最后一个文件之后渲染一个“上传更多”的占位卡片。做法是读fileList的长度判断是否是最后一项再额外渲染const renderFileItem ({ originNode, file, fileList, actions }) { const isLast fileList[fileList.length - 1]?.uid file.uid return ( div classcustom-file-item {/* ... */} {isLast ( a-button classcustom-file-item__more onClick{() uploadRef.value?.click()} 继续添加 /a-button )} /div ) }注意这里的uploadRef是你给a-upload绑定的 ref调用它的click()方法可以触发文件选择框。这种“局部定制 利用原有能力”的组合非常实用。5.3 自定义缩略图源对于图片预览Upload 默认使用file.url或file.thumbUrl作为预览图。如果你需要根据文件内容动态生成缩略图比如视频封面、PDF 预览图可以在beforeUpload阶段用URL.createObjectURL生成临时 URLconst beforeUpload (file) { if (file.type.startsWith(image/)) { file.thumbUrl URL.createObjectURL(file) } return false }注意在上传完成或文件移除时最好调用URL.revokeObjectURL释放临时 URL避免内存泄漏。这里有个容易忽略的细节直接给file对象挂自定义属性在传给后端时需要注意后端其实只关心的内容是file.originFileObj这个原始 File 对象多余的属性不影响。如果你最终是把fileList序列化提交给后端记得只取需要的字段否则自定义字段会被一并带过去。6. 实战案例卡片式上传列表完整实现把前面讲到的点串起来做一个通用性较高的“卡片式文件上传列表”。这个组件我在多个项目中复用过比较有代表性。6.1 需求定义与效果支持图片、PDF、Word 等常见格式。列表以卡片网格展示每张卡片有缩略图/图标、文件名、文件大小、上传状态。上传中显示进度百分比。上传成功后显示“完成”徽标失败显示红色提示且可重试。鼠标悬停卡片时显示预览、下载、删除操作。6.2 完整代码template div classfile-uploader a-upload refuploadRef v-model:file-listfileList classfile-uploader__inner multiple :before-uploadbeforeUpload :item-renderrenderFileItem previewhandlePreview removehandleRemove div classfile-uploader__trigger plus-outlined / span选择文件/span /div /a-upload /div /template script setup import { ref } from vue import { PlusOutlined, FilePdfOutlined, FileWordOutlined, FileExcelOutlined, FileOutlined, PictureOutlined, DeleteOutlined, EyeOutlined, DownloadOutlined, ReloadOutlined } from ant-design/icons-vue const fileList ref([]) const uploadRef ref(null) const beforeUpload (file) { return false } const formatSize (size) { if (!size) return 0 KB if (size 1024) return size B if (size 1024 * 1024) return (size / 1024).toFixed(1) KB return (size / (1024 * 1024)).toFixed(2) MB } const getFileExt (name) { const idx name.lastIndexOf(.) return idx -1 ? name.slice(idx 1).toLowerCase() : } const renderFileIcon (file) { const ext getFileExt(file.name) if ([png, jpg, jpeg, gif, webp, svg].includes(ext)) { // 图片文件优先显示缩略图 return img src{file.thumbUrl || file.url} classfile-card__thumb / } const iconMap { pdf: FilePdfOutlined, doc: FileWordOutlined, docx: FileWordOutlined, xls: FileExcelOutlined, xlsx: FileExcelOutlined } const Component iconMap[ext] || FileOutlined return Component classfile-card__type-icon / } const renderActions (actions) { return ( div classfile-card__actions a-button sizesmall typelink onClick{actions.preview} EyeOutlined / 预览 /a-button a-button sizesmall typelink onClick{actions.download} DownloadOutlined / 下载 /a-button a-button sizesmall typelink danger onClick{actions.remove} DeleteOutlined / 删除 /a-button /div ) } const renderStatus (file) { if (file.status uploading) { return ( div classfile-card__progress a-progress :percentMath.round(file.percent || 0) sizesmall / /div ) } if (file.status error) { return ( div classfile-card__error span上传失败/span a-button sizesmall typelink onClick{actions.retry} ReloadOutlined / 重试 /a-button /div ) } return div classfile-card__done上传完成/div } const renderFileItem ({ originNode, file, fileList, actions }) { return ( div class{[file-card, { file-card--error: file.status error }]} div classfile-card__body div classfile-card__preview{renderFileIcon(file)}/div div classfile-card__info div classfile-card__name title{file.name}{file.name}/div div classfile-card__meta{formatSize(file.size)}/div /div {file.status uploading ? renderStatus(file) : null} {file.status error ? renderStatus(file) : null} {file.status ! uploading file.status ! error ? renderStatus(file) : null} {renderActions(actions)} /div /div ) } const handlePreview (file) { // 这里可以扩展预览逻辑图片直接打开、PDF新窗口打开等 if (file.thumbUrl || file.url) { window.open(file.url || file.thumbUrl, _blank) } } const handleRemove (file) { // 释放临时 Url if (file.thumbUrl file.thumbUrl.startsWith(blob:)) { URL.revokeObjectURL(file.thumbUrl) } } /script代码里有个新加的actions.retry这个 API 在某些版本中不存在需要自己处理。通常的做法是把file的状态手动重置为uploading再重新上传const retryUpload (file) { file.status uploading file.percent 0 // 重新触发上传逻辑 // 如果你用的是自定义上传比如 action 指向后端接口可以直接调用 uploadRef.value.upload() }6.3 配套样式.file-uploader__inner { display: block; } .ant-upload-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 16px; padding: 0; } .ant-upload-list-item-container { padding: 0 !important; border: none !important; } .file-card { border: 1px solid #e5e6eb; border-radius: 8px; overflow: hidden; background: #fff; transition: box-shadow 0.2s; height: 100%; display: flex; flex-direction: column; } .file-card:hover { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); } .file-card--error { border-color: #ff4d4f; } .file-card__body { padding: 12px; flex: 1; position: relative; } .file-card__preview { height: 90px; display: flex; align-items: center; justify-content: center; background: #fafafa; border-radius: 6px; margin-bottom: 8px; overflow: hidden; } .file-card__thumb { max-width: 100%; max-height: 100%; object-fit: contain; } .file-card__type-icon { font-size: 40px; color: #1677ff; } .file-card__name { font-size: 14px; color: #1d2129; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 4px; } .file-card__meta { font-size: 12px; color: #86909c; } .file-card__actions { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.45); display: flex; align-items: center; justify-content: center; gap: 8px; opacity: 0; transition: opacity 0.2s; } .file-card:hover .file-card__actions { opacity: 1; }这个实现里的关键点默认隐藏操作层悬停时显示采用半透明遮罩避免不同状态下的冲突图片优先展示真实缩略图文件类型图标作为兜底。7. 常见问题与排查技巧以下是我在实践过程中踩过的坑有些问题排查起来特别隐蔽值得单独记录。7.1 itemRender 不生效最常出现的问题就是函数写了但不生效。优先检查三点确认用的是item-render还是itemRender。在模板语法中必须用 kebab-case即item-render。用驼峰写法在小写字母的 HTML 属性中不会报错但也不会生效。确认模板编译环境支持 JSX 或h函数报错信息往往出现在控制台不会显示在页面上。确认没有同时开启showUploadList{false}如果列表被全局关闭itemRender自然没有渲染机会。a-upload :show-upload-listfalse :item-renderrenderFileItem这个组合是无效的整个列表都被干掉了。记住itemRender只是替换列表项内部的渲染不是列表开关。7.2 自定义内容点击事件被吞掉itemRender里的操作按钮如果点击无反应很可能是被父级的事件拦截了。上传组件内部在li上绑定了点击、拖拽等事件处理尤其是preview事件经常被默认行为覆盖。解决方式在自定义按钮上调用event.stopPropagation()再调用actions里的方法const handleRemoveClick (event, actions) { event.stopPropagation() actions.remove() }7.3 文件上传到 100% 但状态卡住如果你在beforeUpload返回了falseUpload 组件会认为“用户自处理上传”此时文件停留在uploading状态、进度 0%不会自动变为done。这是非常常见的坑。有两种解法返回true把上传交给action属性指定的后端地址处理组件自动更新状态。如果必须自处理比如使用自己的请求库需要在请求完成后手动修改file.statusconst uploadFile async (file) { file.status uploading file.percent 0 const formData new FormData() formData.append(file, file) // 假设用 axios 上传 try { await axios.post(/api/upload, formData, { onUploadProgress: (e) { file.percent Math.round((e.loaded / e.total) * 100) } }) file.status done file.url 后端返回的文件地址 } catch (err) { file.status error } }注意file是 Upload 内部维护的响应式对象直接改属性是可以触发视图更新的。7.4 自定义属性不显示有人会在beforeUpload里给file挂自定义字段然后在itemRender里读取时报 undefined。原因通常是beforeUpload里的file参数是原始 File 对象而组件内部会把originFileObj和安全字段一起挂到file上。读取时要注意file.uid在部分版本中会重新生成建议用file.uid或者在beforeUpload阶段就把自定义属性挂到返回对象上const beforeUpload (file) { file.customField 自定义值 return file }这里把file原样返回确保后续fileList中的对象带上了自定义字段。7.5 列表顺序错乱如果你使用了异步上传fileList的默认顺序可能和你选择的顺序不一致。fileList是一个数组Vue 的响应式系统会根据uid来追踪项而不是索引。当上传任务完成时一些内部操作可能触发fileList重排。简单处理方式是排序fileList.value.sort((a, b) { return (a.originFileObj?.lastModified || 0) - (b.originFileObj?.lastModified || 0) })但要注意直接改fileList数组的引用要重新赋给 Upload 绑定的v-model:file-list才能触发更新。7.6 缩略图内存泄漏用URL.createObjectURL生成的临时 URL在文件移除、上传成功替换成真实地址后都要释放。建议在onRemove里统一处理const handleRemove (file) { if (file.thumbUrl?.startsWith(blob:)) { URL.revokeObjectURL(file.thumbUrl) } fileList.value fileList.value.filter((item) item.uid ! file.uid) }如果处理的不及时后台系统长时间使用会出现内存持续上涨的问题。这是我上线后接到运维反馈才发现的坑。7.7 与弹窗、抽屉一起使用的层级问题Upload 的文件列表如果放在 Modal 或 Drawer 里操作按钮的 hover 遮罩层可能会被弹层的容器裁剪产生滚动条或者看不见。解决办法是给遮罩容器设置position: absolute而不是fixed并确保 Upload 的父容器没有overflow: hidden。7.8 主题适配如果项目配置了 antd 的暗色主题itemRender里自定义的卡片背景色写死为白色就会很难看。建议使用 antd 的useToken()获取主题 tokenimport { theme } from ant-design-vue const { token } theme.useToken() // token.colorBgContainer / token.colorBorder ...这样卡片背景色、边框色能跟随全局主题自动切换。8. 最后补充一个实用技巧在itemRender里如果需要拿到上传后的完整文件地址比如用一个自定义的“复制链接”按钮不要直接存file.url字符串。后端返回的地址可能是相对路径也可能是 CDN 拼接后的完整路径。更可靠的做法是在上传成功后把完整地址写入file的某个自定义属性比如file.response?.data?.url。用file.response读取接口返回值这是很多刚接手的人容易忽略的。另外自定义列表后Upload 的拖拽上传功能默认仍然生效。如果你在itemRender的布局里用了draggable属性可能会和 Upload 原生拖拽冲突。维护一个自己的卡片拖拽排序建议把 Upload 的drag属性关闭避免事件抢占。这个组件的自定义能力其实只开放了一个口子但够深。itemRender掌握好文件列表就再也不是“只能看不能动”的黑盒了。后面如果再遇到产品提“文件列表要像网盘那样展示”可以把这个案例直接拿来改改就能用。