iT邦幫忙

2025 iThome 鐵人賽

DAY 28
1
Modern Web

在Vibe Coding 時代一起來做沒有AI感的漂亮網站吧!系列 第 28

想做3D?先別急著用Three.js:Spline的使用與為何貼上官網教學都跑不出來物件?

  • 分享至 

  • xImage
  •  

嗨咿我是illumi,今天進入到最後一個單元了!

當你想要在網站中加入炫酷的3D元素時,第一個想到的可能是Three.js。但是等等!在你開始深入複雜的3D數學和WebGL之前,讓我們先來聊聊另一個選擇 - Spline

今天我要和大家分享的是:為什麼在某些情況下,Spline可能是比Three.js更好的選擇,以及如何在Next.js/React專案中使用它。

為什麼不是所有3D需求都需要Three.js?

Three.js 的優勢與挑戰

Three.js 無疑是JavaScript 3D領域的王者,它提供了:

  • 完全的程式化控制
  • 強大的效能最佳化能力
  • 豐富的3D功能和效果
  • 活躍的社群支援

但是,Three.js也有其挑戰:

看似簡單,但要做出真正美觀的3D場景,你需要掌握:

  • 3D數學(向量、矩陣、四元數)
  • 光照系統
  • 材質和紋理
  • 動畫系統
  • 效能最佳化
  • WebGL相關知識

Spline:視覺設計師的3D解決方案

什麼是Spline?

Spline是一個專為網頁設計的3D設計工具,它讓你可以通過視覺化介面創建3D內容,然後輕鬆地嵌入到網站中。

Spline的核心優勢

  1. 視覺化設計:就像使用Figma一樣直觀
  2. 零程式碼3D創作:設計師也能輕鬆上手
  3. 即時預覽:所見即所得的設計體驗
  4. 輕鬆整合:幾行程式碼就能嵌入網站
  5. 響應式設計:自動適應不同裝置

什麼情況下選擇Spline?

適合使用Spline的情況:

  • 展示型3D內容:產品展示、品牌視覺、裝飾性動畫
  • 互動性要求簡單:基本的hover、click互動
  • 設計主導的專案:重視視覺效果勝過複雜邏輯
  • 快速原型:需要快速驗證3D想法
  • 團隊協作:設計師和開發者需要緊密合作

不適合使用Spline的情況:

  • 複雜的遊戲邏輯:需要大量程式化控制
  • 資料視覺化:需要動態生成3D圖表
  • 高效能要求:需要渲染大量物件
  • 特殊演算法:需要自定義渲染流程

實戰:在Next.js中整合Spline

步驟1:安裝必要套件

# 安裝Spline React套件
npm install @splinetool/react-spline

# 如果使用TypeScript
npm install --save-dev @types/react @types/react-dom

步驟2:基礎使用

創建一個簡單的Spline組件:

// components/SplineScene.tsx
'use client';

import Spline from '@splinetool/react-spline/next';

export default function SplineScene() {
  return (
    <div style={{ width: '100%', height: '100vh' }}>
      <Spline
        scene="https://prod.spline.design/你的場景ID/scene.splinecode"
      />
    </div>
  );
}

當你第一次看到 Spline 的官方範例時,可能會覺得:「哇,這也太簡單了吧!」

import Spline from '@splinetool/react-spline/next';

export default function Home() {
  return (
    <main>
      <Spline scene="https://prod.spline.design/你的ID/scene.splinecode" />
    </main>
  );
}

只要三行程式碼,3D 場景就能在你的網站上運行!但當你真正在專案中使用時,卻發現需要寫一大堆複雜的程式碼:

為什麼會這樣?

理想與現實的差距

官方範例的隱藏前提

官方的簡單範例能夠運作,是因為它假設了以下條件:

  1. 你的頁面是純 Server Component
  2. 頁面中沒有其他互動元素
  3. 不需要國際化(i18n)
  4. 沒有使用 GSAP 或其他動畫庫
  5. Spline 場景是頁面的唯一主角

但實際專案中,這些條件幾乎不可能同時滿足。

在這個情境下,如果你直接使用官方範例,會遇到一連串的問題。

問題一:Server Component vs Client Component 的衝突

錯誤訊息

Error: async/await is not yet supported in Client Components

問題根源

@splinetool/react-spline/next 是一個 async Server Component

