Vue3原生Teleport不太好用?试试自己写一个

前端下午茶

共 17161字,需浏览 35分钟

 · 2023-09-06

作者:背对疾风

原文:https://juejin.cn/post/7190346663318257724

VUE3发布距今已经差不多两年了,你所在的公司全面用上VUE3了吗?🤔

今天我们聊聊VUE3的新特性之一,Teleport[1]

VUE3官网描述:

<Teleport> 是一个内置组件,它可以将一个组件内部的一部分模板“传送”到该组件的 DOM 结构外层的位置去。

Teleport

有些同学可能没用过Teleport,所以可能也不了解我为啥会说这个Teleport不太好用。来看一下我下面这个例子。

image.png

如上图所示,这个叫“关于”的页面,是一个项目中所有页面的列表,这个页面上面有一个标题栏,标题栏右边是一个按钮。

这一张图,这个叫做“Home”的页面,也有一个标题栏,标题栏右边也是一个按钮,只不过按钮文字不一样。

移动端的项目,很多页面都有这种标题栏,我们在开发时基本上都要对这个标题栏封装一下。一般有两种思路:

  1. 封装成一个组件,页面内需要标题栏的就写上组件,然后设置标题什么的,我个人感觉这种不太优雅,需要每次都要在页面里写一个这个东西,稍微有一点点繁琐,大概是这样👇。
<template>
  <app-bar title="订单详情">
      <template #right>
        <button>保存</button>
      </template>
    </app-bar>
  <div class="home col">
    <span>测试测试测试</span>
    <button @click="ttt">测试接口</button>
  </div>
</template>
  1. 把标题栏封装在路由根页面里,然后把标题内容什么的设置在路由meta上,由根页面去维护这个标题栏,大概是这样👇。
  • 路由文件
const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'Index',
    redirect: '/home'
  },
  {
    path: '/home',
    name: 'Home',
    component: () => import('@/views/Home/index.vue'),
    meta: {
      title: 'Home',
    }
  },
  {
    path: '/test',
    name: 'Test',
    component: () => import('@/views/Test/index.vue'),
    meta: {
      title: '测试',
    }
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('@/views/About/index.vue'),
    meta: {
      title: '关于',
    }
  }
]
  • 路由根页面
<script setup lang="ts">
  import { useRoute } from 'vue-router'

  const currentRoute = useRoute()
</script>

<template>
  <div class="col">
    <app-bar :title="currentRoute.meta.title" />
    <router-view />
  </div>
</template>

我呢,本着优雅的原则,自然是选了第2种方式了,但是随着业务复杂起来,我发现第二种方法也没那么优雅了。

问题

问题,就出现在标题栏右边这个button上。大部分页面不需要这个标题栏右侧显示东西的,少部分页面需要显示一个保存新建按钮,还有的页面要显示一个切换状态的switch组件,甚至还有要显示各种颜色表示状态的图标的。这样一来就很麻烦了,在meta上配置文字什么的,肯定满足不了设计的脑洞了。

解决方案

挠头之际,我想起来当时看VUE2升级VUE3指南时看到的Teleport组件了,寻思这玩意简直为这需求而生啊。

页面需要什么按钮、图标、switch,直接用Teleport“传”到路由根页面去不就完事了么,看起来非常的简单便捷。

<Teleport> 接收一个 to prop 来指定传送的目标。to 的值可以是一个 CSS 选择器字符串,也可以是一个 DOM 元素对象。这段代码的作用就是告诉 Vue“把以下模板片段传送到 body 标签下”。

但是等我真正用到项目中的时候,发现并不是那么简单。我之前为了优化用户体验,给页面切换加了个前进后退的动画效果,这俩一组合,效果就不太符合预期了。

  • 页面结构长这样
<template>
    <router-view v-slot="{ Component, route }">
      <transition :name="transitionName" @after-enter="afterEnter">
        <!-- 没有key的话,vue的动画无法生效 -->
        <div :key="route.path" class="col page-body">
            <app-bar
              :title="route.meta.title"
            />

              <div class="page-sc">
                <component :is="Component" ref="currentPageComponent" />
              </div>
        </div>
      </transition>
    </router-view>
</template>
  • 效果是这样

可以看到,在我点击“Home”页面的瞬间,右上方有一个按钮短暂出现了一帧,然后就消失了。我用别的动画复现,发现结果也不一样,有的情况不带动画也不能正常显示。后来忙需求,也没精力一直去研究这个,就随便换个方法绕过去了,直到我前段时间发现了怎么在VUE3的模板语法中渲染JSX[2]

解法

众所周知,不管是JSX还是Template模板语法,最后都是生成的虚拟dom对象,既然是对象,那就可以传递,那在A组件中写JSX然后把结果传递B组件,然后在B组件中渲染,就可以做到将视图传递到指定位置渲染的目的了。

我这里需求是子组件传递给父组件,就用provide+inject传递。用法看这里[3]。思路有了,看看实现👇

  • 父组件
<script setup name="Father" lang="ts">
  import { provide, ref } from 'vue'
  import Child from './child.vue'

  const teleportView = ref(null)

  provide('teleportViewCenter', {
    update(view: any) {
      teleportView.value = view
    }
  })
</script>

<template>
  <div class="col">
    <span>我是父组件</span>
    <component :is="teleportView" />
    <Child />
  </div>
</template>
  • 子组件
<script setup name="Child" lang="tsx">
  import { inject } from 'vue'

  const viewCenter = inject<any>('teleportViewCenter')

  function renderJSX({
    return <button>按钮A</button>
  }

  viewCenter.update(renderJSX())
</script>

<template>
  <div class="col">我是子组件</div>
</template>
<style lang="less" scoped></style>
  • 效果图

可以看到按钮能正常显示了🏄‍♀️,然后让我们做一点优化。

优化

视图更新

首先就是更新视图的问题,我们现在渲染的这个按钮是死的,不能动,这肯定不符合需求。所以要改造成活的。

  • 子组件
<script setup lang="tsx">
  import { inject, ref, watch } from 'vue'

  const viewCenter = inject<any>('teleportViewCenter')

  const loading = ref(false)

  // 按钮点击事件,切换loading的值
  function triggerStatus({
    loading.value = !loading.value
  }

  function renderJSX({
    return <button onClick={triggerStatus}>{loading.value ? '正在加载' : '按钮A'}</button>
  }

  // 监听到loading变化时,通知父组件更新视图
  watch(
    loading,
    () => {
      viewCenter.update(renderJSX())
    },
    {
      immediatetrue
    }
  )
</script>

<template>
  <div class="col">我是子组件</div>
</template>
  • 效果
test4.gif

不过这样写有点麻烦,你也可以这么写👇

  // watch(
  //   loading,
  //   () => {
  //     viewCenter.update(renderJSX())
  //   },
  //   {
  //     immediate: true
  //   }
  // )
  
  // 监听到loading变化时,通知父组件更新视图
  watchEffect(() => {
    viewCenter.update(renderJSX())
  })

这样不仅用的代码更少,而且可以追踪renderJSX()中用到的所有依赖的变化。

销毁视图

一般情况下,子组件在销毁时也要顺带销毁子组件传递出去的内容,我们这里在onUnmounted中通知父组件销毁自身。

  • 父组件
<script setup name="Father" lang="ts">
  import { provide, ref } from 'vue'
  import Child from './child.vue'

  const teleportView = ref(null)

  // 控制子组件显示/隐藏的开关
  const showChild = ref(false)

  provide('teleportViewCenter', {
    update(view: any) {
      teleportView.value = view
    },
    destroy() {
      teleportView.value = null
    }
  })
</script>
<template>
  <div class="col">
    <span>我是父组件</span>
    <button @click="showChild = !showChild">展示/隐藏子组件</button>
    <component :is="teleportView" />
    <Child v-if="showChild" />
  </div>
</template>
  • 子组件
<script setup lang="tsx">
  import { inject, ref, onUnmounted, watchEffect } from 'vue'
  
  const viewCenter = inject<any>('teleportViewCenter')
  const loading = ref(false)

  // 按钮点击事件,切换loading的值
  function triggerStatus({
    loading.value = !loading.value
  }

  function renderJSX({
    return <button onClick={triggerStatus}>{loading.value ? '正在加载' : '按钮A'}</button>
  }

  // 监听到loading变化时,通知父组件更新视图
  watchEffect(() => {
    viewCenter.update(renderJSX())
  })

  // 在自身被销毁时,通知父组件销毁传递的view
  onUnmounted(() => {
    viewCenter.destroy()
  })
</script>

<template>
  <div class="col">我是子组件</div>
</template>
  • 效果图

性能优化

很简单,把ref换成shallowRef就好了,shallowRef使用文档看这里[4]

  import { provide, ref, shallowRef, triggerRef } from 'vue'

  //const teleportView = ref(null)
  const teleportView = shallowRef(null)
  
  provide('teleportViewCenter', {
    update(view: any) {
      teleportView.value = view
      // view为null时,再更改view的value,不能正常触发更新,这里手动触发一下
      triggerRef(teleportView)
    },
    destroy() {
      teleportView.value = null
    }
  })

父组件中保存view并不需要把view对象递归转为响应式的,view变化时,我们是整个换掉的。(不过这也要求传递的视图最好不要搞得太复杂)

shallowRef() 和 ref() 不同,浅层 ref 的内部值将会原样存储和暴露,并且不会被深层递归地转为响应式。只有对 .value 的访问是响应式的。shallowRef() 常常用于对大型数据结构的性能优化或是与外部的状态管理系统集成。

代码封装

效果还是ok的,就是用起来稍显繁琐,我们可以用hook来个极简封装👇

  • useTeleportView.ts
import { shallowRef, provide, inject, onUnmounted, watchEffect, triggerRef } from 'vue'

export interface TeleportViewCenter {
  update: (view: JSX.Element | null) => void
  destroy: () => void
}

export function useTeleportSite(name: string) {
  const view = shallowRef<JSX.Element | null>(null)

  const siteInstance: TeleportViewCenter = {
    update(newView) {
      view.value = newView
      // view为null时,再更改view的value,不能触发更新
      triggerRef(view)
    },
    destroy() {
      view.value = null
    }
  }

  provide(name, siteInstance)

  return view
}

export function useTeleportView(name: string, viewRender: () => JSX.Element | null) {
  const siteInstance = inject<TeleportViewCenter>(name)

  const stop = watchEffect(() => {
    const view = viewRender()
    siteInstance?.update(view)
  })

  onUnmounted(() => {
    stop()
    siteInstance?.destroy()
  })
}
  • 父组件中使用
<script setup name="Father" lang="ts">
  import { ref } from 'vue'
  import Child from './child.vue'
  import { useTeleportSite } from '@/hooks/useTeleportView'

  const showChild = ref(false)

  const teleportView = useTeleportSite('teleportViewCenter')
</script>

<template>
  <div class="col">
    <span>我是父组件</span>
    <button @click="showChild = !showChild">展示/隐藏子组件</button>
    <component :is="teleportView" />
    <Child v-if="showChild" />
  </div>
</template>
  • 子组件中使用
<script setup lang="tsx">
  import { ref } from 'vue'
  import { useTeleportView } from '@/hooks/useTeleportView'

  const loading = ref(false)

  // 按钮点击事件,切换loading的值
  function triggerStatus({
    loading.value = !loading.value
  }

  useTeleportView('teleportViewCenter', () => {
    return <button onClick={triggerStatus}>{loading.value ? '正在加载' : '按钮A'}</button>
  })
</script>

<template>
  <div class="col">我是子组件</div>
</template>

总结

🎉这次的分享到这里就结束啦,俗话说没有最好的技术,只有最合适的技术,这种写法比较适用于原生Teleport不太好用的时候,希望你能用的开心。


哦,对了,来看看我最后的效果

test6.gif

参考资料

[1]

https://cn.vuejs.org/guide/built-ins/teleport.html: https://link.juejin.cn?target=https%3A%2F%2Fcn.vuejs.org%2Fguide%2Fbuilt-ins%2Fteleport.html

[2]

https://juejin.cn/post/7176563708288565305: https://juejin.cn/post/7176563708288565305

[3]

https://cn.vuejs.org/guide/components/provide-inject.html: https://link.juejin.cn?target=https%3A%2F%2Fcn.vuejs.org%2Fguide%2Fcomponents%2Fprovide-inject.html

[4]

https://cn.vuejs.org/api/reactivity-advanced.html#shallowref: https://link.juejin.cn?target=https%3A%2F%2Fcn.vuejs.org%2Fapi%2Freactivity-advanced.html%23shallowref

最后



如果你觉得这篇内容对你挺有启发,我想邀请你帮我个小忙:

  1. 点个「喜欢」或「在看」,让更多的人也能看到这篇内容

  2. 我组建了个氛围非常好的前端群,里面有很多前端小伙伴,欢迎加我微信「sherlocked_93」拉你加群,一起交流和学习

  3. 关注公众号「前端下午茶」,持续为你推送精选好文,也可以加我为好友,随时聊骚。



点个喜欢支持我吧,在看就更好了

浏览 438
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报