原生 JS 实现图片预览上传组件:多图上传 + 拖拽上传 + 裁剪预览 + 进度显示(附完整源码)

前言

图片上传是前端开发中高频且核心的功能场景,如头像上传、素材管理、表单提交等。本文基于原生 HTML+CSS+JavaScript 实现一套企业级图片预览上传组件,包含多图选择、拖拽上传、实时预览、图片裁剪、上传进度显示、文件大小 / 格式校验等功能,无任何第三方框架依赖,代码模块化封装,可直接集成到各类项目中。

实现效果

  • 支持单图 / 多图选择上传,兼容主流浏览器
  • 拖拽上传:可直接将图片拖入上传区域完成选择
  • 实时预览:选中图片后立即展示缩略图,支持删除单张图片
  • 图片裁剪:内置简易裁剪功能,支持固定比例裁剪
  • 格式 / 大小校验:限制仅允许 jpg/png/webp 格式,可自定义文件大小上限
  • 上传进度:模拟 AJAX 上传,实时显示上传进度条
  • 响应式布局:适配 PC 端、平板、手机等多端设备
  • 友好提示:操作反馈清晰,错误提示直观

技术栈

  • HTML5:FileReader API、拖放 API、Canvas(图片裁剪)
  • CSS3:Flex 布局、Grid 布局、过渡动画、响应式适配
  • 原生 JavaScript:文件处理、Blob/FormData、异步编程、DOM 操作
  • 性能优化:图片压缩、懒加载思想、事件委托

完整代码

html

