发布于

vue3-teleport组件源码

Authors
  • avatar
    Name
    田中原
    Twitter

阅读 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)
})

单测小结

  1. teleport 具有 2 个属性:to 和 disabled
  2. teleport 可以在 svg 中使用
  3. to 属性具有对应的响应策略
  4. target 可以被多个 teleport 指定使用,按照运行时的顺序在 target 上挂载
  5. disabled 属性在启用时,组件元素正常渲染,当切换为 false 后,对应的元素将会移动到 target 中,反之再移 回来
  6. 组件被销毁时,对应的 target 中的元素也会被销毁

源码解析

从刚才的单测角度来看,内部实现逻辑无非是上面的几点,看源码后的逻辑如下(主要看 TeleportImpl.process 即可):

第一次渲染时
  1. 在当前容器中创建占位节点

    // 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)
    

    这里先创建两个占位的节点,开发模式下是创建注释节点,生产模式下是创建空文本节点。

  2. 在 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 的三个参数:

    1. 第一个参数是要插入的节点
    2. 第二个参数是要插入的父节点
    3. 第三个参数是在哪个节点前插入,如果没有则添加在最后面

    那么这里创建空节点是为了什么?

    留白思考一下...

    分析 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)
        }
      }
    }
    

源码小结

  1. 创建 source 的空的占位节点,并插入到 source 中的对应的位置。
  2. 创建 target 的空节点,并追加到 target 最后
  3. 判断 disabled,true 在 source 中进行 mount,false 在 target 进行 mount
  4. 数据更新后:
  5. 判断当前 disabled 为 true,更新前为 false,表示开始禁用 teleport ,则需要将 dom 移动回 source 中
  6. disabled 判断为 false:
  7. 先判断 to 属性是否发生了变化,如果是,则需要将 dom 移动到新的 target 中
  8. 如果 to 没有变化,则需要判断更新前是 disabled 是否为 ture,如果是表示更新前的 dom 是在 source 中渲染的,现在需要移动到 target 中。