用 Vue3 + Canvas 做了个超实用的水印工具,同事都在抢着用

大家好,我是大华!

在公众号运营和内容创作中,图片版权保护是一个非常重要的问题。今天我分享一个基于Vue3图片水印添加的源码和实现步骤,即使你是前端新手也能轻松掌握!

一、项目效果预览

我们最终将实现一个这样的工具:

  • 支持上传本地图片
  • 两种水印模式:单水印(9个位置可选)和平铺水印
  • 完全自定义水印样式:文本、字体、大小、颜色、透明度、旋转角度
  • 实时预览效果
  • 一键下载带水印的图片

效果图

完整源码在文末可直接复制。

二、环境准备

这个项目只需要一个HTML文件,不需要复杂的开发环境:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue3 图片水印添加器</title>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>
<body>
  <!-- 我们的代码将写在这里 -->
</body>
</html>

三、项目结构设计

让我们先规划一下页面的整体结构:

scss 复制代码
容器 (Container)
├── 头部 (Header)
├── 主要内容区域 (App Content)
    ├── 控制面板 (Controls)
    │   ├── 图片上传区域
    │   ├── 水印模式选择
    │   ├── 水印设置
    │   ├── 位置控制(单水印模式)
    │   ├── 平铺设置(平铺模式)
    │   └── 操作按钮
    └── 预览区域 (Preview)
        ├── 图片预览容器
        └── 预览信息

四、核心代码详解

1. Vue3应用初始化

javascript 复制代码
const { createApp, ref, watch, onMounted } = Vue;

createApp({
  setup() {
    // 这里写我们的逻辑代码
  }
}).mount('#app');

简单解释:

  • createApp:创建Vue应用
  • ref:用于创建响应式数据
  • watch:监听数据变化
  • onMounted:页面加载完成后执行

2. 数据定义

javascript 复制代码
const imageSrc = ref('');  // 存储上传的图片
const watermarkCanvas = ref(null);  // 画布元素引用

// 水印配置数据
const watermark = ref({
  mode: 'single',        // 模式:single(单水印) 或 tile(平铺)
  text: '示例水印',       // 水印文字
  fontFamily: 'Microsoft YaHei',  // 字体
  fontSize: 24,          // 字体大小
  color: '#ffffff',      // 颜色
  opacity: 0.7,          // 透明度
  rotation: -15,         // 旋转角度
  position: 'bottom-right',  // 位置(单水印模式)
  tileSpacingX: 100,     // 水平间距(平铺模式)
  tileSpacingY: 100      // 垂直间距(平铺模式)
});

响应式数据说明: 当这些数据发生变化时,页面会自动更新,这就是Vue3的响应式特性。

3. 图片上传功能

javascript 复制代码
// 触发文件选择
const triggerFileInput = () => {
  document.getElementById('fileInput').click();
};

// 处理图片上传
const handleImageUpload = (event) => {
  const file = event.target.files[0];
  if (file) {
    const reader = new FileReader();
    reader.onload = (e) => {
      imageSrc.value = e.target.result;  // 将图片转为base64格式
    };
    reader.readAsDataURL(file);
  }
};

FileReader小知识: FileReader是浏览器提供的API,可以读取文件内容。readAsDataURL方法将文件读取为Data URL格式,可以直接用在img标签的src属性中。

4. 水印绘制核心逻辑

这是整个项目最核心的部分,我们分步骤来看:

准备工作

javascript 复制代码
const drawWatermark = () => {
  if (!imageSrc.value || !watermarkCanvas.value) return;
  
  const canvas = watermarkCanvas.value;
  const ctx = canvas.getContext('2d');
  const img = new Image();
  
  img.onload = () => {
    // 设置canvas尺寸与图片一致
    canvas.width = img.width;
    canvas.height = img.height;
    
    // 清除之前的绘制内容
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    
    // 设置水印样式
    ctx.font = `${watermark.value.fontSize}px ${watermark.value.fontFamily}`;
    ctx.fillStyle = watermark.value.color;
    ctx.globalAlpha = watermark.value.opacity;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    
    // 根据模式绘制水印
    if (watermark.value.mode === 'single') {
      drawSingleWatermark(ctx, canvas);
    } else {
      drawTiledWatermark(ctx, canvas);
    }
  };
  
  img.src = imageSrc.value;
};

