文件切片、断点续传、获取公众号素材管理、配置H5分享图片标题杂记

994 阅读7分钟

前言

业务中实现了大文件切片,断点续传功能,获取公众号素材管理,配置H5分享图片标题,为了方便查阅,所以在这里做下记录。

本文将记录前端及后端是如何配合,来实现功能的demo。(代码是基于一些业务要求,所以并不适用于大众。)

前端:vue element-ui axios

后端: egg

大文件上传

思路


前端

前端大文件分片相信网上能找到很多的解决方案,大部分都是借用slice将大文件分割成我们需要的大小。然后借助http的并发,来达到在最短的时间内将大文件上传。因为并发,上传的顺序可能不同,所以需要在每个小切片做下标记。

后端

服务端则接收这些切片,并且将相应的切片合并。何时合并,则根据前端是否请求 合并的接口 而定。

前端部分


这里使用element-ui让UI漂亮点。

上传控件

<el-col :span="12">
  <el-progress :percentage="uploadPercentage"></el-progress>
</el-col>
<el-col :span="24">
  <input type="file" @change="handleFileChange" accept="image/gif, image/jpeg">
</el-col>
<el-col :span="24" style="margin-top: 10px;text-align:right">
  <el-button type="success" size="mini" @click="submitUpload">点击上传</el-button>
</el-col>
<el-button type="info" size="mini" @click="cancelLoad">暂停上传</el-button>
<el-button type="info" size="mini" @click="handleResume">回复上传</el-button>

export default {
    ...
    // 选择图片
    handleFileChange(e) {
      const [file] = e.target.files
      if (!file) {
        return
      }
      this.file = file
    }
    ...
}

上传切片

接着实现比较重要的部分,长传需要进行两个部分:

  • 将大文件切片
  • 将切片发送给服务端
let SIZE = 10 * 1024 * 1024

// 点击上传
async submitUpload() {
  // 大文件切片
  const fileChunkList = this.createFileChunk(this.file)
  this.data = fileChunkList.map(({ file }, index) => ({
    chunk: file,
    hash: `${this.radio} - ${index}`, // 每个切片名,这里用 文件名-索引 来做标记(这里的this.radio为文件名)
    index,
    percentage: 0 // 进度条,后面会说明
  }))
  await this.uploadChunks() // 并发请求
}

// 大文件切片
createFileChunk(file, size = SIZE) {
  const fileChunkList = []
  let cur = 0
  while (cur < file.size) {
    fileChunkList.push({ file: file.slice(cur, cur + size) })
    cur += size
  }
  return fileChunkList
}

// 上传切片
async uploadChunks(uploadedList = []) {
  const requestList = this.data
    .filter(({ hash }) => !uploadedList.includes(hash)) // 断电续传功能,可先忽略
    .map(({ chunk, hash }) => {
      const formData = new FormData()
      formData.append(hash, chunk)
      return { formData }
    })
    .map(({ formData }, index) => {
    // API.Upload是自己封装的axios请求,后面会说明
      return API.Upload({
        data: formData,
        f: this.createProgressHandler(this.data[index]), // 进度条,在这里可先忽视
        requestList: this.requestList, // 正在http请求的数量,同样,可先忽视
        formData: true // content-type是否为form-data
      })
    })
  await Promise.all(requestList)
}

当我们点击上传按钮时,通过createFileChunk方法,将大文件分割,这里每个切片都为10M(最后一个切片不一定为10M),后使用map为每个切片添加必要的信息。chunk为二进制文件。hash则为标识,主要以文件名+索引,让后端知道如何按顺序合并。percentage为进度条,如何不需要进度条的可以忽略。

将每个切片添加必要信息后,接着就是并发请求,在uploadChunks方法中,.filter(({ hash }) => !uploadedList.includes(hash))断点续传的功能,可先忽略。将chunk hash放入FormData()中,接着使用promise.all()来并发请求。

请求合并

...
methods: {
...
 async mergeRequest() {
      const res = await API.MergeFile({
        data: {
          size: SIZE,
          fileName: this.radio // 文件名
        }
      })
      if (res) {
        this.$message.success('上传成功')
      }
    },
...
},

