# Dialog 地狱
为啥是地狱?
因为凡是有 Dialog
出现的页面,其代码绝对优雅不起来!因为一旦你在也个组件中引入 Dialog
,就最少需要额外维护一个 visible
变量。如果只是额外维护一个变量这也不是不能接受,可是当同样的 Dialog
组件,即需要在父组件控制它的展示与隐藏,又需要在子组件中控制。
为了演示我们先实现一个 MyDialog
组件,代码来自 ElementPlus 的 Dialog 示例
MyDialog
组件:
<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
实现。
# 命令式一
吐槽一下~,这种是能在函数中处理弹窗逻辑,但是缺点是 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><!--swig0--></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
中被调用,而不是在某个点击事件的处理函数中哦~
# 源码补丁
问题:这类命令式组件无法获取当前组件树的 injection 。
趁着热乎,我想到一个解决获取当前 injection 的解决办法。
那就是将当前组件树的 provides
与 appContext.provides
合并,这样传入的弹窗组件就可以顺利的获取到 app
和当前组件树的 provides
了!