Code前端首页关于Code前端联系我们

或者 yarn add unplugin-vue-components -D

terry 3小时前 阅读数 58 #Vue
文章标签 yarnvuecomponents

Vue3里写全局组件真的比局部组件好用吗?这5个避坑指南全在这

很多刚转Vue3的开发者,一开始看到Composition API带来的灵活性,再加上全局组件“一次注册,全项目用”的便利,可能会忍不住把所有复用性哪怕只有一点点的组件都注册成全局的,但真这么做没问题吗?接下来我们就围绕这个核心问题,结合日常开发场景、Composition API的特性,甚至跨Web Components的玩法,好好聊聊Vue3全局组件的那些事儿。

什么是Vue3全局组件?先把定义掰扯清楚

全局组件和局部组件,本质区别只有注册范围和组件可见性,Vue3里的全局组件,是通过app.component()或者插件里的app.use()(插件内部也会调用app相关API)注册的组件,注册成功后,不需要在任何子组件的components选项里再单独引入,就能直接在整个Vue实例挂载的DOM树里使用。 比如我们做了一个通用的加载动画组件LoadingSpin,如果是全局注册,那么不管是在首页Home.vue、详情页Detail.vue还是嵌套很深的某个表格单元格组件TableCell.vue里,直接写<LoadingSpin />就可以生效;但如果是局部组件,那每个想用的页面或组件,都得先import LoadingSpin from '@/components/LoadingSpin.vue',再在components: { LoadingSpin }里声明一遍。 这里还要补充一个Vue3的小变化:在Vue2里,全局组件还会默认暴露给子组件的template渲染上下文,哪怕是第三方库的组件也一样,但Vue3已经做了限制,现在只有通过app.component()直接注册的,或者插件显式导出的全局组件,才会在全项目可见,这个变化其实是为了减少组件命名冲突的概率,是个好事儿。

Vue3全局组件的适用场景,别乱套

很多人觉得全局组件“省事”就是万能的,但实际上它的适用场景非常明确,只有满足以下3个条件之一的组件,才值得注册成全局的:

  1. 复用频率极高,几乎每个页面/页面的核心模块都用得上:比如刚才说的加载动画、基础按钮、基础输入框、面包屑导航、全局提示弹窗(不是临时弹一次的Toast,Toast更适合做指令或者服务)、底部版权栏这些。
  2. 跨多层嵌套的UI组件库封装的核心原子组件:如果你是自己在项目里封装一套轻量级UI库,那里面的基础原子组件(button、input、radio、checkbox、icon这些)一定要全局注册,不然每次用都引入太痛苦了,而且会增加每个打包文件的冗余代码?不对不对,先别急着下结论,这涉及到后面要讲的Tree Shaking的问题,后面避坑指南会详细说。
  3. 结合defineCustomElement做的跨框架原生Web Components:Vue3提供了defineCustomElement这个API,可以把Vue组件打包成标准的Web Components,这时候不仅可以在Vue项目里全局注册使用,还能直接放到React、Angular甚至原生HTML项目里用,这个是Vue3全局组件的一个新兴玩法,后面也会单独讲。

滥用Vue3全局组件会有哪些问题?避坑指南第一条就是别瞎省事

