天天看点

JavaScript实现放大镜效果

需求分析

要求做一个如图所示的放大镜,鼠标随动,右键开启关闭,含过渡动画。

大体的思路就是绑定鼠标事件,动态地修改background-position,难点在于background-position的计算要考虑周全。

开关动画部分我们选择用transform: scale来实现,相对于改width / height而言,transform做过渡时可以结合transform-origin解决圆心位移到左上角的问题。

性能优化方面,采用了一个简易节流函数对resize和mousemove事件进行了节流。另外,动态修改CSS时也视情况尽量做到了最简,保证不需要的修改不做。

细节体验方面,因为放大镜元素的cursor设为了none,故选择在放大镜关闭时将元素整体位移至视窗外(第二象限),解决了关闭放大镜后鼠标消失的问题。

JavaScript实现放大镜效果

静态CSS部分

首先需要做的是给一个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;
      }
           

JS部分

持续监听鼠标右键: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部分

再来写一个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 & CSS代码

<!DOCTYPE html>
<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 Test</title>
    <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>

           

完整JS代码

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;
    }
  };
}

           

继续阅读