为 Astro 博客添加可插拔的圣诞特效模块

发表于 2025-12-25 3967 字 20 min read

文章目录
cos avatar

cos

FE / ACG / 手工 / 深色模式强迫症 / INFP / 兴趣广泛养两只猫的老宅女 / remote

暂无目录
本文实现了一套可插拔的圣诞节日特效模块,通过 React Three Fiber 与 GLSL 着色器实现双层3D雪花飘落、CSS动画彩灯、SVG圣诞帽及可拖拽开关等功能,结合 nanostores 实现跨组件状态管理,并通过配置化设计与性能优化(如移动端降级、鼠标视差、双层渲染)提升视觉层次与用户体验。

本文由 AI 辅助写作,作记录用

圣诞节到了,想给博客加点节日氛围。最终实现了一套可插拔的圣诞特效模块,包括:

  • 3D 雪花飘落(React Three Fiber + GLSL 着色器)
  • 悬挂彩灯装饰(CSS 动画)
  • 头像圣诞帽(SVG)
  • 圣诞配色方案(CSS 变量)
  • 可拖拽圣诞球开关

先放最终效果:

圣诞特效展示 1

如果嫌弃干扰阅读了,可以点击开关关掉~

圣诞特效展示 2

注意看这个雪是双层的,内容容器夹在雪花中间,远景雪花在文章后面飘过,近景雪花在前面飘过,配合视差效果,感觉还挺满意的。

圣诞特效展示 3

技术选型上,雪花飘落使用 React Three Fiber 实现 GPU 着色器驱动 的 6 层粒子系统,支持鼠标视差效果,在视觉效果和性能之间取得平衡。

整体架构

flowchart TB
    subgraph Config[配置层]
        SC[site-config.ts<br/>christmasConfig]
    end

    subgraph State[状态管理]
        NS[nanostores<br/>christmasEnabled]
        LS[localStorage<br/>持久化]
    end

    subgraph Effects[特效组件]
        SF[SnowfallCanvas<br/>GLSL 雪花着色器]
        CL[ChristmasLights<br/>CSS 彩灯]
        CH[ChristmasHat<br/>SVG 圣诞帽]
        CT[christmas-theme.css<br/>配色方案]
    end

    subgraph UI[用户界面]
        OT[ChristmasOrnamentToggle<br/>可拖拽开关]
    end

    SC --> NS
    NS <--> LS
    NS --> SF
    SC --> CL
    OT --> NS
    SC --> CH
    SC --> CT

所有特效都通过 christmasConfig.enabled 控制,运行时状态通过 nanostores 管理,支持用户手动切换并持久化到 localStorage。

配置结构设计

src/constants/site-config.ts 中定义圣诞配置:

type ChristmasConfig = {
  enabled: boolean;
  features: {
    snowfall: boolean;             // 雪花飘落
    christmasColorScheme: boolean; // 圣诞配色
    christmasCoverDecoration: boolean; // Cover 彩灯装饰
    christmasHat: boolean;         // 头像圣诞帽
  };
  snowfall: {
    speed: number;                 // 下落速度 (1 = 正常)
    intensity: number;             // 雪花密度 (0-1)
    mobileIntensity: number;       // 移动端密度 (0-1)
  };
};

export const christmasConfig: ChristmasConfig = {
  enabled: true,
  features: {
    snowfall: true,
    christmasColorScheme: true,
    christmasCoverDecoration: true,
    christmasHat: true,
  },
  snowfall: {
    speed: 1,
    intensity: 0.6,
    mobileIntensity: 0.4,
  },
};

这种设计允许细粒度控制各个特效,方便调试和按需启用。

状态管理:跨 Astro/React 边界

Astro 的岛屿架构中,.astro 组件和 React 组件需要共享状态。使用 nanostores 作为轻量级状态管理:

// src/store/christmas.ts
import { atom } from 'nanostores';

const STORAGE_KEY = 'christmas-enabled';

// 运行时开关状态
export const christmasEnabled = atom<boolean>(true);

// 初始化:从 localStorage 恢复状态
export function initChristmasState(): void {
  if (typeof window === 'undefined') return;

  const stored = localStorage.getItem(STORAGE_KEY);
  if (stored !== null) {
    christmasEnabled.set(stored === 'true');
  }
  syncChristmasClass(christmasEnabled.get());
}

