kt-blog-web/src/components/blog/ArticleList.tsx

151 lines
3.9 KiB
TypeScript

import { Empty } from 'antdv-next';
import {
computed,
defineComponent,
nextTick,
onBeforeUnmount,
onMounted,
ref,
TransitionGroup,
type ComponentPublicInstance,
type PropType,
watch,
} from 'vue';
import type { BlogArticle } from '@/data/blog';
import ArticleCard from './ArticleCard';
export default defineComponent({
name: 'ArticleList',
props: {
articles: {
type: Array as PropType<BlogArticle[]>,
required: true,
},
batchSize: {
type: Number,
default: 3,
},
initialCount: {
type: Number,
default: 3,
},
},
setup(props) {
const loadMoreTarget = ref<HTMLElement | null>(null);
const visibleCount = ref(0);
let observer: IntersectionObserver | null = null;
const visibleArticles = computed(() => props.articles.slice(0, visibleCount.value));
const hasMoreArticles = computed(() => visibleCount.value < props.articles.length);
/**
* 重新计算首屏文章数量;列表条件变化时收回到初始批次,避免搜索/分类页沿用旧滚动状态。
*/
const resetVisibleCount = () => {
visibleCount.value = Math.min(props.initialCount, props.articles.length);
};
/**
* @param nextCount 本次追加的文章数量,默认按批次无感知追加。
*/
const loadMoreArticles = (nextCount = props.batchSize) => {
visibleCount.value = Math.min(props.articles.length, visibleCount.value + nextCount);
};
/**
* 监听底部哨兵节点,提前一段距离追加下一批文章,替代可见分页按钮。
*/
const observeLoadMoreTarget = () => {
observer?.disconnect();
observer = null;
if (!hasMoreArticles.value || !loadMoreTarget.value) {
return;
}
if (typeof window === 'undefined' || !('IntersectionObserver' in window)) {
visibleCount.value = props.articles.length;
return;
}
observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
loadMoreArticles();
}
},
{
rootMargin: '360px 0px',
threshold: 0,
},
);
observer.observe(loadMoreTarget.value);
};
/**
* @param element Vue ref 回传的哨兵 DOM 或组件实例。
*/
const setLoadMoreTarget = (element: Element | ComponentPublicInstance | null) => {
const nextElement = typeof HTMLElement !== 'undefined' && element instanceof HTMLElement ? element : null;
if (loadMoreTarget.value === nextElement) {
return;
}
loadMoreTarget.value = nextElement;
nextTick(observeLoadMoreTarget);
};
watch(
() => props.articles.map((article) => article.id).join('|'),
() => {
resetVisibleCount();
nextTick(observeLoadMoreTarget);
},
{
immediate: true,
},
);
watch(hasMoreArticles, () => {
nextTick(observeLoadMoreTarget);
});
onMounted(() => {
nextTick(observeLoadMoreTarget);
});
onBeforeUnmount(() => {
observer?.disconnect();
});
return () => (
<>
{props.articles.length > 0 ? (
<div class="kt-blog__post-transition">
<TransitionGroup name="kt-blog__post-transition" tag="div">
{visibleArticles.value.map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</TransitionGroup>
{hasMoreArticles.value ? (
<div
ref={setLoadMoreTarget}
class="kt-blog__post-load-sentinel"
aria-hidden="true"
data-loaded={visibleCount.value}
data-total={props.articles.length}
/>
) : null}
</div>
) : (
<div class="kt-blog__post-empty kt-blog__card">
<Empty description="没有找到相关文章" />
</div>
)}
</>
);
},
});