120行程式碼實現一個互動完整的拖拽上傳元件

前端勸退師發表於2019-08-29

前言

你將在該篇學到:

  • 如何將現有元件改寫為 React Hooks函式元件
  • useStateuseEffectuseRef是如何替代原生命週期和Ref的。
  • 一個完整拖拽上傳行為覆蓋的四個事件:dragoverdragenterdropdragleave
  • 如何使用React Hooks編寫自己的UI元件庫。

逛國外社群時看到這篇:

120行程式碼實現一個互動完整的拖拽上傳元件

How To Implement Drag and Drop for Files in React

文章講了React拖拽上傳的精簡實現,但直接翻譯照搬顯然不是我的風格。

於是我又用React Hooks 重寫了一版,除CSS的程式碼總數 120行。 效果如下:

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元件?

請看動圖:

120行程式碼實現一個互動完整的拖拽上傳元件

120行程式碼實現一個互動完整的拖拽上傳元件

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;
    }
  }
}
複製程式碼

然後就可以看到頁面:

120行程式碼實現一個互動完整的拖拽上傳元件

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. 事件觸發

120行程式碼實現一個互動完整的拖拽上傳元件
完成具有動態互動的拖拽行為並不簡單,需要用到四個事件控制:

  • 區域外:dragleave, 離開範圍
  • 區域內:dragenter,用來確定放置目標是否接受放置。
  • 區域內移動:dragover,用來確定給使用者顯示怎樣的反饋資訊
  • 完成拖拽(落下):drop,允許放置物件。

這四個事件並存,才能阻止 Web 瀏覽器預設行為和形成反饋。

3. 阻止預設行為

程式碼很簡單:

e.preventDefault() //阻止事件的預設行為(如在瀏覽器開啟檔案)
e.stopPropagation() // 阻止事件冒泡
複製程式碼

每個事件階段都需要阻止,為啥呢?舉個?栗子:

const handleDragOver = (e) => {
    // e.preventDefault();
    // e.stopPropagation();
};
複製程式碼

120行程式碼實現一個互動完整的拖拽上傳元件

不阻止的話,就會觸發開啟檔案的行為,這顯然不是我們想看到的。

120行程式碼實現一個互動完整的拖拽上傳元件

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);
    }
})
複製程式碼

120行程式碼實現一個互動完整的拖拽上傳元件
這也太香了吧!!!

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;
  }
}
複製程式碼

然後你就可以拿到檔案慢慢耍了。。。

120行程式碼實現一個互動完整的拖拽上傳元件

120行程式碼實現一個互動完整的拖拽上傳元件

❤️ 看完三件事

如果你覺得這篇內容對你挺有啟發,我想邀請你幫我三個小忙:

  1. 點贊,讓更多的人也能看到這篇內容(收藏不點贊,都是耍流氓 -_-)
  2. 關注公眾號「前端勸退師」,不定期分享原創知識。
  3. 也看看其它文章

120行程式碼實現一個互動完整的拖拽上傳元件

相關文章