// 同步 html.christmas class(用于 CSS 配色切换)
function syncChristmasClass(enabled: boolean): void {
  if (typeof document === 'undefined') return;
  document.documentElement.classList.toggle('christmas', enabled);
}

// 切换开关
export function toggleChristmas(): void {
  const newValue = !christmasEnabled.get();
  christmasEnabled.set(newValue);
  localStorage.setItem(STORAGE_KEY, String(newValue));
  syncChristmasClass(newValue);
}

// 启用圣诞特效
export function enableChristmas(): void {
  christmasEnabled.set(true);
  localStorage.setItem(STORAGE_KEY, 'true');
  syncChristmasClass(true);
}

// 禁用圣诞特效
export function disableChristmas(): void {
  christmasEnabled.set(false);
  localStorage.setItem(STORAGE_KEY, 'false');
  syncChristmasClass(false);
}

在 React 组件中使用 @nanostores/reactuseStore 订阅状态:

import { useStore } from '@nanostores/react';
import { christmasEnabled } from '@store/christmas';

function SnowfallCanvas() {
  const isEnabled = useStore(christmasEnabled);

  if (!isEnabled) return null;
  // ...渲染雪花
}

在 Astro 组件的 <script> 中直接使用:

import { christmasEnabled, toggleChristmas } from '@store/christmas';

christmasEnabled.subscribe((enabled) => {
  // 更新 UI 状态
});

雪花飘落:React Three Fiber 粒子系统

选择 React Three Fiber + 自定义 GLSL 着色器,参考 Shadertoy 上的 “Just snow” 效果,完全在 GPU 上计算和渲染 6 层雪花。

粒子系统实现

使用 ShaderMaterial 实现全屏着色器渲染:

// src/components/christmas/SnowParticles.tsx
import { useFrame, useThree } from '@react-three/fiber';
import { useMemo, useRef, type MutableRefObject } from 'react';
import * as THREE from 'three';

const SnowShaderMaterial = {
  vertexShader: `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform float uTime;
    uniform vec2 uResolution;
    uniform float uSpeed;
    uniform float uIntensity;
    uniform vec2 uMouse;      // 鼠标视差偏移
    uniform int uLayerStart;  // 渲染层范围起始
    uniform int uLayerEnd;    // 渲染层范围结束

    varying vec2 vUv;

    void main() {
      vec2 fragCoord = vUv * uResolution;
      float snow = 0.0;
      float time = uTime * uSpeed;

      // 6层雪花,每层12次迭代
      for(int k = 0; k < 6; k++) {
        if(k < uLayerStart || k > uLayerEnd) continue;
        for(int i = 0; i < 12; i++) {
          float cellSize = 2.0 + (float(i) * 3.0);
          float downSpeed = 0.3 + (sin(time * 0.4 + float(k + i * 20)) + 1.0) * 0.00008;

          // 视差偏移:近景层偏移更多
          float parallaxFactor = 0.5 + float(k) * 0.1;
          vec2 mouseOffset = uMouse * parallaxFactor;

          vec2 uv = (fragCoord.xy / uResolution.x) + mouseOffset + vec2(
            0.01 * sin((time + float(k * 6185)) * 0.6 + float(i)) * (5.0 / float(i)),
            downSpeed * (time + float(k * 1352)) * (1.0 / float(i))
          );

          vec2 uvStep = (ceil((uv) * cellSize - vec2(0.5, 0.5)) / cellSize);
          float x = fract(sin(dot(uvStep.xy, vec2(12.9898 + float(k) * 12.0, 78.233 + float(k) * 315.156))) * 43758.5453 + float(k) * 12.0) - 0.5;
          float y = fract(sin(dot(uvStep.xy, vec2(62.2364 + float(k) * 23.0, 94.674 + float(k) * 95.0))) * 62159.8432 + float(k) * 12.0) - 0.5;

          float d = 5.0 * distance((uvStep.xy + vec2(x * sin(y), y) * sin(time * 2.5) * 0.7 / cellSize), uv.xy);

          float omiVal = fract(sin(dot(uvStep.xy, vec2(32.4691, 94.615))) * 31572.1684);
          if(omiVal < 0.08) {
            snow += (x + 1.0) * 0.4 * clamp(1.9 - d * (15.0 + (x * 6.3)) * (cellSize / 1.4), 0.0, 1.0);
          }
        }
      }

      gl_FragColor = vec4(1.0, 1.0, 1.0, snow * uIntensity);
    }
  `,
};

interface SnowParticlesProps {
  speed?: number;
  intensity?: number;
  parallaxRef?: MutableRefObject<{ x: number; y: number }>;
  layerRange?: [number, number];
}

export function SnowParticles({
  speed = 1,
  intensity = 0.6,
  parallaxRef,
  layerRange = [0, 5]
}: SnowParticlesProps) {
  const shaderMaterial = useRef<THREE.ShaderMaterial>(null);
  const { size } = useThree();
  const [layerStart, layerEnd] = layerRange;

  const uniforms = useMemo(() => ({
    uTime: { value: 0 },
    uResolution: { value: new THREE.Vector2(size.width, size.height) },
    uSpeed: { value: speed },
    uIntensity: { value: intensity },
    uMouse: { value: new THREE.Vector2(0, 0) },
    uLayerStart: { value: layerStart },
    uLayerEnd: { value: layerEnd },
  }), [size.width, size.height, speed, intensity, layerStart, layerEnd]);

  useFrame((state) => {
    if (shaderMaterial.current) {
      shaderMaterial.current.uniforms.uTime.value = state.clock.getElapsedTime();
      if (parallaxRef) {
        shaderMaterial.current.uniforms.uMouse.value.set(parallaxRef.current.x, parallaxRef.current.y);
      }
    }
  });

  return (
    <mesh>
      <planeGeometry args={[2, 2]} />
      <shaderMaterial
        ref={shaderMaterial}
        transparent
        depthWrite={false}
        blending={THREE.AdditiveBlending}
        uniforms={uniforms}
        vertexShader={SnowShaderMaterial.vertexShader}
        fragmentShader={SnowShaderMaterial.fragmentShader}
      />
    </mesh>
  );
}

核心思路:

  • 使用 planeGeometry 创建全屏四边形
  • Fragment Shader 中用 6 层嵌套循环生成不同大小、速度的雪花
  • 每层雪花有独立的下落速度和风力相位
  • 通过 uLayerStartuLayerEnd uniform 控制渲染哪些层(用于前后景分离)

Canvas 配置:不阻挡点击

一个关键问题是 R3F Canvas 默认会拦截所有鼠标事件,导致下方元素无法点击。解决方案:

<Canvas
  // 全屏着色器使用正交相机
  orthographic
  camera={{ zoom: 1, position: [0, 0, 1] }}
  // 禁用 R3F 的事件系统
  eventSource={undefined}
  eventPrefix={undefined}
  style={{ pointerEvents: 'none' }}
  gl={{
    antialias: false,
    alpha: true,
    powerPreference: 'low-power', // 省电模式
  }}
  dpr={[1, 1.5]} // 限制 DPR,移动端性能优化
>

鼠标视差效果

参考 Shadertoy 上的 “Just snow” 效果,添加鼠标移动时的视差偏移,让雪花更有层次感。

核心思路:

  1. 使用 Motion 的 useMotionValue + useSpring 追踪并平滑鼠标位置
  2. 将平滑后的值传递给着色器的 uMouse uniform
  3. 着色器中不同层使用不同的视差因子,近景层偏移更多
// SnowfallCanvas.tsx - 鼠标追踪
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);
const smoothMouseX = useSpring(mouseX, { stiffness: 50, damping: 20 });
const smoothMouseY = useSpring(mouseY, { stiffness: 50, damping: 20 });

useEffect(() => {
  if (isMobile) return; // 移动端无鼠标

  const handleMouseMove = (e: MouseEvent) => {
    // 标准化到 -0.5 ~ 0.5
    mouseX.set(e.clientX / window.innerWidth - 0.5);
    mouseY.set(e.clientY / window.innerHeight - 0.5);
  };

  const handleMouseLeave = () => {
    // 鼠标离开窗口时回到中心
    mouseX.set(0);
    mouseY.set(0);
  };

  window.addEventListener('mousemove', handleMouseMove, { passive: true });
  document.addEventListener('mouseleave', handleMouseLeave);
  return () => {
    window.removeEventListener('mousemove', handleMouseMove);
    document.removeEventListener('mouseleave', handleMouseLeave);
  };
}, [isMobile]);

