1. 为什么我会选择用 Electron React 做 Mac 录屏工具先交代一下背景。前阵子团队需要给内部培训课程做演示视频需要录制屏幕操作同时还要把讲师的人像摄像头画面叠加在视频角落也就是常说的画中画效果。市面上现成的录屏软件不少Mac 上也有 QuickTime Player、OBS 这样成熟的选择但我们的需求比较特殊不仅要录屏还要把摄像头画面、麦克风声音、系统声音一起整合进同一个视频并且希望能快捷地保存成可复用的文件。QuickTime 虽然轻量但画中画支持很不顺手还要额外依赖摄像头软件OBS 功能强大但配置偏复杂对只想“录个屏幕加个人像”的场景来说有点杀鸡用牛刀。更关键的是我需要一个能自由定制、能嵌入到团队内部工具链里的录制方案。于是就有了这个念头自己做一个基于 Electron React 的 Mac 录屏小工具。Electron 的桌面应用能力加上 React 的前端组件化开发方式能让我用 Web 技术栈快速构建跨平台的桌面工具而且 Chromium 底层对getUserMedia、getDisplayMedia这类媒体 API 支持得非常好做录屏这件事天然顺手。我定下的核心目标是最小可用版本能实现三件事——录屏、画中画、视频保存。整个项目从零开始一步步搭起来这里把完整的过程记录下来。适合谁看呢如果你用过 Electron会写基础的 React 组件想了解桌面端如何调用系统摄像头、如何获取屏幕画面、如何用 MediaRecorder 合成音视频那这篇内容就是为你准备的。哪怕你是个前端新手只要能读懂 JavaScript 基础语法跟着文章一步步来也能把这个工具跑起来。2. 整体方案设计与技术选型2.1 先拆需求别急着写代码做工具类项目最忌讳的就是上来就写代码。我用一个下午的时间把需求理成了三大块录屏捕获整个屏幕或者指定窗口的画面分辨率、帧率可调这是录屏工具的基本盘。画中画同时打开摄像头把摄像头视频流叠加到屏幕画面之上一般放在右下角可以调整大小和位置。视频保存把两条视频流和音频流合成一个视频文件保存到 Mac 本地最好还能选择保存格式。围绕这三个块我列了一下技术方案功能模块技术选型选择理由桌面框架Electron跨平台、生态成熟能直接复用 Web 技术栈UI 框架React组件化开发状态管理清晰社区方案多屏幕捕获navigator.mediaDevices.getDisplayMedia()Chromium 原生 API无需额外权限插件摄像头捕获navigator.mediaDevices.getUserMedia()同样原生支持同时拿到音视频流视频编码MediaRecorder 浏览器 WebM 编码不用自己写编码器开箱即用文件保存Electron 的 dialog fs 模块原生文件对话框保存路径可控当时我也有过犹豫要不要直接用 ffmpeg 做视频合成毕竟 MediaRecorder 直接录出来的 WebM 在部分播放器里兼容性一般而且画中画合成需要后期处理。但考虑到最小可用版本要控制复杂度先用 MediaRecorder 录制原始流后续再考虑接入 ffmpeg 做转码和合成这个思路留到后面章节细讲。2.2 Electron 主进程与渲染进程的分工Electron 应用分成主进程和渲染进程这个架构决定了录屏工具的整体设计。主进程跑 Node.js 环境可以操作文件系统、弹窗、创建窗口渲染进程跑的是 Chromium负责页面展示和交互逻辑。两者之间通过 IPCInter-Process Communication通信。录屏这件事技术上讲是渲染进程调用浏览器的媒体 API 完成的因为getDisplayMedia和getUserMedia都是 Chromium 的标准 API只能在渲染进程里用。但保存文件需要主进程配合因为渲染进程没有完整的 Node.js 文件系统权限。所以我的分工是这样的渲染进程负责调起摄像头和屏幕捕获、组合视频流、控制 MediaRecorder 的开始和停止、预览画面。主进程负责接收渲染进程传来的视频数据弹出保存对话框把 Blob 写入本地文件。这种分工很清晰也符合 Electron 设计哲学。你可以把主进程理解成“管家”渲染进程理解成“前台”前台收集好一切素材交给管家归档存放。2.3 为什么画中画用视频流叠加而不是后期合成这是我在设计过程中考虑得最多的问题。画中画有两种实现思路第一种是用 canvas 实时合成把屏幕视频流画到 canvas 背景层再把摄像头视频流画到右上角或右下角的小矩形里最后用canvas.captureStream()把合成结果作为最终录制源传给 MediaRecorder。第二种是分别录制屏幕流和摄像头流然后靠后期工具合成。我选了第一种原因是它更符合“所见即所得”的原则——用户在界面上实时预览到的画面就是最终录制文件里的画面。如果分开录制再后期合成预览和结果就对不上了还会引入音画同步的复杂问题。canvas 合成的具体做法是创建一个 canvas 元素宽高对应屏幕流的尺寸把屏幕流作为底层画上去再把摄像头流画到角落位置最后用 canvas 的captureStream()方法拿到合成后的视频流传给 MediaRecorder。这个方案完全在浏览器内部解决不依赖 ffmpeg省去了很多麻烦。3. 环境准备与项目初始化3.1 搭建 Electron React 开发环境在 Mac 上搭建这套环境主要依赖 Node.js 和 npm。我个人建议用 nvm 管理 Node 版本开发中遇到过 Electron 和 Node 版本不匹配导致的诡异问题用 nvm 可以随时切换版本排查。安装好 Node.js 之后我用 Vite 作为前端构建工具来初始化 React 项目因为它启动速度快、配置简单配合 Electron 开发体验比 webpack 舒服得多。具体步骤# 使用 Vite 创建 React 项目 npm create vitelatest screen-recorder -- --template react # 进入项目目录 cd screen-recorder # 安装依赖 npm install # 安装 Electron 开发依赖 npm install --save-dev electron concurrently wait-on这里装了concurrently和wait-on是用来同时启动 Vite 开发服务器和 Electron 的辅助工具。开发时 Vite 跑在localhost:5173Electron 加载这个地址代码改动后 Vite 热更新Electron 这边也能同步刷新。然后打开package.json加入两个启动脚本{ scripts: { dev: concurrently \npm run dev:vite\ \npm run dev:electron\, dev:vite: vite, dev:electron: wait-on tcp:5173 electron . } }主进程入口文件electron/main.js负责创建窗口并加载 Vite 页面const { app, BrowserWindow } require(electron); const path require(path); function createWindow() { const win new BrowserWindow({ width: 1200, height: 800, title: Screen Recorder, webPreferences: { preload: path.join(__dirname, preload.js), contextIsolation: true, nodeIntegration: false, }, }); win.loadURL(http://localhost:5173); } app.whenReady().then(() { createWindow(); app.on(activate, () { if (BrowserWindow.getAllWindows().length 0) createWindow(); }); }); app.on(window-all-closed, () { if (process.platform ! darwin) app.quit(); });注意一个细节contextIsolation保持truenodeIntegration设为false这是 Electron 的安全默认配置。渲染进程不能直接访问 Node.js API必须通过 preload 脚本桥接这样能避免很多安全隐患。3.2 处理 Mac 上的摄像头和屏幕录制权限这一步是我踩坑最多的地方。macOS 对摄像头和屏幕录制有严格的隐私权限控制首次调用getUserMedia或getDisplayMedia时系统会弹出授权提示。如果用户点了拒绝或者之前不小心在“系统设置 - 隐私与安全性”里关掉了权限那么录制出来的画面就是黑屏或者没有视频流。这里有一个特别容易忽略的问题Electron 应用在开发模式下和打包后的应用标识不同权限记录也是分开的。开发模式下用electron .启动系统可能记为“Electron”这个应用打包后则是你的应用名。所以经常出现开发时一切正常打包后摄像头黑屏因为系统权限没授权给打包后的应用。解决方法是在“系统设置 - 隐私与安全性 - 摄像头”和“屏幕录制”里手动把应用名加进去并打开开关。如果列表里看不到可以用“”号添加应用的.app文件。开发模式下如果遇到问题优先确认终端用的哪个应用名然后在屏幕录制权限里手动授权。3.3 preload 脚本与 IPC 桥接因为渲染进程不能直接用 Node.js 文件系统我写了一个 preload 脚本来暴露保存文件的能力。preload 脚本运行在隔离环境下可以通过contextBridge安全地暴露接口给渲染进程const { contextBridge, ipcRenderer } require(electron); contextBridge.exposeInMainWorld(electronAPI, { saveFile: async (filename, data) { const result await ipcRenderer.invoke(save-file, { filename, data }); return result; }, });主进程里対応する处理逻辑const { ipcMain, dialog } require(electron); const fs require(fs); const { writeFile } require(fs/promises); ipcMain.handle(save-file, async (event, { filename, data }) { const { canceled, filePath } await dialog.showSaveDialog({ defaultPath: filename, filters: [ { name: Video, extensions: [webm] }, ], }); if (canceled || !filePath) { return { canceled: true }; } // 将 Base64 数据转成 Buffer 后写入文件 const buffer Buffer.from(data, base64); await writeFile(filePath, buffer); return { canceled: false, filePath }; });注意这里我用的是 Base64 传递视频数据。Blob 数据本身跨越 IPC 边界时Electron 的序列化机制可以直接传Buffer但如果用ipcRenderer.invoke传大体积二进制数据速度会受影响。实际测试下来录制几分钟的视频文件可能有几十 MBBase64 编码会把体积扩大约 33%所以在 preload 里直接用Uint8Array或ArrayBuffer是更好的选择。上面代码为了简洁用了 Base64你可以在自己的实现里改成直接传ArrayBuffer配合主进程的Buffer.from(arrayBuffer)写入性能更好。4. 核心模块实现录屏、画中画、视频保存4.1 屏幕捕获与 MediaRecorder 初始化React 组件里我创建一个Recorder类来统一管理录制逻辑避免把所有代码塞进组件里导致混乱。核心代码如下class Recorder { constructor() { this.screenStream null; this.cameraStream null; this.mediaRecorder null; this.chunks []; this.combinedStream null; } async startScreenCapture() { this.screenStream await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 30, max: 60 }, width: { ideal: 1920 }, height: { ideal: 1080 }, }, audio: true, }); return this.screenStream; } async startCameraCapture() { this.cameraStream await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: user, }, audio: { echoCancellation: true, noiseSuppression: true, }, }); return this.cameraStream; } startRecording(stream) { this.chunks []; this.mediaRecorder new MediaRecorder(stream, { mimeType: video/webm;codecsvp9,opus, videoBitsPerSecond: 2_500_000, }); this.mediaRecorder.ondataavailable (e) { if (e.data e.data.size 0) { this.chunks.push(e.data); } }; this.mediaRecorder.onstop () { const blob new Blob(this.chunks, { type: video/webm }); this.onStop(blob); }; this.mediaRecorder.start(1000); } stopRecording() { if (this.mediaRecorder this.mediaRecorder.state recording) { this.mediaRecorder.stop(); } this.screenStream?.getTracks().forEach((t) t.stop()); this.cameraStream?.getTracks().forEach((t) t.stop()); } }几个关键参数说明一下MediaRecorder.start(1000)表示每 1000 毫秒触发一次dataavailable事件收集数据块。这个时间间隔影响录制的容错性如果间隔太长程序中途崩溃会丢失多出几秒的数据太短则产生大量小片段拼接时开销大。1000 毫秒是平衡方案。videoBitsPerSecond: 2_500_000表示视频目标码率约 2.5 Mbps在 1080P 下画面质量基本够用文件体积也不会爆炸。如果你录的是代码编辑器这类静态画面可以降到 1 Mbps文件会更小如果是游戏或者视频播放建议上调到 4~6 Mbps。4.2 画中画叠加的 canvas 合成流程画中画的核心是 canvas 合成。我把屏幕流和摄像头流都绘制到一个 canvas 上再通过canvas.captureStream()把绘制结果变成新的视频流继续录制。这里举个具体的实现片段function setupPipCanvas(screenStream, cameraStream) { const video document.createElement(video); video.srcObject screenStream; video.autoplay true; await video.play(); const cameraVideo document.createElement(video); cameraVideo.srcObject cameraStream; cameraVideo.autoplay true; await cameraVideo.play(); const canvas document.createElement(canvas); canvas.width video.videoWidth; canvas.height video.videoHeight; const ctx canvas.getContext(2d); const pipWidth 240; const pipHeight 180; const margin 30; function drawFrame() { ctx.drawImage(video, 0, 0, canvas.width, canvas.height); // 画中画位置右下角 const pipX canvas.width - pipWidth - margin; const pipY canvas.height - pipHeight - margin; // 绘制半透明边框让画中画更醒目 ctx.fillStyle rgba(255, 255, 255, 0.2); ctx.fillRect(pipX - 2, pipY - 2, pipWidth 4, pipHeight 4); ctx.drawImage(cameraVideo, pipX, pipY, pipWidth, pipHeight); requestAnimationFrame(drawFrame); } drawFrame(); const combinedStream canvas.captureStream(30); return combinedStream; }这个drawFrame函数通过requestAnimationFrame持续绘制。注意一个小问题canvas 的captureStream(30)里的 30 指的是帧率但实际上requestAnimationFrame在 Mac 上默认是 60fps如果你用 60fps 的循环去驱动一个 30fps 的流多余的帧会被浏览器丢弃或合并。这样反而容易造成视频卡顿感。更稳妥的做法是在一个requestAnimationFrame回调里用时间戳控制每两帧画一次或者直接将captureStream(60)然后让 MediaRecorder 以 30fps 选择。我在实际测试中发现用requestAnimationFrame每帧绘制 captureStream(30)的组合偶尔会出现画面跳帧。后来改用固定间隔setInterval(draw, 1000 / 30)绘制反而更稳定。不过setInterval可能会在系统休眠时堆积回调最好还是用时间戳判断当前帧是否应该绘制。还有一个常见问题是如果摄像头方向是前置视频画面可能是镜像的。如果需要镜像显示要在绘制前做翻转处理ctx.save(); ctx.translate(pipX pipWidth, pipY); ctx.scale(-1, 1); ctx.drawImage(cameraVideo, 0, 0, pipWidth, pipHeight); ctx.restore();4.3 音频流的合成细节音频部分比视频更细节。屏幕流getDisplayMedia({ audio: true })能捕获到系统声音但这里有个 Mac 特有的坑macOS 的系统音频捕获在 Electron 里默认并不总是可用部分系统版本下getDisplayMedia的音频轨道会静音或缺失。我实际测试了 macOS Ventura 和 SonomaSonoma 下系统音频捕获基本正常Ventura 则要看权限设置。如果你确实需要系统声音建议多加一层判断获取屏幕流的音频轨道后检查track.getSettings()里是否有autoGainControl、noiseSuppression等属性或者直接监听track.onmute事件一旦系统音频静音就提示用户。如果只需要麦克风声音摄像头接口getUserMedia({ audio: true })就已经包含了。把麦克风音频和屏幕视频流合到一起录制时MediaRecorder 会自动混合多条音轨吗这里要特别注意MediaRecorder 虽然接受含有多个音轨的 MediaStream但实际录制时只会选择其中一条音轨进行编码。所以正确的做法是用AudioContext把所有音轨都合并成一条轨道destination再把这个合成音频轨道添加进最终录制流。具体实现是这样的function mergeAudioTracks(streams) { const audioContext new AudioContext(); const destination audioContext.createMediaStreamDestination(); streams.forEach((stream) { stream.getAudioTracks().forEach((track) { const source audioContext.createMediaStreamSource(stream); source.connect(destination); }); }); return destination.stream.getAudioTracks()[0]; }这里用AudioContext把所有音频流汇总到destination一个合成的 MediaStreamAudioDestinationNode然后取出其中的音轨。把这条音轨addTrack到录制流中MediaRecorder 就能录制完整的混合声音了。AudioContext的采样率默认是 48000和系统音频一致不用额外处理。4.4 保存视频文件到本地录完视频后拿到的 Blob 需要转成文件保存。在主进程里用dialog.showSaveDialog弹出保存对话框用户能选择保存位置和文件名。这里有个体验优化的点默认文件名可以带上时间戳比如recording-20250623-153012.webm避免用户每次手动改名。我在主进程的保存逻辑里加了一重校验文件存在时弹复盖确认。Electron 的showSaveDialog自带这个确认不需要额外实现。但要注意 Mac 上的默认行为弹出保存对话框时如果用户直接点回车文件名是默认名不会自动加上.webm扩展名。我处理的方式是在渲染进程把文件名完整拼好再传过来。另外大文件写入时用writeFile比writeFileSync更合适不会阻塞主进程的 UI 线程。录制 30 分钟的 1080P 视频大约 400MB写入耗时可能几秒到十几秒期间如果阻塞了主进程整个应用看起来就像卡死了一样。5. 实操过程从界面搭建到完整录制流程5.1 React 组件结构设计整个前端界面我分成了几个核心组件避免单文件过重App.jsx顶层容器管理录制状态闲置中、录制中、已停止。VideoPreview.jsx展示 Canvas 合成画面的预览区域。ControlBar.jsx包含开始、停止、暂停按钮和画中画开关。SettingsPanel.jsx设置分辨率、帧率、保存路径等参数。App 组件里的状态管理用 React 自带的useState和useRef就够了暂时不需要引入 Redux 或 Zustand 这类重量级状态库。录制相关的媒体流对象不适合放在 React state 里因为它们是二进制流放 state 里会造成组件频繁渲染性能很差。正确做法是用useRef持有媒体流、MediaRecorder 等对象state 只保存 UI 展示需要的枚举值比如status字段。5.2 开始录制函数完整流程我用一个startRecording函数串联整个流程这里放核心伪代码async function startRecording() { setIsStarting(true); try { const screenStream await recorder.startScreenCapture(); const cameraStream pipEnabled ? await recorder.startCameraCapture() : null; let outputStream screenStream; if (cameraStream) { applyPipSettings(cameraStream); outputStream setupPipCanvas(screenStream, cameraStream); } // 合并音频轨道 if (cameraStream) { const audioTrack mergeAudioTracks([screenStream, cameraStream]); outputStream.addTrack(audioTrack); } // 启动录制 recorder.startRecording(outputStream); setStatus(recording); } catch (err) { console.error(Failed to start recording:, err); setError(err.message); } finally { setIsStarting(false); } }这个流程里有几个容易出错的地方限制每条getDisplayMedia的防误用逻辑。Chrome 和 Electron 在页面没有用户手势触发的情况下不允许直接调用getDisplayMedia。所以开始录制按钮的点击事件是最佳触发点。如果你的代码在useEffect里自动启动录制大概率会被浏览器拦截。这一点一定要记住。限制第二条如果用户在弹出的屏幕选择器里点了取消getDisplayMedia会抛异常代码进入catch块。此时不能残留半初始化状态。我在catch块里清理已经拿到的摄像头流和屏幕流防止后续再点开始录制时出现重复获取权限的问题。5.3 停止录制与资源释放停止录制不只是调用mediaRecorder.stop()还要做好流资源清理。很多新人容易漏掉这一步导致摄像头指示灯一直亮着、屏幕选择器的共享状态持续存在。我在实现中坚持“谁创建谁负责释放”的原则function stopRecording() { if (recorder.mediaRecorder recorder.mediaRecorder.state ! inactive) { recorder.mediaRecorder.stop(); } if (recorder.screenStream) { recorder.screenStream.getTracks().forEach((track) track.stop()); } if (recorder.cameraStream) { recorder.cameraStream.getTracks().forEach((track) track.stop()); } if (pipCanvasRef.current) { pipCanvasRef.current.getContext(2d).clearRect(0, 0, pipCanvasRef.current.width, pipCanvasRef.current.height); } }getTracks()返回的是所有轨道stop()会终止轨道的采集摄像头物理指示灯通常会随之熄灭。如果这里不手动 stop应用退出后摄像头可能依然保持占用状态这是一个非常隐蔽的 bug。另外MediaRecorder 的onstop回调里要处理 Blob。因为stop触发后还会产出最后一个数据块所以编码逻辑是onstop里new Blob(chunks)然后转成可传输的对象交给主进程保存。5.4 界面预览与画中画控制预览区我用video元素播放 canvas 捕获流。这里要注意video元素需要设置muted属性否则摄像头采集到的麦克风声音会通过扬声器外放形成刺耳的啸叫。屏幕预览里的声音实际上应该直接听还是静音我建议预览video静音因为真正的音频会编码进视频文件用户之后播放视频文件就能听到。直播场景另说录制场景静音预览是合理的。画中画开关是一个switch控件。打开时立即启动摄像头预览关闭时停止摄像头并释放资源同时 canvas 重新绘制成只有屏幕画面的模式。这个小功能对易用性提升很大如果你录的是纯演示操作不需要真人出镜直接关掉画中画就行。6. 常见问题与排查技巧实录6.1 屏幕流黑屏或只有壁纸这个问题的典型原因是 macOS 屏幕录制权限未授权。排查步骤是首先确认应用是不是在“系统设置 - 隐私与安全性 - 屏幕录制”列表里。然后用活动监视器查看应用进程名和列表里的名字对一下。开发模式下 Electron 的名字可能是 “Electron”而打包后是你的应用名。还需要注意修改权限后必须完全退出应用并重新启动权限才会生效。有时你以为已经重启了其实 Dock 栏里的进程还在需要先强制退出再重新打开。还有一种黑屏是 canvas 绘制时机太早导致没有画面。比如video.play()还没成功就画了第一帧canvas 里就是空白的。解决方法在video.onloadedmetadata回调之后再设置 canvas 宽高并开始绘制。6.2 录制出来的视频没有声音最常见的原因是合并音频轨道时没正确处理。如果你直接把摄像头流的音频轨道和屏幕视频流addTrack到同一个 MediaStream然后传给 MediaRecorderMac 上经常只有一条音轨被编码。请检查mergeAudioTracks是否为最终流添加了一条独立的合成音轨而不要依赖多条原始音轨。第二种原因是麦克风权限没打开getUserMedia返回的视频流里根本没有音轨。可以在获取流之后打印stream.getAudioTracks().length来验证如果为 0优先检查 Mac 隐私权限。第三种原因是系统音频捕获失败。Electron 版本和 macOS 版本的组合有时会导致getDisplayMedia里的audio: true没有生效。用track.getSettings()检查音频轨道是否存在必要时改用麦克风作为音频来源或者通过降级方案处理。6.3 视频文件损坏无法播放Electron MediaRecorder 直接输出的是 WebM 容器。虽然主流浏览器都能播放但一些播放器或者视频剪辑软件例如 Final Cut Pro 的部分版本不认 WebM。我试过 Mac 自带 QuickTime老版本不支持打开 WebM但新版部分可以。VLC 播放器则完全没问题。如果必须输出 MP4最简单的方案是引入 ffmpeg 做转封装。Electron 里可以通过ffmpeg-static这个 npm 包拿到静态 ffmpeg 可执行文件然后用child_process.execFile调用它做转码ffmpeg -i input.webm -c:v copy -c:a aac output.mp4这里-c:v copy指视频流不做重编码只做封装转换速度非常快。但要注意如果原视频流编码不是 H.264MP4 封装里可能无法直接兼容。所以更稳妥的方案是ffmpeg -i input.webm -c:v libx264 -crf 23 -preset fast -c:a aac output.mp4代价是转码耗时较长。我的建议是小项目阶段默认保存 WebM在设置面板里提供一个“转为 MP4”选项用户需要时再手动操作。6.4 录制中感觉卡顿掉帧严重这种问题通常是三个原因叠加造成的逐一排查一是 canvas 绘制太频繁。requestAnimationFrame每帧都执行drawImage在 4K 分辨率下非常耗 CPU。最优做法是只绘制需要的关键帧或者降低 canvas 尺寸。如果屏幕流是 4K你可以把 canvas 宽度降为 1920而不是直接按屏幕原始分辨率绘制这样能显著减少 GPU 开销。二是 MediaRecorder 的码率设置过高。4K 下设置 8 Mbps 以上码率对 CPU 编码器来说压力很大。可以用 V8 引擎的MediaRecorder参数调低码率或者把分辨率降档。三是没有及时释放旧的流。录制过程中如果多次重启录制旧的屏幕流和摄像头流残留在内存里会占用大量带宽和 CPU。每次 start/stop 之间都做全量清理可以缓解。6.5 Electron 打包后功能失效开发模式一切正常打包后就黑屏或者麦克风没声音这种情况十有八九是权限问题也可能是打包配置问题。我用的打包工具是 electron-builder主进程入口、preload 路径、静态资源路径都要检查。electron-builder 的打包配置里有一个常见坑如果 preload 脚本路径写的是path.join(__dirname, preload.js)在 asar 打包模式下这个文件会被放进 asar 包导致 preload 加载失败。解决办法是在打包配置里设置asarUnpack: [**/preload.js]或者把 preload 脚本放在 extraResources 目录。另外打包后的应用名可能和开发时不同macOS 的权限记录按应用标识符区分所以首次运行打包应用时需要重新授权摄像头和屏幕录制权限。7. 项目扩展思路与小技巧项目做到这里一个能用的录屏工具有了。后续想继续完善可以从几个方向扩展。第一是添加暂停与恢复功能。MediaRecorder 原生支持pause()和resume()暂停期间不会产生数据块恢复后继续写入同一个文件。这对录屏工具来说非常实用录制过程中如果要临时处理消息不用频繁启停文件。第二是定时录制与自动停止。用setTimeout控制录制时长适合录固定时长的培训视频。还可以在录制开始时显示倒计时提醒。第三是把画中画位置做成可拖拽。在 canvas 上监听鼠标拖动事件实时更新pipX、pipY坐标就能让用户在录制前自定义摄像头窗口位置。这个体验优化很受欢迎我准备在下个版本实现。第四是截图功能。录屏过程中按快捷键截取当前帧保存为 PNG。实现起来也不难canvas 本身就有toDataURL(image/png)方法把数据传给主进程保存即可。第五是接入系统菜单和快捷键。用 Electron 的globalShortcut注册全局快捷键比如CommandOrControlShiftR开始录制CommandOrControlShiftS停止录制。这个功能需要注册到主进程因为全局快捷键不依赖焦点窗口。最后说一下我个人的使用体会。用 Electron React 做录屏工具最大的收益不是“省了买软件的钱”而是可以对录制流程做完全定制——比如录完视频后自动上传到团队共享目录或者自动把文件名按课程编号规范化这在通用录屏软件里很难实现。如果你只是需要录个屏OBS 会更香但如果你想打造一个融进自己工作流的小工具Electron 这条路非常值得走。整个项目从零搭到能录出第一段完整视频正常节奏大概需要两到三个晚上。核心代码量不大难理解的地方其实只有 canvas 合成和音频合并这两块。一旦把这两块吃透你完全可以基于这套骨架扩展出自己的录屏工具、直播辅助工具甚至会议录制工具。希望这篇记录能帮你少走一些弯路。