跳到主要内容

Transform 变换与矩阵

概述

2D 变换(Transform)是可视化和图形编辑器的核心基础。无论是 CSS、Canvas 还是 SVG,底层都使用仿射变换矩阵来统一处理平移、旋转、缩放和斜切。

为什么要学矩阵变换?

你在 CSS 中写 transform: rotate(45deg) scale(2) 看似简单,但在 Canvas 图形编辑器、可视化缩放平移、拖拽旋转等场景中,你需要直接操作矩阵来实现坐标转换、命中检测、逆向映射等功能。理解矩阵是从"会用 CSS 动画"到"能写图形编辑器"的关键跨越。

变换的直觉理解

想象你面前有一张白纸,纸上画了一个图形。变换就是对这张纸进行操作:

  • 平移(Translate):把纸向某个方向滑动
  • 旋转(Rotate):用图钉固定一个点,旋转纸张
  • 缩放(Scale):用放大镜看纸张(或缩小复印)
  • 斜切(Skew):把纸的上边向右推,下边向左推,变成平行四边形

所有这些操作都可以用一个统一的数学工具来表示——矩阵


基本变换

1. Translate(平移)

几何含义:将图形的每个点沿 x 轴移动 txtx,沿 y 轴移动 tyty。图形的形状和大小不变,只是位置改变。

数学公式

x=x+tx,y=y+tyx' = x + tx \quad,\quad y' = y + ty
translate.ts
/**
* 平移变换:将点 (x, y) 沿两个轴分别移动 tx 和 ty
* 这是最简单的变换——直接加上偏移量
*/
function translate(
x: number, y: number,
tx: number, ty: number
): [number, number] {
return [x + tx, y + ty];
}

// 示例:将点 (10, 20) 向右移 50,向下移 30
const result = translate(10, 20, 50, 30); // [60, 50]

在各平台中的使用

平台语法说明
CSStransform: translate(50px, 30px)也可分开写 translateX / translateY
Canvasctx.translate(50, 30)累积到当前变换矩阵
SVGtransform="translate(50, 30)"SVG 属性语法

2. Rotate(旋转)

几何含义:将图形绕某个点旋转一定角度。默认绕坐标原点 (0,0)(0, 0) 旋转。

弧度与角度

计算机图形学中通常使用弧度(radian)而非角度(degree)。换算关系:弧度=角度×π180\text{弧度} = \text{角度} \times \frac{\pi}{180}。例如 90°=π290° = \frac{\pi}{2}45°=π445° = \frac{\pi}{4}

为什么旋转公式用 cos 和 sin?

这是很多人困惑的地方。让我们从单位圆说起:

在单位圆(半径为 1 的圆)上,一个角度为 θ\theta 的点的坐标恰好是 (cosθ,sinθ)(\cos\theta, \sin\theta)。这是 cos 和 sin 的几何定义

现在假设有一个点 (x,y)(x, y),它到原点的距离是 rr,与 x 轴的夹角是 α\alpha。那么:

x=rcosα,y=rsinαx = r\cos\alpha \quad,\quad y = r\sin\alpha

旋转角度 θ\theta 后,夹角变为 α+θ\alpha + \theta,新坐标为:

x=rcos(α+θ)=r(cosαcosθsinαsinθ)=xcosθysinθx' = r\cos(\alpha + \theta) = r(\cos\alpha\cos\theta - \sin\alpha\sin\theta) = x\cos\theta - y\sin\theta y=rsin(α+θ)=r(sinαcosθ+cosαsinθ)=xsinθ+ycosθy' = r\sin(\alpha + \theta) = r(\sin\alpha\cos\theta + \cos\alpha\sin\theta) = x\sin\theta + y\cos\theta

这就是旋转公式的来源——它本质上是三角函数的和角公式

数学公式

