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

29 阅读7分钟

在之前的文章中,我们详细拆解了如何使用小程序旧版 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 来识别它!

<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) -> 初始化画板宽度 -> 然后从这块白板上拿画笔

// 【代码场景:我们拿到原始图片的路径后,首先需要获取它原本的尺寸】
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 规范里,你必须把图片当作一个对象,等浏览器完全解析完该对象的缓存后,才能画!

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 实例,而是直接把这块画板交出去截图。

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 语法的无依赖组件代码,你可以直接放在页面里运行:

<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 是前端开发者在小程序开发进阶过程中的一块必修内功!