progress要素を使ったロードの状態の管理

ロード状況を表示するため、プログレスバー(インジケーター)を使用する場面が増えてきました。
HTMLには、プログレスバー向けにprogress要素が存在し、タスクの進行状況を管理することができます。

HTML LSよりprogress要素の説明
Can I use
Polyfill

ロードの種類

ローディングアニメーションには、目的地が決まっているDeterminate(確定型)と、
同じ動きを繰り返し行うIndeterminate(不確定型)の2種類が存在します。
このうちプログレスバーは、Determinateに属するものです。
Indeterminateはクルクル回るスピナーなどが当てはまります。

今回Indeterminateについてはあまり触れません。

非progress要素

まず、progress要素を使わず、divなどでプログレスバーを表現する場合です。

html
<div id="progresslabel" class="opacity0">ロードの進捗状況</div>
<div role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-labelledby="progresslabel" ></div>

上記のように、progressbarロールとaria-valueの値を設定しないといけません。
(その他、ロード中の場所へのaria-busyの設定なども必要ですが、割愛します)

progress要素

progress要素です。

html
<label for="progressbar">ロードの進捗状況</label>
<progress id="progressbar" max="100" value="10">10%</progress>

上記のように、最大値のmaxと現在値のvalueを設定します。minはありません。
この要素ではブラウザベンダーの提供するプログレスバーが表示され、valueの値に応じてゲージが増加します。

progress01

子要素にはvalueと同じ値を入れることが推奨されています。
そうすることで、レガシーUAにとっても認識可能な値となるためです。

CSSでの装飾

プログレスバーはForm系の要素と同じく、appearanceなどで装飾されています。
これをCSSで上書きすることができますが、結構カオスです。
主にバー本体とゲージの色を設定できるのですが、ブラウザ間で統一されていません。

ChromeとFirefoxではbarvalueの解釈が異なっています。
さらに、IEではゲージ色の変更に、background-colorではなくcolorを用いています。

css
#progress[value] {
  appearance: none;
  background-color: #fff;
  border: 1px solid #eee;
  border-radius: 2px;
  color: #63cf0c; /* IE */
  height: 10px;
}
/* chrome */
#progress[value]::-webkit-progress-bar {
  background-color: #fff;
}
#progress[value]::-webkit-progress-value {
  background-color: #63cf0c;
}
/* firefox */
#progress[value]::-moz-progress-bar {
  background-color: #63cf0c;
}

詳しくは下記にあります。
The HTML5 progress Element | CSS-Tricks

valueの省略

progress要素は通常Determinateですが、 value属性を省略すると、Indeterminateとしても使用することができます。
ただ、iOS Safariでは空表示と同じ扱いになるようです。

html
<label for="progressbar">いつ終わるかわからないやつ</label>
<progress id="progressbar">Loading</progress>

progress要素の使用

Fetch APIとFirebase Cloud Storage、2つの場面で試してみます。

Fetch APIを使う時

Fetch APIにはXMLHttpRequestのようなprogressイベントがないのですが、Streams APIで近いものを再現することができます。
(IEを除く)

有無を言わさず2GBのdatファイルを読み込むサーバーがあるとします。
これをFetch APIで取得していきます。
content-lengthを後で使用するので、レスポンスヘッダーに追加します。

node
const express = require('express');
const fs = require('fs');

const app = express();

app.use(function(req, res, next) {
  res.header("Access-Control-Allow-Origin", "http://localhost:8080");
  res.header("Access-Control-Expose-Headers", "content-length");
  next();
});

app.listen(3000);

app.get('/test', function(req, res, next) {
  const data = fs.readFileSync('./dummy.dat');
  res.send(data);
});

Fetch APIは、bodyにReadableStreamを返します。
このReadableStreamにはreadメソッドがあり、donevalueのPromiseを返します。
その中のvalueはバイナリデータとして、Uint8Array型で格納されていきます。

読み込んだバイト数はvalue.byteLengthで取得することができます。
最終的にはバイト数がresponse.headers.get('content-length')と同じになるので、そこから全体の割合を算出し、progress要素のvalueに代入します。

readメソッドのdoneはfalseを返しますが、読み込みが完了した時にtrueになります。
要はこれがtrueになるまで、算出処理を再帰的に呼び出し続けるだけです。

Fetch での Stream を用いたプログレス取得とキャンセル | blog.jxck.io
JavaScriptのStreams APIで細切れのデータを読み書きする - Subterranean Flower Blog

ts
function fetchItem() {
  fetch('http://localhost:3000/test').then(res => {
    if (!res.ok || !res.body || !res.headers.get('content-length')) return;

    // 合計バイト数
    const total = res.headers.get('content-length') as string;

    // 読み込みバイト数の初期状態
    let loaded = 0;

    const reader = res.body.getReader();

    function readChunk(readerResult: ReadableStreamReadResult<Uint8Array>) {
      if (readerResult.done) {
        completeFetchItem(); // ← doneがtrueになった時の処理
        return;
      }
      loaded += readerResult.value.byteLength;
      setProgressVal(Math.round((loaded / parseInt(total)) * 100));
      reader.read().then(readChunk);
    }
    reader.read().then(readChunk);
  });
}

// progress要素をいじる処理
function setProgressVal(percentage: number) {
  progressbar.setAttribute('value', String(percentage));
  progressLabel.innerText = `${percentage}%`;
}

2GBの巨大サイズなので、じわじわ読み込んでいます。

progress要素で読み込み状況を表示している様子

長い場合は%を表示しておくことで、待ち時間への不安を軽減する必要があります。
処理によっては中止するボタンがあると良いです。

progress要素の読み込み状況を表示中にキャンセルしている様子

Firebase Cloud Storageにアップロードする時

Firebase Cloud Storageでは、state_changedobserverからsnapshotを取得することができます。
その中にbytesTransferred(転送バイト数)とtotalBytes(全体のバイト数)があるので、Fetchの例と同じように割合を出すだけです。

ウェブでファイルをアップロードする - Firebase

ts
const firebaseStorage = firebase.storage();

function uploadPict(dataUrl: string, fileName: string) {
  const storageRef = firebaseStorage.ref(fileName);
  const task = storageRef.putString(dataUrl, 'data_url');
  task.on(firebase.storage.TaskEvent.STATE_CHANGED, snapshot => {
    setProgressVal((snapshot.bytesTransferred / snapshot.totalBytes) * 100);
  });
}
Firebase Cloud Storageにアップロードしている。読み込みアニメーションがぎこちない

先ほどの大容量ファイルと違って、数MB程度のファイルで常に監視すると、
上動画のようにぎこちない動きになってしまいます。
その場合はある程度読み込ませたところでアニメーションを止めたり、段階的に動かしたりしてごまかします。

Firebase Cloud Storageにアップロードしている

まとめ

  • Determinate(確定的)とIndeterminate(不確定)なLoadingアニメーションがある
  • Determinateなプログレスバーにはprogress要素が使える
  • CSSいじりは辛い
  • 読み込んだバイト数 / 全体バイト数 * 100で割合を出してvalue属性に入れていく
  • Fetch APIではStreams APIから読み込み情報を得られる
  • Firebase Cloud Storageはobserverから読み込み情報を得られる