深入探索基于 WebSocket 的 WebRTC 全栈音视频会议室项目 - p2p.chat

1,149 阅读6分钟

项目概述

p2p.chat 是一个开源的基于 WebSocket 和 WebRTC 实现的点对点的视频会议 Web 项目,主要技术栈是 Socket.io 和 Nextjs。

阅读这个项目的代码之前,需要对 WebRTC 有基本的了解,例如明白怎么建立最基础的 WebRTC 链接,后续的核心逻辑代码中,大部分都围绕着 WebRTC 的建立,不了解的话可能会看得一头雾水。

下面就是项目技术栈的简单介绍

后端部分 @p2p.chat/signalling,基于 Socket.io 的 WebRTC 信令服务器。

前端部分 @p2p.chat/www,基于 Nextjs 的前端网页应用。

@p2p.chat/signalling

信令服务器的实现非常简单,在入口文件 index.js 只执行了两个函数:

import { createConsoleLogger } from "./lib/logger";
import { createServer } from "./websockets";
​
const PORT = 8080;
​
const logger = createConsoleLogger();
const server = createServer(logger);
​
server.listen(PORT);
logger.info(`Started listening on ${PORT}`);

其中 createConsoleLogger 函数是基于 winston 库实现的日志输出函数,用于实现简单的日志格式化功能。

核心部分在于 createServer 函数,createServer 使用 socket.io 启动 WebSocket 服务器,开始监听事件,在监听事件这一步,作者对每一个事件触发回调函数都使用了柯里化,用于保存一些会复用的参数,以 joinRoom 事件为例:

socket.on("joinRoom", onJoinRoom(logger, socket));
​
const onJoinRoom = (logger: Logger, socket: Socket) => (room: string) => {
  logger.info(`join room=${room} sid=${socket.id}`);
​
  socket.join(room);
  socket.broadcast.to(room).emit("peerConnect", socket.id);
};

onJoinRoom 闭包保存了 loggersocket,这是一个保存常用参数的好办法。

在信令服务器监听的这些事件中,几乎只有一个目的:交换客户端之间的信息。

const onConnection = (logger: Logger, server: Server) => (socket: Socket) => {
  logger.info(`connection sid=${socket.id}`);
​
  socket.emit("connected");
  socket.on("joinRoom", onJoinRoom(logger, socket));
  socket.on("disconnecting", onDisconnect(logger, socket));
  socket.on("webRtcAnswer", onWebRtcAnswer(logger, server, socket));
  socket.on("webRtcIceCandidate", onWebRtcIceCandidate(logger, server, socket));
  socket.on("webRtcOffer", onWebRtcOffer(logger, server, socket));
};

监听到事件触发之后,将得到的信息(sid、candidate、offer、answer …)传递给指定的客户端。

因为后端的部分实在是太过于简单,这里就不再贴代码了。

@p2p.chat/www 页面视图

前端页面的逻辑比信令服务器要复杂一些,涉及到 React 的状态传递和 WebRTC 的连接,项目使用 Recoil 作为状态管理库,不过不影响整体的代码理解,不过后边有大量的 Recoil 状态存取,有可能会看的比较晕。

项目 Nextjs 使用的还是 page router 的版本,入口从 _app.tsx 开始,项目中甚至绝大多数页面都是视图相关,没有核心逻辑处理,可以直接跳过。

如果你只想学习这个项目创建 WebRTC 连接进行音视频通话的部分,你可以直接跳转到核心逻辑部分,刨去状态的存取,可以照猫画虎的实现一个最基本的连接逻辑。

值得一提的是作者喜欢用 useCallback 包裹每一个函数,这似乎更像是一种负优化?

components/home/create-room.tsx

components/home/create-room.tsx 组件负责视频会议房间号和房间名的生成,代码逻辑非常简单,完全跳过也不影响,但是这里有一个房间号生成和校验逻辑很有意思,值得看看。完整的创建和校验逻辑放在文章结尾,这里只贴出组件代码。

const [roomName, setRoomName] = React.useState("");

Input 监听 onInput 事件,事件回调是 handleChange

const handleChange = React.useCallback((value: string) => {
    // slugify 用于将字符串中的空格和特殊字符替换为短横线,并将所有字母转换为小写字母。
    setRoomName(slugify(value));
}, []);
export const slugify = (text: string): string => {
  return text
    .replace(/[^-a-zA-Z0-9\s+]+/gi, "") // Remove all non-word chars
    .replace(/\s+/gi, "-") // Replace all spaces with dashes
    .replace(/--+/g, "-") // Replace multiple - with single -
    .toLowerCase();
};

回调将获取到的输入内容经过 slugify 函数处理,主要是将字符串中的空格和特殊字符替换为短横线,并将所有字母转换为小写字母。例如输入 "Hello World!",输出 "hello-world"。最后存储在组件的 roomName 状态中。

Button 监听 onClick 事件,事件回调是 submit

const submit = React.useCallback(() => {
    // cleanSlug 用于清理字符串中的连字符(-),将字符串的开头和结尾的连字符去掉
  let cleanRoomName = cleanSlug(roomName);
    //  判断 cleanRoomName 是否为空,为空则调用 randomRoomName 生成随机字符
  cleanRoomName = cleanRoomName === "" ? randomRoomName() : cleanRoomName;
    // 最后将 cleanRoomName 作为参数传递调用 createRoomCode 函数
    // createRoomCode 的作用是根据传递的 cleanRoomName 和时间戳的后五位来生成一个唯一的 hash
  const roomCode = createRoomCode(cleanRoomName);
    // 触发页面跳转
  router.push(
    `/${roomCode}/${cleanRoomName}?created=true`,
    `/${roomCode}/${cleanRoomName}`
  );
}, [roomName, router]);
// cleanSlug 用于清理字符串中的连字符(-),将字符串的开头和结尾的连字符去掉
export const cleanSlug = (text: string): string => {
  return text
    .replace(/^-+/, "") // Trim - from start of text
    .replace(/-+$/, ""); // Trim - from end of text
};
export const randomRoomName = (): string => {
  return Math.random().toString(16).substr(2, 8);
};
export const createRoomCode = (roomName: string): string => {
    // 截取当前时间戳后五位
  const key = (+new Date()).toString(36).slice(-5);
    // 传递 roomName 和时间戳后五位生成 hash
  const hash = getRoomHash(key, roomName);
    // 拼接并返回
  return key + hash;
};
const getRoomHash = (key: string, roomName: string): string => {
    // 调用 shorthash 生成 hash
  return shorthash.unique(`${key}${roomName}`);
};

pages/[roomCode]/[roomCode].tsx

[roomCode].tsx 所负责的页面是视频会议的主要页面,也是前端视图部分的核心。

const [local, setLocal] = useRecoilState(localState); // 初始状态为 "requestingName"
// 通过 useSetRecoilState 可以在页面不更新的情况下触发状态更新
const setPeers = useSetRecoilState(peersState);
const [room, setRoom] = useRecoilState(roomState); // 初始状态为 "loading"
const socketRef = React.useRef<Socket>(); // 用于存储 socket 实例

room 状态负责页面能否正常显示,它有三个状态分别是 loading、error 和 ready。只有当状态为 ready 时,页面才会进入后续对 local 状态判断的流程中去。

local 状态的不同让当前页面所显示的状态不同,他有着五个状态:requestingNamerequestingPermissionsrequestingDevicesconnectingconnected。分别对应输入用户名(RequestName 组件)、请求权限(RequestPermission 组件)、选择设备(RequestDevices 组件)、加载(Loading 组件)、接通(Call 组件)五个阶段。

除了以上状态,组件内还有三个副作用(useEffect):

第一个副作用用于 roomName 的校验:

React.useEffect(() => {
    // useEffect 回调内是 IIFE 函数,便于写 async 代码
  (async () => {
        // 来自 Next router 的路由状态
    if (!router.isReady) {
      return;
    }
​
        // 从路由参数中取出 roomCode 和 roomName
    const roomCode = router.query.roomCode as string;
    const roomName = router.query.roomName as string;
​
    try {
            // validateRoom 用于验证 roomName 是否合法
      validateRoom(roomCode, roomName);
    } catch (err) {
            // 如果 validateRoom 验证失败会抛出异常
            // 异常将会通过使用 recoil 封装好的状态控制赋值给 roomState 状态
            // 然后触发 Room 组件的更新
​
      setRoom(roomActions.setError);
      return;
    }
​
        // 如果验证成功,roomState 状态会被设置为 ready
    setRoom(roomActions.setReady(roomName));
  })();
}, [router, setRoom]);

第二个函数负责清空副作用和边界情况:

React.useEffect(() => {
  return () => {
    // Reset app state
    setPeers(defaultPeersState);
    setRoom(defaultRoomState);
    setLocal(defaultLocalState);
​
        // socketRef 在 createSocket 函数中会被赋值 socket 实例
        // 清空 socket 实例
    if (socketRef.current !== undefined) {
      socketRef.current.disconnect();
    }
​
        // 清空 streamMap 内的视频流
    streamMap.forEach((stream, key) => {
      stream?.getTracks().forEach((track) => {
        track.stop();
      });
      streamMap.delete(key);
    });
​
        // rtcDataChannelMap 保存的是 RTCDataChannel,在后面会讲到
        // 关闭所有的 RTCDataChannel
    rtcDataChannelMap.forEach((channel, sid) => {
      channel.close();
      rtcDataChannelMap.delete(sid);
    });
​
        // 关闭删除 rtcPeerConnectionMap 的所有 rtcPeerConnectionMap 连接 
    rtcPeerConnectionMap.forEach((rtcPeerConnection, sid) => {
      rtcPeerConnection.close();
      rtcPeerConnectionMap.delete(sid);
    });
  };
}, [setLocal, setPeers, setRoom]);

第三个副作用负责创建 WebSocket 连接

React.useEffect(() => {
  (async () => {
    if (local.status === "connecting") {
      const roomCode = router.query.roomCode as string;
            // 调用 createSocket 创建 WebSocket 链接并监听事件
      createSocket(roomCode, local, socketRef, setLocal, setPeers);
    }
  })();
}, [local, router.query.roomCode, setLocal, setPeers]);

至此 [roomCode].tsx 已经结束了,它将可以触发自身更新的 dispatch 传递出去,后续将根据这些状态的变化更新页面显示或是执行不同的副作用。

components/room/request-permission.tsx

local.statusrequestingPermissions 时,Room 组件会渲染 RequestPermission 组件,这个组件负责创建和存储本地媒体流。

const setLocal = useSetRecoilState(localState);

组件的核心是 requestPermissions 函数:

const requestPermissions = React.useCallback(async () => {
    // createLocalStream 负责创建本地媒体流,然后返回创建的 MediaStream
  const stream = await createLocalStream();
    // 存储创建的 MediaStream 到 streamMap
  streamMap.set(LocalStreamKey, stream);
    // 更新状态让 Room 组件渲染下一个组件
  setLocal(localActions.setRequestingDevices);
}, [setLocal]);

components/room/request-devices.tsx

local.statusrequestingDevices 时,Room 组件会渲染 RequestDevices 组件,这个组件负责选择媒体设备,代码量较多。

const setLocal = useSetRecoilState(localState);
const [devices, setDevices] = React.useState<Devices>();

先从 RequestDevices 组件的副作用开始:

React.useEffect(() => {
  (async () => {
        // getDevices 会获取并返回当前设备上的可用音频和视频设备列表
    setDevices(await getDevices());
  })();
}, []);

获取到了音频和视频设备列表,视图中会根据列表渲染选择器,选择器监听 change 事件,并回调 handleAudioChange 函数和 handleVideoChange 函数:

const handleAudioChange = React.useCallback(
  (deviceId: string | undefined) => {
        // handleDeviceChange 用于中断现有 MediaStream,并执行回调获取新 MediaStream 并存储
    handleDeviceChange(deviceId, async (devices: Devices) => {
            // 在可用设备列表中遍历查找选中的设备 ID
      const selectedAudio =
        devices.audio.find((device) => {
          return device.id === deviceId;
        }) ?? null;
            // 调用 createLocalStream 传入新的设备 ID 创建新的 MediaStream 
      const stream = await createLocalStream({
        audioDeviceId: selectedAudio?.id,
        videoDeviceId: devices.selectedVideo?.id,
      });
      setDevices({ ...devices, selectedAudio });
      return stream;
    });
  },
  [handleDeviceChange]
);

handleVideoChange 函数行为基本和 handleAudioChange 一致。

const handleVideoChange = React.useCallback(
  (deviceId: string | undefined) => {
    handleDeviceChange(deviceId, async (devices: Devices) => {
      const selectedVideo =
        devices.video.find((device) => {
          return device.id === deviceId;
        }) ?? null;
      const stream = await createLocalStream({
        videoDeviceId: selectedVideo?.id,
        audioDeviceId: devices.selectedAudio?.id,
      });
      setDevices({ ...devices, selectedVideo });
      return stream;
    });
  },
  [handleDeviceChange]
);

然后是用于删除视频流和创建新视频流的 handleDeviceChange 函数:

const handleDeviceChange = React.useCallback(
  async (
    deviceId: string | undefined,
    cb: (devices: Devices) => Promise<MediaStream | null>
  ) => {
    if (devices === undefined || deviceId === undefined) {
      return;
    }
​
    const stream = mapGet(streamMap, LocalStreamKey);
        // stopStream 用于删除中断现有 MediaStream
    stopStream(stream);
        // 调用回调获取新 MediaStream 并保存
    streamMap.set(LocalStreamKey, await cb(devices));
  },
  [devices]
);

