export class Timeline {
    static _instance = null;

    constructor() {
        // 如果已存在实例,先销毁旧实例
        if (Timeline._instance) {
            Timeline._instance.destroy();
        }

        this._loading = false;
        this._hasMore = true;
        this._pageIndex = 1;
        this._pageSize = 10;
        this._account = 'pengqian';
        this._scrollHandler = null;
        this._isInitialized = false;
        
        Timeline._instance = this;
        this.init();
    }

    init() {
        // 检测是否存在时间轴结构
        const timelineContainer = document.querySelector('.timeline');
        
        if (!timelineContainer) {
            // 没有时间轴结构,不进行任何初始化
            return;
        }

        // 从容器读取初始配置
        this._pageIndex = parseInt(timelineContainer.dataset.pageIndex) || 1;
        this._pageSize = parseInt(timelineContainer.dataset.pageSize) || 10;
        this._account = timelineContainer.dataset.account || 'pengqian';
        this._hasMore = timelineContainer.dataset.hasMore === 'true';

        this._timelineContainer = timelineContainer;
        this._loadingElement = document.querySelector('.timeline__loading');
        this._endElement = document.querySelector('.timeline__end');

        // 初始化已有项目的动画
        this.initItemsAnimation();

        // 设置滚动监听
        this.setupScrollListener();
        
        // 初始化时隐藏加载提示
        if (this._loadingElement) {
            this._loadingElement.style.display = this._hasMore ? 'flex' : 'none';
        }

        this._isInitialized = true;
    }

    destroy() {
        // 清理滚动事件监听器
        if (this._scrollHandler) {
            window.removeEventListener('scroll', this._scrollHandler);
            this._scrollHandler = null;
        }
        this._isInitialized = false;
        
        // 清理实例引用
        if (Timeline._instance === this) {
            Timeline._instance = null;
        }
    }

    initItemsAnimation() {
        const timelineItems = document.querySelectorAll('.timeline__item');
        
        if (timelineItems.length === 0) return;

        const observerOptions = {
            root: null,
            rootMargin: '0px',
            threshold: 0.1
        };

        const observer = new IntersectionObserver((entries) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    entry.target.classList.add('is-visible');
                    observer.unobserve(entry.target);
                }
            });
        }, observerOptions);

        timelineItems.forEach(item => {
            observer.observe(item);
        });
    }

    setupScrollListener() {
        let ticking = false;

        const checkScroll = () => {
            const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
            const windowHeight = window.innerHeight;
            const documentHeight = document.documentElement.scrollHeight;

            // 距离底部 500px 时开始加载
            const threshold = 500;
            const distanceToBottom = documentHeight - (scrollTop + windowHeight);

            if (distanceToBottom < threshold && this._hasMore && !this._loading) {
                this.loadMore();
            }

            ticking = false;
        };

        this._scrollHandler = () => {
            if (!ticking) {
                window.requestAnimationFrame(checkScroll);
                ticking = true;
            }
        };

        window.addEventListener('scroll', this._scrollHandler, { passive: true });
    }

    async loadMore() {
        if (this._loading || !this._hasMore) {
            return;
        }

        this._loading = true;
        
        if (this._loadingElement) {
            this._loadingElement.style.display = 'flex';
        }

        try {
            const nextPage = this._pageIndex + 1;
            const url = `/Timeline/LoadMore?account=${encodeURIComponent(this._account)}&pageIndex=${nextPage}&pageSize=${this._pageSize}`;
            
            const response = await fetch(url);
            
            if (!response.ok) {
                throw new Error('Network response was not ok');
            }

            const html = await response.text();
            
            if (html.trim() === '') {
                this._hasMore = false;
                this.showEndMessage();
                return;
            }

            // 将新内容插入到时间轴容器中
            const tempDiv = document.createElement('div');
            tempDiv.innerHTML = html;
            
            const newItems = tempDiv.querySelectorAll('.timeline__item');
            
            if (newItems.length === 0) {
                this._hasMore = false;
                this.showEndMessage();
                return;
            }

            // 追加新项目
            newItems.forEach(item => {
                this._timelineContainer.appendChild(item);
            });

            // 为新项目添加动画
            this.initItemsAnimation();

            // 更新页码
            this._pageIndex = nextPage;

            // 检查是否还有更多
            // 如果返回的项目数量少于 pageSize,说明没有更多了
            if (newItems.length < this._pageSize) {
                this._hasMore = false;
                this.showEndMessage();
            }

        } catch (error) {
            console.error('加载时间轴失败:', error);
        } finally {
            this._loading = false;
            if (this._loadingElement) {
                this._loadingElement.style.display = 'none';
            }
        }
    }

    showEndMessage() {
        if (this._loadingElement) {
            this._loadingElement.style.display = 'none';
        }
        if (this._endElement) {
            this._endElement.style.display = 'flex';
        }
    }
}

⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

下面按要点解释这段 Timeline 类的功能、工作流程和各方法的作用,同时列出几个值得注意的细节/改进建议。

总体功能概述

  • 这是一个前端的“时间轴/分页加载”组件类(Timeline),实现了基于滚动的自动加载更多条目(类似无限滚动)以及进入可视区时的逐项动画(通过 IntersectionObserver)。
  • 使用了简单的单例机制(静态属性 Timeline._instance)确保页面上只有一个实例在起作用;若构造新实例,会先销毁已有实例。
  • 从 DOM(.timeline 容器的 data-* 属性)读取初始配置(pageIndex、pageSize、account、hasMore),通过 fetch 向 /Timeline/LoadMore 请求下一页 HTML,然后把返回的 .timeline__item 加到容器里,并为它们触发入场动画。

