Vue.jsでローディング画面を作る

フロントエンドエンジニアもすなるVue.jsというものを、自称フロントエンドエンジニアもしてみむとてするなり。

というわけでVue.js初心者です、こんにちは。先日すごく小さなサイトでVueを導入してみたのですが、そのときに案外情報がなくて困ったんだけど、わりと使いたい、サイトロード時のローディング画面を作ってみました。

#完成形
<p class="codepen" data-height="265" data-theme-id="default" data-default-tab="js,result" data-user="kinacom" data-slug-hash="WNNVVYK" style="height: 265px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="Vue.js ローディング画面">
  <span>See the Pen <a href="https://codepen.io/kinacom/pen/WNNVVYK">
  Vue.js ローディング画面</a> by kinacom (<a href="https://codepen.io/kinacom">@kinacom</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

イラストは[いらすとや](https://www.irasutoya.com/)からお借りしました。

初心者故に果たしてこれがVueのお作法的に良い書き方なのかどうなのかが全くわかりませんが、例えばiframeとか、ループの中に入ってないimgとかでもv-loading-fileを入れればローディング監視対象になります。

もうちょっとがんばれば、サイトロード時だけじゃなくて追加でなにか読み込ませる時にも表示させたりできるのかも？

#やってること

##カスタムディレクティブをつくる
ローディング対象となる要素は、なるべく早く、Vueのマウント前に取得しときたいなあと思って色々やりかたを調べていたんだけど、どうやらカスタムディレクティブというものを設定するとVueインスタンスのcreatedの後、mountedの前に実行できそう。

ということでこんな感じ。

```javascript
// インスタンスプロパティ追加
Vue.prototype.$loadingElements = []

// カスタムディレクティブ追加
Vue.directive('loading-file', {
   inserted: function(el) {
      Vue.prototype.$loadingElements.push(el)
   }
})
```

カスタムディレクティブはDOM操作はできますが、この「このカスタムディレクティブが設定されてる要素をまとめてどっかに取っておく」みたいなことは勝手にやっておいてはくれなさそうなので、コンポーネント含めVueインスタンス内から自由に見られる場所に保管場所を作って、そこに追加している必要がありそうです。

じゃあそれはどうしたらいいのか。グローバル変数？マジで？とか考えてましたが、どうやら[インスタンスプロパティ](https://jp.vuejs.org/v2/cookbook/adding-instance-properties.html)を追加することで想定していることができそうです。

インスタンスプロパティとして配列を一つ作っておいて、カスタムディレクティブが設定されている要素（el）をどんどんそこにぶち込んでいきます。
srcだけじゃなくてelごとぶち込むのは、elがimgだろうがiframeだろうがOKにするため。

##コンポーネント
ここがローディング処理の本体。

```javascript
Vue.component('loading', {
   template: `
<div id="loading" v-show="showLoading" v-if="loadingFilesLength > 0">
   <div id="progress" :class="{'load-complete': loadComplete}">
      <div class="bar" :style="{right: progress}" ref="bar"></div>
      <div class="text">
         <div v-show="loadComplete">Complete</div>
         <div v-show="!loadComplete">Loading...</div>
      </div>
  </div>
</div>
`,
   data: function() {
      return {
         loadingPromise: [],
         loadingFilesLength: 0,
         loadedFilesLength: 0,
         loadComplete: false,
         showLoading: true
      }
   },
   computed: {
      progress: function() {
          // プログレスバーの挙動なので、この辺りは好きに書く。
          return (100 - ((100 / this.loadingFilesLength) * this.loadedFilesLength)) + '%'
      },
      
   },
   methods: {
        loadingFile: function(el) {
            let file;
            
            if(el.src === undefined) {
               return new Promise((resolve, reject) => {
                  this.getLoadingPromise(reject)
               })
            }
            
            if(el.tagName === 'IMG') {
                file = new Image()
                file.src = el.currentSrc === '' ? el.src : el.currentSrc
            } else {
            	const src = el.src
                file = el
                file.src = ''
                file.src = src
            }
            
            return new Promise((resolve, reject) => {
            	  file.addEventListener('load', () => {
                	  this.getLoadingPromise(resolve)
                })
                
                file.addEventListener('error', () => {
                    this.getLoadingPromise(reject)
                })
            })
        },
        completeLoading: function() {
            this.loadComplete = true
            
            // 1秒だけ待ってもらってCompleteしたのをわかりやすくしてみる
            setTimeout(() =>  {
                this.showLoading = false
                this.$emit('file-loaded')
            }, 1000)
        },
        getLoadingPromise: function(promise) {
            this.loadedFilesLength += 1
            promise()
        }
    },
    mounted: function() {
        this.loadingFilesLength = this.$loadingElements.length
        
        if( this.loadingFilesLength > 0) {
            this.$loadingElements.forEach((el) => {
                this.loadingPromise.push(this.loadingFile(el))
            })
            Promise.allSettled(this.loadingPromise).then(() => {
            	setTimeout(() => {
                	this.completeLoading()
                }, 500)
            })
        } else {
           this.showLoading = false
           this.$destroy()
        }
    },
    updated: function() {
       if(this.loadComplete && !this.showLoading) {
          // ロードが終了してローディング画面が非表示になったらdestroyしとく。
          this.$destroy()
       }
    }
})
```

挙動の肝となるのがloadingFileメソッド。Promiseを返します。
これをロードを感知したい要素の分（＝インスタンスプロパティに追加した$loadingElementsの分）だけループしてあげて、全部loadingPromiseに突っ込んでおきます。
で、loadできてresolveしたり、errorになってrejectしたり色々ですが、全部の結果が出たらその内容に関わらずPromise.allSettledで次の処理に移行、っていう感じの流れです。

##Vueインスタンス
```javascript
new Vue({
   el: '#app',
   data: function() {
        return {
           images: [
              {
                 name: '（画像のタイトル）',
                 src: 'http://〜〜〜（画像のURL）'
              }
           ]
        }
    },
    methods: {
       complete() {
          // すべての読み込みの完了時に実行
       }
    }
})
```

コンポーネント側の$emitで処理完了時にカスタムイベントを呼ぶようにすると、インスタンス側でも読み込み後になにかできるようになります。

##HTML
```HTML
<div id="app">
    <h1>画像一覧</h1>
    <ul>
        <li v-for="image in images" :key="image.name">
            <div class="name">{{image.name}}</div>
            <div class="image"><img :src="image.src" alt="" v-loading-file></div>
        </li>
    </ul>
    <loading @loaded-file="complete"></loading>
</div>
```