使用css-transform实现更好的拖拽功能

4,506 阅读6分钟

拖拽功能是目前网页上一种非常常见的功能,例如“登录弹窗”的拖拽。本文将使用transform来实现这一功能。

一、拖拽的用户行为分析与原理解析

二、代码实现

三、总结

本文所涉及的案例可能会用到的一些必备的知识点:
1、JavaScript中的DOM2级事件绑定
2、正则的编写与匹配
3、获取元素计算后的样式的相关API
4、鼠标坐标的位置获取
5、ES6的模板字符串语法
6、另外,为了能够顺利使用到transform,读者可能还需要对CSS3的一些样式规则有些了解

因此,如果读者对以上这些知识点的了解还有欠缺,可以在在此之前捎带预习一下。

另外,本文配套的这个案例虽然采用的webpack构建运行,但核心代码与之无关。
如果读者不熟悉webpack的构建方式,也不用担心会看不懂代码。

文章内容难度:☆


一、拖拽的用户行为分析与原理解析

如果读者熟悉了这个过程并也熟知了其中的原理,可以忽略此部分

拖拽的整个过程大致可以使用此图来描述:


元素的上边距离页面顶部的距离值(以下简称“上边距离”)从Y(a)变成了Y(b),“左边距离”从X(a)变成了X(b),也即完成了元素的移动。

在整个的变化过程中,有这样的一个隐藏信息:鼠标相对于元素的坐标(distX, distY)在整个移动过程中是没有发生变化的,用图上的关系即可以表示为:cX(b) - X(b) = cX(a) - X(a) = distX,cY(b) - Y(b) = cY(a) - Y(a) = distY。那么,在整个移动过程中,元素的“上边距离”= 鼠标移动中任意时刻的Y坐标 - distY,“左边距离”= 鼠标移动中任意时刻的X坐标 - distX。那么怎么求出distX和distY呢?

我们在按下鼠标的那一刻,浏览器就会告诉我们鼠标的坐标(cX, cY),同时,我们也可以求出目标元素的“上边距离”(Y)和“左边距离”(X),这样distX = cX - X,distY = cY - Y。


二、代码实现

1.初始化工作

按照第一部分的分析,我们需要在按下鼠标的那一刻获取元素的“上边距离”和“左边距离”。在传统的采用【position: absolute】定位的实现方式中,这一步我们可以通过DOM的【offsetTop】和【offsetLeft】来分别获取它们的值。但既然我们采用transform的方式来实现,就不再使用这两个属性了。

我们首先设置元素的一些关键样式(部分UI样式已忽略):

.drag-box-translate3d{    
    transform: translate3d(0, 0, 1px);
    -moz-transform: translate3d(0, 0, 1px);
    -webkit-transform: translate3d(0, 0, 1px);
    will-change: transform;
    -moz-will-change: transform;
    -webkit-will-change: transform;
}
值得注意的是,我们采用translate3d的属性值并设置了z轴的值为1px,这样做的目的是强制浏览器使用GPU加速,从而获得更加流畅的体验。
判断浏览器是否启用GPU加速,可以在定位到该元素之后,查看元素的计算后的样式:transform的值是matrix还是matrix3d,显示为后者时,即表示已开启GPU加速。


如果我们使用【position: absolute】来实现,那么初始位置的X(a)和Y(a)分别以left和top的值来分别指定,但采用transform来实现时,我们就可以使用translateX和translateY来分别指定X(a)和Y(a)。在上面的CSS设置中,X(a)和Y(a)就被分别设置为0和0。

我们需要在代码中获取该元素的transform的计算后的值,代码如下:

export function getStyle(
   el,
   attr
){
   if( typeof window.getComputedStyle !== 'undefined' ){
       return window.getComputedStyle(el, null)[attr]
   }else if(typeof el.currentStyle !== 'undefiend' ){
       return el.currentStyle[attr]
   }
   return ''
}

2.绑定mousedown事件并获取distX和distY

我们准备一个独立的文件drag.matrix.js来编写我们的代码,用来实现模块化的编程。

我们首先定义一个模块内的全局变量用来承载需要绑定拖拽功能的元素。

/* 定义元素变量 */
let ELEMENT = null

再定义一个模块内的全局对象用来存储计算用到的各个距离与尺寸数据。

/* 定义距离尺寸的存储池 */
let E_SIZER = {}

mousedown事件的回调函数如下:

/** 
 * mousedown事件
 * @param {MouseEvent} evte 鼠标事件对象
 * @returns {undefined}
 **/ 
