- 发布于
 
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 中找到这个节点。
使用
insertBeforeAPI,将要移动的 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 中。