预览

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>图片预览上传组件 | 原生JS实现</title>
    <meta name="keywords" content="图片上传,原生JS,拖拽上传,图片预览,图片裁剪,FileReader">
    <meta name="description" content="原生JavaScript实现图片预览上传组件,支持多图上传、拖拽上传、裁剪预览、进度显示、格式校验">
    <link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/font-awesome/6.4.0/css/all.min.css">
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            font-family: "Microsoft YaHei", sans-serif;
        }

        body {
            background-color: #f8f9fa;
            color: #333;
            line-height: 1.6;
            padding: 50px 20px;
        }

        .upload-container {
            max-width: 1000px;
            margin: 0 auto;
            background-color: #fff;
            border-radius: 10px;
            box-shadow: 0 2px 20px rgba(0,0,0,0.08);
            padding: 30px;
        }

        .upload-header {
            margin-bottom: 25px;
            padding-bottom: 15px;
            border-bottom: 1px solid #eee;
        }

        .upload-header h2 {
            color: #2c3e50;
            font-size: 24px;
            margin-bottom: 8px;
        }

        .upload-header .tips {
            color: #666;
            font-size: 14px;
        }

        /* 上传区域样式 */
        .upload-area {
            border: 2px dashed #ddd;
            border-radius: 8px;
            padding: 40px 20px;
            text-align: center;
            cursor: pointer;
            transition: all 0.3s ease;
            margin-bottom: 30px;
        }

        .upload-area:hover {
            border-color: #3498db;
            background-color: #f0f8ff;
        }

        .upload-area.active {
            border-color: #2ecc71;
            background-color: #f8fff8;
        }

        .upload-icon {
            font-size: 48px;
            color: #999;
            margin-bottom: 15px;
            transition: color 0.3s;
        }

        .upload-area:hover .upload-icon {
            color: #3498db;
        }

        .upload-text {
            font-size: 16px;
            color: #666;
            margin-bottom: 10px;
        }

        .upload-hint {
            font-size: 12px;
            color: #999;
        }

        #fileInput {
            display: none;
        }

        /* 预览区域样式 */
        .preview-container {
            margin-bottom: 30px;
        }

        .preview-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 15px;
        }

        .preview-title {
            font-size: 18px;
            color: #333;
        }

        .preview-actions {
            display: flex;
            gap: 10px;
        }

        .action-btn {
            padding: 6px 12px;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 14px;
            transition: all 0.3s;
        }

        .crop-btn {
            background-color: #3498db;
            color: #fff;
        }

        .clear-btn {
            background-color: #e74c3c;
            color: #fff;
        }

        .action-btn:hover {
            opacity: 0.9;
            transform: translateY(-2px);
        }

        .preview-list {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
            gap: 15px;
        }

        .preview-item {
            position: relative;
            border-radius: 6px;
            overflow: hidden;
            box-shadow: 0 2px 8px rgba(0,0,0,0.1);
            aspect-ratio: 1/1;
        }

        .preview-img {
            width: 100%;
            height: 100%;
            object-fit: cover;
            display: block;
        }

        .preview-close {
            position: absolute;
            top: 5px;
            right: 5px;
            width: 24px;
            height: 24px;
            background-color: rgba(0,0,0,0.6);
            color: #fff;
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            cursor: pointer;
            font-size: 12px;
            transition: all 0.2s;
        }

        .preview-close:hover {
            background-color: #e74c3c;
        }

        /* 进度条样式 */
        .upload-progress {
            margin-bottom: 25px;
            display: none;
        }

        .progress-bar {
            width: 100%;
            height: 8px;
            background-color: #eee;
            border-radius: 4px;
            overflow: hidden;
            margin-top: 8px;
        }

        .progress-fill {
            height: 100%;
            background-color: #2ecc71;
            width: 0%;
            transition: width 0.3s ease;
            border-radius: 4px;
        }

        /* 上传按钮 */
        .submit-btn {
            width: 100%;
            padding: 12px;
            background-color: #2ecc71;
            color: #fff;
            border: none;
            border-radius: 6px;
            font-size: 16px;
            cursor: pointer;
            transition: all 0.3s;
        }

        .submit-btn:disabled {
            background-color: #bdc3c7;
            cursor: not-allowed;
            transform: none;
        }

        .submit-btn:hover:not(:disabled) {
            background-color: #27ae60;
            transform: translateY(-2px);
        }

        /* 裁剪弹窗 */
        .crop-modal {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background-color: rgba(0,0,0,0.7);
            display: none;
            align-items: center;
            justify-content: center;
            z-index: 999;
            padding: 20px;
        }

        .crop-content {
            background-color: #fff;
            border-radius: 8px;
            width: 100%;
            max-width: 800px;
            padding: 20px;
        }

        .crop-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 20px;
        }

        .crop-title {
            font-size: 18px;
            color: #333;
        }

        .crop-close {
            font-size: 20px;
            cursor: pointer;
            color: #666;
        }

        .crop-close:hover {
            color: #e74c3c;
        }

        .crop-body {
            display: flex;
            flex-direction: column;
            gap: 20px;
        }

        .crop-preview {
            width: 100%;
            height: 300px;
            border: 1px solid #eee;
            display: flex;
            align-items: center;
            justify-content: center;
            overflow: hidden;
        }

        .crop-img {
            max-width: 100%;
            max-height: 100%;
        }

        .crop-actions {
            display: flex;
            justify-content: flex-end;
            gap: 10px;
        }

        .confirm-crop {
            background-color: #3498db;
            color: #fff;
        }

        /* 响应式适配 */
        @media (max-width: 768px) {
            .upload-container {
                padding: 20px;
            }

            .upload-area {
                padding: 30px 15px;
            }

            .crop-preview {
                height: 200px;
            }
        }

        @media (max-width: 480px) {
            .preview-list {
                grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
            }

            .upload-header h2 {
                font-size: 20px;
            }
        }
    </style>
