Vue 中如何转移 Dom ?

时间:2020-03-28 作者:剧中人

这个标题可能很拗口,请允许我先做下简短的解释。

在我们使用 Vue 进行开发时,Dom 最终渲染的结构与 template 嵌套逻辑是一致的,有着严格的父子级关系。

假如我们有特殊需求,需要将某个 viewModel 的 Dom 渲染到父级之外,需要如何操作?

一、真的有这种需求 ?

可能你在看了前面介绍之后会觉得很奇怪,也意识到自己的开发经验里从来没有过这样的需求。

什么样的场景才会需要转移 Dom 呢 ?

如果你有过 UI 组件开发的经验,可能会发现,随着模块划分越来越细,模块层级越来越深,某些组件的 Dom 并不适合渲染在自身所属的 Dom 上。

比如常见的返回顶部按钮,悬浮广告,还有下面三个更生动的例子。

sample

花十秒钟想一下这些组件有什么样的特点 ?

开始

10

9

8

7

6

5

4

3

2

1

结束

这些模块都属于用户交互组件,并不属于页面静态布局的一部分,需要用户的操作来唤醒。

并且受限于页面各个层级的 overflow 设置,如果嵌入结构过深很容易导致渲染不完整。z-index 管理也会更加混乱,并且高频的创建、销毁也会引起不必要的局部重绘。

因此在开发 UI 用户交互组件的时候,如果有必要,可以考虑将 Dom 转移到组件外部,可能更便于管理。

二、遇到了什么蛋疼的功能?

之所以会思考这个问题,是因为最近小剧所在的团队正在开发一款新产品, 暂时不方便透露具体的产品名,等到上线后再找机会和你聊一聊。

因为产品的交互特性需要「右键菜单」的功能,按照惯例开始在 Npm 上找前人发布的开源包。可能在 API 支持程度、组件初始化方式上都和预期差异较大,所以便决定了自己开发一款右键菜单组件。

还好小剧曾在 jQuery 时代写过一款右键菜单组件,所以主体逻辑并没有花太多时间。

结合前面提到的用户交互组件的特点,为了保证右键菜单不受各层级 overflow 的限制,同时便于管理 z-index 层级,需要将右键菜单的 Dom 脱离触发元素层级。

正是因为开发这个功能,做了相关代码对比之后,小剧才总结了这篇文章。

三、如何转移 Dom ?

经过自己实践和对前人代码的研究,发现大概有两种方案可以实现 Dom 的转移,分别是:

渲染前转移 vnode 有很多应用方式,小剧这里以 new Vue 创建新节点为例说明。

渲染后转移 $el 节点相对比较容易理解,下面会举两个例子来说明。

3.1、借助 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 Vuevnode 转移到了外侧,也就起到了 Dom 转移的作用。

小思考

contentmenu 模块的 render 方法什么都不渲染,为什么还能找到 vnode

3.2、手动 appendChild 方式

不知道你有没有用过 Element UI 框架。她的 Dialog 对话框有一个叫 append-to-body 的 prop 参数,只要你添加了这个参数,对话框就会被渲染到页面的 body 节点下。

element代码截图

这一个简单的参数可以解决弹窗嵌套等一系列问题,我们来看一下它是怎么实现的。

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 节点移除,不会创造出新节点。

3.3、指令封装模式

非常感谢胖坤的提醒,让我发现了这个有意思的 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 借鉴了 vuxtransfer-dom 实现方式,而 vuxtransfer-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

vuxiview 都是从 vue@1.x 升级而来的,因此延续了老的命名,但是却使用了新的代码。

提示:故事讲完了

刚才的故事里一共提到了四个仓库。

其中最早的 vue-transfer-dom 版本较老,已经不能使用了。

另外两个加了 thanks to 的代码仅仅是代码片段,为了防止俄罗斯套娃不建议你再加个 thanks to 拷贝走。

因此如果你想用指令封装的模式转移 Dom,可以直接使用 vue-dom-portal

四、项目里建议使用 Dom 转移么?

Use at your own risk! No tests have been written, but it seems to be working.

上面这段话摘自vue-dom-portal 的文档。

无论你使用前面提到的哪种方法来完成 Dom 的转移,都是和 Vue 模块化设计初衷不符的。

你需要自己来保证逻辑的完整性,并且避免因为数据共享导致的内存泄漏。

因此常规的项目开发是不建议手动转移 Dom 的,除非你遇到了类似本文中提到的这些场景。

希望这篇文章有坑到你。