- 发布于
vue3-teleport组件源码
- Authors
- Name
- 田中原
阅读 vue3 teleport 组件源码
teleport
Vue 鼓励我们通过将 UI 和相关行为封装到组件中来构建 UI。我们可以将它们嵌套在另一个内部,以构建一个组成应用程序 UI 的树。
然而,有时组件模板的一部分逻辑上属于该组件,而从技术角度来看,最好将模板的这一部分移动到 DOM 中 Vue app 之外的其他位置。
一个常见的场景是创建一个包含全屏模式的组件。在大多数情况下,你希望模态框的逻辑存在于组件中,但是模态框的快速定位就很难通过 CSS 来解决,或者需要更改组件组合。
teleport 是一个非常有必要的内置组件,这里举例也是我们经常使用到的 dialog。
测试用例
renderer: teleport
should work
test('should work', () => {
const target = nodeOps.createElement('div')
const root = nodeOps.createElement('div')
render(
h(() => [h(Teleport, { to: target }, h('div', 'teleported')), h('div', 'root')]),
root
)
expect(serializeInner(root)).toMatchInlineSnapshot(
// dev 模式下 dom 中会留下 <!--teleport start--><!--teleport end--><div> 节点
`"<!--teleport start--><!--teleport end--><div>root</div>"`
)
expect(serializeInner(target)).toMatchInlineSnapshot(`"<div>teleported</div>"`)
})
should work with SVG
test('should work with SVG', async () => {
const root = document.createElement('div')
const svg = ref()
const circle = ref()
const Comp = defineComponent({
setup() {
return {
svg,
circle,
}
},
template: `
<svg ref="svg"></svg>
<teleport :to="svg" v-if="svg">
<circle ref="circle"></circle>
</teleport>`,
})
domRender(h(Comp), root)
await nextTick()
expect(root.innerHTML).toMatchInlineSnapshot(
`"<svg><circle></circle></svg><!--teleport start--><!--teleport end-->"`
)
expect(svg.value.namespaceURI).toBe('http://www.w3.org/2000/svg')
expect(circle.value.namespaceURI).toBe('http://www.w3.org/2000/svg')
})
should update target
测试 to
属性应该具有响应更新机制。
test('should update target', async () => {
const targetA = nodeOps.createElement('div')
const targetB = nodeOps.createElement('div')
const target = ref(targetA)
const root = nodeOps.createElement('div')
render(
h(() => [h(Teleport, { to: target.value }, h('div', 'teleported')), h('div', 'root')]),
root
)
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<!--teleport start--><!--teleport end--><div>root</div>"`
)
expect(serializeInner(targetA)).toMatchInlineSnapshot(`"<div>teleported</div>"`)
expect(serializeInner(targetB)).toMatchInlineSnapshot(`""`)
target.value = targetB
await nextTick()
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<!--teleport start--><!--teleport end--><div>root</div>"`
)
expect(serializeInner(targetA)).toMatchInlineSnapshot(`""`)
expect(serializeInner(targetB)).toMatchInlineSnapshot(`"<div>teleported</div>"`)
})
should update children
子节点更新测试。
test('should update children', async () => {
const target = nodeOps.createElement('div')
const root = nodeOps.createElement('div')
const children = ref([h('div', 'teleported')])
render(
h(() => h(Teleport, { to: target }, children.value)),
root
)
expect(serializeInner(target)).toMatchInlineSnapshot(`"<div>teleported</div>"`)
// 先清空子节点
children.value = []
await nextTick()
// target 中为空了
expect(serializeInner(target)).toMatchInlineSnapshot(`""`)
// 更新子节点,设置 teleported 文本
children.value = [createVNode(Text, null, 'teleported')]
await nextTick()
expect(serializeInner(target)).toMatchInlineSnapshot(`"teleported"`)
})
should remove children when unmounted
测试组件销毁后,target 中的节点应该被移除。
test('should remove children when unmounted', () => {
const target = nodeOps.createElement('div')
const root = nodeOps.createElement('div')
function testUnmount(props: any) {
render(
h(() => [h(Teleport, props, h('div', 'teleported')), h('div', 'root')]),
root
)
expect(serializeInner(target)).toMatchInlineSnapshot(
props.disabled ? `""` : `"<div>teleported</div>"`
)
render(null, root)
expect(serializeInner(target)).toBe('')
expect(target.children.length).toBe(0)
}
testUnmount({ to: target, disabled: false })
testUnmount({ to: target, disabled: true })
testUnmount({ to: null, disabled: true })
})
component with multi roots should be removed when unmounted
测试多个节点可以被正常渲染,并且销毁时也可以正常移除。
test('component with multi roots should be removed when unmounted', () => {
const target = nodeOps.createElement('div')
const root = nodeOps.createElement('div')
const Comp = {
render() {
return [h('p'), h('p')]
},
}
render(
h(() => [h(Teleport, { to: target }, h(Comp)), h('div', 'root')]),
root
)
expect(serializeInner(target)).toMatchInlineSnapshot(`"<p></p><p></p>"`)
render(null, root)
expect(serializeInner(target)).toBe('')
})
multiple teleport with same target
多个元素传送到一个 target 上,按照顺序在 target 上渲染。
test('multiple teleport with same target', () => {
const target = nodeOps.createElement('div')
const root = nodeOps.createElement('div')
render(
h('div', [h(Teleport, { to: target }, h('div', 'one')), h(Teleport, { to: target }, 'two')]),
root
)
// 两个 dom 都传送到了 target 上
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<div><!--teleport start--><!--teleport end--><!--teleport start--><!--teleport end--></div>"`
)
expect(serializeInner(target)).toMatchInlineSnapshot(`"<div>one</div>two"`)
// update existing content
// 更新节点内容
render(
h('div', [
h(Teleport, { to: target }, [h('div', 'one'), h('div', 'two')]),
h(Teleport, { to: target }, 'three'),
]),
root
)
expect(serializeInner(target)).toMatchInlineSnapshot(`"<div>one</div><div>two</div>three"`)
// toggling 只剩下 three
render(h('div', [null, h(Teleport, { to: target }, 'three')]), root)
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<div><!----><!--teleport start--><!--teleport end--></div>"`
)
expect(serializeInner(target)).toMatchInlineSnapshot(`"three"`)
// toggle back 切换换来可以正常渲染,可以正常渲染
render(
h('div', [
h(Teleport, { to: target }, [h('div', 'one'), h('div', 'two')]),
h(Teleport, { to: target }, 'three'),
]),
root
)
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<div><!--teleport start--><!--teleport end--><!--teleport start--><!--teleport end--></div>"`
)
// should append
expect(serializeInner(target)).toMatchInlineSnapshot(`"three<div>one</div><div>two</div>"`)
// toggle the other teleport
render(h('div', [h(Teleport, { to: target }, [h('div', 'one'), h('div', 'two')]), null]), root)
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<div><!--teleport start--><!--teleport end--><!----></div>"`
)
expect(serializeInner(target)).toMatchInlineSnapshot(`"<div>one</div><div>two</div>"`)
})
should work when using template ref as target
在模板中使用。
test('should work when using template ref as target', async () => {
const root = nodeOps.createElement('div')
const target = ref(null)
const disabled = ref(true)
const App = {
setup() {
return () =>
h(Fragment, [
h('div', { ref: target }),
h(
Teleport,
// 这里也测试了 disabled 属性
{ to: target.value, disabled: disabled.value },
h('div', 'teleported')
),
])
},
}
render(h(App), root)
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<div></div><!--teleport start--><div>teleported</div><!--teleport end-->"`
)
disabled.value = false
await nextTick()
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<div><div>teleported</div></div><!--teleport start--><!--teleport end-->"`
)
})
disabled
disabled 是 teleport 的第二个属性,从这个单测中可以看出来,当开启 disabled 属性后,dom 节点会从 target 中删除并且在原dom节点中正常渲染。
test('disabled', () => {
const target = nodeOps.createElement('div')
const root = nodeOps.createElement('div')
const renderWithDisabled = (disabled: boolean) => {
return h(Fragment, [
h(Teleport, { to: target, disabled }, h('div', 'teleported')),
h('div', 'root'),
])
}
render(renderWithDisabled(false), root)
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<!--teleport start--><!--teleport end--><div>root</div>"`
)
expect(serializeInner(target)).toMatchInlineSnapshot(`"<div>teleported</div>"`)
render(renderWithDisabled(true), root)
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<!--teleport start--><div>teleported</div><!--teleport end--><div>root</div>"`
)
expect(serializeInner(target)).toBe(``)
// toggle back
render(renderWithDisabled(false), root)
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<!--teleport start--><!--teleport end--><div>root</div>"`
)
expect(serializeInner(target)).toMatchInlineSnapshot(`"<div>teleported</div>"`)
})
moving teleport while enabled
test('moving teleport while enabled', () => {
const target = nodeOps.createElement('div')
const root = nodeOps.createElement('div')
render(h(Fragment, [h(Teleport, { to: target }, h('div', 'teleported')), h('div', 'root')]), root)
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<!--teleport start--><!--teleport end--><div>root</div>"`
)
expect(serializeInner(target)).toMatchInlineSnapshot(`"<div>teleported</div>"`)
render(h(Fragment, [h('div', 'root'), h(Teleport, { to: target }, h('div', 'teleported'))]), root)
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<div>root</div><!--teleport start--><!--teleport end-->"`
)
expect(serializeInner(target)).toMatchInlineSnapshot(`"<div>teleported</div>"`)
render(h(Fragment, [h(Teleport, { to: target }, h('div', 'teleported')), h('div', 'root')]), root)
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<!--teleport start--><!--teleport end--><div>root</div>"`
)
expect(serializeInner(target)).toMatchInlineSnapshot(`"<div>teleported</div>"`)
})
moving teleport while disabled
test('moving teleport while disabled', () => {
const target = nodeOps.createElement('div')
const root = nodeOps.createElement('div')
render(
h(Fragment, [
h(Teleport, { to: target, disabled: true }, h('div', 'teleported')),
h('div', 'root'),
]),
root
)
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<!--teleport start--><div>teleported</div><!--teleport end--><div>root</div>"`
)
expect(serializeInner(target)).toBe('')
render(
h(Fragment, [
h('div', 'root'),
h(Teleport, { to: target, disabled: true }, h('div', 'teleported')),
]),
root
)
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<div>root</div><!--teleport start--><div>teleported</div><!--teleport end-->"`
)
expect(serializeInner(target)).toBe('')
render(
h(Fragment, [
h(Teleport, { to: target, disabled: true }, h('div', 'teleported')),
h('div', 'root'),
]),
root
)
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<!--teleport start--><div>teleported</div><!--teleport end--><div>root</div>"`
)
expect(serializeInner(target)).toBe('')
})
should work with block tree
测试 teleport 中的多个元素也可以正常传送渲染
test('should work with block tree', async () => {
const target = nodeOps.createElement('div')
const root = nodeOps.createElement('div')
const disabled = ref(false)
const App = {
setup() {
return {
target: markRaw(target),
disabled,
}
},
render: compile(`
<teleport :to="target" :disabled="disabled">
<div>teleported</div><span>{{ disabled }}</span><span v-if="disabled"/>
</teleport>
<div>root</div>
`),
}
render(h(App), root)
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<!--teleport start--><!--teleport end--><div>root</div>"`
)
expect(serializeInner(target)).toMatchInlineSnapshot(
`"<div>teleported</div><span>false</span><!--v-if-->"`
)
disabled.value = true
await nextTick()
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<!--teleport start--><div>teleported</div><span>true</span><span></span><!--teleport end--><div>root</div>"`
)
expect(serializeInner(target)).toBe(``)
// toggle back
disabled.value = false
await nextTick()
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<!--teleport start--><!--teleport end--><div>root</div>"`
)
expect(serializeInner(target)).toMatchInlineSnapshot(
`"<div>teleported</div><span>false</span><!--v-if-->"`
)
})
the dir hooks of the Teleport's children should be called correctly
测试相关钩子函数应该被正确调用。
// https://github.com/vuejs/vue-next/issues/3497
// 这个 issue 里面提到 unmounted 钩子被调用了 2 次。
test(`the dir hooks of the Teleport's children should be called correctly`, async () => {
const target = nodeOps.createElement('div')
const root = nodeOps.createElement('div')
const toggle = ref(true)
const dir = {
mounted: jest.fn(),
unmounted: jest.fn(),
}
const app = createApp({
setup() {
return () => {
return toggle.value
? h(Teleport, { to: target }, [withDirectives(h('div', ['foo']), [[dir]])])
: null
}
},
})
app.mount(root)
expect(serializeInner(root)).toMatchInlineSnapshot(`"<!--teleport start--><!--teleport end-->"`)
expect(serializeInner(target)).toMatchInlineSnapshot(`"<div>foo</div>"`)
expect(dir.mounted).toHaveBeenCalledTimes(1)
expect(dir.unmounted).toHaveBeenCalledTimes(0)
toggle.value = false
await nextTick()
expect(serializeInner(root)).toMatchInlineSnapshot(`"<!---->"`)
expect(serializeInner(target)).toMatchInlineSnapshot(`""`)
expect(dir.mounted).toHaveBeenCalledTimes(1)
expect(dir.unmounted).toHaveBeenCalledTimes(1)
})
单测小结
- teleport 具有 2 个属性:to 和 disabled
- teleport 可以在 svg 中使用
- to 属性具有对应的响应策略
- target 可以被多个 teleport 指定使用,按照运行时的顺序在 target 上挂载
- disabled 属性在启用时,组件元素正常渲染,当切换为 false 后,对应的元素将会移动到 target 中,反之再移 回来
- 组件被销毁时,对应的 target 中的元素也会被销毁
源码解析
从刚才的单测角度来看,内部实现逻辑无非是上面的几点,看源码后的逻辑如下(主要看 TeleportImpl.process
即可):
第一次渲染时
在当前容器中创建占位节点
// n1 是 oldNode,在第一次调用时为空 if (n1 == null) { // insert anchors in the main view const placeholder = (n2.el = __DEV__ ? createComment('teleport start') : createText('')) const mainAnchor = (n2.anchor = __DEV__ ? createComment('teleport end') : createText('')) insert(placeholder, container, anchor) insert(mainAnchor, container, anchor)
这里先创建两个占位的节点,开发模式下是创建注释节点,生产模式下是创建空文本节点。
在 target 上创建占位节点
// resolveTarget 函数是根据 to 的属性获取目标元素 const target = (n2.target = resolveTarget(n2.props, querySelector)) const targetAnchor = (n2.targetAnchor = createText('')) if (target) { insert(targetAnchor, target) // #2652 we could be teleporting from a non-SVG tree into an SVG tree isSVG = isSVG || isTargetSVG(target) } else if (__DEV__ && !disabled) { warn('Invalid Teleport target on mount:', target, `(${typeof target})`) }
这里的 insert 方法,最底层是 dom 的
insertBefore
方法,insert 的三个参数:- 第一个参数是要插入的节点
- 第二个参数是要插入的父节点
- 第三个参数是在哪个节点前插入,如果没有则添加在最后面
那么这里创建空节点是为了什么?
留白思考一下...
分析 teleport 的功能,在
disabled
场景下,需要在 source 和 target 来回移动 dom。并且当to
属性发生变化后需要将 dom 移动到新的 target 上。所以为了能够定位到这个 dom 节点在 source 和 target 中应该插入的位置,这里利用了 空的文本节点在 dom 中的特性:
将空的文本节点插入到 dom 中,对整体 dom 的视觉是没有任何影响的,但依然可以从 dom 中找到这个节点。
使用
insertBefore
API,将要移动的 dom 节点插入到对应的空节点之前。移动到 target 时,将 dom 插入到 target 中空文本节点之前,移动回 source 时,将 dom 节点插入到 source 中的空节点之前。
然后看整体第一次渲染时的代码:
if (n1 == null) { // insert anchors in the main view const placeholder = (n2.el = __DEV__ ? createComment('teleport start') : createText('')) const mainAnchor = (n2.anchor = __DEV__ ? createComment('teleport end') : createText('')) // 将创建好的 2 个节点插入到 source 中。其中 anchor 为 teleport 后面的元素。 insert(placeholder, container, anchor) insert(mainAnchor, container, anchor) // 获取 target const target = (n2.target = resolveTarget(n2.props, querySelector)) // 创建 target 的占位节点 const targetAnchor = (n2.targetAnchor = createText('')) if (target) { // 如果目标模板存在,则插入 insert(targetAnchor, target) // #2652 we could be teleporting from a non-SVG tree into an SVG tree isSVG = isSVG || isTargetSVG(target) } else if (__DEV__ && !disabled) { warn('Invalid Teleport target on mount:', target, `(${typeof target})`) } const mount = (container: RendererElement, anchor: RendererNode) => { // Teleport *always* has Array children. This is enforced in both the // compiler and vnode children normalization. if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { mountChildren( children as VNodeArrayChildren, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized ) } } // 根据 disabled 属性来决定 dom 挂载到哪里。 if (disabled) { mount(container, mainAnchor) } else if (target) { mount(target, targetAnchor) } } else { // 这里为后续的更新操作, n1 为数据更新前的,n2 为数据更新后的 vnode // update content // 先获取对应的占位的空节点 n2.el = n1.el const mainAnchor = (n2.anchor = n1.anchor)! const target = (n2.target = n1.target)! const targetAnchor = (n2.targetAnchor = n1.targetAnchor)! // 这里记录更新前 disabled 是否为 ture const wasDisabled = isTeleportDisabled(n1.props) const currentContainer = wasDisabled ? container : target const currentAnchor = wasDisabled ? mainAnchor : targetAnchor isSVG = isSVG || isTargetSVG(target) // 先进行数据更新 if (dynamicChildren) { // fast path when the teleport happens to be a block root patchBlockChildren( n1.dynamicChildren!, dynamicChildren, currentContainer, parentComponent, parentSuspense, isSVG, slotScopeIds ) // even in block tree mode we need to make sure all root-level nodes // in the teleport inherit previous DOM references so that they can // be moved in future patches. traverseStaticChildren(n1, n2, true) } else if (!optimized) { patchChildren( n1, n2, currentContainer, currentAnchor, parentComponent, parentSuspense, isSVG, slotScopeIds, false ) } // 这里的 disabled 是从 n2 新的 vnode.props 获取到的 if (disabled) { // 这里是对 disabled 从 false 变成 true 的处理,也就是将 dom 节点移动会 source 中。 if (!wasDisabled) { // enabled -> disabled // move into main container moveTeleport(n2, container, mainAnchor, internals, TeleportMoveTypes.TOGGLE) } } else { // 更新后的数据 disabled 不是 true,先判断 to 属性是否发生了变化 // target changed if ((n2.props && n2.props.to) !== (n1.props && n1.props.to)) { const nextTarget = (n2.target = resolveTarget(n2.props, querySelector)) // 新的 target 如果存在 将节点移动到新的 target 中 if (nextTarget) { moveTeleport(n2, nextTarget, null, internals, TeleportMoveTypes.TARGET_CHANGE) } else if (__DEV__) { warn('Invalid Teleport target on update:', target, `(${typeof target})`) } } else if (wasDisabled) { // 这里处理从禁用到开启的并且 to 没有发生变化的情况,从 source 中移动到 target 中。 // disabled -> enabled // move into teleport target moveTeleport(n2, target, targetAnchor, internals, TeleportMoveTypes.TOGGLE) } } }
源码小结
- 创建 source 的空的占位节点,并插入到 source 中的对应的位置。
- 创建 target 的空节点,并追加到 target 最后
- 判断 disabled,true 在 source 中进行 mount,false 在 target 进行 mount
- 数据更新后:
- 判断当前 disabled 为 true,更新前为 false,表示开始禁用 teleport ,则需要将 dom 移动回 source 中
- disabled 判断为 false:
- 先判断 to 属性是否发生了变化,如果是,则需要将 dom 移动到新的 target 中
- 如果 to 没有变化,则需要判断更新前是 disabled 是否为 ture,如果是表示更新前的 dom 是在 source 中渲染的,现在需要移动到 target 中。