着色器中应用视差:

uniform vec2 uMouse;

// 在每层雪花的 UV 计算中
float parallaxFactor = 0.5 + float(k) * 0.1; // k=0~5 → 0.5~1.0
vec2 mouseOffset = uMouse * parallaxFactor;
vec2 uv = (fragCoord.xy / uResolution.x) + mouseOffset + ...;

由于 Motion 的 spring 值在 React 渲染循环外更新,而 R3F 的 useFrame 也在外部运行,需要用桥接组件订阅 spring 变化并存入 ref:

function SnowParticlesWithParallax({
  speed,
  intensity,
  smoothMouseX,
  smoothMouseY,
  parallaxStrength,
  layerRange,
}: {
  speed: number;
  intensity: number;
  smoothMouseX: MotionValue<number>;
  smoothMouseY: MotionValue<number>;
  parallaxStrength: number;
  layerRange: [number, number];
}) {
  const parallaxRef = useRef({ x: 0, y: 0 });

  useEffect(() => {
    const unsubX = smoothMouseX.on('change', (v) => {
      parallaxRef.current.x = v * parallaxStrength;
    });
    const unsubY = smoothMouseY.on('change', (v) => {
      parallaxRef.current.y = v * parallaxStrength;
    });
    return () => { unsubX(); unsubY(); };
  }, [smoothMouseX, smoothMouseY, parallaxStrength]);

  return <SnowParticles speed={speed} intensity={intensity} parallaxRef={parallaxRef} layerRange={layerRange} />;
}

双层渲染:前景与背景分离

为了让雪花更有层次感,将 6 层雪花分成两组渲染到不同的 z-index:

层级z-index着色器层 (k)效果
背景雪1k=0-2远景、小雪花、视差弱
内容正常文档流-文章容器
前景雪50k=3-5近景、大雪花、视差强

实现方式是给 SnowParticles 添加 layerRange prop,着色器中根据范围跳过不需要的层:

uniform int uLayerStart;
uniform int uLayerEnd;

for(int k = 0; k < 6; k++) {
  if(k < uLayerStart || k > uLayerEnd) continue;
  // ... 原有雪花渲染逻辑
}

然后渲染两个 Canvas:

{/* 背景雪 */}
<SnowfallCanvas zIndex={1} layerRange={[0, 2]} />
{/* 前景雪 */}
<SnowfallCanvas zIndex={50} layerRange={[3, 5]} />

这样内容容器就”夹”在雪花中间,远景雪花在文章后面飘过,近景雪花在前面飘过,配合视差效果产生很好的深度感。

圣诞彩灯:CSS 动画

在 Cover 顶部添加悬挂式彩灯装饰,参考 Toby J 的 CodePen 实现,调整为圣诞配色(红、绿、金)。

实现原理

彩灯效果完全使用 CSS 实现:

  • 灯泡使用 border-radius: 50% 创建椭圆形
  • 灯座使用 ::before 伪元素
  • 电线使用 ::after 伪元素的 border-bottom + border-radius 模拟弧形
  • 闪烁效果使用 @keyframes 动画改变 backgroundbox-shadow
---
// src/components/christmas/ChristmasLights.astro
// 生成足够数量的灯泡覆盖最大屏幕宽度
const lightCount = 50;
---

<ul class="lightrope" aria-hidden="true">
  {Array.from({ length: lightCount }).map(() => <li />)}
</ul>

