鼠标悬浮图标的实现

在项目中遇到这样一个需求:

前端一些交互需要根据后端返回的权限数据进行鉴权,没有权限的交互,鼠标悬浮上去后,鼠标旁边要显示一个锁图标,点击后弹出无权限的提示。类似下面的效果:

基本思路

鼠标悬浮在某一个元素上后,要在鼠标旁边显示一个锁图标,首先就是要获取到鼠标的坐标位置吧,这一点,我们可以通过mouseenter​和mousemove​事件实现:

1
2
3
4
5
6
$0.addEventListener('mousemove',(e)=>{
console.log(e)
})
$0.addEventListener('mouseenter',(e)=>{
console.log(e)
})

这里的回调中的MouseEvent​类型参数有很多个X、Y坐标值:client、movement、offset、page、screen,下面是它们的不同点:

  • clientX:相对与目标元素的坐标
  • movement:相对于最后mousemove事件位置的坐标
  • offset:相对于目标元素内边位置的坐标
  • page、x/y:相对于整个页面的坐标
  • screen:相对于整个屏幕的坐标

拿到坐标后,接下来就是应该怎么把这个锁图标显示出来了。具体思路如下:这个图标应该是一个元素,默认是不显示的,只有在悬浮到目标元素上才显示,即修改display​即可。这里我们大概率是需要手动操纵DOM结构的,Vue中,推荐的修改DOM的一个方法就是自定义指令,在指令mounted​时,创建这个锁图标的元素,默认不显示,并加进DOM里,使用固定定位,在鼠标移入时,显示元素,更新元素的位置,鼠标移出,就隐藏元素。

编写代码

根据上面思路,可以很容易写出下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import type { ObjectDirective } from "vue";

const cursor: ObjectDirective = {
mounted(el: HTMLElement) {
// 创建一个新的 div 元素作为鼠标跟随的提示
const div = document.createElement('div');
div.className = 'v-cursor';
div.id = 'v-cursor';
document.body.appendChild(div);

// 鼠标移动事件处理函数
const mousemoveHandler = (event: MouseEvent) => {
div.style.left = `${event.pageX}px`;
div.style.top = `${event.pageY}px`;
};

// 鼠标进入,添加鼠标移动事件监听
el.addEventListener('mouseenter', () => {
div.style.display = 'block';
el.addEventListener('mousemove', mousemoveHandler);
});

// 鼠标离开,移除鼠标移动事件监听
el.addEventListener('mouseleave', () => {
div.style.display = 'none';
el.removeEventListener('mousemove', mousemoveHandler);
});
},
unmounted() {
const div = document.getElementById('v-cursor');
if (div) {
document.body.removeChild(div);
}
},
};

此外还需要给这个元素一个全局样式:

1
2
3
4
5
6
7
8
9
10
.v-cursor {
display: none;
position: fixed;
width: 16px;
height: 16px;
background-repeat: no-repeat;
background-position: center center;
background-size: 12px 16px;
background-image: url('./assets/lock.svg');
}

然后在main.ts​中注册这一个钩子:

1
2
3
4
5
6
import { createApp } from 'vue';
import App from './App.vue';
import cursor from './diretives/cursor';

const app = createApp(App);
app.directive('cursor', cursor);

写一个demo来测试一下:

1
<Button v-cursor>click</Button>

可以发现鼠标和这个锁图标一直在闪来闪去,看控制台,可以发现这个锁图标的display​在none​和block​之间不停切换,mouseleave​事件也不停地在触发,这个问题是由于鼠标移动到了锁图标的div​上,导致鼠标离开了目标元素,因此不停地触发mouseleave​事件,解决这个问题,我们只需要在样式中添加pointer-events: none;​,或者把元素的位置移到鼠标的右下角就好了:

1
2
3
4
5
6
7
8
9
10
11
.v-cursor {
display: none;
position: fixed;
width: 16px;
height: 16px;
pointer-events: none;
background-repeat: no-repeat;
background-position: center center;
background-size: 12px 16px;
background-image: url('./assets/lock.svg');
}
1
2
3
4
const mousemoveHandler = (event: MouseEvent) => {
div.style.left = `${event.pageX + 10}px`;
div.style.top = `${event.pageY + 10}px`;
};

