如何用 vue3 + ts 开发一个瀑布流滚动加载的列表组件?2023-03-21 02:03:07

背景
    在开发移动端的时候,小伙伴们肯定遇到过向下滚动加载更多数据的需求,而数据以图片为主并且为了更好的吸引用户时,一般都会采用瀑布流滚动加载,那么如何用vue3跟ts去实现这样的加载呢?下面跟着vant4源码一起学习探讨一下~
收获清单

瀑布流滚动加载原理
如何计算是否滚动到底部
用vue3+ts实现瀑布流滚动加载

vant-list组件介绍
瀑布流滚动加载,用于展示长列表,当列表即将滚动到底部时,会触发事件并加载更多列表项。
源码下载
git clone https://github.com/youzan/vant.git
cd vant
pnpm install
pnpm run dev
复制代码
源码分析
利用demo打开list组件
    运行后找到list列表并利用vue-tools打开demo文件,操作如下图所示:

    demo所在文件位置

入口文件
import { withInstall } from ‘../utils’;

import _List, { ListProps } from ‘./List’;

export const List = withInstall(_List);

export default List;

export { listProps } from ‘./List’;

export type { ListProps };

export type { ListInstance, ListDirection, ListThemeVars } from ‘./types’;

declare module ‘vue’ {

export interface GlobalComponents {

VanList: typeof List;

}

}
复制代码
    不难看出入口文件的作用主要是注册list组件,抛出list的一些类型,我们可以在import后打debugger断点来调试分析一下具体实现,调试截图

withInstall方法
// …删减代码

export function withInstall(options: T) {

(options as Record).install = (app: App) => {

const { name } = options;

if (name) {

  app.component(name, options);

  app.component(camelize(`-${name}`), options);

}

};

return options as WithInstall;

}
复制代码
    withInstall方法的作用主要是利用app.component全局注册名为vant-list以及VanList的组件
List组件引用依赖及导出类型
import {
ref,
watch,
nextTick,
onUpdated,
onMounted,
defineComponent,
type ExtractPropTypes,
} from ‘vue’;

// Utils
import {
isHidden,
truthProp,
makeStringProp,
makeNumericProp,
createNamespace,
} from ‘../utils’;

// Composables
import { useRect, useScrollParent, useEventListener } from ‘@vant/use’;
import { useExpose } from ‘../composables/use-expose’;
import { useTabStatus } from ‘../composables/use-tab-status’;

// Components
import { Loading } from ‘../loading’;

// Types
import type { ListExpose, ListDirection } from ‘./types’;

const [name, bem, t] = createNamespace(‘list’);

export const listProps = {
error: Boolean,
offset: makeNumericProp(300),
loading: Boolean,
disabled: Boolean,
finished: Boolean,
errorText: String,
direction: makeStringProp(‘down’),
loadingText: String,
finishedText: String,
immediateCheck: truthProp,
};

export type ListProps = ExtractPropTypes;
复制代码
定义list组件
export default defineComponent({
name,

props: listProps,

emits: [‘load’, ‘update:error’, ‘update:loading’],

setup(props, { emit, slots }) {
// use sync innerLoading state to avoid repeated loading in some edge cases
const loading = ref(props.loading);
const root = ref();
const placeholder = ref();
const tabStatus = useTabStatus();
// 获取元素最近的可滚动父元素。
const scrollParent = useScrollParent(root);
// 省略代码
return () => {
const Content = slots.default?.();
const Placeholder =

;

  return (
    <div ref= { root } role = "feed" class={ bem() } aria - busy={ loading.value }>
      { props.direction === 'down' ? Content : Placeholder }
  { renderLoading() }
  { renderFinishedText() }
  { renderErrorText() }
  { props.direction === 'up' ? Content : Placeholder }
  </div>
  );

};
},
});
复制代码
    这部分代码主要是定义list组件的关键属性以及暴露一些属性,实际渲染的元素是通过插槽来实现的,这里涉及的知识主要是setup的上下文参数,接着在setup里面打debugger来细看一下实现的关键函数

    这是调试截图

check函数
const check = () => {
nextTick(() => {
if (
loading.value ||
props.finished ||
props.disabled ||
props.error ||
// skip check when inside an inactive tab
tabStatus?.value === false
) {
return;
}

    const { offset, direction } = props;
    const scrollParentRect = useRect(scrollParent);

    if (!scrollParentRect.height || isHidden(root)) {
      return;
    }

    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');
    }
  });
};

复制代码
    check函数的作用大致如下:

当加载中、加载完成、禁用加载、报错、当vant-tabs组件不在活跃的状态等情况时停止检查
当可滚动父元素没有高度或者父元素是隐藏状态时停止检查
判断是否触底

向上滚动触发加载时,scrollParentRect.top – placeholderRect.top <= offset为触底
向下滚动触发加载时,placeholderRect.bottom – scrollParentRect.bottom <= offset为触底

    示意图如下