watch: {
    requestList() {
      if (this.requestList.length === 0 && this.file) {
        this.mergeRequest()
    }
}
...

watch部分,是监听请求数量是否变为为0,是后续的断点续传及及进度条部分的功能,如不需要可以将this.mergeRequest()放入uploadChunkspromise.all()的后面。

后端部分

这里我使用egg内部的egg-multipartmode:file,具体用法可自行查阅。

// config/config.default.js
config.multipart = {
    mode: 'file',
    fileSize: '50mb',
    fileExtensions: [
      ''
    ]
};
// controller/picture.js
...
// 上传接口方法
async updatePicture() {
    const { ctx, app } = this
    const { writeFormFile } = app.lib // (1)
    
    const file = ctx.request.files[0]
    try {
      await writeFormFile.UploadDateFile(file)
      ctx.success(true) // (2)
    } catch (err) {
      ctx.fail(err) // (3)
    } finally {
      ctx.cleanupRequestFiles()
    }
}
...

这里说明下,(1)(2)(3)点的写法是自己项目做了一些配置,才可以这样写。自己需要根据项目情况来编写。而ctx.cleanupRequestFiles()则为每次完成后,清除项目内缓存的文件,这点挺重要的,避免不需要的文件堆积。

// app/lib/writeFormFile.js
const fse = require('fs-extra')
const path = require('path')

const UploadDateFile = async file => {

  let { filepath, fieldname } = file

  let name = fieldname.replace(/\s\-\s[0-9]*$/, '') // 取出文件名,前后说过,hash文件名为 文件名-索引,所以这里去除 (- 索引)

  const tempPath = fse.readFileSync(filepath) // 读取临时文件信息

  const { serverPath, loadPath } = await GenerateFileNameAndPath(name)

  try {
    await fse.accessSync(loadPath, fse.constants.R_OK | fse.constants.W_OK)
  } catch (err) {
    console.error('错误err', err)
    await fse.mkdirSync(loadPath)
  }

  const newPath = await path.join(loadPath + '/' + fieldname)
    
  // 将临时文件写入
  await fse.writeFileSync(newPath, tempPath)

  return {
    path: serverPath,
    fullPath: loadPath
  }
}


// 生成文件名和加载路径,根据自己情况改写
const GenerateFileNameAndPath = async (name) => {

  // 加载路径组合
  const rootPath = path.resolve(__dirname, '../../', 'public')
  const serverPath = '/upload/images/stzz/temp/'
  const loadPath = rootPath + serverPath + name

  return {
    serverPath,
    loadPath
  }
}
...

到这里,就完成了后端接收分片的接口了,接下来就是前端请求合并。

// controller/picture.js
...
async mergePicture() {
    const { ctx, app } = this
    const { writeFormFile } = app.lib
    try {
      const params = ctx.joi({ // (1)
        fileName: Joi.string().required(),
        size: Joi.number().integer().required()
      })
      await writeFormFile.MergeFile(params.fileName, params.size)
      ctx.success(true)
    } catch (err) {
      ctx.fail(err)
    }
}
...

同理,(1)为自身项目所有。而在前端部分,可能细心的小朋友有注意到,我们传入了SIZE参数,因为在合并的过程中,如果出现顺序错误,起码写入的位置不变。

...
// app/lib/writeFormFile.js
// 合并文件
const MergeFile = async (fieldname, size) => {

  const rootTempPath = path.resolve(__dirname, '../../', `public/upload/images/stzz/temp/${fieldname}`)

  const chunkPaths = await fse.readdir(rootTempPath) // 读取路径下的文件
  
  // 重新排序
  chunkPaths.sort((a, b) => {
    a = a.split('-')
    b = b.split('-')
    return a[a.length - 1] - b[b.length - 1]
  })

  const rootPath = path.resolve(__dirname, '../../', `public/upload/images/stzz/${fieldname}`)
  
  await Promise.all(
    chunkPaths.map((chunkPath, index) => {
      return pipeStream(
        path.resolve(rootTempPath, chunkPath),
        fse.createWriteStream(rootPath, {
          start: index * size,
          end: (index + 1) * size
        })
      )
    })
  )
  fse.rmdirSync(rootTempPath) //写入成功后,删除临时创建的文件夹
}
...

到这里,大文件切片就完成了!等等,前面我们一直提到进度条,那么接下来如何显示进度条呢

显示进度条


axios允许为上传处理进度事件onUploadProgress

options: {
    onUploadProgress: function (progressEvent) {
      f(progressEvent.loaded)
    }
}

而在前面,我们在uploadChunks方法有用过f方法,这里截出uploadChunks部分内容

...
.map(({ formData }, index) => {
// API.Upload是自己封装的axios请求,后面会说明
  return API.Upload({
    data: formData,
    f: this.createProgressHandler(this.data[index]), // 进度条,在这里可先忽视
    requestList: this.requestList, // 正在http请求的数量,可先忽视
    formData: true // content-type是否为form-data
  })
})

createProgressHandler(item) {
  return e => {
    item.percentage = e
  }
}
...

前面有提过,每个切片写入一些信息,其中就有percentage属性。这里用来监听每个切片请求的进度。

而后,再将每个切片的进度合并与文件总量对比,得出大文件的上传进度。

...
computed: {
    uploadPercentage() {
        if (!this.file || !this.data.length) return 0
        const loaded = this.data
        .map(item => item.percentage)
        .reduce((acc, cur) => acc + cur)
        let num = this.file.size ? parseInt((loaded / this.file.size) * 100) : 0
        return num
    }
}
...

到这里进度条显示就完成了,那么接下来该是 断点续传 的功能实现了

断点续传


断点续传原理是让前端/后端记住已上传的切片,再下次前端上传时切片是,跳过已上传过的切片。为了方便,返回已上传的切片信息由后端实现。

暂停上传


断点续传,能"断"才能"续",而实现"断"的功能,则需要axios的配合了。

使用axioscancelToken配置项,能取消正在上传的文件。

options: {
    cancelToken: common.source.token    
}

在入口main.js配置source,并用vuex存储。

// main.js
...
import axios from 'axios'
import store from './store'

const CancelToken = axios.CancelToken
const source = CancelToken.source()

const common = store.state.common
common.source = source
...

而在vuex中,source为:

// store/modules/common.js
const state = {
  source: {
    token: null,
    cancel: null
  }
}

axiosoptions配置同一个source.token,是为了取消所有正常上传的请求。接下来,当我们点击暂停上传时,就可以取消请求了。

<el-button type="info" size="mini" @click="cancelLoad">暂停上传</el-button>

const CancelToken = axios.CancelToken
const source = CancelToken.source()

...
// 暂停上传
cancelLoad() {
  this.source.cancel('暂停请求')
  this.UPDATE_STATE_ASYN({ // vuex中action方法
    source: { // 这里需要重新赋值新的信息,避免下次请求不了
      cancel: this.source.cancel,
      token: source.token
    }
  })
}
...

取消请求部分就完成,接下来点击回复上传时,怎么知道从哪回复?

<el-button type="info" size="mini" @click="handleResume">回复上传</el-button>
...
 async handleResume() {
  const res = await API.Verify({ // 检查已上传的文件切片
    data: {
      fileName: this.radio // 文件名
    }
  })
  if (res && res.shouldUpload) {
    await this.uploadChunks(res.uploadedList)
  } else {
    this.$message.warning('暂无恢复上传的文件')
  }
}

async uploadChunks(uploadedList = []) {
     const requestList = this.data
    .filter(({ hash }) => !uploadedList.includes(hash)) // 筛选未上传的文件
    ...
}

在点击回复上传后,又怎知啥时候全部上传完成? 在文章中多次提到了requestList变量。这里先介绍下axios的拦截功能。

我们知道,axios有响应拦截部分。所以我们把请求的切片信息存入一个数组requestList

const instance = axios.create()
...
await this.interceptors(instance, requestList)
...
// 响应拦截,保留有用部分
this.interceptors = function (instance, requestList) {
    instance.interceptors.response.use(
      res => {
        let { data, status } = res
        if (status === 200 && data && data.code === 200) {
            const xhrIndex = requestList.findIndex(item => item === instance)
            requestList.splice(xhrIndex, 1) // 当请求完成,移除。
        }
      }
    )
  requestList.push(instance) // 保留所以切片的intance
}

再利用对象的引用,'requestList'数组长度就能监听的到,从而判断是否上传完毕。

到这里断点续传基本完成了。

获取公众号素材管理


在获取公众号素材管理信息时,需要在公众号配置一些基本信息及认证。

公众号平台完善信息

在公众平台官网的开发-基本设置页面,点击“修改配置”按钮,填写服务器地址(URL)、Token和EncodingAESKey,其中URL是开发者用来接收微信消息和事件的接口URL。

验证消息的确来自微信服务器

开发者提交信息后,微信服务器将发送GET请求到填写的服务器地址URL上,GET请求携带参数如下表所示:

后端接口

验证接口

async verify() {
    const { ctx, app, config } = this
    const { wxToken } = config
    const { WeChat } = app.lib
    
    const weChat = new WeChat(wxToken.wechat_stzz)
    weChat.auth(ctx)
}
// app/lib
const sha1 = require("sha1"); //引入加密模块

function WeChat(config) {
  // 传入配置文件
  this.config = config;
  this.token = config.token;
  this.appId = config.appId;
  this.appScrect = config.appScrect;
}

// 微信授权验证方法
WeChat.prototype.auth = function (ctx) {

  // 获取微信服务器发送的数据
  const signature = ctx.query.signature,
    timestamp = ctx.query.timestamp,
    nonce = ctx.query.nonce,
    echostr = ctx.query.echostr

  // token、timestamp、nonce三个参数进行字典序排序
  const arr = [this.token, timestamp, nonce].sort().join('')
  // sha1加密    
  const result = sha1(arr)

  if (result === signature) {
    ctx.body = echostr
  } else {
    ctx.send('mismatch')
  }
}
···

认证成功后,将获得更多接口权限,满足更多业务需求。

素材管理接口

在每次调用公众号接口时,都需要access_token进行验证,我们不可能每次调用接口时,都每次请求获取access_token,因为access_token有时效,所以不需要每次都去请求调用。这里使用eggmiddleware配合redis

const axios = require('axios')
module.exports = (options, app) => {
  return async function (ctx, next) {
    const { redis, config } = app
    const { wxToken } = config
    const stzz = wxToken.wechat_stzz
    const token = await redis.get('db0').get('wechatToken')
    const jspTicket = await redis.get('db0').get('wechatJspTicket')
    // 拿到传会数据的header 中的token值
    const method = ctx.method.toLowerCase()
    try {
      if (method === 'get') {
        await next()
      } else if (!token || !jspTicket) {

        if (!token) {
          const access = await axios.get(`https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${stzz.appId}&secret=${stzz.appSecret}`)
          if (access && access.status === 200 && access.data.access_token) {
            await redis.get('db0').setex('wechatToken', 1.5 * 3600, access.data.access_token)
          } else {
            throw '获取token失败'
          }
        }

        if (!jspTicket) {
          const to = await redis.get('db0').get('wechatToken')
          const ticket = await axios.get(`https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=${to}&type=jsapi`)
          if (ticket && ticket.status === 200 && ticket.data.ticket) {
            await redis.get('db0').setex('wechatJspTicket', 1.5 * 3600, ticket.data.ticket)
          } else {
            throw '获取jspTicket失败'
          }
          // console.log('ticket', ticket)
        }
        await next()
      } else {
        await next()
      }
    } catch (error) {
      ctx.err(error)
    }
  }
}

利用中间件redis就能很方便的获取access_token了。

接下来就是简单的获取素材了,这部分比较简单。

  async getMaterialList() {
    const { app, ctx } = this
    const { redis } = app

    try {
      const token = await redis.get('db0').get('wechatToken')
      // console.log('token', token)
      const list = await axios.post(`https://api.weixin.qq.com/cgi-bin/material/batchget_material?access_token=${token}`, {
        type: 'news',
        offset: 0,
        count: 4
      })
      ctx.success(list && list.data)
    } catch (error) {
      ctx.fail(error)
    }

  }
}

