T-CREATOR

JavaScript Drag & Drop API 完全攻略:ファイルアップロード UI を最速で作る

JavaScript Drag & Drop API 完全攻略:ファイルアップロード UI を最速で作る

Web アプリケーションでファイルをアップロードする際、従来の「ファイル選択」ボタンだけでは物足りないと感じたことはありませんか。現代の Web では、ドラッグ&ドロップでファイルをアップロードできる UI が当たり前になってきました。

この記事では、JavaScript の Drag & Drop API を使って、実用的なファイルアップロード UI を最速で実装する方法をご紹介します。基本的な仕組みから、エラーハンドリング、プレビュー機能まで、実務で使える知識を段階的に解説していきますね。

背景

Web におけるファイルアップロードの進化

Web におけるファイルアップロードは、以下のような進化を遂げてきました。

#時代アップロード方法ユーザビリティ
12000 年代前半<input type="file"> のみ★☆☆☆☆
22000 年代後半Flash/Java アプレット★★☆☆☆
32010 年代前半HTML5 File API★★★☆☆
42010 年代後半〜現在Drag & Drop API + File API★★★★★

初期の Web ではファイル選択ボタンをクリックして、OS のファイル選択ダイアログから選ぶしか方法がありませんでした。しかし HTML5 の登場により、ブラウザ上でのドラッグ&ドロップが標準機能として実装され、ユーザー体験が飛躍的に向上したのです。

Drag & Drop API の基本概念

Drag & Drop API は、以下の要素で構成されています。

下図は、ドラッグ&ドロップの基本フローを示したものです。

mermaidflowchart TB
  user["ユーザー"] -->|ファイルをドラッグ| dragenter["dragenter<br/>イベント発火"]
  dragenter -->|ドロップ領域に入る| dragover["dragover<br/>イベント発火"]
  dragover -->|領域内を移動中| dragover
  dragover -->|領域から出る| dragleave["dragleave<br/>イベント発火"]
  dragover -->|ファイルを放す| drop["drop<br/>イベント発火"]
  drop -->|ファイル情報取得| fileapi["File API<br/>でファイル処理"]

図で理解できる要点:

  • ドラッグ&ドロップは 4 つの主要イベントで構成される
  • 各イベントが連鎖的に発火し、最終的に drop でファイルを取得
  • File API と組み合わせることで実用的な処理が可能になる

各イベントの役割は以下の通りです。

#イベント発火タイミング主な用途
1dragenterドロップ領域に入った時視覚的フィードバックの開始
2dragoverドロップ領域内を移動中デフォルト動作の無効化
3dragleaveドロップ領域から出た時視覚的フィードバックの解除
4dropファイルを放した時ファイル情報の取得・処理

この 4 つのイベントを正しく扱うことが、Drag & Drop API を使いこなす第一歩となります。

課題

従来のファイルアップロードにおける問題点

従来の <input type="file"> だけを使ったアップロード UI には、いくつかの課題がありました。

以下の図は、従来方式とドラッグ&ドロップ方式の操作フローを比較したものです。

mermaidflowchart LR
  subgraph old ["従来方式(クリック選択)"]
    direction TB
    step1["ボタンをクリック"] --> step2["ダイアログが開く"]
    step2 --> step3["フォルダを探す"]
    step3 --> step4["ファイルを選択"]
    step4 --> step5["OK をクリック"]
  end

  subgraph new ["ドラッグ&ドロップ方式"]
    direction TB
    dstep1["ファイルをドラッグ"] --> dstep2["ドロップ領域に放す"]
    dstep2 --> dstep3["即座にアップロード"]
  end

  old -.->|操作ステップ: 5段階| result1["時間がかかる"]
  new -.->|操作ステップ: 2段階| result2["直感的で高速"]

図で理解できる要点:

  • 従来方式は 5 段階の操作が必要で煩雑
  • ドラッグ&ドロップは 2 段階で完了し、ユーザビリティが大幅に向上
  • 操作の簡素化により、ユーザーの離脱率も低下

実装時の技術的課題

Drag & Drop API を実装する際には、以下のような技術的課題があります。

#課題影響対策の必要性
1デフォルト動作の防止ブラウザがファイルを開いてしまう★★★★★
2視覚的フィードバックユーザーが操作を理解できない★★★★☆
3ファイル検証不正なファイルがアップロードされる★★★★★
4エラーハンドリングユーザーに適切なエラーが伝わらない★★★★☆
5クロスブラウザ対応古いブラウザで動作しない★★★☆☆

特に、デフォルト動作の防止を忘れると、ファイルをドロップした際にブラウザがそのファイルを開いてしまうため、必ず preventDefault() を呼び出す必要があります。

解決策

基本的な実装アプローチ

Drag & Drop API を使ったファイルアップロード UI を実装するには、以下のステップで進めていきます。

下図は、実装の全体フローを示したものです。

mermaidflowchart TD
  start["実装開始"] --> html["HTML構造の構築"]
  html --> css["CSS でスタイリング"]
  css --> events["イベントリスナーの設定"]
  events --> prevent["デフォルト動作の防止"]
  prevent --> visual["視覚的フィードバック"]
  visual --> filepro["ファイル処理ロジック"]
  filepro --> valid["ファイル検証"]
  valid --> error["エラーハンドリング"]
  error --> preview["プレビュー機能(任意)"]
  preview --> done["実装完了"]