单水印模式绘制

javascript 复制代码
const drawSingleWatermark = (ctx, canvas) => {
  let x, y;
  const padding = 20;  // 距离边缘的间距
  
  // 根据选择的位置计算坐标
  switch(watermark.value.position) {
    case 'top-left':
      x = padding;
      y = padding;
      ctx.textAlign = 'left';
      ctx.textBaseline = 'top';
      break;
    case 'top-center':
      x = canvas.width / 2;
      y = padding;
      ctx.textBaseline = 'top';
      break;
    // ... 其他7个位置的类似计算
  }
  
  // 应用旋转效果
  ctx.save();                    // 保存当前画布状态
  ctx.translate(x, y);           // 移动坐标原点到水印位置
  ctx.rotate(watermark.value.rotation * Math.PI / 180);  // 旋转
  ctx.fillText(watermark.value.text, 0, 0);  // 绘制文字
  ctx.restore();                 // 恢复画布状态
};

Canvas绘图要点:

  • save()restore():保存和恢复画布状态,避免旋转影响后续绘制
  • translate():移动坐标原点
  • rotate():旋转画布,参数是弧度(角度×π/180)

平铺水印模式绘制

javascript 复制代码
const drawTiledWatermark = (ctx, canvas) => {
  // 计算文字占据的宽度和高度
  const textWidth = ctx.measureText(watermark.value.text).width;
  const textHeight = watermark.value.fontSize;
  
  // 计算每个水印单元的大小(文字+间距)
  const unitWidth = textWidth + watermark.value.tileSpacingX;
  const unitHeight = textHeight + watermark.value.tileSpacingY;
  
  // 计算需要绘制多少行和列(+1确保覆盖整个图片)
  const cols = Math.ceil(canvas.width / unitWidth) + 1;
  const rows = Math.ceil(canvas.height / unitHeight) + 1;
  
  // 循环绘制每个水印
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      const x = j * unitWidth;
      const y = i * unitHeight;
      
      // 交错排列,让平铺效果更自然
      const offsetX = (i % 2) * (unitWidth / 2);
      
      ctx.save();
      ctx.translate(x + offsetX, y);
      ctx.rotate(watermark.value.rotation * Math.PI / 180);
      ctx.fillText(watermark.value.text, 0, 0);
      ctx.restore();
    }
  }
};

5. 图片下载功能

javascript 复制代码
const downloadImage = () => {
  if (!imageSrc.value) return;
  
  // 创建临时canvas
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  const img = new Image();
  
  img.onload = () => {
    // 设置canvas尺寸
    canvas.width = img.width;
    canvas.height = img.height;
    
    // 先绘制原始图片
    ctx.drawImage(img, 0, 0);
    
    // 再绘制水印(逻辑与预览时相同)
    // ... 水印绘制代码
    
    // 创建下载链接
    const link = document.createElement('a');
    link.download = 'watermarked-image.png';
    link.href = canvas.toDataURL('image/png');
    link.click();  // 触发下载
  };
  
  img.src = imageSrc.value;
};

下载原理:

  1. 创建隐藏的canvas
  2. 先画原始图片,再画水印
  3. 将canvas转为Data URL
  4. 创建a标签触发下载

6. 响应式更新

javascript 复制代码
// 监听水印设置变化,实时更新预览
watch(watermark, () => {
  drawWatermark();
}, { deep: true });

// 监听图片变化
watch(imageSrc, () => {
  if (imageSrc.value) {
    setTimeout(drawWatermark, 100);
  }
});

watch函数说明:

  • 第一个参数:要监听的数据
  • 第二个参数:数据变化时执行的回调函数
  • deep: true:深度监听,对象内部属性变化也会触发

五、界面设计与CSS技巧

1. 整体布局

使用Flexbox实现响应式布局:

css 复制代码
.app-content {
  display: flex;
  flex-wrap: wrap;
  gap: 20px;  /* 元素间距 */
}

.controls {
  flex: 1;
  min-width: 320px;  /* 最小宽度,确保在小屏幕上也能正常显示 */
}

.preview {
  flex: 2;
  min-width: 500px;
}

2. 网格布局实现9宫格位置选择

css 复制代码
.position-controls {
  display: grid;
  grid-template-columns: repeat(3, 1fr);  /* 3列等宽 */
  gap: 5px;  /* 格子间距 */
}

3. 视觉反馈

使用CSS过渡效果增强用户体验:

css 复制代码
button {
  transition: all 0.3s;  /* 所有属性变化都有0.3秒过渡 */
}

button:hover {
  transform: translateY(-2px);  /* 悬停时轻微上移 */
  box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);  /* 添加阴影 */
}

六、使用指南

1. 基本使用步骤

  1. 上传图片:点击"选择图片"按钮选择本地图片
  2. 选择模式:根据需求选择"单水印"或"平铺水印"
  3. 设置水印
    • 单水印模式:调整位置、文字、样式
    • 平铺模式:调整间距、文字、样式
  4. 实时预览:右侧查看效果
  5. 下载图片:满意后点击下载

2. 水印设置建议

  • 文字颜色:建议使用白色或浅灰色,通过透明度调节效果
  • 字体大小:根据图片尺寸调整,一般20-40px比较合适
  • 旋转角度:-15°到-45°的斜向水印更难被去除
  • 透明度:0.5-0.8之间既能看清又不会太突兀

七、技术亮点总结

  1. 纯前端实现:所有操作在浏览器完成,图片不上传服务器
  2. 实时预览:所有修改立即显示效果
  3. Canvas绘图:使用HTML5 Canvas实现精准的水印绘制
  4. Vue3响应式:数据驱动视图,代码简洁易维护

八、扩展思路

这个基础版本还可以继续增强:

  1. 图片水印:支持上传Logo图片作为水印
  2. 批量处理:一次为多张图片添加水印
  3. 模板保存:保存常用的水印设置
  4. 更多样式:文字阴影、描边等特效
  5. 压缩优化:下载时自动压缩图片大小