到这里,获取公众号素材管理就完成了。是时候准备H5繁琐的验证及配置了。

H5 分享图片标题配置


公众号平台部分

分享图片标题设置,需要在公众号完善好信息。 如:

  • 绑定域名。“公众号设置”的“功能设置”里填写“JS接口安全域名”。
  • 加入白名单

前端部分

引入JS文件:res2.wx.qq.com/open/js/jwe… (支持https)。

这里强调一下,如果使用的是https协议,则引入的资源必须是https不能是http

axios.post('${baseURL}api/weChat/h5Verify', {
  url: window.location.href
})
.then(function (res) {
  if (res && res.data && res.data.result) {
    const result = res.data.result
    wx.config({
      debug: false,
      appId: result.appId,
      timestamp: result.timestamp,
      nonceStr: result.noncestr,
      signature: result.signature,
      jsApiList: [
        'updateAppMessageShareData',
        'updateTimelineShareData'
      ]
    })
    wx.ready(function () {
      wx.updateAppMessageShareData({
        title: '${this.ruleForm.title}', // 分享标题
        desc: '${this.ruleForm.des}', // 分享描述
        link: '${baseURL}api/public/upload/images/h5/${encodeURIComponent(this.fileList[0].name.split('.')[0] + '.html')}',
        imgUrl: '${this.h5Host}${encodeURIComponent(this.fileList[0].name)}', // 分享图标
        success: function () {// 设置成功
        }
      })
    wx.updateTimelineShareData({
        title: '${this.ruleForm.title}', // 分享标题
        link: '${baseURL}api/public/upload/images/h5/${encodeURIComponent(this.fileList[0].name.split('.')[0] + '.html')}', 
        imgUrl: '${this.h5Host}${encodeURIComponent(this.fileList[0].name)}', // 分享图标
        success: function () {
          // 设置成功
        }
    })
  }
})
.catch(function (error) {
  console.log(error)
})

使用axios.post发送当前网页url。当第二次分享链接时,网页url会自动多个参数如:&f...。这是H5为了判断是否为分享的源头,而定。

后端部分

为了生成wx.config所需要的参数,后端需要拿到wx的access_tokenjspTicket。在前面的middleware有出现,这里就截图一小部分。

// middleware/wechat.js
if (!token) {
  const access = await axios.get(`https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${stzz.appId}&secret=${stzz.appSecret}`)
  if (access && access.status === 200 && access.data.access_token) {
    await redis.get('db0').setex('wechatToken', 1.5 * 3600, access.data.access_token)
  } else {
    throw '获取token失败'
  }
}

if (!jspTicket) {
  const to = await redis.get('db0').get('wechatToken')
  const ticket = await axios.get(`https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=${to}&type=jsapi`)
  if (ticket && ticket.status === 200 && ticket.data.ticket) {
    await redis.get('db0').setex('wechatJspTicket', 1.5 * 3600, ticket.data.ticket)
  } else {
    throw '获取jspTicket失败'
  }
  // console.log('ticket', ticket)
}

同样,jsp_ticket也有时效性,有效时间为2小时。拿到jsp_ticket后,就可以配合url来生成我们需要的参数了。

// controller/
async h5Verify() {
    const { ctx, app, config } = this
    const { wxToken } = config
    const { WeChat } = app.lib
    const { redis } = app
    
    try {
      const params = ctx.joi({
        url: Joi.string().required()
      })
      
      const jspTicket = await redis.get('db0').get('wechatJspTicket')
      const weChat = new WeChat(wxToken.wechat_stzz)
      const res = weChat.getSignature(params.url, jspTicket)
      ctx.success(res)
    } catch (error) {
      ctx.fail(error)
    }
}
// lib/WeChat.js
...
/ 生成签名函数
WeChat.prototype.getSignature = function (nowUrl, key) {
  let noncestr = Math.random()
    .toString(36)
    .substr(2); // 随机字符串
  let timestamp = moment().unix() // 获取时间戳,数值类型
  let jsapi_ticket = `jsapi_ticket=${key}&noncestr=${noncestr}&timestamp=${timestamp}&url=${nowUrl}`
  jsapi_ticket = sha1(jsapi_ticket)
  return {
    noncestr: noncestr,
    timestamp: timestamp,
    signature: jsapi_ticket,
    appId: this.appId
  }
}
...

到这里H5分享图片标题也结束了。


分享不易,喜欢的话一定别忘了点💖!!!

只关注不点💖的都是耍流氓,只收藏也不点💖的也一样是耍流氓。

结束👍👍👍。


参考:

字节跳动面试官:请你实现一个大文件上传和断点续传