// Spline 原始碼
export default async function SplineNext({ ...props }: SplineProps) {
  const hash = await getPreview(props.scene);
  // ...
}

它會在伺服器端預先獲取場景的預覽圖片,提供更好的載入體驗。

但是,當你的頁面因為 next-intl 或其他原因需要使用 async 時,就會產生衝突:

// ❌ 這樣會報錯
"use client";  // 因為某些原因需要 client component

export default async function Page() {  // async 在 client component 中不支援
  return <Spline scene="..." />
}

解決方案:改用客戶端版本

import dynamic from 'next/dynamic';

// 使用普通的客戶端版本,不是 /next 版本
const Spline = dynamic(() => import('@splinetool/react-spline'), {
  ssr: false,  // 關鍵:禁用 SSR
});

這個改動看似簡單,但背後有重要的權衡:

特性 /next 版本 普通版本
預覽佔位符 ✅ 自動生成 ❌ 需自行處理
SSR 支援 ✅ 完整支援 ❌ 必須禁用
載入體驗 🌟 更好 ⚠️ 較差
彈性 ⚠️ 受限 ✅ 更靈活

問題二:事件冒泡破壞其他互動

症狀

// 你的 GSAP 動畫突然不工作了
useEffect(() => {
  gsap.from('.hero-title', { y: 50, opacity: 0 });
  // 動畫沒有觸發!
}, []);

問題根源

Spline 組件內部使用 Canvas 元素,並且會監聽大量的滑鼠和觸控事件:

// Spline 內部會做類似這樣的事
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('wheel', handleWheel);
// ... 還有很多

這些事件會冒泡到父元素,可能干擾頁面上的其他互動邏輯。

解決方案:事件隔離

useEffect(() => {
  const container = containerRef.current;
  if (!container) return;

  const stopEvent = (e: Event) => {
    e.stopPropagation();  // 阻止事件冒泡
  };

  // 隔離所有可能造成干擾的事件
  const events = ["mousedown", "mouseup", "mousemove", "wheel", "touchstart"];
  events.forEach((event) => {
    container.addEventListener(event, stopEvent);
  });

  return () => {
    events.forEach((event) => {
      container.removeEventListener(event, stopEvent);
    });
  };
}, []);

這個看似多餘的程式碼,實際上是在保護你的其他功能不被 Spline 意外影響。

問題三:渲染層級干擾

症狀

// 你的其他元素突然無法點擊或顯示異常
<div className="popup">
  <button>我點不到了!</button>
</div>

問題根源

Canvas 元素會創建一個新的渲染層級(stacking context),可能會覆蓋或影響頁面上的其他元素。

解決方案:CSS 隔離

<div
  style={{
    isolation: "isolate",  // 創建獨立的渲染上下文
    contain: "layout style paint",  // 限制渲染影響範圍
  }}
>
  <Spline scene="..." />
</div>

這兩個 CSS 屬性的作用:

  • isolation: isolate:創建新的堆疊上下文,防止 z-index 混亂
  • contain: layout style paint:告訴瀏覽器這個容器是獨立的,減少重繪範圍

問題四:容器尺寸問題

症狀

// Spline 場景不顯示或尺寸異常
<div>
  <Spline scene="..." />  {/* 看不到任何東西! */}
</div>

問題根源

根據 Spline 的原始碼,組件使用 ParentSize 來決定 Canvas 尺寸:

// Spline 原始碼
<ParentSize
  parentSizeStyles={{ width: '100%', height: '100%' }}
  // ...
>

如果父容器沒有明確的寬高,Canvas 就無法正確渲染。

解決方案:明確設定尺寸

<div
  className="w-full h-full relative"
  style={{
    minHeight: "200px",  // 至少要有最小高度
  }}
>
  <Spline scene="..." />
</div>

或者使用明確的像素值:

<div style={{ width: '400px', height: '400px' }}>
  <Spline scene="..." />
</div>

問題五:錯誤處理的重要性

為什麼需要錯誤處理

Spline 場景可能因為多種原因載入失敗:

  1. 網路問題:伺服器無法連線
  2. CORS 問題:跨域資源限制
  3. 場景檔案損壞:Spline 伺服器的問題
  4. URL 錯誤:場景 ID 不正確

如果沒有處理這些錯誤,整個組件甚至整個頁面都可能崩潰。

