几分钟搞定一个跨窗口动画效果,原理送上!

原地址:twitter.com/_nonfigurat...

原作者简化版的仓库链接: bgstaal/multipleWindow3dScene: A quick example of how one can "synchronize" a 3d scene across multiple windows using three.js and localStorage (github.com)

本文将实现以该源码为基础,做核心原理的介绍。

该源码核心代码总共几百行,两个核心 js 文件有: WindowManager.js、main.js, 除此之外就依赖了 three.js这个库。

WindowManager.js

在 WindowManager 我们主要管理多个窗口应用,记住核心在于监听浏览器 Storage 事件来同步其他窗口的变化 。在 update 方法中不断更新自己窗口中形状的变化。那么如何更新这两个变化呢,我们通过 main.js 传入的winShapeChangeCallback 和 winChangeCallback 回调来控制,这样我们可以解耦出自定义的形状和窗口动画。

js 复制代码
/**
 * 窗口管理
 */
class WindowManager {
    /** object[] */
    #windows;
    // 总更新数
    #count;
    // 当前窗口形状的id
    #id;
    // 当前窗口的形状数据
    #winData;
    /** 某个窗口移动的回调 */
    #winShapeChangeCallback;
    /** 某个窗口被创建或关闭的回调 */
    #winChangeCallback;

	constructor() {
		let that = this;

		// localStorage 改变事件监听
		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) {
					// 一旦存在改变更新 update回调
					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;
		}
		else {
			let c = false;

			for (let i = 0; i < pWins.length; i++) {
				if (pWins[i].id != nWins[i].id) c = true;
			}

			return c;
		}
	}

	// 初始化
	init(metaData) {
		this.#windows = JSON.parse(localStorage.getItem("windows")) || [];

		// 唯一id标识
		this.#count = localStorage.getItem("count") || 0;
		this.#count++;
		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);
		this.updateWindowsLocalStorage();
	}

	// 得到位置信息
	getWinShape() {
		let shape = { x: window.screenLeft, y: window.screenTop, w: window.innerWidth, h: window.innerHeight };
		return shape;
	}

	// 找到windows数组中当前最新的id的索引 (因为顺序可能在切换中不一致)
	getWindowIndexFromId(id) {
		let index = -1;

		for (let i = 0; i < this.#windows.length; i++) {
			if (this.#windows[i].id == id) index = i;
		}

		return index;
	}

	// 更新窗口
	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;

			// 注意只更新我当前的形状的位置,其他形状会在其他窗口更新到localstorag,并同步更新到 storage 事件中
			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;
	}
}

export default WindowManager;

Main.js

在 Main.js 的核心在于监听 visibilitychange 事件来保证窗口在屏幕上,而不是后台中。这个时候我们需要初始化来确保更新当前窗口。比如按初始化顺序的setupScene、setupWindowManager、resize、updateWindowShape、render,但是这里还是注意核心的render渲染方法。

在render方法中我们要确保所有窗口时间的同步,因为会影响到动画的同步更新。同时通过平滑因子 falloff 来控制强度。其他就是窗口移动的动画、多个cube的旋转偏移动画更新。

除此之外,由于每个窗口都保留了一份windows的状态数据,这就意味着你在任意一个窗口在控制台手动删除确实有用,但是一旦你开启另一个窗口数据又会同步到你这个被清理的窗口。所以作者在这里判断了查询字符串 clear = true 的时候做同步的清理windows。

我们可以看下localstorage中保存的windows状态数据:

Main.js 源码:

js 复制代码
import WindowManager from './WindowManager.js'

const t = THREE;
let camera, scene, renderer, world;
let near, far;
let pixR = window.devicePixelRatio ? window.devicePixelRatio : 1;
let cubes = [];
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 internalTime = getTime();
let windowManager;
let initialized = false;

// 从一天开始以秒为单位的时间(以便所有Windows使用相同的时间)
function getTime() {
	return (new Date().getTime() - today) / 1000.0;
}

