如何在一个 Vue 应用中,实现一个可拖拽、可缩放的自由布局容器,并处理元素的吸附对齐和层级管理?

各位观众,大家好!我是今天的主讲人,咱们今天来聊聊如何在 Vue 应用中打造一个炫酷的、自由拖拽缩放布局容器。这玩意儿可不是简单的 div 就能搞定的,我们需要一些巧劲儿和心思。准备好了吗?咱们这就开始!

第一幕:舞台搭建——基础结构与 Vue 组件

首先,我们需要一个 Vue 组件,作为我们自由布局容器的载体。这个组件负责管理所有可拖拽、可缩放的元素,以及处理吸附对齐和层级关系。

<template>
  <div class="free-layout-container"
       @mousedown="startDragContainer"
       @mouseup="stopDragContainer"
       @mousemove="dragContainer"
       :style="{ width: containerWidth + 'px', height: containerHeight + 'px' }">
    <draggable-item
      v-for="item in items"
      :key="item.id"
      :item="item"
      @item-updated="updateItem"
      @bring-to-front="bringToFront"
      @send-to-back="sendToBack"
    />
  </div>
</template>

<script>
import DraggableItem from './DraggableItem.vue';

export default {
  components: {
    DraggableItem,
  },
  data() {
    return {
      items: [ // 初始元素列表
        { id: 1, x: 50, y: 50, width: 100, height: 80, zIndex: 1 },
        { id: 2, x: 200, y: 100, width: 150, height: 120, zIndex: 2 },
        { id: 3, x: 350, y: 150, width: 80, height: 100, zIndex: 3 },
      ],
      containerWidth: 800,
      containerHeight: 600,
      isDraggingContainer: false,
      startContainerX: 0,
      startContainerY: 0,
    };
  },
  methods: {
    updateItem(updatedItem) {
      const index = this.items.findIndex(item => item.id === updatedItem.id);
      if (index !== -1) {
        this.$set(this.items, index, updatedItem); // 使用 $set 触发响应式更新
      }
    },
    bringToFront(itemId) {
      const item = this.items.find(item => item.id === itemId);
      if (item) {
        item.zIndex = Math.max(...this.items.map(item => item.zIndex)) + 1;
      }
    },
    sendToBack(itemId) {
      const item = this.items.find(item => item.id === itemId);
      if (item) {
        item.zIndex = Math.min(...this.items.map(item => item.zIndex)) - 1;
      }
    },
        startDragContainer(event) {
      this.isDraggingContainer = true;
      this.startContainerX = event.clientX - this.$el.offsetLeft;
      this.startContainerY = event.clientY - this.$el.offsetTop;
    },
    stopDragContainer() {
      this.isDraggingContainer = false;
    },
    dragContainer(event) {
      if (!this.isDraggingContainer) return;

      const currentX = event.clientX - this.$el.offsetLeft;
      const currentY = event.clientY - this.$el.offsetTop;

      const deltaX = currentX - this.startContainerX;
      const deltaY = currentY - this.startContainerY;

      // 在这里可以实现容器的拖动逻辑,例如改变容器的位置
      // 但由于容器只是个背景,这里我们主要关注元素内部的拖动和缩放
    },
  },
};
</script>

<style scoped>
.free-layout-container {
  position: relative; /* 关键:设置相对定位,子元素绝对定位才能相对于它 */
  border: 1px dashed #ccc;
  overflow: hidden; /* 防止子元素超出容器 */
  background-color: #f0f0f0;
  cursor: grab;
}
</style>

这段代码定义了一个名为 free-layout-container 的容器,它使用 v-for 渲染 draggable-item 组件。注意几点:

  • position: relative: 容器必须是相对定位,这样内部的元素才能相对于它进行绝对定位。
  • overflow: hidden: 防止子元素超出容器边界。
  • items: 一个数组,存储了每个元素的初始位置、大小和层级信息。
  • updateItem: 用于更新 items 数组中元素的信息。
  • bringToFront & sendToBack: 用于调整元素的层级。
    startDragContainer,stopDragContainer,dragContainer: 用于实现容器的拖动(这个例子中,容器的拖动只是一个占位符,实际应用中可以实现容器的拖动)。

第二幕:元素登场——可拖拽缩放的 DraggableItem 组件

