前言
Vue3探秘系列文章链接:
不止响应式:Vue3探秘系列— 虚拟结点vnode的页面挂载之旅(一)
Hello~大家好。我是秋天的一阵风
有时我们可能会遇到这样的场景:一个组件模板的一部分在逻辑上从属于该组件,但从整个应用视图的角度来看,它在 DOM
中应该被渲染在整个 Vue
应用外部的其他地方。
这类场景最常见的例子就是 全屏的模态框 。理想情况下,我们希望 触发模态框的按钮 和 模态框本身 是在同一个组件中,因为它们都与组件的开关状态有关。但这意味着该模态框将与按钮一起渲染在应用 DOM
结构里很深的地方。这会导致该模态框的CSS布局代码很难写。比如下面的代码:
<div class="outer">
<h3>Tooltips with Vue 3 Teleport</h3>
<div>
<MyModal />
</div>
</div>
接下来我们来看看 <MyModal>
的实现:
<script setup>
import { ref } from 'vue'
const open = ref(false)
</script>
<template>
<button @click="open = true">Open Modal</button>
<div v-if="open" class="modal">
<p>Hello from the modal!</p>
<button @click="open = false">Close</button>
</div>
</template>
<style scoped>
.modal {
position: fixed;
z-index: 999;
top: 20%;
left: 50%;
width: 300px;
margin-left: -150px;
}
</style>
这个组件中有一个 <button>
按钮来触发打开模态框,和一个 class 名为 .modal
的 <div>
,它包含了模态框的内容和一个用来关闭的按钮。
当在初始 HTML 结构中使用这个组件时,会有一些潜在的问题:
position: fixed
能够相对于浏览器窗口放置有一个条件,那就是不能有任何祖先元素设置了transform
、perspective
或者filter
样式属性。也就是说如果我们想要用 CSStransform
为祖先节点<div class="outer">
设置动画,就会不小心破坏模态框的布局!- 这个模态框的
z-index
受限于它的容器元素。如果有其他元素与<div class="outer">
重叠并有更高的z-index
,则它会覆盖住我们的模态框。
一、<Teleport>
的使用
在Vue3中新增了一个内置组件 <Teleport>
, 它提供了一个更简单的方式来解决此类问题,让我们不需要再顾虑 DOM 结构的问题。让我们用 <Teleport>
改写一下 <MyModal>
:
<button @click="open = true">Open Modal</button>
<Teleport to="body">
<div v-if="open" class="modal">
<p>Hello from the modal!</p>
<button @click="open = false">Close</button>
</div>
</Teleport>
<Teleport>
接收一个 to
来指定传送的目标。to
的值可以是一个 CSS 选择器字符串
,也可以是一个 DOM 元素对象
。这段代码的作用就是告诉 Vue“把以下模板片段传送到 body
标签下”。
了解了使用方式,接下来,我们就来分析它的实现原理,看看 Teleport
是如何脱离当前组件渲染子组件的。
二、Teleport 实现原理
我们先来看一下前面示例模板编译后的结果:
import { createVNode as _createVNode, resolveComponent as _resolveComponent, Teleport as _Teleport, openBlock as _openBlock, createBlock as _createBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
const _component_Dialog = _resolveComponent("Dialog")
return (_openBlock(), _createBlock("template", null, [
_createVNode("button", { onClick: _ctx.showDialog }, "Show dialog", 8 /* PROPS */, ["onClick"]),
(_openBlock(), _createBlock(_Teleport, { to: "body" }, [
_createVNode(_component_Dialog, { ref: "dialog" }, null, 512 /* NEED_PATCH */)
]))
]))
}
可以看到,对于 teleport
标签,它是直接创建了 Teleport
内置组件,我们接下来来看它的实现:
const Teleport = {
__isTeleport: true,
process(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized, internals) {
if (n1 == null) {
// 创建逻辑
}
else {
// 更新逻辑
}
},
remove(vnode, { r: remove, o: { remove: hostRemove } }) {
// 删除逻辑
},
move: moveTeleport,
hydrate: hydrateTeleport
}
Teleport
组件的实现就是一个对象,对外提供了几个方法。其中 process
方法负责组件的创建和更新逻辑,remove
方法负责组件的删除逻辑,接下来我们就从这三个方面来分析Teleport
的实现原理。
1. process 创建组件
说到组件的创建或更新,同学们现在应该是已经非常熟悉了,首先就是去找patch
方法,找到处理Teleport类型组件
的分支。
const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, optimized = false) => {
if (n1 && !isSameVNodeType(n1, n2)) {
// 如果存在新旧节点, 且新旧节点类型不同,则销毁旧节点
}
const { type, shapeFlag } = n2
switch (type) {
case Text:
// 处理文本节点
break
case Comment:
// 处理注释节点
break
case Static:
// 处理静态节点
break
case Fragment:
// 处理 Fragment 元素
break
default:
if (shapeFlag & 1 /* ELEMENT */) {
// 处理普通 DOM 元素
}
else if (shapeFlag & 6 /* COMPONENT */) {
// 处理组件
}
else if (shapeFlag & 64 /* TELEPORT */) {
// 处理 TELEPORT
type.process(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized, internals);
}
else if (shapeFlag & 128 /* SUSPENSE */) {
// 处理 SUSPENSE
}
}
}
其实我们之前就可以猜到,在teleport对象
定义的方法一定是在合适的时候执行。
这个teleport
的分支也非常简单:如果 type 是一个 Teleport
组件,则会执行它的 process
方法。
现在我就需要详细地去process
方法里面去查看具体逻辑
function process(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized, internals) {
const { mc: mountChildren, pc: patchChildren, pbc: patchBlockChildren, o: { insert, querySelector, createText, createComment } } = internals
const disabled = isTeleportDisabled(n2.props)
const { shapeFlag, children } = n2
if (n1 == null) {
// 在主视图里插入注释节点或者空白文本节点
const placeholder = (n2.el = (process.env.NODE_ENV !== 'production')
? createComment('teleport start')
: createText(''))
const mainAnchor = (n2.anchor = (process.env.NODE_ENV !== 'production')
? createComment('teleport end')
: createText(''))
insert(placeholder, container, anchor)
insert(mainAnchor, container, anchor)
// 获取目标移动的 DOM 节点
const target = (n2.target = resolveTarget(n2.props, querySelector))
const targetAnchor = (n2.targetAnchor = createText(''))
if (target) {
insert(targetAnchor, target)
}
else if ((process.env.NODE_ENV !== 'production')) {
// 查找不到 target 则报警告
warn('Invalid Teleport target on mount:', target, `(${typeof target})`)
}
const mount = (container, anchor) => {
if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
// 挂载子节点
mountChildren(children, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
}
}
if (disabled) {
// disabled 情况就在原先的位置挂载
mount(container, mainAnchor)
}
else if (target) {
// 挂载到 target 的位置
mount(target, targetAnchor)
}
}
}
(1) 插入注释节点或空白文本结点
如果在非生产环境下,就会在Teleport组件
当前位置插入一个注释节点,组件的el对象
指向teleport start
注释节点且组件的 anchor
对象指向teleport end
注释节点
如果是生产环境,就会插入空白文本节点。
(2) 获取目标移动的 DOM 节点
通过 resolveTarget
方法从props
中的 to
属性以及DOM
选择器拿到对应要移动到的目标元素 target。
(3) 判断disabled情况
判断 disabled
变量的值,它是在 Teleport
组件中通过 prop
传递的,如果 disabled
为 true
,那么子节点仍然挂载到 Teleport
原本视图的位置,如果为 false
,那么子节点则挂载到 target
目标元素位置。
2. process 更新组件
更新情况下其实也是走process
方法,只不过里面分支不同
function process(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized, internals) {
const { mc: mountChildren, pc: patchChildren, pbc: patchBlockChildren, o: { insert, querySelector, createText, createComment } } = internals
const disabled = isTeleportDisabled(n2.props)
const { shapeFlag, children } = n2
if (n1 == null) {
// 创建逻辑
}
else {
n2.el = n1.el
const mainAnchor = (n2.anchor = n1.anchor)
const target = (n2.target = n1.target)
const targetAnchor = (n2.targetAnchor = n1.targetAnchor)
// 之前是不是 disabled 状态
const wasDisabled = isTeleportDisabled(n1.props)
const currentContainer = wasDisabled ? container : target
const currentAnchor = wasDisabled ? mainAnchor : targetAnchor
// 更新子节点
if (n2.dynamicChildren) {
patchBlockChildren(n1.dynamicChildren, n2.dynamicChildren, currentContainer, parentComponent, parentSuspense, isSVG)
if (n2.shapeFlag & 16 /* ARRAY_CHILDREN */) {
const oldChildren = n1.children
const children = n2.children
for (let i = 0; i < children.length; i++) {
if (!children[i].el) {
children[i].el = oldChildren[i].el
}
}
}
}
else if (!optimized) {
patchChildren(n1, n2, currentContainer, currentAnchor, parentComponent, parentSuspense, isSVG)
}
if (disabled) {
if (!wasDisabled) {
// enabled -> disabled
// 把子节点移动回主容器
moveTeleport(n2, container, mainAnchor, internals, 1 /* TOGGLE */)
}
}
else {
if ((n2.props && n2.props.to) !== (n1.props && n1.props.to)) {
// 目标元素改变
const nextTarget = (n2.target = resolveTarget(n2.props, querySelector))
if (nextTarget) {
// 移动到新的目标元素
moveTeleport(n2, nextTarget, null, internals, 0 /* TARGET_CHANGE */)
}
else if ((process.env.NODE_ENV !== 'production')) {
warn('Invalid Teleport target on update:', target, `(${typeof target})`)
}
}
else if (wasDisabled) {
// disabled -> enabled
// 移动到目标元素位置
moveTeleport(n2, target, targetAnchor, internals, 1 /* TOGGLE */)
}
}
}
}
(1) 更新子节点
第一步是更新 Teleport
组件的子节点,这里更新分为优化更新和普通的全量比对更新两种情况,这一块不是本篇的重点,我们不再花时间去研究。只需要知道结果是更新子节点。
(2) 处理 disabled 属性变化的情况
如果新节点 disabled
为 true
,且旧节点的disabled
为 false
的话,说明我们需要把 Teleport
的子节点从目标元素内部移回到主视图内部了。
(3) 处理 to 属性变化的情况
如果新节点 disabled
为 false
,那么先通过 to
属性是否改变来判断目标元素 target
有没有变化。
如果有变化,则把Teleport
的子节点移动到新的target
内部;
如果目标元素没变化,则判断旧节点的 disabled
是否为 true
,如果是则把 Teleport 的子节点从主视图内部移动到目标元素内部了
3. unmount 卸载组件
当组件移除的时候会执行 unmount
方法,它的内部会判断如果移除的组件是一个 Teleport
组件,就会执行组件的 remove
方法
if (shapeFlag & 64 /* TELEPORT */) {
vnode.type.remove(vnode, internals);
}
if (doRemove) {
remove(vnode);
}
function remove(vnode, { r: remove, o: { remove: hostRemove } }) {
const { shapeFlag, children, anchor } = vnode
hostRemove(anchor)
if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
for (let i = 0; i < children.length; i++) {
remove(children[i])
}
}
}
Teleport
的 remove
方法实现很简单,首先通过 hostRemove
移除主视图渲染的锚点 teleport start
注释节点,然后再去遍历 Teleport
的子节点执行remove
移除。
执行完 Teleport
的 remove
方法,会继续执行remove
方法移除 Teleport
主视图的元素 teleport end
注释节点,至此,Teleport 组件完成了移除。
总结
本篇文章我们学习了teleport
的使用以及基本实现原理,对于teleport
组件,它首先是一个对象,对外提供process
和remove
方法,当组件在创建、更新、删除时去执行teleport
对象里这三个方法。
-
如果是创建,需要判断是否生产环境来插入空白文本结点或注释节点,接着通过工具方法获取
target
元素和to
属性,最后根据disabled
参数来决定是否挂载到目标元素位置 -
如果是更新,则会
patchChildren
或patchBlockChildren
来更新子节点。接着会判断新旧disabled
值和新旧to
属性来决定做什么操作 -
如果是删除,就通过
hostRemove
删除锚点节点和reomve
删除子节点