Bun 制作 IDE 的第一个坑:Electrobun 模拟缩放和关闭,没想到这么难

记录踩坑解决, ⭐收藏起来, 遇到问题把他丢给AI

上篇我说想用 Bun + Electrobun 做一个 AI IDE(BunCode)。那篇偏选型与架构,没怎么写界面细节。

这篇讲一个看似简单、实际把我卡了好几轮的问题:

我想做一个自定义标题栏 ------自己画的关闭 / 最小化 / 最大化按钮,点击时要有 Windows 原生的 DWM 动画,并且点击任务栏图标能在「最小化 ↔ 还原」之间来回切换

听起来就是三个按钮 + 几个事件嘛。结果踩了 4 个坑,最后一个坑(任务栏点击切换)连 ElectroBun 的官方 issue 都挂在那(#395),上游没修。

下面按踩坑顺序讲,最后给一份能跑的完整方案。


坑 1 hidden 会丢掉 DWM 系统动画

ElectroBun 创建窗口时有个 titleBarStyle 参数。我一开始选了 hidden(无边框),因为我想完全自己画标题栏。

结果一测试,问题来了:点击最小化 / 关闭时,窗口是"啪"地瞬间消失,没有 Windows 那种缩小进任务栏 / 淡出的动画。

查了一圈才明白根因:Windows 的标题栏动画(DWM 的 WM_NC* 相关的过渡)依赖窗口带 WS_CAPTION 样式位。titleBarStyle: "hidden" 在 Windows 上会把这个样式位去掉,于是 DWM 认为"这不是个有标题栏的窗口",动画直接不应用。

结论先记下来:

titleBarStyle: "hiddenInset" 必须保留 ------ 它在 Windows 上保留 WS_CAPTION,DWM 的最小化 / 关闭 / 最大化动画才正常。退回 hidden 会丢动画。


坑 2 hiddenInset 会露出原生标题栏盖住自定义标题栏

既然 hidden 丢动画,那就改回 hiddenInset 呗?

一试,动画回来了,但新的问题:Windows 上 hiddenInset会显示一条原生标题栏,正好盖在我自己画的自定义 TitleBar 上面。两层标题栏叠在一起,丑到没法看。

这里的矛盾是:

  • • 想要系统动画 → 必须保留 WS_CAPTION → 但 hiddenInset 又把它画出来了,盖住自定义栏

  • • 想要纯自定义 → 去掉标题栏 → 又丢动画

(第一篇里我把这个记为"待解决",当时以为这是死局,只能放弃系统动画用 CSS 模拟。后来才找到下面这套解法。)


坑 3 拖拽千万别用 JS 模拟 setFrame

自定义标题栏做出来了,下一步是拖拽移动窗口

我第一反应是:监听鼠标事件,mousemove 里调 mainWindow.setFrame(x, y, w, h) 来改位置。结果一拖,窗口直接变成 0×0,表现得像被最小化了

根因:setFrame 在拖拽这种高频调用下,某些边界会把窗口尺寸算成 0。ElectroBun 提供了原生拖拽类electrobun-webkit-app-region-drag),应该直接用这个,而不是自己算坐标。

前端只要给标题栏根元素加一个类,里面的按钮区加 no-drag 就行:

bash 复制代码
<Flex className="electrobun-webkit-app-region-drag"
      onDoubleClick={() => rpc.request.windowMaximize()}>
  <div style={{ flex: 1 }} />
  <Flex className="electrobun-webkit-app-region-no-drag">
    <Button type="text" icon={<Minus/>}  onClick={() => rpc.request.windowMinimize()} />
    <Button type="text" icon={<Square/>} onClick={() => rpc.request.windowMaximize()} />
    <Button type="text" icon={<X/>}      onClick={() => rpc.request.windowClose()} />
  </Flex>
</Flex>

styles/index.css 里补一个兼容 shim:

bash 复制代码
.electrobun-webkit-app-region-no-drag { -webkit-app-region: no-drag; }

双击标题栏 windowMaximize() 是兜底------因为 ElectroBun 没启用 NonClientRegion,原生"双击标题栏最大化"缺失,得靠 RPC 自己补。


坑 4 核心坑 任务栏点击切换点自己图标怎么都不缩小

前面三个坑都好绕。第 4 个坑,才是真正让我怀疑人生的。

4.1 需求