再看看效果:

有点意思了,好像基本的需求已经实现了,接下来就是处理一下点击事件,阻止一下原来的onClick​事件就好了,写完收工~

等等,事情可能并没有那么简单!

元素复用

虽然刚刚这样其实已经可以用了,但是看看下面的这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
export default defineComponent({
name: 'CursorDemo',
setup() {
const columns: ColumnsType = [
{
title: 'id',
dataIndex: 'id',
},
{
title: 'operation',
customRender: () => (
<a v-cursor>删除</a>
)
}
];
const data = shallowRef<{ id: number }[]>();
data.value = new Array(100).fill(0).map((_, i) => ({ id: i }));

return () => (
<div class="cursor-demo">
<Table
columns={columns}
dataSource={data.value}
pagination={false}
scroll={{
y: 500
}}
/>
</div>
)
}
})

这里创建了一个100行的表格,其中的一列渲染的是需要鉴权的按钮,这时候可以看到有非常明显的卡顿,这是因为我们刚刚写的指令,每用一次,就创建了一个锁图标,这里有100个,就创建了100个锁图标,不停地操作这100个锁图标,无疑是十分消耗性能的。

因此,我们要避免创建那么多元素,应该只使用一个就好了,这个简单,我们只需在指令里添加一个全局的变量存储这个元素,并且复用就好了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let proxy: HTMLDivElement;
const createProxy = () => {
proxy = document.createElement('div');
proxy.className = 'v-cursor';
proxy.id = 'v-cursor';
document.body.append(proxy);
};

mounted(el: HTMLElement) {
// 创建一个新的 div 元素作为鼠标跟随的提示
if (!proxy) {
createProxy();
}
// ......省略部分代码
}

我们创建了一个proxy变量,暂且叫它代理变量吧,用于存储创建的锁图标元素,现在再试试,已经变成只渲染一个锁图标了。

添加配置项

现在这个指令的用法是直接在标签上使用v-cursor​,还不能自定义,我们想让目标元素在有权限时可以正常显示和点击,无权限时显示锁,所以需要添加配置项,用来自定义是否显示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
type CursorOptions = {
active?: boolean;
onclick?: Function;
};

const defualtOptions: CursorOptions = { active: true };

const cursor: ObjectDirective<any, CursorOptions> = {
mounted(el: HTMLElement, binding) {
// 处理配置项,这里的配置项可以是一个布尔值,也可以是一个对象
const data = { ...defualtOptions };
if (typeof binding.value !== 'object') {
data.active = binding.value;
} else {
Object.assign(data, binding.value);
}
// ......
// 鼠标进入,添加鼠标移动事件监听
el.addEventListener('mouseenter', () => {
if (!data.active) {
return;
}
proxy.style.display = 'block';
el.addEventListener('mousemove', mousemoveHandler);
});
// ......
}
}

现在指令可以这样使用:

1
2
<Button v-cursor={true}>click</Button>
<Button v-cursor={{ active: true }}>click</Button>

处理点击事件

接下来处理一下点击事件,我们想要达到的效果是:

  • 指令不启用时,触发元素本身的点击事件
  • 指令启用时,触发指令里配置的onclick​函数,本身的点击事件不触发

刚刚添加配置项时,已经在类型里定义了onclick​这个属性,是一个函数。我们在指令中监听一下点击事件,在指令启用时,调用stopPropagation​阻止事件传递,并触发传入的函数:

1
2
3
4
5
6
7
8
9
el.addEventListener('click', (event) => {
if (data.active) {
event.stopPropagation();
const callback = data.onclick;
if (typeof callback === 'function') {
callback();
}
}
});

现在把刚刚的表格操作列改成这样,把偶数列的按钮都启用指令,奇数列的正常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
title: 'operation',
customRender: (row) => (
<Button
text
theme="primary"
onClick={() => {
console.log("clicked");
}}
v-cursor={{
active: row.index % 2 === 0,
onclick: () => {
console.log("no permission");
}
}}
>删除</Button>
)
}

