如何用SolidJS、Express和Twilio视频创建一个视频应用程序

336 阅读16分钟

SolidJS是一个新兴的JavaScript框架,根据JS 2021年的状态调查,用户满意度达到90%由于具有与React类似的代码结构,开发者可以对这个新的框架感到自如,并以类似的方式将Twilio Video整合到他们的应用程序中。

在本教程中,你将构建一个生成Twilio视频访问令牌并连接到Twilio视频房间的后端服务器,以及一个分享房间中每个参与者的音频和视频的前端应用程序。

设置你的开发者环境

要开始,请打开你的终端窗口,导航到你将创建两个应用程序的目录。首先,通过在终端中复制和粘贴下面的命令并按下enter ,来设置后端:

mkdir solid-video-frontend solid-video-backend
cd solid-video-backend
npm init -y
npm install twilio express dotenv cors nodemon
touch index.js tokens.js .env

这些命令将创建前端和后端目录*(分别为solid-video-frontendsolid-video-backend* ),导航到后端目录,初始化一个新的Node.js项目,安装五个必要的依赖,并创建index.jstokens.js和*.env*文件。

后台所需的五个依赖项是:

  • twilio,以利用Twilio的视频API
  • express, 建立你的服务器
  • dotenv, 在*.env*中访问你的重要凭证并在环境变量中使用它们
  • cors, 允许跨源的资源共享
  • nodemon, 运行你的服务器

tokens.js中,你将利用Twilio客户端和视频API来创建Twilio视频室并提供视频授予的访问令牌。在index.js中,你将处理所有进入单一路由的请求并提供生成的访问令牌。在*.env*中,你将存储你重要的Twilio凭证。

接下来,通过在终端运行以下命令来设置你的前端:

cd ..
npx degit solidjs/templates/js solid-video-frontend
cd solid-video-frontend
npm init -y
npm install twilio-video

这些命令将引导您回到主目录,创建一个新的 SolidJS 应用程序,初始化一个新的 Node.js 项目,并安装唯一需要的依赖,twilio-video 。该依赖性包含一个辅助库,您将使用它来连接到由您的后端服务器创建的 Twilio 视频房间。

创建你的后端服务器

如前所述,你的后端服务器将处理访问令牌生成和房间创建。导航到你的后端目录,打开index.js文件。通过复制和粘贴下面的代码为你的服务器建立一个基本轮廓:

require("dotenv").config();
const express = require("express");
const cors = require("cors");

const app = express();
app.use(cors({
  origin: `http://localhost:3000`
}));
app.use(express.urlencoded({ extended: false }));
app.use(express.json());

app.post('/video/token', async (req, res) => {
  
})

app.listen(3001, () => {
  console.log('Express server listening on localhost: 3001.')
})

这段代码初始化了dotenvexpresscors 包,包括允许从localhost:3000的前端应用程序进行跨源资源共享的中间件和解析传入数据,并建立了*/video/token路由和监听器。/video/token*路由将是你服务器中唯一的路由。

在服务器可以执行任何功能之前,你需要首先设置你的Twilio凭证。

获取你的Twilio凭证

在.env中,复制并粘贴以下代码:

TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_KEY_SID=
TWILIO_KEY_SECRET=

要添加到.env中的前两个变量可以在你的Twilio控制台的仪表板上找到(假设你创建了一个免费账户)。你的账户SIDAuth Token位于页面底部的账户信息部分。点击相应方框旁边的按钮复制你的账户SID,然后在TWILIO_ACCOUNT_SID= 。重复这一过程,将你的Auth Token粘贴在TWILIO_AUTH_TOKEN=

Twilio Console

最后两样要添加到*.env*的东西是在你的Twilio控制台的这个页面生成的。你可能需要在第一次访问这个页面时输入发送到你邮箱的代码来验证你的账户。在页面的右上方,点击标有创建API密钥的蓝色按钮。输入一个容易区分的Friendly名称,然后点击页面底部标有创建API密钥的蓝色按钮。

