年轻人第一个 React 项目:用 Nodejs 构建直播多路监控

年轻人的第一个React应用:用Node.js构建直播多路监控

本文适合:有一定前端基础,想学习React实战的开发者 技术栈:React + TypeScript + Node.js

前言:为什么要做这个项目?

作为一名云顶之弈爱好者,我经常需要同时观看多个主播的直播来学习不同阵容。但浏览器标签页来回切换实在太麻烦,于是萌生了开发一个多路直播监控工具的想法。

这个项目不仅解决了我的实际需求,还成为了我学习React的绝佳实践。今天就来分享这个项目的完整开发过程。

技术选型与项目搭建

为什么选择React + TypeScript?

bash 复制代码
# 创建前端项目(React)
npx create-react-app ominiview-monitor -- --template typescript

# 创建后端项目(Nodejs) 
npm init

选择React的原因:

  • 组件化思维:直播流卡片天然适合组件化开发
  • 生态丰富:丰富的UI库和工具链支持
  • TypeScript:类型安全,减少运行时错误

核心功能实现

1. 直播流卡片组件设计

typescript 复制代码
// StreamTile.tsx - 直播流卡片组件
interface StreamTileProps {
  stream: StreamSource;
  onRemove: (id: string) => void;
  onToggleMute: (id: string) => void;
}

const StreamTile: React.FC<StreamTileProps> = ({ 
  stream, 
  onRemove, 
  onToggleMute 
}) => {
  const [isLoading, setIsLoading] = useState(true);
  const [hasError, setHasError] = useState(false);
  
  return (
    <div className="stream-tile">
      {/* 视频播放区域 */}
      <video 
        src={stream.url}
        muted={stream.isMuted}
        onLoadedData={() => setIsLoading(false)}
        onError={() => setHasError(true)}
      />
      
      {/* 控制工具栏 */}
      <div className="controls">
        <button onClick={() => onToggleMute(stream.id)}>
          {stream.isMuted ? '🔇' : '🔊'}
        </button>
        <button onClick={() => onRemove(stream.id)}>❌</button>
      </div>
    </div>
  );
};

2. 多布局网格系统

typescript 复制代码
// 布局枚举定义
export enum GridLayout {
  Single = '1x1',
  Dual = '2x1', 
  Triple = '3x1',
  Quad = '2x2',
  Hex = '3x2'
}

// 动态网格类名生成
const getGridClass = (layout: GridLayout): string => {
  const gridMap = {
    [GridLayout.Single]: 'grid-cols-1 grid-rows-1',
    [GridLayout.Dual]: 'grid-cols-2 grid-rows-1',
    [GridLayout.Triple]: 'grid-cols-3 grid-rows-1',
    [GridLayout.Quad]: 'grid-cols-2 grid-rows-2',
    [GridLayout.Hex]: 'grid-cols-3 grid-rows-2'
  };
  return gridMap[layout];
};

3. 多平台直播源智能处理

typescript 复制代码
        // URL解析结果处理过程 - 支持虎牙、斗鱼多平台
        try {
          const response = await fetch('http://localhost:3001/api/parse-url', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({ url: customUrl }),
          });

          if (response.ok) {
            const result = await response.json();
            console.log('解析结果:', result);

            if (result.success && result.data) {
              const parsedData = result.data;

              // 链接优先级列表(按优先级从高到低)
              const urlCandidates: string[] = [];

              // 虎牙格式优先级
              if (parsedData.links?.flv && typeof parsedData.links.flv === 'object') {
                Object.values(parsedData.links.flv).forEach(url => {
                  if (typeof url === 'string') urlCandidates.push(url);
                });
              }

              if (parsedData.links?.hls && typeof parsedData.links.hls === 'object') {
                Object.values(parsedData.links.hls).forEach(url => {
                  if (typeof url === 'string') urlCandidates.push(url);
                });
              }

              // 斗鱼格式优先级
              if (parsedData.links?.pc && typeof parsedData.links.pc === 'string') {
                urlCandidates.push(parsedData.links.pc);
              }

              if (parsedData.links?.mobile && typeof parsedData.links.mobile === 'string') {
                urlCandidates.push(parsedData.links.mobile);
              }

              // CDN链接
              if (parsedData.links?.cdnLinks?.m3u8 && Array.isArray(parsedData.links.cdnLinks.m3u8)) {
                parsedData.links.cdnLinks.m3u8.forEach(url => {
                  if (typeof url === 'string') urlCandidates.push(url);
                });
              }

              if (parsedData.links?.cdnLinks?.flv && Array.isArray(parsedData.links.cdnLinks.flv)) {
                parsedData.links.cdnLinks.flv.forEach(url => {
                  if (typeof url === 'string') urlCandidates.push(url);
                });
              }

              // 代理链接
              if (parsedData.links?.proxy && typeof parsedData.links.proxy === 'string') {
                urlCandidates.push(parsedData.links.proxy);
              }

              // 原始流URL
              if (parsedData.streamUrl && typeof parsedData.streamUrl === 'string') {
                urlCandidates.push(parsedData.streamUrl);
              }

              // 选择第一个有效的链接
              if (urlCandidates.length > 0) {
                finalUrl = urlCandidates[0];
                console.log(`找到 ${urlCandidates.length} 个候选链接,选择:`, finalUrl);
              }

              finalTitle = parsedData.title || finalTitle;
            }
          }
        }

