乐于分享
好东西不私藏

我做了个拖拽式简历编辑器,颜值和性能都拉满了

我做了个拖拽式简历编辑器,颜值和性能都拉满了

夏至未至,我做了个拖拽式简历编辑器。

一个基于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(29)}`;
}

撤销/重做系统 (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<numberany>>(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();
  };
};