1. 先搞清楚:Blob URL 到底"加密"了什么
做视频网站或者给老板做内部培训系统的时候,大家应该都遇到过这个场景:视频要放给用户看,但又不想让人右键一复制就拿到真实地址,更不想让下载工具把 MP4 直接拖走。这时候查看网页元素,video标签的src指向的往往不是https://xxx/video.mp4,而是一长串blob:https://xxx/uuid。标题里说的"Video src Blob加密路径",指的就是这么个东西。
先说一个扎心的真相:Blob URL 本身并不加密。它只是把视频数据从一段网络地址,变成了浏览器内存里的一个"临时对象引用"。你可以把它想象成餐厅后厨的传菜窗口:客人拿到的是窗口里已经做好的菜,而不是厨房的采购单和仓库钥匙。别人看不到原料从哪里来,但菜本身还是原汁原味传输的,并没有做任何编码层面的保护。
那为什么它看起来像"加密"?因为在很多浏览器里,blob:开头的 URL 离开当前页面就失效,直接复制到新标签页往往打不开,普通用户也确实找不到原始文件路径。这种"路径遮蔽"的效果,加上地址栏里一串看不懂的随机串,观感上就很像加密。我们团队当时接手一个视频课程项目时,第一版就是拿它来"防裸奔"的——至少不能让不懂技术的人拿着链接到处发。
1.1 为什么需要一条 Blob 路径
先聊聊需求背景。常见的直链播放方式是这样:
<video src="https://example.com/course/lesson01.mp4" controls></video>这种方式问题非常明显:
- 视频地址直接暴露在 HTML 里,任何人打开开发者工具都能看到完整路径;
- 下载工具可以直接抓取,甚至不需要解析页面,知道规则就能批量爬;
- 一旦资源被到处转发,服务端防盗链基本形同虚设;
- 出于运营需要,有时候视频是临时拼接的,比如课程有多段、需要插入片头片尾,直链反而不好处理。
用 Blob 路径后,前端从接口动态拉数据,生成临时 URL 给播放器,真实存储路径就不出现在页面源码里了。这个方案能同时解决"路径隐藏"和"数据来源可控"两个问题。而且它和权限系统能天然结合:接口先验证用户是否有权,再决定给不给数据。比如某个用户只买了试听课,服务端可以只返回前5分钟的切片数据,其余的分片接口直接返回 403。
1.2 Blob URL 和普通视频 URL 的本质差别
我整理了一张对比表,方便你判断什么场景该用哪种:
| 对比维度 | 普通文件 URL | Blob URL |
|---|---|---|
| 资源来源 | 服务器磁盘/CDN | 浏览器内存中的二进制对象 |
| 生命周期 | 只要文件不删,一直有效 | 只在当前页面会话内有效 |
| 直接下载 | 拿到 URL 就能下载 | 复制到新窗口基本打不开 |
| 是否暴露路径 | 完全暴露 | 不暴露,但仍可被专业手段抓取 |
| 适用场景 | 公开视频、CDN 静态资源 | 私有资源、动态拼接、权限控制 |
| 服务端压力 | 低,非常低 | 需要接口转发或鉴权,成本略高 |
| 兼容性 | 几乎所有环境 | 现代浏览器支持良好 |
核心的实现 API 就是浏览器内置的URL.createObjectURL()。它接收一个Blob对象或MediaSource对象,返回一条临时的blob:地址。反过来,URL.revokeObjectURL()负责释放这条引用,回收内存。
打个比方,Blob对象是内存里的一袋子数据,createObjectURL给这袋子数据发了张临时通行证,播放器凭通行证可以访问袋子里的内容。这张通行证只在当前页面有效,而且每发一张都占内存,用完不回收就会一直占着。这也就是后面我要重点讲的"内存泄漏坑"。
2. 搭建一条能跑的 Blob 视频播放链路
理解了原理之后,直接上实操。下面两种方案我都实际跑过,分别对应"文件整体加载"和"分片流式播放"两个场景。
2.1 方案A:整体文件拉取转 Blob
这是最入门、也最容易理解的方案。核心逻辑:用fetch请求视频文件,把响应转成Blob,然后生成临时 URL 给播放器。
async function loadVideoIntoPlayer(videoUrl) { const response = await fetch(videoUrl, { headers: { 'Authorization': 'Bearer ' + getUserToken() } }); if (!response.ok) { throw new Error('视频拉取失败:' + response.status); } const blobData = await response.blob(); // 如果响应头里 Content-Type 不准确,需要手动修正 const finalBlob = new Blob([blobData], { type: 'video/mp4' }); const blobURL = URL.createObjectURL(finalBlob); const video = document.querySelector('#player'); video.src = blobURL; video.controls = true; // 关键:监听视频加载完成后再释放内存 video.addEventListener('loadedmetadata', () => { // 不能在这里马上 revoke,部分浏览器数据还没吃进去 setTimeout(() => URL.revokeObjectURL(blobURL), 3000); }, { once: true }); return blobURL; }几个关键点单独拎出来说:
第一,为什么用 Authorization 请求头而不是 Cookie?因为很多视频系统的登录态并不依赖 Cookie,而是在前端内存或者 localStorage 里存 token。更重要的一点是,fetch发起的请求比<video>标签的静态请求更容易控制请求头。如果让<video src="blob:...">自己去请求网络,你没有办法给它自定义请求头。所以"先fetch拿数据,再生成内存 URL"这个顺序不能反。
第二,Blob的 MIME 类型问题。后端返回视频文件时,如果响应头的Content-Type没设置正确,response.blob()得到的type可能是空字符串。播放器拿到类型不明的数据会直接罢工。稳妥的做法是用new Blob([blobData], { type: 'video/mp4' })包一层,显式指定类型。如果是video/webm、video/ogg之类的也要对应改。
第三,大文件别硬上。方案A适合几十 MB 以内的短视频或者音频。如果视频有几百 MB,直接把整个文件读进内存,移动端浏览器很容易卡死甚至崩溃。Server 端网络差的时候,用户点开视频要转好几秒才能看到首帧,这个体验是硬伤。所以大文件要走方案B。
2.2 方案B:分片拉取 + 解密 + MSE 播放
如果你既想隐藏路径,又想实现"边下边播",甚至想对切片做一层"前端解密后再播放",那就需要引入 MSE(Media Source Extensions)。
MSE 的原理稍微绕一点,但理解后你就知道为什么它是正路。普通的播放流程是:video标签拿着src去请求数据,浏览器自己处理一切,你插不上手。MSE 则把数据管道交给你:你创建MediaSource对象,拿它的 URL 给video标签,然后自己用 JavaScript 往SourceBuffer里一段一段"投喂"数据。
const video = document.querySelector('#player'); const mediaSource = new MediaSource(); video.src = URL.createObjectURL(mediaSource); mediaSource.addEventListener('sourceopen', async () => { // 注意 codecs 字符串必须和后端转码参数一致 const sourceBuffer = mediaSource.addSourceBuffer( 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"' ); // 分段拉取,例如每段 10 秒 const segmentUrls = [ 'https://example.com/api/video/seg1', 'https://example.com/api/video/seg2', 'https://example.com/api/video/seg3' ]; for (const url of segmentUrls) { const response = await fetch(url, { headers: { 'Authorization': 'Bearer ' + getUserToken() } }); let segmentData = await response.arrayBuffer(); // 如果服务端做过自定义加密,需要在这里解密 // segmentData = await decryptSegment(segmentData, getDecryptKey()); // 注意:appendBuffer 一次只能处理一个分片 sourceBuffer.appendBuffer(segmentData); } mediaSource.endOfStream(); });这里有一个非常重要的概念:video.src指向的MediaSource对象,最终还是会被浏览器映射成一条blob:URL。所以你看到的地址依然是以blob:开头,只不过数据不是一次性塞进去的,而是一段一段喂的。
为什么 MSE 更像"加密路径"?因为 MP4 的moov元数据可能被拆分、切片数据可以乱序传输、服务端可以把每一片用 AES 加一遍,前端拿到后再解密。相比方案A的"整个文件透明传输",MSE 方案至少把数据变成了"有状态、有顺序、有解密步骤"的动态流。每多一层,抓数据的人就要多花一些功夫。当然,这些话必须说在前面:MSE + 自定义解密并不是 DRM 级别保护,懂技术的人仍然可以拿到 key 之后逐段还原,但它已经能拦截掉绝大多数普通用户和半吊子爬虫了。
网络不好的时候会出什么幺蛾子?我遇到过一种经典场景:appendBuffer的时候分片还没下载完,用户拖到了进度条后面,播放器需要一个关键帧但你没喂到位,直接黑屏。解决方式是先下载、后投喂,并且用sourceBuffer.updating状态控制队列,别盲目并发 append。并发 append 是不允许的,会直接抛QuotaExceededError或者InvalidStateError。
3. 让"Blob 加密路径"真正落地:服务端配合与权限设计
很多前端同学把blob:当成了安全银弹,以为加上这层就万事大吉。我见过最夸张的:前端代码里直接写死一个公网 MP4 地址,然后fetch一下转成 blob 播放,就觉得"加密了"。说实话,开发者工具里看 Network 面板,一分钟就能找到原始地址。Blob 路径永远只是整个安全链路上的最后一公里,真正的关卡必须设在服务端。
3.1 真正决定安全性的不是前端,是服务端
一个可行的服务端权限设计,至少要满足这几点:
- 签名 URL:视频请求地址不完全公开,每次都包含用户标识、过期时间和签名;
- 请求校验:服务端校验签名和过期时间,不通过就返回 403,拒绝给数据;
- 单次有效:签名一次性使用,用后立即失效,防止地址被反复利用;
- 按需分发:服务端根据用户权限决定返回完整视频还是片段,或者返回加密后的分段数据。
Node.js 后端生成签名 URL 的伪代码大概长这样:
const crypto = require('crypto'); function generateSignedVideoUrl(userId, videoPath, expiresIn = 600) { const expireAt = Math.floor(Date.now() / 1000) + expiresIn; const data = `${userId}:${videoPath}:${expireAt}`; const signature = crypto .createHmac('sha256', VIDEO_SECRET_KEY) .update(data) .digest('hex'); const params = new URLSearchParams({ path: videoPath, userId, expire: expireAt, sig: signature }); return `/api/video/stream?${params.toString()}`; }前端拿到这个签名 URL 之后,再发起fetch拿数据。注意,即使有人把签名 URL 截走,服务端校验expireAt发现过期,依然返回 403。这就是"前端 Blob 遮蔽路径 + 后端时效签名"的组合打法。
我实际碰到过一个挺尴尬的错误:服务端为了图省事,只校验了签名存在,没校验过期时间。结果有人花三天写了个脚本,天天刷新页面拿新签名,把整个课程视频一集一集爬走了。所以签名 URL 里的expireAt一定要参与签名计算,同时服务端校验时也要拿当前时间比较,别自欺欺人。
3.2 在不同安全等级下怎么选型
我把常见方案按照安全等级、开发成本列了一张表,你根据业务需求自己挑:
| 方案 | 安全等级 | 开发成本 | 适用场景 |
|---|---|---|---|
| 裸 CDN 直链 | 几乎没有 | 极低 | 公开内容、营销视频 |
| Referer 防盗链 | 低 | 低 | 防止站外引用,拦不住下载工具 |
| 签名 + 时效 URL | 中 | 中等 | 付费课程、会员视频的大部分场景 |
| 前端 Blob 遮蔽 | 中低 | 低 | 防普通用户"右键另存为" |
| 分片 + 自定义加密 + MSE | 中高 | 较高 | 需要边下边播、动态拼接的场景 |
| 商业 DRM(如 FairPlay、Widevine) | 高 | 很高 | 影视级付费内容,需申请合规资质 |
说实话,大部分中小团队做到"签名 + 时效 + Blob + 分片自定义加密"就已经够用了。真正的盗版问题不是靠前端技术能解决的,过度投入 DRM 反而拖慢开发进度。
3.3 给前端同学的几个权限设计细节
有几个细节是文档里不常写、但实战中一定会踩到的:
细节一:fetch的 Range 请求。视频播放器支持拖进度条时,如果整个数据都是前端拿fetch拉回来的,没实现 Range,那么拖动进度条时你会非常难受——数据还没全下载下来,你没法提供一个可跳转的资源。一种做法是服务端按 Range 返回分片,前端拿多个分片组装;另一种做法是走 MSE 方案,按时间片投喂数据。如果只是简单地把整个文件 fetch 下来转 blob,你会发现播放器虽然能播,但进度条拖动后老是缓冲,这其实是底层没有 Range 支持导致的。
细节二:跨域问题躲不掉。用fetch拉视频数据,如果资源和前端不在同一个域,浏览器会先发起 CORS 预检。服务端必须正确响应Access-Control-Allow-Origin,如果前端还要带Authorization头,Access-Control-Allow-Headers里也要把它列进去。我遇到过一种情况:后端只配了跨域域名,没配Allow-Headers,结果所有带 token 的请求全部预检失败,页面上看 Network 全红,浪费了一个下午才定位到。
细节三:白名单 IP 访问。如果视频服务部署在独立的存储节点,只允许后端 API 服务器访问,前端不直接对接,这样就算前端被人逆向出接口地址,它拿不到签名也无法直连存储节点。这种架构也是目前比较推荐的:前端 -> 业务后端 -> 存储服务,每层都做隔离,即使某一层被打穿,影响面也有限。
4. 高频问题与排查实录
这部分内容全是我在真实项目里踩过的坑,一条一条列出来,省得你再试错。
4.1 实际排查案例:为什么生成 Blob URL 但还是黑屏
有一次我在某项目里做了一个在线播放页,视频拉取一切正常,Network 面板里能看到返回 200,Blob URL 也赋值给了video.src,但播放器黑屏,控制台也没报错。
排查步骤:
- 先看 Network 里的
Content-Type:响应头显示application/octet-stream,不是video/mp4; - 用
console.log查看blobData.type,结果是空字符串; - 用
new Blob([blobData], { type: 'video/mp4' })修正类型; - 刷新后视频正常播放。
这个案例说明,很多后端框架在返回静态文件时,如果文件扩展名和 MIME 映射没配置好,Content-Type就会变成通用的二进制流。前端做了一层fetch后,原始的响应头信息不再能直接传递到播放器内部,所以必须自己修正Blob.type。
4.2 常见问题速查表
我整理了一个高频故障排查表,几乎覆盖了 Blob 视频方案里 80% 的问题:
| 问题现象 | 可能原因 | 解决方式 |
|---|---|---|
赋值video.src = blobURL后黑屏 | MIME 类型为空或不正确 | 手动new Blob([data], { type: 'video/mp4' }) |
| 视频加载到一半卡住 | 数据没全部拉完就赋值 src | 使用fetch的ReadableStream或改 MSE 分片 |
| 页面刷新后视频打不开 | Blob URL 只在当前页面有效 | 这是预期行为,刷新后需重新拉数据生成新 URL |
| 切换视频页面后内存持续上涨 | 没有调用revokeObjectURL释放 | 组件卸载或loadedmetadata后URL.revokeObjectURL |
| Safari 上无法自动播放 | Safari 自动播放策略严格 | 设置video.muted = true后再调play() |
| 请求报 CORS 错误 | 后端缺少 CORS 响应头 | 服务端增加Access-Control-Allow-Origin和Allow-Headers |
| MSE 模式拖动进度条黑屏 | 缺少关键帧或分片乱序 | 确保sourceBuffer.mode = 'sequence',按时间顺序 append |
appendBuffer抛异常 | 上一次 append 还没结束就再次 append | 监听updateend事件后再 append 下一片 |
| Blob URL 数据量巨大导致崩溃 | 超大视频整体 fetch 进内存 | 切换方案B,使用分片拉取和 MSE |
4.3 内存泄漏这个坑,必须单独说
很多视频网站是用 Vue/React/小程序之类的框架实现的,页面会频繁切换。如果你每次进入页面都生成 Blob URL,离开页面时没销毁,你会发现浏览器内存居高不下,甚至切换到别的页面都会变卡。
正确做法:
// 组件卸载或页面隐藏时释放 beforeDestroy() { if (this.blobUrl) { URL.revokeObjectURL(this.blobUrl); this.blobUrl = ''; } }但这里有个小陷阱:如果你在loadedmetadata事件触发前就调用revokeObjectURL,某些浏览器还没有完全把数据交给播放器,会导致播放失败。我一般会做延时释放:
video.addEventListener('loadedmetadata', () => { setTimeout(() => { URL.revokeObjectURL(blobUrl); }, 5000); }, { once: true });5 秒后播放器通常已经把数据吃到自己的解码缓冲区里了,此时释放内存中的原始 Blob 对象就不会影响播放。如果视频播放过程中需要处理进度条跳转,那就不要过早释放,等视频结束或卸载时再处理。总之,revokeObjectURL用得好是性能优化,用错了就是新 bug,建议模块化封装成公共函数,别到处手写。
4.4 怎么确认 Blob 里到底装的是什么
排查问题时,你会想确认 Blob 数据有没有正常生成,可以在控制台手动跑:
const tempBlob = await fetch('https://example.com/api/video').then(r => r.blob()); console.log(tempBlob); console.log(tempBlob.type, tempBlob.size); const url = URL.createObjectURL(tempBlob); window.open(url, '_blank');如果你在控制台直接执行这段代码,window.open打开一条blob:URL,如果数据本身没问题,新窗口会直接播放或者弹出下载。如果弹出的窗口是空白的,说明数据或者类型有问题。这也是我常用的快速定位手段,比起瞎猜要高效得多。
5. 最后,关于"加密"这件事的一点体会
从我做过的几个视频类项目来看,Blob URL 更多扮演的是"防君子不防小人"的角色。它解决的是路径泄露、右键下载、普通用户分享链接这些实际问题,但它扛不住专门做逆向的人。真正要让视频资源安全一些,核心发力点还是在服务端:签名、时效、权限校验、分片隔离,一层层叠上去,成本可控,效果也实在。
我的建议是:如果你只是做企业内部培训、课程售卖,方案A加签名 URL 就够了,别一上来就上 MSE、DRM,因为那会把播放器的兼容性问题全部甩到你脸上。如果你做的是 UGC 视频平台,需要考虑防下载和防盗录,那再上方案B,同时把转码、分片、解密都托管给可靠的云服务或开源媒体服务器,前端只做组装和播放。
最后再分享一个小技巧:Blob URL 不只适用于视频,音频、PDF、Excel 导出文件都可以用同一套思路做"内存态临时访问",用完即焚。这套方法论你掌握之后,很多"不想暴露真实地址"的下载场景都能举一反三,不只是视频这一条路。