下一页(如下图)包含你的API密钥SIDSecret。点击相应方框旁边的按钮,复制并粘贴你的SID,然后粘贴在TWILIO_KEY_SID= 。重复这个过程,把你的Secret粘贴在TWILIO_KEY_SECRET=

Secret key page

如果你想在另一个项目中使用这个API密钥,那么一定要把它复制并粘贴在一个安全的地方TWILIO_KEY_SECRET 。在你从这个页面继续前进之后,你将无法再次访问它。

填好*.env*文件后,你现在可以勾选确认框,然后点击标有 "完成"的蓝色按钮。

生成访问令牌

要生成一个访问令牌,用户需要向该功能提供一个有效的identity ,并提供他们想要连接的room 名称。复制并粘贴下面的代码到tokens.js中:

const AccessToken = require("twilio").jwt.AccessToken;
const VideoGrant = AccessToken.VideoGrant;

const generateToken = (identity) => {
  return new AccessToken(
    process.env.TWILIO_ACCOUNT_SID,
    process.env.TWILIO_KEY_SID,
    process.env.TWILIO_KEY_SECRET,
    { identity }
  );
};

const videoToken = (identity, room) => {
  const videoGrant = new VideoGrant({ room });
  const token = generateToken(identity);
  token.addGrant(videoGrant);
  return token.toJwt();
};

module.exports = { videoToken }

这段代码初始化了AccessToken 对象和VideoGrant 方法。辅助函数generateToken() 使用提供的identity 创建一个新的访问令牌。videoToken() 函数利用generateToken() 来创建一个令牌,为其添加一个视频授权,最后返回它。videoToken() 函数被导出,并将在你的index.js文件中作为一个辅助函数使用。

最后,打开index.js,添加高亮显示的几行代码:

require("dotenv").config();
const express = require("express");
const cors = require("cors");
const { videoToken } = require("./tokens");

const app = express();
app.use(cors({
  origin: `http://localhost:3000`
}));
app.use(express.urlencoded({ extended: false }));
app.use(express.json());

app.post('/video/token', async (req, res) => {
  const identity = req.body.identity;
  const room = req.body.room;
  const token = await videoToken(identity, room);
  res.set('Content-Type', 'application/json');
  res.send(
    JSON.stringify({ token })
  );
});

app.listen(3001, () => {
  console.log('Express server listening on localhost: 3001.');
});

videoToken() 函数现在被导入到index.js中,并在*/video/token*路由中使用,以创建一个房间并生成将被送回给用户的令牌。为了收到令牌,用户应该在他们的请求正文中包括他们的identity 和他们希望连接的room

打开package.json ,在scripts 属性下添加start 脚本:

{
  "scripts": {
    "start": "nodemon index.js",
  },
}

回到你的终端,确保你在solid-video-backend目录中。然后,运行以下命令来启动你的服务器:

npm run start

现在你的后端服务器已经完成,你可以开始创建你的前端应用程序。

请确保在整个教程的过程中保持你的服务器运行。如果你不这样做,那么你将无法访问任何Twilio视频室。

创建你的前端应用程序

导航到你的solid-video-frontend 目录,在你的代码编辑器中打开它。如果你以前用过React,那么这个文件结构对你来说应该很熟悉。如果你没有,也不用担心!首先,生成前端文件结构。

首先,生成你将在本教程中使用的前端文件结构。在你的前端目录下打开一个终端,运行以下命令:

cd src
mkdir components
cd components
touch Lobby.jsx Participant.jsx Room.jsx Main.jsx
cd ../..

这些命令将生成组件目录和你在本教程中要使用的四个组件,然后将你重定向到前台目录:

  • Lobby 是一个表单组件,用户在这里输入他们的身份和要连接的房间。
  • Participant 是主要的视频组件,显示所有房间参与者的视频和音频
  • Room 是一个为所有参与者提供布局的组件
  • VideoChat 是 和 的父组件,处理页面布局和Room Lobby 状态

在深入研究任何组件之前,请打开src/App.jsx并用下面的代码替换所有预先存在的代码:

