前言

你将在该篇学到:

  • 如何将现有组件改写为 React Hooks函数组件

  • useStateuseEffectuseRef是如何替代原生命周期和Ref的。

  • 一个完整拖拽上传行为覆盖的四个事件:dragoverdragenterdropdragleave

  • 如何使用React Hooks编写自己的UI组件库。

逛国外社区时看到这篇:

How To Implement Drag and Drop for Files in React

文章讲了React拖拽上传的精简实现,但直接翻译照搬显然不是我的风格。

于是我又用React Hooks 重写了一版,除CSS的代码总数 120行。
效果如下:

1. 添加基本目录骨架

app.js

import React from 'react';
import PropTypes from 'prop-types';

import { FilesDragAndDrop } from '../components/Common/FilesDragAndDropHook';

export default class App extends React.Component {
    static propTypes = {};

    onUpload = (files) => {
        console.log(files);
    };

    render() {
        return (
            <div>
                <FilesDragAndDrop
                    onUpload={this.onUpload}
                />
            </div>
        );
    }
}

FilesDragAndDrop.js(非Hooks):

import React from 'react';
import PropTypes from 'prop-types';

import '../../scss/components/Common/FilesDragAndDrop.scss';

export default class FilesDragAndDrop extends React.Component {
    static propTypes = {
        onUpload: PropTypes.func.isRequired,
    };

    render() {
        return (
            <div className='FilesDragAndDrop__area'>
                传下文件试试?
                <span
                    role='img'
                    aria-label='emoji'
                    className='area__icon'
                >
                    &#128526;
                </span>
            </div>
        );
    }
}

1. 如何改写为 Hooks 组件?

请看动图:

2. 改写组件

Hooks版组件属于函数组件,将以上改造:

import React, { useEffect, useState, useRef } from "react";
import PropTypes from 'prop-types';
import classNames from 'classnames';
import classList from '../../scss/components/Common/FilesDragAndDrop.scss';
const FilesDragAndDrop = (props) => {
    return (
        <div className='FilesDragAndDrop__area'>
            传下文件试试?
            <span
                role='img'
                aria-label='emoji'
                className='area__icon'
            >
                &#128526;
            </span>
        </div>
    );
}

FilesDragAndDrop.propTypes = {
    onUpload: PropTypes.func.isRequired,
    children: PropTypes.node.isRequired,
    count: PropTypes.number,
    formats: PropTypes.arrayOf(PropTypes.string)
}

export { FilesDragAndDrop };

FilesDragAndDrop.scss

.FilesDragAndDrop {
  .FilesDragAndDrop__area {
    width: 300px;
    height: 200px;
    padding: 50px;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-flow: column nowrap;
    font-size: 24px;
    color: #555555;
    border: 2px #c3c3c3 dashed;
    border-radius: 12px;

    .area__icon {
      font-size: 64px;
      margin-top: 20px;
    }
  }
}

然后就可以看到页面:

2. 实现分析

从操作DOM、组件复用、事件触发、阻止默认行为、以及Hooks应用方面分析。

1. 操作DOM:`useRef`

由于需要拖拽文件上传以及操作组件实例,需要用到ref属性。

React Hooks中 新增了useRef API
语法

