DOMドラッグ&ドロップファイル操作同期・非同期

【JavaScript】 ドラッグ&ドロップしたファイルの分割読み込みと16進数表示

更新日:2020/07/27

今回はブラウザにテキストファイルをドラッグ&ドロップして、その内容を16進数で表示してみます。
その際、大きなファイルを読み込んだときブラウザの動作を重くする可能性を考慮して、分割読み込みを実装してみます。

 

仕様/デモ

ブラウザのドロップエリアにファイルがドロップされたあと、次の条件でファイルの内容を表示します。

(1) ファイルを16×1000バイトに分割して読み込む

(2) 読み込んだデータを16進数で表示する

(3) 複数ファイルの一括ドロップには対応しません。

■完成DEMO

点線の四角内にファイルをドロップしてください。

ここにドロップ

ファイル名:
状況:

 

完成コード

今回作成したコードです。

完成コード:html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>ドラッグ&ドロップしたファイルの分割読み込みと16進数表示</title>
    <style>
        body{
            min-height: 100vh;
        }
        #ddarea{
            width:200px;
            height:150px;
            border:1px dotted #888;
            margin: 1em auto;
        }
        #ddarea.ddefect{
            border:1px solid #000;
        }

        #txtarea{
            font-family: monospace, monospace;
            width: 90%;
            height: 350px;
            overflow: auto;
            background: black;
            color:white;
            white-space: pre;
            padding: 1em;
        }
        #txtarea p{
            margin: 1px 0;
        }
    </style>
    <script>
    window.addEventListener( "DOMContentLoaded" , ()=> {

        const ddarea = document.getElementById("ddarea");
        const tarea = document.getElementById("txtarea");
        const fnameSpan = document.getElementById("fnameSpan");
        const infoSpan = document.getElementById("infoSpan");

        let dropDisabled = false;

            // ドラッグされたデータが有効かどうかチェック
        const isValid = e => e.dataTransfer.types.indexOf("Files") >= 0 
                                           && e.dataTransfer.items.length<=1;

        const ddEvent = {
            "dragover" : e=>{
                e.preventDefault(); // 既定の処理をさせない
                e.stopPropagation(); // イベント伝播を止める

                if( dropDisabled || !e.currentTarget.isEqualNode( ddarea ) ) {
                        // ドロップ機能停止中または ドロップエリア外ならドロップを無効にする
                    e.dataTransfer.dropEffect = "none";return;
                }

                if( !isValid(e) ){
                        // 無効なデータがドラッグされたらドロップを無効にする
                    e.dataTransfer.dropEffect = "none";return;
                }
                        // ドロップのタイプを変更
                e.dataTransfer.dropEffect = "copy";
                ddarea.classList.add("ddefect");
            },
            "dragleave" : e=>{
                if( dropDisabled || !e.currentTarget.isEqualNode( ddarea ) ) {
                    return;
                }
                e.stopPropagation(); // イベント伝播を止める
                ddarea.classList.remove("ddefect");
            },
            "drop":e=>{
                e.preventDefault(); // 既定の処理をさせない
                e.stopPropagation(); // イベント伝播を止める

                if(e.dataTransfer.files.length > 1) {
                    tarea.textContent = "複数ファイルは対象外です";
                    return;
                }

                dropDisabled = true;

                loadData( e.dataTransfer.files[0] ).then( e => {
                    ddarea.classList.remove("ddefect");
                    dropDisabled = false;
                });
            }
        };

        /**
         * 数値を16進数の文字列に変換
         * @param intData
         * @returns {string}
         */
        const hexText = intData => intData === undefined ? "--" : ("0" + intData.toString(16).toUpperCase()).slice(-2);

        /**
         * 16進数の文字列を取得
         * @param intData
         * @param stratIndex
         * @param lineCount
         * @returns {string}
         */
        const hexLineText = (intData,stratIndex,lineCount) => {

            const lineNumberText = ("00000000" + lineCount.toString(16).toUpperCase()).slice(-8) + "|";
            const result = [lineNumberText];
            for( let i = 0 ; i < 16 ; i ++){
                result.push( hexText(intData[stratIndex+i]) );
            }
            return result.join( " " );
        };
        /**
         * 16進数表示時に先頭2行をDIV要素にセット
         * @param e 表示エリア
         */
        const setHexHead = e =>{
            const headData = [
                "ADDRESS | +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +A +B +C +D +E +F",
                "-".repeat(8) + "+" + "-".repeat(3*16+2)
                ];
            headData.forEach(
                t => {
                    const p = document.createElement("p");
                    p.textContent = t;
                    e.appendChild(p);
                }
            );
        };

        /**
         * ファイル読み込み
         * @param targetFile
         * @returns {Promise<unknown>}
         */
        const loadData = targetFile => {
           return new Promise( (resolve, reject) => {
               const targetFileSize = targetFile.size;

               fnameSpan.textContent = `${targetFile.name}(${targetFileSize}Bytes)  <${targetFile.type}>`;
               infoSpan.textContent = `読み込み中 0 / ${targetFileSize}`;
               tarea.textContent ="";
               setHexHead(tarea);

               const readSize =16 * 1000;
               let loadStartPos = 0;

               let lineCount = 0;

               const reader = new FileReader();

               reader.onload = ()=>{

                   const u8 = new Uint8Array(reader.result);

                   const f = document.createDocumentFragment();

                   for( let i = 0; i < u8.length ; i += 16 ){
                       const p = document.createElement("p");
                       p.textContent = hexLineText( u8 , i , lineCount);
                       lineCount+=16;
                       f.appendChild(p);
                   }

                   tarea.appendChild(f);

                   loadStartPos = loadStartPos + readSize;

                   if( loadStartPos  >= targetFileSize ) {

                       infoSpan.textContent ="読み込み完了";
                       resolve(true);

                   }else{

                       const endPos = (loadStartPos + readSize) > targetFileSize ? targetFileSize : loadStartPos + readSize;

                       infoSpan.textContent = `読み込み中 ${loadStartPos} / ${targetFileSize}`;
                       reader.readAsArrayBuffer( targetFile.slice(loadStartPos,endPos) );
                   }

               };
               reader.onerror = () =>{
                   tarea.textContent = reader.error.message;
                   resolve(false);
               };

               reader.readAsArrayBuffer( targetFile.slice(loadStartPos,readSize) );
           });
        };

        Object.keys( ddEvent ).forEach( e=>{
            ddarea.addEventListener(e,ddEvent[e]);
            document.body.addEventListener(e,ddEvent[e])
        });

    });
    </script>
