基于Electron+React的Mac录屏工具:屏幕捕获、画中画与视频保存全实现

发布时间:2026/9/28 8:22:55
基于Electron+React的Mac录屏工具:屏幕捕获、画中画与视频保存全实现 做了个小东西一个运行在 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 还是 desktopCapturer2.1 两个 API 的适用场景对比Electron 里捕获屏幕有两条路一条是 Chromium 的navigator.mediaDevices.getDisplayMedia()另一条是 Electron 专属的desktopCapturer。我刚开始也纠结后来直接列了个对比表对比点getDisplayMediadesktopCapturer调用位置渲染进程直接调用需要在主进程或 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;codecsvp9) ? video/webm;codecsvp9 : 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 还在渲染进程里。要把它写进文件必须走主进程的 IPCpreload 里暴露一个安全接口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 转成 MP4H.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 系统权限机制这几件事的体感会完全不一样。我个人的体会是小工具的价值不在“功能多”而在“链路完整、稳得住”。守着录屏这一个场景把“选屏-采集-叠加-录制-保存”整条链路打穿比做十个半成品功能有意义得多。现在这版代码我还在用偶尔录需求评审、录课堂演示都挺好使。后续我打算试着把系统声音接进来、再加一个简单的视频裁剪面板但那是下一个项目的故事了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询