function App() {
  return (
    <div className="app">
      <header>
        <h1>Video Chat in SolidJS</h1>
      </header>
      <main>
        Video will go here
      </main>
      <footer>
        <p>Made in SolidJS!</p>
      </footer>
    </div>
  );
};

export default App;

Solid利用JSX来构造被加载到应用程序页面的HTML,与React类似。要在本地提供您的应用程序,请打开您的终端,确保您在solid-video-frontend 中,并运行以下命令:

npm run dev

在整个教程中保持你的应用程序运行。对文件所做的任何更改都将被保存,并且 Solid 将自动用新的更改刷新您的应用程序。在您的浏览器中导航到localhost:3000,您应该看到以下页面。

Plain app

布局看起来和预期的一样,但有点朴素。打开 src/index*.css*,用该文件中的代码替换它。保存index.css文件,你的页面现在应该看起来像这样。

Formatted app

现在,打开components/Main.jsx来设置你的页面布局并初始化一些重要变量。

开发主房间-Main*.jsx*

Main 组件将处理整个页面布局,其中包括RoomLobby 组件。Solid有一个 [Show](https://www.solidjs.com/tutorial/flow_show)组件,它使条件性渲染更易读,并且可以在道具 fallbackwhen 中传递。如果when 条件不是真实的,Solid将渲染传入fallback 道具的任何内容。

如果用户没有从后台获得令牌,Main 组件将显示Lobby 组件,只要用户获得令牌并连接到房间,就会显示Room 组件。

复制并粘贴以下代码到components/Main.jsx

import { createSignal, Show } from "solid-js";
import { createStore } from "solid-js/store";
import Lobby from "./Lobby";
import Room from "./Room";

export default function Main() {
  const [formState, setFormState] = createStore({
    identity: "",
    room: ""
  });
  const [token, setToken] = createSignal(null);

  return(
    <div className="main-content">
      <Show
        when={token() === null}
        fallback={
          <Room
            room={formState.room}
            token={token}
            setToken={setToken}
          />
        }
      >
        <Lobby
          formState={formState}
          setFormState={setFormState}
          token={token}
          setToken={setToken}
        />
      </Show>
    </div>
  );
};

如果你还记得前面的内容,后端服务器需要在请求正文中发送两个参数:identityroom 。这些变量和它们的设置函数将与token 变量和它的设置函数一起传入RoomLobby 组件:

[createSignal](https://www.solidjs.com/docs/latest/api#createsignal)()[createStore](https://www.solidjs.com/docs/latest/api#createstore)()都是类似于React的 [useState](https://reactjs.org/docs/hooks-state.html)()钩子。createSignal() 处理存储为一个单一值,而createStore() 处理存储为代理对象的多个值。

处理form-Lobby*.jsx*

当用户第一次加载你的应用程序时,你将希望他们填写一个表单,在那里他们可以设置他们的用户名和他们想连接的房间的名称。打开components/Lobby.jsx并粘贴以下代码:

export default function Lobby(props) {
  
  const handleChange = (event) => {

  };

  const handleSubmit = async (event) => {
    event.preventDefault();
  };

  return(
    <form onSubmit={handleSubmit} action="POST">
        <label>
          Username 
          <input 
            type="text" 
            placeholder="Username"
            onChange={(evt) => handleChange(evt)} 
            name="identity" 
            id="identity"
            required
          />
        </label>
        <label>
          Room Name 
          <input 
            type="text" 
            placeholder="Room Name"
            onChange={(evt) => handleChange(evt)} 
            name="room" 
            id="room" 
            required
          />
        </label>
      <input type="submit" value="Submit" />
    </form>
  );
}; 

有两个表单输入,将跟踪用户的名字和他们想连接的房间。当表单输入被提交时(输入的焦点消失),那么handleChange() 函数将被调用。当表单被提交时,handleSubmit() 将被调用。

与 React 在传递onChange() 函数时跟踪表单输入中的每个按键不同,Solid 默认跟踪onChange() 表单输入,只要不聚焦或提交。Solid 中的表单输入value 属性的控制

现在,通过添加下面突出显示的几行代码,将适当的功能添加到handleChange()handleSubmit() 函数中:

  const handleChange = (event) => {
    const name = event.target.name
    const value = event.target.value
    props.setFormState(() => ({
      [name]: value
    }));
  };

  const handleSubmit = async (event) => {
    event.preventDefault();
    const data = await fetch('http://localhost:3001/video/token', {
      method: 'POST',
      body: JSON.stringify({
        identity: props.formState.identity,
        room: props.formState.room
      }),
      headers: {
        'Content-Type': 'application/json'
      }
    });
    const response = await data.json();
    await props.setToken(response.token);
  };

不要像在React中那样,在函数调用中对props对象进行结构化。这将破坏道具在 Solid 中的反应性。如果您想在保持反应性的同时提高可读性,请使用 [splitProps](https://www.solidjs.com/docs/latest/api#splitprops)()函数代替。

与React不同,在处理状态变量时,您不需要将之前的 formState 与更新的值合并

连接到表单输入的handleChange() 函数现在将调用从Main 组件传入的setFormState() 函数,只要有输入被提交。当表单被提交时,一个请求通过fetch API被发送到后端服务器,并返回一个有效的访问令牌。从这里,setToken() 函数被调用,token 变量被设置。这使得condition 属性在Main.jsx中的For 组件中成为真值。然后,Room 组件被渲染,而不是Lobby 组件。

要查看Lobby 组件在应用程序中的样子,请用以下几行修改src/App.jsx

import Main from './components/Main';

function App() {
  return (
    <div className="app">
      <header>
        <h1>Video Chat in SolidJS</h1>
      </header>
      <main>
        <Main />
      </main>
      <footer>
        <p>Made in SolidJS!</p>
      </footer>
    </div>
  );
};

export default App;

然后导航到components/Room.jsx并添加以下几行作为占位符:

export default function Room(props) {
  console.log(props.token())
  return(
    <div>
      Check your console for the token! (Press F12)
    </div>
  );
};

现在,你的页面现在应该看起来像这样:

Lobby screen

这是你刚刚完成创建的Lobby 组件。如果你输入任何用户名和房间名称,然后点击标有提交的按钮,你应该从服务器上得到一个令牌。这样做后,Lobby 组件将解体(也被称为卸载),Room 组件将呈现。该应用程序现在应该看起来像这样。

Room component without functionality

当你打开你的控制台(你可以通过点击键盘上的F12 按钮),你会得到一长串的数字和字母--这是你的访问令牌。该令牌将被Room 组件利用来连接到视频房间。

创建房间-Room*.jsx*

现在你有了令牌,你可以用它来连接到Twilio视频室。一旦用户连接到房间,他们就可以让应用程序读取来自房间里每个参与者的音频视频轨道Room 组件将处理连接到房间和每个参与者的布局(包括用户自己)。

接下来的部分可能需要一些时间来理解,所以试着在变量的setter函数之后console.log() ,看看你在做什么!即将到来的roomparticipants 变量是具有许多属性的关键对象,是记录的好对象。

首先,通过替换components/Room.jsx 中的代码,开始布置你的Room 组件的基本内容:

import { createSignal, createEffect, Show, onCleanup, For } from "solid-js";
import { connect } from "twilio-video";
import Participant from "./Participant";

export default function Room(props) {
  const [room, setRoom] = createSignal(null);
  const [participants, setParticipants] = createSignal([]);

  createEffect(async () => {

  });

  onCleanup(() => {

  });

  const handleLogout = () => {
    props.setToken(null);
  };
  
  return(
    <div className="room">
      <div className="room-info">
        <h2>Room: {props.room}</h2>
        <button onClick={handleLogout}>Log out</button>
      </div>
      <h3>You</h3>
      <div className="local-participant">
        <Show
          when={room() !== null}
          fallback={''}>
          <Participant
            participant={room().localParticipant}
          />
        </Show>
      </div>
      <h3>Everyone Else</h3>
      <div className="remote-participants">
        <For each={participants()}>{(participant, i) => 
          <Participant
            participant={participant}
          />
        }</For>
      </div>
    </div>
  );
};

在组件的顶部,将有房间信息和一个标有Log out的按钮,点击后会调用handleLogout() 函数。handleLogout() 函数应该删除用户的令牌,然后触发Show 组件来卸载Room 组件并渲染Lobby 组件。

在此之下,本地参与者(用户)将显示在所有连接到房间的其他参与者之上。Solid的 [For](https://www.solidjs.com/tutorial/flow_for)组件被用来从participants 数组中传入每个participantroom 变量将存储关于用户所连接的房间的所有信息,而participants 数组将存储参与者的信息。

代码的主要功能将被写在 [createEffect](https://www.solidjs.com/docs/latest/api#createeffect)()[onCleanup](https://www.solidjs.com/docs/latest/api#oncleanup)()函数中--这包括连接到房间和监控参与者的连接和断开。createEffect() 函数将在其内部的任何信号发生变化时以及组件首次渲染时(也称为安装)运行。onCleanup() 函数将在组件拆除时运行。

类似于 React 类组件的生命周期方法或 React [useEffect](https://reactjs.org/docs/hooks-effect.html)()钩子,Solid 的生命周期方法有onCleanup(), [onMount](https://www.solidjs.com/docs/latest/api#onmount)(),以及带有createEffect() 函数的效果。

对于实现完整功能的第一步,用下面几行代码填写createEffect()

  createEffect(async () => {
    const connectParticipant = (participant) => {
      setParticipants((participants) => [...participants, participant]);
    };

    const disconnectParticipant = (participant) => {
      setParticipants((participants) => participants.filter((p) => p !== participant));
    };

    const foundRoom = await connect(props.token(), {
      name: props.room
    });

    setRoom(foundRoom);
    
    room().participants.forEach((p) => {
      setParticipants((participants) => [...participants, p]);
    });
    room().on('participantDisconnected', disconnectParticipant);
    room().on('participantConnected', connectParticipant);
  });

createEffect() 里面有两个辅助函数:connectParticipant()disconnectParticipant() 。这些函数在调用时将分别从participants 数组中添加或删除参与者。

每当Room 组件首次呈现时,用户的标记和所需的房间名称就会被传递到从twilio-video 中导入的connect() 函数中。然后该函数会尝试与所需的房间建立连接。一旦找到房间(如果还不存在,则创建房间),参与者数组就会被更新,其中包括当前房间里的每个参与者setParticipants()

最后,连接的Twilio房间可以检测到用户连接到它或从它断开连接,并可以调用适当的辅助函数。

现在,修改onCleanup() ,在它下面添加一些事件监听器:

  onCleanup(() => {
    setRoom((room) => {
      room.disconnect();
      return null;
    });
  });

  window.addEventListener('beforeunload', () => room().disconnect());
  window.addEventListener('pagehide', () => room().disconnect());
  window.addEventListener('onunload', () => room().disconnect());

每当用户退出房间时,该组件就会下马,然后运行onCleanup() 里面的代码,使其与房间断开连接。每当用户关闭窗口或导航离开页面,那么他们也将被断开与房间的连接。

在你设置好Participant 组件并渲染每个参与者的视频和音频轨道之前,测试这个代码功能会很困难。

看到和听到参与者-Participant*.jsx*

现在你已经可以访问你的Twilio视频室的参与者,你需要启用所有房间参与者之间的通信。一旦完成了Participant 组件的所有功能,用户就可以互相识别,看到对方的视频,听到对方的音频。

打开components/Participant.jsx并粘贴以下代码大纲:

import { createEffect, createSignal, onCleanup } from "solid-js"

export default function Participant(props) {
  let video, audio;
  const [videoTracks, setVideoTracks] = createSignal([]);
  const [audioTracks, setAudioTracks] = createSignal([]);

  const trackpubsToTracks = (trackMap) => {
    return Array.from(trackMap.values())
      .map((publication) => publication.track)
      .filter((track) => track !== null);
  };

  createEffect(() => {
    // Will handle participants
  });

  createEffect(() => {
    // Will handle video tracks
  });
  
  createEffect(() => {
    // Will handle audio tracks
  });

  onCleanup(() => {
    
  });

  return(
    <div className="participant">
      <video ref={video} autoPlay={true}></video>
      <audio ref={audio} autoPlay={true}></audio>
      <h3>{props.participant.identity}</h3>
    </div>
  );
};

每个传入Participant 组件的participant 对象都有一个Mapof [TrackPublication](https://media.twiliocdn.com/sdk/js/video/releases/2.2.0/docs/RemoteTrackPublication.html)对象,它们是音频或视频轨道。在用户可以订阅之前,这个Map需要被分解成轨道对象,任何空轨道必须被删除。这些轨道将被存储在audioTracksvideoTracks 数组中,并根据房间里的参与者数量进行更新。

有三个不同的createEffect() 函数,将处理参与者的变化、视频轨道的变化和音频轨道的变化。此外,还有一个onCleanup() 函数,将在用户从房间断开连接时运行。

首先,修改处理与会者的createEffect() 函数:

  createEffect(() => {
    const trackSubscribe = (track) => {
      if(track.kind === 'video') {
        setVideoTracks((tracks) => [...tracks, track]);
      } else {
        setAudioTracks((tracks) => [...tracks, track]);
      };
    };

    const trackUnsubscribe = (track) => {
      if(track.kind === 'video') {
        setVideoTracks((videoTracks) => videoTracks.filter((v) => v !== track));
      } else {
        setAudioTracks((audioTracks) => audioTracks.filter((a) => a !== track));
      };
    };

    setVideoTracks(trackpubsToTracks(props.participant.videoTracks));
    setAudioTracks(trackpubsToTracks(props.participant.audioTracks));

    props.participant.on('trackSubscribed', trackSubscribe);
    props.participant.on('trackUnsubscribed', trackUnsubscribe);
  });

每当一个participant 被传入这个组件,这段代码就会运行。有两个辅助函数,trackSubscribe()trackUnsubscribe() ,它们将根据传入该函数的轨道类型,更新audioTracksvideoTracks 。每当一个参与者订阅或取消订阅另一个参与者的轨道时,这些函数就会运行。

接下来,更新剩下的两个createEffect() 函数,以处理视频和音频轨道:

  createEffect(() => {
    const videoTrack = videoTracks()[0];
    if(videoTrack) {
      videoTrack.attach(video);
    };
  });
  
  createEffect(() => {
    const audioTrack = audioTracks()[0];
    if(audioTrack) {
      audioTrack.attach(audio);
    };
  });

这些函数将把视频和音频轨道附加到videoaudio 变量上,这些变量分别作为<video><audio> 标签的参考。换句话说,这些函数允许用户看到和听到对方的声音!

最后,修改onCleanup() 函数:

  onCleanup(() => {
    setVideoTracks([]);
    setAudioTracks([]);
    props.participant.removeAllListeners();
  });

每当一个用户从房间断开连接时,这个函数会立即删除他们订阅的视频和音频轨道。此外,所有其他用户将取消订阅被断开连接的用户的曲目。

完成这些后,在浏览器中导航到localhost:3000 。输入你想要的用户名和房间名称,然后点击提交!

如果你想测试你的应用程序如何与多个参与者一起工作,你可以从多个浏览器标签或窗口连接到localhost:3000

Working video with three participants

总结

祝贺你!你刚刚学会了如何创建一个新的房间。您刚刚学会了如何用 Solid 和 Twilio 的视频 API 创建一个视频应用程序。希望本教程不仅能帮助您了解Twilio视频的基础知识,还能帮助您了解Solid!我个人非常喜欢这两项技术,并迫不及待地想看到它们在未来的发展。