如果触底了,修改加载状态并触发加载更多事件
这里主要是通过useRect来获取元素相对视口的位置,在上一期的学习中有详细分析,这里就简单提一下,咱们接着看一些函数即组合式api~

isHidden函数
export function isHidden(
elementRef: HTMLElement | Ref
) {
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;
}

复制代码
    isHidden判断元素隐藏

unref:如果参数是 ref,则返回内部值,否则返回参数本身。这是 val = isRef(val) ? val.value : val 计算的一个语法糖。
window.getComputedStyle获取元素是否有hidden属性
根据最近的定位元素是否返回null来判断是否元素是隐藏状态

useScrollParent
import { ref, Ref, onMounted } from ‘vue’;
import { inBrowser } from ‘../utils’;

type ScrollElement = HTMLElement | Window;

const overflowScrollReg = /scroll|auto|overlay/i;
const defaultRoot = inBrowser ? window : undefined;

function isElement(node: Element) {
const ELEMENT_NODE_TYPE = 1;
return (
node.tagName !== ‘HTML’ &&
node.tagName !== ‘BODY’ &&
node.nodeType === ELEMENT_NODE_TYPE
);
}

// https://github.com/vant-ui/vant/issues/3823
export function getScrollParent(
el: Element,
root: ScrollElement | undefined = defaultRoot
) {
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,
root: ScrollElement | undefined = defaultRoot
) {
const scrollParent = ref();

onMounted(() => {
if (el.value) {
scrollParent.value = getScrollParent(el.value, root);
}
});

return scrollParent;
}

复制代码

isElement判断是否是元素节点
getScrollParent 遍历获取最近父级滚动元素,即具有scroll|auto|overlay属性的元素
useScrollParent 返回元素最近的可滚动父元素

useEventListener
import { Ref, watch, isRef, unref, onUnmounted, onDeactivated } from ‘vue’;
import { onMountedOrActivated } from ‘../onMountedOrActivated’;
import { inBrowser } from ‘../utils’;

type TargetRef = EventTarget | Ref;

export type UseEventListenerOptions = {
target?: TargetRef;
capture?: boolean;
passive?: boolean;
};

export function useEventListener(
type: K,
listener: (event: DocumentEventMap[K]) => void,
options?: UseEventListenerOptions
): void;
export function useEventListener(
type: string,
listener: EventListener,
options?: UseEventListenerOptions
): void;
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 = (target?: TargetRef) => {
const element = unref(target);

if (element && !attached) {
  element.addEventListener(type, listener, {
    capture,
    passive,
  });
  attached = true;
}

};

const remove = (target?: TargetRef) => {
const element = unref(target);

if (element && attached) {
  element.removeEventListener(type, listener, capture);
  attached = false;
}

};

onUnmounted(() => remove(target));
onDeactivated(() => remove(target));
onMountedOrActivated(() => add(target));

if (isRef(target)) {
watch(target, (val, oldVal) => {
remove(oldVal);
add(val);
});
}
}

复制代码
    就是官方文档的介绍,即方便地进行事件绑定,在组件 mounted 和 activated 时绑定事件,unmounted 和 deactivated 时解绑事件。

useTabStatus
import { inject, ComputedRef, InjectionKey } from ‘vue’;

// eslint-disable-next-line
export const TAB_STATUS_KEY: InjectionKey> = Symbol();

export const useTabStatus = () => inject(TAB_STATUS_KEY, null);
复制代码
    这里主要用于van-tabs组件中,当所在的组件不处于活跃状态时不进行是否触底的计算
useExpose
import { getCurrentInstance } from ‘vue’;
import { extend } from ‘../utils’;

// expose public api
export function useExpose>(apis: T) {
const instance = getCurrentInstance();
if (instance) {
// Object.assign
extend(instance.proxy as object, apis);
}
}
复制代码
    暴露属性,利用Object.assign将api拷贝到组件实例中
renderFinishedText
const renderFinishedText = () => {
if (props.finished) {
const text = slots.finished ? slots.finished() : props.finishedText;
if (text) {
return

{ text } < /div>;
}
}
};
复制代码

渲染加载完成文字,若有插槽显示插槽,否则显示传入的加载文字
bem函数利用css的bem规范给类名加上 van-list__ 前缀
renderErrorText、renderLoading跟renderFinishedText的实现原理类似,只是判断条件有区别而已

总结
    到这里list源码就告一段落了,不知不觉又是一篇长文,今天调试分析了瀑布流滚动加载的实现,即通过比较可滚动父元素跟占位元素之间的差值与设置的offset的关系来判断是否滚动到底部从而触发加载更多,同时也加深了插槽的理解应用,还有bem规范等等,在实际的开发中也可以借鉴这种写法来封装自己的组件

« »