図で理解できる要点:

  • 実装は HTML/CSS → イベント処理 → ファイル処理という流れで進める
  • 各ステップが依存関係にあり、順序を守ることが重要
  • 視覚的フィードバックとエラーハンドリングがユーザー体験を左右する

それでは、具体的な実装方法を見ていきましょう。

HTML 構造の構築

まず、ドロップ領域となる HTML 要素を作成します。

html<!-- ドロップ領域のコンテナ -->
<div id="dropArea" class="drop-area">
  <p>ここにファイルをドラッグ&ドロップ</p>
  <p>または</p>
  <input type="file" id="fileInput" multiple hidden />
  <button id="selectButton">ファイルを選択</button>
</div>

このコードでは、ドロップ領域と従来のファイル選択ボタンの両方を用意しています。hidden 属性により input 要素は非表示にし、カスタムボタンから呼び出す形にすることで、デザインの自由度を高めることができますね。

次に、アップロードされたファイルのプレビューを表示する領域を追加します。

html<!-- ファイルプレビュー領域 -->
<div id="fileList" class="file-list">
  <!-- ここに選択されたファイルの情報が表示される -->
</div>

CSS によるスタイリング

ドロップ領域に視覚的なフィードバックを提供するためのスタイルを定義します。

css/* ドロップ領域の基本スタイル */
.drop-area {
  width: 100%;
  max-width: 600px;
  padding: 40px;
  border: 3px dashed #ccc;
  border-radius: 8px;
  text-align: center;
  background-color: #f9f9f9;
  transition: all 0.3s ease;
  cursor: pointer;
}

このスタイルにより、ドロップ領域が明確に視覚化されます。点線の枠線と背景色により、ユーザーはここがドロップ可能な領域であることを直感的に理解できるでしょう。

ドラッグ中の状態を示すスタイルを追加します。

css/* ドラッグ中のハイライト表示 */
.drop-area.drag-over {
  border-color: #4caf50;
  background-color: #e8f5e9;
  transform: scale(1.02);
}

/* ファイル選択ボタンのスタイル */
#selectButton {
  padding: 12px 24px;
  font-size: 16px;
  background-color: #2196f3;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  margin-top: 16px;
}

#selectButton:hover {
  background-color: #1976d2;
}

.drag-over クラスを使うことで、ファイルがドロップ領域の上にある時に色が変わり、ユーザーに「今ここで放せばアップロードできる」というフィードバックを提供できます。

ファイルリストの表示スタイルも定義しましょう。

css/* ファイルリストのスタイル */
.file-list {
  margin-top: 24px;
  max-width: 600px;
}

.file-item {
  display: flex;
  align-items: center;
  padding: 12px;
  margin-bottom: 8px;
  background-color: #fff;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.file-item img {
  width: 50px;
  height: 50px;
  object-fit: cover;
  margin-right: 12px;
  border-radius: 4px;
}

イベントリスナーの設定

JavaScript で各イベントリスナーを設定していきます。まず、基本的な DOM 要素の取得から始めましょう。

javascript// DOM 要素の取得
const dropArea = document.getElementById('dropArea');
const fileInput = document.getElementById('fileInput');
const selectButton =
  document.getElementById('selectButton');
const fileList = document.getElementById('fileList');

次に、ファイル選択ボタンのクリックイベントを設定します。

javascript// ファイル選択ボタンのイベント
// クリックすると非表示の input 要素をトリガーする
selectButton.addEventListener('click', () => {
  fileInput.click();
});

// input 要素でファイルが選択された時の処理
fileInput.addEventListener('change', (e) => {
  const files = e.target.files;
  handleFiles(files);
});

このコードにより、カスタムボタンをクリックすると、隠された <input type="file"> が開かれます。これで、ドラッグ&ドロップと従来のファイル選択の両方に対応できるようになりますね。

デフォルト動作の防止

Drag & Drop API で最も重要なのが、ブラウザのデフォルト動作を防止することです。

javascript// dragenter イベント: ドロップ領域に入った時
// デフォルト動作を防止し、視覚的フィードバックを追加
dropArea.addEventListener('dragenter', (e) => {
  e.preventDefault();
  e.stopPropagation();
  dropArea.classList.add('drag-over');
});

preventDefault() を呼び出すことで、ブラウザがファイルを開いてしまうのを防ぎます。また、stopPropagation() でイベントの伝播を止めることで、親要素への影響を防げるのです。

dragover イベントも同様に処理します。

javascript// dragover イベント: ドロップ領域内を移動中
// このイベントは連続的に発火するため、必ず preventDefault() が必要
dropArea.addEventListener('dragover', (e) => {
  e.preventDefault();
  e.stopPropagation();
});

dragover は、ドラッグ中に連続的に発火します。ここで preventDefault() を忘れると、drop イベントが発火しなくなってしまうため、必ず設定しましょう。

ドロップ領域から出た時の処理も追加します。

javascript// dragleave イベント: ドロップ領域から出た時
// 視覚的フィードバックを解除
dropArea.addEventListener('dragleave', (e) => {
  e.preventDefault();
  e.stopPropagation();
  dropArea.classList.remove('drag-over');
});