如果不管什么组件都注册成全局的,哪怕复用性只有2-3次,甚至1次都不到,那会带来4个明显的问题,这也是避坑指南里必须要避开的:

  1. 组件命名冲突风险:虽然Vue3比Vue2做了限制,但如果是多人协作的大项目,或者你同时引入了多个第三方UI库(比如同时用了Element Plus和Ant Design Vue Vue3版本),那原子组件的命名冲突概率还是很高的,比如两个库都有<el-button /><a-button />还好,但如果你自己封装的通用按钮叫<my-button />,后来引入的某个第三方工具库刚好也全局注册了一个叫<my-button />的组件,那整个项目都会报错,而且排查起来非常麻烦——你不知道到底是哪个地方注册的冲突组件,要翻遍所有插件和main.js/main.ts才能找到。
  2. Tree Shaking失效,首屏加载体积变大:这里要先给不太懂Tree Shaking的开发者简单解释一下:Tree Shaking是现代打包工具(比如Vite、Webpack5+)的一个优化功能,它可以“摇掉”项目里没有用到的代码,只打包实际用到的部分,从而减小最终的打包体积,提升首屏加载速度,那为什么全局组件会导致Tree Shaking失效呢?因为app.component()是在Vue实例初始化的时候同步执行的,打包工具无法静态分析出哪些全局组件会被用到,哪些不会,所以只能把所有全局注册的组件都打包进首屏的vendor.js或者common.js里,而如果是局部组件,打包工具可以通过分析每个组件的components选项,知道哪些组件被用到了,哪些没用到,从而只打包用到的部分,举个极端的例子:如果你自己封装了一套有50个原子组件的轻量级UI库,全部全局注册,但你的首页只用了其中的2个,那剩下的48个都会被打包进首屏,白白浪费了用户的流量和加载时间。
  3. 代码可读性和可维护性变差:局部组件最大的优点之一,显式依赖”——你看一个组件的开头,就能知道它依赖了哪些其他组件,代码逻辑非常清晰,但如果是全局组件,你看不到任何显式依赖的声明,当你接手一个陌生的项目,或者回头看自己半年前写的代码时,你根本不知道<LoadingSpin />或者<MyTable />是从哪来的,要去翻main.js/main.ts或者插件代码才能找到,非常浪费时间,而且如果全局组件的props或emits发生了变化,你很难快速定位到所有使用了这个组件的地方(虽然可以用IDE的全局搜索,但如果是跨项目的Web Components就更麻烦了)。
  4. 测试难度增加:在做单元测试的时候,全局组件会污染测试环境,比如你要测试某个子组件TableCell.vue,但TableCell.vue里用到了全局注册的<LoadingSpin />,那你在写测试用例的时候,要么得在测试文件里手动注册LoadingSpin,要么得用Vue Testing Library的render函数的global.components选项临时注册,不然测试就会报错,但如果是局部组件,你只需要在测试文件里直接引入TableCell.vue和它依赖的LoadingSpin.vue就可以了,不会污染其他测试用例的环境。

避坑指南第二条:全局组件也可以做Tree Shaking?只要选对注册方式

刚才提到滥用全局组件会导致Tree Shaking失效,但有没有办法既享受全局组件“一次注册,全项目用”的便利,又能让Tree Shaking正常工作呢?答案是有的,前提是你得用按需全局注册+Tree Shaking友好的封装方式,而不是一次性把所有组件都全局注册一遍。 这里以自己封装的轻量级UI库为例,给大家分享两种常见的方式: 第一种方式是插件化封装+手动按需引入,你可以把每个原子组件都封装成一个单独的插件,比如LoadingSpin组件封装成loading-spin-plugin.js,里面写:

import LoadingSpin from './LoadingSpin.vue'
export default {
  install(app) {
    app.component('LoadingSpin', LoadingSpin)
  }
}

然后在main.js/main.ts里,只引入你项目里用到的组件对应的插件,再用app.use()注册:

import { createApp } from 'vue'
import App from './App.vue'
import LoadingSpinPlugin from '@/components/ui/loading-spin-plugin.js'
import MyButtonPlugin from '@/components/ui/my-button-plugin.js'
const app = createApp(App)
app.use(LoadingSpinPlugin)
app.use(MyButtonPlugin)
app.mount('#app')

这种方式的好处是,你只引入了用到的组件对应的插件,打包工具可以正常Tree Shaking掉其他没用到的组件和插件;坏处是每个组件都要封装成一个单独的插件,有点繁琐,而且如果项目用到的组件很多,main.js/main.ts里的app.use()会写一大串,不够简洁。 第二种方式是自动按需引入插件(比如unplugin-vue-components)+全局组件命名空间,unplugin-vue-components是一个非常流行的Vite/Webpack插件,它可以自动分析你的Vue模板文件,找出用到的组件,然后自动引入并注册(可以选择注册成局部组件,也可以选择注册成全局组件),而且它还支持全局组件命名空间,比如你可以把自己封装的UI库组件都加上U前缀,这样就不会和第三方UI库的组件冲突了。 具体怎么配置呢?以Vite项目为例: 首先安装unplugin-vue-components:

npm install unplugin-vue-components -D# 或者 pnpm add unplugin-vue-components -D

