vue中如何进行DOM转移?

需求来源

什么样的场景才会需要转移 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 借鉴了vuxtransfer-dom 实现方式,而 vux transfer-dom 是基于 vue-dom-portal 改造而来的。

参考地址:http://bh-lay.com/blog/ottsc8e7cm

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值