tl;dr
気軽に removeChild
するもんじゃない。
フレームワーク(Riot.js)は悪くない
概要
動画メインの小規模サイト(厳密にはSPAと言わないかも)
スペック
- 動画は YouTube IFrame API と一部に video要素 を使用
- フロントのフレームワークは Riot.js
- URL制御はRiot.jsのルータを使う
- バックエンドはSinatra(この記事には関係ない)
YouTube IFrame APIで起こった問題
iframeをDOMツリーから切り離すとYouTubeのエラー頻発
<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 のやり取りに失敗しエラーを吐いて、その スコープ内の処理が完全に死ぬ 。
( postMessage の使用なのかよくわかってないのだけど)try/catch
で何故かエラーをキャッチできない。postMessageが行う通信自体をどうにか止める必要がある。
解決策: ちゃんとdestroy
する
this.player
と<iframe>
の関係を断ち切る唯一の方法は this.player.destroy()
メソッドを呼び出すこと。
destroy
メソッドは実行した時点で<iframe>
を自動的にDOMツリーから削除する。また、<iframe>
がDOMツリー上にない状態で実行すると、即座にエラーを吐いて死ぬので、<iframe>
は自分で管理しないといけない。
つまり残念ながら <iframe>
要素自体をRiotに任せるわけにはいかなく、愚直にバニラで createElement
して要素をつくり、適宜 appendChild
でDOMに追加し、DOMから削除はthis.player.destroy()
するという方法をとった。
結構色々試して念には念を入れたソースになってしまった。もしかしたら無駄な処理があるかもしれない。
// 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
するようにした。
// 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要素はそうはいかなかった。
this.video.src = '/path/filename.mp4'
this.track.src = '/path/filename.vtt'
こうしてしまうと、前回代入した字幕がそのまま出るので、更新する度、二重・三重に字幕が出てしまう。
解決策: 都度track要素を生成する
// 消す
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要素は気をつけないといけないと思った。
それを 考慮したコンポーネント を見つけたり、開発したりすることも今後大切かなとも思う。