音视频通用组件设计探索和应用

1、背景介绍

业务层面,基于国家发展改革委办公厅日前印发关于加快推广远程异地评标的通知,部署在全国范围内加快推广远程异地评标,重在解决传统的评标问题,提升整体的公平性和公正性,优化专家资源的评标配置,音视频作为整个业务流程中评标环节关键的桥梁,起着重大的作用。

技术层面,针对目前各中心相关音视频的诉求,以及目前出现的音视频对接成本高、标准不统一、质量难把控、体验不一致等相关问题,提升整体研发效率,收敛用户体验、统一开发标准,保证相关性能检测等至关重要。下面我将详细介绍下在本次研发过程中,基于音视频相关底层原理的探索和通用化组件设计过程中遇到的一些问题探讨。

1.1 音视频监控

整个评标环节,主要用到音视频强化监管手段包括下面三大方面:

加强电子化评标过程中的监管力度,对于音视频各方面的能力,要求非常高,满足业务的整体电子化评标流程,针对不同的业务场景做定制化处理,那么孵化一款音视频通用化组件的产品,提供一站式配置音视频的能力,很有必要。可以有效解决不同团队接入的成本高、业务定制化、开评同屏等问题。

2、音视频WebRTC介绍

2.1 基本原理

音视频底层能力,是基于浏览器提供的WEB端点对点之间通信的WebRTC的能力,通过浏览器提供的API, 可在网页端实现音视频通信,WebRTC主要提供了2种能力,媒体捕获设备点对点连接

a、音视频点对点连接的基本原理流程如下图所示:

客户端A 与客户端B之间,因为防火墙的限制是无法进行通信的,需要借助TURN服务器,进行点与点之间的链接,大致基本原理可以用下图来说明:

b、音视频媒体捕获设备的获取

媒体捕获设备包括摄像头和麦克风,还包括屏幕捕获设备。对于摄像头和麦克风,调用 navigator.mediaDevices.getUserMedia() 来捕获 MediaStreams。对于屏幕录制,使用 navigator.mediaDevices.getDisplayMedia()

javascript 复制代码
// 摄像头和麦克风
const openMediaDevices = async (constraints) => {
    return await navigator.mediaDevices.getUserMedia(constraints);
}

try {
    const stream = openMediaDevices({'video':true,'audio':true});
    console.log('音视频流数据:', stream);
} catch(error) {
    console.error(error);
}

如果存在外接设备摄像头或者麦克风,此时,需要去查询所有当前客户端的所有设备,可以通过调用函数 enumerateDevices() 来实现 。

typescript 复制代码
const getConnectedDevices = async(type) {
    const devices = await navigator.mediaDevices.enumerateDevices();
    return devices.filter(device => device.kind === type)
}

const videoCameras = getConnectedDevices('videoinput');
console.log('所有的摄像头的数据:', videoCameras);

拿到音视频流之后,需要进行播放,可以将其赋值给视频的DOM元素。

ini 复制代码
const  play = async() {
    try {
        const constraints = {'video': true, 'audio': true};
        const stream = await navigator.mediaDevices.getUserMedia(constraints);
        const videoElement = document.querySelector('video#localVideo');
        videoElement.srcObject = stream;
    } catch(error) {
        console.error('Error opening video camera.', error);
    }
}

return (
  <div className="video-demo-container">
    <video id="localVideo" autoplay playsinline controls="false"/>
  </div>
)

2.2 WebRTC通信协议介绍

WebRTC的传输协议主要包括以下几个方面,基于下面几个协议来保证实时音视频既流畅又安全。

我们下面分别看下,这几种协议各自在音视频中起到的作用和分工。

提到DTLS协议 , 大家应该知道另外一个TLS协议 ,但是TLS协议是为TCP设计的,它依赖于TCP的可靠、有序传输,而UDP是不可靠、不保证顺序的,无法直接使用TLS,因此,DTLS主要是为UDP设计的。如下WebRTC各协议工作流程图:

2.3 WebRTC音视频实现过程

如下图所示,整个音视频过程,包括 前期的准备阶段、信令阶段、链接阶段、最后的通信阶段,大致过程如下所示流程图所示:

创建Offer的代码如下:

javascript 复制代码
var peerConnection = new RTCPeerConnection();
// 创建SDP
peerConnection.createOffer(function(offer) {
  // ...
}, function(error) {
  console.log(error);
});

基于以上整个音视频的底层能力,下面我这边将详细介绍下,基于我们的公司的业务情况,针对业务的背景和诉求,提供的一站式接入音视频通用化组件的相关设计和问题探讨。

3、音视频通用组件设计

3.1 组件基本设计思路

音视频通用化组件,会提供基于音视频底层能力,提供SDK和UI组件二方包两个产物,针对每个SDK中,会提供语音转文字、聊天、纯语音、视频+语音等SDK, 可以供不同的场景使用,比如桌面录屏等需要单独调用SDK , 其次针对UI组件,会打包封装好的整套UI产品,通过配置参数,即可实现整套音视频的功能和使用。

实现效果Demo截图如下:

上面的Demo截图功能操作是垂直布局的浮层,也提供了水平方向的布局浮层,主要通过参数配置。