接下来,我们需要创建一个 DraggableItem 组件,让每个元素都具备拖拽和缩放的能力。

<template>
  <div
    class="draggable-item"
    :style="{
      width: item.width + 'px',
      height: item.height + 'px',
      transform: `translate(${item.x}px, ${item.y}px)`,
      zIndex: item.zIndex,
      backgroundColor: backgroundColor
    }"
    @mousedown="startDrag"
    @mouseup="stopDrag"
    @mousemove="drag"
    @dblclick="bringToFront"
  >
    <div class="resize-handle top-left" @mousedown.stop="startResize('top-left')"></div>
    <div class="resize-handle top-right" @mousedown.stop="startResize('top-right')"></div>
    <div class="resize-handle bottom-left" @mousedown.stop="startResize('bottom-left')"></div>
    <div class="resize-handle bottom-right" @mousedown.stop="startResize('bottom-right')"></div>
    <div class="bring-to-front-button" @click.stop="bringToFront">置顶</div>
    <div class="send-to-back-button" @click.stop="sendToBack">置底</div>
  </div>
</template>

<script>
export default {
  props: {
    item: {
      type: Object,
      required: true,
    },
  },
  data() {
    return {
      isDragging: false,
      isResizing: false,
      startDragX: 0,
      startDragY: 0,
      startResizeX: 0,
      startResizeY: 0,
      startWidth: 0,
      startHeight: 0,
      resizeDirection: null,
      backgroundColor: this.getRandomColor(),
    };
  },
  methods: {
    startDrag(event) {
      this.$emit('bring-to-front', this.item.id); // 拖动时置顶
      this.isDragging = true;
      this.startDragX = event.clientX;
      this.startDragY = event.clientY;
    },
    stopDrag() {
      this.isDragging = false;
    },
    drag(event) {
      if (!this.isDragging) return;

      const deltaX = event.clientX - this.startDragX;
      const deltaY = event.clientY - this.startDragY;

      const newX = this.item.x + deltaX;
      const newY = this.item.y + deltaY;

      // 边界检测 (容器坐标是 0,0)
      const containerWidth = this.$parent.containerWidth;
      const containerHeight = this.$parent.containerHeight;

      let clampedX = Math.max(0, Math.min(newX, containerWidth - this.item.width));
      let clampedY = Math.max(0, Math.min(newY, containerHeight - this.item.height));

      // 吸附对齐 (水平和垂直方向的吸附)
      clampedX = this.snapToGrid(clampedX, containerWidth, 'x');
      clampedY = this.snapToGrid(clampedY, containerHeight, 'y');

      this.$emit('item-updated', { ...this.item, x: clampedX, y: clampedY });

      this.startDragX = event.clientX;
      this.startDragY = event.clientY;
    },
    startResize(direction) {
      this.$emit('bring-to-front', this.item.id); // 缩放时置顶
      this.isResizing = true;
      this.resizeDirection = direction;
      this.startResizeX = event.clientX;
      this.startResizeY = event.clientY;
      this.startWidth = this.item.width;
      this.startHeight = this.item.height;
    },
    stopResize() {
      this.isResizing = false;
      this.resizeDirection = null;
    },
    resize(event) {
      if (!this.isResizing) return;

      const deltaX = event.clientX - this.startResizeX;
      const deltaY = event.clientY - this.startResizeY;

      let newWidth = this.startWidth;
      let newHeight = this.startHeight;
      let newX = this.item.x;
      let newY = this.item.y;

      const minSize = 20; // 最小尺寸

      // 根据不同的方向计算新的宽度和高度
      switch (this.resizeDirection) {
        case 'top-left':
          newWidth = this.startWidth - deltaX;
          newHeight = this.startHeight - deltaY;
          newX = this.item.x + deltaX;
          newY = this.item.y + deltaY;
          break;
        case 'top-right':
          newWidth = this.startWidth + deltaX;
          newHeight = this.startHeight - deltaY;
          newY = this.item.y + deltaY;
          break;
        case 'bottom-left':
          newWidth = this.startWidth - deltaX;
          newHeight = this.startHeight + deltaY;
          newX = this.item.x + deltaX;
          break;
        case 'bottom-right':
          newWidth = this.startWidth + deltaX;
          newHeight = this.startHeight + deltaY;
          break;
      }

      // 尺寸限制
      newWidth = Math.max(minSize, newWidth);
      newHeight = Math.max(minSize, newHeight);

      // 边界检测和位置调整
      const containerWidth = this.$parent.containerWidth;
      const containerHeight = this.$parent.containerHeight;

      if (newX < 0) {
        newWidth += newX;
        newX = 0;
      }
      if (newY < 0) {
        newHeight += newY;
        newY = 0;
      }

      if (newX + newWidth > containerWidth) {
        newWidth = containerWidth - newX;
      }

      if (newY + newHeight > containerHeight) {
        newHeight = containerHeight - newY;
      }

      this.$emit('item-updated', { ...this.item, x: newX, y: newY, width: newWidth, height: newHeight });
    },
        bringToFront() {
      this.$emit('bring-to-front', this.item.id);
    },
    sendToBack() {
      this.$emit('send-to-back', this.item.id);
    },
        snapToGrid(value, containerSize, axis) {
        const snapThreshold = 10;  // 吸附阈值,单位像素
        const snapInterval = 20;   // 吸附间隔,单位像素

        for (let i = 0; i <= containerSize; i += snapInterval) {
            if (Math.abs(value - i) <= snapThreshold) {
                return i;
            }
        }
        return value;
    },
    getRandomColor() {
        const letters = '0123456789ABCDEF';
        let color = '#';
        for (let i = 0; i < 6; i++) {
          color += letters[Math.floor(Math.random() * 16)];
        }
        return color;
      },
  },
  mounted() {
    document.addEventListener('mouseup', this.stopResize);
    document.addEventListener('mousemove', this.resize);
  },
  beforeUnmount() {
    document.removeEventListener('mouseup', this.stopResize);
    document.removeEventListener('mousemove', this.resize);
  },
};
</script>

