一、缘起:为什么我要给 DialogBox 加上"resizable"能力?
在企业级应用开发中,DialogBox 组件使用频率极高。传统组件开发模式下,开发者定义好所有功能,用户只能被动接受。但在 AI 时代,组件应该是"可对话"的——用户说"我想把这个弹窗调大一点",AI 就能理解意图并调用相应的 API。
要实现这个愿景,首先得让组件具备足够的能力。因此,本文以 TinyVue 的 DialogBox 组件为例,实现真正的 resizable 功能。这不仅是功能增强,更是为未来的 WebAgent 交互打下基础。
二、实战:深度解析 Renderless 架构的开发体验
2.1 第一次扫描源码的震撼
TinyVue 采用了 Renderless 架构——核心思想是:逻辑和视图彻底分离。
- 逻辑层(
vue.ts):只负责业务逻辑,对应'炒菜'。 - 表现层(
pc.vue、mobile.vue):只负责 UI 渲染,对应'摆盘'。
这样做的好处是 AI 友好。AI 最擅长生成纯逻辑代码,但不太理解复杂的 HTML/CSS。Renderless 架构正好让 AI 专注于它擅长的部分,大大提高了 AI 生成代码的准确性。
2.2 实现 resizable 的核心思路
第一步:理解 OpenTiny 的组件结构
通过查看源码,DialogBox 的文件结构如下:
dialog-box/
├── src/
│ ├── pc.vue # PC 端的 UI 表现
│ └── mobile.vue # 移动端的 UI 表现
├── vue.ts # 核心逻辑层(所有平台共用)
└── types.ts # 类型定义
这意味着只需在 vue.ts 中添加逻辑,然后在 pc.vue 和 mobile.vue 中添加对应的 UI 句柄,就能实现跨平台的 resizable 功能。
第二步:设计 resizable 的数据流
要实现拖拽缩放,需要设计一套完整的数据流:
- 用户按下鼠标 → 记录起始位置和初始尺寸
- 监听鼠标移动 → 计算偏移量
- 更新尺寸 → 触发事件
基于这个流程,设计了以下几个关键状态:
// 在 vue.ts 中添加的状态
resizing: false, // 标记是否正在缩放
resizeDirection: '', // 缩放方向(东南西北、东南、东北等 8 个方向)
startX: 0, // 鼠标按下的 X 坐标
startY: 0, // 鼠标按下的 Y 坐标
startWidth: 0, // 初始宽度
startHeight: 0, // 初始高度
minWidth: 200, // 最小宽度限制
minHeight: 100 // 最小高度限制
第三步:实现核心的三个函数
在 OpenTiny 的 Renderless 架构中,将 resizable 逻辑封装成了三个函数。
1. handleResizeStart(开始拖拽)
const handleResizeStart = (direction) => (event) => {
// 1. 检查是否允许 resize(比如全屏状态下不允许)
if (!props.resize || state.isFull) return;
// 2. 记录初始状态
state.resizing = true;
state.resizeDirection = direction;
state.startX = event.clientX;
state.startY = event.clientY;
// 获取当前尺寸作为基准
// 3. 添加全局监听(这样即使鼠标移出元素也能继续跟踪)
document.addEventListener('mousemove', handleResizeMove);
document.addEventListener('mouseup', handleResizeEnd);
};
为什么要添加到 document 而不是 element?
如果只监听元素本身,当用户快速拖动鼠标超出元素范围时,拖拽就会中断。添加到 document 上,就能保证整个拖拽过程的连续性。
2. handleResizeMove(拖拽中)
const handleResizeMove = (event) => {
if (!state.resizing) return;
// 1. 计算鼠标移动的偏移量
const deltaX = event.clientX - state.startX;
const deltaY = event.clientY - state.startY;
// 2. 根据拖拽方向计算新尺寸
let newWidth = state.startWidth;
let newHeight = state.startHeight;
if (direction.includes('e')) {
// 向东(右)拖拽
newWidth = Math.max(state.minWidth, state.startWidth + deltaX);
}
if (direction.includes('s')) {
// 向南(下)拖拽
newHeight = Math.max(state.minHeight, state.startHeight + deltaY);
}
// ... 其他方向类似处理
// 3. 直接修改 DOM 元素的样式
dialog.style.width = `${newWidth}px`;
dialog.style.height = `${newHeight}px`;
// 4. 触发事件,让外部知道尺寸变化
emit('resize-move', { width: newWidth, height: newHeight });
};
注意使用了 Math.max(state.minWidth, ...),这是为了防止用户把窗口缩得太小。这种边界检查在实际开发中非常重要。
3. handleResizeEnd(结束拖拽)
const handleResizeEnd = () => {
if (!state.resizing) return;
// 1. 重置状态
state.resizing = false;
state.resizeDirection = '';
// 2. 移除监听器(非常重要!防止内存泄漏)
document.removeEventListener('mousemove', handleResizeMove);
document.removeEventListener('mouseup', handleResizeEnd);
// 3. 触发结束事件
emit('resize-end', { width, height });
};
为什么要移除监听器?
这是一个新手容易犯的错误。如果不移除,组件销毁后监听器还在,就会导致内存泄漏。
第四步:在 UI 层添加拖拽句柄
逻辑写好了,接下来要在 UI 上让用户能够操作。在 pc.vue 中添加了 8 个方向的拖拽句柄:
<template>
<div ref="dialog">
<!-- 原有的弹窗内容 -->
<!-- 新增:8 个方向的拖拽句柄 -->
<template v-if="resize && !isFull">
<!-- 四个角 -->
<div @mousedown="handleResizeStart('nw')"></div>
<div @mousedown="handleResizeStart('ne')"></div>
<div @mousedown="handleResizeStart('sw')"></div>
<div @mousedown="handleResizeStart('se')"></div>
<!-- 四个边 -->
<div @mousedown="handleResizeStart('n')"></div>
<div @mousedown="handleResizeStart('s')"></div>
<div @mousedown="handleResizeStart('w')"></div>
<div @mousedown="handleResizeStart('e')">
因为逻辑和 UI 分离,可以轻松地为不同平台定制不同的 UI。比如在 PC 端显示 8 个句柄,在移动端可能只需要显示 4 个角的句柄(节省屏幕空间)。
2.3 踩坑实录:那些让我头疼的问题
坑 1:内存泄漏的教训
刚开始实现时,忘记清理事件监听器。后来添加了 onBeforeUnmount 钩子来兜底:
onBeforeUnmount(() => {
// 确保组件销毁时清理所有监听器
document.removeEventListener('mousemove', handleResizeMove);
document.removeEventListener('mouseup', handleResizeEnd);
});
Vue 提供了完整的生命周期钩子,让我们能在合适的时机做清理工作。
坑 2:CSS 单位的精度问题
一开始用 offsetWidth 获取尺寸,发现会有小数位丢失的问题。比如实际宽度是 500.7px,offsetWidth 返回 501。
解决方案是用 getComputedStyle:
const computedStyle = getComputedStyle(dialog);
const currentWidth = parseFloat(computedStyle.width); // 精确值
offsetWidth 返回的是整数(四舍五入后的像素值),而 getComputedStyle 返回的是精确的计算值。在连续拖拽的场景中,这种精度丢失会累积,导致明显的抖动。
坑 3:移动端的兼容性问题
在 PC 上测试完美,一到移动端就歇菜。原因很简单:移动端没有 mouse 事件,只有 touch 事件。
最初的做法是写两套逻辑,但后来发现了一个更好的方案——Pointer Events。
Pointer Events 是一个 W3C 标准,统一了鼠标、触摸、手写笔的输入事件:
// 一套代码搞定所有输入设备
element.addEventListener('pointerdown', handlePointerDown);
element.addEventListener('pointermove', handlePointerMove);
element.addEventListener('pointerup', handlePointerUp);
这样代码量直接减少了 40%。这就是拥抱 Web 标准的体现。
三、思考:从 resizable 到 WebAgent 的技术演进
3.1 为什么要做这件事?
这不是简单的功能增强,而是为未来的 WebAgent 交互 做准备。想象一下这个场景:
用户对 AI 说:'帮我把这个弹窗调大一点,里面的表格看不全'
如果没有 resizable 能力,AI 只能束手无策。但现在,AI 可以:
- 通过 WebMCP 协议获取 DialogBox 的能力清单
- 识别到
resize方法可用 - 自动调用
handleResize或修改width/height属性 - 完成用户的指令
这就是 GenUI(生成式 UI)的核心理念:UI 不再是静态的,而是可以根据用户意图动态调整的。
3.2 WebMCP:智能体与组件的"翻译官"
WebMCP(Model Context Protocol for Web)就是让 AI 能够理解 Web 组件的能力。每个组件需要暴露一份"能力清单":
{
"component": "DialogBox",
"version": "1.0.0",
"capabilities": {
"methods": ["open", "close", "resize"],
"properties": {
"width": { "type": "string", "writable": true, "description": "弹窗宽度" },
"height": { "type": "string", "writable": true, "description": "弹窗高度" },
"resize"
有了这份清单,AI 就知道哪些方法可调,哪些属性可改,以及有哪些限制条件。
3.3 Renderless 架构对 AI 友好的秘密
经过这次实践,发现 Renderless 架构 简直就是为 AI 而生的:
- 逻辑纯净:AI 最擅长生成纯函数式的逻辑代码,不需要理解 DOM。
- 类型完备:TypeScript 类型定义让 AI 生成的代码更准确。
- 职责单一:逻辑层只管状态和 API,表现层只管渲染,AI 不容易出错。
- 易于测试:纯函数更容易编写单元测试,AI 可以自动生成测试用例。
四、感悟:开发者如何在 AI 时代找到新定位?
4.1 角色转变
开发者的角色真的在变。
- 以前:花大量时间写 CRUD 代码、调试事件监听、写注释文档。
- 现在:花少量时间描述需求,审查 AI 生成的代码,优化架构和性能。
同样一个 resizable 功能,借助 AI,效率显著提升,且代码质量更高(因为 AI 不会漏掉边界检查)。
4.2 什么应该交给 AI,什么必须自己把控?
✅ 放心交给 AI:
- 样板代码(getter/setter、事件处理框架)
- 单元测试
- 类型定义
- 代码格式化和小重构
⚠️ 需要审核:
- 业务逻辑的正确性
- 性能优化方案
- 错误处理策略
- 兼容性处理
❌ 必须自己决策:
- 架构设计和技术选型
- 用户体验细节
- 安全合规问题
- 技术债务的取舍
记住一句话:AI 是最好的副驾驶,但方向盘必须在你手里。
4.3 参与开源的真实收获
参与 OpenTiny 开源项目带来的收获包括:
- 技术层面:真正理解了 Renderless 架构的设计精髓,学会了如何编写对 AI 友好的代码。
- 思维层面:从"使用者"变成"贡献者",开始思考组件的通用性和扩展性。
- 职业发展:留下了实实在在的贡献记录,认识了一群优秀的开源开发者。
最重要的是,感觉不是在"卷",而是在创造价值。每一行代码都可能被成千上万的开发者使用。
五、展望:前端智能化的下一步
5.1 接下来的计划
- 完善 resizable 功能:添加保持宽高比选项、支持拖拽到边缘自动吸附、添加动画过渡效果。
- 探索 WebMCP 集成:为 DialogBox 编写 MCP Schema、实现 AI 可调用的标准接口。
- 输出最佳实践:撰写教程、录制实战视频、在团队内部分享经验。
5.2 给同行的建议
- 别观望,先动手:找个真实的开源项目,提交你的第一个 PR。
- 善用 AI 工具:GitHub Copilot、Cursor 等,选一个顺手的。
- 深入理解架构:不要满足于会用 API,要搞懂背后的设计思想。
- 保持开放心态:新技术层出不穷,快速学习才是核心竞争力。
- 拥抱开源社区:一个人的力量有限,一群人才能走得更远。
六、写在最后
前端智能化不是口号,而是正在发生的现实。从辅助编码到智能体自主执行,这条路可能还需要走几年。每一步前进,都需要我们这一代开发者去铺路。
我很庆幸,自己能参与到这场变革中来。用代码为 AI 时代的基础设施添砖加瓦,这本身就是件很酷的事情。