ファイルのドロップ処理

最も重要な drop イベントの処理を実装します。

javascript// drop イベント: ファイルがドロップされた時
// ここでファイル情報を取得し、処理を開始
dropArea.addEventListener('drop', (e) => {
  e.preventDefault();
  e.stopPropagation();

  // 視覚的フィードバックを解除
  dropArea.classList.remove('drag-over');

  // ドロップされたファイルを取得
  const files = e.dataTransfer.files;

  // ファイル処理関数を呼び出す
  handleFiles(files);
});

e.dataTransfer.files からドロップされたファイルの情報を取得できます。この filesFileList オブジェクトで、配列のように扱うことができますね。

ファイル処理ロジックの実装

取得したファイルを処理する関数を実装しましょう。

javascript/**
 * ファイル処理のメイン関数
 * @param {FileList} files - 処理するファイルのリスト
 */
function handleFiles(files) {
  // FileList を配列に変換して処理
  const fileArray = Array.from(files);

  // 各ファイルに対して処理を実行
  fileArray.forEach((file) => {
    // ファイルの検証
    if (validateFile(file)) {
      // ファイルをプレビュー表示
      displayFile(file);
      // 実際のアップロード処理(任意)
      uploadFile(file);
    }
  });
}

Array.from() を使って FileList を配列に変換することで、forEach などの配列メソッドが使えるようになります。これにより、複数ファイルの処理が簡潔に記述できるのです。

ファイル検証の実装

ファイルのタイプやサイズを検証する関数を作成します。

javascript/**
 * ファイルの検証を行う
 * @param {File} file - 検証するファイル
 * @returns {boolean} 検証結果(true: 有効, false: 無効)
 */
function validateFile(file) {
  // 許可するファイルタイプ(MIME タイプ)
  const allowedTypes = [
    'image/jpeg',
    'image/png',
    'image/gif',
    'image/webp',
  ];

  // 最大ファイルサイズ(5MB)
  const maxSize = 5 * 1024 * 1024;

  // ファイルタイプの検証
  if (!allowedTypes.includes(file.type)) {
    showError(
      `${file.name} は許可されていないファイル形式です。`
    );
    return false;
  }

  // ファイルサイズの検証
  if (file.size > maxSize) {
    showError(`${file.name} は 5MB を超えています。`);
    return false;
  }

  return true;
}

MIME タイプによる検証は、セキュリティ上重要です。ただし、クライアント側の検証だけでは不十分なため、サーバー側でも必ず検証を行いましょう。

エラーメッセージを表示する関数も実装します。

javascript/**
 * エラーメッセージを表示する
 * @param {string} message - 表示するエラーメッセージ
 */
function showError(message) {
  // エラーメッセージ要素を作成
  const errorDiv = document.createElement('div');
  errorDiv.className = 'error-message';
  errorDiv.textContent = message;
  errorDiv.style.cssText = `
    padding: 12px;
    margin: 8px 0;
    background-color: #ffebee;
    color: #c62828;
    border-radius: 4px;
    border-left: 4px solid #c62828;
  `;

  // ドロップ領域の後に挿入
  dropArea.parentNode.insertBefore(
    errorDiv,
    dropArea.nextSibling
  );

  // 3秒後に自動的に削除
  setTimeout(() => {
    errorDiv.remove();
  }, 3000);
}

ファイルプレビューの実装

画像ファイルの場合、サムネイルをプレビュー表示すると便利です。

javascript/**
 * ファイルをプレビュー表示する
 * @param {File} file - 表示するファイル
 */
function displayFile(file) {
  // ファイルアイテムのコンテナを作成
  const fileItem = document.createElement('div');
  fileItem.className = 'file-item';

  // 画像ファイルの場合、サムネイルを表示
  if (file.type.startsWith('image/')) {
    const img = document.createElement('img');

    // FileReader を使って画像データを読み込む
    const reader = new FileReader();
    reader.onload = (e) => {
      img.src = e.target.result;
    };
    reader.readAsDataURL(file);

    fileItem.appendChild(img);
  }

  // ファイル情報を表示する要素を作成
  const fileInfo = createFileInfo(file);
  fileItem.appendChild(fileInfo);

  // ファイルリストに追加
  fileList.appendChild(fileItem);
}

FileReader API を使うことで、ファイルをブラウザ上で読み込み、プレビュー表示ができます。readAsDataURL() メソッドは、ファイルを Base64 エンコードされた Data URL に変換してくれるのです。

ファイル情報を表示する要素を作成する関数も実装しましょう。

javascript/**
 * ファイル情報を表示する要素を作成
 * @param {File} file - ファイルオブジェクト
 * @returns {HTMLElement} ファイル情報要素
 */
function createFileInfo(file) {
  const infoDiv = document.createElement('div');
  infoDiv.className = 'file-info';

  // ファイル名
  const fileName = document.createElement('div');
  fileName.className = 'file-name';
  fileName.textContent = file.name;
  fileName.style.fontWeight = 'bold';

  // ファイルサイズを人間が読みやすい形式に変換
  const fileSize = document.createElement('div');
  fileSize.className = 'file-size';
  fileSize.textContent = formatFileSize(file.size);
  fileSize.style.color = '#666';
  fileSize.style.fontSize = '14px';

  infoDiv.appendChild(fileName);
  infoDiv.appendChild(fileSize);

  return infoDiv;
}

