时间:2020-03-28 作者:剧中人
这个标题可能很拗口,请允许我先做下简短的解释。
在我们使用 Vue 进行开发时,Dom 最终渲染的结构与 template 嵌套逻辑是一致的,有着严格的父子级关系。
假如我们有特殊需求,需要将某个 viewModel 的 Dom 渲染到父级之外,需要如何操作?
可能你在看了前面介绍之后会觉得很奇怪,也意识到自己的开发经验里从来没有过这样的需求。
什么样的场景才会需要转移 Dom 呢 ?
如果你有过 UI 组件开发的经验,可能会发现,随着模块划分越来越细,模块层级越来越深,某些组件的 Dom 并不适合渲染在自身所属的 Dom 上。
比如常见的返回顶部按钮,悬浮广告,还有下面三个更生动的例子。
花十秒钟想一下这些组件有什么样的特点 ?
开始
10
9
8
7
6
5
4
3
2
1
结束
这些模块都属于用户交互组件,并不属于页面静态布局的一部分,需要用户的操作来唤醒。
并且受限于页面各个层级的 overflow
设置,如果嵌入结构过深很容易导致渲染不完整。z-index
管理也会更加混乱,并且高频的创建、销毁也会引起不必要的局部重绘。
因此在开发 UI 用户交互组件的时候,如果有必要,可以考虑将 Dom 转移到组件外部,可能更便于管理。
之所以会思考这个问题,是因为最近小剧所在的团队正在开发一款新产品, 暂时不方便透露具体的产品名,等到上线后再找机会和你聊一聊。
因为产品的交互特性需要「右键菜单」的功能,按照惯例开始在 Npm 上找前人发布的开源包。可能在 API 支持程度、组件初始化方式上都和预期差异较大,所以便决定了自己开发一款右键菜单组件。
还好小剧曾在 jQuery 时代写过一款右键菜单组件,所以主体逻辑并没有花太多时间。
结合前面提到的用户交互组件的特点,为了保证右键菜单不受各层级 overflow
的限制,同时便于管理 z-index
层级,需要将右键菜单的 Dom 脱离触发元素层级。
正是因为开发这个功能,做了相关代码对比之后,小剧才总结了这篇文章。
经过自己实践和对前人代码的研究,发现大概有两种方案可以实现 Dom 的转移,分别是:
vnode
$el
节点渲染前转移 vnode
有很多应用方式,小剧这里以 new Vue
创建新节点为例说明。
渲染后转移 $el
节点相对比较容易理解,下面会举两个例子来说明。
new Vue
创建新节点这里是小剧采用的方案,是渲染前转移 vnode
的一种,实现逻辑比较笨。抛开右键菜单的具体逻辑,单纯来聊一下如何通过 new Vue
来转移 Dom。
先来上两段代码,后面慢慢介绍。
代码一:定义核心模块
// 提供给用户使用的菜单定义组件
Vue.component('contextmenu', {
name: 'contextmenu-collect',
render () {
return null
}
})
// 定义指令
Vue.directive('menu', {
inserted (el, binding, vnode) {
let vm = vnode.context
let refKey = binding.arg
// 监听右键菜单事件
el.addEventListener('contextmenu', event => {
createMenu(event, vm.$refs[refKey].$slots.default)
event.preventDefault()
})
}
})
// 创建菜单
function createMenu (event, vnode) {
let newNode = document.createElement('div')
document.body.appendChild(newNode)
new Vue({
el: newNode,
render(createElement) {
createElement(
'div',
{},
vnode
)
}
})
}
代码二:用户侧使用
<button v-menu:menu-node>右键点我吧</button>
<contextmenu ref="menu-node">
<div>我是菜单里的元素</div>
<span>我也是菜单里的元素耶~</span>
</contextmenu >
我们来看最简单的模块:contextmenu
,它不处理任何逻辑,甚至从 render
函数可以看出来,它不渲染任何 Dom 节点。
再看看 menu
指令,它是串联起全部功能最核心的部分,通过监听元素右键菜单事件,阻止浏览器默认事件并且根据用户定义的菜单内容创建模拟菜单。
createMenu
方法内部,会默认创建新的节点放在 body
节点下,并通过 new Vue
将 vnode
转移到了外侧,也就起到了 Dom 转移的作用。
小思考
contentmenu
模块的render
方法什么都不渲染,为什么还能找到vnode
?
不知道你有没有用过 Element UI 框架。她的 Dialog 对话框有一个叫 append-to-body
的 prop 参数,只要你添加了这个参数,对话框就会被渲染到页面的 body
节点下。
这一个简单的参数可以解决弹窗嵌套等一系列问题,我们来看一下它是怎么实现的。
Dialog 代码: https://github.com/…/dialog/src/component.vue#L200
mounted() {
if (this.visible) {
this.rendered = true;
this.open();
if (this.appendToBody) {
document.body.appendChild(this.$el);
}
}
},
destroyed() {
// if appendToBody is true, remove DOM node after destroy
if (this.appendToBody && this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el);
}
}
相比之下 Element 在对 Dialog 的 Dom 转移实现上更为简单。
在 mounted
阶段 Dom 已经渲染完毕,根据条件将自身的 Dom 强行移至 body
内部,在模块销毁时再手动移除 Dom 节点。
通过这一前一后两个生命周期钩子的操作就完成了 Dom 的转移。
小提示
appendChild
是原生 Dom 操作方法,插入节点时会同时从源 Dom 节点移除,不会创造出新节点。
非常感谢胖坤的提醒,让我发现了这个有意思的 Dom 转移方法。
指令封装本质上也是 appendChild
的模式,只是将 Dom 转移操作提炼成指令,需要的时候直接调用即可完成。
这里就不展开具体代码了,我们聊一聊小剧在研究指令封装模式时发现的有意思的事情。
提示:开始讲故事了
在小剧将右键菜单功能写完之后,得意洋洋地在胖坤面前炫耀流弊的 Dom 转移的实现方式。
胖坤轻描淡写了一句: iview
的源码里也有 Dom 转移的方法,你可以看一下。
内心受挫后,小剧找到了 iview
的源码,顺着代码找到了下面的文件,逻辑相当简练,而且没有第三方依赖。
https://github.com/iview/iview/…/directives/transfer-dom.js
前人总结的代码就是干脆利落,而且还考虑到了将 Dom 转移到指定节点的需求。
正当小剧准备复制代码以备后用的时候,竟然发现文件顶上有两行注释。
// Thanks to: https://github.com/airyland/vux/blob/v2/src/directives/transfer-dom/index.js
// Thanks to: https://github.com/calebroseland/vue-dom-portal
打开第一行的链接后印入眼帘的又是一行注释:
// Thanks to: https://github.com/calebroseland/vue-dom-portal
简直是俄罗斯套娃般的操作。
这三份代码虽然在命名上,部分细节上不太一样,但总体思路和实现方式惊人的一致。
从 thanks to 的注释可以看出来,代码拷贝路径非常清晰,iview
借鉴了 vux
的 transfer-dom
实现方式,而 vux
的 transfer-dom
是基于 vue-dom-portal
改造而来的。
也就是说,这段代码的根源在 vue-dom-portal
仓库内。
那么问题来了:
既然根源在 vue-dom-portal
,为什么借鉴的小伙伴要把它改名成 transfer-dom
呢?
很快,这个疑问在 vue-dom-portal
的文档里找到了答案。
Similar to vue-transfer-dom, but updated for
vue@2.x
.
因为在 vue@1.x
的时候,有另外一个人写了一个叫 vue-transfer-dom
的指令,并且发布至 Npm。
但是原作者并没有开发支持 vue@2.x
的版本,为了有所区分,新的代码取名为 vue-dom-portal
。
而 vux
、iview
都是从 vue@1.x
升级而来的,因此延续了老的命名,但是却使用了新的代码。
提示:故事讲完了
刚才的故事里一共提到了四个仓库。
其中最早的 vue-transfer-dom
版本较老,已经不能使用了。
另外两个加了 thanks to 的代码仅仅是代码片段,为了防止俄罗斯套娃不建议你再加个 thanks to 拷贝走。
因此如果你想用指令封装的模式转移 Dom,可以直接使用 vue-dom-portal。
Use at your own risk! No tests have been written, but it seems to be working.
上面这段话摘自vue-dom-portal
的文档。
无论你使用前面提到的哪种方法来完成 Dom 的转移,都是和 Vue 模块化设计初衷不符的。
你需要自己来保证逻辑的完整性,并且避免因为数据共享导致的内存泄漏。
因此常规的项目开发是不建议手动转移 Dom 的,除非你遇到了类似本文中提到的这些场景。
希望这篇文章有坑到你。