/** * 滚动渐显插件(客户端) * * 扫描页面中所有 [data-reveal] 元素,进入视口时为其添加 .is-visible, * 触发 animations.css 中定义的过渡。支持通过 data-reveal-delay(毫秒)做 stagger。 * * 设计要点: * - 初始隐藏状态完全由 CSS `.js [data-reveal]` 控制, 由 * nuxt.config 的 head 内联脚本在首屏绘制前注入,因此不会造成 SSR 闪烁, * 也无 JS 时内容正常显示。 * - 兼容客户端路由切换:在 app:mounted 与 page:finish 时重新扫描。 * - 【关键】使用 MutationObserver 持续监听 DOM 变更,兜住「挂载后才出现」的元素: * 例如组件在客户端异步取数后渲染、SSR 水合不一致导致 Vue 重建子树、 * 或列表分页 / 条件区块延迟出现。这些元素若漏扫,会因 CSS 的 opacity:0 * 永久不可见,表现为「整块区域样式丢失 / 空白」——这正是本插件必须防住的故障。 * - 尊重 prefers-reduced-motion:此时直接跳过观察器,内容由 CSS 强制可见。 */ export default defineNuxtPlugin((nuxtApp) => { if (typeof window === 'undefined') return const reduceMotion = window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches if (reduceMotion) return const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (!entry.isIntersecting) continue const el = entry.target as HTMLElement const delay = Number(el.dataset.revealDelay || 0) const reveal = () => el.classList.add('is-visible') if (delay > 0) { window.setTimeout(reveal, delay) } else { reveal() } observer.unobserve(el) } }, { threshold: 0, rootMargin: '0px 0px -10% 0px' } ) const scan = () => { const els = document.querySelectorAll( '[data-reveal]:not(.is-visible)' ) els.forEach((el) => observer.observe(el)) } // 合并短时间内的多次 DOM 变更,避免频繁全量查询 let rafId = 0 const scheduleScan = () => { if (rafId) return rafId = requestAnimationFrame(() => { rafId = 0 scan() }) } // 持续监听 DOM 新增节点:任何后续插入的 [data-reveal] 都会被补扫, // 从根本上避免「元素永久停留在 opacity:0」的问题。 const mutationObserver = new MutationObserver((mutations) => { for (const m of mutations) { if (m.addedNodes.length > 0) { scheduleScan() return } } }) const startMutationWatch = () => { mutationObserver.observe(document.body, { childList: true, subtree: true }) } // 首屏挂载后扫描一次,并开始监听后续 DOM 变更 nuxtApp.hook('app:mounted', () => { scheduleScan() startMutationWatch() // 兜底:水合完成后 Vue 可能仍在替换不一致的子树,稍后再补扫一次 window.setTimeout(scheduleScan, 300) }) // 客户端路由切换后重新扫描(新页面可能有新的 [data-reveal]) nuxtApp.hook('page:finish', () => { scheduleScan() }) // 页面卸载时释放监听 if (import.meta.hot) { import.meta.hot.dispose(() => { mutationObserver.disconnect() observer.disconnect() }) } })