Skip to Content
全部文章前端大杂烩扒一扒unplugin自动按需导入的源码

扒一扒unplugin自动按需导入的源码

前言

你是否经常在各大ui组件引导中看到 unplugin-vue-componentsunplugin-auto-import 这两个插件。

  • 在element-plus指引中,你会看到

1

  • 在vant-ui中,可以看到

1

  • 在tdesign中,也能看到它们两兄弟的身影

1

用上它们,你的项目可以自动为你导入所需的依赖, 不需要显示的写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-componentsunplugin-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 的配置文件中

vite.config.js
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()], }), ], })
webpack.config.js
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个函数一个 resolveComponentresolveDirective

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文件中, 有兴趣的朋友可以慢慢去读一下。

最后编辑于

hi