Пример 002: Интеграция с Web API
Обратите внимание на жизненный цикл компонента: очистка и освобождение системных ресурсов плеера.
import { useEffect } from 'react'
import baseClasses from '~/ui.common.module.scss'
import btnClasses from '~/ui.button.module.scss'
import { ReactiveEngine, useReactiveValue } from '@pravosleva/reactive-engine'
import clsx from 'clsx'
import { AudioPlayerLogic } from './service.AudioPlayerLogic'
const engine = new ReactiveEngine()
export const AudioPlayerExample = () => {
const logic = engine.inject(AudioPlayerLogic)
const { loading, error } = useReactiveValue(logic.audioResource)
const currentTrackId = engine.use(logic.currentTrackId)
const isPlaying = engine.use(logic.isPlaying)
useEffect(() => {
return () => {
logic.destroy()
}
}, [logic])
const activeTrack = logic.tracks.find(t => t.id === currentTrackId)
return (
<div
className={clsx(baseClasses.unit, baseClasses.stack2)}
style={{ fontFamily: 'system-ui', minWidth: '400px' }}
>
<div className={baseClasses.absoluteUnitLabel}>Simple Audio Player (DI + Cache)</div>
<div className={baseClasses.stack2}>
{logic.tracks.map((track) => {
const isSelected = track.id === currentTrackId
const isThisTrackError = isSelected && !!error
return (
<div
key={track.id}
onClick={() => logic.selectTrack(track.id)}
style={{
padding: '12px',
borderRadius: '8px',
cursor: 'pointer',
border: isThisTrackError ? '2px solid #ef5350' : isSelected ? '2px solid #1a73e8' : '2px solid lightgray',
display: 'flex',
justifyContent: 'space-between',
gap: '8px',
alignItems: 'center',
transition: 'all 0.2s ease'
}}
>
<b>{track.title}</b>
{
isThisTrackError
? <span>Ошибка загрузки 🔴</span>
: isSelected && loading
? <span>Загрузка... 🟡</span>
: isSelected
? <span>🟢</span>
: <span>⚪</span>
}
</div>
)
})}
</div>
{activeTrack && (
<div className={baseClasses.stack2}>
<div style={{ textAlign: 'left' }}>
Выбрано: <b>{activeTrack.title}</b>
</div>
{error && (
<div style={{ color: '#ef5350', width: '100%', textAlign: 'center' }}>
Error: {error.message}
</div>
)}
{
!error && (
<div style={{ display: 'flex', gap: '16px', justifyContent: 'center', width: '100%' }}>
<button
disabled={loading || !!error || isPlaying}
onClick={() => logic.play()}
className={clsx(
btnClasses.btn,
btnClasses.neonBtn,
btnClasses['neonBtn--primary'],
{
[btnClasses['neonBtn--outlined']]: !isPlaying,
[btnClasses['neonBtn--contained']]: isPlaying
},
)}
style={{ opacity: loading || !!error || isPlaying ? 0.5 : 1 }}
>
Play
</button>
<button
disabled={!isPlaying}
onClick={() => logic.stop()}
className={clsx(
btnClasses.btn,
btnClasses.neonBtn,
{
[btnClasses['neonBtn--primary']]: isPlaying,
[btnClasses['neonBtn--secondary']]: !isPlaying,
},
btnClasses['neonBtn--outlined']
)}
style={{ opacity: !isPlaying ? 0.5 : 1 }}
>
Stop
</button>
</div>
)
}
</div>
)}
</div>
)
}Зачем нужен этот useEffect в этом коде?
- Предотвращение утечек памяти (Memory Leaks): Браузерный объект
AudioContextявляется тяжелой системной сущностью, которая удерживает аудиовыходы устройства. Методlogic.destroy()принудительно закрывает контекст (context.close()) и отвязывает нативные узлы звука (audioSourceNode.disconnect()) в момент ухода компонента с экрана.
export class AudioPlayerLogic extends AbstractService {
// ...
public stop() {
if (this.audioSourceNode) {
try {
this.audioSourceNode.stop()
} catch (e) {
// Игнорируем ошибки, если нода уже завершила работу
}
this.audioSourceNode.disconnect()
this.audioSourceNode = null
}
this.isPlaying.value = false
}
public destroy() {
this.stop()
if (this.audioContext) {
this.audioContext.close()
this.audioContext = null
}
this.currentTrackId.value = null
this.audioCache.clear()
}
}- Очистка оперативной памяти (Heap): Метод очищает накопительный кэш
audioCache.clear(). Без этого декодированные бинарные массивыAudioBufferвсех прослушанных треков остались бы замороженными в памяти приложения навсегда, так как Garbage Collector не может удалить их при наличии живых ссылок внутри синглтон-сервисаAudioPlayerLogic. - Безопасность рантайма: Сброс реактивных сигналов в
nullгарантирует, что при повторном открытии этой страницы плеер стартует с чистого, предсказуемого состояния (без зависших ID треков).
Метод engine.inject(AudioPlayerLogic) возвращает стабильную ссылку на синглтон-сервис, которая никогда не меняется на протяжении всей жизни приложения. Поскольку ссылка logic стабильна, сам хук useEffect при обычном изменении реактивных сигналов и рендерах компонента выполняться повторно не будет.
Соответственно, cleanup-функция...
useEffect(() => {
return () => {
logic.destroy()
}
}, [logic])...выполнится ровно один раз — в момент полного размонтирования (Unmount) React-компонента с экрана. Произойдет это в этих случаях:
- Переключение страниц / вкладок (Routing)
- Условный рендеринг (Conditional Rendering) в родителе
- Горячая перезагрузка при разработке (Vite Hot Reload / Fast Refresh)
Таким образом, массив зависимостей [logic] здесь играет роль предохранителя, который сообщает React: «Следи за сервисом. Пока сервис тот же, ничего не делай. Но если компонент вообще исчезнет из интерфейса — обязательно вызови деструктор плеера»
Логика плеера вынесена в отдельный класс (сервис):
import { AbstractService } from '@pravosleva/reactive-engine'
export interface Track {
id: string
title: string
url: string
}
const TRACK_LIST: Track[] = [
{
id: '1',
title: 'Что Где когда - Пауза 1',
url: 'https://pravosleva.pro/d/sounds-in-access-2025/static/projects/what-where-when/audio/pause1.mp3',
},
{
id: '2',
title: 'Что Где когда - Пауза 3',
url: 'https://pravosleva.pro/d/sounds-in-access-2025/static/projects/what-where-when/audio/pause3.mp3',
},
{
id: '3',
title: 'Incorrect URL',
url: 'https://gdebenzin.app',
}
]
export class AudioPlayerLogic extends AbstractService {
public tracks: Track[] = TRACK_LIST
// Реактивные сигналы состояния для UI
public currentTrackId = this.createSignal<string | null>(null, 'player:signal:track-id')
public isPlaying = this.createSignal<boolean>(false, 'player:signal:is-playing')
// Накопительный кэш аудио-буферов
private audioCache = new Map<string, AudioBuffer>()
// Инфраструктурные объекты Web Audio API
private audioContext: AudioContext | null = null
private audioSourceNode: AudioBufferSourceNode | null = null
/**
* Реактивный ресурс загрузки аудио.
* Отвечает за реактивные статусы loading/error в UI при смене currentTrackId.
*/
public audioResource = this.engine.resource(
async (trackId, abortSignal) => {
if (!trackId) return null
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
}
// Если трек уже есть в кэше, возвращаем его мгновенно
if (this.audioCache.has(trackId)) {
return this.audioCache.get(trackId)!
}
const track = this.tracks.find(t => t.id === trackId)
if (!track) throw new Error('Трек не найден')
const response = await fetch(track.url, { signal: abortSignal })
if (!response.ok) throw new Error(`Ошибка сети: ${response.status}`)
const arrayBuffer = await response.arrayBuffer()
const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer)
// Сохраняем в накопительный кэш
this.audioCache.set(trackId, audioBuffer)
return audioBuffer
},
this.currentTrackId,
{
name: 'player:resource:load-audio',
validateBeforeFetch: (trackId) => !!trackId
}
)
/**
* Экшен простого выбора трека из списка (БЕЗ автоплея)
*/
public selectTrack(trackId: string) {
this.stop()
this.currentTrackId.value = trackId
}
/**
* Экшен запуска воспроизведения
*/
public play() {
const buffer = this.audioResource.data
// Если файл еще грузится, произошла ошибка или музыка уже играет — выходим
if (!buffer || !this.audioContext || this.isPlaying.value) return
// Пробуждаем контекст (нужно, если браузер заблокировал его до первого клика)
if (this.audioContext.state === 'suspended') {
this.audioContext.resume()
}
// Создаем чистую одноразовую ноду источника звука
this.audioSourceNode = this.audioContext.createBufferSource()
this.audioSourceNode.buffer = buffer
this.audioSourceNode.connect(this.audioContext.destination)
// Коллбэк естественного окончания трека
this.audioSourceNode.onended = () => {
if (this.isPlaying.value) {
this.isPlaying.value = false
}
}
this.audioSourceNode.start(0)
this.isPlaying.value = true
}
/**
* Экшен полной остановки воспроизведения
*/
public stop() {
if (this.audioSourceNode) {
try {
this.audioSourceNode.stop()
} catch (e) {
// Игнорируем ошибки, если нода уже завершила работу
}
this.audioSourceNode.disconnect()
this.audioSourceNode = null
}
this.isPlaying.value = false
}
/**
* Деструктор сервиса для очистки при размонтировании
*/
public destroy() {
this.stop()
if (this.audioContext) {
this.audioContext.close()
this.audioContext = null
}
this.currentTrackId.value = null
this.audioCache.clear()
}
}