经过上一篇 [精读《磁贴布局 - 功能分析》](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/265.%E7%B2%BE%E8%AF%BB%E3%80%8A%E7%A3%81%E8%B4%B4%E5%B8%83%E5%B1%80%20-%20%E5%8A%9F%E8%83%BD%E5%88%86%E6%9E%90%E3%80%8B.md) 的分析,这次我们进入实现环节。 ## 精读 实现磁贴布局前,先要实现最基础的组件拖拽流程,然后我们才好在拖拽的基础上增加磁贴效果。 ### 基础拖拽能力 对布局抽象来说,它关心的就是 **可拖拽的组件** 与 **容器** 的 DOM,至于这些 DOM 是如何创建的都可以不用关心,在这个基础上,甚至可以再做一套搭建或者布局框架层,专门实现对 DOM 的管理,但这篇文章还是聚焦在布局的实现层。 布局组件首先要收集到有哪些可拖拽组件与容器,假设业务层将这些 DOM 生成好传给了布局: ```ts const elementMap: Record< string, { dom: HTMLElement; x: number; y: number; width: number; height: number; } > = {}; const containerMap: Record< string, { dom: HTMLElement; rectX: number; rectY: number; width: number; height: number; } > = {}; ``` - `elementMap` 表示可拖拽的组件信息,包括其 DOM 实例,以及相对于父容器的 `x`、`y`、`width`、`height`。 - `containerMap` 表示容器组件信息,之所以存储 `rectX` 与 `rectY` 这两个相对浏览器绝对定位,是因为容器的直接父组件可能是 `element`,比如 `Card` 组件可以同时渲染 `Header` 与 `Footer`,这两个位置都可以拖入 `element`,所以这两个位置都是 `container`,它们是相对父 `element` `Card` 定位的,所以存储绝对定位方便计算。 接下来给 `elementMap` 的每一个组件绑定鼠标按下事件作为 `onDragStart` 时机: ```js Object.keys(elementMap).forEach((componentId) => { elementMap[componentId].dom.onmousedown = () => { // 记录拖拽开始 }; }); ``` 然后在 document 监听 `onMouseMove` 与 `onMouseUp`,分别作为 `onDrag` 与 `onDragEnd` 时机,这样我们就抽象了拖拽的前、中、后三个阶段: ```ts function onDragStart(context, componentId) { context.dragComponent = componentId; } function onDrag(context, event) { // 根据 context.dragComponent 响应组件的拖动 // 将 element x、y 改为 event.clientX、event.clientY 即可 } function onDragEnd(context) { context.dragComponent = undefined; } ``` 这样最基础的拖拽能力就做好了,在实际代码中,可能包含进一步的抽象这里为了简化先忽略,比如可能对所有事件的监听进行 Action 化,以便单测在任何时候模拟用户输入。 ### 磁贴布局影响因子 磁贴布局入场后,仅影响 `onDrag` 阶段。在之前的逻辑中,拖拽是完全自由的,那么磁贴布局就会约束两点: 1. 对当前拖拽组件位置做约束。 2. 可能把其他组件挤走。 对拖拽组件位置的约束是由背后的 “松手 DOM” 决定的,也就是拖拽时 element 是实时跟手的,但如果拖拽位置无法放置,就会在松手时修改落地位置,这个落地位置我们叫做 `safePosition`,即当前组件的安全位置。 所以 `onDrag` 就要计算一个新的 `safePosition`,它应该如何计算,由磁贴的碰撞方式决定,我们可以在 `onDrag` 函数里做如下抽象: ```ts function onDrag(context, event) { // 根据 context.dragComponent 响应组件的拖动 const { safeX, safeY } = collision(context, event.clientX, event.clientY); // 实时的把组件位置改为 event.clientX、event.clientY // 把背后实际落点 DOM 位置改为 safeX、safeY // onDragEnd 时,再把组件位置改为 safeX、safeY,让组件落在安全的位置上 } ``` 接下来就到了重点函数 `collision` 的实现部分,它需要囊括磁贴布局的所有核心逻辑。 `collision` 函数包括两大模块,分别是拖入拖出模块与碰撞模块。拖入拖出判断当前拖拽位置是否进入了一个新容器,或者离开了当前容器;碰撞模块判断当前拖拽位置是否与其他 `element` 产生了碰撞,并做出相应的碰撞效果。 除此之外,磁贴布局还允许组件按照重力影响向上吸附,因此我们需要做一个 `runGravity` 函数,把所有组件按照重力作用排列。 ```ts function collision(context, x, y) { // 先做拖入拖出判断 if (judgeDragInOrOut(context, event)) { // 如果判定为拖入或拖出,则不会产生碰撞,提前 return // 但是拖出时需要对原来的父节点做一次 runGravity // 拖入时不用对原来父节点做 runGravity return { safeX: x, safeY: y }; } // 碰撞模块 return gridCollsion(context, x, y); } ``` 为什么拖入时不用对原来父节点做 runGravity: 假设一个 `element` 从上向下移动入一个 `container`,那么一旦拖入 `container` 就会在其上方产生 Empty 区域,如果此时 `container` 立即受重力作用挤了上去,但鼠标还没松手,可能鼠标位置又立即落在了 `container` 之外,导致组件触发了拖出。因此拖入时,先不要立刻对原先所在的父容器作用重力,这样可以维持拖入时结构的稳定。 #### 拖入拖出模块 拖入拖出判断很简单,即一个 `element` 如果有 x% 进入了 `container` 就判定为拖入,有 y% 离开了 `container` 就判定为离开。 #### 碰撞模块 碰撞模块 `gridCollsion` 比较复杂,这里展开来讲。首先需要写一个矩形相交函数判断两个组件是否产生了碰撞: ```js function gridCollsion(context, x, y) { Object.keys(context.elementMap).forEach((componentId) => { // 判断 context.dragComponent 与 context.elementMap[componentId] 是否相交,相交则认为产生了碰撞 }); } ``` 如果没有产生碰撞,那我们要根据重力影响计算落点 `safeY`(横向不受重力作用且一定跟手,所以不用算 `safeX`)。此时直接调用 `runGravity` 函数,传一个 `extraBox`,这个 `extraBox` 就是当前鼠标位置产生的 box,这个 box 因为没有与任何组件产生碰撞,直接判断一下在重力的作用下,该 `extraBox` 会落在哪个位置即可,这个位置就是 `safeY`: ```js function gridCollision(context, x, y) { // 在某个父容器内计算重力,同时塞入一个 extraBox,返回这个 extraBox 生效重力后的 Y:extraBoxY const { extraBoxY } = runGravity(context, parentId, extraBox); return { safeY: extraBoxY }; } ``` 没有产生碰撞的逻辑相对简单,如果产生了碰撞的逻辑是这样的: ```js // 是否为初始化碰撞。初始化碰撞优先级最低,所以只要发生过非初始碰撞,与其他组件的初始碰撞也视为非初始碰撞 let isInitCollision = true; Object.keys(context.elementMap).forEach((componentId) => { // 判断 context.dragComponent 与 context.elementMap[componentId] 是否相交 const intersect = areRectanglesOverlap(); // 相交 if (intersect.isIntersect) { // 1. 在 context 存储一个全局变量,判断当前组件之前是否相交过,以此来判断是否要修改 isInitCollision // 2. 判断产生碰撞后,该碰撞会导致鼠标位置的 box,也就是 extraBox 放到该组件之上还是之下 } }); ``` 首先要确定当前碰撞是否为初始化碰撞,且一旦有一个组件不是初始化碰撞,就认为没有发生初始化碰撞。原因是初始化碰撞的位置判断比较简单,直接根据 source 与 target `element` 的水平中心点的高低来判断落地位置。如果 source 水平中心点位置比 target 的高,则放到 target 上方,否则放在 target 下方。 如果是非初始化碰撞逻辑会复杂一些,比如下面的例子: ```js // [---] [ C ] // [ B ] // [---] // ↑ // [-------] // [ A ] // [-------] ``` 当 A 组件向上移动时,因为已经与 B 产生了碰撞,所以就会尝试判断合适置于 B 之上,否则永远会把自己锁在 B 的下方。实际上,我们希望 A 的上边缘超过 B 的水平中心点就产生交换,此时 A 的水平中心点还在 B 的水平中心点之下,所以此时按照两种不同的判断规则会产生不同的位置判定,区分的手段就是 A 与 B 是否已经处于相交状态。 现在终于把插入位置算好了(根据是否初始化碰撞,判断 extraBox 落在哪个 `element` 的上方或者下方),那么就进入 `runGravity` 函数: ```js function runGravity(context, parentId, extraBox) {} ``` 这个函数针对某个父容器节点生效重力,因此在不考虑 `extraBox` 的情况下逻辑是这样的: 先拿到该容器下所有子 `element`,对这些 `element` 按照 y 从小到大排序,然后依次计算落点,已经计算过的组件会计算到碰撞影响范围内,也就是新的组件 y 要尽可能小,但如果水平方向与已经算过的组件存在重叠,那么只能顶到这些组件的下方。 如果有 `extraBox` 的话,问题就复杂了一些,看下面的图: ```ts // [---] [ C ] // [ B ] // [---] // ↑ // [-------] // [ A ] // [-------] // A 这个 extraBox before B // 这个例子应该按照 C -> A -> B 的顺序计算重力 // 规则:如果有 before ids(ids y,bottom 都一样),则把排序结果中 y >= ids.y & bottom < ids[0].bottom 的组件抽出来放到 ids 第一个组件之前 // [-------] // [ A ] // [-------] // ↓ // [---] [ C ] // [ B ] // [---] // A 这个 extraBox after B // 这个例子应该按照 C -> A -> B 的顺序计算重力 // 规则:如果有 after ids(ids y,bottom 都一样),则把排序结果中 y <= ids.y & bottom > ids[0].bottom 的组件抽出来放到 ids 最后一个组件之后 ``` 因为 `extraBox` 是一个插入性质的位置,所以计算方式肯定有所不同。以第一个例子为例:当 A 向上移动并可以与 B 产生交换时,最后希望的结果自上至下是 C -> A -> B,但因为 C 和 B 的 y 都是 0,如果我们把 A 与 B 交换理解为 A 的 y 变成 0 从而把 B 挤下去,那么 A 也会把 C 挤下去,导致结果不对。 因此重要的是计算重力的优先级,上面的例子重力计算顺序应该是先算 C,再算 A,再算 B,这个逻辑的判断依据如上面注释所说。 上面说的都是 `isInitCollision=false` 的算法,如果 `isInitCollision=true`,则 `extraBox` 按照 y 顺序普通插入即可。原因看下图: ```js // [-------] [-] // [ ] [ ] // [ ] [D] // [ A ] → [ ] // [ ] [-] // [ ] [-----------------] // [-------] [ ] // [-----] [ C ] // [ B ] [ ] // [-----] [-----------------] ``` 当将 A 向右移动直到与 C 碰撞时,按照 y 来计算重力优先级时结果是正确的。如果按照 extraBox 已产生过碰撞的算法,则会认为 A 放到 C 的上方,但因为 B 相对于 C 满足 `y >= ids.y & bottom < ids[0].bottom`,所以会被提取到 C 的前面计算,导致 B 放在了 A 前面,产生了错误结果。因为这种碰撞被误判为 “A 从 C 的下方向上移动,直到与 C 交换,此时 B 依然要置于 A 的上方”,但实际上并没有产生这样的移动,而是 A 与 C 的一次初始化碰撞,因此不能适用这个算法。 ## 总结 因为篇幅有限,本文仅介绍磁贴布局实现最关键的部分,其他比如步长功能,如果后续有机会再单独整理成一篇文章发出来。 从上面的讨论可以发现,在每次移动时都要重新计算 safe 位置的落点,而这个落点又依赖 `runGravity` 函数,如果每次都要把容器下所有组件排序,并一一计算落点位置的话,时间复杂度达到了 O(n²),如果画布有 100 个组件,就会至少循环一万次,对性能压力是比较大的。因此磁贴布局也要做性能优化,这个我们放到下篇文章介绍。 > 讨论地址是:[精读《磁贴布局 - 功能实现》· Issue #459 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/459) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh))