FKW-凡科快图
凡科快图是一个大型在线图片制作工具平台,提供简便的可视化操作,用户可通过系统模板和拖拉拽等交互,生成高清图片。定位为 Web 端的轻量级 PS,是一款拥有复杂交互逻辑的极简平面设计工具。
目录
一、技术栈总览
| 类别 | 技术选型 | 说明 |
|---|---|---|
| 框架 | Vue 2.6 | 核心视图层框架 |
| 状态管理 | Vuex 3.1 | 多模块命名空间 Store |
| 路由 | Vue Router 3.1 | 部分子项目使用 |
| 图形渲染 | SVG + Canvas | SVG 为主,Canvas 辅助(截图/滤镜/图片处理) |
| 图形库 | Fabric.js 3.6(参考) | 元素模型设计参考了 Fabric.js 的面向对象思路 |
| 富文本 | Quill.js | 富文本编辑能力 |
| 3D 文字 | ThreeText(WebGL) | 3D 立体文字效果 |
| 构建工具 | Webpack 4 | 多入口构建,代码分割 |
| CSS 预处理 | Sass/SCSS | 全局主题变量 + 组件级样式 |
| HTTP 请求 | Axios | 全局拦截器 + Token 管理 |
| 工具库 | Lodash / jQuery 3.4 | jQuery 用于 DOM 操作和事件委托 |
| 组件库 | KtuUI(自研,基于 iView) | 50+ 组件,支持 14+ 语言国际化 |
| 代码规范 | ESLint + Husky + Commitlint | 自定义提交类型:Add/Upd/Fix/Best/Chore/Docs/Revert |
| 开发服务器 | Express + http-proxy-middleware | 智能请求路由代理 |
| QR 码 | jsQR + artQrcode | 二维码识别与艺术化生成 |
| 压缩 | JSZip | 批量下载压缩 |
| 缓存 | IndexedDB | 客户端大数据量缓存 |
二、项目架构
2.1 整体目录结构
ktupc-master/
├── build/ # Webpack 配置(comm/dev/prod/ktuui)
├── config/ # 环境配置(域名/路径/开发者映射)
├── mock/ # API Mock 数据
├── server/ # Express 开发服务器 + 代理
├── src/
│ ├── global/ # 全局共享资源
│ │ ├── components/ # 共享 Vue 组件(IconFont, Login)
│ │ ├── fonts/ # Web 字体
│ │ ├── image/ # 全局图片资源
│ │ ├── js/ # 全局 JS 模块(Quill, Swiper, jQuery 插件等)
│ │ ├── scss/ # 全局 SCSS 变量/mixin
│ │ ├── utils/ # 通用工具(cookie, md5, 浏览器检测, HTTP 拦截器)
│ │ └── vuePlugins/ # Vue 插件(vue-draggable)
│ ├── ktu/ # ★ 核心编辑器模块
│ │ ├── editor/ # PC 端完整编辑器(196 Vue 组件 + 35 基类 + 20 mixin)
│ │ ├── mEditor/ # 小程序/移动端编辑器
│ │ ├── interface/ # 模板导入界面
│ │ └── materialModal/ # 素材选择弹窗
│ ├── ktuhome/ # 首页工具集(8 个子工具)
│ ├── ktucms/ # CMS 管理后台
│ └── KtuUI/ # 自研 UI 组件库(50+ 组件)
├── package.json
├── .eslintrc.js # ESLint 规则(506 行)
└── commitlint.config.js # 提交信息规范
2.2 高层架构图
2.3 编辑器 UI 布局
三、核心模块深度解析
3.1 元素类型系统(OOP 继承体系)
元素工厂:Ktu.element.processElement(data) 根据 data.type 分发到正确构造函数(textbox/cimage/background/path-group/group/qrcode 等 15+ 类型)。
3.2 状态管理架构
3.3 渲染管线
3.4 初始化流程
四、计算密集型核心算法详解
4.1 向量数学基础类(Vector)
class Vector {
constructor(x, y) { this.x = x; this.y = y; }
getMagnitude() { return Math.sqrt(this.x ** 2 + this.y ** 2); } // ||v||
dot(vector) { return this.x * vector.x + this.y * vector.y; } // 点积,用于投影和角度
perpendicular() { return new Vector(this.y, -this.x); } // 左手法线 (y, -x)
normal() { return this.perpendicular().normalize(); } // 单位法向量
// 标准 2D 旋转矩阵:
// [cos(θ) -sin(θ)] [x] [x·cosθ - y·sinθ]
// [sin(θ) cos(θ)] [y] = [x·sinθ + y·cosθ]
rotate(angle) {
const rad = angle * Math.PI / 180;
return { x: this.x * Math.cos(rad) - this.y * Math.sin(rad),
y: this.x * Math.sin(rad) + this.y * Math.cos(rad) };
}
}
4.2 坐标系统与元素定位
坐标模型:left, top 是旋转后的左上角坐标(不是 AABB 左上角)。
calculateCoords() —— 旋转矩阵计算四角
calculateCoords() {
const w = this.getDimensions().w * this.scaleX;
const h = this.getDimensions().h * this.scaleY;
const rad = this.angle * Math.PI / 180;
const tl = { x: this.left, y: this.top }; // 锚点
const tr = { x: tl.x + w * Math.cos(rad), y: tl.y + w * Math.sin(rad) }; // 宽度方向
const br = { x: tr.x - h * Math.sin(rad), y: tr.y + h * Math.cos(rad) }; // 高度方向
const bl = { x: tl.x - h * Math.sin(rad), y: tl.y + h * Math.cos(rad) };
const center = { x: (tl.x + br.x) / 2, y: (tl.y + br.y) / 2 };
return { tl, tr, br, bl, mt, mr, mb, ml, center };
}
宽度方向 (cosθ, sinθ)
TL ──────────────────────→ TR
│ │
│ 高度方向 (-sinθ, cosθ) │
│ center │
↓ ↓
BL ──────────────────────→ BR
4.3 旋转算法 —— 绕中心点旋转
setAngle(angle) {
const delta = angle - this.angle;
const rad = Math.PI * delta / 180;
const { center } = this.coords;
// TL 到 center 的向量,绕原点旋转 delta 角
const v = { x: this.left - center.x, y: this.top - center.y };
const rotated = {
x: Math.cos(rad) * v.x - Math.sin(rad) * v.y,
y: Math.sin(rad) * v.x + Math.cos(rad) * v.y,
};
this.left = rotated.x + center.x; // 新 TL = center + 旋转后的向量
this.top = rotated.y + center.y;
this.angle = angle;
}
4.4 SAT 碰撞检测(分离轴定理)
理论
两个凸多边形不相交 ⟺ 存在一条分离轴。对两个 OBB,只需检查 4 条轴(每个矩形 2 条边法线)。
createOBB —— 有向包围盒
createOBB(obb, object) {
const rad = (obb.angle || 0) * Math.PI / 180;
return {
width: obb.width, height: obb.height,
centerPoint: new Vector(center.x, center.y),
axes: [
new Vector(Math.cos(rad), Math.sin(rad)), // 宽度轴
new Vector(-Math.sin(rad), Math.cos(rad)), // 高度轴
],
};
}
getProjectionRadius —— 投影半径
// r = (W × |axis · axisW| + H × |axis · axisH|) / 2
getProjectionRadius(obb, axis) {
return (obb.width * Math.abs(axis.dot(obb.axes[0]))
+ obb.height * Math.abs(axis.dot(obb.axes[1]))) / 2;
}
isCollided —— SAT 主流程
isCollided(object, customBox) {
const obbA = this.createOBB(this.selectedBox);
const obbB = this.createOBB(objectOBB, object);
const nv = obbA.centerPoint.subtract(obbB.centerPoint); // 中心连线
// 遍历 4 条轴:每条轴上 "投影半径之和 > 中心距" 则此轴无法分离
return obbA.axes.concat(obbB.axes).every(axis =>
this.getProjectionRadius(obbA, axis)
+ this.getProjectionRadius(obbB, axis) > Math.abs(nv.dot(axis))
);
}
鼠标命中检测:传入 1×1 像素 OBB 做 SAT 检测。
4.5 旋转元素的缩放(最复杂的交互)
方向正负号判定
// 1. 控制点在元素本地坐标系的角度
const controlAngleMap = {
mr: 0, br: includedAngle, mb: 90, bl: 180-includedAngle,
ml: 180, tl: 180+includedAngle, mt: 270, tr: 360-includedAngle,
};
// 2. 屏幕空间实际角度 = 元素旋转角 + 控制点本地角度
const totalAngle = target.angle + controlAngleMap[currentControl];
// 3. 根据象限确定正负号
const plusPosition = cornerList[Math.floor(totalAngle / 90)];
const xSign = plusPosition.includes('r') ? 1 : -1;
const ySign = plusPosition.includes('b') ? 1 : -1;
非等比缩放(中间控制点)
// ml 缩放:宽度方向 = (cos(θ), sin(θ)),TL 沿此方向反向移动
if (currentControl === 'ml') {
offset.scaleX = offsetX / target.width;
offset.left = -offsetX * Math.cos(rad);
offset.top = -offsetX * Math.sin(rad);
}
// mt 缩放:高度方向 = (-sin(θ), cos(θ))
if (currentControl === 'mt') {
offset.scaleY = offsetY / target.height;
offset.left = offsetY * Math.sin(rad);
offset.top = -offsetY * Math.cos(rad);
}
等比缩放(角控制点)
// 按宽高比分配
offsetY = (offsetX + offsetY) / (1 + WHRatio);
offsetX = offsetY * WHRatio;
// tl:沿对角线方向偏移
if (currentControl === 'tl') {
const sideLength = Math.sqrt(offsetX ** 2 + offsetY ** 2);
offset.left = -sideLength * Math.sin(atan(WHRatio) - rad) * sign;
offset.top = -sideLength * Math.cos(atan(WHRatio) - rad) * sign;
}
4.6 SnapLine 吸附对齐
角度吸附:0°/45°/90°/135°/180°/225°/270°/315°,±5° 容差。
4.7 Group 嵌套坐标变换
4.8 图片裁剪坐标系
裁剪维护 imageShapeBox(可见裁剪区)和 imageBox(完整图片区)。变换模式:反旋转 → 本地操作 → 重旋转。
旋转裁剪边界约束使用直线方程:y = (x1·y2 - x2·y1 - (y2-y1)·x) / (x1 - x2) 计算旋转边界交点。
4.9 坐标空间总结
| 空间 | 定义 | 用途 |
|---|---|---|
| 文档空间 | 元素 left/top/scale/angle | 所有计算基准 |
| 屏幕空间 | 文档 × scale + editorClientRect | SAT 碰撞检测 |
| 视口空间 | 文档 × scale + viewOffset | CSS transform |
| Group 本地 | 相对 Group 原点 | getPositionToEditor() 转换 |
| 裁剪本地 | imageShapeBox + imageBox | 反旋转→操作→重旋转 |
五、SVG 滤镜与图片特效系统
5.1 SVG 滤镜架构(filters.src.js)
编辑器实现了一套完整的 基于 SVG Filter Primitives 的图像滤镜链,支持 8 种滤镜效果的自由组合和强度调节。多个滤镜以串联链的方式组合在同一个 <filter> 标签内,SVG 会按顺序依次处理每个 filter primitive,上一个的输出作为下一个的输入。
滤镜类继承体系
滤镜链组合机制
class Filters {
constructor(filters) {
this.type = filters.type || 'origin';
this.intensity = filters.intensity || 0;
this.allFilters = ['vignette', 'brightness', 'contrast', 'saturation',
'tint', 'blur', 'sharpen', 'xProcess'];
// 按名称实例化每个子滤镜
this.allFilters.forEach(name => {
if (filters[name]) {
this[name] = new ClassName(filters[name]);
}
});
}
// 生成完整的 SVG defs + filter 标签
getDefs(options) {
const defsStr = this.allFilters.reduce((s, name) =>
this[name] ? s + this[name].getDefs(options) : s, '');
const filtersStr = this.allFilters.reduce((s, name) =>
this[name] ? s + this[name].getFilter(options) : s, '');
return filtersStr ? `
<defs>
${defsStr}
<filter id="filter_${options.filterId}">
${filtersStr}
</filter>
</defs>` : '';
}
}
关键设计:多个滤镜以串联链的方式组合在同一个 <filter> 标签内,SVG 会按顺序依次处理每个 filter primitive,上一个的输出作为下一个的输入。
各滤镜的 SVG 实现详解
1. 亮度(Brightness)—— feColorMatrix
<feColorMatrix type="matrix" values="
1 0 0 0 {value}
0 1 0 0 {value}
0 0 1 0 {value}
0 0 0 1 0
"/>
原理:SVG 颜色矩阵是 5×4 矩阵,对 RGBA 做线性变换。最后一列是偏移量,给 R/G/B 通道加相同的偏移值实现亮度调节。
| R' | | 1 0 0 0 v | | R | R' = R + v
| G' | = | 0 1 0 0 v | | G | G' = G + v
| B' | | 0 0 1 0 v | | B | B' = B + v
| A' | | 0 0 0 1 0 | | A | A' = A
| 1 |
2. 对比度(Contrast)—— feColorMatrix
const slope = 1.015 * (value + 1.0) / (1.0 * (1.015 - value));
const intercept = -0.5 * slope + 0.5;
<feColorMatrix type="matrix" values="
{slope} 0 0 0 {intercept}
0 {slope} 0 0 {intercept}
0 0 {slope} 0 {intercept}
0 0 0 1 0
"/>
原理:对比度调节的数学本质是以 0.5(中灰)为中心的线性缩放。slope 是缩放系数(>1 增加对比度,<1 降低),intercept = 0.5 - 0.5 * slope 确保 0.5 灰度值不变。
输出 = slope × 输入 + intercept
= slope × (输入 - 0.5) + 0.5 // 以中灰为中心缩放
3. 饱和度(Saturation)—— feColorMatrix saturate
<feColorMatrix type="saturate" values="{value + 1}"/>
原理:SVG 内置的 saturate 类型,value=0 为灰度,value=1 为原始颜色,>1 为过饱和。内部使用加权 RGB 混合矩阵(基于人眼亮度感知系数 0.299R + 0.587G + 0.114B)。
4. 色调/染色(Tint)—— feColorMatrix + 颜色梯度
class Tint extends Filter {
constructor(data) {
this.tintList = ['#ee82ee','#ff0000','#ffa500','#ffff00',
'#008000','#0000ff','#4b0082','#ee82ee'];
this.allTints = this.getTints(); // 生成 200 个过渡色
}
// 通过 RGB 线性插值生成色环渐变
gradient(startColor, endColor, step) {
const rStep = (end.r - start.r) / step;
// ... G, B 同理
for (let i = 0; i < step; i++) {
result.push(rgbToHex(start.r + rStep * i, ...));
}
}
getFilter() {
const tint = this.getColorByNum(this.value); // [-100, 100] → 色环颜色
const rgb = hexToRgb(tint).map(c => c / 255);
const offset = this.alpha; // 0.14 默认透明度
return `<feColorMatrix type="matrix" values="
0.86 0 0 0 ${rgb.r * offset}
0 0.86 0 0 ${rgb.g * offset}
0 0 0.86 0 ${rgb.b * offset}
0 0 0 1 0"/>`;
}
}
原理:
- 预生成一个 200 色的色环:从紫→红→橙→黄→绿→蓝→靛→紫,通过 RGB 线性插值平滑过渡
- UI 滑块值 [-100, 100] 映射到色环中的一个颜色
- 将原图 RGB 通道缩放到 0.86(略微降低原色强度),再叠加色调偏移量(
rgb × alpha)
5. 模糊(Blur)—— feGaussianBlur
<feGaussianBlur stdDeviation="{value}"/>
直接使用 SVG 原生高斯模糊,stdDeviation 控制模糊半径。
6. 锐化(Sharpen)—— feConvolveMatrix
<feConvolveMatrix order="3,3" kernelMatrix="
0 {value} 0
{value} {-4v+1} {value}
0 {value} 0
"/>
原理:使用 3×3 卷积核做拉普拉斯锐化。标准拉普拉斯核为:
0 -1 0 0 v 0
-1 5 -1 → v -4v+1 v (v 为负值时为锐化强度)
0 -1 0 0 v 0
中心值 = -4v + 1,确保核的权重之和为 1(亮度不变),周围为负值提取边缘细节。
7. 交叉处理(XProcess)—— feComponentTransfer + 贝塞尔曲线
class XProcess extends Filter {
getTable(xProcess) {
// 根据 xProcess 正负值,计算 R/G/B 三通道的控制点
// 正值:暖色调(红橙增强,蓝色衰减)
// 负值:冷色调(蓝色增强,红色衰减)
return [redControlPoints, greenControlPoints, blueControlPoints];
}
// 三次贝塞尔曲线插值,生成 LUT 查找表
getRange(p0, p1, p2, p3) {
// B(t) = (1-t)³P0 + 3(1-t)²tP1 + 3(1-t)t²P2 + t³P3
function getXY(t, arr) {
const offset = 1 - t;
return arr[0]*offset**3 + 3*arr[1]*offset**2*t + 3*arr[2]*offset*t**2 + arr[3]*t**3;
}
// 以 0.05 步长采样,生成 tableValues
}
getFilter() {
return `
<feComponentTransfer>
<feFuncR type="table" tableValues="${redLUT}"/>
<feFuncG type="table" tableValues="${greenLUT}"/>
<feFuncB type="table" tableValues="${blueLUT}"/>
</feComponentTransfer>`;
}
}
原理:
feComponentTransfer是 SVG 的通道传输函数,可以独立调整 R/G/B 每个通道的色调曲线- 通过 4 个控制点定义三次贝塞尔曲线,模拟胶片交叉冲洗效果
- 将贝塞尔曲线以 0.05 步长采样,生成
tableValues(LUT 查找表) - 正值 → 暖色调(十字冲洗效果),负值 → 冷色调
8. 暗角(Vignette)—— radialGradient + feImage + feComposite
class Vignette extends Filter {
getDefs(options) {
const startOffset = 90 - this.value; // 暗角起始位置
return `
<radialGradient id="stops_${id}" r="75%">
<stop offset="${startOffset}%" stop-color="rgb(0,0,0)" stop-opacity="0"/>
<stop offset="100%" stop-color="rgb(0,0,0)" stop-opacity="1"/>
</radialGradient>
<rect id="rect_${id}" fill="url(#stops_${id})" width="..." height="..."/>`;
}
getFilter(options) {
return `
<feImage xlink:href="#rect_${id}" result="vignette"/>
<feComposite in="vignette" in2="SourceGraphic"/>`;
}
}
原理:
- 创建径向渐变(
radialGradient):从中心透明渐变到边缘黑色 - 用这个渐变填充一个矩形,生成暗角遮罩
feImage将遮罩引入滤镜管线feComposite将遮罩与原图合成(默认over模式,黑色半透明叠加在原图上)
滤镜链示意
5.2 Canvas 图片特效系统(imageEffects.src.js)
除了 SVG 滤镜,项目还实现了基于 Canvas 像素级操作的高级图片样式系统,用于实现 SVG 无法完成的效果。
轮廓检测 —— Marching Squares 算法
getContour() 实现了经典的 Marching Squares 轮廓追踪算法,用于提取 PNG 图片的透明边界:
getContour(imageData, width, start) {
// 判断像素是否非透明
const defineNonTransparent = (x, y) => (imageData[(y * width + x) * 4 + 3] > 0);
// 查找表:16 种 2×2 网格状态对应的移动方向
const contourDx = [1, 0, 1, 1, -1, 0, -1, 1, 0, 0, 0, 0, -1, 0, -1, undefined];
const contourDy = [0, -1, 0, 0, 0, -1, 0, 0, 1, -1, 1, 1, 0, -1, 0, undefined];
do {
// 取当前点周围 2×2 网格的 4 个像素状态,组合为 4-bit 索引
i = 0;
if (defineNonTransparent(x-1, y-1)) i += 1; // 左上
if (defineNonTransparent(x, y-1)) i += 2; // 右上
if (defineNonTransparent(x-1, y )) i += 4; // 左下
if (defineNonTransparent(x, y )) i += 8; // 右下
// 鞍点(对角相同)的特殊处理:使用上一步方向决定
if (i === 6) { dx = pdy === -1 ? -1 : 1; dy = 0; }
else if (i === 9) { dx = 0; dy = pdx === 1 ? -1 : 1; }
else { dx = contourDx[i]; dy = contourDy[i]; }
// 方向变化时记录拐点
if (dx !== pdx && dy !== pdy) {
c.push([x, y]);
}
x += dx; y += dy;
} while (s[0] != x || s[1] != y); // 回到起点闭合
return c;
}
Marching Squares 原理:
2×2 网格有 16 种可能状态(每个像素透明/不透明):
·─· ·─■ ■─· ■─■ ·─· ·─■ ■─· ■─■
·─· ·─· ·─· ·─· ·─■ ·─■ ·─■ ·─■
0 1 2 3 4 5 6 7
·─· ·─■ ■─· ■─■ ·─· ·─■ ■─· ■─■
■─· ■─· ■─· ■─· ■─■ ■─■ ■─■ ■─■
8 9 10 11 12 13 14 15
每种状态对应一个移动方向(dx, dy),沿透明/不透明的边界行走。
用途:
- PNG 图片的描边(stroke):沿检测到的轮廓绘制描边线条
- 多轮廓提取:
getAllContours()循环调用,每次提取一个轮廓后用clip + clearRect擦除,直到无剩余不透明像素 - JPG 图片优化:无透明通道时直接返回四角坐标
Canvas 高级样式渲染
initStyleCanvas() 基于 Canvas 2D 上下文实现了多种图片特效:
function drawStyleCanvas() {
const cover = this.style.covers[0];
switch (this.style.id) {
case 0: // PNG 描边(支持纯色 + 线性渐变描边)
// 使用 Marching Squares 轮廓 → ctx.stroke()
// 支持 createLinearGradient 渐变描边
break;
case 1: // 阴影效果
// ctx.shadowColor / shadowBlur / shadowOffsetX/Y
break;
case 2: // 图案填充
// createPattern() 平铺/拉伸
break;
// ... 更多高级样式
}
}
关键技术点:
- 描边支持渐变:解析 JSON 格式的渐变配置,用
createLinearGradient创建渐变笔刷 - 防抖机制:
debounceFunc(callback, 100)避免连续调整时频繁重绘 - DOM 缓存:
this.imageDom缓存已加载的图片 DOM,this.patternDom缓存 pattern - 多轮廓描边:对含多个不连通区域的 PNG,依次描边每个轮廓
- 坐标变换:
getStyleBoundingRect()在旋转空间中计算含样式的真实包围盒(反旋转→加偏移→重旋转)
SVG 滤镜 vs Canvas 特效对比
| 维度 | SVG 滤镜(filters.src.js) | Canvas 特效(imageEffects.src.js) |
|---|---|---|
| 渲染方式 | 声明式 SVG Filter Primitives | 命令式 Canvas 2D API |
| 适用场景 | 亮度/对比度/饱和度/模糊等全局调色 | 描边/阴影/图案填充等像素级特效 |
| 性能 | GPU 加速(浏览器原生实现) | CPU 渲染,支持防抖优化 |
| 组合方式 | 串联在 <filter> 标签内自动链式处理 | 手动分步绘制 |
| 输出 | SVG 内联,直接渲染 | Canvas → toDataURL → 替换 DOM |
| 跨端 | 后端 Node.js 可同构 | 仅浏览器端 |
六、技术亮点与难点
6.1 亮点
1. Canvas → SVG 统一渲染架构重构
初始版本编辑用 Canvas + 生成用 SVG → 统一采用 SVG。减少 40% 开发维护成本。SVG 可利用 Vue 响应式驱动更新,天然支持 DOM 事件和 CSS 交互,是图形编辑器类项目的最佳选择。
2. 面向对象的元素类型系统
15+ 种元素类型继承体系,工厂模式创建,统一序列化/反序列化接口,扩展性好。
3. 完整的 2D 图形引擎
自研 SAT 碰撞检测 + OBB 包围盒 + 向量运算库 + 5 层坐标空间互转 + 旋转缩放方向判定。
4. 双引擎滤镜系统
SVG Filter Primitives(8 种滤镜链式组合)+ Canvas 像素级特效(Marching Squares 轮廓检测 + 描边/阴影/图案填充)。前者 GPU 加速高效调色,后者精确像素操作。
5. SnapLine 智能吸附
6 参考线 × N 元素全量检测,4px 自适应缩放阈值,画布边缘 + 元素间双重吸附。
6. SVG 前后端同构
Node.js 复用前端 toSvg() 逻辑,前端只传精简 JSON。降低 70% 保存时间,彻底解决 10M+ 大型 SVG 保存失败问题。
7. 完善的工程化演进
Python + YUI → Gulp + Babel + Sass → Webpack 4 模块化 + ESLint + Commitlint + 自研 KtuUI(50+ 组件,14+ 语言)。
8. 首屏性能优化
懒加载 + WebP + 代码分割 + HTTP 缓存 + IndexedDB。首屏 -40%,CDN 流量 -40%。
6.2 难点
1. 旋转元素的缩放方向判定
旋转后拖拽控制点的屏幕偏移方向不直接对应增大/缩小。需通过 controlAngleMap + totalAngle + 象限映射确定正负号,角控制点和中间控制点的锚点调整逻辑完全不同。这是整个编辑器中最容易出 Bug 的模块。
2. 多层坐标变换
Group 嵌套 + 翻转 + 旋转 + 缩放的 5 步变换链极易出错。
3. SVG 滤镜的数学模型
每种滤镜对应不同的 SVG Filter Primitive 和参数计算:对比度的 slope/intercept 公式、XProcess 的贝塞尔曲线 LUT 生成、锐化的拉普拉斯卷积核。
4. Marching Squares 轮廓追踪
16 种网格状态的方向查找表设计,鞍点特殊处理,多轮廓迭代提取。
5. SVG 字体渲染
自动换行手动计算、字间距逐字符定位、下划线手动绘制、倾斜用 transform 模拟。
6. Canvas → SVG 迁移
默认间距、变换基点、裁切方式、翻转 API 全不同,需逐一确保视觉一致。
七、改进建议
7.1 架构层面
| 建议 | 改进方向 | 优先级 |
|---|---|---|
| TypeScript | 核心模块渐进式迁移,特别是坐标运算和滤镜参数 | 高 |
| Vue 3 + Pinia | Composition API 更适合组织复杂逻辑 | 高 |
| Vite | 替代 Webpack 4,开发体验大幅提升 | 中 |
| 全局对象解耦 | provide/inject 替代 Ktu + defineProperty | 高 |
| XState 状态机 | 管理编辑模式互斥切换,替代标志位 | 中 |
7.2 性能优化
| 建议 | 说明 |
|---|---|
| Web Worker | SAT 碰撞、SnapLine 遍历、滤镜计算移至 Worker |
| 四叉树 | 空间索引加速碰撞检测和吸附查询 O(n) → O(log n) |
| OffscreenCanvas | Worker 内图片处理和缩略图生成 |
| CSS Houdini | 用 Paint Worklet 替代部分 Canvas 特效渲染 |
| WebGPU | 图像滤镜用 GPU Compute Shader 加速 |
7.3 代码质量
filters.src.js 中使用 eval() 动态实例化滤镜类,存在安全风险且不利于 Tree Shaking,应改为映射表方式。
| 建议 | 说明 |
|---|---|
| 单元测试 | 补充坐标运算 / SAT / 撤销重做 / 滤镜参数的测试 |
| gl-matrix | 替代自研 Vector,支持完整矩阵运算 |
| 滤镜 eval 移除 | 动态实例化改为映射表 |
| Plugin 架构 | 元素类型/工具/滤镜通过插件注册 |
八、面试高频问题与深度解答
Q1: 为什么从 Canvas 切换到 SVG?
| 维度 | Canvas | SVG |
|---|---|---|
| 渲染 | 位图/像素级 | 矢量/DOM 节点 |
| 事件 | 需自行命中检测 | 原生 DOM 事件 |
| 缩放 | 放大模糊 | 无损缩放 |
| 编程 | 命令式 | 声明式 |
| 交互 | 光标/选区都要自己写 | 天然 CSS/DOM |
原因:Canvas 交互开发成本极高;双套代码维护翻倍;SVG 可利用 Vue 响应式驱动更新。 保留 Canvas:缩略图、像素级滤镜、图片导出。
Q2: SAT 碰撞检测原理?
两个 OBB 不碰撞 ⟺ 存在分离轴。4 条轴(每个矩形 2 条边法线),对每条轴:投影半径之和 > 中心距 → 无法分离。全部无法分离 → 碰撞。投影半径 r = (W×|axis·axisW| + H×|axis·axisH|) / 2。鼠标命中:1×1 OBB 做 SAT。
Q3: 旋转后的缩放怎么处理?
- 方向判定:
totalAngle = 元素角度 + 控制点本地角度,根据象限确定 X/Y 正负号 - 非等比(中间控制点):只改一个维度,锚点沿对应旋转轴反向移动
- 等比(角控制点):按 W:H 比分配偏移,TL 沿对角线方向偏移(极坐标转笛卡尔)
- 对角固定:
acrossMap映射对角控制点作为锚点
Q4: SVG 滤镜系统是怎么实现的?
8 种滤镜串联在一个 <filter> 标签内,每种对应一个 SVG Filter Primitive:
- 亮度:feColorMatrix 偏移 R/G/B 通道
- 对比度:feColorMatrix 以 0.5 为中心的线性缩放(slope/intercept 公式)
- 饱和度:feColorMatrix saturate 类型
- 色调:预生成 200 色色环 → feColorMatrix 叠加色彩偏移
- 模糊:feGaussianBlur
- 锐化:feConvolveMatrix 3×3 拉普拉斯核
- 交叉处理:feComponentTransfer,三次贝塞尔曲线插值生成 R/G/B 通道 LUT
- 暗角:radialGradient → feImage + feComposite 合成
Q5: 图片描边怎么做的?PNG 透明图片怎么描边?
使用 Marching Squares 轮廓追踪算法:
- 取图片 imageData,遍历 2×2 像素网格
- 4 个像素的透明状态组合为 4-bit 索引(16 种状态)
- 查找表确定移动方向,沿透明/不透明边界行走
- 方向变化时记录拐点,回到起点闭合
- 轮廓描边后
clip + clearRect擦除已处理区域 - 循环直到无剩余不透明像素(支持多个不连通区域)
- 最后用 Canvas
stroke()沿轮廓线绘制描边(支持渐变色描边)
Q6: 撤销/重做怎么实现的?
命令模式 + 快照栈:操作前 saveState() 深拷贝 → 操作后 modifiedState() 深拷贝 → 压入 {before, after, actionType} 历史栈(max 100)。25+ 种 HistoryAction 类型,双向反转映射。新操作清空 pointer 之后的分支。
Q7: 大型 SVG 保存失败怎么解决的?
前端只传精简 JSON → 后端 Node.js 同构 toSvg() 逻辑构建完整 SVG → 生成高清图。降低 70% 保存时间,彻底解决 10M+ 失败问题。
Q8: SnapLine 吸附算法?
6 参考点(左/中/右 × 上/中/下)与所有元素的 6 参考点做 3×3 距离比较,阈值 4px/scale(自适应缩放),同时检测画布边缘。角度吸附到 45° 倍数(±5° 容差)。
Q9: 工程化演进历程?
Q10: 如果重新设计会怎么做?
- Vue 3 + TypeScript + Pinia + Vite
- XState 状态机管理编辑模式
- 四叉树加速碰撞/吸附
- Web Worker 执行计算密集操作
- WebGPU 加速滤镜
- CRDT 支持多人协作
- Plugin 架构提升扩展性
- gl-matrix 替代 Vector
- Vitest + Playwright 测试体系
附加高频追问
| 问题 | 考察点 |
|---|---|
| feColorMatrix 的 5×4 矩阵每个参数含义? | SVG 滤镜原理 |
| 贝塞尔曲线的参数方程?如何用于 LUT 生成? | 数学基础 |
| 卷积核的权重之和为什么要等于 1? | 图像处理原理 |
| Marching Squares 的 16 种状态怎么设计查找表? | 算法设计 |
| 2D 旋转矩阵的推导? | 线性代数 |
| 点积的几何意义?项目中哪些地方用到? | SAT 投影 + 角度计算 + 旋转偏移 |
| 四叉树如何优化碰撞检测? | 空间索引 |
| Vue 2 defineProperty 的局限? | 响应式原理 |
| requestAnimationFrame vs setTimeout | 浏览器渲染机制 |
| Web Worker 通信开销?Transferable Objects? | 多线程性能 |
| CRDT vs OT?实时协作怎么做? | 协同编辑 |
| Canvas toDataURL 有什么限制? | 跨域/内存/格式 |
本文档基于对
src/ktu/完整源码的深度扫描生成,涵盖 editor(196 个 Vue 组件 + 35 个基类文件 + 20 个 mixin + 14 个全局模块)、mEditor、interface、materialModal 四个核心子模块。所有算法分析(SAT/坐标变换/旋转缩放/吸附/SVG 滤镜/Marching Squares)均源自实际源码。