You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Nuxt3 SSR下Threejs路由返回时Canvas不存在报错

Nuxt 3 SSR模式下Three.js路由往返后Canvas报错/模型不加载修复

问题复现代码

<template>
  <div>
    <canvas ref="webgl" class="canvas"></canvas>
  </div>
</template>

<script setup>
import * as THREE from "three";

import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
const webgl = ref(null);

var renderer;
onMounted(async () => {
  await nextTick();
  console.log();
  if (process.client) {
    const canvas = document.querySelector(".canvas");
    //const canvas = webgl.value; 无显式报错但功能不生效
    console.log(canvas);
    //获取canvas元素

    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 1000).translateZ(15);

    renderer = new THREE.WebGLRenderer({
      canvas,
      alpha: true,
      antialias: true,
    });

    // 创建渲染器
    renderer.setClearColor(0x000000, 0);
    renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
    renderer.setPixelRatio(window.devicePixelRatio);

    // 窗口resize时避免画面模糊
    window.onresize = () => {
      renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
    };

    // 初始化GLTF模型加载器
    const loader = new GLTFLoader();
    const dracoLoader = new DRACOLoader();
    dracoLoader.setDecoderPath("/draco/");
    loader.setDRACOLoader(dracoLoader);

    const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.9);
    hemiLight.position.set(0, 0, 15);
    scene.add(hemiLight);

    let model;
    loader.load(
      // 资源URL
      "/models/molecule.glb",
      // 资源加载完成回调
      function (gltf) {
        model = gltf.scene;
        model.position.set(0, -0.4, -1);
        if (model) model.rotation.x += 90;
        scene.add(model);
      },
      // 加载进度回调
      function (xhr) {
        console.log((xhr.loaded / xhr.total) * 100 + "% loaded");
      },
      // 加载错误回调
      function (error) {
        console.log("An error happened", error);
      }
    );
    let x = 0;

    // 启动渲染循环
    renderer.setAnimationLoop(() => {
      if (model) {
        // --- 浮动动画
        x = x + 2;
        model.position.y = Math.cos(x * 0.01) * 0.3;
        model.rotation.z = Math.sin(x * 0.01) * 0.2;
        model.rotation.x = Math.sin(x * 3.14 * 0.01) * 0.02 + 90;
      }
      renderer.render(scene, camera);
    });
  }
});

onUnmounted(() => {
  renderer.setAnimationLoop(null);
});
</script>

<style scoped>
.canvas {
  width: 100%;
  height: 100%;
  z-index: 1;
}
</style>

核心问题点

  • onMounted本身仅在客户端执行,额外嵌套process.client判断属于冗余逻辑,容易引发变量作用域异常
  • 用document.querySelector获取DOM不可靠,路由切换组件重挂载时,可能匹配到已销毁的旧canvas实例,或者拿不到当前组件的新DOM节点
  • 组件卸载时资源释放不完整,仅停止动画循环没有销毁渲染器、移除全局resize监听,旧实例残留会干扰二次进入时的初始化逻辑
  • 没有对Three.js渲染区域做SSR隔离,容易引发hydration阶段的DOM不匹配问题
  • 相机初始化时宽高比写死为1,没有同步canvas的实际宽高,容易出现渲染空白

修复后完整实现

<template>
  <!-- 用ClientOnly包裹,彻底跳过该部分SSR渲染 -->
  <ClientOnly>
    <div class="canvas-container">
      <canvas ref="webgl" class="canvas"></canvas>
    </div>
  </ClientOnly>
</template>

<script setup>
// 仅在客户端导入Three.js相关依赖
const THREE = await import('three').then(m => m.default)
const { GLTFLoader } = await import('three/examples/jsm/loaders/GLTFLoader.js')
const { DRACOLoader } = await import('three/examples/jsm/loaders/DRACOLoader.js')

const webgl = ref(null)
let renderer = null
let scene = null
let camera = null
let model = null
let animationId = null
let resizeHandler = null

onMounted(() => {
  const canvas = webgl.value
  if (!canvas) return

  // 初始化场景
  scene = new THREE.Scene()
  
  // 初始化相机,同步canvas实际宽高比
  const width = canvas.clientWidth
  const height = canvas.clientHeight
  camera = new THREE.PerspectiveCamera(32, width / height, 0.1, 1000)
  camera.translateZ(15)

  // 初始化渲染器
  renderer = new THREE.WebGLRenderer({
    canvas,
    alpha: true,
    antialias: true,
  })
  renderer.setClearColor(0x000000, 0)
  renderer.setSize(width, height, false)
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))

  // 窗口大小调整逻辑
  resizeHandler = () => {
    if (!renderer || !canvas || !camera) return
    const w = canvas.clientWidth
    const h = canvas.clientHeight
    camera.aspect = w / h
    camera.updateProjectionMatrix()
    renderer.setSize(w, h, false)
  }
  window.addEventListener('resize', resizeHandler)

  // 初始化模型加载器
  const loader = new GLTFLoader()
  const dracoLoader = new DRACOLoader()
  dracoLoader.setDecoderPath('/draco/') // 本地存放draco解码器文件
  loader.setDRACOLoader(dracoLoader)

  // 添加光源
  const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.9)
  hemiLight.position.set(0, 0, 15)
  scene.add(hemiLight)

  let x = 0
  // 加载模型
  loader.load(
    '/models/molecule.glb',
    (gltf) => {
      model = gltf.scene
      model.position.set(0, -0.4, -1)
      model.rotation.x = Math.PI / 2 // Three.js旋转使用弧度单位,原代码直接写90角度值会导致旋转异常
      scene.add(model)
    },
    (xhr) => {
      console.log(`${(xhr.loaded / xhr.total) * 100}% loaded`)
    },
    (error) => {
      console.error('模型加载失败', error)
    }
  )

  // 渲染循环
  const animate = () => {
    animationId = requestAnimationFrame(animate)
    if (model) {
      x += 2
      model.position.y = Math.cos(x * 0.01) * 0.3
      model.rotation.z = Math.sin(x * 0.01) * 0.2
      model.rotation.x = Math.sin(x * Math.PI * 0.01) * 0.02 + Math.PI / 2
    }
    renderer.render(scene, camera)
  }
  animate()
})

onUnmounted(() => {
  // 完整清理资源
  if (animationId) cancelAnimationFrame(animationId)
  if (resizeHandler) window.removeEventListener('resize', resizeHandler)
  if (renderer) {
    renderer.dispose()
    renderer.forceContextLoss()
    renderer.domElement = null
    renderer = null
  }
  // 清理场景内资源
  if (scene) {
    scene.traverse((item) => {
      if (item.isMesh) {
        item.geometry.dispose()
        if (Array.isArray(item.material)) {
          item.material.forEach(m => m.dispose())
        } else {
          item.material.dispose()
        }
      }
    })
    scene.clear()
    scene = null
  }
  camera = null
  model = null
})
</script>

<style scoped>
.canvas-container {
  width: 100%;
  height: 100%;
}
.canvas {
  width: 100%;
  height: 100%;
  z-index: 1;
}
</style>

额外注意点

  • Three.js的旋转API使用弧度作为单位,直接传入角度值会导致模型初始旋转角度异常
  • draco解码器文件需要下载到本地项目的public/draco目录下,不要依赖公网第三方资源
  • 渲染器像素比最好做上限限制(比如最高2),避免高DPI屏幕下性能损耗过大
  • 组件卸载时必须完整释放WebGL上下文、几何体、材质资源,否则多次路由跳转后会出现内存泄漏

内容的提问来源于stack exchange,提问作者Mattia Peiretti

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.30 22:00:10