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
相关产品推荐
相关产品推荐

