前言
最近维护 Vue2 的老项目,遇到需要将元素发送到 body 之下的需求,于是立马想到了Vue3内置的<Teleport>
组件。可惜 Vue2 官方并未移植该组件,另外在在网上搜索了一下,发现了不少在 Vue2 中模仿 Teleport 的库。不过很多库都是孤立的渲染一个 vue 实例,然后挂载到 body 或者其它目标元素之下。这样做会导致 Teleport 脱离了原来的组件树结构,效果并不理想。因此准备重新封装一个,目标是尽可能的接近 Vue3 的 Teleport 组件。
目标
对标 Vue3 Teleport,组件应当具备以下三个重要特性:
- 只发送DOM,不影响组件树结构,即原来的父子组件关系保持不变(provide/inject 等均不受影响);
- 不占位置,既不额外增加 DOM 元素,也不额外延长组件链;
- 提供属性 to(指定目标元素) 和 disabled(禁用发送),且支持动态绑定;
关键技术
抽象组件
不额外增加 DOM 元素很简单,可以利用 render 函数直接返回插槽内容:
js
export default {
render(h) {
let first = this.$slots.default?.[0]
return first
},
}
不额外延长组件链的话,普通办法是不行了,只能使用抽象组件,即:
js
export default {
abstract: true,
}
其实 Vue2 内置提供的 <KeepAlive>
, <Transition>
组件就是抽象组件,不熟悉的小伙伴可以查阅相关资料或者直接看看 KeepAlive 组件的源码,这里我们就直接使用。
Vnode
一开始由于期望是只操作 DOM,因此很容易想到利用 mounted 事件手动修改 DOM,比如:
js
export default {
mounted() {
let rootEl = this.$slots.default[0].elm
document.body.appendChild(rootEl)
},
}
但是这样做有没有问题呢?Of Course,副作用就在于它会影响所在组件树后续的 update 更新。举个 todos 例子:
vue
<template>
<div>
<ul>
<li v-for="item in list">{{ item }}</li>
<Teleport><button @click="list.push('foo')">Add</button></Teleport>
</ul>
</div>
</template>
此处 Teleport 紧跟一个动态列表,如果是简单修改 DOM 方式实现的话,你会发现点击 Add 之后,页面上的列表并不会增长,仿佛失去了响应性一般。
根本原因还是跟 VirtualDom 的更新机制有关。要说清楚这一点,需要配合一些 vue2 源码,以下是 patch.js 中 updateChildren 函数的部分代码:
js
function updateChildren(parentElm, oldCh, newCh, ...){
// ... 此处省略前 n 行,最后几行代码如下
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
}
简要说明一下,updateChildren() 是用来 patch 更新某个节点的子列表的,oldCh 是之前的 VNodeList,newCh 是最新的 VNodeList;前面省去的 n 行主要是在尝试找到 oldCh 和 newCh 中 Type 相同(key相同、tag相同等多种条件)的节点,更新其属性(props)和位置(position),相当于"修改"操作;最后几行代码则是对漏网之鱼进行批量"新增"或"删除"操作。
对于 todos 例子而言,我们 Add 一个新对象时,之前的节点实际上都没变,updateChildren 最终会走到 addVnodes 调用的地方,而问题就出在 refElm 元素身上。parentElm 好理解,其实就是 ul 元素了,而 refElm 则是 Teleport 节点对应的元素。原本的意思是 parentElm.insertBefore(li, refElm)
即将新增的 li 元素插入到 Teleport 之前,但是我们已将 Teleport 对应的元素移到了 body 之下,于是该 insert 就失败了。
说到这里,目标1的答案就清晰了,我们应当在保证 Teleport 对应的元素不变的前提下,去发送内容 DOM。具体会用到 Teleport 的 $vnode
和 _vnode
两个属性,其中 $vnode
又叫做 Placeholder Vnode,即 Teleport Instance 对应的 vnode 节点;_vnode
叫做根节点,即 Teleport 下属组件树的根节点(todos 例子中,就是元素 button)。一般情况下,两者的关系是 _vnode.parent = $vnode, $vnode.elm = _vnode.elm
。
那么我们要做的便是,只传送根元素 _vnode.elm
,同时用一个"哑"元素占住原来根元素的位置 $vnode.elm=dumbEl
,这个哑元素会成为 addVnodes 中的 refElm,从而恢复损坏的 insert 操作。伪代码如下:
js
this.dumb = document.createComment(' teleport ') // 注释节点很适合当哑元素
this.rootEl = this._vnode.elm // 根元素
this.$vnode.elm = this.dumb // refElm
this.rootEl.parentNode.insertBefore(this.dumb, this.rootEl) // 占据原来根元素的位置
document.body.appendChild(this.rootEl) // 发送
动态绑定
有了占位哑元素后,动态 disabled 就容易实现了,因为我们可以随时撤销"发送"操作,比如:
js
this.dumb.parentNode.insertBefore(this.rootEl, this.dumb) // 逆过程,恢复根元素
this.$vnode.elm = this.rootEl
this.dumb.parentNode.removeChild(this.dumb)
而动态 to 更加简单,在 update 事件中重新"发送"根元素即可:
js
updated() {
document.querySelector(this.to).appendChild(this.rootEl)
},
小结
- 发送时,用"哑元素"代替原来的根元素,然后修改根元素的位置;
- 撤销时,用根元素代替"哑元素",然后移除"哑元素",还原 DOM 结构;
- 整个过程组件树结构不变;
其实 Vue3 的 Teleport 原理上大致也是这般,仔细观察官方 demo 的渲染结果,能够发现:
这两句注释就是占位用的哑元素。另外上图中可以看到 Vue3 使用了两个哑元素,这不是偶然。同时这也是 vue2 只能 99% 而不是 100% 实现 Teleport 的理由------Vue2 不支持多个根节点。
完整的 <Teleport>
组件实现已经发布到 npm 上了,有需要的同学可以直接使用:
css
npm i teleport-vue2