整个音视频核心功能分为下面几个部分:


  • 视频块的大小,会跟随当前屏幕的大小,进行动态变化,且实时计算大小,赋值给每个组件的区域容器的宽度,除此之外,还需要监听window.onResize的大小变化。
  • 操作区功能模块,通过参数配置,由业务侧控制其显隐,包括图标、名称、置灰等。
  • 内置功能区域,主要是通用组件本身提供的内置能力,比如讨论和语音转文字等。
  • 外置功能区域,比如业务自定义插入的组件页面,目前提供2种方式,一种是嵌入式的插入,一种是浮层式插入,依据参数字段 isSlotComponentInsertToPage 来区分。
  • 主要核心的模块,主要包括视频模块、操作区模块、内置/自定义功能区域模块。

测试Demo代码如下:

ini 复制代码
import React, { useEffect, useState , useRef } from 'react';

// 自定义组件弹窗
export const DefineComponent = (props) => {
  const {
    onRemoveSoltComponent,
    onShareScreen,
  } = props
  console.log("===defineComponent", props)
  const onClickSure = async () => {
    await onShareScreen?.()
    await onRemoveSoltComponent?.()
  }
  return (
    <Modal
      title="自定义组件"
      visible={true}
      onCancel={onRemoveSoltComponent}
      onOk={onClickSure}
    >
      <p>Some contents...</p>
      <p>Some contents...</p>
      <p>Some contents...</p>
    </Modal>
  )
}

// 自定义组件弹窗2
export const DefineComponent2 = (props) => {

  const {
    onRemoveSoltComponent,
    targetKey
  } = props;
  console.log("OnCloseDialog", props)

  // 关闭当前组件
  const onRemoveSoltComponentHandle = () => {
    onRemoveSoltComponent(targetKey)
  }

  return <div onClick={onRemoveSoltComponentHandle}>这是一个业务方自定义插入页面的会议监控2222222组件, 点击我可以关闭页面</div>
}

/**
 * 视频+语音的组件,业务方引入demo
 */
const OnlineMeetingDemo = () => {
  const [modalVisible, setModalVisible] = useState(true);
  const modalVisibleControl = {
    visible: modalVisible,
    closeVisible: () => {
      setModalVisible(false);
    },
  }
  // 通用按钮设置数据, 默认通用组件中的按钮,全部展示
  const generalButtonSettingsData = [
    {
      btnKey: BUTTON_CONSTANTS.CHANGE_VIEW_BUTTON_KEY, // 唯一key值
      isDisabled: false, // 是否置灰
      disabledToolTip: "", // 置灰的提示tooltip
      isShow: true, // 按钮是否显示
      onClickBefore: () => {}, // 点击之前
      onClickAfter: () => {
        console.log("切换视图之后")
      }, // 点击之后
    },
    ...
  ];

  // 自定义业务侧按钮数据
  const customButtonSettings = [
    {
      btnImg: 'https://sitecdn.zcycdn.com/1167DH/20248/68cd9c74-6493-4cff-b228-42e3f6965ce5.svg',
      btnName: '业务自定义按钮名称',
      btnKey: 'talkBtn',
      isDisabled: false, // 是否置灰
      isShow: true, // 按钮是否显示
      onClick: (props) => { },
      slotComponent: <div>这是一个业务方自定义插入A组件</div>, 
      isSlotComponentInsertToPage: true, // 自定义组件是否插入到页面
    },
    ...
  ]

  return (
    <div
      className="online-meeting-container"
      style={{ width: '100%', height: '100%' }}
    >
        <OnlineVideoMeeting
          ref={inputRef}
          direction="inline" // vertical | inline 方向
          style={{ width: '100%', height: '100%' }}
          manufacturer="yunxin"
          customConfig={configData} // 业务方相关信息数据
          generalButtonSettings={generalButtonSettingsData} // 通用按钮组件配置
          customButtonSettings={customButtonSettings} // 自定义按钮配置
          onCustomClientEvents={onDefineClientEvents} // 接收到自定义消息类型,事件的回调

          isCanSeeOtherVideo={true} // 默认为true 当前角色,是否可以看到其它人的视频画面 (比如,供应商无法看到专家的画面)
          isOpenAIDenoise={false} // 默认不开启 false,是否开启变声,如果为盲评项目,需要开启变声
          needAIUidList={[]} // 传入需要变声的accId , 仅当isOpenAIDenoise 为true 时候生效
          isSpecailRole={false} // 默认false 特殊的角色,可以进入视频会议,但是不可开启视频和音频, 比如监管角色
          isShowRemoveMember={true} // 默认不显示,是否显示移除人员的按钮 ,默认 为false
          isAutoStartMeeting={false} // 如果为false , 说明 需要点击 开始会议,才可以启动会议 , 默认为true
          isJoinMeetingAutomatically={false}  // 当 isAutoStartMeeting 为false 时,才会生效,是否自动让参会人进入会议,true 是, false的话,需要参会人点击 开始会议 按钮,才可以进入
          isDisabledAllVideo={false} // 是否将所有与会者的视频画面置为不可见,比如盲评项目的话,将所有人的视频画面都置为不可见 , 默认为 false
          disabledVideoToolTip="" // 视频浮层tooltip提示 , 默认为 空
          peerOnLineEvent={(props) => {}} // 加入房间事件
          peerLeaveEvent={(props) => {}} // 离开房间事件
          onCloseModalErrorEvents={() => { }}
          onPublishLocalStreamSuccess={()=>{ }}
          shouldCrash={shouldCrash}
        />
    </div>
  );

};
export default OnlineMeetingDemo;
  

