Electron分片上传及断点续传

阿里云OSS & 腾讯云COS 分片上传与断点续传实践

一、核心概念与适用场景

1. 基础概念

  • 分片上传(Multipart Upload):将一个大文件切割成多个固定大小的分片(Chunk),分别上传至对象存储服务,所有分片上传完成后,由服务端合并为完整文件的上传方案。
  • 断点续传:在分片上传的基础上,记录已成功上传的分片,当网络中断、应用崩溃或上传暂停后,再次上传时仅需上传未完成的分片,无需重复上传整个文件,大幅提升大文件上传的稳定性和效率。

2. 适用场景

  • 大文件上传(单文件超过100MB,如视频、安装包、压缩包、数据集等);
  • 弱网/不稳定网络环境下的文件上传,避免因网络波动导致全文件重传;
  • 需支持暂停/继续上传的业务场景;
  • 需实时展示上传进度的前端交互场景;
  • Electron桌面端应用:需处理GB级甚至TB级超大文件,利用Node.js的文件系统能力避免浏览器内存限制。

3. 核心流程总览

分片上传与断点续传的核心流程,阿里云OSS、腾讯云COS以及Electron+Node.js环境高度一致,仅API命名和参数细节略有差异:

  1. 初始化上传任务:调用接口创建分片上传任务,获取唯一的UploadId(整个上传任务的唯一标识);
  2. 文件分片切割:将文件按指定大小切割为多个分片,为每个分片分配唯一的PartNumber(序号,从1开始);
  3. 分片并行上传:将分片逐个/并行上传,上传成功后存储服务返回ETag(分片的唯一校验值);
  4. 断点续传核心:上传中断后,通过UploadId查询服务端已成功上传的分片,跳过已完成的分片,仅上传剩余分片;
  5. 完成分片合并:所有分片上传完成后,提交所有分片的PartNumberETag,服务端验证后合并为完整文件;
  6. 异常清理:上传取消/失败时,可终止分片任务,清理已上传的分片,避免占用存储资源。

注意:阿里云OSS和腾讯云COS均限制单个分片上传任务最多支持10000个分片,单个分片最小100KB(最后一个分片无大小限制),推荐分片大小为5MB~50MB,根据文件总大小动态调整。


二、前置准备

1. 通用环境配置

(1)跨域配置(前端直传场景)

前端直传对象存储必须配置跨域规则,否则会触发CORS跨域错误:

  • 阿里云OSS:登录OSS控制台 → 对应Bucket → 权限管理 → 跨域设置 → 创建规则:
    • 来源Origin:填写业务域名(如*测试用,生产环境填实际域名);
    • 允许Methods:勾选GET、POST、PUT、DELETE、HEAD
    • 允许Headers:填写*
    • 暴露Headers:填写ETag、x-oss-request-id
    • 缓存时间:填写300。
  • 腾讯云COS:登录COS控制台 → 对应存储桶 → 安全管理 → 跨域访问CORS设置 → 添加规则:
    • 来源Origin:填写业务域名;
    • 允许Methods:勾选GET、POST、PUT、DELETE、HEAD
    • 允许Headers:填写*
    • 暴露Headers:填写ETag
    • 缓存时间:填写300。

(2)权限与安全配置

严禁将永久AccessKey/SecretKey暴露在前端或Electron渲染进程代码中,推荐两种安全方案:

  1. STS临时授权方案:服务端通过STS接口生成临时访问凭证,前端/渲染进程使用临时凭证直传,推荐生产环境使用;
  2. 服务端签名方案:前端/渲染进程请求服务端获取上传签名,携带签名上传,适合简单场景。

本文示例采用官方SDK + STS临时凭证方案,兼顾安全性和开发效率。

2. 阿里云OSS专属准备

  1. 开通阿里云OSS服务,创建Bucket;
  2. 开通STS服务,配置RAM角色,授予OSS分片上传相关权限(AliyunOSSFullAccess 或自定义最小权限);
  3. 安装依赖:
    1
    2
    3
    4
    5
    # 前端/渲染进程SDK
    npm install ali-oss --save
    # Node.js/Electron主进程SDK(同前端SDK,ali-oss兼容Node.js)
    # 服务端STS SDK(Node.js)
    npm install @alicloud/sts-sdk --save

3. 腾讯云COS专属准备

  1. 开通腾讯云COS服务,创建存储桶;
  2. 开通CAM服务,配置API密钥和角色,授予COS分片上传权限;
  3. 安装依赖:
    1
    2
    3
    4
    5
    6
    # 前端/渲染进程SDK
    npm install cos-js-sdk-v5 --save
    # Node.js/Electron主进程SDK
    npm install cos-nodejs-sdk-v5 --save
    # 服务端STS SDK(Node.js)
    npm install qcloud-cos-sts --save

4. Electron环境专属准备

  1. 创建/配置Electron项目,确保Node.js集成正确:
    • 推荐使用contextBridge(安全),在preload.js中暴露文件操作和上传API;
    • 简单项目可在main.js中设置nodeIntegration: true, contextIsolation: false(仅用于测试,生产环境不推荐)。
  2. 安装Electron环境依赖:
    1
    2
    npm install electron fs-extra --save
    # fs-extra:增强版fs模块,文件操作更方便

三、阿里云OSS 分片上传与断点续传实战

1. 核心API说明

API 功能 对应方法 核心作用
初始化分片上传 initMultipartUpload 创建上传任务,返回UploadId和文件名
分片上传 uploadPart 上传单个分片,返回ETag
查询已上传分片 listParts 根据UploadId查询已成功上传的分片,断点续传核心
合并分片 completeMultipartUpload 提交所有分片信息,合并为完整文件
终止分片上传 abortMultipartUpload 取消上传任务,清理已上传分片
断点续传封装 multipartUpload SDK内置的断点续传方法,支持进度回调、暂停/继续

2. 服务端实现:STS临时凭证接口(Node.js)

用于前端/渲染进程获取临时访问凭证,避免AK/SK泄露:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// sts.js 阿里云STS服务
const STS = require('@alicloud/sts-sdk');

// 阿里云配置
const ALIYUN_CONFIG = {
accessKeyId: '你的阿里云AccessKeyId',
accessKeySecret: '你的阿里云AccessKeySecret',
endpoint: 'sts.aliyuncs.com',
roleArn: '你的RAM角色ARN', // 格式:acs:ram::xxx:role/xxx
roleSessionName: 'oss-upload',
// 临时凭证有效期,单位秒
durationSeconds: 3600,
// 最小权限策略:仅允许分片上传相关操作
policy: {
Version: '1',
Statement: [
{
Effect: 'Allow',
Action: [
'oss:PutObject',
'oss:InitiateMultipartUpload',
'oss:UploadPart',
'oss:ListParts',
'oss:CompleteMultipartUpload',
'oss:AbortMultipartUpload'
],
Resource: ['acs:oss:*:*:你的Bucket名称/*']
}
]
}
};

// 生成STS临时凭证
const getSTSToken = async () => {
const sts = new STS({
accessKeyId: ALIYUN_CONFIG.accessKeyId,
accessKeySecret: ALIYUN_CONFIG.accessKeySecret,
endpoint: ALIYUN_CONFIG.endpoint
});
return sts.assumeRole(
ALIYUN_CONFIG.roleArn,
ALIYUN_CONFIG.roleSessionName,
ALIYUN_CONFIG.policy,
ALIYUN_CONFIG.durationSeconds
);
};

// Express接口示例
const express = require('express');
const router = express.Router();

router.get('/oss/sts-token', async (req, res) => {
try {
const token = await getSTSToken();
res.json({
code: 0,
data: {
accessKeyId: token.Credentials.AccessKeyId,
accessKeySecret: token.Credentials.AccessKeySecret,
securityToken: token.Credentials.SecurityToken,
expiration: token.Credentials.Expiration,
bucket: '你的Bucket名称',
region: 'oss-cn-hangzhou' // 你的Bucket地域
}
});
} catch (err) {
res.json({ code: -1, message: '获取STS凭证失败', error: err.message });
}
});

module.exports = router;

3. 前端完整实现:分片上传+断点续传

基于阿里云OSS SDK内置的multipartUpload方法,原生支持断点续传、进度回调、暂停/继续,无需手动实现分片切割和并发控制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
import React, { useState, useRef } from 'react';
import OSS from 'ali-oss';
import axios from 'axios';

const AliyunOssUpload = () => {
// 上传状态
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadStatus, setUploadStatus] = useState('idle'); // idle/uploading/paused/success/fail
const [fileUrl, setFileUrl] = useState('');
// 上传实例/暂停控制器 引用
const uploadClientRef = useRef<OSS | null>(null);
const uploadCheckpointRef = useRef<any>(null);
const abortControllerRef = useRef<OSS.AbortController | null>(null);

// 1. 初始化OSS客户端
const initOssClient = async () => {
// 从服务端获取STS临时凭证
const res = await axios.get('/api/oss/sts-token');
if (res.data.code !== 0) throw new Error(res.data.message);
const { accessKeyId, accessKeySecret, securityToken, bucket, region } = res.data.data;

// 创建OSS客户端
const client = new OSS({
region,
bucket,
accessKeyId,
accessKeySecret,
stsToken: securityToken,
secure: true // 启用HTTPS
});
uploadClientRef.current = client;
return client;
};

// 2. 生成唯一文件名
const generateFileName = (file: File) => {
const suffix = file.name.split('.').pop();
const timestamp = Date.now();
const random = Math.floor(Math.random() * 10000);
return `upload/${timestamp}_${random}.${suffix}`;
};

// 3. 开始上传/断点续传
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;