const refContainer = useRef(initialValue);
  • useRef 返回一个可变的 ref 对象,。

  • 其 .current 属性被初始化为传递的参数(initialValue

  • 返回的对象将存留在整个组件的生命周期中。

...
const drop = useRef();

return (
    <div
        ref={drop}
        className='FilesDragAndDrop'
    />
    ...
    )

2. 事件触发


完成具有动态交互的拖拽行为并不简单,需要用到四个事件控制:

  • 区域外:dragleave,    离开范围

  • 区域内:dragenter,用来确定放置目标是否接受放置。

  • 区域内移动:dragover,用来确定给用户显示怎样的反馈信息

  • 完成拖拽(落下):drop,允许放置对象。

这四个事件并存,才能阻止 Web 浏览器默认行为和形成反馈。

3. 阻止默认行为

代码很简单:

e.preventDefault() //阻止事件的默认行为(如在浏览器打开文件)
e.stopPropagation() // 阻止事件冒泡

每个事件阶段都需要阻止,为啥呢?举个🌰栗子:

const handleDragOver = (e) => {
    // e.preventDefault();
    // e.stopPropagation();
};

不阻止的话,就会触发打开文件的行为,这显然不是我们想看到的。

4. 组件内部状态: useState

拖拽上传组件,除了基础的拖拽状态控制,还应有成功上传文件或未通过验证时的消息提醒。
状态组成应为:

state = {
    dragging: false,
    message: {
        show: false,
        text: null,
        type: null,
    },
};

写成对应useState前先回归下写法:

const [属性, 操作属性的方法] = useState(默认值);

于是便成了:

const [dragging, setDragging] = useState(false);
const [message, setMessage] = useState({ show: false, text: null, type: null });

5. 需要第二个叠加层

除了drop事件,另外三个事件都是动态变化的,而在拖动元素时,每隔 350 毫秒会触发 dragover事件。

此时就需要第二ref来统一控制。

所以全部的ref为:

const drop = useRef(); // 落下层
const drag = useRef(); // 拖拽活动层

6. 文件类型、数量控制

我们在应用组件时,prop需要传入类型和数量来控制

<FilesDragAndDrop
    onUpload={this.onUpload}
    count={1}
    formats={['jpg', 'png']}
>
    <div className={classList['FilesDragAndDrop__area']}>
        传下文件试试?
<span
            role='img'
            aria-label='emoji'
            className={classList['area__icon']}
        >
            &#128526;
</span>
    </div>
</FilesDragAndDrop>
  • onUpload:拖拽完成处理事件

  • count: 数量控制

  • formats: 文件类型。

对应的组件Drop内部事件:handleDrop:

const handleDrop = (e) => {
    e.preventDefault();
    e.stopPropagation();
    setDragging(false)
    const { count, formats } = props;
    const files = [...e.dataTransfer.files];
    if (count && count < files.length) {
        showMessage(`抱歉,每次最多只能上传${count} 文件。`, 'error', 2000);
        return;
    }
    if (formats && files.some((file) => !formats.some((format) => file.name.toLowerCase().endsWith(format.toLowerCase())))) {
        showMessage(`只允许上传 ${formats.join(', ')}格式的文件`, 'error', 2000);
        return;
    }
    if (files && files.length) {
        showMessage('成功上传!', 'success', 1000);
        props.onUpload(files);
    }
};

.endsWith是判断字符串结尾,如:"abcd".endsWith("cd"); // true

showMessage则是控制显示文本:

const showMessage = (text, type, timeout) => {
    setMessage({ show: true, text, type, })
    setTimeout(() =>
        setMessage({ show: false, text: null, type: null, },), timeout);
};

需要触发定时器来回到初始状态

7. 事件在生命周期里的触发与销毁

原本EventListener的事件需要在componentDidMount添加,在componentWillUnmount中销毁:

componentDidMount () {
    this.drop.addEventListener('dragover', this.handleDragOver);
}

componentWillUnmount () {
    this.drop.removeEventListener('dragover', this.handleDragOver);
}

Hooks中有内部操作方法和对应useEffect来取代上述两个生命周期

useEffect示例:

useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]); // 仅在 count 更改时更新

而 每个effect都可以返回一个清除函数。如此可以将添加(componentDidMount)和移除(componentWillUnmount) 订阅的逻辑放在一起。

于是上述就可以写成:

useEffect(() => {
    drop.current.addEventListener('dragover', handleDragOver);
    return () => {
        drop.current.removeEventListener('dragover', handleDragOver);
    }
})


这也太香了吧!!!

3. 完整代码:

FilesDragAndDropHook.js:

import React, { useEffect, useState, useRef } from "react";
import PropTypes from 'prop-types';
import classNames from 'classnames';
import classList from '../../scss/components/Common/FilesDragAndDrop.scss';