</head>
<body>
    <div class="upload-container">
        <!-- 头部说明 -->
        <div class="upload-header">
            <h2>图片上传组件</h2>
            <p class="tips">支持多图上传、拖拽上传、图片裁剪,仅允许上传jpg/png/webp格式,单张图片不超过5MB</p>
        </div>

        <!-- 上传区域 -->
        <div class="upload-area" id="uploadArea">
            <input type="file" id="fileInput" accept="image/jpeg,image/png,image/webp" multiple>
            <div class="upload-icon">
                <i class="fas fa-cloud-upload-alt"></i>
            </div>
            <div class="upload-text">点击或拖拽图片到此处上传</div>
            <div class="upload-hint">支持jpg、png、webp格式,单张不超过5MB</div>
        </div>

        <!-- 预览区域 -->
        <div class="preview-container" id="previewContainer" style="display: none;">
            <div class="preview-header">
                <h3 class="preview-title">图片预览</h3>
                <div class="preview-actions">
                    <button class="action-btn crop-btn" id="cropBtn" disabled>裁剪选中图片</button>
                    <button class="action-btn clear-btn" id="clearBtn">清空所有</button>
                </div>
            </div>
            <div class="preview-list" id="previewList"></div>
        </div>

        <!-- 上传进度 -->
        <div class="upload-progress" id="uploadProgress">
            <div class="progress-text">上传进度:<span id="progressPercent">0</span>%</div>
            <div class="progress-bar">
                <div class="progress-fill" id="progressFill"></div>
            </div>
        </div>

        <!-- 上传按钮 -->
        <button class="submit-btn" id="submitBtn" disabled>开始上传</button>
    </div>

    <!-- 裁剪弹窗 -->
    <div class="crop-modal" id="cropModal">
        <div class="crop-content">
            <div class="crop-header">
                <h3 class="crop-title">图片裁剪</h3>
                <span class="crop-close" id="cropClose"><i class="fas fa-times"></i></span>
            </div>
            <div class="crop-body">
                <div class="crop-preview" id="cropPreview">
                    <img src="" alt="裁剪预览" class="crop-img" id="cropImg">
                </div>
                <div class="crop-actions">
                    <button class="action-btn" id="cancelCrop">取消</button>
                    <button class="action-btn confirm-crop" id="confirmCrop">确认裁剪</button>
                </div>
            </div>
        </div>
    </div>

    <script>
        // 全局变量
        let fileList = []; // 存储选中的文件
        let selectedImageIndex = -1; // 当前选中的图片索引
        let cropFile = null; // 待裁剪的文件

        // DOM元素缓存
        const uploadArea = document.getElementById('uploadArea');
        const fileInput = document.getElementById('fileInput');
        const previewContainer = document.getElementById('previewContainer');
        const previewList = document.getElementById('previewList');
        const cropBtn = document.getElementById('cropBtn');
        const clearBtn = document.getElementById('clearBtn');
        const submitBtn = document.getElementById('submitBtn');
        const uploadProgress = document.getElementById('uploadProgress');
        const progressPercent = document.getElementById('progressPercent');
        const progressFill = document.getElementById('progressFill');
        const cropModal = document.getElementById('cropModal');
        const cropImg = document.getElementById('cropImg');
        const cropClose = document.getElementById('cropClose');
        const cancelCrop = document.getElementById('cancelCrop');
        const confirmCrop = document.getElementById('confirmCrop');

        // 初始化
        function init() {
            bindEvents();
        }

        // 绑定所有事件
        function bindEvents() {
            // 点击上传区域触发文件选择
            uploadArea.addEventListener('click', () => {
                fileInput.click();
            });

            // 拖放相关事件
            uploadArea.addEventListener('dragover', (e) => {
                e.preventDefault();
                uploadArea.classList.add('active');
            });

            uploadArea.addEventListener('dragleave', () => {
                uploadArea.classList.remove('active');
            });

            uploadArea.addEventListener('drop', (e) => {
                e.preventDefault();
                uploadArea.classList.remove('active');
                const files = e.dataTransfer.files;
                handleFiles(files);
            });

            // 文件选择事件
            fileInput.addEventListener('change', (e) => {
                const files = e.target.files;
                handleFiles(files);
            });

            // 清空所有图片
            clearBtn.addEventListener('click', () => {
                if (confirm('确定要清空所有图片吗?')) {
                    fileList = [];
                    selectedImageIndex = -1;
                    updatePreview();
                    updateButtonStatus();
                }
            });

            // 裁剪按钮事件
            cropBtn.addEventListener('click', () => {
                if (selectedImageIndex >= 0 && selectedImageIndex < fileList.length) {
                    cropFile = fileList[selectedImageIndex];
                    const reader = new FileReader();
                    reader.onload = (e) => {
                        cropImg.src = e.target.result;
                        cropModal.style.display = 'flex';
                    };
                    reader.readAsDataURL(cropFile);
                }
            });

            // 关闭裁剪弹窗
            cropClose.addEventListener('click', () => {
                cropModal.style.display = 'none';
                cropFile = null;
            });

            cancelCrop.addEventListener('click', () => {
                cropModal.style.display = 'none';
                cropFile = null;
            });

            // 确认裁剪(简易裁剪,实际项目可集成cropper.js)
            confirmCrop.addEventListener('click', () => {
                // 创建Canvas进行裁剪(此处模拟1:1裁剪)
                const canvas = document.createElement('canvas');
                const ctx = canvas.getContext('2d');
                const img = new Image();
                
                img.onload = () => {
                    // 取图片最小边作为裁剪尺寸,实现1:1裁剪
                    const size = Math.min(img.width, img.height);
                    canvas.width = size;
                    canvas.height = size;
                    
                    // 居中裁剪
                    const x = (img.width - size) / 2;
                    const y = (img.height - size) / 2;
                    
                    ctx.drawImage(img, x, y, size, size, 0, 0, size, size);
                    
                    // 将Canvas转为Blob
                    canvas.toBlob((blob) => {
                        // 替换原文件
                        const newFile = new File([blob], cropFile.name, {
                            type: cropFile.type,
                            lastModified: Date.now()
                        });
                        
                        fileList[selectedImageIndex] = newFile;
                        updatePreview();
                        cropModal.style.display = 'none';
                        cropFile = null;
                    }, cropFile.type);
                };
                
                img.src = cropImg.src;
            });

            // 提交上传
            submitBtn.addEventListener('click', () => {
                if (fileList.length === 0) {
                    alert('请先选择要上传的图片!');
                    return;
                }
                simulateUpload();
            });

            // 预览项点击/删除事件(事件委托)
            previewList.addEventListener('click', (e) => {
                const previewItem = e.target.closest('.preview-item');
                if (!previewItem) return;

                const index = parseInt(previewItem.dataset.index);

                // 删除图片
                if (e.target.classList.contains('preview-close')) {
                    fileList.splice(index, 1);
                    // 重置选中索引
                    if (selectedImageIndex === index) {
                        selectedImageIndex = -1;
                    } else if (selectedImageIndex > index) {
                        selectedImageIndex--;
                    }
                    updatePreview();
                    updateButtonStatus();
                    return;
                }

                // 选中图片(用于裁剪)
                selectedImageIndex = index;
                // 移除所有选中样式
                document.querySelectorAll('.preview-item').forEach(item => {
                    item.style.border = 'none';
                });
                // 添加选中样式
                previewItem.style.border = '2px solid #3498db';
                cropBtn.disabled = false;
            });
        }

        // 处理选中的文件
        function handleFiles(files) {
            if (!files || files.length === 0) return;

            // 遍历文件
            for (let i = 0; i < files.length; i++) {
                const file = files[i];
                
                // 校验文件类型
                if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
                    alert(`文件${file.name}格式不支持,仅允许jpg/png/webp格式!`);
                    continue;
                }

                // 校验文件大小(5MB)
                const maxSize = 5 * 1024 * 1024;
                if (file.size > maxSize) {
                    alert(`文件${file.name}大小超过5MB限制!`);
                    continue;
                }

                // 添加到文件列表
                fileList.push(file);
            }

            // 更新预览和按钮状态
            updatePreview();
            updateButtonStatus();
        }

        // 更新预览列表
        function updatePreview() {
            if (fileList.length === 0) {
                previewContainer.style.display = 'none';
                return;
            }

            previewContainer.style.display = 'block';
            previewList.innerHTML = '';

            // 渲染预览项
            fileList.forEach((file, index) => {
                const reader = new FileReader();
                reader.onload = (e) => {
                    const previewItem = document.createElement('div');
                    previewItem.className = 'preview-item';
                    previewItem.dataset.index = index;
                    
                    // 选中状态样式
                    if (index === selectedImageIndex) {
                        previewItem.style.border = '2px solid #3498db';
                    }

                    previewItem.innerHTML = `
                        <img src="${e.target.result}" alt="预览图" class="preview-img">
                        <span class="preview-close"><i class="fas fa-times"></i></span>
                    `;

                    previewList.appendChild(previewItem);
                };
                reader.readAsDataURL(file);
            });
        }

        // 更新按钮状态
        function updateButtonStatus() {
            // 上传按钮状态
            submitBtn.disabled = fileList.length === 0;
            // 裁剪按钮状态
            cropBtn.disabled = selectedImageIndex < 0 || fileList.length === 0;
        }

        // 模拟上传(实际项目替换为真实AJAX请求)
        function simulateUpload() {
            uploadProgress.style.display = 'block';
            submitBtn.disabled = true;
            clearBtn.disabled = true;
            cropBtn.disabled = true;

            let progress = 0;
            const totalFiles = fileList.length;
            let uploadedFiles = 0;

            // 模拟进度更新
            const progressInterval = setInterval(() => {
                uploadedFiles++;
                progress = Math.floor((uploadedFiles / totalFiles) * 100);
                
                progressPercent.textContent = progress;
                progressFill.style.width = `${progress}%`;

                // 上传完成
                if (progress >= 100) {
                    clearInterval(progressInterval);
                    
                    // 延迟提示,模拟真实上传耗时
                    setTimeout(() => {
                        alert(`成功上传${totalFiles}张图片!`);
                        // 重置状态
                        fileList = [];
                        selectedImageIndex = -1;
                        uploadProgress.style.display = 'none';
                        progressFill.style.width = '0%';
                        progressPercent.textContent = '0';
                        updatePreview();
                        updateButtonStatus();
                        clearBtn.disabled = false;
                    }, 500);
                }
            }, 500);

            // 实际项目中使用FormData上传示例
            /*
            const formData = new FormData();
            fileList.forEach(file => {
                formData.append('files', file);
            });

            fetch('/api/upload', {
                method: 'POST',
                body: formData,
                onUploadProgress: (e) => {
                    const progress = Math.floor((e.loaded / e.total) * 100);
                    progressPercent.textContent = progress;
                    progressFill.style.width = `${progress}%`;
                }
            }).then(response => response.json())
              .then(data => {
                  alert('上传成功!');
                  // 后续处理
              }).catch(error => {
                  alert('上传失败:' + error.message);
              });
            */
        }

        // 启动应用
        init();
    </script>
