扒一扒unplugin自动按需导入的源码
前言
你是否经常在各大ui组件引导中看到 unplugin-vue-components
和 unplugin-auto-import
这两个插件。
- 在element-plus指引中,你会看到
- 在vant-ui中,可以看到
- 在tdesign中,也能看到它们两兄弟的身影
用上它们,你的项目可以自动为你导入所需的依赖, 不需要显示的写import语句来导入依赖,这是一个非常方便的功能,但是你有没有想过,它是如何实现的?
unplugin
他们的前缀都是unplugin,unplugin 的官网是这样介绍它的:
Unplugin is a library that offers an unified plugin system for various build tools. It extends the excellent Rollup plugin API to serve as the standard plugin interface, and provides a compatibility layer based on the build tools employed. Unplugin current supports:
Vite Rollup webpack esbuild Rspack Rolldown Farm
简单来说,unplugin是一个可以帮助你使用一套代码开发插件,却可以运行在不同的打包工具中,他会根据你编写的插件代码,给你转换成不同的打包工具的插件写法。
假如我编写一个打包插件:
const { createUnplugin } = require('unplugin');
const removeConsolePlugin = createUnplugin(() => {
return {
name: 'remove-console-plugin',
transform(code) {
// 移除所有 console.log 语句
return code.replace(/console\.log\([^)]*\);?/g, '');
},
};
});
module.exports = removeConsolePlugin;
createUnplugin的源码是,他会帮我们生成这些打包工具的插件getter,直接取用。
export function createUnplugin<UserOptions, Nested extends boolean = boolean>(
factory: UnpluginFactory<UserOptions, Nested>,
): UnpluginInstance<UserOptions, Nested> {
return {
get esbuild() {
return getEsbuildPlugin(factory)
},
get rollup() {
return getRollupPlugin(factory)
},
get vite() {
return getVitePlugin(factory)
},
get rolldown() {
return getRolldownPlugin(factory)
},
get webpack() {
return getWebpackPlugin(factory)
},
get rspack() {
return getRspackPlugin(factory)
},
get farm() {
return getFarmPlugin(factory)
},
get unloader() {
return getUnloaderPlugin(factory)
},
get raw() {
return factory
},
}
}
最终 createUnplugin()
函数执行后会返回一个这样的结构的对象
{
webpack:()=>{...wbpack格式插件对象...},
vite:()=>{...vite格式插件对象...},
rollup:()=>{...rollup格式插件对象...},
...其他格式插件对象...
}
假如我使用vite,我可以这样使用它:
const { defineConfig } = require('vite');
const removeConsolePlugin = require('./remove-console-plugin');
module.exports = defineConfig({
plugins: [
removeConsolePlugin.vite()
]
});
你现在是不是想,我来看自动导入,你跟我谈什么统一插件平台?
朋友别急,上面的unplugin-vue-components
和 unplugin-auto-import
也不过就是基于unplugin开发的两个打包工具插件而已,因为基于unplugin所以
这两个插件可以用在支持的这些打包工具上,这就是上面那些ui组件会给你提供webpack、vite、rollup等打包工具该怎么配置的示例。
既然他们两也只是个打包工具的插件,那我们来看看 unplugin-auto-import
是怎么工作的
扒一扒这两兄弟的源码
插件的配置
这里拿element-plus
来举例,官网引导配置是这样的:
首先你需要安装unplugin-vue-components 和 unplugin-auto-import这两款插件
npm install -D unplugin-vue-components unplugin-auto-import
然后把下列代码插入到你的 Vite 或 Webpack 的配置文件中
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()],
}),
],
})
const AutoImport = require('unplugin-auto-import/webpack')
const Components = require('unplugin-vue-components/webpack')
const { ElementPlusResolver } = require('unplugin-vue-components/resolvers')
module.exports = {
// ...
plugins: [
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
}
做了这两部之后,你可以在你的代码中删除跟element-plus相关的import语句,包括组件和一些函数式的引用。
没引用之前你的代码是这样的:
<template>
<el-button>I am ElButton</el-button>
</template>
<script>
import { ElButton } from 'element-plus'
import { ElMessage } from 'element-plus'
export default {
components: { ElButton },
mounted(){
ElMessage.success({...});
}
}
</script>
自动导入之后你的代码是这样的:
<template>
<el-button>I am ElButton</el-button>
</template>
<script>
export default {
mounted(){
ElMessage.success({...});
}
}
</script>
读一读unplugin-auto-import的源码
上面2个插件,我们挑一个unplugin-auto-import
出来扒一扒源码,通常像vue、vuex、react等这些依赖的导入都可以通过这个插件来自动导入,而上面的例子中
ElMessage和ElButton都是通过unplugin-vue-components
来导入的
1. resolver
首先入口是插件,并传入一些option配置,配置就是一个resolver
AutoImport({
resolvers: [ElementPlusResolver()],
// 自动导入配置
imports: [
// presets
'vue',
'vue-router',
// custom
{
'@vueuse/core': [
// named imports
'useMouse', // import { useMouse } from '@vueuse/core',
// alias
['useFetch', 'useMyFetch'], // import { useFetch as useMyFetch } from '@vueuse/core',
],
'axios': [
// default imports
['default', 'axios'], // import { default as axios } from 'axios',
],
'[package-name]': [
'[import-names]',
// alias
['[from]', '[alias]'],
],
},
// example type import
{
from: 'vue-router',
imports: ['RouteLocationRaw'],
type: true,
},
],
}),
我们先来看看这个resolver所在的包 ElementPlusResolver
export function ElementPlusResolver(
options: ElementPlusResolverOptions = {},
): ComponentResolver[] {
....省略代码...
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())
},
},
]
}
他主要是2个函数一个 resolveComponent
和 resolveDirective
function resolveComponent(name: string, options: ElementPlusResolverOptionsResolved): ComponentInfo | undefined {
if (options.exclude && name.match(options.exclude))
return
if (!name.match(/^El[A-Z]/))
return
if (name.match(/^ElIcon.+/)) {
return {
name: name.replace(/^ElIcon/, ''),
from: '@element-plus/icons-vue',
}
}
const partialName = kebabCase(name.slice(2))// ElTableColumn -> table-column
const { version, ssr, nightly } = options
// >=1.1.0-beta.1
if (compare(version, '1.1.0-beta.1', '>=') || nightly) {
return {
name,
from: `${nightly ? '@element-plus/nightly' : 'element-plus'}/${ssr ? 'lib' : 'es'}`,
sideEffects: getSideEffects(partialName, options),
}
}
// >=1.0.2-beta.28
else if (compare(version, '1.0.2-beta.28', '>=')) {
return {
from: `element-plus/es/el-${partialName}`,
sideEffects: getSideEffectsLegacy(partialName, options),
}
}
// for <=1.0.1
else {
return {
from: `element-plus/lib/el-${partialName}`,
sideEffects: getSideEffectsLegacy(partialName, options),
}
}
}
function resolveDirective(name: string, options: ElementPlusResolverOptionsResolved): ComponentInfo | undefined {
if (!options.directives)
return
const directives: Record<string, { importName: string, styleName: string }> = {
Loading: { importName: 'ElLoadingDirective', styleName: 'loading' },
Popover: { importName: 'ElPopoverDirective', styleName: 'popover' },
InfiniteScroll: { importName: 'ElInfiniteScroll', styleName: 'infinite-scroll' },
}
const directive = directives[name]
if (!directive)
return
const { version, ssr, nightly } = options
// >=1.1.0-beta.1
if (compare(version, '1.1.0-beta.1', '>=') || nightly) {
return {
name: directive.importName,
from: `${nightly ? '@element-plus/nightly' : 'element-plus'}/${ssr ? 'lib' : 'es'}`,
sideEffects: getSideEffects(directive.styleName, options),
}
}
}
就像vantui、tdesign其实也是类似的,resolver主要的作用就是对一些特定的api和某个关键词开头的组件进行识别,比如上方就识别了
api(Loading、Popover等),以及El开头的组件 if (!name.match(/^El[A-Z]/))
,有兴趣你还可以看看其他的ui组件的resolver,里面的逻辑都是
类似的。
resolver只是识别某个组件是否需要按需导入,导入的逻辑还需要继续扒一扒
2. auto-import插件源码
上半部分用createUnplugin函数创建了一个简单的插件,这就是auto-import插件的入口源码
import type { Options } from '../types'
import path from 'node:path'
import { slash } from '@antfu/utils'
import { isPackageExists } from 'local-pkg'
import pm from 'picomatch'
import { createUnplugin } from 'unplugin'
import { createContext } from './ctx'
export default createUnplugin<Options>((options) => {
let ctx = createContext(options)
return {
name: 'unplugin-auto-import',
enforce: 'post',
transformInclude(id) {
return ctx.filter(id)
},
async transform(code, id) {
return ctx.transform(code, id)
},
async buildStart() {
await ctx.scanDirs()
},
async buildEnd() {
await ctx.writeConfigFiles()
},
vite: {
async config(config) {
if (options.viteOptimizeDeps === false)
return
const exclude = config.optimizeDeps?.exclude || []
const imports = new Set((await ctx.unimport.getImports()).map(i => i.from).filter(i => i.match(/^[a-z@]/) && !exclude.includes(i) && isPackageExists(i)))
if (!imports.size)
return
return {
optimizeDeps: {
include: [...imports],
},
}
},
async handleHotUpdate({ file }) {
const relativeFile = path.relative(ctx.root, slash(file))
if (ctx.dirs?.some(dir => pm.isMatch(slash(relativeFile), slash(typeof dir === 'string' ? dir : dir.glob))))
await ctx.scanDirs()
},
async configResolved(config) {
if (ctx.root !== config.root) {
ctx = createContext(options, config.root)
await ctx.scanDirs()
}
},
},
}
})
这里会把resolver接收之后放到ctx中,所以核心的逻辑基本上都在ctx.ts 文件中
最关键的代码就是transform,这里会对经过resolver识别的组件、依赖使用unimport包中的逻辑来注入应该自动import的代码,code参数就是某个组件文件的源码
imports就是使用AutoImport组件时配置的
const unimport = createUnimport({
imports: [],
presets: options.packagePresets?.map(p => typeof p === 'string' ? { package: p } : p) ?? [],
dirsScanOptions: {
...dirsScanOptions,
cwd: root,
},
dirs,
injectAtEnd,
parser: options.parser,
addons: {
addons: [
resolversAddon(resolvers),// 你resolver识别的导入在这里
{
name: 'unplugin-auto-import:dts',
declaration(dts) {
return `${`
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
${dts}`.trim()}\n`
},
},
],
vueDirectives,
vueTemplate,
},
})
const importsPromise = flattenImports(options.imports)
.then((imports) => {
if (!imports.length && !resolvers.length && !dirs?.length)
console.warn('[auto-import] plugin installed but no imports has defined, see https://github.com/antfu/unplugin-auto-import#configurations for configurations')
const compare = (left: string | undefined, right: NonNullable<(Options['ignore'] | Options['ignoreDts'])>[number]) => {
return right instanceof RegExp
? right.test(left!)
: right === left
}
options.ignore?.forEach((name) => {
const i = imports.find(i => compare(i.as, name))
if (i)
i.disabled = true
})
options.ignoreDts?.forEach((name) => {
const i = imports.find(i => compare(i.as, name))
if (i)
i.dtsDisabled = true
})
return unimport.getInternalContext().replaceImports(imports)
})
async function transform(code: string, id: string) {
await importsPromise
const s = new MagicString(code)
await unimport.injectImports(s, id)
if (!s.hasChanged())
return
writeConfigFilesThrottled()
return {
code: s.toString(),
map: s.generateMap({ source: id, includeContent: true, hires: true }),
}
}
这个文件中也包含生成dts文件、eslint规则来防止静态检查错误提示:
async function writeConfigFiles() {
const promises: any[] = []
if (dts) {
promises.push(
generateDTS(dts).then((content) => {
if (content !== lastDTS) {
lastDTS = content
return writeFile(dts, content)
}
}),
)
}
if (eslintrc.enabled && eslintrc.filepath) {
const filepath = eslintrc.filepath
promises.push(
generateESLint().then(async (content) => {
if (filepath.endsWith('.cjs'))
content = `module.exports = ${content}`
else if (filepath.endsWith('.mjs') || filepath.endsWith('.js'))
content = `export default ${content}`
content = `${content}\n`
if (content.trim() !== lastESLint?.trim()) {
lastESLint = content
return writeFile(eslintrc.filepath!, content)
}
}),
)
}
if (biomelintrc.enabled) {
promises.push(
generateBiomeLint().then((content) => {
if (content !== lastBiomeLint) {
lastBiomeLint = content
return writeFile(biomelintrc.filepath!, content)
}
}),
)
}
if (dumpUnimportItems) {
promises.push(
unimport.getImports().then((items) => {
if (!dumpUnimportItems)
return
const content = JSON.stringify(items, null, 2)
if (content !== lastUnimportItems) {
lastUnimportItems = content
return writeFile(dumpUnimportItems, content)
}
}),
)
}
return Promise.all(promises)
}
最后
整个插件代码不是特别复杂,核心逻辑全部都在ctx.ts 文件中, 有兴趣的朋友可以慢慢去读一下。