
1. 项目概述1.1 核心需求解析先说结论在vue3 uniapp这套技术栈里做图片水印最靠谱的方案就是canvas。为什么因为uniapp虽然提供了view层的能力但在跨端场景下CSS方案的兼容性和可控性都不如canvas来得彻底——尤其当水印需要平铺、换行、旋转或者需要导出带水印的图片时css的局限性就会立刻暴露。canvas可以直接操作像素把文字和图形画进图片里然后把结果导出为base64或本地临时文件这一套流程在App、小程序、H5端都是被官方支持的主路径。这个项目的核心需求拆开来看其实就三件事给一张图片叠加文字水印文字要能自动换行不能因为内容太长就溢出边界或截断水印要多条、多行、多列甚至带角度平铺整张图片很多人写水印功能只处理了“画一行文字”这种最简单的情况遇到长文本就翻车遇到动态数据就手足无措。这个项目把所有边界情况都考虑进去做成一个可复用的方案这也是我写这篇实践笔记的初衷。1.2 适用场景与人群这个方案适合几类人群一是写小程序或跨端应用需要在用户上传图片时自动打上账号昵称、时间戳、平台标识等信息的开发者二是在H5活动页里做图片分享功能但又不想让用户把图拿出去乱用、需要在线打码的情况三是做内部系统需要给截图或合同附件自动加审核标记的场景。不管你是刚接触canvas的新手还是已经用vue3写过不少业务的老手这篇笔记里都会有值得参考的东西。我会从canvas的基础逻辑讲起逐步深入到文字测量、自动换行算法、多行水印的排布计算、常用的问题排查方法最后给你一个可以直接抄作业的完整封装代码。1.3 为什么选择canvas而不是其他方案可能有人会说“我直接用css给图片盖一层水印div不就行了吗干嘛还要用canvas画”区别在于css方案只影响视觉导出的图片依然是原图水印会被剥离。而canvas方案是实实在在把水印画到图片的像素数据里导出之后水印无法去除这是本质区别。再一个uniapp里的image组件虽然支持src属性赋值base64但如果你需要在图片上叠加多行、多列、带角度的水印css绝对定位做起来会非常痛苦而且不同端上view的渲染表现还不完全一致。canvas则直接绕开了这些问题所有逻辑都在同一套绘图API里执行。还有一点uniapp的canvas在App端和小程序端使用的是原生渲染性能已经足够应对把水印绘制到图片这种高频操作在H5端则退回浏览器canvas实现兼容性也没有问题。这几个原因叠加起来让canvas成为这个场景下唯一的正解。2. 核心技术拆解canvas绘图原语与水印原理2.1 canvas的基本绘图流程canvas绘图其实就像在一张白纸上作画需要先拿到“画笔”——也就是上下文对象然后通过一系列API把内容画上去。在uniapp里创建canvas的流程分为三步模板中放置canvas组件 → 获取canvas上下文 → 开始绘制。template canvas canvas-idwatermarkCanvas idwatermarkCanvas stylewidth: 300px; height: 300px;/canvas /template// 获取上下文 const ctx uni.createCanvasContext(watermarkCanvas, this) // 绘制内容 ctx.setFillStyle(#000000) ctx.setFontSize(16) ctx.fillText(水印文字, 100, 100) // 提交绘制 ctx.draw()这里有两个关键点需要说清楚第一uniapp的canvas上下文和浏览器里的原生canvas上下文API略有差异但基本思路是一致的都依赖setFillStyle、setFontSize、fillText这些方法第二所有绘制操作完成后必须调用ctx.draw()这个调用才会真正把内容渲染到canvas上。很多新手第一次写canvas画完发现一片空白基本都是因为忘了ctx.draw()。2.2 fillText与文字测量的底层逻辑fillText(text, x, y)是canvas里绘制文字最核心的方法。这里的x和y不是文字左上角的坐标而是文字基线的起点坐标。文字基线这个概念是从排版规范里沿袭下来的你可以把它理解成文字底部的那条“无形的线”。如果对基线的位置没概念画出来的文字就会出现明显的偏移。为了精确计算文字换行位置必须使用ctx.measureText(text).width来获取文字的宽度。这个API返回的是当前字号和font配置下某个字符串的实际像素宽度。注意这个测量是同步的而且必须在设置完字号之后再调用顺序反了测出来的就是错误的宽度。ctx.setFontSize(30) const width ctx.measureText(这是一段测试文字).width测量结果的单位是px这是后续实现自动换行的基础。2.3 自动换行的核心算法思路自动换行的核心就一句话把字符串逐字拼接每拼一个字符就测量一次当前总宽度如果超过了允许的最大宽度就在这个字符之前换行。听起来简单但实现的时候有几个容易忽略的细节。首先单字测量和整句测量要区分。如果用整句测量一次性判断是否超出溢出之后再回头找换行位置会白白多做一次测量。更好的做法是一字一字地推进每次都把“当前行已有内容新字符”作为一个整体来测量这样测量次数虽然多但逻辑最清晰也最容易保证正确性。其次英文字符和中文字符的处理不能一视同仁。一个中文字符的宽度通常是英文小写字母的两倍左右如果不考虑这一点同样的字符数量英文会显得稀疏中文会显得拥挤。好在canvas的measureText已经帮我们把这种宽度差异计算好了我们只需要按字符逐个测量即可。最后边界条件要处理好。如果某个单词特别长比整个canvas宽度还长那无论如何都会超宽。这时候要做两种选择要么硬性截断要么强行换行。在用户上传内容这种场景里我通常选择“宁可溢出也不硬切”因为硬切会造成语义断裂让一句话变得没头没尾。2.4 多水印平铺的排布算法多水印平铺与单行水印最大的不同在于需要同时考虑两个方向上的间距水印行与行之间的垂直间距以及水印内容与内容之间的水平间距。如果仅仅把几行文字画到图片上看起来会是几排“站队”的文字而非真正意义上的平铺水印。平铺水印的做法是把整个水印单元一组文字可能是一个多行组合作为一个整体先计算出这个单元占据的宽度和高度然后按预设的行距、列距在整个图片背景上循环绘制。这个思路和一格一格格子铺地砖没什么区别难点只在于如何计算格子的大小和起点。考虑一个典型场景水印区域旋转30度之后整个绘制区域的长宽比例会发生变化如果仍然按照旋转前的网格间距来排布容易出现覆盖不均的情况。所以正确的做法是先确定水印单元的原始宽高再计算旋转后的外接矩形宽高把外接矩形的尺寸作为网格的“单位尺寸”。// 计算旋转后的外接矩形尺寸 const rad (rotation * Math.PI) / 180 const absCos Math.abs(Math.cos(rad)) const absSin Math.abs(Math.sin(rad)) const newWidth unitHeight * absSin unitWidth * absCos const newHeight unitHeight * absCos unitWidth * absSin这里用了三角变换新手建议直接记住公式不必深究推导过程。有了外接矩形尺寸后网格间距就用这个尺寸加上我们自己定义的水平间距和垂直间距。3. 完整实现从零到上线的基础版水印组件3.1 模板结构与组件设计在设计组件之前先想清楚它对外要暴露什么能力。我最终确定的需求列表是src原图片路径、watermarkText水印文本支持多行、options包括字号、颜色、透明度、旋转角度、间距等配置。不改动内部数据结构的前提下尽量让组件支持多样化的使用方式。template view classwatermark-container canvas classwatermark-canvas canvas-idwatermarkCanvas idwatermarkCanvas/canvas /view /template为什么用canvas而不是image组件来承载最终结果因为canvas组件可以直接导出图片而image组件只能显示。流程上我们先把原图绘制到canvas上再叠加水印最后通过uni.canvasToTempFilePath得到带水印的图片路径交给业务方去使用。3.2 获取源图片信息与canvas初始化canvas要正确绘制图片前提是拿到图片的实际宽高。如果你直接拿一个css宽高来初始化canvas而图片本身的尺寸和css尺寸不一致画出来的图会被拉伸变形水印位置也会跟着跑偏。正确做法是先用uni.getImageInfo获取图片的原始宽高然后把这些宽高直接设置到canvas的样式上。注意在uniapp中canvas的宽高不能像浏览器里那样通过设置width和height属性来控制而是必须写在style上这个差异坑了不少人。uni.getImageInfo({ src: this.src, success: (res) { const { width, height } res this.canvasWidth width this.canvasHeight height } })获取到宽高之后还需要确认一个关键参数绘图顺序。我们要先画原图再画水印这样水印才能覆盖在图片之上。如果你把顺序搞反了水印会被原图盖住最终导出的图片完全看不到水印痕迹。3.3 图片绘制与水印叠加的代码框架完整绘制框架如下我先写个简化版本理清思路后面再逐步加强功能drawWatermark() { const ctx uni.createCanvasContext(watermarkCanvas, this) // 绘制原图 ctx.drawImage(this.src, 0, 0, this.canvasWidth, this.canvasHeight) // 设置公共样式 ctx.setGlobalAlpha(this.options.alpha || 0.3) ctx.setFillStyle(this.options.color || #000000) ctx.setFontSize(this.options.fontSize || 20) // 绘制文字 this.watermarkLines.forEach((item, index) { const y item.y item.texts.forEach((text, textIndex) { ctx.fillText(text, item.x, y textIndex * lineHeight) }) }) // 一次性提交绘制 ctx.draw(false, () { // 导出带水印图片 this.exportImage() }) }这里的watermarkLines是一个预先计算好的数组每个元素包含一组文字的起点坐标和分行后的文本数组。自动换行的计算在进入drawWatermark之前就完成了这样把“计算”和“绘制”分离代码会清晰很多。3.4 导出图片的完整流程绘制完成之后需要把canvas内容导出为图片。uniapp提供了uni.canvasToTempFilePath这个API使用它可以把canvas绘制内容保存为临时文件路径后续再把临时文件转化为本地文件或者直接上传。uni.canvasToTempFilePath({ canvasId: watermarkCanvas, success: (res) { // res.tempFilePath 就是带水印的图片临时路径 this.$emit(success, res.tempFilePath) }, fail: (err) { this.$emit(fail, err) } }, this)在H5端如果canvas中绘制了跨域的图片导出的临时文件可能会被污染导致toTempFilePath失败。这个问题我在第5部分会专门讲这里先埋个伏笔。另外导出后的图片质量也可以通过destWidth和destHeight参数来控制默认情况下导出图片和canvas的像素尺寸一致如果想压缩体积可以在这里做缩放。4. 自动换行的实战处理与多水印排列4.1 文本预处理去除换行符与特殊字符处理用户输入到水印框里的文本往往带有各种隐藏的格式比如从记事本里复制过来的文本自带\r\n换行符或者中间包含多个连续空格。直接把这些字符串丢给canvas去画会出现两种情况换行符不生效显示成一个奇怪的方块或者因为多余空格导致测量宽度异常。所以在处理之前要先做一次文本清理const cleanText watermarkText.replace(/[\r\n]/g, ).replace(/\s/g, ).trim()这一步把原始文本中的换行统一替换为空格、连续空格压缩为单个空格、去掉首尾空格。这样后续的自动换行逻辑才能在一个干净的基础上运行。4.2 分行算法基于measureText的逐字测宽自动换行的核心代码如下wrapText(ctx, text, maxWidth) { const lines [] let currentLine const chars text.split() for (let i 0; i chars.length; i) { const char chars[i] const testLine currentLine char const testWidth ctx.measureText(testLine).width if (testWidth maxWidth currentLine ! ) { lines.push(currentLine) currentLine char } else { currentLine testLine } } if (currentLine) { lines.push(currentLine) } return lines }算法的逻辑和解说稍微展开一下每次拿“当前行已有内容下一个字符”去测量如果测量宽度超过maxWidth就把当前行结清新字符另起一行。这里有三个边界条件当currentLine为空时即使testWidth超过maxWidth也必须硬塞进去否则这个字符就丢失了换行之后要把直角字符作为新一行的第一个字符不能丢掉循环结束后要检查currentLine是否为空白避免多出一个空行影响后续计算实测下来这个算法的性能完全够用。一般水印文本在百字以内逐字测量也就一百来次调用canvas的measureText不会造成可感知的卡顿。4.3 行高的确定与多行水印组合自动换行拿到多行文本之后需要确定每一行的绘制位置。行高lineHeight可以设置为字号乘以一个系数正常情况下1.4到1.6都是合理范围。如果你是做UI适配的还可以用系统提供的ctx.getTextMetrics或者ctx.getFontInfo来获取更多字体度量信息不过这两个API在uniapp不同端的支持情况不一致为了兼容性我建议手动指定倍数。const fontSize this.options.fontSize || 20 const lineHeight Math.floor(fontSize * 1.5)有了行高之后整个水印单元占用的高度就是lines.length * lineHeight。这个高度主要用于后续平铺时的网格间距计算。4.4 多水印平铺旋转角度、行列距与覆盖密度先设计基础的平铺方式不做旋转。假设水印单元是3行文字单元宽unitW高unitH。平铺的行距设为rowGap列距设为colGap那么整个网格的步长就是unitW colGap水平方向和unitH rowGap垂直方向。for (let row 0; row * (unitH rowGap) canvasHeight; row) { const y row * (unitH rowGap) rowGap for (let col 0; col * (unitW colGap) canvasWidth; col) { const x col * (unitW colGap) colGap drawUnit(ctx, x, y) } }这样就能把水印铺满整个图片。如果接着考虑旋转比如让水印呈30度斜角就必须使用上一节里提到的外接矩形算法来重新计算步长否则旋转后的水印单元会和相邻单元重叠导致看起来“糊成一团”。关于密度的选择我建议行距和列距不要设得太小否则水印会盖住图片主体内容视觉上很重也不宜太大否则整张图只有一小块有标记失去了水印的意义。常规做法是把行距设为单元高度的3到5倍列距设为单元宽度的2到4倍让水印若隐若现但可辨识。4.5 封装一个可复用组件把以上所有逻辑整合进一个组件里对外只用props和events通信。这样不管是页面调用、在app端使用、还是嵌套在其他组件中都能保持逻辑一致。核心组件的props设计如下属性名类型默认值说明srcString原图路径支持网络地址和本地路径textString水印文本内容fontSizeNumber20水印字号colorString#000000水印颜色alphaNumber0.3全局透明度rotateNumber0旋转角度度rowGapNumber0行间距pxcolGapNumber10列间距pxmaxWidthNumber300水印单元最大宽度组件对外暴露一个generate方法调用后开始内部绘制流程完成时通过success事件返回带水印的图片临时路径。5. 常见问题与排查技巧实录5.1 canvas在H5端导出白图这个是出现频率最高的一个问题。现象是canvas在页面上能正常看到图片和水印但调用uni.canvasToTempFilePath导出的图片是纯白或纯黑的完全看不到内容。原因通常是canvas中绘制了跨域的图片。浏览器canvas的安全策略规定如果canvas里含有跨域图片且没有给图片设置跨域属性canvas会被标记为“被污染”此时toDataURL和toBlob都会被浏览器拒绝uniapp的canvasToTempFilePath调到底层也是这两个API。解决办法有两种第一种给图片设置crossOrigin属性让服务器允许跨域请求第二种确保源图片是base64格式避免跨域实测下来如果图片是来自阿里云OSS或腾讯云COS这些对象存储服务需要登录存储服务商控制台设置CORS规则允许来自你的域名或webview的跨域请求这个配置必须在服务端完成前端无法单纯靠代码解决。5.2 App端canvas显示正常但导出模糊App端的canvas和H5端不一样它使用原生渲染。如果你给canvas设置了固定的css宽高比如300px但canvas实际绘图分辨率只有300x300导出图片就会比较模糊。解决办法是在绘制前使用uni.getSystemInfo获取设备的像素比dpr然后按dpr缩放canvas的实际像素尺寸再按这个尺寸绘制内容。const dpr uni.getSystemInfoSync().pixelRatio this.canvasWidth imageWidth * dpr this.canvasHeight imageHeight * dpr可以把它理解成“视网膜屏幕适配”css宽度不变但canvas的实际像素数量翻倍这样导出图片的清晰度才能达到预期。手机上通常dpr在2到3之间图片尺寸放大两倍后水印文字的锐利程度完全不一样。5.3 微信小程序端canvas层级问题小程序里的canvas是原生组件层级最高覆盖在普通view之上。如果你的页面里有弹窗、抽屉、或底部导航canvas会直接挡住这些元素。小程序提供了cover-view和cover-image来解决这个覆盖问题但你会发现自己需要在canvas上层再套一层cover-view来放其他控件逻辑上非常别扭。更推荐的做法是把canvas隐藏。只要你的目标是“生成图片”那就可以把canvas的宽高设为0或者给它设置display:none在后台完成绘制和导出。用户看到的结果只是你最终导出的那张图片canvas藏在背后不参与展示这样层级问题就不复存在了。5.4 文字水印在深色图片上看不清如果用户上传的图片是一张深色背景的风景照黑色水印文字几乎不可见。最简单的办法是给水印加一层白色描边或者同时画两次——一次白色粗字号做底一次黑色细字号做盖。在canvas里可以先用ctx.strokeText描边然后用ctx.fillText填充// 白色描边 ctx.setStrokeStyle(#ffffff) ctx.setLineWidth(3) ctx.strokeText(text, x, y) // 黑色填充 ctx.setFillStyle(#000000) ctx.fillText(text, x, y)这样水印在任何背景上都清晰可读成本极低实测效果很好。5.5 多行水印的间距与实际效果不理想如果你设置的旋转角度不是常见的0度或90度平铺的水印单元之间会出现间距不均匀的情况。排查这个问题的时候先去检查外接矩形算法是否写对了。我见过好几版代码都因为矩形旋转后宽高计算错误导致网格间距混乱。还有一个很隐蔽的问题如果你使用了自动换行水印单元的宽度是根据文本测量宽度动态变化的而不是预设的固定值。如果两行文本长度差异巨大那么水印单元的实际宽度会以最宽行为准这会影响整个网格的步长。需要在计算完成后固定unitW不能每一次绘制都重新测量。6. 多域名适配与跨端异常处理6.1 H5端如何适配多个域名在uniapp开发H5时一个常见的需求是同一套代码需要部署到两个域名下比如一个测试环境域名、一个正式环境域名。这涉及到图片资源、接口请求地址的动态切换。我的做法是在manifest.json里定义两个环境变量然后在运行时根据window.location自动判断应该使用哪个域名作为图片服务器地址。// config.js const envMap { dev.example.com: { baseURL: https://dev-api.example.com, imageBaseURL: https://dev-image.example.com }, www.example.com: { baseURL: https://api.example.com, imageBaseURL: https://image.example.com } } const currentHost window.location.hostname const env envMap[currentHost] || envMap[dev.example.com]这个逻辑放在水印组件里的价值在于如果原图是从不同域名加载的跨域处理策略也会不一样。必须确保请求图片时带上正确的domain参数否则可能出现水印绘制成功后导出白图的困惑。6.2 图片加载失败时的兜底逻辑水印组件依赖图片成功加载后才开始绘制。如果图片链接失效或者网络异常直接调用drawImage会静默失败页面里只看到一片空白。推荐的做法是在绘制前先加载一张默认的占位图或者给组件暴露一个error事件让业务方来决定接下来怎么处理。uni.getImageInfo({ src: this.src, success: (res) { this.drawWatermark(res) }, fail: () { uni.getImageInfo({ src: this.defaultImage, success: (res) { this.drawWatermark(res) }, fail: (err) { this.$emit(error, err) } }) } })6.3 iOS Safari下canvas导出的特殊坑有一段时间iOS Safari的canvas在图片绘制完成但没有执行绘制回调前就调用了导出方法会导致导出白图。解决办法是在ctx.draw的回调里执行导出不要依赖外部定时器。ctx.draw(false, () { setTimeout(() { uni.canvasToTempFilePath({ // 导出配置 }) }, 100) })加100毫秒延迟是为了让canvas渲染引擎有时间完成内部提交实测这个延迟在几乎所有iOS版本上都是足够的且不会对用户体验造成可感知影响。7. 性能优化与扩展应用7.1 长文本水印的分片渲染策略如果你要处理的水印文本非常长比如一段用户协议或者免责声明一次性调用fillText把所有文本画上去在低端Android机上有概率出现卡顿或掉帧。更合理的做法是把长文本按页面可视区域切分为多个部分每一部分单独绘制。当然这属于极端场景一般水印文本不会长到那个程度。7.2 缓存机制避免重复绘制相同图片如果同一张图片在短时间内被多次请求加水印每次都要重新走一遍完整的绘制流程这在列表页会非常浪费性能。优化的思路是用图片的src加上水印参数做缓存键把生成结果存入内存或本地缓存。const key ${this.src}_${this.text}_${this.options.fontSize}_${this.options.rotate} if (this.cache[key]) { this.$emit(success, this.cache[key]) return }尤其在做分享卡片生成这类功能时缓存能显著降低用户连续操作时的等待时间。7.3 uniCloud云函数方案替代客户端绘制如果你的业务对即时性要求不高可以考虑把水印绘制放到uniCloud云函数上执行服务端用类Node图像库方案处理客户端只负责上传原图并下载结果。这个方案的好处是统一了逻辑不受客户端不同平台的canvas差异影响但缺点是需要考虑云函数执行时长和费用适合图片频率不高、但对质量和一致性要求很高的场景。7.4 更高级的玩法动态水印与过期时间水印不一定非要写死一段文字完全可以做成动态的比如当前时间戳、用户名加手机号后四位、专属邀请码等信息。这些信息的价值在于追踪泄露来源。如果希望水印在一定时间后失效比如15分钟内有效可以把过期时间戳编码进水印文字里由后端校验。前端只需要保证绘制时把这个参数动态传入即可。8. 组件最终完整代码把上面所有逻辑整合成完整的代码这里给出一个可直接投入使用的组件版本。代码里包含自动换行、旋转平铺、导出事件、缓存处理。template canvas canvas-idwmCanvas idwmCanvas styleposition: fixed; left: -9999px; top: -9999px;/canvas /template script export default { name: WatermarkCanvas, props: { src: { type: String, default: }, text: { type: String, default: }, fontSize: { type: Number, default: 20 }, color: { type: String, default: #000000 }, alpha: { type: Number, default: 0.3 }, rotate: { type: Number, default: 0 }, rowGap: { type: Number, default: 80 }, colGap: { type: Number, default: 80 }, maxWidth: { type: Number, default: 260 } }, data() { return { cacheMap: {} } }, methods: { generate() { const key ${this.src}_${this.text}_${this.fontSize}_${this.rotate} if (this.cacheMap[key]) { this.$emit(success, this.cacheMap[key]) return } uni.getImageInfo({ src: this.src, success: (res) { this.drawWatermark(res) }, fail: (err) { this.$emit(fail, err) } }) }, drawWatermark(imageInfo) { const { width: imgWidth, height: imgHeight } imageInfo const dpr uni.getSystemInfoSync().pixelRatio || 1 const canvasWidth imgWidth * dpr const canvasHeight imgHeight * dpr const ctx uni.createCanvasContext(wmCanvas, this) // 绘制原图 ctx.drawImage(this.src, 0, 0, canvasWidth, canvasHeight) // 计算自动换行 ctx.setFontSize(this.fontSize) ctx.setFillStyle(this.color) ctx.setGlobalAlpha(this.alpha) const lines this.wrapText(ctx, this.text, this.maxWidth) const lineHeight Math.floor(this.fontSize * 1.5) const unitH lines.length * lineHeight const unitW this.maxWidth // 计算旋转后的外接矩形 const rad (this.rotate * Math.PI) / 180 const absCos Math.abs(Math.cos(rad)) const absSin Math.abs(Math.sin(rad)) const rotatedW unitH * absSin unitW * absCos const rotatedH unitH * absCos unitW * absSin const stepX rotatedW this.colGap const stepY rotatedH this.rowGap // 平铺水印 for (let row 0; row * stepY canvasHeight; row) { for (let col 0; col * stepX canvasWidth; col) { const x col * stepX const y row * stepY ctx.save() ctx.translate(x unitW / 2, y unitH / 2) ctx.rotate((this.rotate * Math.PI) / 180) lines.forEach((line, idx) { ctx.fillText(line, -unitW / 2, -unitH / 2 (idx 1) * lineHeight) }) ctx.restore() } } ctx.draw(false, () { setTimeout(() { uni.canvasToTempFilePath({ canvasId: wmCanvas, destWidth: imgWidth, destHeight: imgHeight, success: (res) { this.cacheMap[${this.src}_${this.text}_${this.fontSize}_${this.rotate}] res.tempFilePath this.$emit(success, res.tempFilePath) }, fail: (err) { this.$emit(fail, err) } }, this) }, 100) }) }, wrapText(ctx, text, maxWidth) { const cleanText text.replace(/[\r\n]/g, ).replace(/\s/g, ).trim() const lines [] let currentLine for (let i 0; i cleanText.length; i) { const testLine currentLine cleanText[i] const testWidth ctx.measureText(testLine).width if (testWidth maxWidth currentLine ! ) { lines.push(currentLine) currentLine cleanText[i] } else { currentLine testLine } } if (currentLine) { lines.push(currentLine) } return lines } } } /script这段代码已经在实际项目中验证过几个来回覆盖了大多数基础需求。如果你需要在生产环境使用建议再加上以下功能的适配上传进度状态、图片方向矫正EXIF、以及不同端上的尺寸极限判断。9. 最后再分享几个实战细节水印功能虽然看起来简单真正落地的时候还是会有不少细节需要反复调优。这里挑几个我踩过的坑作为最后的提醒。第一canvas的宽高属性在某些小程序端有上限超出一定像素比如3000px后绘制会直接失败或内存溢出。如果你要处理的图片分辨率特别高可以先等比压缩到一个合理范围再绘制水印不用非保持原图大小。第二给ctx.setGlobalAlpha设置数值时注意保留两位小数过高的透明度会让水印完全看不见过低的透明度又会让水印看起来太突兀。我实测下来0.25到0.35之间是视觉效果最好的区间。第三如果水印文字里包含特殊字符比如emoji不同端的canvas渲染效果差异极大Android上画出来可能是方块。这种情况最好提前做一次白名单过滤把无法渲染的字符替换掉。第四硬件加速相关的坑也不能忽视。部分安卓机型在小程序里开启硬件加速后canvas导出偶发黑屏。实在躲不开可以尝试给canvas设置force-use-old-canvas属性或者关闭webgl渲染模式。第五水印的定位基准点设计要清晰。我上面代码里把所有坐标都以单元左上角为基准再做translate和rotate这样逻辑统一不会出现多个水印单元旋转中心不一致的问题。如果你准备在生产环境使用这个组件建议自己再写一个简单的测试用例页面把不同尺寸的图片、不同长度的文本、不同旋转角度都过一遍。水印这种功能看起来不起眼一旦出了错用户第一反应就是你保护的内容被泄露出去了信任损失比功能本身的价值大得多。