Vue3创建一个可移动的元素

102 阅读3分钟

在 Vue 3 中,使用 Composition API 可以以更加简洁和高效的方式组织和管理代码,尤其是在处理像拖动功能这类需要处理事件监听、响应式数据和计算值的功能时。

目标:

使用 Vue 3 的 Composition API 分解可拖动组件。

我选择这样做是因为拖动一个组件需要以下功能:

  • 响应式值(reactive values)
  • 计算值(computed values)
  • 监听(watch)
  • 事件监听器(event listeners)

计划

这个实验的思路是将拖动功能从组件中分离出来,这样我们就可以调用一个函数并将返回的值传递给模板。组件的代码应该像这样:

// reusable function
const makeDragable = element => {
  // create reactive object
  const position = reactive({x: 0, y: 0, /*etc...*/ });

  // compute style
  const style = computed(() => {
    // To Be Implemented (TBI)
    return {};
  });

  // create  mouse interaction functions
  const onMouseDown = e => {/* TBI */};
  const onMouseMove = e => {/* TBI */};
  const onMouseUp = e => {/* TBI */};

  // assign mousedown listener
  element.addEventListener("mousedown", onMouseDown);

  // return objects
  return { position, style };
}

// my component
const MyComponent = Vue.createComponent({
  setup() {
    const { position, style } = makeDragable(el);
    return { position, style };
  },
  template: document.getElementById("myComponent").innerHTML
});

这段代码展示了如何将拖动功能和组件分离开来,并传递给模板的思路。问题在于,el 没有定义,如果我们定义它,它会是 null,因为组件在 setup 执行时还没有挂载。

解决这个问题的方式是,创建一个对模板中元素的响应式引用(ref),然后将这个引用传递给函数。

  setup() {
    // 创建响应式引用变量 el
    const el = ref(null);
    // 将 el 传递给函数来赋值鼠标事件
    const { position, style } = makeDragable(el);
    // 将 el 传递给模板
    return { el, position, style };
  },
  template: document.getElementById("myComponent").innerHTML
});

然后,我们可以在模板中使用 ref="el" 来传递引用:


<template id="myComponent">
  <div ref="el" :style="style">
    <h3>DRAG ME</h3>
    <pre>{{ position }}</pre>
  </div>
</template>

这样,我们就创建了一个对变量 el 的响应式引用,初始化时为 null,并将其传递给模板。模板将该引用绑定到 div 元素上。

此时,makeDragable 函数中的 elnull 变成了一个 HTMLElement。如果我们在首次运行时直接添加监听器,会失败,因为此时该元素还没有挂载,el 仍为 null。为了确保监听器正确绑定到元素,我们使用 watch 来监控 el,当其变化时添加相应的功能。

// 可复用的函数
const makeDragable = element => {
  const position = reactive({x: 0, y: 0, /*etc...*/ });

  // 计算样式
  const style = computed(() => {
    // 待实现(TBI)
    return {};
  });

  const onMouseDown = e => {/* TBI */};
  const onMouseMove = e => {/* TBI */};
  const onMouseUp = e => {/* TBI */};

  // 添加 watch 来在 el 变化时分配功能
  watch(element, element => {
    if (!(element instanceof HTMLElement)) return;
    element.addEventListener("mousedown", onMouseDown);
  });

  // 返回对象
  return { position, style };
}

完成代码

至于 Composition API 的实现,这几乎已经完成了。接下来只是实现鼠标交互,完整代码可以在文章结尾提供


<div id="app"></div>

<!-- APP Template -->
<template id="appTemplate">
  <!-- one component -->
  <my-component>
    <!-- nested child component -->
    <my-component></my-component>
  </my-component>
</template>

<!-- myComponent Template -->
<template id="myComponent">
  <div ref="el" class="dragable" :style="style">
    <h3>DRAG ME</h3>
    <pre>{{ position }}</pre>
    <pre>{{ style }}</pre>
    <slot></slot>
  </div>
</template>

<style>
.dragable {font-family: "Lucida Sans", Geneva, Verdana, sans-serif;width: 40%;max-width: 90%;min-width: 320px;min-height: 6.5em;margin: 0;color: rgb(6, 19, 29);background-color: rgb(187, 195, 209);border-radius: 16px;padding: 16px;touch-action: none;user-select: none;-webkit-transform: translate(0px, 0px);transform: translate(0px, 0px);transition: transform 0.1s ease-in, box-shadow 0.1s ease-out;border: 1px solid rgb(6, 19, 29);} pre { width: 48%; display: inline-block; overflow: hidden; font-size: 10px; }
</style>



const { reactive, computed, ref, onMounted, watch } = Vue;

const makeDragable = element => {
  const position = reactive({
    init: false,
    x: 0,
    y: 0,
    width: 0,
    height: 0,
    isDragging: false,
    dragStartX: null,
    dragStartY: null
  });

  const style = computed(() => {
    if (position.init) {
      return {
        position: "absolute",
        left: position.x + "px",
        top: position.y + "px",
        width: position.width + "px",
        height: position.height + "px",
        "box-shadow": position.isDragging
          ? "3px 6px 16px rgba(0, 0, 0, 0.15)"
          : "",
        transform: position.isDragging ? "translate(-3px, -6px)" : "",
        cursor: position.isDragging ? "grab" : "pointer"
      };
    }
    return {};
  });

  const onMouseDown = e => {
    let { clientX, clientY } = e;
    position.dragStartX = clientX - position.x;
    position.dragStartY = clientY - position.y;

    position.isDragging = true;

    document.addEventListener("mouseup", onMouseUp);
    document.addEventListener("mousemove", onMouseMove);
  };

  const onMouseMove = e => {
    let { clientX, clientY } = e;
    position.x = clientX - position.dragStartX;
    position.y = clientY - position.dragStartY;
  };

  const onMouseUp = e => {
    let { clientX, clientY } = e;
    position.isDragging = false;
    position.dragStartX = null;
    position.dragStartY = null;
    document.removeEventListener("mouseup", onMouseUp);
    document.removeEventListener("mousemove", onMouseMove);
  };

  watch(element, (element, prevElement, onCleanup) => {
    if (!element instanceof HTMLElement) return;
    let rect = element.getBoundingClientRect(element);

    position.init = true;
    position.x = Math.round(rect.x);
    position.y = Math.round(rect.y);
    position.width = Math.round(rect.width);
    position.height = Math.round(rect.height);

    element.addEventListener("mousedown", onMouseDown);

    onCleanup(() => {
      // do cleanup
    })
  });

  return {
    position,
    style
  };
};

const MyComponent = Vue.createComponent({
  setup(props) {
    const el = ref(null);
    const { position, style } = makeDragable(el);

    return {
      el,
      position,
      style
    };
  },
  template: document.getElementById("myComponent").innerHTML
});

const App = {
  template: document.getElementById("appTemplate").innerHTML
};

const app = Vue.createApp({});
app.component("my-component", MyComponent);
app.mount(App, "#app");