前端进阶:小程序 Canvas 2D 终极指北 — 给图片优雅添加水印

在之前的文章中,我们详细拆解了如何使用小程序旧版 Canvas API 给图片添加水印。随着小程序框架(如 Taro、uniapp)和微信底层基础库的演进,Canvas 2D 凭借更高清的渲染质量和更好的性能,已经逐渐成为业界首选方案。

今天,我们将之前的打水印代码,全面升级为 Canvas 2D 的版本!不仅能学到如何平滑迁移,最后还会彻底讲透"新旧 Canvas 到底有什么区别"。


💡 为什么我们要换用 Canvas 2D?

Canvas 2D 的 API 设计完全对齐了 Web 标准标准(W3C Standard)。这意味着:

  1. 渲染更清晰:支持硬件加速,不会轻易出现糊边。
  2. 不用重复造轮子 :只要你有 HTML5 开发经验,可以直接零成本迁移过去,再也不用记 wx.createCanvasContext 这种蹩脚的"微信特色特供版"原生 API 啦!
  3. 同层渲染支持更好:旧版 Canvas 在小程序中是原生组件,层级最高,经常盖住网页中的其他弹窗(比如弹框、Toast);而 Canvas 2D 引入了同层渲染,和普通 view 标签能和谐共存。

🚀 核心实践:用 Canvas 2D 把图"画"出来

整体的思路和旧版类似(获取尺寸 -> 建黑框 -> 写白字 -> 导出),但在实现的手法上大变样了。快来看看新代码。

第 1 步:改变 HTML 标签的宣告方式

首先,我们需要在 <canvas> 标签上明确声明 type="2d"。注意,有了这个类型声明,canvas-id 就不再生效了,我们必须通过普通的 HTML id 来识别它!

html 复制代码
<template>
  <view class="container">
    <button @click="takePhoto">拍照并加水印</button>

    <canvas
      type="2d"                 <!-- 核心改动 1:声明为 Web 标准 2D 画布 -->
      id="wmCanvas"             <!-- 核心改动 2:使用 id 代替 canvas-id -->
      class="watermark_canvas"
      :style="'width:' + canvasWidth + 'px;height:' + canvasHeight + 'px;'"
    ></canvas>
  </view>
</template>

第 2 步:获取画布节点 (Node) 和 网页画笔 (Context)

旧版我们是用 wx.createCanvasContext("wmCanvas", this) 凭空抓取一把画笔。 在 Canvas 2D 时代,我们必须老老实实地:先在图纸上找到标签(Node) -> 初始化画板宽度 -> 然后从这块白板上拿画笔

javascript 复制代码
// 【代码场景:我们拿到原始图片的路径后,首先需要获取它原本的尺寸】
wx.getImageInfo({
  src: imgPath,
  success: (imgInfo) => {
    // 1. 和旧版逻辑一模一样,我们算出不让真机崩溃的安全比例宽和高
    const ratio = Math.min(1, 1280 / Math.max(imgInfo.width, imgInfo.height));
    const drawWidth = Math.max(1, Math.round(imgInfo.width * ratio));
    const drawHeight = Math.max(1, Math.round(imgInfo.height * ratio));

    // 同步更新页面上 canvas 标签的尺寸大小
    this.canvasWidth = drawWidth;
    this.canvasHeight = drawHeight;

    // 2. 也是等画布在页面上调整完大小后,我们再通过 DOM 节点分析来寻找它
    this.$nextTick(() => {
      setTimeout(() => {
        // (1) 获取当前页面组件的作用域 (在 Taro / 原生小程序框架中十分必要,避免找错 canvas)
        const instance = Taro.getCurrentInstance
          ? Taro.getCurrentInstance()
          : null;
        const pages = Taro.getCurrentPages ? Taro.getCurrentPages() : [];
        const scope =
          this.$scope ||
          (instance && instance.page) ||
          (pages && pages[pages.length - 1]);

        // (2) 发起类似 Web 中 document.getElementById 的查询请求
        const query = Taro.createSelectorQuery().in(scope);
        query
          .select("#wmCanvas")
          .fields({ node: true, size: true }) // 告诉微信,我们需要真实 DOM 节点
          .exec((res) => {
            // 3. 拦截节点实例
            const canvas = res && res[0] && res[0].node;
            if (!canvas) return console.error("画布初始化没找到对应的节点!");

            // 4. 重塑画板的物理像素大小(极度关键:保证导出不再是黑屏或者残缺一半)
            canvas.width = drawWidth;
            canvas.height = drawHeight;

            // 5. 正式拿到属于这块画板的 2D 水彩笔!
            const ctx = canvas.getContext("2d");

            // 接下来我们就可以传址开启真正的绘图流程了...
            drawWatermarkCore(canvas, ctx, drawWidth, drawHeight, imgInfo.path);
          });
      }, 60);
    });
  },
});

