SPAで動画を使って大ヤケドした話


# tl;dr

気軽に `removeChild` するもんじゃない。

フレームワーク（Riot.js）は悪くない

# 概要

動画メインの小規模サイト（厳密にはSPAと言わないかも）

## スペック

- 動画は **[YouTube IFrame API](https://developers.google.com/youtube/iframe_api_reference?hl=ja)** と一部に **[video要素](https://developer.mozilla.org/ja/docs/Web/HTML/Element/video)** を使用
- フロントのフレームワークは **[Riot.js](http://riotjs.com/)**
- URL制御はRiot.jsのルータを使う
- バックエンドはSinatra（この記事には関係ない）

# YouTube IFrame APIで起こった問題

## iframeをDOMツリーから切り離すとYouTubeのエラー頻発

```html:youtube.tag
<youtube>
<iframe if={ page == 'hoge' } id="player" src="~" />
<script>
routing (path) {
	this.page = path
}
riot.route(this.routing)

onReady () {
	this.player = new YT.Player('player', option)
}
window.onYouTubeIframeAPIReady = this.onReady
</script>
</youtube>
```

Riot.jsのテンプレートエンジンの便利機能のひとつ **条件属性** をつかって、ページを判定して表示の切り替える設計にしていた。
if属性はCSSの`display: none;`ではなく`removeChild`でDOMツリーからの削除を行う。
なのでページによっては`iframe`がDOMツリー上にない状態ができるのだが、紐付いている`this.player`（`YT.Player`インスタンス）の **[postMessage](https://developer.mozilla.org/ja/docs/Web/API/Window/postMessage)** のやり取りに失敗しエラーを吐いて、その **スコープ内の処理が完全に死ぬ** 。
（ **postMessage** の使用なのかよくわかってないのだけど）`try/catch`で何故かエラーをキャッチできない。postMessageが行う通信自体をどうにか止める必要がある。


### 解決策: ちゃんと`destroy`する

`this.player`と`<iframe>`の関係を断ち切る唯一の方法は `this.player.destroy()`メソッドを呼び出すこと。
`destroy`メソッドは実行した時点で`<iframe>`を自動的にDOMツリーから削除する。また、`<iframe>`がDOMツリー上にない状態で実行すると、即座にエラーを吐いて死ぬので、`<iframe>`は自分で管理しないといけない。
つまり残念ながら `<iframe>`要素自体をRiotに任せるわけにはいかなく、愚直に[バニラ](http://qiita.com/nobkz/items/9c79f019f93335ee5140)で `createElement` して要素をつくり、適宜 `appendChild`でDOMに追加し、DOMから削除は`this.player.destroy()`するという方法をとった。

結構色々試して念には念を入れたソースになってしまった。もしかしたら無駄な処理があるかもしれない。

```javascript:youtube.tag
// this.iframe = document.createElement('iframe') とする

// DOMツリーから削除する場合
this.player.stopVideo() // 一応止める
var iframe = this.player.getIFrame()
if (iframe) { // 紐付いているiframeがあるかどうか念のため確認
	this.player.destroy()
}
iframe = document.getElementById('player')
if (iframe) {
	this.root.removeChild(iframe);
}
if (this.iframe && this.iframe.parentNode) {
	this.root.removeChild(this.iframe)
}
this.player = null
this.iframe = null
```

# video要素で起こった問題

## 1. video要素をDOMツリーから切り離しても音が鳴ったまま

同じくRiotにまかせて `<video if={ page === 'hoge' }>` のような書き方をしていたんだけど、どうも再生中にページを切り替えても **音が消えない**。デベロッパーツールからDOMツリーを参照したり、`querySelector`で探しても、どう考えても`<video>`が存在しないのに動画の音声だけ流れて続けている。

### 解決策: ちゃんと `pause` する

最初はこの仕様に驚きを隠せなかったけど、まあ…そういう仕様なら仕方ない。やってないけどきっとAudioもそうなんだろう。覚えておこう。
そしてつまり **やっぱり** Riotには任せることができないので、`createElement`で生成したvideo要素をしっかり管理して、動画を停止させてから`removeChild`するようにした。

```javascript:x-video.tag
// this.video = document.createElement('video') とする

this.video.pause()
this.root.removeChild(this.video)
this.video.removeEventListener('xxx', this.onxxx)
this.video = null
```

## 2. 字幕が重複する

動画を他のソースに更新する際、video要素ならそのまま `src`にパスを代入しなおせば動画が切り替わる。
しかし字幕を扱うtrack要素はそうはいかなかった。

```javascript:x-video.tag
this.video.src = '/path/filename.mp4'
this.track.src = '/path/filename.vtt'
```

こうしてしまうと、前回代入した字幕がそのまま出るので、更新する度、二重・三重に字幕が出てしまう。

### 解決策: 都度track要素を生成する

```javascript:x-video.tag
// 消す
if (this.track) {
	this.video.removeChild(this.track)
	this.track = null
}
// 新しく要素を生成する
this.track = document.createElement('track')
this.video.appendChild(this.track)

this.video.src = '/path/filename.mp4'
this.track.src = '/path/filename.vtt'
```

なにこれ面倒くさッ。でもこれが一番早い解決方法だった。

# まとめ

JSのメモリ上に存在する要素のインスタンスと、DOMツリーの関係をいまいち理解していなかったのが最大の要因だったと今更思う。
`removeChild`しても要素はメモリーに残って仕事をし続けるので、きちんとした手順で仕事を終わらせてあげてから削除しないといけない。

今回はRiot.jsだったけど、それに限らずテンプレートエンジンや仮想DOMを採用しているフレームワークは、そのへんが意識されて `removeChild`（Riotの場合は一部`innerHTML`）はしていないと思うので、iframeとvideo、あとたぶんaudio要素は気をつけないといけないと思った。
それを **考慮したコンポーネント** を見つけたり、開発したりすることも今後大切かなとも思う。