如果你在 uni-app 项目里接过“只能通过 script 方式引入”的第三方 SDK,比如这套腾讯云点播(VOD)播放器 SDK,多半会经历一段很别扭的时期:官方文档甩过来一坨<script>标签,让你在页面里new TcPlayer()就算完事。可等你跑到 uni-app H5 工程里照着文档操作,却发现怎么弄都不对劲——import 不进来,require 也不行,硬塞 index.html 又怕全站跟着倒霉。这篇文章就以 uni-app H5 平台接入腾讯云点播播放器为例,把这类外部 JS SDK 的引入逻辑彻底拆开:为什么不能像普通 npm 包一样引、静态挂载和动态注入各有什么代价、加载器怎么封装、实际接入点播 SDK 时踩过哪些坑。无论你是刚摸 uni-app 的新手,还是正在为某个“script-only”的统计、地图或播放类 SDK 发愁的老手,这套思路都能直接用。
1. 从“只能 script 引入”说起:这类 SDK 的加载本质
1.1 为什么有些 SDK 就是不出 npm 包
先把这件事想明白,后面封装才不会绕弯路。腾讯云点播这类播放器 SDK,本质上不是一个简单 JS 文件,它内部包含解码内核、调度逻辑、日志上报、容灾切换等一大堆东西,编译出来体积往往不小。官方要兼容的用户分布太杂——有人用原生 JS,有人用 Vue,有人用 React,还有人干脆是服务端渲染的老页面,所以最稳妥的交付方式就是:给你一个<script>标签,往页面一插,然后暴露一个全局对象TcPlayer。这种交付方式对官方来说维护成本最低,对使用方来说最通用,代价就是它不遵守现代模块体系的“规矩”——没有 ESM 导出,没有有效的 CommonJS 入口,打包工具拿到它也是白拿。
我碰到过不少开发者在这类 SDK 上死磕,非要找到它内部某个模块再手动 import,最后要么拿到一堆空对象,要么触发一堆奇怪的依赖错误。方向就错了。这类 SDK 跟 npm 包的关系,类似于“成品电视”和“电视机主板”:官方交付的是整机,你非要拆开找主板再焊进去,当然折腾。
1.2 uni-app H5 编译后的运行方式,决定了你不能直接 import
uni-app 的 H5 端,编译产物本质是一个 Vue SPA。你业务代码里所有import,最终会被打包工具处理成一个 bundle,页面运行时从 bundle 里加载模块。但上面说了,script-only SDK 没有模块出口,你在代码里写import TcPlayer from 'xxx',大概率得到的是undefined或者直接编译报错。更麻烦的是,这类 SDK 通常要求在浏览器 HTML 文档里插入自己的<script>标签,它才能正确初始化全局对象、建立内部依赖关系。
换到其他平台更头疼。小程序端和 App 端(非 webview 部分)根本没有document,也没有<head>这个概念,你想动态插 script 都没有对象可插。所以这里要先建立一个核心认知:在 H5 平台,外部 SDK 存不存在,取决于最终这个页面的 HTML 文档里有没有那行<script>。所有接入方案,本质上都是在回答“怎么让这行 script 出现在该出现的地方”。
2. 两条基础路径:静态挂标签与动态注入
2.1 方案A:在根目录 index.html 里直接写死
uni-app 的 H5 工程根部有一个index.html,它是 H5 平台专属的 HTML 模板文件,小程序和 App 打包时不会读它。最省事的做法,就是在这个文件里直接按官方文档写:
<!-- index.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- 点播播放器SDK:实际地址以你开通VOD后控制台/官方文档给出的为准 --> <link rel="stylesheet" href="https://your-cdn-host.example.com/tcplayer.min.css" /> <script src="https://your-cdn-host.example.com/tcplayer.min.js"></script> </head> <body> <div id="app"></div> </body> </html>优点非常直接:加载时序最稳定。这个 script 在 Vue 应用启动之前就执行完了,任何页面代码里直接用window.TcPlayer都不会遇到“还没加载完”的问题。坏处也明显:全站所有 H5 页面,不管用不用播放器,首屏都要把这个 SDK 下载下来。点播播放器 SDK 体积不小,如果项目里只有一个页面用点播,其他页面会被白白拖慢。另外全局变量长期挂着,万一别的插件也声明同名全局变量,排查起来会很酸爽。
2.2 方案B:运行时按需动态注入 script 标签
动态注入的思路是:等页面真的需要调用 SDK 时,再通过 JS 往<head>里追加一个<script>节点。核心代码其实就是模拟浏览器加载 script 的过程:
function appendScript(src) { return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = src; script.async = true; script.onload = () => resolve(); script.onerror = (e) => reject(e); document.head.appendChild(script); }); }这个方案最大的价值是按需加载:只有进入点播页面的用户才会触发 SDK 下载,其他页面完全不受影响。代价是你得自己管理加载状态、缓存、失败重试,还得小心处理页面生命周期——在 uni-app 页面onLoad里触发加载,在onUnload里销毁实例。
2.3 两条路怎么选:我的取舍标准
| 对比维度 | index.html 静态引入 | 动态按需注入 |
|---|---|---|
| 接入成本 | 最低,加两行标签 | 需要封装一个加载器 |
| 首屏影响 | 全站一起下载,无关页面受影响 | 只有用到 SDK 的页面才下载 |
| 加载时序 | 早于 Vue 启动,稳定 | 由业务代码控制,需等待 Promise |
| 多页面复用 | 全局常驻,随手可用 | 可通过缓存共享 |
| 失败重试 | 浏览器自动处理,策略有限 | 可在加载器里自定义超时与重试 |
我的默认选择是动态注入,除非整个项目几乎所有页面都要用播放器。理由很简单:可控。你能精确知道 SDK 什么时候加载、什么时候初始化、什么时候能拿到全局对象,而不是让它在所有页面里“随时可能被其他代码影响”。下面这个加载器封装,就是我实际项目里沉淀下来的通用版本,换任何 script-only SDK 都可以复用。
3. 封装通用加载器 loadSDK:代码、设计与边界
3.1 完整代码
先说件重要的小事:文件名建议改成loadSDK.web.js。uni-app 约定.web.js结尾的文件只会在 H5 平台参与编译,小程序端和 App 端打包时直接忽略。这就从源头上避免了document跑到小程序环境里报错的问题。
// utils/loadSDK.web.js // 该文件仅参与 H5 平台编译 const scriptCache = {}; const styleCache = {}; function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function appendScript(src, timeout) { return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = src; script.async = true; let timer = setTimeout(() => { cleanup(); reject(new Error('loadScript timeout: ' + src)); }, timeout); function cleanup() { clearTimeout(timer); script.onload = null; script.onerror = null; } script.onload = function () { cleanup(); resolve(); }; script.onerror = function () { cleanup(); reject(new Error('loadScript error: ' + src)); }; document.head.appendChild(script); }); } function appendStyle(href) { return new Promise((resolve, reject) => { if (styleCache[href]) { resolve(); return; } const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href; link.onload = function () { styleCache[href] = true; resolve(); }; link.onerror = function () { reject(new Error('loadStyle error: ' + href)); }; document.head.appendChild(link); }); } async function detectGlobal(globalVar, timeout) { const deadline = Date.now() + timeout; while (!window[globalVar]) { if (Date.now() > deadline) { throw new Error('global variable not found: ' + globalVar); } await wait(200); } return window[globalVar]; } export function loadSDK(src, options = {}) { const { globalVar = '', css = '', timeout = 15000 } = options; // 全局变量已存在,说明 SDK 早就加载好了 if (globalVar && window[globalVar]) { return Promise.resolve(window[globalVar]); } // 同一个 src 正在加载或已加载完成,复用同一个 Promise if (scriptCache[src]) { return scriptCache[src]; } const task = (async function () { if (css) { await appendStyle(css); } await appendScript(src, timeout); if (globalVar) { return await detectGlobal(globalVar, timeout); } return null; })(); scriptCache[src] = task; // 加载失败时清掉缓存,保证下次还能重试 task.catch(function () { if (scriptCache[src] === task) { delete scriptCache[src]; } }); return task; }3.2 设计拆解:为什么用 Promise 缓存而不是每次都 append
这是我封装时最坚持的一点。点播页面可能被用户反复进入,也可能多个模块同时调用loadSDK。如果不做缓存,同一个 SDK 会被重复 append 到页面上,轻则重复下载浪费流量,重则多次执行全局初始化脚本,导致全局对象被覆盖、播放器实例状态混乱。我在项目里真实遇到过:两个页面几乎同时调用同一个 SDK,结果页面里出现两份 script,日志里反复出现 SDK 自检失败。
用scriptCache按 src 缓存 Promise 之后,不管并发多少次,只有第一次会真正创建<script>节点,后续调用全部等同一个 Promise 完成后复用结果,全局对象保证只初始化一次。另外我特别处理了“全局变量已存在”的情况——如果 SDK 是被其他途径先加载了(比如某个页面静态挂载过),loadSDK就直接返回现有对象,避免重复加载。
3.3 边界情况:超时、全局变量名、加载成功但未就绪
动态加载脚本最隐蔽的坑,是script.onload触发不等于 SDK 可以用了。很多 SDK 加载完主脚本后,内部还会继续初始化、加载依赖、注册全局对象,整个过程是异步的。我在早期版本里只监听 onload,结果有几次 onload 已经触发了,window.TcPlayer还是 undefined。所以加载器里必须有detectGlobal这一步——轮询检测全局变量,给它一个截止时间,超时就报告一个明确的错误,而不是让业务代码懵懵懂懂地拿到 undefined。
超时时间我一般给 15 秒,如果是比较大且依赖外部 CDN 的 SDK,会放宽到 20 秒。太短容易在弱网环境下误报,太长则失败感知太慢。还有一个细节:CSS 必须在 JS 之前加载完成,否则播放器初始化时拿不到正确样式,界面会错乱得莫名其妙。所以loadSDK的css参数和appendStyle是专门为这类“带壳”SDK 准备的,别省。
4. 腾讯云点播(VOD)实战接入
4.1 接入前的准备:拿到 fileID 和 SDK 地址
接点播播放器之前,先把两样东西准备好。第一是 appId,在你开通点播服务的云控制台可以找到;第二是 fileID,上传视频成功后,在媒资管理里选中那个视频,它的基本信息里就有 fileID。播放器 SDK 的 CDN 地址,以你控制台或官方文档实际给出的为准——建议直接把地址放到一个常量文件里,比如sdk-config.js,后面换版本、切环境都只改一处。
这里补充一个容易忽略的点:点播播放器不一定只有播放功能,还涉及防盗链、加密播放、打点上报等配置。如果项目对视频有更高要求,SDK 初始化时的参数会是重点。但核心接入逻辑不变,都是“先把 SDK 加载出来,再创建播放器实例”。
4.2 页面完整代码:加载、创建、暂停、销毁
下面是一个标准点播页面的完整写法,关键代码全部放在 H5 条件编译里:
<template> <view class="vod-page"> <view id="player-container" class="player-container"></view> </view> </template> <script> // #ifdef H5 import { loadSDK } from '@/utils/loadSDK.web.js'; const TC_PLAYER_SCRIPT = 'https://your-cdn-host.example.com/tcplayer.min.js'; const TC_PLAYER_CSS = 'https://your-cdn-host.example.com/tcplayer.min.css'; // #endif export default { data() { return { player: null }; }, onLoad() { // #ifdef H5 // onLoad 触发时页面 DOM 还没渲染,必须等 nextTick this.$nextTick(() => { this.initTcPlayer(); }); // #endif }, onHide() { // #ifdef H5 if (this.player) { this.player.pause(); } // #endif }, onUnload() { // #ifdef H5 this.destroyTcPlayer(); // #endif }, methods: { async initTcPlayer() { const TcPlayer = await loadSDK(TC_PLAYER_SCRIPT, { globalVar: 'TcPlayer', css: TC_PLAYER_CSS, timeout: 20000 }); // 防止极端情况下重复创建,先销毁旧的 this.destroyTcPlayer(); this.player = new TcPlayer('player-container', { appId: '1250000000', // 替换成你自己的 AppID fileID: '5285890xxxx', // 替换成目标视频的 fileID autoplay: false, width: '100%', height: '100%' }); }, destroyTcPlayer() { if (this.player) { this.player.destroy(); this.player = null; } // 清空容器残留 DOM,避免下次创建时出现多余节点 const container = document.getElementById('player-container'); if (container) { container.innerHTML = ''; } } } }; </script> <style scoped> .vod-page { width: 100%; height: 100vh; } .player-container { width: 100%; height: 600rpx; background: #000; } </style>几个细节我有必要多说两句:
- 用
$nextTick而不是直接在onLoad里创建播放器。uni-app 页面onLoad触发时,页面模板还未必渲染完成,播放器 SDK 要找player-container这个 DOM 节点,找不到就会初始化失败。你可以在控制台看到类似“container is null”的报错。 onHide里暂停播放,是因为 uni-app 页面跳转时,原页面不会销毁,只会触发onHide。如果不暂停,用户跳到下一页,视频还在出声,非常尴尬。onUnload里销毁实例,是因为页面被navigateBack弹出后,DOM 会销毁,如果播放器实例还活着,轻则内存泄漏,重则下次进入页面时多个实例同时初始化,出现黑屏、双声道之类的问题。
4.3 播放器实例必须销毁得干干净净
“销毁得干干净净”是我踩坑之后总结的,包含了三件事:调用播放器自带的destroy()方法、把实例引用置空、清空容器 innerHTML。第三件事很多人会漏。实际中我发现,容器里可能残留播放器内部创建的视频节点、调试节点,下次进入页面时如果容器没清空,旧节点和新节点同时存在,播放器行为会变得非常奇怪。虽然我这边用的播放器自己有清理逻辑,但加上 innerHTML 清空是零成本保险。
4.4 顺手封装成组件,方便多页面复用
如果项目里有多个页面都要播视频,把加载和销毁逻辑封装成组件更省心。组件内部负责初始化、销毁,外部只管传 fileID:
<template> <view :id="containerId" class="vod-player"></view> </template> <script> // #ifdef H5 import { loadSDK } from '@/utils/loadSDK.web.js'; const TC_PLAYER_SCRIPT = 'https://your-cdn-host.example.com/tcplayer.min.js'; const TC_PLAYER_CSS = 'https://your-cdn-host.example.com/tcplayer.min.css'; // #endif export default { name: 'VodPlayer', props: { fileID: { type: String, required: true }, appId: { type: String, required: true }, autoplay: { type: Boolean, default: false } }, data() { return { containerId: '', player: null }; }, created() { // #ifdef H5 this.containerId = 'vod-player-' + Date.now(); // #endif }, mounted() { // #ifdef H5 this.$nextTick(() => this.init()); // #endif }, beforeDestroy() { // #ifdef H5 this.destroyPlayer(); // #endif }, watch: { fileID() { // #ifdef H5 this.destroyPlayer(); this.$nextTick(() => this.init()); // #endif } }, methods: { async init() { const TcPlayer = await loadSDK(TC_PLAYER_SCRIPT, { globalVar: 'TcPlayer', css: TC_PLAYER_CSS }); this.destroyPlayer(); this.player = new TcPlayer(this.containerId, { appId: this.appId, fileID: this.fileID, autoplay: this.autoplay, width: '100%', height: '100%' }); }, destroyPlayer() { if (this.player) { this.player.destroy(); this.player = null; } } } }; </script>封装组件有几个好处:多个页面复用加载器缓存,SDK 只下载一次;每个实例用独立 containerId,避免容器 id 冲突;fileID 变化时组件自动重建播放器,外部使用方完全不用关心加载和销毁的时序。
5. 踩坑记录:几个典型问题的完整排查链路
5.1 脚本 onload 触发了,window 上却没有 TcPlayer
这是动态加载最常见的困惑。我最初也以为 onload 就代表万事大吉,结果实际操作中发现,某些环境里 SDK 主脚本加载完,它内部的异步初始化还没跑完,全局变量自然还没挂上。我的排查链路大概是这样的:
- 先打开浏览器 Network,确认这个 script 请求返回 200,而不是被缓存策略、防盗链规则拦成 404。
- 再开 Console,看 SDK 自身有没有抛错。有时是页面混合内容问题——HTTPS 页面加载 HTTP 资源会被浏览器直接拦截,控制台会有明显的错误日志。
- 如果网络和控制台都正常,但
window.TcPlayer还是 undefined,就在加载完成后的回调里打印一下Object.keys(window).filter(k => k.toLowerCase().includes('player')),看 SDK 实际的全局变量长什么样。别完全信任文档示例里的命名,不同版本可能存在差异。 - 最终靠加载器里的
detectGlobal轮询兜底:先等主脚本 onload,再轮询全局变量,都通过才算加载完成。
这个排查过程真正教会我的不是某个 SDK 的特殊性,而是“加载完成”的标准必须由全局变量来定义,而不是由 onload 事件来定义。
5.2 页面返回再进入,黑屏或声音重叠
播放器页面的生命周期问题,是 uni-app 特有的坑。我在实际项目里遇到过这样的场景:列表页进入播放页,播放页再跳到详情页,从详情页返回播放页,再返回列表页。问题就出在“详情页返回播放页”这个环节——播放页并没有被销毁,只是触发了onHide,这时从详情页回来触发的是onShow,而不是onLoad。如果我在onShow里也写了一遍创建播放器的逻辑,就会创建出第二个实例,表现为黑屏、视频重叠、两个声音同时响。
排查时我先在创建播放器的地方打了个断点,发现new TcPlayer触发了两次,然后才意识到生命周期理解错了。正确的处理逻辑是:onLoad只创建一次,onHide暂停,onUnload销毁。如果业务上要求从详情页返回时重新播放,可以在onShow里判断“播放器实例是否存在”,存在就调用play(),不存在才重新创建。
5.3 小程序/App 平台打包报错:document is not defined
H5 端一切正常,切到小程序工具里一跑,控制台直接报document is not defined,堆栈指向 loadSDK 文件。这个坑的原因很直接:加载器文件被小程序打包进去了,运行在小程序环境里,document不存在。排查链路如下:
- 看编译产物里是否有 loadSDK 的代码。打开构建后的项目源码,全局搜文件内容,确认它进入了非 H5 的包。
- 检查 import 语句是否被条件编译包住。如果页面里写的是普通的
import { loadSDK } from '@/utils/loadSDK.web.js',没写在// #ifdef H5里面,那么在小程序平台它仍然会被引用。 - 正确的做法是把文件命名
.web.js,并且所有调用点也包上 H5 条件编译,双保险。
至于 App 端,如果你也想用同一套播放器,我的建议别硬来。App 端非 webview 环境跑不了浏览器 API,更常见的做法是做一个内嵌点播页,App 里用web-view组件承载这个 H5 页面,再从外层用uni.postMessage和页面通信。这个思路能复用全部 H5 代码,只是多了一层 web-view 的封装成本。
5.4 容器还没渲染就创建播放器:拿到空 DOM
这个问题我在 4.2 提前预警过,但它确实值得单独拿出来说说,因为太容易犯了。uni-app 页面onLoad触发时,页面数据已经准备好,但视图渲染是异步的。播放器 SDK 初始化时要拿到容器 id 对应的真实 DOM,你直接new TcPlayer('player-container', ...)时,getElementById('player-container')返回 null,初始化直接失败,而且失败日志有时候藏在 SDK 内部,很难一眼看出来。
排查时我会在创建播放器前加一行:
console.log(document.getElementById('player-container'));如果打印出来是 null,说明时机太早了。解法就是$nextTick或onReady之后再创建。组件化之后尤其要注意:mounted并不保证子组件里的 DOM 已经挂好,必须$nextTick再操作。
最后放一个我日常排查这类问题的检查清单,供直接参考:
| 检查项 | 快速排查方式 |
|---|---|
| SDK 请求是否 200 | DevTools Network,注意 404 和混合内容拦截 |
| SDK 是否自身报错 | Console 看红色日志,优先处理 |
| 全局变量名是否正确 | 对照文档,或打印 window 下相关键名 |
| 容器 DOM 是否已渲染 | 创建播放器前打印 getElementById 结果 |
| 播放器是否重复创建 | 在 new 的位置打断点,统计触发次数 |
| 非 H5 平台是否报错 | 切换小程序/App 编译运行,看堆栈指向什么文件 |
最后再多说一句
回到开头那个问题:uni-app 里接 script-only SDK,本质上不是“怎么把这个文件弄进来”的问题,而是“怎么控制这个全局对象在正确的时机出现”。把加载器固定下来之后,你会发现这套 Promise 缓存模式几乎是通用的——今天接腾讯云点播,明天接某个统计 SDK,后天接外部地图 SDK,改动量就是改一下 src 和 globalVar。我个人强烈建议这类 SDK 的 CDN 地址全部集中到一个配置文件里,按环境区分测试和正式地址,别像我早期那样把地址散落在各个页面里,升级一次版本要全局搜索好几轮。多花半小时做封装,后面换 SDK、查时序问题,都会轻松很多。