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

原地址: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)

相关推荐
别拿曾经看以后~35 分钟前
【el-form】记一例好用的el-input输入框回车调接口和el-button按钮防重点击
javascript·vue.js·elementui
我要洋人死38 分钟前
导航栏及下拉菜单的实现
前端·css·css3
川石课堂软件测试41 分钟前
性能测试|docker容器下搭建JMeter+Grafana+Influxdb监控可视化平台
运维·javascript·深度学习·jmeter·docker·容器·grafana
科技探秘人1 小时前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
科技探秘人1 小时前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome
JerryXZR1 小时前
前端开发中ES6的技术细节二
前端·javascript·es6
七星静香1 小时前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel
q2498596931 小时前
前端预览word、excel、ppt
前端·word·excel
小华同学ai1 小时前
wflow-web:开源啦 ,高仿钉钉、飞书、企业微信的审批流程设计器,轻松打造属于你的工作流设计器
前端·钉钉·飞书
problc1 小时前
Flutter中文字体设置指南:打造个性化的应用体验
android·javascript·flutter