组件暴露出去参数给外部使用,通过cloneElement useImperativeHandle、钩子函数回调callBack等方式,进行内外部之间的通信。

整体的音视频产品设计图如下所示:

3.2 多渠道视频SDK协议约束

因业务的发展诉求,在设计底层的SDK的时候,需要考虑后续的SDK的可扩展性,像我们的渠道商有多个,每个视频渠道商提供的对应的视频SDK是不一样的,那么如何去兼容不同的视频渠道商SDK呢?

第一种SDK设计方案,如果渠道商SDK频繁更改, 以及频繁接入新的SDK的话,比较适用此业务场景

通过 在自己的二方包中,统一动态加载注入 不同的SDK的包版本,每个视频渠道商均为单独的二方包,如下图所示:

第二种SDK设计方案,如果渠道商SDK较少,且更改不频繁,那么比较适用此业务场景

以上两种SDK设计方案,对于我们当前的业务诉求,SDK渠道商较少,且稳定,那么目前采用的是第二种设计方案,代码Demo类似如下所示:

typescript 复制代码
import Channel1SDK from "./channelSource/channel1";
import Channel2SDK from "./channelSource/channel2";

/**
 * 根据配置获取对应的SDK的实例
 * @param type
 * @returns
 */
export const getSDKInstance = async (type) => {
  if (!type) {
    console.log("请传入三方音视频type");
    return;
  }
  if (type === "channel1") {
    return new Channel1SDK();
  }
  if (type === "channel2") {
    return new Channel2SDK();
  }
  ...
};

针对每个SDK, 设置统一的底层的协议API方法,如下类似代码:

typescript 复制代码
import AudioTransformText from "src/public/audioTransform";
import  Video  from './video';
import   IM  from './im';
import   Audio  from './audio';
import Utils from './utils';
import BaseSDK from '../../basicSdk';

/*
 * channel1 的SDK 聊天、视频、音频
 * @export
 * @class Channel1SDK
 * @implements {BaseSDK}
* */
import { IMPropsType , OptsPropsType } from "src/types/typings";

 export default class Channel1SDK  implements BaseSDK{
  constructor(){

  }
  // 获取IM SDK
  async getIM (payload?:IMPropsType) {
    return new IM (payload);
  }

  // 获取WEBRTC SDK
  async getVideo (payload?:OptsPropsType) {
    return new Video (payload);
  }

  // 获取AUDIO SDK
  async getAudio (payload?:OptsPropsType) {
    return new Audio (payload);
  }

  // 获取通用工具 SDK
  async getUtils() {
    return new Utils(); // 通用工具类
  }

  async getAudioTransText() {
    return new AudioTransformText(); // 获取语音转文字SDK
  }

}

每一个IMVideoAudioUtilsAudioTransformText均基于自己自己的BaseSDK 去进行实现,比如IM 自己的 BaseIMSDK如下代码所示:

typescript 复制代码
import { HistoryMsgsPropsType, ResendMsgPropsType  } from "src/types/typings";

/**
 * @desc 基础的IM 声明
 */
export default interface BaseIMSDK {

  //发送消息 type 为 "text"表示文本 , "file"表示文件
  sendMsg:(msg?:any , type?:string) => Promise<boolean>;

  // 消息回调 将接收到的文本或文件
  // 业务方直接 im.onReciveMsg = (msg) => {}; 使用获取到的msg即可
  onReciveMsg:(msg?:any) => void;

  //撤回消息
  recallMsg:(msg?:any) => Promise<Boolean>;

  // 获取历史消息
  getHistoryMsgs:(payload? : HistoryMsgsPropsType ) => Promise<Array<any>>;

  // 消息重发
  resendMsg:(payload?:ResendMsgPropsType) => Promise<Boolean>;

  // 消息接收方在收到消息并阅读后, 调用此方法发送已读回执
  sendReceiptMsg : (payload?:ResendMsgPropsType) => Promise<Boolean>;

  // 消息已读回执 回调函数
  onReceiptedMsg : (msg?:any) => Promise<Boolean>;

}

后续针对每个渠道商,按照基于定的统一协议类API的方法,去实现对应的功能即可。

3.3 组件UI降级容错

即当某个组件因为某些原因(如数据错误、依赖缺失、内部异常等)无法正常渲染时,我们不会让整个页面崩溃,而是展示一个备用的UI(如错误提示、占位符等),同时保证其他组件的正常渲染。

3.3.1 组件级UI降级容错处理方法

通常有以下几种常用的方法:

我们详细看下,几种不同的错误边界处理方法的应用。

Error Boundaries

React 16引入的概念,可以捕获子组件树中的JavaScript错误,记录错误,并显示一个降级UI,它定义了static getDerivedStateFromError()componentDidCatch()两个生命周期方法中的一个或两个, 注意,错误边界无法捕获事件处理、异步代码、服务端渲染等错误。

当捕获到error的时候,进行默认视图展示,而不是使整个界面出现白屏的现象,涉及包括整个界面,以及局部的组件的崩溃等组件情况,相关ErrorBoundary代码如下:

javascript 复制代码
import React from 'react';
import ErrorView from './components/ErrorView';
import { pushLogToSentry } from './helpers';
import { ERROR_TYPE } from './constants';