然后在vite.config.js/vite.config.ts里配置:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
// 这里假设你自己封装的UI库组件都放在src/components/ui目录下
export default defineConfig({
  plugins: [
    vue(),
    Components({
      // 自动搜索组件的目录
      dirs: ['src/components/ui'],
      // 自动引入的组件的扩展名
      extensions: ['vue'],
      // 是否深度搜索子目录
      deep: true,
      // 全局组件的命名空间,这里设为'U',所以组件名字会变成ULoadingSpin、UMyButton
      globalNamePrefix: 'U',
      // 选择自动注册成全局组件,默认是局部组件
      dts: true, // 生成TypeScript类型声明文件(如果是TS项目的话)
      resolvers: [] // 这里可以加第三方UI库的解析器,比如Element Plus的解析器
    })
  ]
})

配置好之后,你只需要把自己封装的UI库组件放在src/components/ui目录下,然后在Vue模板文件里直接写<ULoadingSpin />或者<UMyButton />就可以了,unplugin-vue-components会自动引入并全局注册这些组件,而且只会引入你实际用到的组件,打包工具可以正常Tree Shaking掉其他没用到的,这种方式既解决了全局组件命名冲突的问题,又解决了Tree Shaking失效的问题,还非常简洁方便,是目前多人协作大项目里最常用的方式之一。

避坑指南第三条:结合defineCustomElement做跨框架原生Web Components,这才是全局组件的“高级玩法”

刚才提到Vue3提供了defineCustomElement这个API,可以把Vue组件打包成标准的Web Components,这是Vue3全局组件的一个新兴的、非常有用的玩法,接下来我们就好好聊聊这个。 什么是Web Components?Web Components是一套浏览器原生的API,它可以让你创建自定义的、可复用的HTML标签,这些标签可以在任何支持Web Components的浏览器里使用,不管你用的是什么前端框架(Vue、React、Angular、原生HTML都可以),而Vue3的defineCustomElementAPI,就是把Vue组件的Composition API、响应式数据、插槽、事件这些特性,封装成了标准的Web Components,这样你就可以用Vue的开发方式来写跨框架的组件了。 那怎么用defineCustomElement把Vue组件打包成Web Components呢?给大家举个简单的例子: 创建一个Vue组件MyCounter.vue,注意这里的组件写法和普通Vue组件稍微有点不一样:

<template>
  <div class="my-counter">
    <button @click="count--">-</button>
    <span>{{ count }}</span>
    <button @click="count++">+</button>
  </div>
</template>
<script setup>
import { ref, defineProps, defineEmits, defineCustomElement } from 'vue'
// 注意:defineCustomElement需要显式导入,而且script setup的最后必须调用它并返回
// 定义props,和普通Vue组件一样
const props = defineProps({
  initialCount: {
    type: Number,
    default: 0
  }
})
// 定义emits,和普通Vue组件一样,但注意emits的名字必须用kebab-case,因为Web Components的事件名是区分大小写的,而且HTML里不能用驼峰
const emits = defineEmits(['count-changed'])
// 响应式数据
const count = ref(props.initialCount)
// 监听count的变化,触发emits
watch(count, (newVal) => {
  emits('count-changed', newVal)
})
// 最后必须调用defineCustomElement并返回,否则无法打包成Web Components
// 注意:这里的第一个参数可以省略,省略的话打包出来的Web Components的标签名就是文件名的kebab-case,比如MyCounter.vue就会变成my-counter
// 如果要指定标签名,必须包含连字符(-),这是Web Components的要求
export default defineCustomElement({
  // 这里可以写组件的配置,比如name、styles(如果用scoped style的话,Vue3会自动把样式封装到Shadow DOM里)
  name: 'MyCounter',
  // 注意:如果是普通Vue组件,script setup里的代码会自动绑定到组件实例上,但如果是defineCustomElement,我们需要把props、emits、响应式数据、方法这些显式暴露给组件配置吗?不对不对,Vue3的script setup + defineCustomElement已经做了自动处理,我们只需要像写普通Vue组件一样写script setup,然后最后调用defineCustomElement并返回就可以了
  // 哦对了,如果要把样式封装到Shadow DOM里,必须在style标签里加scoped吗?或者不加?其实Vue3的defineCustomElement默认会把组件的样式(不管有没有scoped)都封装到Shadow DOM里,这样组件的样式就不会和外部的样式冲突,是个非常好的特性
})
</script>
<style scoped>
.my-counter {
  display: flex;
  gap: 8px;
  align-items: center;
  padding: 8px 16px;
  border: 1px solid #e0e0e0;
  border-radius: 4px;
  width: fit-content;
}
.my-counter button {
  width: 32px;
  height: 32px;
  border: none;
  border-radius: 4px;
  background-color: #42b983;
  color: white;
  font-size: 18px;
  cursor: pointer;
}
.my-counter button:hover {
  background-color: #3aa876;
}
.my-counter span {
  font-size: 20px;
  font-weight: bold;
  min-width: 32px;
  text-align: center;
}
</style>

