Zoom Video SDK Web 框架集成实践:Next.js 与 Vue/Nuxt 完整模式(knowledge-work-plugins)
【免费下载链接】knowledge-work-pluginsOpen source repository of plugins primarily intended for knowledge workers to use in Claude Cowork项目地址: https://gitcode.com/GitHub_Trending/kn/knowledge-work-plugins
本文基于 framework-integrations.md 展开,系统讲解 Zoom Video SDK for Web 在 Next.js(App Router / Pages Router)、Vue 3 / Nuxt 3 及 Zoom For Government 环境下的集成模式。读完后你将掌握:服务端 JWT 签名的正确生成方式、各框架"仅客户端运行 SDK"的标准解法、客户端组件的生命周期管理,以及跨框架通用的事件驱动视频渲染模式。
一、文档定位与适用背景
该文档位于 knowledge-work-plugins 仓库中partner-built/zoom-plugin插件的 Video SDK Web 技能库内,与 会话加入模式、视频渲染指南、React Hooks 指南 等示例文档共同构成 SKILL.md 定义的完整文档导航。zoom-plugin 本身是一个面向 Zoom 集成规划、构建与调试的 Claude 插件(见 插件 README),其/build-zoom-video-sdk-app工作流会路由到这套 video-sdk 参考库。
在开始框架集成前,会话加入模式 中给出了通用前提条件,这些前提对所有框架都适用:
- 来自 Zoom Marketplace 的 Video SDK 凭证(SDK Key / SDK Secret);
- 服务端生成的 JWT 签名(客户端绝不能接触 SDK Secret);
- 现代浏览器支持:Chrome 80+、Firefox 75+、Safari 14+、Edge 80+。
框架集成文档解决的核心问题是:@zoom/videosdk基于 WebAssembly 构建,只能在浏览器端运行,而 Next.js、Nuxt 这类支持服务端渲染(SSR)的框架会在 Node.js 环境中执行组件代码——如果不做隔离,SDK 导入会直接导致 SSR 崩溃。因此每种框架都需要一个"服务端只负责发签名、客户端负责跑 SDK"的分层方案。
二、Next.js(App Router)集成
官方快速启动仓库为zoom/videosdk-nextjs-quickstart(app-router分支)。
2.1 项目结构
app/ ├── api/ │ └── signature/ │ └── route.ts # Server-side JWT generation ├── video/ │ └── page.tsx # Video call component ├── layout.tsx └── page.tsx .env.local ├── ZOOM_SDK_KEY="your-key" └── ZOOM_SDK_SECRET="your-secret"要点:凭证通过.env.local注入,仅在服务端可用;app/api/signature/route.ts是唯一的签名端点;app/video/page.tsx是视频通话客户端组件。
2.2 服务端 JWT 生成(App Router)
// app/api/signature/route.ts import { NextRequest, NextResponse } from 'next/server'; import KJUR from 'jsrsasign'; export async function POST(request: NextRequest) { const { topic, role } = await request.json(); const iat = Math.floor(Date.now() / 1000) - 30; const exp = iat + 60 * 60 * 2; // 2 hours const header = { alg: 'HS256', typ: 'JWT' }; const payload = { app_key: process.env.ZOOM_SDK_KEY, tpc: topic, role_type: role || 1, version: 1, iat, exp, }; const signature = KJUR.jws.JWS.sign( 'HS256', JSON.stringify(header), JSON.stringify(payload), process.env.ZOOM_SDK_SECRET! ); return NextResponse.json({ signature }); }对照 会话加入模式 中client.join(topic, signature, userName, password)的参数要求,各 JWT 字段含义如下:
| 字段 | 取值 | 说明 |
|---|---|---|
app_key | ZOOM_SDK_KEY | Marketplace 应用的 SDK Key,必须与客户端 SDK 所属应用一致 |
tpc | 请求体中的topic | 会话名,必须与join()传入的 topic 完全一致 |
role_type | 默认1 | 角色类型;首个以role=1加入的用户成为主持人(host) |
version | 1 | 签名版本 |
iat | 当前时间 - 30 秒 | 签发时间;前移 30 秒是为容忍服务器与 Zoom 服务端之间的时钟偏差 |
exp | iat + 2 小时 | 过期时间;签名过期后join()会报Invalid signature |
签名算法为 HS256 对称签名,密钥即ZOOM_SDK_SECRET——这正是它必须留在服务端的原因。
2.3 客户端组件(App Router)
// app/video/page.tsx 'use client'; import { useEffect, useState, useRef } from 'react'; import ZoomVideo, { VideoClient, Stream, VideoQuality } from '@zoom/videosdk'; export default function VideoPage() { const [client, setClient] = useState<typeof VideoClient | null>(null); const [stream, setStream] = useState<typeof Stream | null>(null); const [isJoined, setIsJoined] = useState(false); const containerRef = useRef<HTMLDivElement>(null); useEffect(() => { const init = async () => { const zmClient = ZoomVideo.createClient(); await zmClient.init('en-US', 'Global', { patchJsMedia: true }); setClient(zmClient); }; init(); return () => { ZoomVideo.destroyClient(); }; }, []); const joinSession = async (topic: string, userName: string) => { if (!client) return; // Get signature from API route const res = await fetch('/api/signature', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ topic, role: 1 }), }); const { signature } = await res.json(); // Join session await client.join(topic, signature, userName); const mediaStream = client.getMediaStream(); setStream(mediaStream); setIsJoined(true); }; const startVideo = async () => { if (!stream || !client) return; await stream.startVideo(); const currentUser = client.getCurrentUserInfo(); const element = await stream.attachVideo( currentUser.userId, VideoQuality.Video_360P ); containerRef.current?.appendChild(element); }; return ( <div> {!isJoined ? ( <button onClick={() => joinSession('my-topic', 'User')}> Join Session </button> ) : ( <> <div ref={containerRef} /> <button onClick={startVideo}>Start Video</button> </> )} </div> ); }结合 SDK 架构模式 可以进一步解读几个关键细节:
init('en-US', 'Global', { patchJsMedia: true })的第二参数是资产来源,可选'Global'(默认,source.zoom.us)、'CDN'(CloudFront)、'CN'(jssdk.zoomus.cn)或自托管资产的自定义路径;- 组件状态管理严格对应 SDK 生命周期:
client在useEffect中创建并init(),stream只在join()完成后从client.getMediaStream()获取,随后再startVideo()和attachVideo(); - 清理函数中调用
ZoomVideo.destroyClient()销毁单例客户端,避免热重载或路由切换后残留状态。
2.4 SSR 注意事项
关键点:Video SDK 使用 WebAssembly,必须仅在客户端运行。
// ❌ WRONG: Importing at top level causes SSR issues import ZoomVideo from '@zoom/videosdk'; // ✅ CORRECT: Dynamic import or use 'use client' directive 'use client'; // Or use dynamic import const ZoomVideo = dynamic(() => import('@zoom/videosdk'), { ssr: false });在 App Router 中,'use client'指令本身已足够阻止组件在服务端执行(SDK 的实际使用发生在useEffect内,只在浏览器触发);dynamic(..., { ssr: false })则是更彻底的隔离方式,连模块求值也推迟到客户端。
三、Next.js(Pages Router)集成
官方快速启动仓库的pages-router分支对应这套模式。
3.1 服务端 JWT(Pages Router)
与 App Router 版本逻辑完全一致,只是路由形式从route.ts的命名导出变为pages/api的默认导出,并额外增加了方法校验:
// pages/api/signature.ts import type { NextApiRequest, NextApiResponse } from 'next'; import KJUR from 'jsrsasign'; export default function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== 'POST') { return res.status(405).json({ error: 'Method not allowed' }); } const { topic, role } = req.body; const iat = Math.floor(Date.now() / 1000) - 30; const exp = iat + 60 * 60 * 2; const header = { alg: 'HS256', typ: 'JWT' }; const payload = { app_key: process.env.ZOOM_SDK_KEY, tpc: topic, role_type: role || 1, version: 1, iat, exp, }; const signature = KJUR.jws.JWS.sign( 'HS256', JSON.stringify(header), JSON.stringify(payload), process.env.ZOOM_SDK_SECRET! ); res.status(200).json({ signature }); }3.2 客户端组件(Pages Router)
Pages Router 没有'use client'指令,官方模式是页面壳 + 动态导入:页面本身保持纯服务端渲染,把含 SDK 的实际组件抽到components/Video.tsx,用next/dynamic以ssr: false加载:
// pages/video.tsx import { useEffect, useState } from 'react'; import dynamic from 'next/dynamic'; // Dynamic import to prevent SSR issues const VideoComponent = dynamic(() => import('../components/Video'), { ssr: false, }); export default function VideoPage() { return <VideoComponent />; }components/Video.tsx内部即可按 App Router 客户端组件同款写法使用createClient() → init() → join() → getMediaStream()流程(参见 会话加入模式 的完整 React 示例)。
四、Vue 3 / Nuxt 3 集成
官方快速启动仓库为zoom/videosdk-vue-nuxt-quickstart(main分支)。
4.1 Composition API 模式
完整会话组件包含:客户端初始化与销毁、参与者列表响应式维护、基于peer-video-state-change的远端视频挂载/卸载、音频静音切换:
<!-- components/VideoSession.vue --> <script setup lang="ts"> import { ref, onMounted, onUnmounted } from 'vue'; import ZoomVideo, { VideoClient, Stream, VideoQuality } from '@zoom/videosdk'; const props = defineProps<{ topic: string; signature: string; userName: string; }>(); const client = ref<typeof VideoClient | null>(null); const stream = ref<typeof Stream | null>(null); const isJoined = ref(false); const videoContainer = ref<HTMLDivElement | null>(null); // Reactive participants list const participants = ref<any[]>([]); onMounted(async () => { // Initialize client const zmClient = ZoomVideo.createClient(); await zmClient.init('en-US', 'Global', { patchJsMedia: true }); client.value = zmClient; // Set up event listeners zmClient.on('user-added', updateParticipants); zmClient.on('user-removed', updateParticipants); zmClient.on('user-updated', updateParticipants); zmClient.on('peer-video-state-change', handleVideoChange); }); onUnmounted(async () => { if (client.value) { await client.value.leave(); ZoomVideo.destroyClient(); } }); const updateParticipants = () => { if (client.value) { participants.value = client.value.getAllUser(); } }; const handleVideoChange = async (payload: { action: string; userId: number }) => { if (!stream.value || !videoContainer.value) return; if (payload.action === 'Start') { const element = await stream.value.attachVideo( payload.userId, VideoQuality.Video_360P ); videoContainer.value.appendChild(element); } else { await stream.value.detachVideo(payload.userId); } }; const joinSession = async () => { if (!client.value) return; await client.value.join(props.topic, props.signature, props.userName); stream.value = client.value.getMediaStream(); isJoined.value = true; updateParticipants(); }; const startVideo = async () => { if (!stream.value || !client.value) return; await stream.value.startVideo(); const currentUser = client.value.getCurrentUserInfo(); const element = await stream.value.attachVideo( currentUser.userId, VideoQuality.Video_360P ); videoContainer.value?.appendChild(element); }; const toggleMute = async () => { if (!stream.value) return; const muted = stream.value.isAudioMuted(); if (muted) { await stream.value.unmuteAudio(); } else { await stream.value.muteAudio(); } }; </script> <template> <div class="video-session"> <div v-if="!isJoined"> <button @click="joinSession">Join Session</button> </div> <div v-else> <div ref="videoContainer" class="video-container" /> <div class="participants"> <div v-for="p in participants" :key="p.userId"> {{ p.displayName }} - {{ p.bVideoOn ? 'Video On' : 'Video Off' }} </div> </div> <div class="controls"> <button @click="startVideo">Start Video</button> <button @click="toggleMute">Toggle Mute</button> </div> </div> </div> </template>与 React 版本对比,值得注意的框架差异有三处:
- 事件监听注册时机:Vue 示例在
onMounted初始化客户端的同时即注册user-added/user-removed/user-updated/peer-video-state-change监听器,利用 ref 闭包在回调中读取最新状态,无需像 React 那样按client、stream依赖项拆分多个useEffect; - 清理顺序:
onUnmounted中先leave()退出会话,再destroyClient()销毁单例——这正是 RUNBOOK.md 中"Cleanup + Upgrade Posture"一节要求的"退出会话、释放客户端资源"的落地; - 参与者状态:通过
client.getAllUser()同步到响应式数组,bVideoOn布尔字段驱动模板渲染。
4.2 Nuxt 3 客户端专用插件
Nuxt 3 中把 SDK 注入为全局客户端能力,用.client.ts后缀确保插件只在浏览器执行:
// plugins/videosdk.client.ts import ZoomVideo from '@zoom/videosdk'; export default defineNuxtPlugin(() => { return { provide: { zoomVideo: ZoomVideo, }, }; });<!-- pages/video.vue --> <script setup> const { $zoomVideo } = useNuxtApp(); // Use $zoomVideo.createClient() etc. </script>页面内也可直接用<ClientOnly>组件包裹含 SDK 的模板部分,作为另一层 SSR 隔离。
五、Zoom For Government(ZFG)
如果目标用户是 Zoom For Government 环境,需要使用来自 marketplace.zoomgov.com 的独立 SDK Key,并且有两条落地路径:
选项 1:使用 ZFG 专属包版本
{ "dependencies": { "@zoom/videosdk": "1.11.0-zfg" } }client.init('en-US', 'Global');选项 2:自定义 WebEndpoint
保持标准包版本,但把init()的资产路径与接入点显式指向政府云:
client.init('en-US', 'https://source.zoomgov.com/videosdk/1.11.0/lib', { webEndpoint: 'www.zoomgov.com', });这印证了 SDK 架构模式 中对init(language, dependentAssets, options)的说明:第二个参数支持自定义路径,ZFG 只是把"自托管路径"用在了官方政府云资产上。
六、官方示例仓库
文档汇总了各框架对应的官方示例仓库(名称与分支如下,可按名称检索官方发布渠道获取):
| 框架 | 仓库 | 分支 |
|---|---|---|
| Vanilla JS/TS | videosdk-web-sample | master |
| React | videosdk-react | main |
| Next.js (App) | videosdk-nextjs-quickstart | app-router |
| Next.js (Pages) | videosdk-nextjs-quickstart | pages-router |
| Vue/Nuxt | videosdk-vue-nuxt-quickstart | main |
| UI Toolkit (React) | videosdk-zoom-ui-toolkit-react-sample | main |
| Auth Endpoint | videosdk-auth-endpoint-sample | main |
其中 Auth Endpoint 示例专门演示服务端签名服务的独立实现,适合作为本文 JWT 端点的对照参考。
七、跨框架通用模式
框架集成文档最后总结了所有框架都必须遵守的三条模式,这也是框架无关的 Video SDK 契约:
7.1 仅客户端运行
| 框架 | 解决方案 |
|---|---|
| Next.js | 'use client'或dynamic(..., { ssr: false }) |
| Nuxt 3 | .client.ts插件或<ClientOnly> |
| Vue SPA | 无需特殊处理 |
7.2 生命周期管理
// Always follow this order: const client = ZoomVideo.createClient(); await client.init(...); await client.join(...); const stream = client.getMediaStream(); // ONLY after join() // Cleanup on unmount await client.leave(); ZoomVideo.destroyClient();这个顺序在 SKILL.md 中被标记为 "SDK Lifecycle (CRITICAL ORDER)",并明确警告:违反该顺序会导致静默失败。最典型的是getMediaStream()必须在join()完成后调用——在此之前调用会返回undefined,而不会抛出任何异常:
// WRONG: Getting stream before joining const stream = client.getMediaStream(); // Returns undefined! await client.join(...); // CORRECT: Get stream after joining await client.join(...); const stream = client.getMediaStream(); // Works!7.3 事件驱动视频渲染
// All frameworks should use this pattern client.on('peer-video-state-change', async ({ action, userId }) => { if (action === 'Start') { const el = await stream.attachVideo(userId, VideoQuality.Video_360P); container.appendChild(el); } else { await stream.detachVideo(userId); } });远端视频不是"拉"出来的,而是由peer-video-state-change事件"推"出来的;attachVideo()返回一个 VideoPlayer DOM 元素,必须手动appendChild到容器。renderVideo()已弃用,不要使用(见 视频渲染指南)。
八、深入:仓库文档对关键细节的佐证
框架集成文档给出的是"骨架代码",仓库同目录下的其他文档补充了生产化所需的关键细节:
1. 中会话加入(mid-session join)的手动补渲染
peer-video-state-change只会在你加入之后发生。如果会议里已有人开着摄像头,你需要在join()后主动遍历渲染存量参与者(会话加入模式 中的renderExistingParticipants()):
async function renderExistingParticipants() { await new Promise(resolve => setTimeout(resolve, 500)); // 等待参与者列表加载 const users = client.getAllUser(); const currentUserId = client.getCurrentUserInfo().userId; for (const user of users) { if (user.bVideoOn && user.userId !== currentUserId) { const element = await stream.attachVideo(user.userId, VideoQuality.Video_360P); document.getElementById(`video-${user.userId}`).appendChild(element); } } }2. 画质选择与 WebRTC 模式
VideoQuality枚举的数值映射为:Video_90P(0)、Video_180P(1)、Video_360P(2,推荐默认)、Video_720P(3)、Video_1080P(4)。1080P 需要在init()时启用 WebRTC 模式:
await client.init('en-US', 'Global', { patchJsMedia: true, webrtc: true, // Required for HD video });启用后可用stream.isSupportHDVideo()检测设备能力、stream.getVideoMaxQuality()获取当前最大画质,再决定是否以 720P/1080P 挂载。
3. 加入前的能力检查与错误处理
SKILL.md 建议在任何框架的客户端初始化前做兼容性检查:
const compatibility = ZoomVideo.checkSystemRequirements(); console.log('Audio:', compatibility.audio, 'Video:', compatibility.video);而加入失败时的错误分支在框架无关层面已约定好(见 会话加入模式 的handleJoinError):错误信息含signature表示签名无效需重新签发;含Session表示主持人尚未开会;含password表示密码错误;含Permission表示摄像头/麦克风权限被拒。这些判断逻辑可以直接复用到本文各框架的joinSession中。
4. CDN 加载与 HD 视频的部署细节
若走 CDN 而非 npm,全局对象是WebVideoSDK.default而非ZoomVideo,且source.zoom.us可能被网络策略或广告拦截器阻断——SKILL.md 给出的降级策略是白名单放行,或(在允许的前提下)自托管镜像并保持版本同步。追求 HD 性能时还需要在服务器配置 COOP/COEP 响应头(Cross-Origin-Opener-Policy: same-origin、Cross-Origin-Embedder-Policy: require-corp)以启用 SharedArrayBuffer;自 v1.11.2 起该项已是可选项而非硬性要求。
5. 排障入口
集成出问题时的快速诊断清单在 common-issues.md:依次核对生命周期顺序、getMediaStream()时机、peer-video-state-change监听、attachVideo()用法、浏览器权限与版本兼容。更完整的预检流程见 RUNBOOK.md(确认集成面 → 确认凭证 → 确认生命周期顺序 → 确认事件状态处理 → 确认清理策略 → 快速探针 → 决策树)。
九、小结与延伸阅读
框架集成的本质是把 Video SDK 的严格生命周期契约(createClient → init → join → getMediaStream)正确地"安放进"各框架的执行模型里:Next.js 靠'use client'/dynamic隔离 SSR,Nuxt 靠.client.ts插件,签名永远由服务端 API 路由用 HS256 生成、tpc与join()的 topic 严格一致、iat前移 30 秒防时钟偏差。掌握这些后,具体框架差异只剩生命周期钩子的写法不同。
深入阅读建议按 SKILL.md 的导航顺序:
- SDK 架构模式 —— 通用五步模式,理解它即可实现任何功能;
- 会话加入模式 —— JWT + 加入会话完整代码;
- 视频渲染 ——
attachVideo()全部模式; - React Hooks —— 官方
@zoom/videosdk-react封装库(useSession、useSessionUsers等); - 常见问题 与 RUNBOOK —— 排障清单。
【免费下载链接】knowledge-work-pluginsOpen source repository of plugins primarily intended for knowledge workers to use in Claude Cowork项目地址: https://gitcode.com/GitHub_Trending/kn/knowledge-work-plugins
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考