实现CSS动画中,遇到的几个问题

出现这些问题,主要是我想要实现一个更通用的 FLIP 动画,FLIP 这里就不赘述了。我遇到的困扰,主要是怎么记录元素状态。在做变换动画时候,希望用 transform 动画,取代会引起重绘元素属性修改,例如位置信息适用 translate 替代,长宽变化适用 scale 替代。这时候就需要获取元素信息。在这个过程中,我遇到了不少问题。越搞越觉得 MDN 上写的太简单了。尤其是我用到的 getBoundingClientRect 和 getComputedStyle API,问题一言难尽,倒也不是 MDN 刻意写简单了,里面涉及的问题本身就比较杂。

getBoundingClientRect 注意事项

getBoundingClientRect 可以获取元素相对视口的位置关系(DOMRect)。这样位置反转的思路就很简单,只需要获取开始结束的 (x/left, y/top),计算差值,使用 translate 进行反转即可。

实际使用中发现 DOMRect 存在几个问题。首先要明确,它计算的是相对视口的位置关系。用以下 DOM 结构进行示范。

xml 复制代码
<style>
    html,
    body {
        height: 100%;
    }
    body {
        margin: 0;
    }
    .wrapper {
        height: 100%;
        overflow-y: scroll;
    }
    .box {
        width: 300px;
        height: 400px;
        margin: auto;
    }
    #box {
        background-color: palegreen;
        margin-top: 100px;
    }
</style>
​
<div class="wrapper">
    <div id="box" class="box"></div>
</div>

获取此时 div#box 的 DOMRect,可以看到 y: 100。如果设置 wrapper.scrollTop = 200,就会发现 y 变成了 -100。明确了这一点,就需要注意一种情况,变换中元素相对视口位置,发生了意外的变化。

在 div#box 前插入一个 <div id="dummy" class="box"></div>,在一秒钟后移除,重新计算 rect,看看效果。

ini 复制代码
 setTimeout(() => {
    const dummy = document.querySelector('#dummy');
    dummy.remove()
    const rect = box.getBoundingClientRect();
    console.log('Box Rect:', rect);
}, 1000);

这里我们没有对 div#box 进行定位操作,rect 的 y 值依旧从 500 变到了 100。这种情况可以使用 FLIP,也可以不用,完全取决于动画变现目的。多个元素的移动,只对一个元素做动画,具有一定的引导效果。

接下来的问题就比较麻烦了,DOMRect 是受 transform 影响的

都知道一个 3 4 5 的直角三角形,每个角大概是 37、53 和 90。给 div#box 添加 rotate(53deg),查看此时的 DOMRect。会发现 width=499.9986877441406,大致是矩形对角线宽度。y=59.84166717529297,对于这种直角三角形,底边上的高长度应该是 240,原本中心到顶部位置是 200,差了 40,也是差不多的。

css 复制代码
#box {
    ...
    transform: rotate(53deg);
}

这也就说明,DOMRect 计算的是进行变换后的元素相对视口的的信息。听起来很合理,但这意味着我们需要将变换效果纳入考量。不能直接使用 DOMRect 中的 x 和 y 计算位置信息,更不能使用 width 和 height 计算内容区宽高相对关系。

所以我们需要元素的 css 信息。

getComputedStyle

getComputedStyle 是 Window 下面的一个方法,返回对应元素的所有 css 属性。它有两个参数,第一个参数 element 是目标元素,第二个参数 pseudoElt 可选,可传入伪元素选择器的字符串来匹配伪元素。属性值会实时同步,获取时进行计算。

transfrom 会影响 DOMRect 计算,移除 transform 不就可以正常计算了嘛。但是在移除前,需要保存原来的 transform,反转时需要应用原来的 transform。上面说过,DOMRect 的宽高不能直接用,也要用 css 属性。

arduino 复制代码
const cssComputedStyle = window.getComputedStyle(box);
console.log(cssComputedStyle.width); // 300px
box.style.width = '500px'; 
console.log(cssComputedStyle.width); // 500px 可以看到是同步的