<style>
  .lightrope {
    --globe-width: 12px;
    --globe-height: 28px;
    --globe-spacing: 40px;
    --globe-spread: 3px;
    --light-off-opacity: 0.4;

    /* 圣诞配色 */
    --light-red: 185, 50, 50;
    --light-green: 34, 139, 34;
    --light-gold: 218, 165, 32;

    position: fixed;
    top: 42px;
    left: 0;
    z-index: 100;
    width: 100%;
    pointer-events: none;
  }

  .lightrope li {
    display: inline-block;
    width: var(--globe-width);
    height: var(--globe-height);
    margin: calc(var(--globe-spacing) / 2);
    border-radius: 50%;
    background: rgb(var(--light-red));
    box-shadow: 0px 4px 24px 3px rgba(var(--light-red), 1);
    animation: flash-red 2s ease-in-out infinite;
  }

  /* 绿色灯泡 - 每隔一个 */
  .lightrope li:nth-child(2n + 1) {
    background: rgb(var(--light-green));
    animation-name: flash-green;
    animation-duration: 0.4s;
  }

  /* 金色灯泡 - 每 4 个中的第 2 个 */
  .lightrope li:nth-child(4n + 2) {
    background: rgb(var(--light-gold));
    animation-name: flash-gold;
    animation-duration: 1.1s;
  }

  /* 灯座 */
  .lightrope li::before {
    content: '';
    position: absolute;
    top: -5px;
    width: 10px;
    height: 9px;
    background: hsl(var(--background) / 0.6);
    border-radius: 3px;
  }

  /* 连接电线 */
  .lightrope li::after {
    content: '';
    position: absolute;
    top: -14px;
    left: 9px;
    width: 52px;
    height: 18px;
    border-bottom: solid hsl(var(--background) / 0.6) 2px;
    border-radius: 50%;
  }

  /* 闪烁动画 */
  @keyframes flash-red {
    0%,
    100% {
      background: rgb(var(--light-red));
      box-shadow: 0px 4px 24px 3px rgba(var(--light-red), 1);
    }
    50% {
      background: rgba(var(--light-red), 0.4);
      box-shadow: 0px 4px 24px 3px rgba(var(--light-red), 0.2);
    }
  }

  /* 无障碍:减少动画 */
  @media (prefers-reduced-motion: reduce) {
    .lightrope li {
      animation: none;
    }
  }
</style>

在 Cover 中使用

// src/components/ui/cover/Cover.astro
+ import { christmasConfig } from '@constants/site-config';
+ import ChristmasLights from '@components/christmas/ChristmasLights.astro';

<div class="relative flex h-[60dvh] max-h-200 overflow-hidden">
+ {christmasConfig.enabled && christmasConfig.features.christmasCoverDecoration && <ChristmasLights />}
  <div class="absolute inset-0 h-full bg-black/40"></div>
  ...
</div>

彩灯使用 position: fixed 固定在视口顶部,z-index: 100 确保显示在导航栏之上。电线和灯座颜色使用 hsl(var(--background) / 0.6) 自动适配亮暗主题。

圣诞帽:SVG 绘制

头像上的圣诞帽使用纯 SVG 绘制,支持自定义尺寸、位置、旋转角度和颜色:

---
interface Props {
  className?: string;
  size?: number | string;
  offset?: { x?: number; y?: number };
  rotate?: number;
  colors?: {
    primary?: string;
    secondary?: string;
    white?: string;
  };
}

const {
  className,
  size = '100%',
  offset = { x: -28, y: -50 },
  rotate = 10,
  colors = {
    primary: '#FF4E50',
    secondary: '#C00000',
    white: '#FFFFFF',
  },
} = Astro.props;

// 生成唯一 ID 避免多个帽子时 gradient 冲突
const uniqueId = Math.random().toString(36).substring(2, 9);
---

<div
  class:list={['christmas-hat-wrapper', className]}
  style={{
    width: typeof size === 'number' ? `${size}px` : size,
    height: typeof size === 'number' ? `${size}px` : size,
    top: `${offset.y}%`,
    left: `${offset.x}%`,
    transform: `rotate(${rotate}deg)`,
  }}
>
  <svg viewBox="0 0 200 200" fill="none">
    <defs>
      <linearGradient id={`hatGradient_${uniqueId}`} ...>
        <stop stop-color={colors.primary}></stop>
        <stop offset="1" stop-color={colors.secondary}></stop>
      </linearGradient>
    </defs>
    <!-- 帽身 -->
    <path d="M179.712 87.522 ..." fill={`url(#hatGradient_${uniqueId})`}></path>
    <!-- 白边 -->
    <path d="M181.512 88.5738 ..." stroke={colors.white} stroke-width="28"></path>
    <!-- 毛球 -->
    <circle cx="38.5" cy="144.5" r="19.5" fill={colors.white}></circle>
  </svg>
</div>

<style>
  .christmas-hat-wrapper {
    position: absolute;
    pointer-events: none;
    z-index: 20;
    transition: transform 0.3s ease;
    filter: drop-shadow(0px 10px 15px rgba(0, 0, 0, 0.1));
  }