// 查询字符串有clear= true 就清空( 因为多个窗户都有一个备份)
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;

		// add a short timeout because window.offsetX reports wrong values before a short period 
		setTimeout(() => {
			// 基础的three配置
			setupScene();
			// 初始化窗口管理
			setupWindowManager();
			// 第一次窗口适配
			resize();

			// 更新窗口形状
			updateWindowShape(false);
			render();

			// 监听窗口变化
			window.addEventListener('resize', resize);
		}, 500)
	}

	function setupScene() {
		camera = new t.OrthographicCamera(0, 0, window.innerWidth, window.innerHeight, -10000, 10000);

		camera.position.z = 2.5;
		near = camera.position.z - .5;
		far = camera.position.z + 0.5;

		scene = new t.Scene();
		scene.background = new t.Color(0.0);
		scene.add(camera);

		renderer = new t.WebGLRenderer({ antialias: true, depthBuffer: true });
		renderer.setPixelRatio(pixR);

		world = new t.Object3D();
		scene.add(world);

		renderer.domElement.setAttribute("id", "scene");
		document.body.appendChild(renderer.domElement);
	}

	function setupWindowManager() {
		windowManager = new WindowManager();
		windowManager.setWinShapeChangeCallback(updateWindowShape);
		// 传入要更新的cube回调
		windowManager.setWinChangeCallback(windowsUpdated);

		let metaData = { foo: "bar" };

		windowManager.init(metaData);

		windowsUpdated();
	}

	function windowsUpdated() {
		updateNumberOfCubes();
	}

	function updateNumberOfCubes() {
		let wins = windowManager.getWindows();

		// 移除所有cube
		cubes.forEach((c) => {
			world.remove(c);
		})

		cubes = [];

		// 循环创建cube
		for (let i = 0; i < wins.length; i++) {
			let win = wins[i];

			let c = new t.Color();
			c.setHSL(i * .1, 1.0, .5);

			let s = 100 + i * 50;
			let cube = new t.Mesh(new t.BoxGeometry(s, s, s), new t.MeshBasicMaterial({ color: c, wireframe: true }));
			cube.position.x = win.shape.x + (win.shape.w * .5);
			cube.position.y = win.shape.y + (win.shape.h * .5);

			world.add(cube);
			cubes.push(cube);
		}
	}

	function updateWindowShape(easing = true) {
		// winodw.screenX 取反得到正向偏移
		sceneOffsetTarget = { x: -window.screenX, y: -window.screenY };
		// 初始化直接相等  在world.position就不会有变化
		if (!easing) sceneOffset = sceneOffsetTarget;
	}


	function render() {
		let t = getTime();

		windowManager.update();

		// 根据新的偏移量和滑动因子的大小来做窗口移动阻尼的效果
		let falloff = 0.2;
		sceneOffset.x = sceneOffset.x + ((sceneOffsetTarget.x - sceneOffset.x) * falloff);
		sceneOffset.y = sceneOffset.y + ((sceneOffsetTarget.y - sceneOffset.y) * falloff);

		// 通过基类也就是object3d来控制当前视口的位置
		world.position.x = sceneOffset.x;
		world.position.y = sceneOffset.y;

		let wins = windowManager.getWindows();

		// 循环更新旋转cube
		for (let i = 0; i < cubes.length; i++) {
			let cube = cubes[i];
			let win = wins[i];
			let _t = t;// + i * .2;

			let posTarget = { x: win.shape.x + (win.shape.w * .5), y: win.shape.y + (win.shape.h * .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 * .5;
			cube.rotation.y = _t * .3;
		};

		renderer.render(scene, camera);
		requestAnimationFrame(render);
	}


	function resize() {
		let width = window.innerWidth;
		let height = window.innerHeight

		// 环绕控制,用于观察物体
		camera = new t.OrthographicCamera(0, width, 0, height, -10000, 10000);
		// 一旦窗口变化,更新相机的透视矩阵和渲染器屏幕尺寸
		camera.updateProjectionMatrix();
		renderer.setSize(width, height);
	}
}

效果:

总结

通过上文的代码解释,是不是感觉很简单,这个时候我们可以动手用 Three.js 对大量的cube改成大量的粒子模型,通过噪声来控制粒子的起伏动画,是不是很有意思?

如果你对可视化感兴趣,想要有长远的技术提升,不妨看一看这本最新的掘金小册 《前端可视化入门与实战》!前端可视化入门与实战 - 谦宇 - 掘金小册 (juejin.cn)

相关推荐
WeiXiao_Hyy16 分钟前
成为 Top 1% 的工程师
java·开发语言·javascript·经验分享·后端
吃杠碰小鸡33 分钟前
高中数学-数列-导数证明
前端·数学·算法
kingwebo'sZone38 分钟前
C#使用Aspose.Words把 word转成图片
前端·c#·word
xjt_09011 小时前
基于 Vue 3 构建企业级 Web Components 组件库
前端·javascript·vue.js
我是伪码农1 小时前
Vue 2.3
前端·javascript·vue.js
夜郎king2 小时前
HTML5 SVG 实现日出日落动画与实时天气可视化
前端·html5·svg 日出日落
辰风沐阳2 小时前
JavaScript 的宏任务和微任务
javascript
夏幻灵3 小时前
HTML5里最常用的十大标签
前端·html·html5
冰暮流星3 小时前
javascript之二重循环练习
开发语言·javascript·数据库
Mr Xu_3 小时前
Vue 3 中 watch 的使用详解:监听响应式数据变化的利器
前端·javascript·vue.js