要求做一个如图所示的放大镜,鼠标随动,右键开启关闭,含过渡动画。
大体的思路就是绑定鼠标事件,动态地修改background-position,难点在于background-position的计算要考虑周全。
开关动画部分我们选择用transform: scale来实现,相对于改width / height而言,transform做过渡时可以结合transform-origin解决圆心位移到左上角的问题。
性能优化方面,采用了一个简易节流函数对resize和mousemove事件进行了节流。另外,动态修改CSS时也视情况尽量做到了最简,保证不需要的修改不做。
细节体验方面,因为放大镜元素的cursor设为了none,故选择在放大镜关闭时将元素整体位移至视窗外(第二象限),解决了关闭放大镜后鼠标消失的问题。
首先需要做的是给一个fixed定位,让我们的放大镜脱离文档流,并相对于窗口进行定位。
放大镜有圆形边框,这里用border-radius:50%,并给内外侧都套上box-shadow。
transform: scale需要先给一个初始的0,否则之后在JS中初始化会触发一次动画。
开关时的过渡动画用transition: transform即可。
其他如border,width,height,transform,left,top,background-position等属性则全部通过JS操作。
CSS代码如下:
#magnifier {
position: fixed;
border-radius: 50%;
box-shadow: 0 0 4px 2px rgba(51, 51, 51, 0.2) inset,
0 0 4px 2px rgba(51, 51, 51, 0.3);
background-image: url("1.jpeg");
background-repeat: no-repeat;
transform: scale(0, 0);
transition: transform 0.15s ease-out;
cursor: none;
}
持续监听鼠标右键:contextmenu事件,用一个变量控制放大镜的开关,根据该变量的值来决定添加或移除mousemove监听器。
先定义配置项常量,RAD代表放大镜的半径,FPS设置每秒最大的事件触发次数(用于节流),BORDER为放大镜边框的宽度。日后如果要做成组件,这些都可以当做可配置的props传入。
const RAD = 200,
FPS = 125,
BORDER = 8;
再定义两个全局变量,toggled记录当前放大镜的开闭状态,mag为放大镜对应的DOM元素。
var toggled = false,
mag = null;
窗口加载时监听contextmenu,并初始化一些样式。
window.onload = function() {
document.addEventListener("contextmenu", handleRightClick);
mag = document.getElementById("magnifier");
Object.assign(mag.style, {
width: `${RAD * 2}px`,
height: `${RAD * 2}px`,
backgroundSize: `${window.innerWidth}px ${window.innerHeight}px`,
border: `${BORDER}px solid #fff`
});
};
下面是contextmenu回调的实现。要注意backgroundPosition的值是鼠标坐标取反,并且要把放大镜大小和边框宽度一并计算在内。
function handleRightClick(e) {
e.preventDefault();
if (toggled) {
// toggle off
document.removeEventListener("mousemove", handleMouseMove);
Object.assign(mag.style, {
left: `${-RAD * 2}px`,
top: `${-RAD * 2}px`,
transform: `scale(0, 0)`
});
} else {
// toggle on
document.addEventListener("mousemove", handleMouseMove);
const { clientX: x, clientY: y } = e;
Object.assign(mag.style, {
transform: "scale(1, 1)",
left: `${x - RAD}px`,
top: `${y - RAD}px`,
backgroundPosition: `${-x + RAD - BORDER}px ${-y + RAD - BORDER}px`
});
}
toggled = !toggled;
}
接下来是mousemove回调的实现。注意这里声明时就要用throttle包装,确保回调指向正确。
const handleMouseMove = throttle(function(e) {
const { clientX: x, clientY: y } = e;
Object.assign(mag.style, {
left: `${x - RAD}px`,
top: `${y - RAD}px`,
backgroundPosition: `${-x + RAD - BORDER}px ${-y + RAD - BORDER}px`
});
}, Math.floor(1000 / FPS));
再来写一个resize监听器,窗口大小改变时仅改变background-size即可,同样需要节流。因为这里只需要改一项属性,所以就不需要Object.assign了。
window.onresize = throttle(function() {
mag.style.backgroundSize = `${window.innerWidth}px ${window.innerHeight}px`;
}, Math.floor(1000 / FPS));
最后是throttle节流器的实现,闭包内只保存时间。这里之所以不做timeout型节流器是因为会造成多余的位移。
function throttle(fun, delay) {
let last = Date.now();
return function() {
let ctx = this,
args = arguments,
now = Date.now();
if (now - last > delay) {
fun.apply(ctx, args);
last = now;
}
};
}
timeout型节流器代码如下,在这个项目中并不适用。可以看到无论我们如何操作,它最后都会多执行一次回调,也就是timeout所触发的。这会使得放大镜的mousemove回调多进行一次,从而造成不可知的位移。
function throttle(fun, delay) {
let last = Date.now(),
timeout;
return function() {
let context = this,
args = arguments,
now = Date.now();
clearTimeout(timeout);
if (now - last > delay) {
fun.apply(context, arguments);
last = now;
} else {
timeout = setTimeout(() => {
fun.apply(context, args);
}, delay);
}
};
}
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Magnifer Testtitle>
<style>
* {
margin: 0;
padding: 0;
}
html,
body {
height: 100vh;
background: #0ff;
}
#magnifier {
position: fixed;
border-radius: 50%;
box-shadow: 0 0 4px 2px rgba(51, 51, 51, 0.2) inset,
0 0 4px 2px rgba(51, 51, 51, 0.3);
background-image: url("1.jpeg");
background-repeat: no-repeat;
transform: scale(0, 0);
transition: transform 0.15s ease-out;
cursor: none;
}
style>
head>
<body>
<div id="magnifier">div>
body>
<script src="index.js">script>
html>
const RAD = 200,
FPS = 125,
BORDER = 8;
var toggled = false,
mag = null;
function handleRightClick(e) {
e.preventDefault();
if (toggled) {
// toggle off
document.removeEventListener("mousemove", handleMouseMove);
Object.assign(mag.style, {
left: `${-RAD * 2}px`,
top: `${-RAD * 2}px`,
transform: `scale(0, 0)`
});
} else {
// toggle on
document.addEventListener("mousemove", handleMouseMove);
const { clientX: x, clientY: y } = e;
Object.assign(mag.style, {
transform: "scale(1, 1)",
left: `${x - RAD}px`,
top: `${y - RAD}px`,
backgroundPosition: `${-x + RAD - BORDER}px ${-y + RAD - BORDER}px`
});
}
toggled = !toggled;
}
const handleMouseMove = throttle(function(e) {
const { clientX: x, clientY: y } = e;
console.log({ x, y });
Object.assign(mag.style, {
left: `${x - RAD}px`,
top: `${y - RAD}px`,
backgroundPosition: `${-x + RAD - BORDER}px ${-y + RAD - BORDER}px`
});
}, Math.floor(1000 / FPS));
window.onload = function() {
document.addEventListener("contextmenu", handleRightClick);
mag = document.getElementById("magnifier");
Object.assign(mag.style, {
width: `${RAD * 2}px`,
height: `${RAD * 2}px`,
backgroundSize: `${window.innerWidth}px ${window.innerHeight}px`,
border: `${BORDER}px solid #fff`
});
};
window.onresize = throttle(function() {
Object.assign(mag.style, {
backgroundSize: `${window.innerWidth}px ${window.innerHeight}px`
});
});
function throttle(fun, delay) {
let last = Date.now();
return function() {
let ctx = this,
args = arguments,
now = Date.now();
if (now - last > delay) {
fun.apply(ctx, args);
last = now;
}
};
}