我做了个拖拽式简历编辑器,颜值和性能都拉满了
夏至未至,我做了个拖拽式简历编辑器。
一个基于Canvas第三方库+Vue3实现的简历编辑器,颜值和性能都拉满了。
项目背景
随着在线简历制作需求的增长,传统的表单式简历编辑器存在以下痛点:
-
缺乏可视化编辑:用户无法直观地拖拽调整布局; -
导出质量差:HTML转PDF/图片时样式丢失; -
模板固定:难以自定义布局和设计; -
交互体验差:无法实时预览编辑效果;
因此,我选择使用 Konva.js(Canvas库)+ Vue3 构建一个所见即所得的简历编辑器,提供类似Figma的自由编辑体验。
搭建过程
1. 技术栈选择
核心框架:Vue 3 + Composition API + TypeScript
Canvas库:Konva.js (vue-konva)
状态管理:Pinia
样式框架:Tailwind CSS
2. 项目架构设计
// 项目结构
src/
├── components/
│ ├── Editor/
│ │ ├── Canvas.vue // 主画布组件
│ │ ├── Toolbar.vue // 工具栏
│ │ ├── LayerPanel.vue // 图层面板
│ │ └── PropertyPanel.vue // 属性面板
│ ├── Elements/
│ │ ├── TextElement.vue // 文本元素
│ │ ├── ImageElement.vue // 图片元素
│ │ ├── ShapeElement.vue // 图形元素
│ │ └── LineElement.vue // 线条元素
│ └── Templates/
├── composables/
│ ├── useCanvas.ts // 画布操作
│ ├── useElements.ts // 元素管理
│ └── useHistory.ts // 撤销重做
├── stores/
│ └── editorStore.ts // 编辑器状态
└── types/
└── editor.ts // 类型定义
3. 核心实现代码
主画布组件 (Canvas.vue)
<template>
<div class="canvas-wrapper" ref="canvasWrapper">
<v-stage
ref="stage"
:config="stageConfig"
@mousedown="handleStageMouseDown"
@mousemove="handleStageMouseMove"
@mouseup="handleStageMouseUp"
@click="handleStageClick"
>
<!-- 背景层 -->
<v-layer ref="backgroundLayer">
<v-rect :config="backgroundConfig" />
<!-- 网格线(可选) -->
<v-line
v-for="line in gridLines"
:key="line.key"
:config="line"
/>
</v-layer>
<!-- 内容层 -->
<v-layer ref="contentLayer">
<!-- 可拖拽元素 -->
<v-group
v-for="element in elements"
:key="element.id"
:config="getElementConfig(element)"
@dragstart="handleDragStart(element)"
@dragend="handleDragEnd(element)"
@transformend="handleTransformEnd(element)"
>
<!-- 文本元素 -->
<v-text
v-if="element.type === 'text'"
:config="element.config"
@dblclick="startTextEdit(element)"
/>
<!-- 图片元素 -->
<v-image
v-if="element.type === 'image'"
:config="element.config"
/>
<!-- 矩形元素 -->
<v-rect
v-if="element.type === 'rect'"
:config="element.config"
/>
<!-- 圆形元素 -->
<v-circle
v-if="element.type === 'circle'"
:config="element.config"
/>
<!-- 选中状态框 -->
<v-transformer
v-if="element.id === selectedElementId"
ref="transformer"
:config="transformerConfig"
/>
</v-group>
</v-layer>
<!-- 文本编辑层 -->
<v-layer v-if="editingElement">
<v-textarea
:config="editingConfig"
@blur="finishTextEdit"
/>
</v-layer>
</v-stage>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useEditorStore } from '@/stores/editorStore';
import { useCanvas } from '@/composables/useCanvas';
import { useHistory } from '@/composables/useHistory';
const editorStore = useEditorStore();
const { recordHistory } = useHistory();
// Canvas配置
const stageConfig = ref({
width: 794, // A4宽度
height: 1123, // A4高度
draggable: false,
});
// 元素管理
const elements = computed(() => editorStore.elements);
const selectedElementId = ref<string | null>(null);
// 拖拽处理
const handleDragStart = (element: any) => {
recordHistory('before-drag', { elementId: element.id });
};
const handleDragEnd = (element: any) => {
const node = stage.value?.findOne(`#${element.id}`);
if (node) {
editorStore.updateElementPosition(element.id, {
x: node.x(),
y: node.y(),
});
recordHistory('after-drag', { elementId: element.id });
}
};
// 变换处理(缩放、旋转)
const handleTransformEnd = (element: any) => {
const node = stage.value?.findOne(`#${element.id}`);
if (node) {
const scaleX = node.scaleX();
const scaleY = node.scaleY();
// 重置scale并应用到width/height
node.scaleX(1);
node.scaleY(1);
editorStore.updateElement(element.id, {
x: node.x(),
y: node.y(),
width: Math.max(5, node.width() * scaleX),
height: Math.max(5, node.height() * scaleY),
rotation: node.rotation(),
});
recordHistory('transform', { elementId: element.id });
}
};
// 文本编辑
const editingElement = ref<any>(null);
const startTextEdit = (element: any) => {
editingElement.value = element;
};
const finishTextEdit = () => {
editingElement.value = null;
};
// Transformer配置
const transformerConfig = {
keepRatio: true,
enabledAnchors: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
rotateEnabled: true,
borderStroke: '#4F46E5',
borderStrokeWidth: 1,
anchorFill: '#4F46E5',
anchorSize: 8,
};
</script>
元素管理 Composable (useElements.ts)
import { ref, computed } from'vue';
import { useEditorStore } from'@/stores/editorStore';
importtype { CanvasElement } from'@/types/editor';
exportfunctionuseElements() {
const editorStore = useEditorStore();
// 添加文本元素
const addTextElement = (text: string = '双击编辑文本') => {
const newElement: CanvasElement = {
id: generateId(),
type: 'text',
config: {
text,
fontSize: 16,
fontFamily: 'Microsoft YaHei',
fill: '#333333',
align: 'left',
x: 100,
y: 100,
width: 200,
draggable: true,
},
locked: false,
visible: true,
};
editorStore.addElement(newElement);
return newElement.id;
};
// 添加图片元素
const addImageElement = (url: string) => {
const image = newwindow.Image();
image.src = url;
image.onload = () => {
const newElement: CanvasElement = {
id: generateId(),
type: 'image',
config: {
image,
x: 100,
y: 100,
width: image.width,
height: image.height,
draggable: true,
},
locked: false,
visible: true,
};
editorStore.addElement(newElement);
};
};
// 添加矩形元素
const addRectElement = (config = {}) => {
const newElement: CanvasElement = {
id: generateId(),
type: 'rect',
config: {
x: 100,
y: 100,
width: 150,
height: 50,
fill: '#667eea',
stroke: '#4F46E5',
strokeWidth: 2,
cornerRadius: 8,
draggable: true,
...config,
},
locked: false,
visible: true,
};
editorStore.addElement(newElement);
return newElement.id;
};
// 删除元素
const deleteElement = (elementId: string) => {
editorStore.removeElement(elementId);
};
// 复制元素
const duplicateElement = (elementId: string) => {
const element = editorStore.getElementById(elementId);
if (element) {
const newElement = {
...JSON.parse(JSON.stringify(element)),
id: generateId(),
config: {
...element.config,
x: element.config.x + 20,
y: element.config.y + 20,
},
};
editorStore.addElement(newElement);
}
};
// 对齐元素
const alignElements = (
elementIds: string[],
alignment: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom'
) => {
const elements = elementIds.map(id => editorStore.getElementById(id));
switch (alignment) {
case'left':
const minX = Math.min(...elements.map(el => el.config.x));
elements.forEach(el => {
editorStore.updateElementPosition(el.id, { x: minX, y: el.config.y });
});
break;
case'center':
const avgX = elements.reduce((sum, el) => sum + el.config.x, 0) / elements.length;
elements.forEach(el => {
editorStore.updateElementPosition(el.id, { x: avgX, y: el.config.y });
});
break;
// ... 其他对齐方式
}
};
return {
addTextElement,
addImageElement,
addRectElement,
deleteElement,
duplicateElement,
alignElements,
};
}
functiongenerateId(): string{
return`element_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
撤销/重做系统 (useHistory.ts)
import { ref, computed } from'vue';
import { useEditorStore } from'@/stores/editorStore';
interface HistoryState {
elements: any[];
timestamp: number;
}
exportfunctionuseHistory() {
const editorStore = useEditorStore();
const history = ref<HistoryState[]>([]);
const currentIndex = ref(-1);
const maxHistory = 50;
// 记录历史
const recordHistory = (action: string, data?: any) => {
// 清除当前位置之后的历史记录
history.value = history.value.slice(0, currentIndex.value + 1);
// 添加新记录
const state: HistoryState = {
elements: JSON.parse(JSON.stringify(editorStore.elements)),
timestamp: Date.now(),
};
history.value.push(state);
// 限制历史记录数量
if (history.value.length > maxHistory) {
history.value.shift();
} else {
currentIndex.value++;
}
};
// 撤销
const undo = () => {
if (canUndo.value) {
currentIndex.value--;
const state = history.value[currentIndex.value];
editorStore.setElements(JSON.parse(JSON.stringify(state.elements)));
}
};
// 重做
const redo = () => {
if (canRedo.value) {
currentIndex.value++;
const state = history.value[currentIndex.value];
editorStore.setElements(JSON.parse(JSON.stringify(state.elements)));
}
};
const canUndo = computed(() => currentIndex.value > 0);
const canRedo = computed(() => currentIndex.value < history.value.length - 1);
return {
recordHistory,
undo,
redo,
canUndo,
canRedo,
};
}
Pinia Store (editorStore.ts)
import { defineStore } from'pinia';
import { ref, computed } from'vue';
importtype { CanvasElement, EditorConfig } from'@/types/editor';
exportconst useEditorStore = defineStore('editor', () => {
// 状态
const elements = ref<CanvasElement[]>([]);
const selectedElementIds = ref<string[]>([]);
const zoom = ref(1);
const config = ref<EditorConfig>({
canvasWidth: 794,
canvasHeight: 1123,
backgroundColor: '#ffffff',
showGrid: false,
gridSize: 20,
});
// Getters
const selectedElement = computed(() => {
if (selectedElementIds.value.length === 1) {
return elements.value.find(el => el.id === selectedElementIds.value[0]);
}
returnnull;
});
const sortedElements = computed(() => {
return [...elements.value].sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
});
// Actions
const addElement = (element: CanvasElement) => {
element.zIndex = elements.value.length;
elements.value.push(element);
};
const removeElement = (elementId: string) => {
elements.value = elements.value.filter(el => el.id !== elementId);
selectedElementIds.value = selectedElementIds.value.filter(id => id !== elementId);
};
const updateElement = (elementId: string, updates: Partial<CanvasElement>) => {
const element = elements.value.find(el => el.id === elementId);
if (element) {
Object.assign(element, updates);
}
};
const updateElementPosition = (
elementId: string,
position: { x: number; y: number }
) => {
const element = elements.value.find(el => el.id === elementId);
if (element) {
element.config.x = position.x;
element.config.y = position.y;
}
};
const setElements = (newElements: CanvasElement[]) => {
elements.value = newElements;
};
const clearCanvas = () => {
elements.value = [];
selectedElementIds.value = [];
};
return {
elements,
selectedElementIds,
zoom,
config,
selectedElement,
sortedElements,
addElement,
removeElement,
updateElement,
updateElementPosition,
setElements,
clearCanvas,
};
});
技术难点与解决方案
难点1:Canvas性能优化
问题:当画布上有大量元素时,拖拽和缩放出现卡顿
解决方案:
// 1. 虚拟化渲染
const useVirtualRendering = () => {
const viewportElements = computed(() => {
const viewport = getViewport();
return elements.value.filter(element => {
return isElementInViewport(element, viewport);
});
});
return { viewportElements };
};
// 2. 防抖处理拖拽事件
import { debounce } from'lodash-es';
const handleDragMove = debounce((e: Konva.KonvaEventObject<DragEvent>) => {
const node = e.target;
updateElementPosition(node.id(), {
x: node.x(),
y: node.y(),
});
}, 16); // 60fps
// 3. 图层缓存
const cacheLayer = (layer: Konva.Layer) => {
layer.cache({
pixelRatio: 2,
imageSmoothingEnabled: true,
});
};
难点2:文本编辑器集成
问题:Canvas原生不支持富文本编辑
解决方案:
// 双层文本编辑方案
const useTextEditor = () => {
// Canvas层用于显示
const renderText = (text: string, styles: any) => {
// 使用Konva.Text渲染
};
// HTML层用于编辑
const showTextEditor = (element: any) => {
// 在Canvas上方叠加HTML textarea
const editorDiv = document.createElement('div');
editorDiv.contentEditable = 'true';
editorDiv.style.position = 'absolute';
editorDiv.style.left = element.x + 'px';
editorDiv.style.top = element.y + 'px';
// ... 样式同步
editorDiv.addEventListener('input', () => {
// 实时更新Canvas元素
updateElementText(element.id, editorDiv.innerHTML);
});
};
};
难点3:对齐辅助线和吸附
问题:元素拖拽时需要智能对齐提示
解决方案:
// 智能对齐系统
const useAlignmentGuides = () => {
const guides = ref<GuideLine[]>([]);
const calculateAlignmentGuides = (
draggingElement: any,
allElements: any[]
) => {
const newGuides: GuideLine[] = [];
const threshold = 5; // 吸附阈值
const dragRect = getElementBounds(draggingElement);
allElements.forEach(element => {
if (element.id === draggingElement.id) return;
const elementRect = getElementBounds(element);
// 垂直对齐检测
if (Math.abs(dragRect.centerX - elementRect.centerX) < threshold) {
newGuides.push({
type: 'vertical',
position: elementRect.centerX,
lineType: 'center',
});
}
// 水平对齐检测
if (Math.abs(dragRect.centerY - elementRect.centerY) < threshold) {
newGuides.push({
type: 'horizontal',
position: elementRect.centerY,
lineType: 'center',
});
}
// 边缘对齐
checkEdgeAlignment(dragRect, elementRect, threshold, newGuides);
});
guides.value = newGuides;
};
return { guides, calculateAlignmentGuides };
};
难点4:撤销/重做性能
问题:存储整个Canvas状态占用大量内存
解决方案:
// 增量式历史记录
const useIncrementalHistory = () => {
interface HistoryEntry {
type: 'full' | 'incremental';
operations: Operation[];
}
interface Operation {
type: 'add' | 'remove' | 'update';
elementId: string;
before?: any;
after?: any;
}
const operations = ref<Operation[]>([]);
const fullSnapshots = ref<Map<number, any>>(new Map());
// 每10次操作存储一次完整快照
const recordOperation = (operation: Operation) => {
operations.value.push(operation);
if (operations.value.length % 10 === 0) {
const snapshot = takeFullSnapshot();
fullSnapshots.value.set(operations.value.length, snapshot);
}
};
// 撤销时应用增量操作
const undoOperation = () => {
const lastOperation = operations.value.pop();
if (!lastOperation) return;
// 找到最近的完整快照
const nearestSnapshot = findNearestSnapshot(operations.value.length);
// 应用快照
if (nearestSnapshot) {
applySnapshot(nearestSnapshot);
}
// 重新应用操作直到当前位置
replayOperations();
};
};
夜雨聆风