このプレイヤーの実装方法がお題です。
**ReactHooksでつくった版**はこちら
はじめに
この記事は、React コンポーネントで内部状態をもつ要素を扱うための How を書いたものです。
今回のネタでいうと、内部状態をもつ要素とは、audio 要素のことです。
ここでいう内部状態をもつ要素はどんなものかというと、
const audio = new Audio("sample.mp3") // コンストラクタでaudio要素を生成
audio.play() // 再生
audio.currentTime = 50 // 経過時間を50秒にする
的なやつです。
内部状態を持っている要素は React の setState で更新する対象にできないし、しないべきです。
(↑ このテーマは 大きくて書ききれないので割愛します)
そうなると出てくる問題として、audio 要素の状態を直接更新しても、React コンポーネントは再描画されない んですよね。
はい。今回の問題は上記の通りで、 目指すのは、
audio 要素の状態を更新すると React コンポーネントが再描画される です。
そのために今回、render prop という React コンポーネントの実装パターンを使います。
render prop について
render prop について今回は詳しく説明しませんが、以前書いた記事で使用例などを交えて説明しています。
簡単に説明すると 「コンポーネントの render メソッドを外部から定義するためのテクニック」 です。
本テーマ: 内部状態をもつ要素を React で扱うための How
まず render prop パターンを使って何がしたいかというと、
audio 要素(内部状態をもつ厄介なやつ)の状態管理と画面描画の責務を分離したいんです。
audio 要素の状態を知ってるヤツには見た目の知識を与えず、その逆もまた然りとする感じです。
なので、実装としては、大きく二つのコンポーネントを用意します。
-
AudioProvider: audio の内部状態を知ってるし、変更できるけど、audio 要素が画面でどう使われるかは知らないコンポーネント
-
AudioPlayer: Provider から audio の情報(経過時間とか)を受け取って、画面を表示するコンポーネント
AudioPlayer (audio 情報を受け取って見た目を作るコンポーネント)
まず、audio 要素の 情報を受け取って画面表示する側の実装をお見せします
今回は冒頭の GIF でお見せした通り、再生・停止と 30 秒ジャンプができるだけのシンプルなプレイヤーを作りました。
const AudioPlayer = () => (
<AudioProvider
url="path/to/audioUrl"
render={({ currentTime, paused, play, pause, jump }) => (
<div>
<p>currenttime: {currentTime}</p>
<button onClick={paused ? play : pause}>
{paused ? "Play" : "Pause"}
</button>
<button onClick={() => jump(30)}>30sec ▶︎</button>
</div>
)}
/>
);
render={({ currentTime, paused, play, pause, jump }) => ...
の部分ですが、
render という名前の props に 引数を受け取ってコンポーネントを返す関数
を渡しています。
実際、このコンポーネントはそんな重要ではないです。 値や関数を受け取って画面に表示してるだけなので。
ただよくみると、受け取っている引数が currentTime(音声の経過時間)
とか play(再生するための関数)
とかですが、これは AudioProvider から渡されます。
それでは本題の AudioProvider 側の実装を 通して、 引数どっからきてるの?
を 紹介します
AudioProvider (audio の内部状態を扱うコンポーネント)
class AudioProvider extends React.Component {
audio = new Audio(this.props.url); // audio要素の生成
componentDidMount = () => {
/**
* audioの内部状態に変化があったときに再描画するための処理
* ex.) this.playメソッドが実行されると、"play" を登録してるイベントリスナーが反応し、
* コンポーネントが強制的に再描画される
*/
this.audio.addEventListener("play", this.forceUpdate);
this.audio.addEventListener("pause", this.forceUpdate);
this.audio.addEventListener("ended", this.forceUpdate);
this.audio.addEventListener("timeupdate", this.forceUpdate);
};
componentWillUnmount = () => {
this.audio.removeEventListener("play", this.forceUpdate);
this.audio.removeEventListener("pause", this.forceUpdate);
this.audio.removeEventListener("ended", this.forceUpdate);
this.audio.removeEventListener("timeupdate", this.forceUpdate);
};
// --------状態変更用のコールバック関数--------
play = () => this.audio.play();
pause = () => this.audio.pause();
jump = value => (this.audio.currentTime += value);
render = () =>
this.props.render({
currentTime: this.audio.currentTime,
paused: this.audio.paused,
play: this.play,
pause: this.pause,
jump: this.jump
});
}
AudioProvider が
- audio 要素を保持し、
- 変更をゴリゴリ行い、
- 変更を検知して自分自身(とその配下の AudioPlayer コンポーネント)を再描画し、
- 新しい状態を render prop に渡す
などなど、汚れ仕事をたくさんしています。
ここで大事なのは、 audio要素自体をrender propに渡さない
ということです。
audio 要素の変更がAudioProviderのおかげでブラックボックスにできているのに、
audio要素自体を渡してしまうと、渡された側(AudioPlayer 側)でaudio要素の状態を変えたりできちゃいます。
それを防ぐために、
audio 要素を分解して currentTime や paused などを ただの値として render prop に渡しているワケです。
render = () =>
this.props.render({
currentTime: this.audio.currentTime,
paused: this.audio.paused,
play: this.play,
pause: this.pause,
jump: this.jump
});
さいごに
audio 要素だけではなくて、 内部状態をもつ要素をReactコンポーネントで綺麗に扱いたい 場合はこのパターンでだいたい乗り切れる気がします。
ただ、解決策は今回の render prop パターンだけではないと思うので、みなさんのプラクティスも、ぜひ教えて下さい