const FilesDragAndDrop = (props) => {
    const [dragging, setDragging] = useState(false);
    const [message, setMessage] = useState({ show: false, text: null, type: null });
    const drop = useRef();
    const drag = useRef();
    useEffect(() => {
        // useRef 的 drop.current 取代了 ref 的 this.drop
        drop.current.addEventListener('dragover', handleDragOver);
        drop.current.addEventListener('drop', handleDrop);
        drop.current.addEventListener('dragenter', handleDragEnter);
        drop.current.addEventListener('dragleave', handleDragLeave);
        return () => {
            drop.current.removeEventListener('dragover', handleDragOver);
            drop.current.removeEventListener('drop', handleDrop);
            drop.current.removeEventListener('dragenter', handleDragEnter);
            drop.current.removeEventListener('dragleave', handleDragLeave);
        }
    })
    const handleDragOver = (e) => {
        e.preventDefault();
        e.stopPropagation();
    };

    const handleDrop = (e) => {
        e.preventDefault();
        e.stopPropagation();
        setDragging(false)
        const { count, formats } = props;
        const files = [...e.dataTransfer.files];

        if (count && count < files.length) {
            showMessage(`抱歉,每次最多只能上传${count} 文件。`, 'error', 2000);
            return;
        }

        if (formats && files.some((file) => !formats.some((format) => file.name.toLowerCase().endsWith(format.toLowerCase())))) {
            showMessage(`只允许上传 ${formats.join(', ')}格式的文件`, 'error', 2000);
            return;
        }

        if (files && files.length) {
            showMessage('成功上传!', 'success', 1000);
            props.onUpload(files);
        }
    };

    const handleDragEnter = (e) => {
        e.preventDefault();
        e.stopPropagation();
        e.target !== drag.current && setDragging(true)
    };

    const handleDragLeave = (e) => {
        e.preventDefault();
        e.stopPropagation();
        e.target === drag.current && setDragging(false)
    };

    const showMessage = (text, type, timeout) => {
        setMessage({ show: true, text, type, })
        setTimeout(() =>
            setMessage({ show: false, text: null, type: null, },), timeout);
    };

    return (
        <div
            ref={drop}
            className={classList['FilesDragAndDrop']}
        >
            {message.show && (
                <div
                    className={classNames(
                        classList['FilesDragAndDrop__placeholder'],
                        classList[`FilesDragAndDrop__placeholder--${message.type}`],
                    )}
                >
                    {message.text}
                    <span
                        role='img'
                        aria-label='emoji'
                        className={classList['area__icon']}
                    >
                        {message.type === 'error' ? <>&#128546;</> : <>&#128536;</>}
                    </span>
                </div>
            )}
            {dragging && (
                <div
                    ref={drag}
                    className={classList['FilesDragAndDrop__placeholder']}
                >
                    请放手
                    <span
                        role='img'
                        aria-label='emoji'
                        className={classList['area__icon']}
                    >
                        &#128541;
                    </span>
                </div>
            )}
            {props.children}
        </div>
    );
}

FilesDragAndDrop.propTypes = {
    onUpload: PropTypes.func.isRequired,
    children: PropTypes.node.isRequired,
    count: PropTypes.number,
    formats: PropTypes.arrayOf(PropTypes.string)
}

export { FilesDragAndDrop };

App.js

import React, { Component } from 'react';
import { FilesDragAndDrop } from '../components/Common/FilesDragAndDropHook';
import classList from '../scss/components/Common/FilesDragAndDrop.scss';

export default class App extends Component {
    onUpload = (files) => {
        console.log(files);
    };
    render () {
        return (
            <FilesDragAndDrop
                onUpload={this.onUpload}
                count={1}
                formats={['jpg', 'png', 'gif']}
            >
                <div className={classList['FilesDragAndDrop__area']}>
                    传下文件试试?
            <span
                        role='img'
                        aria-label='emoji'
                        className={classList['area__icon']}
                    >
                        &#128526;
            </span>
                </div>
            </FilesDragAndDrop>
        )
    }
}

FilesDragAndDrop.scss

.FilesDragAndDrop {
  position: relative;

  .FilesDragAndDrop__placeholder {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    width: 100%;
    height: 100%;
    z-index: 9999;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-flow: column nowrap;
    background-color: #e7e7e7;
    border-radius: 12px;
    color: #7f8e99;
    font-size: 24px;
    opacity: 1;
    text-align: center;
    line-height: 1.4;

    &.FilesDragAndDrop__placeholder--error {
      background-color: #f7e7e7;
      color: #cf8e99;
    }

    &.FilesDragAndDrop__placeholder--success {
      background-color: #e7f7e7;
      color: #8ecf99;
    }

    .area__icon {
      font-size: 64px;
      margin-top: 20px;
    }
  }
}

.FilesDragAndDrop__area {
  width: 300px;
  height: 200px;
  padding: 50px;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-flow: column nowrap;
  font-size: 24px;
  color: #555555;
  border: 2px #c3c3c3 dashed;
  border-radius: 12px;

  .area__icon {
    font-size: 64px;
    margin-top: 20px;
  }
}

然后你就可以拿到文件慢慢耍了。。。




————————————————————————————————————————

 

1. 整体实现

首先,我将创建一个包含拖拽上传和文件下载功能的完整页面:

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="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
        }

        body {
            background-color: #f5f7fa;
            color: #333;
            line-height: 1.6;
            padding: 20px;
            max-width: 1200px;
            margin: 0 auto;
        }

        header {
            text-align: center;
            margin-bottom: 40px;
            padding: 20px;
            background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
            color: white;
            border-radius: 12px;
            box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
        }

        h1 {
            font-size: 2.5rem;
            margin-bottom: 10px;
        }

        .subtitle {
            font-size: 1.1rem;
            opacity: 0.9;
            max-width: 800px;
            margin: 0 auto;
        }

        .container {
            display: flex;
            flex-wrap: wrap;
            gap: 30px;
            margin-bottom: 40px;
        }

        .section {
            flex: 1;
            min-width: 300px;
            background-color: white;
            border-radius: 12px;
            padding: 30px;
            box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
        }

        .section-title {
            display: flex;
            align-items: center;
            gap: 10px;
            font-size: 1.8rem;
            color: #2c3e50;
            margin-bottom: 20px;
            padding-bottom: 10px;
            border-bottom: 2px solid #f0f0f0;
        }

        .section-title i {
            color: #3498db;
        }

        .upload-area {
            border: 3px dashed #bdc3c7;
            border-radius: 10px;
            padding: 40px 20px;
            text-align: center;
            transition: all 0.3s ease;
            margin-bottom: 25px;
            cursor: pointer;
            background-color: #f8f9fa;
        }

        .upload-area:hover, .upload-area.dragover {
            border-color: #3498db;
            background-color: #e8f4fc;
        }

        .upload-icon {
            font-size: 60px;
            color: #3498db;
            margin-bottom: 15px;
        }

        .upload-text {
            font-size: 1.2rem;
            margin-bottom: 10px;
            color: #2c3e50;
        }

        .upload-subtext {
            color: #7f8c8d;
            margin-bottom: 20px;
        }

        .browse-btn {
            background-color: #3498db;
            color: white;
            border: none;
            padding: 10px 25px;
            border-radius: 6px;
            font-size: 1rem;
            cursor: pointer;
            transition: background-color 0.3s;
        }

        .browse-btn:hover {
            background-color: #2980b9;
        }

        #fileInput {
            display: none;
        }

        .file-list {
            margin-top: 20px;
        }

        .file-item {
            display: flex;
            align-items: center;
            justify-content: space-between;
            padding: 15px;
            background-color: #f8f9fa;
            border-radius: 8px;
            margin-bottom: 10px;
            border-left: 4px solid #3498db;
        }

        .file-info {
            display: flex;
            align-items: center;
            gap: 15px;
        }

        .file-icon {
            font-size: 24px;
            color: #3498db;
        }

        .file-name {
            font-weight: 600;
            margin-bottom: 5px;
        }

        .file-size {
            color: #7f8c8d;
            font-size: 0.9rem;
        }

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

        .action-btn {
            background: none;
            border: none;
            font-size: 1.2rem;
            cursor: pointer;
            padding: 5px;
            border-radius: 4px;
            transition: all 0.2s;
        }

        .download-btn {
            color: #27ae60;
        }

        .download-btn:hover {
            background-color: rgba(39, 174, 96, 0.1);
        }

        .delete-btn {
            color: #e74c3c;
        }

        .delete-btn:hover {
            background-color: rgba(231, 76, 60, 0.1);
        }

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

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

        .download-section {
            margin-top: 30px;
        }

        .download-grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
            gap: 15px;
            margin-top: 15px;
        }

        .download-card {
            background-color: #f8f9fa;
            border-radius: 8px;
            padding: 20px;
            display: flex;
            align-items: center;
            gap: 15px;
            transition: transform 0.3s, box-shadow 0.3s;
            border-left: 4px solid #27ae60;
        }

        .download-card:hover {
            transform: translateY(-5px);
            box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
        }

        .download-icon {
            font-size: 30px;
            color: #27ae60;
        }

        .download-info h4 {
            margin-bottom: 5px;
            color: #2c3e50;
        }

        .download-info p {
            color: #7f8c8d;
            font-size: 0.9rem;
        }

        .download-btn-card {
            background-color: #27ae60;
            color: white;
            border: none;
            padding: 8px 15px;
            border-radius: 4px;
            cursor: pointer;
            font-size: 0.9rem;
            transition: background-color 0.3s;
        }

        .download-btn-card:hover {
            background-color: #219955;
        }

        .explanation {
            background-color: white;
            border-radius: 12px;
            padding: 30px;
            margin-top: 40px;
            box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
        }

        .explanation h2 {
            color: #2c3e50;
            margin-bottom: 20px;
            padding-bottom: 10px;
            border-bottom: 2px solid #f0f0f0;
        }

        .explanation h3 {
            color: #3498db;
            margin: 25px 0 15px;
        }

        .explanation p {
            margin-bottom: 15px;
            line-height: 1.7;
        }

        .code-block {
            background-color: #2c3e50;
            color: #ecf0f1;
            padding: 15px;
            border-radius: 8px;
            font-family: 'Courier New', monospace;
            margin: 15px 0;
            overflow-x: auto;
            font-size: 0.9rem;
            line-height: 1.5;
        }

        .note {
            background-color: #fff8e1;
            border-left: 4px solid #ffc107;
            padding: 15px;
            margin: 20px 0;
            border-radius: 0 8px 8px 0;
        }

        .tip {
            background-color: #e8f5e9;
            border-left: 4px solid #4caf50;
            padding: 15px;
            margin: 20px 0;
            border-radius: 0 8px 8px 0;
        }

        footer {
            text-align: center;
            margin-top: 50px;
            padding: 20px;
            color: #7f8c8d;
            font-size: 0.9rem;
        }

        @media (max-width: 768px) {
            .container {
                flex-direction: column;
            }
            
            .section {
                width: 100%;
            }
        }
    </style>
