極致舒適的 Vue 彈窗使用方案
來自:掘金,作者:youth 君
鏈接:https://juejin.cn/post/7253062314306322491
一個Hook
讓你體驗極致舒適的Dialog
使用方式!
Dialog 地獄
爲啥是地獄?
因爲凡是有Dialog
出現的頁面,其代碼絕對優雅不起來!因爲一旦你在也個組件中引入Dialog
,就最少需要額外維護一個visible
變量。如果只是額外維護一個變量這也不是不能接受,可是當同樣的Dialog
組件,即需要在父組件控制它的展示與隱藏,又需要在子組件中控制。
爲了演示我們先實現一個MyDialog
組件,代碼來自 ElementPlus 的 Dialog 示例
<script setup lang="ts">
import { computed } from 'vue';
import { ElDialog } from 'element-plus';
const props = defineProps<{
visible: boolean;
title?: string;
}>();
const emits = defineEmits<{
(event: 'update:visible', visible: boolean): void;
(event: 'close'): void;
}>();
const dialogVisible = computed<boolean>({
get() {
return props.visible;
},
set(visible) {
emits('update:visible', visible);
if (!visible) {
emits('close');
}
},
});
</script>
<template>
<ElDialog v-model="dialogVisible" :title="title" width="30%">
<span>This is a message</span>
<template #footer>
<span>
<el-button @click="dialogVisible = false">Cancel</el-button>
<el-button type="primary" @click="dialogVisible = false"> Confirm </el-button>
</span>
</template>
</ElDialog>
</template>
演示場景
就像下面這樣:
示例代碼如下:
<script setup lang="ts">
import { ref } from 'vue';
import { ElButton } from 'element-plus';
import Comp from './components/Comp.vue';
import MyDialog from './components/MyDialog.vue';
const dialogVisible = ref<boolean>(false);
const dialogTitle = ref<string>('');
const handleOpenDialog = () => {
dialogVisible.value = true;
dialogTitle.value = '父組件彈窗';
};
const handleComp1Dialog = () => {
dialogVisible.value = true;
dialogTitle.value = '子組件1彈窗';
};
const handleComp2Dialog = () => {
dialogVisible.value = true;
dialogTitle.value = '子組件2彈窗';
};
</script>
<template>
<div>
<ElButton @click="handleOpenDialog"> 打開彈窗 </ElButton>
<Comp text="子組件1" @submit="handleComp1Dialog"></Comp>
<Comp text="子組件2" @submit="handleComp2Dialog"></Comp>
<MyDialog v-model:visible="dialogVisible" :title="dialogTitle"></MyDialog>
</div>
</template>
這裏的MyDialog
會被父組件和兩個Comp
組件都會觸發,如果父組件並不關心子組件的onSubmit
事件,那麼這裏的submit
在父組件裏唯一的作用就是處理Dialog
的展示!!!🧐這樣真的好嗎?不好!
來分析一下,到底哪裏不好!
MyDialog
本來是submit
動作的後續動作,所以理論上應該將MyDialog
寫在Comp
組件中。但是這裏爲了管理方便,將MyDialog
掛在父組件上,子組件通過事件來控制MyDialog
。
再者,這裏的**handleComp1Dialog
和handleComp2Dialog
函數除了處理MyDialog
外,對於父組件完全沒有意義卻寫在父組件裏。**
如果這裏的Dialog
多的情況下,簡直就是Dialog
地獄啊!🤯
理想的父組件代碼應該是這樣:
<script setup lang="ts">
import { ElButton } from 'element-plus';
import Comp from './components/Comp.vue';
import MyDialog from './components/MyDialog.vue';
const handleOpenDialog = () => {
// 處理 MyDialog
};
</script>
<template>
<div>
<ElButton @click="handleOpenDialog"> 打開彈窗 </ElButton>
<Comp text="子組件1"></Comp>
<Comp text="子組件2"></Comp>
</div>
</template>
在函數中處理彈窗的相關邏輯才更合理。
解決之道
🤔朕觀之,是書之文或不雅,致使人之心有所厭,何得無妙方可解決?
依史記之辭曰:“天下苦Dialog
久矣,苦楚深深,望有解脫之道。” 於是,諸位賢哲紛紛舉起討伐Dialog
之旌旗,終 “命令式Dialog
” 逐漸突破困境之境地。
沒錯現在網上對於Dialog
的困境,給出的解決方案基本上就 “命令式Dialog
” 看起來比較優雅!這裏給出幾個網上現有的命令式Dialog
實現。
命令式一
吐槽一下~,這種是能在函數中處理彈窗邏輯,但是缺點是MyDialog
組件與showMyDialog
是兩個文件,增加了維護的成本。
命令式二
基於第一種實現的問題,不就是想讓MyDialog.vue
和.js
文件合體嗎?於是諸位賢者想到了JSX
。於是進一步的實現是這樣:
嗯,這下完美了!🌝
完美?還是要吐槽一下~
-
如果我的系統中有很多彈窗,難道要給每個彈窗都寫成這樣嗎?
-
這種兼容
JSX
的方式,需要引入支持JSX
的依賴! -
如果工程中不想即用
template
又用JSX
呢? -
如果已經存在使用
template
的彈窗了,難道推翻重寫嗎? -
...
思考
首先承認一點命令式的封裝的確可以解決問題,但是現在的封裝都存一定的槽點。
如果有一種方式,即保持原來對話框的編寫方式不變,又不需要關心**JSX
和template
的問題,還保存了命令式封裝的特點**。這樣是不是就完美了?
那真的可以同時做到這些嗎?
如果存在一個這樣的 Hook 可以將狀態驅動的 Dialog,轉換爲命令式的 Dialog 嗎,那不就行了?
它來了:useCommandComponent
父組件這樣寫:
<script setup lang="ts">
import { ElButton } from 'element-plus';
import { useCommandComponent } from '../../hooks/useCommandComponent';
import Comp from './components/Comp.vue';
import MyDialog from './components/MyDialog.vue';
const myDialog = useCommandComponent(MyDialog);
</script>
<template>
<div>
<ElButton @click="myDialog({ title: '父組件彈窗' })"> 打開彈窗 </ElButton>
<Comp text="子組件1"></Comp>
<Comp text="子組件2"></Comp>
</div>
</template>
Comp
組件這樣寫:
<script setup lang="ts">
import { ElButton } from 'element-plus';
import { useCommandComponent } from '../../../hooks/useCommandComponent';
import MyDialog from './MyDialog.vue';
const myDialog = useCommandComponent(MyDialog);
const props = defineProps<{
text: string;
}>();
</script>
<template>
<div>
<span>{{ props.text }}</span>
<ElButton @click="myDialog({ title: props.text })">提交(需確認)</ElButton>
</div>
</template>
對於MyDialog
無需任何改變,保持原來的樣子就可以了!
useCommandComponent
真的做到了,即保持原來組件的編寫方式,又可以實現命令式調用!
使用效果:
是不是感受到了莫名的舒適?🤨
不過別急😊,要想體驗這種極致的舒適,你的Dialog
還需要遵循兩個約定!
兩個約定
如果想要極致舒適的使用useCommandComponent
,那麼彈窗組件的編寫就需要遵循一些約定(其實這些約定應該是彈窗組件的最佳實踐)。
約定如下:
-
彈窗組件的**
props
需要有一個名爲visible
的屬性**,用於驅動彈窗的打開和關閉。 -
彈窗組件需要**
emit
一個close
事件**,用於彈窗關閉時處理命令式彈窗。
如果你的彈窗組件滿足上面兩個約定,那麼就可以通過useCommandComponent
極致舒適的使用了!!
這兩項約定雖然不是強制的,但是這確實是最佳實踐!不信你去翻所有的 UI 框看看他們的實現。我一直認爲學習和生產中多學習優秀框架的實現思路很重要!
如果不遵循約定
這時候有的同學可能會說:哎嘿,我就不遵循這兩項約定呢?我的彈窗就是要標新立異的不用**visible
屬性來控制打開和關閉,我起名爲dialogVisible
呢?我的彈窗就是沒有close
事件呢?我的事件是具有業務意義的submit
、cancel
呢?**..
得得得,如果真的沒有遵循上面的兩個約定,依然可以舒適的使用useCommandComponent
,只不過在我看來沒那麼極致舒適!雖然不是極致舒適,但也要比其他方案舒適的多!
如果你的彈窗真的沒有遵循 “兩個約定”,那麼你可以試試這樣做:
<script setup lang="ts">
// ...
const myDialog = useCommandComponent(MyDialog);
const handleDialog = () => {
myDialog({
title: '父組件彈窗',
dialogVisible: true,
onSubmit: () => myDialog.close(),
onCancel: () => myDialog.close(),
});
};
</script>
<template>
<div>
<ElButton @click="handleDialog"> 打開彈窗 </ElButton>
<!--...-->
</div>
</template>
如上,只需要在調用myDialog
函數時在props
中將驅動彈窗的狀態設置爲true
,在需要關閉彈窗的事件中調用myDialog.close()
即可!
這樣是不是看着雖然沒有上面的極致舒適,但是也還是挺舒適的?
源碼與實現
實現思路
對於useCommandComponent
的實現思路,依然是命令式封裝。相比於上面的那兩個實現方式,useCommandComponent
是將組件作爲參數傳入,這樣保持組件的編寫習慣不變。並且useCommandComponent
遵循單一職責原則,只做好組件的掛載和卸載工作,提供足夠的兼容性。
其實
useCommandComponent
有點像React
中的高階組件的概念
源碼
源碼不長,也很好理解!在實現useCommandComponent
的時候參考了 ElementPlus 的 MessageBox。
源碼如下:
import { AppContext, Component, ComponentPublicInstance, createVNode, getCurrentInstance, render, VNode } from 'vue';
export interface Options {
visible?: boolean;
onClose?: () => void;
appendTo?: HTMLElement | string;
[key: string]: unknown;
}
export interface CommandComponent {
(options: Options): VNode;
close: () => void;
}
const getAppendToElement = (props: Options): HTMLElement => {
let appendTo: HTMLElement | null = document.body;
if (props.appendTo) {
if (typeof props.appendTo === 'string') {
appendTo = document.querySelector<HTMLElement>(props.appendTo);
}
if (props.appendTo instanceof HTMLElement) {
appendTo = props.appendTo;
}
if (!(appendTo instanceof HTMLElement)) {
appendTo = document.body;
}
}
return appendTo;
};
const initInstance = <T extends Component>(
Component: T,
props: Options,
container: HTMLElement,
appContext: AppContext | null = null
) => {
const vNode = createVNode(Component, props);
vNode.appContext = appContext;
render(vNode, container);
getAppendToElement(props).appendChild(container);
return vNode;
};
export const useCommandComponent = <T extends Component>(Component: T): CommandComponent => {
const appContext = getCurrentInstance()?.appContext;
// 補丁:Component中獲取當前組件樹的provides
if (appContext) {
const currentProvides = (getCurrentInstance() as any)?.provides;
Reflect.set(appContext, 'provides', {...appContext.provides, ...currentProvides});
}
const container = document.createElement('div');
const close = () => {
render(null, container);
container.parentNode?.removeChild(container);
};
const CommandComponent = (options: Options): VNode => {
if (!Reflect.has(options, 'visible')) {
options.visible = true;
}
if (typeof options.onClose !== 'function') {
options.onClose = close;
} else {
const originOnClose = options.onClose;
options.onClose = () => {
originOnClose();
close();
};
}
const vNode = initInstance<T>(Component, options, container, appContext);
const vm = vNode.component?.proxy as ComponentPublicInstance<Options>;
for (const prop in options) {
if (Reflect.has(options, prop) && !Reflect.has(vm.$props, prop)) {
vm[prop as keyof ComponentPublicInstance] = options[prop];
}
}
return vNode;
};
CommandComponent.close = close;
return CommandComponent;
};
export default useCommandComponent;
除了命令式的封裝外,我加入了const appContext = getCurrentInstance()?.appContext;
。這樣做的目的是,傳入的組件在這裏其實已經獨立於應用的 Vue 上下文了。爲了讓組件依然保持和調用方相同的 Vue 上下文,我這裏加入了獲取上下文的操作!
基於這個情況,在使用useCommandComponent
時需要保證它在setup
中被調用,而不是在某個點擊事件的處理函數中哦~
源碼補丁
非常感謝 @bluryar 關於命令式組件無法獲取當前組件樹的 injection 的指出!!🫰👍
趁着熱乎,我想到一個解決獲取當前 injection 的解決辦法。那就是將當前組件樹的provides
與appContext.provides
合併,這樣傳入的彈窗組件就可以順利的獲取到app
和當前組件樹的provides
了!
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/cIiMoQizqgTgJdlWSTutIg