基于腾讯云COS的小程序素材上传功能实现
前言
由于开发小程序需要上传图片素材时总是需要后端协助,最近在项目中实现了一个小程序素材上传功能,支持图片上传到腾讯云COS,并提供便捷的链接复制和预览功能,减少沟通合作成本,本文将分享这个功能的实现思路和核心代码。
功能概述
小程序素材上传功能主要包括以下特性:
- 支持批量上传图片
- 文件格式和大小校验
- 自定义上传路径配置
- 支持链接复制和在线预览
- 重复文件检测提示
- 基于腾讯云COS的对象存储
技术方案
1. 整体架构
前端组件 → 腾讯云COS SDK → 后端获取临时凭证 → 对象存储
2. 核心依赖
javascript
import COS from 'cos-js-sdk-v5'
import { Modal, Upload, Icon, Form, Select, Input, Radio, message } from 'antd'
3. 上传流程
- 用户选择文件类型和业务类型
- 配置上传路径参数
- 拖拽或点击上传图片
- 前端获取临时凭证
- 直接上传到COS
- 返回文件链接
核心实现
1. 模态框组件结构
jsx
const AppletMaterModal = (props) => {
const { form = {}, visible, onCancel, brandCode } = props
const { getFieldDecorator, validateFields, getFieldsValue, getFieldValue } = form
const [fileList, setFileList] = useState([])
const [cosData, setCosData] = useState(null)
const [credentials, setCredentials] = useState(null)
const cosPromiseRef = useRef(null)
// ...
}
2. 文件校验
在上传前进行严格的格式和大小校验:
javascript
const uploadImgValidateFields = async (file) => {
return new Promise((resolve) => {
validateFields(async (error, values) => {
if (error) return resolve(false)
// 格式校验
const acceptStr = '.jpeg,.jpg,.png,.gif'
const checkType = validType(file, acceptStr)
if (!checkType) {
message.error('仅支持JPEG/JPG/PNG/GIF格式')
return resolve(false)
}
// 大小校验
const maxSizeMB = 5
if (file.size / 1024 / 1024 > maxSizeMB) {
message.error(`图片大小不能超过${maxSizeMB}MB`)
return resolve(false)
}
resolve(true)
})
})
}
// 上传图片校验
const handleBeforeUploadImg = async (file) => {
return new Promise(async (resolve, reject) => {
try {
const checkResult = await uploadImgValidateFields(file)
if (checkResult) {
resolve(true)
}
resolve(false)
} catch (error) {
resolve(false)
}
})
}
3. COS实例管理
使用useRef缓存COS实例,避免重复创建:
javascript
const getCosInstance = useCallback(async (params) => {
// 如果已有数据,直接返回
if (cosData && credentials) {
return { cos: cosData, credentials }
}
// 如果已有正在进行的请求,复用该 Promise
if (cosPromiseRef.current) {
return cosPromiseRef.current
}
// 创建新的请求
cosPromiseRef.current = (async () => {
try {
const cosInstance = Cos()
const cosInstanceData = await cosInstance.getCosInstance(params)
setCosData(cosInstanceData.cos)
setCredentials(cosInstanceData.credentials)
return cosInstanceData
} finally {
// 请求完成后清除引用
cosPromiseRef.current = null
}
})()
return cosPromiseRef.current
}, [cosData, credentials])
4. 自定义上传逻辑
javascript
const customRequest = useCallback(async (info) => {
const { file } = info
const valid = await handleBeforeUploadImg(file)
if (!valid) return
const { businessType, path, fileUploadPathContainsDate } = getFieldsValue()
const { name } = file
const params = {
file: file,
fileType: 1,
accessType: 1,
fileNameList: [name],
fileNums: 1,
businessType: businessType,
fileUploadPathContainsDate: fileUploadPathContainsDate,
addFileUploadPath: path
}
try {
// 等待唯一的 COS 实例数据
const { cos, credentials } = await getCosInstance(params)
const response = await Cos().pushObject(params, cos, credentials)
const imgUrl = {
name: response.fileName,
url: `${response.domainUrl}${response.filePath}${response.fileName}`,
}
setFileList(prev => [...prev, imgUrl])
message.success('上传成功')
} catch (error) {
if (error.code === 400000) {
const imgUrl = {
name: name,
url: error.data,
repeat: true
}
setFileList(prev => [...prev, imgUrl])
}
}
}, [fileList, cosData, credentials])
5. COS工具类封装
javascript
export class Cos {
/**
* 获取cos实例
*/
async getCosInstance(params) {
const credentials = await this.getCredential(params)
const config = {
TmpSecretId: credentials.tmpSecretId,
TmpSecretKey: credentials.tmpSecretKey,
XCosSecurityToken: credentials.sessionToken,
StartTime: credentials.startTime,
ExpiredTime: credentials.expiredTime,
DomainUrl: credentials.domainUrl,
}
this._cos = new COS({
getAuthorization: (_, callback) => callback(config),
})
return { cos: this._cos, credentials: credentials }
}
/**
* 获取credential
*/
async getCredential(params) {
const response = await requestService('common.cosFileUpload.getCredential', {
...params,
})
const { code, data, message } = response
if (code !== 200) {
throw new Error(`获取cos credential 错误:${message}`)
}
const { expiredTime, fileUploadPath, credential, bucket, region, startTime, domainUrl } = data
return {
expiredTime,
startTime,
fileUploadPath,
bucket,
region,
domainUrl,
...credential,
}
}
/**
* push 文件对象
*/
async pushObject(config, cos, credentials) {
const { fileUploadPath, bucket, region, domainUrl } = credentials
const { key, ...record } = this.createRecord(config, { filePath: fileUploadPath })
const { file, onProgress = null } = config
return new Promise((resolve, reject) => {
const payload = {
Bucket: bucket,
Region: region,
Key: key,
StorageClass: 'STANDARD',
Body: file,
onProgress: onProgress,
}
cos.putObject(payload, (err, responseData) => {
if (err) {
reject({ message: '上传异常,请稍后重试' })
return
}
resolve({ ...responseData, ...record, domainUrl })
})
})
}
/**
* 生成日志记录
*/
createRecord = (config, params) => {
const { filePath } = params
const { accessType, module, file = {}, fileNameList = [] } = config
const { size: fileSize, type = '' } = file
let suffix = type.split('/')[1]
if (!type) {
const index = file.name.lastIndexOf('.')
suffix = file.name.substring(index + 1)
}
const fileName = fileNameList.length ? `${fileNameList[0]}` : `${uuidv4().replace(/-/g, '')}.${suffix}`
const record = {
accessType,
fileName,
fileSize: fileSize / 1024,
module,
suffix,
key: filePath + fileName,
...params,
}
return record
}
}
亮点功能
1. 智能路径配置
支持自定义上传路径,可选择是否添加时间目录:
jsx
<Form.Item label="追加路径">
{getFieldDecorator('path', {
initialValue: 'gg',
rules: [{ required: false }],
})(
<Input onChange={() => handleResetCos()} />
)}
</Form.Item>
<Form.Item label="路径添加时间目录">
{getFieldDecorator('fileUploadPathContainsDate', {
initialValue: 1,
rules: [{ required: false }],
})(
<Radio.Group onChange={() => handleResetCos()}>
<Radio value={1}>是</Radio>
<Radio value={0}>否</Radio>
</Radio.Group>
)}
</Form.Item>
2. 链接复制功能
提供便捷的链接复制功能,支持现代浏览器和兼容旧浏览器:
javascript
const handleCopy = (item) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(item.url)
.then(() => {
message.success('链接已复制到剪贴板')
})
.catch((err) => {
message.error('复制失败: ', err)
})
} else {
// Fallback for older browsers
const textArea = document.createElement('textarea')
textArea.value = item.url
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
message.success('链接已复制到剪贴板')
}
}
3. 重复文件检测
上传时检测文件是否已存在,并给出提示:
javascript
try {
const { cos, credentials } = await getCosInstance(params)
const response = await Cos().pushObject(params, cos, credentials)
const imgUrl = {
name: response.fileName,
url: `${response.domainUrl}${response.filePath}${response.fileName}`,
}
setFileList(prev => [...prev, imgUrl])
message.success('上传成功')
} catch (error) {
if (error.code === 400000) {
const imgUrl = {
name: name,
url: error.data,
repeat: true
}
setFileList(prev => [...prev, imgUrl])
}
}
4. 拖拽上传体验
使用Ant Design的Dragger组件,提供友好的拖拽上传体验:
jsx
const draggerProps = {
name: 'file',
multiple: true,
accept: 'image/jpeg,image/jpg,image/png,image/gif',
action: '',
showUploadList: false,
beforeUpload: () => true,
customRequest: customRequest
}
<Dragger {...draggerProps} style={{maxWidth: '500px'}}>
<p className="ant-upload-drag-icon">
<Icon type="inbox" />
</p>
<p className="ant-upload-text">点击或拖动图片到此区域进行上传</p>
<p className="ant-upload-hint">支持:.jpeg,.jpg,.png,.gif格式,大小不超过5Mb</p>
<p className="ant-upload-hint">支持单次或批量上传</p>
</Dragger>
遇到的问题和解决方案
1. COS实例重复创建
问题:每次上传都创建新的COS实例,导致性能浪费
解决方案 :使用useRef缓存COS实例和Promise,确保同一批次上传复用同一个实例
javascript
const cosPromiseRef = useRef(null)
const getCosInstance = useCallback(async (params) => {
if (cosData && credentials) {
return { cos: cosData, credentials }
}
if (cosPromiseRef.current) {
return cosPromiseRef.current
}
cosPromiseRef.current = (async () => {
try {
const cosInstance = Cos()
const cosInstanceData = await cosInstance.getCosInstance(params)
setCosData(cosInstanceData.cos)
setCredentials(cosInstanceData.credentials)
return cosInstanceData
} finally {
cosPromiseRef.current = null
}
})()
return cosPromiseRef.current
}, [cosData, credentials])
2. 临时凭证过期
问题:临时凭证有过期时间,需要及时更新
解决方案 :在getCosInstance中检查凭证状态,过期时自动重新获取
javascript
const getCosInstance = useCallback(async (params) => {
// 如果已有数据,直接返回
if (cosData && credentials) {
return { cos: cosData, credentials }
}
// 否则重新获取
cosPromiseRef.current = (async () => {
try {
const cosInstance = Cos()
const cosInstanceData = await cosInstance.getCosInstance(params)
setCosData(cosInstanceData.cos)
setCredentials(cosInstanceData.credentials)
return cosInstanceData
} finally {
cosPromiseRef.current = null
}
})()
return cosPromiseRef.current
}, [cosData, credentials])
3. 批量上传并发控制
问题:批量上传时可能出现并发冲突
解决方案 :使用Promise.all管理批量上传,确保所有上传任务完成后再更新状态
javascript
async pushObject(config, cos, credentials) {
const { file } = config
if (Array.isArray(file)) {
const promiseAll = []
file.map((item, index) => {
config.file = item
promiseAll.push(this.uploadReal(config, cos, credentials))
})
return Promise.all(promiseAll.map((p) => p.catch(() => {})))
} else {
return this.uploadReal(config, cos, credentials)
}
}
4. 文件格式校验
问题:需要确保上传的文件格式符合要求
解决方案:在上传前进行严格的格式校验
javascript
const uploadImgValidateFields = async (file) => {
return new Promise((resolve) => {
validateFields(async (error, values) => {
if (error) return resolve(false)
// 格式校验
const acceptStr = '.jpeg,.jpg,.png,.gif'
const checkType = validType(file, acceptStr)
if (!checkType) {
message.error('仅支持JPEG/JPG/PNG/GIF格式')
return resolve(false)
}
// 大小校验
const maxSizeMB = 5
if (file.size / 1024 / 1024 > maxSizeMB) {
message.error(`图片大小不能超过${maxSizeMB}MB`)
return resolve(false)
}
resolve(true)
})
})
}
完整组件代码
jsx
import React, { memo, useCallback, useState, useRef } from 'react'
import { Modal, Upload, Icon, Form, Select, Input, Radio, message } from 'antd'
import Cos from './cos'
import { getBrandMap } from 'constant'
import {
getBusinessTypeList,
validType,
fileTypeList,
} from 'scrmMessage/constans'
import { initial } from 'lodash'
import styles from './index.scss'
const { Dragger } = Upload
const formItemLayout = {
labelCol: {
xs: { span: 24 },
sm: { span: 6 },
},
wrapperCol: {
xs: { span: 24 },
sm: { span: 18 },
},
}
const brandMap = getBrandMap()
const AppletMaterModal = (props) => {
const { form = {}, visible, onCancel, brandCode } = props
const { getFieldDecorator, validateFields, getFieldsValue, getFieldValue } = form
const cosPromiseRef = useRef(null)
const [fileList, setFileList] = useState([])
const [cosData, setCosData] = useState(null)
const [credentials, setCredentials] = useState(null)
const uploadImgValidateFields = async (file) => {
return new Promise((resolve) => {
validateFields(async (error, values) => {
if (error) return resolve(false)
const acceptStr = '.jpeg,.jpg,.png,.gif'
const checkType = validType(file, acceptStr)
if (!checkType) {
message.error('仅支持JPEG/JPG/PNG/GIF格式')
return resolve(false)
}
const maxSizeMB = 5
if (file.size / 1024 / 1024 > maxSizeMB) {
message.error(`图片大小不能超过${maxSizeMB}MB`)
return resolve(false)
}
resolve(true)
})
})
}
const handleBeforeUploadImg = async (file) => {
return new Promise(async (resolve, reject) => {
try {
const checkResult = await uploadImgValidateFields(file)
if (checkResult) {
resolve(true)
}
resolve(false)
} catch (error) {
resolve(false)
}
})
}
const handleCopy = (item) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(item.url)
.then(() => {
message.success('链接已复制到剪贴板')
})
.catch((err) => {
message.error('复制失败: ', err)
})
} else {
const textArea = document.createElement('textarea')
textArea.value = item.url
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
message.success('链接已复制到剪贴板')
}
}
const getCosInstance = useCallback(async (params) => {
if (cosData && credentials) {
return { cos: cosData, credentials }
}
if (cosPromiseRef.current) {
return cosPromiseRef.current
}
cosPromiseRef.current = (async () => {
try {
const cosInstance = Cos()
const cosInstanceData = await cosInstance.getCosInstance(params)
setCosData(cosInstanceData.cos)
setCredentials(cosInstanceData.credentials)
return cosInstanceData
} finally {
cosPromiseRef.current = null
}
})()
return cosPromiseRef.current
}, [cosData, credentials])
const customRequest = useCallback(async (info) => {
const { file } = info
const valid = await handleBeforeUploadImg(file)
if (!valid) return
const { businessType, path, fileUploadPathContainsDate } = getFieldsValue()
const { name } = file
const params = {
file: file,
fileType: 1,
accessType: 1,
fileNameList: [name],
fileNums: 1,
businessType: businessType,
fileUploadPathContainsDate: fileUploadPathContainsDate,
addFileUploadPath: path
}
try {
const { cos, credentials } = await getCosInstance(params)
const response = await Cos().pushObject(params, cos, credentials)
const imgUrl = {
name: response.fileName,
url: `${response.domainUrl}${response.filePath}${response.fileName}`,
}
setFileList(prev => [...prev, imgUrl])
message.success('上传成功')
} catch (error) {
if (error.code === 400000) {
const imgUrl = {
name: name,
url: error.data,
repeat: true
}
setFileList(prev => [...prev, imgUrl])
}
}
}, [fileList, cosData, credentials])
const draggerProps = {
name: 'file',
multiple: true,
accept: 'image/jpeg,image/jpg,image/png,image/gif',
action: '',
showUploadList: false,
beforeUpload: () => true,
customRequest: customRequest
}
const handleResetCos = () => {
setCosData(null)
setCredentials(null)
}
return (
<Modal
title="上传图片"
visible={visible}
footer={null}
okText="确定"
cancelText="取消"
width={500}
onCancel={onCancel}
maskClosable={false}
destroyOnClose
>
<div>
<Form>
<Form.Item label="品牌" {...formItemLayout}>
<span>{brandMap[brandCode]}</span>
</Form.Item>
<Form.Item label="文件类型" {...formItemLayout}>
{getFieldDecorator('fileType', {
initialValue: 1,
rules: [{ required: true, message: '请选择文件类型' }],
})(
<Radio.Group onChange={() => handleResetCos()}>
<Radio value={1}>图片</Radio>
<Radio disabled={true} value={2}>文件</Radio>
<Radio disabled={true} value={3}>视频/音频</Radio>
</Radio.Group>
)}
</Form.Item>
<Form.Item label="业务类型" {...formItemLayout}>
{getFieldDecorator('businessType', {
rules: [{ required: true, message: '请选择业务类型' }],
})(
<Select onChange={() => handleResetCos()}>
{getBusinessTypeList(brandCode).map((item, index) => (
<Select.Option key={index} value={item.value}>{item.label}</Select.Option>
))}
</Select>
)}
</Form.Item>
<Form.Item label="追加路径" {...formItemLayout}>
{getFieldDecorator('path', {
initialValue: 'gg',
rules: [{ required: false }],
})(
<Input onChange={() => handleResetCos()} />
)}
</Form.Item>
<Form.Item label="路径添加时间目录" {...formItemLayout}>
{getFieldDecorator('fileUploadPathContainsDate', {
initialValue: 1,
rules: [{ required: false }],
})(
<Radio.Group onChange={() => handleResetCos()}>
<Radio value={1}>是</Radio>
<Radio value={0}>否</Radio>
</Radio.Group>
)}
</Form.Item>
</Form>
{getFieldValue('fileType') == 1 ? <Dragger {...draggerProps} style={{maxWidth: '500px'}}>
<p className="ant-upload-drag-icon">
<Icon type="inbox" />
</p>
<p className="ant-upload-text">点击或拖动图片到此区域进行上传</p>
<p className="ant-upload-hint">支持:.jpeg,.jpg,.png,.gif格式,大小不超过5Mb</p>
<p className="ant-upload-hint">支持单次或批量上传</p>
</Dragger>:null}
{fileList.map((item, index) => (
<div key={index} style={{ display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<div className={item?.repeat?styles.AppletMaterModal_red:''}>{item.name}</div>
<div>
<Icon onClick={() => window.open(item.url, '_blank')} style={{ cursor: 'pointer', margin: '0 20px' }} type="eye" theme="twoTone" />
<Icon onClick={() => handleCopy(item)} style={{ cursor: 'pointer' }} type="copy" theme="twoTone" />
</div>
</div>
))}
</div>
</Modal>
)
}
export default memo(Form.create()(AppletMaterModal))
总结
在实际使用中,该功能表现稳定,能够满足日常的小程序素材管理需求。后续可以考虑添加进度条显示、断点续传、图片压缩等高级功能,进一步提升用户体验。
参考文档
希望这篇文章对你有所帮助!如有问题欢迎交流讨论。