最近,一则纯前端实现的“量子纠缠”效果视频在网络上迅速传播开来。视频中,作者在两个窗口中打开了相同的网页,然后在两个窗口中同时移动鼠标。
令人惊奇的是,两个窗口中的画面竟然同步移动。不少前端开发者表示:“前端白学了,这效果也太神奇了!”
视频作者开源了一个简化版的实现源码,该项目在 GitHub 上已获得超过 7.4k Star。
完整源码地址:https://github.com/bgstaal/multipleWindow3dScene
下面我们来对最主要的三个文件进行分析:
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<title>3d example using three.js and multiple windows</title>
<script type="text/javascript" src="./three.r124.min.js"></script>
<style type="text/css">
*
{
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<script type="module" src="./main.js"></script>
</body>
</html>
这段代码为具有多个窗口的 three.js
应用程序设置了基本结构。 main.js
文件将处理 3D 场景的实际实现和跨多个窗口的同步。
main.js
import WindowManager from './WindowManager.js'; // 导入 WindowManager 类
const THREE = t;
let camera, scene, renderer, world; // 声明相机、场景、渲染器和世界对象
let near, far; // 声明 near 和 far 平面距离
let pixR = window.devicePixelRatio ? window.devicePixelRatio : 1; // 获取像素密度
let cubes = []; // 创建空数组存放立方体对象
let sceneOffsetTarget = { x: 0, y: 0 }; // 定义场景偏移目标
let sceneOffset = { x: 0, y: 0 }; // 定义场景偏移量
// 获取自今日凌晨以来的时间(以确保所有窗口使用相同的时间)
function getTime() {
return (new Date().getTime() - today) / 1000.0;
}
// 检查 URL 中是否有 "clear" 参数,如果存在则清除 localStorage
if (new URLSearchParams(window.location.search).get('clear')) {
localStorage.clear();
} else {
// 添加事件监听器,当页面可见性发生变化时触发 init 函数
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'hidden' && !initialized) {
init();
}
});
// 添加 window.onload 事件监听器,当页面加载完成后触发 init 函数
window.onload = () => {
if (document.visibilityState !== 'hidden') {
init();
}
};
function init() { // 初始化函数
initialized = true; // 设置初始化标志位
// 添加短暂停,因为 window.offsetX 在短时间内会返回错误的值
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; // 设置相机位置
near = camera.position.z - 0.5; // 计算近平面距离
far = camera.position.z + 0.5; // 计算远平面距离
scene = new THREE.Scene(); // 创建场景
scene.background = new THREE.Color(0.0); // 设置场景背景颜色
scene.add(camera); // 将相机添加到场景中
renderer = new THREE.WebGLRenderer({ antialias: true, depthBuffer: true }); // 创建 WebGL 渲染器
renderer.setPixelRatio(pixR); // 设置像素密度
world = new THREE.Object3D(); // 创建世界对象
scene.add(world); // 将世界对象添加到场景中
renderer.domElement.setAttribute('id', 'scene'); // 设置渲染器 DOM 元素的 ID
document.body.appendChild(renderer.domElement); // 将渲染器 DOM 元素添加到 body 中
}
function setupWindowManager() { // 初始化窗口管理器函数
windowManager = new WindowManager(); // 创建窗口管理器实例
windowManager.setWinShapeChangeCallback(updateWindowShape); // 设置窗口形状变化回调函数
windowManager.setWinChangeCallback(windowsUpdated); // 设置窗口更改回调函数
// 添加自定义元数据到每个窗口实例
let metaData = { foo: 'bar' };
// 初始化窗口管理器并添加当前窗口
windowManager.init(metaData);
// 调用 windowsUpdated 函数来更新立方体数量
windowsUpdated();
}
function windowsUpdated() {
updateNumberOfCubes();
}
function updateNumberOfCubes() {
let wins = windowManager.getWindows(); // 获取当前窗口配置
// 删除所有现有立方体
cubes.forEach((c) => {
world.remove(c); // 从世界对象中移除立方体
});
cubes = []; // 重置立方
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 c = new THREE.Color();
c.setHSL(i * 0.1, 1.0, 0.5);
// 生成立方体
let cube = new THREE.Mesh(new THREE.BoxGeometry(100 + i * 50, 100 + i * 50, 100 + i * 50), new THREE.MeshBasicMaterial({ color: c, 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 updateWindowShape(easing = true) {
// 将场景偏移量设置为当前窗口的偏移量
sceneOffsetTarget = { x: -window.screenX, y: -window.screenY };
if (!easing) {
sceneOffset = sceneOffsetTarget; // 立即更新场景偏移量
}
}
function render() {
// 获取当前时间
let t = getTime();
// 更新窗口管理器
windowManager.update();
// 计算场景偏移量
let 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 < wins.length; i++) {
let cube = cubes[i];
let win = wins[i];
let _t = t; // + i * 0.2;
let posTarget = { x: win.shape.x + (win.shape.w * 0.5), y: win.shape.y + (win.shape.h * 0.5) };
cube.position.x = cube.position.x + ((posTarget.x - cube.position.x) * falloff);
cube.position.y = cube.position.y + ((posTarget.y - cube.position.y) * falloff);
cube.rotation.x = _t * 0.5;
cube.rotation.y = _t * 0.3;
}
// 渲染场景
renderer.render(scene, camera);
// 请求下一次渲染
requestAnimationFrame(render);
}
// 调整渲染器大小
function resize() {
let width = window.innerWidth;
let height = window.innerHeight;
camera = new THREE.OrthographicCamera(0, width, 0, height, -10000, 10000);
camera.updateProjectionMatrix();
renderer.setSize(width, height);
}
该代码使用 THREE.js 库来创建一个简单的场景,其中包含多个立方体。立方体的数量和位置由窗口管理器控制。窗口管理器负责跟踪所有打开的窗口,并根据每个窗口的大小和位置更新立方体的数量和位置。
setupScene()
函数设置场景,包括相机、场景、渲染器和世界对象。setupWindowManager()
函数初始化窗口管理器,并添加当前窗口到窗口管理器中。windowsUpdated()
函数更新立方体数量,根据当前窗口的数量。updateNumberOfCubes()
函数删除所有现有立方体,并添加新的立方体,数量与当前窗口数量相同。render()
函数渲染场景,包括计算场景偏移量、遍历所有窗口并更新立方体位置、渲染场景。resize()
函数调整渲染器大小,使其适应窗口大小。
WindowManager.js
class WindowManager
{
#windows; // 存储所有窗口信息的数组
#count; // 窗口计数器
#id; // 当前窗口的ID
#winData; // 当前窗口的数据
#winShapeChangeCallback; // 窗口形状更改回调函数
#winChangeCallback; // 窗口更改回调函数
constructor ()
{
let that = this;
// 监听 localStorage 变化事件,当其他窗口修改 localStorage 时触发
addEventListener("storage", (event) =>
{
if (event.key == "windows") // 判断是否为 "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); // 获取当前窗口在窗口列表中的索引
// 从窗口列表中删除当前窗口并更新 localStorage
that.#windows.splice(index, 1); // 删除窗口数据
that.updateWindowsLocalStorage(); // 更新窗口数据到 localStorage
});
}
// 检查窗口数据是否发生变化
#didWindowsChange (pWins, nWins)
{
if (pWins.length != nWins.length) // 窗口数量不相等
{
return true; // 窗口数据发生变化
}
else
{
let c = false; // 默认没有变化
for (let i = 0; i < pWins.length; i++) // 遍历所有窗口
{
if (pWins[i].id != nWins[i].id) c = true; // 窗口ID不相等
}
return c; // 如果存在窗口ID不一致,则窗口数据发生变化
}
}
// 初始化当前窗口,并为每个窗口存储自定义元数据
init (metaData)
{
this.#windows = JSON.parse(localStorage.getItem("windows")) || []; // 获取窗口数据,如果不存在则初始化为空数组
this.#count= localStorage.getItem("count") || 0; // 获取窗口计数器,如果不存在则初始化为0
this.#count++; // 窗口计数器加1
this.#id = this.#count; // 设置当前窗口ID
let shape = this.getWinShape(); // 获取当前窗口形状
this.#winData = {id: this.#id, shape: shape, metaData: metaData}; // 创建当前窗口数据对象
this.#windows.push(this.#winData); // 将当前窗口数据添加到窗口列表
localStorage.setItem("count", this.#count); // 更新窗口计数器到 localStorage
this.updateWindowsLocalStorage(); // 更新窗口数据到 localStorage
}
getWinShape ()
{
let shape = {x: window.screenLeft, y: window.screenTop, w: window.innerWidth, h: window.innerHeight}; // 获取当前窗口形状
return shape;
}
getWindowIndexFromId (id) // 查找指定ID的窗口索引
{
let index = -1; // 初始化索引为-1
for (let i = 0; i < this.#windows.length; i++) // 遍历所有窗口
{
if (this.#windows[i].id == id) index = i; // 如果窗口ID匹配,更新索引
}
return index; // 返回索引
}
updateWindowsLocalStorage () // 更新窗口数据到 localStorage
{
localStorage.setItem("windows", JSON.stringify(this.#windows)); // 将窗口数据转换为字符串并存储到 localStorage
}
update () // 更新当前窗口数据
{
let winShape = this.getWinShape(); // 获取当前窗口形状
// 检查窗口形状是否发生变化
if (winShape.x != this.#winData.shape.x ||
winShape
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; // 更新窗口列表中当前窗口的形状
//console.log(windows);
if (this.#winShapeChangeCallback) this.#winShapeChangeCallback(); // 调用窗口形状更改回调函数
this.updateWindowsLocalStorage(); // 更新窗口数据到 localStorage
}
}
setWinShapeChangeCallback (callback) // 设置窗口形状更改回调函数
{
this.#winShapeChangeCallback = callback;
}
setWinChangeCallback (callback) // 设置窗口更改回调函数
{
this.#winChangeCallback = callback;
}
getWindows () // 获取所有窗口
{
return this.#windows;
}
getThisWindowData () // 获取当前窗口数据
{
return this.#winData;
}
getThisWindowID () // 获取当前窗口ID
{
return this.#id;
}
}
export default WindowManager;
WindowManager 类提供了以下方法:
init()
:初始化当前窗口,并添加自定义元数据getWinShape()
:获取当前窗口的形状getWindowIndexFromId()
:获取指定 ID 的窗口在数组中的索引updateWindowsLocalStorage()
:更新 localStorage 中的窗口数据update()
:更新窗口形状setWinShapeChangeCallback()
:设置窗口形状变化回调函数setWinChangeCallback()
:设置窗口变化回调函数getWindows()
:获取所有窗口数据getThisWindowData()
:获取当前窗口数据getThisWindowID()
:获取当前窗口的 ID
在一个立方体展示应用程序中,作者使用了以下几种方式来跟踪所有打开的窗口,并根据每个窗口的大小和位置更新立方体的数量和位置:
- 使用
window.screenLeft
、window.screenTop
、window.innerWidth
和window.innerHeight
属性来计算每个窗口的形状。 - 使用
localStorage
来保存每个窗口的形状信息。 - 当有新的窗口打开时,将其形状信息添加到
localStorage
中。 - 每隔一段时间,每个窗口都会从
localStorage
中获取所有窗口的形状信息,并根据这些信息更新立方体的数量和位置。
这种方法可以有效地跟踪所有打开的窗口,并确保每个窗口都看到相同的立方体数量和位置。当窗口的位置,即screenTop
、screenLeft
发生变化时,就更新立方体。