空数据占位符这个需求在vue2项目里看着不起眼但凡是做过真实业务系统的人都知道列表为空、搜索无结果、Tab切换没数据这三件事处理不好就是产品体验的硬伤。我经手过好几个vue2老项目包括用hbuilderx打包的混合App、后台管理系统的数据大屏几乎每个项目到最后都会面临同样的问题接口正常返回了但是数据是空的页面上那一块区域就直接塌了。这篇文章不聊虚的直接从组件设计、props规划、插槽机制到老项目改造把vue2里空数据占位符的完整落地方案拆开讲清楚适合正在维护vue2项目、或者想把空状态处理做规范的前端开发者参考。1. 为什么空数据场景需要专门的占位符1.1 空数据不是空白是产品体验的最后一个环节很多开发者在初期写页面时习惯用v-iflist.length 0来做列表渲染控制数据为空时直接什么都不显示。这个做法在功能上没错但在真实业务里用户看到的是一块没有任何反馈的空白区域很容易误以为页面出bug了或者接口挂掉了。我自己在维护一个vue2老项目的过程中接到过好几次这样的反馈这个页面打开是空的是不是接口报错了排查下来接口正常、数据为空、页面没有任何提示。问题的根源就在于缺少空数据占位符。后来我把占位统一抽成组件之后这类反馈基本消失了。用户看到暂无数据四个字至少知道这是业务上的空而不是系统层面的故障。空数据占位符解决的核心问题有三个明确传达当前无内容的状态消除用户的不确定性为后续操作提供入口比如去创建重新搜索刷新试试保持页面结构的完整性避免布局塌陷导致视觉跳动1.2 三种常见的空数据实现方式对比我在不同的vue2项目里见过各种做法总结下来有三种主流方案方案一每写一个页面就复制一段v-if判断加HTML模板。这是最原始的做法在小项目里勉强能撑住但问题很明显每个页面的空状态样式需要重新调图片路径各自维护文案不一致后面想统一改样式就得全局搜索替换改动成本极高。我在一个后台管理系统里就吃过这种亏光暂无数据就有四种写法有的带图片有的纯文字有的甚至没有样式纯粹一行灰字。方案二用第三方组件库自带的空状态组件。比如Element UI 2.x版本本身没有独立的Empty组件需要额外引入Ant Design Vue里是有的。如果项目刚好用了这套组件库直接用是最省事的。但维护老项目的同学都懂很多vue2老项目的UI层是自定义的没有引入完整的组件库体系或者组件库版本太老自带的空状态样式跟项目整体设计语言不匹配。方案三业务通用组件自行封装。也就是这篇文章要讲的方案。封装一个Empty组件通过props控制文字、图片、按钮通过插槽扩展自定义内容在需要的地方引入使用。它的优势在于一次开发处处复用文案统一、样式统一、维护成本最低。我在实际项目里的选择也很明确如果是新项目且用了完整组件库直接查组件库文档用自带的如果是老项目、混合开发项目比如hbuilderx打包的vue2应用、或者组件库版本较旧那就花半小时抽一个自己的空数据占位符组件一劳永逸。2. 组件设计与参数规划2.1 先想清楚要暴露哪些props写组件之前别急着敲代码。空数据占位符虽然看着简单但不同业务场景对它的要求差异很大。我建议先梳理一下项目里可能出现空数据的场景列一个清单列表页没数据需要提示文字可能还需要刷新按钮搜索页无结果需要提示文字可能需要清空搜索条件按钮Tab切换某块区域为空只需要静态提示不需要操作按钮加载失败接口报错需要错误提示可能需要重试按钮引导页无内容可能需要一个去创建的主操作按钮基于这些场景组件的props应该至少包含以下几项props类型默认值说明textString暂无数据主提示文案descriptionString副描述文案搜索场景常用imageString自定义图片地址不传则用默认占位图imageSizeNumber/String图片尺寸可传100px或100showButtonBooleanfalse是否显示操作按钮buttonTextString刷新按钮文字buttonTypeStringprimary按钮样式类型这里有一个容易被忽略的点imageSize为什么要支持Number和String两种类型因为有些场景你需要的是固定尺寸传Number就行组件内部转成px但有些场景你想用百分比或者rem单位那必须传String。Vue2里可以用validator做类型校验允许这两种类型通过。props: { imageSize: { validator: function (value) { return typeof value number || typeof value string; } } }2.2 插槽设计的两个关键决策props能解决文字和按钮的定制但真实业务里总有意外。比如某个页面要求在空状态里放一个去创建订单的链接而另一个页面想在空状态里加一个换肤的图片插画。如果全靠props去定义这个组件的props会膨胀到难以维护。正确的做法是预留插槽。我在设计这个组件时只暴露了两个插槽但这两个插槽几乎能覆盖所有扩展场景默认插槽插在图片和文字下方用于放置自定义操作区。当默认插槽有内容时showButton自动失效以插槽内容为准。这样既保留了组件内置按钮的便利性又给了业务方完全自定义的能力。图片插槽用于替换默认图片。有些业务方想放自己设计的插画或者想放一个带动效的gif通过image属性只能传静态图插槽才是通用的解法。这里要特别注意Slot的作用域问题。在Vue2.6版本之前作用域插槽的写法是slot-scope2.6之后统一推荐v-slot。我见过不少老项目里还有用slotxxx和slot-scopeprops的老写法这类写法在Vue2.6及以上版本里仍然兼容但如果你是在2.6环境下新写代码建议直接用v-slot。如果项目是特别老的vue2版本比如2.5.x那只能用slot-scope。我在一个hbuilderx打包的vue2项目里就遇到过这种兼容性问题后面细说。2.3 样式隔离与主题定制空数据占位符用scoped样式基本是标配但有一个问题容易踩坑如果你把组件放在一个scoped父组件里想要覆盖子组件内部某些样式直接写子组件的类名是无效的。解决办法有两个在子组件容器上加一个唯一的class允许外部通过非scoped的全局样式去覆盖给组件支持一个customClass属性业务方传入自定义类名来调整细节我更推荐第二种方式。因为第一种方式需要业务方写穿透或::v-deep写法上比较绕而且不同预处理器的写法不统一。而customClass就是一个普通的prop组件内部把传入的类名拼到根节点上业务方怎么写样式都不会污染全局。div classempty-state :classcustomClass组件根节点的命名也很关键。empty-state这种是比较常见的名字但如果你同时用了UI组件库要注意类名冲突。我在项目里习惯加一个统一前缀比如app-empty这样既表明身份也降低冲突概率。3. 完整实现从零写一个可复用的Empty组件3.1 组件模板与核心逻辑接下来直接上代码。这个组件用Vue2的标准选项式API编写不依赖任何第三方库纯CSS实现在任何vue2项目里都可以直接复制使用。template div classapp-empty :classcustomClass div classapp-empty__image :styleimageStyle img v-ifimage :srcimage :alttext classapp-empty__img / img v-else-if$slots.image classapp-empty__img src alt / slot v-else nameimage svg classapp-empty__default-icon viewBox0 0 64 41 xmlnshttp://www.w3.org/2000/svg g fillnone fill-ruleevenodd path dM0 1h64v39H0z fill#F5F6FA/ path dM22 16h20v8H22z fill#D8DDE5/ circle cx40 cy16 r3 fill#AEB4BF/ /g /svg /slot /div div v-iftext classapp-empty__text{{ text }}/div div v-ifdescription classapp-empty__description{{ description }}/div div classapp-empty__action slot button v-ifshowButton classapp-empty__button :classapp-empty__button-- buttonType click$emit(button-click) {{ buttonText }}/button /slot /div /div /template等一下上面模板里v-else-if$slots.image这段逻辑是有问题的。插槽是否存在的判断在Vue2里应该用this.$slots.image来检测但模板里直接访问$slots是可以的。不过默认插槽和具名插槽的处理顺序要理清楚如果传了image属性直接用v-ifimage渲染图片如果没有image属性但传了image插槽渲染插槽两者都没有渲染内置的默认SVG图标所以模板逻辑应该是div classapp-empty__image :styleimageStyle img v-ifimage :srcimage :alttext classapp-empty__img / slot v-else nameimage svg v-if!image classapp-empty__default-icon .../svg /slot /div这里我实际清理过一版最终可用的模板如下template div classapp-empty :classcustomClass div classapp-empty__image :styleimageStyle img v-ifimage :srcimage :alttext classapp-empty__img / slot v-else nameimage div classapp-empty__default-icon svg ...省略具体路径.../svg /div /slot /div p v-iftext classapp-empty__text{{ text }}/p p v-ifdescription classapp-empty__description{{ description }}/p div classapp-empty__action slot button v-ifshowButton classapp-empty__button :classapp-empty__button-- buttonType click$emit(button-click) {{ buttonText }}/button /slot /div /div /templatescript部分的逻辑和计算属性script export default { name: AppEmpty, props: { text: { type: String, default: 暂无数据 }, description: { type: String, default: }, image: { type: String, default: }, imageSize: { type: [Number, String], default: }, showButton: { type: Boolean, default: false }, buttonText: { type: String, default: 刷新 }, buttonType: { type: String, default: primary, validator: function (value) { return [primary, default, text].indexOf(value) ! -1; } }, customClass: { type: String, default: } }, computed: { imageStyle: function () { var size this.imageSize; if (!size) { return {}; } var px typeof size number ? size px : size; return { width: px, height: px }; } } }; /script这里我故意用var而不是const因为很多vue2老项目的构建配置比较旧eslint规则还停留在es5阶段不识别const的写法。虽然现代vue2项目基本都支持ES6语法了但兼容低版本构建配置是维护老项目的常识。3.2 全局注册与按需注册组件写好后注册方式有讲究。我建议在main.js里做全局注册尤其是空数据占位符这种使用频率极高、几乎每个页面都可能用到的组件。import AppEmpty from /components/AppEmpty/index.vue; Vue.component(AppEmpty, AppEmpty);全局注册之后业务页面里就可以直接这么用了template div classorder-list ul v-iforders.length li v-foritem in orders :keyitem.id{{ item.name }}/li /ul app-empty v-else text暂无订单 description去下单开启你的第一笔交易吧 show-button button-text去下单 button-clickgoCreate / /div /template有人会问全局注册会不会增加打包体积确实会增加一点点但对于这种纯组件打包后的代码量很小可以忽略不计。相比之下全局注册带来的写法统一、少写import的好处更大。如果项目里有按需加载的诉求比如用babel-plugin-component做了组件库的按需引入那空数据占位符这种自研组件直接用局部注册也完全没问题script import AppEmpty from /components/AppEmpty/index.vue; export default { components: { AppEmpty } }; /script两种方式无所谓优劣看项目组织习惯。我个人的建议是公司内部多个项目共用的公共组件放独立的业务组件库或/components统一目录全局注册单项目内的小组件局部注册更灵活。3.3 在业务页面中的实战用法组件封装好了真正的价值体现在业务接入上。我以一个典型的后台管理系统订单列表页为例这个页面有三个tab分别是全部待处理已完成每个tab对应的数据源是切换的。如果没有统一的空状态处理代码会变成这样template div div v-ifcurrentList.length 0 classempty-box img src../../assets/empty.png / p暂无数据/p /div ul v-else ... /ul /div /template每个tab写一遍三个tab三份代码。如果其中两个tab的空状态文案还不一样那真的是每天在复制粘贴中度过。接入app-empty之后template div classorder-page el-tabs v-modelactiveTab tab-clickhandleTabClick el-tab-pane v-fortab in tabs :keytab.key :labeltab.label :nametab.key div v-loadingloading ul v-iftabList.length li v-foritem in tabList :keyitem.id.../li /ul app-empty v-else :textemptyText :show-buttonactiveTab all button-text去下单 button-clickhandleEmptyAction / /div /el-tab-pane /el-tabs /div /template这里的emptyText用计算属性根据当前tab动态返回文案computed: { emptyText: function () { if (this.activeTab pending) { return 暂无待处理订单辛苦了; } return 暂无相关订单; } }注意我在app-empty外层套了一个v-loadingElement UI的加载指令。为什么要这样因为接口请求期间页面应该显示loading状态请求结束后如果是空数据再显示空状态。如果直接把v-iftabList.length和v-else放在loading里会出现一个很尴尬的瞬间loading结束后、空状态渲染前页面会闪一下空白。虽然组件本身没有做loading的逻辑但通过v-loading指令和占位符的配合可以在视觉上做到无缝衔接。4. 进阶场景与扩展技巧4.1 列表页与搜索页的空状态区分列表页和搜索页的空状态本质上要传递的信息完全不同列表页为空当前模块还没有任何业务数据用户的预期是我可能需要去创建一条搜索结果为空用户的搜索条件没有匹配到任何数据预期是我可能需要调整搜索条件如果两边用同一个文案和同一个按钮体验就很怪。所以实际使用中我会给组件加一个mode的概念虽然底层实现没变但文案和按钮的组合可以做差异化!-- 列表页 -- app-empty text暂无合同数据 description录入你的第一份合同开始管理合同信息 show-button button-text新建合同 button-clickhandleCreate / !-- 搜索页 -- app-empty text未找到相关结果 description试试调整搜索关键词或清空筛选条件 show-button button-text重置搜索 button-typedefault button-clickhandleReset /这里还有一个细节搜索页的按钮颜色尽量用default或者text不要用primary。因为搜索重置是辅助操作视觉权重不该高于新建合同这类主操作。组件通过buttonType参数就能直接控制这也是我当时设计这个prop的初衷。4.2 与路由、权限、Loading的组合使用真实项目的空数据场景往往不是孤立的。我在一个数据管理后台遇到过一种情况某个菜单页面打开后接口还没返回时页面显示的是一个大大的空占位符过了一秒数据才出来。用户反馈说页面一跳一跳的。排查后发现是开发者在created钩子里先设置了list []然后页面的v-else就命中了空状态组件而loading层又没有挡住这个区域。要解决这个问题简单的思路是空数据占位符必须放在loading层内部并且依赖loading状态来控制是否显示。标准的组合写法template div classpage-wrapper div v-loadingloading classpage-content app-empty v-if!loading list.length 0 text没有数据 / ul v-else-iflist.length 0 li v-foritem in list :keyitem.id{{ item.name }}/li /ul /div /div /template script export default { data() { return { loading: true, list: [] }; }, created() { this.fetchList(); }, methods: { fetchList() { this.loading true; getList() .then((res) { this.list res.data || []; }) .finally(() { this.loading false; }); } } }; /script这里的关键是v-if!loading list.length 0把loading状态纳入判断。没有这个条件组件在数据加载前就提前渲染了视觉上就会出现先显示空状态再跳变成列表的怪现象。如果是涉及权限的页面比如某些用户没有查看某个模块的权限后端返回空数据还是返回403要区分清楚。403属于异常场景不应该显示暂无数据而应该显示无权限访问。这种情况就不能用empty组件了我会单独做一个no-permission组件。如果你非要用一个组件硬扛所有场景props会越来越复杂维护成本反而上升。4.3 适配老项目的低成本改造方案很多vue2老项目的基础设施非常陈旧可能是几年前的脚手架构建工具是webpack 3甚至更低babel配置也未必支持最新的ES语法。在这种项目里引入新组件最大的敌人不是Vue本身而是构建链路的兼容性。我之前在一个用hbuilderx打包的vue2混合App项目里就把这个组件塞进去了。当时踩了几个坑这里直接分享经验第一路径别名可能不存在。老项目的webpack配置里未必有这个别名import路径就得写相对路径。比如把组件放在src/components/AppEmpty/index.vue业务页面引用时写import AppEmpty from ../../components/AppEmpty/index.vue;不要嫌丑稳定运行优先级最高。第二避免使用可选链、展开运算符等新语法。就算vue-loader的babel插件能转译大部分语法老项目的eslint规则可能直接报错。我写的组件代码里故意避免使用?.和...全部用ES5写法就是考虑到这一点。第三图片资源路径处理。如果你给image属性传了本地图片路径在vue2老项目中要注意模板里:srcimage的路径是运行时解析的不是构建期解析的。如果你传的是../assets/empty.png这种相对路径运行时找的是当前页面文件的相对路径大概率会404。正确做法有两种一种是图片放到static或public目录直接传绝对路径/static/empty.png另一种是通过require()引入图片后传打包后的路径。很多开发者在这个点上卡很久我直接说结论在老项目里图片路径用绝对路径方案最省心。app-empty image/static/img/empty-box.png /第四不要依赖async/await之外的新API。这不是组件层面的问题是业务页面接入时的问题。老项目的babel-polyfill可能没有完整引入某些ES6的API比如Array.prototype.findIndex在低版本Android WebView里可能不存在。如果占位符组件的description文案需要做字符串拼接尽量用而不是模板字符串稳妥第一。5. 常见问题与排查实录5.1 插槽不生效的排查这是我在实际项目中收到最多的提问。比如开发者在业务页面里写了app-empty div自定义内容/div /app-empty但是页面上显示的仍然是内置的暂无数据和按钮自定义内容根本没渲染。排查思路是这样的先确认Vue版本。如果是Vue 2.6及以上版本普通插槽直接通过slot渲染就好了。但我见过一个有意思的情况——有人把组件注册成AppEmpty然后在模板里写app-empty小写同时又在components里注册了AppEmpty这种大小写混用在某些配置下可能导致注册不上的问题但通常报错信息会明确提示组件未注册。插槽不生效最常见的原因是v-if判断把插槽内容挡掉了。比如组件模板里div classapp-empty__action slot v-ifshowButton button内置按钮/button /slot /div这种情况下如果showButton是false不管业务方传不传默认插槽内容都不会渲染。插槽的作用域比v-if更容易被开发者忽略。所以我最后采用的方案是action区域的插槽不绑定v-if是否显示按钮由插槽是否有内容来决定。div classapp-empty__action slot button v-ifshowButton click$emit(button-click){{ buttonText }}/button /slot /div这样设计之后业务方传了插槽就用插槽的内容没传插槽且showButton为true时就显示内置按钮逻辑清楚多了。5.2 scoped样式下图片不显示的坑这个坑比较隐蔽。组件内部用了scoped模板里渲染了外部传入的image地址。正常情况下没问题但如果你传给image的是本地相对路径且组件放在不同的目录层级下图片就会404。这个问题的本质是路径解析组件模板里的:srcimageimage值是业务页面里写死的路径字符串浏览器会基于当前页面URL去解析。如果业务页面路径和图片实际路径不一致就会找不到。解决方式前面说过用绝对路径或者require。但还有一个相关的问题如果你在业务页面里这么写app-empty :imagerequire(/assets/empty.png) /require在模板里能用但这里有一个构建上的坑——如果老项目的webpack没有配置url-loader来处理小图片或者limit设置得很小图片可能被转成base64塞进JS里导致组件打包体积异常膨胀。排查时如果发现打包后JS大了几十KB先检查是不是图片被转成base64了。5.3 v-if判断与占位符闪烁问题组件用得好好的但页面初次加载时空数据占位符会闪一下才变成列表内容。这个问题的根源是数据请求是异步的list初始值是[]页面渲染时list.length 0条件成立空状态先渲染出来等请求返回后才切换成列表。解决方案前面已经提到了就是加一个loading状态的判断。但还有一个细节如果接口请求很快比如走了缓存loading状态可能只存在几十毫秒空状态虽然判断了!loading但因为Vue的异步更新机制可能还是会闪一下。我处理这个问题的经验是不要依赖loading状态来做唯一的闪烁控制可以在data里加一个initialized标志接口返回后置为truedata() { return { loading: true, initialized: false, list: [] }; }, created() { this.fetchList(); }, methods: { fetchList() { this.loading true; getList().then((res) { this.list res.data || []; this.initialized true; }).finally(() { this.loading false; }); } }模板判断改成app-empty v-ifinitialized list.length 0 /initialized的作用就是确保空状态只在接口真正返回且没有数据时显示不管loading有多短都不会出现抢跑渲染。5.4 组件复用与代码体积控制最后聊一下组件复用层面的问题。有同学会问这个组件这么简单是不是可以不用封装成文件直接在页面里写死就行了我的体会是项目里只有一处空数据场景你怎么写都行一旦出现两处以上就必须抽公共组件了。因为空状态的文案和图片产品经理一定会让你改改两处靠搜索改十处就痛苦了。组件化之后改一处全局生效。代码体积方面这个组件本身的体积非常小核心逻辑就是props校验、计算属性和插槽渲染打包后gzip之前也就几KB。不要为了省这几KB去牺牲统一维护性不划算。还有一点想补充如果你的项目已经用了Element UI 2.x可以直接用它自带的el-empty组件我上面这些封装经验可以当作理解其内部设计逻辑的参考。如果你用的是更老的项目、或者产品设计要求跟Element UI不一致那就自己封装成本真的不高。我自己更倾向于自研一个轻量的组件因为它不受组件库版本限制样式完全可控。空数据占位符这个需求说大不大说小不小但它确实是维护vue2老项目时绕不开的一个细节。我在实际项目里实现和优化这个组件的经验就是props不要贪多插槽要留够loading状态要配合好老项目的构建兼容性要提前考虑。照着这个思路写一个组件出来放到项目里用上一个月你就能感受到它给开发效率和体验一致性带来的提升了。如果你在接入过程中踩到其他坑欢迎拿你项目的实际情况来对照这篇文章排查一下。