【源码学习】如何用 vue3 + ts 开发一个瀑布流滚动加载的列表组件?
背景
在开发移动端的时候,小伙伴们肯定遇到过向下滚动加载更多数据的需求,而数据以图片为主并且为了更好的吸引用户时,一般都会采用瀑布流滚动加载,那么如何用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<T extends Component>(options: T) {
(options as Record<string, unknown>).install = (app: App) => {
const { name } = options;
if (name) {
app.component(name, options);
app.component(camelize(`-${name}`), options);
}
};
return options as WithInstall<T>;
}
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<ListDirection>('down'),
loadingText: String,
finishedText: String,
immediateCheck: truthProp,
};
export type ListProps = ExtractPropTypes<typeof listProps>;
定义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<HTMLElement>();
const placeholder = ref<HTMLElement>();
const tabStatus = useTabStatus();
// 获取元素最近的可滚动父元素。
const scrollParent = useScrollParent(root);
// 省略代码
return () => {
const Content = slots.default?.();
const Placeholder = <div ref={ placeholder } class={ bem('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<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;
}
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<Element | undefined>,
root: ScrollElement | undefined = defaultRoot
) {
const scrollParent = ref<Element | Window>();
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<EventTarget | undefined>;
export type UseEventListenerOptions = {
target?: TargetRef;
capture?: boolean;
passive?: boolean;
};
export function useEventListener<K extends keyof DocumentEventMap>(
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<ComputedRef<boolean>> = 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<T = Record<string, any>>(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 <div class={ bem('finished-text') }> { text } < /div>;
}
}
};
- 渲染加载完成文字,若有插槽显示插槽,否则显示传入的加载文字
- bem函数利用css的bem规范给类名加上
van-list__
前缀 renderErrorText、renderLoading跟renderFinishedText的实现原理类似,只是判断条件有区别而已
总结
到这里list源码就告一段落了,不知不觉又是一篇长文,今天调试分析了瀑布流滚动加载的实现,即通过比较可滚动父元素跟占位元素之间的差值与设置的offset的关系来判断是否滚动到底部从而触发加载更多,同时也加深了插槽的理解应用,还有bem规范等等,在实际的开发中也可以借鉴这种写法来封装自己的组件~
转载自:https://juejin.cn/post/7212079828479426618