N

ninglab

记录代码,沉淀逻辑

初始化中...

© 2026 ninglab · 正在为你准备内容...

返回列表

Nuxt 4 博客重构实战:实现动态文章目录与阅读进度条

使用 Intersection Observer 与 requestAnimationFrame 优化长文阅读体验

Nuxt 4 博客重构实战:实现动态文章目录与阅读进度条

在技术博客的重构过程中,内容固然重要,但阅读体验往往决定了读者能否耐心看完一篇长文。作为一个前端开发者,我始终认为博客不仅是内容的载体,更是前端技术的试验田。

在将博客迁移到 Nuxt 4 的过程中,我决定为文章页加入两个核心体验优化:动态文章目录(TOC)沉浸式阅读进度条。本文将记录这两个功能的实现思路,以及在 SSR 环境下遇到的一些坑。

一、 动态文章目录(TOC)的实现

对于包含大量代码和层级标题的技术文章,侧边目录是刚需。我们需要实现两个功能:解析标题生成目录、滚动时高亮当前章节。

1. 提取文章标题

在 Nuxt 中,Markdown 渲染后通常会包裹在一个特定的容器内(例如 .article-content)。我们可以在组件挂载后,通过 DOM 查询提取所有的 h2h3 标签。

TypeScript
// composables/useToc.ts
import { ref, onMounted, onUnmounted } from 'vue'

interface TocItem {
  id: string
  text: string
  level: number
}

export function useToc(containerSelector = '.article-content') {
  const headings = ref<TocItem[]>([])
  const activeId = ref('')
  let observer: IntersectionObserver | null = null

  const generateToc = () => {
    // 仅在客户端执行 DOM 操作
    if (import.meta.server) return
    
    const container = document.querySelector(containerSelector)
    if (!container) return

    const elements = container.querySelectorAll('h2, h3')
    headings.value = Array.from(elements).map((el) => ({
      id: el.id || `heading-${Math.random().toString(36).substr(2, 9)}`,
      text: el.textContent || '',
      level: parseInt(el.tagName.substring(1)),
    }))

    // 确保标题有 id,方便锚点跳转
    elements.forEach((el, index) => {
      if (!el.id) el.id = headings.value[index].id
    })
    
    initObserver()
  }

  // ... 初始化 IntersectionObserver 的逻辑
}

2. 滚动高亮当前章节

早期我使用监听 scroll 事件来计算标题位置,但在长文章中会导致严重的性能问题。后来改用 Intersection Observer API,由浏览器底层异步监听,性能极佳。

TypeScript
  const initObserver = () => {
    if (observer) observer.disconnect()

    observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          // 当标题进入视口时,更新激活状态
          if (entry.isIntersecting) {
            activeId.value = entry.target.id
          }
        })
      },
      {
        // 调整根容器的边距,让标题在滚动到视口顶部 20% 时就触发高亮
        rootMargin: '-20% 0px -80% 0px', 
        threshold: 0,
      }
    )

    headings.value.forEach((item) => {
      const el = document.getElementById(item.id)
      if (el) observer?.observe(el)
    })
  }

  onMounted(() => {
    generateToc()
  })

  onUnmounted(() => {
    observer?.disconnect()
  })

  return { headings, activeId }
}

💡 踩坑记录: 在 Nuxt 的 SSR 模式下,直接在 setup 阶段访问 document 会报错。必须确保 DOM 操作在 onMounted 钩子中执行,或者使用 import.meta.client 进行环境判断。

二、 沉浸式阅读进度条

进度条能给读者一个明确的心理预期:“这篇文章还有多少没看完”。实现思路很简单:计算当前滚动高度占页面总高度的百分比。

1. 基础逻辑与性能优化

直接监听 scroll 事件并频繁修改响应式数据会导致页面卡顿。我们需要结合 requestAnimationFrame 进行节流优化。

TypeScript
// composables/useReadingProgress.ts
import { ref, onMounted, onUnmounted } from 'vue'

export function useReadingProgress() {
  const progress = ref(0)
  let ticking = false

  const updateProgress = () => {
    const scrollTop = window.scrollY || document.documentElement.scrollTop
    const docHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight
    
    // 防止除以 0 或负数
    progress.value = docHeight > 0 ? Math.min(100, (scrollTop / docHeight) * 100) : 0
    ticking = false
  }

  const onScroll = () => {
    if (!ticking) {
      window.requestAnimationFrame(updateProgress)
      ticking = true
    }
  }

  onMounted(() => {
    window.addEventListener('scroll', onScroll, { passive: true })
    updateProgress() // 初始化
  })

  onUnmounted(() => {
    window.removeEventListener('scroll', onScroll)
  })

  return { progress }
}

2. 结合 Tailwind CSS v4 实现丝滑动画

在 Nuxt 4 中,我们可以直接使用 Tailwind CSS v4 来渲染进度条。利用 CSS 变量和 transition,可以让进度条的变化更加平滑。

Vue
<template>
  <div class="fixed top-0 left-0 w-full h-1 z-50 bg-gray-200 dark:bg-gray-800">
    <div 
      class="h-full bg-blue-500 transition-all duration-150 ease-out"
      :style="{ width: `${progress}%` }"
    />
  </div>
</template>

<script setup lang="ts">
const { progress } = useReadingProgress()
</script>

💡 优化细节:

  1. 使用 passive: true 监听滚动事件,告诉浏览器不会调用 preventDefault(),从而提升滚动性能。
  2. 进度条使用 transition-all duration-150,避免数值跳变带来的视觉突兀感。

三、 总结

通过这次重构,我深刻体会到:优秀的阅读体验是由无数个细节堆砌而成的

  • Intersection Observer 替代传统的滚动监听,是处理目录高亮的最佳实践。
  • 在 SSR 框架(如 Nuxt)中,时刻牢记客户端与服务端的环境差异,避免 hydration mismatch(水合不匹配)错误。
  • 善用 requestAnimationFrame 和 CSS transition,可以用极低的成本换取丝滑的视觉体验。

目前这两个功能已经在 ninglab.top 上线,阅读体验有了显著提升。下一步,我计划引入 Giscus 评论系统,让博客真正“活”起来。

如果你也在重构博客,或者对 Nuxt 4 的某些特性有疑问,欢迎在评论区交流!

© 2026 ninglab. All rights reserved.