</head>
<body>

<div id="ddarea" ><p>ここにドロップ</p></div>
<p>ファイル名:<span id="fnameSpan"></span><br>
状況:<span id="infoSpan"></span></p>
<div id="txtarea"></div>
</body>
</html>

ドラッグ部について

ブラウザにファイルをドラッグした際の処理は、次の記事で紹介しているものとほぼ同じです。
参考記事:【JavaScript】 ファイルをドラッグ&ドロップの基本形

ここでは解説していないので、参考記事を見てください。

なお今回はファイル読み込みに非同期処理をおこなうため、フラグ(dropDisabled)で読み込み中かどうか判断しています。
dropDisabledがtrueのとき、ドラッグを無効にしています。

 

ドロップ時の処理

ブラウザにファイルがドロップされると、次のメソッドが呼び出されます。

ドロップイベントの処理


            "drop":e=>{
                e.preventDefault(); // 既定の処理をさせない
                e.stopPropagation(); // イベント伝播を止める

                if(e.dataTransfer.files.length > 1) {
                    tarea.textContent = "複数ファイルは対象外です";
                    return;
                }

                dropDisabled = true;

                loadData( e.dataTransfer.files[0] ).then( e => {
                    ddarea.classList.remove("ddefect");
                    dropDisabled = false;
                });

            }

規定処理の中止とイベントの伝播をストップしたあと、dropDisabledにtrueをセットして、ファイル読み込み処理中にドロップイベントが発生しないようにします。