解決方案:完整的錯誤處理

const [hasError, setHasError] = useState(false);

const onError = (error: any) => {
  console.error("Spline error:", error);
  setHasError(true);
};

if (hasError) {
  return (
    <div className="w-full h-full flex items-center justify-center">
      <div className="text-center">
        <p className="text-gray-600">3D 場景載入失敗</p>
        <button onClick={() => window.location.reload()}>
          重新載入
        </button>
      </div>
    </div>
  );
}

return (
  <Spline
    scene="..."
    onError={onError}  // 關鍵:捕捉錯誤
  />
);

完整的生產級組件

結合以上所有解決方案,這是一個生產級的 Spline 組件:

"use client";

import { useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";

// 動態載入,避免 SSR 問題
const Spline = dynamic(() => import("@splinetool/react-spline"), {
  ssr: false,
  loading: () => (
    <div className="w-full h-full flex items-center justify-center">
      <div className="animate-pulse text-gray-500">載入 3D 場景...</div>
    </div>
  ),
});

interface BubbleProps {
  scene: string;
  className?: string;
}

export default function Bubble({ scene, className = "" }: BubbleProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [isLoaded, setIsLoaded] = useState(false);
  const [hasError, setHasError] = useState(false);

  // 載入成功的回調
  const onLoad = () => {
    console.log("Spline scene loaded successfully");
    setIsLoaded(true);
  };

  // 錯誤處理
  const onError = (error: any) => {
    console.error("Spline loading error:", error);
    setHasError(true);
  };

  // 事件隔離:防止干擾頁面其他互動
  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const stopEvent = (e: Event) => {
      e.stopPropagation();
      e.stopImmediatePropagation();
    };

    const events = [
      "mousedown", "mouseup", "mousemove",
      "wheel", "touchstart", "touchmove", "touchend"
    ];

    events.forEach((event) => {
      container.addEventListener(event, stopEvent, { passive: false });
    });

    return () => {
      events.forEach((event) => {
        container.removeEventListener(event, stopEvent);
      });
    };
  }, []);

  // 錯誤狀態回退 UI
  if (hasError) {
    return (
      <div className={`w-full h-full flex items-center justify-center bg-gray-50 rounded-lg ${className}`}>
        <div className="text-center p-6">
          <div className="w-16 h-16 mx-auto mb-4 bg-gray-200 rounded-full"></div>
          <p className="text-gray-600 mb-3">無法載入 3D 場景</p>
          <button
            onClick={() => window.location.reload()}
            className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
          >
            重新載入
          </button>
        </div>
      </div>
    );
  }

  return (
    <div
      ref={containerRef}
      className={`relative ${className}`}
      style={{
        isolation: "isolate",           // CSS 隔離
        contain: "layout style paint",  // 限制渲染範圍
        minHeight: "200px",             // 確保容器有高度
        width: "100%",
        height: "100%",
      }}
    >
      {/* 載入中狀態 */}
      {!isLoaded && !hasError && (
        <div className="absolute inset-0 flex items-center justify-center z-10">
          <div className="animate-pulse text-gray-500">載入 3D 場景...</div>
        </div>
      )}

      {/* Spline 場景 */}
      <Spline
        scene={scene}
        onLoad={onLoad}
        onError={onError}
      />
    </div>
  );
}

使用這個組件

在你的頁面中:

// app/[locale]/page.tsx
import { getTranslations } from 'next-intl/server';
import Bubble from '@/components/Bubble';

export default async function HomePage() {
  const t = await getTranslations('HomePage');

  return (
    <div>
      <h1>{t('title')}</h1>

      {/* 使用生產級的 Bubble 組件 */}
      <div className="w-96 h-96 my-8">
        <Bubble scene="https://prod.spline.design/你的ID/scene.splinecode" />
      </div>

      {/* 其他內容不會被影響 */}
      <InteractiveSection />
    </div>
  );
}

這樣就成功放上去啦!

https://ithelp.ithome.com.tw/upload/images/20250929/20178506jtcsrifp4T.png


上一篇
多語系網站 Next-intl 多行文字處理:到底該用巢狀物件還是陣列?
下一篇
輸出3D物件到網頁不再有浮水印!限時免費3d軟體會員領取
系列文
在Vibe Coding 時代一起來做沒有AI感的漂亮網站吧!30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言