第10章:框架集成、实战项目与部署优化
前面的九章,我们从 Viewer 核心 API、标记系统、插件生态、地图集成一路讲到视频全景和移动端交互——你已经有能力基于 PSV 构建功能齐全的全景应用。本章将这些能力整合到真实的生产环境中:在现代前端框架(React / Vue)中封装和复用全景组件,通过三个完整的实战项目巩固所学知识,最后讨论性能优化、构建部署以及从 v4 向 v5 的迁移路径。
无论你是在做一个房地产 VR 看房项目,还是一个景区全景导览平台,抑或是 360° 视频展厅,本章的内容都将提供可以直接落地的参考方案。
10.1 React 集成
将 PSV 集成到 React 中有两条路径:使用社区维护的封装库 react-photo-sphere-viewer,或者手动编写 useEffect 生命周期管理。前者适合快速开发,后者适合需要完全控制 Viewer 行为的场景。
10.1.1 社区封装:react-photo-sphere-viewer
react-photo-sphere-viewer 由 Elia Lazzari(GitHub: Elius94)维护,它将 Viewer 实例化、生命周期管理和 DOM 容器绑定封装为 React 组件,支持 props 传递配置、事件回调以及通过 ref 暴露 Viewer 方法。
安装:
npm install @photo-sphere-viewer/core react-photo-sphere-viewer
注意:自 v5.0.0-psv5.7.1 起,react-photo-sphere-viewer 不再内置 @photo-sphere-viewer/core,需要单独安装。所有插件和适配器也需要直接从 @photo-sphere-viewer/* 包导入。
基础用法:
import { ReactPhotoSphereViewer } from 'react-photo-sphere-viewer';
import '@photo-sphere-viewer/core/index.css';function App() {return (<div className="App"><ReactPhotoSphereViewersrc="panorama.jpg"height="100vh"width="100%"/></div>);
}
带插件的完整示例:
import { ReactPhotoSphereViewer } from 'react-photo-sphere-viewer';
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
import { CompassPlugin } from '@photo-sphere-viewer/compass-plugin';
import { GalleryPlugin } from '@photo-sphere-viewer/gallery-plugin';
import '@photo-sphere-viewer/core/index.css';
import '@photo-sphere-viewer/markers-plugin/index.css';
import '@photo-sphere-viewer/gallery-plugin/index.css';function App() {const handleReady = (viewer) => {console.log('Viewer ready, plugins:', viewer.getPlugin('markers'));};const handleClick = (data, viewer) => {console.log('Clicked at yaw:', data.data.yaw, 'pitch:', data.data.pitch);};return (<ReactPhotoSphereViewersrc="living-room.jpg"height="100vh"width="100%"navbar={['zoom', 'fullscreen', 'gallery', 'caption']}caption="客厅全景"defaultYaw="45deg"defaultPitch="5deg"plugins={[[MarkersPlugin, {markers: [{id: 'door',position: { yaw: '90deg', pitch: '0deg' },html: '主卧入口',tooltip: '点击进入主卧',},],}],[CompassPlugin, {hotspots: [{ yaw: '0deg' },{ yaw: '90deg' },{ yaw: '180deg' },{ yaw: '270deg' },],}],[GalleryPlugin, {items: [{ id: '1', panorama: 'living-room.jpg', thumbnail: 'thumb-living.jpg', name: '客厅' },{ id: '2', panorama: 'bedroom.jpg', thumbnail: 'thumb-bedroom.jpg', name: '主卧' },],}],]}onReady={handleReady}onClick={handleClick}/>);
}
Props 分类说明:
| 类别 | Props | 说明 |
|---|---|---|
| 标准 Props | src, height, width, containerClass |
基础容器和图片配置 |
| 特效 Props | littlePlanet, fishEye |
小行星效果和鱼眼效果 |
| 控制 Props | navbar, hideNavbarButton, lang |
导航栏和行为控制 |
| Viewer 原 Props | panorama, plugins, adapter, defaultYaw, defaultPitch, minFov, maxFov, moveSpeed, zoomSpeed, panoData, rendererParameters 等 |
几乎所有 ViewerConfig 选项都能作为 Props 传入 |
| 事件 Props | onReady, onClick, onDblclick, onPositionChange, onZoomChange |
回调接收 (eventData, viewerInstance) |
通过 ref 调用 Viewer 方法:
import { useRef } from 'react';
import { ReactPhotoSphereViewer } from 'react-photo-sphere-viewer';function App() {const viewerRef = useRef();const switchPanorama = () => {viewerRef.current.setPanorama('new-pano.jpg', {transition: { speed: 800, effect: 'fade' },});};const getPluginData = () => {const markersPlugin = viewerRef.current.getPlugin('markers');console.log('Current markers:', markersPlugin);};return (<><ReactPhotoSphereViewerref={viewerRef}src="panorama.jpg"height="80vh"/><button onClick={switchPanorama}>切换全景</button><button onClick={getPluginData}>获取标记数据</button></>);
}
ref 暴露的主要方法:
| 方法 | 说明 |
|---|---|
setPanorama(path, options?) |
切换全景图 |
getPlugin(id) |
获取已注册的插件实例 |
getPosition() |
获取当前视角位置 |
getZoomLevel() |
获取当前缩放级别 |
rotate(position) |
旋转到指定位置 |
zoom(value) / zoomIn(step) / zoomOut(step) |
缩放控制 |
animate(options) |
启动动画 |
destroy() |
销毁 Viewer |
setOption(key, value) / setOptions(partial) |
动态修改配置 |
enterFullscreen() / exitFullscreen() |
全屏控制 |
showError(msg) / hideError() |
错误提示 |
10.1.2 手动封装 React 组件
如果项目需要对 Viewer 生命周期做更精细的控制——如条件性创建、与 Redux/Zustand 状态同步、或在 StrictMode 下避免双重实例化——手动封装是更好的选择。
基础封装:PanoramaViewer 组件:
import { useEffect, useRef, useCallback } from 'react';
import { Viewer } from '@photo-sphere-viewer/core';
import '@photo-sphere-viewer/core/index.css';export function PanoramaViewer({panorama,plugins,defaultYaw = 0,defaultPitch = 0,onReady,className = '',style = {},
}) {const containerRef = useRef(null);const viewerRef = useRef(null);// 初始化useEffect(() => {if (!containerRef.current) return;const viewer = new Viewer({container: containerRef.current,panorama,plugins: plugins || [],defaultYaw,defaultPitch,});viewerRef.current = viewer;viewer.addEventListener('ready', () => {onReady?.(viewer);}, { once: true });return () => {viewer.destroy();viewerRef.current = null;};}, []); // 仅在挂载时创建// 响应式全景切换(不重建 Viewer)useEffect(() => {if (!viewerRef.current) return;viewerRef.current.setPanorama(panorama, {transition: { speed: 1000, effect: 'fade' },});}, [panorama]);return (<divref={containerRef}className={`panorama-container ${className}`}style={{ width: '100%', height: '100%', ...style }}/>);
}
严格模式下的安全封装:
React 18+ 的 StrictMode 会在开发环境下双重调用 useEffect,导致 Viewer 被创建两次。以下模式处理这个问题:
useEffect(() => {let viewer = null;let destroyed = false;const container = containerRef.current;if (!container) return;viewer = new Viewer({container,panorama,plugins,});if (destroyed) {viewer.destroy();return;}viewerRef.current = viewer;return () => {destroyed = true;viewer?.destroy();viewerRef.current = null;};
}, []);
动态全景切换的 React 模式:
当全景 URL 频繁变化时,每次销毁重建 Viewer 会带来闪烁和性能开销。推荐使用 setPanorama 进行热切换:
function RoomViewer({ currentRoom }) {const containerRef = useRef(null);const viewerRef = useRef(null);const initializedRef = useRef(false);// 仅首次挂载时创建 VieweruseEffect(() => {if (initializedRef.current || !containerRef.current) return;initializedRef.current = true;const viewer = new Viewer({container: containerRef.current,panorama: currentRoom.panorama,plugins: [[MarkersPlugin, { markers: currentRoom.markers }],],});viewerRef.current = viewer;return () => {viewer.destroy();viewerRef.current = null;initializedRef.current = false;};}, []);// 房间切换时更新全景和标记useEffect(() => {const viewer = viewerRef.current;if (!viewer) return;viewer.setPanorama(currentRoom.panorama, {transition: { speed: 800, effect: 'fade' },});const markersPlugin = viewer.getPlugin('markers');if (markersPlugin) {markersPlugin.setMarkers(currentRoom.markers);}}, [currentRoom]);return <div ref={containerRef} style={{ width: '100%', height: '100vh' }} />;
}
子组件通信模式:
当需要从父组件控制 Viewer 行为(如通过外部 UI 旋转视角、切换标记可见性),可以通过 useImperativeHandle 暴露操作接口:
import { forwardRef, useImperativeHandle, useRef, useEffect } from 'react';
import { Viewer } from '@photo-sphere-viewer/core';export const PanoramaViewer = forwardRef(function PanoramaViewer({ panorama, plugins }, ref
) {const containerRef = useRef(null);const viewerRef = useRef(null);useEffect(() => {const viewer = new Viewer({container: containerRef.current,panorama,plugins,});viewerRef.current = viewer;return () => viewer.destroy();}, []);useImperativeHandle(ref, () => ({rotateTo(yaw, pitch) {viewerRef.current?.rotate({ yaw, pitch });},getViewer() {return viewerRef.current;},getPlugin(id) {return viewerRef.current?.getPlugin(id);},}));return <div ref={containerRef} style={{ width: '100%', height: '100vh' }} />;
});// 父组件使用
function App() {const viewerRef = useRef();return (<><button onClick={() => viewerRef.current?.rotateTo('45deg', '10deg')}>看向厨房</button><PanoramaViewerref={viewerRef}panorama="house.jpg"plugins={[[MarkersPlugin, { markers: [...] }]]}/></>);
}
10.2 Vue 集成
PSV 官方没有提供现成的 Vue 封装库,但 Vue 3 的 Composition API 让手动封装变得极为顺畅。以下展示三种封装方案,从简单到复杂逐级推进。
10.2.1 Vue 3 组件封装
基础单文件组件(SFC):
<template><div ref="containerRef" class="panorama-viewer"></div>
</template><script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import { Viewer } from '@photo-sphere-viewer/core';
import '@photo-sphere-viewer/core/index.css';const props = defineProps({panorama: { type: String, required: true },plugins: { type: Array, default: () => [] },defaultYaw: { type: [Number, String], default: 0 },defaultPitch: { type: [Number, String], default: 0 },
});const emit = defineEmits(['ready', 'positionChange']);const containerRef = ref(null);
let viewer = null;onMounted(() => {viewer = new Viewer({container: containerRef.value,panorama: props.panorama,plugins: props.plugins,defaultYaw: props.defaultYaw,defaultPitch: props.defaultPitch,});viewer.addEventListener('ready', () => {emit('ready', viewer);}, { once: true });viewer.addEventListener('position-updated', (e) => {emit('positionChange', e.position);});
});onBeforeUnmount(() => {viewer?.destroy();viewer = null;
});// 响应式全景切换
watch(() => props.panorama, (newVal) => {viewer?.setPanorama(newVal, {transition: { speed: 800, effect: 'fade' },});
});
</script><style scoped>
.panorama-viewer {width: 100%;height: 100vh;
}
</style>
父组件使用:
<template><div><PanoramaViewerref="viewerComp":panorama="currentPano":plugins="viewerPlugins"@ready="onViewerReady"@position-change="onPositionChange"/><button @click="gotoKitchen">看向厨房</button></div>
</template><script setup>
import { ref } from 'vue';
import PanoramaViewer from './PanoramaViewer.vue';
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
import '@photo-sphere-viewer/markers-plugin/index.css';const viewerComp = ref(null);
const currentPano = ref('living-room.jpg');const viewerPlugins = [[MarkersPlugin, {markers: [{ id: 'kitchen', position: { yaw: '90deg', pitch: '0deg' }, html: '厨房' },],}],
];function onViewerReady(viewer) {console.log('Viewer ready');
}function onPositionChange(position) {console.log('Position:', position.yaw, position.pitch);
}function gotoKitchen() {viewerComp.value?.viewer?.rotate({ yaw: '90deg', pitch: '0deg' });
}
</script>
10.2.2 可复用 Composable:usePhotoSphereViewer
将 Viewer 生命周期管理抽取为 Composable,让多个组件共享同一套逻辑:
// composables/usePhotoSphereViewer.js
import { onBeforeUnmount, ref, shallowRef } from 'vue';
import { Viewer } from '@photo-sphere-viewer/core';
import '@photo-sphere-viewer/core/index.css';export function usePhotoSphereViewer(config) {const containerRef = ref(null);const viewerRef = shallowRef(null);const isReady = ref(false);function init() {if (!containerRef.value || viewerRef.value) return;const viewer = new Viewer({container: containerRef.value,...config,});viewerRef.value = viewer;viewer.addEventListener('ready', () => {isReady.value = true;config.onReady?.(viewer);}, { once: true });return viewer;}function destroy() {viewerRef.value?.destroy();viewerRef.value = null;isReady.value = false;}function getPlugin(id) {return viewerRef.value?.getPlugin(id);}function setPanorama(path, options) {return viewerRef.value?.setPanorama(path, options);}function rotate(position) {viewerRef.value?.rotate(position);}onBeforeUnmount(destroy);return {containerRef,viewerRef,isReady,init,destroy,getPlugin,setPanorama,rotate,};
}
在组件中使用 Composable:
<template><div ref="containerRef" class="viewer"></div>
</template><script setup>
import { watch } from 'vue';
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
import { usePhotoSphereViewer } from '../composables/usePhotoSphereViewer';const props = defineProps({panorama: { type: String, required: true },
});const {containerRef,viewerRef,isReady,init,getPlugin,setPanorama,rotate,
} = usePhotoSphereViewer({panorama: props.panorama,plugins: [[MarkersPlugin, {markers: [{ id: 'm1', position: { yaw: '45deg', pitch: '10deg' }, html: '标记1' },],}],],navbar: ['zoom', 'fullscreen', 'markers'],
});// 在此触发初始化(需要在 onMounted 之后执行)
import { onMounted } from 'vue';onMounted(() => {init();
});watch(() => props.panorama, (url) => {setPanorama(url, { transition: { speed: 600 } });
});defineExpose({ viewerRef, rotate, getPlugin });
</script>
10.3 TypeScript 实践
PSV v5 全量采用 TypeScript 编写,类型体系完备。在本节中,我们讨论在框架集成中如何充分利用类型系统。
10.3.1 核心类型导入
所有类型均从 @photo-sphere-viewer/core 导出:
import type {Viewer,ViewerConfig,ViewerState,Position,ExtendedPosition,AnimateOptions,PanoData,PluginConfig,CssSize,
} from '@photo-sphere-viewer/core';
| 类型 | 用途 |
|---|---|
Viewer |
Viewer 实例类型 |
ViewerConfig |
创建 Viewer 时的配置对象类型(v4 中叫 ViewerOptions) |
ViewerState |
Viewer 运行时状态(v4 中叫 ViewerProps) |
Position |
球面坐标 { yaw, pitch } |
ExtendedPosition |
扩展坐标(支持角度字符串如 '45deg') |
AnimateOptions |
动画配置 |
PanoData |
全景图像元数据 |
PluginConfig |
插件配置条目类型 |
CssSize |
CSS 尺寸类型({ width, height }) |
10.3.2 事件类型
PSV v5 使用原生 EventTarget API,事件类型通过 events 命名空间导出:
import { events } from '@photo-sphere-viewer/core';
import type {ClickEvent,PositionUpdateEvent,ZoomUpdateEvent,ReadyEvent,RenderEvent,
} from '@photo-sphere-viewer/core';const viewer = new Viewer({ container, panorama: 'pano.jpg' });viewer.addEventListener(events.PositionUpdateEvent.type, (e: PositionUpdateEvent) => {console.log(e.position.yaw, e.position.pitch);
});viewer.addEventListener(events.ClickEvent.type, (e: ClickEvent) => {console.log('Clicked at', e.data.yaw, e.data.pitch, 'on marker:', e.data.marker);
});
插件也有独立的事件类型:
import type { SelectMarkerEvent } from '@photo-sphere-viewer/markers-plugin';viewer.addEventListener('select-marker', (e: SelectMarkerEvent) => {console.log('Selected marker:', e.marker.id);
});
10.3.3 插件配置类型
每个插件的配置类型命名规则为 XxxPluginConfig(v4 中叫 XxxPluginOptions):
import type {MarkersPluginConfig,MarkerConfig,
} from '@photo-sphere-viewer/markers-plugin';
import type { GalleryPluginConfig } from '@photo-sphere-viewer/gallery-plugin';
import type { VirtualTourPluginConfig } from '@photo-sphere-viewer/virtual-tour-plugin';const markersConfig: MarkersPluginConfig = {markers: [{id: 'p1',position: { yaw: 0.5, pitch: 0.1 },html: 'Point 1',},],
};
10.3.4 React 组件 TypeScript 封装
import { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import { Viewer, type ViewerConfig } from '@photo-sphere-viewer/core';
import '@photo-sphere-viewer/core/index.css';interface PanoramaViewerProps extends Omit<ViewerConfig, 'container'> {className?: string;style?: React.CSSProperties;
}export interface PanoramaViewerHandle {viewer: Viewer | null;rotateTo: (yaw: number | string, pitch: number | string) => void;
}export const PanoramaViewer = forwardRef<PanoramaViewerHandle, PanoramaViewerProps>(function PanoramaViewer({ className = '', style = {}, ...viewerConfig }, ref) {const containerRef = useRef<HTMLDivElement>(null);const viewerRef = useRef<Viewer | null>(null);useEffect(() => {if (!containerRef.current) return;const viewer = new Viewer({container: containerRef.current,...viewerConfig,});viewerRef.current = viewer;return () => {viewer.destroy();viewerRef.current = null;};}, []);useImperativeHandle(ref, () => ({get viewer() {return viewerRef.current;},rotateTo(yaw, pitch) {viewerRef.current?.rotate({ yaw, pitch });},}));return (<divref={containerRef}className={`panorama-viewer ${className}`}style={{ width: '100%', height: '100%', ...style }}/>);}
);
10.3.5 tsconfig.json 配置
{"compilerOptions": {"target": "ES2020","module": "ESNext","moduleResolution": "bundler","lib": ["ES2020", "DOM", "DOM.Iterable"],"jsx": "react-jsx","strict": true,"esModuleInterop": true,"skipLibCheck": true,"forceConsistentCasingInFileNames": true,"resolveJsonModule": true,"isolatedModules": true,"declaration": true,"declarationMap": true,"sourceMap": true}
}
关键配置项说明:
| 配置 | 值 | 原因 |
|---|---|---|
target |
ES2020 |
PSV v5 使用 ES2020+ 语法,index.module.js 包含可选链等特性 |
moduleResolution |
bundler |
现代打包器(Vite/Webpack 5)模式,兼容 PSV 的 ESM 导出 |
strict |
true |
PSV 类型定义完整,使用严格模式能及早发现类型错误 |
skipLibCheck |
true |
Three.js 的类型定义较宽泛,跳过避免编译耗时 |
10.3.6 泛型约束自定义插件
当扩展自定义插件时,利用泛型约束确保类型安全:
import { AbstractPlugin, type Viewer, type PluginConfig } from '@photo-sphere-viewer/core';interface HotspotPluginConfig extends PluginConfig {hotspots: Array<{id: string;position: { yaw: number | string; pitch: number | string };color?: string;radius?: number;}>;
}class HotspotPlugin extends AbstractPlugin {static override id = 'hotspot-plugin';constructor(viewer: Viewer, config: HotspotPluginConfig) {super(viewer);this.config = { ...HotspotPlugin.defaultConfig, ...config };}static defaultConfig: HotspotPluginConfig = {hotspots: [],};declare config: HotspotPluginConfig;
}
10.4 实战项目一:房产虚拟看房
10.4.1 需求分析
一个典型的房地产 VR 看房应用需要以下能力:
| 需求 | 描述 |
|---|---|
| 多房间全景漫游 | 客厅、卧室、厨房、卫生间之间自由切换 |
| POI 标记 | 标注户型亮点(采光面、精装细节、家电品牌) |
| 平面图导航 | 楼层平面图上显示当前位置和可跳转点位 |
| 移动端适配 | 支持陀螺仪旋转、触摸手势缩放 |
| 过渡动画 | 场景切换时平滑过渡 |
| 信息面板 | 点击标记展示详细信息 |
10.4.2 技术选型
| 插件 | 用途 |
|---|---|
| VirtualTourPlugin | 管理房间节点和链接,控制漫游逻辑 |
| MarkersPlugin | 在房间内标注 POI 信息点 |
| PlanPlugin | 基于 Leaflet 的真实 GIS 地图(可选)或 MapPlugin 展示楼层平面图 |
| GyroscopePlugin | 移动端陀螺仪交互 |
| GalleryPlugin | 房间缩略图快速切换 |
对于平面图导航,如果项目需要真实的地理坐标定位,使用 PlanPlugin(Leaflet);如果只是楼层示意图,使用 MapPlugin 更轻量。
10.4.3 项目结构
vr-house/
├── index.html
├── src/
│ ├── main.js
│ ├── config/
│ │ ├── rooms.js # 房间数据(全景图URL、标记、链接)
│ │ └── floorPlan.js # 平面图数据
│ ├── viewer/
│ │ └── HouseViewer.js # 核心 Viewer 管理器
│ ├── ui/
│ │ ├── RoomInfoPanel.js # 房间信息面板
│ │ └── FloorPlanMap.js # 平面图导航
│ └── utils/
│ └── preloader.js # 预加载工具
└── panoramas/ # 全景图资源├── living-room.jpg├── bedroom.jpg├── kitchen.jpg└── thumbs/ # 缩略图
10.4.4 核心代码实现
房间数据配置(config/rooms.js):
export const rooms = {'living-room': {id: 'living-room',name: '客厅',panorama: 'panoramas/living-room.jpg',thumbnail: 'panoramas/thumbs/living-room.jpg',defaultYaw: '30deg',description: '宽敞明亮,南北通透',// VirtualTour 节点链接links: [{ nodeId: 'bedroom', position: { yaw: '90deg', pitch: '0deg' }, name: '主卧' },{ nodeId: 'kitchen', position: { yaw: '-90deg', pitch: '0deg' }, name: '厨房' },],// MarkersPlugin 标记markers: [{id: 'window-view',position: { yaw: '0deg', pitch: '-5deg' },html: '<div class="marker marker-view">好视野</div>',tooltip: '南向落地窗,采光极佳',data: { type: 'feature', detail: '3.2m面宽落地窗' },},{id: 'floor-material',position: { yaw: '-45deg', pitch: '10deg' },html: '<div class="marker marker-material">实木地板</div>',tooltip: '橡木实木复合地板',data: { type: 'material', detail: '橡木实木复合,地暖适用' },},],// 平面图坐标floorPlan: { x: 200, y: 100 },},'bedroom': {id: 'bedroom',name: '主卧',panorama: 'panoramas/bedroom.jpg',thumbnail: 'panoramas/thumbs/bedroom.jpg',links: [{ nodeId: 'living-room', position: { yaw: '-90deg', pitch: '0deg' }, name: '客厅' },],markers: [{id: 'closet',position: { yaw: '45deg', pitch: '0deg' },html: '<div class="marker marker-closet">步入式衣帽间</div>',data: { type: 'feature', detail: '8m²步入式衣帽间' },},],floorPlan: { x: 350, y: 100 },},'kitchen': {id: 'kitchen',name: '厨房',panorama: 'panoramas/kitchen.jpg',thumbnail: 'panoramas/thumbs/kitchen.jpg',links: [{ nodeId: 'living-room', position: { yaw: '90deg', pitch: '0deg' }, name: '客厅' },],markers: [{id: 'oven',position: { yaw: '-30deg', pitch: '5deg' },html: '<div class="marker marker-appliance">集成灶</div>',data: { type: 'appliance', detail: '方太集成烹饪中心' },},],floorPlan: { x: 100, y: 250 },},
};
核心 Viewer 管理器(viewer/HouseViewer.js):
import { Viewer } from '@photo-sphere-viewer/core';
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
import { VirtualTourPlugin } from '@photo-sphere-viewer/virtual-tour-plugin';
import { GyroscopePlugin } from '@photo-sphere-viewer/gyroscope-plugin';
import { MapPlugin } from '@photo-sphere-viewer/map-plugin';
import '@photo-sphere-viewer/core/index.css';
import '@photo-sphere-viewer/markers-plugin/index.css';
import '@photo-sphere-viewer/virtual-tour-plugin/index.css';
import '@photo-sphere-viewer/map-plugin/index.css';
import { rooms } from '../config/rooms';export class HouseViewer {constructor(container, options = {}) {this.container = container;this.currentRoomId = null;this.onRoomChange = options.onRoomChange || (() => {});this.onMarkerClick = options.onMarkerClick || (() => {});this._initViewer();}_initViewer() {const firstRoom = Object.values(rooms)[0];this.viewer = new Viewer({container: this.container,panorama: firstRoom.panorama,defaultYaw: firstRoom.defaultYaw,navbar: ['zoom','fullscreen','autorotate','gyroscope',{id: 'floor-plan-toggle',title: '平面图',content: '🏠',className: 'floor-plan-btn',onClick: () => this.toggleFloorPlan(),},],plugins: [[MarkersPlugin, {markers: firstRoom.markers,}],[VirtualTourPlugin, {positionMode: 'manual',renderMode: 'markers',nodes: this._buildNodes(),startNodeId: firstRoom.id,}],[GyroscopePlugin, {touchmove: true,}],[MapPlugin, {imageUrl: 'floor-plan.png',center: firstRoom.floorPlan,size: '250px',position: 'bottom right',visibleOnLoad: false,hotspots: this._buildHotspots(firstRoom.id),}],],});this._bindEvents();}_buildNodes() {return Object.values(rooms).map(room => ({id: room.id,panorama: room.panorama,name: room.name,links: room.links,markers: room.markers,defaultYaw: room.defaultYaw,}));}_buildHotspots(currentRoomId) {return Object.values(rooms).map(room => ({id: room.id,x: room.floorPlan.x,y: room.floorPlan.y,tooltip: room.name,}));}_bindEvents() {// 房间切换事件this.viewer.addEventListener('node-changed', (e) => {const room = rooms[e.nodeId];if (!room) return;this.currentRoomId = e.nodeId;// 更新标记const markersPlugin = this.viewer.getPlugin('markers');if (markersPlugin) {markersPlugin.setMarkers(room.markers);}// 更新平面图热点样式const mapPlugin = this.viewer.getPlugin('map');if (mapPlugin) {mapPlugin.setCenter(room.floorPlan, false);}this.onRoomChange(room);});// 标记点击事件this.viewer.addEventListener('select-marker', (e) => {this.onMarkerClick(e.marker);});}toggleFloorPlan() {const mapPlugin = this.viewer.getPlugin('map');if (!mapPlugin) return;if (mapPlugin.isVisible()) {mapPlugin.close();} else {mapPlugin.open();}}navigateTo(roomId) {const tourPlugin = this.viewer.getPlugin('virtualTour');if (tourPlugin) {tourPlugin.setCurrentNode(roomId);}}destroy() {this.viewer?.destroy();}
}
入口文件(main.js):
import { HouseViewer } from './viewer/HouseViewer.js';const container = document.getElementById('viewer');
const infoPanel = document.getElementById('room-info');const houseViewer = new HouseViewer(container, {onRoomChange: (room) => {infoPanel.innerHTML = `<h2>${room.name}</h2><p>${room.description}</p>`;},onMarkerClick: (marker) => {if (marker.data?.detail) {showDetailPopup(marker.data.detail);}},
});function showDetailPopup(text) {const popup = document.getElementById('detail-popup');popup.textContent = text;popup.style.display = 'block';setTimeout(() => { popup.style.display = 'none'; }, 3000);
}
10.4.5 关键设计决策
| 决策点 | 选择 | 理由 |
|---|---|---|
| 标记渲染模式 | renderMode: 'markers' |
使用 3D 标记(而非 HTML 覆盖层),交互更自然,随球面旋转消失 |
| 位置模式 | positionMode: 'manual' |
在 VirtualTour 节点中手动指定每个房间链接的位置,比 GPS 模式更灵活 |
| 平面图插件 | MapPlugin(非 PlanPlugin) | 室内看房用楼层平面图即可,无需 Leaflet 的真实地理定位 |
| 导航栏定制 | 自定义按钮混合默认按钮 | 保留核心交互(缩放、全屏),添加自定义"平面图"切换按钮 |
| 陀螺仪 | 始终加载 GyroscopePlugin | 移动端看房是核心场景,加载即可用 |
10.5 实战项目二:景区全景导览
10.5.1 需求分析
景区全景导览与室内看房的核心差异在于:场景更开放、视角更广阔、用户对地理位置和方向的感知需求更强。
| 需求 | 描述 |
|---|---|
| 多景点切换 | 观景台、瀑布、寺庙等多个景点之间切换 |
| 自动导览模式 | 按推荐路线自动切换全景和视角 |
| 缩略图画廊 | 所有景点的缩略图导航 |
| 信息面板 | 当前景点的名称、海拔、介绍文字 |
| 地图联动 | 地图上同步显示当前位置和朝向(户外的真实 GPS 坐标更有意义) |
| 指南针 | 角落指南针帮助辨别方向 |
10.5.2 技术选型
| 插件 | 用途 |
|---|---|
| GalleryPlugin | 景点缩略图导航 |
| AutorotatePlugin | 自动旋转或在导览模式下按关键点序列展示 |
| MarkersPlugin | 景点内的标记(观鸟点、休息区、历史遗迹说明牌) |
| CompassPlugin | 指南针指示当前朝向 |
| MapPlugin | 景区手绘地图叠加 |
10.5.3 核心代码实现
import { Viewer } from '@photo-sphere-viewer/core';
import { GalleryPlugin } from '@photo-sphere-viewer/gallery-plugin';
import { AutorotatePlugin } from '@photo-sphere-viewer/autorotate-plugin';
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
import { CompassPlugin } from '@photo-sphere-viewer/compass-plugin';
import '@photo-sphere-viewer/core/index.css';
import '@photo-sphere-viewer/gallery-plugin/index.css';
import '@photo-sphere-viewer/markers-plugin/index.css';
import '@photo-sphere-viewer/compass-plugin/index.css';const scenicSpots = [{id: 'viewpoint',name: '观景台',panorama: 'panoramas/viewpoint.jpg',thumbnail: 'panoramas/thumbs/viewpoint.jpg',description: '海拔 1800m,可俯瞰整片山谷',markers: [{id: 'east-peak',position: { yaw: '0deg', pitch: '5deg' },html: '<div class="marker-scenic">东峰</div>',data: { spot: 'east-peak' },},{id: 'waterfall',position: { yaw: '120deg', pitch: '-10deg' },html: '<div class="marker-scenic">银链瀑布</div>',data: { spot: 'waterfall' },},],},{id: 'waterfall',name: '银链瀑布',panorama: 'panoramas/waterfall.jpg',thumbnail: 'panoramas/thumbs/waterfall.jpg',description: '落差 68m 的三叠瀑布',markers: [{id: 'viewpoint',position: { yaw: '180deg', pitch: '10deg' },html: '<div class="marker-scenic">返回观景台</div>',data: { spot: 'viewpoint' },},],},{id: 'temple',name: '古寺',panorama: 'panoramas/temple.jpg',thumbnail: 'panoramas/thumbs/temple.jpg',description: '始建于明代的古刹',markers: [{id: 'ancient-tree',position: { yaw: '-45deg', pitch: '5deg' },html: '<div class="marker-scenic">千年银杏</div>',data: { tree: 'ginkgo' },},],},
];const viewer = new Viewer({container: document.getElementById('viewer'),panorama: scenicSpots[0].panorama,navbar: ['zoom','fullscreen','gallery','autorotate',{id: 'tour-mode',title: '导览模式',content: '🎧',onClick: () => startAutoTour(),},],plugins: [[GalleryPlugin, {thumbnailSize: { width: 120, height: 60 },items: scenicSpots.map(spot => ({id: spot.id,panorama: spot.panorama,thumbnail: spot.thumbnail,name: spot.name,})),}],[AutorotatePlugin, {autostartDelay: null,speed: '1rpm',}],[MarkersPlugin, {markers: scenicSpots[0].markers,}],[CompassPlugin, {hotspots: [{ yaw: '0deg' }, // 北{ yaw: '90deg' }, // 东{ yaw: '180deg' }, // 南{ yaw: '270deg' }, // 西],}],],
});// 信息面板更新
function updateInfoPanel(spot) {document.getElementById('spot-name').textContent = spot.name;document.getElementById('spot-desc').textContent = spot.description;
}// 全景切换时同步更新标记和信息
viewer.addEventListener('panorama-loaded', (e) => {const spot = scenicSpots.find(s => s.panorama === e.data.panorama);if (!spot) return;const markersPlugin = viewer.getPlugin('markers');markersPlugin?.setMarkers(spot.markers);updateInfoPanel(spot);
});// 标记点击跳转
viewer.addEventListener('select-marker', (e) => {const targetSpotId = e.marker.data?.spot;if (targetSpotId) {const target = scenicSpots.find(s => s.id === targetSpotId);if (target) {viewer.setPanorama(target.panorama, {transition: { speed: 1200, effect: 'fade' },});}}
});// 自动导览模式
let tourTimer = null;function startAutoTour() {const autorotate = viewer.getPlugin('autorotate');let index = 0;function nextSpot() {const spot = scenicSpots[index % scenicSpots.length];viewer.setPanorama(spot.panorama, {transition: { speed: 1000, effect: 'fade' },});autorotate?.start();index++;tourTimer = setTimeout(() => {autorotate?.stop();nextSpot();}, 8000); // 每个景点展示8秒}if (tourTimer) {clearTimeout(tourTimer);tourTimer = null;autorotate?.stop();return;}nextSpot();
}
10.5.4 地图联动与指南针
对于户外景区全景,建议使用 PlanPlugin 关联真实 GPS 坐标实现地图联动:
import { PlanPlugin } from '@photo-sphere-viewer/plan-plugin';
import '@photo-sphere-viewer/plan-plugin/index.css';// PlanPlugin 配置
[PlanPlugin, {defaultZoom: 14,coordinates: [120.15, 30.28], // 景区中心 GPShotspots: scenicSpots.map(spot => ({id: spot.id,coordinates: spot.coordinates, // [lng, lat]tooltip: spot.name,color: '#e74c3c',})),// 地图上标记的名称poiTooltip: (poi) => poi.tooltip,
}]
MapPlugin 与 PlanPlugin 的对比选择:
| 维度 | MapPlugin | PlanPlugin |
|---|---|---|
| 底图 | 自定义静态图片(PNG/JPG/SVG) | Leaflet 瓦片地图(OSM 等) |
| 定位方式 | 像素坐标或角度+距离 | GPS 经纬度 |
| 适合场景 | 景区手绘地图、室内平面图 | 需要真实地理位置的户外全景 |
| 依赖 | 无 | Leaflet(约 40KB gzip) |
| 交互能力 | 缩放平移,热点可点击 | 标准地图交互(缩放、平移、图层切换) |
10.6 实战项目三:360° 视频展厅
10.6.1 需求分析
360° 视频展厅与静态全景项目的最大区别在于"时间维度"——视频在播放过程中画面不断变化,需要在时间轴上做标记和交互。
| 需求 | 描述 |
|---|---|
| 360° 视频播放 | 支持多分辨率、流畅播放 |
| 移动端 VR 模式 | 双屏立体显示 + 陀螺仪 |
| 时间轴标记 | 视频播放到特定时间点时触发事件 |
| 场景切换 | 多段视频之间的切换 |
| 播放控制 | 暂停/播放、音量、进度条 |
| 加载状态 | 视频加载进度指示 |
10.6.2 技术选型
| 插件/适配器 | 用途 |
|---|---|
| EquirectangularVideoAdapter | 等距柱状投影视频适配器(360° MP4) |
| VideoPlugin | 视频播放控制(播放/暂停、进度条、音量) |
| ResolutionPlugin | 多分辨率切换(必须配合 SettingsPlugin) |
| SettingsPlugin | 设置面板框架 |
| StereoPlugin | 立体 VR 视图(Cardboard 等) |
| GyroscopePlugin | 移动端陀螺仪旋转 |
10.6.3 核心代码实现
import { Viewer } from '@photo-sphere-viewer/core';
import { EquirectangularVideoAdapter } from '@photo-sphere-viewer/equirectangular-video-adapter';
import { VideoPlugin } from '@photo-sphere-viewer/video-plugin';
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin';
import { StereoPlugin } from '@photo-sphere-viewer/stereo-plugin';
import { GyroscopePlugin } from '@photo-sphere-viewer/gyroscope-plugin';
import '@photo-sphere-viewer/core/index.css';
import '@photo-sphere-viewer/video-plugin/index.css';
import '@photo-sphere-viewer/resolution-plugin/index.css';
import '@photo-sphere-viewer/settings-plugin/index.css';
import '@photo-sphere-viewer/stereo-plugin/index.css';const viewer = new Viewer({container: document.getElementById('viewer'),adapter: [EquirectangularVideoAdapter, {autoplay: false,muted: false,}],panorama: {source: 'exhibition-4k.mp4',},navbar: ['zoom','fullscreen','settings',{id: 'stereo-toggle',title: 'VR模式',content: '🥽',onClick: () => {viewer.getPlugin('stereo')?.toggle();},},],plugins: [[VideoPlugin, {// 视频进度上的关键帧标记keypoints: [{ time: 5, label: '展厅A入口' },{ time: 18, label: '主展区中心' },{ time: 35, label: '互动装置区' },{ time: 52, label: '出口/礼品店' },],// 自定义进度条样式progressbar: {className: 'custom-progress-bar',},}],[SettingsPlugin, {}],[ResolutionPlugin, {resolutions: [{id: '4K',label: '超清 4K',panorama: { source: 'exhibition-4k.mp4' },},{id: '1080p',label: '高清 1080p',panorama: { source: 'exhibition-1080p.mp4' },},{id: '720p',label: '流畅 720p',panorama: { source: 'exhibition-720p.mp4' },},],}],[StereoPlugin, {// VR 立体视图配置overlay: null, // 禁用默认叠加层}],[GyroscopePlugin, {touchmove: true,absolute: false,}],],
});// 视频进度事件——在特定时间点触发交互
viewer.addEventListener('keypoint-reached', (e) => {const { time, label } = e;showSectionInfo(label, time);
});function showSectionInfo(label, time) {const infoBar = document.getElementById('section-info');infoBar.textContent = `当前位置:${label}`;infoBar.style.display = 'block';setTimeout(() => { infoBar.style.display = 'none'; }, 4000);
}// 多段视频切换
function switchToVideo(videoUrl, quality = '1080p') {viewer.setPanorama({ source: videoUrl },{ transition: { speed: 500, effect: 'fade' } });
}// 视频播放状态监听
viewer.addEventListener('play', () => {console.log('视频开始播放');
});viewer.addEventListener('pause', () => {console.log('视频暂停');
});// 获取视频插件实例以进行程序化控制
const videoPlugin = viewer.getPlugin('video');document.getElementById('play-btn').addEventListener('click', () => {videoPlugin?.play();
});document.getElementById('pause-btn').addEventListener('click', () => {videoPlugin?.pause();
});document.getElementById('mute-btn').addEventListener('click', () => {videoPlugin?.toggleMute();
});document.getElementById('seek-to-30').addEventListener('click', () => {videoPlugin?.setTime(30);
});
10.6.4 视频关键帧与场景切换
VideoPlugin 的 keypoints 配置允许在进度条上标记关键时间点。结合事件监听可以实现精准的场景联动:
// 场景数据配置
const exhibitionSections = [{ startTime: 0, endTime: 10, name: '前厅', panorama: 'exhibition-part1.mp4' },{ startTime: 10, endTime: 25, name: '主展厅', panorama: 'exhibition-part2.mp4' },{ startTime: 25, endTime: 40, name: '互动区', panorama: 'exhibition-part3.mp4' },
];// 监听 keypoint 事件,触发 UI 更新
viewer.addEventListener('keypoint-reached', (e) => {const section = exhibitionSections.find(s => e.time >= s.startTime && e.time <= s.endTime);if (section) {document.getElementById('section-indicator').textContent = section.name;}
});
四个与视频相关的 keypoint 事件:
| 事件 | 参数 | 触发时机 |
|---|---|---|
keypoint-reached |
{ time, label } |
视频进度到达标记点 |
play |
— | 视频开始播放 |
pause |
— | 视频暂停 |
volumechange |
{ volume, muted } |
音量变化 |
10.6.5 视频多分辨率策略
ResolutionPlugin 的核心价值在于让用户根据网络条件选择画质。推荐提供三档分辨率:
| 分辨率 | 典型参数 | 码率建议 | 适用网络 |
|---|---|---|---|
| 4K | 3840×1920 | 15-25 Mbps | Wi-Fi / 5G |
| 1080p | 1920×960 | 5-10 Mbps | 4G |
| 720p | 1280×640 | 2-4 Mbps | 弱网 / 3G |
通过 SettingsPlugin 作为容器,ResolutionPlugin 会自动注册画质切换按钮。
10.7 性能优化
全景应用的性能瓶颈通常不在 JS 逻辑,而在 GPU 纹理带宽和渲染管线。本节讨论从图像分辨率到渲染参数的完整优化链条。
10.7.1 全景图分辨率选择策略
全景图的理想分辨率取决于两个因素:设备视口宽度和最大 FOV。全景球面上的纹理利用率不是 100%——用户同一时刻只能看到球面的一部分(约 90°-100° FOV)。
| 设备类型 | 屏幕宽度 | 推荐全景图分辨率 | 每像素对 |
|---|---|---|---|
| 桌面显示器 | 1920px | 4096×2048 (4K) | 约 2.1 |
| 笔记本 | 1440px | 3072×1536 | 约 2.1 |
| 平板横屏 | 1024px | 2048×1024 (2K) | 约 2.0 |
| 手机竖屏 | 375px | 1024×512 | 约 1.8 |
"每像素对" = 全景图水平分辨率 / 屏幕宽度。该值在 1.5-2.0 之间时,人眼感知的清晰度损失很小,同时比加载原图节省 50%-75% 的带宽。
策略:根据屏幕尺寸动态加载不同分辨率
function getOptimalResolution() {const width = window.innerWidth;if (width >= 1600) return '4k';if (width >= 900) return '2k';return '1k';
}const resolutionMap = {'4k': 'panoramas/living-room-4k.jpg','2k': 'panoramas/living-room-2k.jpg','1k': 'panoramas/living-room-1k.jpg',
};const optimalUrl = resolutionMap[getOptimalResolution()];const viewer = new Viewer({container: document.getElementById('viewer'),panorama: optimalUrl,
});
也可以在 <picture> 风格中使用 srcset 类似的方式(需自行实现),或结合 ResolutionPlugin 动态切换。
10.7.2 分块加载(TilesAdapter)
当单张全景图超过 8K 时,使用分块加载能显著降低首屏等待时间。EquirectangularTilesAdapter 将全景图切分为多级分辨率的瓦片:
import { EquirectangularTilesAdapter } from '@photo-sphere-viewer/equirectangular-tiles-adapter';const viewer = new Viewer({container: document.getElementById('viewer'),adapter: [EquirectangularTilesAdapter, {// 使用 GDAL2Tiles 或类似工具生成的瓦片baseUrl: 'tiles/',tileSize: 512,levels: [{ width: 512, cols: 1, rows: 1 }, // 缩放级别 0{ width: 1024, cols: 2, rows: 1 }, // 缩放级别 1{ width: 2048, cols: 4, rows: 2 }, // 缩放级别 2{ width: 4096, cols: 8, rows: 4 }, // 缩放级别 3{ width: 8192, cols: 16, rows: 8 }, // 缩放级别 4],}],
});
| 场景 | 推荐方案 |
|---|---|
| 普通全景图(≤ 4K) | 单张 Equirectangular,不做分块 |
| 高清全景图(4K - 8K) | 单张 + 资源预加载(相邻节点),根据需要决定是否分块 |
| 超高清全景图(> 8K) | 必须分块 TilesAdapter,否则加载时间过长 |
| 动态按需缩放 | TilesAdapter 自动根据 FOV 选择瓦片级别,无需手动干预 |
10.7.3 缓存策略
PSV 依赖浏览器的标准 HTTP 缓存机制。全景图作为最大资源,应配置较长的缓存时间:
Nginx 配置示例:
location /panoramas/ {expires 30d;add_header Cache-Control "public, immutable";
}
使用 Service Worker 做离线缓存(进阶):
// sw.js
const CACHE_NAME = 'pano-cache-v1';
const PANO_URLS = ['/panoramas/living-room.jpg','/panoramas/bedroom.jpg','/panoramas/kitchen.jpg',
];self.addEventListener('install', (event) => {event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(PANO_URLS)));
});
对于需要离线展示的看房或导览应用,Service Worker 缓存可以确保用户在无网络环境下仍能浏览已缓存的房间。
10.7.4 预加载策略
预加载可以消除场景切换时的黑屏等待时间。以下是三种策略:
策略一:相邻节点预加载
function preloadAdjacentNodes(currentRoomId, rooms) {const room = rooms[currentRoomId];const adjacentIds = room.links.map(link => link.nodeId);adjacentIds.forEach(id => {const img = new Image();img.src = rooms[id].panorama;});
}viewer.addEventListener('node-changed', (e) => {preloadAdjacentNodes(e.nodeId, rooms);
});
策略二:画廊缩略图预加载(GalleryPlugin 自动处理)
GalleryPlugin 默认会预加载所有 thumbnail 图片,全景图本身在用户点击时才加载——这已经是合理的默认行为。
策略三:空闲时间预加载
function idlePreload(urls) {if ('requestIdleCallback' in window) {requestIdleCallback(() => {urls.forEach(url => {const link = document.createElement('link');link.rel = 'prefetch';link.href = url;document.head.appendChild(link);});});}
}
10.7.5 Three.js 渲染优化
rendererParameters 配置:
const viewer = new Viewer({container: document.getElementById('viewer'),panorama: 'panorama.jpg',rendererParameters: {antialias: false, // 全景球面不需要抗锯齿powerPreference: 'high-performance',},
});
| 参数 | 推荐值 | 说明 |
|---|---|---|
antialias |
false |
全景球面边缘在相机坐标系中很稳定,无需 MSAA |
powerPreference |
'high-performance' |
优先使用独立 GPU |
alpha |
false(默认) |
除非需要透明背景,否则关闭以节省资源 |
pixelRatio 控制:
// 限制设备像素比,避免高 DPI 屏过度渲染
const viewer = new Viewer({container: document.getElementById('viewer'),panorama: 'panorama.jpg',rendererParameters: {// 通过外部控制 pixelRatio},
});// 在创建后手动设置
viewer.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
将 pixelRatio 上限设为 2,在 Retina 屏上节省约 50% 的像素填充开销,而视觉效果几乎无差异。
FPS 控制:
PSV 默认会根据用户交互状态自动调整渲染频率——交互时高帧率,静止时停止渲染。当使用了 AutorotatePlugin 等持续运动的插件后,渲染会保持激活。通过 needsContinuousUpdate() 可以手动控制:
// 启用持续渲染
viewer.needsContinuousUpdate(true);// 禁用持续渲染(恢复按需渲染)
viewer.needsContinuousUpdate(false);
10.7.6 代码分割
PSV 的 monorepo 包结构天然支持按需加载。不要一次性导入所有插件:
// 不推荐:一次性导入所有可能的插件
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
import { GalleryPlugin } from '@photo-sphere-viewer/gallery-plugin';
import { VirtualTourPlugin } from '@photo-sphere-viewer/virtual-tour-plugin';
import { GyroscopePlugin } from '@photo-sphere-viewer/gyroscope-plugin';
// ... 10+ imports// 推荐:按路由/页面按需加载
const loadViewerPlugins = async (pageType) => {switch (pageType) {case 'house':return {tour: await import('@photo-sphere-viewer/virtual-tour-plugin'),markers: await import('@photo-sphere-viewer/markers-plugin'),};case 'scenic':return {gallery: await import('@photo-sphere-viewer/gallery-plugin'),compass: await import('@photo-sphere-viewer/compass-plugin'),};}
};
CSS 同样需要按需引入,不要在入口文件引入所有插件的样式。
10.7.7 Lighthouse 指标
一个优化良好的全景页面,在 Lighthouse 上的表现可以达到以下水平:
| 指标 | 优化后数值 | 优化手段 |
|---|---|---|
| FCP(首次内容绘制) | < 1.5s | 合理分辨率、渐进式加载 |
| LCP(最大内容绘制) | < 2.5s | 全景图 HTTP 缓存、CDN、TilesAdapter |
| TBT(总阻塞时间) | < 100ms | 代码分割、延迟加载非核心插件 |
| CLS(累计布局偏移) | < 0.1 | 固定 Viewer 容器尺寸、loadingImg 占位 |
| Performance Score | 90+ | 综合上述优化 |
10.8 构建与部署
10.8.1 Webpack 配置
// webpack.config.js
const path = require('path');module.exports = {entry: './src/main.js',output: {path: path.resolve(__dirname, 'dist'),filename: 'bundle.[contenthash].js',clean: true,},module: {rules: [{test: /\.js$/,exclude: /node_modules/,use: {loader: 'babel-loader',options: {presets: ['@babel/preset-env'],},},},{test: /\.css$/,use: ['style-loader', 'css-loader'],},{// 处理 Three.js 和 PSV 包中的静态资源test: /\.(glb|gltf|hdr|bin)$/,type: 'asset/resource',},],},resolve: {extensions: ['.js'],// 确保 Three.js 只用一份副本alias: {three: path.resolve('./node_modules/three'),},},
};
10.8.2 Vite 配置
Vite 对 ESM 原生支持的特性与 PSV 的 index.module.js 天然契合:
// vite.config.js
import { defineConfig } from 'vite';export default defineConfig({build: {target: 'es2020',rollupOptions: {output: {manualChunks: {'three': ['three'],'psv-core': ['@photo-sphere-viewer/core'],'psv-plugins': ['@photo-sphere-viewer/markers-plugin','@photo-sphere-viewer/virtual-tour-plugin','@photo-sphere-viewer/gallery-plugin',],},},},},
});
通过 manualChunks 将 Three.js 和 PSV 拆分为独立 chunk,利用浏览器缓存减少二次访问的加载量。
10.8.3 静态资源路径处理
全景图通常托管在 CDN 或对象存储上。部署时确保路径正确:
// 环境感知的路径配置
const BASE_URL = import.meta.env.VITE_CDN_URL || '';
const PANO_PATH = `${BASE_URL}/panoramas`;const viewer = new Viewer({container: document.getElementById('viewer'),panorama: `${PANO_PATH}/living-room.jpg`,
});
Vite 中处理 public 目录的全景图:
将全景图放在 public/panoramas/ 下,构建后会被原样复制到 dist/panoramas/。代码中引用时使用绝对路径:
panorama: '/panoramas/living-room.jpg'
10.8.4 GitHub Pages / Vercel / Netlify 部署
GitHub Pages:
GitHub Pages 对静态站点部署提供了最简单的路径。将构建输出推到 gh-pages 分支或配置 GitHub Actions 自动部署:
# .github/workflows/deploy.yml
name: Deploy to GitHub Pages
on:push:branches: [main]
jobs:deploy:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v4- uses: actions/setup-node@v4with:node-version: 20- run: npm ci- run: npm run build- uses: peaceiris/actions-gh-pages@v4with:github_token: ${{ secrets.GITHUB_TOKEN }}publish_dir: ./dist
Vercel:
Vercel 自动检测 Vite 项目,零配置部署。注意全景图资源可能超出 Vercel 的单文件 100MB 限制——这种情况下应将全景图托管在外部 CDN。
Netlify:
类似 Vercel,连接仓库后自动构建。需要在 netlify.toml 中指定构建命令和输出目录:
[build]command = "npm run build"publish = "dist"[[headers]]for = "/panoramas/*"[headers.values]Cache-Control = "public, max-age=2592000, immutable"
10.8.5 CORS 和跨域资源加载
当全景图托管在不同于网页的域名时,浏览器会执行 CORS 检查。解决方式:
- CDN 配置 CORS 头:
Access-Control-Allow-Origin: *
- Viewer 中启用跨域:
const viewer = new Viewer({container: document.getElementById('viewer'),panorama: 'https://cdn.example.com/panoramas/living-room.jpg',withCredentials: false, // 默认值,匿名请求不发送 Cookie
});
- 使用
<img>的crossorigin属性(Three.js 内部通过 Image 加载纹理时设置)。
10.8.6 HTTPS 要求
以下功能强制要求 HTTPS(或在 localhost 下允许 HTTP):
| 功能 | 原因 |
|---|---|
| GyroscopePlugin | W3C DeviceOrientation API 仅在安全上下文可用 |
| 全屏 API | requestFullscreen() 在某些浏览器中仅 HTTPS 可用 |
| Service Worker | 仅在 HTTPS 或 localhost 下注册 |
| 跨域纹理 | crossOrigin: 'anonymous' 的 Three.js 纹理加载在某些浏览器中要求 HTTPS |
部署到生产环境时,GitHub Pages / Vercel / Netlify 默认提供 HTTPS,无需额外配置。
10.8.7 Tree Shaking 与压缩
PSV 的 ESM 构建版本(index.module.js)支持 Tree Shaking。确保打包器配置为 ESM 模式即可。
针对 Three.js 的优化:
Three.js 的某些模块(如 WebGL 相关的内部实现)可能被完整引入。如果项目只用到了 PSV 提供的 Three.js 场景(不自行创建 Mesh 等),可以考虑使用 three-stdlib 或 import map 精确控制。
Brocoli / Gzip 压缩建议:
| 资源类型 | 压缩后典型大小 | 压缩策略 |
|---|---|---|
| PSV Core | ~45 KB (gzip) | Brotli 可再减少 15% |
| PSV + 3 个插件 | ~80 KB (gzip) | Tree Shaking + 代码分割 |
| Three.js | ~120 KB (gzip) | 仅导入用到的模块 |
| 全景图(2K) | ~500 KB (JPG) | WebP 可再减少 25-35% |
| 全景图(4K) | ~1.5 MB (JPG) | 按设备分辨率加载 + WebP |
10.9 从 v4 迁移到 v5
如果你的项目还在使用 Photo-Sphere-Viewer v4,本节提供完整的迁移清单。
10.9.1 包名变更对照
| v4 包 | v5 包 | 说明 |
|---|---|---|
photo-sphere-viewer |
@photo-sphere-viewer/core |
核心 Viewer |
photo-sphere-viewer/dist/plugins/markers |
@photo-sphere-viewer/markers-plugin |
标记插件 |
photo-sphere-viewer/dist/plugins/virtual-tour |
@photo-sphere-viewer/virtual-tour-plugin |
虚拟导览 |
photo-sphere-viewer/dist/plugins/gallery |
@photo-sphere-viewer/gallery-plugin |
画廊 |
photo-sphere-viewer/dist/plugins/gyroscope |
@photo-sphere-viewer/gyroscope-plugin |
陀螺仪 |
photo-sphere-viewer/dist/plugins/compass |
@photo-sphere-viewer/compass-plugin |
指南针 |
photo-sphere-viewer/dist/plugins/autorotate-keypoints |
@photo-sphere-viewer/autorotate-plugin |
自动旋转 |
photo-sphere-viewer/dist/plugins/visible-range |
@photo-sphere-viewer/visible-range-plugin |
可见范围 |
photo-sphere-viewer/dist/plugins/video |
@photo-sphere-viewer/video-plugin |
视频控制 |
photo-sphere-viewer/dist/plugins/resolution |
@photo-sphere-viewer/resolution-plugin |
分辨率 |
photo-sphere-viewer/dist/plugins/settings |
@photo-sphere-viewer/settings-plugin |
设置面板 |
photo-sphere-viewer/dist/plugins/stereo |
@photo-sphere-viewer/stereo-plugin |
VR 立体 |
photo-sphere-viewer/dist/adapters/equirectangular |
@photo-sphere-viewer/core(内置) |
等距柱状投影 |
photo-sphere-viewer/dist/adapters/cubemap |
@photo-sphere-viewer/cubemap-adapter |
立方体贴图 |
photo-sphere-viewer/dist/adapters/equirectangular-tiles |
@photo-sphere-viewer/equirectangular-tiles-adapter |
分块全景 |
photo-sphere-viewer/dist/adapters/equirectangular-video |
@photo-sphere-viewer/equirectangular-video-adapter |
视频全景 |
10.9.2 事件系统迁移
v4 使用 uEvent 库,v5 改用原生 EventTarget API:
// v4
viewer.on('position-updated', (e, position) => {console.log(position.longitude, position.latitude);
});
viewer.once('ready', () => {});
viewer.off('position-updated', handler);// v5
viewer.addEventListener('position-updated', (e) => {console.log(e.position.yaw, e.position.pitch);
});
viewer.addEventListener('ready', () => {}, { once: true });
viewer.removeEventListener('position-updated', handler);
10.9.3 坐标重命名
球面坐标和像素坐标全部重命名,以避免与 GPS 系统混淆:
| v4 | v5 | 含义 |
|---|---|---|
longitude |
yaw |
水平偏航角 |
latitude |
pitch |
垂直俯仰角 |
x(纹理坐标) |
textureX |
纹理像素 X 坐标 |
y(纹理坐标) |
textureY |
纹理像素 Y 坐标 |
配置项同步重命名:
| v4 | v5 |
|---|---|
defaultLong |
defaultYaw |
defaultLat |
defaultPitch |
10.9.4 标记 API 变更
// v4
markers: [{id: 'zone',polygonRad: [{ longitude: 0.1, latitude: 0.2 }, ...],polygonPx: [100, 200, 300, ...],polylineRad: [...],polylinePx: [...],
}]// v5
markers: [{id: 'zone',polygon: [{ yaw: 0.1, pitch: 0.2 }, ...],polygonPixels: [100, 200, 300, ...],polyline: [...],polylinePixels: [...],
}]
10.9.5 TypeScript 类型重命名
| v4 | v5 |
|---|---|
ViewerOptions |
ViewerConfig |
ViewerProps |
ViewerState |
XxxAdapterOptions |
XxxAdapterConfig |
XxxPluginOptions |
XxxPluginConfig |
MarkerProperties |
MarkerConfig |
10.9.6 自动旋转迁移
v4 的自动旋转是 Viewer 的内置功能(autorotateSpeed、autorotateDelay 等选项)。v5 将其彻底迁移到独立插件:
// v4
const viewer = new Viewer({panorama: 'pano.jpg',autorotateSpeed: '1rpm',autorotateDelay: 2000,autorotateZoom: false,
});// v5
import { AutorotatePlugin } from '@photo-sphere-viewer/autorotate-plugin';const viewer = new Viewer({panorama: 'pano.jpg',plugins: [[AutorotatePlugin, {autostartDelay: 2000,speed: '1rpm',autostartOnIdle: true,}],],
});
所有 autorotateXxx 配置项在 v5 核心中已删除。
10.9.7 迁移检查清单
10.10 常见问题与故障排除
10.10.1 全景图不显示
| 症状 | 原因 | 解决 |
|---|---|---|
| 黑屏 | 容器尺寸为 0 | 给容器设置明确的高度(如 100vh 或 500px),或在 Viewer 构造后调用 autoSize() |
| 白屏 | 图片格式不支持 | 确认使用 JPEG/PNG/WebP 格式;检查图片 URL 是否可访问 |
| 黑屏(有时) | CORS 被拦截 | 检查浏览器控制台的跨域错误;在 CDN 配置 Access-Control-Allow-Origin: * |
| 画面变形 | 全景图不是等距柱状投影 | 确认图片为 2:1 比例;若不是标准全景图,使用 panoData 配置裁剪参数 |
| 加载转圈不消失 | 图片过大 | 降低分辨率、启用 TilesAdapter、或使用 WebP 格式 |
10.10.2 插件不工作
| 症状 | 常见原因 |
|---|---|
| 插件无效果 | 未引入 CSS(@photo-sphere-viewer/xxx-plugin/index.css) |
插件报错 xyz is undefined |
插件导入顺序错误,依赖的插件未注册(如 ResolutionPlugin 依赖 SettingsPlugin) |
| 插件 API 调用无响应 | 在 ready 事件之前调用了插件方法;应等待 viewer.addEventListener('ready', ...) |
| TypeScript 报类型错误 | 未安装插件的类型包或使用了 v4 的类型名(XxxOptions → XxxConfig) |
10.10.3 标记位置偏移
标记在全景球面上偏移通常由以下原因造成:
- 坐标系混淆——最常发生在从 v4 迁移到 v5 后仍使用
longitude/latitude名称。v5 已重命名为yaw/pitch。 - panoData 配置不匹配——如果全景图被裁剪(cropped panorama),但没有提供
panoData(fullWidth、fullHeight、croppedX、croppedY等),标记将基于"完整"球面坐标计算,导致偏移。 - 纹理坐标 vs 球面坐标混用——
polygonPixels使用像素坐标,polygon使用弧度坐标。在使用标记生成工具提取坐标时,确认使用的是哪种坐标系统。
检查方法:
viewer.addEventListener('click', (e) => {console.log('Click position:', e.data);// 对比标记的 position 与点击位置是否在合理范围内
});
10.10.4 移动端性能差
| 症状 | 可能原因 | 解决 |
|---|---|---|
| 拖动卡顿 | 全景图分辨率过高 | 移动端使用 1024×512 或 1920×960 分辨率 |
| 发热严重 | 持续高帧率渲染 | 不使用 AutorotatePlugin 时自动停止渲染;限制 pixelRatio ≤ 2 |
| 陀螺仪不响应 | 非 HTTPS 环境或无传感器权限 | 部署到 HTTPS;iOS 13+ 需用户主动授权 DeviceOrientationEvent.requestPermission() |
| 页面崩溃 | 内存不足 | 切换全景图前 destroy() 旧 Viewer;同一页面不要同时存在多个 Viewer 实例 |
iOS 陀螺仪权限请求:
const gyroBtn = document.getElementById('enable-gyro');gyroBtn.addEventListener('click', async () => {if (typeof DeviceOrientationEvent?.requestPermission === 'function') {const permission = await DeviceOrientationEvent.requestPermission();if (permission === 'granted') {// 权限已获取,陀螺仪插件自动激活}}
});
10.10.5 同页面多个 Viewer
在同一个页面上创建多个 Viewer 实例是可行的,但需要注意:
// 为每个 Viewer 使用独立的容器
const viewer1 = new Viewer({container: document.getElementById('viewer-1'),panorama: 'pano1.jpg',
});const viewer2 = new Viewer({container: document.getElementById('viewer-2'),panorama: 'pano2.jpg',
});
注意事项:
| 注意点 | 说明 |
|---|---|
| 容器隔离 | 每个 Viewer 必须有独立的 HTML 容器元素 |
| 全屏冲突 | 同时只能有一个 Viewer 进入全屏模式 |
| 内存 | 每个 Viewer 都会创建独立的 WebGL 上下文;移动端建议最多 2 个同时活跃 |
| 事件隔离 | 每个 Viewer 的事件系统完全独立,互不干扰 |
| 导航栏 ID 冲突 | v5 的导航栏按钮有默认 ID,多实例时注意避免 CSS ID 冲突 |
10.10.6 内存泄漏
最常见的两种内存泄漏模式:
模式一:事件监听器未清理
// 错误
function setupViewer() {const viewer = new Viewer({ container, panorama });document.addEventListener('resize', () => viewer.autoSize());// 如果 viewer 被 destroy 了,resize 监听器仍持有引用
}// 正确
function setupViewer() {const viewer = new Viewer({ container, panorama });const handleResize = () => viewer.autoSize();document.addEventListener('resize', handleResize);// 在销毁时清理外部事件const originalDestroy = viewer.destroy.bind(viewer);viewer.destroy = () => {document.removeEventListener('resize', handleResize);originalDestroy();};
}
模式二:SPA 路由切换时未销毁
// React / Vue 路由切换
useEffect(() => {const viewer = new Viewer({ container, panorama });return () => {viewer.destroy(); // 必须在路由离开时销毁};
}, []);
验证内存泄漏:Chrome DevTools → Performance → 录制一段全景图切换操作 → 检查 JS Heap 是否持续增长。
10.10.7 构建工具兼容性
| 问题 | 原因 | 解决 |
|---|---|---|
Cannot find module |
CJS/ESM 混用 | 确保 moduleResolution: 'bundler' 或 'node16';使用现代打包器(Vite/Webpack 5) |
| Three.js 重复打包 | 多个三方库各自引入 Three.js | 配置 resolve.alias 统一指向同一份 Three.js |
| CSS 加载失败 | 打包器未配置 CSS loader | Vite 天然支持 import '*.css';Webpack 需 css-loader + style-loader |
window is not defined |
SSR/SSG 环境(Next.js、Gatsby、Nuxt) | PSV 依赖 WebGL 和 DOM,无法在服务端运行;使用 'use client' 或 client:only 标记为客户端组件 |
Next.js 中的正确处理:
// app/page.js
'use client';import dynamic from 'next/dynamic';// 动态导入,禁止 SSR
const PanoramaViewer = dynamic(() => import('../components/PanoramaViewer'),{ ssr: false }
);export default function Page() {return <PanoramaViewer panorama="pano.jpg" />;
}
10.11 本章小结
本章是全系列教程的终章,聚焦于将 Photo-Sphere-Viewer 的知识转化为可部署的生产应用。以下是对本章核心要点的回顾。
10.11.1 核心要点回顾
| 模块 | 核心要点 |
|---|---|
| React 集成 | react-photo-sphere-viewer 快速上手,手动 useEffect 封装精细控制;通过 ref (useImperativeHandle) 暴露 Viewer 方法 |
| Vue 集成 | Vue 3 Composition API + onMounted/onBeforeUnmount 生命周期;usePhotoSphereViewer composable 实现逻辑复用 |
| TypeScript | 所有类型从 @photo-sphere-viewer/core 导入;PSV v5 事件系统基于 EventTarget,类型通过 events 命名空间获取 |
| 房产看房 | VirtualTourPlugin 管理房间节点;MarkersPlugin 标注 POI;MapPlugin 平面图导航;GyroscopePlugin 移动端交互 |
| 景区导览 | GalleryPlugin 缩略图导航;AutorotatePlugin 自动导览;CompassPlugin 方向指示;地图联动(PlanPlugin 真实地理 / MapPlugin 手绘地图) |
| 视频展厅 | EquirectangularVideoAdapter 视频适配;VideoPlugin 时间轴标记;ResolutionPlugin 多分辨率;StereoPlugin VR 立体 |
| 性能优化 | 按屏幕宽度选择分辨率;TilesAdapter 分块加载(> 8K 必备);pixelRatio ≤ 2;antialias=false;代码分割按需加载 |
| 构建部署 | Webpack/Vite 配置 manualChunks 拆分 Three.js 和 PSV;静态资源 CDN 托管;HTTPS 是陀螺仪和全屏的前置条件 |
| v4→v5 迁移 | 包名全部变更到 @photo-sphere-viewer/*;事件 API 由 uEvent → EventTarget;坐标 longitude/latitude → yaw/pitch;类型 XxxOptions → XxxConfig |
10.11.2 学习路径总结
从第 1 章到第 10 章,本教程覆盖了以下完整的学习路径:
概述与学习路线│▼
环境搭建 → Viewer 核心配置 ──→ 全景图类型与适配器│▼标记系统深度解析│▼插件体系与自定义开发│▼┌───────────────────┼───────────────────┐▼ ▼ ▼虚拟导览+画廊 视频全景+移动端 地图集成+辅助│ │ │└───────────────────┼───────────────────┘▼框架集成+实战项目+部署
10.11.3 后续学习方向
掌握了本教程的全部内容后,你可以向以下方向进一步深入:
| 方向 | 学习内容 |
|---|---|
| Three.js 深度 | 理解 PSV 底层的 Scene/Camera/Renderer 架构,开发自定义 shader 效果 |
| WebXR | 使用 StereoPlugin 的底层 WebXR API,实现原生 VR 头显支持而不依赖 Cardboard polyfill |
| 全景图制作 | 学习 equirectangular 全景图拍摄流程(节点云台 + 拼接 + 后期),从源头控制内容质量 |
| GIS 与全景融合 | 将 PlanPlugin 的能力与 PostGIS/GeoJSON 结合,构建大规模地理全景数据库 |
| WebCodecs + WebGL | 对 360° 视频实现帧级处理,如实时叠加字幕、虚拟植入 3D 对象 |
| 服务端渲染优化 | 为全景图 CDN 部署边缘函数(Edge Functions),实现动态分辨率选择和 WebP 格式转换 |