标准 Windows 行为:

  • • 窗口在前台 → 点任务栏自己的图标 → 最小化

  • • 窗口已最小化 → 点任务栏自己的图标 → 还原

这两个状态来回切换。我之前已经有"点击别的程序图标会触发我们最小化"的能力(带 DWM 动画,正常)。但点自己图标就是不动

4.2 排查 缩小的信号从哪来

我一开始在后端挂了三套检测:

测下来发现诡异现象:

  • 点别的程序图标 → 我们失去前台 → blur / 轮询检测到 → ShowWindow(SW_MINIMIZE) → 最小化(带动画)✅

  • 点自己任务栏图标 → 窗口仍是前台blur 不触发,hook 也等不到 SC_MINIMIZE → 毫无反应 ❌

4.3 真凶 ElectroBun 不转发 SC_MINIMIZE

查到 ElectroBun 的 issue #395:在使用 hiddenInset 这种自定义标题栏时,框架不把 WM_SYSCOMMAND / SC_MINIMIZE 转发给 WndProc

所以:

  • • hook 永远等不到 SC_MINIMIZE → 不触发 ShowWindow

  • • 点自己图标时任务栏自己不抢焦点 → 窗口仍是前台 → blur 也不触发

两条路全断。"恢复能用"是因为 SC_RESTORE 真实可达 + 原生 OS 对最小化窗口的任务栏点击本来就会还原,所以那条方向幸存。结果是:恢复半边工作,缩小半边静默失效。

我当时还专门验证了一下:窗口前台时,点别的程序图标会带动画最小化------这证明 blur / 轮询这套检测本身是好的,唯一缺口就是"点自己图标"这一个场景

4.4 解法 用 FFI 给窗口补样式位让系统原生处理

既然应用层抓不到信号,那就别让应用层处理------让 Windows 自己处理。

WS_MINIMIZEBOXWS_MAXIMIZEBOX 这两个样式位,决定了"点任务栏图标时系统是否原生切换最小化 / 最大化"。ElectroBun 的窗口默认没给齐。我们用 FFI 直接补上:

bash 复制代码
const user32 = dlopen("user32.dll", {
  GetWindowLongPtrW: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i64 },
  SetWindowLongPtrW: { args: [FFIType.ptr, FFIType.i32, FFIType.i64], returns: FFIType.i64 },
  SetWindowPos: { args: [FFIType.ptr, FFIType.ptr, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.u32], returns: FFIType.bool },
  FindWindowW: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.ptr },
});

const GWL_STYLE = -16;
const WS_MINIMIZEBOX = 0x00020000;
const WS_MAXIMIZEBOX = 0x00010000;
const SWP_FRAMECHANGED = 0x0020, SWP_NOMOVE = 0x0002, SWP_NOSIZE = 0x0001, SWP_NOZORDER = 0x0004, SWP_NOACTIVATE = 0x0010;

function applyTaskbarStyle(hwnd: Pointer) {
  const style = Number(user32.symbols.GetWindowLongPtrW(hwnd, GWL_STYLE));
  const want = style | WS_MINIMIZEBOX | WS_MAXIMIZEBOX;
  if (want === style) return;
  user32.symbols.SetWindowLongPtrW(hwnd, GWL_STYLE, want);
  user32.symbols.SetWindowPos(
    hwnd, null, 0, 0, 0, 0,
    SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED | SWP_NOACTIVATE,
  );
  logger.info(`taskbar style applied: 0x${want.toString(16)}`);
}

HWND 用 FindWindowW("Bun IDE") 抓------窗口标题要和 BrowserWindow 构造时一致。补完样式位后,点自己任务栏图标就变成系统原生切换了,带 DWM 动画 ,完全不再依赖 hook 去猜 SC_MINIMIZE

这里有个细节:补样式位要在 HWND 可用之后做。ElectroBun 启动时 HWND 未必立即可抓,所以我加了"立即试一次 + 轮询里再试一次"的兜底,抓到就 applyTaskbarStyle 一次。

4.5 三路检测保留处理失去前台场景

虽然"点自己图标"交给系统了,但"点别的程序 / Alt+Tab 导致我们失去前台"这种情况,还是得靠之前的检测。所以最终方案是补样式位 + 三路兜底并存

