Vue3 的組合式 API 以及基於 Proxy 響應式原理已經有很多文章介紹過了,除了這些比較亮眼的更新,Vue3 還新增了一個內建元件:Teleport。這個元件的作用主要用來將模板內的 DOM 元素移動到其他位置。
使用場景
業務開發的過程中,我們經常會封裝一些常用的元件,例如 Modal 元件。相信大家在使用 Modal 元件的過程中,經常會遇到一個問題,那就是 Modal 的定位問題。
話不多說,我們先寫一個簡單的 Modal 元件。
<!-- Modal.vue -->
<style lang="scss">
.modal {
&__mask {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
}
&__main {
margin: 0 auto;
margin-bottom: 5%;
margin-top: 20%;
width: 500px;
background: #fff;
border-radius: 8px;
}
/* 省略部分樣式 */
}
</style>
<template>
<div class="modal__mask">
<div class="modal__main">
<div class="modal__header">
<h3 class="modal__title">彈窗標題</h3>
<span class="modal__close">x</span>
</div>
<div class="modal__content">
彈窗文字內容
</div>
<div class="modal__footer">
<button>取消</button>
<button>確認</button>
</div>
</div>
</div>
</template>
<script>
export default {
setup() {
return {};
},
};
</script>
複製程式碼
然後我們在頁面中引入 Modal 元件。
<!-- App.vue -->
<style lang="scss">
.container {
height: 80vh;
margin: 50px;
overflow: hidden;
}
</style>
<template>
<div class="container">
<Modal />
</div>
</template>
<script>
export default {
components: {
Modal,
},
setup() {
return {};
}
};
</script>
複製程式碼
如上圖所示, div.container
下彈窗元件正常展示。使用 fixed
進行佈局的元素,在一般情況下會相對於螢幕視窗來進行定位,但是如果父元素的 transform
, perspective
或 filter
屬性不為 none
時,fixed
元素就會相對於父元素來進行定位。
我們只需要把 .container
類的 transform
稍作修改,彈窗元件的定位就會錯亂。
<style lang="scss">
.container {
height: 80vh;
margin: 50px;
overflow: hidden;
transform: translateZ(0);
}
</style>
複製程式碼
這個時候,使用 Teleport
元件就能解決這個問題了。
Teleport 提供了一種乾淨的方法,允許我們控制在 DOM 中哪個父節點下呈現 HTML,而不必求助於全域性狀態或將其拆分為兩個元件。 -- Vue 官方文件
我們只需要將彈窗內容放入 Teleport
內,並設定 to
屬性為 body
,表示彈窗元件每次渲染都會做為 body
的子級,這樣之前的問題就能得到解決。
<template>
<teleport to="body">
<div class="modal__mask">
<div class="modal__main">
...
</div>
</div>
</teleport>
</template>
複製程式碼
可以在 codesandbox.io/embed/vue-m… 檢視程式碼。
原始碼解析
我們可以先寫一個簡單的模板,然後看看 Teleport
元件經過模板編譯後,生成的程式碼。
Vue.createApp({
template: `
<Teleport to="body">
<div> teleport to body </div>
</Teleport>
`
})
複製程式碼
簡化後代碼:
function render(_ctx, _cache) {
with (_ctx) {
const { createVNode, openBlock, createBlock, Teleport } = Vue
return (openBlock(), createBlock(Teleport, { to: "body" }, [
createVNode("div", null, " teleport to body ", -1 /* HOISTED */)
]))
}
}
複製程式碼
可以看到 Teleport
元件通過 createBlock
進行建立。
// packages/runtime-core/src/renderer.ts
export function createBlock(
type, props, children, patchFlag
) {
const vnode = createVNode(
type,
props,
children,
patchFlag
)
// ... 省略部分邏輯
return vnode
}
export function createVNode(
type, props, children, patchFlag
) {
// class & style normalization.
if (props) {
// ...
}
// encode the vnode type information into a bitmap
const shapeFlag = isString(type)
? ShapeFlags.ELEMENT
: __FEATURE_SUSPENSE__ && isSuspense(type)
? ShapeFlags.SUSPENSE
: isTeleport(type)
? ShapeFlags.TELEPORT
: isObject(type)
? ShapeFlags.STATEFUL_COMPONENT
: isFunction(type)
? ShapeFlags.FUNCTIONAL_COMPONENT
: 0
const vnode: VNode = {
type,
props,
shapeFlag,
patchFlag,
key: props && normalizeKey(props),
ref: props && normalizeRef(props),
}
return vnode
}
// packages/runtime-core/src/components/Teleport.ts
export const isTeleport = type => type.__isTeleport
export const Teleport = {
__isTeleport: true,
process() {}
}
複製程式碼
傳入 createBlock
的第一個引數為 Teleport
,最後得到的 vnode 中會有一個 shapeFlag
屬性,該屬性用來表示 vnode 的型別。isTeleport(type)
得到的結果為 true
,所以 shapeFlag
屬性最後的值為 ShapeFlags.TELEPORT
(1 << 6
)。
// packages/shared/src/shapeFlags.ts
export const enum ShapeFlags {
ELEMENT = 1,
FUNCTIONAL_COMPONENT = 1 << 1,
STATEFUL_COMPONENT = 1 << 2,
TEXT_CHILDREN = 1 << 3,
ARRAY_CHILDREN = 1 << 4,
SLOTS_CHILDREN = 1 << 5,
TELEPORT = 1 << 6,
SUSPENSE = 1 << 7,
COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,
COMPONENT_KEPT_ALIVE = 1 << 9
}
複製程式碼
在元件的 render 節點,會依據 type
和 shapeFlag
走不同的邏輯。
// packages/runtime-core/src/renderer.ts
const render = (vnode, container) => {
if (vnode == null) {
// 當前元件為空,則將元件銷燬
if (container._vnode) {
unmount(container._vnode, null, null, true)
}
} else {
// 新建或者更新元件
// container._vnode 是之前已建立元件的快取
patch(container._vnode || null, vnode, container)
}
container._vnode = vnode
}
// patch 是表示補丁,用於 vnode 的建立、更新、銷燬
const patch = (n1, n2, container) => {
// 如果新舊節點的型別不一致,則將舊節點銷燬
if (n1 && !isSameVNodeType(n1, n2)) {
unmount(n1)
}
const { type, ref, shapeFlag } = n2
switch (type) {
case Text:
// 處理文字
break
case Comment:
// 處理註釋
break
// case ...
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
// 處理 DOM 元素
} else if (shapeFlag & ShapeFlags.COMPONENT) {
// 處理自定義元件
} else if (shapeFlag & ShapeFlags.TELEPORT) {
// 處理 Teleport 元件
// 呼叫 Teleport.process 方法
type.process(n1, n2, container...);
} // else if ...
}
}
複製程式碼
可以看到,在處理 Teleport
時,最後會呼叫 Teleport.process
方法,Vue3 中很多地方都是通過 process 的方式來處理 vnode 相關邏輯的,下面我們重點看看 Teleport.process
方法做了些什麼。
// packages/runtime-core/src/components/Teleport.ts
const isTeleportDisabled = props => props.disabled
export const Teleport = {
__isTeleport: true,
process(n1, n2, container) {
const disabled = isTeleportDisabled(n2.props)
const { shapeFlag, children } = n2
if (n1 == null) {
const target = (n2.target = querySelector(n2.prop.to))
const mount = (container) => {
// compiler and vnode children normalization.
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(children, container)
}
}
if (disabled) {
// 開關關閉,掛載到原來的位置
mount(container)
} else if (target) {
// 將子節點,掛載到屬性 `to` 對應的節點上
mount(target)
}
}
else {
// n1不存在,更新節點即可
}
}
}
複製程式碼
其實原理很簡單,就是將 Teleport
的 children
掛載到屬性 to
對應的 DOM 元素中。為了方便理解,這裡只是展示了原始碼的九牛一毛,省略了很多其他的操作。
總結
希望在閱讀文章的過程中,大家能夠掌握 Teleport
元件的用法,並使用到業務場景中。儘管原理十分簡單,但是我們有了 Teleport
元件,就能輕鬆解決彈窗元素定位不準確的問題。