教你利用Vue3模仿Windows窗口
一、前言
Vue3終于在2022年2月7日正式發(fā)布了,之前用vite+vue3搭了一個小demo,資料太少而我太菜了,所以一直不敢用Vue3搭新項目,現(xiàn)在隨著Vue3正式版本的發(fā)布,而且相關配合的子項目庫也已經完善,大量的翻譯資料和文獻都已經可以百度到了,再加上領導支持用Vue3新框架,所以我在新項目上著手用vue-cli(@vue/cli 4.5.9)腳手架搭建Vue3項目。

圖1 拖拽窗體效果展示
主要需求是做一個可以拖動并且放大縮小的窗體,類似于系統(tǒng)桌面的窗口,功能是可拖動然后寬高可通過鼠標拖拽調整,查閱了大量的博文后,打算基于Vue的自定義指令directive來實現(xiàn),指令便于引用,而且使用的功能并不需要按照使用場景特殊化的修改,所以可以將這兩個功能封裝到指令中,然后基于這兩個指令(v-drag、v-resize)再去封裝一個通用窗體容器組件,項目框架基于Vue3+TS來實現(xiàn),由于TS是剛上手,所以基本any一把梭,希望各位大佬莫要嘲笑,不熟悉TS的同學也可以看著代碼實現(xiàn)一套JS版本的,主要功能都是JS基本功,和框架、語言的關系不大,只要能理解實現(xiàn)方法,簡單的三劍客也能實現(xiàn)這個功能。接下來著手實現(xiàn)這個組件吧。
二、功能分析

圖2 dom對象屬性