| 信号 | 作用 | | :-- | :-- | | FFI 补 WS_MINIMIZEBOX / WS_MAXIMIZEBOX | 点自己图标 → 系统原生切换(带动画) | | WH_CALLWNDPROCRET hook | 部分环境 SC_MINIMIZE/SC_RESTORE 转发时生效 | | 原生 blur / focus | 失焦→最小化 / 聚焦且最小化→还原 | | GetForegroundWindow 轮询 | 兜底 blur 不触发、前台确实变化的场景 |

入口只一行:

bash 复制代码
installTaskbarToggle({ title: "Bun IDE" });

三个信号统一用 FFI ShowWindow(hwnd, SW_MINIMIZE | SW_RESTORE) 切换,再用 blockUntil 350ms 防竞态。

4.6 一个已知的副作用

因为任务栏点击与应用失焦在系统层面无法区分,所以切到别的窗口 / Alt+Tab 也会触发我们最小化。这个我接受------它带来的收益(点自己图标能原生切换)远大于这点代价。


最终方案 三个按钮 系统动画 任务栏切换

整合下来,后端的窗口 RPC 非常简单:

bash 复制代码
windowClose:    () => { globalThis.mainWindow?.close(); },
windowMinimize: () => { globalThis.mainWindow?.minimize(); },
windowMaximize: () => {
  const win = globalThis.mainWindow;
  if (!win) return false;
  const maximized = win.isMaximized?.() ?? false;
  maximized ? win.unmaximize?.() : win.maximize?.();
  return !maximized;
},

创建窗口时保留 hiddenInset(保住动画),标题和 FindWindowW 用的字符串一致:

bash 复制代码
const mainWindow = new BrowserWindow({
  title: "Bun IDE",
  url: hmrUrl ?? "views://mainview/index.html",
  titleBarStyle: "hiddenInset",
  frame,
  rpc,
});
globalThis.mainWindow = mainWindow;
installTaskbarToggle({ title: "Bun IDE" });

验证清单 真机

后端 BrowserWindow 在启动时创建,HMR 改不了------必须关掉旧窗口重跑 bun dev

看后端日志有没有 taskbar style applied: 0x...,确认样式位补上了。

重要:绝对不要改 node_modules 里 ElectroBun 的原生代码(issue #395 上游没修),所有兜底都在应用层 FFI 实现。


写在最后

这个坑之所以"难",不是因为代码量大,而是因为失败信号藏在操作系统和框架的缝隙里

  • • 系统动画依赖 WS_CAPTION,但自定义标题栏又想去掉它

  • • 点自己图标的信号被框架吞了,应用层永远收不到

  • • 最后的解法不在 ElectroBun 的 API 里,而在 Windows 的窗口样式位上------用 FFI 直接补 WS_MINIMIZEBOX / WS_MAXIMIZEBOX,把控制权交还给系统

一句话总结这次的收获:当框架截断了你需要的系统消息,就别在应用层猜了,直接用 FFI 把窗口样式补回系统期望的样子,让 Windows 自己干活。


项目名:BunCode(暂未开源)

技术栈:Bun · Electrobun · React 19 · TypeScript · Ant Design · Monaco Editor · Zustand

固定每周更新开发进度,关注不错过下一篇。

相关推荐
一缕清烟在人间2 小时前
鸿蒙应用开发实战【27】— DatabaseService 数据库初始化
javascript·数据库·harmonyos·鸿蒙系统
研☆香3 小时前
JavaScript 绘制简单不规则图形:三角形与五角星实战教程
开发语言·javascript·ecmascript
想要成为糕糕手3 小时前
NO.48 旋转图像 —— LeetCode 热题 100 面试导向深度解析
javascript·算法·面试
JieE2123 小时前
LeetCode 138 随机链表的复制|两种解法详解(哈希表 + 原地 O (1) 空间)
javascript·算法·面试
用户852495071843 小时前
RAG实战:从零打造智能知识库问答系统
javascript
秋天的一阵风4 小时前
🔥 ECMAScript 2026 来了!使用这些新特性,JS 代码直接少一半
前端·javascript·ecmascript 6
前端进阶之旅4 小时前
Next.js 16.3 新特性全解析 AI 驱动开发与 Instant Navigations 实战指南
开发语言·javascript·人工智能
Cobyte4 小时前
编译解析器的基础——状态模式与状态机的原理
前端·javascript·vue.js
触底反弹5 小时前
⚡ Promise.all 性能优化:让 Agent 的工具调用飞起来
javascript·人工智能·后端