JavaScript 拖拉檔案上傳(香草版)
2 |
自從 IE「榮退」(雖然我也曾是「IE 必須死」派,念在當全端攻城獅靠他吃了十幾年飯不能忘本,該有的尊敬還是要給)、Edge 也投靠 Chromium 幫,瀏覽器再次進入大一統時代,寫企業應用前端介面頓時簡單許多,不必再為跨瀏覽器傷透腦筋。
而隨著 HTML5 / CSS / 瀏覽器 API 規格日益完備,過往需要用 jQuery 找第三方程式庫才能完成的一般需求,現在用香草 JavaScript 便能搞定,不知怎麼寫?讓 Github Copilot 手把手教你吧~
總之,Github Copilot 上手後,我新開專案已經很久沒有載入 jQuery 了,幾乎都是 Vue.js + 香䓍 JavaScript KO。依此態勢,jQuery 能不能再戰十年?好問題。延伸閱讀:AI 來襲,jQuery 還能再戰 10 年?
十年前寫過有進度條的 HTML5 檔案上傳,為證明時代進步我也有進度,這回來挑戰香草版的網頁拖拉檔案上傳!
先看結果:
這個功能還蠻常見的,用起來很直覺流暢,現在我們也能自己做,程式還不難,讓人感嘆時代的進步。
整理用到的 API:
- HTML 元素支援 drop 事件,能對拖拉操作做出反應。拖拉檔案到元素放開,在 drop 事件可由
event.dataTransfer.items
取得拖拉項目,由.kind == 'file'
判斷是否為檔案。若是,呼叫.getAsFile()
可取得檔案物件。 - 讀取檔案內容用
new FileReader().readAsArrayBuffer(fileItem)
,在FileReader.onload()
事件可拿到檔案內容 ArrayBuffer (相當於 byte[]),再呼叫我們的 WebAPI 上傳檔案。 - 遇到檔案較多、較大時,上傳需要一些時間,加上復古精簡版動畫進度條顯示進度,具有降低焦慮、提高滿意度的效果。研究發現 fetch 不提供上傳下載進度資訊,要回頭用 XMLHttpRequest (XHR) 靠 onprogress 事件實現。
- 順手也加上列出檔案清單、下載檔案連結及刪除功能,讓整個展示更完整一點。
WebAPI 在本例中僅為配角,我用 ASP.NET Core Minimal API 簡單搭了一下,提供查詢檔案清單、上傳、下載及刪除 API。其中用到上回介紹的 MapGroup() 分群技巧,把 WebAPI Action 集中在 "/api" 路由下。
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var storePath = "D:\\Store";
var apiGroup = app.MapGroup("/api");
apiGroup.MapGet("/", () => {
return Results.Json(Directory.GetFiles(storePath).Select(o => Path.GetFileName(o)));
});
// TODO: Add necessary authorization checks,實務應用時應加上權限檢查並啟用 Antiforgery 驗證
apiGroup.MapPost("/{fileName}", async (string fileName, HttpContext ctx) => {
using var fs = new FileStream(Path.Combine(storePath, fileName), FileMode.Create);
await ctx.Request.Body.CopyToAsync(fs);
return Results.Ok();
}).DisableAntiforgery();
apiGroup.MapGet("/{fileName}", (string fileName) => {
var fileStream = new FileStream(Path.Combine(storePath, fileName), FileMode.Open);
return Results.File(fileStream, "application/octet-stream", fileName, enableRangeProcessing: false);
});
apiGroup.MapDelete("/{fileName}", (string fileName) => {
File.Delete(Path.Combine(storePath, fileName));
return Results.Ok();
});
app.UseFileServer();
app.UseDefaultFiles();
app.Run();
就醬,一個自製拖拉檔案上傳網頁就完成囉~ 附上完整 HTML 及 JavaScript 程式碼。
<!DOCTYPE html>
<html>
<head>
<title>Drop and Upload</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<style>
body,
textarea {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
textarea {
box-sizing: border-box;
display: block;
width: 100%;
height: 60px;
background-color: #eee;
margin: 10px 0;
resize: none;
text-align: center;
line-height: 50px;
font-size: 16pt;
}
span.delete {
color: red;
cursor: pointer;
}
:root {
--pgb-fill-color: rgb(0, 0, 255);
--pgb-text-color: rgb(255, 255, 0);
}
.pgb-bar {
width: 400px;
height: 1.5em;
background-color: white;
border: 1px solid var(--pgb-fill-color);
position: relative;
opacity: 0.75;
margin-bottom: 6px;
font-size: 10pt;
[pgb-bg] {
background-color: var(--pgb-fill-color);
position: absolute;
width: 0%;
height: 100%;
top: 0;
left: 0;
filter: saturate(80%);
}
[pgb-tw] {
color: var(--pgb-text-color);
mix-blend-mode: difference;
text-align: left;
margin-left: 0.25em;
;
line-height: 1.5em;
font-family: 'Courier New', Courier, monospace;
[pgb-p] {
float: right;
margin-right: 0.25em;
margin-top: 0.0.5em;
}
}
}
#app {
width: 400px;
}
</style>
</head>
<body>
<div id="app">
<div class="paste-area">
<textarea readonly placeholder="Drop here" @dragover.prevent @drop="handleDrop"></textarea>
</div>
<div class="dir">
<ul>
<li v-for="file in files">
<a :href="`api/${file}`" target="download">{{ file }}</a> <span class="delete"
@click="deleteFile(file)">X</span>
</li>
</ul>
</div>
<div class="progress"></div>
</div>
<iframe name="download" style="display:none"></iframe>
<script>
class ProgressBar extends HTMLElement {
static get observedAttributes() {
return ['value', 'title', 'width'];
}
constructor() {
super();
}
connectedCallback() {
this.innerHTML = `
<div class="pgb-bar">
<div pgb-bg></div>
<div pgb-tw><span pgb-t></span><span pgb-p></span></div>
</div>
`;
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'value') {
let percentage = parseInt(newValue);
if (isNaN(percentage) || percentage < 0) percentage = 0; else if (percentage > 100) percentage = 100;
this.querySelector('[pgb-bg]').style.width = `${percentage}%`;
this.querySelector('[pgb-p]').textContent = `${percentage}%`;
}
else if (name === 'title') {
let title = newValue || 'title attr not defined';
if (title.length > 24) title = title.substr(0, 24) + '...';
this.querySelector('[pgb-t]').textContent = title;
}
else if (name === 'width') {
const width = parseInt(newValue) || 400;
this.querySelector('.pgb-bar').style.width = `${newValue}px`;
}
}
}
customElements.define('progress-bar', ProgressBar);
</script>
<script>
const app = Vue.createApp({
data() {
return {
files: []
};
},
methods: {
handleDrop(event) {
event.preventDefault();
if (event.dataTransfer.items) {
for (var i = 0; i < event.dataTransfer.items.length; i++) {
if (event.dataTransfer.items[i].kind === 'file') {
var file = event.dataTransfer.items[i].getAsFile();
this.push(file);
}
}
} // IE use event.dataTransfer.files, ignored here
},
push(file) {
const reader = new FileReader();
const self = this;
reader.onload = async (event) => {
const fileContent = event.target.result;
// use xhr instead of fetch to send fileContent and attach onprogress event
const xhr = new XMLHttpRequest();
xhr.open('POST', `api/${file.name}`, true);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
const progressBar = document.createElement('progress-bar');
document.querySelector('.progress').appendChild(progressBar);
progressBar.setAttribute('title', file.name);
progressBar.setAttribute('width', '400');
xhr.upload.onprogress = (event) => {
if (event.lengthComputable)
progressBar.setAttribute('value', event.loaded * 100 / event.total);
};
xhr.onload = () => {
progressBar.setAttribute('value', 100);
setTimeout(() => {
progressBar.remove();
if (!document.querySelectorAll('progress-bar').length)
self.dir();
}, 500);
};
xhr.send(fileContent);
};
reader.readAsArrayBuffer(file);
},
async deleteFile(file) {
await fetch(`api/${file}`, {
method: 'DELETE'
});
this.dir();
},
dir() {
const self = this;
fetch(`api/`)
.then(response => response.json())
.then(data => {
self.files = data;
});
}
},
mounted() {
this.dir();
}
});
const vm = app.mount('#app');
</script>
</body>
</html>
The retirement of IE and Edge’s shift to Chromium have simplified cross-browser development. HTML5/CSS advancements make jQuery often unnecessary. A project demonstrates modern file upload using vanilla JavaScript, highlighting progress over the past decade.
Comments
# by 雅竹題
第一次把 黑大 的程式跑起來,剛剛跑起來突然被 "ASP.NET Core Minimal API" 電到,看那麼久現在才有感覺XD
# by Jeffrey
to 雅竹題,是吧是吧,追求極簡風的我對 ASP.NET Core Minimal API 更是愛不釋手。