第 3 步:把图片当成一个"真实对象"加载完毕再画

这一步是很多第一次接触 Canvas 2D 的老司机最容易翻车的地方! 旧版我们能直接 ctx.drawImage('图片的临时本地路径.jpg');但在 Web 规范里,你必须把图片当作一个对象,等浏览器完全解析完该对象的缓存后,才能画!

javascript 复制代码
const drawWatermarkCore = (canvas, ctx, drawWidth, drawHeight, imgPath) => {
  // 前期的公式就算省略,和旧版一模一样!算出字体大小和居中位置
  const fontSize = 16;
  const boxX = 40;
  // ...

  // 【1. 用画板亲自创造一个空白的图像容器】
  const image = canvas.createImage();

  // 【2. 照片是个异步过程!等图像数据流成功涌入到这具容器内,触发加载完毕的回调】
  image.onload = () => {
    // (1) 把加载完实体的照片铺面屏幕
    ctx.drawImage(image, 0, 0, drawWidth, drawHeight);

    // (2) 画半透明黑底
    ctx.fillStyle = "rgba(0, 0, 0, 0.22)"; // Note:变成了属性赋值
    ctx.fillRect(boxX, boxY, boxWidth, boxHeight);

    // (3) 写纯白字体
    ctx.fillStyle = "#ffffff";
    ctx.font = `${fontSize}px sans-serif`; // Note:字号变成了 CSS 简写语法

    lines.forEach((line, index) => {
      ctx.fillText(line, boxX + 10, textY);
    });

    // ⚠️【高能预警】Canvas 2D 属于"所画即所得":
    // 没有 ctx.draw() !
    // 没有 ctx.draw() !
    // 没有 ctx.draw() 啦!画完上面几行,画布上的字和图就已经成型了!准备导出吧。

    exportImage(canvas, drawWidth, drawHeight);
  };

  // 如果中途断网或文件损坏导致报错
  image.onerror = (err) => {
    console.error("图片转译抛锚了", err);
  };

  // 【3. 把之前手机本地文件里的照片路径,塞进这个图像容器(必须塞在 onload 事件之后)】
  image.src = imgPath;
};

第 4 步:从画板对象里把照片截图出炉

因为我们在第三步已经拿到过 canvas 对象了,所以生成临时图片方法里,也不再需要提供 canvasIdthis 实例,而是直接把这块画板交出去截图。

javascript 复制代码
const exportImage = (canvas, drawWidth, drawHeight) => {
  wx.canvasToTempFilePath({
    canvas: canvas, // 直接给出整个 Node 节点即可!不要再传 Id!
    x: 0,
    y: 0,
    width: drawWidth,
    height: drawHeight,
    destWidth: drawWidth,
    destHeight: drawHeight,
    fileType: "jpg",
    quality: 0.9,
    success: (res) => {
      // 生成无与伦比的高清图成功!
      this.imgWithWatermark = res.tempFilePath;
    },
  });
};

完整可用代码 (可以直接 Copy 进项目哦)

为了大家能够拿来即用,这里是一份融合了所有计算细节、基于 Taro/Vue 语法的无依赖组件代码,你可以直接放在页面里运行:

html 复制代码
<template>
  <view class="container">
    <button @click="takePhoto">拍照并加水印</button>

    <view class="preview" v-if="imgWithWatermark">
      <view class="title">由于新版 Canvas 清晰度太高,建议横屏观看效果:</view>
      <image class="result-img" mode="widthFix" :src="imgWithWatermark"></image>
    </view>

    <!-- 同样地,把 Canvas 藏出屏幕外,用作在后台悄悄合成图的底板 -->
    <canvas
      type="2d"
      id="wmCanvas"
      class="watermark_canvas"
      :style="'width:' + canvasWidth + 'px;height:' + canvasHeight + 'px;'"
    ></canvas>
  </view>
</template>

