PeerJS入门教程

2,320 阅读8分钟

10多年前,谷歌以略低于7000万美元的价格收购了一家名为GIPS的小公司。这标志着谷歌开始努力将实时通信(RTC)原生引入浏览器。通过开源GIPS的主要产品,谷歌制定了一个标准,该标准首先由爱立信实施,然后在W3C规范中得到巩固。

今天,这个标准被称为WebRTC,在所有主要的浏览器和平台中得到广泛支持。这项技术使你有能力在你的页面上为不同的用户提供一种交换视频、音频和其他数据的方式,而不受用户的具体浏览器和设备类型的影响。

WebRTC的一个缺点是,它相当复杂。幸运的是,我们可以使用PeerJS--一个简化WebRTC的库,它提供了一个完整的、可配置的、易于使用的点对点连接API。

安装PeerJS

像现在大多数JS库一样,你可以通过导入语句在你的捆绑项目中使用PeerJS,或者直接从CDN(如unpkg)中包含该脚本。

你可以将PeerJS包含在一个带有脚本的页面中,如下图。

<script src="https://unpkg.com/peerjs@1.3.1/dist/peerjs.min.js"></script>

这将使Peer 类可用,然后允许我们创建一个实例并开始类似于聊天的活动。

const peer = new Peer({
  host: "0.peerjs.com",
  port: 443,
  path: "/",
  pingInterval: 5000,
});

上面,我们使用默认设置创建了一个实例。PeerJS需要一个中央服务器来识别哪些对等人是可用的。

为了完成聊天,我们仍然需要打开一个与聊天伙伴的连接,并发送和接收信息。让我们看看这在PeerJS中是如何完成的。

const conn = peer.connect("other-peer-id");
conn.on("open", () => {
  conn.send("Hello World!");
});
conn.on("data", (data) => {
  console.log("Received data", data);
});

很多时候,这不是创建一个连接,而是实际接收或处理一个连接请求。传入的请求可以由存在于Peer 实例中的connection 事件来处理。

peer.on("connection", (conn) => {
  conn.on("data", (data) => {
    console.log("Received data", data);
  });
});

从这里开始,PeerJS还允许我们利用数据连接,而不仅仅是文本。结合浏览器中的getUserMedia API,我们可以做到:

getUserMedia(
  { video: true, audio: true },
  (stream) => {
    const call = peer.call("other-peer-id", stream);

    call.on("stream", (remoteStream) => {
      // Show stream in some video/canvas element.
    });
  },
  console.error
);

同样地,接收视频和音频流也是可能的:

peer.on("call", (call) => {
  getUserMedia(
    { video: true, audio: true },
    (stream) => {
      call.answer(stream);

      call.on("stream", (remoteStream) => {
        // Show stream in some video/canvas element.
      });
    },
    console.error
  );
});

用PeerJS创建一个简单的聊天应用程序

让我们用上一节的代码来构建一个简单的例子,让我们能够进行和接收一对一的视频通话。我们的例子应该遵循这个工作流程:

  1. 你打开一个要求你输入名字的网站
  2. 输入你的名字后,一个屏幕要求你输入你想打电话的人的名字

我们将首先初始化一个新的项目,并添加所有的依赖项:

npm init -y
npm i peerjs react react-dom react-router react-router-dom --save
npm i @types/react @types/react-dom @types/react-router-dom typescript parcel@next --save-dev

现在,我们需要创建一个HTML文件,作为Parcel的入口点。

Parcel是一个捆绑器,它将把我们用来编写应用程序的所有不同来源转换成网络浏览器可以理解的文件。例如,对于造型,我们使用SASS,对于脚本,我们使用TypeScript--这两种格式没有浏览器能够理解。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>PeerJS Example</title>
    <link rel="stylesheet" href="./style.scss" />
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="./app.tsx"></script>
  </body>
</html>

我们将在没有任何设计元素的情况下开始,而是专注于为两个同伴创建一个基于文本的聊天应用程序。为了方便起见,这个例子的应用程序是用React编写的,但也可以自由选择你喜欢的UI库或框架。

基本上,我们要做的是:

const App = () => {
  return (
    <BrowserRouter>
      <Switch>
        <Route exact path="/" component={NameInput} />
        <Route exact path="/overview" component={Overview} />
        <Route exact path="/call" component={Call} />
      </Switch>
    </BrowserRouter>
  );
};

render(<App />, document.querySelector("#app"));

我们将使用三个组件来处理我们应用程序的三个不同区域:

  • NameInput ,让用户选择他们的名字
  • Overview 允许用户拨打或接收电话
  • Call 来处理一个正在进行的通话

我们还将通过将Peerconnection 的值放在全局中来保持简单。它们仍然可以在组件中使用--例如,对于NameComponent ,我们写道:

const NameInput = () => {
  const history = useHistory();
  // use local copy of the global to manage the different behaviors reliably
  const [availablePeer, setAvailablePeer] = React.useState(peer);

  const submit = React.useCallback((ev) => {
    const input = ev.currentTarget.elements.namedItem("name");
    const user = input.value;
    ev.preventDefault();
    // let's set the peer
    setAvailablePeer(new PeerJs(user));
  }, []);

  React.useEffect(() => {
    // apply the local peer to the global variables
    peer = availablePeer;

    // entering the name is only necessary if we don't have a peer yet;
    // if we have then let's show the overview
    if (availablePeer) {
      history.replace("/overview");
    }
  }, [availablePeer]);

  return (
    <form onSubmit={submit}>
      <label>Your name:</label>
      <input name="name" />
      <button>Save</button>
    </form>
  );
};

对于Overview 组件来说也是如此:

const Overview = () => {
  const history = useHistory();
  const [availablePeer] = React.useState(peer);
  // use local copy of the global to manage the different behaviors reliably
  const [availableConnection, setAvailableConnection] =
    React.useState(connection);

  const submit = React.useCallback(
    (ev) => {
      const input = ev.currentTarget.elements.namedItem("name");
      const otherUser = input.value;
      ev.preventDefault();
      // make the call
      setAvailableConnection(availablePeer.connect(otherUser));
    },
    [availablePeer]
  );

  React.useEffect(() => {
    connection = availableConnection;

    if (!availablePeer) {
      // no peer yet? we need to start at the name input
      history.replace("/");
    } else if (availableConnection) {
      // already a connection? then let's show the ongoing call
      history.replace("/call");
    } else {
      // let's wait for a connection to be made
      peer.on("connection", setAvailableConnection);
      return () => peer.off("connection", setAvailableConnection);
    }
  }, [availablePeer, availableConnection]);

  return (
    <div>
      <h1>Hi, {availablePeer?.id}</h1>
      <form onSubmit={submit}>
        <label>Name to call:</label>
        <input name="name" />
        <button>Call</button>
      </form>
    </div>
  );
};

对于Call ,主要归结为使用三个函数:

  • 主动结束调用("断开连接")。
  • 处理消息的发送("提交")。
  • 对来自另一方的断开连接和消息做出反应(使用useEffect Hook的侧面效果)

在代码中,这看起来如下:

React.useEffect(() => {
  connection = availableConnection;

  if (!availableConnection) {
    history.replace('/overview');
  } else {
    const dataHandler = (data: string) => {
      setMessages((msgs) => [...msgs, data]);
    };
    const closeHandler = () => {
      setAvailableConnection(undefined);
    };
    availableConnection.on('data', dataHandler);
    availableConnection.on('close', closeHandler);
    return () => {
      availableConnection.off('data', dataHandler);
      availableConnection.off('close', closeHandler);
    };
  }
}, [availableConnection]);

const submit = React.useCallback(
  (ev) => {
    const input = ev.currentTarget.elements.namedItem('message');
    const message = input.value;
    ev.preventDefault();
    availableConnection.send(message);
    input.value = '';
  },
  [availableConnection],
);

const disconnect = React.useCallback(() => {
  availableConnection.close();
  setAvailableConnection(undefined);
}, [availableConnection]);

上述代码的结果应该如下图所示。请记住,用户体验和整体造型并不是我们在这部分演示中的重点。聊天的效果很好,从功能的角度来看,它满足了要求。

PeerJS chat demo

一个简单的PeerJS聊天