最后是绑定在下一步操作按钮上的 joinRoom 函数:

const joinRoom = React.useCallback(async () => {
  const stream = mapGet(streamMap, LocalStreamKey);
    // getVideoAudioEnabled 用于从 MediaStream 中获取音频和视频是否启用的状态。
  const { audioEnabled, videoEnabled } = getVideoAudioEnabled(stream);
    // 设置 local 状态
  setLocal(localActions.setConnecting(audioEnabled, videoEnabled));
}, [setLocal]);

components/room/call.tsx

local.statusconnecting 时,将触发 Room 组件的第三个 useEffect,这个副作用中将创建 WebSocket 连接,并设置监听事件,在监听的 connected 事件中的 onConnected 回调中,将会把状态设置为 connected,Room 渲染最后一个组件 Call。

Call 组件包含 components/room/grid.tsxcomponents/room/controls.tsx,分别负责媒体的显示和媒体的控制功能。

components/room/grid.tsx

Grid 组件的代码比较多,但是大部分代码实际上用于计算视频数量和容器大小,通过计算得出网格的行和列数,使用 react-resize-detector 库对元素大小进行监听。

这里项目作者使用了一些比较少见的做法:使用 useMemo 缓存组件

const videos = React.useMemo<React.ReactElement[]>(() => {
  return [
        // videos 列表第一项是本地 MediaStream
    <LocalVideo key="local" />,
        // 通过遍历 peers 渲染远程 MediaStream
    ...peers.map((peer) => <PeerVideo key={peer.sid} peer={peer} />),
  ];
}, [peers]);

最后将 videos 渲染到页面上,完成了 MediaStream 的显示。

值得注意的是,在 LocalVideo 组件内部,使用了 assertlocal.status 进行状态检查,但是这种状态检查在出错时会直接抛出异常终端渲染,项目中也没有出现使用 ErrorBoundary 捕获错误,这似乎更像是一种错误?

export default function LocalVideo() {
  const local = useRecoilValue(localState);
​
    // 错误后会直接中断渲染
  assert(local.status === "connecting" || local.status === "connected");
  const stream = mapGet(streamMap, LocalStreamKey);
  const { audioEnabled, videoEnabled } = getVideoAudioEnabled(stream);
​
  return (
    <GridVideo
      audioDisabled={!audioEnabled}
      local
      name={`${local.name} (You)`}
      stream={stream}
      videoDisabled={!videoEnabled}
    />
  );
}

components/room/controls.tsx

Controls 组件的代码也不少,但是重点的函数只有两个,它们是负责控制声音的 handleToggleAudio、负责控制视频的 handleToggleVideo

const handleToggleAudio = React.useCallback(() => {
    // peers 存储的是当前存在的远端连接的信息,包含 sid、status、音视频状态
  peers.forEach((peer) => {
        // rtcDataChannelMap 存储由 RTCDataChannel.createDataChannel 创建的 RTCDataChannel
    const channel = rtcDataChannelMap.get(peer.sid);
​
    if (channel !== undefined) {
            // sendMessage 函数实际上调用 RTCDataChannel 实例的 send 方法发送第二个参数的 JSON
            // 这一步通过发送 JSON 格式的内容给远端实际上是为了更新试图中显示的远端控制状态
      sendMessage(channel, {
        type: "peer-state",
        name: local.name,
        audioEnabled: !audioEnabled,
        videoEnabled,
      });
    }
  });
​
    // 设置本地 MediaStream 的音频状态
  const audioTracks = stream?.getAudioTracks();
​
  if (audioTracks !== undefined && audioTracks.length > 0) {
    audioTracks[0].enabled = !audioEnabled;
  }
​
  setLocal(localActions.setAudioVideoEnabled(!audioEnabled, videoEnabled));
}, [audioEnabled, local.name, peers, setLocal, stream, videoEnabled]);

handleToggleAudiohandleToggleVideo 行为也是一致的。

const handleToggleVideo = React.useCallback(() => {
  peers.forEach((peer) => {
    const channel = rtcDataChannelMap.get(peer.sid);
​
    if (channel !== undefined) {
      sendMessage(channel, {
        type: "peer-state",
        name: local.name,
        audioEnabled,
        videoEnabled: !videoEnabled,
      });
    }
  });
​
  const videoTracks = stream?.getVideoTracks();
​
  if (videoTracks !== undefined && videoTracks.length > 0) {
    videoTracks[0].enabled = !videoEnabled;
  }
​
  setLocal(localActions.setAudioVideoEnabled(audioEnabled, !videoEnabled));
}, [audioEnabled, local.name, peers, setLocal, stream, videoEnabled]);

函数执行时通过调用 getVideoAudioEnabled 传入 MediaStream 获取音频和视频的初始状态

const { audioEnabled, videoEnabled } = getVideoAudioEnabled(stream);

至此视图层面部分比较核心的代码已经结束了,接下来是前端项目中比较核心逻辑处理部分。

@p2p.chat/www 核心逻辑

lib/mesh/websocket.ts

lib/mesh/websocket.ts 负责创建 socket 和监听事件并执行回调,可以说是这个项目中最核心的部分,因为这部分代码负责了整个项目的主要功能:WebRTC 连接的建立

createSocket 函数和后端部分的实现基本一样,也是作者喜欢的闭包保存参数的方式。

export const createSocket = async (
  roomCode: string, // roomCode 房间号,闭包保存
  local: Local, // 由 Recoil 管理的状态
  socketRef: React.MutableRefObject<Socket | undefined>, // React ref
  setLocal: SetLocal, // 用于更改 Recoil 中 local 状态
  setPeers: SetPeers // 用于更改 Recoil 中 peers 状态
): Promise<void> => {
  assert(process.env.NEXT_PUBLIC_SIGNALLING_URL !== undefined);
  const socket: Socket = io(process.env.NEXT_PUBLIC_SIGNALLING_URL);
​
  socketRef.current = socket;
​
  socket.on("connected", onConnected(socket, roomCode, setLocal));
  socket.on("peerConnect", onPeerConnect(socket, local, setPeers));
  socket.on("peerDisconnect", onPeerDisconnect(setPeers));
  socket.on("webRtcOffer", onWebRtcOffer(socket, local, setPeers));
  socket.on("webRtcAnswer", onWebRtcAnswer(socket));
  socket.on("webRtcIceCandidate", onWebRtcIceCandidate(setPeers));
};

在这里事件的触发需要区分已存在客户端和新加入客户端,后面会简称为发送方和加入方。因为有些事件只会在发送方被触发。

connected 在 WebSocket 连接成功后会触发,主要任务是加入房间和同步状态。

// onConnected 是发送方和加入方都会被触发的事件回调
const onConnected = (socket: Socket, roomCode: string, setLocal: SetLocal) => () => {
	console.debug(`connected`);
	socket.emit("joinRoom", roomCode);
	setLocal(localActions.setSocket);
};

peerConnect 在加入方进入房间时在发送方被触发,事件回调 onPeerConnect 也就是已经存在于房间内的客户端需要执行的任务。

// onPeerConnect 是仅会在发送方被触发的事件回调
const onPeerConnect =
  (socket: Socket, local: Local, setPeers: SetPeers) => async (sid: string) => {
        // sid 是经过 WebSocket 传递的加入方的 sid
    console.debug(`peerConnect sid=${sid}`);
​
        // 如果已经存有相同的 RTCPeerConnection 实例则不需要重复创建
    if (rtcPeerConnectionMap.get(sid)) {
      console.warn("Received connect from known peer");
      return;
    }
​
        // 调用 createRtcPeerConnection 创建 RTCPeerConnection 实例
    const rtcPeerConnection = createRtcPeerConnection(
      socket,
      local,
      sid,
      setPeers,
      true
    );
        // 存储创建的 RTCPeerConnection 实例
    rtcPeerConnectionMap.set(sid, rtcPeerConnection);
        // 调用 createOffer 创建 Offer
    const offerSdp = await rtcPeerConnection.createOffer();
        // 设置本地的会话描述(session description)
    rtcPeerConnection.setLocalDescription(offerSdp);
        // 更新状态
    setPeers(peersActions.addPeer(sid));
​
        // 向指定 sid 的加入方触发 webRtcOffer 事件
    socket.emit("webRtcOffer", { offerSdp, sid });
  };

webRtcOffer 会被加入发触发,事件回调 onWebRtcOffer 也就是新加入房间的客户端需要执行的任务。

由于这个项目的前端和后端中 socket 的事件命名是完全一样的,而且后端的事件回调基本上只做一件事情:向全部或者指定 sid 的客户端传递数据。所以直接从前端的事件触发顺序往下看也能够看明白。

// onWebRtcOffer 是仅会在加入方被触发的事件回调
const onWebRtcOffer =
  (socket: Socket, local: Local, setPeers: SetPeers) =>
  async ({ offerSdp, sid }: WebRtcOffer) => {
        // sid 是经过 WebSocket 传递的发送方的 sid
    console.debug(`webRtcOffer fromSid=${socket.id} toSid=${sid}`);
​
        // 调用 createRtcPeerConnection 创建 RTCPeerConnection 实例
    const rtcPeerConnection = createRtcPeerConnection(
      socket,
      local,
      sid,
      setPeers,
      false
    );
        // 存储创建的 RTCPeerConnection 实例
    rtcPeerConnectionMap.set(sid, rtcPeerConnection);
        // 更新状态
    setPeers(peersActions.addPeer(sid));
        // 设置远端的会话描述(session description)
    rtcPeerConnection.setRemoteDescription(new RTCSessionDescription(offerSdp));
        // 调用 createAnswer 创建 Answer
    const answerSdp = await rtcPeerConnection.createAnswer();
        // 设置本地的会话描述(session description)
    rtcPeerConnection.setLocalDescription(answerSdp);
​
        // 向指定 sid 的发送方触发 webRtcAnswer 事件
    socket.emit("webRtcAnswer", { answerSdp, sid });
  };

webRtcAnswer 会被发送方触发,事件回调 onWebRtcAnswer 发送方收到来自加入方发送的 Answer 之后需要执行的任务。

const onWebRtcAnswer = (socket: Socket) => (webRtcAnswer: WebRtcAnswer) => {
    // socket.id 是加入方也就是自己的 sid
    // webRtcAnswer.sid 是经过 WebSocket 传递的发送方的 sid
  console.debug(`webRtcAnswer fromSid=${socket.id} toSid=${webRtcAnswer.sid}`);
    // 取出在 onPeerConnect 中存入的 RTCPeerConnection 实例
  const rtcPeerConnection = mapGet(rtcPeerConnectionMap, webRtcAnswer.sid);
    // 设置远端的会话描述(session description)
  rtcPeerConnection.setRemoteDescription(
    new RTCSessionDescription(webRtcAnswer.answerSdp)
  );
};

webRtcAnswer 事件之后,WebRTC 连接就已经完成了基本的信息交换,接下来就是处理 ICE 候选者(ICE candidate)的生成和收集,而这一步被封装在了 createRtcPeerConnection 函数中。

createRtcPeerConnection 函数内会用 RTCPeerConnection 监听 onicecandidate 事件,这里会触发 WebSocket 的 webRtcIceCandidate 事件。

webRtcAnswer 是发送方和接收方都会触发的事件,用于交换彼此的 candidate 信息。 Typescript

// onWebRtcIceCandidate 是发送方和加入方都会被触发的事件回调
const onWebRtcIceCandidate =
  (setPeers: SetPeers) => (webRtcIceCandidate: WebRtcIceCandidate) => {
        // WebRtcIceCandidate 是经由 WebSocket 传递的包含远端 sid 和远端 candidate 的对象
        // 取出 RTCPeerConnection 实例
    const rtcPeerConnection = mapGet(
      rtcPeerConnectionMap,
      webRtcIceCandidate.sid
    );
    console.debug(
      "received ice candidate",
      webRtcIceCandidate.candidate,
      rtcPeerConnection.iceConnectionState
    );
        // 调用 addIceCandidate 将 remote candidate 添加到 RTCPeerConnection 中
    rtcPeerConnection.addIceCandidate(
      new RTCIceCandidate({
        sdpMLineIndex: webRtcIceCandidate.label,
        candidate: webRtcIceCandidate.candidate,
      })
    );
​
        // 根据 rtcPeerConnection.iceConnectionState 状态更新 Recoil 状态
    if (
      rtcPeerConnection.iceConnectionState === "connected" ||
      rtcPeerConnection.iceConnectionState === "completed"
    ) {
      setPeers(peersActions.setPeerConnected(webRtcIceCandidate.sid));
    }
  };

到这里前端的 WebRTC 连接的建立就完成了。

lib/mesh/webrtc.ts

lib/mesh/webrtc.ts 中只有一个函数 createRtcPeerConnection,它主要负责创建 RTCPeerConnection 实例并返回。

const iceServers = {
  iceServers: [
    { urls: "stun:stun1.l.google.com:19302" },
    { urls: "stun:stun2.l.google.com:19302" },
  ],
};
​
export const createRtcPeerConnection = (
  socket: Socket<ServerEvents, ClientEvents>,
  local: Local,
  sid: string,
  setPeers: SetPeers,
  creator: boolean
): RTCPeerConnection => {
  assert(local.status === "connecting");
​
    // 创建 RTCPeerConnection 实例
  const rtcPeerConnection = new RTCPeerConnection(iceServers);
    // 取出本地 MediaStream
  const stream = mapGet(streamMap, LocalStreamKey);
​
    // 将本地的 MediaStream 轨道添加到 RTCPeerConnection 中
  stream?.getTracks().forEach((track) => {
    rtcPeerConnection.addTrack(track, stream);
  });
​
    // 获取远端发送来的 MediaStream 轨道
  rtcPeerConnection.ontrack = (e) => {
    if (e.streams.length > 0) {
      streamMap.set(sid, e.streams[0]);
    }
  };
​
    // 当RTCPeerConnection 收集到新的 ICE 候选者时,会触发 onicecandidate 回调
  rtcPeerConnection.onicecandidate = (e) => {
    console.debug(
      "ice candidate",
      e.candidate?.candidate,
      rtcPeerConnection.iceConnectionState
    );
    if (e.candidate !== null) {
            // 触发 webRtcIceCandidate 事件传递 candidate
      socket.emit("webRtcIceCandidate", {
        sid,
        label: e.candidate.sdpMLineIndex,
        candidate: e.candidate.candidate,
      });
    }
​
        // 根据 rtcPeerConnection.iceConnectionState 状态更新 Recoil 状态
    if (
      rtcPeerConnection.iceConnectionState === "connected" ||
      rtcPeerConnection.iceConnectionState === "completed"
    ) {
      setPeers(peersActions.setPeerConnected(sid));
    }
  };
​
    // creator 参数用于区别发送方和加入方
    // 在发送方会触发的 onPeerConnect 回调中 creator 会被传递为 true
    // 在加入方会触发的 onWebRtcOffer 回调中 creator 会被传递为 false
  if (creator) {
        // 如果是发送方调用函数,教会调用 createDataChannel 创建 RTCDataChannel 实例
    const channel = rtcPeerConnection.createDataChannel("data");
        // registerDataChannel 是封装好的用于向 RTCDataChannel 发送信息的函数
        // 在项目中主要负责向会议中的客户端广播音频和视频状态
    registerDataChannel(sid, channel, local, setPeers);
  } else {
        // 当远端创建 RTCDataChannel 后,会触发 ondatachannel 事件
    rtcPeerConnection.ondatachannel = (event) => {
            // 调用 registerDataChannel 方法
      registerDataChannel(sid, event.channel, local, setPeers);
    };
  }
​
  return rtcPeerConnection;
};

lib/mesh/data.ts

registerDataChannel 是封装好的用于向 RTCDataChannel 发送信息的函数。

export const registerDataChannel = (
  sid: string,
  channel: RTCDataChannel,
  local: Local,
  setPeers: SetPeers
): void => {
  assert(local.status !== "requestingName" && local.status !== "requestingPermissions");
​
    // 监听传入的 RTCDataChannel 实例的 onopen 事件
  channel.onopen = () => {
    const stream = mapGet(streamMap, LocalStreamKey);
        // getVideoAudioEnabled 负责获取传入 MediaStream 的音频和视频的状态
    const { audioEnabled, videoEnabled } = getVideoAudioEnabled(stream);
        // sendMessage 函数实际上是调用 RTCDataChannel 实例的 send 方法
        // 向 RTCDataChannel 广播音频和视频状态
    sendMessage(channel, {
      type: "peer-state",
      name: local.name,
      audioEnabled,
      videoEnabled,
    });
  };
​
    // 监听 RTCDataChannel 实例的 onmessage 事件
  channel.onmessage = function (event) {
    const message: Message = JSON.parse(event.data);
​
    switch (message.type) {
      case "peer-state": {
                // onPeerState 是封装好的更新 Recoil 状态的函数
        onPeerState(sid, message, setPeers);
      }
    }
  };
​
    // 存储 RTCDataChannel 实例
  rtcDataChannelMap.set(sid, channel);
};
export const sendMessage = (channel: RTCDataChannel, message: Message) => {
    // 调用 RTCDataChannel 实例的 send 方法
  channel.send(JSON.stringify(message));
};

lib/mesh/stream.ts

export const createLocalStream = async ({
  audioDeviceId,
  videoDeviceId,
}: {
  audioDeviceId?: string;
  videoDeviceId?: string;
} = {}): Promise<MediaStream | null> => {
    // 如果没有传递参数,则默认获取摄像头和麦克风权限
  const audio = audioDeviceId !== undefined ? { deviceId: audioDeviceId } : true;
  const video = videoDeviceId !== undefined ? { deviceId: videoDeviceId } : true;
​
    // 用 try-catch 穷举获取全部权限
    // getMediaStream 是 getUserMedia 函数的封装
  try {
    // Try and get video and audio
    return await getMediaStream({ video, audio });
  } catch (err) {
    console.error(err);
    try {
      // Try just audio
      return await getMediaStream({ audio });
    } catch (err) {
      console.error(err);
      try {
        // Try just video
        return await getMediaStream({ video });
      } catch (err) {
        console.error(err);
        // No stream
        return null;
      }
    }
  }
};
const getMediaStream = async (
  constraints: MediaStreamConstraints
): Promise<MediaStream> => {
  return navigator.mediaDevices.getUserMedia(constraints);
};

getVideoAudioEnabled 负责获取传入 MediaStream 的音频和视频的状态。

export const getVideoAudioEnabled = (
  stream: MediaStream | null
): { audioEnabled: boolean; videoEnabled: boolean } => {
  if (stream === null) {
    return { audioEnabled: false, videoEnabled: false };
  }
​
  const videoTracks = stream.getVideoTracks();
  const audioTracks = stream.getAudioTracks();
​
    // 检查状态
  const videoEnabled =
    videoTracks !== undefined &&
    videoTracks.length > 0 &&
    videoTracks[0].enabled;
  const audioEnabled =
    audioTracks !== undefined &&
    audioTracks.length > 0 &&
    audioTracks[0].enabled;
​
  return { audioEnabled, videoEnabled };
};

getDevices 会获取并返回当前设备上的可用音频和视频设备列表。

export const getDevices = async (): Promise<Devices> => {
  const devices: Devices = {
    audio: [],
    selectedAudio: null,
    video: [],
    selectedVideo: null,
  };
​
    // 遍历所有设备
  (await navigator.mediaDevices.enumerateDevices()).forEach((mediaDevice) => {
    // We can't see what the device is. This happens when you enumerate devices
    // without permission having being granted to access that type of device.
        // 忽略无法识别的设备
    if (mediaDevice.label === "") {
      return;
    }
​
    const device: Device = {
      id: mediaDevice.deviceId,
      name: mediaDevice.label,
    };
​
        // 处理音频
    if (mediaDevice.kind.toLowerCase().includes("audio")) {
      devices.audio.push(device);
​
            // 默认选择第一个音频设备
      if (devices.selectedAudio === null) {
        devices.selectedAudio = device;
      }
    }
​
        // 处理视频
    if (mediaDevice.kind.toLowerCase().includes("video")) {
      devices.video.push(device);
​
            // 默认选择第一个视频设备
      if (devices.selectedVideo === null) {
        devices.selectedVideo = device;
      }
    }
  });
​
  return devices;
};

值得记录

项目中有一个地方值得我学习一下,就是对于房间号的创建和验证,这两个实现函数在 [lib/rooms/room-encoding.ts](https://github.com/tom-james-watson/p2p.chat/blob/master/www/lib/rooms/room-encoding.ts) 文件下。

createRoomCode 函数会获取当前的事件戳后 5 位,然后根据这后 5 位生成一个 hash。

// createRoomCode 负责根据传入的 roomName 创建房间号
export const createRoomCode = (roomName: string): string => {
    // 截取时间戳后五位生成
  const key = (+new Date()).toString(36).slice(-5);
    // 传递 getRoomHash key 和 roomName 生成 hash
  const hash = getRoomHash(key, roomName);
    // 最后再拼接上时间戳后五位生成就是生成的 RoomCode
  return key + hash;
};

getRoomHash 函数是对 shorthash 库的封装

const getRoomHash = (key: string, roomName: string): string => {
  return shorthash.unique(`${key}${roomName}`);
};

validateRoom 用于校验房间号是否正确,其逻辑就是截取出 createRoomCode 函数中放置的时间戳后五位,再次重复 createRoomCode 逻辑,判断房间是否存在。

export const validateRoom = (roomCode: string, roomName: string): void => {
  try {
    const key = roomCode.substr(0, 5);
    const hash = roomCode.substr(5);
​
        // 重复生成
    const computedHash = getRoomHash(key, roomName);
​
        // 判断生成结果是否和 createRoomCode 的结果一致
    if (hash !== computedHash) {
      throw new Error("Bad room hash");
    }
  } catch (e) {
    console.error(e);
    throw new Error("Invalid room code");
  }
};

这种方式可以简单的验证一个值是否有效,在项目中用来确定一个房间号是否存在,很有意思。