likes
comments
collection
share

前端基于excljs导出xlsx时图片资源的处理及踩坑实录

作者站长头像
站长
· 阅读数 9

相信做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的主进程内通过expressws 实现一个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()
 })