我们希望在指令启用时,只打印”no permission”,不启用时,只打印”click”。点击按钮试一下,发现启用指令的按钮两个字符串都被同时打印了:

怎么会这样?不是已经调用了stopPropagation​了吗?

这是因为这里的事件侦听器是在事件的冒泡阶段触发的,而onXYZ​这类属性绑定事件是在目标阶段触发的,因此阻止事件传播是无效的。我们只需要给addEventListener​加上第三个可选参数onCapture​为true​,让事件在捕获阶段触发,即可阻止事件传播:

1
2
3
el.addEventListener('click', (event) => {
//......
}, true);

效果:

处理disabled的元素

在页面中,需要判断有无权限的一般都是类似button​、input​等这样的表单元素,我们一般希望这些表单元素在无权限的时候,以它们禁用的样式来显示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
title: 'operation',
customRender: (row) => (
<Button
text
theme="primary"
onClick={() => {
console.log("clicked");
}}
disabled={row.index % 2 === 0}
v-cursor={{
active: row.index % 2 === 0,
onclick: () => {
console.log("no permission");
}
}}
>删除</Button>
)
}

在上面的例子的基础上,我们给启用指令的元素加上了disabled​属性。但是点击后,两个点击事件都不执行了:

这是因为disabled​属性会阻止点击事件的触发,为此我们需要特殊处理一下disabled​的表单元素的情况。

在指令中,我们可以通过el.disabled​来判断元素是否被禁用,通过是否被禁用,在元素上面添加一个笼罩层,将点击事件绑定到笼罩层上,来模拟元素的点击:

先写一个函数,用于在这个元素内添加一个笼罩层:

1
2
3
4
5
6
7
8
9
10
11
12
13
const addCoverLayer = (el: HTMLElement) => {
const coverLayer = document.createElement('div');
coverLayer.style.position = 'absolute';
coverLayer.style.top = '0';
coverLayer.style.left = '0';
coverLayer.style.width = '100%';
coverLayer.style.height = '100%';
coverLayer.style.zIndex = '10';
coverLayer.className = 'cover-layer';
el.style.position = 'relative';
el.appendChild(coverLayer);
return coverLayer;
};

添加了笼罩层后,绑定事件的对象也都需要改变,我们也创建一个变量用来存储绑定事件的对象,在后面直接为这个变量添加事件侦听器即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let target = el;
// 如果元素是禁用状态,添加一个遮罩层
if (el.disabled) {
target = addCoverLayer(el);
}

// 鼠标进入,添加鼠标移动事件监听
target.addEventListener('mouseenter', () => {
// ......
target.addEventListener('mousemove', mousemoveHandler);
});

// 鼠标离开,移除鼠标移动事件监听
target.addEventListener('mouseleave', () => {
// ......
target.removeEventListener('mousemove', mousemoveHandler);
});

target.addEventListener('click', (event: MouseEvent) => {
// ......
}, true);

现在disabled​的元素也可以正常触发指定的点击事件了。

优化

现在看起来好像已经没有什么问题了,但是目前代码写的还是像一坨💩,我们还需要优化、完善一下代码。

我们可以把添加鼠标事件侦听器的部分封装成一个函数,为了防止内存泄漏,我们需要再unmounted​时移除事件侦听器,因此事件侦听器的回调函数不能是一个匿名函数。现在就遇到一个问题,如果把这些回调函数写在外面了,我们要怎么通过获取到自定义指令内的信息呢?

前面的代码里,我们都是需要在回调函数里通过active​属性来判断是否需要显示锁图标、是否需要阻止事件传播等。我们可以在mounted​函数的el​参数上动手脚。我们在el上添加一个__cursorTarget__​属性,用来存储真正用于添加事件侦听器的元素,然后在这个元素上添加一个__cursorOption__​属性,保存是否启用指令。最后在回调中通过currentTarget拿到元素,再拿到__cursorOption__​,判断是否需要操作即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
import type { ObjectDirective } from "vue";

type CursorOptions = {
active?: boolean; // 是否启用
onclick?: Function; // 自定义点击事件
};