<style scoped>
.draggable-item {
  position: absolute; /* 关键:绝对定位 */
  box-sizing: border-box;
  border: 1px solid #333;
  cursor: move;
  display: flex;
  justify-content: center;
  align-items: center;
  user-select: none; /* 禁止选中,避免拖拽时选中文字 */
}

.resize-handle {
  position: absolute;
  width: 10px;
  height: 10px;
  background-color: #007bff;
  cursor: pointer;
}

.resize-handle.top-left {
  top: -5px;
  left: -5px;
  cursor: nw-resize;
}

.resize-handle.top-right {
  top: -5px;
  right: -5px;
  cursor: ne-resize;
}

.resize-handle.bottom-left {
  bottom: -5px;
  left: -5px;
  cursor: sw-resize;
}

.resize-handle.bottom-right {
  bottom: -5px;
  right: -5px;
  cursor: se-resize;
}

.bring-to-front-button, .send-to-back-button {
  position: absolute;
  top: 5px;
  right: 5px;
  padding: 2px 5px;
  background-color: rgba(0, 0, 0, 0.5);
  color: white;
  font-size: 12px;
  cursor: pointer;
  border-radius: 3px;
}

.send-to-back-button {
    top: 30px;
}
</style>

这个组件的代码有点长,我们来分解一下:

  • position: absolute: 元素必须是绝对定位,才能相对于容器进行自由定位。
  • transform: translate(): 使用 transform 来移动元素,性能更好。
  • zIndex: 控制元素的层级。
  • resize-handle: 四个角上的小方块,用于缩放元素。
  • startDrag, stopDrag, drag: 处理拖拽逻辑。
  • startResize, stopResize, resize: 处理缩放逻辑。
  • bringToFront, sendToBack: 将元素置顶或置底。
  • snapToGrid: 实现吸附对齐功能。
  • getRandomColor: 随机生成背景颜色,让每个元素看起来更醒目。
  • 事件监听器: 组件在 mounted 阶段注册全局的 mouseupmousemove 事件监听器,并在 beforeUnmount 阶段移除它们,以避免内存泄漏。

第三幕:吸附对齐——让布局更有秩序

snapToGrid 方法是实现吸附对齐的关键。它接受元素的位置、容器大小和轴向作为参数,然后将元素的位置吸附到最近的网格线上。

snapToGrid(value, containerSize, axis) {
    const snapThreshold = 10;  // 吸附阈值,单位像素
    const snapInterval = 20;   // 吸附间隔,单位像素

    for (let i = 0; i <= containerSize; i += snapInterval) {
        if (Math.abs(value - i) <= snapThreshold) {
            return i;
        }
    }
    return value;
}
  • snapThreshold: 吸附阈值,表示元素距离网格线多近时才会被吸附。
  • snapInterval: 网格线的间隔。