Event對象屬性
因為是拖動和改變元素尺寸大小的功能,所以需要知道JS元素中的幾個屬性,如上圖所示,我們需要知道的如下所示:
- Dom對象屬性
- offsetTop: 返回當前元素上邊界到其上級元素(offsetParent)的上邊界的距離【只讀】
- offsetLeft: 返回當前元素左邊界到其上級元素(offsetParent)的左邊界的距離【只讀】
- offsetWidth: 返回元素的寬度,包含padding+border-width【只讀】
- offsetHeight: 返回元素的高度,包含padding+border-width 【只讀】
- clientWidth: 返回元素的寬度
- clientHeight: 返回元素的高度
- Event對象屬性
- offsetX: 相對于元素的橫坐標
- offsetY: 相對于元素的縱坐標
- clientX: 相對于瀏覽器窗口的橫坐標
- clientY: 相對于瀏覽器窗口的縱坐標
- pageX: 相對于頁面的橫坐標
- pageY: 相對于頁面的縱坐標
熟悉這幾個屬性后就可以著手來實現(xiàn)拖動和尺寸調整了,主要實現(xiàn)思路如下:
- v-drag 將該指令掛載到第一個子元素,然后通過監(jiān)聽子元素的事件來實現(xiàn),通過子元素先獲取到父元素方便后續(xù)對其進行操作,當鼠標按下事件觸發(fā)的時候開始對鼠標移動事件監(jiān)聽,按下的時候需要記錄鼠標所在位置的x,y軸的坐標值(相對于頁面的位置x,y),然后記錄拖動前父元素的top,left的數(shù)值,再獲取窗口的寬高,減去父元素本身的寬高,計算得到父元素所能移動的最大位移距離,超過距離不能再移動。最后通過mousemove開始實時計算鼠標位移距離,并將變化的位移距離更新到父元素,實現(xiàn)元素的移動功能。
- v-resize 調整元素寬高的指令有一些復雜,需要給元素指定一個name屬性為resize,綁定該指令不能覆蓋預設的name值,然后通過name屬性確定是該元素。這里先定義一些需要記錄的屬性數(shù)據(jù),首先是cursor的屬性值,cursor是css中的指定鼠標樣式的屬性,這里一共8個方位,所以分別列出這些屬性,并和top、bottom、left、right做一個關系映射,這樣方便理解,也容易操作。然后是記錄元素修改前的大小、位置、鼠標按下的位置、改變方向,定義完這些變量后,對一些特殊的方法進行聚合,首先是獲取鼠標的方位,通過計算鼠標在元素內移動的位置,設置一個內邊距觸發(fā)計算方法,這里設置offset偏移量為12px,當鼠標在元素水平或垂直距離邊框為12px的時候,就可以通過getDirection獲取到鼠標所在的方位。再定義一個computedDistance 方法,用于計算鼠標前后移動的x,y的距離,最后就是計算改變尺寸方法的封裝,changeSize方法中獲取到鼠標位移的距離,然后結合移動的方向記錄值,進行方法調用修改尺寸,方法中只將一半做了最小寬高設置,這里可以通過css來設置不用在js中編寫,后續(xù)組件封裝會看到。同樣觸發(fā)的方式是onmousedown的時候開啟事件,這里會獲取是否在8個方位范圍上,如果在就記錄按下按鈕時的數(shù)據(jù)和方位,并且觸發(fā)移動計算方法,鼠標按鈕抬起釋放的時候會對數(shù)據(jù)和方法重置,結束尺寸調整。 鼠標樣式控制可以分開來看,主要對于寬高調整沒有影響,監(jiān)聽8個方位,然后修改鼠標樣式,使交互操作更加友好。
三、指令封裝
v-drag與v-resize指令:
//directives.ts
import { App } from "vue";
import { throttle } from "@/utils"; //節(jié)流函數(shù)不再展示,不要直接去除即可,在下面樣式引用去除即可
const directives = {
drag: {
mounted(el: any, binding: any, vnode: any) {
// 如果傳遞了false就不啟用指令,反之true undefined null 不傳 則啟動
if (!binding.value && (binding.value ?? "") !== "") return;
// 拖拽實現(xiàn)
const odiv = el.parentNode;
el.onmousedown = (eve: any) => {
odiv.style.zIndex = 1; //當前拖拽的在最前面顯示
eve = eve || window.event;
const mx = eve.pageX; //鼠標點擊時的坐標
const my = eve.pageY; //鼠標點擊時的坐標
const dleft = odiv.offsetLeft; //窗口初始位置
const dtop = odiv.offsetTop;
const clientWidth = document.documentElement.clientWidth; //頁面的寬
const oWidth = odiv.clientWidth; //窗口的寬
const maxX = clientWidth - oWidth; // x軸能移動的最大距離
const clientHeight = document.documentElement.clientHeight; //頁面的高
const oHeight = odiv.clientHeight; //窗口的高度
const maxY = clientHeight - oHeight; //y軸能移動的最大距離
document.onmousemove = (e: any) => {
const x = e.pageX;
const y = e.pageY;
let left = x - mx + dleft; //移動后的新位置
let top = y - my + dtop; //移動后的新位置
if (left < 0) left = 0;
if (left > maxX) left = maxX;
if (top < 0) top = 0;
if (top > maxY) top = maxY;
odiv.style.left = left + "px";
odiv.style.top = top + "px";
odiv.style.marginLeft = 0;
odiv.style.marginTop = 0;
};
document.onmouseup = () => {
document.onmousemove = null;
};
};
}
},
resize: {
mounted(el: any, binding: any, vnode: any) {
// 如果傳遞了false就不啟用指令,反之true undefined null 不傳 則啟動
if (!binding.value && (binding.value ?? "") !== "") return;
// 給選定的元素綁定name屬性 設置name為resize區(qū)分只有該元素可以縮放
el.name = "resize";
// 八個方位對應
const mouseDir = {
top: "n-resize", //上
bottom: "s-resize", //下
left: "w-resize", //左
right: "e-resize", //右
topright: "ne-resize", //右上
topleft: "nw-resize", //左上
bottomleft: "sw-resize", //左下
bottomright: "se-resize" //右下
};
// 記錄被修改元素的原始位置大小,以及變更方向
const pos = { width: 0, height: 0, top: 0, left: 0, x: 0, y: 0, dir: "" };
// 獲取鼠標所在方位
const getDirection = (ev: any): string => {
let dir = "";
const xP = ev.offsetX;
const yP = ev.offsetY;
const offset = 12; //內邊距為多少時觸發(fā)
// 計算是那個方位
if (yP < offset) dir += "top";
else if (yP > ev.toElement.clientHeight - offset) dir += "bottom";
if (xP < offset) dir += "left";
else if (xP > ev.toElement.clientWidth - offset) dir += "right";
return dir;
};
// 計算移動距離
const computedDistance = (pre: any, cur: any): any => {
return [cur.x - pre.x, cur.y - pre.y];
};
//數(shù)據(jù)重置
const resetData = () => {
pos.width = 0;
pos.height = 0;
pos.top = 0;
pos.left = 0;
pos.x = 0;
pos.y = 0;
pos.dir = "";
document.onmousemove = null;
};
// 變更尺寸方法
const changeSize = (e: any) => {
// 兩個點之間的差值,計算鼠標位移數(shù)值
const [disX, disY] = computedDistance(
{ x: pos.x, y: pos.y },
{ x: e.pageX, y: e.pageY }
);
const addWid = pos.width + disX;
const subWid = pos.width - disX;
const addHig = pos.height + disY;
const subHig = pos.height - disY;
const minX = 200;
const minY = 200;
//上下左右的變更方法
const top = () => {
if (subHig <= minY) return; //不能小于最小最高
el.style.height = subHig + "px";
el.style.top = pos.top + disY + "px";
}; // 上
const bottom = () => {
el.style.height = addHig + "px";
}; // 下
const left = () => {
if (subWid <= minX) return; //不能小于最小寬度
el.style.width = subWid + "px";
el.style.left = pos.left + disX + "px";
}; // 左
const right = () => {
el.style.width = addWid + "px";
}; // 右
// 變更方位及其修改方法映射
const doFn = {
top, //上
bottom, //下
left, //左
right, //右
topright: () => {
top();
right();
}, //右上
topleft: () => {
top();
left();
}, //左上
bottomleft: () => {
bottom();
left();
}, //左下
bottomright: () => {
bottom();
right();
} //右下
};
doFn[pos.dir]();
};
//鼠標按下 觸發(fā)變更事件
el.onmousedown = (e: any) => {
if (e.target.name !== "resize") return;
let d = getDirection(e);
//當位置為四個邊和四個角才開啟尺寸修改
if (mouseDir[d]) {
pos.width = el.clientWidth;
pos.height = el.clientHeight;
pos.top = el.offsetTop;
pos.left = el.offsetLeft;
pos.x = e.pageX;
pos.y = e.pageY;
pos.dir = d;
document.onmousemove = changeSize;
}
document.onmouseup = resetData;
};
/** 鼠標樣式變更 */
const changeShowCursor = throttle((e: any) => {
e.preventDefault();
el.style.cursor = "default"; //先恢復鼠標默認
if (e.target.name !== "resize") return;
// 修改鼠標顯示效果
let d = getDirection(e);
// 確定是某個方位的動向
el.style.cursor = mouseDir[d] || "default";
}, 200); //節(jié)流0.2s
el.onmousemove = changeShowCursor; //監(jiān)聽根元素上移動的鼠標事件
}
}
};
export default (app: App) => {
//批量注冊指令
Object.entries(directives).forEach(([key, fn]) => {
app.directive(key, fn);
});
};上面的兩個指令,主要都是獲取元素本身,使用原生的js方法對元素進行操作,需要注意的是v-drag是綁定在根元素的第一個子元素上(調整父元素的位置),而v-resize則是綁定元素本身(調整元素本身的大小)。完成兩個指令的編寫后,可以在局部引用注冊或是全局注冊,這里我使用全局注冊的方法。
//main.ts 全局注冊
import { createApp } from "vue";
import App from "./App.vue";
import registerDirectives from "@/directives";
const app = createApp(App);
registerDirectives(app);
app.mount("#app");全局注冊指令完成后,就可以在組件內使用這兩個指令了,接下來我們編寫一個比較通用的彈窗組件,可以打開關閉,并且能夠拖動和尺寸調整。
四、通用組件封裝
這里封裝組件的過程和Vue2差別不大,只是組件的編寫采用Vue3的組合式API寫法,其他方面基本都差不多,對于vue的css過渡效果2和3的版本有些許差異,這里請自行查閱Vue3文檔,剩下就是定義一些需要修改的屬性,使用props接收,并且設置默認值,盡量讓組件可以更方便的自定義修改和擴展。
下面是使用兩個指令后,封裝的一個彈窗組件,這里面在設置窗體css樣式drag-dialog的時候使用了min-width: 200px;min-height: 200px;max-width: 100vw;max-height: 100vh;在這里通過對寬高的限制,就可以不用通過js來限制窗體的大小調整了,之前在寫v-resize指令的時候有提到過,使用js來控制顯示窗體的最小和最大顯示范圍,這里個人覺得還是通過css編寫方便一些。
<template>
<transition name="drag-win">
<div
class="drag-dialog ban-select-font"
ref="dragWin"
v-show="props.modelValue"
v-resize="props.resizeAble"
>
<!-- 拖拽窗體頭部 -->
<div class="drag-bar" :style="props.headStyle" v-drag="props.dragAble">
<slot name="head" />
<div
class="drag-btn drag-close"
@click="controlDialog"
v-if="props.closeShow"
/>
<i
class="drag-btn drag-full"
@click="fullScreen"
v-if="props.fullShow"
/>
</div>
<!-- 拖拽框主要部分 -->
<div class="drag-main" :style="props.mainStyle">
<slot />
</div>
</div>
</transition>
</template>
<script lang="ts" setup>
import { ref } from "vue";
// props傳入數(shù)據(jù)類型約束
interface Props {
modelValue: boolean; //控制窗體的顯示與否
width?: string; // 默認寬 —— 設置頭高 寬高最好傳入變量
height?: string; // 默認高
headHeight?: string; // 默認控制欄高
headStyle?: string; // 控制欄樣式
mainStyle?: string; //主要內容區(qū)域樣式
resizeAble?: boolean | string; // 是否可以調整尺寸 默認可以調整
dragAble?: boolean | string; // 是否可以拖拽 默認可拖拽
closeShow?: boolean; // 關閉控制顯示 默認不顯示
fullShow?: boolean; // 全屏控制顯示 默認不顯示
}
/** 組件調整參數(shù)默認值 */
const props = withDefaults(defineProps<Props>(), {
modelValue: true,
width: "500px",
height: "60vh",
headHeight: "35px",
headStyle: "",
mainStyle: "",
resizeAble: "",
dragAble: "",
closeShow: false,
fullShow: false
});
// 窗體記錄數(shù)據(jù)類型約束
interface recordType {
width: number;
height: number;
top: number;
left: number;
fill: boolean;
}
//記錄原來的大小
const recordBox: recordType = {
width: 0,
height: 0,
top: 0,
left: 0,
fill: false
};
//獲取窗口實體
const dragWin: any = ref(null);
// 事件定義
const emits = defineEmits(["update:modelValue"]);
/** 方法定義 */
// 內部控制窗口開關
const controlDialog = () => {
emits("update:modelValue", !props.modelValue);
};
// 全屏控件
const fullScreen = () => {
const tmp = dragWin.value;
const style = dragWin.value.style;
// 寬的樣式 如果被手動縮小或者放大,則表示非全屏狀態(tài),則將狀態(tài)置為false
if (!style.width || style.width !== "100vw") {
recordBox.fill = false;
}
// 全屏或是還原
if (recordBox.fill) {
style.width = `${recordBox.width}px`;
style.height = `${recordBox.height}px`;
style.top = `${recordBox.top}px`;
style.left = `${recordBox.left}px`;
} else {
// 記錄一下原來的樣式
recordBox.width = tmp.offsetWidth;
recordBox.height = tmp.offsetHeight;
recordBox.top = tmp.offsetTop;
recordBox.left = tmp.offsetLeft;
//全屏樣式
style.width = "100vw";
style.height = "100vh";
style.top = "0px";
style.left = "0px";
}
recordBox.fill = !recordBox.fill; // 全屏狀態(tài)變換
};
</script>
<style scoped>
/* 禁止選中文字 */
.ban-select-font {
-moz-user-select: none; /*火狐*/
-webkit-user-select: none; /*webkit瀏覽器*/
-ms-user-select: none; /*IE10*/
-khtml-user-select: none; /*早期瀏覽器*/
user-select: none;
}
.drag-dialog {
position: fixed;
width: v-bind("props.width");
height: v-bind("props.height");
left: calc(50% - v-bind("props.width") / 2);
top: calc(50% - v-bind("props.height") / 2);
box-sizing: border-box;
padding: 8px;
overflow: hidden;
color: #fff;
min-width: 200px;
min-height: 200px;
max-width: 100vw;
max-height: 100vh;
background-color: #313438cc;
}
.drag-bar {
width: 100%;
cursor: move;
height: v-bind("props.headHeight");
border-bottom: 1px solid #fff;
box-sizing: border-box;
padding: 1px 2px 9px;
}
.drag-btn {
width: 25px;
height: 25px;
float: right;
cursor: pointer;
margin-left: 5px;
border-radius: 50%;
}
.drag-full {
background-color: #28c940b8;
}
.drag-full:hover {
background-color: #28c93f;
}
.drag-close {
background-color: #f2473ec7;
}
.drag-close:hover {
background-color: #f2473e;
}
.drag-main {
width: 100%;
height: calc(100% - v-bind("props.headHeight"));
box-sizing: border-box;
overflow: auto;
font-size: 13px;
line-height: 1.6;
}
/* vue漸入漸出樣式 */
.drag-win-enter-from,
.drag-win-leave-to {
opacity: 0;
transform: scale(0);
}
.drag-win-enter-to,
.drag-win-leave-from {
opacity: 1;
}
.drag-win-enter-active,
.drag-win-leave-active {
transition: all 0.5s ease;
}
</style>這個組件編寫還是有一些問題的,比如打開關閉的時候如果設置過top、left屬性,就會變回初始化時候定義的位置,這里可以參考放大縮小記錄一下窗口的位置等屬性,做一個關閉打開窗體的記錄,我這里沒有寫相關的代碼,主要是對我這個項目影響不大,所以有需要的同學可以自己嘗試一下怎么編寫(ps:主要還是懶)。
編寫完組件后就可以引用注冊,可以全局或局部注冊,這里我使用局部引用注冊,然后編寫了兩個小例子,來使用封裝好的組件,可以查看組件封裝的props,通過里面的屬性來進行組件定制化配置,增減所需功能,然后這里有兩個style,一個是頭部的樣式headStyle,一個是主體樣式mainStyle,最外層樣式直接在引用時編寫style調整即可,然后窗體寬高最好通過傳入字符串變量的方式,因為這里還涉及窗體所在容器內的具體位置計算,默認是水平垂直都居中。下面是引用代碼:
<template>
<div>示例演示:</div>
<button @click="control">{{ btnName }}</button>
<button @click="box = !box">box控制</button>
<IsDragDialog v-model="show" closeShow fullShow>
<template #head>我是頭</template>
<div>我是內容區(qū)域</div>
</IsDragDialog>
<!-- 關閉某些選項 -->
<IsDragDialog
style="top: 200px; left: 10px"
v-model="box"
:resize-able="false"
drag-able
closeShow
fullShow
width="100px"
height="100px"
/>
</template>
<script lang="ts" setup>
import IsDragDialog from "@/components/IsDragDialog.vue"; //因為使用的是 script setup 這里組件會直接注冊
import { computed } from "@vue/reactivity";
import { ref } from "vue";
const show = ref(true);
const box = ref(true);
const control = () => {
show.value = !show.value;
};
const btnName = computed(() => {
return show.value ? `關閉窗口` : `打開窗口`;
});
</script>五、總結及其源代碼參考
功能實現(xiàn)主要還是對于dom元素自帶的屬性需要熟悉掌握,然后通過js的監(jiān)聽事件進行組合事件觸發(fā),修改位置,調整dom元素的大小等等,通過一系列的變量參數(shù)修改與記錄,來實現(xiàn)拖動和dom元素拖拽調整的功能。博文中的代碼可能還不夠全面,所以我將這個代碼抽離然后寫了個demo,基于vue/cli搭了個VUe3+TS的小例子,可以在gitee上下載,下面是源碼地址,npm i然后npm run sreve就可以查看組件demo了,其實這個組件還可以打包成npm包,但是精力有限,而且這個組件兼容性可能會有問題,所以等以后有機會再做個npm包吧。各位大佬,如果有什么更好的想法歡迎分享,也可以指出本文不足或錯誤之處,歡迎指正批評。
源代碼地址:gitee.com/zero-dg/dra…
六、博文參考
http://www.dbjr.com.cn/article/245780.htm
到此這篇關于Vue3模仿Windows窗口的文章就介紹到這了,更多相關Vue3模仿Windows窗口內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
使用vue+element?ui實現(xiàn)走馬燈切換預覽表格數(shù)據(jù)
這次做項目的時候遇到需要切換預覽表格數(shù)據(jù)的需求,所以下面這篇文章主要給大家介紹了關于使用vue+element?ui實現(xiàn)走馬燈切換預覽表格數(shù)據(jù)的相關資料,文中通過實例代碼介紹的非常詳細,需要的朋友可以參考下2022-08-08
Vue實現(xiàn)Tab標簽路由效果并用Animate.css做轉場動畫效果的代碼
這篇文章主要介紹了Vue實現(xiàn)Tab標簽路由效果,并用Animate.css做轉場動畫效果,本文通過實例代碼給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2020-07-07
vue3.0?移動端二次封裝van-uploader實現(xiàn)上傳圖片(vant組件庫)
這篇文章主要介紹了vue3.0?移動端二次封裝van-uploader上傳圖片組件,此功能最多上傳6張圖片,并可以實現(xiàn)本地預覽,實現(xiàn)代碼簡單易懂,需要的朋友可以參考下2022-05-05
詳解Vue-cli webpack移動端自動化構建rem問題
這篇文章主要介紹了詳解Vue-cli webpack移動端自動化構建rem問題,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-04-04
關于Element-ui中Table表格無法顯示的問題及解決
這篇文章主要介紹了關于Element-ui中Table表格無法顯示的問題及解決方案,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2022-08-08