需要注意几点,css 同一属性可能有多种写法,getComputedStyle 返回的值是被统一格式化的,和我们目标相关的主要是 transformtransform-origin。transform 返回的 matrix 格式的字符串,transform-origin 返回的是 offset 格式的。示例代码中 div#box 返回结果如下:

javascript 复制代码
const cssComputedStyle = window.getComputedStyle(box);
cssComputedStyle.getPropertyValue('transform'); // "matrix(0.601815, 0.798636, -0.798636, 0.601815, 0, 0)"
cssComputedStyle.getPropertyValue('transform-origin'); // "150px 200px"

transform-origin 默认值是 center,也是元素x、y轴中心点。这里元素宽度是 300px,一半也就是 150px,高度同理。需要注意的是,transform-origin 是按照元素未做变形的形状确定的。给 div#box 添加 scale(0.5),可以看到 transform-origin 依旧是 "150px 200px"。也很好理解,transform-origin 是变换的原点,要先有这个点才能变换,不变也是合理的。

transform 要复杂一点,返回的值是 matrix,如果是 3D 变换,就是 matrix3d。通常我们使用的都是单独的 transform 函数,例如 scale、translate 等,遇到 matrix 会有一点懵。首先要明白线性代数中,矩阵如果计算,简单说就是行乘列。简单举几个例子:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> [ 1 0 o f f s e t X 0 1 o f f s e t Y ] [ o l d X o l d Y 1 ] = [ o l d X + o f f s e t X o l d Y + o f f s e t Y ] \begin{bmatrix} 1 & 0 & offsetX \\ 0 & 1 & offsetY \end{bmatrix} \begin{bmatrix} oldX \\ oldY \\ 1 \end{bmatrix} = \begin{bmatrix} oldX + offsetX \\ oldY + offsetY \end{bmatrix} </math>[1001offsetXoffsetY] oldXoldY1 =[oldX+offsetXoldY+offsetY]

上面就是 translate 的计算过程,分解看第一行乘第一列,1 \times oldX + 0 \times oldY + offsetX \times 1 = oldX + offsetX,第二行乘第一列同理。transform 可以添加多种变换,最终都可以转换成这种矩阵形式。对于单独的变换只需要用上面这种一个 2 x 3 表示变换方式,加一个一维的位置矩阵即可完成变换。多个变换,需要累乘多个矩阵,所以就填充成 3 x 3 矩阵。这样 3 x 3 矩阵相乘,结果还是 3 x 3 矩阵。