function bindMouseDownEvent(
    evte
){
    // 阻止冒泡
    evte.stopPropagation()
    // 阻止默认事件
    evte.preventDefault()

    // 解析matrix的正则
    let matrix3dReg = /^matrix3d\((?:[-\d.]+,\s*){12}([-\d.]+),\s*([-\d.]+)(?:,\s*[-\d.]+){2}\)/,
        matrixReg = /^matrix\((?:[-\d.]+,\s*){4}([-\d.]+),\s*([-\d.]+)\)$/
    // 获取解析后的transform样式属性值(计算后的样式)
    let matrix3dSourceValue = util.getStyle(
        evte.target, 
        'transform'
    )
    // 使用正则解析matrix
    let matrix3dArrValue = 
        matrix3dSourceValue.match( matrix3dReg ) || matrix3dSourceValue.match( matrixReg )
 
    // 记录鼠标点击时的坐标
    E_SIZER['clientX'] = evte.clientX
    E_SIZER['clientY'] = evte.clientY
    // 记录matrix解析后的translateX & translateY的值
    E_SIZER['targetX'] = matrix3dArrValue[1]
    E_SIZER['targetY'] = matrix3dArrValue[2]

    // 计算坐标边界巨鹿
    E_SIZER['distX'] = E_SIZER['clientX'] - E_SIZER['targetX']
    E_SIZER['distY'] = E_SIZER['clientY'] - E_SIZER['targetY']

    // 绑定mousemove事件
    document.addEventListener('mousemove', bindMouseMoveEvent, false)
}  

被设置了transform属性值为translate3d的元素,浏览器会将这个样式的属性值计算为matrix3d(...)的矩阵。


那么怎么获取到translateX和translateY的值呢?

这里提供两个正则,用来解析matrix或matrix3d的值并得到translateX和translateY的值:

/^matrix3d\((?:[-\d.]+,\s*){12}([-\d.]+),\s*([-\d.]+)(?:,\s*[-\d.]+){2}\)/
/^matrix\((?:[-\d.]+,\s*){4}([-\d.]+),\s*([-\d.]+)\)$/

这两个正则可以直接使用,例如:


有了以上的分析和知识储备,我们就可以在鼠标按下的那一刻,获取到元素的初始X(a)和Y(a)的值了,也即上述的【bindMouseDownEvent】函数。

3.绑定mousemove事件移动元素

mouseover事件的回调函数如下:

/** 
 * mousemove事件
 * @param {MouseEvent} evte 鼠标事件对象
 * @returns {undefined}
 **/ 
function bindMouseMoveEvent(
    evte
){
    evte.stopPropagation()
    evte.preventDefault()

    let moveX = evte.clientX - E_SIZER['distX']
    let moveY = evte.clientY - E_SIZER['distY']
 
    // 写入style
    ELEMENT.style.transform = 
    ELEMENT.style.mozTransform = 
    ELEMENT.style.webkitTransform = 
    `translate3d(${moveX}px, ${moveY}px, 1px)`
}  

如果读者对本文第一部分的分析理解了的话,对于这一段函数应该会比较容易理解了。我们只要将鼠标在移动中的坐标值“转换”到元素的身上,即可完成对元素的实时移动了。

我们需要将【bindMouseMoveEvent】绑定到document上,因为在快速移动过程中,鼠标实际上会移出元素,如果直接将该回调函数绑定到元素上,可能会导致移动过程异常终止。

4.绑定mouseup解除功能

mouseup事件的回调函数如下:

/** 
 * mouseup事件
 * @param {MouseEvent} evte 鼠标事件对象
 * @returns {undefined}
 **/ 
function bindMouseUpEvent(
    evte
){
    evte.stopPropagation()
    evte.preventDefault()

    document.removeEventListener('mousemove', bindMouseMoveEvent)
}  

我们需要将绑定到document上的mousemove回调事件函数移除。

5.初始化事件绑定

/** 
 * 绑定事件
 * @param {MouseEvent} evte 鼠标事件对象
 * @returns {undefined}
 **/
function initBindEvent(){
    // 绑定mousedown事件 
    ELEMENT.addEventListener('mousedown', bindMouseDownEvent, false)        
    // 绑定mouseup事件
    document.addEventListener('mouseup', bindMouseUpEvent, false)
}

同样的,我们需要将mouseup事件的回调函数绑定到document。

这样,我们我完成了拖拽功能的主体部分的开发工作,只要将其功能绑定到指定的元素上即可(可以访问文章尾部的github地址来体验)。‘


三、总结

今天我们使用transform对传统的使用position: absolute的拖拽功能进行了升级,避免了在页面元素在移动过程中的不断的回流重绘,从而提升了功能性能。

在不考虑对老旧浏览器的兼容的情况下,可以尽量地使用CSS来获取更优的用户体验。


本例的github:func-matirx-drag

期待点赞;不足之处,欢迎指出。

2019-09-20

知乎专栏:前端小知识