Vue.jsでパーティクルを自動生成して雪を降らせる

# はじめに
この記事は自動生成した要素をアニメーションさせてから自動消滅させるまでの流れを把握することを目的にしています。
初心者向けの内容ですが、2 ファイル合計 120 行程度のコードなので気軽にご覧ください。
※ガチのパーティクルを簡単に扱いたい場合には [Vue Particles](https://madewithvuejs.com/vue-particles) が便利です。

## 出来上がるもの
ゆきが降っています！（断言）
![yuki.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150372/3fa38e46-e825-b134-b5aa-c4a095fdc70f.gif)


## チートシート的なもの（初心者向け）
![vuejsのsnow.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150372/938a30a9-b1c8-8c0b-eabf-c7bf570e6d64.jpeg)
 
* [定期的な雪情報の生成](#定期的な雪情報の生成) → requestAnimationFrame   
- 雪情報配列への格納 → push
* [雪情報の定義](#雪情報の定義) → コンポーネント化 
- 見た目の構成 → template 
* [すべての雪の描画](#すべての雪の描画) → v-for 
* [雪の落下アニメーション](#雪の落下) → transform  
* [雪のタイミング制御](#雪のタイミング制御) → setTimeout  
* [雪を消す通知](#雪を消す通知) → emit  
* [雪情報配列からの削除](#雪情報配列からの削除) → splice

## コード全文
```Javascript:App.vue
<template>
  <div id="app">
    <snow v-for="(particle,index) in perticles" v-bind:key="particle.id"
      :x="particle.x"
      :y="particle.y"
      :limit_y="windowHeight-30"
      :dr="particle.dr"
      @thaw="thaw_snow(index)"
    >
    </snow>
  </div>
</template>

<script>
import Snow from './components/snow.vue'

export default {
  name: 'app',
  components: {
    Snow
  },
  data () {
    return {
      perticles: [],
      windowWidth: window.innerWidth,
      windowHeight: window.innerHeight,
      lastSpawnTime: 0,
    }
  },
  mounted() {
    window.addEventListener('resize', this.get_window_size)
    this.spawn_loop(0)
  },
  methods: {
    random_x(){
      return Math.floor(Math.random() * this.windowWidth)
    },
    next_id(){
      const usedids = this.perticles.reduce((accumulator, element) => {
          accumulator[element.id] = true
          return accumulator
      }, []);
      const nextid = usedids.findIndex((exists) => !exists)
      return nextid < 0 ? usedids.length : nextid
    },
    spawn_snow(){
      const id = this.next_id()
      const particle = { id, x: this.random_x(), y:0, dr: 1300}
      this.$data.perticles.push(particle)
    },
    thaw_snow(index){
      this.perticles.splice(index, 1)
    },
    get_window_size: function() {
      this.windowWidth = window.innerWidth
      this.windowHeight = window.innerHeight
    },
    spawn_loop: function(timestamp) {
      if(timestamp - this.lastSpawnTime > 60){
        this.spawn_snow()
        this.lastSpawnTime = timestamp
      }
      window.requestAnimationFrame(this.spawn_loop)
    }
  }
}
</script>
```

```Javascript:snow.vue
<template>
  <div class="snow-container"
  :style="{
     transform: `translate(${x}px, ${y+dy}px)`,
     transition: `transform ${dr}ms linear`
   }"
   >
    <span>ゆき</span>
  </div>
</template>

<script>
export default {
  name: 'snow',
  props: {
    x: { type: [Number], default: 0 },
    y: { type: [Number], default: 0 },
    limit_y: { type: [Number], default: 0 },
    dr: { type: [Number], default: 0 },
  },
  data () {
    return {
      dy: 0
    }
  },
  mounted(){
    window.setTimeout(() => {
      this.fall()
    } , 100)
  },
  methods: {
    fall(){
      this.dy = this.limit_y - this.y
      window.setTimeout(() => {
        this.$emit('thaw')
      } , this.dr)
    }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
div.snow-container {
  position: absolute;
  animation: fadein 1s ease 0s 1 normal;
}

@keyframes fadein {
  0% {opacity: 0}
  20% {opacity: 1}
}

</style>
```

## 定期的な雪情報の生成
雪の生成と管理に使う情報は App.vue で保持しています。

```Javascript:App.vue
data () {
  return {
    perticles: [],
    windowWidth: window.innerWidth,
    windowHeight: window.innerHeight,
    lastSpawnTime: 0,
  }
}
```

雪の位置情報は perticles 配列に格納します。 
画面の横幅/縦幅は windowWidth / windowHeight に格納します。 
最後に雪を生成した時刻は lastSpawnTime に格納します。

では雪を生成（spawn）する関数を見ます。

```Javascript:App.vue
random_x(){
  return Math.floor(Math.random() * this.windowWidth)
},
next_id(){
  const usedids = this.perticles.reduce((accumulator, element) => {
      accumulator[element.id] = true
      return accumulator
  }, []);
  const nextid = usedids.findIndex((exists) => !exists)
  return nextid < 0 ? usedids.length : nextid
},
spawn_snow(){
  const id = this.next_id()
  const particle = { id, x: this.random_x(), y:0, dr: 1300}
  this.$data.perticles.push(particle)
},
```

関数`random_x()`は画面の横幅に収まるようにランダムな x 座標を取得します。
関数`next_id()` は perticles 内で未使用の id を探し出します。
@FumioNonaka 様の記事を参考にさせていただきました。  
[追加と削除が繰り返される配列要素のオブジェクトに一意のid番号を振る](https://qiita.com/FumioNonaka/items/63ce689b3599a6922182)

関数`spawn_snow()` は新しい雪情報を perticles に追加します。
雪の y 座標は画面の最上部である 0 にしています。
dr は 雪の落下にかかる時間（ミリ秒）で、1.3 秒に固定値しています。

`spawn_snow()`を定期的に実行するために、マウント後に発生するイベントで関数`spawn_loop()`呼び出します。

```Javascript:App.vue
mounted() {
  window.addEventListener('resize', this.get_window_size)
  this.spawn_loop(0)
},
```

```Javascript:App.vue
spawn_loop: function(timestamp) {
  if(timestamp - this.lastSpawnTime > 60){
    this.spawn_snow()
    this.lastSpawnTime = timestamp
  }
  window.requestAnimationFrame(this.spawn_loop)
}
```
関数`spawn_loop()`は、内部で関数`requestAnimationFrame()`を使用して繰り返し`spawn_loop()`を呼び出します。
前回の生成時刻から 60 ミリ秒経過している場合に関数`spawn_snow()`を実行します。

## 雪情報の定義
雪情報は snow.vue の snow コンポーネントの props で定義しています。

```Javascript:snow.vue
export default {
  name: 'snow',
  props: {
    x: { type: [Number], default: 0 },
    y: { type: [Number], default: 0 },
    limit_y: { type: [Number], default: 0 },
    dr: { type: [Number], default: 0 },
  },
```

x,y は最初に雪を表示する画面上の x 座標と y 座標です。
limit_y は雪が降った後で消える地点の y 座標です。
dr は雪が現れてから降り終わるまでの時間（ミリ秒）です。

蛇足ですが、これらの情報は実際には App.vue が管理します。snow.vue の役割はこれらの値を App.vue から受け取って表示に反映するだけです。

## すべての雪の描画
v-for ディレクティブを使用して perticles の要素数だけ snow コンポーネントを生成しています。

```Javascript:App.vue
<snow v-for="(particle,index) in perticles" v-bind:key="particle.id"
  :x="particle.x"
  :y="particle.y"
  :limit_y="windowHeight-30"
  :dr="particle.dr"
  @thaw="thaw_snow(index)"
>
```
ここでは、App.vue が持つ情報と snow コンポーネントの props を関連付けています。
後述しますが、snow コンポーネントが通知する `@thaw`イベントのハンドラも定義しています。

## 雪の落下アニメーション
snow コンポーネントのスタイルを指定します。

```Javascript:snow.vue
div.snow-container {
  position: absolute;
  animation: fadein 1s ease 0s 1 normal;
}

@keyframes fadein {
  0% {opacity: 0}
  20% {opacity: 1}
}
```
要素の座標は `absolute` （絶対位置）にしています。
また、雪が現れた瞬間はうっすらと半透明にしたいので、キーフレームアニメーションでフェードインの効果を付けています。

雪が降る動きは CSS アニメーションを用います。
まず、雪コンポーネント唯一の data として dy を定義しています。

```Javascript:snow.vue
data () {
  return {
    dy: 0
  }
},
```
dy は y 座標の変位です。props の y との合計値が落下後の y 座標になります。

```Javascript:snow.vue
<div class="snow-container"
  :style="{
   transform: `translate(${x}px, ${y+dy}px)`,
   transition: `transform ${dr}ms linear`
 }"
 >
  <span>ゆき</span>
</div>
```

Vue.jsでの CSS ア二メーションは @yuneco 様の記事を参考にさせていただきました。
[Vue.js+SVGで自由にCSSアニメーションしたい人のための完全解説（ソース付き）](https://qiita.com/yuneco/items/1f31418269f9df15cf9f)

## 雪のタイミング制御
雪の落下タイミングを調整するために、マウント後のイベント内で関数`setTimeout()`を用います。

```Javascript:snow.vue
mounted(){
  window.setTimeout(() => {
    this.fall()
  } , 100)
},
```

マウント後、つまり雪が画面上部（y 座標：0）の位置に現れてから 100 ミリ秒後に落下用の関数`fall()`を実行します。 

```Javascript:snow.vue
fall(){
  this.dy = this.limit_y
    window.setTimeout(() => {
      this.$emit('thaw')
  } , this.dr)
}
```

`fall()`が dy を変更することで、落下アニメーションが発生します。
その後、アニメーション時間分待機してから、雪を消す通知を発行します。（後述）

## 雪を消す通知

雪を消すための通知には`$emit`を用います。

```Javascript:snow.vue
fall(){
  this.dy = this.limit_y
    window.setTimeout(() => {
      this.$emit('thaw')
  } , this.dr)
}
```
上記のコードでは dr ミリ秒後（アニメーション時間後）に`thaw`という名前のイベントを発行します。

```Javascript:App.vue
<snow v-for="(particle,index) in perticles" v-bind:key="particle.id"
  :x="particle.x"
  :y="particle.y"
  :limit_y="windowHeight-30"
  :dr="particle.dr"
  @thaw="thaw_snow(index)"
>
```
App.vue は snow コンポーネントが発行する`@thaw`イベントを検知するために、バインディングしています。
App.vue は`@thaw`イベントを受け取ると、関数`thaw_snow()`を実行します。

## 雪情報配列からの削除
`thaw_snow()`はアニメーションの終了した雪を配列から削除します。  

```Javascript:App.vue
thaw_snow(index){
  this.perticles.splice(index, 1)
},
```
引数で配列番号を受け取り、`splice()`で配列要素を削除します。

## まとめ
Vue.js でコンポーネント化した要素の扱い方を学ぶために、自動生成やメッセージを試してみました。
しかしながら要素の削除においては、ICS Media の 池田 泰延 様が以下のような調査報告をなさっています。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">Vue.jsの性能が優れないのはDevToolsを使うと理解できます。<br><br>リストレンダリングにおいて、追加・更新時は問題ないものの、削除時には仮想DOMから実DOMへの反映がうまくいっていません。<br>（要素が全部作り直されている）<br><br>比較検証を実施することで、Vue.jsに苦手処理があることが判明するわけです。 <a href="https://t.co/2eVu6ErQvq">pic.twitter.com/2eVu6ErQvq</a></p>&mdash; 池田 泰延 (@clockmaker) <a href="https://twitter.com/clockmaker/status/1156754496195715072?ref_src=twsrc%5Etfw">August 1, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

要素を動的に追加、削除する際は注意が必要ですね。