ファイルサイズをわかりやすく表示するヘルパー関数を作成します。

javascript/**
 * ファイルサイズを読みやすい形式に変換
 * @param {number} bytes - バイト数
 * @returns {string} フォーマットされたサイズ(例: "1.5 MB")
 */
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 (
    Math.round((bytes / Math.pow(k, i)) * 100) / 100 +
    ' ' +
    sizes[i]
  );
}

このヘルパー関数により、「5242880 bytes」のような数値が「5 MB」のように読みやすく表示されます。ユーザー体験の向上につながる重要な工夫ですね。

ファイルアップロード処理

最後に、実際にサーバーへファイルをアップロードする処理を実装します。

javascript/**
 * ファイルをサーバーにアップロード
 * @param {File} file - アップロードするファイル
 */
async function uploadFile(file) {
  // FormData オブジェクトを作成
  const formData = new FormData();
  formData.append('file', file);

  try {
    // fetch API でファイルを送信
    const response = await fetch('/api/upload', {
      method: 'POST',
      body: formData,
    });

    // レスポンスの処理
    if (!response.ok) {
      throw new Error(
        `HTTP error! status: ${response.status}`
      );
    }

    const result = await response.json();
    console.log('アップロード成功:', result);
  } catch (error) {
    console.error('アップロードエラー:', error);
    showError(
      `${file.name} のアップロードに失敗しました。`
    );
  }
}

FormData を使うことで、ファイルを簡単にサーバーへ送信できます。fetch API と組み合わせることで、モダンで読みやすいコードになりますね。

アップロード進捗を表示する拡張版も見てみましょう。

javascript/**
 * 進捗表示付きファイルアップロード
 * @param {File} file - アップロードするファイル
 * @param {HTMLElement} progressElement - 進捗バー要素
 */
function uploadFileWithProgress(file, progressElement) {
  const formData = new FormData();
  formData.append('file', file);

  // XMLHttpRequest を使用(fetch は進捗イベントに対応していないため)
  const xhr = new XMLHttpRequest();

  // 進捗イベントのリスナー
  xhr.upload.addEventListener('progress', (e) => {
    if (e.lengthComputable) {
      // 進捗率を計算(0-100)
      const percentComplete = (e.loaded / e.total) * 100;

      // 進捗バーを更新
      progressElement.style.width = percentComplete + '%';
      progressElement.textContent =
        Math.round(percentComplete) + '%';
    }
  });

  // 完了時の処理
  xhr.addEventListener('load', () => {
    if (xhr.status === 200) {
      console.log('アップロード完了:', xhr.responseText);
      progressElement.style.backgroundColor = '#4CAF50';
    } else {
      showError(`アップロードエラー: ${xhr.status}`);
      progressElement.style.backgroundColor = '#f44336';
    }
  });

  // エラー時の処理
  xhr.addEventListener('error', () => {
    showError('ネットワークエラーが発生しました。');
    progressElement.style.backgroundColor = '#f44336';
  });

  // リクエストを送信
  xhr.open('POST', '/api/upload');
  xhr.send(formData);
}

進捗表示を実装する場合は、fetch ではなく XMLHttpRequest を使う必要があります。これにより、大きなファイルのアップロード時にユーザーが進捗を確認できるようになるのです。

具体例

実践的なファイルアップロード UI の実装

ここまで学んだ内容を統合して、実務で使える完全なファイルアップロード UI を構築していきます。

下図は、完成形の UI とデータフローを示したものです。

mermaidflowchart TD
  user["ユーザー"] -->|ドラッグ&ドロップ| drop["Drop イベント"]
  user -->|クリック選択| click["Click イベント"]

  drop --> validate["ファイル検証"]
  click --> validate

  validate -->|OK| preview["プレビュー表示"]
  validate -->|NG| error["エラー表示"]

  preview --> upload["サーバーへ<br/>アップロード"]
  upload --> progress["進捗表示"]
  progress --> success["完了通知"]

  error --> retry["再試行を促す"]

図で理解できる要点:

  • ドラッグ&ドロップとクリック選択の両方に対応
  • ファイル検証を必ず通過させることでセキュリティを確保
  • プレビュー → アップロード → 進捗表示という流れでユーザー体験を向上

完全な HTML 構造

実用的な UI の HTML 構造を作成します。

html<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />
    <title>ファイルアップロード - Drag & Drop</title>
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <div class="container">
      <h1>ファイルアップロード</h1>

      <!-- ドロップ領域 -->
      <div id="dropArea" class="drop-area">
        <svg
          class="upload-icon"
          width="64"
          height="64"
          viewBox="0 0 24 24"
        >
          <path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z" />
        </svg>
        <p class="drop-text">
          ファイルをここにドラッグ&ドロップ
        </p>
        <p class="drop-subtext">または</p>
        <input
          type="file"
          id="fileInput"
          multiple
          accept="image/*"
          hidden
        />
        <button id="selectButton" class="select-button">
          ファイルを選択
        </button>
        <p class="file-hint">
          画像ファイル(JPEG, PNG, GIF, WebP)/ 最大 5MB
        </p>
      </div>

      <!-- ファイルプレビューエリア -->
      <div id="fileList" class="file-list"></div>
    </div>

    <script src="app.js"></script>
  </body>
