vue中如何进行DOM转移?

本文探讨了在Vue组件开发中,当遇到深度嵌套、用户交互组件管理困难等问题时,如何通过新节点创建和指令封装两种方式转移DOM。重点介绍了`v-menu`指令和Element-UI的`appendToBody`功能,以及iview中transfer-dom指令的实现原理。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

需求来源

什么样的场景才会需要转移 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

### Vue3 移动端适配方案 #### 响应式设计 响应式设计是一种通过动态调整布局和样式的策略,使网页能够在不同设备上提供一致的用户体验。在 Vue3 项目中实现响应式设计可以通过 CSS 的媒体查询来完成。 媒体查询是 CSS3 提供的强大功能之一,能够基于特定条件(如屏幕宽度、分辨率等)改变样式规则[^2]。例如,在 `vite.config.ts` 文件中配置插件时,可以结合 UnoCSS 或其他工具库定义适应不同屏幕尺寸的样式规则: ```javascript import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import vueJsx from '@vitejs/plugin-vue-jsx' import unocss from 'unocss/vite' export default defineConfig({ plugins: [ vue(), vueJsx(), unocss({ rules: [ ['sm', { '@media': '(min-width: 640px)' }], ['md', { '@media': '(min-width: 768px)' }] ] }) ] }) ``` 上述代码片段展示了如何利用 UnoCSS 定义简单的断点规则,并将其应用于组件中的类名绑定[^1]。 #### 触摸事件处理 触摸事件对于移动端交互至关重要。Vue.js 支持原生 DOM 事件监听器语法,可以直接用于捕获触控操作。常见的触摸事件包括但不限于 `touchstart`, `touchmove`, 和 `touchend`。这些事件可以在模板中直接绑定到方法上: ```html <template> <div @touchstart="handleTouchStart" @touchmove="handleTouchMove"> Touch me! </div> </template> <script setup> const handleTouchStart = (event) => { console.log('Finger touched the screen at:', event.touches[0].clientX, event.touches[0].clientY); }; const handleTouchMove = (event) => { console.log('Moving finger to position:', event.touches[0].clientX, event.touches[0].clientY); }; </script> ``` 此示例演示了如何捕捉并记录手指接触屏幕的位置以及移动轨迹[^3]。 #### CSS 媒体查询的应用 为了增强页面在各种设备上的显示效果,推荐使用 CSS 媒体查询配合 Flexbox 或 Grid Layout 来创建灵活的布局结构。下面是一个基本的例子,说明当视口小于等于 600px 时隐藏某些元素: ```css @media only screen and (max-width: 600px) { .hide-on-mobile { display: none; } } ``` 将此类样式嵌入到项目的全局样式文件或单独模块化管理,有助于保持代码清晰度的同时满足多终端需求。 --- ###
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值