</head>
<body>
    <header>
        <h1>拖拽上传与文件下载功能详解</h1>
        <p class="subtitle">完整实现拖拽上传组件和多种文件下载方法,包含详细的技术原理和代码示例</p>
    </header>

    <div class="container">
        <div class="section">
            <h2 class="section-title"><i class="fas fa-cloud-upload-alt"></i> 拖拽上传区域</h2>
            <div class="upload-area" id="dropArea">
                <div class="upload-icon">
                    <i class="fas fa-file-upload"></i>
                </div>
                <p class="upload-text">拖拽文件到此处上传</p>
                <p class="upload-subtext">支持单个或多个文件上传,最大文件大小:10MB</p>
                <button class="browse-btn" id="browseBtn">或点击浏览文件</button>
                <input type="file" id="fileInput" multiple>
            </div>

            <div class="file-list" id="fileList">
                <!-- 文件列表将动态生成 -->
            </div>
        </div>

        <div class="section">
            <h2 class="section-title"><i class="fas fa-cloud-download-alt"></i> 文件下载区域</h2>
            <p>选择以下示例文件进行下载测试:</p>
            
            <div class="download-section">
                <div class="download-grid">
                    <div class="download-card">
                        <div class="download-icon">
                            <i class="fas fa-file-pdf"></i>
                        </div>
                        <div class="download-info">
                            <h4>示例PDF文档</h4>
                            <p>PDF文件,大小:245KB</p>
                        </div>
                        <button class="download-btn-card" data-file="sample.pdf">下载</button>
                    </div>
                    
                    <div class="download-card">
                        <div class="download-icon">
                            <i class="fas fa-file-image"></i>
                        </div>
                        <div class="download-info">
                            <h4>示例图片</h4>
                            <p>JPG图片,大小:1.2MB</p>
                        </div>
                        <button class="download-btn-card" data-file="sample.jpg">下载</button>
                    </div>
                    
                    <div class="download-card">
                        <div class="download-icon">
                            <i class="fas fa-file-excel"></i>
                        </div>
                        <div class="download-info">
                            <h4>示例表格</h4>
                            <p>Excel文件,大小:89KB</p>
                        </div>
                        <button class="download-btn-card" data-file="sample.xlsx">下载</button>
                    </div>
                    
                    <div class="download-card">
                        <div class="download-icon">
                            <i class="fas fa-file-code"></i>
                        </div>
                        <div class="download-info">
                            <h4>示例代码</h4>
                            <p>ZIP压缩包,大小:312KB</p>
                        </div>
                        <button class="download-btn-card" data-file="sample.zip">下载</button>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <div class="explanation">
        <h2>技术实现详解</h2>
        
        <h3>1. 拖拽上传原理</h3>
        <p>HTML5 引入了拖放 API,使我们能够实现拖拽上传功能。主要涉及以下事件:</p>
        
        <div class="code-block">
