【JavaScript】利用`localStorage`实现多窗口数据交互同步【附完整源码】
3D多窗口交互系统:点线虚线与引力场效果详解
一、项目概述
这个项目创建了一个3D
多窗口交互系统,当用户打开多个窗口时,会在每个窗口中显示旋转的3D
立方体,并在两个窗口之间生成动态的引力场效果线。这些引力场线以点状虚线呈现,并带有垂直于主线的短线装饰。
1. 打开一个网页的效果如下:
1. 打开两个网页的效果如下(网页重合时):
1. 打开两个网页的效果如下(网页不重合时):
二、核心功能解析
1. 多窗口管理机制
1.1 WindowManager类
这个类是项目的核心控制器,负责管理所有关联窗口的状态:
class WindowManager {#windows; // 存储所有窗口数据#count; // 窗口计数器#id; // 当前窗口ID#winData; // 当前窗口数据#winShapeChangeCallback; // 窗口形状变化回调#winChangeCallback; // 窗口数量变化回调
}
1.2 窗口同步原理
localStorage
:所有窗口共享的存储空间storage
事件监听:当任何窗口更新数据时,其他窗口会收到通知beforeunload
事件:窗口关闭时自动清理自身数据
2. 3D场景搭建
2.1 Three.js基础设置
function setupScene() {// 使用正交相机(适合2D/3D混合场景)camera = new THREE.OrthographicCamera(0, 0, window.innerWidth, window.innerHeight, -10000, 10000);// 创建场景并设置黑色背景scene = new THREE.Scene();scene.background = new THREE.Color(0.0);// 创建WebGL渲染器renderer = new THREE.WebGLRenderer({antialias: true, depthBuffer: true});// 创建世界容器(方便整体移动)world = new THREE.Object3D();scene.add(world);
}
2.2 立方体创建逻辑
每个窗口对应一个彩色线框立方体:
let color = new THREE.Color();
color.setHSL(i * 0.1, 1.0, 0.5); // 使用HSL色彩空间,按窗口顺序分配颜色let cube = new THREE.Mesh(new THREE.BoxGeometry(size, size, size), new THREE.MeshBasicMaterial({color: color, wireframe: true})
);
3. 引力场效果实现
3.1 引力场主曲线
// 创建点状虚线效果
const mainMaterial = new THREE.LineDashedMaterial({color: color,dashSize: 3, // 虚线段的长度gapSize: 5, // 间隔的长度linewidth: 2,transparent: true,opacity: 0.8
});
3.2 动态曲线路径
根据窗口距离计算曲线强度:
const strength = Math.min(1, 2000 / distance); // 距离越近强度越大// 创建波浪形曲线路径
const curveAmount = 50 * (1 - strength) * Math.sin(t * Math.PI);
mainPoints.push(new THREE.Vector3(x + curveAmount * Math.cos(t * Math.PI * 4),y + curveAmount * Math.sin(t * Math.PI * 4),0
));
3.3 顶点装饰短线
// 计算垂直于曲线的方向
const tangentX = nextPoint.x - point.x;
const tangentY = nextPoint.y - point.y;
const normalX = -tangentY / tangentLength; // 法线向量X
const normalY = tangentX / tangentLength; // 法线向量Y// 创建垂直于主线的短线
vertexPoints.push(new THREE.Vector3(point.x - normalX * vertexLineLength,point.y - normalY * vertexLineLength,0
));
三、关键技术点详解
1. 窗口位置同步
1.1 实时更新机制
function updateWindowShape(easing = true) {// 计算场景偏移目标值(使所有窗口内容在全局坐标系中正确对齐)sceneOffsetTarget = {x: -window.screenX, y: -window.screenY};if (!easing) sceneOffset = sceneOffsetTarget;
}
1.2 平滑过渡效果
// 使用缓动算法实现平滑移动
const falloff = 0.05;
sceneOffset.x = sceneOffset.x + ((sceneOffsetTarget.x - sceneOffset.x) * falloff);
sceneOffset.y = sceneOffset.y + ((sceneOffsetTarget.y - sceneOffset.y) * falloff);
2. 立方体动画
cube.rotation.x = time * 0.5; // 绕X轴旋转
cube.rotation.y = time * 0.3; // 绕Y轴旋转// 平滑移动到目标位置
cube.position.x = cube.position.x + (targetPos.x - cube.position.x) * falloff;
cube.position.y = cube.position.y + (targetPos.y - cube.position.y) * falloff;
3. 响应式设计
function resize() {// 根据窗口大小调整相机和渲染器const width = window.innerWidth;const height = window.innerHeight;camera = new THREE.OrthographicCamera(0, width, 0, height, -10000, 10000);renderer.setSize(width, height);
}
四、小白使用指南
1. 如何运行项目
- 将代码保存为
HTML
文件 - 右击选择在浏览器中打开该文件
- 复制地址栏
URL
并在新窗口中打开(至少打开2个窗口查看完整效果)
2. 调试技巧
- 添加
?clear
参数重置所有存储数据 - 按
F12
打开开发者工具查看控制台日志
3. 自定义修改建议
- 修改立方体样式:调整
BoxGeometry
参数和材质属性 - 改变引力场效果:修改
LineDashedMaterial
的dashSize/gapSize - 添加更多交互:在
render
函数中加入鼠标/键盘交互逻辑
五、总结与扩展
展示了如何利用现代Web
技术创建复杂的多窗口交互体验。关键创新点包括:
- 使用
localStorage
实现跨窗口通信 - 引用
Three.js
实现高性能3D渲染 - 利用点状虚线模拟引力场实现可视化
可扩展方向:
- 添加窗口碰撞检测和物理效果
六、【源代码】
<!DOCTYPE html>
<html lang="en">
<head><title>3D Multi-Window with Dot-Dashed Gravity Fields</title><script src="https://cdn.jsdelivr.net/npm/three@0.124.0/build/three.min.js"></script><style>* {margin: 0;padding: 0;}#scene {position: fixed;top: 0;left: 0;width: 100%;height: 100%;}</style>
</head>
<body><script>class WindowManager {#windows;#count;#id;#winData;#winShapeChangeCallback;#winChangeCallback;constructor() {let that = this;addEventListener("storage", (event) => {if (event.key == "windows") {let newWindows = JSON.parse(event.newValue);let winChange = that.#didWindowsChange(that.#windows, newWindows);that.#windows = newWindows;if (winChange) {if (that.#winChangeCallback) that.#winChangeCallback();}}});window.addEventListener('beforeunload', function(e) {let index = that.getWindowIndexFromId(that.#id);that.#windows.splice(index, 1);that.updateWindowsLocalStorage();});}#didWindowsChange(pWins, nWins) {if (pWins.length != nWins.length) return true;for (let i = 0; i < pWins.length; i++) {if (pWins[i].id != nWins[i].id) return true;}return false;}init(metaData) {this.#windows = JSON.parse(localStorage.getItem("windows")) || [];this.#count = localStorage.getItem("count") || 0;this.#count++;this.#id = this.#count;let shape = this.getWinShape();this.#winData = {id: this.#id, shape: shape, metaData: metaData};this.#windows.push(this.#winData);localStorage.setItem("count", this.#count);this.updateWindowsLocalStorage();}getWinShape() {return {x: window.screenLeft, y: window.screenTop, w: window.innerWidth, h: window.innerHeight};}getWindowIndexFromId(id) {for (let i = 0; i < this.#windows.length; i++) {if (this.#windows[i].id == id) return i;}return -1;}updateWindowsLocalStorage() {localStorage.setItem("windows", JSON.stringify(this.#windows));}update() {let winShape = this.getWinShape();if (winShape.x != this.#winData.shape.x ||winShape.y != this.#winData.shape.y ||winShape.w != this.#winData.shape.w ||winShape.h != this.#winData.shape.h) {this.#winData.shape = winShape;let index = this.getWindowIndexFromId(this.#id);this.#windows[index].shape = winShape;if (this.#winShapeChangeCallback) this.#winShapeChangeCallback();this.updateWindowsLocalStorage();}}setWinShapeChangeCallback(callback) {this.#winShapeChangeCallback = callback;}setWinChangeCallback(callback) {this.#winChangeCallback = callback;}getWindows() {return this.#windows;}getThisWindowData() {return this.#winData;}getThisWindowID() {return this.#id;}}// Main applicationconst THREE = window.THREE;let camera, scene, renderer, world;let pixR = window.devicePixelRatio ? window.devicePixelRatio : 1;let cubes = [];let gravityFields = [];let sceneOffsetTarget = {x: 0, y: 0};let sceneOffset = {x: 0, y: 0};let today = new Date();today.setHours(0);today.setMinutes(0);today.setSeconds(0);today.setMilliseconds(0);today = today.getTime();let windowManager;let initialized = false;function getTime() {return (new Date().getTime() - today) / 1000.0;}if (new URLSearchParams(window.location.search).get("clear")) {localStorage.clear();} else { document.addEventListener("visibilitychange", () => {if (document.visibilityState != 'hidden' && !initialized) {init();}});window.onload = () => {if (document.visibilityState != 'hidden') {init();}};function init() {initialized = true;setTimeout(() => {setupScene();setupWindowManager();resize();updateWindowShape(false);render();window.addEventListener('resize', resize);}, 500);}function setupScene() {camera = new THREE.OrthographicCamera(0, 0, window.innerWidth, window.innerHeight, -10000, 10000);camera.position.z = 2.5;scene = new THREE.Scene();scene.background = new THREE.Color(0.0);scene.add(camera);renderer = new THREE.WebGLRenderer({antialias: true, depthBuffer: true});renderer.setPixelRatio(pixR);world = new THREE.Object3D();scene.add(world);renderer.domElement.setAttribute("id", "scene");document.body.appendChild(renderer.domElement);}function setupWindowManager() {windowManager = new WindowManager();windowManager.setWinShapeChangeCallback(updateWindowShape);windowManager.setWinChangeCallback(windowsUpdated);let metaData = {foo: "bar"};windowManager.init(metaData);windowsUpdated();}function windowsUpdated() {updateNumberOfCubes();updateGravityFields();}function updateNumberOfCubes() {let wins = windowManager.getWindows();cubes.forEach((c) => {world.remove(c);});cubes = [];for (let i = 0; i < wins.length; i++) {let win = wins[i];let color = new THREE.Color();color.setHSL(i * 0.1, 1.0, 0.5);let size = 100 + i * 50;let cube = new THREE.Mesh(new THREE.BoxGeometry(size, size, size), new THREE.MeshBasicMaterial({color: color, wireframe: true}));cube.position.x = win.shape.x + (win.shape.w * 0.5);cube.position.y = win.shape.y + (win.shape.h * 0.5);world.add(cube);cubes.push(cube);}}function updateGravityFields() {gravityFields.forEach((field) => {world.remove(field);});gravityFields = [];let wins = windowManager.getWindows();if (wins.length === 2) {const win1 = wins[0];const win2 = wins[1];const pos1 = {x: win1.shape.x + (win1.shape.w * 0.5),y: win1.shape.y + (win1.shape.h * 0.5)};const pos2 = {x: win2.shape.x + (win2.shape.w * 0.5),y: win2.shape.y + (win2.shape.h * 0.5)};const dx = pos2.x - pos1.x;const dy = pos2.y - pos1.y;const distance = Math.sqrt(dx * dx + dy * dy);// 创建主引力场线(点状虚线)const mainPoints = [];const segments = 30;const strength = Math.min(1, 2000 / distance);const color = new THREE.Color();color.setHSL(0.7 * (1 - strength), 1.0, 0.5);// 创建主曲线路径for (let i = 0; i <= segments; i++) {const t = i / segments;const x = pos1.x + t * dx;const y = pos1.y + t * dy;const curveAmount = 50 * (1 - strength) * Math.sin(t * Math.PI);mainPoints.push(new THREE.Vector3(x + curveAmount * Math.cos(t * Math.PI * 4),y + curveAmount * Math.sin(t * Math.PI * 4),0));}// 主引力场线(点状虚线)const mainGeometry = new THREE.BufferGeometry().setFromPoints(mainPoints);const mainMaterial = new THREE.LineDashedMaterial({color: color,dashSize: 3,gapSize: 5,linewidth: 2,transparent: true,opacity: 0.8});const mainLine = new THREE.Line(mainGeometry, mainMaterial);mainLine.computeLineDistances();world.add(mainLine);gravityFields.push(mainLine);// 添加顶点虚线(从每个顶点向外辐射的短线)const vertexSegmentCount = 8;const vertexLineLength = 20 * strength;for (let i = 0; i <= segments; i += 2) {if (i === 0 || i === segments) continue; // 跳过端点const point = mainPoints[i];const nextPoint = mainPoints[i+1];// 计算切线方向const tangentX = nextPoint.x - point.x;const tangentY = nextPoint.y - point.y;const tangentLength = Math.sqrt(tangentX * tangentX + tangentY * tangentY);const normalX = -tangentY / tangentLength;const normalY = tangentX / tangentLength;// 创建垂直于曲线的短线const vertexPoints = [];vertexPoints.push(new THREE.Vector3(point.x - normalX * vertexLineLength,point.y - normalY * vertexLineLength,0));vertexPoints.push(new THREE.Vector3(point.x + normalX * vertexLineLength,point.y + normalY * vertexLineLength,0));const vertexGeometry = new THREE.BufferGeometry().setFromPoints(vertexPoints);const vertexMaterial = new THREE.LineDashedMaterial({color: color,dashSize: 1,gapSize: 2,linewidth: 1,transparent: true,opacity: 0.6});const vertexLine = new THREE.Line(vertexGeometry, vertexMaterial);vertexLine.computeLineDistances();world.add(vertexLine);gravityFields.push(vertexLine);}}}function updateWindowShape(easing = true) {sceneOffsetTarget = {x: -window.screenX, y: -window.screenY};if (!easing) sceneOffset = sceneOffsetTarget;}function render() {let time = getTime();windowManager.update();const falloff = 0.05;sceneOffset.x = sceneOffset.x + ((sceneOffsetTarget.x - sceneOffset.x) * falloff);sceneOffset.y = sceneOffset.y + ((sceneOffsetTarget.y - sceneOffset.y) * falloff);world.position.x = sceneOffset.x;world.position.y = sceneOffset.y;let wins = windowManager.getWindows();for (let i = 0; i < cubes.length; i++) {let cube = cubes[i];let win = wins[i];let targetPos = {x: win.shape.x + (win.shape.w * 0.5),y: win.shape.y + (win.shape.h * 0.5)};cube.position.x = cube.position.x + (targetPos.x - cube.position.x) * falloff;cube.position.y = cube.position.y + (targetPos.y - cube.position.y) * falloff;cube.rotation.x = time * 0.5;cube.rotation.y = time * 0.3;};if (wins.length === 2) {updateGravityFields();}renderer.render(scene, camera);requestAnimationFrame(render);}function resize() {const width = window.innerWidth;const height = window.innerHeight;camera = new THREE.OrthographicCamera(0, width, 0, height, -10000, 10000);camera.updateProjectionMatrix();renderer.setSize(width, height);}}</script>
</body>
</html>