需求及分析
需求是这样的,当用户进入页面的时候,会根据用户的邀请码生成一个二维码海报,然后用户点击下载,可以将这张海报保存到用户手机的相册。
这是一个很普通的需求,但是没想到让我遇到了一个大坑(后面细说),卡了我整整一天的进度。
一开始想着用HBX的 海报插件 来实现,然后发现这些海报插件,要么是不适配vue3,要么是可定制性不够高,只能按照它写好的模板进行替换,再或者就是要去其他网站用在线海报编辑器编辑好了之后,再生成配置文件,有点麻烦。简单的说就是,要么用不了,要么不好用。
于是便想着通过html2canvas来自己实现。
静态页面构建
新建一个组件 .vue
文件,然后在里面搭建页面结构,引入几张图片,得到下图。

页面结构如下:
vue
<!-- 海报容器 -->
<view class="post-container" id="htmlCanvas">
<!-- 海报背景图 -->
<image src="@/static/bg.png" class="post" mode="aspectFit"></image>
<!-- 海报上的艺术字 -->
<image src="@/static/art-text.png" class="art-text" mode="aspectFit"></image>
<!-- 邀请码背景 -->
<image src="@/static/rounded-rect.png" class="rounded-rect" mode="aspectFit"></image>
<!-- 邀请码文字 -->
<view class="invite-text">
<text>邀请码:{{inviteCode}}</text>
<text>一起开黑组队启航</text>
</view>
</view>
<!-- 分享按钮 -->
<view class="share-btn-group">
<view class="share-item">
<text>微信好友</text>
</view>
<view class="share-item">
<text>微信朋友圈</text>
</view>
<view class="share-item" @click="canvas.toImage">
<text>下载图片</text>
</view>
</view>
html2canvas实现需求
首先是安装:
shell
npm i html2canvas --save
因为html2canvas要操作DOM元素,因此这里我们要用到renderjs,根据uni官方文档的说明,我们在文件中新增一对 script
标签,如下:
vue
<script module="canvas" lang="renderjs">
// ... 代码
</script>
其中module表示模块名,可以任意起,但是不能和vue的data、computed等里面的属性起冲突。 lang属性则是固定为renderjs,表示启用renderjs。
此时我们组件中存在两个script标签,如下:
vue
<script>
export default {
onLoad() {},
methods: {},
}
</script>
<script module="canvas" lang="renderjs">
export default {
mounted() {},
methods: {},
}
</script>
根据uniapp文档,我们将上面的script标签称为逻辑层 ,而下面使用renderjs的则称为视图层 ,在视图层中我们可以使用 document
和 window
等对象,进而可以操作DOM元素。
除此之外,renderjs还有挺多注意事项的,建议大家遇到问题了先去官方文档中查看这些注意事项,比如说:
- APP 端可以使用 dom、bom API,不可直接访问逻辑层数据,不可以使用 uni 相关接口(如:uni.request);
- vue3 项目不支持
setup script
用法; - 可以使用 vue 组件的生命周期(不支持 beforeDestroy、destroyed、beforeUnmount、unmounted),不可以使用 App、Page 的生命周期;
- 目前仅支持内联使用;
toImage方法的实现
我们在视图层引入html2canvas,然后在其methods属性中定义一个 toImage
异步方法,该方法代码如下:
js
async toImage() {
try {
const timeout = setTimeout(async () => {
const htmlCanvas = document.getElementById('htmlCanvas')
const canvas = await html2canvas(htmlCanvas, {
backgroundColor: null,
allowTaint: true,
useCORS: true
})
const base64Data = canvas.toDataURL('image/png')
this.$ownerInstance.callMethod('creates', base64Data)
clearTimeout(timeout)
}, 500);
} catch (e) {
console.log(e);
}
}
图片跨域
toImage的定时器一定要加上的,不然的话会有问题,然后就是获取DOM,使用html2canvas,然后通过 toDataURL
获取图片的base64数据,一切都好像很顺利,直到我运行的时候,控制台出现这行红字:

shell
Uncaught (in promise) SecurityError: Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported. at app-renderjs.js:7906
简单的说,就是canvas引入的图片跨域了,导致画布被污染,所以不能使用 toDataURL
这个方法导出数据了,于是我赶紧查看html2canvas的文档,发现有两个配置项与这个有关:
- allowTaint:允许画布被污染;
- useCORS:使用跨域;
我将这俩配置项都设为true之后,再运行,哦豁,还是同样的错误。
赶紧上网咨询广大网友,得到的方法是:
- 换成网络图片,然后让后端设置成允许跨域;
- 给img标签加上一个跨域的属性
crossorigin
,然后设为anonymous
;
首先第一个方法,不是很ok,因为后端比较忙,而且就3张图片,我也实在是懒得叫。 然后第二个方法,好像可以,但是又瞬间清醒,我用的是image标签,不是img标签,在移动端使用img标签会不显示图片的,最后抱着希望跑去uni官网看看image标签会不会有这个属性:

很可惜,没有。
难道真的要去麻烦后端了吗!不不不,要不咋说网友是万能的呢,翻了十几页搜索结果之后,终于在一篇帖子中找到了,这位仁兄也遇到了同样的困境,canvas跨域,然后没有后端帮忙,他给出的解决方案如下:

二话不说,我直接就跑去把用到的图片转成了base64,然后封装成组件:

然后重写改写模板结构:
vue
<view class="post-container" id="htmlCanvas">
<Background class="post" />
<ArtText class="art-text" />
<RoundedSquare class="invite-code" />
<view class="invite-text">
<text>邀请码:{{inviteCode}}</text>
<text>一起开黑组队启航</text>
</view>
</view>
再去控制台一看,诶嘿,妥了,获取到图片的base64数据了,接下来就到去逻辑层实现 creates
方法了,因为我们将base64数据传给这个方法了。
creates方法的实现
这个方法的逻辑很简单,将base64数据转换成tempFilePath,然后调用 uni.saveImageToPhotosAlbum
将图片保存到用户相册即可。
base64ToPath方法的实现
如何将base64数据转换成tempFilePath呢,网上教的都是转成blob,但是这在uniapp的移动端中不适用啊,网上搜不到,我就去插件库中抄一下,看看它们是怎么实现的,嘿,当我打开LimeEchart的目录的时候,很快就让我在一个 utils.js
文件中翻到了一个名为 base64ToPath
的工具函数,啥也不说,直接复制:
js
/**
* base64转filePath
* @param {Object} base64
*/
export function base64ToPath(base64) {
return new Promise((resolve, reject) => {
const [, format, bodyData] = /data:image\/(\w+);base64,(.*)/.exec(base64) || [];
const bitmap = new plus.nativeObj.Bitmap('bitmap' + Date.now())
bitmap.loadBase64Data(base64, () => {
if (!format) {
reject(new Error('ERROR_BASE64SRC_PARSE'))
}
const time = new Date().getTime();
const filePath = `_doc/uniapp_temp/${time}.${format}`
bitmap.save(filePath, {},
() => {
bitmap.clear()
resolve(filePath)
},
(error) => {
bitmap.clear()
console.error(`${JSON.stringify(error)}`)
reject(error)
})
}, (error) => {
bitmap.clear()
console.error(`${JSON.stringify(error)}`)
reject(error)
})
})
}
然后就可以接着实现creates方法了:
js
creates(base64Data) {
uni.showLoading({
title: '下载中',
})
base64ToPath(base64Data).then(filePath => {
uni.saveImageToPhotosAlbum({
filePath,
success: () => {
uni.hideLoading()
uni.showToast({
title: '图片下载成功',
mask: false,
duration: 1500
});
}
});
})
}
到这里,基本上没有问题了,然后我就开了模拟器,进行了一波真机运行,确实很ok。

正式项目中遇到的坑
本次遇到最大的坑来了,上面是我在一个demo项目中实现的(编译快),然后我将其移动到正式项目中去。
然后发现,当我点击下载图片的时候,死活触发不了 toImage
方法,但是逻辑层的方法却不受影响,第一时间想到的是,难道是层级问题?毕竟这个页面用了挺多绝对定位和 z-index
的,但是跑了一下H5,控制台看了一下,没有问题啊,有层级问题的话,旁边分享到微信的两个按钮也应该用不了啊,咋就视图层的方法触发不了呢?
于是乎,我给下载图片按钮绑定了一个逻辑层的方法,然后想这个方法中调用视图层的方法:
vue
<!-- <view class="share-item" @click="canvas.toImage"> -->
<view class="share-item" @click="downloadImage">
<text>下载图片</text>
</view>
<script>
// ... 省略其他
downloadImage(){
console.log('执行')
this.canvas.toImage()
}
</script>
然后点击下载图片,控制台中输出了"执行"俩字,接着就是报错,说 canvas.toImage is not a function
,不是吧啊sir,我打印this出来,上面确实有canvas属性的喔,然后canvas里面也确实有toImage的喔,而且这个toImage也确实是function类型的喔,后来想想,我那是在H5看的this,跟移动端不一样,毕竟文档也写了这么两句话:
- H5 端逻辑层和视图层实际运行在同一个环境中,相当于使用 mixin 方式,可以直接访问逻辑层数据;
- uni-app的app端逻辑层和视图层是分离的; 好吧,看来想解决这个问题,得先弄清楚到底是什么原因导致的它不响应这个点击事件。
插槽中的元素无法触发视图层的点击事件
为了弄明白到底是什么原因导致的点击事件触发,我决定使用排除法。
首先我直接把demo项目中的这个功能直接整个复制到了正式项目中去(覆盖掉该模块的其他代码),然后运行,发现是可以下载图片的,也就是说,功能确实是没有问题的。
然后我开始恢复页面样式(既然功能没问题,那我把页面也恢复,那这个需求不就解决了吗),恢复的过程中,我发现猫腻了,因为这个项目中很多地方都用到了天空和海平面作为背景图,然后这俩还是分开的,于是我之前封装了一个页面容器组件pageContainer,然后这个组件的插槽部分就是页面内容了,这样就不用我每个页面都重新写背景了。
当我使用pageContainer包裹页面内容的时候,视图层的方法就无法被触发。
vue
<!-- 触发 -->
<view class="page-container">
<view class="share-btn-group">
<view class="share-item" @click="canvas.toImage">
<text>下载图片</text>
</view>
</view>
</view>
<!-- 不触发 -->
<PageContainer>
<view class="page-container">
<view class="share-btn-group">
<view class="share-item" @click="canvas.toImage">
<text>下载图片</text>
</view>
</view>
</view>
</PageContainer>
找到了问题所在,那解决起来就简单了,我直接不用pageContaienr组件了,这个页面直接写背景图,最后一试,果然没问题了。
写在最后
真的是麻了,本来挺简单的一个功能,结果一开始卡在base64转filePath上面,找了很多方法都没用,然后又因为跨域的问题,我不得已转变思路,改成了当用户点击下载的时候,调用系统的截图功能,将当前页面截图下来保存,后来截图会把按钮那些也截下来,用户体验不好,而且后面分享到微信及朋友圈也需要图片,因此这个方法行不通。
又换回了html2canvas,结果又卡在了视图层事件不触发这上面,关键是,它不报错,点击之后啥反应都没有,人家逻辑层起码还抛一个 not a function
出来,然后文档中没有提到过这个(可能是那句只支持内联?),社区中也没有,网上还找不到。