// 拖拽事件监听
dropArea.addEventListener('dragenter', handleDragEnter);
dropArea.addEventListener('dragover', handleDragOver);
dropArea.addEventListener('dragleave', handleDragLeave);
dropArea.addEventListener('drop', handleDrop);

// 处理拖拽进入
function handleDragEnter(e) {
    e.preventDefault();
    dropArea.classList.add('dragover');
}

// 处理拖拽悬停
function handleDragOver(e) {
    e.preventDefault();
    e.dataTransfer.dropEffect = 'copy';
}

// 处理拖拽离开
function handleDragLeave(e) {
    if (!dropArea.contains(e.relatedTarget)) {
        dropArea.classList.remove('dragover');
    }
}

// 处理文件放置
function handleDrop(e) {
    e.preventDefault();
    dropArea.classList.remove('dragover');
    
    const files = e.dataTransfer.files;
    handleFiles(files);
}
        </div>
        
        <div class="note">
            <p><strong>注意:</strong>必须阻止 dragenter、dragover 和 drop 事件的默认行为,否则浏览器会尝试打开拖拽的文件。</p>
        </div>
        
        <h3>2. 文件处理与上传</h3>
        <p>获取文件后,我们需要验证文件类型和大小,并显示上传进度:</p>
        
        <div class="code-block">
// 处理选中的文件
function handleFiles(files) {
    for (let file of files) {
        // 验证文件大小(10MB限制)
        if (file.size > 10 * 1024 * 1024) {
            alert(`文件 ${file.name} 超过10MB限制`);
            continue;
        }
        
        // 创建文件项
        createFileItem(file);
        
        // 模拟上传过程
        simulateUpload(file);
    }
}

// 模拟上传进度
function simulateUpload(file) {
    let progress = 0;
    const interval = setInterval(() => {
        progress += Math.random() * 10;
        if (progress >= 100) {
            progress = 100;
            clearInterval(interval);
            // 上传完成
            updateFileStatus(file.name, 'uploaded');
        }
        updateProgress(file.name, progress);
    }, 200);
}
        </div>
        
        <div class="tip">
            <p><strong>提示:</strong>在实际应用中,您需要使用 XMLHttpRequest 或 Fetch API 将文件上传到服务器,并监听进度事件。</p>
        </div>
        
        <h3>3. 文件下载方法</h3>
        <p>前端实现文件下载有多种方法:</p>
        
        <div class="code-block">
// 方法1:使用 <a> 标签下载
function downloadByAnchor(url, filename) {
    const a = document.createElement('a');
    a.href = url;
    a.download = filename || 'download';
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
}

// 方法2:下载Blob数据
function downloadBlob(blob, filename) {
    const url = URL.createObjectURL(blob);
    downloadByAnchor(url, filename);
    // 释放URL对象
    setTimeout(() => URL.revokeObjectURL(url), 100);
}

// 方法3:下载JSON等文本数据
function downloadJSON(data, filename) {
    const blob = new Blob([JSON.stringify(data, null, 2)], {
        type: 'application/json'
    });
    downloadBlob(blob, filename);
}

// 方法4:使用服务器端文件
function downloadFromServer(filename) {
    // 实际项目中,这里应该指向服务器文件路径
    const url = `/api/download/${filename}`;
    downloadByAnchor(url, filename);
}
        </div>
        
        <h3>4. 完整的前后端交互流程</h3>
        <p>在实际项目中,上传和下载通常需要与后端服务器交互:</p>
        
        <div class="code-block">
// 前端上传文件到服务器
async function uploadFileToServer(file) {
    const formData = new FormData();
    formData.append('file', file);
    
    try {
        const response = await fetch('/api/upload', {
            method: 'POST',
            body: formData,
            // 可监听上传进度(需要服务器支持)
        });
        
        if (response.ok) {
            const result = await response.json();
            return result.fileUrl;
        } else {
            throw new Error('上传失败');
        }
    } catch (error) {
        console.error('上传错误:', error);
        throw error;
    }
}

