Nuxt 4 博客重构实战:实现动态文章目录与阅读进度条
使用 Intersection Observer 与 requestAnimationFrame 优化长文阅读体验
Nuxt 4 博客重构实战:实现动态文章目录与阅读进度条
在技术博客的重构过程中,内容固然重要,但阅读体验往往决定了读者能否耐心看完一篇长文。作为一个前端开发者,我始终认为博客不仅是内容的载体,更是前端技术的试验田。
在将博客迁移到 Nuxt 4 的过程中,我决定为文章页加入两个核心体验优化:动态文章目录(TOC)和沉浸式阅读进度条。本文将记录这两个功能的实现思路,以及在 SSR 环境下遇到的一些坑。
一、 动态文章目录(TOC)的实现
对于包含大量代码和层级标题的技术文章,侧边目录是刚需。我们需要实现两个功能:解析标题生成目录、滚动时高亮当前章节。
1. 提取文章标题
在 Nuxt 中,Markdown 渲染后通常会包裹在一个特定的容器内(例如 .article-content)。我们可以在组件挂载后,通过 DOM 查询提取所有的 h2 和 h3 标签。
// 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,由浏览器底层异步监听,性能极佳。
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 进行节流优化。
// 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,可以让进度条的变化更加平滑。
<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>
💡 优化细节:
- 使用
passive: true监听滚动事件,告诉浏览器不会调用preventDefault(),从而提升滚动性能。 - 进度条使用
transition-all duration-150,避免数值跳变带来的视觉突兀感。
三、 总结
通过这次重构,我深刻体会到:优秀的阅读体验是由无数个细节堆砌而成的。
- Intersection Observer 替代传统的滚动监听,是处理目录高亮的最佳实践。
- 在 SSR 框架(如 Nuxt)中,时刻牢记客户端与服务端的环境差异,避免 hydration mismatch(水合不匹配)错误。
- 善用
requestAnimationFrame和 CSStransition,可以用极低的成本换取丝滑的视觉体验。
目前这两个功能已经在 ninglab.top 上线,阅读体验有了显著提升。下一步,我计划引入 Giscus 评论系统,让博客真正“活”起来。
如果你也在重构博客,或者对 Nuxt 4 的某些特性有疑问,欢迎在评论区交流!