1. 介绍wxml-to-canvas
这是一个类似html2canvas
的插件,在canvas
上绘制页面,但是有段时间没维护了。
我的需求是将一个长页面截图并保存。
2.使用
如果是原生微信小程序,就按照官网整就行了。
这里说一下uniapp
的方式(我是这么做的,当然还有其他方法):
- 新建一个代码片段
npm install --save wxml-to-canvas
- 构建
npm
- 复制
miniprogram_npm
中的widget-ui
和wxml-to-canvas
到项目中的wxcomponents
文件夹。 - 在
page.json
配置
json
"usingComponents": {
"wxml-to-canvas": "/wxcomponents/wxml-to-canvas"
}
- 页面中的使用就和文档一样了。
html
<wxml-to-canvas
ref="wxml2canvas"
class="wxml2canvas"
:width="width"
:height="height"/>
js
Page({
onLoad() {
// 获取自定义组件实例
this.widget = this.selectComponent('.widget')
// 绘制页面
this.widget.renderToCanvas({
// 模板
wxml: "<view class='container'><text class='text'>hello</text><view>",
// 样式
style: {
container: {
backgroundColor: '#f50'
},
text: {
width: 30,
fontSize: 16
}
}
})
// 导出图片
this.widget.canvasToTempFilePath()
},
})
3.原理
这个插件原理还是比较简单,就是把模板解析成一个个节点,然后根据样式绘制不同的元素。比如view -> rect
、image -> drawImage
、text -> drawText
。实际上也就只支持这三个元素。
插件中使用的widget-ui
就是解析模板和样式的:
js
// 源代码中能看到renderToCanvas的流程
async renderToCanvas(args) {
// 传进来的模板和样式
const {wxml, style} = args
const ctx = this.ctx
const canvas = this.canvas
// 解析wxml
const {root: xom} = xmlParse(wxml)
const widget = new Widget(xom, style)
const container = widget.init()
const draw = new Draw(ctx, canvas, use2dCanvas)
// 绘制节点
await draw.drawNode(container)
return Promise.resolve(container)
},
然后遍历节点树根据标签配置分别绘制:
js
async drawNode(element) {
const {layoutBox, computedStyle, name} = element
const {src, text} = element.attributes
if (name === 'view') {
// 绘制view
} else if (name === 'image') {
// 绘制image
} else if (name === 'text') {
// 绘制text
}
// 遍历节点树
const childs = Object.values(element.children)
for (const child of childs) {
await this.drawNode(child)
}
}
所以很多插件不支持的样式标签,你可以自己加上去。
4. 坑
这个插件还是蛮多的,这里记录下我使用的过程中遇到的。
4.0. 获取元素尺寸
这个插件最重要的一点就是最好最好所有的元素都得有宽高尺寸,不然到时候显示不出来,都没办法调试,不知道怎么回事。
获取元素尺寸我用的就是createSelectorQuery
:
js
const query = uni.createSelectorQuery()
query
.selectAll('.class1,.class2,.class3')
.boundingClientRect()
.exec(res => console.log(res))
// 获取到尺寸就可以放进样式中了
4.1. 无法显示本地图片
html
<!-- 你可能想要这样引入 -->
<image src="./img/icon.png"/>
但是这样肯定不行,因为没有经过处理,这个路径是无法识别的。
解决方案:
- 使用
base64
,这种方案只适合use2dCanvas
为true
的时候。 - 先写入文件再调用,这种方法适合
use2dCanvas
为false
的时候。
4.1.1 方案一
这个方案简单粗暴但是还有点问题,源码中没有针对base64
做处理,所以需要改下源码。
js
async drawImage(img, box, style) {
await new Promise((resolve, reject) => {
// ....
const _drawImage = (img) => {
// ....
}
// ....
const isTempFile = /^wxfile:\/\//.test(img)
const isNetworkFile = /^https?:\/\//.test(img)
// 主要就是加这里
const isBase64 = img.includes(';base64,')
// 之前是没有判断的,会直接报错
if (isTempFile || isBase64) {
_drawImage(img)
} else if (isNetworkFile) {
// ....
} else {
reject(new Error(`image format error: ${img}`))
}
})
}
4.1.2 方案二
这个方案适合use2dCanvas=false
的时候,也就是使用老版canvas
接口的时候,因为老版接口不支持使用base64
。
js
// 设置图片
const filePath = `${wx.env.USER_DATA_PATH}/export-bg1.png`
const fs = wx.getFileSystemManager()
await (() => new Promise((resolve) => {
fs.access({
path: filePath,
success(res) {
// 文件存在
resolve()
},
fail(res) {
// 文件不存在或其他错误
fs.writeFile({
filePath: filePath,
// 我这里将base64的数据存储为文件
data: '/9j/4AAQSkZJRg',
encoding: 'base64',
success(res) {
resolve()
},
fail(res) {
console.error(res)
},
})
}
})
}))()
// 模板里面就可以使用这个路径了
wxml: `
<image src="${filePath}"/>
`
4.2. 无法显示文字
多半是尺寸没设置,最重要的是设置text
的宽度,其实这里的text
跟view
没有什么区别,在canvas
中就只是一个图形而已。
最好不要搞嵌套text
,就像上面说的,对于canvas
来讲没有什么块级元素,行内元素,排版是通过计算得出的x,y
,所以嵌套text
会有问题。最好就是平级的text
,设置一下父节点的flex
就行了。
js
{
wxml: `
<view class="container">
<text class="text1">100</text>
<text class="text2">㎡</text>
</view>
`,
style:{
container: {
flexDirection: 'row'
},
text1: {
width: 30,
height:30,
},
text2: {
width: 20,
height: 20
}
}
}
4.3. 文字加粗
改一下源码,把样式加上
js
drawText(text, box, style) {
const ctx = this.ctx
let {
left: x, top: y, width: w, height: h
} = box
let {
color = '#000',
lineHeight = '1em',
fontSize = 14,
textAlign = 'left',
verticalAlign = 'top',
backgroundColor = 'transparent',
// 加一个fontWeight
fontWeight = 'normal'
} = style
// 这里也加一个${fontWeight}
// 补充一下fontSize在老版本的canvas中不能有小数,所以这里也判断了一下
ctx.font = `${fontWeight} ${this.use2dCanvas ? fontSize : Math.round(fontSize)}px sans-serif`
// ....
}
4.4. 动态设置宽高
由于源码中只初始化了一次,所以后续canvas
宽高发生变化是没有反应的。我懒得加上监听了,所以偷懒了一下,只加了一个初始完成的事件。宽高变化了重新加载一次组件就完事了。
js
// 源码中初始化部分
lifetimes: {
attached() {
// ....
this.setData({use2dCanvas}, () => {
if (use2dCanvas) {
const query = this.createSelectorQuery()
query.select(`#${canvasId}`)
.fields({node: true, size: true})
.exec(res => {
const canvas = res[0].node
const ctx = canvas.getContext('2d')
canvas.width = res[0].width * dpr
canvas.height = res[0].height * dpr
ctx.scale(dpr, dpr)
this.ctx = ctx
this.canvas = canvas
// 加了一个初始化完成的事件
this.triggerEvent('load')
})
} else {
this.ctx = wx.createCanvasContext(canvasId, this)
// 加了一个初始化完成的事件
this.triggerEvent('load')
}
})
}
},
4.5. flex
问题
父元素要设置宽高才能应用justifyContent
和alignItems
flexDirection: 'row'
时子元素必须设置宽度,可以使用flex: 1
,但是不会自动扩展尺寸
4.6. 单位转换
canvas
使用的单位是px
,需要转化rpx
:
js
/**
* rpx转px
*/
export const rpx2px = (rpx) => {
// 这里的deviceInfo实际上就是getSystemInfo接口获取的数据
const {
devicePixelRatio,
screenWidth
} = store.state.deviceInfo
const px = (screenWidth / 750) * rpx
// 小于1像素的处理
if(Math.floor(px) === 0) {
if(devicePixelRatio === 1) {
return 1
}else {
return 0.5
}
}
return px
}
4.7.设置边框
四周边框的原理实际上是套了一个更大一点的矩形。所以内部需要指定背景色,不然就都是边框的颜色。
js
drawView(box, style) {
const ctx = this.ctx
const {
left: x, top: y, width: w, height: h
} = box
const {
borderRadius = 0,
borderWidth = 0,
borderColor,
color = '#000',
backgroundColor = 'transparent',
} = style
ctx.save()
// 外环
if (borderWidth > 0) {
ctx.fillStyle = borderColor || color
this.roundRect(x, y, w, h, borderRadius)
}
// 内环
ctx.fillStyle = backgroundColor
const innerWidth = w - 2 * borderWidth
const innerHeight = h - 2 * borderWidth
const innerRadius = borderRadius - borderWidth >= 0 ? borderRadius - borderWidth : 0
this.roundRect(x + borderWidth, y + borderWidth, innerWidth, innerHeight, innerRadius)
ctx.restore()
}
4.8. 截取超长图
新版的canvas
是有尺寸限制的:
js
// 大概就是这么多,可能就5000px左右
deviceInfo.pixelRatio * 1365
对于高dpr
的设备这个尺寸绘制长图还是有点费劲,所以要绘制长图最好是使用老接口。
老版本我试了一下,截取20000px
都没有问题,当然这个和设备还是有关系,不过对于一般的业务也是足够的。
4.9. 使用老版本canvas
的问题
绘制图片不能用base64
,先写入文件 这个前面说了。
注意高度不要算出什么NaN
之类的,这个在新接口中好像不会有问题,在老版本的接口中会出现高度不正常。
4.10. renderToCanvas
异步不可靠
源码中对renderToCanvas
做了异步处理,代码逻辑没问题,但是实际绘制耗时任务的时候renderToCanvas
的promise
结束了但是绘制有可能还没完成。如果没完成的时候导出图片,就会是残缺样式的。
我的解决方法是在导出前延迟1s
。虽然不是很保险,但是基本上没问题。
4.11. 隐藏canvas
这个主要针对使用老版本canvas
的时候,因为新版本可以随便修改层级了。
解决方法:
包裹一个view
,设置宽高0,定位到屏幕外。
css
.wxml2canvas {
position: absolute;
top: -9999px;
left: 0;
width: 0;
height: 0;
overflow: hidden;
}
5. 效果
我在项目中实现的截图效果大概就是这样:
说实话就这么一个表格都要拼接好久的模板,复杂的截图用这个插件根本无法做到,只有期待谁可以完善这套东西,增加一些标签和样式。