完整代码

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue3 图片水印添加器</title>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    }
    
    body {
      background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
      min-height: 100vh;
      padding: 20px;
      display: flex;
      justify-content: center;
      align-items: center;
    }
    
    .container {
      max-width: 1400px;
      width: 100%;
      background: white;
      border-radius: 15px;
      box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
      overflow: hidden;
    }
    
    header {
      background: linear-gradient(90deg, #4b6cb7 0%, #182848 100%);
      color: white;
      padding: 20px;
      text-align: center;
    }
    
    h1 {
      font-size: 28px;
      margin-bottom: 10px;
    }
    
    .subtitle {
      font-size: 16px;
      opacity: 0.8;
    }
    
    .app-content {
      display: flex;
      flex-wrap: wrap;
      padding: 20px;
      gap: 20px;
    }
    
    .controls {
      flex: 1;
      min-width: 320px;
      padding: 20px;
      background: #f8f9fa;
      border-radius: 10px;
    }
    
    .preview {
      flex: 2;
      min-width: 500px;
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 20px;
    }
    
    .control-group {
      margin-bottom: 20px;
    }
    
    h2 {
      font-size: 18px;
      margin-bottom: 15px;
      color: #2c3e50;
      border-bottom: 1px solid #eaeaea;
      padding-bottom: 8px;
    }
    
    label {
      display: block;
      margin-bottom: 8px;
      font-weight: 500;
      color: #34495e;
    }
    
    input, select {
      width: 100%;
      padding: 10px;
      border: 1px solid #ddd;
      border-radius: 5px;
      font-size: 14px;
    }
    
    input[type="color"] {
      height: 40px;
      padding: 3px;
    }
    
    input[type="range"] {
      padding: 0;
    }
    
    .range-value {
      display: inline-block;
      width: 40px;
      text-align: center;
      margin-left: 10px;
    }
    
    .button-group {
      display: flex;
      gap: 10px;
      margin-top: 20px;
    }
    
    button {
      flex: 1;
      padding: 12px;
      border: none;
      border-radius: 5px;
      font-weight: 600;
      cursor: pointer;
      transition: all 0.3s;
    }
    
    .upload-btn {
      background: #3498db;
      color: white;
    }
    
    .download-btn {
      background: #2ecc71;
      color: white;
    }
    
    .reset-btn {
      background: #e74c3c;
      color: white;
    }
    
    button:hover {
      transform: translateY(-2px);
      box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
    }
    
    button:disabled {
      background: #bdc3c7;
      cursor: not-allowed;
      transform: none;
      box-shadow: none;
    }
    
    .preview-container {
      width: 100%;
      max-width: 700px;
      border: 1px solid #eaeaea;
      border-radius: 10px;
      overflow: hidden;
      position: relative;
      background: #f8f9fa;
      min-height: 400px;
      display: flex;
      justify-content: center;
      align-items: center;
    }
    
    #previewImage {
      max-width: 100%;
      max-height: 500px;
      display: block;
    }
    
    .watermark-canvas {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      pointer-events: none;
    }
    
    .upload-placeholder {
      text-align: center;
      color: #7f8c8d;
      padding: 40px;
    }
    
    .upload-placeholder i {
      font-size: 48px;
      margin-bottom: 15px;
      display: block;
      color: #bdc3c7;
    }
    
    .position-controls {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 5px;
      margin-top: 10px;
    }
    
    .position-btn {
      padding: 10px;
      background: #ecf0f1;
      border: 1px solid #bdc3c7;
      border-radius: 5px;
      text-align: center;
      cursor: pointer;
      transition: all 0.2s;
    }
    
    .position-btn.active {
      background: #3498db;
      color: white;
      border-color: #2980b9;
    }
    
    .position-btn:hover {
      background: #d5dbdb;
    }
    
    .position-btn.active:hover {
      background: #2980b9;
    }
    
    .mode-controls {
      display: flex;
      gap: 10px;
      margin-top: 10px;
    }
    
    .mode-btn {
      flex: 1;
      padding: 10px;
      background: #ecf0f1;
      border: 1px solid #bdc3c7;
      border-radius: 5px;
      text-align: center;
      cursor: pointer;
      transition: all 0.2s;
    }
    
    .mode-btn.active {
      background: #9b59b6;
      color: white;
      border-color: #8e44ad;
    }
    
    .mode-btn:hover {
      background: #d5dbdb;
    }
    
    .mode-btn.active:hover {
      background: #8e44ad;
    }
    
    .tile-controls {
      margin-top: 15px;
      padding: 15px;
      background: #e8f4fc;
      border-radius: 8px;
      border-left: 4px solid #3498db;
    }
    
    @media (max-width: 768px) {
      .app-content {
        flex-direction: column;
      }
      
      .controls, .preview {
        min-width: 100%;
      }
    }
  </style>