try {
setUploadStatus('uploading');
setUploadProgress(0);
const client = await initOssClient();
const fileName = generateFileName(file);
// 中断控制器,用于暂停上传
const abortController = new OSS.AbortController();
abortControllerRef.current = abortController;

// 核心:分片上传+断点续传
const result = await client.multipartUpload(fileName, file, {
// 分片大小,单位MB,这里设置为10MB
partSize: 10 * 1024 * 1024,
// 并行上传数量
parallel: 3,
// 中断信号
signal: abortController.signal,
// 进度回调
progress: (p, checkpoint) => {
// 保存断点信息,刷新页面后可用于续传
uploadCheckpointRef.current = checkpoint;
setUploadProgress(Math.floor(p * 100));
},
// 断点续传核心:传入上次中断的checkpoint,自动跳过已上传分片
checkpoint: uploadCheckpointRef.current,
// 开启元数据校验,避免分片损坏
meta: {
filename: file.name,
size: file.size.toString()
}
});

// 上传成功,获取文件访问地址
setFileUrl(result.res.requestUrls[0].split('?')[0]);
setUploadStatus('success');
} catch (err: any) {
if (err.name === 'abort') {
setUploadStatus('paused');
console.log('上传已暂停');
} else {
setUploadStatus('fail');
console.error('上传失败:', err);
}
}
};

// 4. 暂停上传
const handlePause = () => {
abortControllerRef.current?.abort();
};

// 5. 继续上传(断点续传)
const handleResume = async () => {
if (!uploadCheckpointRef.current || !uploadClientRef.current) return;
try {
setUploadStatus('uploading');
const abortController = new OSS.AbortController();
abortControllerRef.current = abortController;
const file = uploadCheckpointRef.current.file;

// 续传:传入已保存的checkpoint
const result = await uploadClientRef.current.multipartUpload(
uploadCheckpointRef.current.name,
file,
{
partSize: 10 * 1024 * 1024,
parallel: 3,
signal: abortController.signal,
progress: (p, checkpoint) => {
uploadCheckpointRef.current = checkpoint;
setUploadProgress(Math.floor(p * 100));
},
checkpoint: uploadCheckpointRef.current
}
);

setFileUrl(result.res.requestUrls[0].split('?')[0]);
setUploadStatus('success');
} catch (err: any) {
if (err.name === 'abort') {
setUploadStatus('paused');
} else {
setUploadStatus('fail');
console.error('续传失败:', err);
}
}
};

// 6. 取消上传
const handleCancel = async () => {
if (!uploadCheckpointRef.current || !uploadClientRef.current) return;
try {
// 终止分片任务,清理已上传分片
await uploadClientRef.current.abortMultipartUpload(
uploadCheckpointRef.current.name,
uploadCheckpointRef.current.uploadId
);
// 重置状态
uploadCheckpointRef.current = null;
abortControllerRef.current = null;
uploadClientRef.current = null;
setUploadProgress(0);
setUploadStatus('idle');
setFileUrl('');
} catch (err) {
console.error('取消上传失败:', err);
}
};

return (
<div style={{ padding: 20, maxWidth: 600, margin: '0 auto' }}>
<h3>阿里云OSS 分片上传&断点续传</h3>

<div style={{ marginBottom: 20 }}>
<input
type="file"
onChange={handleFileChange}
disabled={uploadStatus === 'uploading'}
/>
</div>

{/* 上传进度 */}
{uploadStatus !== 'idle' && (
<div style={{ marginBottom: 20 }}>
<div style={{ width: '100%', height: 8, background: '#eee', borderRadius: 4, overflow: 'hidden' }}>
<div
style={{
width: `${uploadProgress}%`,
height: '100%',
background: '#165DFF',
transition: 'width 0.3s'
}}
/>
</div>
<p>上传进度:{uploadProgress}% | 状态:{uploadStatus}</p>
</div>
)}

{/* 操作按钮 */}
<div style={{ display: 'flex', gap: 10, marginBottom: 20 }}>
{uploadStatus === 'uploading' && (
<button onClick={handlePause}>暂停上传</button>
)}
{uploadStatus === 'paused' && (
<button onClick={handleResume}>继续上传</button>
)}
{uploadStatus !== 'idle' && uploadStatus !== 'success' && (
<button onClick={handleCancel}>取消上传</button>
)}
</div>

{/* 上传结果 */}
{fileUrl && (
<div>
<p>上传成功!文件地址:</p>
<a href={fileUrl} target="_blank" rel="noreferrer">{fileUrl}</a>
</div>
)}
</div>
);
};

export default AliyunOssUpload;

四、腾讯云COS 分片上传与断点续传实战

1. 核心API说明

API 功能 对应方法 核心作用
初始化分片上传 initiateMultipartUpload 创建上传任务,返回UploadId
分片上传 uploadPart 上传单个分片,返回ETag
查询已上传分片 listParts 查询已上传分片,断点续传核心
合并分片 completeMultipartUpload 合并所有分片为完整文件
终止分片上传 abortMultipartUpload 取消任务,清理分片
高级封装 sliceUploadFile SDK内置分片上传+断点续传方法

