最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • [vue] 省心省力的骨架屏2,列表骨架屏

    正文概述 掘金(背对疾风)   2021-04-03   1009

    vue3 骨架屏+上拉加载更多封装

    前言,介绍

    这个列表的骨架屏之前有个初代的版本,也写过一次博客,不过是发在了csdn上面,后来改了一次,因为那篇博文阅读量不高,也没动力去更了。初代版本跟现在主要区别是实现思路不一样,之前是使用骨架屏是一个模块,然后具体列表渲染又是一个模块,切换的时候是整个来切的,过渡虽然有,但是不是特别自然,这个第二版就是采用的和我之前的博文一个省心省力的骨架屏一样的方案了,都是替换css。写法上也有不同,这次参考了 有赞 团队的 vant 的list写法(抄了好多代码),相当于在 vant的list组件上加了骨架屏功能了,感谢vant团队。

    功能

    提供骨架屏展示、瀑布流滚动加载,用于展示长列表,当列表即将滚动到底部时,会触发事件并加载更多列表项。

    效果(选择网点时)

    [vue] 省心省力的骨架屏2,列表骨架屏

    用法

    <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-keyvue的for循环绑定的keyString,Function默认值为indexv-model:loading是否处于加载状态,加载过程中不触发 load 事件Booleanfalseerror是否加载失败,加载失败后点击错误提示可以重新触发 load 事件booleanfalsefinished是否已加载完成,加载完成后不再触发 load 事件Booleanfalseloading-text加载过程中的提示文案String加载中...finished-text加载完成后的提示文案String没有更多了...error-text加载失败后的提示文案String加载失败了,点我重新加载empty-text数据为空时的提示文案String暂无数据immediate-check是否在初始化时立即执行滚动位置检查Booleantrueempty-item设置骨架屏所用到的数据模板,主要用于撑开元素标签Object{}bind-scroll-document列表所在的可滑动的容器,默认为windowObjectwindow
    • 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 组件:

      1. init,初始化加载中,当loading为true且listData长度为0时,为init状态,此时显示骨架屏
      2. 非加载中,loading 为 false,此时会根据列表滚动位置判断是否触发 load 事件
      3. 加载中,loading 为 true,表示正在发送异步请求,此时不会触发 load 事件
      4. 加载完成,finished 为 true且listData长度不为0,此时不会触发 load 事件
      5. 暂无数据,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();
        }
      });
    }
    

    起源地下载网 » [vue] 省心省力的骨架屏2,列表骨架屏

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元