第四幕:层级管理——让元素不再乱序

bringToFrontsendToBack 方法用于调整元素的层级。它们通过修改元素的 zIndex 属性来实现。

bringToFront(itemId) {
  const item = this.items.find(item => item.id === itemId);
  if (item) {
    item.zIndex = Math.max(...this.items.map(item => item.zIndex)) + 1;
  }
},
sendToBack(itemId) {
  const item = this.items.find(item => item.id === itemId);
  if (item) {
    item.zIndex = Math.min(...this.items.map(item => item.zIndex)) - 1;
  }
},
  • bringToFront: 将元素的 zIndex 设置为所有元素中最大的 zIndex 加 1,从而将元素置于最顶层。
  • sendToBack: 将元素的 zIndex 设置为所有元素中最小的 zIndex 减 1,从而将元素置于最底层。

第五幕:事件处理——让操作更流畅

DraggableItem 组件中,我们需要监听鼠标事件,才能实现拖拽和缩放的功能。

  • mousedown: 当鼠标按下时,开始拖拽或缩放。
  • mouseup: 当鼠标松开时,停止拖拽或缩放。
  • mousemove: 当鼠标移动时,更新元素的位置或大小。

为了避免事件冲突,我们需要使用 .stop 修饰符来阻止事件冒泡。例如:

<div class="resize-handle top-left" @mousedown.stop="startResize('top-left')"></div>

第六幕:边界检测——让元素不跑出容器

在拖拽和缩放过程中,我们需要进行边界检测,防止元素超出容器的范围。

// 边界检测
const containerWidth = this.$parent.containerWidth;
const containerHeight = this.$parent.containerHeight;

let clampedX = Math.max(0, Math.min(newX, containerWidth - this.item.width));
let clampedY = Math.max(0, Math.min(newY, containerHeight - this.item.height));

这段代码将元素的位置限制在容器的范围内。

第七幕:性能优化——让体验更丝滑

  • 使用 transform: translate(): 使用 transform 来移动元素,比直接修改 lefttop 属性性能更好。
  • 避免频繁更新 DOM: 在拖拽和缩放过程中,不要频繁更新 DOM。可以先计算出新的位置和大小,然后在鼠标松开时再更新 DOM。
  • 使用 requestAnimationFrame: 使用 requestAnimationFrame 来优化动画效果。

代码示例 (FreeLayoutContainer.vue)

<template>
  <div class="free-layout-container"
       :style="{ width: containerWidth + 'px', height: containerHeight + 'px' }">
    <draggable-item
      v-for="item in items"
      :key="item.id"
      :item="item"
      @item-updated="updateItem"
      @bring-to-front="bringToFront"
      @send-to-back="sendToBack"
    />
  </div>
</template>

<script>
import DraggableItem from './DraggableItem.vue';

export default {
  components: {
    DraggableItem,
  },
  data() {
    return {
      items: [
        { id: 1, x: 50, y: 50, width: 100, height: 80, zIndex: 1 },
        { id: 2, x: 200, y: 100, width: 150, height: 120, zIndex: 2 },
        { id: 3, x: 350, y: 150, width: 80, height: 100, zIndex: 3 },
      ],
      containerWidth: 800,
      containerHeight: 600,
    };
  },
  methods: {
    updateItem(updatedItem) {
      const index = this.items.findIndex(item => item.id === updatedItem.id);
      if (index !== -1) {
        this.$set(this.items, index, updatedItem);
      }
    },
    bringToFront(itemId) {
      const item = this.items.find(item => item.id === itemId);
      if (item) {
        item.zIndex = Math.max(...this.items.map(item => item.zIndex)) + 1;
      }
    },
    sendToBack(itemId) {
      const item = this.items.find(item => item.id === itemId);
      if (item) {
        item.zIndex = Math.min(...this.items.map(item => item.zIndex)) - 1;
      }
    },
  },
};
</script>

<style scoped>
.free-layout-container {
  position: relative;
  border: 1px dashed #ccc;
  overflow: hidden;
  background-color: #f0f0f0;
}
</style>

代码示例 (DraggableItem.vue)

