项目概述
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 只执行了两个函数:
TypeScript
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
事件为例:
TypeScript
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
闭包保存了 logger
和 socket
,这是一个保存常用参数的好办法。
在信令服务器监听的这些事件中,几乎只有一个目的:交换客户端之间的信息。
TypeScript
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
组件负责视频会议房间号和房间名的生成,代码逻辑非常简单,完全跳过也不影响,但是这里有一个房间号生成和校验逻辑很有意思,值得看看。完整的创建和校验逻辑放在文章结尾,这里只贴出组件代码。
tsx
const [roomName, setRoomName] = React.useState("");
Input 监听 onInput 事件,事件回调是 handleChange
tsx
const handleChange = React.useCallback((value: string) => {
// slugify 用于将字符串中的空格和特殊字符替换为短横线,并将所有字母转换为小写字母。
setRoomName(slugify(value));
}, []);
Typescript
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
:
tsx
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]);
Typescript
// cleanSlug 用于清理字符串中的连字符(-),将字符串的开头和结尾的连字符去掉
export const cleanSlug = (text: string): string => {
return text
.replace(/^-+/, "") // Trim - from start of text
.replace(/-+$/, ""); // Trim - from end of text
};
Typescript
export const randomRoomName = (): string => {
return Math.random().toString(16).substr(2, 8);
};
Typescript
export const createRoomCode = (roomName: string): string => {
// 截取当前时间戳后五位
const key = (+new Date()).toString(36).slice(-5);
// 传递 roomName 和时间戳后五位生成 hash
const hash = getRoomHash(key, roomName);
// 拼接并返回
return key + hash;
};
Typescript
const getRoomHash = (key: string, roomName: string): string => {
// 调用 shorthash 生成 hash
return shorthash.unique(`${key}${roomName}`);
};
pages/[roomCode]/[roomCode].tsx
[roomCode].tsx
所负责的页面是视频会议的主要页面,也是前端视图部分的核心。
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 状态的不同让当前页面所显示的状态不同,他有着五个状态:requestingName
、requestingPermissions
、requestingDevices
、connecting
、connected
。分别对应输入用户名(RequestName 组件)、请求权限(RequestPermission 组件)、选择设备(RequestDevices 组件)、加载(Loading 组件)、接通(Call 组件)五个阶段。
除了以上状态,组件内还有三个副作用(useEffect
):
第一个副作用用于 roomName 的校验:
tsx
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]);
第二个函数负责清空副作用和边界情况:
tsx
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 连接
tsx
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.status
为 requestingPermissions
时,Room 组件会渲染 RequestPermission
组件,这个组件负责创建和存储本地媒体流。
tsx
const setLocal = useSetRecoilState(localState);
组件的核心是 requestPermissions
函数:
tsx
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.status
为 requestingDevices
时,Room 组件会渲染 RequestDevices
组件,这个组件负责选择媒体设备,代码量较多。
tsx
const setLocal = useSetRecoilState(localState);
const [devices, setDevices] = React.useState<Devices>();
先从 RequestDevices
组件的副作用开始:
tsx
React.useEffect(() => {
(async () => {
// getDevices 会获取并返回当前设备上的可用音频和视频设备列表
setDevices(await getDevices());
})();
}, []);
获取到了音频和视频设备列表,视图中会根据列表渲染选择器,选择器监听 change 事件,并回调 handleAudioChange
函数和 handleVideoChange
函数:
tsx
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
一致。
tsx
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
函数:
tsx
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
函数:
tsx
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.status
为 connecting
时,将触发 Room 组件的第三个 useEffect
,这个副作用中将创建 WebSocket 连接,并设置监听事件,在监听的 connected
事件中的 onConnected
回调中,将会把状态设置为 connected
,Room 渲染最后一个组件 Call。
Call 组件包含 components/room/grid.tsx
和 components/room/controls.tsx
,分别负责媒体的显示和媒体的控制功能。
components/room/grid.tsx
Grid
组件的代码比较多,但是大部分代码实际上用于计算视频数量和容器大小,通过计算得出网格的行和列数,使用 react-resize-detector
库对元素大小进行监听。
这里项目作者使用了一些比较少见的做法:使用 useMemo
缓存组件
tsx
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 组件内部,使用了 assert
对 local.status
进行状态检查,但是这种状态检查在出错时会直接抛出异常终端渲染,项目中也没有出现使用 ErrorBoundary 捕获错误,这似乎更像是一种错误?
tsx
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
。
tsx
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]);
handleToggleAudio
和 handleToggleVideo
行为也是一致的。
tsx
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
获取音频和视频的初始状态
tsx
const { audioEnabled, videoEnabled } = getVideoAudioEnabled(stream);
至此视图层面部分比较核心的代码已经结束了,接下来是前端项目中比较核心逻辑处理部分。
@p2p.chat/www 核心逻辑
lib/mesh/websocket.ts
lib/mesh/websocket.ts
负责创建 socket 和监听事件并执行回调,可以说是这个项目中最核心的部分,因为这部分代码负责了整个项目的主要功能:WebRTC 连接的建立。
createSocket 函数和后端部分的实现基本一样,也是作者喜欢的闭包保存参数的方式。
Typescript
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 连接成功后会触发,主要任务是加入房间和同步状态。
Typescript
// onConnected 是发送方和加入方都会被触发的事件回调
const onConnected = (socket: Socket, roomCode: string, setLocal: SetLocal) => () => {
console.debug(`connected`);
socket.emit("joinRoom", roomCode);
setLocal(localActions.setSocket);
};
peerConnect
在加入方进入房间时在发送方被触发,事件回调 onPeerConnect
也就是已经存在于房间内的客户端需要执行的任务。
Typescript
// 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 的客户端传递数据。所以直接从前端的事件触发顺序往下看也能够看明白。
Typescript
// 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
之后需要执行的任务。
Typescript
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
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
实例并返回。
Typescript
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
发送信息的函数。
Typescript
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);
};
Typescript
export const sendMessage = (channel: RTCDataChannel, message: Message) => {
// 调用 RTCDataChannel 实例的 send 方法
channel.send(JSON.stringify(message));
};
lib/mesh/stream.ts
Typescript
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;
}
}
}
};
Typescript
const getMediaStream = async (
constraints: MediaStreamConstraints
): Promise<MediaStream> => {
return navigator.mediaDevices.getUserMedia(constraints);
};
getVideoAudioEnabled
负责获取传入 MediaStream
的音频和视频的状态。
Typescript
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
会获取并返回当前设备上的可用音频和视频设备列表。
Typescript
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。
Typescript
// 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
库的封装
Typescript
const getRoomHash = (key: string, roomName: string): string => {
return shorthash.unique(`${key}${roomName}`);
};
validateRoom
用于校验房间号是否正确,其逻辑就是截取出 createRoomCode
函数中放置的时间戳后五位,再次重复 createRoomCode
逻辑,判断房间是否存在。
Typescript
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");
}
};
这种方式可以简单的验证一个值是否有效,在项目中用来确定一个房间号是否存在,很有意思。