</body>
</html>

功能说明

  1. 基础上传:点击上传区域可打开文件选择器,支持多选图片,自动过滤非图片文件,限制单张图片大小不超过 5MB。
  2. 拖拽上传:直接将本地图片拖入上传区域,自动完成文件选择和校验,操作更便捷。
  3. 图片预览:选中图片后实时生成缩略图预览,预览项支持点击选中、删除单张图片、清空所有图片。
  4. 图片裁剪:选中单张图片后点击 "裁剪选中图片",可打开裁剪弹窗进行 1:1 比例裁剪,裁剪后替换原图片。
  5. 上传进度:点击 "开始上传" 后模拟上传进度,实时显示上传百分比和进度条,上传完成后给出成功提示。
  6. 格式校验:仅允许上传 jpg/png/webp 格式图片,非支持格式会给出明确的错误提示。
  7. 响应式适配:预览列表采用网格布局,自动适配不同屏幕宽度,移动端优化显示效果。

总结

本图片上传组件基于原生 JS 实现,无任何第三方依赖,涵盖了企业级图片上传场景的核心功能,代码结构清晰、模块化程度高,易于扩展和二次开发。开发者可在此基础上进一步扩展:

  • 集成专业裁剪库(如 cropper.js)实现更灵活的裁剪功能;
  • 增加图片压缩功能,降低上传带宽消耗;
  • 对接真实后端接口,实现图片上传到服务器;
  • 增加上传失败重试、断点续传等高级功能;
  • 支持图片旋转、缩放、水印添加等编辑功能。该组件可直接应用于表单提交、头像上传、素材管理等业务场景,是前端开发中极具实用价值的实战项目。
