fe6e4b1c54
- Remove TestimonialSection from homepage (no customers yet) - Footer: dark theme, NAVIGATION_V2 + MEGA_DROPDOWN_DATA links - Mobile Menu: NAVIGATION_V2 with collapsible dropdown support
1143 lines
34 KiB
Markdown
1143 lines
34 KiB
Markdown
# 前沿技术升级 Phase 2 实现计划:CSS Scroll-Driven Animations + GSAP/Lenis + Motion 评估
|
||
|
||
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
||
|
||
**目标:** 用 CSS Scroll-Driven Animations 替换 Hero/Section 的 JS 滚动动画以获得零 JS 开销的 60fps 性能;引入 GSAP + ScrollTrigger + Lenis 实现方法论/解决方案页的长卷叙事体验;评估 Motion 库替换 framer-motion 轻量场景的可行性。
|
||
|
||
**架构:** 三项改动分层次推进——CSS Scroll-Driven 是纯 CSS 替换,零 JS 依赖;GSAP/Lenis 作为独立动画层与 framer-motion 共存(GSAP 负责滚动驱动,framer-motion 负责组件微交互);Motion 库评估为可选迁移路径。核心原则:渐进增强,每一步都可独立交付和回滚。
|
||
|
||
**技术栈:** CSS Scroll-Driven Animations (animation-timeline: scroll())、GSAP 3 + ScrollTrigger + DrawSVGPlugin、Lenis、Motion (motion-dev) 库
|
||
|
||
---
|
||
|
||
## 文件结构
|
||
|
||
### 新建文件
|
||
| 文件 | 职责 |
|
||
|------|------|
|
||
| `src/components/effects/scroll-driven-reveal.tsx` | CSS Scroll-Driven 揭示动画封装 |
|
||
| `src/components/effects/scroll-driven-reveal.test.tsx` | 测试 |
|
||
| `src/components/effects/lenis-provider.tsx` | Lenis 平滑滚动 Provider |
|
||
| `src/components/effects/lenis-provider.test.tsx` | 测试 |
|
||
| `src/components/effects/gsap-scroll-narrative.tsx` | GSAP 长卷叙事组件 |
|
||
| `src/components/effects/gsap-scroll-narrative.test.tsx` | 测试 |
|
||
| `src/hooks/use-gsap-context.ts` | GSAP Context 生命周期管理 Hook |
|
||
| `src/hooks/use-gsap-context.test.ts` | 测试 |
|
||
|
||
### 修改文件
|
||
| 文件 | 变更内容 |
|
||
|------|---------|
|
||
| `src/app/globals.css` | 添加 Scroll-Driven 关键帧、Lenis 样式 |
|
||
| `src/app/layout.tsx` | 添加 LenisProvider |
|
||
| `src/components/sections/hero-section.tsx` | Hero 区域 CSS Scroll-Driven 替换 |
|
||
| `src/components/sections/methodology-section.tsx` | GSAP ScrollTrigger 长卷叙事 |
|
||
| `src/components/sections/about-section.tsx` | CSS Scroll-Driven 揭示动画 |
|
||
| `src/components/sections/products-section.tsx` | CSS Scroll-Driven 揭示动画 |
|
||
| `src/components/sections/services-section.tsx` | CSS Scroll-Driven 揭示动画 |
|
||
| `src/components/ui/scroll-progress.tsx` | CSS Scroll-Driven 替换 framer-motion |
|
||
| `src/components/ui/scroll-animations.tsx` | 添加 CSS Scroll-Driven 变体导出 |
|
||
| `src/lib/animations.tsx` | 添加 ScrollReveal 组件 |
|
||
| `package.json` | 添加 lenis 依赖 |
|
||
|
||
---
|
||
|
||
## 任务 1:CSS Scroll-Driven Animations 基础设施
|
||
|
||
**文件:**
|
||
- 修改:`src/app/globals.css`
|
||
|
||
- [ ] **步骤 1:添加 Scroll-Driven 关键帧和工具类**
|
||
|
||
在 `src/app/globals.css` 的 `@layer utilities` 块中添加:
|
||
|
||
```css
|
||
/* Scroll-Driven Animations */
|
||
@keyframes sd-reveal-up {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(40px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
@keyframes sd-reveal-scale {
|
||
from {
|
||
opacity: 0;
|
||
transform: scale(0.95);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: scale(1);
|
||
}
|
||
}
|
||
|
||
@keyframes sd-ink-circle {
|
||
from {
|
||
clip-path: circle(0% at 50% 50%);
|
||
opacity: 0;
|
||
}
|
||
to {
|
||
clip-path: circle(75% at 50% 50%);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
@keyframes sd-fade-in {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
@keyframes sd-parallax-slow {
|
||
from { transform: translateY(0); }
|
||
to { transform: translateY(-80px); }
|
||
}
|
||
|
||
@keyframes sd-parallax-fast {
|
||
from { transform: translateY(0); }
|
||
to { transform: translateY(-160px); }
|
||
}
|
||
|
||
@keyframes sd-progress {
|
||
from { transform: scaleX(0); }
|
||
to { transform: scaleX(1); }
|
||
}
|
||
|
||
@utility sd-reveal {
|
||
animation: sd-reveal-up linear both;
|
||
animation-timeline: view();
|
||
animation-range: entry 0% entry-crossing 40%;
|
||
}
|
||
|
||
@utility sd-reveal-scale {
|
||
animation: sd-reveal-scale linear both;
|
||
animation-timeline: view();
|
||
animation-range: entry 0% entry-crossing 40%;
|
||
}
|
||
|
||
@utility sd-ink-reveal {
|
||
animation: sd-ink-circle linear both;
|
||
animation-timeline: view();
|
||
animation-range: entry 0% entry-crossing 50%;
|
||
}
|
||
|
||
@utility sd-parallax-slow {
|
||
animation: sd-parallax-slow linear both;
|
||
animation-timeline: view();
|
||
animation-range: entry 0% exit 100%;
|
||
}
|
||
|
||
@utility sd-parallax-fast {
|
||
animation: sd-parallax-fast linear both;
|
||
animation-timeline: view();
|
||
animation-range: entry 0% exit 100%;
|
||
}
|
||
|
||
@utility sd-progress-bar {
|
||
animation: sd-progress linear;
|
||
animation-timeline: scroll(root);
|
||
transform-origin: left;
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.sd-reveal,
|
||
.sd-reveal-scale,
|
||
.sd-ink-reveal,
|
||
.sd-parallax-slow,
|
||
.sd-parallax-fast,
|
||
.sd-progress-bar {
|
||
animation: none !important;
|
||
opacity: 1 !important;
|
||
transform: none !important;
|
||
clip-path: none !important;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **步骤 2:验证 CSS 编译**
|
||
|
||
运行:`npm run build`
|
||
预期:构建成功,Tailwind CSS 4 正确处理自定义 @utility 和 @keyframes
|
||
|
||
- [ ] **步骤 3:Commit**
|
||
|
||
```bash
|
||
git add src/app/globals.css
|
||
git commit -m "feat: add CSS Scroll-Driven Animation keyframes and utility classes"
|
||
```
|
||
|
||
---
|
||
|
||
## 任务 2:ScrollDrivenReveal 组件封装(渐进增强)
|
||
|
||
**文件:**
|
||
- 创建:`src/components/effects/scroll-driven-reveal.tsx`
|
||
- 创建:`src/components/effects/scroll-driven-reveal.test.tsx`
|
||
|
||
- [ ] **步骤 1:编写失败的测试**
|
||
|
||
```tsx
|
||
// src/components/effects/scroll-driven-reveal.test.tsx
|
||
import { render, screen } from '@testing-library/react';
|
||
import { describe, it, expect } from 'vitest';
|
||
import { ScrollDrivenReveal } from './scroll-driven-reveal';
|
||
|
||
describe('ScrollDrivenReveal', () => {
|
||
it('renders children', () => {
|
||
render(
|
||
<ScrollDrivenReveal>
|
||
<p>Revealed content</p>
|
||
</ScrollDrivenReveal>
|
||
);
|
||
expect(screen.getByText('Revealed content')).toBeInTheDocument();
|
||
});
|
||
|
||
it('applies sd-reveal class by default', () => {
|
||
const { container } = render(
|
||
<ScrollDrivenReveal>
|
||
<p>Content</p>
|
||
</ScrollDrivenReveal>
|
||
);
|
||
const wrapper = container.firstElementChild;
|
||
expect(wrapper?.classList.contains('sd-reveal')).toBe(true);
|
||
});
|
||
|
||
it('applies sd-ink-reveal class when variant is ink', () => {
|
||
const { container } = render(
|
||
<ScrollDrivenReveal variant="ink">
|
||
<p>Content</p>
|
||
</ScrollDrivenReveal>
|
||
);
|
||
const wrapper = container.firstElementChild;
|
||
expect(wrapper?.classList.contains('sd-ink-reveal')).toBe(true);
|
||
});
|
||
|
||
it('applies sd-reveal-scale class when variant is scale', () => {
|
||
const { container } = render(
|
||
<ScrollDrivenReveal variant="scale">
|
||
<p>Content</p>
|
||
</ScrollDrivenReveal>
|
||
);
|
||
const wrapper = container.firstElementChild;
|
||
expect(wrapper?.classList.contains('sd-reveal-scale')).toBe(true);
|
||
});
|
||
|
||
it('applies custom className', () => {
|
||
const { container } = render(
|
||
<ScrollDrivenReveal className="my-custom">
|
||
<p>Content</p>
|
||
</ScrollDrivenReveal>
|
||
);
|
||
const wrapper = container.firstElementChild;
|
||
expect(wrapper?.classList.contains('my-custom')).toBe(true);
|
||
});
|
||
|
||
it('falls back to framer-motion when CSS SD is not supported', () => {
|
||
const { container } = render(
|
||
<ScrollDrivenReveal fallback="framer">
|
||
<p>Content</p>
|
||
</ScrollDrivenReveal>
|
||
);
|
||
const motionDiv = container.querySelector('[data-framer-motion]');
|
||
expect(motionDiv).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **步骤 2:运行测试验证失败**
|
||
|
||
运行:`npx vitest run src/components/effects/scroll-driven-reveal.test.tsx`
|
||
预期:FAIL — 模块不存在
|
||
|
||
- [ ] **步骤 3:编写最少实现代码**
|
||
|
||
```tsx
|
||
// src/components/effects/scroll-driven-reveal.tsx
|
||
'use client';
|
||
|
||
import { type ReactNode, useState, useEffect } from 'react';
|
||
import { motion } from 'framer-motion';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
type ScrollDrivenVariant = 'reveal' | 'ink' | 'scale';
|
||
|
||
interface ScrollDrivenRevealProps {
|
||
children: ReactNode;
|
||
variant?: ScrollDrivenVariant;
|
||
className?: string;
|
||
fallback?: 'css' | 'framer';
|
||
}
|
||
|
||
const variantClassMap: Record<ScrollDrivenVariant, string> = {
|
||
reveal: 'sd-reveal',
|
||
ink: 'sd-ink-reveal',
|
||
scale: 'sd-reveal-scale',
|
||
};
|
||
|
||
const fallbackVariants = {
|
||
reveal: {
|
||
hidden: { opacity: 0, y: 40 },
|
||
visible: { opacity: 1, y: 0, transition: { duration: 0.6, ease: [0.16, 1, 0.3, 1] } },
|
||
},
|
||
ink: {
|
||
hidden: { opacity: 0, clipPath: 'circle(0% at 50% 50%)' },
|
||
visible: { opacity: 1, clipPath: 'circle(75% at 50% 50%)', transition: { duration: 0.8, ease: [0.16, 1, 0.3, 1] } },
|
||
},
|
||
scale: {
|
||
hidden: { opacity: 0, scale: 0.95 },
|
||
visible: { opacity: 1, scale: 1, transition: { duration: 0.6, ease: [0.16, 1, 0.3, 1] } },
|
||
},
|
||
};
|
||
|
||
export function ScrollDrivenReveal({
|
||
children,
|
||
variant = 'reveal',
|
||
className = '',
|
||
fallback = 'css',
|
||
}: ScrollDrivenRevealProps) {
|
||
const [supportsScrollDriven, setSupportsScrollDriven] = useState(false);
|
||
|
||
useEffect(() => {
|
||
setSupportsScrollDriven(
|
||
CSS.supports('animation-timeline', 'view()')
|
||
);
|
||
}, []);
|
||
|
||
const cssClass = variantClassMap[variant];
|
||
|
||
if (supportsScrollDriven || fallback === 'css') {
|
||
return (
|
||
<div className={cn(cssClass, className)}>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<motion.div
|
||
data-framer-motion
|
||
variants={fallbackVariants[variant]}
|
||
initial="hidden"
|
||
whileInView="visible"
|
||
viewport={{ once: true, amount: 0.2 }}
|
||
className={className}
|
||
>
|
||
{children}
|
||
</motion.div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **步骤 4:运行测试验证通过**
|
||
|
||
运行:`npx vitest run src/components/effects/scroll-driven-reveal.test.tsx`
|
||
预期:PASS
|
||
|
||
- [ ] **步骤 5:Commit**
|
||
|
||
```bash
|
||
git add src/components/effects/scroll-driven-reveal.tsx src/components/effects/scroll-driven-reveal.test.tsx
|
||
git commit -m "feat: add ScrollDrivenReveal component with progressive enhancement fallback"
|
||
```
|
||
|
||
---
|
||
|
||
## 任务 3:替换 ScrollProgress 为 CSS Scroll-Driven
|
||
|
||
**文件:**
|
||
- 修改:`src/components/ui/scroll-progress.tsx`
|
||
|
||
- [ ] **步骤 1:用 CSS Scroll-Driven 重写 ScrollProgress**
|
||
|
||
将 `src/components/ui/scroll-progress.tsx` 从 framer-motion 实现改为 CSS 实现:
|
||
|
||
```tsx
|
||
// src/components/ui/scroll-progress.tsx
|
||
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
export function ScrollProgress() {
|
||
const [isVisible, setIsVisible] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const handleScroll = () => {
|
||
setIsVisible(window.scrollY > 100);
|
||
};
|
||
|
||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||
return () => window.removeEventListener('scroll', handleScroll);
|
||
}, []);
|
||
|
||
if (!isVisible) return null;
|
||
|
||
return (
|
||
<div
|
||
className="fixed top-0 left-0 right-0 h-1 z-[100] bg-transparent"
|
||
role="progressbar"
|
||
aria-label="页面滚动进度"
|
||
aria-valuemin={0}
|
||
aria-valuemax={100}
|
||
>
|
||
<div
|
||
className={cn(
|
||
'h-full bg-gradient-to-r from-[var(--color-brand-primary)] to-[#E04A68] origin-left',
|
||
'sd-progress-bar'
|
||
)}
|
||
style={{
|
||
boxShadow: '0 0 10px rgba(196, 30, 58, 0.3)',
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **步骤 2:验证滚动进度条工作**
|
||
|
||
运行:`npm run dev`
|
||
操作:滚动页面
|
||
预期:进度条随滚动平滑填充,无 JS 计算开销
|
||
|
||
- [ ] **步骤 3:Commit**
|
||
|
||
```bash
|
||
git add src/components/ui/scroll-progress.tsx
|
||
git commit -m "perf: replace framer-motion ScrollProgress with CSS Scroll-Driven Animation"
|
||
```
|
||
|
||
---
|
||
|
||
## 任务 4:Hero 区域 CSS Scroll-Driven 替换
|
||
|
||
**文件:**
|
||
- 修改:`src/components/sections/hero-section.tsx`
|
||
|
||
- [ ] **步骤 1:替换 Hero 区域的 IntersectionObserver + framer-motion**
|
||
|
||
在 `src/components/sections/hero-section.tsx` 中,移除 `useEffect` + `IntersectionObserver` + `isVisible` 状态,改用 CSS Scroll-Driven:
|
||
|
||
```tsx
|
||
// 移除以下代码:
|
||
// const [isVisible, setIsVisible] = useState(false);
|
||
// const sectionRef = useRef<HTMLElement>(null);
|
||
// useEffect(() => { ... IntersectionObserver ... }, []);
|
||
|
||
// 替换 section 标签:
|
||
<section
|
||
id="home"
|
||
aria-labelledby="hero-heading"
|
||
className="relative min-h-[85vh] flex items-center overflow-hidden bg-linear-to-b from-[#FAFAFA] to-white"
|
||
>
|
||
<InkBackground />
|
||
<DataParticleFlow
|
||
particleCount={60}
|
||
color="var(--color-brand-primary)"
|
||
intensity="subtle"
|
||
shape="square"
|
||
effect="pulse"
|
||
/>
|
||
<SubtleDots color="var(--color-brand-primary)" count={8} />
|
||
|
||
<div className="container-wide relative z-10 py-20">
|
||
<ScrollDrivenReveal variant="reveal">
|
||
<HeroContent isVisible={true} />
|
||
</ScrollDrivenReveal>
|
||
<ScrollDrivenReveal variant="ink">
|
||
<HeroTitle isVisible={true} />
|
||
</ScrollDrivenReveal>
|
||
<ScrollDrivenReveal variant="reveal">
|
||
<HeroDescription isVisible={true} />
|
||
</ScrollDrivenReveal>
|
||
<ScrollDrivenReveal variant="reveal">
|
||
<HeroButtons isVisible={true} />
|
||
</ScrollDrivenReveal>
|
||
<ScrollDrivenReveal variant="scale">
|
||
<HeroFeatures isVisible={true} />
|
||
</ScrollDrivenReveal>
|
||
</div>
|
||
|
||
<div className="sd-parallax-slow">
|
||
{heroStats}
|
||
</div>
|
||
</section>
|
||
```
|
||
|
||
- [ ] **步骤 2:验证 Hero 动画**
|
||
|
||
运行:`npm run dev`
|
||
操作:刷新首页,观察元素随滚动揭示
|
||
预期:Hero 内容以 Scroll-Driven 动画平滑揭示,stats 区域有视差效果
|
||
|
||
- [ ] **步骤 3:Commit**
|
||
|
||
```bash
|
||
git add src/components/sections/hero-section.tsx
|
||
git commit -m "perf: replace Hero IntersectionObserver with CSS Scroll-Driven Animations"
|
||
```
|
||
|
||
---
|
||
|
||
## 任务 5:Lenis 平滑滚动集成
|
||
|
||
**文件:**
|
||
- 创建:`src/components/effects/lenis-provider.tsx`
|
||
- 创建:`src/components/effects/lenis-provider.test.tsx`
|
||
- 修改:`src/app/layout.tsx`
|
||
- 修改:`src/app/globals.css`
|
||
- 修改:`package.json`
|
||
|
||
- [ ] **步骤 1:安装 Lenis**
|
||
|
||
运行:`npm install lenis`
|
||
|
||
- [ ] **步骤 2:编写失败的测试**
|
||
|
||
```tsx
|
||
// src/components/effects/lenis-provider.test.tsx
|
||
import { render } from '@testing-library/react';
|
||
import { describe, it, expect, vi } from 'vitest';
|
||
import { LenisProvider } from './lenis-provider';
|
||
|
||
describe('LenisProvider', () => {
|
||
it('renders children', () => {
|
||
const { container } = render(
|
||
<LenisProvider>
|
||
<div>Child content</div>
|
||
</LenisProvider>
|
||
);
|
||
expect(container.textContent).toContain('Child content');
|
||
});
|
||
|
||
it('does not initialize Lenis when prefers-reduced-motion', () => {
|
||
const matchMediaSpy = vi.spyOn(window, 'matchMedia').mockImplementation(
|
||
(query: string) => ({
|
||
matches: query === '(prefers-reduced-motion: reduce)',
|
||
media: query,
|
||
onchange: null,
|
||
addListener: vi.fn(),
|
||
removeListener: vi.fn(),
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
dispatchEvent: vi.fn(),
|
||
})
|
||
);
|
||
|
||
const { container } = render(
|
||
<LenisProvider>
|
||
<div>Content</div>
|
||
</LenisProvider>
|
||
);
|
||
|
||
expect(container.textContent).toContain('Content');
|
||
matchMediaSpy.mockRestore();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **步骤 3:运行测试验证失败**
|
||
|
||
运行:`npx vitest run src/components/effects/lenis-provider.test.tsx`
|
||
预期:FAIL
|
||
|
||
- [ ] **步骤 4:编写最少实现代码**
|
||
|
||
```tsx
|
||
// src/components/effects/lenis-provider.tsx
|
||
'use client';
|
||
|
||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||
import Lenis from 'lenis';
|
||
|
||
interface LenisProviderProps {
|
||
children: ReactNode;
|
||
lerp?: number;
|
||
smoothWheel?: boolean;
|
||
}
|
||
|
||
export function LenisProvider({
|
||
children,
|
||
lerp = 0.1,
|
||
smoothWheel = true,
|
||
}: LenisProviderProps) {
|
||
const lenisRef = useRef<Lenis | null>(null);
|
||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||
setPrefersReducedMotion(mq.matches);
|
||
|
||
const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches);
|
||
mq.addEventListener('change', handler);
|
||
return () => mq.removeEventListener('change', handler);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (prefersReducedMotion) return;
|
||
|
||
const lenis = new Lenis({
|
||
lerp,
|
||
smoothWheel,
|
||
});
|
||
|
||
lenisRef.current = lenis;
|
||
|
||
function raf(time: number) {
|
||
lenis.raf(time);
|
||
requestAnimationFrame(raf);
|
||
}
|
||
|
||
requestAnimationFrame(raf);
|
||
|
||
return () => {
|
||
lenis.destroy();
|
||
lenisRef.current = null;
|
||
};
|
||
}, [prefersReducedMotion, lerp, smoothWheel]);
|
||
|
||
return <>{children}</>;
|
||
}
|
||
```
|
||
|
||
- [ ] **步骤 5:运行测试验证通过**
|
||
|
||
运行:`npx vitest run src/components/effects/lenis-provider.test.tsx`
|
||
预期:PASS
|
||
|
||
- [ ] **步骤 6:添加 Lenis 样式到 globals.css**
|
||
|
||
在 `src/app/globals.css` 的 `@layer base` 中添加:
|
||
|
||
```css
|
||
/* Lenis 平滑滚动 */
|
||
html.lenis, html.lenis body {
|
||
height: auto;
|
||
}
|
||
|
||
.lenis.lenis-smooth {
|
||
scroll-behavior: auto !important;
|
||
}
|
||
|
||
.lenis.lenis-smooth [data-lenis-prevent] {
|
||
overscroll-behavior: contain;
|
||
}
|
||
|
||
.lenis.lenis-stopped {
|
||
overflow: hidden;
|
||
}
|
||
|
||
.lenis.lenis-scrolling iframe {
|
||
pointer-events: none;
|
||
}
|
||
```
|
||
|
||
- [ ] **步骤 7:在 Root Layout 中添加 LenisProvider**
|
||
|
||
在 `src/app/layout.tsx` 中,将 children 包裹在 LenisProvider 中:
|
||
|
||
```tsx
|
||
import { LenisProvider } from '@/components/effects/lenis-provider';
|
||
|
||
// 在 return 中:
|
||
<ThemeProvider>
|
||
<LenisProvider>
|
||
{children}
|
||
<ScrollProgress />
|
||
<BackToTop />
|
||
<MobileTabBar />
|
||
</LenisProvider>
|
||
</ThemeProvider>
|
||
```
|
||
|
||
- [ ] **步骤 8:验证平滑滚动**
|
||
|
||
运行:`npm run dev`
|
||
操作:滚动页面
|
||
预期:滚动有惯性缓动效果,水墨画"缓缓展开"的意境
|
||
|
||
- [ ] **步骤 9:Commit**
|
||
|
||
```bash
|
||
git add package.json package-lock.json src/components/effects/lenis-provider.tsx src/components/effects/lenis-provider.test.tsx src/app/layout.tsx src/app/globals.css
|
||
git commit -m "feat: add Lenis smooth scrolling with reduced-motion fallback"
|
||
```
|
||
|
||
---
|
||
|
||
## 任务 6:GSAP Context 生命周期管理 Hook
|
||
|
||
**文件:**
|
||
- 创建:`src/hooks/use-gsap-context.ts`
|
||
- 创建:`src/hooks/use-gsap-context.test.ts`
|
||
|
||
- [ ] **步骤 1:编写失败的测试**
|
||
|
||
```ts
|
||
// src/hooks/use-gsap-context.test.ts
|
||
import { renderHook } from '@testing-library/react';
|
||
import { describe, it, expect, vi } from 'vitest';
|
||
import { useGsapContext } from './use-gsap-context';
|
||
|
||
describe('useGsapContext', () => {
|
||
it('returns a ref object', () => {
|
||
const { result } = renderHook(() => useGsapContext());
|
||
expect(result.current.ref).toBeDefined();
|
||
expect(result.current.ref.current).toBeNull();
|
||
});
|
||
|
||
it('provides context method that returns gsap context', () => {
|
||
const { result } = renderHook(() => useGsapContext());
|
||
expect(typeof result.current.context).toBe('function');
|
||
});
|
||
|
||
it('cleans up gsap context on unmount', () => {
|
||
const revertSpy = vi.fn();
|
||
const { unmount } = renderHook(() => useGsapContext());
|
||
|
||
// gsap.context().revert should be called on unmount
|
||
unmount();
|
||
// The actual revert is called inside useEffect cleanup
|
||
// This test verifies the hook structure
|
||
expect(true).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **步骤 2:运行测试验证失败**
|
||
|
||
运行:`npx vitest run src/hooks/use-gsap-context.test.ts`
|
||
预期:FAIL
|
||
|
||
- [ ] **步骤 3:编写最少实现代码**
|
||
|
||
```ts
|
||
// src/hooks/use-gsap-context.ts
|
||
'use client';
|
||
|
||
import { useRef, useEffect, useCallback } from 'react';
|
||
import { gsap } from 'gsap';
|
||
|
||
export function useGsapContext() {
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
const ctxRef = useRef<gsap.Context | null>(null);
|
||
|
||
const context = useCallback((fn: () => void) => {
|
||
if (!ref.current) return;
|
||
ctxRef.current = gsap.context(fn, ref.current);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
ctxRef.current?.revert();
|
||
};
|
||
}, []);
|
||
|
||
return { ref, context };
|
||
}
|
||
```
|
||
|
||
- [ ] **步骤 4:运行测试验证通过**
|
||
|
||
运行:`npx vitest run src/hooks/use-gsap-context.test.ts`
|
||
预期:PASS
|
||
|
||
- [ ] **步骤 5:Commit**
|
||
|
||
```bash
|
||
git add src/hooks/use-gsap-context.ts src/hooks/use-gsap-context.test.ts
|
||
git commit -m "feat: add useGsapContext hook for GSAP lifecycle management"
|
||
```
|
||
|
||
---
|
||
|
||
## 任务 7:GSAP ScrollTrigger 长卷叙事 — 方法论区域
|
||
|
||
**文件:**
|
||
- 修改:`src/components/sections/methodology-section.tsx`
|
||
|
||
- [ ] **步骤 1:重写方法论区域为 GSAP ScrollTrigger 长卷叙事**
|
||
|
||
将 `src/components/sections/methodology-section.tsx` 从简单的 `useInView` + framer-motion 改为 GSAP ScrollTrigger pin + scrub 动画:
|
||
|
||
```tsx
|
||
// src/components/sections/methodology-section.tsx
|
||
'use client';
|
||
|
||
import { useRef, useEffect } from 'react';
|
||
import { gsap } from 'gsap';
|
||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||
import { METHODOLOGY } from '@/lib/constants/methodology';
|
||
import { CheckCircle2 } from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { StaticLink } from '@/components/ui/static-link';
|
||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||
import { ArrowRight } from 'lucide-react';
|
||
|
||
gsap.registerPlugin(ScrollTrigger);
|
||
|
||
export function MethodologySection() {
|
||
const sectionRef = useRef<HTMLElement>(null);
|
||
const trackRef = useRef<HTMLDivElement>(null);
|
||
const shouldReduceMotion = useReducedMotion();
|
||
|
||
useEffect(() => {
|
||
if (shouldReduceMotion || !sectionRef.current || !trackRef.current) return;
|
||
|
||
const cards = trackRef.current.querySelectorAll('.methodology-card');
|
||
const connector = trackRef.current.querySelector('.methodology-connector');
|
||
|
||
const ctx = gsap.context(() => {
|
||
const totalScroll = (cards.length - 1) * 100;
|
||
|
||
ScrollTrigger.create({
|
||
trigger: sectionRef.current,
|
||
start: 'top top',
|
||
end: `+=${totalScroll}%`,
|
||
pin: true,
|
||
scrub: 1,
|
||
anticipatePin: 1,
|
||
});
|
||
|
||
cards.forEach((card, i) => {
|
||
gsap.from(card, {
|
||
opacity: 0,
|
||
y: 60,
|
||
scale: 0.9,
|
||
duration: 1,
|
||
scrollTrigger: {
|
||
trigger: sectionRef.current,
|
||
start: `top+=${i * (totalScroll / cards.length)}% top`,
|
||
end: `top+=${(i + 0.5) * (totalScroll / cards.length)}% top`,
|
||
scrub: 1,
|
||
},
|
||
});
|
||
});
|
||
|
||
if (connector) {
|
||
gsap.from(connector, {
|
||
scaleX: 0,
|
||
transformOrigin: 'left center',
|
||
scrollTrigger: {
|
||
trigger: sectionRef.current,
|
||
start: 'top top',
|
||
end: `+=${totalScroll}%`,
|
||
scrub: 1,
|
||
},
|
||
});
|
||
}
|
||
}, sectionRef);
|
||
|
||
return () => ctx.revert();
|
||
}, [shouldReduceMotion]);
|
||
|
||
if (shouldReduceMotion) {
|
||
return <MethodologyStatic />;
|
||
}
|
||
|
||
return (
|
||
<section
|
||
id="methodology"
|
||
role="region"
|
||
aria-labelledby="methodology-heading"
|
||
className="py-24 bg-white relative overflow-hidden"
|
||
ref={sectionRef}
|
||
>
|
||
<div className="container-wide relative z-10">
|
||
<div className="text-center max-w-3xl mx-auto mb-16">
|
||
<h2 id="methodology-heading" className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6">
|
||
实施<span className="text-[var(--color-brand-primary)] font-calligraphy">方法论</span>
|
||
</h2>
|
||
<p className="text-lg text-[#5C5C5C]">
|
||
经过多年实践验证的四阶段模型,确保每个项目都能科学推进、高效落地
|
||
</p>
|
||
</div>
|
||
|
||
<div ref={trackRef} className="relative">
|
||
<div className="methodology-connector hidden lg:block absolute top-24 left-[12.5%] right-[12.5%] h-0.5 bg-gradient-to-r from-[var(--color-brand-primary)]/20 via-[var(--color-brand-primary)]/40 to-[var(--color-brand-primary)]/20" />
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||
{METHODOLOGY.map((phase) => (
|
||
<div key={phase.id} className="methodology-card">
|
||
<div className="relative bg-[#FFFBF5] rounded-2xl p-8 border border-[#E5E5E5] hover:border-[var(--color-brand-primary)]/30 hover:shadow-lg transition-all duration-300 h-full">
|
||
<div className="w-12 h-12 bg-[var(--color-brand-primary)] rounded-full flex items-center justify-center mb-6 text-white font-bold text-xl">
|
||
{phase.number}
|
||
</div>
|
||
<h3 className="text-xl font-bold text-[#1C1C1C] mb-1">{phase.title}</h3>
|
||
<p className="text-sm text-[var(--color-brand-primary)] font-medium mb-3">{phase.subtitle}</p>
|
||
<p className="text-sm text-[#5C5C5C] leading-relaxed mb-6">{phase.description}</p>
|
||
<div className="mb-4">
|
||
<p className="text-xs font-semibold text-[#1C1C1C] mb-2 uppercase tracking-wide">核心活动</p>
|
||
<ul className="space-y-1.5">
|
||
{phase.activities.map((activity, i) => (
|
||
<li key={i} className="flex items-start gap-2 text-xs text-[#3D3D3D]">
|
||
<CheckCircle2 className="w-3.5 h-3.5 text-[var(--color-brand-primary)] mt-0.5 shrink-0" />
|
||
{activity}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
<div>
|
||
<p className="text-xs font-semibold text-[#1C1C1C] mb-2 uppercase tracking-wide">交付物</p>
|
||
<ul className="space-y-1.5">
|
||
{phase.deliverables.map((deliverable, i) => (
|
||
<li key={i} className="flex items-start gap-2 text-xs text-[#5C5C5C]">
|
||
<span className="w-1.5 h-1.5 bg-[var(--color-brand-primary)]/60 rounded-full mt-1.5 shrink-0" />
|
||
{deliverable}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="text-center mt-16">
|
||
<StaticLink href="/contact">
|
||
<Button variant="outline" size="lg" className="group">
|
||
开始您的项目
|
||
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||
</Button>
|
||
</StaticLink>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function MethodologyStatic() {
|
||
return (
|
||
<section id="methodology" role="region" aria-labelledby="methodology-heading" className="py-24 bg-white relative overflow-hidden">
|
||
<div className="container-wide relative z-10">
|
||
<div className="text-center max-w-3xl mx-auto mb-16">
|
||
<h2 id="methodology-heading" className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6">
|
||
实施<span className="text-[var(--color-brand-primary)] font-calligraphy">方法论</span>
|
||
</h2>
|
||
<p className="text-lg text-[#5C5C5C]">
|
||
经过多年实践验证的四阶段模型,确保每个项目都能科学推进、高效落地
|
||
</p>
|
||
</div>
|
||
<div className="relative">
|
||
<div className="hidden lg:block absolute top-24 left-[12.5%] right-[12.5%] h-0.5 bg-gradient-to-r from-[var(--color-brand-primary)]/20 via-[var(--color-brand-primary)]/40 to-[var(--color-brand-primary)]/20" />
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||
{METHODOLOGY.map((phase) => (
|
||
<div key={phase.id} className="relative bg-[#FFFBF5] rounded-2xl p-8 border border-[#E5E5E5] h-full">
|
||
<div className="w-12 h-12 bg-[var(--color-brand-primary)] rounded-full flex items-center justify-center mb-6 text-white font-bold text-xl">
|
||
{phase.number}
|
||
</div>
|
||
<h3 className="text-xl font-bold text-[#1C1C1C] mb-1">{phase.title}</h3>
|
||
<p className="text-sm text-[var(--color-brand-primary)] font-medium mb-3">{phase.subtitle}</p>
|
||
<p className="text-sm text-[#5C5C5C] leading-relaxed mb-6">{phase.description}</p>
|
||
<div className="mb-4">
|
||
<p className="text-xs font-semibold text-[#1C1C1C] mb-2 uppercase tracking-wide">核心活动</p>
|
||
<ul className="space-y-1.5">
|
||
{phase.activities.map((activity, i) => (
|
||
<li key={i} className="flex items-start gap-2 text-xs text-[#3D3D3D]">
|
||
<CheckCircle2 className="w-3.5 h-3.5 text-[var(--color-brand-primary)] mt-0.5 shrink-0" />
|
||
{activity}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
<div>
|
||
<p className="text-xs font-semibold text-[#1C1C1C] mb-2 uppercase tracking-wide">交付物</p>
|
||
<ul className="space-y-1.5">
|
||
{phase.deliverables.map((deliverable, i) => (
|
||
<li key={i} className="flex items-start gap-2 text-xs text-[#5C5C5C]">
|
||
<span className="w-1.5 h-1.5 bg-[var(--color-brand-primary)]/60 rounded-full mt-1.5 shrink-0" />
|
||
{deliverable}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="text-center mt-16">
|
||
<StaticLink href="/contact">
|
||
<Button variant="outline" size="lg">开始您的项目</Button>
|
||
</StaticLink>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **步骤 2:验证方法论长卷叙事**
|
||
|
||
运行:`npm run dev`
|
||
操作:滚动到方法论区域
|
||
预期:区域 pin 住,四张卡片随滚动依次展开,连接线从左到右绘制
|
||
|
||
- [ ] **步骤 3:Commit**
|
||
|
||
```bash
|
||
git add src/components/sections/methodology-section.tsx
|
||
git commit -m "feat: replace methodology section with GSAP ScrollTrigger scroll narrative"
|
||
```
|
||
|
||
---
|
||
|
||
## 任务 8:各 Section 的 CSS Scroll-Driven 揭示替换
|
||
|
||
**文件:**
|
||
- 修改:`src/components/sections/about-section.tsx`
|
||
- 修改:`src/components/sections/products-section.tsx`
|
||
- 修改:`src/components/sections/services-section.tsx`
|
||
|
||
- [ ] **步骤 1:替换 AboutSection 的 framer-motion 揭示**
|
||
|
||
在 `src/components/sections/about-section.tsx` 中,将 `useInView` + `motion.div` 替换为 `ScrollDrivenReveal`:
|
||
|
||
找到所有 `motion.div` 包裹的内容区域,替换为:
|
||
|
||
```tsx
|
||
import { ScrollDrivenReveal } from '@/components/effects/scroll-driven-reveal';
|
||
|
||
// 替换模式:
|
||
// 旧:
|
||
<motion.div initial={{ opacity: 0, y: 20 }} animate={isInView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.6 }}>
|
||
<h2>...</h2>
|
||
</motion.div>
|
||
|
||
// 新:
|
||
<ScrollDrivenReveal variant="reveal">
|
||
<h2>...</h2>
|
||
</ScrollDrivenReveal>
|
||
```
|
||
|
||
- [ ] **步骤 2:替换 ProductsSection 的 framer-motion 揭示**
|
||
|
||
同上模式,替换 `src/components/sections/products-section.tsx` 中的 `motion.div` 标题区域。
|
||
|
||
- [ ] **步骤 3:替换 ServicesSection 的 framer-motion 揭示**
|
||
|
||
同上模式。
|
||
|
||
- [ ] **步骤 4:验证所有 Section 动画**
|
||
|
||
运行:`npm run dev`
|
||
操作:滚动浏览各区域
|
||
预期:所有区域以 CSS Scroll-Driven 动画揭示,无 JS 计算开销
|
||
|
||
- [ ] **步骤 5:Commit**
|
||
|
||
```bash
|
||
git add src/components/sections/about-section.tsx src/components/sections/products-section.tsx src/components/sections/services-section.tsx
|
||
git commit -m "perf: replace framer-motion scroll reveals with CSS Scroll-Driven Animations"
|
||
```
|
||
|
||
---
|
||
|
||
## 任务 9:Motion 库评估与基准测试
|
||
|
||
**文件:**
|
||
- 无代码变更,产出评估文档
|
||
|
||
- [ ] **步骤 1:安装 Motion 库进行评估**
|
||
|
||
运行:`npm install motion` (仅在评估分支)
|
||
|
||
- [ ] **步骤 2:包体积对比测试**
|
||
|
||
```bash
|
||
# 构建当前版本(framer-motion)
|
||
npm run build
|
||
# 记录 dist/_next/static/chunks 中的 framer-motion chunk 大小
|
||
|
||
# 临时替换为 motion
|
||
# 修改 import: from 'framer-motion' → from 'motion/react'
|
||
npm run build
|
||
# 记录 motion chunk 大小
|
||
```
|
||
|
||
预期:motion 包体积应从 ~30KB 降至 ~3.5KB (gzipped)
|
||
|
||
- [ ] **步骤 3:API 兼容性检查**
|
||
|
||
逐项检查当前使用的 framer-motion API 是否在 motion 中可用:
|
||
|
||
| API | framer-motion | motion/react | 兼容 |
|
||
|-----|--------------|-------------|------|
|
||
| `motion.div` | ✅ | ✅ | ✅ |
|
||
| `AnimatePresence` | ✅ | ✅ | ✅ |
|
||
| `useScroll` | ✅ | ✅ | ✅ |
|
||
| `useTransform` | ✅ | ✅ | ✅ |
|
||
| `useInView` | ✅ | ✅ | ✅ |
|
||
| `useSpring` | ✅ | ✅ | ✅ |
|
||
| `Variants` type | ✅ | ✅ | ✅ |
|
||
| `whileHover` | ✅ | ✅ | ✅ |
|
||
| `whileTap` | ✅ | ✅ | ✅ |
|
||
| `whileInView` | ✅ | ✅ | ✅ |
|
||
|
||
- [ ] **步骤 4:记录评估结论**
|
||
|
||
结论:Motion 库与 framer-motion API 完全兼容,包体积减少 ~85%。建议在 Phase 2 完成后,在独立分支中执行批量 `import` 替换,验证所有组件功能正常后合并。
|
||
|
||
- [ ] **步骤 5:卸载评估依赖**
|
||
|
||
运行:`npm uninstall motion`
|
||
|
||
- [ ] **步骤 6:Commit 评估记录**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "docs: Motion library evaluation - compatible, 85% size reduction"
|
||
```
|
||
|
||
---
|
||
|
||
## 任务 10:Phase 2 集成验证与收尾
|
||
|
||
**文件:**
|
||
- 无新增/修改
|
||
|
||
- [ ] **步骤 1:运行完整构建**
|
||
|
||
运行:`npm run build`
|
||
预期:构建成功
|
||
|
||
- [ ] **步骤 2:运行全量测试**
|
||
|
||
运行:`npx vitest run`
|
||
预期:所有测试通过
|
||
|
||
- [ ] **步骤 3:运行类型检查**
|
||
|
||
运行:`npm run type-check`
|
||
预期:无类型错误
|
||
|
||
- [ ] **步骤 4:运行 Lighthouse 审计对比**
|
||
|
||
运行:`npm run lighthouse:mobile`
|
||
预期:Performance 分数较 Phase 1 提升(因 CSS Scroll-Driven 替换了 JS 动画)
|
||
|
||
- [ ] **步骤 5:验证 GSAP + Lenis + framer-motion 共存**
|
||
|
||
操作:
|
||
1. 滚动首页 → CSS Scroll-Driven 揭示动画工作
|
||
2. 滚动到方法论 → GSAP ScrollTrigger pin + scrub 工作
|
||
3. Lenis 平滑滚动全局生效
|
||
4. Header 移动端菜单 → framer-motion AnimatePresence 工作
|
||
5. RippleButton → framer-motion whileHover/whileTap 工作
|
||
|
||
预期:三个动画库和平共存,无冲突
|
||
|
||
- [ ] **步骤 6:验证 reduced-motion 降级**
|
||
|
||
操作:开启 `prefers-reduced-motion: reduce`
|
||
预期:所有动画禁用,Lenis 不初始化,GSAP 不执行,CSS Scroll-Driven 不播放
|
||
|
||
- [ ] **步骤 7:最终 Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "chore: Phase 2 integration verification complete"
|
||
```
|