1.引言
在桌面应用开发中,本地视频的高效加载与播放是提升用户体验的关键。本文介绍如何通过 Electron 的协议处理器与 Node.js 流式 API,实现高性能的本地视频加载方案。
自定义协议流传输的优势
| 特性 |
文件路径 |
Blob |
流式传输 |
| 内存占用 |
高(线性增长) |
极高(全量驻留) |
低(动态分片) |
| 大文件支持 |
❌ |
❌ |
✅ |
| 首帧时间 |
慢 |
慢 |
快 |
| 拖拽播放 |
卡顿 |
卡顿 |
流畅 |
| 内存泄漏风险 |
中 |
高 |
低 |
2. 核心实现原理
2.1 协议层架构设计
1 2 3 4 5 6
| sequenceDiagram Renderer->>Main: 请求 media://id.mp4 Main->>FileSystem: 创建可读流 FileSystem->>Main: 返回文件流 Main->>Renderer: 流式传输响应
|
2.2 关键技术选型
- Electron Protocol API:注册自定义协议替代
file://
- Node.js Stream:实现分块读取与传输
- HTTP Range:支持视频播放器范围请求
3. 技术实现
3.1 安全映射机制
在主进程中实现一个 map 将真实路径存放起来,当使用 electron dialog 选择文件后,可以生成一个 id 返回给 renderer 端,renderer 端通过 id 来获取
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const mediaFileMap = new Map<string, string>();
export const addMediaFile = (id: string, path: string) => { mediaFileMap.set(id, path); };
export const getMediaFile = (id: string) => { return mediaFileMap.get(id); };
export const removeMediaFile = (id: string) => { mediaFileMap.delete(id); };
export const clearMediaFileMap = () => { mediaFileMap.clear(); };
|
这样做的好处有两点:
- 避免暴露真实路径
- 如果路径中有空格等特殊字符串,经过 encodeURIComponent 后,会传递不到给自定义的 Protocol
3.2 协议注册与配置
1 2 3 4 5 6 7 8 9 10 11 12 13
| import { protocol } from 'electron';
protocol.registerSchemesAsPrivileged([ { scheme: 'media', privileges: { standard: true, secure: true, stream: true, bypassCSP: true, }, }, ]);
|
3.3 路径获取
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| import path from 'path'; import { existsSync } from 'fs'; import { getMediaFile } from '../utils/store/media-file-map';
const getMediaFilePath = (reqUrl: string) => { const filePath = decodeURIComponent(reqUrl.replace('media://', '')); const [id] = filePath.split('.'); const mediaFilePath = getMediaFile(id);
if (!mediaFilePath) { return ''; }
const normalizedPath = path.normalize(mediaFilePath);
if (!existsSync(normalizedPath)) { return ''; }
return normalizedPath; };
|
3.4 获取 range
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import { promises } from 'fs';
const getRange = async (normalizedPath: string, rangeHeader: string | null) => { const { size } = await promises.stat(normalizedPath); let start = 0, end = size - 1; if (rangeHeader) { const match = rangeHeader.match(/bytes=(\d*)-(\d*)/); if (match) { start = match[1] ? parseInt(match[1], 10) : start; end = match[2] ? parseInt(match[2], 10) : end; } } const chunkSize = (end || size - 1) - start + 1;
return { start, end, chunkSize, size }; };
|
3.5 实现流式响应
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| export const initMediaProtocol = () => { protocol.handle('media', async (req) => { try { const normalizedPath = getMediaFilePath(req.url);
if (!normalizedPath) { return new Response('File not found', { status: 404 }); }
const rangeHeader = req.headers.get('Range');
const { start, end, chunkSize, size } = await getRange(normalizedPath, rangeHeader);
const stream = createReadStream(normalizedPath, { start, end }); stream.on('error', (error) => { stream.destroy(); return new Response(`Error: ${error.message}`, { status: 500 }); }); return new Response(stream as any, { status: rangeHeader ? 206 : 200, headers: { 'Content-Range': `bytes ${start}-${end || size - 1}/${size}`, 'Accept-Ranges': 'bytes', 'Content-Length': chunkSize.toString(), 'Content-Type': getContentType(normalizedPath), }, }); } catch (error: any) { console.error('Error handling media protocol:', error); return new Response(`Error: ${error.message}`, { status: 500 }); } }); };
|
initMediaProtocol这个方法要在 app when ready 后执行。
完整代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
| import { protocol } from 'electron'; import path from 'path'; import { getMediaFile } from '../utils/store/media-file-map'; import { getContentType } from '../utils/content-type'; import { createReadStream, existsSync, promises } from 'fs';
protocol.registerSchemesAsPrivileged([ { scheme: 'media', privileges: { standard: true, secure: true, stream: true, bypassCSP: true, }, }, ]);
const getMediaFilePath = (reqUrl: string) => { const filePath = decodeURIComponent(reqUrl.replace('media://', '')); const [id] = filePath.split('.'); const mediaFilePath = getMediaFile(id);
if (!mediaFilePath) { return ''; }
const normalizedPath = path.normalize(mediaFilePath);
if (!existsSync(normalizedPath)) { return ''; }
return normalizedPath; };
const getRange = async (normalizedPath: string, rangeHeader: string | null) => { const { size } = await promises.stat(normalizedPath); let start = 0, end = size - 1; if (rangeHeader) { const match = rangeHeader.match(/bytes=(\d*)-(\d*)/); if (match) { start = match[1] ? parseInt(match[1], 10) : start; end = match[2] ? parseInt(match[2], 10) : end; } } const chunkSize = (end || size - 1) - start + 1;
return { start, end, chunkSize, size }; };
export const initMediaProtocol = () => { protocol.handle('media', async (req) => { try { const normalizedPath = getMediaFilePath(req.url);
if (!normalizedPath) { return new Response('File not found', { status: 404 }); }
const rangeHeader = req.headers.get('Range');
const { start, end, chunkSize, size } = await getRange(normalizedPath, rangeHeader);
const stream = createReadStream(normalizedPath, { start, end }); stream.on('error', (error) => { stream.destroy(); return new Response(`Error: ${error.message}`, { status: 500 }); }); return new Response(stream as any, { status: rangeHeader ? 206 : 200, headers: { 'Content-Range': `bytes ${start}-${end || size - 1}/${size}`, 'Accept-Ranges': 'bytes', 'Content-Length': chunkSize.toString(), 'Content-Type': getContentType(normalizedPath), }, }); } catch (error: any) { console.error('Error handling media protocol:', error); return new Response(`Error: ${error.message}`, { status: 500 }); } }); };
|
4. 渲染端应用
渲染端中只要拼接好对应协议即可

5. 效果

使用这种流式数据播放,能够随时拖动时间轴,每次也只会拿时间轴开始到一定范围的数据,减少内存占用。
6. 不足
如上图效果所示,缓冲区的 end 都是到视频最大的 size,这样不太合理。前端应该拿到 content-range 后将通过切分做进步的优化,比如分多段来获取缓存。