PHP、HTML、原生JS实现大文件切片上传
index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文件切片上传</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="container">
<h1>文件切片上传</h1>
<div class="upload-box">
<div class="file-select">
<input type="file" id="fileInput" />
<label for="fileInput" class="file-label">选择文件</label>
</div>
<div class="upload-info" id="uploadInfo" style="display: none;">
<p>文件名: <span id="fileName"></span></p>
<p>文件大小: <span id="fileSize"></span></p>
<p>上传进度: <span id="progressPercent">0%</span></p>
<div class="progress-bar">
<div class="progress" id="progressBar"></div>
</div>
<div class="buttons">
<button id="uploadBtn" class="btn upload-btn">开始上传</button>
<button id="cancelBtn" class="btn cancel-btn">取消上传</button>
</div>
</div>
<div id="uploadResult" class="upload-result"></div>
</div>
</div>
<script src="js/upload.js"></script>
</body>
</html>
server:
ChunkUploader.php
<?php
/**
* 文件切片上传类
*/
class ChunkUploader {
/**
* 配置选项
*/
private $config = [
'tempDir' => '../temp', // 临时文件目录
'uploadDir' => '../uploads', // 上传文件目录
'allowedTypes' => [], // 允许的文件类型,空数组表示允许所有
'maxFileSize' => 0, // 最大文件大小,0表示不限制
'overwrite' => false // 是否覆盖同名文件
];
/**
* 构造函数
* @param array $config 配置选项
*/
public function __construct(array $config = []) {
// 合并配置
$this->config = array_merge($this->config, $config);
// 确保目录存在
$this->ensureDirectoryExists($this->config['tempDir']);
$this->ensureDirectoryExists($this->config['uploadDir']);
}
/**
* 处理切片上传
* @return array 处理结果
*/
public function handleChunkUpload() {
try {
// 检查请求
if (!isset($_FILES['chunk']) || !isset($_POST['fileId']) ||
!isset($_POST['chunkIndex']) || !isset($_POST['totalChunks']) ||
!isset($_POST['fileName'])) {
return $this->error('参数不完整');
}
$fileId = $_POST['fileId'];
$chunkIndex = (int)$_POST['chunkIndex'];
$totalChunks = (int)$_POST['totalChunks'];
$fileName = $_POST['fileName'];
// 检查上传的文件
if ($_FILES['chunk']['error'] !== UPLOAD_ERR_OK) {
return $this->error('文件上传错误: ' . $this->getUploadErrorMessage($_FILES['chunk']['error']));
}
// 创建文件ID目录
$fileIdDir = $this->config['tempDir'] . DIRECTORY_SEPARATOR . $fileId;
$this->ensureDirectoryExists($fileIdDir);
// 保存切片
$chunkPath = $fileIdDir . DIRECTORY_SEPARATOR . $chunkIndex;
if (!move_uploaded_file($_FILES['chunk']['tmp_name'], $chunkPath)) {
return $this->error('无法保存切片文件');
}
return [
'success' => true,
'message' => '切片上传成功',
'data' => [
'fileId' => $fileId,
'chunkIndex' => $chunkIndex,
'totalChunks' => $totalChunks
]
];
} catch (Exception $e) {
return $this->error('上传处理异常: ' . $e->getMessage());
}
}
/**
* 合并切片
* @return array 处理结果
*/
public function mergeChunks() {
try {
// 检查请求
if (!isset($_POST['fileId']) || !isset($_POST['fileName']) || !isset($_POST['totalChunks'])) {
return $this->error('参数不完整');
}
$fileId = $_POST['fileId'];
$fileName = $_POST['fileName'];
$totalChunks = (int)$_POST['totalChunks'];
// 检查文件ID目录
$fileIdDir = $this->config['tempDir'] . DIRECTORY_SEPARATOR . $fileId;
if (!is_dir($fileIdDir)) {
return $this->error('找不到切片文件目录');
}
// 检查所有切片是否存在
for ($i = 0; $i < $totalChunks; $i++) {
$chunkPath = $fileIdDir . DIRECTORY_SEPARATOR . $i;
if (!file_exists($chunkPath)) {
return $this->error("切片 {$i} 不存在");
}
}
// 生成目标文件路径
$targetPath = $this->config['uploadDir'] . DIRECTORY_SEPARATOR . $fileName;
// 检查是否允许覆盖
if (file_exists($targetPath) && !$this->config['overwrite']) {
$fileInfo = pathinfo($fileName);
$baseName = $fileInfo['filename'];
$extension = isset($fileInfo['extension']) ? '.' . $fileInfo['extension'] : '';
$targetPath = $this->config['uploadDir'] . DIRECTORY_SEPARATOR . $baseName . '_' . time() . $extension;
}
// 合并文件
$targetFile = fopen($targetPath, 'wb');
if (!$targetFile) {
return $this->error('无法创建目标文件');
}
for ($i = 0; $i < $totalChunks; $i++) {
$chunkPath = $fileIdDir . DIRECTORY_SEPARATOR . $i;
$chunkContent = file_get_contents($chunkPath);
fwrite($targetFile, $chunkContent);
unlink($chunkPath); // 删除已合并的切片
}
fclose($targetFile);
// 删除临时目录
rmdir($fileIdDir);
return [
'success' => true,
'message' => '文件合并成功',
'data' => [
'fileName' => basename($targetPath),
'filePath' => $targetPath,
'fileSize' => filesize($targetPath)
]
];
} catch (Exception $e) {
return $this->error('合并处理异常: ' . $e->getMessage());
}
}
/**
* 确保目录存在
* @param string $dir 目录路径
*/
private function ensureDirectoryExists($dir) {
if (!file_exists($dir)) {
if (!mkdir($dir, 0777, true)) {
throw new Exception("无法创建目录: {$dir}");
}
}
}
/**
* 获取上传错误信息
* @param int $errorCode 错误代码
* @return string 错误信息
*/
private function getUploadErrorMessage($errorCode) {
switch ($errorCode) {
case UPLOAD_ERR_INI_SIZE:
return '上传的文件超过了 php.ini 中 upload_max_filesize 选项限制的值';
case UPLOAD_ERR_FORM_SIZE:
return '上传文件的大小超过了 HTML 表单中 MAX_FILE_SIZE 选项指定的值';
case UPLOAD_ERR_PARTIAL:
return '文件只有部分被上传';
case UPLOAD_ERR_NO_FILE:
return '没有文件被上传';
case UPLOAD_ERR_NO_TMP_DIR:
return '找不到临时文件夹';
case UPLOAD_ERR_CANT_WRITE:
return '文件写入失败';
case UPLOAD_ERR_EXTENSION:
return '文件上传被PHP扩展程序中断';
default:
return '未知上传错误';
}
}
/**
* 返回错误信息
* @param string $message 错误信息
* @return array 错误结果
*/
private function error($message) {
return [
'success' => false,
'message' => $message
];
}
/**
* 验证文件类型
* @param string $fileName 文件名
* @return bool 是否允许
*/
private function validateFileType($fileName) {
// 如果没有设置允许的类型,则允许所有
if (empty($this->config['allowedTypes'])) {
return true;
}
$extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
return in_array($extension, $this->config['allowedTypes']);
}
/**
* 验证文件大小
* @param int $fileSize 文件大小
* @return bool 是否允许
*/
private function validateFileSize($fileSize) {
// 如果没有设置最大大小,则允许所有
if ($this->config['maxFileSize'] <= 0) {
return true;
}
return $fileSize <= $this->config['maxFileSize'];
}
/**
* 清理过期的临时文件
* @param int $maxAge 最大保留时间(秒)
*/
public function cleanupTempFiles($maxAge = 86400) {
$tempDir = $this->config['tempDir'];
if (!is_dir($tempDir)) {
return;
}
$now = time();
$dirs = glob($tempDir . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR);
foreach ($dirs as $dir) {
// 检查目录修改时间
$modTime = filemtime($dir);
if (($now - $modTime) > $maxAge) {
$this->removeDirectory($dir);
}
}
}
/**
* 递归删除目录
* @param string $dir 目录路径
*/
private function removeDirectory($dir) {
if (!is_dir($dir)) {
return;
}
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . DIRECTORY_SEPARATOR . $file;
is_dir($path) ? $this->removeDirectory($path) : unlink($path);
}
rmdir($dir);
}
}
merge.php
<?php
// 设置无限执行时间
set_time_limit(0);
// 引入上传类
require_once 'ChunkUploader.php';
// 创建上传实例
$uploader = new ChunkUploader([
'tempDir' => '../temp',
'uploadDir' => '../uploads'
]);
// 处理合并请求
$result = $uploader->mergeChunks();
// 返回JSON结果
header('Content-Type: application/json');
echo json_encode($result);
upload.php
<?php
// 设置无限执行时间
set_time_limit(0);
// 引入上传类
require_once 'ChunkUploader.php';
// 创建上传实例
$uploader = new ChunkUploader([
'tempDir' => '../temp',
'uploadDir' => '../uploads'
]);
// 处理上传请求
$result = $uploader->handleChunkUpload();
// 返回JSON结果
header('Content-Type: application/json');
echo json_encode($result);
js:
upload.js
class ChunkUploader {
constructor(options) {
this.file = null;
this.chunkSize = options.chunkSize || 2 * 1024 * 1024; // 默认2MB一片
this.uploadUrl = options.uploadUrl || 'upload.php';
this.mergeUrl = options.mergeUrl || 'merge.php';
this.onProgress = options.onProgress || function() {};
this.onSuccess = options.onSuccess || function() {};
this.onError = options.onError || function() {};
this.onFileSelected = options.onFileSelected || function() {};
this.chunks = [];
this.uploadedChunks = 0;
this.totalChunks = 0;
this.isUploading = false;
this.uploadController = null;
}
// 选择文件
selectFile(file) {
this.file = file;
this.chunks = this.createChunks(file);
this.totalChunks = this.chunks.length;
this.uploadedChunks = 0;
this.onFileSelected({
name: file.name,
size: this.formatFileSize(file.size),
totalChunks: this.totalChunks
});
}
// 创建文件切片
createChunks(file) {
const chunks = [];
const totalChunks = Math.ceil(file.size / this.chunkSize);
for (let i = 0; i < totalChunks; i++) {
const start = i * this.chunkSize;
const end = Math.min(file.size, start + this.chunkSize);
const chunk = file.slice(start, end);
chunks.push(chunk);
}
return chunks;
}
// 开始上传
async startUpload() {
if (!this.file || this.isUploading) return;
this.isUploading = true;
this.uploadController = new AbortController();
try {
// 生成唯一文件标识
const fileId = this.generateFileId(this.file);
// 上传所有切片
const uploadPromises = this.chunks.map((chunk, index) =>
this.uploadChunk(chunk, index, fileId, this.uploadController.signal)
);
await Promise.all(uploadPromises);
// 所有切片上传完成,通知服务器合并
if (this.isUploading) {
await this.mergeChunks(fileId, this.file.name, this.totalChunks);
this.onSuccess({
fileName: this.file.name,
fileId: fileId
});
}
} catch (error) {
if (error.name !== 'AbortError') {
this.onError({
message: '上传失败: ' + error.message
});
}
} finally {
this.isUploading = false;
this.uploadController = null;
}
}
// 上传单个切片
async uploadChunk(chunk, index, fileId, signal) {
const formData = new FormData();
formData.append('chunk', chunk, `${index}`);
formData.append('fileId', fileId);
formData.append('chunkIndex', index);
formData.append('totalChunks', this.totalChunks);
formData.append('fileName', this.file.name);
const response = await fetch(this.uploadUrl, {
method: 'POST',
body: formData,
signal: signal
});
if (!response.ok) {
throw new Error(`切片 ${index} 上传失败: ${response.statusText}`);
}
this.uploadedChunks++;
const progress = Math.floor((this.uploadedChunks / this.totalChunks) * 100);
this.onProgress({
progress: progress,
uploadedChunks: this.uploadedChunks,
totalChunks: this.totalChunks
});
return await response.json();
}
// 通知服务器合并切片
async mergeChunks(fileId, fileName, totalChunks) {
const formData = new FormData();
formData.append('fileId', fileId);
formData.append('fileName', fileName);
formData.append('totalChunks', totalChunks);
const response = await fetch(this.mergeUrl, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('文件合并失败: ' + response.statusText);
}
return await response.json();
}
// 取消上传
cancelUpload() {
if (this.isUploading && this.uploadController) {
this.uploadController.abort();
this.isUploading = false;
return true;
}
return false;
}
// 生成文件唯一标识
generateFileId(file) {
return `${file.name}_${file.size}_${new Date().getTime()}`;
}
// 格式化文件大小
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
}
// 页面加载完成后初始化上传器
document.addEventListener('DOMContentLoaded', function() {
const fileInput = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn');
const cancelBtn = document.getElementById('cancelBtn');
const uploadInfo = document.getElementById('uploadInfo');
const fileName = document.getElementById('fileName');
const fileSize = document.getElementById('fileSize');
const progressBar = document.getElementById('progressBar');
const progressPercent = document.getElementById('progressPercent');
const uploadResult = document.getElementById('uploadResult');
// 初始化上传器
const uploader = new ChunkUploader({
chunkSize: 2 * 1024 * 1024, // 2MB一片
uploadUrl: 'server/upload.php',
mergeUrl: 'server/merge.php',
onFileSelected: function(data) {
fileName.textContent = data.name;
fileSize.textContent = data.size;
uploadInfo.style.display = 'block';
progressBar.style.width = '0%';
progressPercent.textContent = '0%';
uploadResult.style.display = 'none';
},
onProgress: function(data) {
progressBar.style.width = data.progress + '%';
progressPercent.textContent = data.progress + '%';
},
onSuccess: function(data) {
uploadResult.textContent = `文件 ${data.fileName} 上传成功!`;
uploadResult.className = 'upload-result success';
uploadResult.style.display = 'block';
},
onError: function(error) {
uploadResult.textContent = error.message;
uploadResult.className = 'upload-result error';
uploadResult.style.display = 'block';
}
});
// 文件选择事件
fileInput.addEventListener('change', function(e) {
if (this.files.length > 0) {
uploader.selectFile(this.files[0]);
}
});
// 开始上传
uploadBtn.addEventListener('click', function() {
uploader.startUpload();
});
// 取消上传
cancelBtn.addEventListener('click', function() {
if (uploader.cancelUpload()) {
uploadResult.textContent = '上传已取消';
uploadResult.className = 'upload-result';
uploadResult.style.display = 'block';
}
});
});
css:
style.css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft YaHei', Arial, sans-serif;
background-color: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 800px;
margin: 50px auto;
padding: 20px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
h1 {
text-align: center;
margin-bottom: 30px;
color: #2c3e50;
}
.upload-box {
padding: 20px;
}
.file-select {
margin-bottom: 20px;
text-align: center;
}
input[type="file"] {
display: none;
}
.file-label {
display: inline-block;
padding: 12px 24px;
background-color: #3498db;
color: white;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
}
.file-label:hover {
background-color: #2980b9;
}
.upload-info {
margin-top: 20px;
}
.progress-bar {
height: 20px;
background-color: #e0e0e0;
border-radius: 10px;
margin: 15px 0;
overflow: hidden;
}
.progress {
height: 100%;
background-color: #2ecc71;
width: 0;
transition: width 0.3s ease;
}
.buttons {
display: flex;
justify-content: center;
gap: 15px;
margin-top: 20px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
transition: background-color 0.3s;
}
.upload-btn {
background-color: #2ecc71;
color: white;
}
.upload-btn:hover {
background-color: #27ae60;
}
.cancel-btn {
background-color: #e74c3c;
color: white;
}
.cancel-btn:hover {
background-color: #c0392b;
}
.upload-result {
margin-top: 20px;
padding: 15px;
border-radius: 4px;
display: none;
}
.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}