在之前的文章中,我们详细拆解了如何使用小程序旧版 Canvas API 给图片添加水印。随着小程序框架(如 Taro、uniapp)和微信底层基础库的演进,Canvas 2D 凭借更高清的渲染质量和更好的性能,已经逐渐成为业界首选方案。
今天,我们将之前的打水印代码,全面升级为 Canvas 2D 的版本!不仅能学到如何平滑迁移,最后还会彻底讲透"新旧 Canvas 到底有什么区别"。
💡 为什么我们要换用 Canvas 2D?
Canvas 2D 的 API 设计完全对齐了 Web 标准标准(W3C Standard)。这意味着:
- 渲染更清晰:支持硬件加速,不会轻易出现糊边。
- 不用重复造轮子 :只要你有 HTML5 开发经验,可以直接零成本迁移过去,再也不用记
wx.createCanvasContext这种蹩脚的"微信特色特供版"原生 API 啦! - 同层渲染支持更好:旧版 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 对象了,所以生成临时图片方法里,也不再需要提供 canvasId 和 this 实例,而是直接把这块画板交出去截图。
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 是前端开发者在小程序开发进阶过程中的一块必修内功!