</html>

この HTML では、アイコンや説明文を含めた、よりわかりやすい UI を構築しています。accept 属性により、ファイル選択ダイアログで画像ファイルのみがフィルタリングされるのです。

完全な CSS スタイル

プロフェッショナルな見た目を実現する CSS を実装します。

css/* 基本スタイル */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont,
    'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
  background-color: #f5f5f5;
  padding: 20px;
}

.container {
  max-width: 800px;
  margin: 0 auto;
}

h1 {
  color: #333;
  margin-bottom: 24px;
  text-align: center;
}

次に、ドロップ領域のスタイルを定義します。

css/* ドロップ領域 */
.drop-area {
  background-color: #fff;
  border: 3px dashed #d0d0d0;
  border-radius: 12px;
  padding: 48px 24px;
  text-align: center;
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  cursor: pointer;
}

.drop-area:hover {
  border-color: #2196f3;
  background-color: #f8f9fa;
}

.drop-area.drag-over {
  border-color: #4caf50;
  background-color: #e8f5e9;
  transform: scale(1.02);
  box-shadow: 0 8px 16px rgba(76, 175, 80, 0.2);
}

.upload-icon {
  fill: #999;
  margin-bottom: 16px;
  transition: fill 0.3s ease;
}

.drop-area:hover .upload-icon,
.drop-area.drag-over .upload-icon {
  fill: #4caf50;
}

ホバーやドラッグ時のアニメーションを追加することで、ユーザーにリアルタイムなフィードバックを提供できます。cubic-bezier を使った滑らかなトランジションが心地よい操作感を生み出しますね。

テキストとボタンのスタイルも設定しましょう。

css/* テキストスタイル */
.drop-text {
  font-size: 18px;
  color: #333;
  margin-bottom: 8px;
  font-weight: 500;
}

.drop-subtext {
  color: #999;
  margin: 16px 0;
  font-size: 14px;
}

.file-hint {
  color: #666;
  font-size: 12px;
  margin-top: 16px;
}

/* ボタンスタイル */
.select-button {
  background-color: #2196f3;
  color: white;
  border: none;
  padding: 12px 32px;
  font-size: 16px;
  border-radius: 6px;
  cursor: pointer;
  transition: background-color 0.3s ease, transform 0.1s
      ease;
  font-weight: 500;
}

.select-button:hover {
  background-color: #1976d2;
  transform: translateY(-1px);
}

.select-button:active {
  transform: translateY(0);
}

ファイルリストの表示スタイルを実装します。

css/* ファイルリスト */
.file-list {
  margin-top: 32px;
}

.file-item {
  display: flex;
  align-items: center;
  background-color: #fff;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 16px;
  margin-bottom: 12px;
  transition: box-shadow 0.3s ease;
}

