前几天我让 AI 编程助手帮我写了一个 Easy window resize 功能,起因很简单:我手上有个内部工具,左侧是导航面板,右侧是内容区,用户抱怨面板宽度不能记忆,每次打开都要重新拖,而且拖拽的时候总是会选中文字。我原本以为这就是个小脚本的活,让 AI 写个函数,五分钟搞定。结果等我真正把需求拆开、把代码跑起来,才发现里面藏着不少边界问题。这篇文章就记录一下这次完整的实操过程:从给 AI 的提示词怎么写,到 Pointer Events 拖拽算法、最小最大尺寸限制、内存清理,再到几个我实际踩过、查了半天才弄明白的坑。如果你是前端开发者,或者正在尝试用 AI 助手做小工具,后面这些内容应该能直接帮到你。
1. 需求拆解:Easy window resize 到底要解决什么问题
1.1 表面需求只是“能拖”,实际需求是“拖得舒服”
我把第一版需求发给 AI 助手的时候,说的是“帮我实现窗口 resize”。它很快给了一段 mousedown/mousemove/mouseup 的代码,功能确实能跑,但只要真实用一下就会发现完全不是那回事。从右边缘拖能变宽,从左边缘拖的时候方向却是反的;拖快一点鼠标出了窗口,尺寸就不动了;拖的时候文字被选中,整个页面高亮一片。最要命的是没有任何最小宽度限制,面板可以被拖到只剩几个像素,用户想点回去都难。
等到我把这些细节列出来,才发现这个需求远远不止“能拖”两个字。我最终整理出来的行为清单是:支持左、右、上、下四个边缘和四个角落拖拽;拖拽过程中要给容器加一个状态类,避免文本选中;宽度/高度要卡在区间内,最小不低于内容可用宽度,最大不能盖住其他模块;尺寸变化时触发回调,拖拽结束保存数值;如果容器的布局上下文发生变化,还要能重新计算边界。把这份清单整理完,我心里就有数了:这不是一个几十行的脚本,而是一个需要正经设计的可复用组件。
调窗口大小这个动作,在桌面操作系统里已经存在了几十年,用户对它是有肌肉记忆的。你做一个网页面板,如果拖拽行为和桌面系统不一致,比如不能从左边拖,或者拖到边界外就卡住,用户会立刻觉得“这东西不顺手”。所以表面上是“能拖”,本质上是要“拖得舒服”。
1.2 技术选型:CSS resize、原生拖拽还是 Pointer Events
第一个想到的是 CSS 的 resize 属性。确实,CSS 里有一个 resize: both,可以直接让元素右下角出现可拖拽的手柄,代码几乎为零。但实际用起来限制也非常明显:它只能从右下角拖;手柄样式不可控;没有拖拽过程中的回调,你无法在拖拽结束后去保存尺寸;而且它还依赖 overflow 的属性,在某些布局下根本不会出现手柄。对于我要的“左侧导航面板可调宽度”这个场景,完全不够用。
第二个方案是传统的 mousedown + document mousemove。这是网上最常见的写法,原理也不复杂:按下记录起点,移动时计算偏移,松开删除监听。但它有个很烦人的问题:如果鼠标从元素中部快速移出窗口,mousemove 事件可能丢;如果页面里有 iframe,鼠标移到 iframe 上方,事件直接被 iframe 吞掉;而且你还要自己想着把监听挂在 document 上、在 mouseup 时解绑,漏一步就埋雷。
我最终选的是 Pointer Events。这个方案把鼠标、触摸笔、手指事件统一成一个模型,并且提供了 setPointerCapture 接口,可以把后续的指针事件持续派发给同一个元素,从根本上解决“鼠标移出容器就丢事件”的问题。这在现代浏览器里已经是很成熟的方案,代码量不算大,但体验比 mousedown 方案稳定得多。下面这段就是我实际用的核心逻辑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心实现细节:拖拽算法与边界计算
2.1 为什么用 Pointer Events 而不用 Mouse Events
Pointer Events 的核心是三个事件:pointerdown、pointermove、pointerup。使用 pointerdown 时,e.pointerId 是当前指针的唯一 ID,然后调用 handle.setPointerCapture(e.pointerId),浏览器会把接下来这个指针的所有事件都送入 handle,哪怕指针已经移出 handle、移出窗口。在传统 mousedown 方案里,你必须监听 document 的 mousemove 和 mouseup,然后还要用 e.relatedTarget 之类的逻辑来兜底,很容易出问题。代码如下:
ts复制handle.addEventListener('pointerdown', (e: PointerEvent) => {
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const startWidth = container.getBoundingClientRect().width;
const startHeight = container.getBoundingClientRect().height;
handle.setPointerCapture(e.pointerId);
const onMove = (ev: PointerEvent) => {
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
applySize(startWidth + dx, startHeight + dy);
};
const onUp = () => {
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', onUp);
handle.removeEventListener('pointercancel', onUp);
options.onResizeEnd?.(getSize());
};
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
handle.addEventListener('pointercancel', onUp);
});
注意这里我把监听绑在 handle 上而不是 document 上,是因为 setPointerCapture 已经保证了事件不会丢。如果鼠标弹起时 handle 不在视口内,pointerup 依然会被派发到 handle。如果项目里的目标浏览器不支持 Pointer Events,再考虑降级方案,但至少在这个内部工具里,我可以放心用它。
2.2 八个方向 handle 和偏移量计算
窗口调整大小不能只支持右下角。桌面用户习惯从左边、上边、以及四个角去调整。所以组件需要生成八个方向的 handle:n、s、e、w、ne、nw、se、sw。每个 handle 在拖动时对宽高的影响是不同的:
- e 和 se:向右拖动增加宽度;偏移量 deltaX 直接加到宽度上
- w 和 sw:向左拖动增加宽度,但偏移量为 -deltaX;也就是说鼠标向左移,宽度变大
- s 和 se:向下拖动增加高度;deltaY 直接加
- n 和 nw:向上拖动增加高度;偏移量为 -deltaY
为了处理这种方向差异,我定义了一个方向字符串“se”,然后判断字符串里是否包含某个字母:
ts复制const dir = currentHandle.dataset.direction as ResizeDirection;
const deltaX = startWidth + (dir.includes('e') ? dx : dir.includes('w') ? -dx : 0);
这里有个细节:用 getBoundingClientRect() 获取起始宽高,而不是 offsetWidth。因为如果容器有 CSS transform,比如缩放动画,offsetWidth 返回的是布局宽度逻辑值,会和实际像素偏移不一致;getBoundingClientRect() 返回的是当前视口里的真实像素尺寸。后者能保证拖拽的实际视觉反馈和鼠标移动完全同步。
2.3 最小/最大尺寸、容器边界与高分屏适配
拖拽过程中直接 newWidth = startWidth + deltaX 是不行的,必须 clamp 到合法范围内。最小尺寸不能拍脑袋,要参考容器内部内容的最小布局宽度。我给导航面板定的最小宽度是 240px,最小高度是 160px,然后最大尺寸根据父容器剩下的空间动态计算,再留出边距。逻辑如下:
ts复制const parentRect = parent.getBoundingClientRect();
const maxWidth = Math.max(minWidth, parentRect.width - 48);
const maxHeight = Math.max(minHeight, parentRect.height - 48);
newWidth = Math.min(Math.max(newWidth, minWidth), maxWidth);
这里用 48 而不是 20,是因为还要考虑如果面板本身有 padding/border,视觉上不能完全贴满父容器边缘。不同应用可以调整,比如弹窗类组件可以留 80px,侧边栏类组件可以留 32px。另外,高分屏上 CSS 像素和设备物理像素存在 devicePixelRatio 差异,我在组件里统一使用 CSS 像素,也就是 clientX/clientY,因为 DOM 尺寸也是 CSS 像素,两者天然一致,不需要额外换算。如果你的项目需要精确读取设备物理像素,才需要按 window.devicePixelRatio 换算。
2.4 性能优化:requestAnimationFrame 与文本防选
直接在每个 pointermove 里设置 style.width 会触发布局重排,拖拽频率高的时候页面可能出现肉眼可见的抖动。解决方法是用 requestAnimationFrame 合并多次移动事件,只在浏览器绘制前应用最后一次尺寸:
ts复制let raf = 0;
const onMove = (e: PointerEvent) => {
if (raf) return;
raf = requestAnimationFrame(() => {
applySize(e);
raf = 0;
});
};
另外,pointerdown 触发后要立刻给 body 加一个禁用文本选择的 class,比如 user-select: none,并在 pointerup 时移除。否则拖拽过程中光标一旦划到文字上就会选中。你可以尝试直接 e.preventDefault(),但只对部分元素有效,最稳妥的是全局加 class。
3. 用 AI 助手从零实现这个组件的完整过程
3.1 给 AI 的第一个提示词:具体到什么程度才算合格
我见过太多人让 AI 写代码,只丢一句话“帮我写一个 resizable”。这种提示词的结果通常是表面能跑,但一测就崩。真正有效的提示词应该包含四块信息:功能边界、输入输出、技术约束、异常处理。当时我实际发给 AI 的提示词大概是这样的:
“我用 TypeScript 写一个无依赖的类,叫 EasyWindowResize。constructor 接收 container、parent、handle 列表和一个 options 对象。支持的方向是 n/s/e/w/ne/nw/se/sw,拖拽时给 body 加 no-select class,松开后触发 onResizeEnd。最小宽高从 options 传,没有传就用 240/160。最大宽高动态计算为父容器减去 48px。用 Pointer Events 和 setPointerCapture。提供 destroy 方法移除所有监听。不要写注释,代码要简洁。”
注意我没有让它用自己的理解发挥,而是把所有关键决策先做了。AI 在代码生成方面很强,但在需求澄清和边界定义上非常被动,你要是不说最小宽度,它绝不会自己想到加。这也说明“明确提示词”不是在限制 AI,而是在帮你把需求想清楚。需求清楚之后,AI 的回答命中率高很多,来回修改的次数也少。
3.2 第一版代码的问题:AI 写代码也要 review
AI 给我第一版代码之后,我实测就发现了四个问题。
第一个是方向反了。它默认从右下角 handle 出发,把所有方向的偏移都用正数处理,导致从左边拖的时候宽度的变化是反的。我让它改成用方向字符串判断正负,这个逻辑才正确。
第二个是事件丢失。它把 mousemove 绑定在 document 上,鼠标快速移出浏览器窗口后,mouseup 监听偶尔收不到,导致整个拖拽状态卡死。我要求改用 setPointerCapture,并将 pointermove 绑到 handle 上,才彻底解决。
第三个是没有清理方法。销毁组件时如果没有解绑监听,页面切换到其他路由后内存里还留着 handler,非常恶心。我要求加上 destroy,把所有 addEventListener 都对应 remove。
第四个是边界条件。它在最大宽度上写死了一个数值,没有考虑父容器 resize 的情况。我改成每次拖动开始时动态计算父容器大小,宽度变了也能实时适应。
这里我想说,AI 生成的代码一定不能“信任式复制”。它不是不对,而是只能做到“在常规上下文中合理”,无法感知你的业务上下文。你把错误反馈给它,它能很快改,但如果你不 review,错误就会变成线上 bug。好的流程是:AI 负责初稿,你负责提出它没考虑到的问题。
3.3 把脚本改造成可复用组件的重构
第一版所有逻辑都是写在事件回调里,我做完功能后顺手做了一次重构,让它真正变成可复用的模块。主要做了三件事:
第一,把 options 收敛到一个接口里。handleSize、minWidth、minHeight、storageKey、onResize、onResizeEnd 全部作为可配置项,不再散落在代码里。这样后续要调整参数,只需要改一处。
第二,把 handle 的定位逻辑统一。在组件初始化时给每个 handle 设置 position: absolute,并按方向计算 top/right/bottom/left 的位置。比如 w 方向的 handle 在左侧居中,se 方向的 handle 在右下角。这样调用方只需要在容器里放置几个空元素,组件负责摆位置和绑定事件。
第三,加入尺寸持久化。这个需求一开始就存在,但 AI 第一版没有做。我在 onResizeEnd 里把最终尺寸用 localStorage 存储,初始化时读取并恢复。读取时要注意:localStorage 的返回值一定是字符串,直接赋值给 style.width 只会得到 NaN,需要先用 Number() 转一次。
这里的关键是:AI 可以帮你生成函数体,但组件边界、生命周期、数据流这些架构决策还是要你自己定。代码长见识,结构长经验。
4. 实操中的翻车记录与排查技巧
4.1 高频问题排查速查表
我把这一轮开发中遇到的所有问题整理成了表格,方便你直接对照:
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 拖拽时鼠标移出浏览器,尺寸不再变化 | 事件绑定在 document 上,失去焦点后未收到 mouseup | 使用 Pointer Events + setPointerCapture |
| 页面出现蓝色选中文字 | 拖拽过程中文本被默认处理 | pointerdown 后给 body 加 user-select: none |
| 从左边拖宽度反而变大 | 偏移量未按方向取反 | 根据方向字符串计算 deltaX 的正负 |
| localStorage 中读取宽度后显示 NaN | 存进去的是字符串,直接赋值给宽度 | Number(localStorage.getItem(key)) 转换 |
| 容器在 flex 布局中拖不动 | 子元素被 flex 属性撑开,width 设置无效 | 给被拖拽容器加 flex: none |
| 元素有 CSS transform 缩小时,坐标不准 | 使用 offsetWidth 拿到的是逻辑尺寸 | 用 getBoundingClientRect() 读取真实像素尺寸 |
| iframe 悬浮在 handle 上方造成事件丢失 | iframe 拦截了 pointer 事件 | 拖拽时插入遮罩层,或给 iframe 临时禁用 |
但实际上不止这些,还有几个更隐蔽的坑,单独说。
4.2 我踩过的几个隐蔽坑
第一个是 CSS resize 残留问题。页面里如果之前用 CSS 的 resize: both 做过侧边栏,即使你已经不需要这个手柄,它依然会在右下角显示。拖拽时可以看到一个原生三角柄,很别扭。排查了半天才发现是样式表里写了 * { resize: both } 这种全局规则,覆盖掉就消失了。
第二个是 flex 布局的宽高拉扯。我把组件放在一个 flex 布局里,初始时设置了 width,但父容器是 display: flex,而且子元素的 flex 属性没有清掉,浏览器会自动伸缩它的宽度,导致自己拖拽效果无效,看起来怎么拖都是那个宽度。解决办法是给容器加 flex: none 或 flex-grow: 0,让手动设置的 width 不被自动计算覆盖。
第三个是 transform scale 下的坐标偏移。项目里有一个整体缩放动画,容器实际尺寸和视觉尺寸不同,导致拖拽位置偏移。用 getBoundingClientRect() 也不会完全解决,因为 clientX/clientY 是相对视口的,transform 后的坐标也要按比例换算。准确做法是在计算 deltaX/deltaY 后除以当前容器的 scale 值。
第四个是低版本浏览器的兼容性。Pointer Events 在现代浏览器都已经支持,但一些老的内嵌 webview 不支持;如果目标平台有这种环境,需要 fallback 到 mousedown/touchstart。我没有做这个兼容,因为项目明确只支持最新版,如果是做通用组件,建议加一行能力检测。
4.3 可直接抄的配置参数与建议
下面这组参数是我实际用在内部工具里的,可以直接抄过去再根据场景微调:
| 参数 | 推荐值 | 原因 |
|---|---|---|
| handleSize | 8px | 太小难点击,太大遮挡内容 |
| minWidth | 240px | 保证侧边栏内容可读 |
| minHeight | 160px | 保证面板头部操作区不折叠 |
| maxMargin | 48px | 防止面板贴到边缘,留出间距 |
| storageKey | easy-window-resize:v1 | 带版本号,后续改规则可以自动失效 |
| noSelectClass | resize-no-select | 在 body 上加,避免与项目状态类冲突 |
| raf 开关 | true | 默认开启,减少布局抖动 |
配套的默认配置对象:
ts复制const DEFAULT_OPTIONS = {
handleSize: 8,
minWidth: 240,
minHeight: 160,
maxMargin: 48,
storageKey: 'easy-window-resize:v1',
noSelectClass: 'resize-no-select',
useRAF: true,
};
这些参数不是拍脑袋,都是通过实际测试得出的。比如 handleSize 8px 是我试过 4px、6px、8px、12px 后的结论,4px 在触屏上很难命中,12px 又太占空间。如果你做的是触屏优先的应用,可以适当调到 10px-12px。
5. 组件做完之后:还可以扩展成什么
5.1 从 Web 组件到桌面全局窗口调整
有些朋友看到这个标题可能会问:Easy window resize 不是应该直接调整操作系统里的窗口吗?这是两种完全不同的应用场景。Web 端组件是调整页面内部的面板、弹窗尺寸;桌面端全局窗口调整,需要调用操作系统的窗口管理 API,比如在 Windows 上可以用系统调用设置窗口位置和尺寸,但通常还涉及窗口边框绘制、热区命中、权限申请等,复杂度不是一个量级。我这次做的 Web 组件,更像是一个可以集成到页面里的“减配版桌面交互”。
如果你真的要做桌面全局窗口调整工具,建议从 Electron 的无边框窗口入手,因为它有现成的窗口控制接口,可以实现类似网页的拖拽逻辑,只是把 DOM 尺寸替换成系统窗口位置。不过这里要提一句,涉及系统权限的功能在打包发布时要格外注意合规性,个人自用没问题,给外部用户使用前一定要仔细评估。
5.2 基于这个组件的后续迭代建议
完成了 resize 之后,你会发现这是一个特别棒的“对话式开发”起点。因为交互逻辑已经很明确,你完全可以继续跟 AI 助手说:“基于现在这个组件,帮我加一个双击左边框自动折叠,再按一次恢复宽度”或“增加键盘快捷键,按 1/2 把面板宽度设为容器的一半”。这类需求是增量式迭代,AI 做起来非常快,因为它能看到你上一个版本的代码,不必从头理解。
我的建议是:每加一个功能都先给 AI 一个约束——不要改现有接口,尽量在新增方法里实现。这样组件越加越丰富,但对外 API 不变,调用方不会崩。比如我后来的版本里就加了 dispatchCustomEvent,通过 CustomEvent 把 resize 过程广播给其他模块,这样即使不传回调也能在外部监听。
另外,如果做多窗口或浏览器扩展,可以考虑把尺寸存储从 localStorage 换成 sessionStorage 或 chrome.storage,因为这几种存储的访问时机和异步行为不一样,直接换可能会出现读取时尺寸还是初始值的问题。这种小坑特别多,建议在动手前先明确存储边界。
这次做 Easy window resize 最大的收获不是那几段代码,而是我意识到,AI 编程助手真正能提效的前提,是你自己能先把需求拆清楚。以前我总觉得让 AI 写代码是偷懒,实际做下来发现,它写出来十行,你 review 二十行;但你让它帮你查一个冷门事件,查一个边界处理的方案,比查文档快得多。所以如果你也想试试这种开发方式,别急着让它输出完整项目,先从这种有边界、可验证的小功能开始,把提示词写具体,把测试做完整,它会比你想的可靠很多。