2. 服务端实现:STS临时凭证接口(Node.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// sts.js 腾讯云STS服务
const STS = require('qcloud-cos-sts');
const express = require('express');
const router = express.Router();

// 腾讯云配置
const TENCENT_CONFIG = {
secretId: '你的腾讯云SecretId',
secretKey: '你的腾讯云SecretKey',
bucket: '你的存储桶名称-APPID',
region: 'ap-beijing', // 存储桶地域
// 临时凭证有效期
durationSeconds: 3600,
// 允许的操作
allowActions: [
'cos:PutObject',
'cos:InitiateMultipartUpload',
'cos:UploadPart',
'cos:ListParts',
'cos:CompleteMultipartUpload',
'cos:AbortMultipartUpload'
],
// 允许的资源
allowPrefix: 'upload/*' // 仅允许upload目录下的文件上传
};

// 生成STS临时凭证
router.get('/cos/sts-token', async (req, res) => {
const policy = {
version: '2.0',
statement: [
{
effect: 'allow',
action: TENCENT_CONFIG.allowActions,
resource: [
`qcs::cos:${TENCENT_CONFIG.region}:uid/${TENCENT_CONFIG.bucket.split('-')[1]}:${TENCENT_CONFIG.bucket}/${TENCENT_CONFIG.allowPrefix}`
]
}
]
};

STS.getCredential({
secretId: TENCENT_CONFIG.secretId,
secretKey: TENCENT_CONFIG.secretKey,
policy: policy,
durationSeconds: TENCENT_CONFIG.durationSeconds
}, (err, credential) => {
if (err) {
return res.json({ code: -1, message: '获取STS凭证失败', error: err.message });
}
res.json({
code: 0,
data: {
TmpSecretId: credential.credentials.tmpSecretId,
TmpSecretKey: credential.credentials.tmpSecretKey,
SecurityToken: credential.credentials.sessionToken,
ExpiredTime: credential.expiredTime,
bucket: TENCENT_CONFIG.bucket,
region: TENCENT_CONFIG.region
}
});
});
});

module.exports = router;

3. 前端完整实现:分片上传+断点续传

基于腾讯云COS SDK的sliceUploadFile方法,原生支持断点续传、进度回调、暂停/继续:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
import React, { useState, useRef } from 'react';
import COS from 'cos-js-sdk-v5';
import axios from 'axios';

const TencentCosUpload = () => {
// 上传状态
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadStatus, setUploadStatus] = useState('idle');
const [fileUrl, setFileUrl] = useState('');
// 引用
const cosClientRef = useRef<COS | null>(null);
const uploadTaskRef = useRef<any>(null);
const uploadInfoRef = useRef<{
bucket: string;
region: string;
fileName: string;
uploadId: string;
} | null>(null);

// 1. 初始化COS客户端
const initCosClient = async () => {
// 从服务端获取STS临时凭证
const res = await axios.get('/api/cos/sts-token');
if (res.data.code !== 0) throw new Error(res.data.message);
const { TmpSecretId, TmpSecretKey, SecurityToken, bucket, region } = res.data.data;

// 创建COS客户端
const cos = new COS({
getAuthorization: (options, callback) => {
callback({
TmpSecretId,
TmpSecretKey,
SecurityToken,
ExpiredTime: res.data.data.ExpiredTime
});
}
});
cosClientRef.current = cos;
return { cos, bucket, region };
};

// 2. 生成唯一文件名
const generateFileName = (file: File) => {
const suffix = file.name.split('.').pop();
const timestamp = Date.now();
const random = Math.floor(Math.random() * 10000);
return `upload/${timestamp}_${random}.${suffix}`;
};

// 3. 开始上传/断点续传
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;

try {
setUploadStatus('uploading');
setUploadProgress(0);
const { cos, bucket, region } = await initCosClient();
const fileName = generateFileName(file);

// 核心:分片上传+断点续传
uploadTaskRef.current = cos.sliceUploadFile({
Bucket: bucket,
Region: region,
Key: fileName,
Body: file,
// 分片大小,单位字节,10MB
SliceSize: 10 * 1024 * 1024,
// 并行上传数量
AsyncLimit: 3,
// 开启断点续传
EnableResume: true,
// 进度回调
onProgress: (info) => {
setUploadProgress(Math.floor(info.percent * 100));
},
// 上传完成回调
onFileFinish: (err, data) => {
if (err) throw err;
// 保存上传信息,用于取消/续传
uploadInfoRef.current = {
bucket,
region,
fileName,
uploadId: data.UploadId
};
// 拼接文件访问地址
const url = `https://${bucket}.cos.${region}.myqcloud.com/${fileName}`;
setFileUrl(url);
setUploadStatus('success');
}
});
} catch (err) {
setUploadStatus('fail');
console.error('上传失败:', err);
}
};

// 4. 暂停上传
const handlePause = () => {
if (uploadTaskRef.current) {
uploadTaskRef.current.pause();
setUploadStatus('paused');
}
};

// 5. 继续上传
const handleResume = () => {
if (uploadTaskRef.current) {
uploadTaskRef.current.resume();
setUploadStatus('uploading');
}
};

// 6. 取消上传
const handleCancel = async () => {
if (!uploadInfoRef.current || !cosClientRef.current) return;
try {
const { bucket, region, fileName, uploadId } = uploadInfoRef.current;
// 终止分片任务
await cosClientRef.current.abortMultipartUpload({
Bucket: bucket,
Region: region,
Key: fileName,
UploadId: uploadId
});
// 重置状态
uploadTaskRef.current = null;
uploadInfoRef.current = null;
cosClientRef.current = null;
setUploadProgress(0);
setUploadStatus('idle');
setFileUrl('');
} catch (err) {
console.error('取消上传失败:', err);
}
};

return (
<div style={{ padding: 20, maxWidth: 600, margin: '0 auto' }}>
<h3>腾讯云COS 分片上传&断点续传</h3>

<div style={{ marginBottom: 20 }}>
<input
type="file"
onChange={handleFileChange}
disabled={uploadStatus === 'uploading'}
/>
</div>

{/* 上传进度 */}
{uploadStatus !== 'idle' && (
<div style={{ marginBottom: 20 }}>
<div style={{ width: '100%', height: 8, background: '#eee', borderRadius: 4, overflow: 'hidden' }}>
<div
style={{
width: `${uploadProgress}%`,
height: '100%',
background: '#006EFF',
transition: 'width 0.3s'
}}
/>
</div>
<p>上传进度:{uploadProgress}% | 状态:{uploadStatus}</p>
</div>
)}

{/* 操作按钮 */}
<div style={{ display: 'flex', gap: 10, marginBottom: 20 }}>
{uploadStatus === 'uploading' && (
<button onClick={handlePause}>暂停上传</button>
)}
{uploadStatus === 'paused' && (
<button onClick={handleResume}>继续上传</button>
)}
{uploadStatus !== 'idle' && uploadStatus !== 'success' && (
<button onClick={handleCancel}>取消上传</button>
)}
</div>

{/* 上传结果 */}
{fileUrl && (
<div>
<p>上传成功!文件地址:</p>
<a href={fileUrl} target="_blank" rel="noreferrer">{fileUrl}</a>
</div>
)}
</div>
);
};

export default TencentCosUpload;

五、Electron + Node.js 环境分片上传与断点续传

Electron结合了Node.js和Chromium,利用Node.js的fs模块可以处理GB级甚至TB级的超大文件,避免浏览器内存限制,同时可以将断点信息持久化到本地文件,更稳定可靠。

1. Electron环境前置准备

(1)项目配置

推荐使用contextBridge安全地暴露API,避免直接在渲染进程中使用Node.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// main.js 主进程
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');

let mainWindow;

function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.js'), // 预加载脚本
contextIsolation: true, // 开启上下文隔离
nodeIntegration: false // 关闭渲染进程Node.js集成
}
});

mainWindow.loadFile('index.html');
}

app.whenReady().then(createWindow);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// preload.js 预加载脚本
const { contextBridge, ipcRenderer } = require('electron');

// 暴露上传相关API到渲染进程
contextBridge.exposeInMainWorld('electronAPI', {
// 选择文件
selectFile: () => ipcRenderer.invoke('select-file'),
// 开始上传
startUpload: (filePath, cloudType) => ipcRenderer.invoke('start-upload', filePath, cloudType),
// 暂停上传
pauseUpload: () => ipcRenderer.invoke('pause-upload'),
// 继续上传
resumeUpload: () => ipcRenderer.invoke('resume-upload'),
// 取消上传
cancelUpload: () => ipcRenderer.invoke('cancel-upload'),
// 监听上传进度
onUploadProgress: (callback) => ipcRenderer.on('upload-progress', (event, progress) => callback(progress)),
// 监听上传状态
onUploadStatus: (callback) => ipcRenderer.on('upload-status', (event, status) => callback(status)),
// 监听上传完成
onUploadComplete: (callback) => ipcRenderer.on('upload-complete', (event, url) => callback(url))
});

(2)安装依赖

1
npm install ali-oss cos-nodejs-sdk-v5 fs-extra crypto --save

2. 核心工具函数(Node.js)

(1)流式计算文件MD5

用于唯一标识文件,判断文件是否被修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// utils.js
const crypto = require('crypto');
const fs = require('fs-extra');

/**
* 流式计算文件MD5
* @param {string} filePath 文件路径
* @returns {Promise<string>} MD5值
*/
const calculateFileMD5 = (filePath) => {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5');
const stream = fs.createReadStream(filePath);

stream.on('error', (err) => reject(err));
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
});
};

module.exports = { calculateFileMD5 };

(2)断点缓存管理

将上传进度持久化到用户数据目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// cache.js
const { app } = require('electron');
const fs = require('fs-extra');
const path = require('path');

// 缓存文件路径
const CACHE_FILE = path.join(app.getPath('userData'), 'upload-cache.json');

/**
* 读取缓存
* @returns {Object} 缓存数据
*/
const readCache = async () => {
try {
const exists = await fs.pathExists(CACHE_FILE);
if (!exists) return {};
return await fs.readJson(CACHE_FILE);
} catch (err) {
console.error('读取缓存失败:', err);
return {};
}
};

/**
* 写入缓存
* @param {string} key 缓存键(文件MD5)
* @param {Object} data 缓存数据
*/
const writeCache = async (key, data) => {
try {
const cache = await readCache();
cache[key] = { ...data, updatedAt: Date.now() };
await fs.writeJson(CACHE_FILE, cache);
} catch (err) {
console.error('写入缓存失败:', err);
}
};

