vue3组合式编程列表组件封装
最终效果示例:
列表类型不重要——文本框、图片流、卡片瀑布,任你选择。通过 slot 插槽注入自己的模板,组件只负责架构,内容交给你。

前言
UniApp 开发中,列表场景是绕不开的标配——初始加载、下拉刷新、分页、上拉加载更多,外加缺省页提示,每次都要写一遍,重复劳动不说,还容易漏掉边界。这次抽离出一个基于 Vue3 组合式 API 的通用列表封装,把那些琐碎的循环逻辑统一收敛到 hook 里,组件只做壳,业务代码只关心数据渲染。实测下来,开发效率提升不少。
组件实现
创建一个 MoreList 组件,核心骨架如下——主体通过 slot 容纳你的自定义列表,当数据为空时展示预设的缺省图,有数据则自动显示 uni-load-more 组件,支持“上拉加载更多”、“加载中”、“已经到底”三种状态。
<script setup>
import { ref } from "vue"; const props = defineProps({
moreStatus: {
type: String,
default: "more",
},
hasData: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["loadMore"]); const contentText = ref({
contentdown: "上拉加载更多",
contentrefresh: "数据加载中……",
contentnomore: "已经到底啦~",
}); const handleLoadMore = () => {
emit("loadMore");
};
script><template>
<view class="more-list">
<view class="list-content">
<view v-if="hasData">
<slot>slot>
view>
<view v-else class="empty-data">
<image src="/static/images/empty-data.png" class="empty-icon">image>
<view class="empty-text">数据为空view>
view>
view>
<uni-load-more
v-if="hasData"
:status="moreStatus"
:contentText="contentText"
@click="handleLoadMore"
>uni-load-more>
view>
template><style lang="scss" scoped>
.more-list {
height: 100%;
}
.empty-data {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24rpx;
}
.empty-icon {
width: 320rpx;
height: 320rpx;
}
.empty-text {
font-size: 32rpx;
color: #666;
}
style>
hook实现
接下来是核心逻辑层——useMoreList 组合式函数。它接收一个配置对象,自动完成分页参数维护、触底加载、状态流转,并暴露 listData、moreStatus、hasData 等响应式变量供组件消费。代码里还预留了 validateConfig 对配置项做校验(这里简化处理,实际可补充类型检查)。
import { ref, computed, isReactive, reactive } from "vue";
import { onReachBottom } from "@dcloudio/uni-app";/**
* 对配置项进行校验处理
* @param {*} config 配置项
* @param {*} fields 需要校验的字段数组
*/
const validateConfig = (config, fields) => {
// to do ...
};/**
* 无限滚动列表
* @param {*} config 配置项
* @param {*} config.api 接口函数
* @param {*} config.isAutoLoad 初始是否自动加载列表 默认true
* @param {*} config.pageSize 每页加载数量
* @param {*} config.params 其他参数
* @param {*} config.isRefreshBottom 是否触底刷新默认true
* @returns
*/
export const useMoreList = (config) => {
validateConfig(config);
// 列表
const listData = ref([]);
// 加载状态 more:加载更多 / noMore:没有更多了 / loading:加载中
const moreStatus = ref("more");
// 是否触底刷新
const isRefreshBottom = ref(true);
// 初始是否自动加载列表
const isAutoLoad = ref(true);
// 分页参数
const pagination = ref({
currentPage: 1,
pageSize: config?.pageSize || 10,
});
// api其他参数
const params = ref(null);
// 列表是否有数据
const hasData = computed(() => listData.value.length > 0);
// 加载更多
const handleLoadMore = async () => {
if (moreStatus.value !== "more") {
return;
}
moreStatus.value = "loading";
// 校验api函数是否合法
if (!config.api || typeof config.api !== "function") {
console.error("useMoreList 中 config.api 必须是一个函数");
return;
}
const res = await config.api({
...pagination.value,
...params.value,
});
if (res.code === 0) {
listData.value = [...listData.value, ...res.data];
moreStatus.value = listData.value.length >= res.data.total ? "noMore" : "more";
} else {
moreStatus.value = "noMore";
}
}; // 初始化配置项
const initConfig = () => {
if (config.params) {
params.value = reactive(config.params);
} if (config.isRefreshBottom !== undefined) {
isRefreshBottom.value = config.isRefreshBottom;
} if (config.isAutoLoad !== undefined) {
isAutoLoad.value = config.isAutoLoad;
} // 初始加载列表
if (isAutoLoad.value) {
handleLoadMore();
} // 是否绑定触底刷新事件
if (isRefreshBottom.value) {
onReachBottom(() => {
pagination.value.currentPage++;
handleLoadMore();
});
}
}; // 重置列表
const reset = () => {
listData.value = [];
moreStatus.value = "more";
pagination.value.currentPage = 1;
pagination.value.pageSize = config?.pageSize || 10;
handleLoadMore();
}; // 初始化配置项
initConfig(); return {
listData,
moreStatus,
hasData,
pagination,
params,
handleLoadMore,
reset,
};
};
使用
实际业务中怎么用?很简单——引入 MoreList 组件和 useMoreList hook,把接口回调传入,然后在 slot 里渲染你的列表模板。下面是一份带删除功能的帖子列表示例,卡片用了九宫格图片布局,代码结构很清晰。
<view class="container" :style="{ backgroundColor: postList.length > 0 ? '#f5f7fa' : '#ffffff' }">
<MoreList :moreStatus="moreStatus" :hasData="true" @loadMore="handleLoadMore">
<view class="post-list">
<view class="post-card" v-for="post in postList" :key="post.id">
<view class="post-title">{{ post.title }}view>
<view class="post-desc">{{ post.desc }}view>
<view class="post-images" :class="{ single: post.images.length === 1 }">
<image v-for="(img, index) in post.images" :key="index" :src="img" class="post-img" mode="aspectFill" />
view>
<view class="post-actions">
<view class="delete-btn" @click="handleDelete(post.id)">删除帖子view>
view>
view>
view>
MoreList>
view>
<script setup>
import { ref } from "vue";
import MoreList from "@/components/MoreList/index.vue";
import { useMoreList } from "@/hooks/useMoreList.js"; const { params, listData, moreStatus, handleLoadMore, hasData, reset } = useMoreList({
api: () => {},
}); const postList = ref([
{
id: 1,
title: "周末露营|风景超美",
desc: "和朋友一起去山里露营,晚上看星星,空气特别清新~",
images: ["https://picsum.photos/400/300?random=1"],
},
{
id: 2,
title: "日常穿搭分享",
desc: "简约通勤风,舒适百搭,上班族必备~",
images: [
"https://picsum.photos/400/300?random=2",
"https://picsum.photos/400/300?random=3",
"https://picsum.photos/400/300?random=4",
],
},
{
id: 3,
title: "我的生活碎片|6图",
desc: "记录生活中的美好瞬间,美食、风景、日常~",
images: [
"https://picsum.photos/400/300?random=5",
"https://picsum.photos/400/300?random=6",
"https://picsum.photos/400/300?random=7",
"https://picsum.photos/400/300?random=8",
"https://picsum.photos/400/300?random=9",
"https://picsum.photos/400/300?random=10",
],
},
{
id: 4,
title: "九宫格生活照|9图",
desc: "一次性分享9张生活照片,满满都是回忆~",
images: [
"https://picsum.photos/400/300?random=11",
"https://picsum.photos/400/300?random=12",
"https://picsum.photos/400/300?random=13",
"https://picsum.photos/400/300?random=14",
"https://picsum.photos/400/300?random=15",
"https://picsum.photos/400/300?random=16",
"https://picsum.photos/400/300?random=17",
"https://picsum.photos/400/300?random=18",
"https://picsum.photos/400/300?random=19",
],
},
{
id: 5,
title: "美食探店|打卡网红餐厅",
desc: "终于打卡了这家网红餐厅,环境超棒,菜品也很好吃!",
images: [
"https://picsum.photos/400/300?random=20",
"https://picsum.photos/400/300?random=21",
"https://picsum.photos/400/300?random=22",
],
},
{
id: 6,
title: "读书分享|近期书单",
desc: "最近读了几本书,分享给大家,每本都很值得一读~",
images: ["https://picsum.photos/400/300?random=23"],
},
]); const handleDelete = (id) => {
uni.showModal({
title: "提示",
content: "确定要删除这篇帖子吗?",
success: (res) => {
if (res.confirm) {
const index = postList.value.findIndex((post) => post.id === id);
if (index > -1) {
postList.value.splice(index, 1);
uni.showToast({
title: "删除成功",
icon: "success",
});
}
}
},
});
};
script><style lang="scss" scoped>
.container {
min-height: 100vh;
padding: 24rpx;
box-sizing: border-box;
} /* 列表容器 */
.post-list {
display: flex;
flex-direction: column;
gap: 32rpx;
} /* 帖子卡片 */
.post-card {
background: #fff;
border-radius: 32rpx;
padding: 32rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.06);
} /* 标题 */
.post-title {
font-size: 34rpx;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 16rpx;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
} /* 描述 */
.post-desc {
font-size: 28rpx;
color: #666;
margin-bottom: 24rpx;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
} /* 图片布局:自动支持 1~9 张 */
.post-images {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12rpx;
margin-bottom: 28rpx;
width: 100%;
} /* 1张图时占满整行 */
.post-images.single {
grid-template-columns: 1fr;
} .post-img {
width: 100%;
height: auto;
aspect-ratio: 1/1;
border-radius: 20rpx;
background: #f0f2f5;
} /* 操作栏 */
.post-actions {
display: flex;
justify-content: flex-end;
padding-top: 24rpx;
border-top: 2rpx solid #f0f2f5;
} /* 删除按钮 */
.delete-btn {
padding: 12rpx 28rpx;
border-radius: 40rpx;
background-color: #227cff;
color: #fff;
font-size: 26rpx;
}
style>
结尾
整体来看,这套封装足够覆盖日常开发里 80% 的列表场景——初始加载、自动触底、缺省图、重置刷新,一应俱全。代码没有写得很复杂,核心逻辑就在 hook 里,跟 Vue2 的 mixin 思路有点像,但组合式 API 让数据流更清晰、类型推导更友好。如果以上逻辑还不能完全匹配你的业务,欢迎直接在评论区聊聊需求,后续会持续迭代优化。(实测效果稳定,没有发现什么副作用。)
