做了个小东西:一个运行在 Mac 上的录屏工具,基于 Electron + React,支持录屏、画中画(摄像头小窗叠加)和视频保存。项目不大,但完整的走通了“采集屏幕流 → 叠加摄像头画面 → 录制编码 → 落盘保存”这条链路。这篇文章把整个项目的技术选型、核心实现、关键代码和踩坑过程都拆开讲讲,适合想用 Electron 做桌面工具、但不想一上来就啃大框架的朋友参考。
1. 项目定位与技术选型:为什么从录屏这棵小树苗开始
1.1 需求边界:先做能跑通的最小版本
我在动手之前先把需求压到最小:能选屏幕、能开始录制、能在角落里叠一个摄像头小窗、点停止的时候弹出保存框把文件写下来。其他什么系统音频内录、多显示器选择、滤镜、倍速剪辑,统统砍掉。
这个决策很重要。Electron 项目最常见的死法就是一开始就追求大而全,结果主进程、渲染进程、IPC、打包、权限、编解码全搅在一起,出了问题都不知道去哪里排查。录屏工具天然是“流式数据 + UI 控制 + 文件输出”的组合,非常适合当 Electron 练手项目:它逼着你把主进程和渲染进程的分工搞清楚,又不会复杂到劝退。
1.2 为什么选 Electron + React,而不是 Swift 或者别的
Mac 上做录屏,原生方案确实更“正”:用 ScreenCaptureKit 拿系统级画面,性能好、能内录音频。但那意味着我要写 Swift、处理 App Sandbox、学一套全新的 UI 框架,开发成本一下就上去了。我的目标是快速验证想法、把核心逻辑跑通,Electron 的优势恰好就在这里:
- 复用 Web 生态里成熟的能力,
getDisplayMedia、getUserMedia、MediaRecorder都是浏览器原生 API,不用额外装 SDK。 - UI 层直接用 React 做,状态管理、组件拆分、样式方案都现成,不用重新学。
- 最终产物是跨平台的,同一个代码改改还能出 Windows 版。
React 在这里承担的角色也很纯粹:管理录制状态(闲置/采集中/录制中/已停止)、维护画中画的开关和拖拽位置、渲染预览画面。它不是项目的复杂度来源,而是让 UI 层好维护的“减负工具”。
我特别想提醒一点:别为了 React 而 React。如果你只是弹个窗口、放个按钮,原生 HTML 就够了。React 的用武之地在于画中画拖拽、录制状态切换这种多状态联动,以及后续想加录制列表、设置面板时的组件化扩展。
1.3 工程骨架与 IPC 设计方向
小项目也要有清晰的工程边界。我按“主进程负责系统能力和文件操作,渲染进程负责交互和媒流体处理”的原则拆分了目录:
electron-screen-recorder/ ├── main.js # 主进程:窗口创建、保存文件 IPC ├── preload.js # 预加载脚本:用 contextBridge 暴露安全 API ├── package.json └── src/ ├── App.jsx # 根组件:录制状态机、按钮、画中画容器 ├── components/ │ ├── ScreenPicker.jsx # 屏幕源选择 │ ├── CameraPreview.jsx # 摄像头预览/画中画 │ └── RecordButton.jsx # 录制控制 └── hooks/ └── useRecorder.js # 封装 MediaRecorder 逻辑渲染进程里跑着 React,但它没有权限写文件、弹系统对话框。这些操作必须通过预加载脚本暴露的接口,走 IPC 到主进程完成。这样设计既安全又清晰:渲染进程只关心“拿到什么流”,主进程只关心“把数据存到哪里”。
2. 屏幕捕获:getDisplayMedia 还是 desktopCapturer
2.1 两个 API 的适用场景对比
Electron 里捕获屏幕有两条路:一条是 Chromium 的navigator.mediaDevices.getDisplayMedia(),另一条是 Electron 专属的desktopCapturer。我刚开始也纠结,后来直接列了个对比表:
| 对比点 | getDisplayMedia | desktopCapturer |
|---|---|---|
| 调用位置 | 渲染进程直接调用 | 需要在主进程或 preload 中调用,再通过 IPC 传回 |
| 系统选源 UI | 自动弹出系统级选择器 | 需要自己写选源列表 |
| 适用场景 | 快速实现、原型验证 | 需要完全自定义选源界面时 |
| 复杂度 | 低 | 中高 |
| 维护成本 | 随 Chromium 版本演进,基本稳定 | 需要关注 Electron 升级带来的 API 变化 |
对这个项目来说,getDisplayMedia最合适。它自带系统选源框,Mac 用户看到的是熟悉的系统界面,不需要我再维护一个源列表组件。唯一的注意点是:这个 API 必须在安全上下文里用,Electron 的file://加载方式在较新版本里需要注意配置,不过默认配置下实测没问题。
2.2 获取屏幕视频流的实际操作
屏幕视频流的获取代码很简单,但有几个约束参数值得抠一下:
const getScreenStream = async () => { const stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 30, max: 60 }, displaySurface: 'monitor', }, audio: false, }); return stream; };几个关键点:
frameRate我用ideal: 30, max: 60。默认帧率有时候会掉到 15 以下,录出来的视频明显不流畅。设成 30 ideal 能让系统尽量保证 30 帧,max 60 给高端显示器留余量。如果你的电脑性能一般,建议直接把 max 设为 30,省电也省 CPU。displaySurface: 'monitor'表示录制整个显示器。如果你只想录某个窗口,可以设'window',但窗口最小化或遮挡时会录到其他内容,这里按项目需求选了 monitor。audio: false是因为 Electron 基于 Chromium 实现,桌面系统的“系统内录音频”支持受限。这个取舍后面第 5 节专门讲。
用户第一次点开始录制时,系统会弹出屏幕录制权限申请。这一步没法在代码里跳过,只能在应用里做好引导文案。如果用户拒绝授权,getDisplayMedia会抛NotAllowedError,捕获到之后要给出明确的提示,别让用户对着黑屏发呆。
2.3 Mac 权限处理与隐藏坑
Mac 的屏幕录制权限比 Windows 严格得多:不仅应用首次录制要弹窗,而且每次重启之后权限状态可能会变(取决于系统版本)。如果用户点完“拒绝”,后续再想开,需要去“系统设置 → 隐私与安全性 → 屏幕录制”手动开启并重启应用。
我在应用里做了两层防护。第一层是启动时主动探测:用一个非常短的getDisplayMedia调用试一下是否能正常拿到流,拿不到就弹出引导文案。第二层是录制中监听onended事件:比如用户切换显示器分辨率、或系统临时收回权限,流会被系统终止,这时候要自动停止录制并保留已经录到的内容,而不是直接报错丢数据。
3. 画中画:摄像头叠加与自由拖动
3.1 摄像头流的获取与预览
画中画本质上是把摄像头画面当作一层 UI 叠加在录制的屏幕上。先通过getUserMedia拿摄像头流:
const getCameraStream = async () => { return navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 640 }, height: { ideal: 360 }, facingMode: 'user', }, audio: false, }); };摄像头画面我建议用 640x360,因为画中画在屏幕上通常只占一个小角落,给 1080p 纯属浪费带宽和 CPU。真正需要 1080p 摄像头的人毕竟是少数,等以后加设置项再放开。
预览方式是在 React 组件里放一个<video>标签,把摄像头流塞进去:
const videoRef = useRef(null); useEffect(() => { if (videoRef.current && cameraStream) { videoRef.current.srcObject = cameraStream; videoRef.current.play(); } }, [cameraStream]);3.2 画中画布局与拖拽实现细节
画中画的样式核心是四件事:固定位置、固定宽高、圆角、阴影。
.pip-container { position: fixed; right: 24px; bottom: 24px; width: 240px; aspect-ratio: 16 / 9; border-radius: 12px; overflow: hidden; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3); cursor: move; z-index: 99; }如果你希望用户能拖动画中画,常见的思路是监听pointerdown、pointermove、pointerup,在移动中更新坐标。这里有个陷阱:如果用mousemove监听document,录制过程中鼠标事件会被视频流抢占,导致拖动失灵。推荐用pointer事件,并记录起始偏移量:
const onPointerDown = (e) => { const startX = e.clientX; const startY = e.clientY; const startLeft = pipRect.left; const startTop = pipRect.top; const onPointerMove = (moveEvent) => { setPipRect({ left: startLeft + moveEvent.clientX - startX, top: startTop + moveEvent.clientY - startY, }); }; const onPointerUp = () => { document.removeEventListener('pointermove', onPointerMove); document.removeEventListener('pointerup', onPointerUp); }; document.addEventListener('pointermove', onPointerMove); document.addEventListener('pointerup', onPointerUp); };注意:直接对pipRect的状态做高频更新会触发 React 频繁重渲染,240 毫秒一次没问题,但拖动时是每帧一次,建议用requestAnimationFrame包一层,或者干脆把拖拽逻辑放到一个独立的 DOM 元素上用原生transform处理,React 只负责最终落定值。
3.3 录制时怎么把画中画“固化”进去
这一点特别值得说清楚:我用的方案是把画中画直接显示在屏幕上,用getDisplayMedia录整个显示器,这样摄像头画面作为屏幕的一部分被自然录进去。这省去了一堆音视频合成的代码,也是最符合直觉的录屏工具行为——你看屏幕上有什么,录出来就是什么。
如果选了这种方案,请务必记得:用户拖动画中画的过程也会被录进去。如果你想录相对“干净”的画面,可以在画中画上加一个“拖动时不实时录制”的标记位,在ondataavailable收集数据时把这几帧踢掉,但那会增加复杂度。小版本的折中方案是:录制过程中把画中画的位置固定住,或者干脆录制前提醒用户“画中画将保持固定位置”。
另外一种进阶思路是把摄像头 track 通过MediaStream.addTrack()合并进屏幕流,交给一个MediaRecorder去统一编码。这样录出来的视频不受 UI 拖拽影响,但实现复杂度和画面合成控制的难度都会上一个台阶。我留给后续迭代。
4. 视频保存:从 Blob 到本地文件的完整链路
4.1 MediaRecorder 录制参数与分片收集
录制的核心对象是MediaRecorder。我封装了一个useRecorderhook,管理“流 → 录制 → 停止 → 生成 Blob”的完整生命周期:
const useRecorder = (stream) => { const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); const startRecording = () => { const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9') ? 'video/webm;codecs=vp9' : 'video/webm'; const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 8_000_000, }); chunksRef.current = []; recorder.ondataavailable = (e) => { if (e.data.size > 0) { chunksRef.current.push(e.data); } }; recorder.onstop = () => { const blob = new Blob(chunksRef.current, { type: mimeType }); handleSave(blob); }; recorder.start(1000); // 每秒触发一次 dataavailable mediaRecorderRef.current = recorder; }; const stopRecording = () => { mediaRecorderRef.current?.stop(); stream.getTracks().forEach((track) => track.stop()); }; return { startRecording, stopRecording }; };几个参数分别说明:
mimeType选择逻辑先检测 VP9,不支持再退到通用 WebM。VP9 在同等码率下画质比 VP8 好,但有些老播放器不支持。videoBitsPerSecond: 8_000_000即 8 Mbps。屏幕录制的内容通常包含大量文字和静态界面,8 Mbps 在 1080p 下够用,如果录动态画面可以调到 12 Mbps。recorder.start(1000)表示每 1000 毫秒触发一次ondataavailable,把数据分片推入数组。这是防止内存飙升的关键,如果你用默认值(0 表示只在停止时一次性抛出),录制超过 10 分钟后内存占用会很可观。
停止录制时一定要做两件事:调recorder.stop(),同时把stream.getTracks()全部stop()。不释放 track 会导致摄像头指示灯一直亮着、屏幕录制图标一直不消失,这在 Mac 上体验很糟糕。
4.2 主进程保存链路
录制完成后,Blob 还在渲染进程里。要把它写进文件,必须走主进程的 IPC:
preload 里暴露一个安全接口:
const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electronAPI', { saveVideo: (buffer, defaultName) => ipcRenderer.invoke('save-video', buffer, defaultName), });主进程收到 Buffer 后弹系统保存对话框:
const { app, dialog, ipcMain, BrowserWindow } = require('electron'); const path = require('path'); const fs = require('fs/promises'); ipcMain.handle('save-video', async (event, buffer, defaultName) => { const { canceled, filePath } = await dialog.showSaveDialog({ defaultPath: path.join(app.getPath('downloads'), defaultName), filters: [{ name: '视频文件', extensions: ['webm'] }], }); if (canceled || !filePath) { return { canceled: true }; } await fs.writeFile(filePath, Buffer.from(buffer)); return { canceled: false, filePath }; });渲染进程侧调用方式:
const blob = new Blob(chunks, { type: mimeType }); const buffer = await blob.arrayBuffer(); const result = await window.electronAPI.saveVideo(buffer, `录屏-${Date.now()}.webm`);这里有几个值得注意的细节:
blob.arrayBuffer()在 Electron 里是异步的,且返回的ArrayBuffer可以直接通过 IPC 结构化克隆传给主进程,不需要手动转Uint8Array。- 默认文件名带上时间戳能避免用户连续录制多段视频时互相覆盖。
dialog.showSaveDialog不要传入BrowserWindow.fromWebContents(event.sender)之外的窗口,防止在安全上下文里被错误调用。实测传错窗口对象会导致对话框弹不出来。
4.3 保存格式与性能优化
MediaRecorder输出现成支持最好的是 WebM。WebM 在 Mac 的 QuickTime 里可能打不开,但 Chrome 和 VLC 都能正常播放。如果想让 QuickTime 也能打开,通用的做法是录制完后再调一次 FFmpeg 转成 MP4(H.264)。不过这个小项目我不建议现在就上 FFmpeg,先把“录出来能保存、能打开”这个闭环跑通,转码作为后续迭代方向更合适。
另一个性能优化点是录制时把videoBitsPerSecond设低一点,能有效降低 CPU 占用和磁盘写入压力。我实测录制 4K 屏幕加 720p 摄像头,8 Mbps 的 WebM 文件在普通 MacBook 上表现还行,但 4K 高码率时 CPU 风扇会起飞,建议录制过程实时显示 CPU 占用,或者至少给用户一个码率选项。
5. 踩坑记录与排查技巧实录
做这个项目的过程中踩了不少坑,有些坑在官方文档里很难找到明确答案,只能靠实测验证。这里挑几个典型的分享:
5.1 常见问题速查表
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 点击录制后屏幕黑屏 | 未授予屏幕录制权限 | 系统设置 → 隐私与安全性 → 屏幕录制 → 打开对应应用权限,重启应用 |
| 摄像头灯一直亮着不灭 | getUserMedia的 track 未释放 | 在停止录制时调stream.getTracks().forEach(track => track.stop()) |
| 录制 10 分钟后内存飙升 | MediaRecorder 未分片 | start(1000)让ondataavailable每 1 秒触发一次 |
| 保存的 WebM 在 QuickTime 打不开 | MediaRecorder 默认编码不是 H.264 | 用 VLC 播放,或接后续 FFmpeg 转 MP4 |
| 画中画拖动卡顿、掉帧 | React 重渲染太频繁 | 用requestAnimationFrame限制更新频率,或原生transform拖动 |
| 录出来没有系统声音 | Chromium 对桌面系统音频捕获有限制 | 当前版本先支持麦克风,系统声音接入 BlackHole 虚拟声卡方案 |
5.2 三个典型排查案例
案例一:权限申请弹窗被吞。首次启动应用时,getDisplayMedia的权限弹窗有时候会一闪而过,用户在弹窗出现前就点了按钮,导致权限状态异常。解决方法是启动时先做一次“权限探测”,用一个隐藏的getDisplayMedia调用提前触发权限流程,完成后再显示主界面。
案例二:摄像头预览画面倒置。Mac 上有些型号的摄像头默认返回的帧方向是镜像的,需要在<video>标签上设置transform: scaleX(-1)。但这也带来一个问题:录制时屏幕显示的画面和实际用户的观感不一致,需要提醒用户“预览已镜像,录制内容将保持正像”。
案例三:保存对话框点了取消,但录制状态已经停止。用户误触停止、又不想保存的场景很常见。我这里实现的逻辑是:停止录制后先弹保存框,取消则丢弃数据但保留录制按钮可用,不会把应用搞成半卡死状态。
5.3 AI 辅助开发的一点体会
标题里写了“AI 辅助”,我也确实用 AI 生成了不少样板代码。我的经验是:让 AI 写“小而纯”的逻辑块很高效,比如状态机封装、MediaRecorder 配置、IPC 桥接这些模式固定的代码;但涉及系统权限、音视频流生命周期、Electron 版本差异的细节,AI 经常给出“看起来很合理但是编出来的 API”。这类逻辑必须自己逐行审查,并且跑一遍真实验证再集成。
我还见过有人让 AI 直接给整个录屏工具生成完整代码,结果 Electron 版本和 Chromium API 对不上,跑起来黑屏、报错一堆。AI 辅助的正确打开方式是把它当“高级搜索 + 代码模板生成器”,而不是“替代思考的编译器”。
6. 几个运行时优化的进一步探索
到这里核心功能已经完整跑通了,再分享几个我实际测试后觉得值得做的优化方向,不需要一次性全做,按优先级来:
- 录制倒计时:用户点击“开始”后 3 秒再正式录制,避免把按钮点击动作和鼠标轨迹录进视频,实测体验提升明显。
- 快捷键停止:录制中需要快速停止时,全局快捷键比切窗口去点按钮可靠得多。Electron 的
globalShortcut注册很简单,把这个快捷键注册成“停止录制并弹出保存框”。 - 低电量/高 CPU 模式下自动降帧率:MacBook 在电池模式下 CPU 占用会比较高,遇到这种情况自动把
frameRate的ideal降为 24,能显著减少风扇噪音和掉帧问题。 - 录制前校验磁盘空间:用
app.getPath('temp')所在分区的剩余空间判断,剩余不足时提前警告用户。MediaRecorder 写满磁盘后行为诡异,与其等它出错,不如先拦一道。
不过我也真心建议:不要急着把上面这些都做完再发布。先用当前最小版本解决自己的录屏需求,跑几周,记录使用不便的地方,再有针对性地迭代。这样你的项目永远是从真实需求出发,而不是一开始就陷入功能扩展的泥潭。
7. 最后再说两句实在话
这个项目的代码量不大,但走了一遍之后,你对 Electron 主进程/渲染进程协作、React 管理媒体流状态、Mac 系统权限机制这几件事的体感会完全不一样。
我个人的体会是:小工具的价值不在“功能多”,而在“链路完整、稳得住”。守着录屏这一个场景,把“选屏-采集-叠加-录制-保存”整条链路打穿,比做十个半成品功能有意义得多。现在这版代码我还在用,偶尔录需求评审、录课堂演示都挺好使。后续我打算试着把系统声音接进来、再加一个简单的视频裁剪面板,但那是下一个项目的故事了。