<template>
  <div
    class="draggable-item"
    :style="{
      width: item.width + 'px',
      height: item.height + 'px',
      transform: `translate(${item.x}px, ${item.y}px)`,
      zIndex: item.zIndex,
      backgroundColor: backgroundColor
    }"
    @mousedown="startDrag"
    @mouseup="stopDrag"
    @mousemove="drag"
    @dblclick="bringToFront"
  >
    <div class="resize-handle top-left" @mousedown.stop="startResize('top-left')"></div>
    <div class="resize-handle top-right" @mousedown.stop="startResize('top-right')"></div>
    <div class="resize-handle bottom-left" @mousedown.stop="startResize('bottom-left')"></div>
    <div class="resize-handle bottom-right" @mousedown.stop="startResize('bottom-right')"></div>
    <div class="bring-to-front-button" @click.stop="bringToFront">置顶</div>
    <div class="send-to-back-button" @click.stop="sendToBack">置底</div>
  </div>
</template>

<script>
export default {
  props: {
    item: {
      type: Object,
      required: true,
    },
  },
  data() {
    return {
      isDragging: false,
      isResizing: false,
      startDragX: 0,
      startDragY: 0,
      startResizeX: 0,
      startResizeY: 0,
      startWidth: 0,
      startHeight: 0,
      resizeDirection: null,
      backgroundColor: this.getRandomColor(),
    };
  },
  methods: {
    startDrag(event) {
      this.$emit('bring-to-front', this.item.id);
      this.isDragging = true;
      this.startDragX = event.clientX;
      this.startDragY = event.clientY;
    },
    stopDrag() {
      this.isDragging = false;
    },
    drag(event) {
      if (!this.isDragging) return;

      const deltaX = event.clientX - this.startDragX;
      const deltaY = event.clientY - this.startDragY;

      const newX = this.item.x + deltaX;
      const newY = this.item.y + deltaY;

      const containerWidth = this.$parent.containerWidth;
      const containerHeight = this.$parent.containerHeight;

      let clampedX = Math.max(0, Math.min(newX, containerWidth - this.item.width));
      let clampedY = Math.max(0, Math.min(newY, containerHeight - this.item.height));

      clampedX = this.snapToGrid(clampedX, containerWidth, 'x');
      clampedY = this.snapToGrid(clampedY, containerHeight, 'y');

      this.$emit('item-updated', { ...this.item, x: clampedX, y: clampedY });

      this.startDragX = event.clientX;
      this.startDragY = event.clientY;
    },
    startResize(direction) {
      this.$emit('bring-to-front', this.item.id);
      this.isResizing = true;
      this.resizeDirection = direction;
      this.startResizeX = event.clientX;
      this.startResizeY = event.clientY;
      this.startWidth = this.item.width;
      this.startHeight = this.item.height;
    },
    stopResize() {
      this.isResizing = false;
      this.resizeDirection = null;
    },
    resize(event) {
      if (!this.isResizing) return;

      const deltaX = event.clientX - this.startResizeX;
      const deltaY = event.clientY - this.startResizeY;

      let newWidth = this.startWidth;
      let newHeight = this.startHeight;
      let newX = this.item.x;
      let newY = this.item.y;

      const minSize = 20;

      switch (this.resizeDirection) {
        case 'top-left':
          newWidth = this.startWidth - deltaX;
          newHeight = this.startHeight - deltaY;
          newX = this.item.x + deltaX;
          newY = this.item.y + deltaY;
          break;
        case 'top-right':
          newWidth = this.startWidth + deltaX;
          newHeight = this.startHeight - deltaY;
          newY = this.item.y + deltaY;
          break;
        case 'bottom-left':
          newWidth = this.startWidth - deltaX;
          newHeight = this.startHeight + deltaY;
          newX = this.item.x + deltaX;
          break;
        case 'bottom-right':
          newWidth = this.startWidth + deltaX;
          newHeight = this.startHeight + deltaY;
          break;
      }

      newWidth = Math.max(minSize, newWidth);
      newHeight = Math.max(minSize, newHeight);

      const containerWidth = this.$parent.containerWidth;
      const containerHeight = this.$parent.containerHeight;

      if (newX < 0) {
        newWidth += newX;
        newX = 0;
      }
      if (newY < 0) {
        newHeight += newY;
        newY = 0;
      }

      if (newX + newWidth > containerWidth) {
        newWidth = containerWidth - newX;
      }

      if (newY + newHeight > containerHeight) {
        newHeight = containerHeight - newY;
      }

      this.$emit('item-updated', { ...this.item, x: newX, y: newY, width: newWidth, height: newHeight });
    },
        bringToFront() {
      this.$emit('bring-to-front', this.item.id);
    },
    sendToBack() {
      this.$emit('send-to-back', this.item.id);
    },
        snapToGrid(value, containerSize, axis) {
        const snapThreshold = 10;
        const snapInterval = 20;

        for (let i = 0; i <= containerSize; i += snapInterval) {
            if (Math.abs(value - i) <= snapThreshold) {
                return i;
            }
        }
        return value;
    },
    getRandomColor() {
        const letters = '0123456789ABCDEF';
        let color = '#';
        for (let i = 0; i < 6; i++) {
          color += letters[Math.floor(Math.random() * 16)];
        }
        return color;
      },
  },
  mounted() {
    document.addEventListener('mouseup', this.stopResize);
    document.addEventListener('mousemove', this.resize);
  },
  beforeUnmount() {
    document.removeEventListener('mouseup', this.stopResize);
    document.removeEventListener('mousemove', this.resize);
  },
};
</script>

