前言
🎉 欢迎来到 Vue3探秘系列专栏!
在这里,我们将深入探索 Vue3 的各种奥秘,从源码到实践,一步步揭开它的神秘面纱。📚
🚀 以下是本系列文章的导航目录,方便你快速找到感兴趣的内容:
- 虚拟结点 vnode 的页面挂载之旅(一)
不止响应式:Vue3探秘系列— 虚拟结点vnode的页面挂载之旅(一)
🌟 探索虚拟 DOM 如何变成页面上的真实内容,开启 Vue3 的渲染之旅!- 组件更新会发生什么(二)
不止响应式:Vue3探秘系列— 组件更新会发生什么(二)
🔃 深入组件更新的内部机制,看看 Vue3 是如何高效更新界面的。- diff 算法的完整过程(三)
不止响应式:Vue3探秘系列— diff算法的完整过程(三)
🧩 揭秘 Vue3 的 diff 算法,理解它是如何高效比较和更新 DOM 的。- 组件的初始化过程(四)
不止响应式:Vue3探秘系列— 组件的初始化过程(四)
🌱 从零开始,了解 Vue3 组件是如何初始化的,掌握组件生命周期的关键步骤。- 响应式设计(五)
终于轮到你了:Vue3探秘系列— 响应式设计(五)
🔗 深入 Vue3 的响应式系统,探索Proxy如何实现高效的数据响应机制。这只是系列的一部分,更多精彩内容还在持续更新中!🔍
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删除子节点