/**
* 删除缓存
* @param {string} key 缓存键
*/
const deleteCache = async (key) => {
try {
const cache = await readCache();
delete cache[key];
await fs.writeJson(CACHE_FILE, cache);
} catch (err) {
console.error('删除缓存失败:', err);
}
};

module.exports = { readCache, writeCache, deleteCache };

3. 阿里云OSS Electron主进程实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
// upload-aliyun.js
const OSS = require('ali-oss');
const fs = require('fs-extra');
const path = require('path');
const { calculateFileMD5 } = require('./utils');
const { readCache, writeCache, deleteCache } = require('./cache');

// 阿里云配置(实际项目中建议从服务端获取STS)
const ALIYUN_CONFIG = {
region: 'oss-cn-hangzhou',
bucket: '你的Bucket名称',
accessKeyId: '你的临时AccessKeyId',
accessKeySecret: '你的临时AccessKeySecret',
stsToken: '你的临时SecurityToken'
};

let ossClient = null;
let uploadCheckpoint = null;
let abortController = null;
let currentFilePath = null;

/**
* 初始化OSS客户端
*/
const initOssClient = () => {
if (!ossClient) {
ossClient = new OSS({
...ALIYUN_CONFIG,
secure: true
});
}
return ossClient;
};

/**
* 生成唯一文件名
*/
const generateFileName = (filePath) => {
const suffix = path.extname(filePath);
const timestamp = Date.now();
const random = Math.floor(Math.random() * 10000);
return `upload/${timestamp}_${random}${suffix}`;
};

/**
* 开始上传
*/
const startUpload = async (filePath, mainWindow) => {
try {
currentFilePath = filePath;
const client = initOssClient();
const fileName = generateFileName(filePath);
const fileSize = (await fs.stat(filePath)).size;
const fileMD5 = await calculateFileMD5(filePath);

// 检查本地缓存
const cache = await readCache();
if (cache[fileMD5]) {
uploadCheckpoint = cache[fileMD5].checkpoint;
mainWindow.webContents.send('upload-status', 'resuming');
} else {
uploadCheckpoint = null;
mainWindow.webContents.send('upload-status', 'uploading');
}

abortController = new OSS.AbortController();

// 分片上传
const result = await client.multipartUpload(fileName, filePath, {
partSize: 50 * 1024 * 1024, // 50MB分片,适合超大文件
parallel: 3,
signal: abortController.signal,
checkpoint: uploadCheckpoint,
progress: async (p, checkpoint) => {
uploadCheckpoint = checkpoint;
// 实时保存缓存
await writeCache(fileMD5, {
filePath,
fileName,
fileSize,
fileMD5,
checkpoint
});
// 通知渲染进程进度
mainWindow.webContents.send('upload-progress', Math.floor(p * 100));
}
});

// 上传成功
const fileUrl = result.res.requestUrls[0].split('?')[0];
await deleteCache(fileMD5);
mainWindow.webContents.send('upload-status', 'success');
mainWindow.webContents.send('upload-complete', fileUrl);
} catch (err) {
if (err.name === 'abort') {
mainWindow.webContents.send('upload-status', 'paused');
} else {
console.error('上传失败:', err);
mainWindow.webContents.send('upload-status', 'fail');
}
}
};

/**
* 暂停上传
*/
const pauseUpload = () => {
if (abortController) {
abortController.abort();
}
};

/**
* 继续上传
*/
const resumeUpload = async (mainWindow) => {
if (currentFilePath) {
await startUpload(currentFilePath, mainWindow);
}
};

/**
* 取消上传
*/
const cancelUpload = async () => {
if (abortController) {
abortController.abort();
}
if (uploadCheckpoint && ossClient) {
try {
await ossClient.abortMultipartUpload(
uploadCheckpoint.name,
uploadCheckpoint.uploadId
);
} catch (err) {
console.error('终止分片任务失败:', err);
}
}
// 清除缓存
if (currentFilePath) {
const fileMD5 = await calculateFileMD5(currentFilePath);
await deleteCache(fileMD5);
}
// 重置状态
uploadCheckpoint = null;
abortController = null;
currentFilePath = null;
};

module.exports = { startUpload, pauseUpload, resumeUpload, cancelUpload };

4. 主进程IPC事件绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// main.js 补充
const { dialog } = require('electron');
const { startUpload, pauseUpload, resumeUpload, cancelUpload } = require('./upload-aliyun');
// 腾讯云实现类似,可单独创建upload-tencent.js

// 选择文件
ipcMain.handle('select-file', async () => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'All Files', extensions: ['*'] }]
});
return result.filePaths[0];
});

// 开始上传
ipcMain.handle('start-upload', async (event, filePath, cloudType) => {
if (cloudType === 'aliyun') {
await startUpload(filePath, mainWindow);
} else if (cloudType === 'tencent') {
// 腾讯云实现
}
});

// 暂停上传
ipcMain.handle('pause-upload', async () => {
await pauseUpload();
});