现在,是时候通过添加音频和视频功能使聊天变得更好。

添加音频和视频

PeerJS通过建立在getUserMedia 浏览器API上提供音频和视频流。

要做的第一件事是获得对getUserMedia 的引用。我们将专门使用 "旧的 "和更成熟的API,可以直接从navigator ,尽管有一个新的API可用,即 [navigator.mediaDevices](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/mediaDevices).由于它的工作方式略有不同,但更重要的是,它还没有像旧的API那样得到广泛的支持,所以我们现在将避免使用它。

考虑到这一点,获得getUserMedia 的参考的可靠方法是:

const getUserMedia =
  navigator.getUserMedia ||
  navigator["webkitGetUserMedia"] ||
  navigator["mozGetUserMedia"];

在我们获得一个有效的引用后(或者没有--在这种情况下,它实际上应该出错或有某种回退),我们可以使用它。

让我们在之前的聊天信息例子的基础上,在connection ,添加关于谁实际调用谁的信息。

// if we are the ones who called
connection["caller"] = availablePeer.id;

// if the other party called and we received
const handler = (connection) => {
  connection["caller"] = connection.peer;
  setAvailableConnection(connection);
};

现在,我们可以向Call 组件添加以下Hook。

React.useEffect(() => {
  if (availableConnection && availablePeer) {
    let dispose = () => {};
    const handler = (call) => {
      getUserMedia(
        { video: true, audio: true },
        (stream) => {
          showVideo(stream, selfVideo.current);
          call.answer(stream);
        },
        (error) => {
          console.log("Failed to get local stream", error);
        }
      );

      dispose = showStream(call, otherVideo.current);
    };

    if (availableConnection["caller"] === availablePeer.id) {
      getUserMedia(
        { video: true, audio: true },
        (stream) => {
          showVideo(stream, selfVideo.current);
          dispose = showStream(
            availablePeer.call(availableConnection.peer, stream),
            otherVideo.current
          );
        },
        (error) => {
          console.log("Failed to get local stream", error);
        }
      );
    } else {
      availablePeer.on("call", handler);
    }

    return () => {
      availablePeer.off("call", handler);
      dispose();
    };
  }
}, [availableConnection, availablePeer]);

这里发生了很多事情,所以让我们一个一个地去看:

  • 这个Hook应该只在有调用的情况下工作
  • 我们定义了一个处理程序,它将在即将收到呼叫的时候被使用
  • 如果我们启动了呼叫,我们也应该启动流
  • 无论谁发起或接收了呼叫,我们都要使用 "Hook "来获取本地流。getUserMedia
  • 然后通过调用或被调用来接收远程流。

视频元素只是参考,即:

// define refs
const otherVideo = React.useRef();
const selfVideo = React.useRef();

// ...
<video ref={otherVideo} width={500} height={500} />
<video ref={selfVideo} width={200} height={200} />

很好,试试这个可能会产生一个这样的应用:

PeerJS video demo

一个简单的PeerJS视频聊天

你可以在我的GitHub上找到我们在这篇文章中构建的例子。

实际应用

那么,你能用PeerJS做什么?有很多。你可以考虑用自己的方式来替代笨重的视频聊天解决方案,如Teams或Slack。当然,你可能很快就会得出结论,这些解决方案虽然笨重,但有一些不错的功能和必要的弹性,这是你对生产级软件的要求。但是对于简单的事情,在我们在本文中构建的内容之外,你需要的东西并不多。

我曾用它来建立一个家庭通信网络应用。我家里的每台平板电脑和计算机都可以访问托管在Raspberry Pi上的小型网络服务器,它提供聊天页面和PeerJS服务器实例。这允许我发现家里的每个人,并在没有任何中间层的情况下进行简单、无摩擦的沟通。

总结

如果你需要驯服WebRTC的野兽,并在你的网络应用中轻松实现视频和音频通话功能,PeerJS是一个很好的库。它很容易上手,并提供了所有必要的手段来帮助你快速完成生产准备。

虽然使用所提供的云服务是一个很好的开始,但所提供的开源Node.js服务器也可以作为一个模板来托管你自己的同行经纪人。

你将用WebRTC和PeerJS构建什么?