// 服务器端示例(Node.js + Express)
/*
app.post('/api/upload', (req, res) => {
    const upload = multer({ storage: storage }).single('file');
    
    upload(req, res, (err) => {
        if (err) {
            return res.status(500).json({ error: err.message });
        }
        
        // 文件信息
        const file = req.file;
        
        // 返回文件URL供下载
        res.json({
            success: true,
            fileUrl: `/uploads/${file.filename}`,
            fileName: file.originalname
        });
    });
});

app.get('/api/download/:filename', (req, res) => {
    const filename = req.params.filename;
    const filePath = path.join(__dirname, 'uploads', filename);
    
    // 设置下载响应头
    res.download(filePath, filename, (err) => {
        if (err) {
            res.status(404).send('文件未找到');
        }
    });
});
*/
        </div>
        
        <h3>5. 安全考虑</h3>
        <p>实现文件上传下载功能时,安全性非常重要:</p>
        <ul>
            <li><strong>文件类型验证:</strong>不仅在前端验证,服务器端也必须验证文件类型</li>
            <li><strong>文件大小限制:</strong>防止大文件攻击</li>
            <li><strong>文件名处理:</strong>防止路径遍历攻击,重命名上传的文件</li>
            <li><strong>病毒扫描:</strong>对上传的文件进行病毒扫描</li>
            <li><strong>访问控制:</strong>限制某些文件的下载权限</li>
        </ul>
        
        <h3>6. 性能优化</h3>
        <p>对于大文件上传下载,可以考虑以下优化:</p>
        <ul>
            <li><strong>分片上传:</strong>将大文件分割成小片上传,支持断点续传</li>
            <li><strong>压缩传输:</strong>在传输前压缩文件</li>
            <li><strong>CDN加速:</strong>使用CDN分发下载文件</li>
            <li><strong>并行上传:</strong>同时上传多个文件分片</li>
            <li><strong>进度反馈:</strong>提供详细的上传/下载进度信息</li>
        </ul>
    </div>

    <footer>
        <p>拖拽上传与文件下载功能详解 &copy; 2023 | 本示例仅用于学习目的</p>
        <p>技术实现:HTML5 拖放API + File API + Fetch API</p>
    </footer>

    <script>
        // DOM元素
        const dropArea = document.getElementById('dropArea');
        const fileInput = document.getElementById('fileInput');
        const browseBtn = document.getElementById('browseBtn');
        const fileList = document.getElementById('fileList');
        const downloadButtons = document.querySelectorAll('.download-btn-card');
        
        // 存储上传的文件信息
        const uploadedFiles = new Map();
        
        // 初始化拖拽上传事件
        function initDragAndDrop() {
            // 拖拽事件监听
            dropArea.addEventListener('dragenter', handleDragEnter);
            dropArea.addEventListener('dragover', handleDragOver);
            dropArea.addEventListener('dragleave', handleDragLeave);
            dropArea.addEventListener('drop', handleDrop);
            
            // 点击浏览按钮
            browseBtn.addEventListener('click', () => fileInput.click());
            
            // 文件选择变化
            fileInput.addEventListener('change', (e) => {
                handleFiles(e.target.files);
                fileInput.value = ''; // 重置input
            });
        }
        
        // 处理拖拽进入
        function handleDragEnter(e) {
            e.preventDefault();
            dropArea.classList.add('dragover');
        }
        
        // 处理拖拽悬停
        function handleDragOver(e) {
            e.preventDefault();
            e.dataTransfer.dropEffect = 'copy';
        }
        
        // 处理拖拽离开
        function handleDragLeave(e) {
            if (!dropArea.contains(e.relatedTarget)) {
                dropArea.classList.remove('dragover');
            }
        }
        
        // 处理文件放置
        function handleDrop(e) {
            e.preventDefault();
            dropArea.classList.remove('dragover');
            
            const files = e.dataTransfer.files;
            handleFiles(files);
        }
        
        // 处理选中的文件
        function handleFiles(files) {
            for (let file of files) {
                // 验证文件大小(10MB限制)
                if (file.size > 10 * 1024 * 1024) {
                    alert(`文件 ${file.name} 超过10MB限制`);
                    continue;
                }
                
                // 创建文件项
                createFileItem(file);
                
                // 模拟上传过程
                simulateUpload(file);
            }
        }
        
        // 创建文件列表项
        function createFileItem(file) {
            const fileId = 'file-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
            
            const fileItem = document.createElement('div');
            fileItem.className = 'file-item';
            fileItem.id = fileId;
            
            // 文件图标根据类型选择
            const fileIcon = getFileIcon(file.type);
            
            // 格式化文件大小
            const fileSize = formatFileSize(file.size);
            
            fileItem.innerHTML = `
                <div class="file-info">
                    <div class="file-icon">
                        <i class="${fileIcon}"></i>
                    </div>
                    <div>
                        <div class="file-name">${file.name}</div>
                        <div class="file-size">${fileSize}</div>
                        <div class="progress-bar">
                            <div class="progress-fill" id="progress-${fileId}"></div>
                        </div>
                    </div>
                </div>
                <div class="file-actions">
                    <button class="action-btn download-btn" title="下载" οnclick="downloadFile('${file.name}')">
                        <i class="fas fa-download"></i>
                    </button>
                    <button class="action-btn delete-btn" title="删除" οnclick="deleteFile('${fileId}')">
                        <i class="fas fa-trash"></i>
                    </button>
                </div>
            `;
            
            fileList.appendChild(fileItem);
            
            // 存储文件信息
            uploadedFiles.set(fileId, {
                name: file.name,
                size: file.size,
                type: file.type,
                file: file,
                status: 'uploading'
            });
        }
        
        // 模拟上传进度
        function simulateUpload(file) {
            let progress = 0;
            const fileName = file.name;
            const fileElements = Array.from(fileList.children);
            const fileElement = fileElements.find(el => 
                el.querySelector('.file-name').textContent === fileName
            );
            
            if (!fileElement) return;
            
            const fileId = fileElement.id;
            const progressFill = document.getElementById(`progress-${fileId}`);
            
            const interval = setInterval(() => {
                progress += Math.random() * 10;
                if (progress >= 100) {
                    progress = 100;
                    clearInterval(interval);
                    
                    // 更新文件状态
                    updateFileStatus(fileId, 'uploaded');
                    
                    // 添加上传完成标记
                    const fileInfo = uploadedFiles.get(fileId);
                    if (fileInfo) {
                        fileInfo.status = 'uploaded';
                        uploadedFiles.set(fileId, fileInfo);
                    }
                }
                updateProgress(fileId, progress);
            }, 200);
        }
        
        // 更新上传进度
        function updateProgress(fileId, progress) {
            const progressFill = document.getElementById(`progress-${fileId}`);
            if (progressFill) {
                progressFill.style.width = `${progress}%`;
                
                // 根据进度改变颜色
                if (progress < 30) {
                    progressFill.style.backgroundColor = '#e74c3c';
                } else if (progress < 70) {
                    progressFill.style.backgroundColor = '#f39c12';
                } else {
                    progressFill.style.backgroundColor = '#2ecc71';
                }
            }
        }
        
        // 更新文件状态
        function updateFileStatus(fileId, status) {
            const fileElement = document.getElementById(fileId);
            if (!fileElement) return;
            
            const fileInfoDiv = fileElement.querySelector('.file-info');
            if (status === 'uploaded') {
                // 添加上传完成标记
                const statusSpan = document.createElement('span');
                statusSpan.className = 'file-status';
                statusSpan.innerHTML = '<i class="fas fa-check-circle" style="color:#2ecc71; margin-left:10px;"></i>';
                statusSpan.title = '上传完成';
                fileInfoDiv.appendChild(statusSpan);
                
                // 隐藏进度条
                const progressBar = fileElement.querySelector('.progress-bar');
                if (progressBar) {
                    progressBar.style.display = 'none';
                }
            }
        }
        
        // 删除文件
        function deleteFile(fileId) {
            const fileElement = document.getElementById(fileId);
            if (fileElement) {
                fileElement.style.transform = 'translateX(100%)';
                fileElement.style.opacity = '0';
                
                setTimeout(() => {
                    fileElement.remove();
                    uploadedFiles.delete(fileId);
                }, 300);
            }
        }
        
        // 下载文件
        function downloadFile(filename) {
            // 在实际应用中,这里应该从服务器下载文件
            // 此处模拟创建一个文本文件并下载
            
            const content = `这是 ${filename} 的内容示例。\n\n` +
                          `在实际应用中,这个文件会从服务器获取。\n` +
                          `生成时间:${new Date().toLocaleString()}\n\n` +
                          `拖拽上传与文件下载功能详解示例`;
            
            const blob = new Blob([content], { type: 'text/plain' });
            const url = URL.createObjectURL(blob);
            
            const a = document.createElement('a');
            a.href = url;
            a.download = filename;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            
            // 释放URL对象
            setTimeout(() => URL.revokeObjectURL(url), 100);
            
            // 显示下载提示
            showDownloadToast(filename);
        }
        
        // 显示下载提示
        function showDownloadToast(filename) {
            // 创建一个提示元素
            const toast = document.createElement('div');
            toast.style.cssText = `
                position: fixed;
                bottom: 20px;
                right: 20px;
                background-color: #27ae60;
                color: white;
                padding: 15px 20px;
                border-radius: 8px;
                box-shadow: 0 4px 12px rgba(0,0,0,0.15);
                z-index: 1000;
                display: flex;
                align-items: center;
                gap: 10px;
                transform: translateY(100px);
                opacity: 0;
                transition: transform 0.3s, opacity 0.3s;
            `;
            
            toast.innerHTML = `
                <i class="fas fa-check-circle"></i>
                <span>${filename} 开始下载</span>
            `;
            
            document.body.appendChild(toast);
            
            // 显示提示
            setTimeout(() => {
                toast.style.transform = 'translateY(0)';
                toast.style.opacity = '1';
            }, 10);
            
            // 3秒后隐藏提示
            setTimeout(() => {
                toast.style.transform = 'translateY(100px)';
                toast.style.opacity = '0';
                
                setTimeout(() => {
                    document.body.removeChild(toast);
                }, 300);
            }, 3000);
        }
        
        // 根据文件类型获取图标
        function getFileIcon(fileType) {
            if (fileType.includes('image/')) return 'fas fa-file-image';
            if (fileType.includes('video/')) return 'fas fa-file-video';
            if (fileType.includes('audio/')) return 'fas fa-file-audio';
            if (fileType.includes('pdf')) return 'fas fa-file-pdf';
            if (fileType.includes('zip') || fileType.includes('compressed')) return 'fas fa-file-archive';
            if (fileType.includes('word') || fileType.includes('document')) return 'fas fa-file-word';
            if (fileType.includes('excel') || fileType.includes('spreadsheet')) return 'fas fa-file-excel';
            if (fileType.includes('text/')) return 'fas fa-file-alt';
            return 'fas fa-file';
        }
        
        // 格式化文件大小
        function formatFileSize(bytes) {
            if (bytes === 0) return '0 Bytes';
            
            const k = 1024;
            const sizes = ['Bytes', 'KB', 'MB', 'GB'];
            const i = Math.floor(Math.log(bytes) / Math.log(k));
            
            return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
        }
        
        // 初始化下载按钮
        function initDownloadButtons() {
            downloadButtons.forEach(button => {
                button.addEventListener('click', function() {
                    const filename = this.getAttribute('data-file');
                    downloadFile(filename);
                });
            });
        }
        
        // 初始化
        document.addEventListener('DOMContentLoaded', () => {
            initDragAndDrop();
            initDownloadButtons();
            
            // 添加一些示例文件
            const sampleFiles = [
                { name: '项目文档.pdf', size: 245678, type: 'application/pdf' },
                { name: '风景图片.jpg', size: 1245678, type: 'image/jpeg' },
                { name: '会议记录.docx', size: 567890, type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }
            ];
            
            // 3秒后显示示例文件
            setTimeout(() => {
                sampleFiles.forEach(file => {
                    const mockFile = new File([''], file.name, {
                        type: file.type,
                        lastModified: Date.now()
                    });
                    
                    // 使用Object.defineProperty设置文件大小
                    Object.defineProperty(mockFile, 'size', {
                        value: file.size
                    });
                    
                    createFileItem(mockFile);
                    
                    // 模拟上传完成
                    setTimeout(() => {
                        const fileElements = Array.from(fileList.children);
                        const fileElement = fileElements.find(el => 
                            el.querySelector('.file-name').textContent === file.name
                        );
                        
                        if (fileElement) {
                            updateFileStatus(fileElement.id, 'uploaded');
                        }
                    }, 1000);
                });
            }, 1500);
        });
    </script>