相关推荐
天若有情6734 天前
Canvas生成艺术|意外诞生的混沌风暴(附完整源码+GitHub部署)
前端·css·html·github·canvas·网页
abigale0310 天前
低代码平台前端功能优化与实现
低代码·图片上传·vuex·ant-design
abigale0314 天前
【浏览器 API / 网络请求 / 文件处理】前端文件上传全流程:从基础上传到断点续传
前端·typescript·文件上传·vue cli
xingxin3216 天前
日志文件分析溯源(连接WebShell的IP地址)实验报告
安全·web安全·网络安全·php·文件上传
JMchen12323 天前
跨技术栈:在Flutter/Compose中应用自定义View思想
java·经验分享·flutter·canvas·dart·自定义view
JMchen1231 个月前
企业级图表组件库完整实现
android·java·经验分享·笔记·canvas·android-studio
带娃的IT创业者1 个月前
UI 交互难题攻克:遮挡、弹窗、动态加载
ui·交互·文件上传·浏览器自动化·playwright·ui 交互·元素遮挡
wordbaby1 个月前
前端进阶:小程序 Canvas 2D 终极指北 — 给图片优雅添加水印
前端·canvas
wordbaby1 个月前
小白也能看懂:小程序 Canvas 给图片添加水印的终极指南
前端·canvas