export class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      hasError: false,
    };
  }

  static getDerivedStateFromError(error) {
    window?.console?.log('getDerivedStateFromError:', error);
    // 更新 state 使下一次渲染能够显示降级后的 UI
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    window?.console?.log('componentDidCatch error:', error);
    window?.console?.log('componentDidCatch errorInfo:', errorInfo);
    try {
      if (__DEV__) return;
    } catch (err) {
      //
    }

    // 将错误日志上报给服务器
    try {
      pushLogToSentry({
        message: `
        url: src/components/ErrorBoundary
        errorName: ${error?.name}
        errorMsg: ${error?.meesage}
        errorStack: ${error?.stack}
        errorComponentStack: ${errorInfo?.componentStack}
        `,
        level: ERROR_TYPE,
      });
    } catch (err) {
      //
    }
  }

  render() {
    const { children, onRefresh, errorWrapProps = {} } = this.props;
    const { hasError } = this.state;
    // 错误展示的视图
    if (hasError) {
      return (
        <ErrorView
          onRefresh={onRefresh}
          errorWrapProps={errorWrapProps}
        />
      );
    }
    return children;
  }
}

export const withErrorBoundary = ({ onRefresh, errorWrapProps = {} } = {}) => (Component) => {
  return class extends ErrorBoundary {
    render() {
      const { hasError } = this.state;
      // 错误展示的视图
      if (hasError) {
        return <ErrorView onRefresh={onRefresh} errorWrapProps={errorWrapProps} />;
      }
      if (Component) {
        return <Component {...this.state} {...this.props} />;
      }
      return null;
    }
  };
};

业务侧引入的时候,如下代码:

javascript 复制代码
import  ErrorBoundary from "src/components"

const MyComponent = ()=>{
  return (
    <ErrorBoundary>
      <MyComponent />
    </ErrorBoundary>
  )
}

那么针对这种错误边界的处理方法,如何去模拟针对音视频通用化组件崩溃的各种场景呢?

  • 测试步骤
css 复制代码
graph TD
    A[创建会崩溃的子组件] --> B[在父组件中嵌入]
    B --> C[触发崩溃条件]
    C --> D[检测父组件行为]
    D --> E[捕获边界情况]
  • 功能测试场景
测试动作 预期结果 检测方法
触发子组件崩溃 子组件显示错误边界内容 检查DOM元素 .error-fallback
父组件按钮功能 父组件状态正常更新 检查 parentState 状态
父组件渲染 崩溃区域外UI完整无白屏 可视检查 + DOM 快照对比
控制台错误 错误被捕获未扩散 监听 window.onerror 事件
  • 测试过程&结果如下截图:

1、模拟音视频组件,引入到业务工程项目中,此时如果音视频组件内部出现崩溃的情况,即报错了

throw new Error,此时,业务工程业务不应该受到影响,界面显示正常。

点击按钮,触发子组件崩溃,此时 父组件依然可以正常显示。

2、模拟业务自定义组件崩溃的情况,在音视频通用化组件中,可以自定义插入业务方的组件,此时如果业务方组件崩溃了,那么对应的音视频组件不应该受到影响,不会出现白屏的现象。

try-catch

错误边界无法捕获异步代码和事件中的错误,这些错误我们需要手动处理,例如,在事件处理函数中。

scala 复制代码
class MyComponent extends React.Component {
  state = { hasError: false };

  handleClick = () => {
    try {
      // 可能会抛出错误的操作
      doSomething();
    } catch (error) {
      this.setState({ hasError: true });
      console.error(error);
    }
  }

  render() {
    if (this.state.hasError) {
      return <div>Error in event handler.</div>;
    }

    return <button onClick={this.handleClick}>Click me</button>;
  }
}

对于异步代码(如setTimeout、Promise),我们可以使用类似的try-catch,但注意,在Promise中要用catch:

javascript 复制代码
someAsyncFunction()
  .then(data => {
    // 处理数据
  })
  .catch(error => {
    // 处理错误
    this.setState({ hasError: true });
  });

高阶组件(HOC)

可以创建一个高阶组件,它接收一个组件,返回一个包裹了错误边界的新组件

javascript 复制代码
export const withErrorBoundary = ({ onRefresh, errorWrapProps = {} } = {}) => (Component) => {
  return class extends ErrorBoundary {
    render() {
      const { hasError } = this.state;
      // 错误展示的视图
      if (hasError) {
        return <ErrorView onRefresh={onRefresh} errorWrapProps={errorWrapProps} />;
      }
      if (Component) {
        return <Component {...this.state} {...this.props} />;
      }
      return null;
    }
  };
};

业务侧使用的方式如下:

javascript 复制代码
import { withErrorBoundary } from 'src/components';
const  MyConponent = ()=>{
  .....
}

export default withErrorBoundary()(MyConponent)
3.3.2 常见的降级场景与处理方案
  • 组件加载失败降级

场景:异步组件加载超时或网络错误

javascript 复制代码
import React, { Suspense, useState, useCallback } from 'react';