</body>
</html>

2. 详细技术解析

2.1 拖拽上传的核心原理

拖拽上传功能基于HTML5的拖放API和File API实现:

  1. 拖放事件

    • dragenter:当拖拽元素进入放置区域时触发

    • dragover:当拖拽元素在放置区域上方悬停时持续触发

    • dragleave:当拖拽元素离开放置区域时触发

    • drop:当拖拽元素被放置在目标区域时触发

  2. 数据传递

    • 通过e.dataTransfer.files获取拖拽的文件列表

    • 可以访问文件的名称、大小、类型和最后修改时间等属性

  3. 阻止默认行为

    • 必须阻止dragoverdrop事件的默认行为,防止浏览器打开文件

2.2 文件上传的几种方式

  1. 传统表单上传

    • 使用<form><input type="file">

    • 页面会刷新,用户体验差

  2. AJAX上传

    • 使用FormData对象和XMLHttpRequest

    • 可以监听上传进度

  3. Fetch API上传

    • 使用Fetch API和FormData

    • 语法更简洁,但不支持进度监听

  4. 分片上传

    • 将大文件分割成多个小块上传

    • 支持断点续传和并行上传

2.3 文件下载的实现方法

  1. <a>标签下载

    javascript

    <a href="/path/to/file.pdf" download="filename.pdf">下载</a>
    • download属性指定下载的文件名

    • 只能下载同源文件或数据URL

  2. Blob对象下载

    javascript

    const blob = new Blob([data], { type: 'application/pdf' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'file.pdf';
    a.click();
    URL.revokeObjectURL(url); // 释放内存
  3. 服务器端下载

    • 后端设置响应头:Content-Disposition: attachment; filename="file.pdf"

    • 前端直接访问文件URL即可下载

  4. 流式下载

    • 使用Fetch API获取文件流

    • 适用于大文件下载,可以显示下载进度

2.4 实际项目中的考虑

  1. 安全性

    • 验证文件类型(MIME类型和文件扩展名)

    • 限制文件大小

    • 重命名上传的文件

    • 对用户上传的文件进行病毒扫描

  2. 性能优化

    • 压缩图片等大文件

    • 使用CDN加速文件下载

    • 实现文件分片上传和断点续传

  3. 用户体验

    • 提供清晰的上传/下载进度

    • 支持拖拽和粘贴上传

    • 显示文件预览(图片、PDF等)

    • 实现上传队列和批量操作

3. 总结

本文详细介绍了拖拽上传组件和文件下载功能的实现方法,包括:

  1. 拖拽上传:利用HTML5拖放API和File API,实现友好的拖拽上传界面

  2. 文件处理:验证文件类型和大小,显示上传进度,处理上传结果

  3. 文件下载:多种下载方法的实现原理和适用场景

  4. 前后端交互:完整的文件上传下载流程

  5. 安全与性能:实际项目中需要考虑的安全问题和性能优化

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