</style>

通过 props 可以灵活调整帽子的位置和样式,适配不同尺寸的头像。使用 linearGradient 实现帽身的渐变效果,drop-shadow 添加阴影增加立体感。

圣诞配色方案

通过 CSS 自定义属性实现主题切换,当 <html>.christmas 类时激活:

/* src/styles/christmas/christmas-theme.css */
.christmas {
  --christmas-red: 0 70% 45%;
  --christmas-green: 150 60% 35%;
  --christmas-gold: 43 75% 55%;

  /* 覆盖主色调 */
  --primary: var(--christmas-red);
}

.christmas.dark {
  --christmas-red: 0 65% 55%;
  --gradient-bg-start: #1a1512;
  --gradient-bg-end: #0d1a0d;
}

在 Layout 中根据配置添加类名:

<html class:list={[{ christmas: christmasConfig.enabled && christmasConfig.features.christmasColorScheme }]}></html>

开关按钮

在悬浮按钮组中添加圣诞开关:

{
  christmasConfig.enabled && (
    <button id="toggle-christmas" class="rounded-full p-2 shadow-lg backdrop-blur-sm" aria-label="切换圣诞特效">
      <Icon name="ri:snowy-fill" class="christmas-on h-5 w-5" />
      <Icon name="ri:snowy-line" class="christmas-off hidden h-5 w-5" />
    </button>
  )
}

<script>
  import { christmasEnabled, toggleChristmas, initChristmasState } from '@store/christmas';

  function init() {
    initChristmasState();

    const btn = document.getElementById('toggle-christmas');
    btn?.addEventListener('click', () => {
      toggleChristmas();
    });

    // 订阅状态变化,更新按钮图标
    christmasEnabled.subscribe((enabled) => {
      const onIcon = btn?.querySelector('.christmas-on');
      const offIcon = btn?.querySelector('.christmas-off');
      onIcon?.classList.toggle('hidden', !enabled);
      offIcon?.classList.toggle('hidden', enabled);
    });
  }

  document.addEventListener('astro:page-load', init);
</script>

圣诞球开关:可拖拽的装饰组件

除了简单的图标按钮,还实现了一个更有趣的交互方式:悬挂在页面右上角的圣诞球装饰,支持拖拽下拉触发开关。

实现原理

使用 Motion 的 drag API 实现拖拽交互:

  • useMotionValue 追踪拖拽位置
  • useTransform 派生绳子高度
  • dragSnapToOrigin 自动回弹
  • 拖拽超过阈值时触发状态切换
// src/components/christmas/ChristmasOrnamentToggle.tsx
import { cn } from '@lib/utils';
import { useStore } from '@nanostores/react';
import { christmasEnabled, disableChristmas, toggleChristmas } from '@store/christmas';
import { AnimatePresence, motion, useMotionValue, useReducedMotion, useTransform } from 'motion/react';
import { useState } from 'react';

const DRAG_THRESHOLD = 80;
const ORNAMENT_SIZE = 72;
const STRING_HEIGHT = 80;

