如何利用 Vue 的自定义指令和 `MutationObserver`,实现一个响应式网格布局,支持列宽拖拽和自动重排?

各位屏幕前的老铁们,大家好! 今天咱们来聊聊怎么用 Vue 的自定义指令和 MutationObserver,打造一个响应式、可拖拽、自动重排的网格布局。 这玩意儿听起来唬人,其实拆解开来,也就那么回事儿。 咱们争取用最接地气的方式,把这事儿给整明白。

一、需求分析:我们要造个啥?

在开始撸代码之前,咱们得先搞清楚目标:

  1. 响应式: 布局要能根据屏幕尺寸自动调整,保证在各种设备上都能看。
  2. 列宽拖拽: 允许用户手动调整列的宽度,就像你在 Excel 里拉表格一样。
  3. 自动重排: 当列宽变化时,其他列要能自动调整大小,保持整体布局的平衡。
  4. 基于 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);
      },
    });
  },
};

这个自定义指令实现了以下功能:

  1. 找到列的拖拽手柄(.resize-handle)。
  2. 监听手柄的 mousedown 事件,开始拖拽。
  3. mousemove 事件中,计算新的列宽,并更新 DOM 元素的 width 样式。
  4. 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
        }
      });
    }

这段代码的逻辑如下:

  1. 计算所有固定宽度列(单位为 px)的总宽度。
  2. 计算网格容器的剩余空间,需要减去 gap 的总宽度。
  3. 计算需要动态调整的列(单位为 fr)的数量。
  4. 如果没有需要动态调整的列,直接返回。
  5. 计算每份剩余空间的宽度。
  6. 更新所有 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
        }
      });
    }
  }

这段代码的逻辑很简单:

  1. mounted 钩子中,监听 resize 事件,并调用 handleResize 方法。
  2. beforeDestroy 钩子中,移除 resize 事件监听器,避免内存泄漏。
  3. handleResize 方法调用 recalculateColumns 方法,重新计算列宽。

六、进阶优化:让布局更强大

到这里,我们的布局已经基本完成了。 但是,还有一些可以优化的地方:

  1. 限制最小列宽: 可以设置一个最小列宽,防止用户将列拖拽得太窄,影响内容显示。
  2. 节流: resize 事件触发频率很高,可以对 handleResize 方法进行节流,避免频繁计算列宽。
  3. 持久化: 可以将列宽保存到 localStorage 中,下次打开页面时恢复之前的布局。
  4. 更灵活的重排策略: 目前的重排策略是将剩余空间平均分配给 fr 列。 可以根据实际需求,实现更灵活的重排策略,例如:优先调整某些列的宽度。
  5. 支持更多单位: 除了 pxfr,还可以支持其他单位,例如:%emrem 等。

七、总结:代码之外的思考

咱们用 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.throttlehandleResize 方法进行节流,避免频繁计算列宽。
  • 事件监听器移除: 在unbind中正确移除了事件监听器.

希望这篇文章能帮助你理解 Vue 自定义指令和 MutationObserver 的用法,并能应用到实际项目中。 咱们下期再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注