// 继续上传
ipcMain.handle('resume-upload', async () => {
await resumeUpload(mainWindow);
});

// 取消上传
ipcMain.handle('cancel-upload', async () => {
await cancelUpload();
});

5. 渲染进程调用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Electron 大文件上传</title>
</head>
<body>
<div style="padding: 20px;">
<h3>Electron + Node.js 大文件上传</h3>
<button id="selectFileBtn">选择文件</button>
<p id="filePath"></p>

<div style="margin-top: 20px;">
<label>
<input type="radio" name="cloudType" value="aliyun" checked> 阿里云OSS
</label>
<label style="margin-left: 20px;">
<input type="radio" name="cloudType" value="tencent"> 腾讯云COS
</label>
</div>

<div style="margin-top: 20px;">
<button id="startUploadBtn" disabled>开始上传</button>
<button id="pauseUploadBtn" disabled>暂停</button>
<button id="resumeUploadBtn" disabled>继续</button>
<button id="cancelUploadBtn" disabled>取消</button>
</div>

<div style="margin-top: 20px;">
<div id="progressBar" style="width: 100%; height: 20px; background: #eee; border-radius: 10px; overflow: hidden;">
<div id="progressFill" style="width: 0%; height: 100%; background: #165DFF; transition: width 0.3s;"></div>
</div>
<p id="progressText">进度:0% | 状态:idle</p>
</div>

<div id="result" style="margin-top: 20px; display: none;">
<p>上传成功!文件地址:</p>
<a id="fileUrl" href="#" target="_blank"></a>
</div>
</div>

<script>
const { electronAPI } = window;
let selectedFilePath = null;

// DOM元素
const selectFileBtn = document.getElementById('selectFileBtn');
const filePathEl = document.getElementById('filePath');
const startUploadBtn = document.getElementById('startUploadBtn');
const pauseUploadBtn = document.getElementById('pauseUploadBtn');
const resumeUploadBtn = document.getElementById('resumeUploadBtn');
const cancelUploadBtn = document.getElementById('cancelUploadBtn');
const progressFill = document.getElementById('progressFill');
const progressText = document.getElementById('progressText');
const resultEl = document.getElementById('result');
const fileUrlEl = document.getElementById('fileUrl');

// 选择文件
selectFileBtn.addEventListener('click', async () => {
const filePath = await electronAPI.selectFile();
if (filePath) {
selectedFilePath = filePath;
filePathEl.textContent = `已选择:${filePath}`;
startUploadBtn.disabled = false;
}
});

// 开始上传
startUploadBtn.addEventListener('click', async () => {
const cloudType = document.querySelector('input[name="cloudType"]:checked').value;
await electronAPI.startUpload(selectedFilePath, cloudType);
startUploadBtn.disabled = true;
pauseUploadBtn.disabled = false;
cancelUploadBtn.disabled = false;
});

// 暂停上传
pauseUploadBtn.addEventListener('click', async () => {
await electronAPI.pauseUpload();
pauseUploadBtn.disabled = true;
resumeUploadBtn.disabled = false;
});

// 继续上传
resumeUploadBtn.addEventListener('click', async () => {
await electronAPI.resumeUpload();
resumeUploadBtn.disabled = true;
pauseUploadBtn.disabled = false;
});

// 取消上传
cancelUploadBtn.addEventListener('click', async () => {
await electronAPI.cancelUpload();
pauseUploadBtn.disabled = true;
resumeUploadBtn.disabled = true;
cancelUploadBtn.disabled = true;
startUploadBtn.disabled = false;
progressFill.style.width = '0%';
progressText.textContent = '进度:0% | 状态:idle';
});

// 监听上传进度
electronAPI.onUploadProgress((progress) => {
progressFill.style.width = `${progress}%`;
progressText.textContent = `进度:${progress}% | 状态:${progressText.textContent.split('|')[1]}`;
});

// 监听上传状态
electronAPI.onUploadStatus((status) => {
progressText.textContent = `进度:${progressFill.style.width} | 状态:${status}`;
});

// 监听上传完成
electronAPI.onUploadComplete((url) => {
resultEl.style.display = 'block';
fileUrlEl.href = url;
fileUrlEl.textContent = url;
pauseUploadBtn.disabled = true;
resumeUploadBtn.disabled = true;
cancelUploadBtn.disabled = true;
startUploadBtn.disabled = false;
});
</script>
</body>
</html>

六、通用最佳实践

1. 分片大小选择

  • 推荐分片大小为5MB~50MB,根据文件总大小动态调整:
    • 1GB以内文件:5MB分片,最多200片;
    • 1GB10GB文件:10MB20MB分片;
    • 10GB以上大文件(Electron环境):50MB分片,确保不超过10000片上限。
  • 避免分片过小:会导致请求数过多,增加上传失败概率;
  • 避免分片过大:单个分片上传失败后重传成本高,弱网环境下易超时。

2. 并发控制

  • 浏览器对同一域名的并发请求数有限制(Chrome为6个),推荐并行上传数量设置为3~5个
  • Electron环境可适当提高到5~8个,但需注意内存占用;
  • 实现分片重传机制:单个分片上传失败后,自动重试2~3次,避免整个上传任务失败。

