Vue3 选择器 Canvas 增加页面性能
基于Vue3 Composition API和Canvas实现的交互式选择器,支持PC端和移动端的拖动选择、多选取消选择功能

vue3组件封装
<script lang="ts" setup>
import { onMounted, reactive, watch } from 'vue';
import { CheckList } from '/@/types';
const props = defineProps({
list: {
type: Array as PropType<CheckList[]>,
default: () => [],
},
});
const emit = defineEmits(['changeValue']);
const canvas: Ref<HTMLCanvasElement | null> = ref(null);
const ctx: Ref<CanvasRenderingContext2D | null> = ref(null);
const rows = 8;
const cols = 12;
const rowLabels = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
const colLabels = Array.from({ length: 12 }, (_, i) => i + 1);
type Position = {
x: number;
y: number;
};
const isSelecting = ref(false);
const startPos = ref<Position>({ x: 0, y: 0 });
const endPos = ref<Position>({ x: 0, y: 0 });
const selectionMode = ref('add');
const options = ref([...props.list]);
const selectedItems = computed(() => options.value.filter((opt) => opt.selected).map((opt) => opt.id));
const selectedCount = computed(() => options.value.filter((opt) => opt.selected).length);
const initCanvas = () => {
if (canvas.value == null) return;
const canvasEl: HTMLCanvasElement = canvas.value;
ctx.value = canvasEl.getContext('2d');
canvasEl.width = canvasEl.clientWidth;
canvasEl.height = canvasEl.clientHeight;
drawGrid();
};
const drawGrid = () => {
if (options.value.length == 0 || !canvas.value || !ctx.value) return;
const canvasEl = canvas.value;
ctx.value.clearRect(0, 0, canvasEl.width, canvasEl.height);
const cellWidth = canvasEl.width / cols;
const cellHeight = canvasEl.height / rows;
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const x = col * cellWidth;
const y = row * cellHeight;
const index = row * cols + col;
const isSelected = options.value[index].selected;
ctx.value.fillStyle = isSelected ? '#00a9bb' : '#ffffff';
ctx.value.fillRect(x, y, cellWidth, cellHeight);
ctx.value.strokeStyle = isSelected ? '#eeeeee' : '#cccccc';
ctx.value.lineWidth = isSelected ? 3 : 1;
ctx.value.strokeRect(x, y, cellWidth, cellHeight);
ctx.value.fillStyle = isSelected ? '#fff' : '#000000';
ctx.value.font = `bold ${cellHeight * 0.3}px Arial`;
ctx.value.textAlign = 'center';
ctx.value.textBaseline = 'middle';
ctx.value.fillText(options.value[index].id, x + cellWidth / 2, y + cellHeight / 2);
}
}
if (isSelecting.value) {
const x = Math.min(startPos.value.x, endPos.value.x);
const y = Math.min(startPos.value.y, endPos.value.y);
const width = Math.abs(endPos.value.x - startPos.value.x);
const height = Math.abs(endPos.value.y - startPos.value.y);
ctx.value.fillStyle = selectionMode.value === 'add' ? 'rgba(100, 200, 255, 0.2)' : 'rgba(255, 100, 100, 0.2)';
ctx.value.fillRect(x, y, width, height);
ctx.value.strokeStyle = selectionMode.value === 'add' ? 'rgba(100, 200, 255, 0.8)' : 'rgba(255, 100, 100, 0.8)';
ctx.value.lineWidth = 2;
ctx.value.strokeRect(x, y, width, height);
}
};
const getCanvasPos = (event: MouseEvent | TouchEvent) => {
if (canvas.value == null) return;
const canvasEl: HTMLCanvasElement = canvas.value;
const rect = canvasEl.getBoundingClientRect();
let clientX: number, clientY: number;
if ('touches' in event) {
clientX = event.touches[0].clientX;
clientY = event.touches[0].clientY;
} else {
clientX = event.clientX;
clientY = event.clientY;
}
return {
x: clientX - rect.left,
y: clientY - rect.top,
};
};
const startSelection = (event: MouseEvent | TouchEvent) => {
event.preventDefault();
const pos: any = getCanvasPos(event);
startPos.value = { ...pos };
endPos.value = { ...pos };
isSelecting.value = true;
if (canvas.value == null) return;
const canvasEl: HTMLCanvasElement = canvas.value;
const cellWidth = canvasEl.width / cols;
const cellHeight = canvasEl.height / rows;
const colIndex = Math.floor(pos.x / cellWidth);
const rowIndex = Math.floor(pos.y / cellHeight);
const index = rowIndex * cols + colIndex;
if (index >= 0 && index < options.value.length) {
selectionMode.value = options.value[index].selected ? 'remove' : 'add';
options.value[index].selected = !options.value[index].selected;
} else {
selectionMode.value = 'add';
}
drawGrid();
};
const updateSelection = (event: MouseEvent | TouchEvent) => {
if (!isSelecting.value) return;
event.preventDefault();
const pos: any = getCanvasPos(event);
endPos.value = { ...pos };
updateSelectedOptions();
drawGrid();
};
const endSelection = (event: MouseEvent | TouchEvent) => {
if (!isSelecting.value) return;
event.preventDefault();
isSelecting.value = false;
drawGrid();
};
const updateSelectedOptions = () => {
if (canvas.value == null) return;
const canvasEl: HTMLCanvasElement = canvas.value;
const cellWidth = canvasEl.width / cols;
const cellHeight = canvasEl.height / rows;
const minX = Math.min(startPos.value.x, endPos.value.x);
const maxX = Math.max(startPos.value.x, endPos.value.x);
const minY = Math.min(startPos.value.y, endPos.value.y);
const maxY = Math.max(startPos.value.y, endPos.value.y);
const startCol = Math.max(0, Math.floor(minX / cellWidth));
const endCol = Math.min(cols - 1, Math.floor(maxX / cellWidth));
const startRow = Math.max(0, Math.floor(minY / cellHeight));
const endRow = Math.min(rows - 1, Math.floor(maxY / cellHeight));
for (let row = startRow; row <= endRow; row++) {
for (let col = startCol; col <= endCol; col++) {
const index = row * cols + col;
if (selectionMode.value === 'add') {
options.value[index].selected = true;
} else {
options.value[index].selected = false;
}
}
}
};
const radioValue = ref('1');
const handleRadio = (e: any) => {
if (e.target.value === '1') {
selectAll();
} else if (e.target.value === '2') {
clearSelection();
}
};
const selectAll = () => {
options.value.forEach((opt) => (opt.selected = true));
drawGrid();
};
const clearSelection = () => {
options.value.forEach((opt) => (opt.selected = false));
drawGrid();
};
onMounted(() => {
initCanvas();
window.addEventListener('resize', initCanvas);
});
watch(
options,
(newVal) => {
if (newVal.every((item) => item.selected)) {
radioValue.value = '1';
} else if (newVal.every((item) => !item.selected)) {
radioValue.value = '2';
} else {
radioValue.value = '3';
}
emit('changeValue', newVal);
},
{
deep: true,
},
);
</script>
<template>
<div class="box">
<canvas
ref="canvas"
@mousedown="startSelection"
@mousemove="updateSelection"
@mouseup="endSelection"
@mouseleave="endSelection"
@touchstart="startSelection"
@touchmove="updateSelection"
@touchend="endSelection"
></canvas>
<div class="mt-20 pl-26">
<a-radio-group v-model:value="radioValue" @change="handleRadio" name="radioGroup">
<a-radio value="1">全满</a-radio>
<a-radio value="2">全空</a-radio>
</a-radio-group>
<div mt-10>注:单击带蓝绿色表示有,单击显白色表示无</div>
</div>
</div>
</template>
<style lang="less" scoped>
canvas {
width: 450px;
height: 300px;
background: rgba(10, 15, 30, 0.7);
display: block;
cursor: pointer;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
</style>
html代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue3 Canvas 选择器组件</title>
<script src="https://unpkg.com/vue@3.2.47/dist/vue.global.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
body {
background: linear-gradient(135deg, #1a1a2e, #16213e, #0f3460);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
color: #fff;
}
#app {
max-width: 1200px;
width: 100%;
}
.container {
display: flex;
flex-direction: column;
gap: 30px;
}
header {
text-align: center;
padding: 30px 20px;
background: rgba(255, 255, 255, 0.05);
border-radius: 20px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.3);
}
h1 {
font-size: 2.8rem;
margin-bottom: 15px;
background: linear-gradient(to right, #4facfe, #00f2fe);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.subtitle {
font-size: 1.2rem;
opacity: 0.85;
max-width: 800px;
margin: 0 auto;
line-height: 1.6;
}
.content {
display: flex;
gap: 30px;
flex-wrap: wrap;
}
.canvas-container {
flex: 1;
min-width: 300px;
background: rgba(255, 255, 255, 0.05);
border-radius: 20px;
padding: 25px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.3);
}
canvas {
width: 100%;
height: 600px;
background: rgba(10, 15, 30, 0.8);
border-radius: 15px;
display: block;
cursor: pointer;
box-shadow: 0 5px 25px rgba(0, 0, 0, 0.5);
}
.instructions {
font-size: 0.9rem;
text-align: center;
margin-top: 15px;
opacity: 0.8;
}
.info-panel {
width: 320px;
background: rgba(255, 255, 255, 0.05);
border-radius: 20px;
padding: 30px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.3);
}
.info-panel h2 {
font-size: 1.8rem;
margin-bottom: 25px;
color: #00f2fe;
text-align: center;
background: linear-gradient(to right, #4facfe, #00f2fe);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.stats {
display: flex;
justify-content: space-between;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.stat {
text-align: center;
padding: 15px;
background: rgba(0, 0, 0, 0.2);
border-radius: 15px;
flex: 1;
margin: 0 10px;
}
.stat-value {
font-size: 2.5rem;
font-weight: bold;
color: #4facfe;
margin-bottom: 5px;
}
.stat-label {
font-size: 0.95rem;
opacity: 0.8;
}
.selected-items {
max-height: 300px;
overflow-y: auto;
margin-top: 20px;
}
.selected-items h3 {
margin-bottom: 20px;
color: #00f2fe;
text-align: center;
font-size: 1.4rem;
}
.items-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
.item {
background: linear-gradient(to right, rgba(79, 172, 254, 0.2), rgba(0, 242, 254, 0.2));
padding: 8px 15px;
border-radius: 25px;
font-size: 1rem;
border: 1px solid rgba(79, 172, 254, 0.4);
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 15px;
justify-content: center;
margin-top: 30px;
}
button {
background: linear-gradient(to right, #4facfe, #00f2fe);
color: white;
border: none;
padding: 12px 30px;
border-radius: 30px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
letter-spacing: 0.5px;
min-width: 180px;
}
button:hover {
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
}
button:active {
transform: translateY(1px);
}
.empty-message {
text-align: center;
opacity: 0.6;
font-style: italic;
margin: 25px 0;
padding: 20px;
background: rgba(0, 0, 0, 0.15);
border-radius: 15px;
}
.mode-indicator {
display: flex;
justify-content: center;
gap: 20px;
margin-top: 15px;
}
.mode {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 20px;
background: rgba(0, 0, 0, 0.2);
}
.mode-color {
width: 20px;
height: 20px;
border-radius: 50%;
}
.add-color {
background: rgba(100, 200, 255, 0.8);
}
.remove-color {
background: rgba(255, 100, 100, 0.8);
}
.active-mode {
background: rgba(79, 172, 254, 0.3);
border: 1px solid rgba(79, 172, 254, 0.6);
}
footer {
text-align: center;
padding: 25px;
opacity: 0.7;
font-size: 0.95rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 20px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.3);
}
@media (max-width: 768px) {
.content {
flex-direction: column;
}
.info-panel {
width: 100%;
}
h1 {
font-size: 2.2rem;
}
canvas {
height: 500px;
}
.stat-value {
font-size: 2rem;
}
}
.selected-items::-webkit-scrollbar {
width: 8px;
}
.selected-items::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.selected-items::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, #4facfe, #00f2fe);
border-radius: 4px;
}
</style>
</head>
<body>
<div id="app">
<div class="container">
<header>
<h1>Vue3 Canvas 选择器组件</h1>
<p class="subtitle">基于Vue3 Composition API和Canvas实现的交互式选择器,支持PC端和移动端的拖动选择、多选取消选择功能</p>
</header>
<div class="content">
<canvas-selector></canvas-selector>
</div>
<footer>
<p>Vue3 + Canvas 实现 | 支持PC端和移动端 | 拖动选择多个选项 | 点击切换选择模式</p>
</footer>
</div>
</div>
<script>
const { createApp, ref, onMounted, computed, defineComponent } = Vue;
const CanvasSelector = defineComponent({
setup() {
const canvas = ref(null);
const ctx = ref(null);
const rows = 12;
const cols = 8;
const colLabels = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
const rowLabels = Array.from({ length: 12 }, (_, i) => i + 1);
const isSelecting = ref(false);
const startPos = ref({ x: 0, y: 0 });
const endPos = ref({ x: 0, y: 0 });
const selectionMode = ref('add');
const options = ref(Array(rows * cols).fill().map((_, i) => ({
id: `${colLabels[i % cols]}${rowLabels[Math.floor(i / cols)]}`,
selected: false
})));
const selectedItems = computed(() =>
options.value.filter(opt => opt.selected).map(opt => opt.id)
);
const selectedCount = computed(() =>
options.value.filter(opt => opt.selected).length
);
const initCanvas = () => {
const canvasEl = canvas.value;
ctx.value = canvasEl.getContext('2d');
canvasEl.width = canvasEl.clientWidth;
canvasEl.height = canvasEl.clientHeight;
drawGrid();
};
const drawGrid = () => {
const canvasEl = canvas.value;
ctx.value.clearRect(0, 0, canvasEl.width, canvasEl.height);
const cellWidth = canvasEl.width / cols;
const cellHeight = canvasEl.height / rows;
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const x = col * cellWidth;
const y = row * cellHeight;
const index = row * cols + col;
const isSelected = options.value[index].selected;
ctx.value.fillStyle = isSelected ? 'rgba(79, 172, 254, 0.7)' : 'rgba(30, 35, 60, 0.8)';
ctx.value.fillRect(x, y, cellWidth, cellHeight);
ctx.value.strokeStyle = isSelected ? 'rgba(0, 242, 254, 0.9)' : 'rgba(100, 150, 255, 0.3)';
ctx.value.lineWidth = isSelected ? 3 : 1;
ctx.value.strokeRect(x, y, cellWidth, cellHeight);
ctx.value.fillStyle = isSelected ? '#fff' : 'rgba(255, 255, 255, 0.7)';
ctx.value.font = `bold ${cellHeight * 0.3}px Arial`;
ctx.value.textAlign = 'center';
ctx.value.textBaseline = 'middle';
ctx.value.fillText(
options.value[index].id,
x + cellWidth / 2,
y + cellHeight / 2
);
}
}
ctx.value.fillStyle = 'rgba(200, 220, 255, 0.9)';
ctx.value.font = `bold ${cellHeight * 0.25}px Arial`;
for (let col = 0; col < cols; col++) {
ctx.value.fillText(
colLabels[col],
(col + 0.5) * cellWidth,
cellHeight * 0.2
);
}
for (let row = 0; row < rows; row++) {
ctx.value.fillText(
rowLabels[row].toString(),
cellWidth * 0.2,
(row + 0.5) * cellHeight
);
}
if (isSelecting.value) {
const x = Math.min(startPos.value.x, endPos.value.x);
const y = Math.min(startPos.value.y, endPos.value.y);
const width = Math.abs(endPos.value.x - startPos.value.x);
const height = Math.abs(endPos.value.y - startPos.value.y);
ctx.value.fillStyle = selectionMode.value === 'add'
? 'rgba(100, 200, 255, 0.2)'
: 'rgba(255, 100, 100, 0.2)';
ctx.value.fillRect(x, y, width, height);
ctx.value.strokeStyle = selectionMode.value === 'add'
? 'rgba(100, 200, 255, 0.8)'
: 'rgba(255, 100, 100, 0.8)';
ctx.value.lineWidth = 2;
ctx.value.setLineDash([5, 3]);
ctx.value.strokeRect(x, y, width, height);
ctx.value.setLineDash([]);
}
};
const getCanvasPos = (event) => {
const canvasEl = canvas.value;
const rect = canvasEl.getBoundingClientRect();
let clientX, clientY;
if (event.type.includes('touch')) {
clientX = event.touches[0].clientX;
clientY = event.touches[0].clientY;
} else {
clientX = event.clientX;
clientY = event.clientY;
}
return {
x: clientX - rect.left,
y: clientY - rect.top
};
};
const startSelection = (event) => {
event.preventDefault();
const pos = getCanvasPos(event);
startPos.value = { ...pos };
endPos.value = { ...pos };
isSelecting.value = true;
const canvasEl = canvas.value;
const cellWidth = canvasEl.width / cols;
const cellHeight = canvasEl.height / rows;
const colIndex = Math.floor(pos.x / cellWidth);
const rowIndex = Math.floor(pos.y / cellHeight);
const index = rowIndex * cols + colIndex;
if (index >= 0 && index < options.value.length) {
selectionMode.value = options.value[index].selected ? 'remove' : 'add';
options.value[index].selected = !options.value[index].selected;
} else {
selectionMode.value = 'add';
}
drawGrid();
};
const updateSelection = (event) => {
if (!isSelecting.value) return;
event.preventDefault();
const pos = getCanvasPos(event);
endPos.value = { ...pos };
updateSelectedOptions();
drawGrid();
};
const endSelection = (event) => {
if (!isSelecting.value) return;
event.preventDefault();
isSelecting.value = false;
drawGrid();
};
const updateSelectedOptions = () => {
const canvasEl = canvas.value;
const cellWidth = canvasEl.width / cols;
const cellHeight = canvasEl.height / rows;
const minX = Math.min(startPos.value.x, endPos.value.x);
const maxX = Math.max(startPos.value.x, endPos.value.x);
const minY = Math.min(startPos.value.y, endPos.value.y);
const maxY = Math.max(startPos.value.y, endPos.value.y);
const startCol = Math.max(0, Math.floor(minX / cellWidth));
const endCol = Math.min(cols - 1, Math.floor(maxX / cellWidth));
const startRow = Math.max(0, Math.floor(minY / cellHeight));
const endRow = Math.min(rows - 1, Math.floor(maxY / cellHeight));
for (let row = startRow; row <= endRow; row++) {
for (let col = startCol; col <= endCol; col++) {
const index = row * cols + col;
if (selectionMode.value === 'add') {
options.value[index].selected = true;
} else {
options.value[index].selected = false;
}
}
}
};
const selectAll = () => {
options.value.forEach(opt => opt.selected = true);
drawGrid();
};
const clearSelection = () => {
options.value.forEach(opt => opt.selected = false);
drawGrid();
};
const toggleSelectionMode = () => {
selectionMode.value = selectionMode.value === 'add' ? 'remove' : 'add';
};
onMounted(() => {
initCanvas();
window.addEventListener('resize', initCanvas);
});
return {
canvas,
selectedItems,
selectedCount,
selectionMode,
startSelection,
updateSelection,
endSelection,
selectAll,
clearSelection,
toggleSelectionMode
};
},
template: `
<div class="canvas-container">
<canvas ref="canvas"
@mousedown="startSelection"
@mousemove="updateSelection"
@mouseup="endSelection"
@mouseleave="endSelection"
@touchstart="startSelection"
@touchmove="updateSelection"
@touchend="endSelection"></canvas>
<p class="instructions">PC端:点击并拖动鼠标进行选择 | 移动端:触摸并滑动进行选择</p>
<div class="mode-indicator">
<div class="mode" :class="{ 'active-mode': selectionMode === 'add' }">
<div class="mode-color add-color"></div>
<span>添加模式</span>
</div>
<div class="mode" :class="{ 'active-mode': selectionMode === 'remove' }">
<div class="mode-color remove-color"></div>
<span>移除模式</span>
</div>
</div>
</div>
<div class="info-panel">
<h2>选择信息面板</h2>
<div class="stats">
<div class="stat">
<div class="stat-value">{{ selectedCount }}</div>
<div class="stat-label">已选选项</div>
</div>
<div class="stat">
<div class="stat-value">96</div>
<div class="stat-label">总选项</div>
</div>
</div>
<div class="selected-items">
<h3>已选选项 ({{ selectedCount }})</h3>
<div v-if="selectedItems.length > 0" class="items-list">
<div v-for="item in selectedItems" :key="item" class="item">{{ item }}</div>
</div>
<div v-else class="empty-message">暂无选择,请在左侧区域进行选择</div>
</div>
<div class="controls">
<button @click="selectAll">全选</button>
<button @click="clearSelection">清空</button>
<button @click="toggleSelectionMode">
{{ selectionMode === 'add' ? '切换到移除模式' : '切换到添加模式' }}
</button>
</div>
</div>
`
});
createApp({
components: {
CanvasSelector
}
}).mount('#app');
</script>
</body>
</html>