{x=xcosθysinθy=xsinθ+ycosθ\begin{cases} x' = x \cos\theta - y \sin\theta \\ y' = x \sin\theta + y \cos\theta \end{cases}
rotate.ts
/**
* 绕原点旋转
* @param angle - 旋转角度(弧度),正值为逆时针
*/
function rotate(
x: number, y: number,
angle: number
): [number, number] {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return [
x * cos - y * sin, // x 分量:水平投影 - 垂直投影
x * sin + y * cos, // y 分量:水平投影 + 垂直投影
];
}

绕任意点旋转

实际开发中,我们很少绕原点旋转,通常是绕图形中心或某个锚点旋转。思路是三步走

rotateAround.ts
/**
* 绕任意点 (cx, cy) 旋转
* 核心思路:先把旋转中心移到原点,旋转完再移回去
*/
function rotateAround(
x: number, y: number,
cx: number, cy: number,
angle: number
): [number, number] {
// 第 1 步:将旋转中心平移到原点
const dx = x - cx;
const dy = y - cy;

// 第 2 步:绕原点旋转
const cos = Math.cos(angle);
const sin = Math.sin(angle);

// 第 3 步:旋转后平移回去
return [
dx * cos - dy * sin + cx,
dx * sin + dy * cos + cy,
];
}

// 示例:将点 (100, 0) 绕点 (50, 0) 旋转 90°
const result = rotateAround(100, 0, 50, 0, Math.PI / 2);
// 结果约为 (50, 50) — 点在以 (50,0) 为圆心的圆上旋转了 90°

在各平台中的使用

平台语法说明
CSStransform: rotate(45deg)配合 transform-origin 指定旋转中心
Canvasctx.rotate(Math.PI / 4)需手动 translate 实现绕任意点旋转
SVGtransform="rotate(45, cx, cy)"SVG 直接支持指定旋转中心

3. Scale(缩放)

几何含义:将图形在 x 方向放大/缩小 sxsx 倍,在 y 方向放大/缩小 sysy 倍。sx=sysx = sy 时为等比缩放,否则为非等比缩放(图形会变形)。

数学公式

x=x×sx,y=y×syx' = x \times sx \quad,\quad y' = y \times sy
scale.ts
/** 基于原点缩放 */
function scale(
x: number, y: number,
sx: number, sy: number
): [number, number] {
return [x * sx, y * sy];
}

/**
* 绕任意点 (cx, cy) 缩放
* 与绕任意点旋转思路相同:先移到原点,缩放,再移回去
*/
function scaleAround(
x: number, y: number,
cx: number, cy: number,
sx: number, sy: number
): [number, number] {
return [
(x - cx) * sx + cx,
(y - cy) * sy + cy,
];
}

// 示例:将点 (100, 100) 以 (50, 50) 为中心放大 2 倍
const result = scaleAround(100, 100, 50, 50, 2, 2);
// 结果为 (150, 150) — 离中心的距离翻倍了
平台语法说明
CSStransform: scale(1.5, 2)transform-origin 控制缩放中心
Canvasctx.scale(1.5, 2)会影响后续所有绘制
SVGtransform="scale(1.5, 2)"基于原点缩放
负值缩放 = 翻转

scale(-1, 1) 是水平翻转,scale(1, -1) 是垂直翻转,scale(-1, -1) 等于旋转 180°。


4. Skew(斜切/错切)

几何含义:将图形沿某个方向"倾斜"。想象把一个矩形推成平行四边形——这就是斜切。

  • 水平斜切:上边不动,下边向右推(或反过来)
  • 垂直斜切:左边不动,右边向下推(或反过来)

数学公式

{x=x+y×tan(α)y=x×tan(β)+y\begin{cases} x' = x + y \times \tan(\alpha) \\ y' = x \times \tan(\beta) + y \end{cases}

其中 α\alpha 是水平斜切角度,β\beta 是垂直斜切角度。

skew.ts
/**
* 斜切变换
* @param angleX - 水平斜切角度(弧度)
* @param angleY - 垂直斜切角度(弧度)
*/
function skew(
x: number, y: number,
angleX: number, angleY: number
): [number, number] {
return [
x + y * Math.tan(angleX), // x 受 y 影响,产生水平偏移
x * Math.tan(angleY) + y, // y 受 x 影响,产生垂直偏移
];
}

// 示例:水平斜切 30°
const result = skew(100, 50, Math.PI / 6, 0);
// x' = 100 + 50 * tan(30°) ≈ 100 + 28.87 = 128.87
// y' = 50(垂直方向不变)

CSS:transform: skew(10deg, 5deg)skewX(10deg) / skewY(5deg)


仿射变换矩阵

前面我们分别介绍了四种基本变换,每种都有自己的公式。但在实际开发中,我们经常需要组合多种变换(例如先旋转再缩放再平移)。如果每种变换都单独计算,代码会很复杂,也不容易组合和分解。

矩阵就是解决这个问题的工具——它用一个统一的格式来表示所有变换。

为什么要用矩阵?

核心思想

矩阵是"变换的统一语言"。不管你要平移、旋转、缩放还是它们的任意组合,都可以用一个矩阵来表示。而多个变换的组合,只需要把它们的矩阵相乘

没有矩阵使用矩阵
每种变换一个函数统一的矩阵乘法
组合变换需要嵌套调用矩阵相乘得到组合矩阵
逆变换需要逐个反推求逆矩阵即可
传参繁琐6 个数字表示一切

齐次坐标:为什么 2D 要用 3x3 矩阵?

旋转和缩放可以用 2x2 矩阵表示(它们是线性变换),但平移不行——平移是"加法"操作,不是"乘法"操作:

旋转/缩放:[xy]=[acbd]×[xy]\text{旋转/缩放:} \begin{bmatrix} x' \\ y' \end{bmatrix} = \begin{bmatrix} a & c \\ b & d \end{bmatrix} \times \begin{bmatrix} x \\ y \end{bmatrix} 平移:x=x+tx,  y=y+ty(没法用 2×2 矩阵表示!)\text{平移:} x' = x + tx,\; y' = y + ty \quad\text{(没法用 2×2 矩阵表示!)}

为了把平移也纳入矩阵乘法,数学家引入了齐次坐标(Homogeneous Coordinates):在 2D 坐标后面加一个 11,把 (x,y)(x, y) 变成 (x,y,1)(x, y, 1),同时把矩阵扩展为 3×33 \times 3

[xy1]=[acebdf001]×[xy1]\begin{bmatrix} x' \\ y' \\ 1 \end{bmatrix} = \begin{bmatrix} a & c & e \\ b & d & f \\ 0 & 0 & 1 \end{bmatrix} \times \begin{bmatrix} x \\ y \\ 1 \end{bmatrix}

展开后:

x=ax+cy+e,y=bx+dy+fx' = ax + cy + e \quad,\quad y' = bx + dy + f

其中 e,fe, f 就是平移量!这样所有变换都可以用矩阵乘法统一表示了。

齐次坐标的第三行

矩阵最后一行始终是 [0,0,1][0, 0, 1],运算中不会改变。所以实际存储时只需要 6 个数字 [a, b, c, d, e, f],这正是 CSS matrix() 和 Canvas setTransform() 使用的参数。

各变换对应的矩阵

变换abcdef矩阵形式
单位矩阵(无变换)100100II
平移 (tx,ty)(tx, ty)1001txtyTT
缩放 (sx,sy)(sx, sy)sx00sy00SS
旋转 θ\thetacosθ\cos\thetasinθ\sin\thetasinθ-\sin\thetacosθ\cos\theta00RR
水平斜切 θ\theta10tanθ\tan\theta100KxK_x
垂直斜切 θ\theta1tanθ\tan\theta0100KyK_y

变换组合的顺序(重点!)

变换顺序非常重要

矩阵乘法不满足交换律A×BB×AA \times B \neq B \times A。先旋转再平移和先平移再旋转的结果完全不同。

直觉理解:假设你站在原点,面朝右方:

  • 先平移 (100, 0) 再旋转 90°:你先走到 (100, 0),然后原地转身。最终你在 (100, 0),面朝上。
  • 先旋转 90° 再平移 (100, 0):你先原地转身面朝上,然后沿你的"前方"走 100 步。最终你在 (0, 100),面朝上。

CSS 中的顺序规则

CSS transform 顺序
/* CSS 从右到左应用!最右边的变换最先作用于元素 */
transform: translate(100px, 0) rotate(90deg);
/* 等价于:先 rotate(90deg),再 translate(100px, 0) */
/* 矩阵表示:T × R × point */

Canvas 中的顺序规则

Canvas transform 顺序
// Canvas 也是"后写的先执行"
ctx.translate(100, 0); // 第二步
ctx.rotate(Math.PI / 2); // 第一步(先执行)
// 等价于:先旋转,再平移
记忆方法

CSS 和 Canvas 都遵循**"就近原则"**——离坐标点最近的变换最先执行。CSS 中最近的是最右边,Canvas 中最近的是最后一行代码。


Matrix 类实现

Matrix.ts
/**
* 2D 仿射变换矩阵
*
* 内部存储 6 个参数 [a, b, c, d, e, f]
* 对应 3×3 矩阵:
* | a c e |
* | b d f |
* | 0 0 1 |
*
* 与 CSS matrix(a, b, c, d, e, f) 和
* Canvas ctx.setTransform(a, b, c, d, e, f) 参数顺序一致
*/
class Matrix {
constructor(
public a: number = 1, // 水平缩放
public b: number = 0, // 垂直倾斜
public c: number = 0, // 水平倾斜
public d: number = 1, // 垂直缩放
public e: number = 0, // 水平平移
public f: number = 0, // 垂直平移
) {}

/**
* 矩阵乘法:this × other
* 将 other 的变换"追加"到当前矩阵
*
* 矩阵乘法公式(3×3 矩阵相乘,第三行固定 [0,0,1]):
* | a1 c1 e1 | | a2 c2 e2 |
* | b1 d1 f1 | × | b2 d2 f2 |
* | 0 0 1 | | 0 0 1 |
*/
multiply(m: Matrix): Matrix {
return new Matrix(
this.a * m.a + this.c * m.b, // 新 a
this.b * m.a + this.d * m.b, // 新 b
this.a * m.c + this.c * m.d, // 新 c
this.b * m.c + this.d * m.d, // 新 d
this.a * m.e + this.c * m.f + this.e, // 新 e
this.b * m.e + this.d * m.f + this.f, // 新 f
);
}

/** 对点 (x, y) 应用变换,得到变换后的坐标 */
transformPoint(x: number, y: number): [number, number] {
return [
this.a * x + this.c * y + this.e,
this.b * x + this.d * y + this.f,
];
}

/**
* 求逆矩阵:执行反向变换
*
* 用途:屏幕坐标 → 数据坐标(在缩放平移的画布中点击时,
* 需要知道点击的是数据空间的哪个位置)
*
* 逆矩阵公式利用行列式 det = ad - bc
*/
inverse(): Matrix {
const det = this.a * this.d - this.b * this.c; // 行列式
if (Math.abs(det) < 1e-10) {
throw new Error('Matrix is not invertible (det ≈ 0)');
}
const invDet = 1 / det;
return new Matrix(
this.d * invDet,
-this.b * invDet,
-this.c * invDet,
this.a * invDet,
(this.c * this.f - this.d * this.e) * invDet,
(this.b * this.e - this.a * this.f) * invDet,
);
}

// ---------- 静态工厂方法 ----------

/** 创建平移矩阵 */
static translate(tx: number, ty: number): Matrix {
return new Matrix(1, 0, 0, 1, tx, ty);
}

/** 创建旋转矩阵(弧度) */
static rotate(angle: number): Matrix {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return new Matrix(cos, sin, -sin, cos, 0, 0);
}

/** 创建缩放矩阵(sy 省略时为等比缩放) */
static scale(sx: number, sy: number = sx): Matrix {
return new Matrix(sx, 0, 0, sy, 0, 0);
}

/** 创建斜切矩阵 */
static skew(angleX: number, angleY: number): Matrix {
return new Matrix(1, Math.tan(angleY), Math.tan(angleX), 1, 0, 0);
}

/**
* 绕指定点 (cx, cy) 进行旋转 + 缩放
* 等价于:translate(cx,cy) × rotate(angle) × scale(sx,sy) × translate(-cx,-cy)
*/
static transformAround(
cx: number, cy: number,
angle: number,
sx: number, sy: number
): Matrix {
return Matrix.translate(cx, cy)
.multiply(Matrix.rotate(angle))
.multiply(Matrix.scale(sx, sy))
.multiply(Matrix.translate(-cx, -cy));
}

// ---------- 输出与转换 ----------

/** 应用到 Canvas 上下文 */
applyToContext(ctx: CanvasRenderingContext2D): void {
ctx.setTransform(this.a, this.b, this.c, this.d, this.e, this.f);
}

/** 转为 CSS transform 字符串 */
toCSSString(): string {
return `matrix(${this.a}, ${this.b}, ${this.c}, ${this.d}, ${this.e}, ${this.f})`;
}

/** 转为 DOMMatrix(浏览器原生矩阵对象) */
toDOMMatrix(): DOMMatrix {
return new DOMMatrix([this.a, this.b, this.c, this.d, this.e, this.f]);
}

/** 从 DOMMatrix 创建 */
static fromDOMMatrix(dm: DOMMatrix): Matrix {
return new Matrix(dm.a, dm.b, dm.c, dm.d, dm.e, dm.f);
}

/**
* 矩阵分解:从组合矩阵中提取出各个分量
*
* 原理:
* - e, f 直接就是平移量
* - scaleX = 第一列向量的长度 = sqrt(a² + b²)
* - scaleY = 第二列向量的长度 = sqrt(c² + d²)
* - rotation = 第一列向量与 x 轴的夹角 = atan2(b, a)
*/
decompose(): {
translateX: number;
translateY: number;
rotation: number;
scaleX: number;
scaleY: number;
} {
const { a, b, c, d, e, f } = this;
const scaleX = Math.sqrt(a * a + b * b);
const scaleY = Math.sqrt(c * c + d * d);
const rotation = Math.atan2(b, a);

return {
translateX: e,
translateY: f,
rotation,
scaleX,
// 行列式为负说明有翻转,scaleY 取负值
scaleY: (a * d - b * c < 0) ? -scaleY : scaleY,
};
}
}

DOMMatrix 浏览器原生 API

浏览器提供了原生的 DOMMatrix API,无需手写矩阵运算。

dommatrix-usage.ts
// 创建单位矩阵
const m = new DOMMatrix();

// 链式变换(注意:DOMMatrix 的方法会修改自身并返回自身)
const result = new DOMMatrix()
.translateSelf(100, 50) // 平移
.rotateSelf(45) // 旋转 45°(注意这里用角度,不是弧度!)
.scaleSelf(2, 2); // 缩放

// 读取矩阵参数
console.log(result.a, result.b, result.c, result.d, result.e, result.f);

// 对点进行变换
const point = new DOMPoint(10, 20);
const transformed = result.transformPoint(point);
console.log(transformed.x, transformed.y);

// 求逆矩阵
const inv = result.inverse();

// 矩阵乘法
const combined = m.multiply(result);

// 从 CSS transform 字符串创建
const fromCSS = new DOMMatrix('rotate(45deg) scale(2)');

// 应用到 Canvas
const ctx = document.querySelector('canvas')!.getContext('2d')!;
ctx.setTransform(result); // 可以直接传入 DOMMatrix
DOMMatrix vs 手写 Matrix
对比DOMMatrix手写 Matrix
浏览器兼容现代浏览器均支持无兼容问题
3D 支持内置 3D(4x4 矩阵)需要额外实现
性能原生实现,较快JS 实现
可控性API 固定可自由扩展
Node.js不可用可用

建议:简单场景用 DOMMatrix;需要在 Node.js 运行或需要自定义扩展时用手写 Matrix。


实际应用

Zoom & Pan(缩放平移)

最常见的可视化交互——鼠标滚轮缩放、拖拽平移画布。

CanvasViewport.ts
/**
* 画布视口管理器
* 维护一个变换矩阵,管理画布的缩放和平移
*/
class CanvasViewport {
private matrix = new Matrix();

// 缩放限制
private minScale = 0.1;
private maxScale = 10;

/**
* 以鼠标位置为中心缩放
* 原理:translate(cx,cy) × scale(factor) × translate(-cx,-cy)
* 这样鼠标所指的点在缩放前后位置不变
*/
zoom(factor: number, cx: number, cy: number): void {
// 检查缩放限制
const currentScale = this.getScale();
const newScale = currentScale * factor;
if (newScale < this.minScale || newScale > this.maxScale) return;

const zoomMatrix = Matrix.translate(cx, cy)
.multiply(Matrix.scale(factor))
.multiply(Matrix.translate(-cx, -cy));
this.matrix = zoomMatrix.multiply(this.matrix); // 左乘:新变换叠加到现有变换之上
}

/** 平移画布 */
pan(dx: number, dy: number): void {
this.matrix = Matrix.translate(dx, dy).multiply(this.matrix);
}

/** 获取当前缩放比例 */
getScale(): number {
return Math.sqrt(this.matrix.a ** 2 + this.matrix.b ** 2);
}

/** 屏幕坐标 → 数据坐标(鼠标点击时需要转换) */
screenToWorld(sx: number, sy: number): [number, number] {
return this.matrix.inverse().transformPoint(sx, sy);
}

/** 数据坐标 → 屏幕坐标 */
worldToScreen(wx: number, wy: number): [number, number] {
return this.matrix.transformPoint(wx, wy);
}

/** 重置视口 */
reset(): void {
this.matrix = new Matrix();
}

/** 应用到 Canvas */
apply(ctx: CanvasRenderingContext2D): void {
this.matrix.applyToContext(ctx);
}
}

鼠标滚轮缩放的完整实现

bindWheelZoom.ts
/**
* 绑定鼠标滚轮缩放和拖拽平移
*/
function bindViewportInteraction(
canvas: HTMLCanvasElement,
viewport: CanvasViewport,
render: () => void
): void {
// ---- 滚轮缩放 ----
canvas.addEventListener('wheel', (e: WheelEvent) => {
e.preventDefault();

// 获取鼠标在 Canvas 上的坐标
const rect = canvas.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;

// deltaY > 0 表示向下滚(缩小),< 0 表示向上滚(放大)
const factor = e.deltaY > 0 ? 0.9 : 1.1; // 每次缩放 10%
viewport.zoom(factor, cx, cy);
render();
}, { passive: false });

// ---- 拖拽平移 ----
let isDragging = false;
let lastX = 0;
let lastY = 0;

canvas.addEventListener('mousedown', (e: MouseEvent) => {
isDragging = true;
lastX = e.clientX;
lastY = e.clientY;
canvas.style.cursor = 'grabbing';
});

window.addEventListener('mousemove', (e: MouseEvent) => {
if (!isDragging) return;
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
viewport.pan(dx, dy);
lastX = e.clientX;
lastY = e.clientY;
render();
});

window.addEventListener('mouseup', () => {
isDragging = false;
canvas.style.cursor = 'grab';
});
}

图形编辑器中的物体变换

ShapeTransform.ts
/** 图形对象:用矩阵存储变换状态 */
interface Shape {
localMatrix: Matrix; // 物体自身的变换(位置、旋转、缩放)
vertices: [number, number][]; // 物体的顶点(在物体局部坐标系中)
}

/** 获取物体在画布上的实际顶点坐标 */
function getWorldVertices(shape: Shape): [number, number][] {
return shape.vertices.map(([x, y]) =>
shape.localMatrix.transformPoint(x, y)
);
}

/** 绕物体中心旋转 */
function rotateShape(shape: Shape, angle: number): void {
const { translateX: cx, translateY: cy } = shape.localMatrix.decompose();
const rotMatrix = Matrix.transformAround(cx, cy, angle, 1, 1);
shape.localMatrix = rotMatrix.multiply(shape.localMatrix);
}

/** 点击命中检测:判断屏幕坐标是否落在图形内 */
function hitTest(
shape: Shape,
screenX: number, screenY: number,
viewMatrix: Matrix
): boolean {
// 1. 屏幕坐标 → 世界坐标
const [wx, wy] = viewMatrix.inverse().transformPoint(screenX, screenY);
// 2. 世界坐标 → 物体局部坐标
const [lx, ly] = shape.localMatrix.inverse().transformPoint(wx, wy);
// 3. 在局部坐标系中判断是否在图形内(简化为 AABB)
const bounds = getBounds(shape.vertices);
return lx >= bounds.minX && lx <= bounds.maxX
&& ly >= bounds.minY && ly <= bounds.maxY;
}

function getBounds(vertices: [number, number][]): {
minX: number; minY: number; maxX: number; maxY: number;
} {
const xs = vertices.map(v => v[0]);
const ys = vertices.map(v => v[1]);
return {
minX: Math.min(...xs), minY: Math.min(...ys),
maxX: Math.max(...xs), maxY: Math.max(...ys),
};
}

CSS Transform

基础用法

css-transform.css
.box {
/* 单个变换 */
transform: translate(50px, 30px);
transform: rotate(45deg);
transform: scale(1.5);
transform: skew(10deg, 5deg);

/* 组合变换 — 从右到左执行 */
transform: translate(50px, 30px) rotate(45deg) scale(1.5);

/* matrix 形式 — 6 个参数的终极表达 */
transform: matrix(1.0607, 1.0607, -1.0607, 1.0607, 50, 30);
}

transform-origin

transform-origin 定义变换的基准点。默认是元素中心 50% 50%

transform-origin 示例
.box {
/* 绕左上角旋转 */
transform-origin: 0 0;
transform: rotate(45deg);

/* 绕右下角缩放 */
transform-origin: 100% 100%;
transform: scale(2);

/* 绕自定义点旋转 */
transform-origin: 30px 50px;
transform: rotate(90deg);
}
transform-origin 的本质

transform-origin: ox oy + transform: T 等价于:

transform: translate(ox, oy) T translate(-ox, -oy)

浏览器自动帮你做了"平移到原点→变换→平移回去"的操作。

CSS 中用 matrix 合并变换

css-matrix-calc.ts
// 用 Matrix 类计算 CSS transform 的等效 matrix 值
const m = Matrix.translate(50, 30)
.multiply(Matrix.rotate(Math.PI / 4)) // 45deg
.multiply(Matrix.scale(1.5));

console.log(m.toCSSString());
// 输出:matrix(1.0607, 1.0607, -1.0607, 1.0607, 50, 30)

// 也可以用 DOMMatrix
const dm = new DOMMatrix('translate(50px, 30px) rotate(45deg) scale(1.5)');
console.log(dm.a, dm.b, dm.c, dm.d, dm.e, dm.f);

SVG Transform

SVG 的 transform 属性和 CSS transform 类似,但语法略有不同。

SVG transform 语法
<svg viewBox="0 0 200 200">
<!-- 平移 -->
<rect transform="translate(50, 30)" width="40" height="40" fill="blue" />

<!-- 旋转:rotate(角度, cx, cy) — SVG 直接支持指定旋转中心! -->
<rect transform="rotate(45, 100, 100)" width="40" height="40" fill="red" />

<!-- 缩放 -->
<rect transform="scale(1.5)" width="40" height="40" fill="green" />

<!-- 组合(从右到左执行) -->
<rect transform="translate(100, 50) rotate(30) scale(0.8)" width="40" height="40" />

<!-- matrix(a, b, c, d, e, f) -->
<rect transform="matrix(1, 0, 0, 1, 50, 50)" width="40" height="40" />
</svg>

viewBox 与坐标变换

SVG 的 viewBox 本质上也是一个变换矩阵,它定义了 SVG 内部的坐标系统:

viewBox 示例
<!-- 物理尺寸 400x400,但内部坐标系是 0~100 -->
<!-- 等价于对所有内容进行了 scale(4, 4) -->
<svg width="400" height="400" viewBox="0 0 100 100">
<!-- 这里的坐标 (50, 50) 在屏幕上会映射到 (200, 200) -->
<circle cx="50" cy="50" r="10" fill="red" />
</svg>
SVG vs CSS transform 差异
特性SVG transformCSS transform
旋转中心rotate(45, cx, cy) 直接指定需要 transform-origin
单位无单位(SVG 用户单位)需要 px/em/% 等
3D不支持支持
动画SMIL 或 CSSCSS transition/animation

Canvas 变换 API

Canvas API等价矩阵操作说明
ctx.translate(tx, ty)当前矩阵 × T(tx,ty)T(tx, ty)累积平移
ctx.rotate(angle)当前矩阵 × R(θ)R(\theta)累积旋转(弧度)
ctx.scale(sx, sy)当前矩阵 × S(sx,sy)S(sx, sy)累积缩放
ctx.transform(a,b,c,d,e,f)当前矩阵 × 新矩阵累积自定义变换
ctx.setTransform(a,b,c,d,e,f)直接设置为新矩阵替换(非累积)
ctx.resetTransform()重置为单位矩阵 II清除所有变换
ctx.getTransform()返回当前矩阵返回 DOMMatrix 对象
canvas-transform.ts
const ctx = canvas.getContext('2d')!;

// save/restore 会保存和恢复变换矩阵
ctx.save();
ctx.translate(100, 100);
ctx.rotate(Math.PI / 4);
ctx.fillRect(-25, -25, 50, 50); // 绕 (100,100) 旋转 45° 的正方形
ctx.restore(); // 恢复之前的变换状态

// 使用 setTransform 直接设置(不受之前变换影响)
ctx.setTransform(1, 0, 0, 1, 0, 0); // 重置为单位矩阵
ctx.setTransform(2, 0, 0, 2, 100, 50); // 缩放 2 倍 + 平移 (100, 50)

// 获取当前变换矩阵
const currentMatrix = ctx.getTransform(); // 返回 DOMMatrix

3D 变换

CSS 支持 3D 变换,底层使用 4×44 \times 4 矩阵:

基本 3D 变换

3d-transform.css
/* 透视:创建 3D 空间感,值越小透视感越强 */
.container {
perspective: 800px; /* 在父容器上设置,子元素共享同一视角 */
perspective-origin: 50% 50%; /* 视点位置,默认中心 */
}

/* 或者在元素自身设置透视 */
.box {
transform: perspective(800px) rotateY(45deg);
}

/* 3D 旋转 */
.box {
transform: rotateX(45deg); /* 绕 X 轴旋转(上下翻转效果) */
transform: rotateY(45deg); /* 绕 Y 轴旋转(左右翻转效果) */
transform: rotateZ(45deg); /* 绕 Z 轴旋转(等同于 rotate) */
transform: rotate3d(1, 1, 0, 45deg); /* 绕自定义轴 (1,1,0) 旋转 */
}

/* 3D 平移和缩放 */
.box {
transform: translateZ(100px); /* Z 轴平移(靠近/远离) */
transform: translate3d(50px, 30px, 100px);
transform: scaleZ(2); /* Z 轴缩放 */
}

3D 空间保持

preserve-3d.css
/* 关键属性:让子元素保持在父元素的 3D 空间中 */
.scene {
perspective: 800px;
}

.card {
transform-style: preserve-3d; /* 子元素保持 3D(默认 flat 会压平) */
transition: transform 0.6s;
}

.card:hover {
transform: rotateY(180deg);
}

/* 翻牌效果:正反两面 */
.card-front, .card-back {
position: absolute;
backface-visibility: hidden; /* 隐藏背面,翻过去时不显示 */
}

.card-back {
transform: rotateY(180deg); /* 背面初始旋转 180° */
}
3D 翻牌效果完整示例
flip-card.css
.flip-card {
width: 200px;
height: 300px;
perspective: 1000px;
}

.flip-card-inner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.6s;
transform-style: preserve-3d;
}

.flip-card:hover .flip-card-inner {
transform: rotateY(180deg);
}

.flip-card-front,
.flip-card-back {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}

.flip-card-front {
background: #2196f3;
color: white;
}

.flip-card-back {
background: #ff9800;
color: white;
transform: rotateY(180deg);
}
3D 变换的矩阵

3D 变换使用 4×44 \times 4 矩阵,CSS 对应 matrix3d(16个参数)。DOMMatrix 也支持 3D(.is2D 属性可判断)。一般前端开发中很少需要直接操作 4x4 矩阵——用 CSS 属性就够了。


性能优化

will-change 与 GPU 加速

gpu-acceleration.css
.animated {
/* transform 和 opacity 可以被 GPU 加速(合成层) */
/* 只做 transform + opacity 的动画,避免重排重绘 */
transform: translateZ(0); /* 老方法:强制创建合成层 */
}

.will-animate {
will-change: transform; /* 现代方法:提前告知浏览器 */
/* 注意:不要滥用!每个 will-change 都会占用 GPU 内存 */
}

/* 正确用法:在需要时才添加 */
.card {
transition: transform 0.3s;
}
.card:hover {
will-change: transform; /* hover 时才提升 */
}
.card::after {
/* 动画结束后浏览器会自动回收 */
}
will-change 注意事项
  • 不要在大量元素上设置 will-change,会导致 GPU 内存暴涨
  • 不要过早设置,应该在元素即将发生变化时才添加
  • transform: translateZ(0) 是 hack,优先使用 will-change
  • 只有 transformopacity 能触发 GPU 合成层动画
动画属性触发操作性能
transform合成(Composite)最优
opacity合成(Composite)最优
background-color重绘(Repaint)中等
width / height重排(Reflow)最差
top / left重排(Reflow)最差
性能黄金法则

动画只使用 transformopacity。需要改变位置用 translateX/Y 代替 left/top,需要改变大小用 scale 代替 width/height


常见面试问题

Q1: CSS transform 中的 matrix(a, b, c, d, e, f) 各参数含义?

答案

对应 2D 仿射变换矩阵的 6 个参数:

[acebdf001]\begin{bmatrix} a & c & e \\ b & d & f \\ 0 & 0 & 1 \end{bmatrix}
  • a, d:缩放(水平/垂直方向)
  • b, c:旋转和斜切
  • e, f:平移(水平/垂直方向)

变换公式:x=ax+cy+ex' = ax + cy + ey=bx+dy+fy' = bx + dy + f。单位矩阵是 matrix(1, 0, 0, 1, 0, 0),表示无变换。

Q2: 如何实现绕任意点旋转?

答案

三步:

  1. 将旋转中心平移到原点 translate(-cx, -cy)
  2. 绕原点旋转 rotate(angle)
  3. 平移回去 translate(cx, cy)

用矩阵表示:T(cx,cy)×R(θ)×T(cx,cy)T(cx,cy) \times R(\theta) \times T(-cx,-cy)

CSS 的 transform-origin 正是简化了这个操作。在 Canvas 中需要手动实现:

ctx.translate(cx, cy);
ctx.rotate(angle);
ctx.translate(-cx, -cy);

SVG 的 rotate() 可以直接指定旋转中心:rotate(45, 100, 100)

Q3: 什么是逆矩阵?在可视化中有什么用?

答案

逆矩阵执行反向变换——如果矩阵 MM 把点 AA 变换到 BB,那么 M1M^{-1}BB 变回 AA

最常见的用途是坐标转换:在缩放平移的画布中,鼠标点击的是屏幕坐标,但我们需要知道对应的数据坐标才能做命中检测。公式:

worldPoint=M1×screenPoint\text{worldPoint} = M^{-1} \times \text{screenPoint}

逆矩阵的计算依赖行列式 det=adbc\text{det} = ad - bc。当行列式为 0 时,矩阵不可逆(例如 scale(0) 把所有点压缩到了一条线上,无法恢复)。

Q4: Canvas 中 transform 和 setTransform 有什么区别?

答案

  • transform(a,b,c,d,e,f)累积变换——将新矩阵左乘到当前矩阵上
  • setTransform(a,b,c,d,e,f)替换变换——先重置为单位矩阵再设置

需要精确控制变换时用 setTransform,链式叠加变换时用 transform。另外 resetTransform() 等价于 setTransform(1, 0, 0, 1, 0, 0)

Q5: 如何从 matrix 值反推出 translate、rotate、scale?

答案

这叫矩阵分解(Matrix Decomposition):

  • 平移:translateX=e,  translateY=f\text{translateX} = e, \;\text{translateY} = f
  • 缩放:scaleX=a2+b2\text{scaleX} = \sqrt{a^2 + b^2}scaleY=c2+d2\text{scaleY} = \sqrt{c^2 + d^2}
  • 旋转:rotation=atan2(b,a)\text{rotation} = \text{atan2}(b, a)
  • 翻转判断:若行列式 adbc<0ad - bc < 0,则 scaleY 取负值

注意:矩阵分解不总是唯一的(如旋转 180° + scaleX = -1 和纯 scaleY = -1 可能产生相同矩阵),但上述方法是工程中最常用的约定。

Q6: CSS transform 的叠加顺序怎么理解?

答案

CSS transform: A B C右到左应用,等价于矩阵 A×B×C×pointA \times B \times C \times \text{point}

/* 执行顺序:先 scale → 再 rotate → 最后 translate */
transform: translate(100px, 0) rotate(45deg) scale(2);

Canvas 也是同理——后调用的变换先作用于坐标。记忆方法:离坐标点最近的变换最先执行

顺序不同结果不同:先旋转再平移 vs 先平移再旋转,最终位置完全不同,因为矩阵乘法不满足交换律。

Q7: transform-origin 的原理是什么?

答案

transform-origin 本质上是自动添加了"平移到原点 → 变换 → 平移回去"的操作:

transform-origin: ox oy + transform: T
// 等价于:
transform: translate(ox, oy) T translate(-ox, -oy)

默认值 50% 50% 表示以元素中心为基准点。这就是为什么 rotate(45deg) 默认绕元素中心旋转。

Q8: DOMMatrix 是什么?和手写矩阵有什么区别?

答案

DOMMatrix 是浏览器原生的矩阵 API,支持 2D 和 3D 变换。

const m = new DOMMatrix('rotate(45deg) scale(2)');
const point = m.transformPoint(new DOMPoint(10, 20));
const inv = m.inverse();

优点:原生实现性能好、支持 3D、可直接传给 Canvas ctx.setTransform(domMatrix)。 缺点:Node.js 不可用、API 固定不可扩展。

实际项目中,简单场景用 DOMMatrix,需要在 Node.js(如 SSR)或需要自定义扩展时用手写 Matrix 类。

Q9: will-change 是什么?为什么不能滥用?

答案

will-change 提前告知浏览器某个属性即将变化,浏览器会为该元素创建独立的合成层并提交给 GPU。

好处:transformopacity 动画可以完全在 GPU 上完成,不触发重排重绘,性能极佳。

坏处:每个合成层都占用额外的 GPU 内存。如果对大量元素设置 will-change,会导致内存暴涨甚至页面崩溃。

正确用法:在动画即将开始时添加(如 :hover 或 JS 中动画开始前),动画结束后移除。不要在 CSS 中对所有元素静态设置。

Q10: 为什么推荐用 transform 做动画而不是 top/left?

答案

属性触发阶段性能
top/left重排 → 重绘 → 合成
background-color重绘 → 合成
transform/opacity仅合成最优

transform 不改变元素在文档流中的位置,浏览器只需在合成阶段(Composite)移动图层,完全由 GPU 处理。而 top/left 会触发重排(Layout),影响周围元素的布局计算,然后还要重绘和合成。

/* 差 — 触发重排 */
.box { transition: left 0.3s; }
.box:hover { left: 100px; }

/* 好 — 仅合成 */
.box { transition: transform 0.3s; }
.box:hover { transform: translateX(100px); }

Q11: 3D transform 中 perspective 的作用是什么?

答案

perspective 定义了观察者到 z=0 平面的距离,用于产生近大远小的透视效果。

  • 值越小(如 200px),透视感越强(物体变形更明显)
  • 值越大(如 2000px),透视感越弱(接近正交投影)
  • 不设置 perspective,3D 变换看不出立体效果

两种设置方式:

  • 父元素上 perspective: 800px:所有子元素共享同一个视角(更自然)
  • 元素自身 transform: perspective(800px) rotateY(45deg):每个元素有独立视角

Q12: transform-style: preserve-3d 是做什么的?

答案

默认情况下,经过 3D 变换的元素的子元素会被"压平"到父元素平面上(flat)。设置 transform-style: preserve-3d 后,子元素会保持在真正的 3D 空间中。

典型应用场景:翻牌效果。正面和背面是两个子元素,各自在 3D 空间中有不同朝向。如果不设置 preserve-3d,翻转时两个面会重叠而不是一前一后。

配合 backface-visibility: hidden 使用,可以隐藏元素的"背面"。

Q13: 齐次坐标是什么?为什么 2D 变换要用 3x3 矩阵?

答案

旋转和缩放是线性变换,可以用 2x2 矩阵表示。但平移是"加法"操作(x=x+txx' = x + tx),无法用 2x2 矩阵的乘法表示。

齐次坐标的做法是在 2D 坐标后加一个 1:(x,y)(x,y,1)(x, y) \to (x, y, 1),矩阵扩展为 3x3。这样平移量 (e,f)(e, f) 可以放在矩阵的最右列,所有变换都能用统一的矩阵乘法表示。

矩阵第三行固定为 [0,0,1][0, 0, 1],所以实际只需存储 6 个参数。CSS matrix() 和 Canvas setTransform() 正是传这 6 个参数。

Q14: Canvas 中如何实现"以鼠标为中心缩放"?

答案

核心思路:缩放前后,鼠标所指的数据点在屏幕上的位置不变。

// cx, cy 是鼠标在 Canvas 上的坐标
// factor 是缩放因子
const zoomMatrix = Matrix.translate(cx, cy) // 3. 移回去
.multiply(Matrix.scale(factor)) // 2. 缩放
.multiply(Matrix.translate(-cx, -cy)); // 1. 移到原点
viewMatrix = zoomMatrix.multiply(viewMatrix); // 叠加到视图矩阵

这和"绕任意点旋转"的思路一模一样——先平移、变换、再平移回去。

Q15: 如何检测一个矩阵是否包含旋转或斜切?

答案

检查矩阵的 bc 参数:

  • b=0b = 0c=0c = 0:无旋转无斜切(仅平移+缩放)
  • b=cb = -ca=da = d:纯旋转(无斜切)
  • 其他情况:包含斜切
function isRotationOnly(m: Matrix): boolean {
return Math.abs(m.b + m.c) < 1e-10 && Math.abs(m.a - m.d) < 1e-10;
}

function hasNoRotationOrSkew(m: Matrix): boolean {
return Math.abs(m.b) < 1e-10 && Math.abs(m.c) < 1e-10;
}

这在性能优化中有用——如果确定没有旋转和斜切,碰撞检测可以用更简单的 AABB 算法而不需要 SAT。


相关链接

外部文档

项目内文档