3. 断点续传优化

  • 双缓存机制
    • 前端/渲染进程:将checkpoint保存到localStorage
    • Electron环境:将checkpoint和文件MD5保存到用户数据目录的JSON文件;
  • 服务端校验:续传前先调用listParts查询服务端已上传的分片,以服务端数据为准,避免本地缓存与服务端不一致;
  • 分片MD5校验:上传前计算每个分片的MD5值,上传后校验ETag,确保分片数据完整性。

4. 存储与成本优化

  • 配置生命周期规则:自动删除超过7天未合并的分片,避免无效占用存储资源;
  • 前端限制文件类型和大小,避免无效上传;
  • 大文件上传前先做秒传校验:计算文件MD5,查询服务端是否已存在该文件,若存在直接返回文件地址,无需重复上传。

5. 安全最佳实践

  • 严禁在前端/渲染进程代码中硬编码永久AK/SK,必须使用STS临时凭证或服务端签名;
  • STS临时凭证遵循最小权限原则,仅授予上传所需的最小权限,限制有效期和上传目录;
  • 配置Bucket防盗链,限制Referer白名单,避免恶意上传。

6. Electron环境专属优化

  • 超大文件处理:使用fs.createReadStream流式读取分片,避免一次性加载整个文件到内存;
  • 断点缓存清理:定期清理已完成或超过30天的缓存文件,避免占用用户磁盘空间;
  • 多文件上传队列:使用async/await控制并发,避免同时上传过多文件导致内存不足或应用崩溃;
  • 进度实时持久化:每上传完成一个分片就保存一次缓存,避免应用崩溃后丢失进度。

七、常见问题与解决方案

1. 跨域问题(前端直传场景)

  • 现象:前端上传时报CORS错误;
  • 解决方案:检查Bucket的跨域配置,确保来源、Methods、Headers、暴露Headers配置正确,配置后等待5分钟生效,清除浏览器缓存重试。

2. 分片合并失败

  • 现象:所有分片上传完成后,合并时报InvalidPart/PartOrder错误;
  • 解决方案:
    1. 确保提交的PartNumber从1开始连续递增,无缺失;
    2. 确保提交的ETag与每个分片上传返回的ETag完全一致;
    3. 检查分片大小,除最后一个分片外,其他分片必须大于等于100KB。

3. 断点续传进度丢失

  • 现象:页面刷新/应用重启后,断点续传无法恢复之前的进度;
  • 解决方案:
    1. 前端:将checkpoint/UploadId和文件唯一标识(如文件MD5)持久化到localStorage
    2. Electron:将checkpoint保存到用户数据目录的JSON文件,通过文件MD5关联;
    3. 续传前通过文件MD5查询本地缓存的UploadId,再调用listParts校验服务端分片进度。

4. 大文件上传内存溢出

  • 前端场景:上传GB级大文件时,浏览器内存占用过高,甚至崩溃;
    • 解决方案:降低分片大小和并行上传数量,避免同时加载过多分片数据。
  • Electron场景:处理TB级文件时内存溢出;
    • 解决方案:使用fs.createReadStream流式读取分片,分片上传完成后立即释放ArrayBuffer内存,降低并行上传数量。

5. 上传时报AccessDenied权限错误

  • 现象:上传接口返回403 AccessDenied;
  • 解决方案:
    1. 检查STS临时凭证的权限策略,确保包含分片上传相关的Action;
    2. 检查Bucket的权限策略,是否禁止了对应IP/账号的上传操作;
    3. 检查临时凭证是否已过期,需在凭证过期前重新获取。

6. Electron环境fs模块权限问题

  • 现象:读取/写入文件时报Permission denied
  • 解决方案:
    1. 确保应用有文件读取/写入权限(macOS需在Info.plist中声明,Windows需以管理员身份运行);
    2. 避免直接操作系统敏感目录,优先使用app.getPath('userData')等Electron提供的安全目录。

八、阿里云OSS vs 腾讯云COS 核心差异对比

对比维度 阿里云OSS 腾讯云COS
分片数量限制 最多10000个分片,单个分片最小100KB 最多10000个分片,单个分片最小100KB
断点续传SDK支持 multipartUpload 方法,需手动保存checkpoint sliceUploadFile 方法,内置EnableResume自动缓存断点
临时凭证 STS服务,RAM角色授权 CAM服务,STS临时密钥
前端SDK ali-oss,TS支持完善 cos-js-sdk-v5,TS支持完善
Node.js SDK ali-oss(兼容Node.js) cos-nodejs-sdk-v5(Node.js专属)
合并分片校验 需提交PartNumber+ETag,严格校验分片顺序 需提交PartNumber+ETag,严格校验分片顺序
无效分片生命周期 需手动配置生命周期规则,自动清理未合并的分片 需手动配置生命周期规则,自动清理未合并的分片

Electron分片上传及断点续传
https://cszy.top/20230106-electron分片上传及断点续传/
作者
csorz
发布于
2023年1月6日
许可协议