scale(x,y) scaleX(x) scaleY(y) translateX(x) translateY(y) translate(x, y)
<math xmlns="http://www.w3.org/1998/Math/MathML"> [ x 0 0 0 y 0 0 0 1 ] \begin{bmatrix} x & 0 & 0 \\ 0 & y & 0 \\ 0 & 0 & 1 \\ \end{bmatrix} </math> x000y0001 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ x 0 0 0 1 0 0 0 1 ] \begin{bmatrix} x & 0 & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \\ \end{bmatrix} </math> x00010001 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ 1 0 0 0 y 0 0 0 1 ] \begin{bmatrix} 1 & 0 & 0 \\ 0 & y & 0 \\ 0 & 0 & 1 \\ \end{bmatrix} </math> 1000y0001 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ 1 0 x 0 1 0 0 0 1 ] \begin{bmatrix} 1 & 0 & x \\ 0 & 1 & 0 \\ 0 & 0 & 1 \\ \end{bmatrix} </math> 100010x01 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ 1 0 0 0 1 y 0 0 1 ] \begin{bmatrix} 1 & 0 & 0 \\ 0 & 1 & y \\ 0 & 0 & 1 \\ \end{bmatrix} </math> 1000100y1 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ 1 0 x 0 1 y 0 0 1 ] \begin{bmatrix} 1 & 0 & x \\ 0 & 1 & y \\ 0 & 0 & 1 \\ \end{bmatrix} </math> 100010xy1
skewX(x) skewY(x) skew(x,y) rotete( <math xmlns="http://www.w3.org/1998/Math/MathML"> θ \theta </math>θ)
<math xmlns="http://www.w3.org/1998/Math/MathML"> [ 1 tan ⁡ x 0 0 1 0 0 0 1 ] \begin{bmatrix} 1 & \tan x & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \\ \end{bmatrix} </math> 100tanx10001 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ 1 0 0 tan ⁡ y 1 0 0 0 1 ] \begin{bmatrix} 1 & 0 & 0 \\ \tan y & 1 & 0 \\ 0 & 0 & 1 \\ \end{bmatrix} </math> 1tany0010001 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ 1 tan ⁡ x 0 tan ⁡ y 1 0 0 0 1 ] \begin{bmatrix} 1 & \tan x & 0 \\ \tan y & 1 & 0 \\ 0 & 0 & 1 \\ \end{bmatrix} </math> 1tany0tanx10001 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ cos ⁡ θ − sin ⁡ θ 0 sin ⁡ θ cos ⁡ θ 0 0 0 1 ] \begin{bmatrix} \cos \theta & -\sin \theta & 0 \\ \sin \theta & \cos \theta & 0 \\ 0 & 0 & 1 \\ \end{bmatrix} </math> cosθsinθ0−sinθcosθ0001

以上就是所有的变形矩阵,想要看细节可以看这里

这里单独解释一下旋转,旋转很难搞明白。其他的变换都是复合变换,有一个 x 轴,有一个 y 轴变换,单独拆分看都不麻烦。其实旋转也一样,把旋转拆分成 x、y 轴两种。假设有点 (x, y),可以拆分成 x 轴上的向量 (x, 0),和 y 轴上向量 (0, y)。注意,前端的旋转是顺时针旋转,但是 y 轴是向下的。我们以前做题的坐标系,y 轴是向上的,对应过来旋转就应该是逆时针旋转。(x, 0) 旋转后应该是 (xcos <math xmlns="http://www.w3.org/1998/Math/MathML"> θ \theta </math>θ, xsin <math xmlns="http://www.w3.org/1998/Math/MathML"> θ \theta </math>θ)。(0, y)旋转后回到第二象限,最终是 (-ysin <math xmlns="http://www.w3.org/1998/Math/MathML"> θ \theta </math>θ, ycos <math xmlns="http://www.w3.org/1998/Math/MathML"> θ \theta </math>θ)。旋转后点坐标就应该是,两个的 x 坐标与 y 坐标分别相加 (xcos <math xmlns="http://www.w3.org/1998/Math/MathML"> θ \theta </math>θ - ysin <math xmlns="http://www.w3.org/1998/Math/MathML"> θ \theta </math>θ, xsin <math xmlns="http://www.w3.org/1998/Math/MathML"> θ \theta </math>θ + ycos <math xmlns="http://www.w3.org/1998/Math/MathML"> θ \theta </math>θ)。根据矩阵乘法,变换矩阵也就是上面的样子。

简单变换中有如下对应关系, matrix(a, b, c, d, tx, ty) = matrix(sclaeX, skewY, skewX, scaleY, translateX, translateY)。这里旋转角度可以根据 a、b 的值确定。假设旋转角度是 θ,a = cosθ,b = sinθ,这样就可以使用 Math.atan2(b, a) 计算角度,Math.atan2 返回的是弧度,可以转成角度。

