今天遇到一个诡异的情况,在chrome中好好的视频播放,到了safari中却看到一个毁坏的视频图标:
打开元素检查,发现红色提示“Failed to load resource: 插件处理的载入”,新窗口打开视频链接,在网络中看到视频请求红色,点击看到“尝试载入资源时发生错误。”。
经过一番排查,了解到出现该问题的原因是
当播放视频时,浏览器通常不会一次性下载整个视频文件,而是会发送 范围请求(Range Requests),分块下载视频。这使得用户可以快速开始播放、拖动进度条,并节省带宽。Safari 对此要求尤其严格。如果你的服务器(比如 Nginx, Apache, Node.js 等)没有正确配置来响应这些请求,Safari 就会报错。
国内的大模型对此一无所知,还是google gemini如一位经验丰富的老手,最快找到问题。虽然一开始我也不确定是否是因为这个原因,但是后面处理后验证了这个说法。
我开始着手按照这个思路进行调整。
我观察了自己服务端返回资源的逻辑,特别是视频资源,如果服务器上存在对应的视频文件,我会以流式返回资源,具体即在headers中增加:
Connection: keep-alive Transfer-Encoding: chunked
Range: bytes=500-999
HTTP/1.1 206 Partial Content Content-Range: bytes 500-999/2000 Content-Length: 500 Content-Type: video/mp4
这是非常标准的响应,即服务端同意返回第500-999个比特。在这个响应中Content-Length是必须的,而且值为range比特数。206状态码表示这是一个未完成的资源响应,客户端应该继续请求,直到拿到最终结果。
但是,如果按照上面的响应方式,我需要在服务端做大改造,即我需要按比特截取视频,这显然有点强人所难。因此,我探索是否可以有其他方式,在改动比较小的情况下解决。
最后,我找到一种方案,在原来响应200状态码的情况下,我返回:
Accept-Ranges: bytes Content-Length: 552302
即我返回了整个视频的长度,并添加了Accept-Ranges头来标识。
完成这个调整之后,我发现直接请求视频地址,会报502,看nginx日志后发现是Transfer-Encoding: chunked与Content-Length两个头互斥引起的。经过调整,在Range Requests的场景下,不在使用Transfer-Encoding: chunked进行响应。
最终通过这样的改造,safari可以正常响应播放视频了。本来以为解决了,但是没想到发现其实是有问题的,当视频体积稍大的时候,报错依然存在。safari实际上还是以range模式拉取视频数据,因此,还是必须实现range请求改造。
标准的Range响应要使用206状态码。因此,世纪改造还是比较复杂的,下面是大致的代码:
const responseBuffer = (buff) => { const range = header('range'); const byteLength = buff.byteLength; // 当不存在range请求头时,直接返回 if (!range) { header('Content-Length', byteLength); return buff; } const [start, end] = range.replace('bytes=', '').split('-'); // 当range头为 Range: bytes=0- 时,表示读取整个视频,因此也直接返回 if (start === '0' && end === '') { header('Content-Length', byteLength); return buff; } const begin = start === '' ? 0 : +start; const to = end === '' ? byteLength : +end + 1; header('accept-ranges', 'bytes'); // 处理一些不符合请求要求的情况 if (Number.isNaN(begin) || Number.isNaN(to) || begin < 0 || to > byteLength || begin >= to) { status(416); header('Content-Range', `bytes */${byteLength}`); return; } const length = to - begin; header('Content-Length', length); header('Content-Range', `bytes ${start}-${end}/${byteLength}`); // 当返回Content-Range时,状态码必须为206 status(206); const clip = buff.slice(begin, to); return clip; };
通过上面这段代码,当浏览器发起的请求为不同情况时,返回不同的请求头和buff,这样就可以让safari正常播放。
此外,还有一个点需要注意,safari视频请求其实分了两个阶段:
第一阶段是与视频建立可读性连接,并根据content-length,计算range bytes的大小,确认视频可读性之后,这个连接被放弃,之后开始加载video标签,上面的data:image/png..就是video标签视频播放器的素材(例如播放按钮、画中画图标等),在完成video标签组件的渲染之后,才会发起第二阶段的连接。
在第一次连接时,请求并不带Ranges头,而是一个普通的http请求,在第二阶段连接时才会带上Ranges头。第二阶段实际上会发起多个range请求,上面截图中只有一个请求,是因为视频体积比较小,而当content-length较大时,safari会自动拆分为多个range来发起多个请求,因此,每一个range请求返回的结果其实都应该不一样。这也是我第一次调整代码后,留下了尾巴,带来的问题。
这就意味着,我们后端代码中,必须通过Ranges头来判断请求处于哪一个阶段,并且根据实际情况返回Content-Length,否则就有可能在第一次连接时就由于代码疏忽导致相应失败。
而比较坑爹的是,safari和chrome的video逻辑不同。上面这套逻辑适用于safari,但在chrome中,似乎容忍的range体积大很多,我测试了几个视频,都是直接Range: bytes 0-
,意思是请求整个视频,而此时如何返回206的话,chrome又无法播放视频。真的是造孽了……不过好在,遇到这种情况的时候,直接返回200,就可以正常播放,于是才有了上面代码中的特殊逻辑。
以上就是解决整个问题的完整思路。
2025-08-08 71