多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

CSS transform与matrix全解析:坐标系、3D变换与透视原理

CSS transform与matrix全解析:坐标系、3D变换与透视原理 CSS 的transform大概是前端开发里最“反直觉”的属性之一。尤其是当你在代码里写下rotateY(60deg) translateZ(300px)这种组合时脑子里浮现的往往是一个画面元素到底飞去哪了它看起来是变大还是变小是往左偏还是往右偏更别提matrix(a, b, c, d, e, f)那一串数字一眼看过去就是高中数学卷子上密密麻麻的矩阵于是很多人选择绕道走能用rotate()、scale()实现就绝不碰矩阵。但我想说transform的本质就是坐标系变换它并不是“魔法”而是一套严谨的几何规则。只要你把坐标系、执行顺序、透视这几个概念捋清楚rotateY与translateZ的组合效果是可以精确预测的matrix()也只是一个参数化包装的 2D 变换矩阵弄懂六个参数的位置后你甚至可以手算任意复合变换的最终矩阵。这篇内容我打算从坐标系讲到矩阵再结合真实可复现的 CSS 代码把 3D 变换和matrix()一次讲透适合那些用过transform但没深究过原理、碰到 3D 翻转和矩阵就发怵的前端开发者、页面设计师以及所有想彻底驾驭 CSS 动画的人。1. 先搞清楚 transform 的本质坐标系才是主角1.1 坐标原点与变换基准很多人在写transform时默认以为它是“让元素移动/旋转一下”这个理解没错但不够底层。transform真正在做的事情是改变元素自身坐标系然后把元素绘制到新坐标系里。而坐标系的起点就是transform-origin指定的点默认是元素的几何中心也就是50% 50%。我们以一张 200x200px 的图片为例。它的局部坐标系 x 轴朝右、y 轴朝下、z 轴朝屏幕外。默认变换原点在图片中心。执行scale(0.5)图片会以中心为基准缩小到一半但如果改transform-origin: left top图片就会以左上角为锚点缩小视觉上会往左上角“缩”。这是一个很容易被忽略的基础点但后面讲rotateY和translateZ时你会回来感谢它的。另一个容易踩坑的概念是transform不会改变元素的布局尺寸它只改变视觉呈现。元素在文档流里占的位置还是原来那么大不会因为平移或者旋转而让周围元素让路。这一点在做 3D 卡片叠放时特别重要——你以为元素已经“转进去了”但它的兄弟元素仍然按照未变换前的布局位置排列。1.2 从左到右的执行顺序transform: rotateY(60deg) translateZ(300px)和transform: translateZ(300px) rotateY(60deg)这两行代码看着像只是换个顺序但最终效果完全不同。CSS 的规则是函数列表从左到右依次应用先执行的变换会改变坐标系后续的变换在改变后的坐标系里生效。我习惯用“走路”来类比。rotateY(60deg) translateZ(300px)就是先向右转身 60 度然后沿着你面向的方向走 300 步。整个过程你始终朝着新方向走。而translateZ(300px) rotateY(60deg)则是先沿着原来的方向向前走 300 步走到位置之后再原地转身 60 度。最终你站在哪里、面朝哪里结果显然不一样。在数学上这对应矩阵乘法。CSS 的每个变换函数都能写成矩阵多个函数连续执行等价于矩阵连乘而矩阵乘法不满足交换律所以变换顺序不能随意调整。这个视角在后面讲matrix()时会派上大用场。1.3 rotateY(60deg) translateZ(300px) 到底长什么样先直接给结论这一行代码会让元素绕垂直轴Y 轴向右旋转 60 度然后沿“旋转后的 Z 轴方向”平移 300px。注意这里的关键是“旋转后的 Z 轴”。CSS 默认坐标系的 Z 轴正方向是朝向观察者的也就是指向屏幕外。当元素绕 Y 轴旋转 60 度后整个局部坐标系也跟着转原本指向屏幕外的局部 Z 轴现在指向了“右前方”与屏幕平面的夹角是 30 度水平向右偏转 60 度。于是translateZ(300px)就是把元素沿着这个右前方方向推 300px。在屏幕上的投影可以拆成两个方向的分量水平向右位移300 * sin(60deg)约等于259.8px沿 Z 轴方向靠近观察者300 * cos(60deg)等于150px如果你给这个 transform 加了透视perspective那 150px 的 Z 向分量会让元素看起来略微变大因为它在靠近你同时元素的正面因为旋转了 60 度在屏幕上显示的宽度会被压缩近似等于原宽度乘以cos(60deg)也就是 50%。如果没加透视元素依然会有向右的水平位移和宽度压缩但不会有近大远小的体积感。所以整体视觉效果就是一面原本正对着你的“板子”先像门一样向右打开 60 度一边打开一边向它的正面朝向方向斜着移动了 300px。看起来既偏右、又有一点点朝向你的倾斜。2. 3D 变换的完整拼图透视与三维空间感2.1 perspective 是 3D 的灵魂你写了rotateY(60deg)但屏幕上一点 3D 感都没有多半是少了perspective。perspective的中文叫“透视”本质是视距也就是观察者眼睛到屏幕平面的距离。CSS 里设置perspective: 900px意思是假设你站在距元素 900px 远的地方观察。数值越小透视效果越强、近大远小的对比越夸张数值越大透视效果越弱越接近正交投影。使用透视有两种常见方式/* 方式一写在父容器上所有子元素的 3D 变换共享这个透视 */ .scene { perspective: 900px; } /* 方式二写在元素的 transform 列表最前面只作用于当前元素 */ .card { transform: perspective(900px) rotateY(60deg); }这两种方式视觉效果略有差别。父容器perspective会建立一个统一的透视投影空间所有子元素的视觉灭点一致写在transform里的perspective()则只对当前元素生效更适合单个元素独立做 3D 效果。在实际项目里卡片翻转、轮播封面这类场景我倾向于在父容器上设置perspective因为更容易统一控制场景深度感。常用视距值在 600px 到 1000px 之间900px 是比较舒服的默认值——既能看到明显的 3D 感又不会夸张失真。2.2 transform-style: preserve-3d 的隐藏作用设置transform-style: preserve-3d后子元素会保留它们各自的 3D 位置而不是被拍平到父元素的平面上。默认值是flat在这个默认状态下即便子元素写了translateZ()或rotateY()并且父元素有透视子元素的 3D 效果也会被压成平面投影。可以把flat想象成用一张玻璃把所有的 3D 空间“压扁”成一个平面子元素在 Z 轴上的深度信息全部丢失而preserve-3d就是撤掉这块玻璃让子元素真正悬浮在三维空间里。下图这个场景非常典型一个卡片容器里面正面和背面两张脸。如果你不写transform-style: preserve-3d背面那张脸即使设置了rotateY(180deg)也不会翻到真正属于“背面”的位置翻转效果会变得很奇怪。所以只要涉及嵌套的 3D 变换preserve-3d基本是必须项。2.3 backface-visibility控制背面显示3D 旋转最大的敌人是“背面穿帮”。当一个元素旋转超过 90 度观众会看到它的背面而默认情况下背面内容是镜像的看起来就是“反着的字”。backface-visibility: hidden可以解决这个问题。它表示当元素背面朝向观察者时不渲染该元素。这在实现双面卡片时非常关键——正面元素旋转到背面方向就隐藏背面元素预旋转 180 度放在相反方向这样翻转时正好交替显示。需要注意一点backface-visibility只在元素有 3D 变换时才生效。单纯用rotateX(0deg)或者平面旋转不会触发背面逻辑。2.4 用代码重现 rotateY(60deg) translateZ(300px) 的真实效果我做一个最小可复现的示例方便你直观看到这个变换在透视下的样子。div classscene div classbox/div /div.scene { width: 300px; height: 300px; margin: 80px auto; perspective: 900px; border: 1px dashed #ccc; display: flex; align-items: center; justify-content: center; } .box { width: 160px; height: 200px; background: linear-gradient(135deg, #6366f1, #8b5cf6); border-radius: 12px; transform: rotateY(60deg) translateZ(300px); }你可以把这段代码丢进浏览器里跑一下。加perspective的情况下紫色卡片会明显“翘”向右侧同时整体向右偏移并且由于向观察者靠近卡片看起来比无透视时略大一点。改动一下把 transform 换成translateZ(300px) rotateY(60deg)你会发现元素先沿原始 Z 轴靠近你 300px再原地绕 Y 轴旋转 60 度最终位置和朝向完全不同这就是顺序的威力。如果你手边没有浏览器也可以通过getComputedStyle(document.querySelector(.box)).transform查看浏览器返回的矩阵值它会告诉你浏览器内部计算出的最终矩阵是一个 3D 矩阵这就是验证自己理解的最佳工具。3. matrix() 矩阵变换从黑盒到可控3.1 六个参数分别是什么matrix()在很多人眼里是一串天书其实它只是 2D 变换矩阵的一种行主序写法。语法是matrix(a, b, c, d, e, f)它等价的数学矩阵是| a c e | | b d f | | 0 0 1 |一个 2D 平面上的点(x, y)经过这个矩阵变换后得到新坐标(x, y)计算方式如下x a * x c * y e y b * x d * y f这里每个参数都有自己的“初始职责”a和d分别控制水平缩放和垂直缩放b和c控制倾斜与旋转相关的分量e和f控制水平平移和垂直平移注意参数的排列顺序和矩阵中位置的对应关系。很多人以为matrix(1, 0, 0, 1, 10, 20)中第一个数是表达“横向”实际上第一个数a在矩阵里是左上角确实影响横向缩放但要严格按矩阵计算不能靠感觉猜。matrix(1, 0, 0, 1, 10, 20)的完整含义是水平缩放 1 倍垂直缩放 1 倍没有旋转和倾斜水平平移 10px垂直平移 20px。它就是translate(10px, 20px)的等价矩阵。再看旋转 45 度对应矩阵。旋转角度为 θ 时a cos(θ) b sin(θ) c -sin(θ) d cos(θ)所以rotate(45deg)等价于matrix(0.7071, 0.7071, -0.7071, 0.7071, 0, 0)我可以带你手算验证一个点假设元素右上角局部坐标是(100, 0)代入公式x 0.7071 * 100 (-0.7071) * 0 0 70.71 y 0.7071 * 100 0.7071 * 0 0 70.71点(100, 0)在旋转 45 度后变成(70.71, 70.71)在这个坐标系里它正好落在右下方 45 度位置符合几何直觉。3.2 缩放、倾斜、平移如何对应矩阵参数缩放scale(sx, sy)matrix(sx, 0, 0, sy, 0, 0)平移translate(tx, ty)matrix(1, 0, 0, 1, tx, ty)水平倾斜skewX(α)matrix(1, 0, tan(α), 1, 0, 0)垂直倾斜skewY(β)matrix(1, tan(β), 0, 1, 0, 0)旋转rotate(θ)matrix(cosθ, sinθ, -sinθ, cosθ, 0, 0)单看这些公式你可能觉得没必要因为日常写scale()、rotate()更直观。但matrix()真正的价值在于当多个基础变换叠加时它可以预先算出一个总矩阵从而只需要一次矩阵运算就完成全部变换减少浏览器在动画逐帧更新时的计算开销。假设你要实现一个“先放大 1.5 倍再顺时针旋转 30 度最后向右平移 100px、向下平移 50px”的复合变换。如果分开写.box { transform: scale(1.5) rotate(30deg) translate(100px, 50px); }这个顺序表示先缩放再旋转最后平移。用矩阵描述就是M T(100, 50) * R(30deg) * S(1.5)把具体值代入矩阵运算缩放矩阵 Sa1.5, d1.5旋转矩阵 Racos30≈0.866, bsin30≈0.5, c-0.5, d≈0.866旋转乘缩放后的前四个参数a1.5*0.866≈1.299, b1.5*0.5≈0.75, c1.5*(-0.5)≈-0.75, d1.5*0.866≈1.299最后乘平移矩阵e100, f50所以最终等价于.box { transform: matrix(1.299, 0.75, -0.75, 1.299, 100, 50); }你可以把这两段分别加上然后用浏览器检查元素视觉上不会有任何差别。这就是matrix()的简化价值把一串动画里的复合变换提前折叠成单个矩阵。3.3 matrix3d3D 世界里的完整矩阵matrix()只能表达 2D 变换但rotateY、translateZ这类 3D 变换需要用到 4x4 的matrix3d()。它的参数多达 16 个matrix3d(m11, m12, m13, m14, m21, m22, m23, m24, m31, m32, m33, m34, m41, m42, m43, m44)对应的 4x4 矩阵是| m11 m21 m31 m41 | | m12 m22 m32 m42 | | m13 m23 m33 m43 | | m14 m24 m34 m44 |这里我不展开讲 16 个参数全部含义只说一个常用片段绕 Y 轴旋转 60 度的matrix3d可以这样写matrix3d(0.5, 0, -0.866, 0, 0, 1, 0, 0, 0.866, 0, 0.5, 0, 0, 0, 0, 1)你可以拿它和rotateY(60deg)做对比浏览器计算出的最终矩阵就是这种形式。理解了这个结构你在调试时看到控制台输出的matrix3d就不会慌张它无非是浏览器把你的transform列表展开成了矩阵形式而已。日常开发很少手写matrix3d但知道它的存在能帮助你阅读getComputedStyle的返回值。3.4 实战在拖拽场景用 matrix 保存状态matrix()一个非常实用的场景是拖拽。比如用鼠标拖动元素每帧需要更新它的位移。如果你用transform: translate(x, y)每次都要记录当前的 x、y然后重新拼接字符串如果元素本身还有旋转拼字符串就很容易出错。更稳妥的做法是把元素初始变换用matrix()表示每次鼠标移动时把新的位移叠加进去。原理是当前矩阵等于“位移矩阵”乘以“上一帧矩阵”。假设上一帧矩阵是 M当前鼠标相对上一帧的位移是(dx, dy)那么新的矩阵是T(dx, dy) * M也就是说把 M 里面的e和f分别加上dx和dy就行前提是拖拽过程中元素没有再发生旋转或缩放。如果元素本身有旋转位移分量就不能直接加而需要把dx, dy先逆旋转再叠加。这时候手写矩阵才能做到逻辑清晰用字符串拼接会绕进一个巨大的坑里。我给一个简化示例const el document.getElementById(box); let currentMatrix new DOMMatrixReadOnly(getComputedStyle(el).transform); el.addEventListener(pointerdown, e { const startX e.clientX; const startY e.clientY; const original currentMatrix; const onMove ev { const dx ev.clientX - startX; const dy ev.clientY - startY; const offset new DOMMatrix().translate(dx, dy); const next offset.multiply(original); el.style.transform next.toString(); }; const onUp () { currentMatrix new DOMMatrixReadOnly(getComputedStyle(el).transform); window.removeEventListener(pointermove, onMove); window.removeEventListener(pointerup, onUp); }; window.addEventListener(pointermove, onMove); window.addEventListener(pointerup, onUp); });用原生DOMMatrixAPI比手拼矩阵字符串可靠得多。浏览器已经帮我们封装好了矩阵乘法new DOMMatrix().translate(dx, dy)生成位移矩阵multiply(original)做矩阵连乘最终把矩阵对象转成字符串赋给transform。这是matrix()相关内容在真实项目里最典型的应用之一。4. 实操从零写一个带 3D 翻转的卡片组件4.1 HTML 结构三层分隔先搭建最经典的 3D 翻转卡片结构div classscene div classcard div classface front h2正面/h2 p鼠标悬停或点击翻转/p /div div classface back h2背面/h2 p这里是更多内容/p /div /div /div三层结构的角色划分.scene透视容器负责提供 3D 空间感。.card旋转主体承载正面和背面两张脸。.face卡片的两面绝对定位铺满整个卡片区域。这种结构看起来简单却是最容易理解、也最不容易出 bug 的模板。4.2 CSS 代码从透视到翻转.scene { width: 240px; height: 340px; margin: 60px auto; perspective: 900px; } .card { position: relative; width: 100%; height: 100%; transform-style: preserve-3d; transition: transform 0.8s cubic-bezier(0.4, 0, 0.2, 1); } .card:hover { transform: rotateY(180deg); } .face { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; backface-visibility: hidden; border-radius: 16px; color: #fff; } .face.front { background: linear-gradient(135deg, #4f46e5, #7c3aed); } .face.back { background: linear-gradient(135deg, #0f766e, #14b8a6); transform: rotateY(180deg); }几个关键点拆开说。.scene的perspective: 900px是整个 3D 效果成立的前提没有它.card的rotateY(180deg)会变成平面上的左右翻转近大远小的体积感完全消失。.card的transform-style: preserve-3d保证两张.face在 3D 空间里各自占据真实位置。如果没有这一行子元素会被拍扁到卡片平面上翻转时两张脸会在同一个平面重叠交替甚至出现闪动。.face.back预先设置rotateY(180deg)是因为它要一开始就待在卡片背面。等到.card整体旋转到 180 度时背面那张脸的局部坐标恰好转回正面朝向观察者于是显示出来而.face.front在此时由于backface-visibility: hidden正好隐藏。整个动画自然流畅。4.3 扩展把 rotateY(60deg) translateZ(300px) 用进轮播封面卡片翻转是 3D 变换的入门但rotateY加translateZ的组合更常出现在封面轮播、图片墙这类场景。你可以把一张图片设置成transform: rotateY(60deg) translateZ(300px)再用一个父容器加perspective: 900px和preserve-3d这样多张图片可以在空间里围成一个弧形鼠标移动时每一张都保持不同的深度和角度。一个简化的双封面示例div classstage div classphoto styletransform: rotateY(-30deg) translateZ(200px)封面A/div div classphoto styletransform: rotateY(30deg) translateZ(200px)封面B/div /div.stage { position: relative; width: 500px; height: 320px; perspective: 1000px; transform-style: preserve-3d; } .photo { position: absolute; left: 50%; top: 50%; width: 200px; height: 280px; margin-left: -100px; margin-top: -140px; border-radius: 12px; display: flex; align-items: center; justify-content: center; color: #fff; }这里 A 和 B 各自朝着相反方向旋转 30 度再沿各自新的 Z 轴平移 200px最终它们在空间里呈现“八字排列”的效果左边封面左偏、右边封面右偏又有前后深度差。你可以在perspective值 600px 到 1200px 之间调节体会近大远小的强度变化。这个效果如果去掉perspective就会变成单纯的平面倾斜加位移少了很多立体味道。4.4 踩坑实录第一次写翻转卡片的三个教训我最早做翻转卡片时踩过几个典型的坑写出来帮你避开。第一个坑只给.card写了perspective却忘了放在父容器上。结果翻转时虽然能转但平面感很强完全没有“从屏幕里翻出来”的立体感。原因是perspective必须作用于三维空间而不能只当作旋转时的一个修饰属性。第二个坑两个 face 都没有加backface-visibility。翻转 180 度后背面那张脸虽然转到了正面朝向但正面那张脸因为旋转到背面默认会露出镜像的背面内容于是两张脸叠在一起文字左一个右一个画面极其混乱。后来统一加上backface-visibility: hidden同时确保背面那张脸预先rotateY(180deg)世界立刻清净了。第三个坑动画过程中卡片有闪烁或锯齿。主要原因通常是preserve-3d没写对层级或者背面 face 没做translateZ(0)之类的 3D 提升。如果你遇到翻转时有轻微闪动可以给.face加上will-change: transform这会让浏览器把元素提升到独立的合成层减少绘制闪烁。5. 常见问题与排查技巧实录5.1 我写了 rotateY 却看不到 3D 效果排查思路按优先级排列是否缺少perspective。这是最常见的原因。写了rotateY但没有透视浏览器只是把该元素的 3D 旋转正交投影到屏幕上效果等同于水平缩放没有近大远小。perspective是否写错了位置。如果你的perspective写在元素自己的transform里必须放在列表第一位例如transform: perspective(800px) rotateY(60deg)。放在后面会被浏览器忽略或者产生意料之外的结果。是否被transform-style: flat连坐。父容器如果默认flat子元素的 3D 空间会被压平。如果需要多层嵌套 3D 效果在相关容器上补transform-style: preserve-3d。元素本身是否有可见特征。一个纯白色背景的元素旋转后可能看起来和没转一样因为视觉上缺乏阴影、渐变或边框来体现角度变化。调试时可以临时加一条边框或者渐变背景方便观察方向。5.2 旋转 180 度后文字是镜像的这是一个 3D 翻转必然遇到的问题。当元素绕 Y 轴旋转 180 度它正面向下看就是镜像的。解决方法有两种使用backface-visibility: hidden让背面不可见这样你根本看不到镜像内容。主动把背面内容预旋转 180 度把它放置在真正的背面位置。这个我前面卡片案例已经用过。如果两种都试过仍然镜像检查一下你是否用了嵌套的多个 rotate比如在父级又叠加了一层rotateY(180deg)导致翻转次数变成偶数次镜像被“翻”回正序。这种情况下需要整体审视变换顺序。5.3 matrix() 手算结果和浏览器渲染不一致手算matrix()出错绝大多数原因是参数顺序理解错误。记住公式x a * x c * y e y b * x d * y f不要习惯性地认为矩阵是按“行从左到右”对应参数排列。matrix(a, b, c, d, e, f)中第二、第三个参数在矩阵里的位置不是相邻的b在第二行第一列c在第一行第二列。另外复合变换的矩阵乘法顺序也很容易搞反。如果你要验证transform: scale(1.5) rotate(30deg) translate(100px, 50px)计算的矩阵应该是M T(100, 50) * R(30deg) * S(1.5)而不是把顺序倒过来。由于矩阵乘法不满足交换律顺序一旦反了结果必然不同。我的经验是每做一次复合矩阵计算都取元素上的一个关键点比如中心偏移后的右上角坐标手动验算一遍再和浏览器里的实际位置对比能快速定位是计算错误还是概念错误。5.4 动画卡顿怎么办3D 变换本身性能很不错因为它通常走 GPU 合成。但如果你发现动画掉帧可以从几个方向排查动画属性尽量只动transform和opacity不要去动left、top、width等触发重排的属性。对需要频繁变化的元素加will-change: transform提前告诉浏览器把它提升到独立合成层。不要在手机上做超大面积的 3D 变换像素填充量会很高适当降低perspective的强度或者使用更小的元素尺寸。如果多个元素同时做 3D 动画尽量让它们共享同一个preserve-3d父容器减少合成层数量。5.5 快捷排查小技巧临时给元素加辅助线调试transform时我经常建议别人做两件事一是给元素加一个很显眼的 border 或者背景渐变方便观察角度二是临时把transform改成rotateY(0deg) translateZ(0px)确认元素定位正常。还有一种做法在浏览器控制台执行getComputedStyle(el).transform浏览器会返回最终的矩阵值你可以直接看到缩放、旋转、平移参数是否符合预期这一步能节省大量“盲猜”时间。我个人在实际操作中最大的体会是transform不是一个靠“试”就能掌握的知识它本身就是一套几何模型。你把坐标系的顺序、透视、矩阵参数之间的关系理清之后写出什么代码就能大概预料到什么画面即使偶尔翻车也能通过浏览器控制台反推错误原因。最后分享一个我们团队内部常用的小技巧如果感觉某个 3D 变换组合特别复杂先把它拆成两步来看——第一步只做旋转观察方向第二步只做平移观察位置。两个步骤分别验证之后再合并成一行代码你会发现自己对 CSS transform 的控制力瞬间上了一个台阶。
返回列表