<script>
  // 这里引入你框架提供的基础对象,例如 Taro
  import Taro from "@tarojs/taro";

  export default {
    data() {
      return {
        imgWithWatermark: "",
        canvasWidth: 300,
        canvasHeight: 300,
      };
    },
    methods: {
      // 1. 获取当前时间的格式化字符串
      formatCurrentTime() {
        const d = new Date();
        const p = (num) => num.toString().padStart(2, "0");
        return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(
          d.getHours(),
        )}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
      },

      takePhoto() {
        wx.chooseMedia({
          count: 1,
          mediaType: ["image"],
          sourceType: ["camera", "album"],
          sizeType: ["compressed"],
          success: (res) => {
            this.doWatermark(res.tempFiles[0].tempFilePath);
          },
        });
      },

      doWatermark(imgPath) {
        // 准备要在相纸上写的水印文案
        const lines = [
          `巡检记录人:李工程师`,
          `当前任务区:A区服务器机房`,
          `拍摄录入时间:${this.formatCurrentTime()}`,
          `仅供公司系统上传使用`,
        ];

        wx.getImageInfo({
          src: imgPath,
          success: (imgInfo) => {
            // 真机上尺寸过大极易导致导出的图片截断,我们强制让边长不超过 1280
            const maxSide = 1280;
            const ratio = Math.min(
              1,
              maxSide / Math.max(imgInfo.width, imgInfo.height),
            );
            const drawWidth = Math.max(1, Math.round(imgInfo.width * ratio));
            const drawHeight = Math.max(1, Math.round(imgInfo.height * ratio));

            this.canvasWidth = drawWidth;
            this.canvasHeight = drawHeight;

            // 开启画布绘制主流程
            this.$nextTick(() => {
              setTimeout(() => {
                // (1) 兼容各种环境里的作用域查找
                const instance = Taro.getCurrentInstance
                  ? Taro.getCurrentInstance()
                  : null;
                const pages = Taro.getCurrentPages
                  ? Taro.getCurrentPages()
                  : [];
                const scope =
                  this.$scope ||
                  (instance && instance.page) ||
                  (pages && pages[pages.length - 1]);

                if (!scope)
                  return wx.showToast({
                    icon: "none",
                    title: "页面未完全就绪!",
                  });

                // (2) 寻找页面上真实挂载的 Canvas 节点
                const query = Taro.createSelectorQuery().in(scope);
                query
                  .select("#wmCanvas")
                  .fields({ node: true, size: true })
                  .exec((res) => {
                    const canvas = res && res[0] && res[0].node;
                    if (!canvas)
                      return wx.showToast({
                        icon: "none",
                        title: "找不到画布元素",
                      });

                    // 非常关键,这一步没做导出来的图可能会残缺并带有黑框
                    canvas.width = drawWidth;
                    canvas.height = drawHeight;

                    const ctx = canvas.getContext("2d");

                    // (3) 基于画布宽度的动态字体与排版宽高运算
                    const fontSize = Math.max(
                      16,
                      Math.round(drawWidth * 0.038),
                    );
                    const lineHeight = Math.round(fontSize * 1.5);
                    const textPadding = Math.round(fontSize * 0.8);
                    const boxPadding = Math.round(fontSize * 0.9);
                    const boxHeight =
                      boxPadding * 2 + lineHeight * lines.length;
                    const boxWidth = Math.round(drawWidth * 0.92);
                    const boxX = Math.round((drawWidth - boxWidth) / 2); // 居中
                    const boxY = drawHeight - boxHeight - boxPadding; // 贴底

                    // ================ 核心 2D 作图逻辑 ================
                    const image = canvas.createImage();

                    image.onload = () => {
                      // 铺设图片底图
                      ctx.drawImage(image, 0, 0, drawWidth, drawHeight);
                      // 画个垫底黑框
                      ctx.fillStyle = "rgba(0, 0, 0, 0.22)";
                      ctx.fillRect(boxX, boxY, boxWidth, boxHeight);
                      // 切字体渲染色
                      ctx.fillStyle = "#ffffff";
                      ctx.font = `${fontSize}px sans-serif`;

                      // 把文案行行写下
                      lines.forEach((line, index) => {
                        const textY =
                          boxY +
                          boxPadding +
                          lineHeight * (index + 1) -
                          (lineHeight - fontSize) / 2;
                        ctx.fillText(line, boxX + textPadding, textY);
                      });

                      // 立刻调用快照方法(此处不需要旧版的 ctx.draw 啦!)
                      wx.canvasToTempFilePath({
                        canvas: canvas, // 传入实体 Node!
                        x: 0,
                        y: 0,
                        width: drawWidth,
                        height: drawHeight,
                        destWidth: drawWidth,
                        destHeight: drawHeight,
                        fileType: "jpg",
                        quality: 0.9,
                        success: (res) => {
                          this.imgWithWatermark = res.tempFilePath;
                        },
                        fail: (err) => {
                          console.error("canvasToTempFilePath fail", err);
                        },
                      });
                    };

                    image.onerror = (err) => {
                      console.error("canvas image load fail", err);
                    };

                    // 触发图片的加载
                    image.src = imgPath;
                  });
              }, 60); // 留点时间让 Vue 的绑定属性被 Webview 真实渲染完
            });
          },
        });
      },
    },
  };
