Element Plus 的按需导入是很常见的功能,但是对于整个过程都用了哪些插件、怎么实现的,可能大家就比较少了解。本文逐步剖析了按需导入的整体实现,相信大家在阅读后都能有所收获。
有较多源码+注释,因为代码块不能指定部分区域的背景色,阅读起来可能有点费眼,请见谅。
用法(Vite)
// vite.config.ts
import { defineConfig } from 'vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
// ...
plugins: [
// ...
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
})
可以看出核心就是引入了 unplugin-auto-import
和 unplugin-vue-components
两个插件,并使用了同样的参数 ElementPlusResolver
,并且它们都是基于 unplugin
开发的。整体架构如下:
接下来介绍各插件的功能和实现
unplugin
功能介绍
提供统一构建工具的插件接口(基于 Rollup 插件 API 设计),换个说法就是让你的插件能够同时在 Vite、Webpack、Rollup 等主流构建工具中使用。使用方式如下:
import { createUnplugin } from 'unplugin'
export const unplugin = createUnplugin((options: UserOptions) => {
return {
name: 'unplugin-prefixed-name',
// webpack's id filter is outside of loader logic,
// an additional hook is needed for better perf on webpack
transformInclude(id) {
return id.endsWith('.vue')
},
// just like rollup transform
// ******重点******
transform(code) {
return code.replace(/<template>/, '<template><div>Injected</div>')
},
// more hooks coming
}
})
export const vitePlugin = unplugin.vite
export const rollupPlugin = unplugin.rollup
export const webpackPlugin = unplugin.webpack
export const rspackPlugin = unplugin.rspack
export const esbuildPlugin = unplugin.esbuild
transform
的作用就是对源码进行转换,本文主要关注插件中 transform
的处理过程,unplugin 的实现就不展开了。
unplugin-vue-components
功能介绍
- 自动导入 import 和注册组件,提升开发效率,如下:
# 开发代码
<template>
<div>
<HelloWorld msg="Hello Vue 3.0 + Vite" />
</div>
</template>
<script>
export default {
name: 'App'
}
</script>
# 经过插件自动导入后
<template>
<div>
<HelloWorld msg="Hello Vue 3.0 + Vite" />
</div>
</template>
<script>
import HelloWorld from './src/components/HelloWorld.vue'
export default {
name: 'App',
components: {
HelloWorld
}
}
</script>
-
对于 ts 项目,还会自动生成对应组件的声明 components.d.ts
源码定位
- 根据
import Components from 'unplugin-vue-components/vite'
在 unplugin-vue-components 的 package.json 找对应路径
// package.json
"./vite": {
"types": "./dist/vite.d.ts",
"require": "./dist/vite.js",
"import": "./dist/vite.mjs"
}
- 查看构建配置找到源码入口
其实就是把 /src 目录下的 .ts 文件分别构建输出,可以定位到 /src/vite.ts
// package.json
"scripts": {
"build": "tsup && esno scripts/postbuild.ts",
// tsup.config.ts
export const tsup: Options = {
entry: [
'src/*.ts',
],
- 利用编辑器定位 transform
-
定位 transform 核心实现 /src/core/transformer.ts
跳转至 transformer 可以看到有两个方法 transformComponent、transformDirectives ,其中 transformDirectives 是自动导入指令时运行的,先关注 transformComponent
即可。
源码分析
- 通过简单例子调试分析
transformComponent
方法的源码
// App.vue
<script setup>
</script>
<template>
<el-button type="primary">
我是一个按钮
</el-button>
<el-rate :model-value="5" />
</template>
<style scoped>
</style>
// src/core/transforms/component.ts
export default async function transformComponent(code: string, transformer: SupportedTransformer, s: MagicString, ctx: Context, sfcPath: string) {
// code:.vue 文件经过编译后的字符串,这里只截取出其中一部分
// "const _component_el_button = _resolveComponent("el-button")\n const _component_el_rate = _resolveComponent("el-rate")"
// no:计数
let no = 0
// results:调用 resolveVue3 获取代码中使用了哪些组件
// [
// {
// rawName: "el-button",
// replace: (resolved) => s.overwrite(start, end, resolved),
// },
// {
// rawName: "el-rate",
// replace: (resolved) => s.overwrite(start, end, resolved),
// },
// ]
const results = transformer === 'vue2' ? resolveVue2(code, s) : resolveVue3(code, s)
// 以 { rawName: 'el-button' } 为例
for (const { rawName, replace } of results) {
debug(`| ${rawName}`)
// name = "ElButton"
const name = pascalCase(rawName)
ctx.updateUsageMap(sfcPath, [name])
// component:获取对应组件的导入来源
// {
// as: "ElButton",
// name: "ElButton",
// from: "element-plus/es",
// sideEffects: [
// "element-plus/es/components/base/style/css",
// "element-plus/es/components/button/style/css",
// ],
// }
const component = await ctx.findComponent(name, 'component', [sfcPath])
if (component) {
const varName = `__unplugin_components_${no}`
// stringifyComponentImport() 返回需要导入的语句字符串
// `
// import { ElButton as __unplugin_components_0 } from 'element-plus/es';
// import 'element-plus/es/components/base/style/css';
// import 'element-plus/es/components/button/style/css';
// `
// s.prepend() 在源码最前面插入语句
s.prepend(`${stringifyComponentImport({ ...component, as: varName }, ctx)};\n`)
no += 1
// 替换组件名:ElButton => __unplugin_components_0
replace(varName)
}
}
debug(`^ (${no})`)
}
- 接下来我们剖析
resolveVue3
和findComponent
的过程
resolveVue3
可以看到是用正则将代码中所有组件调用匹配出来,如 “_resolveComponent(“el-button”)”,解析成需要的格式
// src/core/transforms/component.ts
function 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),
})
}
}
// [
// {
// rawName: "el-button",
// replace: (resolved) => s.overwrite(start, end, resolved),
// },
// {
// rawName: "el-rate",
// replace: (resolved) => s.overwrite(start, end, resolved),
// },
// ]
return results
}
findComponent
先看折叠的代码
// src/core/context.ts
async findComponent(name: string, type: 'component' | 'directive', excludePaths: string[] = []): Promise<ComponentInfo | undefined> {
let info = this._componentNameMap[name]
// 如果之前有解析过,且不在排除路径中,就可以直接返回缓存的结果
if (info && !excludePaths.includes(info.from) && !excludePaths.includes(info.from.slice(1)))
return info
// 如果插件有配置自定义解析器(ElementPlusResolver),则进行遍历
for (const resolver of this.options.resolvers) {
// ...
}
return undefined
}
展开看遍历逻辑
// src/core/context.ts
for (const resolver of this.options.resolvers) {
if (resolver.type !== type)
continue
// 调用自定义解析器(ElementPlusResolver)中的方法 resolver.resolve('ElButton') 获取导入组件所需信息
// {
// name: "ElButton",
// from: "element-plus/es",
// sideEffects: [
// "element-plus/es/components/base/style/css",
// "element-plus/es/components/button/style/css",
// ],
// }
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')
this.addCustomComponents(info)
else if (type === 'directive')
this.addCustomDirectives(info)
return info
}
接下来看 ElementPlusResolver
// src\core\resolvers\element-plus.ts
export function ElementPlusResolver(
options: ElementPlusResolverOptions = {},
): ComponentResolver[] {
// ...
return [
{
type: 'component',
// 这里就是上个代码块中调用的 resolver.resolve
resolve: async (name: string) => {
// ...
// 详情见下面
return resolveComponent(name, options)
},
},
{
type: 'directive',
// ...
},
]
}
function resolveComponent(name: string, options: ElementPlusResolverOptionsResolved): ComponentInfo | undefined {
// ...
// icon 组件为单独项目,不同处理,继续往下看
if (name.match(/^ElIcon.+/)) {
return {
name: name.replace(/^ElIcon/, ''),
from: '@element-plus/icons-vue',
}
}
// ...
// >=1.1.0-beta.1
if (compare(version, '1.1.0-beta.1', '>=')) {
return {
name,
// 根据引入方式选择路径
from: `element-plus/${ssr ? 'lib' : 'es'}`,
// 组件样式需要另外导入,因此还需要进一步处理,继续看下面
sideEffects: getSideEffects(partialName, options),
}
}
// ... 旧版 element plus 的兼容
}
function getSideEffects(dirName: string, options: ElementPlusResolverOptionsResolved): SideEffectsInfo | undefined {
const { importStyle, ssr } = options
const themeFolder = 'element-plus/theme-chalk'
const esComponentsFolder = 'element-plus/es/components'
// 根据 importStyle 配置返回 sass / css 资源路径
// sass 配置通常用于定制主题:
// ElementPlusResolver({
// importStyle: "sass",
// ...
// }),
if (importStyle === 'sass') {
return ssr
? [`${themeFolder}/src/base.scss`, `${themeFolder}/src/${dirName}.scss`]
: [`${esComponentsFolder}/base/style/index`, `${esComponentsFolder}/${dirName}/style/index`]
}
else if (importStyle === true || importStyle === 'css') {
// [
// "element-plus/es/components/base/style/css",
// "element-plus/es/components/button/style/css"
// ]
return ssr
? [`${themeFolder}/base.css`, `${themeFolder}/el-${dirName}.css`]
: [`${esComponentsFolder}/base/style/css`, `${esComponentsFolder}/${dirName}/style/css`]
}
}
unplugin-auto-import
功能介绍
自动按需导入调用的API,如
// 处理前
<script setup>
ElMessage('自动导入');
</script>
// 处理后等同效果
import { ElMessage } from 'element-plus'
ElMessage('自动导入');
源码分析过程与 unplugin-vue-components 类似,大家可以自行探索。
结尾
感谢阅读,由于篇幅问题,只能尽量保证完整性,如有缺漏、错误的地方请指出,欢迎大家在评论区讨论。