在 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
函数中的 el
从 null
变成了一个 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");