技术难点与解决方案

难点:FLV格式直播流播放

传统HTML5 video标签不支持FLV格式,我们使用flv.js库来解决:

typescript 复制代码
      import flvjs from 'flv.js';

      try {
        // 创建FLV播放器实例
        // 第一个参数是播放器配置对象
        const flvPlayer = flvjs.createPlayer({
          type: 'flv',           // 指定流媒体类型为FLV格式
          url: stream.url,        // 设置直播流URL地址
          isLive: true           // 标记为直播流(非点播)
        }, {
          // 第二个参数是播放器选项配置
          enableWorker: false,    // 禁用Web Worker(提高兼容性,但可能影响性能)
          lazyLoad: true,        // 启用懒加载,延迟加载视频数据
          lazyLoadMaxDuration: 3 * 60, // 懒加载最大时长:3分钟
          seekType: 'range',      // 设置seek类型为range(支持HTTP范围请求)
        });

        // 将播放器附加到视频DOM元素
        flvPlayer.attachMediaElement(videoRef.current);
        // 开始加载视频流
        flvPlayer.load();
        // 开始播放视频
        flvPlayer.play();

        // 监听加载完成事件
        flvPlayer.on(flvjs.Events.LOADING_COMPLETE, () => {
          setIsLoading(false);    // 隐藏加载状态
          setHasError(false);     // 清除错误状态
        });

        // 监听错误事件
        flvPlayer.on(flvjs.Events.ERROR, (error) => {
          console.error('FLV player error:', error); // 输出错误信息到控制台
          setIsLoading(false);    // 隐藏加载状态
          setHasError(true);      // 设置错误状态
        });

        // 监听元数据到达事件(流媒体信息已获取)
        flvPlayer.on(flvjs.Events.METADATA_ARRIVED, () => {
          setIsLoading(false);    // 隐藏加载状态
          setHasError(false);     // 清除错误状态
        });

        // 将播放器实例保存到ref中,便于后续管理和销毁
        flvPlayerRef.current = flvPlayer;

      } catch (error) {
        // 捕获创建播放器过程中的异常
        console.error('Failed to create FLV player:', error); // 输出错误信息
        setIsLoading(false);      // 隐藏加载状态
        setHasError(true);        // 设置错误状态
      }

部署与使用

本地开发

bash 复制代码
# 安装依赖
npm install

# 启动开发服务器
npm run start

生产构建

bash 复制代码
npm run build

总结与展望

通过这个项目,不仅掌握了React的核心概念(组件化、状态管理、生命周期),还深入理解了现代前端工程化实践。

技术收获:

  • React Hooks的熟练使用
  • TypeScript类型系统设计
  • 工程化构建配置

未来规划:

  • 支持更多直播平台(B站、抖音等)
  • 添加录制功能
  • 开发浏览器插件版本

给新手的建议

  1. 从实际需求出发:解决真实问题能让学习更有动力
  2. 循序渐进:先实现核心功能,再逐步优化
  3. 善用工具:gemini3、豆包等工具能提升开发效率
  4. 代码规范:从一开始就养成良好的编码习惯

源码地址

项目已开源,欢迎Star和贡献: 👉 GitHub仓库地址

如果文章对你有帮助,欢迎点赞收藏~有任何问题可以在评论区交流!

相关推荐
天蓝色的鱼鱼4 小时前
Ant Design 6.0 正式发布:前端开发者的福音与革新
前端·react.js·ant design
老刘莱国瑞5 小时前
前后端开发规范 (React + Flask + MongoDB)
mongodb·react.js·flask
LRH5 小时前
React函数组件与Hooks的实现原理
前端·javascript·react.js
LFly_ice8 小时前
学习React-25-React-路由懒加载
javascript·学习·react.js
w***Q3508 小时前
React调试
javascript·react.js·ecmascript
o***Z44818 小时前
React自然语言
前端·react.js·前端框架
J***Q29218 小时前
React部署方案详解
前端·react.js·前端框架
q***R30818 小时前
React组件性能分析
前端·react.js·前端框架
5***790018 小时前
React趋势
前端·react.js·前端框架