Jekyll 博客的索引分页与静态 API

AutoPages Pagination Scroll LoadIndicator

Jekyll 是一个从格式化文本生成静态站点的工具,所谓“静态”是指所有 HTML 页面都在编译时创建,部署后不能动态生成新页面。由 HTML 组成的静态网站可以不需要传统后端服务直接托管到免费的 Github Pages、Cloudflare Pages 或 OSS 对象存储上,非常适合博客这样的轻量用途。

但“静态”并不意味着网站不能交互,可以在页面里加载 JS 代码监测交互事件动态改变显示内容,所以静态博客同样有非常大的灵活性。

索引分页

博客首页一般显示文章索引,当文章数量很多时为优化性能不应该一次加载整个列表,而是先加载一段再按需逐渐加载剩余内容,即 Pagination 分页。

分页有 2 种,一种是“静态”,Jekyll 编译时生成多个页面,每个页面只显示一部分,并在底部设置索引导航。这种方式简单所以应用广泛,但我倾向文章列表是一条完整时间线,拆分到多个页面会破坏连续性。所以选择第二种“动态”,即用 JS 监测用户滚动分段加载文章列表填充界面,就像普通 App 那样。

如此,需要让 Jekyll 在编译时生成分段的文章列表,每段保存为单独的 JSON 文件,可以被 JS 下载解析,即是所谓的“静态 API”。

Auto Pages

Jekyll 提供 jekyll-paginate-v2 插件实现高级分页,它的 Auto Pages 模式可以自动给每一个 Category、Collection、Tag 分页,使整个站点的文章以不同方式分类生成可用 API。

# Jekyll配置文件 _config.yml
plugins:
  # 启用分页插件
  - jekyll-paginate-v2

# 配置分页参数
pagination:
  enabled: true
  # 要扫描的文章目录 _posts 
  collection: "posts"
  # 每页的文章数
  per_page: 10
  # 生成分页文件的路径,后面可以为 Category、Tag、Collection 单独配置
  permalink: ""
  # 分页文件后缀
  extension: json
  # 分页文件名,:num 表示当前页码,从1开始,比如 page-2.json 
  indexpage: "page-:num"
  # 是否按时间倒序
  sort_reverse: true

# 配置自动分页
autopages:
  enabled: true
  # 禁用 Collection 自动分页
  collections:
    enabled: false
  # 启用 Category 自动分页
  categories:
    enabled: true
    # 每页的布局文件,定义生成什么样的分页格式
    # 指定为 _layouts/paginate-posts-json.html,将会输出为 JSON 格式
    layouts:
      - paginate-posts-json.html
    # 指定输出的 JSON 文件路径,:cat 表示当前 Category 字符
    # 比如名为 repost 的 Category,分页文件将被输出到 /api/paginate/categories/repost 目录下
    permalink: "/api/paginate/categories/:cat"
    slugify:
      mode: "default"
      case: false
  # 启用Tag自动分页
  tags:
    enabled: true
    # 使用同样的布局文件,以同样的分页格式输出
    layouts:
      - paginate-posts-json.html
    # :cat 表示当前 Tag 字符
    permalink: "/api/paginate/tags/:tag"
    slugify:
      mode: "default"
      case: false

本博客文章以 Category 分类,配置分页后在不同索引页加载对应 JSON 即可。至于 Tag ,是文章标记的一些标签,以 Chip 形式显示在文章顶部,点击会弹出相关文章列表,所以也为它启用分页。

_layouts/paginate-posts-json.html里定义输出分页的单页格式,这里配置为 JSON,Jekyll 会自动把每一页的文章列表填充到 Liquid 模版语言的paginator变量里,遍历paginator.posts生成的即是当页文章列表:

// _layouts/paginate-tag.html
// 用 Liquid 模版语言定义 JSON 格式
{
    "data": {
        "totalPosts": {{ paginator.total_posts }},
        "totalPages": {{ paginator.total_pages }},
        "postsPerPage": {{ paginator.per_page }},
        "currentPageIndex": {{ paginator.page }},
        "previousPagePath": "{{ paginator.previous_page_path }}",
        "nextPagePath": "{{ paginator.next_page_path }}"
    },
    "posts": [
        {%- for post in paginator.posts %}
        {
            "title": "{{ post.title }}",
            "date": "{{ post.date | date: "%Y年%m月%d日" }}",
            "path": "{{ post.url }}",
            "author": "{{ post.author }}",
            "description": "{{ post.description}}",
            "cover": "{{ post.image }}"
        }{% unless forloop.last %},{% endunless %}
        {%- endfor %}
    ]
}

注意_config.yml中的分页目录permalink: "/api/paginate/categories/:cat"和分页文件名indexpage: "page-:num",配置后执行jekyll build命令会扫描_posts目录下的文章,在_site目录生成按 Category 分类的分页文件。

|-_site/api/paginate/categories/
    |-cat1/
        |-page-1.json
        |-page-2.json
    |-cat2/
        |-page-1.json
        |-page-2.json
        |-page-3.json

JS 下载分页 JSON 文件即可按需分段加载文章列表。

Generator

Auto Pages 自动模式之外,jekyll-paginate-v2 支持配置 Generator 实现更丰富的分页条件。比如对同时包含 2 个 Tag 的文章列表分页,使用 Auto Pages 是做不到的,需要像定义普通 Jekyll 页面那样定义 Generator。

上面为 Tag 配置的分页目录是/api/paginate/tags/:tag,保持结构统一,这里把同时包含tag1tag2的分页也输出到该目录:

# 创建 api/paginate/tags/tag1&tag2.txt
# 在 Front Matter 中定义 pagination
---
# 指定同样的输出格式
layout: paginate-posts-json
sitemap: false
permalink: /api/paginate/tags/tag1&tag2
pagination:
    permalink: ""
    enabled: true
    # 同时包含 tag1 和 tag2
    tag: "tag1,tag2"
    extension: .json
    indexpage: 'page-:num'
---

bundle exec jekyll build后可以看到输出文件:

|-_site/api/paginate/tags/
    |-tag1/
        |-page-1.json
        |-page-2.json
    |-tag2/
        |-page-1.json
        |-page-2.json
        |-page-3.json
    |-tag1&tag2/
        |-page-1.json
        |-page-2.json

加载时机

滚动加载新数据是一个常见交互,如何检测滚动判断加载时机也是一个常见话题。列表“滚动”是指其height高度超过viewport显示区域处于overflow溢出的可滚动状态,只需检测列表的scrollY滚动距离,通过height - viewport.height - scrollY计算列表底部与viewport显示区域的距离,小于一个threshold阈值即是触发加载新数据的时机。

scroll

同时应注意过滤滚动时高频触发的重复加载事件,一种思路是记录最近一次“加载”的时间戳,忽略指定时间间隔内的后续事件。

IntersectionObserver

监听滚动事件之外,另一种方式是在列表底部添加一个 Sentinel 哨兵元素,比如LoadingHint加载指示器,通过IntersectionObserver监听其是否进入指定的Viewport视口范围来触发加载事件。

IntersectionObserver观察者仅在被观察元素进入或离开指定视口元素的指定区域时才会触发事件,相比“像素级”变化的滚动事件对性能影响更小。注意 Intersection 是以被观察元素和其指定父级元素视口区域的重叠尺寸为判断依据的,当被观察元素的尺寸为 0 或display: none,则重叠尺寸永远为 0,这种情况下即使进入显示区域也不会触发进入事件。

隐藏作为哨兵的加载指示器时应避免这种情况:

.loading-hint-wrapper {
    height: 4.45rem;
    
    &.hide {
        // 隐藏元素,不能用 display: none 或 height: 0,否则 intersection observer 无法正确监听元素的进入和离开
        // intersection observer 是通过计算相交面积判断元素是否进入和离开 viewport 的,高度为 0 相交面积永远为 0
        height: 1px;
        // 视觉隐藏
        visibility: hidden;
        // 避免溢出显示
        overflow: hidden;
    }

一个基于 React 的LoadingHint实现示例:

/**
 * 用于父级组件提供滚动容器元素的 Context,LoadingHint 组件会使用 IntersectionObserver 监听自身是否进入该元素视口,从而触发加载更多的回调函数
 */
export const ScrollContext = createContext<React.RefObject<HTMLDivElement | null> | null>(null);

export const useScrollRoot = () => useContext(ScrollContext);

/**
 * 控制 UI 的属性与回调函数
 */
interface Props {
    loading: boolean
    loadHint?: string
    onClickHint: () => void
    onLoadMore?: () => void
}
/**
 * 加载指示器,同时也作为哨兵元素
 */
export function LoadingHint(props: Props) {
    const containerRef = useRef<HTMLDivElement>(null)
    const onLoadMoreRef = useRef(props.onLoadMore)
    const scrollRoot = useScrollRoot()

    useEffect(() => {
        // 缓存最新的回调函数引用
        onLoadMoreRef.current = props.onLoadMore
    }, [props.onLoadMore])

    useEffect(() => {
        let gap = window.innerHeight * 0.8
        if (scrollRoot?.current) {
            // 从 Context 中获取父级组件设置的滚动视口元素,其尺寸作为 Intersection 检测的扩展区域尺寸
            gap = scrollRoot.current.clientHeight * 0.8
        }
        if (gap == 0) {
            gap = 400
        }
        consoleObjDebug("LoadingHint useEffect scrollRoot:", scrollRoot?.current)
        const interSectionObserver = new IntersectionObserver(entries => {
            entries.forEach(entry => {
                consoleDebug("LoadingHint IntersectionObserver isIntersection: " + entry.isIntersecting)
                if (entry.isIntersecting) {
                    // 元素进入检测区域,触发加载更多回调
                    if (onLoadMoreRef.current) {
                        onLoadMoreRef.current()
                    }
                }
            })
        }, {
            // 设置哨兵元素归属的 overflow 元素,精准判断是否进入其 viewport 显示区域
            // null 默认为浏览器视口
            root: scrollRoot?.current ?? null,
            // 重叠尺寸阈值为 0 代表元素的任意部分进入 viewport 都会触发观察者事件
            threshold: 0,
            // 设置对检测视口的扩展尺寸,在元素进入视口之前的指定距离触发进入事件即是通过配置这里实现的
            rootMargin: `0px 0px ${gap}px 0px`
        })

        if (containerRef.current) {
            // 当前元素作为被观察者,即哨兵
            interSectionObserver.observe(containerRef.current)
        }
        return () => {
            consoleDebug("LoadingHint useEffect cleanup")
            if (containerRef.current) {
                interSectionObserver.unobserve(containerRef.current)
            }
        }
        // 这里锚定 loading 状态,确保每次加载完成后触发 intersection 检测
    }, [props.loading, props.loadHint])

    const hide = useMemo(() => {
        return !props.loading && props.loadHint == null
    }, [props.loading, props.loadHint])

    return (
        <div ref={containerRef} className={`loading-hint-wrapper center-inline-items ${hide ? "hide" : ""}`.trim()}>
            <ProgressCircular loading={props.loading} classes={props.loading ? ["show"] : []} />
            <Button text={props.loadHint ?? ""} onClick={props.loading ? undefined : props.onClickHint} tabIndex={-1}
                classes={!props.loading && props.loadHint != null ? ["show"] : []} />
        </div>
    )
}