</head>
<body>
  <div id="app">
    <div class="container">
      <header>
        <h1>Vue3 图片水印添加器</h1>
        <p class="subtitle">上传图片,添加自定义水印,支持单水印和平铺模式</p>
      </header>
      
      <div class="app-content">
        <div class="controls">
          <div class="control-group">
            <h2>图片上传</h2>
            <input type="file" id="fileInput" @change="handleImageUpload" accept="image/*" style="display: none;">
            <button class="upload-btn" @click="triggerFileInput">选择图片</button>
          </div>
          
          <div class="control-group">
            <h2>水印模式</h2>
            <div class="mode-controls">
              <div class="mode-btn" 
                   :class="{active: watermark.mode === 'single'}" 
                   @click="watermark.mode = 'single'">单水印</div>
              <div class="mode-btn" 
                   :class="{active: watermark.mode === 'tile'}" 
                   @click="watermark.mode = 'tile'">平铺水印</div>
            </div>
          </div>
          
          <div class="control-group">
            <h2>水印设置</h2>
            <label for="watermarkText">水印文本</label>
            <input type="text" id="watermarkText" v-model="watermark.text" placeholder="输入水印文本">
            
            <label for="fontFamily">字体</label>
            <select id="fontFamily" v-model="watermark.fontFamily">
              <option value="Arial">Arial</option>
              <option value="Verdana">Verdana</option>
              <option value="Helvetica">Helvetica</option>
              <option value="Tahoma">Tahoma</option>
              <option value="Times New Roman">Times New Roman</option>
              <option value="Courier New">Courier New</option>
              <option value="Georgia">Georgia</option>
              <option value="Microsoft YaHei">微软雅黑</option>
              <option value="SimHei">黑体</option>
              <option value="SimSun">宋体</option>
            </select>
            
            <label for="fontSize">字体大小: <span class="range-value">{{ watermark.fontSize }}px</span></label>
            <input type="range" id="fontSize" v-model.number="watermark.fontSize" min="10" max="60" step="1">
            
            <label for="color">颜色</label>
            <input type="color" id="color" v-model="watermark.color">
            
            <label for="opacity">透明度: <span class="range-value">{{ watermark.opacity }}</span></label>
            <input type="range" id="opacity" v-model.number="watermark.opacity" min="0.1" max="1" step="0.1">
            
            <label for="rotation">旋转角度: <span class="range-value">{{ watermark.rotation }}°</span></label>
            <input type="range" id="rotation" v-model.number="watermark.rotation" min="0" max="360" step="1">
          </div>
          
          <div class="control-group" v-if="watermark.mode === 'single'">
            <h2>水印位置</h2>
            <div class="position-controls">
              <div class="position-btn" 
                   :class="{active: watermark.position === 'top-left'}" 
                   @click="watermark.position = 'top-left'">左上</div>
              <div class="position-btn" 
                   :class="{active: watermark.position === 'top-center'}" 
                   @click="watermark.position = 'top-center'">中上</div>
              <div class="position-btn" 
                   :class="{active: watermark.position === 'top-right'}" 
                   @click="watermark.position = 'top-right'">右上</div>
              <div class="position-btn" 
                   :class="{active: watermark.position === 'center-left'}" 
                   @click="watermark.position = 'center-left'">左中</div>
              <div class="position-btn" 
                   :class="{active: watermark.position === 'center'}" 
                   @click="watermark.position = 'center'">中心</div>
              <div class="position-btn" 
                   :class="{active: watermark.position === 'center-right'}" 
                   @click="watermark.position = 'center-right'">右中</div>
              <div class="position-btn" 
                   :class="{active: watermark.position === 'bottom-left'}" 
                   @click="watermark.position = 'bottom-left'">左下</div>
              <div class="position-btn" 
                   :class="{active: watermark.position === 'bottom-center'}" 
                   @click="watermark.position = 'bottom-center'">中下</div>
              <div class="position-btn" 
                   :class="{active: watermark.position === 'bottom-right'}" 
                   @click="watermark.position = 'bottom-right'">右下</div>
            </div>
          </div>
          
          <div class="control-group" v-if="watermark.mode === 'tile'">
            <h2>平铺设置</h2>
            <div class="tile-controls">
              <label for="tileSpacingX">水平间距: <span class="range-value">{{ watermark.tileSpacingX }}px</span></label>
              <input type="range" id="tileSpacingX" v-model.number="watermark.tileSpacingX" min="20" max="200" step="5">
              
              <label for="tileSpacingY">垂直间距: <span class="range-value">{{ watermark.tileSpacingY }}px</span></label>
              <input type="range" id="tileSpacingY" v-model.number="watermark.tileSpacingY" min="20" max="200" step="5">
            </div>
          </div>
          
          <div class="button-group">
            <button class="download-btn" @click="downloadImage" :disabled="!imageSrc">下载图片</button>
            <button class="reset-btn" @click="resetWatermark">重置设置</button>
          </div>
        </div>
        
        <div class="preview">
          <h2>预览效果</h2>
          <div class="preview-container">
            <div v-if="!imageSrc" class="upload-placeholder">
              <i>📷</i>
              <p>请上传图片以添加水印</p>
            </div>
            <img v-else :src="imageSrc" id="previewImage" alt="预览图片">
            <canvas class="watermark-canvas" ref="watermarkCanvas"></canvas>
          </div>
          <div v-if="imageSrc" class="preview-info">
            <p>当前模式: <strong>{{ watermark.mode === 'single' ? '单水印' : '平铺水印' }}</strong></p>
            <p v-if="watermark.mode === 'single'">位置: <strong>{{ getPositionText(watermark.position) }}</strong></p>
            <p v-else>平铺间距: <strong>{{ watermark.tileSpacingX }}px × {{ watermark.tileSpacingY }}px</strong></p>
          </div>
        </div>
      </div>
    </div>
  </div>

  <script>
    const { createApp, ref, watch, onMounted } = Vue;
    
    createApp({
      setup() {
        const imageSrc = ref('');
        const watermarkCanvas = ref(null);
        
        const watermark = ref({
          mode: 'single', // 'single' 或 'tile'
          text: '示例水印',
          fontFamily: 'Microsoft YaHei',
          fontSize: 24,
          color: '#ffffff',
          opacity: 0.7,
          rotation: -15,
          position: 'bottom-right',
          tileSpacingX: 100,
          tileSpacingY: 100
        });
        
        const getPositionText = (position) => {
          const positionMap = {
            'top-left': '左上',
            'top-center': '中上',
            'top-right': '右上',
            'center-left': '左中',
            'center': '中心',
            'center-right': '右中',
            'bottom-left': '左下',
            'bottom-center': '中下',
            'bottom-right': '右下'
          };
          return positionMap[position] || position;
        };
        
        const triggerFileInput = () => {
          document.getElementById('fileInput').click();
        };
        
        const handleImageUpload = (event) => {
          const file = event.target.files[0];
          if (file) {
            const reader = new FileReader();
            reader.onload = (e) => {
              imageSrc.value = e.target.result;
            };
            reader.readAsDataURL(file);
          }
        };
        
        const drawWatermark = () => {
          if (!imageSrc.value || !watermarkCanvas.value) return;
          
          const canvas = watermarkCanvas.value;
          const ctx = canvas.getContext('2d');
          const img = new Image();
          
          img.onload = () => {
            // 设置canvas尺寸与图片一致
            canvas.width = img.width;
            canvas.height = img.height;
            
            // 清除画布
            ctx.clearRect(0, 0, canvas.width, canvas.height);
            
            // 设置水印样式
            ctx.font = `${watermark.value.fontSize}px ${watermark.value.fontFamily}`;
            ctx.fillStyle = watermark.value.color;
            ctx.globalAlpha = watermark.value.opacity;
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            
            if (watermark.value.mode === 'single') {
              // 单水印模式
              let x, y;
              const padding = 20;
              
              switch(watermark.value.position) {
                case 'top-left':
                  x = padding;
                  y = padding;
                  ctx.textAlign = 'left';
                  ctx.textBaseline = 'top';
                  break;
                case 'top-center':
                  x = canvas.width / 2;
                  y = padding;
                  ctx.textBaseline = 'top';
                  break;
                case 'top-right':
                  x = canvas.width - padding;
                  y = padding;
                  ctx.textAlign = 'right';
                  ctx.textBaseline = 'top';
                  break;
                case 'center-left':
                  x = padding;
                  y = canvas.height / 2;
                  ctx.textAlign = 'left';
                  break;
                case 'center':
                  x = canvas.width / 2;
                  y = canvas.height / 2;
                  break;
                case 'center-right':
                  x = canvas.width - padding;
                  y = canvas.height / 2;
                  ctx.textAlign = 'right';
                  break;
                case 'bottom-left':
                  x = padding;
                  y = canvas.height - padding;
                  ctx.textAlign = 'left';
                  ctx.textBaseline = 'bottom';
                  break;
                case 'bottom-center':
                  x = canvas.width / 2;
                  y = canvas.height - padding;
                  ctx.textBaseline = 'bottom';
                  break;
                case 'bottom-right':
                  x = canvas.width - padding;
                  y = canvas.height - padding;
                  ctx.textAlign = 'right';
                  ctx.textBaseline = 'bottom';
                  break;
              }
              
              // 应用旋转
              ctx.save();
              ctx.translate(x, y);
              ctx.rotate(watermark.value.rotation * Math.PI / 180);
              ctx.fillText(watermark.value.text, 0, 0);
              ctx.restore();
            } else {
              // 平铺水印模式
              const textWidth = ctx.measureText(watermark.value.text).width;
              const textHeight = watermark.value.fontSize;
              
              // 计算每个水印单元的大小(包括间距)
              const unitWidth = textWidth + watermark.value.tileSpacingX;
              const unitHeight = textHeight + watermark.value.tileSpacingY;
              
              // 计算需要绘制的水印数量
              const cols = Math.ceil(canvas.width / unitWidth) + 1;
              const rows = Math.ceil(canvas.height / unitHeight) + 1;
              
              // 平铺绘制水印
              for (let i = 0; i < rows; i++) {
                for (let j = 0; j < cols; j++) {
                  const x = j * unitWidth;
                  const y = i * unitHeight;
                  
                  // 交错排列,使平铺更自然
                  const offsetX = (i % 2) * (unitWidth / 2);
                  
                  ctx.save();
                  ctx.translate(x + offsetX, y);
                  ctx.rotate(watermark.value.rotation * Math.PI / 180);
                  ctx.fillText(watermark.value.text, 0, 0);
                  ctx.restore();
                }
              }
            }
          };
          
          img.src = imageSrc.value;
        };
        
        const downloadImage = () => {
          if (!imageSrc.value) return;
          
          const canvas = document.createElement('canvas');
          const ctx = canvas.getContext('2d');
          const img = new Image();
          
          img.onload = () => {
            canvas.width = img.width;
            canvas.height = img.height;
            
            // 绘制原始图片
            ctx.drawImage(img, 0, 0);
            
            // 设置水印样式
            ctx.font = `${watermark.value.fontSize}px ${watermark.value.fontFamily}`;
            ctx.fillStyle = watermark.value.color;
            ctx.globalAlpha = watermark.value.opacity;
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            
            if (watermark.value.mode === 'single') {
              // 单水印模式
              let x, y;
              const padding = 20;
              
              switch(watermark.value.position) {
                case 'top-left':
                  x = padding;
                  y = padding;
                  ctx.textAlign = 'left';
                  ctx.textBaseline = 'top';
                  break;
                case 'top-center':
                  x = canvas.width / 2;
                  y = padding;
                  ctx.textBaseline = 'top';
                  break;
                case 'top-right':
                  x = canvas.width - padding;
                  y = padding;
                  ctx.textAlign = 'right';
                  ctx.textBaseline = 'top';
                  break;
                case 'center-left':
                  x = padding;
                  y = canvas.height / 2;
                  ctx.textAlign = 'left';
                  break;
                case 'center':
                  x = canvas.width / 2;
                  y = canvas.height / 2;
                  break;
                case 'center-right':
                  x = canvas.width - padding;
                  y = canvas.height / 2;
                  ctx.textAlign = 'right';
                  break;
                case 'bottom-left':
                  x = padding;
                  y = canvas.height - padding;
                  ctx.textAlign = 'left';
                  ctx.textBaseline = 'bottom';
                  break;
                case 'bottom-center':
                  x = canvas.width / 2;
                  y = canvas.height - padding;
                  ctx.textBaseline = 'bottom';
                  break;
                case 'bottom-right':
                  x = canvas.width - padding;
                  y = canvas.height - padding;
                  ctx.textAlign = 'right';
                  ctx.textBaseline = 'bottom';
                  break;
              }
              
              // 应用旋转
              ctx.save();
              ctx.translate(x, y);
              ctx.rotate(watermark.value.rotation * Math.PI / 180);
              ctx.fillText(watermark.value.text, 0, 0);
              ctx.restore();
            } else {
              // 平铺水印模式
              const textWidth = ctx.measureText(watermark.value.text).width;
              const textHeight = watermark.value.fontSize;
              
              // 计算每个水印单元的大小(包括间距)
              const unitWidth = textWidth + watermark.value.tileSpacingX;
              const unitHeight = textHeight + watermark.value.tileSpacingY;
              
              // 计算需要绘制的水印数量
              const cols = Math.ceil(canvas.width / unitWidth) + 1;
              const rows = Math.ceil(canvas.height / unitHeight) + 1;
              
              // 平铺绘制水印
              for (let i = 0; i < rows; i++) {
                for (let j = 0; j < cols; j++) {
                  const x = j * unitWidth;
                  const y = i * unitHeight;
                  
                  // 交错排列,使平铺更自然
                  const offsetX = (i % 2) * (unitWidth / 2);
                  
                  ctx.save();
                  ctx.translate(x + offsetX, y);
                  ctx.rotate(watermark.value.rotation * Math.PI / 180);
                  ctx.fillText(watermark.value.text, 0, 0);
                  ctx.restore();
                }
              }
            }
            
            // 创建下载链接
            const link = document.createElement('a');
            link.download = 'watermarked-image.png';
            link.href = canvas.toDataURL('image/png');
            link.click();
          };
          
          img.src = imageSrc.value;
        };
        
        const resetWatermark = () => {
          watermark.value = {
            mode: 'single',
            text: '示例水印',
            fontFamily: 'Microsoft YaHei',
            fontSize: 24,
            color: '#ffffff',
            opacity: 0.7,
            rotation: -15,
            position: 'bottom-right',
            tileSpacingX: 100,
            tileSpacingY: 100
          };
        };
        
        // 监听水印设置变化,实时更新预览
        watch(watermark, () => {
          drawWatermark();
        }, { deep: true });
        
        // 监听图片变化,更新水印
        watch(imageSrc, () => {
          if (imageSrc.value) {
            // 等待DOM更新后绘制水印
            setTimeout(drawWatermark, 100);
          }
        });
        
        onMounted(() => {
          // 初始化水印
          drawWatermark();
        });
        
        return {
          imageSrc,
          watermarkCanvas,
          watermark,
          getPositionText,
          triggerFileInput,
          handleImageUpload,
          downloadImage,
          resetWatermark
        };
      }
    }).mount('#app');
  </script>
