前端基于excljs导出xlsx时图片资源的处理及踩坑实录
相信做ToB项目的前端er或多或少都遇到过需要导出excl的需求,可能有些是让后台来实现,前端仅仅只是实现流的下载,毕竟后端有
easypoi
是吧,也有些是前端做的导出,可能是js-xlsx
结合file-saver
或者是excljs
结合fileSaver
前言
前段时间平台上线了防疫申报的功能,司机可以在小程序上将健康码和行程码上传,然后通过ocr识别对应的资料来保存防疫相关的信息,然后客户提出来要建立电子台账,要支持导出excl来方便各部门进行数据汇总和统计,讨论后决定由前端来实现数据导出,好处有三:
- 模版和数据都在前端,组装表头方便
- 在客户端实现不占用服务器的算力资源
- 减少了服务器公网流量的消耗
书归正传,下面是我的实现路径
技术选型
目前市面常见的前端excl导出就是我提到的那两类js-xlsx
或者excljs
, 我采用的是excljs的方式,原因是因为excljs提供的API更为丰富,可以方便我们进行定制
实现思路
经过查阅excljs
的文档发现,excljs
提供了针对工作簿的操作writeBuffer
,这是一个异步的方法,然后通过file-saver
就能实现buffer转excl了
// 写入 buffer
const buffer = await workbook.xlsx.writeBuffer();
// fileSaver(内容,配置,文件名)
fileSaver(new Blob([buffer], {
type: 'application/octet-stream'
}), fileName + '.' + 'xlsx')
那么问题就转变成了,如何通过excljs
来构造工作簿
如何通过excljs
来构造工作簿
我们要导出的数据,可以分为两部分,一部分是表头,一部分是数据,我们先不考虑图片的情况,先 创建工作簿,然后根据创建表头, 填充数据
- 创建工作簿,工作表
const Excel = require('exceljs')
let workbook = null
let worksheet = null
workbook = new Excel.Workbook()
workbook.created = new Date()
workbook.modified = new Date()
worksheet = workbook.addWorksheet('sheet1')
- 创建表头, 我的表头是通过模版生成的,所以表头是遍历出来的。表头主要由两个个属性构成
header
表示表头的名字,key
表示对应数据的属性名,imageColumnsKey
对应的是需要处理的图片类型的key
let columns = []
let imageColumnsKey = {}
temp.forEach((v, inx) => {
if (v.fieldType === 'image' ||
v.fieldType === 'multipleImage' ||
v.fieldType === 'accredit') {
imageColumnsKey[v.fieldName] = inx
}
columns.push({ header: v.fieldCustomTitle, key: v.fieldName, width: 100 })
})
worksheet.columns = columns
注意 : worksheet.columns
只能通过直接赋值的方式来用,而不能用worksheet.columns.push
原因是 excljs
内部针对排序会做处理,通过push
添加的表头将会导致下标获取异常,excljs
会抛出一个column.equivalentTo is not a function
这样的异常。
参考文档: github.com/exceljs/exc…
- 填充数据 填充数据比较简单,用
worksheet.addRow
增行即可
dataList.forEach(v => {
let row = worksheet.addRow(v)
// 指定行高是为了给图片预留一个高度
row.height = 100
})
处理数据为图片类型的情况
excljs
提供了worksheet.addImage
方法来将图片插入工作表
将图像添加到工作表是一个分为两个步骤的过程。首先,通过
addImage()
函数将图像添加到工作簿中,该函数还将返回imageId
值。然后,使用imageId
,可以将图像作为平铺背景或覆盖单元格区域添加到工作表中
将图片添加到工作簿⬆
Workbook.addImage
函数支持按文件名或按 Buffer
添加图像。请注意,在两种情况下,都必须指定扩展名。有效的扩展名包括 “jpeg”,“png”,“gif”。 虽然官网提供了三种生成imageId
的方式,但是实际上,在浏览器环境只有base64
的方式可以成功添加到工作簿并生成imageId
// 通过文件名将图像添加到工作簿 适用于nodejs环境
const imageId1 = workbook.addImage({
filename: 'path/to/image.jpg',
extension: 'jpeg',
});
// 通过 buffer 将图像添加到工作簿 适用于nodejs环境
const imageId2 = workbook.addImage({
buffer: fs.readFileSync('path/to.image.png'),
extension: 'png',
});
// 通过 base64 将图像添加到工作簿 适用于浏览器环境
const myBase64Image = "data:image/png;base64,iVBORw0KG...";
const imageId2 = workbook.addImage({
base64: myBase64Image,
extension: 'png',
});
图片添加到工作簿以后,可以添加背景,可以在指定位置插入,因与本文主题无关,不再赘述
将图片添加到单元格⬆
您可以将图像添加到单元格,然后以 96dpi 定义其宽度和高度(以像素为单位)注意 col
与 row
都是从 0 开始的 。
worksheet.addImage(imageId2, {
// 指定在哪个单元格插入
tl: { col: 0, row: 0 },
// 指定图像大小
ext: { width: 500, height: 200 }
});
添加带有超链接的图片⬆
您可以将带有超链接的图像添加到单元格,并在图像范围内定义超链接。
worksheet.addImage(imageId2, {
tl: { col: 0, row: 0 },
// 指定图像大小
ext: { width: 500, height: 200 },
hyperlinks: {
//鼠标点击后打开的链接地址
hyperlink: 'http://www.somewhere.com',
// 鼠标移动到图片上时弹出的说明文字
tooltip: 'http://www.somewhere.com'
}
});
注意这里我们需要关注的问题再一次发生了转变,那就是 如何在浏览器端通过图片地址转换为base64?
浏览器端通过图片地址转换为base64
- 通过canvas.toDataURL的方式生成base64
- 通过xhr指定responseType的方式来生成base64
不管是方式1还是方式2,一百度有一堆的文章可以看,这里我就不再赘述了。
到这里就结束了吗?并没有
这里我们还需要考虑一种情况,那就是 我们的图片地址与我们的域名间,会不会发生mixed Content
的情况,简单来说,就是会不会出现,我们的业务域名是https
,但是图片地址协议是http
这样的情况,如果能够升级当然更好,假如没办法升级呢?
不管是方式1还是方式2,均无法解决混合内容的安全策略与HSTS升级策略的问题,所以我们必须像一条可以绕开的路
已知:https协议使用ws协议依然可以正常通信,而不必一定是wss协议, 所以问题就转变为了如何借助ws来实现http图片的base64转换
通过websocket实现图片的base64转换
这里我们需要借助一个跨平台的客户端构建库electron
,然后在electron
的主进程内通过express
和 ws
实现一个ws的服务端,业务域名作为客户端将图片地址发送给服务端,服务端解析完成后,通过消息的方式推送给客户端,然后再调用addImage
添加到工作簿。
// 客户端
let ws = new WebSocket('ws://127.0.0.1:56565')
ws.onopen = () => {
ws.send(JSON.stringify({ text: 'connect' }))
}
let asyncImageArr = []
// 记录下标与字段名,方便匹配
arr.map(v = {
asyncImageArr.push({ url: k, inx, i })
})
// 向服务端发送消息
ws.send(JSON.stringify({
type: 'download',
data: asyncImageArr
}))
// 服务端
function createServer () {
const express = require('express')
const http = require('http')
const Ws = require('ws').Server
const app = express()
const server = http.createServer(app)
server.listen(56565)
const wsServer = new Ws({ server })
wsServer.on('connection', socket => {
console.log('连接成功')
// 监听客户端发过来的消息
socket.on('message', msg => {
const parseMsg = JSON.parse(msg)
// 判断类型是下载时进行处理,留出扩展
if (parseMsg.type === 'download') {
const data = parseMsg.data
const arr = []
data.forEach(v => {
arr.push(getBase64(v))
})
// promise.all 保证调用顺序即执行顺序
Promise.all(arr).then(res => {
socket.send(JSON.stringify({
type: 'download',
data: res
}))
})
}
})
})
}
// 通过promise的方式处理图片
function getBase64 (v) {
const http = require('http')
return new Promise((resolve, reject) => {
http.get(v.url, function (res) {
const chunks = []
let size = 0
res.on('data', function (chunk) {
chunks.push(chunk)
size += chunk.length
})
res.on('end', function (err) {
if (err) {
resolve({
type: 'download',
inx: v.inx,
i: v.i,
url: '图片拉取失败'
})
} else {
let ImgType = (v.url.match(/.(w+)$/) && v.url.match(/.(w+)$/)[1]) || 'jpg'
if (ImgType === 'svg') {
ImgType += '+xml'
}
const imgData = Buffer.concat(chunks, size)
const base64 = 'data:image/' + ImgType + ';base64,' + imgData.toString('base64')
resolve({
type: 'download',
inx: v.inx,
i: v.i,
url: base64
})
}
})
})
})
}
知识点: Promise.all 来保证 调用顺序就是执行结果的返回顺序
结语
通过以上内容,我们就实现了一个可以导出图片的exclUtil,代码我贴在下面了,假如没有混合内容的问题,那ws的部分完全用不到,你可以采用我上面提到的浏览器转base64的方法自行改写即可。
完整代码[初衷是为了方便大家直接拿过去用,希望可以不算字数]
浏览器这部分的代码,里面用到了一些变量,我没提到的变量基本上就是图片的base地址之类的
import saveAs from 'file-saver'
import { deepClone } from '@/util/util'
const Excel = require('exceljs')
function init () {
return new Promise((resolve) => {
let workbook = null
let worksheet = null
workbook = new Excel.Workbook()
workbook.created = new Date()
workbook.modified = new Date()
worksheet = workbook.addWorksheet('sheet1')
let ws = new WebSocket('ws://127.0.0.1:56565')
ws.onopen = () => {
ws.send(JSON.stringify({ text: 'connect' }))
resolve({
workbook,
worksheet,
ws
})
}
ws.onerror = (e) => {
alert(e)
}
})
}
async function downloadImageExcl (origin, temp, dzPrefix, fileName = '千云物流自定义表单') {
let { workbook, worksheet, ws } = await init()
let columns = []
let imageColumnsKey = {}
temp.forEach((v, inx) => {
if (v.fieldType === 'image' ||
v.fieldType === 'multipleImage' ||
v.fieldType === 'accredit') {
imageColumnsKey[v.fieldName] = inx
}
columns.push({ header: v.fieldCustomTitle, key: v.fieldName, width: 100 })
})
worksheet.columns = columns
let dataList = []
let asyncImageArr = []
let data = deepClone(origin)
data.forEach((v, inx) => {
for (let i in imageColumnsKey) {
let imageList = []
if (v[i].indexOf('/beeFile') !== -1) {
let arr = v[i]
.replace(new RegExp('/beeFile', 'g'), '?lastPath=')
.split(',')
arr.forEach(url => {
imageList.push(formatImage(url, dzPrefix))
})
} else {
let arr = v[i]
arr.forEach(url => {
imageList.push(formatImage(url))
})
}
v[i] = imageList
v[i].forEach(k => {
asyncImageArr.push({ url: k, inx, i })
})
}
dataList.push({ ...v })
})
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'download',
data: asyncImageArr
}))
ws.onmessage = e => {
let data = JSON.parse(e.data)
if (data.type !== 'download') return false
let arr = data.data
arr.forEach(v => {
if (!dataList[v.inx][v.i + '_base64']) {
dataList[v.inx][v.i + '_base64'] = []
}
dataList[v.inx][v.i + '_base64'].push(v.url)
})
console.log(dataList)
dataList.forEach(v => {
let row = worksheet.addRow(v)
row.height = 100
})
dataList.forEach((v, inx) => {
for (let i in imageColumnsKey) {
v[i + '_base64'].forEach((k, keyInx) => {
if (k !== '图片拉取失败') {
let imageType = (v[i][keyInx].match(/.(w+)$/) && v[i][keyInx].match(/.(w+)$/)[1]) || 'jpg'
const imageId = workbook.addImage({
base64: k,
extension: imageType
})
worksheet.addImage(imageId, {
tl: { col: imageColumnsKey[i], row: inx + 1 },
ext: { width: 100, height: 100 },
hyperlinks: {
hyperlink: v[i][keyInx],
tooltip: v[i][keyInx]
}
})
}
})
}
})
workbook.xlsx.writeBuffer().then(function (buffer) {
saveAs(new Blob([buffer], {
type: 'application/octet-stream'
}), fileName + '.' + 'xlsx')
}).catch(e => {
console.log('err', e)
})
}
} else {
alert('下载客户端未启动.')
}
}
function formatImage (src, dzPrefix) {
let imgUrl = null
if (src.indexOf('?lastPath=') !== -1) {
imgUrl = dzPrefix + src
} else {
imgUrl = process.env.VUE_APP_IMGURL + src
}
return imgUrl
}
export default downloadImageExcl
参考资料: 基于Electron+vue的跨平台实践初探
electorn 客户端的代码 写在background.js 即可,如果不会初始化,参考上面的参考资料进行项目的初始化, createServer的代码在上面很完整,就不重复粘贴了
// 应用准备完成时调用创建服务的方法
app.whenReady().then(() => {
createServer()
})
转载自:https://juejin.cn/post/7152817023343394823