type CursorElement = HTMLElement & { __cursorOption__: CursorOptions; };

// 默认配置项
const defualtOptions: CursorOptions = { active: true };

// 用于存储锁图标的div元素
let proxy: HTMLDivElement;
const createProxy = () => {
proxy = document.createElement('div');
proxy.className = 'v-cursor';
proxy.id = 'v-cursor';
document.body.append(proxy);
};

// 用于禁用元素时,添加一个遮罩层
const addCoverLayer = (el: HTMLElement) => {
const coverLayer = document.createElement('div');
coverLayer.style.position = 'absolute';
coverLayer.style.top = '0';
coverLayer.style.left = '0';
coverLayer.style.width = '100%';
coverLayer.style.height = '100%';
coverLayer.style.zIndex = '10';
coverLayer.className = 'cover-layer';
el.style.position = 'relative';
el.appendChild(coverLayer);
return coverLayer;
};

let frameId: number;
const updateProxyPosition = (event: MouseEvent) => {
if (frameId) {
window.cancelAnimationFrame(frameId);
}
frameId = window.requestAnimationFrame(() => {
proxy.style.left = `${event.clientX + 10}px`;
proxy.style.top = `${event.clientY + 10}px`;
});
};


// 移除遮罩层
const removeCoverLayer = (el: HTMLElement) => {
const coverLayer = el.querySelector('.cover-layer');
if (coverLayer) {
el.removeChild(coverLayer);
}
};

// 四个事件处理函数
const mouseenter = (event: MouseEvent) => {
const target = event.currentTarget as CursorElement;
const data = target.__cursorOption__;
if (data.active) {
proxy.style.display = 'block';
target.addEventListener('mousemove', mousemove);
}
}
const mousemove = (event: MouseEvent) => {
const el = event.currentTarget as CursorElement;
const data = el.__cursorOption__;
if (data.active) {
updateProxyPosition(event);
}
};
const mouseleave = (event: MouseEvent) => {
const el = event.currentTarget as CursorElement;
proxy.style.display = 'none';
el.removeEventListener('mousemove', mousemove);
};
const click = (event: MouseEvent) => {
const el = event.currentTarget as CursorElement;
const data = el.__cursorOption__;
if (data.active) {
event.stopPropagation();
const callback = data.onclick;
if (typeof callback === 'function') {
callback(data);
}
}
};

// 添加事件监听
const addEventListener = (el: HTMLElement) => {
el.addEventListener('mouseenter', mouseenter, true);
el.addEventListener('mouseleave', mouseleave, true);
el.addEventListener('click', click, true);
};

// 移除事件监听
const removeEventListener = (el: HTMLElement) => {
if (el) {
el.removeEventListener('mouseenter', mouseenter);
el.removeEventListener('mouseleave', mouseleave);
el.removeEventListener('click', click);
}
};

const cursor: ObjectDirective<any, CursorOptions> = {
mounted(el: any, binding) {
// 处理配置项,这里的配置项可以是一个布尔值,也可以是一个对象
const data = { ...defualtOptions };
if (typeof binding.value !== 'object') {
data.active = binding.value;
} else {
Object.assign(data, binding.value);
}
if (!data.active) {
return;
}

// 创建一个新的div元素作为锁图标
if (!proxy && data.active) {
createProxy();
}

// 用于存储添加事件监听的元素
let target = el;
// 如果元素是禁用状态,添加一个遮罩层
if (el.disabled) {
target = addCoverLayer(el);
}

// 避免在外部函数中,无法获取到bingding值
el.__cursorTarget__ = target;
target.__cursorOption__ = data;
addEventListener(target);
},
unmounted(el) {
removeCoverLayer(el);
const target = el.__cursorTarget__;
removeEventListener(target);
const cursorElement = document.getElementById('v-cursor');
if (cursorElement) {
document.body.removeChild(cursorElement);
}
},
};

export default cursor;

至此,整个自定义指令就写完了,算是勉强能用,在配置项上有很多地方偷懒了,其实还可以加上自定义图标、大小、位置、或者类名等等配置项,只不过现在项目没这个需求,所以就暂时不加了。。。