需求来源
什么样的场景才会需要转移 Dom 呢 ?
如果你有过 UI 组件开发的经验,可能会发现,随着模块划分越来越细,模块层级越来越深,某些组件的 Dom 并不适合渲染在自身所属的 Dom 上。
比如常见的返回顶部按钮,悬浮广告等,例如如下:
- 对话框
- 右键菜单
- 用户提示组件
这些组件有一个共同的特点,需要用户手动操作来展示相应的内容。
并且受限于页面各个层级的 overflow 设置,如果嵌入结构过深很容易导致渲染不完整。z-index 管理也会更加混乱,并且高频的创建、销毁也会引起不必要的局部重绘。
因此在开发 UI 用户交互组件的时候,如果有必要,可以考虑将 Dom 转移到组件外部,可能更便于管理。
如何转移DOM?
大致有两种方案:
- 渲染前转移vnode
- 渲染后转移$el节点
通过new Vue()创建新节点
例如右键菜单,通过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) {
return createElement(
"div",
{
attrs: {
id: "right-contextmenu"
}
},
vnode
);
}
});
}
当然创建菜单的方法createMenu也可以如下代码:
// 创建菜单
function createMenu(event, vnode) {
let newNode = document.createElement("div");
document.body.appendChild(newNode);
const MyComponent = new Vue({
render: (h) => {
return h(
"div",
{
attrs: {
id: "right-contextmenu"
}
},
vnode
);
}
});
newNode.appendChild(MyComponent.$mount().$el);
}
用户使用:
<button v-menu:menu-node>右键点我吧</button>
<contextmenu ref="menu-node">
<div>我是菜单里的元素</div>
<span>我也是菜单里的元素耶~</span>
</contextmenu>
其中contextmenu
模块,它没有处理任何逻辑,其中的render函数渲染为null。
menu
指令通过监听元素右键菜单事件来创建右键自定义菜单。
createMenu
方法内部,会默认创建新的节点放在 body 节点下,并通过 new Vue
将 vnode 转移到了外侧,也就起到了 Dom 转移的作用。
手动 appendChild 方式
PC常用的Element-UI 框架,它的 Dialog 对话框有一个叫 append-to-body
的参数,只要你添加了这个参数,对话框就会被渲染到页面的 body 节点下。
这一个简单的参数可以解决弹窗嵌套等一系列问题,我们来看一下它是怎么实现的。
地址:dialog弹框实现
其中核心代码:
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);
}
}
在 mounted
阶段 Dom 已经渲染完毕,根据条件将自身的 Dom
强行移至 body
内部,在模块销毁时再手动移除 Dom
节点。
appendChild
是原生 Dom 操作方法,插入节点时会同时从源 Dom 节点移除,不会创造出新节点。
指令封装模式
指令封装本质上也是 appendChild
的模式,只是将 Dom 转移操作提炼成指令,需要的时候直接调用即可完成。
其中iview 的源码里也有 Dom 转移的方法,地址:https://github.com/iview/iview/blob/a61acfdcb725579735574e250f1ef7840975347c/src/directives/transfer-dom.js,其中代码如下:
// Thanks to: https://github.com/airyland/vux/blob/v2/src/directives/transfer-dom/index.js
// Thanks to: https://github.com/calebroseland/vue-dom-portal
/**
* Get target DOM Node
* @param {(Node|string|Boolean)} [node=document.body] DOM Node, CSS selector, or Boolean
* @return {Node} The target that the el will be appended to
*/
function getTarget (node) {
if (node === void 0) {
node = document.body
}
if (node === true) { return document.body }
return node instanceof window.Node ? node : document.querySelector(node)
}
const directive = {
inserted (el, { value }, vnode) {
if ( el.dataset && el.dataset.transfer !== 'true') return false;
el.className = el.className ? el.className + ' v-transfer-dom' : 'v-transfer-dom';
const parentNode = el.parentNode;
if (!parentNode) return;
const home = document.createComment('');
let hasMovedOut = false;
if (value !== false) {
parentNode.replaceChild(home, el); // moving out, el is no longer in the document
getTarget(value).appendChild(el); // moving into new place
hasMovedOut = true
}
if (!el.__transferDomData) {
el.__transferDomData = {
parentNode: parentNode,
home: home,
target: getTarget(value),
hasMovedOut: hasMovedOut
}
}
},
componentUpdated (el, { value }) {
if ( el.dataset && el.dataset.transfer !== 'true') return false;
// need to make sure children are done updating (vs. `update`)
const ref$1 = el.__transferDomData;
if (!ref$1) return;
// homes.get(el)
const parentNode = ref$1.parentNode;
const home = ref$1.home;
const hasMovedOut = ref$1.hasMovedOut; // recall where home is
if (!hasMovedOut && value) {
// remove from document and leave placeholder
parentNode.replaceChild(home, el);
// append to target
getTarget(value).appendChild(el);
el.__transferDomData = Object.assign({}, el.__transferDomData, { hasMovedOut: true, target: getTarget(value) });
} else if (hasMovedOut && value === false) {
// previously moved, coming back home
parentNode.replaceChild(el, home);
el.__transferDomData = Object.assign({}, el.__transferDomData, { hasMovedOut: false, target: getTarget(value) });
} else if (value) {
// already moved, going somewhere else
getTarget(value).appendChild(el);
}
},
unbind (el) {
if (el.dataset && el.dataset.transfer !== 'true') return false;
el.className = el.className.replace('v-transfer-dom', '');
const ref$1 = el.__transferDomData;
if (!ref$1) return;
if (el.__transferDomData.hasMovedOut === true) {
el.__transferDomData.parentNode && el.__transferDomData.parentNode.appendChild(el)
}
el.__transferDomData = null
}
};
export default directive;
看到顶部有两行注释:
// 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
import objectAssign from 'object-assign'
/**
* Get target DOM Node
* @param {(Node|string|Boolean)} [node=document.body] DOM Node, CSS selector, or Boolean
* @return {Node} The target that the el will be appended to
*/
function getTarget (node) {
if (node === void 0) {
return document.body
}
if (typeof node === 'string' && node.indexOf('?') === 0) {
return document.body
} else if (typeof node === 'string' && node.indexOf('?') > 0) {
node = node.split('?')[0]
}
if (node === 'body' || node === true) {
return document.body
}
return node instanceof window.Node ? node : document.querySelector(node)
}
function getShouldUpdate (node) {
// do not updated by default
if (!node) {
return false
}
if (typeof node === 'string' && node.indexOf('?') > 0) {
try {
const config = JSON.parse(node.split('?')[1])
return config.autoUpdate || false
} catch (e) {
return false
}
}
return false
}
const directive = {
inserted (el, { value }, vnode) {
el.className = el.className ? el.className + ' v-transfer-dom' : 'v-transfer-dom'
const parentNode = el.parentNode
var home = document.createComment('')
var hasMovedOut = false
if (value !== false) {
parentNode.replaceChild(home, el) // moving out, el is no longer in the document
getTarget(value).appendChild(el) // moving into new place
hasMovedOut = true
}
if (!el.__transferDomData) {
el.__transferDomData = {
parentNode: parentNode,
home: home,
target: getTarget(value),
hasMovedOut: hasMovedOut
}
}
},
componentUpdated (el, { value }) {
const shouldUpdate = getShouldUpdate(value)
if (!shouldUpdate) {
return
}
// need to make sure children are done updating (vs. `update`)
var ref$1 = el.__transferDomData
// homes.get(el)
var parentNode = ref$1.parentNode
var home = ref$1.home
var hasMovedOut = ref$1.hasMovedOut // recall where home is
if (!hasMovedOut && value) {
// remove from document and leave placeholder
parentNode.replaceChild(home, el)
// append to target
getTarget(value).appendChild(el)
el.__transferDomData = objectAssign({}, el.__transferDomData, { hasMovedOut: true, target: getTarget(value) })
} else if (hasMovedOut && value === false) {
// previously moved, coming back home
parentNode.replaceChild(el, home)
el.__transferDomData = objectAssign({}, el.__transferDomData, { hasMovedOut: false, target: getTarget(value) })
} else if (value) {
// already moved, going somewhere else
getTarget(value).appendChild(el)
}
},
unbind: function unbind (el, binding) {
el.className = el.className.replace('v-transfer-dom', '')
if (el.__transferDomData && el.__transferDomData.hasMovedOut === true) {
el.__transferDomData.parentNode && el.__transferDomData.parentNode.appendChild(el)
}
el.__transferDomData = null
}
}
export default directive
又看到了一行注释:
// Thanks to: https://github.com/calebroseland/vue-dom-portal
从上面代码及注释看,iview
借鉴了vux
的 transfer-dom
实现方式,而 vux
的 transfer-dom
是基于 vue-dom-portal
改造而来的。
参考地址:http://bh-lay.com/blog/ottsc8e7cm