自從 IE「榮退」(雖然我也曾是「IE 必須死」派,念在當全端攻城獅靠他吃了十幾年飯不能忘本,該有的尊敬還是要給)、Edge 也投靠 Chromium 幫,瀏覽器再次進入大一統時代,寫企業應用前端介面頓時簡單許多,不必再為跨瀏覽器傷透腦筋。

而隨著 HTML5 / CSS / 瀏覽器 API 規格日益完備,過往需要用 jQuery 找第三方程式庫才能完成的一般需求,現在用香草 JavaScript 便能搞定,不知怎麼寫?讓 Github Copilot 手把手教你吧~

總之,Github Copilot 上手後,我新開專案已經很久沒有載入 jQuery 了,幾乎都是 Vue.js + 香䓍 JavaScript KO。依此態勢,jQuery 能不能再戰十年?好問題。延伸閱讀:AI 來襲,jQuery 還能再戰 10 年?

十年前寫過有進度條的 HTML5 檔案上傳,為證明時代進步我也有進度,這回來挑戰香草版的網頁拖拉檔案上傳!

先看結果:

這個功能還蠻常見的,用起來很直覺流暢,現在我們也能自己做,程式還不難,讓人感嘆時代的進步。

整理用到的 API:

  1. HTML 元素支援 drop 事件,能對拖拉操作做出反應。拖拉檔案到元素放開,在 drop 事件可由 event.dataTransfer.items 取得拖拉項目,由 .kind == 'file' 判斷是否為檔案。若是,呼叫 .getAsFile() 可取得檔案物件。
  2. 讀取檔案內容用 new FileReader().readAsArrayBuffer(fileItem),在 FileReader.onload() 事件可拿到檔案內容 ArrayBuffer (相當於 byte[]),再呼叫我們的 WebAPI 上傳檔案。
  3. 遇到檔案較多、較大時,上傳需要一些時間,加上復古精簡版動畫進度條顯示進度,具有降低焦慮、提高滿意度的效果。研究發現 fetch 不提供上傳下載進度資訊,要回頭用 XMLHttpRequest (XHR) 靠 onprogress 事件實現。
  4. 順手也加上列出檔案清單、下載檔案連結及刪除功能,讓整個展示更完整一點。

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 更是愛不釋手。

Post a comment