各位屏幕前的老铁们,大家好! 今天咱们来聊聊怎么用 Vue 的自定义指令和 MutationObserver
,打造一个响应式、可拖拽、自动重排的网格布局。 这玩意儿听起来唬人,其实拆解开来,也就那么回事儿。 咱们争取用最接地气的方式,把这事儿给整明白。
一、需求分析:我们要造个啥?
在开始撸代码之前,咱们得先搞清楚目标:
- 响应式: 布局要能根据屏幕尺寸自动调整,保证在各种设备上都能看。
- 列宽拖拽: 允许用户手动调整列的宽度,就像你在 Excel 里拉表格一样。
- 自动重排: 当列宽变化时,其他列要能自动调整大小,保持整体布局的平衡。
- 基于 Vue: 所有操作都要在 Vue 的框架下进行,充分利用 Vue 的数据绑定和组件化能力。
二、技术选型:兵器库里的家伙事儿
要实现这些功能,我们需要用到以下几个关键技术:
- Vue 自定义指令: 用于直接操作 DOM 元素,实现拖拽功能。
MutationObserver
: 监听 DOM 变化,当列宽改变时触发自动重排。- CSS Grid 布局: 提供灵活的网格布局能力,简化响应式和重排的实现。
- Vue 计算属性: 动态计算列宽,实现响应式布局。
三、代码实现:撸起袖子就是干
咱们先来搭个架子,建一个 Vue 组件,里面包含网格容器和一些列:
<template>
<div class="grid-container" ref="gridContainer">
<div
v-for="(col, index) in columns"
:key="index"
class="grid-item"
:style="{ width: col.width }"
v-drag-resize="col"
>
<div class="grid-item-content">
Column {{ index + 1 }}
</div>
<div class="resize-handle"></div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
columns: [
{ width: '1fr' },
{ width: '1fr' },
{ width: '1fr' },
],
};
},
mounted() {
this.observeGridChanges();
},
methods: {
observeGridChanges() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
// 列宽变化了,重新计算
this.recalculateColumns();
}
});
});
observer.observe(this.$refs.gridContainer, {
attributes: true,
childList: true,
subtree: true,
});
},
recalculateColumns() {
// 这里是重新计算列宽的逻辑,后面会详细讲解
console.log("列宽变化了,需要重新计算");
}
}
};
</script>
<style scoped>
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); /* 初始的响应式布局 */
gap: 10px;
padding: 10px;
}
.grid-item {
position: relative;
background-color: #eee;
border: 1px solid #ccc;
padding: 10px;
box-sizing: border-box;
overflow: hidden; /* 避免内容溢出 */
}
.grid-item-content {
/* 你的内容 */
}
.resize-handle {
position: absolute;
top: 0;
right: 0;
width: 10px;
height: 100%;
background-color: rgba(0, 0, 0, 0.2);
cursor: ew-resize;
}
</style>
这段代码定义了一个简单的网格布局,使用了 CSS Grid 实现了基本的响应式效果。 现在,咱们来重点实现自定义指令 v-drag-resize
,让列可以拖拽:
// dragResize.js
export default {
install(Vue) {
Vue.directive('drag-resize', {
bind(el, binding) {
const handle = el.querySelector('.resize-handle');
if (!handle) return;
let startX = 0;
let startWidth = 0;
let isDragging = false;
const dragStart = (e) => {
isDragging = true;
startX = e.clientX;
startWidth = el.offsetWidth;
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', dragEnd);
};
const drag = (e) => {
if (!isDragging) return;
const width = startWidth + (e.clientX - startX);
el.style.width = width + 'px';
};
const dragEnd = () => {
isDragging = false;
document.removeEventListener('mousemove', drag);
document.removeEventListener('mouseup', dragEnd);
// 更新 data 中的 width 值
binding.value.width = el.style.width;
};
handle.addEventListener('mousedown', dragStart);
},
unbind(el) {
// 移除事件监听器,避免内存泄漏
const handle = el.querySelector('.resize-handle');
if (!handle) return;
handle.removeEventListener('mousedown', this.dragStart); //这里需要用之前保存的函数引用,暂时省略
document.removeEventListener('mousemove', this.drag);
document.removeEventListener('mouseup', this.dragEnd);
},
});
},
};
这个自定义指令实现了以下功能:
- 找到列的拖拽手柄(
.resize-handle
)。 - 监听手柄的
mousedown
事件,开始拖拽。 - 在
mousemove
事件中,计算新的列宽,并更新 DOM 元素的width
样式。 - 在
mouseup
事件中,停止拖拽,并将新的列宽同步到 Vue 的data
中。
别忘了在 main.js
中注册这个指令:
import Vue from 'vue'
import App from './App.vue'
import dragResize from './dragResize';
Vue.use(dragResize);
new Vue({
render: h => h(App),
}).$mount('#app')
现在,你可以拖拽列的宽度了,但是其他列不会自动调整。 接下来,咱们来实现自动重排的逻辑。
四、自动重排:让布局动起来
自动重排的关键在于监听列宽的变化,并根据新的列宽重新计算其他列的宽度。 咱们可以使用 MutationObserver
来监听 DOM 变化,当列宽改变时触发自动重排。
回到组件的 methods
中,完善 recalculateColumns
方法:
recalculateColumns() {
// 1. 计算固定宽度列的总宽度
let fixedWidthSum = 0;
this.columns.forEach(col => {
if (col.width.endsWith('px')) {
fixedWidthSum += parseFloat(col.width);
}
});
// 2. 计算剩余空间
const containerWidth = this.$refs.gridContainer.offsetWidth;
const remainingSpace = containerWidth - fixedWidthSum - (this.columns.length - 1) * 10; // 减去 gap
// 3. 计算需要动态调整的列的数量
const flexibleColumnCount = this.columns.filter(col => col.width.endsWith('fr')).length;
// 4. 如果没有需要动态调整的列,直接返回
if (flexibleColumnCount === 0) return;
// 5. 计算每份剩余空间的宽度
const frWidth = remainingSpace / flexibleColumnCount;
// 6. 更新 fr 列的宽度
this.columns.forEach(col => {
if (col.width.endsWith('fr')) {
col.width = frWidth + 'px'; // 将 fr 转换为 px
}
});
}
这段代码的逻辑如下:
- 计算所有固定宽度列(单位为
px
)的总宽度。 - 计算网格容器的剩余空间,需要减去
gap
的总宽度。 - 计算需要动态调整的列(单位为
fr
)的数量。 - 如果没有需要动态调整的列,直接返回。
- 计算每份剩余空间的宽度。
- 更新所有
fr
列的宽度,将fr
转换为px
。 这里有个关键点:咱们把fr
转换成了px
。 这是因为MutationObserver
监听的是 DOM 元素的style
属性变化,而style
属性只能设置具体的像素值。
五、响应式优化:让布局更聪明
现在,我们的布局已经可以拖拽和自动重排了,但是还不够完美。 当屏幕尺寸变化时,布局不会自动调整。 为了实现真正的响应式,我们需要监听屏幕尺寸的变化,并重新计算列宽。
咱们可以使用 window.addEventListener('resize', ...)
来监听屏幕尺寸的变化。 在 mounted
钩子中添加以下代码:
mounted() {
this.observeGridChanges();
window.addEventListener('resize', this.handleResize);
this.handleResize(); // 初始化时也计算一次
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize);
},
methods: {
handleResize() {
// 在屏幕尺寸变化时,重新计算列宽
this.recalculateColumns();
},
observeGridChanges() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
// 列宽变化了,重新计算
this.recalculateColumns();
}
});
});
observer.observe(this.$refs.gridContainer, {
attributes: true,
childList: true,
subtree: true,
});
},
recalculateColumns() {
// 1. 计算固定宽度列的总宽度
let fixedWidthSum = 0;
this.columns.forEach(col => {
if (col.width.endsWith('px')) {
fixedWidthSum += parseFloat(col.width);
}
});
// 2. 计算剩余空间
const containerWidth = this.$refs.gridContainer.offsetWidth;
const remainingSpace = containerWidth - fixedWidthSum - (this.columns.length - 1) * 10; // 减去 gap
// 3. 计算需要动态调整的列的数量
const flexibleColumnCount = this.columns.filter(col => col.width.endsWith('fr')).length;
// 4. 如果没有需要动态调整的列,直接返回
if (flexibleColumnCount === 0) return;
// 5. 计算每份剩余空间的宽度
const frWidth = remainingSpace / flexibleColumnCount;
// 6. 更新 fr 列的宽度
this.columns.forEach(col => {
if (col.width.endsWith('fr')) {
col.width = frWidth + 'px'; // 将 fr 转换为 px
}
});
}
}
这段代码的逻辑很简单:
- 在
mounted
钩子中,监听resize
事件,并调用handleResize
方法。 - 在
beforeDestroy
钩子中,移除resize
事件监听器,避免内存泄漏。 handleResize
方法调用recalculateColumns
方法,重新计算列宽。
六、进阶优化:让布局更强大
到这里,我们的布局已经基本完成了。 但是,还有一些可以优化的地方:
- 限制最小列宽: 可以设置一个最小列宽,防止用户将列拖拽得太窄,影响内容显示。
- 节流:
resize
事件触发频率很高,可以对handleResize
方法进行节流,避免频繁计算列宽。 - 持久化: 可以将列宽保存到
localStorage
中,下次打开页面时恢复之前的布局。 - 更灵活的重排策略: 目前的重排策略是将剩余空间平均分配给
fr
列。 可以根据实际需求,实现更灵活的重排策略,例如:优先调整某些列的宽度。 - 支持更多单位: 除了
px
和fr
,还可以支持其他单位,例如:%
、em
、rem
等。
七、总结:代码之外的思考
咱们用 Vue 的自定义指令和 MutationObserver
,实现了一个响应式、可拖拽、自动重排的网格布局。 这个过程中,我们学到了以下几点:
- 分解问题: 将复杂的需求分解成小的、可管理的任务。
- 选择合适的工具: 根据需求选择合适的工具和技术。
- 关注细节: 注意细节,例如:内存泄漏、性能优化等。
- 持续改进: 不断改进代码,使其更加健壮、灵活、易于维护。
最后,送给大家一句鸡汤:代码是写给人看的,顺便让机器执行。 写代码不仅要实现功能,还要考虑代码的可读性和可维护性。
附录:完整代码
这里提供一个完整的代码示例,包含所有的功能:
<template>
<div class="grid-container" ref="gridContainer">
<div
v-for="(col, index) in columns"
:key="index"
class="grid-item"
:style="{ width: col.width }"
v-drag-resize="col"
>
<div class="grid-item-content">
Column {{ index + 1 }}
</div>
<div class="resize-handle"></div>
</div>
</div>
</template>
<script>
import throttle from 'lodash.throttle';
export default {
data() {
return {
columns: [
{ width: '1fr', minWidth: 50 }, // 添加最小宽度
{ width: '1fr', minWidth: 50 },
{ width: '1fr', minWidth: 50 },
],
minColumnWidth: 50,
};
},
mounted() {
this.observeGridChanges();
this.handleResize(); // 初始化时也计算一次
window.addEventListener('resize', this.throttledHandleResize);
},
beforeDestroy() {
window.removeEventListener('resize', this.throttledHandleResize);
},
created() {
this.throttledHandleResize = throttle(this.handleResize, 200); // 200ms 节流
},
methods: {
handleResize() {
// 在屏幕尺寸变化时,重新计算列宽
this.recalculateColumns();
},
observeGridChanges() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
// 列宽变化了,重新计算
this.recalculateColumns();
}
});
});
observer.observe(this.$refs.gridContainer, {
attributes: true,
childList: true,
subtree: true,
});
},
recalculateColumns() {
let fixedWidthSum = 0;
let flexibleColumnCount = 0;
this.columns.forEach(col => {
if (col.width.endsWith('px')) {
let width = parseFloat(col.width);
width = Math.max(width, col.minWidth); // 应用最小宽度
fixedWidthSum += width;
col.width = width + 'px'; // 更新 data 中的宽度
} else if (col.width.endsWith('fr')) {
flexibleColumnCount++;
}
});
const containerWidth = this.$refs.gridContainer.offsetWidth;
const remainingSpace = containerWidth - fixedWidthSum - (this.columns.length - 1) * 10;
if (flexibleColumnCount === 0) return;
const frWidth = remainingSpace / flexibleColumnCount;
this.columns.forEach(col => {
if (col.width.endsWith('fr')) {
let width = Math.max(frWidth, col.minWidth); //应用最小宽度
col.width = width + 'px'; // 将 fr 转换为 px
}
});
}
}
};
</script>
<style scoped>
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); /* 初始的响应式布局 */
gap: 10px;
padding: 10px;
}
.grid-item {
position: relative;
background-color: #eee;
border: 1px solid #ccc;
padding: 10px;
box-sizing: border-box;
overflow: hidden; /* 避免内容溢出 */
min-width: 50px;
}
.grid-item-content {
/* 你的内容 */
}
.resize-handle {
position: absolute;
top: 0;
right: 0;
width: 10px;
height: 100%;
background-color: rgba(0, 0, 0, 0.2);
cursor: ew-resize;
}
</style>
// dragResize.js
export default {
install(Vue) {
Vue.directive('drag-resize', {
bind(el, binding) {
const handle = el.querySelector('.resize-handle');
if (!handle) return;
let startX = 0;
let startWidth = 0;
let isDragging = false;
const dragStart = (e) => {
isDragging = true;
startX = e.clientX;
startWidth = el.offsetWidth;
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', dragEnd);
};
const drag = (e) => {
if (!isDragging) return;
const width = startWidth + (e.clientX - startX);
// 应用最小宽度限制
const minWidth = binding.value.minWidth || 50; // 默认最小宽度为 50px
if (width < minWidth) {
width = minWidth;
}
el.style.width = width + 'px';
};
const dragEnd = () => {
isDragging = false;
document.removeEventListener('mousemove', drag);
document.removeEventListener('mouseup', dragEnd);
// 更新 data 中的 width 值
binding.value.width = el.style.width;
};
handle.addEventListener('mousedown', dragStart);
// 保存 dragStart,drag,dragEnd 的引用,方便移除
el.dragStart = dragStart;
el.drag = drag;
el.dragEnd = dragEnd;
},
unbind(el) {
// 移除事件监听器,避免内存泄漏
const handle = el.querySelector('.resize-handle');
if (!handle) return;
handle.removeEventListener('mousedown', el.dragStart);
document.removeEventListener('mousemove', el.drag);
document.removeEventListener('mouseup', el.dragEnd);
},
});
},
};
这个代码示例添加了以下优化:
- 最小列宽: 每个列对象都添加了
minWidth
属性,用于限制最小列宽。 - 节流: 使用
lodash.throttle
对handleResize
方法进行节流,避免频繁计算列宽。 - 事件监听器移除: 在unbind中正确移除了事件监听器.
希望这篇文章能帮助你理解 Vue 自定义指令和 MutationObserver
的用法,并能应用到实际项目中。 咱们下期再见!