文章目录
序言
要想实现 山大会议 客户端的各种功能,并提高开发效率,我需要在开发的途中设计一些供其他组件使用的工具类,并把一些可以复用的代码提取出来进行封装。
HTTP 请求工具类
这是一个我用于发送 Http 异步请求的工具类,我在项目中引入了 axios 模块,并根据 axios 模块进行封装,得到了自己的用于发送网络请求的工具类。
整个工具类的代码如下:
// Axios.ts
import axios, {
AxiosInstance, AxiosRequestHeaders } from 'axios';
import store from 'Utils/Store/store';
const instance = axios.create({
baseURL: 'http://meeting.aiolia.top:8080/',
});
// const wsInstance = axios.create({
// baseURL: 'http://meeting.aiolia.top:8080/chat/',
// });
instance.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded';
// wsInstance.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded';
store.subscribe(() => {
const token = store.getState().authToken;
instance.defaults.headers.common['Authorization'] = token;
// wsInstance.defaults.headers.common['Authorization'] = token;
});
function convertParamsToData(param: object) {
const paramArr = [];
for (const key in param) {
if (Object.hasOwnProperty.call(param, key)) {
const value = param[key as keyof typeof param];
paramArr.push(`${
encodeURI(key)}=${
encodeURI(value)}`);
}
}
return paramArr.length > 0 ? paramArr.join('&') : '';
}
/**
* 根据 AxiosInstance 实例生成 Ajax 对象
* @param {AxiosInstance} instance AxiosInstance 实例
*/
class Ajax {
instance: AxiosInstance;
constructor(instance: AxiosInstance) {
this.instance = instance;
}
post(url: string, params?: object, headers?: AxiosRequestHeaders): Promise<any> {
return new Promise((resolve, reject) => {
this.instance({
method: 'post',
url,
data: params ? convertParamsToData(params) : '',
headers,
})
.then((response) => {
resolve(response.data);
})
.catch((error) => {
reject({
error,
ajax: true,
});
});
});
}
file(url: string, params: object, headers = {
}): Promise<any> {
const param = new FormData();
for (const key in params) {
if (Object.hasOwnProperty.call(params, key)) {
param.append(key, params[key as keyof typeof params]);
}
}
return new Promise((resolve, reject) => {
this.instance({
method: 'post',
url,
data: param,
headers: Object.assign(headers, {
'Content-Type': 'multipart/form-data' }),
})
.then((response) => {
resolve(response.data);
})
.catch((error) => {
reject({
error,
ajax: true,
});
});
});
}
get(url: string, params?: object, headers?: AxiosRequestHeaders): Promise<any> {
return new Promise((resolve, reject) => {
this.instance({
method: 'GET',
url,
headers,
params,
})
.then((response) => {
resolve(response.data);
})
.catch((error) => {
reject({
error,
ajax: true,
});
});
});
}
}
const ajax = new Ajax(instance);
// const wsAjax = new Ajax(wsInstance);
export default ajax;
事件总线
由于 React 倡导单向数据流,我们如果不通过一些特殊写法很难实现类似于 Vue 那样的双向绑定。Redux 是一种解决方案,然而 Redux 的部分写法太过繁琐,如果是一些很简单的操作没有必要通过 Redux 实现。因此,我为项目新增了一个事件总线的工具类,它采用发布-订阅的模式进行设计,整体代码如下:
interface EventBusFunction {
func: Function;
once: boolean;
}
class EventBus {
events: {
[key: string]: EventBusFunction[];
};
handlers: {
[key: string]: Function;
};
constructor() {
this.events = {
};
this.handlers = {
};
}
/**
* 为事件总线添加监听
* @param {string} type 添加的事件柄
* @param {Function} func 事件触发后执行的函数
*/
on(type: string, func: Function) {
if (!this.events[type]) this.events[type] = [];
this.events[type].push({
func,
once: false,
});
}
/**
* 为事件总线添加监听,只监听一次
* @param {string} type 添加的事件柄
* @param {Function} func 事件触发后执行的函数
*/
once(type: string, func: Function) {
if (!this.events[type]) this.events[type] = [];
this.events[type].push({
func,
once: true,
});
}
/**
* 触发事件
* @param {string} type 要触发的事件类型
* @param {...any} args 传入的参数
*/
emit(type: string, ...args: any[]) {
if (this.events[type]) {
const cbs = this.events[type];
const newCbs = new Array();
while (cbs.length > 0) {
const cb = cbs.pop() as EventBusFunction;
cb.func.apply(this, args);
if (!cb.once) newCbs.push(cb);
}
if (newCbs.length === 0) delete this.events[type];
else this.events[type] = newCbs;
}
}
/**
* 从事件总线上移除某个函数的监听
* @param {string} type 从哪个事件柄上移除函数
* @param {Function} func 需要移除的函数
* @returns {boolean} 是否执行了移除事件
*/
off(type: string, func: Function): boolean {
if (this.events && this.events[type]) {
const cbs = this.events[type];
let index = -1;
for (const cb of cbs) {
index++;
if (cb.func === func) {
cbs.splice(index, 1);
if (cbs.length === 0) delete this.events[type];
return true;
}
}
}
return false;
}
/**
* 从事件总线上移除某个事件的全部监听
* @param {string} type 需要移除的事件柄
*/
offAll(type: string) {
if (this.events) {
delete this.events[type];
}
}
/**
* 向事件总线添加事件柄回调
* @param {string} type 事件柄名
* @param {Function} cb 回调函数
*/
handle(type: string, cb: Function): {
ok: boolean; err?: Error } {
if (this.handlers[type]) {
return {
ok: false,
err: new Error(`Handler '${
type}' has already been registered.`),
};
} else {
this.handlers[type] = cb;
return {
ok: true,
};
}
}
/**
* 异步触发事件柄函数
* @param {string} type 要触发的事件柄
* @param {Array} args 传给回调函数的参数
* @returns {Promise} 经过 Promise 封装后的回调函数执行结果
*/
invoke(type: string, ...args: any[]) {
const handler = this.handlers[type];
if (handler) {
return Promise.resolve(handler.apply(this, args));
} else {
throw Promise.resolve(new Error(`Handler '${
type}' has not been registered yet.`));
}
}
/**
* 同步触发事件柄函数
* @param {string} type 要触发的事件柄
* @param {Array} args 传给回调函数的参数
* @returns 回调函数执行结果
*/
invokeSync(type: string, ...args: any[]) {
const handler = this.handlers[type];
if (handler) {
return handler.apply(this, args);
} else {
throw new Error(`Handler '${
type}' has not been registered yet.`);
}
}
/**
* 移除事件柄监听
* @param {string} type 要移除的事件柄
*/
removeHandler(type: string) {
delete this.handlers[type];
}
}
const eventBus = new EventBus();
export default eventBus;
自定义 Hooks
在上一篇博客中,我介绍了一个在本项目中的自定义Hook——useVolume,除了这个 Hook ,我还定义了另一个 Hook,它们被共同编写在了 MyHooks.ts
文件当中。除了 useVolume,另一个自定义 Hook 是 usePrevious,它负责保存一个变量在上一个 React 时钟的值。具体实现也非常简单巧妙:
const {
useRef, useEffect } = require('react');
/**
* 【自定义Hooks】保留数据在上一个时刻的状态
* @param {any} value 需要保留的数据
* @returns 数据在上一时刻的状态
*/
const usePrevious = (value: any): typeof value => {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
};
基于 useEffect
的特性,使得该 Hook 能够先行 return ref.current
,而后才通过 useEffect
执行副作用,更新 ref.current
的值,从而实现保留上一个时钟状态下的值。
消息提示音
在我们的项目中,我额外添加了一个 MSN 功能,用户可通过好友系统添加其他用户为好友,并与之进行即时通信。为了提高用户的使用体验,我编写了这样一个工具类,来为用户提供消息提示音。
export const AUDIO_TYPE = {
MESSAGE_RECEIVED: 'info',
WEBRTC_CALLING: 'call',
WEBRTC_ANSWERING: 'answer',
};
export const buildPropmt = function (audioType: string, loop = false) {
const audioContext = new AudioContext();
let source = audioContext.createBufferSource();
const audio = require(`./audios/${
audioType}.mp3`);
const startAudioPropmt = () => {
if (source.buffer) {
source.stop();
source = audioContext.createBufferSource();
}
fetch(audio.default)
.then((res) => {
return res.arrayBuffer();
})
.then((arrayBuffer) => {
return audioContext.decodeAudioData(arrayBuffer, (decodeData) => {
return decodeData;
});
})
.then(async (audioBuffer) => {
stopAudioPropmt();
source.buffer = audioBuffer;
source.loop = loop;
source.connect(audioContext.destination);
});
source.start(0);
};
const stopAudioPropmt = () => {
if (source.buffer) {
source.stop();
source = audioContext.createBufferSource();
}
};
return [startAudioPropmt, stopAudioPropmt];
};
我利用 AudioContext
创建了 AudioBufferSourceNode
,并通过它来播放二进制流的音频文件。为了方便其他组件使用,我将该功能进行封装,并返回了两个函数,一个用于播放音频,另一个用于停止播放以防止出现诸如内存泄漏的恶性 bug 。
一些常量的定义
在开发过程中,我用到了许多常量,而这些常量通常被多个组件、多个模块所使用。为了减少代码量,并且提高可维护性,我将它们提取出来,统一放在了 Utils/Constraints.ts
下。
// Constraints.ts
/**
* 这个文件用来存放一些常量
*/
// 音视频设备
export const DEVICE_TYPE = {
VIDEO_DEVICE: 'video',
AUDIO_DEVICE: 'audio',
};
/**
* 通话状态
*/
export const CALL_STATUS_FREE = 0;
export const CALL_STATUS_OFFERING = 1;
export const CALL_STATUS_OFFERED = 2;
export const CALL_STATUS_ANSWERING = 3;
export const CALL_STATUS_CALLING = 4;
/**
* 回复好友申请
*/
export const ACCEPT_FRIEND_REQUEST = 2;
export const REJECT_FRIEND_REQUEST = 1;
export const NO_OPERATION_FRIEND_REQUEST = -1;
/**
* 聊天系统 WebSocket type 参数
*/
export enum ChatWebSocketType {
UNDEFINED_0, // 未定义 0 占位
CHAT_SEND_PRIVATE_MESSAGE, // 发送私聊消息
CHAT_READ_MESSAGE, // 签收私聊消息
CHAT_SEND_FRIEND_REQUEST, // 发送好友请求
CHAT_ANSWER_FRIEND_REQUEST, // 响应好友请求
CHAT_PRIVATE_WEBRTC_OFFER, // 发送视频聊天请求 OFFER
CHAT_PRIVATE_WEBRTC_ANSWER, // 响应视频聊天请求 ANSWER
CHAT_PRIVATE_WEBRTC_CANDIDATE, // 视频聊天 ICE 候选者
CHAT_PRIVATE_WEBRTC_DISCONNECT, // 断开视频聊天
CHAT_PRIVATE_WEBRTC_REQUEST, // 发送视频通话请求
CHAT_PRIVATE_WEBRTC_RESPONSE, // 响应视频通话请求
}
/**
* 私人聊天响应常量
*/
export const PRIVATE_WEBRTC_ANSWER_TYPE = {
NO_USER: -2, // 不存在的用户
REJECT: -1, // 拒绝请求
BUSY: 0, // 占线中
ACCEPT: 1, // 接受请求
};
// NOTE: 支持的编码器
const senderCodecs = RTCRtpSender.getCapabilities('video')?.codecs as RTCRtpCodecCapability[];
const receiverCodecs = RTCRtpReceiver.getCapabilities('video')?.codecs as RTCRtpCodecCapability[];
(() => {
const senderH264Index = senderCodecs?.findIndex(
(c) =>
c.mimeType === 'video/H264' &&
c.sdpFmtpLine ===
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f'
);
const senderH264 = (senderCodecs as Array<RTCRtpCodecCapability>)[
senderH264Index ? senderH264Index : 0
];
senderCodecs?.splice(senderH264Index ? senderH264Index : 0, 1);
senderCodecs?.unshift(senderH264);
const receiverH264Index = receiverCodecs?.findIndex(
(c) =>
c.mimeType === 'video/H264' &&
c.sdpFmtpLine ===
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f'
);
const receiverH264 = (receiverCodecs as Array<RTCRtpCodecCapability>)[
receiverH264Index ? receiverH264Index : 0
];
receiverCodecs?.splice(receiverH264Index ? receiverH264Index : 0, 1);
receiverCodecs?.unshift(receiverH264);
})();
export {
senderCodecs, receiverCodecs };
同时,由于本项目采用的开发语言是 TypeScript,为了开发方便,我还需要定义多个 interface ,我将它们放置在了 Utils/Types.ts
中。
// Types.ts
import {
ReactNode } from 'react';
export interface ChatMessage {
date: number;
fromId: number;
id: number;
message: string;
toId: number;
myId?: number;
userId: number;
}
export interface DeviceInfo {
webLabel?: ReactNode;
deviceId: string;
label: string;
}
export interface UserInfo {
email: string;
exp: number;
iat: number;
id: number;
iss: string;
profile: string | false;
role: [
{
authority: string;
id: number;
}
];
sub: string;
username: string;
}
interface ElectronWindow {
captureDesktop: () => Promise<HTMLVideoElement>;
ipc: {
on: (channel: string, cb: Function) => void;
once: (channel: string, cb: Function) => void;
invoke: (channel: string, ...args: any) => Promise<any>;
removeListener: (channel: string, cb: Function) => void;
send: (channel: string, ...args: any) => void;
};
}
declare const window: Window & typeof globalThis & ElectronWindow;
const eWindow = window;
export {
eWindow };
一些全局函数
剩下的则是一些并不那么好分类的零散的函数,它们也经常被各种不同的组件调用,拆分成单独的文件未免有些过于浪费了,因此我将它们统统放在了一个 Global.ts
当中。
获取模态屏主容器
在项目中,我引入了 antd
UI 组件库,而且经常使用它们的模态屏组件。为了美观,这些模态屏经常只覆盖除了顶部拖动栏之外的区域,因此,我专门写了一个函数用于为它们提供挂载容器。
/**
* 用来返回 mainContent 模态屏遮罩层挂载DOM
* @returns Id值为'mainContent'的DOM
*/
function getMainContent(): HTMLElement {
const content = document.getElementById('mainContent');
if (content) {
return content;
} else {
return document.body;
}
}
token 解析函数
在这个项目中,由于后端采用了分布式的架构,因此我们决定使用 Token (jwt)来保存用户状态。在客户端,我选择使用 jwtDecode
这个模块来解析 jwt 。
但是,如果使用 jwtDecode
解析一些不是合法 Token 的字符串则会甩出异常。为了减少 try...catch
语句的使用,我选择对解析函数也进行封装。
import jwtDecode from 'jwt-decode';
import {
UserInfo } from './Types';
/**
* 由于直接使用 jwtDecode 解析非法 token 会报错,因此进行封装
* @param {string} token
* @returns 解析后的 token
*/
function decodeJWT(token: string): UserInfo {
try {
return jwtDecode(token);
} catch (error: any) {
console.log(error);
return {
email: '',
exp: 0,
iat: 0,
id: 0,
iss: '',
profile: false,
role: [
{
authority: '',
id: 0,
},
],
sub: '',
username: '',
};
}
}
获取设备流
在客户端代码的许多地方,都需要去获取用户的多媒体设备的多媒体流,因此我将该方法提取为了全局函数。
import {
DEVICE_TYPE } from './Constraints';
import store from './Store/store';
import {
DeviceInfo } from './Types';
/**
* 封装后的获取设备流函数
* @param {string} device 设备类型 DEVICE_TYPE
* @returns
*/
async function getDeviceStream(device: string): Promise<MediaStream> {
switch (device) {
case DEVICE_TYPE.AUDIO_DEVICE:
const audioDevice = store.getState().usingAudioDevice as DeviceInfo;
const audioConstraints = {
deviceId: {
exact: audioDevice.deviceId,
},
noiseSuppression: localStorage.getItem('noiseSuppression') !== 'false',
echoCancellation: localStorage.getItem('echoCancellation') !== 'false',
};
try {
return await navigator.mediaDevices.getUserMedia({
audio: audioConstraints });
} catch (e) {
return await getDefaultStream();
}
case DEVICE_TYPE.VIDEO_DEVICE:
const videoDevice = store.getState().usingVideoDevice as DeviceInfo;
const videoConstraints = {
deviceId: {
exact: videoDevice.deviceId,
},
width: 1920,
height: 1080,
frameRate: {
max: 30,
},
};
try {
return await navigator.mediaDevices.getUserMedia({
video: videoConstraints,
});
} catch (e) {
return await getDefaultStream();
}
default:
return new MediaStream();
}
}
它将根据传入的设备类型,返回经过 Promise 封装后的对应的设备流。但是,我们不能排除某些用户的电脑上不存在音视频设备,一旦某个流无法获取则会造成灾难性的后果。为了防止这种事情发生,我写了一个获取默认的流的函数。
let defaultVideoWidget: HTMLVideoElement | undefined;
function getDefaultStream(): Promise<MediaStream> {
return new Promise((resolve) => {
if (defaultVideoWidget) {
resolve((defaultVideoWidget as any).captureStream(1) as MediaStream);
} else {
defaultVideoWidget = document.createElement('video');
defaultVideoWidget.autoplay = true;
defaultVideoWidget.src = '../electronAssets/null.mp4';
defaultVideoWidget.loop = true;
defaultVideoWidget.onloadedmetadata = () => {
resolve((defaultVideoWidget as any).captureStream(1) as MediaStream);
};
}
});
}
它将在流捕获失败时返回一个默认的具有音频和视频轨道的多媒体流。
时间换算函数
时间间隔函数
为了根据不同的即时消息发送时间显示不同的内容,我定义了一组用来获取消息发送至今过去了多长时间的函数。
export const A_SECOND_TIME = 1000;
export const A_MINUTE_TIME = 60 * A_SECOND_TIME;
export const AN_HOUR_TIME = 60 * A_MINUTE_TIME;
export const A_DAY_TIME = 24 * AN_HOUR_TIME;
export const isSameDay = (
timeStampA: string | number | Date,
timeStampB: string | number | Date
) => {
const dateA = new Date(timeStampA);
const dateB = new Date(timeStampB);
return dateA.setHours(0, 0, 0, 0) === dateB.setHours(0, 0, 0, 0);
};
export const isSameWeek = (
timeStampA: string | number | Date,
timeStampB: string | number | Date
) => {
let A = new Date(timeStampA).setHours(0, 0, 0, 0);
let B = new Date(timeStampB).setHours(0, 0, 0, 0);
const timeDistance = Math.abs(A - B);
return timeDistance / A_DAY_TIME;
};
export const isSameYear = (
timeStampA: string | number | Date,
timeStampB: string | number | Date
) => {
const dateA = new Date(timeStampA);
const dateB = new Date(timeStampB);
dateA.setHours(0, 0, 0, 0);
dateB.setHours(0, 0, 0, 0);
dateA.setMonth(0, 1);
dateB.setMonth(0, 1);
return dateA.getFullYear() === dateB.getFullYear();
};
星期数转汉字函数
同时,还定义了一个根据星期数转换为中文的函数。
export const translateDayNumberToDayChara = (day: any) => {
if (typeof day === 'number') {
day = day % 7;
}
switch (day) {
case 0:
return '星期天';
case 1:
return '星期一';
case 2:
return '星期二';
case 3:
return '星期三';
case 4:
return '星期四';
case 5:
return '星期五';
case 6:
return '星期六';
default:
return String(day);
}
};
桌面捕获函数
除了基本的音视频设备流,我们的项目还需要实现用户桌面的捕获。但是在 electron 中,我们无法使用 navigator.mediaDevices.getDisplayMedia
这个 API ,因此我们需要自行实现对应的功能。
首先,我们需要在 electron 的主进程中调用 desktopCapture 模块对用户的桌面进行捕获。
// main.js
const {
desktopCapturer } = require('electron');
const ipc = require('electron').ipcMain;
ipc.handle('DESKTOP_CAPTURE', () => {
return new Promise(async (resolve, reject) => {
try {
const sources = await desktopCapturer.getSources({
types: ['screen'] });
resolve(sources[0]);
} catch (err) {
reject(err);
}
});
});
而在 Global.ts
中,我需要编写如下代码:
function getDesktopStream(): Promise<MediaStream> {
return new Promise((resolve) => {
eWindow.ipc.invoke('DESKTOP_CAPTURE').then((source) => {
(navigator as any).mediaDevices
.getUserMedia({
audio: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: source.id,
},
},
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: source.id,
},
},
})
.then((stream: MediaStream) => {
resolve(stream);
});
});
});
}
我们将以这种方式,实现对用户桌面的截取。