フロントエンドの基本的なテクニックを解説してみる 〜 フォルダ読み込みとPromisification

こんにちは、切削チームの石川です。 DTダイナミクスのテックブログにScalaやRustの記事が投稿されてきており、せっかくなのでフロントエンドやTypeScriptに関する記事も書いてみようと思います。

meviyではフォルダアップロード機能が実装されていますが、ファイルやフォルダのアップロードにはフロントエンド・サーバーサイドともに基本的なテクニックが詰まっています。

フォルダアップロードに関するさまざまなテクニックの中から、今回はブラウザでのフォルダ読み込みとPromiseに関する基本的な部分を説明していきます。

ブラウザでフォルダを読み込む際の基本

Google DriveやOne Driveなどではフォルダを読み込んでアップロードする機能がありますが、webkitdirectoryFile and Directory Entries APIを使うとそのような機能を実装できます。

HTMLInputElement: webkitdirectory property - Web APIs | MDN

File and Directory Entries API - Web APIs | MDN

上記APIを使うとフォルダを選択し、フォルダの内容をブラウザで読み込んでJavaScript上でファイルを扱った処理ができます。

具体的な例としては、以下の用途で使えます。

  • サーバーへフォルダの内容をアップロードする
  • フォルダ内の音声ファイルをまとめて読み込む
  • フォルダ内の画像ファイルをまとめて読み込む

JavaScript側の処理としては「選択されたフォルダを見る → フォルダ内のファイルを読み込む → 個別の処理を行う」の流れで、上記具体例の処理が行えます。

Promisificationについて

JavaScriptではasync/awaitで非同期処理を順番に実行していくことが可能です。

わかりやすいものだとfetchでリクエストを送り、レスポンスからjsonのデータを取得する処理をawaitを使って書くというものがあります。

実際のJavaScriptでは後述する通り、Promiseに対応しておらずそのままではasync/awaitで使えないものも存在します

例えば「非同期でファイルを読み込み、読み込みが終わったら値を返したい」という場合は、ファイル読み込みの関数をPromiseで扱えるような記述が必要です。

この「Promiseで使えるようにする」というのを Promisification と言います(これを後述のサンプルコードで解説します)。

実際に使ってみる

文章だけの説明だとわかりにくいので、さっそくですがフォルダを選択してファイルのデータを読み込むサンプルコードを記載しておきます。

この記事ではwebkitdirectoryの方を使ったサンプルを書いています。 FileSystemDirectoryEntryなどを使ったサンプルは次回の記事で記載・解説します。

※実際の開発ではTypeScriptを使いますが、ここではわかりやすさのため素のJavaScript で記載しています。

※inputタグでフォルダを読み込む場合、空のフォルダは認識されませんのでご注意ください。  中にファイルが存在するフォルダを選択してください。

サンプルコード

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Directory Select Sample</title>
  </head>
  <body>
    <!-- フォルダ選択に必要なプロパティを書いてinputタグを配置 -->
    <input id="selectDirectoryElement" type="file" webkitdirectory directory>

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

app.js

// ArrayBufferとしてファイルを読み込む
// 音声データを加工する等、ブラウザ上でデータ加工を行う場合はreader.resultをDataView化したものをresolveで返す
const loadFileDataAsArrayBuffer = function(file){
  return new Promise(function(resolve, reject){
    try {
      const reader = new FileReader()
      reader.onload = function(){
        resolve(reader.result)
      }
      reader.readAsArrayBuffer(file)
    } catch(e) {
      reject(e)
    }
  })
}

window.onload = function() {
  const selectDirectoryElement = document.getElementById("selectDirectoryElement")

  // フォルダ選択された際の処理
  // 空のフォルダは認識されない
  selectDirectoryElement.addEventListener("change", async function(e) {
    const files = this.files
    console.log(files) // FileList

    const fileItems = await Promise.all(Array.from(files).map(
      async function(currentFile){
        const itemArrayBuffer = await loadFileDataAsArrayBuffer(currentFile)
        // ここではFileのインスタンスを生成しているが、実際の開発では用途に応じたオブジェクトやインスタンスを返す
        return new File(
          [itemArrayBuffer],
          currentFile.name,
          {
            type: currentFile.type,
            lastModified: currentFile.lastModified
          }
        )
      }
    ))

    console.log(fileItems) // 読み込んだファイル一覧
    
    // ここにファイルをサーバーにアップロードしたり、ファイルのデータを使った処理を書く
  })
}

async/awaitでファイル読み込み処理を非同期で行えるようにする

上記のサンプルコードでは loadFileDataAsArrayBuffer 関数でファイルの内容を読み込んでいますが、readAsArrayBufferなどが書かれた箇所が return new Promise(function(resolve, reject){ で囲まれています。

JavaScriptでは FileReader を使ってファイルを読み込みますが、 onload で結果を扱うようになっています。

JavaScriptにおいてはAPIがPromiseに対応していない場合は return new Promise でラップした上で結果をresolveする必要があり、それによってasync/awaitで使えるようになります(これがPromisificationです)。

上記サンプルコードではonloadでFileReaderのresultをresolveしてファイル表示しているだけですが、resultをDataViewなどのコンストラクタの引数へ渡すことによって、データの読み込みや加工をJavaScript上で行えます(この記事はフォルダアップロードとPromiseに関する記事なので詳細は割愛します)。

これにより記事前半で書いた音声ファイルを扱うなどの処理が実現されます。

複数のPromiseはPromise.allで扱う

JavaScriptにおいて、複数のPromiseを返す場合は Promise.all で複数のPromiseを待機できます。 複数のAPIをfetchで叩く場合など、ファイル読み込み以外にも用途はいくつかあるかと思います。

複数のPromiseを扱うメソッドとしては他に Promise.allSettled などが存在し、ご参考までにPromise.allとPromise.allSettledの使い分けは以下の通りです。

Promise.all() は、入力されたプロミスのいずれかが拒否されると直ちに拒否されます。それに対して、Promise.allSettled() が返すプロミスは、入力されたプロミスのいずれかが拒否されたかどうかに関わらず、すべての入力されたプロミスが完了するのを待ちます。入力された反復可能オブジェクトに含まれるプロミスのすべての最終結果が必要な場合は、allSettled() を使用してください。

Promise.all https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise/all

Promise.allSettled() メソッドはプロミスの並行処理 メソッドの 1 つです。Promise.allSettled() は、通常、正常に完了するために互いに依存しない複数の非同期タスクがある場合、または各プロミスの結果を常に知りたい場合に使用されます。

それに対して、 Promise.all() が返すプロミスは、タスクが他にも依存している場合や、どれかが拒否されたらすぐに拒否したい場合により適しているかもしれません。

Promise.allSettled https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled

ちなみにですが、上記リンク先にもある通りJavaScriptでのPromiseはシングルスレッドです。 並列処理をさせたい場合はワーカースレッドを使用する、サーバー側で処理するといった対応が必要になります。

まとめ

現在のChromeでは、JavaScriptでもローカルのフォルダを扱えるようになっています。

JavaScriptでフォルダ内のファイルを読み込むことによってデスクトップアプリのような動作もさせることが可能で、他のフォルダ絡みのAPIと組み合わせればローカルでのファイルの読み書きも実現できてしまいます。

フォルダアップロード1つとっても使われる技術はいくつかあるので、今後もフォルダアップロードを中心に記事を書いていきます。