<style scoped>
.draggable-item {
  position: absolute;
  box-sizing: border-box;
  border: 1px solid #333;
  cursor: move;
  display: flex;
  justify-content: center;
  align-items: center;
  user-select: none;
}

.resize-handle {
  position: absolute;
  width: 10px;
  height: 10px;
  background-color: #007bff;
  cursor: pointer;
}

.resize-handle.top-left {
  top: -5px;
  left: -5px;
  cursor: nw-resize;
}

.resize-handle.top-right {
  top: -5px;
  right: -5px;
  cursor: ne-resize;
}

.resize-handle.bottom-left {
  bottom: -5px;
  left: -5px;
  cursor: sw-resize;
}

.resize-handle.bottom-right {
  bottom: -5px;
  right: -5px;
  cursor: se-resize;
}

.bring-to-front-button, .send-to-back-button {
  position: absolute;
  top: 5px;
  right: 5px;
  padding: 2px 5px;
  background-color: rgba(0, 0, 0, 0.5);
  color: white;
  font-size: 12px;
  cursor: pointer;
  border-radius: 3px;
}

.send-to-back-button {
    top: 30px;
}
</style>

总结

通过以上步骤,我们就实现了一个可拖拽、可缩放的自由布局容器,并处理了元素的吸附对齐和层级管理。当然,这只是一个基础版本,你可以根据自己的需求进行扩展和优化。例如,可以添加更多的缩放方向、支持键盘操作、实现撤销/重做功能等等。

一些额外的思考

功能点 实现方式 优点 缺点
拖拽 使用 mousedownmouseupmousemove 事件监听器,结合 transform: translate() 属性。 性能好,兼容性好。 需要手动计算元素的位置,代码量稍多。
缩放 在元素的四个角上添加 resize-handle,使用 mousedownmouseupmousemove 事件监听器,结合 widthheight 属性。 实现简单,易于理解。 需要处理不同方向的缩放逻辑,代码量稍多。
吸附对齐 遍历容器内的所有网格线,计算元素与网格线的距离,如果距离小于阈值,则将元素吸附到网格线上。 可以实现精确的对齐,提高布局的美观性。 计算量较大,可能会影响性能。需要根据实际情况调整阈值和网格线间隔。
层级管理 使用 zIndex 属性来控制元素的层级。通过点击事件,动态修改元素的 zIndex 属性,将其置顶或置底。 实现简单,易于理解。 需要维护一个全局的 zIndex 计数器,以确保每个元素的 zIndex 都是唯一的。
性能优化 使用 transform: translate()、避免频繁更新 DOM、使用 requestAnimationFrame 提高用户体验,使操作更流畅。 需要对浏览器的渲染机制有一定的了解。
移动端适配 使用 touch 事件代替 mouse 事件,例如 touchstarttouchendtouchmove 可以在移动设备上流畅运行。 需要处理 touch 事件的兼容性问题。
可访问性 为元素添加 aria-label 属性,使用户可以使用屏幕阅读器访问元素。 提高应用的可访问性,使更多人可以使用。

发表回复

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