前言
你將在該篇學到:
- 如何將現有元件改寫為
React Hooks
函式元件 useState
、useEffect
、useRef
是如何替代原生命週期和Ref
的。- 一個完整拖拽上傳行為覆蓋的四個事件:
dragover
、dragenter
、drop
、dragleave
- 如何使用
React Hooks
編寫自己的UI元件庫。
逛國外社群時看到這篇:
文章講了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'
>
😎
</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'
>
😎
</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']}
>
😎
</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' ? <>😢</> : <>😘</>}
</span>
</div>
)}
{dragging && (
<div
ref={drag}
className={classList['FilesDragAndDrop__placeholder']}
>
請放手
<span
role='img'
aria-label='emoji'
className={classList['area__icon']}
>
😝
</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']}
>
😎
</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;
}
}
複製程式碼
然後你就可以拿到檔案慢慢耍了。。。
❤️ 看完三件事
如果你覺得這篇內容對你挺有啟發,我想邀請你幫我三個小忙:
- 點贊,讓更多的人也能看到這篇內容(收藏不點贊,都是耍流氓 -_-)
- 關注公眾號「前端勸退師」,不定期分享原創知識。
- 也看看其它文章