続くloadData()は、ファイルを読み込みブラウザに表示する関数です。
この関数はPromiseオブジェクトを返します。

処理終了後resolve()を呼び出すので、then()でdropDisabledを無効にするなどの後処理をおこなっています。
参考記事:【JavaScript】 非同期はPromise?解説が難しいので自分で理解してみた

なおloadData()には、引数としてFileオブジェクトを渡しています。
e.dataTransfer.filesはドロップされたファイルの一覧で、個々のファイルは数値インデックスで参照します。

今回は複数ファイルのドラッグを無効にしています。

ドラッグファイルの数が一つのみかチェックしているコード

const isValid = e => e.dataTransfer.types.indexOf("Files") >= 0 && e.dataTransfer.items.length<=1;

しかしSafariがドラッグ時に複数ファイルチェックできないので、ドロップイベント内でドロップ数のチェックをおこなっています。

 

ファイル読み込みについて

分割読み込みの是非

JavaScriptは基本的に、ブラウザ上でユーザーがクリックなどの行動を切っ掛けとして呼び出されます。
ユーザーはクリック1回だけでなく複数回クリックしたり、その他の動作をおこないます。

しかしスクリプト実行中は、ユーザーの行動に対して応答できません。
処理時間が長くなると、場合によっては次のようなメッセージがブラウザに表示されます。

ウェブページがブラウザの動作を遅くしています。どうしますか?

このメッセージを見たユーザーはスクリプトになんらかの問題があるように感じ、そのページだけでなくサイト全体の信頼を失ってしまう可能性が高いです。
そのためスクリプトの実行時間は、可能な限り短くすることが望ましいです。

読み込んだファイルの処理も同様です。

今回は、バイト単位で数値を16進数の文字列に変換します。
これは非常に重い処理です。
そのため、一度に処理するデータ量を減らして処理時間を短縮し、ブラウザに処理を戻す必要があります。

その手段として、ファイルの分割読み込みをおこないます。

分割読み込みの方法

ファイルの読み込みは、FileReaderオブジェクトを使用します。
次の例は、ファイルを一括で文字データの配列に読み込む例です。

ファイルの一括読み込み


const reader = new FileReader();

reader.onload = ()=>{
    const u8 = new Uint8Array(reader.result);
};

reader.readAsArrayBuffer( targetFile );

※targetFileは、Fileオブジェクト

分割で読み込むときは、次の手順をおこないます。

  1. Fileオブジェクトのsliceメソッドで、読み込み開始位置と終了位置を定義したBlobオブジェクトを取得します。
  2. FileReaderオブジェクトのreadAsArrayBufferメソッドの引数に、取得したBlobオブジェクトを指定します。
  3. 読み込みが完了してデータの処理が終わったら、読み込み開始位置と終了位置を更新して再度1から3をおこないます。
  4. 最後まで読み込んだら終了です。

sliceメソッドは、0から始まる開始位置と終了位置の一つ後ろの位置を指定します。

file.slice(3,10)なら3から9バイト目まで読み込まれる

※終了位置がファイルサイズより大きい場合、ファイルサイズが適用されます。

具体的には次のようなコードになります。

ファイルの分割読み込み


let stratPos = 0;
const readSize = 16 * 1000; // 一度に読み込むバイト数

const fileSize = targetFile.size;

const reader = new FileReader();

reader.onload = ()=>{
    const u8 = new Uint8Array(reader.result);
     // ・・・・ u8に対する処理をおこなう

     // 次のデータ読み込みのための処理
    stratPos += readSize;
    if( stratPos < fileSize ) {  // 残りデータあり
         const blob = targetFile.slice( stratPos , readSize );
          reader.readAsArrayBuffer( blob );
    }
};

const blob = targetFile.slice( stratPos , readSize );
reader.readAsArrayBuffer( blob );

なお、一度に読み込むサイズが小さくなると、onloadの実行回数が増えます。
ブラウザがonloadを呼び出し、再度onloadを呼び出すまでそれなりの時間を要します。
その結果、ファイル全体を処理するまでの時間が増えます。

