vue3 骨架屏+上拉加载更多封装
前言,介绍
这个列表的骨架屏之前有个初代的版本,也写过一次博客,不过是发在了csdn上面,后来改了一次,因为那篇博文阅读量不高,也没动力去更了。初代版本跟现在主要区别是实现思路不一样,之前是使用骨架屏是一个模块,然后具体列表渲染又是一个模块,切换的时候是整个来切的,过渡虽然有,但是不是特别自然,这个第二版就是采用的和我之前的博文一个省心省力的骨架屏一样的方案了,都是替换css。写法上也有不同,这次参考了 有赞 团队的 vant 的list写法(抄了好多代码),相当于在 vant的list组件上加了骨架屏功能了,感谢vant团队。
功能
提供骨架屏展示、瀑布流滚动加载,用于展示长列表,当列表即将滚动到底部时,会触发事件并加载更多列表项。
效果(选择网点时)
用法
<div class="router_view" style="height:800px;overflow-y: auto;">
<ListView
:list-data="data"
:bind-scroll-document="routerView"
:empty-item="emptyItem"
:finished="finished"
v-model:loading="showLoading"
:error="showError"
@load="requestData"
v-slot="{ item }"
>
<div class="item row-center item-between">
<img
class="item_pic"
:src="item.full_photo"
/>
<div class="col center_info">
<span class="name">
联系人: {{ item.concact_name }}
</span>
<span class="time">
填写时间: {{ item.collection_time }}
</span>
</div>
<router-link
:to="{ path: '/form',
query: { mode: 'edit_draft',fileId:item.fileid }}"
class="edit_text"
>编辑</router-link
</div>
</ListView>
</div>
<script lang='ts'>
import { defineComponent, reactive, toRefs } from "vue";
import ListView from "@/components/list_view/list_view.vue";
export default defineComponent({
name: "",
components: {
ListView,
},
setup() {
const state = reactive({
list: [],
showLoading: true,
showError: false,
finished: false
});
// 设置骨架屏所用到的数据模板,主要用于撑开span标签
const emptyItem = {
full_photo: "",
concact_name: "asdasd",
collection_time: "2021-3-3 15:23",
};
//绑定一个可滑动的容器,默认情况下是window,也就是浏览器的默认滑动
// 如果限定列表是在某一个元素内滑动,就需要把这个可滑动的元素传入ListView组件
// 用来绑定滑动事件,如果没有不传就好了
const routerView = document.querySelector(".router_view");
const requestData = () => {
// 异步更新数据
// setTimeout 仅做示例,真实场景中一般为 ajax 请求
setTimeout(() => {
for (let i = 0; i < 10; i++) {
state.list.push({
full_photo:"http://static.feidaojixie.com/machine/51271/full_photo/e9532332299e357ab815373a145f8ce2",
concact_name: "联系人" + (state.list.length + 1),
collection_time: "2021-3-3 15:23",
});
}
// 加载状态结束
state.showLoading = false;
// 数据全部加载完成
if (state.list.length >= 40) {
state.finished = true;
}
}, 3000);
};
return {
...toRefs(state),
emptyItem,
routerView,
requestData,
};
},
});
</script>
- API
- Props
参数 | 说明 | 类型 | 默认值 | list-data | 数据数组 | Array | [] | bind-key | vue的for循环绑定的key | String,Function | 默认值为index | v-model:loading | 是否处于加载状态,加载过程中不触发 load 事件 | Boolean | false | error | 是否加载失败,加载失败后点击错误提示可 | 以重新触发 load 事件 | boolean | false | finished | 是否已加载完成,加载完成后不再触发 load 事件 | Boolean | false | loading-text | 加载过程中的提示文案 | String | 加载中... | finished-text | 加载完成后的提示文案 | String | 没有更多了... | error-text | 加载失败后的提示文案 | String | 加载失败了,点我重新加载 | empty-text | 数据为空时的提示文案 | String | 暂无数据 | immediate-check | 是否在初始化时立即执行滚动位置检查 | Boolean | true | empty-item | 设置骨架屏所用到的数据模板,主要用于撑开元素标签 | Object | {} | bind-scroll-document | 列表所在的可滑动的容器,默认为window | Object | window |
---|
- Events
事件名 | 说明 | 回调参数 | load | 滚动条与底部距离小于 offset 时触发 | - |
---|
- Slots
名称 | 说明 | default | 列表内容 | loading | 自定义底部加载中提示 | finished | 自定义底部加载完成提示 | error | 自定义底部加载失败提示 | empty | 自定义列表数据为空提示 |
---|
问题
-
骨架屏使用什么实现? 骨架屏是通过css样式给子项中的img和span、a标签设置背景色来实现的,所以需要传递 empty-item 参数来撑起列表元素的span和a标签,如果你还使用了其他标签,可以参考源码中css样式添加其他标签
-
List 的运行机制是什么? List 会监听浏览器或目标元素的滚动事件并计算列表的位置,当列表底部与可视区域的距离小于 offset 时,List 会触发一次 load 事件。
-
loading 和 finished 分别是什么含义? List 有以下五种状态,理解这些状态有助于你正确地使用 List 组件:
- init,初始化加载中,当loading为true且listData长度为0时,为init状态,此时显示骨架屏
- 非加载中,loading 为 false,此时会根据列表滚动位置判断是否触发 load 事件
- 加载中,loading 为 true,表示正在发送异步请求,此时不会触发 load 事件
- 加载完成,finished 为 true且listData长度不为0,此时不会触发 load 事件
- 暂无数据,finished为true,loading为false,finished 为true
在每次请求完毕后,需要手动将 loading 设置为 false,表示加载结束
全部代码
// 使用yarn构建
vue create --preset direct:https://gitee.com/wqja/vue3_ts_preset.git --clone my-project
- ListView.tsx
import {computed, defineComponent, nextTick, onMounted, onUpdated, PropType, ref, watch} from 'vue';
import './list_view.css'
import './skeleton.css'
// 这里直接使用了vant的工具类
import { useRect, useScrollParent, useEventListener } from '@/use';
import {isHidden} from "@/util/Utils";
type ViewStatusType = 'INIT'|'SHOW'|'ERROR'|'FINISHED'|'EMPTY';
export default defineComponent({
name:'ListViewTSX',
props:{
listData: {
type: Array,
default: () => {
return [];
},
},
bindKey:{
type: [String,Function],
default:() => null
},
loading: {
type: Boolean,
default: () => false,
},
error: {
type: Boolean,
default: () => false,
},
finished: {
type: Boolean,
default: () => false,
},
emptyText: {
type: String,
default: () => {
return "暂无数据";
},
},
emptyItem: {
type: Object,
default: () => {
return {};
}
},
placeCount:{
type: Number,
default: () => {
return 10;
}
},
loadingText: {
type: String,
default: () => "加载中...",
},
finishedText: {
type: String,
default: () => "没有更多了",
},
errorText: {
type: String,
default: () => "加载失败了,点我重新加载",
},
offset: {
type: Number,
default: () => 100,
},
immediateCheck: {
type: Boolean,
default: () => true,
},
direction: {
type: String as PropType<'up' | 'down'>,
default: 'down',
},
noPackage:{
type: Boolean,
default:()=>{
return false;
}
}
},
emits: ['load', 'update:error', 'update:loading'],
setup(props,{ emit, slots }) {
const loading = ref(false);
const root = ref<HTMLElement>();
const placeholder = ref<HTMLElement>();
const scrollParent = useScrollParent(root);
const check = () => {
nextTick(() => {
if (loading.value || props.finished || props.error) {
return;
}
const { offset,direction } = props;
const scrollParentRect = useRect(scrollParent);
if (!scrollParentRect.height || isHidden(root)) {
return false;
}
let isReachEdge = false;
const placeholderRect = useRect(placeholder);
if (direction === 'up') {
isReachEdge = scrollParentRect.top - placeholderRect.top <= offset;
} else {
isReachEdge =
placeholderRect.bottom - scrollParentRect.bottom <= offset;
}
if (isReachEdge) {
loading.value = true;
emit('update:loading', true);
emit('load');
}
});
};
watch([() => props.loading, () => props.finished], check);
onUpdated(() => {
loading.value = props.loading!;
});
onMounted(() => {
if (props.immediateCheck) {
check();
}
});
useEventListener('scroll', check, { target: scrollParent });
const clickErrorText = () => {
emit('update:error', false);
check();
};
const viewStatus = computed<ViewStatusType>(():ViewStatusType => {
if (props.listData.length === 0 && props.loading) {
return "INIT";
}
if (props.listData.length === 0 && props.finished) {
return "EMPTY";
}
if (props.error) {
return "ERROR";
}
if (props.finished) {
return "FINISHED";
}
return "SHOW";
});
const listData = computed(()=>{
if(viewStatus.value==='INIT'){
const emptyArr = [];
const count = props.placeCount>10?10:props.placeCount;
for(let i=0;i<count;i++){
emptyArr.push(props.emptyItem);
}
return emptyArr;
}else if(viewStatus.value === 'EMPTY'){
return []
}
return props.listData;
})
const itemKeyFun = (item,index):string => {
if(viewStatus.value==='INIT'){
return index;
}
if(!props.bindKey){
return index;
}else{
if(props.bindKey instanceof Function){
return props.bindKey(item,index);
}else{
return item[props.bindKey]
}
}
}
const contentList = () =>{
if(props.noPackage){
return listData.value.map((e,index)=>{
return slots.default?.({
item:e,
index:index,
vClass:viewStatus.value==='INIT'?'skeleton-view-empty-view':'skeleton-view-default-view'
})
})
}else{
return listData.value.map((e,index)=>{
return (
<div key={itemKeyFun(e,index)} class={viewStatus.value==='INIT'?'skeleton-view-empty-view':'skeleton-view-default-view'}>{
slots.default?.({
item:e,
index:index
})}
</div>
)
})
}
}
const renderFinishedText = () => {
if (viewStatus.value === 'FINISHED') {
const text = slots.finished ? slots.finished() : props.finishedText;
if (text) {
return <div class='list-view-center'>{text}</div>;
}
}
};
const renderErrorText = () => {
if (viewStatus.value === 'ERROR') {
const text = slots.error ? slots.error() : props.errorText;
if (text) {
return (
<div class='list-view-center' onClick={clickErrorText}>
{text}
</div>
);
}
}
};
const renderLoading = () => {
if (viewStatus.value === 'SHOW') {
return (
<div class='list-view-center'>
{slots.loading ? (
slots.loading()
) : (
<div class='list-view-center' onClick={clickErrorText}>
加载中...
</div>
)}
</div>
);
}
};
return () => {
const Content = contentList();
const Placeholder = <div ref={placeholder} />;
return (
<div ref={root} role="feed" >
{props.direction === 'down' ? Content : Placeholder}
{renderLoading()}
{renderFinishedText()}
{renderErrorText()}
{props.direction === 'up' ? Content : Placeholder}
</div>
);
}
}
})
- list_view.css
.list-view-center{
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 20px;
color: #777;
font-size: 15px;
}
- skeleton.css 和CalmView 的一样,具体介绍可点击这里查看
.skeleton-view-default-view span,
.skeleton-view-default-view a,
.skeleton-view-default-view img
{
transition: all .7s ease;
background-color: rgba(0, 0, 0, 0)
}
.skeleton-view-empty-view {
pointer-events: none;
}
.skeleton-view-empty-view span,
.skeleton-view-empty-view a {
color: rgba(0, 0, 0, 0) !important;
border-radius: 2px;
background: linear-gradient(
-45deg,
#F5F5F5 0%,
#DCDCDC 25%,
#F5F5F5 50%,
#DCDCDC 75%,
#F5F5F5 100%
);
animation: gradientBG 4s ease infinite;
background-size: 400% 400%;
background-color:#DCDCDC;
transition: all 1s ease;
}
/* [src=""],img:not([src])*/
.skeleton-view-empty-view img {
content: url(../../assets/img/no_url.png);// 一张空白的图片,可自行替换
border-radius: 2px;
background: linear-gradient(
-45deg,
#F5F5F5 0%,
#DCDCDC 25%,
#F5F5F5 50%,
#DCDCDC 75%,
#F5F5F5 100%
);
animation: gradientBG 4s ease infinite;
background-size: 400% 400%;
background-color:#DCDCDC;
transition: all 1s ease;
}
@keyframes gradientBG {
0% {
background-position: 100% 100%;
}
50% {
background-position: 0% 0%;
}
100% {
background-position: 100% 100%;
}
}
相关工具类(全都是复制的vant里的工具类,如有侵权,请联系我删除)
- isHidden
import { unref, Ref } from 'vue';
export function isHidden(
elementRef: HTMLElement | Ref<HTMLElement | undefined>
) {
const el = unref(elementRef);
if (!el) {
return false;
}
const style = window.getComputedStyle(el);
const hidden = style.display === 'none';
// offsetParent returns null in the following situations:
// 1. The element or its parent element has the display property set to none.
// 2. The element has the position property set to fixed
const parentHidden = el.offsetParent === null && style.position !== 'fixed';
return hidden || parentHidden;
}
- useRect
import { Ref, unref } from 'vue';
function isWindow(val: unknown): val is Window {
return val === window;
}
export const useRect = (
elementRef: (Element | Window) | Ref<Element | Window | undefined>
) => {
const element = unref(elementRef);
if (isWindow(element)) {
const width = element.innerWidth;
const height = element.innerHeight;
return {
top: 0,
left: 0,
right: width,
bottom: height,
width,
height,
};
}
if (element && element.getBoundingClientRect) {
return element.getBoundingClientRect();
}
return {
top: 0,
left: 0,
right: 0,
bottom: 0,
width: 0,
height: 0,
};
};
- useEventListener
import { Ref, unref, onUnmounted, onDeactivated } from 'vue';
import { onMountedOrActivated } from '../onMountedOrActivated';
const inBrowser = typeof window !== 'undefined';
let supportsPassive = false;
if (inBrowser) {
try {
const opts = {};
Object.defineProperty(opts, 'passive', {
get() {
supportsPassive = true;
},
});
window.addEventListener('test-passive', null as any, opts);
// eslint-disable-next-line no-empty
} catch (e) {}
}
export type UseEventListenerOptions = {
target?: EventTarget | Ref<EventTarget | undefined>;
capture?: boolean;
passive?: boolean;
};
export function useEventListener(
type: string,
listener: EventListener,
options: UseEventListenerOptions = {}
) {
if (!inBrowser) {
return;
}
const { target = window, passive = false, capture = false } = options;
let attached: boolean;
const add = () => {
const element = unref(target);
if (element && !attached) {
element.addEventListener(
type,
listener,
supportsPassive ? { capture, passive } : capture
);
attached = true;
}
};
const remove = () => {
const element = unref(target);
if (element && attached) {
element.removeEventListener(type, listener, capture);
attached = false;
}
};
onUnmounted(remove);
onDeactivated(remove);
onMountedOrActivated(add);
}
- useScrollParent
import { ref, Ref, onMounted } from 'vue';
type ScrollElement = HTMLElement | Window;
const overflowScrollReg = /scroll|auto/i;
function isElement(node: Element) {
const ELEMENT_NODE_TYPE = 1;
return (
node.tagName !== 'HTML' &&
node.tagName !== 'BODY' &&
node.nodeType === ELEMENT_NODE_TYPE
);
}
// https://github.com/youzan/vant/issues/3823
function getScrollParent(el: Element, root: ScrollElement = window) {
let node = el;
while (node && node !== root && isElement(node)) {
const { overflowY } = window.getComputedStyle(node);
if (overflowScrollReg.test(overflowY)) {
return node;
}
node = node.parentNode as Element;
}
return root;
}
export function useScrollParent(el: Ref<Element | undefined>) {
const scrollParent = ref<Element | Window>();
onMounted(() => {
if (el.value) {
scrollParent.value = getScrollParent(el.value);
}
});
return scrollParent;
}
- onMountedOrActivated
import { nextTick, onMounted, onActivated } from 'vue';
export function onMountedOrActivated(hook: () => any) {
let mounted: boolean;
onMounted(() => {
hook();
nextTick(() => {
mounted = true;
});
});
onActivated(() => {
if (mounted) {
hook();
}
});
}
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!