空数据占位符这个需求,在vue2项目里看着不起眼,但凡是做过真实业务系统的人都知道,列表为空、搜索无结果、Tab切换没数据这三件事,处理不好就是产品体验的硬伤。我经手过好几个vue2老项目,包括用hbuilderx打包的混合App、后台管理系统的数据大屏,几乎每个项目到最后都会面临同样的问题:接口正常返回了,但是数据是空的,页面上那一块区域就直接"塌"了。这篇文章不聊虚的,直接从组件设计、props规划、插槽机制到老项目改造,把vue2里空数据占位符的完整落地方案拆开讲清楚,适合正在维护vue2项目、或者想把空状态处理做规范的前端开发者参考。
1. 为什么空数据场景需要专门的占位符
1.1 空数据不是"空白",是产品体验的最后一个环节
很多开发者在初期写页面时,习惯用v-if="list.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 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| text | String | '暂无数据' | 主提示文案 |
| description | String | '' | 副描述文案,搜索场景常用 |
| image | String | '' | 自定义图片地址,不传则用默认占位图 |
| imageSize | Number/String | '' | 图片尺寸,可传'100px'或100 |
| showButton | Boolean | false | 是否显示操作按钮 |
| buttonText | String | '刷新' | 按钮文字 |
| buttonType | String | 'primary' | 按钮样式类型 |
这里有一个容易被忽略的点: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-scope,2.6之后统一推荐v-slot。我见过不少老项目里还有用slot="xxx"和slot-scope="props"的老写法,这类写法在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 class="empty-state" :class="customClass">组件根节点的命名也很关键。empty-state这种是比较常见的名字,但如果你同时用了UI组件库,要注意类名冲突。我在项目里习惯加一个统一前缀,比如app-empty,这样既表明身份,也降低冲突概率。
3. 完整实现:从零写一个可复用的Empty组件
3.1 组件模板与核心逻辑
接下来直接上代码。这个组件用Vue2的标准选项式API编写,不依赖任何第三方库,纯CSS实现,在任何vue2项目里都可以直接复制使用。
<template> <div class="app-empty" :class="customClass"> <div class="app-empty__image" :style="imageStyle"> <img v-if="image" :src="image" :alt="text" class="app-empty__img" /> <img v-else-if="$slots.image" class="app-empty__img" src="" alt="" /> <slot v-else name="image"> <svg class="app-empty__default-icon" viewBox="0 0 64 41" xmlns="http://www.w3.org/2000/svg"> <g fill="none" fill-rule="evenodd"> <path d="M0 1h64v39H0z" fill="#F5F6FA"/> <path d="M22 16h20v8H22z" fill="#D8DDE5"/> <circle cx="40" cy="16" r="3" fill="#AEB4BF"/> </g> </svg> </slot> </div> <div v-if="text" class="app-empty__text">{{ text }}</div> <div v-if="description" class="app-empty__description">{{ description }}</div> <div class="app-empty__action"> <slot> <button v-if="showButton" class="app-empty__button" :class="'app-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-if="image"渲染图片 - 如果没有
image属性但传了image插槽,渲染插槽 - 两者都没有,渲染内置的默认SVG图标
所以模板逻辑应该是:
<div class="app-empty__image" :style="imageStyle"> <img v-if="image" :src="image" :alt="text" class="app-empty__img" /> <slot v-else name="image"> <svg v-if="!image" class="app-empty__default-icon" ...></svg> </slot> </div>这里我实际清理过一版,最终可用的模板如下:
<template> <div class="app-empty" :class="customClass"> <div class="app-empty__image" :style="imageStyle"> <img v-if="image" :src="image" :alt="text" class="app-empty__img" /> <slot v-else name="image"> <div class="app-empty__default-icon"> <svg ...省略具体路径...></svg> </div> </slot> </div> <p v-if="text" class="app-empty__text">{{ text }}</p> <p v-if="description" class="app-empty__description">{{ description }}</p> <div class="app-empty__action"> <slot> <button v-if="showButton" class="app-empty__button" :class="'app-empty__button--' + buttonType" @click="$emit('button-click')" >{{ buttonText }}</button> </slot> </div> </div> </template>script部分的逻辑和计算属性:
<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 class="order-list"> <ul v-if="orders.length"> <li v-for="item in orders" :key="item.id">{{ item.name }}</li> </ul> <app-empty v-else text="暂无订单" description="去下单开启你的第一笔交易吧" show-button button-text="去下单" @button-click="goCreate" /> </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-if="currentList.length === 0" class="empty-box"> <img src="../../assets/empty.png" /> <p>暂无数据</p> </div> <ul v-else> ... </ul> </div> </template>每个tab写一遍,三个tab三份代码。如果其中两个tab的空状态文案还不一样,那真的是每天在复制粘贴中度过。
接入app-empty之后:
<template> <div class="order-page"> <el-tabs v-model="activeTab" @tab-click="handleTabClick"> <el-tab-pane v-for="tab in tabs" :key="tab.key" :label="tab.label" :name="tab.key"> <div v-loading="loading"> <ul v-if="tabList.length"> <li v-for="item in tabList" :key="item.id">...</li> </ul> <app-empty v-else :text="emptyText" :show-button="activeTab === 'all'" button-text="去下单" @button-click="handleEmptyAction" /> </div> </el-tab-pane> </el-tabs> </div> </template>这里的emptyText用计算属性,根据当前tab动态返回文案:
computed: { emptyText: function () { if (this.activeTab === 'pending') { return '暂无待处理订单,辛苦了'; } return '暂无相关订单'; } }注意我在app-empty外层套了一个v-loading(Element UI的加载指令)。为什么要这样?因为接口请求期间,页面应该显示loading状态,请求结束后如果是空数据,再显示空状态。如果直接把v-if="tabList.length"和v-else放在loading里,会出现一个很尴尬的瞬间:loading结束后、空状态渲染前,页面会闪一下空白。虽然组件本身没有做loading的逻辑,但通过v-loading指令和占位符的配合,可以在视觉上做到无缝衔接。
4. 进阶场景与扩展技巧
4.1 列表页与搜索页的空状态区分
列表页和搜索页的空状态,本质上要传递的信息完全不同:
- 列表页为空:当前模块还没有任何业务数据,用户的预期是"我可能需要去创建一条"
- 搜索结果为空:用户的搜索条件没有匹配到任何数据,预期是"我可能需要调整搜索条件"
如果两边用同一个文案和同一个按钮,体验就很怪。所以实际使用中,我会给组件加一个mode的概念,虽然底层实现没变,但文案和按钮的组合可以做差异化:
<!-- 列表页 --> <app-empty text="暂无合同数据" description="录入你的第一份合同,开始管理合同信息" show-button button-text="新建合同" @button-click="handleCreate" /> <!-- 搜索页 --> <app-empty text="未找到相关结果" description="试试调整搜索关键词,或清空筛选条件" show-button button-text="重置搜索" button-type="default" @button-click="handleReset" />这里还有一个细节:搜索页的按钮颜色尽量用default或者text,不要用primary。因为搜索重置是辅助操作,视觉权重不该高于"新建合同"这类主操作。组件通过buttonType参数就能直接控制,这也是我当时设计这个prop的初衷。
4.2 与路由、权限、Loading的组合使用
真实项目的空数据场景往往不是孤立的。我在一个数据管理后台遇到过一种情况:某个菜单页面打开后,接口还没返回时,页面显示的是一个大大的空占位符,过了一秒数据才出来。用户反馈说"页面一跳一跳的"。
排查后发现,是开发者在created钩子里先设置了list = [],然后页面的v-else就命中了空状态组件,而loading层又没有挡住这个区域。要解决这个问题,简单的思路是:空数据占位符必须放在loading层内部,并且依赖loading状态来控制是否显示。
标准的组合写法:
<template> <div class="page-wrapper"> <div v-loading="loading" class="page-content"> <app-empty v-if="!loading && list.length === 0" text="没有数据" /> <ul v-else-if="list.length > 0"> <li v-for="item in list" :key="item.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老项目中要注意:模板里:src="image"的路径是运行时解析的,不是构建期解析的。如果你传的是../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 class="app-empty__action"> <slot v-if="showButton"> <button>内置按钮</button> </slot> </div>这种情况下,如果showButton是false,不管业务方传不传默认插槽,内容都不会渲染。插槽的作用域比v-if更容易被开发者忽略。所以我最后采用的方案是:action区域的插槽不绑定v-if,是否显示按钮由插槽是否有内容来决定。
<div class="app-empty__action"> <slot> <button v-if="showButton" @click="$emit('button-click')">{{ buttonText }}</button> </slot> </div>这样设计之后,业务方传了插槽就用插槽的内容,没传插槽且showButton为true时就显示内置按钮,逻辑清楚多了。
5.2 scoped样式下图片不显示的坑
这个坑比较隐蔽。组件内部用了scoped,模板里渲染了外部传入的image地址。正常情况下没问题,但如果你传给image的是本地相对路径,且组件放在不同的目录层级下,图片就会404。
这个问题的本质是路径解析:组件模板里的:src="image",image值是业务页面里写死的路径字符串,浏览器会基于当前页面URL去解析。如果业务页面路径和图片实际路径不一致,就会找不到。
解决方式前面说过,用绝对路径或者require。但还有一个相关的问题:如果你在业务页面里这么写:
<app-empty :image="require('@/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标志,接口返回后置为true:
data() { 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-if="initialized && list.length === 0" />initialized的作用就是确保空状态只在接口真正返回且没有数据时显示,不管loading有多短,都不会出现抢跑渲染。
5.4 组件复用与代码体积控制
最后聊一下组件复用层面的问题。有同学会问:这个组件这么简单,是不是可以不用封装成文件,直接在页面里写死就行了?我的体会是:项目里只有一处空数据场景,你怎么写都行;一旦出现两处以上,就必须抽公共组件了。因为空状态的文案和图片,产品经理一定会让你改,改两处靠搜索,改十处就痛苦了。组件化之后,改一处全局生效。
代码体积方面,这个组件本身的体积非常小,核心逻辑就是props校验、计算属性和插槽渲染,打包后gzip之前也就几KB。不要为了省这几KB去牺牲统一维护性,不划算。
还有一点想补充:如果你的项目已经用了Element UI 2.x,可以直接用它自带的el-empty组件,我上面这些封装经验可以当作理解其内部设计逻辑的参考。如果你用的是更老的项目、或者产品设计要求跟Element UI不一致,那就自己封装,成本真的不高。我自己更倾向于自研一个轻量的组件,因为它不受组件库版本限制,样式完全可控。
空数据占位符这个需求,说大不大,说小不小,但它确实是维护vue2老项目时绕不开的一个细节。我在实际项目里实现和优化这个组件的经验就是:props不要贪多,插槽要留够,loading状态要配合好,老项目的构建兼容性要提前考虑。照着这个思路写一个组件出来,放到项目里用上一个月,你就能感受到它给开发效率和体验一致性带来的提升了。如果你在接入过程中踩到其他坑,欢迎拿你项目的实际情况来对照这篇文章排查一下。