つまり読み込むサイズをできるだけ大きくした方が、完了までの時間が短くなります。
しかし大きすぎると、ブラウザの動作を重くしているとの判定を受けてしまいます。

どのサイズがベストかは端末のスペックにもよるので、ここでは示しません。
(今回は16×1000バイトですが、この値には特に根拠はありません。)

プログラム実装時に、検討してみてください。

 

ファイル読み込み&表示処理

次のコードは、ファイル読み込みと表示処理をおこなっているloadData関数です。

ファイル読み込み&表示処理部

        /**
         * ファイル読み込み
         * @param targetFile
         * @returns {Promise<unknown>}

         */
        const loadData = targetFile => {
           return new Promise( (resolve, reject) => {
               const targetFileSize = targetFile.size;

               fnameSpan.textContent = `${targetFile.name}(${targetFileSize}Bytes)  <${targetFile.type}>`;
               infoSpan.textContent = `読み込み中 0 / ${targetFileSize}`;
               tarea.textContent ="";
               setHexHead(tarea);

               const readSize = 16 * 1000;

               let loadStartPos = 0;

               let lineCount = 0;

               const reader = new FileReader();

               reader.onload = ()=>{

                   const u8 = new Uint8Array(reader.result);

                   const f = document.createDocumentFragment();

                   for( let i = 0; i < u8.length ; i += 16 ){
                       const p = document.createElement("p");
                       p.textContent = hexLineText( u8 , i , lineCount);
                       lineCount+=16;
                       f.appendChild(p);
                   }

                   tarea.appendChild(f);

                   loadStartPos = loadStartPos + readSize;
                   if( loadStartPos  >= targetFileSize ) {

                       infoSpan.textContent ="読み込み完了";
                       resolve(true);

                   }else{

                       const endPos = (loadStartPos + readSize) > targetFileSize ? targetFileSize : loadStartPos + readSize;

                       infoSpan.textContent = `読み込み中 ${loadStartPos} / ${targetFileSize}`;
                       reader.readAsArrayBuffer( targetFile.slice(loadStartPos,endPos) );
                   }

               };
               reader.onerror = () =>{
                   tarea.textContent = reader.error.message;
                   resolve(false);
               };

               reader.readAsArrayBuffer( targetFile.slice(loadStartPos,readSize) );

           });
        };

引数で受け取ったtargetFileは、Fileオブジェクトです。

このオブジェクトをFileReaderオブジェクトのreadAsArrayBufferメソッドで読み込み、Uint8Arrayオブジェクトのコンストラクタで文字データの配列を取得します。

読み込んだデータは16バイトを一行として、16進数の文字列に変換します。
変換した文字列はDOMのp要素にセットして、表示エリアの子要素として追加します。
コード中のsetHexHead/hexLineText関数は、コードの流れをわかりやすくするためにloadData関数から分離してあります。処理コードはリンク先を見てください。

データ表示後、読み込むデータが残っている場合は、readAsArrayBufferメソッドで残りのデータを読み込みます。

最後に、loadData関数はPromiseオブジェクトを返す関数なので、最後にresolve()またはreject()で結果を通知しています。

Promiseの解決


resolve(true );

更新日:2020/07/27

書いた人(管理人):けーちゃん

スポンサーリンク

記事の内容について

null

こんにちはけーちゃんです。
説明するのって難しいですね。

「なんか言ってることおかしくない?」
たぶん、こんなご意見あると思います。

裏付けを取りながら記事を作成していますが、僕の勘違いだったり、そもそも情報源の内容が間違えていたりで、正確でないことが多いと思います。
そんなときは、ご意見もらえたら嬉しいです。

掲載コードについては事前に動作確認をしていますが、貼り付け後に体裁を整えるなどをした結果動作しないものになっていることがあります。
生暖かい視線でスルーするか、ご指摘ください。

ご意見、ご指摘はこちら。
https://note.affi-sapo-sv.com/info.php

 

このサイトは、リンクフリーです。大歓迎です。