export function ChristmasOrnamentToggle() {
  const isEnabled = useStore(christmasEnabled);
  const shouldReduceMotion = useReducedMotion();
  const [isPulling, setIsPulling] = useState(false);
  const [isHovered, setIsHovered] = useState(false);

  const y = useMotionValue(0);
  // 绳子高度跟随球的位置,确保始终连接
  const stringHeight = useTransform(y, (v) => STRING_HEIGHT + Math.max(0, v));

  const handleDragEnd = () => {
    setIsPulling(false);
    const currentY = y.get();
    if (currentY > DRAG_THRESHOLD / 2) {
      if (isEnabled) {
        disableChristmas();
      } else {
        toggleChristmas();
      }
    }
  };

  return (
    <div className="fixed top-0 right-20 z-90 flex w-[100px] justify-center">
      {/* 绳子 - 高度随拖拽变化 */}
      <motion.div
        className="pointer-events-none absolute top-0 w-[2px] origin-top bg-linear-to-b from-yellow-700 via-yellow-500 to-yellow-400"
        style={{ height: stringHeight }}
      />

      {/* 球体 - 可拖拽 */}
      <motion.button
        className={cn(
          'absolute z-101 cursor-grab touch-none select-none active:cursor-grabbing',
          'rounded-full outline-none focus-visible:ring-4 focus-visible:ring-yellow-400/50',
        )}
        style={{
          width: ORNAMENT_SIZE,
          height: ORNAMENT_SIZE,
          top: STRING_HEIGHT,
          y, // Motion 拖拽自动更新此值
        }}
        drag={shouldReduceMotion ? false : 'y'}
        dragConstraints={{ top: 0, bottom: DRAG_THRESHOLD + 30 }}
        dragElastic={0.2}
        dragSnapToOrigin // 松开后自动回弹
        onDragStart={() => setIsPulling(true)}
        onDragEnd={handleDragEnd}
        onClick={() => toggleChristmas()}
        whileHover={{ scale: 1.05 }}
        whileTap={{ scale: 0.95 }}
        aria-label={isEnabled ? '关闭圣诞模式' : '开启圣诞模式'}
        aria-pressed={isEnabled}
        type="button"
      >
        <OrnamentSvg isEnabled={isEnabled} />

        {/* 拖拽提示 */}
        <AnimatePresence>
          {isEnabled && (isPulling || isHovered) && (
            <motion.div
              initial={{ opacity: 0, y: 10 }}
              animate={{ opacity: 1, y: 30 }}
              exit={{ opacity: 0 }}
              className="pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2"
            >
              <div className="rounded-full bg-red-950/90 px-3 py-1 text-[10px] text-white">
                下拉关闭
              </div>
            </motion.div>
          )}
        </AnimatePresence>
      </motion.button>
    </div>
  );
}

关键技术点

  1. 绳子与球的同步:将 y MotionValue 同时用于球的位置和绳子高度计算,确保两者始终连接
  2. dragSnapToOrigin:Motion 内置的回弹动画,松开后自动回到初始位置
  3. useReducedMotion:尊重用户的动画偏好,禁用拖拽时改为点击切换
  4. dragElastic:设置 0.2 的弹性系数,超出约束时有轻微的弹性效果

性能优化

移动端适配

使用自定义 hook 检测移动端并调整雪花密度:

const isMobile = useIsMobile();
const shouldReduceMotion = useReducedMotion();

// 移动端使用更低的密度
const finalIntensity = isMobile ? mobileIntensity : intensity;
// 移动端禁用鼠标视差
const finalParallaxStrength = isMobile ? 0 : parallaxStrength;

// 尊重用户的动画偏好设置
if (shouldReduceMotion || !isChristmasEnabled) {
  return null;
}

R3F Canvas 配置

<Canvas
  orthographic
  camera={{ zoom: 1, position: [0, 0, 1] }}
  gl={{
    antialias: false,      // 禁用抗锯齿
    alpha: true,
    powerPreference: 'low-power',
  }}
  dpr={[1, 1.5]}           // 限制像素比
/>

文件清单

文件用途
src/constants/site-config.ts圣诞配置定义
src/store/christmas.ts状态管理
src/components/christmas/SnowfallCanvas.tsxR3F 雪花画布包装器
src/components/christmas/SnowParticles.tsxGLSL 着色器粒子系统
src/components/christmas/ChristmasLights.astroCSS 彩灯装饰
src/components/christmas/ChristmasHat.astroSVG 圣诞帽
src/components/christmas/ChristmasEffects.astro双层雪花包装器
src/components/christmas/ChristmasOrnamentToggle.tsx可拖拽圣诞球开关
src/styles/christmas/christmas-theme.css配色方案

总结

这套圣诞特效模块的设计原则如下:

  1. 可插拔:通过配置开关,不影响非节日期间的使用
  2. 性能友好:GLSL 着色器 GPU 渲染 + CSS 动画,移动端自动降级
  3. 用户可控:提供可拖拽圣诞球开关,尊重 prefers-reduced-motion
  4. 跨框架:nanostores 实现 Astro/React 状态同步
  5. 层次感:双层渲染 + 鼠标视差,雪花有前后景深度

明年可以在此基础上扩展更多节日特效(新年烟花?万圣节南瓜?),架构已经预留了扩展空间。

Refs

本文随时修订中,有错漏可直接评论

先使用 Remark42 作为临时评论系统,样式等有待优化