</script>

<style>
  .container {
    padding: 20px;
  }
  .watermark_canvas {
    position: fixed;
    top: -9999px;
    left: -9999px;
    opacity: 0;
  }
  .result-img {
    width: 100%;
    border-radius: 8px;
    margin-top: 10px;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  }
  .title {
    font-size: 14px;
    color: #666;
    margin-top: 15px;
  }
</style>

🏆 终极灵魂拷问:旧版 Canvas vs Canvas 2D 到底差在哪?

回顾我们今天改造的代码,你会发现核心逻辑只是皮囊换了!我把重点区别提炼成以下表格,保证你从此在面试和实战中得心应手:

差异维度 以前的旧代码 (经典版 Canvas API) 现在的 Canvas 2D (推荐写法)
标签宣告 canvas-id="myId" 无需声明类型 必须加 type="2d" 及普通 id="myId"
获取画笔 (Context) 简单粗暴指令:wx.createCanvasContext(Id, this) 遵循 W3C 标准:先用 SelectorQuery 获取 Node 元素节点,再由节点 canvas.getContext("2d") 获取。
API 调用风格 特有的函数调用方式:ctx.setFillStyle()ctx.setFontSize() W3C 原生属性赋值:ctx.fillStyle = 'red'ctx.font = '16px auto'
绘制本地图片 万能参数,可以直接传进 String 路径:ctx.drawImage('img.jpg', ...) 非常规范,必须先根据节点创建原生对象:let img = canvas.createImage() ,等 img.onload 触发后再把对象当作参数传进 drawImage
真正渲染的时机 所有命令类似于"记录剧本",最后必须使用打板: ctx.draw(false, callback) 统一执行。 所见即所得,写下一句 fillText,画板上立刻浮现,全面废除了 ctx.draw 方法。
导出为图片 wx.canvasToTempFilePath 认准 canvasId 弃用 Id 判断,直接传入实体 canvas 节点本身,而且更加流畅、不易报错!

全篇总结 : 如果说旧版的 API 像是微信自己包了一层"快捷指令糖衣",适合简单业务;那 Canvas 2D 就是一把真刀真枪、符合全球标准的 HTML5 瑞士军刀

它在初始化的 SelectorQuery 查询和图片 onload 等待阶段略显繁琐,但这换来的是彻底消灭奇奇怪怪的组件层级覆盖 Bug、更好的渲染性能、以及你可以毫无障碍地把网上的网页端 Canvas 老特技和流行库直接搬进小程序! 掌握 Canvas 2D 是前端开发者在小程序开发进阶过程中的一块必修内功!

相关推荐
南囝coding17 分钟前
Anthropic 内部数百个 Claude Code Skills,他们总结的这套方法值得看
前端·后端
Dxy12393102161 小时前
如何使用jQuery获取一类元素并遍历它们
前端·javascript·jquery
csdn小瓯1 小时前
AI质量评估体系:LLM-as-a-Judge实现与自动化测试实战
前端·网络·人工智能
jiayong231 小时前
第 43 课:任务详情抽屉里的批量处理闭环与删除联动
java·开发语言·前端
刀法如飞1 小时前
JavaScript 数组去重的 20 种实现方式,学会用不同思路解决问题
前端·javascript·算法
小江的记录本1 小时前
【AI大模型选型指南】《2026年5月(最新版)国内外主流AI大模型选型指南》(个人版)
前端·人工智能·后端·ai·aigc·ai编程·ai写作
@PHARAOH2 小时前
HOW - 前端输入场景支持拼音匹配
前端
计算机安禾2 小时前
【c++面向对象编程】第21篇:运算符重载基础:语法、规则与不可重载的运算符
java·前端·c++
__log2 小时前
Vue 3 核心技术深度解析:从“会用API“到“懂原理、能表达“
前端·javascript·vue.js