.file-item:hover {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.file-item img {
  width: 60px;
  height: 60px;
  object-fit: cover;
  border-radius: 6px;
  margin-right: 16px;
  border: 1px solid #e0e0e0;
}

.file-info {
  flex: 1;
}

.file-name {
  font-weight: 500;
  color: #333;
  margin-bottom: 4px;
}

.file-size {
  font-size: 14px;
  color: #666;
}

進捗バーのスタイルも追加しましょう。

css/* 進捗バー */
.progress-container {
  width: 100%;
  height: 6px;
  background-color: #e0e0e0;
  border-radius: 3px;
  margin-top: 8px;
  overflow: hidden;
}

.progress-bar {
  height: 100%;
  background-color: #2196f3;
  border-radius: 3px;
  transition: width 0.3s ease;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 11px;
  color: white;
  font-weight: 500;
}

/* エラーメッセージ */
.error-message {
  background-color: #ffebee;
  color: #c62828;
  padding: 12px 16px;
  border-radius: 6px;
  border-left: 4px solid #c62828;
  margin: 16px 0;
  animation: slideIn 0.3s ease;
}

@keyframes slideIn {
  from {
    opacity: 0;
    transform: translateY(-10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

完全な JavaScript 実装

すべての機能を統合した JavaScript コードを実装します。まず、初期化処理から始めましょう。

javascript// DOM 要素の取得と初期化
document.addEventListener('DOMContentLoaded', () => {
  const dropArea = document.getElementById('dropArea');
  const fileInput = document.getElementById('fileInput');
  const selectButton =
    document.getElementById('selectButton');
  const fileList = document.getElementById('fileList');

  // イベントリスナーの設定
  initializeEventListeners();
});

イベントリスナーを一括で設定する関数を作成します。

javascript/**
 * すべてのイベントリスナーを初期化
 */
function initializeEventListeners() {
  // ファイル選択ボタン
  selectButton.addEventListener('click', () => {
    fileInput.click();
  });

  // input 要素でのファイル選択
  fileInput.addEventListener('change', (e) => {
    handleFiles(e.target.files);
  });

  // ドラッグ&ドロップイベント
  ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(
    (eventName) => {
      dropArea.addEventListener(
        eventName,
        preventDefaults,
        false
      );
    }
  );

  // ハイライト表示の制御
  ['dragenter', 'dragover'].forEach((eventName) => {
    dropArea.addEventListener(eventName, highlight, false);
  });

  ['dragleave', 'drop'].forEach((eventName) => {
    dropArea.addEventListener(
      eventName,
      unhighlight,
      false
    );
  });

  // ファイルドロップ時の処理
  dropArea.addEventListener('drop', handleDrop, false);
}

配列の forEach を使うことで、複数のイベントに同じ処理を適用できます。コードの重複を避け、メンテナンス性が向上するのです。

デフォルト動作を防止する関数を実装します。

javascript/**
 * ブラウザのデフォルト動作を防止
 * @param {Event} e - イベントオブジェクト
 */
function preventDefaults(e) {
  e.preventDefault();
  e.stopPropagation();
}

/**
 * ドロップ領域をハイライト
 */
function highlight() {
  dropArea.classList.add('drag-over');
}

/**
 * ドロップ領域のハイライトを解除
 */
function unhighlight() {
  dropArea.classList.remove('drag-over');
}

ドロップイベントのハンドラを実装します。

javascript/**
 * ファイルドロップ時の処理
 * @param {DragEvent} e - ドラッグイベント
 */
function handleDrop(e) {
  // DataTransfer からファイルを取得
  const dt = e.dataTransfer;
  const files = dt.files;

  handleFiles(files);
}

ファイル処理のメインロジックを実装します。

javascript/**
 * ファイル処理のメイン関数
 * @param {FileList} files - 処理するファイルリスト
 */
function handleFiles(files) {
  // FileList を配列に変換
  const fileArray = [...files];

  // 各ファイルを処理
  fileArray.forEach((file) => {
    // ファイル検証
    if (validateFile(file)) {
      // UI に表示
      displayFilePreview(file);
      // サーバーにアップロード
      uploadFileWithProgress(file);
    }
  });
}

/**
 * ファイルの検証
 * @param {File} file - 検証するファイル
 * @returns {boolean} 検証結果
 */
function validateFile(file) {
  const allowedTypes = [
    'image/jpeg',
    'image/png',
    'image/gif',
    'image/webp',
  ];
  const maxSize = 5 * 1024 * 1024; // 5MB

  // ファイルタイプの確認
  if (!allowedTypes.includes(file.type)) {
    showError(
      `"${file.name}" は対応していない形式です。` +
        `対応形式: JPEG, PNG, GIF, WebP`
    );
    return false;
  }

  // ファイルサイズの確認
  if (file.size > maxSize) {
    showError(
      `"${file.name}" のサイズが大きすぎます。` +
        `最大サイズ: 5MB (現在: ${formatFileSize(
          file.size
        )})`
    );
    return false;
  }

  return true;
}

スプレッド構文(...)を使うことで、FileList を配列に変換できます。これにより、配列の各種メソッドが使えるようになり、コードがより簡潔になりますね。

ファイルプレビューを表示する関数を実装します。

javascript/**
 * ファイルのプレビューを表示
 * @param {File} file - 表示するファイル
 */
function displayFilePreview(file) {
  const fileItem = document.createElement('div');
  fileItem.className = 'file-item';
  fileItem.id = `file-${Date.now()}-${Math.random()
    .toString(36)
    .substr(2, 9)}`;

  // 画像プレビュー
  const img = document.createElement('img');
  const reader = new FileReader();

  reader.onload = (e) => {
    img.src = e.target.result;
  };

  reader.readAsDataURL(file);
  fileItem.appendChild(img);

  // ファイル情報
  const fileInfo = document.createElement('div');
  fileInfo.className = 'file-info';

  const fileName = document.createElement('div');
  fileName.className = 'file-name';
  fileName.textContent = file.name;

  const fileSize = document.createElement('div');
  fileSize.className = 'file-size';
  fileSize.textContent = formatFileSize(file.size);

  fileInfo.appendChild(fileName);
  fileInfo.appendChild(fileSize);

  // 進捗バーコンテナ
  const progressContainer = document.createElement('div');
  progressContainer.className = 'progress-container';

  const progressBar = document.createElement('div');
  progressBar.className = 'progress-bar';
  progressBar.style.width = '0%';

  progressContainer.appendChild(progressBar);
  fileInfo.appendChild(progressContainer);

  fileItem.appendChild(fileInfo);
  fileList.appendChild(fileItem);

  // 進捗バー要素を返す(アップロード処理で使用)
  return progressBar;
}

一意な ID を生成するために、タイムスタンプとランダム文字列を組み合わせています。これにより、同じファイル名でも確実に区別できるのです。

進捗表示付きアップロード関数を実装します。

javascript/**
 * 進捗表示付きファイルアップロード
 * @param {File} file - アップロードするファイル
 */
function uploadFileWithProgress(file) {
  const formData = new FormData();
  formData.append('file', file);
  formData.append('timestamp', Date.now());

  const xhr = new XMLHttpRequest();

  // 対応する進捗バー要素を取得
  const fileItem = fileList.lastElementChild;
  const progressBar =
    fileItem.querySelector('.progress-bar');

  // アップロード進捗
  xhr.upload.addEventListener('progress', (e) => {
    if (e.lengthComputable) {
      const percentComplete = (e.loaded / e.total) * 100;
      progressBar.style.width = percentComplete + '%';

      if (percentComplete < 100) {
        progressBar.textContent =
          Math.round(percentComplete) + '%';
      }
    }
  });

  // アップロード完了
  xhr.addEventListener('load', () => {
    if (xhr.status >= 200 && xhr.status < 300) {
      progressBar.style.width = '100%';
      progressBar.style.backgroundColor = '#4CAF50';
      progressBar.textContent = '完了';

      console.log('アップロード成功:', xhr.responseText);
    } else {
      handleUploadError(xhr.status, progressBar);
    }
  });

  // エラー処理
  xhr.addEventListener('error', () => {
    progressBar.style.backgroundColor = '#f44336';
    progressBar.textContent = 'エラー';
    showError('ネットワークエラーが発生しました。');
  });

  // タイムアウト処理(30秒)
  xhr.addEventListener('timeout', () => {
    progressBar.style.backgroundColor = '#ff9800';
    progressBar.textContent = 'タイムアウト';
    showError('アップロードがタイムアウトしました。');
  });

  xhr.timeout = 30000; // 30秒
  xhr.open('POST', '/api/upload');
  xhr.send(formData);
}

アップロードエラーを処理する関数を追加します。

javascript/**
 * アップロードエラーの処理
 * @param {number} status - HTTP ステータスコード
 * @param {HTMLElement} progressBar - 進捗バー要素
 */
function handleUploadError(status, progressBar) {
  progressBar.style.backgroundColor = '#f44336';
  progressBar.textContent = 'エラー';

  let errorMessage = 'アップロードに失敗しました。';

  // エラーコード別のメッセージ
  switch (status) {
    case 400:
      errorMessage = 'Error 400: 不正なリクエストです。';
      break;
    case 401:
      errorMessage = 'Error 401: 認証が必要です。';
      break;
    case 413:
      errorMessage =
        'Error 413: ファイルサイズが大きすぎます。';
      break;
    case 415:
      errorMessage =
        'Error 415: サポートされていないファイル形式です。';
      break;
    case 500:
      errorMessage =
        'Error 500: サーバーエラーが発生しました。';
      break;
    case 503:
      errorMessage =
        'Error 503: サーバーが一時的に利用できません。';
      break;
    default:
      errorMessage = `Error ${status}: アップロードに失敗しました。`;
  }

  showError(errorMessage);
}

HTTP ステータスコードに応じて適切なエラーメッセージを表示することで、ユーザーが問題を理解しやすくなります。エラーコードを含めることで、検索による解決策の発見も容易になるのです。

ユーティリティ関数を実装します。

javascript/**
 * ファイルサイズを人間が読みやすい形式に変換
 * @param {number} bytes - バイト数
 * @returns {string} フォーマットされたサイズ
 */
function formatFileSize(bytes) {
  if (bytes === 0) return '0 Bytes';

  const k = 1024;
  const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));

  return (
    parseFloat((bytes / Math.pow(k, i)).toFixed(2)) +
    ' ' +
    sizes[i]
  );
}

/**
 * エラーメッセージを表示
 * @param {string} message - エラーメッセージ
 */
function showError(message) {
  const errorDiv = document.createElement('div');
  errorDiv.className = 'error-message';
  errorDiv.textContent = message;

  // ドロップ領域の後に挿入
  dropArea.parentNode.insertBefore(
    errorDiv,
    dropArea.nextSibling
  );

  // 5秒後に自動削除
  setTimeout(() => {
    errorDiv.style.opacity = '0';
    setTimeout(() => errorDiv.remove(), 300);
  }, 5000);
}

サーバーサイド実装例(Node.js + Express)

クライアント側だけでなく、サーバー側の実装例も見ていきましょう。

javascript// 必要なモジュールのインポート
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');

const app = express();
const port = 3000;

Multer の設定を行います。

javascript// Multer のストレージ設定
const storage = multer.diskStorage({
  // 保存先ディレクトリ
  destination: (req, file, cb) => {
    const uploadDir = 'uploads/';

    // ディレクトリが存在しない場合は作成
    if (!fs.existsSync(uploadDir)) {
      fs.mkdirSync(uploadDir, { recursive: true });
    }

    cb(null, uploadDir);
  },

  // ファイル名の設定
  filename: (req, file, cb) => {
    // 元のファイル名から拡張子を取得
    const ext = path.extname(file.originalname);
    // タイムスタンプ + ランダム文字列でユニークな名前を生成
    const uniqueName =
      Date.now() +
      '-' +
      Math.round(Math.random() * 1e9) +
      ext;
    cb(null, uniqueName);
  },
});

ファイルフィルターを設定します。

javascript// ファイルフィルター(アップロード可能なファイルタイプの制限)
const fileFilter = (req, file, cb) => {
  const allowedTypes = [
    'image/jpeg',
    'image/png',
    'image/gif',
    'image/webp',
  ];

  if (allowedTypes.includes(file.mimetype)) {
    cb(null, true);
  } else {
    cb(
      new Error('許可されていないファイル形式です。'),
      false
    );
  }
};

// Multer インスタンスの作成
const upload = multer({
  storage: storage,
  fileFilter: fileFilter,
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB
  },
});

アップロードエンドポイントを実装します。

javascript// 静的ファイルの配信
app.use(express.static('public'));

// アップロードエンドポイント
app.post(
  '/api/upload',
  upload.single('file'),
  (req, res) => {
    try {
      if (!req.file) {
        return res.status(400).json({
          success: false,
          message: 'ファイルがアップロードされていません。',
        });
      }

      // アップロード成功
      res.status(200).json({
        success: true,
        message: 'ファイルのアップロードに成功しました。',
        file: {
          originalName: req.file.originalname,
          filename: req.file.filename,
          size: req.file.size,
          mimetype: req.file.mimetype,
          path: req.file.path,
        },
      });
    } catch (error) {
      console.error('アップロードエラー:', error);
      res.status(500).json({
        success: false,
        message: 'サーバーエラーが発生しました。',
      });
    }
  }
);

エラーハンドリングミドルウェアを追加します。

javascript// Multer エラーハンドリング
app.use((error, req, res, next) => {
  if (error instanceof multer.MulterError) {
    // Multer 固有のエラー
    if (error.code === 'LIMIT_FILE_SIZE') {
      return res.status(413).json({
        success: false,
        message:
          'Error 413: ファイルサイズが 5MB を超えています。',
      });
    }

    return res.status(400).json({
      success: false,
      message: `Error 400: ${error.message}`,
    });
  }

  // その他のエラー
  res.status(500).json({
    success: false,
    message: 'Error 500: サーバーエラーが発生しました。',
  });
});

// サーバー起動
app.listen(port, () => {
  console.log(
    `サーバーが起動しました: http://localhost:${port}`
  );
});

サーバー側でも必ずファイル検証を行うことが重要です。クライアント側の検証は簡単に回避できるため、セキュリティの観点からサーバー側でも二重にチェックする必要があるのです。

応用例:複数ファイルの一括アップロード

複数ファイルを一度にアップロードする機能を追加してみましょう。

javascript/**
 * 複数ファイルの一括アップロード
 * @param {FileList} files - アップロードするファイルリスト
 */
async function uploadMultipleFiles(files) {
  const fileArray = Array.from(files);
  const uploadPromises = [];

  fileArray.forEach((file) => {
    if (validateFile(file)) {
      const progressBar = displayFilePreview(file);
      const uploadPromise = uploadSingleFile(
        file,
        progressBar
      );
      uploadPromises.push(uploadPromise);
    }
  });

  try {
    // すべてのアップロードが完了するまで待機
    const results = await Promise.all(uploadPromises);
    console.log(
      'すべてのアップロードが完了しました:',
      results
    );

    showSuccess(
      `${results.length} 件のファイルをアップロードしました。`
    );
  } catch (error) {
    console.error('一括アップロードエラー:', error);
    showError(
      '一部のファイルのアップロードに失敗しました。'
    );
  }
}

Promise.all() を使うことで、複数のアップロードを並列実行し、すべての完了を待つことができます。これにより、効率的な一括アップロードが実現できるのです。

成功メッセージを表示する関数も追加しましょう。

javascript/**
 * 成功メッセージを表示
 * @param {string} message - 成功メッセージ
 */
function showSuccess(message) {
  const successDiv = document.createElement('div');
  successDiv.className = 'success-message';
  successDiv.textContent = message;
  successDiv.style.cssText = `
    padding: 12px 16px;
    margin: 16px 0;
    background-color: #e8f5e9;
    color: #2e7d32;
    border-radius: 6px;
    border-left: 4px solid #4CAF50;
    animation: slideIn 0.3s ease;
  `;

  dropArea.parentNode.insertBefore(
    successDiv,
    dropArea.nextSibling
  );

  setTimeout(() => {
    successDiv.style.opacity = '0';
    setTimeout(() => successDiv.remove(), 300);
  }, 3000);
}

まとめ

JavaScript の Drag & Drop API を使ったファイルアップロード UI の実装方法について、基礎から実践まで解説してきました。

重要なポイントをおさらいしましょう。

#ポイント重要度
1preventDefault() でデフォルト動作を防止する★★★★★
24 つのイベント(dragenter, dragover, dragleave, drop)を正しく処理する★★★★★
3視覚的フィードバックでユーザビリティを向上させる★★★★☆
4クライアントとサーバーの両方でファイル検証を行う★★★★★
5エラーコードを含めた適切なエラーハンドリングを実装する★★★★☆
6FileReader API でプレビュー機能を提供する★★★☆☆
7XMLHttpRequest で進捗表示を実装する★★★☆☆

Drag & Drop API は、モダンな Web アプリケーションには欠かせない機能です。今回ご紹介した実装パターンを基に、プロジェクトの要件に合わせてカスタマイズしていただければと思います。

特に、セキュリティ面では必ずサーバー側でもファイル検証を行い、適切なエラーハンドリングを実装することを忘れないでください。ユーザーにとって使いやすく、開発者にとってメンテナンスしやすいコードを心がけることが、長期的なプロジェクトの成功につながるのです。

この記事が、皆さんのファイルアップロード UI 実装の参考になれば幸いです。

関連リンク