Vant 的 Card 卡片组件是移动端商品列表、订单详情、活动入口等页面的高频基础块。它把缩略图、标题、描述、价格、数量、底部按钮等常用信息做了统一布局,并预留了多个插槽用于扩展。要真正用好它,需要理解默认渲染顺序、props 的显示规则以及插槽在 Vue 3 中的作用域变化。本文以 Vant 4 为例,结合可运行的 Vue 3 组合式 API 代码,梳理从基础渲染到自定义布局的完整思路,并汇总常见问题。

注意示例中组件名为 <van-card>,在使用按需引入时需要通过插件或 unplugin-vue-components 的解析器自动注册,否则页面会提示未知组件。下文涉及完整模板代码,变量命名与 Vant 官方文档保持一致。
一、基础引入与核心属性解析
在 Vue 3 项目中安装 Vant 后,如果只需要使用 Card 组件,可以按需引入,避免全量加载影响首屏体积。如果项目已经通过 unplugin-vue-components 配置了自动按需引入,则直接写 <van-card> 即可;如果手动引入,则需要显式注册组件并引入对应样式。
import { createApp } from 'vue';
import { Card } from 'vant';
import 'vant/lib/index.css';
const app = createApp();
app.use(Card);
app.mount('#app');
Card 组件的常用属性包括 thumb、title、desc、tag、num、price、origin-price、currency、centered、lazy-load 和 thumb-link。其中 thumb 接收图片地址,price 和 origin-price 用于显示现价与划线原价,num 用来展示购买数量,tag 可以在图片角标位置显示一条文本标签。默认情况下标题、描述、价格和数量会按照 Vant 预设的移动端样式排布,如果不想让卡片内容垂直居中,可以设置 :centered="false" 让内容顶部对齐,这在描述文字较长时非常有用。
下面是一个最基础的商品卡片示例,展示了图片、标题、描述、价格和数量的组合渲染。注意 thumb 的图片地址必须可访问,否则会出现占位空白。实际项目中可以使用商品列表接口返回的缩略图地址,并按需开启 lazy-load 实现懒加载。
<template>
<div class="page">
<van-card
num="2"
price="88.00"
desc="纯棉基础款,多色可选"
title="简约短袖T恤"
thumb="https://picsum.photos/200/200?random=2"
/>
</div>
</template>
<script setup>
import { ref } from 'vue';
</script>
这个示例没有使用任何插槽,但已经能覆盖大多数简单卡片场景。价格部分如果传入数字类型,JavaScript 的浮点误差可能导致显示异常,因此建议统一使用字符串或先做格式化。比如后端返回 price: 88,前端可以转换成 88.00 再传给组件,保证视觉统一。
二、插槽与事件:让 Card 支持复杂布局
基础用法只能满足固定的图文布局,一旦需要加入标签组、自定义按钮或底部说明信息,就必须使用具名插槽。Vant Card 提供了 default、title、desc、num、price、origin-price、tags、tag、thumb、footer、bottom 等插槽。其中 tags 插槽通常用来放多个标签,footer 插槽放右下角操作按钮,thumb 插槽可以完全替换图片区域。
事件方面,Card 组件主要涉及两个:click 和 click-thumb。click 在点击卡片任意位置时触发,click-thumb 在点击缩略图时触发。如果在 footer 插槽中放了按钮,点击按钮会冒泡触发卡片本身的 click 事件,导致页面上出现“只点按钮却整卡跳转”的问题。解决方式很简单,在按钮事件上添加 .stop 修饰符即可阻止冒泡。
<template>
<van-card
title="无线蓝牙耳机"
desc="降噪版 30小时续航"
price="299.00"
origin-price="399.00"
thumb="https://picsum.photos/200/200?random=3"
@click="onCardClick"
@click-thumb="onThumbClick"
>
<template #tags>
<van-tag type="danger">新品</van-tag>
<van-tag plain type="primary">包邮</van-tag>
</template>
<template #footer>
<van-button size="small" type="primary" @click.stop="onAddCart">加入购物车</van-button>
</template>
</van-card>
</template>
<script setup>
const onCardClick = () => {
console.log('卡片被点击');
};
const onThumbClick = () => {
console.log('缩略图被点击');
};
const onAddCart = () => {
console.log('加入购物车');
};
</script>
这段代码中 #tags 插槽放入了两个 Vant Tag 组件,它们会显示在原本单个 tag 属性所在区域附近。#footer 插槽放入了操作按钮,按钮点击时通过 @click.stop 阻止事件继续传递,因此不会触发外层 onCardClick。
三、Vant Card常见问题解答汇总
日常使用中关于 van-card 的问题主要集中在图片、样式、事件和价格四个方面。以下汇总了高频情况,并给出具体修复思路。
1. 图片不显示或只显示占位
如果 thumb 已经传了地址但图片仍然不显示,先检查 URL 是否可访问,以及是否有防盗链限制。如果开启了 lazy-load,确认图片容器高度是否被父级 CSS 压缩为 0,或者容器有没有设置 overflow: hidden 导致加载后不可见。在移动端列表中尤其要注意 van-card__thumb 的宽高,默认是 88px 左右的方形区域,如果 URL 返回的图片比例差异过大,可能会被裁切,但不影响显示。
2. 修改内部样式不生效
在 Vue 单文件组件中,如果使用了 scoped 样式,直接写 .van-card__title 不会命中组件内部 DOM,因为 Vant 的样式作用域隔离了。正确做法是使用 :deep() 穿透。比如要调整标题字号和缩略图圆角,可以这样处理:
.card-wrapper :deep(.van-card__thumb) {
width: 100px;
height: 100px;
border-radius: 8px;
}
.card-wrapper :deep(.van-card__title) {
font-size: 16px;
line-height: 1.4;
}
还需要注意优先级问题。如果某些样式怎么都改不动,可以通过浏览器开发者工具查看实际生成的 class 名称,确认选择器是否准确。
3. 点击事件冲突
当卡片整体可点击,同时右下角又有“加入购物车”“查看详情”等按钮时,需要在按钮的点击事件后追加 .stop 修饰符。如果不加,按钮点击会继续冒泡到卡片容器的 click 事件,导致操作逻辑串联。反过来,如果希望整个卡片点击后跳转详情,但缩略图有独立行为,则使用 click-thumb 单独处理缩略图点击,避免和 click 重复。
4. 价格小数位和货币符号问题
价格建议传给组件前先格式化,例如 const formatPrice = (value) => Number(value || 0).toFixed(2)。这样能避免 price="88" 和 price="88.00" 视觉不一致的问题。货币符号可以通过 currency 属性传入,默认是人民币符号。如果使用自定义币种,不要直接在 price 字符串里拼接,否则排序和计算可能受影响。
5. 多个 Card 之间间距过大
Vant Card 组件底部默认带有边距,多个卡片堆叠时会出现明显间隔。在商品列表页中通常会去掉这部分间距,改用边框或分割线区分条目。可以通过外层容器配合深度选择器重置:
.card-list {
padding: 10px;
}
.card-list .van-card {
margin-bottom: 0;
border-bottom: 1px solid #ebedf0;
}
如果使用 scoped,记得套上 :deep()。在长列表场景中,不建议直接改全局 Vant 样式,因为会影响其他页面的卡片间距。通过页面级容器类控制是最稳妥的做法。
四、商品列表卡片封装实践
实际项目中,商品卡片通常不会直接散落页面,而是封装成一个业务组件统一管理。这样可以避免每个页面重复写标签、插槽和价格格式化逻辑。下面是一个简单的 ProductCard 组件封装示例,它接收一个商品对象,内部渲染 van-card 并向外抛出选中和加入购物车事件。
<template>
<div class="product-card">
<van-card
:num="product.num"
:price="formatPrice(product.price)"
:origin-price="formatPrice(product.originPrice)"
:desc="product.desc"
:title="product.title"
:thumb="product.thumb"
@click="$emit('select', product)"
>
<template #tags>
<van-tag
v-for="tag in product.tags"
:key="tag"
plain
type="primary"
>{{ tag }}</van-tag>
</template>
<template #footer>
<van-button
size="small"
type="danger"
@click.stop="$emit('add-cart', product)"
>加入购物车</van-button>
</template>
</van-card>
</div>
</template>
<script setup>
const props = defineProps({
product: {
type: Object,
required: true
}
});
const emit = defineEmits(['select', 'add-cart']);
const formatPrice = (value) => {
return Number(value || 0).toFixed(2);
};
</script>
<style scoped>
.product-card :deep(.van-card) {
margin-bottom: 0;
}
.product-card :deep(.van-card__thumb) {
border-radius: 6px;
overflow: hidden;
}
</style>
这个封装重点关注两点:一是价格在传给 van-card 之前已经完成格式化,父组件不需要关心显示细节;二是 footer 中的按钮阻止了事件冒泡,避免触发外层 select。在列表页使用该组件时,直接监听 select 和 add-cart 事件即可完成页面业务串联。
如果后续需要增加库存、活动角标、划线说明等字段,可以在 ProductCard 内部继续扩展对应的插槽,而不会影响外层页面结构。对于更复杂的列表,还可以配合 Vant 的 List 组件实现滚动加载,但卡片本身保持轻量,不要把所有业务逻辑都塞进 Card 内部,否则会降低可维护性。