From 76e7b4b8177f0581145f7f33021c7b0882cd5b4e Mon Sep 17 00:00:00 2001 From: chenchun Date: Mon, 27 Apr 2026 02:28:49 +0800 Subject: [PATCH] =?UTF-8?q?refactor(components):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=8A=A8=E7=94=BB=E7=BB=84=E4=BB=B6=E4=BB=A3=E7=A0=81=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=B9=B6=E7=A7=BB=E9=99=A4=E5=86=97=E4=BD=99=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit style(pages): 使用实用类替换内联样式 refactor(composables): 简化页面标题模板逻辑 docs(components): 添加详细注释说明动画逻辑 --- app/app.vue | 4 +- app/components/common/AppLoader.vue | 107 +++++------------- app/components/home/HomeHero.vue | 161 ++++++++-------------------- app/components/home/HomeIntro.vue | 89 ++++----------- app/pages/index.vue | 13 +-- 5 files changed, 98 insertions(+), 276 deletions(-) diff --git a/app/app.vue b/app/app.vue index f9b59cf..784ee14 100644 --- a/app/app.vue +++ b/app/app.vue @@ -10,10 +10,10 @@ const siteImage = '/og-image.png' // 配置全局 Title 模板 useHead({ - titleTemplate: computed(() => (titleChunk?: string) => { + titleTemplate: (titleChunk?: string) => { const siteName = t('app.name') return titleChunk ? `${titleChunk} - ${siteName}` : siteName - }) + } }) // 配置全局基础 SEO 和 Meta (响应式支持国际化) diff --git a/app/components/common/AppLoader.vue b/app/components/common/AppLoader.vue index 3d11203..4d9535d 100644 --- a/app/components/common/AppLoader.vue +++ b/app/components/common/AppLoader.vue @@ -8,7 +8,7 @@ const loaderRef = ref(null) const loaderBarRef = ref(null) const appStore = useAppStore() -// Lock scroll while app is loading +// 加载期间锁定页面滚动 usePageScrollLock(appStore.isAppLoading) let loaderTween: gsap.core.Tween | null = null @@ -16,17 +16,14 @@ let removeLoadListener: (() => void) | null = null let introStartTimer: ReturnType | null = null onMounted(() => { - const loaderElement = loaderRef.value - const loaderBarElement = loaderBarRef.value + if (!loaderRef.value || !loaderBarRef.value) return - if (!loaderElement || !loaderBarElement) return + // 1. 初始化 GSAP 样式状态 + gsap.set(loaderRef.value, { display: 'grid', yPercent: 0, autoAlpha: 1 }) + gsap.set(loaderBarRef.value, { scaleX: 0.18, transformOrigin: 'left center' }) - // Initial GSAP setup - gsap.set(loaderElement, { display: 'grid', yPercent: 0, autoAlpha: 1 }) - gsap.set(loaderBarElement, { scaleX: 0.18, transformOrigin: 'left center' }) - - // Loader bar bouncing animation - loaderTween = gsap.to(loaderBarElement, { + // 2. 进度条往复加载动画 + loaderTween = gsap.to(loaderBarRef.value, { scaleX: 0.82, duration: 1.2, ease: 'power1.inOut', @@ -35,43 +32,32 @@ onMounted(() => { }) const loaderStartedAt = window.performance.now() - const minLoaderDuration = 1200 + const minLoaderDuration = 1200 // 保证加载动画至少播放 1.2 秒,避免一闪而过 const finishLoading = () => { removeLoadListener?.() - removeLoadListener = null - - if (introStartTimer) { - window.clearTimeout(introStartTimer) - introStartTimer = null - } + if (introStartTimer) clearTimeout(introStartTimer) + // 计算还需要等待多久才能达到最小加载时间 const remainingTime = Math.max(0, minLoaderDuration - (window.performance.now() - loaderStartedAt)) - introStartTimer = window.setTimeout(() => { - // End the loader loop and slide out + introStartTimer = setTimeout(() => { + // 停止往复动画 loaderTween?.kill() - loaderTween = null - - const tl = gsap.timeline({ + + // 3. 执行加载器退场动画 + gsap.timeline({ onComplete: () => { - gsap.set(loaderElement, { display: 'none' }) - appStore.setAppLoading(false) + gsap.set(loaderRef.value, { display: 'none' }) + appStore.setAppLoading(false) // 触发全局状态,通知其他组件入场 } }) - - tl.to(loaderBarElement, { - scaleX: 1, - duration: 0.25, - ease: 'power2.out', - }).to(loaderElement, { - yPercent: -100, - duration: 0.75, - ease: 'power4.inOut', - }) + .to(loaderBarRef.value, { scaleX: 1, duration: 0.25, ease: 'power2.out' }) + .to(loaderRef.value, { yPercent: -100, duration: 0.75, ease: 'power4.inOut' }) }, remainingTime) } + // 监听浏览器 window.onload 事件以判断页面资源是否加载完毕 if (document.readyState === 'complete') { finishLoading() } else { @@ -82,59 +68,18 @@ onMounted(() => { onBeforeUnmount(() => { loaderTween?.kill() - if (introStartTimer) window.clearTimeout(introStartTimer) + if (introStartTimer) clearTimeout(introStartTimer) removeLoadListener?.() }) - - diff --git a/app/components/home/HomeHero.vue b/app/components/home/HomeHero.vue index 70c2fc0..d69b1a0 100644 --- a/app/components/home/HomeHero.vue +++ b/app/components/home/HomeHero.vue @@ -3,27 +3,11 @@ import { onMounted, onBeforeUnmount, ref } from 'vue' import { useNuxtApp } from '#app' import gsap from 'gsap' +// 定义背景画廊与主视图绑定的总滚动距离(像素),数值越大,用户向下滚动时 Hero 面板缩放的过程就越长 const heroPinDistance = 1200 - const heroGalleryColumns = [ - { - side: 'left', - images: [ - 'https://picsum.photos/id/1018/900/1200', - 'https://picsum.photos/id/1015/900/1200', - 'https://picsum.photos/id/1024/900/1200', - 'https://picsum.photos/id/1039/900/1200', - ], - }, - { - side: 'right', - images: [ - 'https://picsum.photos/id/1043/900/1200', - 'https://picsum.photos/id/1050/900/1200', - 'https://picsum.photos/id/1067/900/1200', - 'https://picsum.photos/id/1074/900/1200', - ], - }, + { side: 'left', images: ['1018', '1015', '1024', '1039'].map(id => `https://picsum.photos/id/${id}/900/1200`) }, + { side: 'right', images: ['1043', '1050', '1067', '1074'].map(id => `https://picsum.photos/id/${id}/900/1200`) }, ] as const const heroStageRef = ref(null) @@ -32,16 +16,19 @@ const heroPanelRef = ref(null) let cleanupHeroScroll: (() => void) | null = null +// 暴露供外部(如 index.vue)使用的异步播放方法 const play = () => new Promise((resolve) => { if (!heroRef.value) return resolve() - + let eventDispatched = false - + + // 执行入场动画:将 Hero 整体容器从屏幕下方(yPercent: 100)滑入屏幕(yPercent: 0) gsap.to(heroRef.value, { yPercent: 0, duration: 1.2, ease: 'power3.out', onUpdate() { + // 当上滑动画进度超过 60% 时,触发全局事件,通知导航栏等其他组件可以开始它们自己的进场动画了 if (!eventDispatched && this.progress() > 0.6) { eventDispatched = true window.dispatchEvent(new CustomEvent('hero-intro-done')) @@ -51,83 +38,89 @@ const play = () => new Promise((resolve) => { }) }) -onMounted(() => { - if (heroRef.value) { - gsap.set(heroRef.value, { yPercent: 100 }) - } - +// 设置页面向下滚动时的视差缩放逻辑(绑定到 Lenis 的平滑滚动数值上) +const setupHeroScroll = () => { if (!heroStageRef.value || !heroPanelRef.value) return const { $lenis } = useNuxtApp() as { $lenis?: { scroll: number } } - const pinEndScale = 0.65 - const finalScale = 0.18 const stageTop = heroStageRef.value.offsetTop - - gsap.set(heroPanelRef.value, { - scale: 1, - force3D: true, - transformOrigin: 'center center', - }) - let renderedScale = -1 + // 初始化 Hero 面板的缩放状态和 3D 硬件加速 + gsap.set(heroPanelRef.value, { scale: 1, force3D: true, transformOrigin: 'center center' }) + + // 核心的每帧更新逻辑 const updateHeroPanelScale = () => { const exitDistance = window.innerHeight const totalDistance = heroPinDistance + exitDistance + // 如果启用了 Lenis,则读取其平滑的插值滚动值,否则回退到浏览器原生的 window.scrollY const currentScroll = $lenis ? $lenis.scroll : window.scrollY + + // 计算当前元素在滚动容器内走过的有效距离 const currentDistance = gsap.utils.clamp(0, totalDistance, currentScroll - stageTop) + // 根据滚动距离,分两个阶段计算 Hero 面板的缩放比例 (scale): + // 阶段一:在设置的 1200px 距离内,面板从 1.0 缩小到 0.65 + // 阶段二:超过 1200px 后,跟随页面继续向下滚出的过程,面板从 0.65 继续缩小到 0.18 const newScale = currentDistance <= heroPinDistance - ? gsap.utils.interpolate(1, pinEndScale, currentDistance / heroPinDistance) - : gsap.utils.interpolate(pinEndScale, finalScale, (currentDistance - heroPinDistance) / exitDistance) + ? gsap.utils.interpolate(1, 0.65, currentDistance / heroPinDistance) + : gsap.utils.interpolate(0.65, 0.18, (currentDistance - heroPinDistance) / exitDistance) + // 只有当缩放值的变化超过肉眼/渲染的有效阈值(0.001)时,才真正触发 DOM 更新,节省性能 if (Math.abs(renderedScale - newScale) > 0.001) { renderedScale = newScale gsap.set(heroPanelRef.value, { scale: newScale }) } } + // 立即执行一次计算初始状态 updateHeroPanelScale() + + // 将更新函数绑定到 gsap 的全局 ticker(类似于 requestAnimationFrame),保证在每一帧平滑读取数据并更新 gsap.ticker.add(updateHeroPanelScale) + + // 返回清理函数 cleanupHeroScroll = () => gsap.ticker.remove(updateHeroPanelScale) +} + +onMounted(() => { + // 组件挂载时,先将整个 Hero 强行置于屏幕下方,等待外部调用 `play()` 才滑入 + if (heroRef.value) gsap.set(heroRef.value, { yPercent: 100 }) + // 初始化滚动监听逻辑 + setupHeroScroll() }) onBeforeUnmount(() => { cleanupHeroScroll?.() - cleanupHeroScroll = null }) -defineExpose({ - heroStageRef, - heroRef, - play, -}) +defineExpose({ heroStageRef, heroRef, play }) diff --git a/app/components/home/HomeIntro.vue b/app/components/home/HomeIntro.vue index 58c5e3f..3c3618f 100644 --- a/app/components/home/HomeIntro.vue +++ b/app/components/home/HomeIntro.vue @@ -3,119 +3,74 @@ import { ref, onBeforeUnmount } from 'vue' import gsap from 'gsap' const introTitle = 'ArcticNewOne' +// 将标题拆分为单个字符,用于后续 GSAP 的逐字动画 const introLetters = introTitle.split('') const introRef = ref(null) const introTitleRef = ref(null) let ctx: gsap.Context | null = null +// 暴露供外部(如 index.vue)使用的异步播放方法 const play = () => new Promise((resolve) => { if (!introRef.value || !introTitleRef.value) return resolve() + // 获取所有需要执行动画的字符和 TM 标志 DOM 节点 const chars = gsap.utils.toArray('.hero-intro-char', introRef.value) const tm = introRef.value.querySelector('.hero-intro-tm') if (!chars.length || !tm) return resolve() + // 使用 gsap.context 包装时间轴,方便在组件卸载时统一清理所有动画状态(revert) ctx = gsap.context(() => { - // 初始化样式 + // 1. 初始化 DOM 的初始样式状态,防止闪烁 gsap.set(introRef.value, { display: 'grid', yPercent: 0, autoAlpha: 1 }) gsap.set(introTitleRef.value, { y: 0, autoAlpha: 1 }) + // 将每个字符初始放置在视口下方(yPercent: 110),等待升起 gsap.set(chars, { y: 0, yPercent: 110 }) + // TM 标志初始透明度为 0 gsap.set(tm, { autoAlpha: 0 }) + // 2. 创建并执行时间轴 gsap.timeline({ defaults: { ease: 'power3.out' }, onComplete: () => { + // 动画完全结束时,隐藏整个组件容器并 resolve Promise,通知外部可进行下一步 gsap.set(introRef.value, { display: 'none' }) resolve() } }) - // 逐字升起 + // 步骤 A:字符从下方逐字平滑升起入场 (stagger 产生阶梯感) .to(chars, { yPercent: 0, duration: 0.75, stagger: 0.055 }) - // TM 渐显 + // 步骤 B:TM 标志渐显入场 .to(tm, { autoAlpha: 1, duration: 0.4, ease: 'power2.out' }) - // 停顿 0.35s 后,整体上移并淡出标题 + // 停顿 0.35s 后,为接下来的离场动画打上时间标签 .addLabel('introOut', '+=0.35') + // 步骤 C:标题整体淡出 .to(introTitleRef.value, { autoAlpha: 0, duration: 1.05, ease: 'power4.inOut' }, 'introOut') + // 步骤 D:同时,整个 Intro 黑色背景容器向上滑出屏幕 .to(introRef.value, { yPercent: -100, duration: 1.05, ease: 'power4.inOut' }, 'introOut') }, introRef.value) }) +// 组件销毁前清理 GSAP 动画上下文,避免内存泄漏或在 SPA 路由切换时导致状态异常 onBeforeUnmount(() => ctx?.revert()) defineExpose({ play }) - - diff --git a/app/pages/index.vue b/app/pages/index.vue index 6f5795c..0fb0cf9 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -64,20 +64,11 @@ watch(() => appStore.isAppLoading, async (isLoading) => { -
+

Next Section

-
+

{{ t('home.welcome') }}

- -