上述的方法,是你在网上最容易查到的信息。不能说完全错,但是要注意上述方法,只适用简单的变换!!! 对于多个变换,要进行矩阵累乘,矩阵乘法并不适用交换律。旋转和缩放的复合过程,有两种方式,第一种,先旋转,后缩放,计算如下:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> [ cos ⁡ θ − sin ⁡ θ 0 sin ⁡ θ cos ⁡ θ 0 0 0 1 ] [ x 0 0 0 y 0 0 0 1 ] = [ x cos ⁡ θ − y sin ⁡ θ 0 x sin ⁡ θ y cos ⁡ θ 0 0 0 1 ] \begin{bmatrix} \cos \theta & -\sin \theta & 0 \\ \sin \theta & \cos \theta & 0 \\ 0 & 0 & 1 \\ \end{bmatrix} \begin{bmatrix} x & 0 & 0 \\ 0 & y & 0 \\ 0 & 0 & 1 \\ \end{bmatrix} = \begin{bmatrix} x\cos \theta & -y\sin \theta & 0 \\ x\sin \theta & y\cos \theta & 0 \\ 0 & 0 & 1 \\ \end{bmatrix} </math> cosθsinθ0−sinθcosθ0001 x000y0001 = xcosθxsinθ0−ysinθycosθ0001

反之,先缩放后旋转,则是这种:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> [ x 0 0 0 y 0 0 0 1 ] [ cos ⁡ θ − sin ⁡ θ 0 sin ⁡ θ cos ⁡ θ 0 0 0 1 ] = [ x cos ⁡ θ − x sin ⁡ θ 0 y sin ⁡ θ y cos ⁡ θ 0 0 0 1 ] \begin{bmatrix} x & 0 & 0 \\ 0 & y & 0 \\ 0 & 0 & 1 \\ \end{bmatrix} \begin{bmatrix} \cos \theta & -\sin \theta & 0 \\ \sin \theta & \cos \theta & 0 \\ 0 & 0 & 1 \\ \end{bmatrix} = \begin{bmatrix} x\cos \theta & -x\sin \theta & 0 \\ y\sin \theta & y\cos \theta & 0 \\ 0 & 0 & 1 \\ \end{bmatrix} </math> x000y0001 cosθsinθ0−sinθcosθ0001 = xcosθysinθ0−xsinθycosθ0001

对于这两种方式,计算旋转角度的方式完全不同。第一种变换方式,尚且适用我们前面说的变换方式,a、b 分别对应 x <math xmlns="http://www.w3.org/1998/Math/MathML"> cos ⁡ θ \cos \theta </math>cosθ 和 x <math xmlns="http://www.w3.org/1998/Math/MathML"> sin ⁡ θ \sin \theta </math>sinθ,计算后 x 会被消掉。第二种方法,则是完全行不通。

在实际代码中,也能看到,两种变换结果完全不同。遗憾的是,我们不能规定如何使用 transform 函数。各种变换后,我也没能找到规律去处理,这个问题目前就只能先放下。好处是我对 transform 的理解更深了一点,虽然用处不好。另外,万幸我要做的动画效果没有那么复杂的复合关系,简化了一些处理最终还是能用。

相关推荐
光影少年8 分钟前
vue3.0性能提升主要通过那几方面体现的?
前端·vue.js
小磊哥er20 分钟前
【前端工程化】前端开发中的这些设计规范你知道吗
前端
江城开朗的豌豆21 分钟前
路由守卫:你的Vue路由‘保安’,全局把关还是局部盯梢?
前端·javascript·vue.js
Jinxiansen021129 分钟前
Vue 3 响应式核心源码详解(基于 @vue/reactivity)
前端·javascript·vue.js
OEC小胖胖5 小时前
去中心化身份:2025年Web3身份验证系统开发实践
前端·web3·去中心化·区块链
vvilkim6 小时前
Electron 进程间通信(IPC)深度优化指南
前端·javascript·electron
ai小鬼头8 小时前
百度秒搭发布:无代码编程如何让普通人轻松打造AI应用?
前端·后端·github
漂流瓶jz8 小时前
清除浮动/避开margin折叠:前端CSS中BFC的特点与限制
前端·css·面试
前端 贾公子8 小时前
在移动端使用 Tailwind CSS (uniapp)
前端·uni-app
散步去海边8 小时前
Cursor 进阶使用教程
前端·ai编程·cursor