</body>
</html>

完整代码可以直接复制到HTML文件中运行,无需任何额外配置。


希望这篇教程能帮助你理解Vue3和Canvas的配合使用,并为你的图片版权保护提供有力工具。

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《SpringBoot 中的 7 种耗时统计方式,你用过几种?》

《千万级大表如何新增字段?别再直接 ALTER 了》

《Vue3 如何优雅地实现一个全局的 loading 组件》

《我用 Vue3 + Canvas 做了个超实用的水印工具,同事都在抢着用》

相关推荐
炒毛豆4 小时前
uniapp微信小程序+vue3基础内容介绍~(含标签、组件生命周期、页面生命周期、条件编译(一码多用)、分包))
vue.js·微信小程序·uni-app
Bacon4 小时前
前端:从0-1实现一个脚手架
前端
Bacon4 小时前
前端项目部署实战 nginx+docker持续集成
前端
beckyye5 小时前
阿里云智能语音简单使用:语音识别
前端·语音识别·录音
东东2335 小时前
前端规范工具之husky与lint-staged
前端·javascript·eslint
jump6805 小时前
手写事件总线、事件总线可能带来的内存泄露问题
前端
岁月宁静5 小时前
在 Vue 3.5 中优雅地集成 wangEditor,并定制“AI 工具”下拉菜单(总结/润色/翻译)
前端·javascript·vue.js
执沐5 小时前
基于HTML 使用星辰拼出爱心,并附带闪烁+流星+点击生成流星
前端·html
#做一个清醒的人5 小时前
【electron6】Web Audio + AudioWorklet PCM 实时采集噪音和模拟调试
前端·javascript·electron·pcm