前言
业务中实现了大文件切片,断点续传功能,获取公众号素材管理,配置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()放入uploadChunks的promise.all()的后面。
后端部分
这里我使用egg内部的egg-multipart的mode: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的配合了。
使用axios的cancelToken配置项,能取消正在上传的文件。
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
}
}
在axios的options配置同一个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有时效,所以不需要每次都去请求调用。这里使用egg的middleware配合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_token及jspTicket。在前面的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}×tamp=${timestamp}&url=${nowUrl}`
jsapi_ticket = sha1(jsapi_ticket)
return {
noncestr: noncestr,
timestamp: timestamp,
signature: jsapi_ticket,
appId: this.appId
}
}
...
到这里H5分享图片标题也结束了。
分享不易,喜欢的话一定别忘了点💖!!!
只关注不点💖的都是耍流氓,只收藏也不点💖的也一样是耍流氓。
结束👍👍👍。
参考: