乐于分享
好东西不私藏

读源码是一种修行:vue-grid-layout 源码探索手记

读源码是一种修行:vue-grid-layout 源码探索手记

读源码是一种修行:vue-grid-layout 源码探索手记

工程师的成长,往往藏在别人的代码里。


一、为什么选 vue-grid-layout 来读

读源码这件事,方向比努力重要。最近正在研究低代码平台和可视化配置,选 vue-grid-layout 有三个理由:

  • • 体量适中:核心代码几千行,不是那种几十个文件的巨型框架,适合单次深读
  • • 算法明确:核心逻辑就一件事——「元素碰撞后怎么找新位置」,目标清晰
  • • 实用性强:读完之后立刻能在自己的项目里用上,成就感来得快

比起读 Vue 源码(读了也不知道往哪儿用),vue-grid-layout 是那种「读完能用,用完有感」的选择。


二、研究路径:从外到内,四步破局

我的源码阅读方法论总结为四步:

第一步:跑起来。 先让 demo 在本地跑通,任意拖拽、缩放,观察现象。好奇心在哪里?拖拽时其他卡片为什么会「自动弹开」?缩放时边界怎么计算的?

第二步:找入口。 找到组件的入口文件,搞清楚 GridLayout 和 GridItem 的关系。谁是容器,谁是被拖的那个?数据流是什么方向?

第三步:揪核心算法。 找到最核心的那个函数——在 vue-grid-layout 里就是 correctBounds 和 packLayout,把这两个函数读透,就理解了 80%。

第四步:画架构图。 读完之后,把组件关系、事件流向、状态更新链路用图的方式画出来,形成自己的理解。


三、源码里的发现

发现 1:拖拽用的不是 Drag & Drop API,而是纯 JS 手动计算

🔍 工具选型

源码里 GridItem.vue 完全没有用 HTML5 的 draggable 属性或者 Drag and Drop API,而是:

// 监听原生鼠标事件
this
.$el.addEventListener('mousedown', this.onMouseDown, true)
document
.addEventListener('mousemove', this.onMouseMove, true)
document
.addEventListener('mouseup', this.onMouseUp, true)

为什么?答案在第一次拖拽测试时就出现了——HTML5 DnD API 做不到精确的像素级控制。拖拽时需要实时知道「光标在容器内的 x/y 坐标」,DnD API 只提供「开始拖」和「结束拖」两个事件,中间的过程是黑盒。

所以作者选择了最原始的方式:手动记录鼠标初始位置,每次 mousemove 计算偏移量,换算成网格单位,再去跟其他卡片做碰撞检测。这套方案代码量更大,但完全可控。

收获:工具选型时,不要被「标准 API」绑架。如果标准方案做不到你需要的精度,手写一套更可靠。


发现 2:碰撞检测的判断逻辑极其简洁

⚡ 算法设计

源码里碰撞检测的核心代码只有这几行:

// 判断是否超出边界
function
 isOverBounds(comp, bounds) {
  return
 comp.x < bounds.x1 ||
    comp.x + comp.w > bounds.x2 ||
    comp.y < bounds.y1 ||
    comp.y + comp.h > bounds.y2
}

// 判断两个矩形是否碰撞(取反即不碰撞)

function
 collides(l1, l2) {
  return
 !(l2.x >= l1.x + l1.w ||
    l2.x + l2.w <= l1.x ||
    l2.y >= l1.y + l1.h ||
    l2.y + l2.h <= l1.y)
}

没有任何复杂的数据结构,没有 KD-Tree,没有空间索引,就是纯数学的矩形不相交判断。

第一眼看到时,心里冒出一个疑问:如果有 100 个卡片,每次拖拽都要两两检测,不就是 O(n²)?

然后去查了相关资料,实际使用场景中卡片数量很少超过 20 个,O(n²) 在这个量级下完全可接受,加空间索引带来的复杂度提升是过度设计。

收获:复杂度分析要结合实际业务规模。教科书上的「最优解」不一定是最优工程解。在性能问题真实发生之前,不要为它提前付出复杂度代价。


发现 3:verticalCompact 的实现是「贪心算法」

🧮 算法思维

垂直压缩的核心逻辑:

// 按 y 坐标排序后,从上往下逐个安放
layout.forEach(item => {
  while
 (isColliding(item, others)) {
    item.y += 1  // 碰撞就往下挪一格,直到不再碰撞
  }
})

这是一个典型的贪心算法(Greedy Algorithm):每个卡片尽可能往上放,只要不碰撞就继续放,直到碰到上面的卡片为止。

贪心算法的特点:局部最优,不保证全局最优。比如两个高瘦的卡片放在右侧,底部有一个矮胖的卡片,贪心可能会把它放在右侧而不是左侧更优的位置——但对于 UI 场景,这种「不够完美」的排列肉眼几乎分辨不出来,而实现却极其简单。

收获:工程中的算法选择,本质上是「精度」和「成本」的权衡。贪心算法不是「偷懒」,是一种务实的工程哲学。


发现 4:拖拽时用 CSS transform,结束时才更新状态

🎯 性能设计

这部分代码很值得细看:

// 拖拽进行中:直接操作 DOM,不走响应式
this
.newX = left   // 计算出的像素偏移
this
.newY = top
this
.$el.style.transform = `translate(${this.newX}px, ${this.newY}px)`

// 拖拽结束后:通过 emit 通知父组件更新真实数据

this
.$emit('update:layout', this.compact(layout))

这是一个**乐观更新(Optimistic Update)**的设计思路:用户操作时先给即时反馈(CSS transform),等操作结束了再去更新真正的数据状态。

这样设计的好处是:拖拽过程里没有任何 Vue 响应式系统介入,没有 Object.defineProperty 的依赖追踪开销,没有 virtual DOM diff,拖拽体验非常丝滑。

mousemove 可能是每秒 60 次的频率,如果每次都触发响应式更新,卡顿是必然的。

收获:用户交互的实时反馈层和业务数据层应该解耦。「手感」由 DOM 负责,「数据」由响应式系统负责,高频事件不要让响应式系统介入。


发现 5:serialized 与 responsive 设计

💡 数据模型

// 导出布局——就是一行 JSON
exportLayout
() {
  return
 JSON.stringify(this.layout)
}

// 导入布局——同样一行 JSON

importLayout
(layoutData) {
  this
.layout = JSON.parse(layoutData)
}

看起来平平无奇,但如果你做过可视化编辑器就知道:布局数据的可序列化是拖拽编辑器的命门。 很多团队自己做的拖拽组件,做到一半发现「怎么保存布局」成了一个难题——就是因为没有在一开始设计好数据模型。

vue-grid-layout 把 layout 数组直接暴露出来,用户想存哪就存哪:localStorage、数据库、文件,随意。

responsive 布局的实现更聪明:不重新计算位置,而是在不同断点存不同的 layout,窗口 resize 时直接切换。这种「以空间换时间」的策略,在响应式场景下比「监听 resize 重新计算」稳定得多。

收获:数据模型设计要走在 UI 实现前面。能序列化的数据结构,才是有持久价值的系统。


五、总结

读源码的过程,也是心态的修炼。

最深的感受是:源码读多了,你会发现所有好的代码都有同一个特点——克制。

vue-grid-layout 的作者明明可以加很多功能:嵌套布局、动画、约束系统,但他选择只做一件事,把这件事做稳定。这种克制,是工程能力的体现,也是工程师成熟度的标志。

如果你也想读 vue-grid-layout 的源码,三个具体建议:

① 从 GridItem.vue 的 onMouseDown 开始,顺着事件链路走到 emit,顺藤摸瓜把拖拽流程串清楚,大概一个小时能读完核心逻辑。

② responsiveUtils.js 是第二个必读文件,这里藏着所有布局算法的核心(verticalCompact、packLayout 都在这里)。

③ 第三个读什么,看你的需求——想做性能优化就读虚拟滚动相关;想扩展功能就读事件系统;想做嵌套布局……目前没有成熟的开源方案,这也是一个研究机会。


读源码不是为了背代码,是为了建立对「好代码」的系统性判断力。

vue-grid-layout 不是最完美的库,但它在「简单」「功能」「性能」三角中找到了一个很舒服的平衡点。这种平衡感,才是我们应该从源码里带走的东西。


*如果你读过 vue-grid-layout,或者有其他推荐的「体量适中、算法明确」的源码,欢迎留言交流。