你可以在main.js/main.ts里把这个Web Components注册成全局的自定义HTML标签:

import { createApp } from 'vue'
import App from './App.vue'
import MyCounter from './components/MyCounter.vue'
// 注册成全局的自定义HTML标签,标签名必须包含连字符
customElements.define('my-counter', MyCounter)
const app = createApp(App)
app.mount('#app')

注册好之后,你不仅可以在Vue项目里直接写<my-counter initial-count="5" @count-changed="handleCountChanged" />,还可以把这个组件打包成一个单独的JS文件(比如用Vite的库模式打包),然后直接放到原生HTML项目里用:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">原生HTML里用Vue3 Web Components</title>
  <!-- 引入打包好的JS文件 -->
  <script src="./dist/my-counter.umd.js"></script>
</head>
<body>
  <!-- 直接使用自定义HTML标签 -->
  <my-counter initial-count="10"></my-counter>
  <!-- 监听count-changed事件 -->
  <script>
    const counter = document.querySelector('my-counter')
    counter.addEventListener('count-changed', (e) => {
      console.log('当前计数:', e.detail)
    })
  </script>
</body>
</html>

这种方式非常适合做一些跨平台的、通用的UI组件,比如企业官网的导航栏、底部版权栏、表单验证组件这些,不管你以后换什么前端框架,这些组件都可以直接复用,不需要重新开发。

避坑指南第四条:全局组件的命名要有规范,别瞎起名字

刚才提到全局组件会有命名冲突的风险,所以给全局组件起名字的时候,一定要有一套严格的规范,常见的规范有以下几种:

  1. 加上项目/团队的缩写前缀:比如你的项目叫“电商后台管理系统”,缩写是“ECM”,那你的全局组件可以叫<ECMLoadingSpin /><ECMMyButton /><ECMBreadcrumb />;如果你的团队叫“前端一组”,缩写是“FE1”,那可以叫<FE1LoadingSpin />,这种方式是最常用的,也是最有效的,因为项目/团队的缩写一般是唯一的,很少会和第三方UI库的组件冲突。
  2. 加上业务领域的前缀:比如你的全局组件是专门用于订单模块的,可以叫<OrderTable /><OrderForm />;如果是专门用于用户模块的,可以叫<UserAvatar /><UserSelect />,不过这种方式只适合业务领域非常明确的、复用频率极高的业务组件,不适合通用的原子组件,因为通用的原子组件没有明确的业务领域。
  3. 全用kebab-case命名:虽然Vue3支持在template里用驼峰命名(比如<LoadingSpin />),但HTML本身是不区分大小写的,如果你以后要把这个组件打包成Web Components,或者直接放到原生HTML里用,就必须用kebab-case命名(比如<loading-spin />),所以建议大家不管是全局组件还是局部组件,在template里都用kebab-case命名,这样更规范,也更符合HTML的标准。

避坑指南第五条:全局提示类组件(比如Toast、Modal)别用组件,用指令或者服务

很多刚转Vue3的开发者,会把全局提示类组件(登录成功”的Toast提示、“确认删除”的Modal弹窗)也注册成全局组件,然后在template里写<Toast v-if="showToast" message="登录成功" /><Modal v-if="showModal" title="确认删除" @confirm="handleDelete" />,但这种方式其实非常麻烦,因为你需要在每个想用Toast或Modal的组件里都声明一个响应式变量showToastshowModal,还要手动控制它的显示和隐藏。 其实全局提示类组件更适合做成指令或者服务,而不是组件,比如Vue3的第三方UI库Element Plus里的ElMessage(Toast提示)、ElMessageBox(Modal弹窗),都是做成服务的,你只需要在需要的地方直接调用ElMessage.success('登录成功')ElMessageBox.confirm('确认删除吗?')就可以了,非常方便。 那怎么自己用Vue3的Composition API封装一个Toast服务呢?给大家举个简单的例子: 创建一个Vue组件Toast.vue

