はじめに
現在弊社ではスポーツメディアサービス"スポーツブル"の大規模なリプレイスが進行中で、フロントエンドについては2月末にリリースを行いました。
フロントエンドは、従来のイベントページごとに Vue の開発環境があり開発環境の乱立状態から、 Next.js (App Router) へと全面的にリプレイスしました。
また、スポーツブルではスポーツのライブ映像配信を行っています。
そのために OVP (Online Video Platform) として Brightcove や STREAKS (PLAY社), YouTube などを使用しており、フロントエンドではそれに対応した動画プレーヤーを使用しています。(Brightcove, STREAKS は video.js ベースの SDK が提供されています)
リプレイスにあたって、それら動画プレーヤーを React で書き直すことになったわけですが、その際にややハマった点を記載します。
課題
Failed to execute 'removeChild' on 'Node'
React のSPA(CSR遷移)で素朴に <video> 要素を JSX で書いて SDK に渡すと、ページ遷移時に React が管理しているはずの DOM を、SDK が先に消してしまうことによって発生しています。
TL;DR
| 方式 | React が管理する要素 | SDK が管理する要素 | CSR遷移 |
|---|---|---|---|
| 素朴な方式 | <video> |
<video> (同じ) |
💥 衝突 |
| divコンテナパターン |
<div> のみ |
<video> (動的生成) |
✅ 安全 |
コア原則: React と SDK の DOM 管理範囲を完全に分離する。 これを個人的な命名ですが "divコンテナパターン" と呼ぶことにします。
動画プレーヤーSDKに限らず、SDK側がDOM操作を行うタイプのSDKは同様の実装で対処できるはずです。
この原則は React ドキュメントの以下で述べられています。本記事ではこれを動画プレーヤー SDK に適用する際の具体的な実装パターン(Strict Mode 対策、広告プラグインとの共存、SDK 別の初期化・破棄)を扱います。
https://react.dev/learn/manipulating-the-dom-with-refs#best-practices-for-dom-manipulation-with-refs
You can safely modify parts of the DOM that React has no reason to update. For example, if some
<div>is always empty in the JSX, React won’t have a reason to touch its children list. Therefore, it is safe to manually add or remove elements there.
目次
- 問題:なぜ衝突が起きるのか
- 解決策:divコンテナパターン
- SDK別の実装
- ネイティブ
<video>との使い分け - React 18 Strict Mode への対応
- 広告プラグインとの共存
- まとめ
問題:なぜ衝突が起きるのか
素朴なアプローチ
// ❌ よくある実装:JSX で <video> を書き、ref で SDK に渡す
function NaivePlayer({ videoId }: { videoId: string }) {
const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => {
if (!videoRef.current) return
const player = videojs(videoRef.current, { /* options */ })
return () => {
player.dispose() // 💥 ここで問題が起きる
}
}, [videoId])
return <video ref={videoRef} className="video-js" />
}
何が起きるか
- CSR遷移(Next.js の
router.push()等)でコンポーネントがアンマウントされる - cleanup 関数で
player.dispose()が呼ばれる -
dispose()は SDK が管理する<video>要素とその子要素を DOM から削除する -
React も同じ
<video>要素の削除を試みる →removeChildエラー
Uncaught DOMException: Failed to execute 'removeChild' on 'Node':
The node to be removed is not a child of this node.
根本原因はReact と SDK の両方が同じ DOM ノードの所有権を主張していることです。
React の仮想DOM: <div> → <video ref={videoRef} /> ← React が管理
SDK の内部状態: <video> → コントロールUI、広告レイヤー等 ← SDK が管理
↑ 同じノード!
解決策:divコンテナパターン
React には <div> コンテナだけを管理させ、<video> 要素は useEffect 内で動的に生成する。
React の仮想DOM: <div ref={containerRef} /> ← React が管理(これだけ)
SDK の内部状態: <div> → <video> → コントロールUI ← SDK が管理
↑ React は中身を知らない
これにより、dispose() が <video> 以下を全て削除しても、React は <div> しか管理していないため衝突しません。
基本テンプレート(video.js の例)
function SafePlayer({ videoId }: { videoId: string }) {
const containerRef = useRef<HTMLDivElement>(null)
const playerRef = useRef<Player | null>(null)
useEffect(() => {
if (!containerRef.current) return
// 1. コンテナをクリア(Strict Mode 対策)
containerRef.current.innerHTML = ''
// 2. <video> 要素を動的に生成
const videoEl = document.createElement('video')
videoEl.className = 'video-js'
videoEl.controls = true
videoEl.playsInline = true
containerRef.current.appendChild(videoEl)
// 3. SDK で初期化
const player = videojs(videoEl, { /* options */ })
playerRef.current = player
// 4. Cleanup: SDK の dispose() に全てを任せる
return () => {
if (playerRef.current) {
playerRef.current.dispose()
playerRef.current = null
}
// 念のためコンテナもクリア
if (containerRef.current) {
containerRef.current.innerHTML = ''
}
}
}, [videoId])
// React は <div> だけを描画
return <div ref={containerRef} />
}
SDK別の実装
video.js
素の video.js の場合、前述の基本テンプレートがそのまま適用できます。コード例は基本テンプレートを参照してください。
ポイント: dispose() は video.js の内部状態(IMA3 広告プラグインの再生済みフラグなど)もリセットします。CSR遷移で同じ記事に戻った際に広告が正しく再初期化されるためには、この完全なリセットが必須です。
STREAKS
STREAKS は video.js ベースの動画配信SDKです。divコンテナパターンの部分(<video> の動的生成、dispose() による cleanup)は video.js と同じですが、初期化に STREAKS 固有の API(window.streaksplayer() / PlaybackApi)を使います。
なお、STREAKS SDK はスクリプトの動的ロードが必要です。実際のプロダクションコードでは SDK ロード完了を state で管理し、ロード完了後に別の useEffect でプレーヤーを初期化する二段階のアプローチを取っています。ここでは SDK ロード済みを前提とした簡略版を示します。
function StreaksPlayer({ projectId, mediaId, apiKey, autoPlay, muted }: StreaksProps) {
const containerRef = useRef<HTMLDivElement>(null)
const playerRef = useRef<StreaksPlayerInstance | null>(null)
useEffect(() => {
if (!containerRef.current) return
// Strict Mode: 前回のマウントの残骸をクリア
containerRef.current.innerHTML = ''
// <video> を動的生成(video.js と同じ)
const videoEl = document.createElement('video')
videoEl.id = `streaks-${mediaId}`
videoEl.className = 'video-js'
videoEl.controls = true
videoEl.muted = muted
videoEl.playsInline = true
containerRef.current.appendChild(videoEl)
// STREAKS SDK でプレーヤーを作成(window.streaksplayer は SDK のグローバル関数)
const player = window.streaksplayer(videoEl.id, { muted })
playerRef.current = player
// Playback API でメディアを取得し、player.loadMedia() で読み込む
const api = new window.streaks.api.PlaybackApi({
host: 'playback.api.streaks.jp',
project: projectId,
apiKey,
})
api.params({ media_id: mediaId })
api.request((err, playbackJson) => {
if (err) return
player.loadMedia(playbackJson, { autoplay: autoPlay, muted })
})
// Cleanup は video.js と同じ
return () => {
if (playerRef.current) {
playerRef.current.dispose()
playerRef.current = null
}
if (containerRef.current) {
containerRef.current.innerHTML = ''
}
}
}, [projectId, mediaId, apiKey, autoPlay, muted])
return <div ref={containerRef} />
}
STREAKS 固有のポイント:
-
window.streaksplayer()が STREAKS SDK のプレーヤー生成関数(video.js のvideojs()に相当) -
PlaybackApiでメディア情報を取得し、player.loadMedia()で広告設定とともに読み込む - SDK のスクリプトロードが非同期のため、プロダクションでは
isSdkLoadedstate による二段階初期化が必要
Brightcove
Brightcove は video.js ベースですが、独自の初期化フローがあります。
function BrightcovePlayer({ videoId, accountId, autoPlay, muted }: BrightcoveProps) {
const containerRef = useRef<HTMLDivElement>(null)
const playerRef = useRef<BrightcoveVideoJsPlayer | null>(null)
useEffect(() => {
if (!containerRef.current) return
let destroyed = false
// Strict Mode 対策
containerRef.current.innerHTML = ''
// <video> を動的生成(data 属性で Brightcove に情報を渡す)
const videoEl = document.createElement('video')
videoEl.id = `brightcove-${videoId}`
videoEl.dataset.videoId = videoId
videoEl.dataset.account = accountId
videoEl.dataset.player = 'default'
videoEl.dataset.embed = 'default'
videoEl.className = 'video-js'
videoEl.controls = true
videoEl.muted = muted
videoEl.playsInline = true
containerRef.current.appendChild(videoEl)
// Brightcove Player スクリプトを注入
const script = document.createElement('script')
script.src = `//players.brightcove.net/${accountId}/default_default/index.min.js`
document.head.appendChild(script)
// スクリプトによる自動初期化の完了を待つ(非同期のためポーリング)
const pollId = setInterval(() => {
if (destroyed) { clearInterval(pollId); return }
if (typeof videojs === 'undefined') return
const player = videojs.getPlayer(videoEl.id)
if (!player) return
clearInterval(pollId)
playerRef.current = player
player.ready(() => {
if (destroyed) return
// ✅ autoplay は HTML 属性ではなく player.ready() 内で制御
if (autoPlay) player.play()
})
}, 200)
return () => {
destroyed = true
clearInterval(pollId)
if (playerRef.current) {
playerRef.current.dispose()
playerRef.current = null
}
if (containerRef.current) {
containerRef.current.innerHTML = ''
}
// スクリプトも削除(次回マウント時にフレッシュなスクリプトをロード)
script.remove()
}
}, [videoId, accountId, autoPlay, muted])
return <div ref={containerRef} />
}
Brightcove 固有の注意点:
-
スクリプトによる自動初期化: Brightcove Player のスクリプトは
<video>の data 属性を自動検出してプレーヤーを初期化するが、CSR遷移などで再マウントした場合、スクリプトのキャッシュにより自動検出が動かないことがある。videojs.getPlayer()でポーリングすることで確実に取得する。 -
スクリプトの差し替え: cleanup 時に
script.remove()で古いスクリプトを削除し、次のマウントで新しいスクリプトを追加する。これにより IMA3 広告プラグインの UI が正しく再構築される。 -
autoplay の扱い: HTML の
autoplay属性は使わない。スクリプト再実行のタイミング次第で広告プラグインと競合するため、player.ready()内でplayer.play()を明示的に呼ぶ。プロダクションではブラウザの自動再生ポリシーでplay()が拒否された場合のミュート再試行も必要。
参考:
Brightcove の React 実装としては React Player Loader という、 Brightcove が公開しているが公式サポート対象外のパッケージがありますが、サポート対象外なのとやや古そうなので使用していません。
https://player.support.brightcove.com/coding-topics/react-player-loader.html
The React Player Loader is an open source tool and is NOT officially supported by Brightcove. Also, this library supports only Brightcove Players v6.11.0 and higher.
YouTube IFrame API
YouTube は video.js 系ではありませんが、同じ原則が適用できます。YT.Player API は渡された <div> を <iframe> に置換する仕組みです。
function YouTubePlayer({ videoId, autoPlay, muted }: YouTubePlayerProps) {
const containerRef = useRef<HTMLDivElement>(null)
const ytPlayerRef = useRef<YT.Player | null>(null)
// SSR/CSR で安定した ID を生成
const reactId = useId()
const placeholderId = `yt-player${reactId.replace(/:/g, '-')}`
useEffect(() => {
let destroyed = false
loadYouTubeApi().then(() => {
if (destroyed) return
if (!document.getElementById(placeholderId)) return
// YT.Player が placeholderId の <div> を <iframe> に置換
ytPlayerRef.current = new YT.Player(placeholderId, {
videoId,
width: '100%',
height: '100%',
playerVars: {
autoplay: autoPlay ? 1 : 0,
mute: muted ? 1 : 0,
playsinline: 1,
origin: window.location.origin,
},
events: {
onReady: () => { /* ... */ },
onStateChange: (e) => {
switch (e.data) {
case YT.PlayerState.PLAYING: /* ログ開始 */ break
case YT.PlayerState.PAUSED: /* ログ停止 */ break
case YT.PlayerState.ENDED: /* ログ停止 */ break
}
},
},
})
})
return () => {
destroyed = true
if (ytPlayerRef.current) {
ytPlayerRef.current.destroy() // iframe を削除 + API 状態をリセット
ytPlayerRef.current = null
}
}
}, [videoId, autoPlay, muted, placeholderId])
return (
<div ref={containerRef}>
<div id={placeholderId} />
</div>
)
}
YouTube 固有のポイント:
-
destroy()が iframe の削除まで行うため、innerHTML = ''は不要 -
useId()で SSR/CSR の hydration ミスマッチを防ぐ -
originパラメータは postMessage のセキュリティに必要
ネイティブ <video> との使い分け
全ての動画プレーヤーにdivコンテナパターンが必要なわけではありません。
SDK を使わないネイティブ <video> は、React に直接管理させて問題ありません。
// ✅ ネイティブ <video> — React 管理で OK
function NativePlayer({ src, autoPlay }: { src: string; autoPlay?: boolean }) {
const videoRef = useRef<HTMLVideoElement>(null)
// autoplay 制御 + CSR遷移時の再生停止
// HTML の autoPlay 属性ではなく useEffect で制御することで、
// Strict Mode の cleanup でタイマーがキャンセルされ二重発火を防止する
useEffect(() => {
const videoEl = videoRef.current
if (!videoEl) return
let timer: ReturnType<typeof setTimeout> | null = null
if (autoPlay) {
// setTimeout(0) で次のタスクに遅延させ、
// Strict Mode の即座の unmount でキャンセルされるようにする
timer = setTimeout(() => {
videoEl.play().catch(() => {
// 自動再生がブロックされた場合、ミュートで再試行
videoEl.muted = true
videoEl.play().catch(() => {})
})
}, 0)
}
return () => {
// タイマーキャンセル + 再生停止
if (timer) clearTimeout(timer)
videoEl.pause()
}
}, [autoPlay])
return (
<video
ref={videoRef}
src={src}
controls
playsInline
onPlay={() => { /* ... */ }}
onPause={() => { /* ... */ }}
/>
)
}
判断基準
| 条件 | 方式 |
|---|---|
| SDK が DOM を自前管理する(video.js, Brightcove, YouTube API) | divコンテナパターン |
ネイティブ <video> をそのまま使う(HLS, mp4, WebM) |
React 管理 |
| SDK が React コンポーネントを提供している(react-player 等) | SDK のコンポーネントに従う |
判断のコツ: SDK に dispose() や destroy() メソッドがあるなら、divコンテナパターンが必要と考えてほぼ間違いありません。
React 18 Strict Mode への対応
React 18 の Strict Mode は開発時にエフェクトを2回実行します(mount → unmount → mount)。
divコンテナパターンでは、2回目の mount 時に前回の残骸がコンテナ内に残っている可能性があります。
useEffect(() => {
if (!containerRef.current) return
// ✅ エフェクト開始時にコンテナをクリア
// → 1回目の mount の残骸があっても安全
containerRef.current.innerHTML = ''
const videoEl = document.createElement('video')
containerRef.current.appendChild(videoEl)
// ...SDK 初期化...
return () => {
// ✅ Cleanup でもクリア
playerRef.current?.dispose()
if (containerRef.current) {
containerRef.current.innerHTML = ''
}
}
}, [/* deps */])
タイムライン(Strict Mode):
1st mount: innerHTML='' → createElement → SDK init
1st unmount: dispose() → innerHTML=''
2nd mount: innerHTML='' → createElement → SDK init ← ここが本番の mount
2nd unmount: (コンポーネント本来の unmount)
innerHTML = '' を mount と cleanup の両方で行うことで、どのタイミングでも安全になります。
ネイティブ <video> の場合は innerHTML クリアではなく、前述の setTimeout(0) + cleanup での clearTimeout パターンで対応します。Strict Mode の 1st unmount で clearTimeout によりタイマーがキャンセルされるため、play() の二重発火を防止できます。
広告プラグインとの共存
video.js 系の広告プラグイン(IMA3 等)は、プレーヤー初期化時に play → pause → play という一連のイベントを発火させることがあります。これをそのまま受け取ると、ログや分析で誤った再生開始イベントを記録してしまいます。
対処法は SDK や広告プラグインの構成によって異なりますが、我々のケースでは以下の2つのアプローチで対応しました。
-
デバウンス:
playイベントを即座に処理せず 200ms 待ち、その間にpauseが来たらキャンセルすることで、初期化チェーンを吸収する(STREAKS で採用) -
フラグ管理:
ads-ad-started/ads-ad-endedイベントで広告再生中フラグを管理し、広告中のplayイベントを無視する(Brightcove で採用)
どちらが必要かは実際にイベントログを確認してから判断するのが確実です。まずフラグ管理のみで実装し、play → pause → play の連鎖が確認されたらデバウンスを追加する段階的アプローチがおすすめです。
まとめ
divコンテナパターンの全体像
┌─ React の責務 ────────────────────────────────────┐
│ <div ref={containerRef} /> │
│ ↑ React はこの div のライフサイクルだけを管理 │
└───────────────────────────────────────────────────-─┘
┌─ useEffect 内 ────────────────────────────────────┐
│ mount: │
│ containerRef.innerHTML = '' // Strict Mode │
│ videoEl = createElement('video') │
│ containerRef.appendChild(videoEl) │
│ SDK.init(videoEl) // SDK に委譲 │
│ │
│ cleanup: │
│ player.dispose() // SDK が全管理 │
│ containerRef.innerHTML = '' // 念のためクリア │
└────────────────────────────────────────────────────┘
SDK別の対応表
| SDK | 初期化 | 破棄 | Strict Mode | 広告対応 |
|---|---|---|---|---|
| video.js | videojs(videoEl, options) |
dispose() + innerHTML=''
|
innerHTML='' |
SDK構成による |
| STREAKS |
streaksplayer() + PlaybackApi
|
dispose() + innerHTML=''
|
innerHTML='' |
デバウンス |
| Brightcove | スクリプト注入 + ポーリング |
dispose() + innerHTML='' + script.remove()
|
innerHTML='' |
フラグ管理 |
| YouTube | new YT.Player(divId) |
destroy() |
不要(destroy() で完結) |
N/A |
| ネイティブ video | React JSX | pause() |
setTimeout(0) + clearTimeout
|
N/A |
この方式が有効な場面
- CSR遷移のある SPA(Next.js App Router, React Router 等)
- SDK が DOM を自前管理する動画プレーヤー
- 広告プラグインが内部状態を持つケース
- React 18 Strict Mode で開発している場合
「React の管理範囲」と「SDK の管理範囲」を明確に分離するだけで、多くの動画SDK関連バグを予防できます。困ったら div を一枚挟む ——シンプルですが効果的なパターンです。
最後に
運動通信社は「日本を世界が憧れるスポーツ大国にする」というビジョンを達成するべく、一緒に働く仲間を募集しています!
PMやアプリエンジニア、Webエンジニアなど色んな職種を募集しておりカジュアル面談大歓迎なので是非採用フォームよりご連絡ください!
ぜひ一緒に、自分たちも世の中もワクワクさせるサービスを作りましょう!