背景
最近在弄自定义表单,需要拖动组件进行表单设计,所以用到了 Vue.Draggable(中文文档)。Vue.Draggable 是一款基于 Sortable.js 实现的 vue 拖拽插件,文档挺简单的,用起来也方便,但没想到接下来给我遇到了灵异事件…
坑的表现
当我写完了由配置对象到组件的渲染逻辑之后,便开始了阶段性测试。我先是拖入了一个输入框,它正常的渲染了出来,并且各项功能都很正常。
然后我又拖了个文本域进去,随手把它放在输入框的下面。
结果意想不到的事发生了,文本域居然跑到了输入框的上面去了,我惊呆了…
印象中拖入放置时元素在列表中的位置是 Vue.Draggable 自己维护的啊,我没做什么控制,怎么可能出问题呢?满脑子疑惑的我又拖了个文本域放在输入框下面,结果它有一次惊呆了我,它正常了,没有跑到输入框上面去…
我刷新页面打算重新试一下。
● 第一步,拖入一个输入框,正常。
● 第二步,拖入一个文本域放在输入框下面,不正常,跑上面去了。
● 第三步,再次拖入一个文本域放在输入框下面,正常。
好家伙,看来按这个步骤是百分百重现了。老老实实去检查代码,确认没有手动维护过 Vue.Draggable 中的 list
。在 add
事件中打印 event.newIndex
(以下称 addEvent.newIndex
),发现 addEvent.newIndex
的值是正常的,但是却与新增元素在 list
中的下标不一致,又在 change
事件中打印 newIndex
(以下称 changeEvent.newIndex
),发现 changeEvent.newIndex
却是指向新元素在 list
中的位置。
但是 changeEvent.newIndex
的值不对啊!它应该跟 addEvent.newIndex
一样才对啊!文本域应该在输入框的下面才对啊!啊啊啊!!!难道我发现了 Vue.Draggable 的 BUG?
填坑
结论直达
两个事件中的 newIndex
完全是由 Vue.Draggable 自身维护的,要想找到导致它俩不同的原因,只能去看看 Vue.Draggable 的源码了。于是我拉取了 Vue.Draggable 的源码,打算来研究一下。值得庆幸的是 Vue.Draggable 的源码很少,只有 400 多行,读起来比较简单。
我很快找到了下面处理 add
事件的代码。
// Vue.Draggable 源码
// onDragAdd 方法是 Draggable 组件内部方法,它调用之后才会 emit Draggable 组件的 add 事件
// 可以在源码中搜索 delegateAndEmit 查找绑定事件的位置
// vue 组件 methods 选项中的方法
onDragAdd(evt) {
const element = evt.item._underlying_vm_;
if (element === undefined) {
return;
}
removeNode(evt.item);
// evt.newIndex 是 add 事件中的 newIndex
const newIndex = this.getVmIndex(evt.newIndex);
this.spliceList(newIndex, 0, element);
this.computeIndexes();
// added.newIndex 是 change 事件中的 newIndex
const added = { element, newIndex };
this.emitChanges({ added });
},
从上面的代码中可以看出,change
事件中的 newIndex
是 add
事件中的 newIndex
经由 this.getVmIndex()
方法加工而来的。那么我们看一下 this.getVmIndex()
方法做了什么加工导致了它们的不一样。
// Vue.Draggable 源码
// added.newIndex 依赖于 this.visibleIndexes (一个数组),当新元素的下标小于 this.visibleIndexes 的长度减一时,返回 this.visibleIndexes 的长度,否则返回 this.visibleIndexes 中下标为 domIndex 的值
/**
* vue 组件 methods 选项中的方法,计算并返回 change 事件中的 newIndex
* @param {number} domIndex - add 事件中的 newIndex
* @returns {number}
*/
getVmIndex(domIndex) {
const indexes = this.visibleIndexes;
const numberIndexes = indexes.length;
return domIndex > numberIndexes - 1 ? numberIndexes : indexes[domIndex];
},
getVmIndex()
方法用于计算 changeEvent.newIndex
。它的参数 domIndex
即为 addEvent.newIndex
。
从上面的代码可以看出 changeEvent.newIndex
还依赖于 this.visibleIndexes
(一个数组),当 domIndex
(新元素的下标)大于 this.visibleIndexes
的长度 - 1(最后一个元素的下标)时,changeEvent.newIndex
为 this.visibleIndexes
的长度(最后一个元素的下标 + 1),否则为 this.visibleIndexes
中下标为 domIndex
的值。
看来还要弄明白 this.visibleIndexes
是什么,下面的代码说明了 this.visibleIndexes
的由来。
// vue 组件 methods 选项中的方法,
computeIndexes() {
this.$nextTick(() => {
// 这个 computeIndexes 并不是在 methods 中声明的,因此调用时没有使用 this
this.visibleIndexes = computeIndexes(
this.getChildrenNodes(),
this.rootContainer.children,
this.transitionMode,
this.footerOffset
);
});
},
/**
* 计算 this.visibleIndexes 列表
* @param {Array<VNode>} slots - isTransition 为 true 时,表示 TransitionGroup 的默认插槽,否则表示 draggable 组件的默认插槽
* @param {Array<Node>} children - isTransition 为 true 时,表示 TransitionGroup 子元素列表,否则表示 draggable 组件子元素列表
* @param {boolean} isTransition - 是否使用了 TransitionGroup 组件
* @param {number} footerOffset - footer 插槽根元素的个数,没有使用 footer 插槽时为 0
* @returns
*/
function computeIndexes(slots, children, isTransition, footerOffset) {
if (!slots) {
return [];
}
const elmFromNodes = slots.map(elt => elt.elm);
const footerIndex = children.length - footerOffset;
// rawIndexes 列表表示显示的节点,其虚拟节点在 slots 中的位置
const rawIndexes = [...children].map((elt, idx) => {
return idx >= footerIndex ? elmFromNodes.length : elmFromNodes.indexOf(elt);
});
// 如果使用了 TransitionGroup 组件,则将 children 中有而 slots 中没有的过滤掉
return isTransition ? rawIndexes.filter(ind => ind !== -1) : rawIndexes;
}
由上面的代码可以看出 rawIndexes
数组表示:显示的节点,其虚拟节点在 slots 中的位置。rawIndexes
元素的下标表示节点在 children
中的下标,元素的值表示节点在 slots
中的下标(如果节点在 footer 之后,则值为 slots
的长度)。
现在我们再来看 getVmIndex()
方法,this.visibleIndexes
是由 slots
和 children
维护的,而它决定了 changeEvent.newIndex
的值,所以影响 changeEvent.newIndex
的根本因素就是 slots
和 children
。
找到了根本因素接下来就简单了。我重复执行出现问题的操作步骤,然后在这个过程中打印 slots
和 children
,我惊讶的发现当我向 Vue.Draggable 第一次拖入输入框组件时,slots
和 children
的长度居然不一样!children
是空的, 而 slots
的长度虽然正常,但其中的虚拟节点的 elm
属性却是 undefined
看到这里我恍然大悟,正是 slots
和 children
异常的值导致了 changeEvent.newIndex
的计算错误,那么是什么导致了它们值的异常呢?也许你有注意到 slots
虽然长度正常,但其中的虚拟节点的 elm
属性却是 undefined
!
是的,没错,正是因为输入框组件采用了懒加载的方式进行引入,而导致的这个诡异的问题!
万万没想到,组件的引入方式居然还会导致奇怪的问题出现!
总结
所以,如果想在 Vue.Draggable 中使用自定义组件,那么千万不要使用懒加载的方式引入这些组件!