<template>
  <transition name="toast-fade">
    <div v-if="visible" class="toast">{{ message }}</div>
  </transition>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const props = defineProps({
  message: {
    type: String,
    required: true
  },
  duration: {
    type: Number,
    default: 2000
  }
})
const emits = defineEmits(['close'])
const visible = ref(false)
let timer = null
// 组件挂载后显示Toast
onMounted(() => {
  visible.value = true
  // 延迟duration毫秒后隐藏Toast
  timer = setTimeout(() => {
    visible.value = false
  }, props.duration)
})
// 监听transition的after-leave事件,触发close事件,然后卸载组件
const handleAfterLeave = () => {
  emits('close')
}
// 组件卸载前清除定时器
onUnmounted(() => {
  if (timer) {
    clearTimeout(timer)
    timer = null
  }
})
</script>
<style scoped>
.toast {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  padding: 12px 24px;
  background-color: rgba(0, 0, 0, 0.75);
  color: white;
  border-radius: 4px;
  font-size: 14px;
  z-index: 9999;
}
.toast-fade-enter-active,
.toast-fade-leave-active {
  transition: opacity 0.3s ease;
}
.toast-fade-enter-from,
.toast-fade-leave-to {
  opacity: 0;
}
</style>

创建一个Toast服务toast.js

import { createApp, h } from 'vue'
import Toast from './Toast.vue'
// 创建一个函数,用来生成Toast
const toast = (options) => {
  // 处理参数,如果是字符串,直接作为message
  if (typeof options === 'string') {
    options = { message: options }
  }
  // 合并默认参数
  const { message, duration = 2000 } = options
  // 创建一个临时的div容器
  const container = document.createElement('div')
  document.body.appendChild(container)
  // 创建Vue应用实例,渲染Toast组件
  const app = createApp({
    render() {
      // 用h函数渲染Toast组件,监听close事件
      return h(Toast, {
        message,
        duration,
        onClose: () => {
          // 关闭后卸载Vue应用实例,移除临时的div容器
          app.unmount()
          document.body.removeChild(container)
        }
      })
    }
  })
  // 挂载Vue应用实例到临时的div容器
  app.mount(container)
}
// 导出toast函数,同时可以封装success、error、warning等方法
export default toast
export const toastSuccess = (message, duration) => toast({ message, duration, type: 'success' })
export const toastError = (message, duration) => toast({ message, duration, type: 'error' })
export const toastWarning = (message, duration) => toast({ message, duration, type: 'warning' })

你可以在需要的地方直接引入并调用这个服务:

<template>
  <button @click="showToast">显示Toast</button>
</template>
<script setup>
import toast, { toastSuccess } from '@/components/toast.js'
const showToast = () => {
  // 调用默认的toast函数
  toast('这是一个普通的Toast提示')
  // 调用封装好的toastSuccess函数
  setTimeout(() => {
    toastSuccess('登录成功!')
  }, 1000)
}
</script>

这种方式是不是比全局组件方便多了?而且不会污染template,也不会有Tree Shaking失效的问题。

Vue3全局组件到底什么时候用?

聊到这里,相信大家对Vue3全局组件已经有了一个比较全面的了解,最后我们再总结一下:

  • 能用局部组件的,尽量用局部组件:局部组件有显式依赖、代码可读性好、可维护性好、测试方便、Tree Shaking正常工作等优点,是首选。
  • 只有满足以下3个条件之一的,才用全局组件:复用频率极高的通用原子组件、自己封装的轻量级UI库的核心原子组件(最好用unplugin-vue-components自动按需引入)、结合defineCustomElement做的跨框架原生Web Components。
  • 全局提示类组件别用组件,用指令或者服务:这样更方便,也更符合Vue3的开发理念。
  • 给全局组件起名字要有规范:加上项目/团队的缩写前缀,全用kebab-case命名,避免命名冲突。

希望这篇文章能帮到刚转Vue3的开发者,让大家在开发过程中少走弯路,写出更规范、更高效、更易维护的代码。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门