创建一个可拖拽的 Web Component。这个组件将允许你放入任何 HTML 内容,并在屏幕内自由拖拽,同时确保它始终显示在其他元素之上,并且不会移出屏幕可视区域。
这个任务分为几个部分:
- 创建 Web Component 类: 定义组件的行为和结构。
- 实现拖拽逻辑: 处理鼠标/触摸事件以移动组件。
- 边界检测: 确保组件不会移出屏幕。
- 样式 : 设置必要的 CSS 样式(如
position
,z-index
,cursor
)。 - 注册组件: 让浏览器识别我们的新 HTML 标签。
- 使用示例: 展示如何在 HTML、Vue 和 React 中使用。
- 详细代码讲解: 深入解释每一部分代码。
1. Web Component 代码 (draggable-component.js
)
javascript
/**
* DraggableComponent 类定义了一个可拖拽的 Web Component。
* 用户可以将任何 DOM 元素放入此组件的插槽中,
* 这些内容将可以在屏幕可视区域内被拖拽。
*
* 特性:
* - 使用 Shadow DOM 进行样式和结构封装。
* - 支持鼠标和触摸事件(通过 Pointer Events)。
* - 拖拽时自动提升层级 (z-index)。
* - 限制拖拽范围,防止元素移出可视窗口。
* - 允许通过 CSS 自定义变量 `--initial-top`, `--initial-left`, `--z-index`, `--dragging-z-index` 来配置初始位置和层级。
*/
class DraggableComponent extends HTMLElement {
constructor() {
super(); // 必须首先调用 super()
// 初始化内部状态
this.isDragging = false; // 标记是否正在拖拽
this.offsetX = 0; // 鼠标/触摸点相对于元素左上角的 X 偏移
this.offsetY = 0; // 鼠标/触摸点相对于元素左上角的 Y 偏移
this.currentX = 0; // 元素当前的 X 坐标
this.currentY = 0; // 元素当前的 Y 坐标
// 创建 Shadow DOM
this.attachShadow({ mode: 'open' });
// 定义组件的 HTML 结构
// 使用 <slot> 允许用户插入自定义内容
// 使用一个内部 div (draggable-content) 来应用拖拽样式和行为
this.shadowRoot.innerHTML = `
<style>
/* :host 选择器用于选择 Shadow DOM 的宿主元素 */
:host {
/*
* 默认设置为绝对定位。用户也可以根据需要改为 'fixed'。
* 'absolute' 相对于最近的已定位祖先元素定位。
* 'fixed' 相对于视口定位。
* 对于全屏拖拽,'fixed' 可能更直观。我们这里使用 'absolute' 作为默认,
* 但拖拽逻辑是基于视口坐标计算的,因此也能很好地工作。
* 如果父容器有 transform, perspective, 或 filter 属性,'fixed' 的行为会像 'absolute'。
*/
position: absolute;
/*
* 从 CSS 自定义变量获取初始位置,如果未提供,则默认为 10px。
* 用户可以通过 style="--initial-top: 50px; --initial-left: 100px;" 来设置。
*/
top: var(--initial-top, 10px);
left: var(--initial-left, 10px);
/*
* 设置默认的 z-index。使用 CSS 变量允许用户自定义。
*/
z-index: var(--z-index, 100);
/*
* 默认光标样式,提示用户这是可拖拽的。
*/
cursor: grab;
/*
* 防止用户选择元素内的文本,提升拖拽体验。
*/
user-select: none;
-webkit-user-select: none; /* 兼容 Safari */
-moz-user-select: none; /* 兼容 Firefox */
-ms-user-select: none; /* 兼容 IE/Edge */
/*
* 确保宿主元素可见,即使没有显式设置尺寸。
* 'inline-block' 或 'block' 都可以,取决于期望的布局行为。
* 'inline-block' 使其能像内联元素一样流动,但可以设置宽高。
*/
display: inline-block;
/* 添加一个小的视觉提示,可选 */
/* border: 1px dashed #ccc; */
/* padding: 5px; */ /* 如果添加内边距,需要调整边界计算 */
}
/* 当 :host 元素处于拖拽状态时 (通过添加 .dragging 类) */
:host(.dragging) {
/*
* 拖拽时的光标样式。
*/
cursor: grabbing;
/*
* 拖拽时提升 z-index,确保在最上层。
* 使用 CSS 变量允许用户自定义拖拽时的层级。
*/
z-index: var(--dragging-z-index, 1000);
}
/*
* #draggable-content 是 Shadow DOM 内部用于包裹 <slot> 的容器。
* 这样做可以确保即使 <slot> 内容为空,也有一个可交互的区域。
* 并且可以应用一些不希望直接影响宿主元素的样式。
* 在这个简单例子中,它不是严格必需的,但提供了更好的结构。
*/
#draggable-content {
/* 可以根据需要添加样式,例如内边距、边框等 */
/* padding: 10px; */
/* background-color: rgba(255, 255, 255, 0.8); */ /* 半透明背景 */
/* border-radius: 5px; */
}
</style>
<div id="draggable-content">
<slot></slot> <!-- 用户内容将插入到这里 -->
</div>
`;
// 获取对内部容器的引用,虽然在这个实现中没有直接操作它,
// 但保留引用可能对未来扩展有用。
this.contentElement = this.shadowRoot.getElementById('draggable-content');
}
// --- 生命周期回调 ---
// 当元素首次连接到文档 DOM 时调用
connectedCallback() {
// 检查是否已在服务器端渲染环境中执行过初始化
// (虽然对于纯客户端 Web Component 不常见,但作为良好实践)
if (!this.ownerDocument || !this.ownerDocument.defaultView) {
return;
}
// 添加事件监听器
// 使用 pointerdown 可以同时处理鼠标和触摸事件
this.addEventListener('pointerdown', this.onDragStart);
// 从 CSS 变量或默认值初始化当前位置
// 注意:这里读取的是初始 CSS 设置,拖拽后会动态更新 style 属性
const style = window.getComputedStyle(this);
// parseFloat 会尝试解析像素值等,例如 "10px" -> 10
// 如果初始值不是像素(例如 %),这里可能需要更复杂的处理,
// 但对于绝对/固定定位,像素值是最常见的。
this.currentX = parseFloat(style.left || '10');
this.currentY = parseFloat(style.top || '10');
// 确保初始位置在屏幕内 (如果初始值过大)
// 这段逻辑也可以放在 onDragStart 之后,或者在首次渲染时
// 这里先做一次简单的检查
this._ensureWithinBounds();
}
// 当元素从文档 DOM 中移除时调用
disconnectedCallback() {
// 移除事件监听器,防止内存泄漏
this.removeEventListener('pointerdown', this.onDragStart);
// 如果拖拽过程中元素被移除,也需要清理 document 上的监听器
// (虽然 onDragEnd 通常会处理,但以防万一)
this.ownerDocument.removeEventListener('pointermove', this.onDragging);
this.ownerDocument.removeEventListener('pointerup', this.onDragEnd);
}
// --- 事件处理方法 ---
/**
* 处理拖拽开始事件 (pointerdown)
* @param {PointerEvent} event - 指针事件对象
*/
onDragStart = (event) => {
// 阻止默认行为,例如文本选择或图片拖拽
// event.preventDefault(); // 注意:有时阻止默认行为会干扰按钮等内部元素的点击
// 检查是否是主按钮(通常是鼠标左键或单指触摸)
if (event.button !== 0 && event.pointerType === 'mouse') {
// console.log('Drag start ignored: not primary button');
return;
}
this.isDragging = true;
this.classList.add('dragging'); // 添加 CSS 类以改变样式 (如光标, z-index)
// 获取元素的当前边界信息
const rect = this.getBoundingClientRect();
// 计算鼠标/触摸点相对于元素左上角的偏移量
// event.clientX/Y: 鼠标相对于视口的坐标
// rect.left/top: 元素左上角相对于视口的坐标
this.offsetX = event.clientX - rect.left;
this.offsetY = event.clientY - rect.top;
// console.log(`Drag Start: OffsetX=${this.offsetX}, OffsetY=${this.offsetY}`);
// 在 document 上添加移动和结束事件的监听器
// 这样即使鼠标移出元素范围,也能继续跟踪
// 使用 .bind(this) 或箭头函数确保 `this` 指向组件实例
this.ownerDocument.addEventListener('pointermove', this.onDragging);
this.ownerDocument.addEventListener('pointerup', this.onDragEnd);
// 监听 pointercancel 以处理意外中断,例如系统对话框弹出
this.ownerDocument.addEventListener('pointercancel', this.onDragEnd);
}
/**
* 处理拖拽过程中的移动事件 (pointermove)
* @param {PointerEvent} event - 指针事件对象
*/
onDragging = (event) => {
if (!this.isDragging) {
return; // 如果没有开始拖拽,则不执行任何操作
}
// 阻止默认的拖拽行为(例如,在触摸设备上滚动页面)
// event.preventDefault(); // 谨慎使用,可能阻止滚动
// 计算元素的新理论位置 (相对于视口)
// 新位置 = 当前鼠标/触摸位置 - 初始偏移量
let newX = event.clientX - this.offsetX;
let newY = event.clientY - this.offsetY;
// console.log(`Dragging: ClientX=${event.clientX}, ClientY=${event.clientY}, NewX=${newX}, NewY=${newY}`);
// --- 边界检测 ---
const rect = this.getBoundingClientRect(); // 获取当前尺寸和位置
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// 限制左边界
if (newX < 0) {
newX = 0;
}
// 限制上边界
if (newY < 0) {
newY = 0;
}
// 限制右边界
// 元素的右边 (newX + width) 不能超过视口宽度
if (newX + rect.width > viewportWidth) {
newX = viewportWidth - rect.width;
// 如果元素比视口还宽,确保左边至少是 0
if (newX < 0) newX = 0;
}
// 限制下边界
// 元素的下边 (newY + height) 不能超过视口高度
if (newY + rect.height > viewportHeight) {
newY = viewportHeight - rect.height;
// 如果元素比视口还高,确保上边至少是 0
if (newY < 0) newY = 0;
}
// 更新内部状态和元素的 style
this.currentX = newX;
this.currentY = newY;
this.style.left = `${this.currentX}px`;
this.style.top = `${this.currentY}px`;
// console.log(`Position Updated: Left=${this.style.left}, Top=${this.style.top}`);
}
/**
* 处理拖拽结束事件 (pointerup 或 pointercancel)
* @param {PointerEvent} event - 指针事件对象
*/
onDragEnd = (event) => {
if (!this.isDragging) {
return; // 如果没有开始拖拽,则不执行任何操作
}
this.isDragging = false;
this.classList.remove('dragging'); // 移除拖拽状态的 CSS 类
// 移除在 document 上添加的监听器
this.ownerDocument.removeEventListener('pointermove', this.onDragging);
this.ownerDocument.removeEventListener('pointerup', this.onDragEnd);
this.ownerDocument.removeEventListener('pointercancel', this.onDragEnd);
// console.log('Drag End');
}
/**
* 内部辅助方法:确保元素在可视区域内
* (可以在初始化或窗口大小改变时调用)
*/
_ensureWithinBounds() {
const rect = this.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let needsUpdate = false;
let finalX = this.currentX;
let finalY = this.currentY;
// 检查并修正 X 坐标
if (rect.left < 0) {
finalX = 0;
needsUpdate = true;
} else if (rect.right > viewportWidth) {
finalX = viewportWidth - rect.width;
if (finalX < 0) finalX = 0; // 处理元素比视口宽的情况
needsUpdate = true;
}
// 检查并修正 Y 坐标
if (rect.top < 0) {
finalY = 0;
needsUpdate = true;
} else if (rect.bottom > viewportHeight) {
finalY = viewportHeight - rect.height;
if (finalY < 0) finalY = 0; // 处理元素比视口高的情况
needsUpdate = true;
}
// 如果需要更新位置
if (needsUpdate) {
this.currentX = finalX;
this.currentY = finalY;
this.style.left = `${this.currentX}px`;
this.style.top = `${this.currentY}px`;
// console.log(`Bounds Ensured: Left=${this.style.left}, Top=${this.style.top}`);
}
}
// --- 可以在这里添加其他方法 ---
// 例如,一个方法用于以编程方式移动元素
// moveTo(x, y) {
// // 这里也应该包含边界检查
// this.currentX = x;
// this.currentY = y;
// this._ensureWithinBounds(); // 使用边界检查来设置最终位置
// }
// 例如,监听窗口大小变化,重新检查边界
// connectedCallback() {
// ...
// window.addEventListener('resize', this._ensureWithinBounds);
// }
// disconnectedCallback() {
// ...
// window.removeEventListener('resize', this._ensureWithinBounds);
// }
}
// --- 注册自定义元素 ---
// 定义自定义元素的标签名,必须包含一个连字符
const tagName = 'draggable-component';
if (!customElements.get(tagName)) {
customElements.define(tagName, DraggableComponent);
// console.log(`Custom element <${tagName}> defined.`);
} else {
// console.warn(`Custom element <${tagName}> already defined.`);
}
// 导出类,如果需要在其他模块中导入(例如,用于测试或扩展)
// export default DraggableComponent; // 如果使用模块系统
2. 使用示例
a) 在纯 HTML 中使用
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Draggable Web Component 示例</title>
<style>
body {
font-family: sans-serif;
height: 200vh; /* 使页面可滚动,测试 fixed 定位(如果使用)和边界 */
position: relative; /* 让 absolute 定位的子元素相对于 body */
margin: 0;
padding: 20px;
box-sizing: border-box;
}
h1 {
text-align: center;
}
/* 可以为特定的拖拽组件实例设置不同的初始位置和层级 */
#drag1 {
--initial-top: 50px;
--initial-left: 50px;
--z-index: 101;
background-color: lightblue;
padding: 20px;
border-radius: 8px;
box-shadow: 2px 2px 5px rgba(0,0,0,0.2);
}
#drag2 {
--initial-top: 200px;
--initial-left: 150px;
--z-index: 102; /* 比 drag1 高 */
--dragging-z-index: 1001; /* 拖拽时更高 */
background-color: lightcoral;
padding: 15px;
border: 2px solid darkred;
}
#drag2 button {
margin-top: 10px;
padding: 5px 10px;
}
</style>
</head>
<body>
<h1>拖拽 Web Component 示例</h1>
<p>尝试拖拽下面的方块。它们会被限制在窗口可视区域内。</p>
<!-- 引入 Web Component 的 JavaScript 文件 -->
<script src="draggable-component.js"></script>
<!-- 使用自定义元素 -->
<draggable-component id="drag1">
<h2>可拖拽内容 1</h2>
<p>这里可以放任何 HTML 内容。</p>
<img src="https://via.placeholder.com/100" alt="Placeholder">
</draggable-component>
<draggable-component id="drag2">
<h3>可拖拽内容 2</h3>
<p>包含一个按钮:</p>
<button onclick="alert('按钮被点击了!')">点我</button>
<p>即使有按钮,整个组件仍然可以拖拽。</p>
</draggable-component>
<div style="margin-top: 400px; height: 100px; background: #eee;">
一些其他内容,用于测试层级和滚动。
</div>
</body>
</html>
b) 在 Vue 中使用
Vue 3 对 Web Components 有良好的支持。
vue
<template>
<div id="app">
<h1>Vue 中的 Draggable Web Component</h1>
<!-- 确保 JS 文件已被加载 -->
<!-- 你可以在 main.js 中 import './path/to/draggable-component.js'; -->
<!-- 或者在 public/index.html 中通过 <script> 标签加载 -->
<draggable-component id="vue-drag1" style="--initial-top: 80px; --initial-left: 300px; background-color: lightgreen; padding: 25px;">
<h4>来自 Vue 的可拖拽内容</h4>
<p>计数器: {{ count }}</p>
<button @click="count++">增加</button>
</draggable-component>
<draggable-component id="vue-drag2" :style="drag2Style">
<h5>另一个 Vue 拖拽块</h5>
<input type="text" placeholder="输入一些文字...">
</draggable-component>
</div>
</template>
<script>
import { ref, computed } from 'vue';
// 如果 draggable-component.js 没有默认导出或副作用导入,确保它在某处被执行以注册组件
// 例如,在 main.js 中: import './components/draggable-component.js';
export default {
name: 'App',
setup() {
const count = ref(0);
// 动态设置样式
const drag2Style = computed(() => ({
'--initial-top': '250px',
'--initial-left': '50px',
'--z-index': 105,
backgroundColor: 'lightgoldenrodyellow',
padding: '20px',
border: '1px solid orange'
}));
return {
count,
drag2Style
};
}
}
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
padding: 20px;
}
/* 可以添加一些全局样式或针对特定 id 的样式 */
#vue-drag1 {
border-radius: 10px;
}
</style>
注意 : 在 Vue (或 React) 项目中,你需要确保 draggable-component.js
文件被执行一次,以便 customElements.define
被调用。通常这可以在你的主入口文件(如 main.js
或 index.js
)中通过 import './path/to/draggable-component.js';
来完成。
c) 在 React 中使用
React 对 Web Components 的支持也很好,但需要注意属性传递(尤其是对于非字符串属性,不过我们这个组件主要通过 CSS 变量和 slot,比较简单)。
jsx
import React, { useState, useEffect } from 'react';
import './App.css';
// 确保 Web Component 脚本被执行以注册组件
// import './path/to/draggable-component.js'; // 在 index.js 或 App.js 顶部导入
function App() {
const [inputValue, setInputValue] = useState('');
// 确保组件脚本只加载一次
useEffect(() => {
// 如果脚本是动态加载或有副作用,可以在这里处理
// 如果已经在 index.js 中导入,这里就不需要了
// import('./path/to/draggable-component.js');
}, []);
const drag2Style = {
'--initial-top': '150px',
'--initial-left': '400px',
'--z-index': 110,
backgroundColor: 'lightblue',
padding: '20px',
border: '1px dashed blue'
};
return (
<div className="App">
<h1>React 中的 Draggable Web Component</h1>
{/* React 会将 style 对象转换为内联样式 */}
{/* 对于 CSS 变量,可以直接在 style 对象中设置 */}
<draggable-component id="react-drag1" style={drag2Style}>
<h4>来自 React 的可拖拽内容</h4>
<p>这是一个输入框:</p>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Type something..."
// 阻止输入框的 mousedown 事件冒泡到 draggable-component,
// 否则可能导致无法聚焦输入框就开始拖拽
onPointerDown={(e) => e.stopPropagation()}
/>
<p>输入的值: {inputValue}</p>
</draggable-component>
{/* 也可以直接写 style 字符串,但不推荐 */}
<draggable-component id="react-drag2" style={{ '--initial-top': '350px', '--initial-left': '100px', backgroundColor: 'pink', padding: '15px' }}>
<h5>另一个 React 拖拽块</h5>
<button
onClick={() => alert('React Button Clicked!')}
// 同理,阻止按钮事件冒泡以防意外拖拽
onPointerDown={(e) => e.stopPropagation()}
>
React 按钮
</button>
</draggable-component>
</div>
);
}
export default App;
React 注意事项:
- 事件处理 : 对于 Web Component 内部触发的标准 DOM 事件,React 的合成事件系统可能无法直接捕获。如果需要监听 Web Component 的自定义事件,需要手动添加和移除事件监听器(使用
useRef
和useEffect
)。对于我们这个例子,拖拽逻辑是内部封装的,不需要外部监听。 - 内部元素交互 : 正如 React 示例中
input
和button
的onPointerDown={(e) => e.stopPropagation()}
所示,有时需要阻止内部可交互元素的pointerdown
事件冒泡,否则点击输入框或按钮可能会意外触发组件的拖拽行为。这是 Web Component 与 React 事件系统交互时的一个常见细节。
3. 详细代码讲解 (draggable-component.js
)
下面是 draggable-element.js
文件的详细实现和讲解。
javascript
// 定义 DraggableElement 类,继承自 HTMLElement
class DraggableElement extends HTMLElement {
// 构造函数,在元素实例创建时调用
constructor() {
// 必须首先调用 super()
super();
// 创建 Shadow DOM,用于封装组件内部结构和样式
// mode: 'open' 允许外部 JavaScript 通过 element.shadowRoot 访问 Shadow DOM
this.attachShadow({ mode: 'open' });
// 初始化内部状态变量
this.isDragging = false; // 标记是否正在拖拽
this.offsetX = 0; // 鼠标按下时,鼠标指针相对于元素左上角的 X 轴偏移量
this.offsetY = 0; // 鼠标按下时,鼠标指针相对于元素左上角的 Y 轴偏移量
this.currentX = 0; // 元素当前的 X 轴坐标 (相对于视口)
this.currentY = 0; // 元素当前的 Y 轴坐标 (相对于视口)
// 创建 Shadow DOM 的内部结构
// 使用 <slot> 元素允许用户将任意内容插入到组件中
// 使用 <div> 作为容器,方便应用样式和定位
this.shadowRoot.innerHTML = `
<style>
/* 定义容器的基本样式 */
.draggable-container {
/* 使用固定定位,使元素相对于视口定位,脱离文档流 */
position: fixed;
/* 设置一个较高的 z-index,确保元素显示在其他内容上层 */
z-index: 1000;
/* 鼠标指针样式,提示用户该元素可拖拽 */
cursor: grab;
/* 平滑过渡效果,用于位置变化 */
transition: box-shadow 0.2s ease-in-out;
/* 用户无法选中元素内的文本,优化拖拽体验 */
user-select: none;
-webkit-user-select: none; /* 兼容 Safari */
-moz-user-select: none; /* 兼容 Firefox */
-ms-user-select: none; /* 兼容 IE/Edge */
/* 初始位置,可以根据需要调整或通过属性设置 */
left: 0px;
top: 0px;
/* 添加一个细边框,方便看到元素边界 */
/* border: 1px dashed #ccc; */
/* 触摸操作优化 */
touch-action: none; /* 防止在触摸设备上滚动页面 */
}
/* 正在拖拽时的样式 */
.draggable-container.dragging {
/* 改变鼠标指针样式,表示正在拖拽 */
cursor: grabbing;
/* 添加阴影效果,提升视觉反馈 */
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
</style>
<div class="draggable-container">
<!-- slot 元素是 Web Components 的一个核心特性 -->
<!-- 它允许父文档中的 Light DOM 节点被渲染到 Shadow DOM 的指定位置 -->
<!-- 这里未命名 slot 会接收所有未分配给具名 slot 的子节点 -->
<slot></slot>
</div>
`;
// 获取 Shadow DOM 中的容器元素
this.container = this.shadowRoot.querySelector('.draggable-container');
// --- 事件处理方法绑定 ---
// 为了在事件监听器中正确访问 'this' (指向 DraggableElement 实例),
// 需要将这些方法绑定到当前实例。否则,在事件回调中 'this' 会指向触发事件的元素 (或 window)。
this.onMouseDown = this.onMouseDown.bind(this);
this.onMouseMove = this.onMouseMove.bind(this);
this.onMouseUp = this.onMouseUp.bind(this);
// 触摸事件绑定
this.onTouchStart = this.onTouchStart.bind(this);
this.onTouchMove = this.onTouchMove.bind(this);
this.onTouchEnd = this.onTouchEnd.bind(this);
}
// --- 生命周期回调 ---
// connectedCallback: 当元素首次被插入到文档 DOM 时调用
connectedCallback() {
console.log('Draggable element connected to DOM.');
// 添加鼠标按下事件监听器到容器元素
// 使用 { passive: false } 确保在触摸事件中可以调用 preventDefault (如果需要的话)
this.container.addEventListener('mousedown', this.onMouseDown, { passive: false });
// 添加触摸开始事件监听器
this.container.addEventListener('touchstart', this.onTouchStart, { passive: false });
// 初始化元素位置
// 尝试从元素的 style 属性读取初始位置
// 如果没有设置,则默认为 (0, 0)
const initialRect = this.getBoundingClientRect(); // 获取元素在视口中的初始位置和尺寸
this.currentX = initialRect.left;
this.currentY = initialRect.top;
// 将初始位置应用到容器的 style 上
// 注意:我们操作的是 Shadow DOM 内部的 container,而不是 host 元素 (this)
this.container.style.left = `${this.currentX}px`;
this.container.style.top = `${this.currentY}px`;
// 确保初始位置在屏幕内 (如果初始位置通过 CSS 设置在屏幕外)
this.ensureInBounds();
}
// disconnectedCallback: 当元素从文档 DOM 中移除时调用
disconnectedCallback() {
console.log('Draggable element disconnected from DOM.');
// 移除事件监听器,防止内存泄漏
this.container.removeEventListener('mousedown', this.onMouseDown);
// 全局监听器也需要移除 (如果在 onMouseUp/onTouchEnd 中没有移除的话)
// 但最佳实践是在 onMouseUp/onTouchEnd 中移除 mousemove/mouseup/touchmove/touchend
window.removeEventListener('mousemove', this.onMouseMove);
window.removeEventListener('mouseup', this.onMouseUp);
// 移除触摸事件监听器
this.container.removeEventListener('touchstart', this.onTouchStart);
window.removeEventListener('touchmove', this.onTouchMove);
window.removeEventListener('touchend', this.onTouchEnd);
window.removeEventListener('touchcancel', this.onTouchEnd); // 也处理触摸取消事件
}
// --- 事件处理方法 ---
// 鼠标按下事件处理
onMouseDown(event) {
// 阻止默认行为,例如文本选择或图片拖拽
event.preventDefault();
// 检查是否是鼠标左键按下 (event.button === 0)
if (event.button !== 0) {
return;
}
this.startDragging(event.clientX, event.clientY);
// 在 window 上添加 mousemove 和 mouseup 监听器
// 这样即使鼠标移出元素范围,拖拽也能继续
window.addEventListener('mousemove', this.onMouseMove, { passive: false });
window.addEventListener('mouseup', this.onMouseUp, { passive: false });
}
// 触摸开始事件处理
onTouchStart(event) {
// 阻止默认的触摸行为,如滚动
// event.preventDefault(); // 注意:有时阻止默认行为可能会干扰其他触摸交互,根据需要启用
// 处理单点触摸
if (event.touches.length === 1) {
const touch = event.touches[0];
this.startDragging(touch.clientX, touch.clientY);
// 在 window 上添加 touchmove 和 touchend 监听器
window.addEventListener('touchmove', this.onTouchMove, { passive: false });
window.addEventListener('touchend', this.onTouchEnd, { passive: false });
window.addEventListener('touchcancel', this.onTouchEnd, { passive: false }); // 处理触摸取消
}
}
// 开始拖拽的通用逻辑 (由 onMouseDown 和 onTouchStart 调用)
startDragging(clientX, clientY) {
this.isDragging = true;
// 添加拖拽中的视觉反馈样式
this.container.classList.add('dragging');
// 改变鼠标样式为 grabbing
this.container.style.cursor = 'grabbing';
// 计算鼠标点击位置相对于元素左上角的偏移量
// clientX/Y: 鼠标指针相对于浏览器视口左上角的坐标
// this.currentX/Y: 元素容器当前相对于视口左上角的坐标
this.offsetX = clientX - this.currentX;
this.offsetY = clientY - this.currentY;
console.log(`Dragging started at (${clientX}, ${clientY}), offset (${this.offsetX}, ${this.offsetY})`);
}
// 鼠标移动事件处理
onMouseMove(event) {
// 阻止默认行为
event.preventDefault();
this.handleMove(event.clientX, event.clientY);
}
// 触摸移动事件处理
onTouchMove(event) {
// 阻止默认行为,如页面滚动
event.preventDefault();
// 处理单点触摸移动
if (event.touches.length === 1) {
const touch = event.touches[0];
this.handleMove(touch.clientX, touch.clientY);
}
}
// 处理移动的通用逻辑 (由 onMouseMove 和 onTouchMove 调用)
handleMove(clientX, clientY) {
// 如果没有处于拖拽状态,则不执行任何操作
if (!this.isDragging) {
return;
}
// --- 计算元素的新位置 ---
// 新的 X 坐标 = 当前鼠标 X 坐标 - 鼠标按下时的 X 轴偏移量
let newX = clientX - this.offsetX;
// 新的 Y 坐标 = 当前鼠标 Y 坐标 - 鼠标按下时的 Y 轴偏移量
let newY = clientY - this.offsetY;
// --- 边界检测 ---
// 获取视口的尺寸
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// 获取元素容器的尺寸 (offsetWidth/Height 包含边框和内边距)
const elementWidth = this.container.offsetWidth;
const elementHeight = this.container.offsetHeight;
// 限制左边界:元素的左边不能超过视口的左边 (0)
if (newX < 0) {
newX = 0;
}
// 限制上边界:元素的上边不能超过视口的上边 (0)
if (newY < 0) {
newY = 0;
}
// 限制右边界:元素的右边 (newX + elementWidth) 不能超过视口的右边 (viewportWidth)
if (newX + elementWidth > viewportWidth) {
// 如果超过,将元素的左边定位在 (viewportWidth - elementWidth)
newX = viewportWidth - elementWidth;
// 处理元素比视口宽的情况
if (newX < 0) newX = 0;
}
// 限制下边界:元素的下边 (newY + elementHeight) 不能超过视口的下边 (viewportHeight)
if (newY + elementHeight > viewportHeight) {
// 如果超过,将元素的上边定位在 (viewportHeight - elementHeight)
newY = viewportHeight - elementHeight;
// 处理元素比视口高的情况
if (newY < 0) newY = 0;
}
// --- 更新元素位置 ---
// 更新内部状态变量
this.currentX = newX;
this.currentY = newY;
// 应用新的坐标到容器的 style 上
// 使用 transform: translate() 性能通常比直接修改 left/top 好,
// 但为了边界计算简单,这里继续使用 left/top。
// 如果追求极致性能,可以考虑 translate 并相应调整边界计算。
this.container.style.left = `${this.currentX}px`;
this.container.style.top = `${this.currentY}px`;
// console.log(`Dragging to (${this.currentX}, ${this.currentY})`); // 频繁输出会影响性能
}
// 鼠标松开事件处理
onMouseUp(event) {
// 阻止默认行为
event.preventDefault();
this.stopDragging();
// 移除在 window 上添加的事件监听器
window.removeEventListener('mousemove', this.onMouseMove);
window.removeEventListener('mouseup', this.onMouseUp);
}
// 触摸结束/取消事件处理
onTouchEnd(event) {
// 阻止默认行为
// event.preventDefault(); // 可能不需要
this.stopDragging();
// 移除在 window 上添加的事件监听器
window.removeEventListener('touchmove', this.onTouchMove);
window.removeEventListener('touchend', this.onTouchEnd);
window.removeEventListener('touchcancel', this.onTouchEnd);
}
// 停止拖拽的通用逻辑 (由 onMouseUp 和 onTouchEnd 调用)
stopDragging() {
if (this.isDragging) {
this.isDragging = false;
// 移除拖拽中的视觉反馈样式
this.container.classList.remove('dragging');
// 恢复鼠标指针样式
this.container.style.cursor = 'grab';
console.log(`Dragging stopped at (${this.currentX}, ${this.currentY})`);
}
}
// --- 辅助方法 ---
// 确保元素在视口边界内
ensureInBounds() {
// 获取当前视口和元素尺寸
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const elementWidth = this.container.offsetWidth;
const elementHeight = this.container.offsetHeight;
let correctedX = this.currentX;
let correctedY = this.currentY;
let needsCorrection = false;
// 检查并修正 X 轴位置
if (correctedX < 0) {
correctedX = 0;
needsCorrection = true;
} else if (correctedX + elementWidth > viewportWidth) {
correctedX = Math.max(0, viewportWidth - elementWidth); // 处理元素比视口宽的情况
needsCorrection = true;
}
// 检查并修正 Y 轴位置
if (correctedY < 0) {
correctedY = 0;
needsCorrection = true;
} else if (correctedY + elementHeight > viewportHeight) {
correctedY = Math.max(0, viewportHeight - elementHeight); // 处理元素比视口高的情况
needsCorrection = true;
}
// 如果位置需要修正,则更新状态和样式
if (needsCorrection) {
console.log(`Position corrected to stay in bounds: (${correctedX}, ${correctedY})`);
this.currentX = correctedX;
this.currentY = correctedY;
this.container.style.left = `${this.currentX}px`;
this.container.style.top = `${this.currentY}px`;
}
}
// --- 属性处理 (可选) ---
// 如果需要通过 HTML 属性配置初始位置或行为,可以在这里添加
// static get observedAttributes() { return ['initial-x', 'initial-y']; }
// attributeChangedCallback(name, oldValue, newValue) { ... }
} // 类定义结束
// --- 注册自定义元素 ---
// 将 DraggableElement 类与自定义标签名 'draggable-element' 关联起来
// 标签名必须包含一个连字符 (-)
// window.customElements.get() 可以用来检查是否已注册
if (!window.customElements.get('draggable-element')) {
window.customElements.define('draggable-element', DraggableElement);
console.log('Custom element "draggable-element" defined.');
} else {
console.warn('"draggable-element" is already defined.');
}
// --- 导出 (如果使用模块) ---
// export default DraggableElement; // 如果在模块化环境中使用
代码讲解:
-
类定义 (
class DraggableElement extends HTMLElement
):- 我们创建了一个名为
DraggableElement
的类,它继承自HTMLElement
,这是所有 HTML 元素接口的基类。这使得我们的类能够表现得像一个标准的 HTML 元素。
- 我们创建了一个名为
-
构造函数 (
constructor
):super()
: 必须首先调用父类的构造函数。this.attachShadow({ mode: 'open' })
: 创建一个 Shadow DOM。Shadow DOM 提供了封装,将组件的内部结构和样式与主文档隔离。mode: 'open'
意味着可以通过组件实例的shadowRoot
属性从外部 JavaScript 访问 Shadow DOM。- 状态变量初始化 :
isDragging
: 布尔值,标记当前是否正在拖拽元素。offsetX
,offsetY
: 数字,存储鼠标按下时,鼠标指针位置与元素左上角之间的距离。这确保了拖拽时元素不会突然跳到鼠标指针的位置,而是保持相对位置。currentX
,currentY
: 数字,存储元素容器当前在视口中的left
和top
坐标。
- Shadow DOM 结构 :
<style>
: 定义了组件内部的 CSS 样式。.draggable-container
: 设置了position: fixed
使元素相对于视口定位,z-index
确保它在顶层,cursor: grab
提供可拖拽的视觉提示,user-select: none
防止拖拽时选中内部文本。touch-action: none
阻止触摸设备上的默认滚动行为。.draggable-container.dragging
: 定义了正在拖拽时的样式,如cursor: grabbing
和box-shadow
,提供视觉反馈。
<div class="draggable-container">
: 这是实际进行定位和应用样式的容器。<slot></slot>
: 这是 Web Components 的关键部分。它是一个占位符,允许使用者将任何 HTML 内容(Light DOM)插入到这个 Web Component 中,这些内容会被渲染在<slot>
所在的位置。
this.container
: 获取对 Shadow DOM 中.draggable-container
div 的引用,方便后续操作。- 事件处理方法绑定 : 使用
bind(this)
将事件处理函数(如onMouseDown
)的this
上下文永久绑定到DraggableElement
实例。这在将方法作为事件监听器传递时至关重要。
-
生命周期回调 (
connectedCallback
,disconnectedCallback
):connectedCallback
: 当元素被添加到 DOM 时触发。- 添加
mousedown
和touchstart
事件监听器到内部的container
元素上。拖拽的起点是用户与这个容器交互。 - 获取元素的初始位置 (
getBoundingClientRect
) 并存储到currentX
,currentY
。 - 将初始位置应用到
container
的style.left
和style.top
。 - 调用
ensureInBounds()
确保初始位置(可能由外部 CSS 设置)在屏幕内。
- 添加
disconnectedCallback
: 当元素从 DOM 中移除时触发。- 移除所有添加的事件监听器(包括在
window
上添加的mousemove
/mouseup
/touchmove
/touchend
),以防止内存泄漏。
- 移除所有添加的事件监听器(包括在
-
事件处理方法 (
onMouseDown
,onTouchStart
,onMouseMove
,onTouchMove
,onMouseUp
,onTouchEnd
):onMouseDown
/onTouchStart
:- 阻止事件的默认行为(如文本选择、默认触摸滚动)。
- 记录拖拽状态 (
isDragging = true
)。 - 计算
offsetX
和offsetY
。 - 在
window
对象上添加mousemove
/touchmove
和mouseup
/touchend
监听器。监听window
确保了即使鼠标/手指移出元素区域,拖拽也能继续进行,并在任何地方松开都能停止拖拽。 - 调用
startDragging
处理通用逻辑。
startDragging
:- 设置
isDragging
标志。 - 添加
.dragging
类以应用拖拽样式。 - 计算
offsetX
,offsetY
。
- 设置
onMouseMove
/onTouchMove
:- 阻止默认行为。
- 如果
isDragging
为true
,则调用handleMove
。
handleMove
:- 计算基于当前鼠标/触摸位置和初始偏移量的新坐标 (
newX
,newY
)。 - 边界检测 : 获取视口 (
window.innerWidth/Height
) 和元素 (container.offsetWidth/Height
) 的尺寸。检查newX
,newY
是否会导致元素超出视口边界。如果超出,则将坐标调整为边界值。特别处理了元素尺寸大于视口尺寸的情况(通过Math.max(0, ...)
确保坐标不为负)。 - 更新
currentX
,currentY
状态。 - 将新的
currentX
,currentY
应用到container
的style.left
和style.top
,移动元素。
- 计算基于当前鼠标/触摸位置和初始偏移量的新坐标 (
onMouseUp
/onTouchEnd
:- 阻止默认行为。
- 调用
stopDragging
处理通用逻辑。 - 关键 : 移除在
window
上添加的mousemove
/touchmove
和mouseup
/touchend
监听器。这是必要的清理步骤。
stopDragging
:- 设置
isDragging = false
。 - 移除
.dragging
类。 - 恢复默认的
grab
光标。
- 设置
-
辅助方法 (
ensureInBounds
):- 一个独立的函数,用于检查当前
currentX
,currentY
是否使元素保持在视口内,并在必要时进行修正。这在connectedCallback
中用于处理初始位置,也可以在窗口大小改变时调用(如果需要响应式调整)。
- 一个独立的函数,用于检查当前
-
自定义元素注册 (
customElements.define
):window.customElements.define('draggable-element', DraggableElement);
这行代码将DraggableElement
类注册到浏览器,并将其与 HTML 标签名draggable-element
关联起来。之后,就可以在 HTML 中像使用<p>
,<div>
一样使用<draggable-element>
了。- 使用
window.customElements.get()
检查是为了防止重复注册引发错误。
如何在 Vue 和 React 中使用:
由于这是一个标准的 Web Component,你可以在任何支持 Web Components 的框架(包括 Vue 和 React)中直接使用它,就像使用普通的 HTML 标签一样。
Vue 示例:
vue
<template>
<div>
<h1>Vue App</h1>
<draggable-element style="top: 50px; left: 50px;">
<div class="content-box">
<h2>Drag Me (Vue)</h2>
<p>This is content inside the draggable element.</p>
<button @click="vueClickHandler">Vue Button</button>
</div>
</draggable-element>
<!-- 确保 draggable-element.js 已被加载 -->
</div>
</template>
<script>
// 确保 draggable-element.js 在某处被加载,例如在 main.js 或 index.html
// import './path/to/draggable-element.js'; // 如果使用构建工具
export default {
name: 'App',
methods: {
vueClickHandler() {
alert('Vue button clicked!');
}
},
// 告诉 Vue 忽略自定义元素 draggable-element
compilerOptions: {
isCustomElement: tag => tag === 'draggable-element'
} // Vue 3 配置方式
// Vue 2: Vue.config.ignoredElements = ['draggable-element']; (在 main.js 中设置)
}
</script>
<style scoped>
.content-box {
background-color: lightblue;
padding: 20px;
border: 1px solid blue;
min-width: 150px; /* 给内容一个最小宽度 */
}
h1 {
text-align: center;
}
</style>
React 示例:
jsx
import React from 'react';
// 确保 draggable-element.js 在某处被加载,例如在 index.js 或 index.html
// import './path/to/draggable-element.js'; // 如果使用构建工具
function App() {
const reactClickHandler = () => {
alert('React button clicked!');
};
return (
<div>
<h1 style={{ textAlign: 'center' }}>React App</h1>
{/* React 会将 style 转换为字符串或处理对象 */}
<draggable-element style={{ top: '150px', left: '150px' }}>
<div style={{ backgroundColor: 'lightcoral', padding: '20px', border: '1px solid red', minWidth: '150px' }}>
<h2>Drag Me (React)</h2>
<p>This is content inside the draggable element.</p>
{/* 事件处理仍然可以在内部元素上正常工作 */}
<button onClick={reactClickHandler}>React Button</button>
</div>
</draggable-element>
</div>
);
}
export default App;
关键点总结:
- 封装: Shadow DOM 提供了样式和结构的封装。
- 内容投射 :
<slot>
允许将任意用户提供的 HTML (Light DOM) 渲染到组件内部。 - 定位 : 使用
position: fixed
相对于视口定位。 - 事件处理 : 通过
mousedown
/touchstart
,mousemove
/touchmove
,mouseup
/touchend
实现拖拽逻辑。 - 坐标计算: 精确计算偏移量和新位置。
- 边界检测: 确保元素始终在视口内。
- 跨框架兼容: 作为标准 Web Component,无需额外包装即可在 Vue、React、Angular 或原生 JS 中使用。
- 清理 : 在
disconnectedCallback
和onMouseUp
/onTouchEnd
中移除事件监听器至关重要,以避免内存泄漏。 - 触摸支持 : 添加了
touchstart
,touchmove
,touchend
事件处理,使其在移动设备上也能工作。