最近工作中遇到一个需求,类似这样
点击商品二维码,生成一张带有商品图片、标题、描述、二维码等信息的图片,用户长按进行保存。
在使用html2canvas进行项目开发的时候,遇到很多的问题,主要为一下方面:
1、图片跨域问题
2、截图不全问题
3、html2canvas在IOS13.4.1 上失效问题
4、canvas 嵌套 canvas 问题
5、img标签使用 base64 文件 在安卓真机上闪退问题
下面把我的探坑之旅和解决思路做个梳理 →
需求实现主要为以下三大步:
第一:如何生成二维码
第二:如何生成图片
第三:如何实现长按保存
- 如何生成二维码
这里我使用的是 qrcode 插件(官网地址:https://davidshimjs.github.io/qrcodejs/)
QRCode组件 附上代码:
import React, { PureComponent } from 'react'
import QRCode from 'qrcode'
import { color as d3Color } from 'd3-color'
/**
* 转化css颜色值为 RGBA hex形式的值 比如: #fff => #ffffffff
* @param {css color} cssColor - css颜色值
*/
const convertColor = (cssColor) => {
const temp = d3Color(cssColor)
if (temp === null) {
return undefined
}
const alpha = Number(((temp.a || 1) * 255).toFixed(0))
const result = [temp.r, temp.g, temp.b, alpha]
.map((e) => {
const s = e.toString('16')
return s.length < 2 ? `0${s}` : s
})
.join('')
return result
}
// 合并配置信息
const mergeConfig = (options) => {
const {
ecLevel,
margin,
width,
color,
background, // scale,
} = options
return {
errorCorrectionLevel: ecLevel || 'M', // L, M, Q, H,
margin: margin || 2,
// scale: scale || 4,
width: width || 100,
color: {
dark: convertColor(color) || '#000000ff',
light: convertColor(background) || '#ffffffff',
},
}
}
export default class ReactQRCode extends PureComponent {
componentDidMount = () => {
this.draw()
}
componentDidUpdate = () => {
this.draw()
}
draw = () => {
const { value, onDrowSuccess, ...rest } = this.props
const cfg = mergeConfig(rest)
QRCode.toCanvas(this.canvas, `${value}`, cfg).then(() => {
onDrowSuccess && onDrowSuccess(this.canvas.toDataURL('image/jpeg'))
}).catch((err) => {
window.console.error(err)
})
}
render() {
return (
<canvas
style={{ width: 0 }}
ref={(ref) => {
this.canvas = ref
}}
/>
)
}
}
调用方式:
<QRCode value="http://abc" width={240} color="black" background="#fff" ecLevel="H" />
- 如何生成图片
经过多方考察调研,最终我使用的是 html2Canvas插件(官网地址:http://html2canvas.hertzen.com/)
html2Canvas的git⭐️⭐️指数还挺高的,并且浏览器兼容版本还不错。
下面开始进入正题→
- 首先,想要使用html2Canvas画图之前,我们需要确保想要绘制的html页面已经生成,否则,画出来的图可能不完整,所以我们将画图的操作放到 componentDidMount 这一生命周期进行,确保页面已经渲染完成。
附上代码:
class DrowProductQrCode extends Component {
componentDidMount() {
// 获取dom节点
this.element = document.getElementById('productQrCode')
this.canvas2Image()
}
canvas2Image = () => {
html2canvas(this.element).then((canvas) => {
const url = canvas.toDataURL('image/jpeg')
const oImg = document.createElement('img')
oImg.href = url
document.body.appendChild(oImg)
})
}
render() {
const { qrCodeUrl, goodImg, name, title } = this.props
return (
<div className={styles.container} id="productQrCode">
<Flex>
<div className={styles.goodImg}>
<img className={styles.img} crossOrigin="Anonymous" src={goodImg} alt="商品图片" />
</div>
<div className={styles.goodInfo}>
<div className={styles.title}>{name}</div>
<div className={styles.desc}>{title}</div>
</div>
</Flex>
<QrCode value={qrCodeUrl} width={220} />
<div className={styles.tips}>
扫描上面的二维码,查看内容
</div>
</div>
)
}
}
这时候我们会发现控制台报错了
最直观的报错提示: been blocked by CORS policy: Response to preflight request doesn’t pass access control check: No ‘Access-Control-Allow-Origin’ header is present on the requested resource. If an opaque response serves your needs, set the request’s mode to ‘no-cors’ to fetch the resource with CORS disabled.
意思是我们的 图片 跨域了,因为我们的图片大多都存储在阿里云或者其他服务器上,从我们本地去使用canvas去访问这张图片时,会存在跨域问题。
- 接下来,如何解决跨域问题成了关键
根据 html2Canvas 的官方文档我们可以知道:
html2Canvas为我们提供了两个参数以解决跨域问题,而这里,根据我们的报错信息(by CORS policy)我们使用的就是useCORS。
于是,我们给代码加上这一参数
html2canvas(this.element, {
useCORS: true,
}).then((canvas) => {...})
结果还是不起作用,我们再一次在控制台看见了这可怕的鲜红字眼
这是怎么回事呐?
原来当我们在设置 useCORS: true 这一参数时,需要给img 标签加上 允许跨域的 标识(crossOrigin=“Anonymous”)
像这样
<img className={styles.img} crossOrigin="Anonymous" src={goodImg} alt="商品图片" />
这时候我的内心已经小有雀跃了,持着激动的心,颤抖的手按下了保存按钮
啊哦。。。
这可怕的鲜红字眼又出现了。。
但其中有一条信息非常值得我们关注:No ‘Access-Control-Allow-Origin’ header is present on the requested resource.
这表明,我们需要我们的后端在我们请求这张图片时给我么加上 Access-Control-Allow-Origin :允许跨域访问的域名 这项设置,必须这张图片是允许我们这个域 跨域访问时, 我们才能成功拿到这张图片。
有的人很好奇,为什么平时我们的代码中 ,使用过那么多img 标签,为什么没有遇到这个问题。这是因为 我们给 img 标签设置了 crossOrigin=“Anonymous” ,这才导致的。
接下来,我就屁颠屁颠去找到我司可爱的运维小哥,让他把我的域给允许跨域了。
现在!现在!我感觉已经越过了艰难险阻,是时候看见光明了,我再次怀着激动的心,颤抖的手刷新页面
我 我 我 我去!
这鲜红的字眼
让我有点恶心了
这 这 究竟是怎么肥事,我不忙明白了。运营小哥也仔仔细细的看了他加的配置, 写错了字母
于是我的眼里又燃起了希望呀,运营小哥一顿操作猛如虎,图片请求还是 500
这时候,我注意到了一个问题
为什么 5f68413ce4b0c9f1400679f6.jpg 这张图片被请求了好几次?而且居然前面还有请求成功的。这,这。。
这时候,百度的一篇文章给了我答案
CORS的配置方法一般是针对每个访问来源单独配置规则,勿将多个来源驾到一个规则,多个规则之间不要有覆盖冲突。
原来,因为我是在商品详情页引入的 DrowProductQrCode 组件,商品详情页可能有很多地方在同时访问这张商品的图片,这就导致了我们的配置冲突了,这张图片到底是走缓存还是走请求,走请求是一次还是多次?
所以我灵机一动,给我们的 卡片 DrowProductQrCode 里的这张图片加上一个时间戳,这样浏览器每次就会认为这是一个新的请求,这样就不在存在以上问题了。
const getTimestamp = new Date().getTime()
goodImg = `${goodImg}?timestamp=${getTimestamp}`
再次怀着激动的心,颤抖的手按下保存按钮, 终于成功的出来了商品图片
但是里面的二维码却没有出来。。。。
这这又是为什么呐
我们在仔仔细细的康康我们代码
我们在我们将要绘制canvas的html片段里又嵌套了一个canvas,这可如何是好,canvas画图的时候没有支持这个canvas嵌套canvas的操作。
- 接下来如何解决canvas嵌套canvas的操作问题又成了关键
其实这很好解决
如果不能使canvas嵌套canvas,那我们就把里面的cavas转化成为html,不就行了,
// 在 QrCode 组件上传入一个回调函数,当二维码的 canvas 绘制完成之后,我们将canvas 转化成为 base 64 的文件返回回来
<QrCode onDrowSuccess={this.drowQrCodeSuccess} value={invitaionUrl(currentUserId, id)} width={220} />
我们的再去调一下后端上传图片的接口,将base 64 的图片上传上去,得到存在我们自己服务器上的二维码 url.
/**
* 将以base64的图片url数据转换为Blob
* @param base64 用url方式表示的base64图片数据
* @return blob 返回blob对象
*/
function dataURItoBlob(dataURI) {
let byteString
if (dataURI.split(',')[0].indexOf('base64') >= 0) byteString = atob(dataURI.split(',')[1])
else byteString = unescape(dataURI.split(',')[1])
const mimeString = dataURI
.split(',')[0]
.split(':')[1]
.split(';')[0]
const ia = new Uint8Array(byteString.length)
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i)
}
return new Blob([ia], { type: mimeString })
}
drowQrCodeSuccess = (url) => {
uploadPublicFile(dataURItoBlob(url))
.then((data) => {
const imgUrl = getOssFileUrl(data)
this.setState({
qrCodeUrl: imgUrl,
})
})
.catch(err => console.log('err', err))
}
大家一定也想问,为什么不直接用base 64 的图片作为 img 标签的 url 放在 html 文件里,继续往后面读。。。
就这样,我们的 二维码 卡片 canvas终于画出来了,普天同庆,可喜可贺 吗?
我们突然发现画出来的canvas图不太完整,少了一些东西
头 头 头有点大…
- 接下来如何解决截图不完整问题又成了关键
经过多方调研发现,是因为我们的内容过长,出现了滚动条或者其他原因导致 html2Canvas 截图不完整,网上有很多解决方法,但是经过我的多方实践,如果是出现了滚动条最好用的方法还是这个:
加上这两个参数就可以了,简单粗暴,效果完美
接下来,就是最后一步
- 如何实现长按保存
二维码卡片画出来了,接下来就是保存图片。
老规矩,我们先将canvas 转化为 url
const url = canvas.toDataURL('image/jpeg')
然后写一个长按下载函数
componentDidMount() {
// 监听容器点击事件
this.longPress(this.downloadImg, this.element)
}
// 组件销毁时移除监听事件
componentWillUnmount() {
this.element.removeEventListener('touchstart', this.touchstart)
this.element.removeEventListener('touchend', this.touchend)
}
// 封装一个长按方法
longPress = () => {
this.timeout = 0
this.element.addEventListener('touchstart', this.touchstart, false)
this.element.addEventListener('touchend', this.touchend, false)
}
touchstart = () => {
// 长按时间超过800ms,则执行传入的方法
this.timeout = setTimeout(this.downloadImg, 800)
}
touchend = () => {
// 长按时间少于800ms,不会执行传入的方法
clearTimeout(this.timeout)
}
// 图片下载
downloadImg = () => {
const { goodQrCode, fileName } = this.props
const oImg = document.createElement('a')
oImg.download = fileName
oImg.href = goodQrCode
oImg.click()
oImg.remove()
}
致此,下载就此完成。在pc端操作起来特别顺畅
于是,我拿出测试机,在ios手机上测试, IOS手机长按会自动调起系统的保存图片方法,好像没什么问题,虽然没使用我们的代码,但是目的是达到了。接下来就是安卓机,
长按,闪退。。。
长按, 闪退。。。
换个安卓机
长按,闪退。。。
长按, 闪退。。。
怎么肥事。。
拿出数据线,打开uc-devtools, 连接手机,真机调试一看,发现每次长按后,页面就被 crash 掉了。经过百度发现,因为 base 64的文件太长了,在很多手机上无法支持预览及下载。
这下明白了为什么我上面生成的 qrCode 为什么不直接使用 base 64的文件作为 img 的 src 路径了吧。
老办法,我们调用后端接口,将图片上传到我们自己的服务器,然后用后端返回的地址作为图片链接。
你以为这就结束了吗?
no no no
坑还没踏完呐
测试在测试的时候,发现ios的一款手机的二维码怎么也出不来
经过调查发现,我所使用的 html2canvas 版本(1.0.0-rc.7 ) 在IOS13.4.1 系统版本不生效,需要把它降到 html2canvas 1.0.0-rc.4 版本方可成功
附上代码 ->
// npm 管理
// 先卸载旧版本
npm uninstall html2canvas
// 安装新版本
npm install --save html2canvas@1.0.0-rc.4
// yarn 管理
// 先卸载旧版本
yarn remove html2canvas
// 安装新版本
yarn add html2canvas@1.0.0-rc.4
完美解决!
但是大家也知道,使用 a 标签下载图片 基本不太现实,他只能新开一个窗口,预览图片,然后用户自己手动截屏或者靠系统、浏览器自带的长按保存图片方法。想要是实现长按保存的效果只能靠调起 native 方法、或者后端实现下载功能,我们请求接口来得以实现。
那么问题来,如果后端和native都不愿意或者没法实现,产品又非让你做出这个效果来
那你就… 你就… 你就… 找他理论(低头)去
最后附上完整代码逻辑:
GoodsDetailPage:
handleCanvas2ImageOK = (url) => {
this.setState({
goodQrCode: url,
productQrCodeDivShow: false,
})
}
render() {
return {
<div>
// 商品二维码卡片
<GoodQrCodeModal
// 影藏modal弹框方法
hideCodeModal={this.hideCodeModal}
// 是否展示modal弹框
codeModalShow={codeModalShow}
// qrcode 生成的二维码上传到后端后的url地址
goodQrCode={goodQrCode}
// 下载的文件名
fileName={name}
/>
// 生成商品二维码的HTML代码, 通过 productQrCodeDivShow 字段控制其展示
// productQrCodeDivShow 的作用就是让GoodsDetailPage页面渲染时将 商品二维码卡片 生成,然后返回 商品二维码卡片 的url, 影藏商品二维码的HTML。
{productQrCodeDivShow && (
<ProductQrCode
currentUserId={userId}
detail={detail}
onCanvas2ImageOK={this.handleCanvas2ImageOK}
/>
)}
</div>
}
}
ProductQrCode:
/**
* 将以base64的图片url数据转换为Blob
* @param base64 用url方式表示的base64图片数据
* @return blob 返回blob对象
*/
function dataURItoBlob(dataURI) {
let byteString
if (dataURI.split(',')[0].indexOf('base64') >= 0) byteString = atob(dataURI.split(',')[1])
else byteString = unescape(dataURI.split(',')[1])
const mimeString = dataURI
.split(',')[0]
.split(':')[1]
.split(';')[0]
const ia = new Uint8Array(byteString.length)
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i)
}
return new Blob([ia], { type: mimeString })
}
class ProductQrCode extends Component {
state = {
qrCodeUrl: '',
}
componentDidMount() {
}
drowQrCodeSuccess = (url) => {
uploadPublicFile(dataURItoBlob(url))
.then((data) => {
const imgUrl = getOssFileUrl(data)
this.setState({
qrCodeUrl: imgUrl,
})
})
.catch(err => console.log('err', err))
}
render() {
const { currentUserId, detail, onCanvas2ImageOK } = this.props
const { name, title, pics, id } = detail || []
const getTimestamp = new Date().getTime()
let goodImg = getObjField(getOssFileUrl(pics), '[0]')
goodImg = `${goodImg}?timestamp=${getTimestamp}`
const { qrCodeUrl } = this.state
return (
<div>
<QrCode onDrowSuccess={this.drowQrCodeSuccess} value={invitaionUrl(currentUserId, id)} width={220} />
// 确保qrcode 已生成 二维码,并且上传到服务器获取到url地址
{qrCodeUrl && (
<DrowProductQrCode
onCanvas2ImageOK={onCanvas2ImageOK}
qrCodeUrl={qrCodeUrl}
name={name}
title={title}
goodImg={goodImg}
/>
)}
</div>
)
}
}
export default ProductQrCode
class DrowProductQrCode extends Component {
componentDidMount() {
// 获取dom节点
this.element = document.getElementById('productQrCode')
this.canvas2Image()
}
canvas2Image = () => {
const { onCanvas2ImageOK } = this.props
html2canvas(this.element, {
// 允许跨域 (allowTaint, useCORS)设置其一
useCORS: true,
scrolly: 0,
scrollx: 0,
}).then((canvas) => {
const url = canvas.toDataURL('image/jpeg')
// 将canvas生成的 base64 的地址转化为 blob(base64 过长导致手机下载出现问题) , 上传到oss获取图片URL
const blobFile = dataURItoBlob(url)
uploadPublicFile(blobFile)
.then((data) => {
const imgUrl = getOssFileUrl(data)
onCanvas2ImageOK && onCanvas2ImageOK(imgUrl)
})
.catch(err => console.log('err', err))
})
}
render() {
const { qrCodeUrl, goodImg, name, title } = this.props
return (
<div className={styles.container} id="productQrCode">
<Flex>
<div className={styles.goodImg}>
<img className={styles.img} crossOrigin="Anonymous" src={goodImg} alt="商品图片" />
</div>
<div className={styles.goodInfo}>
<div className={styles.title}>{name}</div>
<div className={styles.desc}>{title}</div>
</div>
</Flex>
<img className={styles.qrCode} crossOrigin="Anonymous" src={qrCodeUrl} alt="商品图片" />
<div className={styles.tips}>
扫描上面的二维码,查看内容
</div>
</div>
)
}
}
GoodQrCodeModal:
import React from 'react'
import { Modal } from 'antd-mobile'
import styles from './GoodQrCodeModal.scss'
class GoodQrCodeModal extends React.PureComponent {
componentDidMount() {
}
render() {
const {
codeModalShow, hideCodeModal, goodQrCode, fileName,
} = this.props
return (
<Modal
className={styles.codeModal}
visible={codeModalShow}
maskClosable
transparent
onClose={hideCodeModal}
>
<GoodQrCodeImg goodQrCode={goodQrCode} fileName={fileName} />
</Modal>
)
}
}
export default GoodQrCodeModal
class GoodQrCodeImg extends React.PureComponent {
componentDidMount() {
this.element = document.getElementById('goodQrCode')
// 监听容器点击事件
this.longPress(this.downloadImg, this.element)
}
componentWillUnmount() {
this.element.removeEventListener('touchstart', this.touchstart)
this.element.removeEventListener('touchend', this.touchend)
}
// 封装一个长按方法
longPress = () => {
this.timeout = 0
this.element.addEventListener('touchstart', this.touchstart, false)
this.element.addEventListener('touchend', this.touchend, false)
}
touchstart = () => {
// 长按时间超过800ms,则执行传入的方法
this.timeout = setTimeout(this.downloadImg, 800)
}
touchend = () => {
// 长按时间少于800ms,不会执行传入的方法
clearTimeout(this.timeout)
}
// 图片下载
downloadImg = () => {
const { goodQrCode, fileName } = this.props
const oImg = document.createElement('a')
oImg.download = fileName
oImg.href = goodQrCode
oImg.click()
oImg.remove()
}
render() {
const { goodQrCode } = this.props
return (
<img id="goodQrCode" className={styles.goodQrCode} src={goodQrCode} alt="商品二维码" />
)
}
}
以上就是全部大致思路啦
如有bug, 请多指教✍️✍️✍️
如果对你有帮助,就给我点个赞吧