逐方法说明

  1. constructor()
  • 如果已有实例(Timeline._instance),会调用其 destroy() 先销毁旧实例。
  • 初始化内部状态变量:_loading、_hasMore、_pageIndex、_pageSize、_account 等。
  • 保存实例到 Timeline._instance 并立即调用 this.init() 进行初始化。
  1. init()
  • 查找页面上第一个 .timeline 容器;如果找不到则直接返回(不做初始化)。
  • 从容器的 data-* 属性读取 pageIndex、pageSize、account、hasMore,用于后续分页请求与状态。
  • 缓存常用 DOM 节点:_timelineContainer、_loadingElement(.timeline__loading)、_endElement(.timeline__end)。
  • 调用 initItemsAnimation() 为当前已有的项目安装可视区入场动画(IntersectionObserver)。
  • 调用 setupScrollListener() 在 window 上注册滚动监听,用于触发加载更多。
  • 根据 hasMore 决定是否显示加载提示(loading element)。
  • 标记 _isInitialized = true。
  1. destroy()
  • 移除 window 的滚动事件监听(如果存在),把 _scrollHandler 清空。
  • 置 _isInitialized = false。
  • 如果当前实例是静态单例,清除 Timeline._instance 的引用。
  1. initItemsAnimation()
  • 查询所有 .timeline__item 元素(全局查询,而不是限定到容器)。
  • 使用 IntersectionObserver(threshold 0.1)观察这些元素:一旦元素进入可视区(isIntersecting),就给元素添加 is-visible 类,并对该元素调用 observer.unobserve,确保每个元素只触发一次动画。
  • 每次调用都会创建新的 IntersectionObserver 并为当前项注册观察。
  1. setupScrollListener()
  • 实现了基于 requestAnimationFrame 的简单节流(ticking 标志)。
  • 每次滚动时,使用 requestAnimationFrame 调用 checkScroll(),避免高频滚动直接触发大量计算。
  • checkScroll() 计算页面距离底部的距离 distanceToBottom;当 distanceToBottom < 500(阈值为 500px)、且有更多(_hasMore)且当前不在加载(!_loading)时,调用 loadMore()。
  1. loadMore()
  • 防止并发调用:若 _loading 为 true 或 _hasMore 为 false,则直接返回。
  • 设置 _loading = true,并显示 _loadingElement(如果有)。
  • 构造下一页 URL:/Timeline/LoadMore?account=...&pageIndex=nextPage&pageSize=...
  • 用 fetch 请求该 URL,读取文本(html)。
  • 若返回空字符串或解析后无 .timeline__item,则把 _hasMore 设为 false 并调用 showEndMessage()。
  • 若返回含有新项,则把新项追加到 _timelineContainer 中,并为新项调用 initItemsAnimation()(注册动画观察)。
  • 更新 _pageIndex 到 nextPage;如果新项数量 < pageSize,则推断没有更多,设置 _hasMore = false 并 showEndMessage()。
  • 无论成功或失败,finally 中把 _loading 设为 false 并隐藏 loading 元素。
  1. showEndMessage()
  • 隐藏加载提示元素(_loadingElement),显示结束提示元素(_endElement,.timeline__end),表示已到末尾没有更多内容。

值得注意的问题与改进建议(简要)

  • initItemsAnimation 每次都会创建新的 IntersectionObserver,但并不保存/断开前一个 observer,这可能导致未必要的多次 observer 实例(可考虑把 observer 存为实例属性并在 destroy 中 disconnect)。
  • initItemsAnimation、querySelectorAll 等操作是全局查询(document)而不是限定在 this._timelineContainer 内,若页面上有多个类似结构或其它区域也用同类类名,可能会互相影响。建议把选择器基于 this._timelineContainer 限定范围。
  • 构造函数中直接销毁旧实例这一策略简单,但可能在某些场景下引发状态竞争或页面闪烁,取决于何时创建实例。通常建议在应用初始化阶段只创建一次实例,或使用工厂函数控制生命周期。
  • 使用 innerHTML 将返回的 HTML 注入页面,需确保后端返回的内容可信,以避免 XSS 风险。
  • loadMore 在 fetch 请求失败时只 console.error,用户体验上可能需要显示错误提示或重试机制。
  • pageIndex 的更新策略是请求成功后才 ++,这是合理的,但要注意并发请求场景(当前逻辑已用 _loading 阻止并发)。
  • 锚点/滚动阈值(500px)是硬编码,可能需要根据设计调整或做成可配置项。
  • 如果页面中动态移除或替换 timeline 容器,destroy() 需要配合调用以移除监听器并释放资源。

总结

  • 这个类实现了一个单例式的时间轴组件:初始化时读取 DOM 配置,给已有条目添加“进入可视区动画”,在滚动接近底部时自动 fetch 下一页 HTML 并追加到时间轴,管理加载状态与“没有更多”的展示。代码结构清晰,核心点为 IntersectionObserver(动画) + requestAnimationFrame 节流的滚动监听 + fetch 加载更多。
评论加载中...