// 1. 错误边界降级
class ErrorBoundary extends React.Component {
  state = { hasError: false, error: null };
  
  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }
  
  componentDidCatch(error, errorInfo) {
    console.error('Component loading failed:', error, errorInfo);
  }
  
  render() {
    if (this.state.hasError) {
      // 降级到基础UI
      return this.props.fallback || (
        <div className="component-fallback">
          <h3>功能加载中...</h3>
          <button onClick={() => this.setState({ hasError: false })}>
            重试
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

// 2. 异步组件降级
const LazyComponent = React.lazy(() => 
  import('./ComplexComponent')
    .catch(() => ({ 
      default: () => <BasicComponent /> 
    }))
);

// 备用基础组件
const BasicComponent = () => (
  <div className="basic-version">
    <h4>基础模式</h4>
    <p>当前功能以简化模式显示</p>
  </div>
);

// 使用示例
const App = () => (
  <ErrorBoundary fallback={<BasicComponent />}>
    <Suspense fallback={<div>加载中...</div>}>
      <LazyComponent />
    </Suspense>
  </ErrorBoundary>
);
  • 功能依赖降级

场景:浏览器不支持某些API或第三方库加载失败

javascript 复制代码
// 特性检测降级
const withFeatureDetection = (features) => (Component) => (props) => {
  const [supportedFeatures, setSupportedFeatures] = useState({});
  
  useEffect(() => {
    // 检测浏览器支持情况
    const checks = {
      webGL: !!document.createElement('canvas').getContext('webgl'),
      touch: 'ontouchstart' in window,
      serviceWorker: 'serviceWorker' in navigator,
      // 添加更多特性检测...
    };
    
    setSupportedFeatures(checks);
  }, []);

  // 根据支持情况返回不同组件
  if (features.requiresWebGL && !supportedFeatures.webGL) {
    return <CanvasFallback {...props} />;
  }
  
  return <Component {...props} supportedFeatures={supportedFeatures} />;
};

// 第三方库降级
const MapComponent = () => {
  const [mapLib, setMapLib] = useState(null);
  
  useEffect(() => {
    // 动态加载地图库
    import('advanced-map-library')
      .then(lib => setMapLib(lib))
      .catch(error => {
        console.warn('高级地图库加载失败,使用基础版本');
        // 降级到基础地图或静态图片
        import('./basic-map').then(lib => setMapLib(lib));
      });
  }, []);
  
  if (!mapLib) return <div>地图加载中...</div>;
  
  return mapLib.default ? 
    <mapLib.default /> : 
    <img src="/static-map.jpg" alt="静态地图" />;
};
  • 性能降级方法

场景:设备性能不足时自动降低视觉效果

javascript 复制代码
// 性能感知降级
const usePerformanceMode = () => {
  const [performanceMode, setPerformanceMode] = useState('normal');
  
  useEffect(() => {
    // 检测设备性能
    const detectPerformance = async () => {
      // 1. 检查网络速度
      if (navigator.connection) {
        const { effectiveType, downlink } = navigator.connection;
        if (effectiveType === 'slow-2g' || downlink < 1) {
          setPerformanceMode('low');
          return;
        }
      }
      
      // 2. 检查设备内存
      if (navigator.deviceMemory && navigator.deviceMemory < 4) {
        setPerformanceMode('low');
        return;
      }
      
      // 3. 检查电池状态
      if (navigator.getBattery) {
        navigator.getBattery().then(battery => {
          if (battery.level < 0.2 && !battery.charging) {
            setPerformanceMode('low-power');
          }
        });
      }
    };
    
    detectPerformance();
  }, []);
  
  return performanceMode;
};

// 自适应组件
const AdaptiveChart = ({ data }) => {
  const performanceMode = usePerformanceMode();
  
  switch (performanceMode) {
    case 'low':
    case 'low-power':
      return <SimpleChart data={data} />; // 简化版图表
      
    case 'normal':
      return <InteractiveChart data={data} />; // 交互式图表
      
    default:
      return <StaticChart data={data} />; // 静态图表
  }
};
  • 渐进增强策略

通过设置不同版本的方式,去进行设置不同的模式切换

javascript 复制代码
// 组件级渐进增强
const ProgressiveEnhancementComponent = ({ enhancedFeatures = true }) => {
  const [isEnhanced, setIsEnhanced] = useState(enhancedFeatures);
  
  // 降级开关(用于调试或用户手动选择)
  const toggleEnhancement = () => setIsEnhanced(!isEnhanced);
  
  return (
    <div className="progressive-component">
      <button onClick={toggleEnhancement} className="enhancement-toggle">
        {isEnhanced ? '切换到基础模式' : '切换到增强模式'}
      </button>
      
      {isEnhanced ? (
        // 增强版本 - 包含动画、复杂交互
        <EnhancedVersion />
      ) : (
        // 基础版本 - 简单静态内容
        <BasicVersion />
      )}
    </div>
  );
};

总结概括的话,大概主要是从下面的几个方面:

3.4 组件的动态注入

在音视频通用化组件中,涉及到诸多业务场景,动态配置注入组件的的功能,下面详细的介绍下,相关的一些组件动态注入的实现方式,我们工程中用的比较多的就是静态导入了,下面看下两者区别。

javascript 复制代码
// 静态导入 - 编译时绑定
import StaticComponentDemo from './StaticComponentDemo';

// 动态注入 - 运行时加载
const DynamicComponentDemo = React.lazy(() => import('./DynamicComponentDemo'));

React组件动态注入主要是通过将​代码分割​​、​​按需加载​​和​​运行时集成​​相结合的方式 , 是在运行时按需加载和渲染组件​​

动态注入组件的特点主要有下面几点:

可以减少初始加载体积,提升首屏速度,支持插件化、主题化、A/B 测试等高级场景,保持代码模块化,简化复杂应用结构,实现真正的热插拔组件系统。React组件动态注入有哪几种方式呢?

1、React.lazySuspense的方式

此种方式主要是按需加载组件,React.lazy函数允许动态地加载组件,它返回一个Promise,该Promise解析为一个包含React组件的模块。通常与Suspense组件一起使用,在加载过程中显示回退内容。

javascript 复制代码
import React, { Suspense } from 'react';

const LazyComponentDemo = React.lazy(() => import('./LazyComponentDemo'));

const  MyComponentDemo =()=> {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <LazyComponentDemo />
      </Suspense>
    </div>
  );
}
  • React.lazy接受一个函数,该函数返回一个动态import()的Promise。
  • 当组件首次渲染时,会触发加载。加载过程中,Suspense会显示fallback内容。
  • 加载完成后,组件被渲染。

注意​:React.lazy只支持默认导出(export default)的组件。

React.lazy 内部实现

ini 复制代码
function lazy(ctor) {
  let thenable = null;
  
  return {
    $$typeof: REACT_LAZY_TYPE,
    _payload: {
      _status: -1,  // 状态: -1=未加载, 0=加载中, 1=加载成功, 2=加载失败
      _result: ctor, // 加载函数
    },
    _init(payload) {
      if (payload._status === -1) {
        const ctorResult = ctor();
        thenable = ctorResult;
        
        payload._status = 0;
        ctorResult.then(
          module => {
            if (payload._status === 0) {
              payload._status = 1;
              payload._result = module.default;
            }
          },
          error => {
            if (payload._status === 0) {
              payload._status = 2;
              payload._result = error;
            }
          }
        );
      }
      
      switch (payload._status) {
        case 1: return payload._result;
        case 2: throw payload._result;
        default: throw thenable;
      }
    }
  };
}

Suspense 工作原理 渲染遇到未准备好的组件时抛出 Promise , React 捕获该 Promise 并暂停渲染 , 显示最近的 Suspense , fallback UI ,Promise 完成后重新尝试渲染 , 成功则显示组件,失败则抛出错误。

2、使用高阶组件(HOC)实现动态加载

创建一个高阶组件,在组件加载过程中管理加载状态,并在加载完成后渲染目标组件,比较适用需要自定义加载逻辑或处理非默认导出的组件。

javascript 复制代码
import React, { useState, useEffect } from 'react';

const withDynamicImportDemo = (importFunc)=> {
  return function DynamicComponent(props) {
    const [Component, setComponent] = useState(null);

    useEffect(() => {
      importFunc().then(module => {
        setComponent(() => module.default); // 假设是默认导出
      });
    }, []);

    return Component ? <Component {...props} /> : <div>Loading...</div>;
  };
}

// 使用
const MyLazyComponent = withDynamicImportDemo(() => import('./MyComponent'));

3、import()动态加载 + 状态管理

主要是在事件处理函数中动态导入组件,然后更新状态触发重新渲染。

javascript 复制代码
import React, { useState } from 'react';

const MyComponentDemo = ()=> {
  const [Component, setComponent] = useState(null);

  const handleClick = async () => {
    const { default: DynamicComponent } = await import('./DynamicComponent');
    setComponent(() => DynamicComponent);
  };

  return (
    <div>
      <button onClick={handleClick}>点击加载组件</button>
      {Component ? <Component /> : <p>测试点击</p>}
    </div>
  );
}

4、loadable-components

loadable-components是一个流行的库,提供了更强大的动态导入功能,包括服务端渲染支持。如SSR支持、预加载等,可以考虑使用这种方式。

javascript 复制代码
import loadable from '@loadable/component';

const LoadableComponent = loadable(() => import('./MyComponentDemo'), {
  fallback: <div>测试</div>,
});

const MyComponentPage = ()=> {
  return <LoadableComponent />;
}

5、ReactDOM.createPortal

使用ReactDOM.createPortal将组件渲染到指定的DOM节点。结合动态加载,可以先加载组件再将其渲染到目标位置。

javascript 复制代码
import React, { useState } from 'react';
import ReactDOM from 'react-dom';

const DynamicPortalDemo = ()=> {
  const [targetNode] = useState(() => document.getElementById('portal-root'));
  const [Component, setComponent] = useState(null);

  useEffect(() => {
    import('./PortalComponent').then(module => {
      setComponent(() => module.default);
    });
  }, []);

  if (!Component || !targetNode) return null;

  return ReactDOM.createPortal(<Component />, targetNode);
}

在音视频通用化组件中,我们看下实现组件动态加载的界面,整个语音转文字组件、交流讨论组件、以及业务自定义的组件,像讲标顺序组件、会议监控、会议笔录组件等,都是动态注入的组件。

相关的代码如下所示:

ini 复制代码
  // 遍历注册对应的组件
const getDomContent = (arr) => {
  return (
    <>
      {arr?.map((item = {}) => {
        const DomItem = innerContainerComponent[item?.btnKey];
        return (
          <div className="online-meeting-inner-slot-list" key={item.btnKey}>
            <React.Suspense fallback={<div />}>
              {DomItem && (
                <DomItem
                  innerComponentList = {innerSlotComponent}
                  direction={direction}
                  btnConfig={item}
                  btnKey={item?.btnKey}
                  im={im}
                  onCloseIMDialog={() => {
                    onCloseIMDialog(item?.btnKey);
                  }}
                      onRemoveSoltComponent={() => onRemoveSoltComponent(item?.btnKey)}
                  {...props}
                />
              )}
            </React.Suspense>
          </div>
        );
      })}
    </>
  );
};

return (
  <div className="online-meeting-inner-slot-container">
    {getDomContent(innerSlotComponent)}
  </div>
);

利用组件动态注入,可以进行一些性能优化方面的优化策略,主要有下面几个方面:

ini 复制代码
const componentCacheDemo = new Map();
const CachedComponent = (loader)=> {
  const [Component, setComponent] = useState(null);
  
  useEffect(() => {
    if (componentCacheDemo.has(loader)) {
      setComponent(componentCacheDemo.get(loader));
      return;
    }
    
    loader().then(module => {
      const Comp = module.default;
      componentCacheDemo.set(loader, Comp);
      setComponent(() => Comp);
    });
  }, [loader]);
  
  return Component;
}

组件动态加载的未来发展趋势,像服务端组件直接动态加载 、远程组件动态加载、基于 AI 的预测加载等,随着 React 18 并发特性、Server ComponentsModule Federation 等技术的发展,动态加载组件将成为一种核心模式。

4、音视频通用组件多端消息收发

4.1 IM消息收发

IM主要是在视频会议中, 我们的主持人在视频会议中会操控其他与会人员的音视频开启关闭,邀请与会人员的开启页面监控等操作。这些操作我们会通过IM消息发送的方式通知给相应的与会人员,其次包括视频会议的讨论功能。

我们看下,IM消息收发的流程是如何实现的?

客户端A 与客户端B,会通过当前本端获取到唯一标识Token, 进行登录,且实现消息之间的传送。底层能力也是基于WebRTC的点与点之间的通信链接 和 WebSocket来实现的。

音视频IM系统包含多种消息类型,每种都有不同的传输要求。

ini 复制代码
// 消息类型定义
enum MessageType {
  TEXT = 'text',           // 文本消息
  IMAGE = 'image',         // 图片消息
  AUDIO = 'audio',         // 音频消息
  VIDEO = 'video',         // 视频消息
  FILE = 'file',           // 文件消息
  CALL_INVITE = 'call_invite',    // 通话邀请
  CALL_ACCEPT = 'call_accept',    // 通话接受
  CALL_REJECT = 'call_reject',    // 通话拒绝
  CALL_END = 'call_end',          // 通话结束
  TYPING = 'typing',              // 输入状态
  READ_RECEIPT = 'read_receipt'   // 已读回执
}

消息收发核心代码如下所示:

kotlin 复制代码
class IMCore {
  private wsManager: WebSocketManager;     // WebSocket连接管理
  private messageQueue: MessageQueue;      // 消息队列
  private mediaProcessor: MediaProcessor;  // 媒体处理
  private storage: MessageStorage;         // 消息存储
  
  constructor() {
    this.init();
  }
  
  private async init() {
    await this.setupWebSocket();
    this.setupMessageHandlers();
  }
}

我们看下音视频通用化组件中实现的效果截图:

主持人 | 客户端A

专家|客户端B


当客户端A发送发送消息的时候,此时客户端B的人,存在监听消息事件,当监听到消息的时候,动态将此消息文本,注入到数组中,且在界面上显示。

ini 复制代码
 // 监听消息
  const onMsg = (msgsValue = []) => {
    if (msgsValue?.length > 0) {
      const originHistoryMsgs = originHistoryMsgsRef?.current || [];
      const newMsgs = originHistoryMsgs.concat(msgsValue);
      setMsgs(newMsgs);
    }
  };

// 初始化请求历史数据,以及注册接受IM发送过来的消息
  useEffect(() => {
    const { im } = props;
    getHistoryData();
    try {
      im.onReciveMsg = onMsg;
    } catch (err) {
      //
    }
  }, []);

4.2 webSocket消息收发

在音视频中,另外一个消息收发的方式,就是使用 webSocket,进行实时的通信,这种应该是比较常见使用的。webSocket的特性主要包括下面这些方面:

客户端和服务器可​同时​​发送和接收数据 ,建立连接后,数据传输无需 HTTP 握手开销,一个连接持续复用,避免频繁建立/断开连接的开销,可进行高效传输图片、音频、视频等二进制消息。

我们来看下webSocket的相关的特性和具体实现:

1、连接建立:HTTP 升级握手

WebSocket 连接始于标准的 HTTP 请求,通过 Upgrade头切换协议

yaml 复制代码
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket          # 关键:请求升级协议
Connection: Upgrade         # 关键:要求切换连接
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==  # 客户端随机密钥
Sec-WebSocket-Version: 13    # 协议版本

2、服务器响应协议升级

yaml 复制代码
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=  # 验证密钥

3、密钥验证原理

4、连接保活:心跳机制

通过 ​Ping/Pong 帧​​ 维持连接活性。

整体的通信过程如下所示:

5、连接关闭:握手终止

关闭流程需双方交换关闭帧, 大致流程如下:

总结一下,主要是下面这些方面:

封装的webSocket的demo代码大致如下:

kotlin 复制代码
/**
 * @description 消息实时通信组件(websocket实现)
 * @class WebsocketDemo
 * @constructor 非静态类
 */
class WebsocketDemo {
  constructor(opt) {
    // websocket实例
    this.ws = null;
    // url
    this.url = opt.url || null;
    // 心跳包数据,客户端发送ping,服务端回复pong
    this.heartData = opt.heartData || 'ping';
    // 客户端发送心跳的定时器 timer
    this.heartTimer = null;
    // 客户端多久发送一次心跳包,默认 2mins
    this.heartInterval = opt.heartInterval || 2 * 60 * 1000;
    // 是否允许重连。默认自动拿上一次 uri 进行重连。如果连接为服务端返回的动态 uri 时,请设置该值为 false
    this.allowReconnect = opt.allowReconnect;
    // 重连计数器
    this.reconnectCounter = 0;
    // 重连次数上限,默认最多尝试重连 3 次
    this.reconnectLimit = opt.reconnectLimit || 3;
    // 重连的定时器 timer
    this.reconnectTimer = null;
    // 定时多久重连一次,默认为 5s
    this.reconnectInterval = opt.reconnectInterval || 5 * 1000;
    // 创建连接成功后的回调函数
    this.connectedCallback = opt.connectedCallback || function () {};
    // 接收到服务器端消息后的回调函数
    this.getMsgCallback = opt.getMsgCallback || function () {};
    window.onbeforeunload = () => {
      this.ws.close();
    };
    this.createWebSocket(this.url);
  }

  // 创建连接
  createWebSocket(url) {
    try {
      // 判断当前浏览器是否支持 websocket
      if ('WebSocket' in window) {
        this.ws = new WebSocket(this.url);
      } else {
        alert(
          '您的浏览器不支持websocket协议,建议使用新版谷歌、火狐等浏览器,请勿使用IE10以下浏览器,360浏览器请使用极速模式,不要使用兼容模式!',
        );
      }
      this.init();
    } catch (e) {
      this.reconnect(url);
    }
  }

  // 初始化事件处理
  init() {
    this.ws.onclose = (closeEvent) => {
      this.closedCallback();
      if (
        closeEvent.code !== 4001 &&
        closeEvent.code !== 4003 &&
        closeEvent.code !== 1100 &&
        closeEvent.code !== 1000
      ) {
        this.reconnect(this.url);
      }
    };
    this.ws.onerror = () => {
      this.errorCallback();
      this.reconnect(this.url);
    };
    this.ws.onopen = () => {
      this.reconnectCounter = 0; // 重连计数器重置
      this.startHeartBeat(); // 心跳检测重置,并启动
      this.connectedCallback();
    };
    this.ws.onmessage = (event) => {
      this.startHeartBeat(); 
      if (event.data !== 'pong') {
        this.getMsgCallback(event.data);
      }
    };
  }

  // 发送消息
  send(data) {
    const dataStr = typeof data !== 'string' ? JSON.stringify(data) : data;
    this.ws.send(dataStr);
  }

  // 关闭连接
  close() {
    this.allowReconnect = false;
    this.ws.close();
  }

  // 尝试重连
  reconnect() {
    clearTimeout(this.reconnectTimer);
    if (this.allowReconnect === false) return;
    if (this.reconnectCounter < this.reconnectLimit) {
      this.reconnectTimer = setTimeout(() => {
        this.createWebSocket(this.url);
        this.reconnectCounter += 1;
      }, this.reconnectInterval);
    }
  }

  // 启动心跳机制
  startHeartBeat() {
    clearTimeout(this.heartTimer); // 清除 客户端发送心跳的定时器
    this.heartTimer = setTimeout(() => {
      this.ws.send('ping');
    }, this.heartInterval);
  }
}

export default WebsocketDemo;

调用接入使用的时候,如下代码实例:

javascript 复制代码
this.wsInstanceDemo = new WebsocketDemo({
      url: wsUrl,
      allowReconnect: false,
      heartInterval,
      connectedCallback: () => {
        global.console.log('连接创建成功');
      },
      getMsgCallback: (data) => {
        // 处理接受到的消息数据
        ....
      }
})

5、小结

以上是针对整个音视频相关的探索,未来针对音视频产品,还需要持续的打磨, 往AI方向靠齐,比如可以结合 ML5 + WebRTC的方式,在实时音视频方面,比如媒体等相关的功能,可以结合起来使用。

相关推荐
Hilaku2 小时前
我用AI重构了一段500行的屎山代码,这是我的Prompt和思考过程
前端·javascript·架构
IT_陈寒2 小时前
Vite性能优化实战:5个被低估的配置让你的开发效率提升50%
前端·人工智能·后端
IT_陈寒2 小时前
Java性能调优的7个被低估的技巧:从代码到JVM全链路优化
前端·人工智能·后端
掘金安东尼3 小时前
前端周刊第439期(2025年11月3日–11月9日)
前端·javascript·vue.js
码农刚子3 小时前
ASP.NET Core Blazor进阶1:高级组件开发
前端·前端框架
道可到3 小时前
重新审视 JavaScript 中的异步循环
前端
起这个名字3 小时前
微前端应用通信使用和原理
前端·javascript·vue.js
QuantumLeap丶3 小时前
《uni-app跨平台开发完全指南》- 06 - 页面路由与导航
前端·vue.js·uni-app
CSharp精选营3 小时前
ASP.NET Core Blazor进阶1:高级组件开发
前端·.net core·blazor