unplugin-vue-components 是一款按需自动导入Vue组件的库。支持 Vue2 和 Vue3,同时支持组件和指令。使用此插件库后,不再需要手动导入组件,插件会自动识别按需导入组件以及对应样式,我们只需要像全局组件那样使用即可。
当然上面说的并不严谨,此库并非全项目导入我们的组件,也会根据一些配置来限制导入组件的范围。下面我们先说下在项目中是如何使用的。
当前项目采用的是 Vue(3.2.16) + Vite(3.2.2) + Ts(4.5.5) + unplugin-vue-components(0.22.11)
npm i unplugin-vue-components -D
import { ConfigEnv, UserConfig } from 'vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default ({ _mode }: ConfigEnv): UserConfig => {
return {
base: './',
plugins: [
vue(),
Components({
resolvers: [ElementPlusResolver({ importStyle: "sass" })]
})
]
}
}
在 plugins 中添加 ElementPlusResolver 解析器,此时我们在使用 ElementPlus 中组件时就无需 import 导入,可直接在 template 使用
{
"compilerOptions": {},
"include": ["components.d.ts"],
}
以上全部设置完成后会在根目录下生成 components.d.ts,并会自动更新此类型声明文件,此时我们就可以正常开发了。
坦率的说,在大部分项目中大家应该都是这样配置的,虽然这样配置并没有错,但前提是您必须知道它正在发挥着什么作用。
我们习惯把全局组件放到 src/components 文件夹下并导出注册到全局,有一天您新增了一个组件但忘记注册到全局了,令人不可思议的是你正在 template 模版中使用它。随着组件使用次数的增加,您终于发现了这个问题。先是后背一阵冷汗,项目都上线了,这是要祭天的节奏啊,强装镇静但颤抖的手还是让同事发现了一些异常,通过在本地运行了几遍都很正常才发现这不是通往祭天的路,一知半解的您此时也不太确定要不要把它再注册到全局,毕竟好多地方都在用。平复好心情以后您终于发现了这篇文章,恭喜您!在接下来的2分钟里我将为你揭晓答案,请准备好瓜子花生,我们这就发车了。
上面的 vite.config.ts 中 Components 的设置等同于下面的配置。如:
import { ConfigEnv, UserConfig } from 'vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default ({ _mode }: ConfigEnv): UserConfig => {
return {
base: './',
plugins: [
vue(),
Components({
dts: true,
// 要搜索组件的目录
dirs: ['src/components'],
// 组件有效扩展名
extensions: ['vue'],
// 按需自动导入 elementPlus 组件
resolvers: [
ElementPlusResolver({ importStyle: 'sass' })
],
})
]
}
}
以上配置发挥着两个作用
看到这里你可能只用了30秒就明白了为什么你没有注册到全局,然而还可以正常在 template 模版中使用了。没错你确实明白了,你也必须要明白否则下一个疑问可能就不会涌上心头?
你应该要有这样的疑问?使用了 unplugin-vue-components 插件库,是不是就不需要全局组件了?是不是就不能使用全局组件了?是不是全局组件和此库必须选其一呢?
我来试着回答一下,使用 unplugin-vue-components 库全部按需导入真的很香,完全支持这么干。您也肯定猜到了,或者您也肯定迫不及待的想知道但是了。没错这里必须有但是。像进度条、空布局、表单/表格以及一些项目独有且频繁使用的组件我们可以把它们做成全局组件,这样做更有意义并更符合逻辑。怎么办三个字飘然而过?
unplugin-vue-components 库肯定也想到了这个问题,都是一群成年人开发的库,都是一群世界顶级的技术天才开发的库,怎么可能会让我们做选择题?成年人的世界那是我都要。当你想要一探究竟时,整个宇宙都会合力助你。兜兜转转终于发现 dirs 属性可能能帮我们实现愿望。
在上面的代码中我们知道 dirs 和 extensions 都有默认值,它们表示的意思是 在 dirs 指定的目录下按需导入以 extensions 指定的后缀名的组件。这句话虽然有点绕,但此时你也应该恍然大悟。您真的懂了吗?可能懂了但真的懂了要怎么做了吗?还是烦请看一看我的解决方式吧,觉得有用就用上,觉得没用就当图个乐吧。
此时按需导入就 只会 搜索我们指定的 dirs 路径,这也是我 比较推荐 的写法。
我的建议是: globalComponents 全局组件文件夹我们最好建上,因为如果我们不创建,目录虽然看起来更清晰一些,但这只体现在我们开发人员都知道每一个目录作用的前提下,假如新来一个同事,我们又没有告诉他每个文件夹的含义,他在创建 局部组件 时是很有可能直接在 components 目录下新建,而压根就没有注意到 localComponents 文件夹的存在,如果我们添加了 globalComponents 文件下,那新来的同事肯定会 思考 一下每个文件夹含义,并根据文件夹内其他组件模版来创建组件。项目目录如:
deep: true 时会深度搜索并按需导入组件,设置false时则 只会 导入 dirs 根目录下的组件(只导入一层),不会深度匹配导入
此时我们可以搭配 第一种 组合文件夹的方式。也可以把 局部组件直接新建到 components 文件夹下,而全局组件则放到 src/components/globalComponents 文件下如:。
// vite.config.ts
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default ({ _mode }: ConfigEnv): UserConfig => {
return {
base: './',
plugins: [
vue(),
Components({
dts: true,
// 要搜索组件的目录
dirs: ['src/components'],
// 组件有效扩展名
extensions: ['vue'],
// 只导入 dirs 指定的根目录组件,不进行深度导入
deep: false,
// 按需自动导入 elementPlus 组件
resolvers: [ElementPlusResolver({ importStyle: 'sass' })],
})
]
}
}
通过上面的两种方式,我们就可以有选择的注册全局组件和局部组件了。当然 unplugin-vue-components 还有一些不常用的配置。如: globs
globs:导入我们指定的文件,设置 globs 后将忽略 dirs 和 extensions 属性的设置。如:
// vite.config.ts
import Components from 'unplugin-vue-components/vite'
export default ({ _mode }: ConfigEnv): UserConfig => {
return {
base: './',
plugins: [
vue(),
Components({
dts: true,
// 要搜索组件的目录
dirs: ['src/components'], // 默认值也是 src/components
// 组件有效扩展名
extensions: ['vue'],
globs: ['src/views/*.{vue}'],
})
]
}
}
此配置将忽略 dirs 和 extensions 属性配置,而使用 globs 配置,即:只会按需导入 src/views 文件夹下所有以 .vue 结尾的文件
如何使用终于讲完了,看到这里如果您走了,那您肯定不是冲着该篇文章的标题来的。我们的重头戏才刚刚开始,下一站原理分析。
// vite.config.ts
export default ({ _mode }: ConfigEnv): UserConfig => {
return {
plugins: [
Components({
dirs: ['src/components/localComponents'],
resolvers: [ElementPlusResolver({ importStyle: 'sass' })],
})
]
}
}
// src/views/test/Index.vue
ElmentPlus按钮
局部按需导入组件
全局组件
全局组件我们直接使用,这很好理解。那UI库组件以及局部组件 unplugin-vue-components 是怎么做到按需引入的呢?
unplugin-vue-components 为主流的UI组件库提供了内置的支持,如:Element Plus、Ant Design Vue、View UI、Vant等,通过使用对应UI组件库的解析器,就会自动引入对应的组件库的组件和对应样式。
unplugin-vue-components 为主流UI库提供的解析器大多数都是函数 如:Element Plus、Ant Design Vue、View UI,但也有例外如:Bootstrap 解析器就是一个对象。
我们以 ElementPlusResolver 函数解析器来说明
export function ElementPlusResolver(options){
return [
{ type: 'component',
resolve: (name: string) => {
return {
name, // ElButton
from, // element-plus/es
sideEffects // element-plus/es/components/button/style/index
}
}},
{ type: 'directive', resolve: (name: string) => return {...省略}) }
]
}
由此可知 ElementPlusResolver 解析器不仅支持 组件 按需导入,同时也支持指令。当是组件时 resolve 函数就会根据name值来返回一个对象如:
{
name: 'ElButton',
from: 'element-plus/es',
sideEffects: 'element-plus/es/components/button/style/index'
}
此时 unplugin-vue-components 就会根据上面获得的对象来按需引入 ElButton 组件及样式如:
import { ElButton } from 'element-plus/es';
import 'element-plus/es/components/button/style/index';
当然最终都会为 组件 起一个别名再使用,这里我不太理解为什么要使用别名,希望知道的大佬帮解释一下!!!
我们先看下模版转换成 渲染函数 是什么样子?
由图可知: 所有组件都会使用 _resolveComponent 包裹,resolveComponent() 函数的作用就是 按名称手动解析已注册的组件
紧接着 unplugin-vue-components 插件开始工作,通过 transform 钩子函数拿到上面的 渲染函数 代码并进行转换
由图可知: 经过转换后的 渲染函数,已经帮我们按需导入了以下代码
/* unplugin-vue-components disabled */
import __unplugin_components_1 from '/Users/admin/liuyz/release-project/manage-system-pc/src/components/localComponents/LocalTestComponents.vue';
import { ElButton as __unplugin_components_0 } from 'element-plus/es'
import 'element-plus/es/components/button/style/index'
并将 render 函数中 _resolveComponent 替换成 _unplugin_components${no},从而达到了按需使用组件的功能。
我们知道 unplugin-vue-components 是 vite 的一款插件库,我们要将插件添加到项目的 devDependencies 中并使用数组形式的 plugins 选项配置它们。
通常的惯例是创建一个 Vite/Rollup 插件作为一个返回实际插件对象的工厂函数。该函数可以接受允许用户自定义插件行为的选项(引用),我想当然的以为 unplugin-vue-components 是 Vite 的专属插件,因为它使用了 Vite 特有的钩子函数,其实不然,unplugin-vue-components 不限制打包工具。
我们还是拿上面的例子来做分析如:
// vite.config.ts
export default ({ _mode }: ConfigEnv): UserConfig => {
return {
plugins: [
Components({
dirs: ['src/components/localComponents'],
resolvers: [ElementPlusResolver({ importStyle: 'sass' })],
})
]
}
}
// src/views/test/Index.vue
ElmentPlus按钮
局部按需导入组件
全局组件
// src/core/unplugin.ts
export default createUnplugin((options = {}) => {
// 很重要 生成此插件对象 用于后续 钩子函数使用
const ctx: Context = new Context(options)
return {
name: 'unplugin-vue-components',
// 该钩子会在每个传入模块请求时被调用 转换 渲染函数
async transform(code, id) {
if (!shouldTransform(code))
return null
try {
// 转换 code
const result = await ctx.transform(code, id)
// 生成 components.d.ts 文件
ctx.generateDeclaration()
return result
}
catch (e) {
this.error(e)
}
},
vite: {
configResolved(config: ResolvedConfig) {
ctx.setRoot(config.root)
ctx.sourcemap = true
if (ctx.options.dts) {
ctx.searchGlob()
// 当 components.d.ts 不存在时重新生成
if (!existsSync(ctx.options.dts))
ctx.generateDeclaration()
}
},
configureServer(server: ViteDevServer) {
// 观察项目所有目录下的变化
ctx.setupViteServer(server)
},
},
}
})
入口函数很简单同时也很重要,一共做了四件事情
下面我们就围绕着这四步来一一分析
// src/core/context.ts
constructor(
private rawOptions: Options,
) { // 构造函数
this.options = resolveOptions(rawOptions, this.root)
this.generateDeclaration = throttle(500, this._generateDeclaration.bind(this), { noLeading: false })
this.setTransformer(this.options.transformer)
}
// src/core/options.ts
export const defaultOptions: Omit, 'include' | 'exclude' | 'transformer' | 'globs' | 'directives' | 'types' | 'version'> = {
// resolveOptions 函数 默认参数
dirs: 'src/components',
extensions: 'vue',
deep: true,
dts: isPackageExists('typescript'),
directoryAsNamespace: false,
collapseSamePrefixes: false,
globalNamespaces: [],
resolvers: [],
importPathTransform: v => v,
allowOverrides: false,
}
resolveOptions 函数最终会返回如下对象
{
dirs: [ 'src/components/localComponents' ],
extensions: [ 'vue' ],
deep: true,
dts: '/Users/admin/liuyz/release-project/manage-system-pc/components.d.ts',
directoryAsNamespace: false,
collapseSamePrefixes: false,
globalNamespaces: [],
resolvers: [
{ type: 'component', resolve: [AsyncFunction: resolve] },
{ type: 'directive', resolve: [AsyncFunction: resolve] }
],
importPathTransform: [Function: importPathTransform],
allowOverrides: false,
types: [],
resolvedDirs: [
'/Users/admin/liuyz/release-project/manage-system-pc/src/components/localComponents'
],
globs: [
'/Users/admin/liuyz/release-project/manage-system-pc/src/components/localComponents/**/*.vue'
],
root: '/Users/admin/liuyz/release-project/manage-system-pc',
version: 3,
transformer: 'vue3',
directives: true
}
在上面的 resolveOptions 对象中,我们需要记住以下几个重要的信息
很简单,主要是用来生成 components.d.ts 文件的
setTransformer(name: Options['transformer']) {
debug.env('transformer', name)
// 这里我们使用 vue3
this.transformer = transformer(this, name || 'vue3')
}
// src/core/transformer.ts
export default function transformer(ctx: Context, transformer: SupportedTransformer): Transformer {
return async (code, id, path) => {
// 搜索指定dirs组件并把信息保存到 _componentNameMap 对象中
ctx.searchGlob()
// /Users/admin/liuyz/release-project/manage-system-pc/src/views/test/Index.vue
const sfcPath = ctx.normalizePath(path)
debug(sfcPath)
// 魔法字符串 对字符串进行替换操作
const s = new MagicString(code)
// 转换字符串 实现按需导入
await transformComponent(code, transformer, s, ctx, sfcPath)
if (ctx.options.directives)
await transformDirectives(code, transformer, s, ctx, sfcPath)
s.prepend(DISABLE_COMMENT)
const result: TransformResult = { code: s.toString() }
// 返回按需导入的代码
return result
}
}
this.setTransformer 函数主要就是给 this.transformer 赋值匿名函数,匿名函数的作用主要是对字符串进行替换实现按需导入,最重要的属于 transformComponent 函数,我们后续再慢慢说,最后返回替换后的 result,供插件的钩子函数 transform 中使用
此时 Context 对象创建完成,当然此对象内部还有很多方法正在迫不及待的等着调用。接下来代码回调到了 transform 钩子函数。
transform(code: string, id: string) {
const { path, query } = parseId(id)
return this.transformer(code, id, path, query)
}
它们衔接的很好啊。刚在创建的 Context 对象中为 this.transformer 赋了值,在 transform 中马上就调用了,此时我们不得不看看 transformComponent 到底都做了些什么事情了?
src/core/transforms/component.ts
const resolveVue3 = (code: string, s: MagicString) => {
const results: ResolveResult[] = []
for (const match of code.matchAll(/_resolveComponent[0-9]*\("(.+?)"\)/g)) {
const matchedName = match[1]
if (match.index != null && matchedName && !matchedName.startsWith('_')) {
const start = match.index
const end = start + match[0].length
results.push({
rawName: matchedName,
// 提供替换的方法
replace: resolved => s.overwrite(start, end, resolved),
})
}
}
return results
}
src/core/transforms/component.ts
export default async function transformComponent(code: string, transformer: SupportedTransformer, s: MagicString, ctx: Context, sfcPath: string) {
let no = 0
// 这里是vue3项目,因此使用 resolveVue3
const results = transformer === 'vue2' ? resolveVue2(code, s) : resolveVue3(code, s)
for (const { rawName, replace } of results) {
debug(`| ${rawName}`)
// 变成大驼峰命名 如 el-button -> ElButton
const name = pascalCase(rawName)
ctx.updateUsageMap(sfcPath, [name])
// 从本地或指定解析器(如: ElementPlus)中寻找组件
const component = await ctx.findComponent(name, 'component', [sfcPath])
if (component) {
const varName = `__unplugin_components_${no}`
// 替换
s.prepend(`${stringifyComponentImport({ ...component, as: varName }, ctx)};\n`)
no += 1
// 替换
replace(varName)
}
}
}
transformComponent 函数的主要作用就是用 { …component, as: varName } 和 _unplugin_components${ no } 替换需要按需导入的组件。
一共分四个步骤
[
{ rawName: 'el-button', replace: (resolved) => s.overwrite(start, end, resolved) },
{ rawName: 'local-test-components', replace: (resolved) => s.overwrite(start, end, resolved) },
{ rawName: 'global-loading-components', replace: (resolved) => s.overwrite(start, end, resolved) }
]
当找到 src/components/localComponents 下组件时返回的 component 对象为
{
as: 'LocalTestComponents',
from: '/Users/admin/liuyz/release-project/manage-system-pc/src/components/localComponents/LocalTestComponents.vue'
}
当找到 ElementPlus 下组件时返回的 component 对象为
{
as: 'ElButton',
name: 'ElButton',
from: 'element-plus/es',
sideEffects: 'element-plus/es/components/button/style/index'
}
当是 src/components/localComponents 组件时会添加
import __unplugin_components_1 from '/Users/admin/liuyz/release-project/manage-system-pc/src/components/localComponents/LocalTestComponents.vue';
当是 ElementPlus 组件时会添加
import { ElButton as __unplugin_components_0 } from 'element-plus/es';
import 'element-plus/es/components/button/style/index';
可以看到 把 ElButton as __unplugin_components_0,同时也引入了 button 的样式文件
当是 src/components/localComponents 组件时会添加
把 _resolveComponent(“local-test-components”) 替换为 __unplugin_components_1
当是 ElementPlus 组件时会添加
把 _resolveComponent(“el-button”) 替换为 __unplugin_components_0
当是全局组件时或者说找到该组件时则什么都不做
//还是原来代码
_resolveComponent("global-loading-components")
async findComponent(name: string, type: 'component' | 'directive', excludePaths: string[] = []): Promise {
// _componentNameMap 存放所有本地 dirs 目录下的组件的信息
let info = this._componentNameMap[name]
if (info && !excludePaths.includes(info.from) && !excludePaths.includes(info.from.slice(1)))
return info
// 这里每次都会处理第三方UI库的组件
for (const resolver of this.options.resolvers) {
// 判断是否存在类型 如:全局组件不存在类型则执行下次循环,而ElementPlus组件存在类型则继续向下执行
if (resolver.type !== type)
continue
// 一般用于调用第三方UI库解析器
const result = await resolver.resolve(type === 'directive' ? name.slice(DIRECTIVE_IMPORT_PREFIX.length) : name)
if (!result)
continue
if (typeof result === 'string') {
info = {
as: name,
from: result,
}
}
else {
info = {
as: name,
...normalizeComponetInfo(result),
}
}
if (type === 'component')
// 添加UI库组件信息 用于写入 components.d.ts 中
this.addCustomComponents(info)
else if (type === 'directive')
this.addCustomDirectives(info)
return info
}
return undefined
}
该函数的主要作用就是查找组件
{
LocalTestComponents: {
as: 'LocalTestComponents',
from: '/Users/admin/liuyz/release-project/manage-system-pc/src/components/localComponents/LocalTestComponents.vue'
}
}
看到这里您是否有一个疑问? 在 findComponent 函数中 _componentNameMap 本地组件对象是什么时候赋的值呢?
回答
其实在 src / core / transformer.ts 文件的 transformer 返回的函数中通过 ctx.searchGlob() 赋值的。我们现在来具体看看它是怎么实现的!
// src/core/context.ts
searchGlob() {
// 只会执行一次
if (this._searched)
return
// 搜索组件
searchComponents(this)
this._searched = true
}
// src/core/fs/glob.ts
export function searchComponents(ctx: Context) {
const root = ctx.root
//globs: ['/Users/admin/liuyz/release-project/manage-system-pc/src/components/localComponents/**/*.vue']
const files = fg.sync(ctx.options.globs, {
ignore: ['node_modules'],
onlyFiles: true,
cwd: root,
absolute: true,
})
ctx.addComponents(files)
}
// 添加组件
addComponents(paths: string | string[]) {
const size = this._componentPaths.size
toArray(paths).forEach(p => this._componentPaths.add(p))
if (this._componentPaths.size !== size) {
this.updateComponentNameMap()
return true
}
return false
}
这里是从 globs 目录下搜索所有以.vue 结尾的文件并保存到 _componentPaths 的Set对象中,此时 _componentPaths 为
{
'/Users/admin/liuyz/release-project/manage-system-pc/src/components/localComponents/LocalTestComponents.vue'
}
private updateComponentNameMap() {
this._componentNameMap = {}
Array
.from(this._componentPaths)
.forEach((path) => {
const name = pascalCase(getNameFromFilePath(path, this.options))
if (this._componentNameMap[name] && !this.options.allowOverrides) {
console.warn(`[unplugin-vue-components] component "${name}"(${path}) has naming conflicts with other components, ignored.`)
return
}
this._componentNameMap[name] = {
as: name,
from: path,
}
})
}
该函数只会把本地组件信息存放到 _componentNameMap 对象中,如:
{
as: 'LocalTestComponents',
from: '/Users/admin/liuyz/release-project/manage-system-pc/src/components/localComponents/LocalTestComponents.vue'
}
总结
setupWatcher(watcher: fs.FSWatcher) {
const { globs } = this.options
// 删除文件时触发
watcher
.on('unlink', (path) => {
if (!matchGlobs(path, globs))
return
path = slash(path)
this.removeComponents(path)
this.onUpdate(path)
})
// 添加文件时触发
watcher
.on('add', (path) => {
if (!matchGlobs(path, globs))
return
path = slash(path)
this.addComponents(path)
this.onUpdate(path)
})
}
这里主要对本项目 文件任何变动 进行监听。当然我们只需要对 dirs 目录下的文件进行一些处理,所以会通过 matchGlobs 进行拦截,当条件满足时才会又通过 addComponents/removeComponents 对 _componentNameMap 对象进行更新
主线逻辑终于分析完了,看到这里您应该也累了,但先别下车, ElementPlusResolver 解析器又突上眉头,我们再坚持坚持,争取一鼓作气把它看完
resolvers: [ ElementPlusResolver({importStyle: 'sass'})]
// src/core/resolvers/element-plus.ts
export function ElementPlusResolver(
options: ElementPlusResolverOptions = {},
): ComponentResolver[] {
let optionsResolved: ElementPlusResolverOptionsResolved
// 拿到合并后的 optionsResolved 参数
async function resolveOptions() {
if (optionsResolved)
return optionsResolved
optionsResolved = {
ssr: false,
version: await getPkgVersion('element-plus', '2.2.2'),
importStyle: 'css',
directives: true,
exclude: undefined,
noStylesComponents: options.noStylesComponents || [],
...options,
}
return optionsResolved
}
return [
{
type: 'component',
resolve: async (name: string) => {
const options = await resolveOptions()
if ([...options.noStylesComponents, ...noStylesComponents].includes(name))
return resolveComponent(name, { ...options, importStyle: false })
else return resolveComponent(name, options) // 一般都这里
},
},
{
type: 'directive',
resolve: async (name: string) => {
return resolveDirective(name, await resolveOptions())
},
},
]
}
我们发现 ElementPlusResolver 是一个函数,执行后返回
[
{ type: 'component', resolve: (name: string) => resolveComponent(name, options) },
{ type: 'directive', resolve: (name: string) => resolveDirective(name, await resolveOptions()) }
]
这正好和我们上面对应上了, 这里我把[AsyncFunction: resolve]替换成了箭头函数。这里为每一个类型提供了 resolve 函数,而这里 resolveComponent 函数就会返回组件的名称、组件文件路径、组件样式路径
{
name: 'ElButton',
from: 'element-plus/es',
sideEffects: 'element-plus/es/components/button/style/index'
}
这个时候您可能需要再看下 findComponent 函数中这行代码了
const result = await resolver.resolve(type === 'directive' ? name.slice(DIRECTIVE_IMPORT_PREFIX.length) : name)
这里 result 的结果就 resolveComponent 函数返回的结果
unplugin-vue-components 通过对原始 渲染函数 进行加工转换,实现按需自动导入组件的功能。从而减小项目包体积,加快页面加载速度,进而提升用户体验。
好了到站了!终于可以下车了!希望这篇文章对您有所收获。