unimport系列——揭秘自动引入api那些事


theme: github

前言

相信很多vue开发的同学都应该听说过antfu这号人物,也使用过他开发的系列工具,比如我个人常用的vueuse vitesse unocss等,对于一些懒得引入api的开发者来说(是的,就是我),unplugin-vue-components unplugin-auto-import更是偷懒必备神器,下面我就从使用和原理两方面来浅析unplugin-auto-import插件。

介绍

unplugin-auto-import插件,为 Vite、Webpack、Rollup 和 esbuild 按需自动导入 API,支持TypeScript

使用方法

这章节我们先来简单使用下unplugin-auto-import,对于已经使用过该插件的同学可以先跳过这章节看后面的原理。

准备工程

我们先创建一个vite+vue+typescript工程

pnpm create vite
D:\project>pnpm create vite

Progress: resolved 1, reused 1, downloaded 0, added 1, done
+ √ Project name: ... unimport-fun
+ √ Select a framework: » Vue
+ √ Select a variant: » TypeScript

Scaffolding project in D:\project\unimport-fun...

Done. Now run:

  cd unimport-fun
  pnpm install
  pnpm run dev


D:\project>

pnpm install后运行工程

image.png

下面有个count按钮,对应的是src/components/HelloWorld.vue,我们查看这个组件,以下是精简后的主要代码:

<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
  <div class="card">
    <button type="button" @click="count++">count is {{ count }}</button>
  </div>
</template>

就是一个ref响应式数据count,点击按钮+1,这对于学过vue3的应该都不难,觉得理解上有困难的同学可以先去学习下vue3的基础。

引入插件

执行命令安装插件

pnpm add unplugin-auto-import

vite.config.ts配置插件

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
+ import AutoImport from 'unplugin-auto-import/vite'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
+   AutoImport()
  ]
})

unplugin-auto-import插件内置了一些预设,预设的作用是不用我们自己去配置,就能使用主流框架的api,比如我希望自动导入vue3的api,是这样使用的:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
+     imports: ['vue']
    })
  ]
})

这时候运行工程,会发现工程中多了个auto-imports.d.ts文件,内容是:

// Generated by 'unplugin-auto-import'
export {}
declare global {
  const EffectScope: typeof import('vue')['EffectScope']
  const computed: typeof import('vue')['computed']
  const createApp: typeof import('vue')['createApp']
  const customRef: typeof import('vue')['customRef']
  const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
  const defineComponent: typeof import('vue')['defineComponent']
  const effectScope: typeof import('vue')['effectScope']
  const getCurrentInstance: typeof import('vue')['getCurrentInstance']
  const getCurrentScope: typeof import('vue')['getCurrentScope']
  const h: typeof import('vue')['h']
  const inject: typeof import('vue')['inject']
  const isProxy: typeof import('vue')['isProxy']
  const isReactive: typeof import('vue')['isReactive']
  const isReadonly: typeof import('vue')['isReadonly']
  const isRef: typeof import('vue')['isRef']
  const markRaw: typeof import('vue')['markRaw']
  const nextTick: typeof import('vue')['nextTick']
  const onActivated: typeof import('vue')['onActivated']
  const onBeforeMount: typeof import('vue')['onBeforeMount']
  const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
  const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
  const onDeactivated: typeof import('vue')['onDeactivated']
  const onErrorCaptured: typeof import('vue')['onErrorCaptured']
  const onMounted: typeof import('vue')['onMounted']
  const onRenderTracked: typeof import('vue')['onRenderTracked']
  const onRenderTriggered: typeof import('vue')['onRenderTriggered']
  const onScopeDispose: typeof import('vue')['onScopeDispose']
  const onServerPrefetch: typeof import('vue')['onServerPrefetch']
  const onUnmounted: typeof import('vue')['onUnmounted']
  const onUpdated: typeof import('vue')['onUpdated']
  const provide: typeof import('vue')['provide']
  const reactive: typeof import('vue')['reactive']
  const readonly: typeof import('vue')['readonly']
  const ref: typeof import('vue')['ref']
  const resolveComponent: typeof import('vue')['resolveComponent']
  const resolveDirective: typeof import('vue')['resolveDirective']
  const shallowReactive: typeof import('vue')['shallowReactive']
  const shallowReadonly: typeof import('vue')['shallowReadonly']
  const shallowRef: typeof import('vue')['shallowRef']
  const toRaw: typeof import('vue')['toRaw']
  const toRef: typeof import('vue')['toRef']
  const toRefs: typeof import('vue')['toRefs']
  const triggerRef: typeof import('vue')['triggerRef']
  const unref: typeof import('vue')['unref']
  const useAttrs: typeof import('vue')['useAttrs']
  const useCssModule: typeof import('vue')['useCssModule']
  const useCssVars: typeof import('vue')['useCssVars']
  const useSlots: typeof import('vue')['useSlots']
  const watch: typeof import('vue')['watch']
  const watchEffect: typeof import('vue')['watchEffect']
  const watchPostEffect: typeof import('vue')['watchPostEffect']
  const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}

熟悉的人应该会发现这就是vue的所有api,这时候我们把HelloWorld.vue中引入ref的语句去掉:

<script setup lang="ts">
+ // import { ref } from 'vue'

defineProps<{ msg: string }>()

const count = ref(0)
</script>

image.png

可以看到,虽然去掉ref的import,但其实还是能正常使用ref的功能

我们这次用computed试试:

<script setup lang="ts">
// import { ref } from 'vue'

defineProps<{ msg: string }>()

const count = ref(0)
+ const doubleCount = computed(() => count.value * 2)
</script>

<template>
+   <h2>{{ doubleCount }}</h2>
  <div class="card">
    <button type="button" @click="count++">count is {{ count }}</button>
  </div>
</template>

image.png

可以看到,ref和computed都能生效

类型问题

如果是用的vscode,会发现ref和computed处会有类型报错:

image.png

作为强迫症肯定不能置之不管,其实我们只需要将刚才说的自动生成的auto-import.d.ts,改一下生成的位置,到src目录下即可,我们修改vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      imports: ['vue'],
+      dts: 'src/auto-imports.d.ts'
    })
  ]
})

image.png

可以看到,重新运行后在src目录下会生成auto-imports.d.ts,并且HelloWorld.vue组件不会报类型错误了:

image.png

这时候把根目录下的auto-imports.d.ts删掉即可。

到这里基本使用讲完啦,如果还需要更灵活的操作,可以通过查阅官方文档

原理分析

我们先把unplugin-auto-import的源码克隆到本地:

git clone https://github.com/antfu/unplugin-auto-import.git

浅看下工程目录:

image.png

我们是vite工程,所以引入的语句是import AutoImport from 'unplugin-auto-import/vite',可以看到入口文件就是这个vite.ts文件,核心处理在unplugin目录下

image.png

可以看到,最终导出一个createUnplugin方法执行的结果,这里可以做个额外拓展,这里使用的unplugin插件,是unplugin-auto-import插件能被webpack,vite,rollup,esbuild使用的关键处理,我们先不深究,我考虑后续写新文来介绍这插件(鸽 鸽 鸽

unplugin.ts文件中操作不多,主要是围绕ctx变量做个操作,我们先不看这个变量的内容,我们先理解这是一个对象,里面包含了很多方法:

let ctx = createContext(options)

export function createContext(options: Options = {}, root = process.cwd()) {
    // ...省略
    
    return {
        root,
        dirs,
        filter,
        scanDirs,
        writeConfigFiles,
        writeConfigFilesThrottled,
        transform,
        generateDTS,
        generateESLint,
      }
}

我们先来解析下createUnplugin中返回的对象的各个属性的含义:

属性 含义
name 插件的名字
enforce 控制插件的执行时机,这里的post是指在插件流的靠后执行
transformInclude 表示哪些文件需要进行转换,用正则表达式控制,在这里是执行ctx.filter(id)方法,其中这个id是指文件名
transform 执行代码转换处理,可以根据你的设定来令到代码发生改变,比如我希望代码中的var全部变成let,就可以在这里进行处理(简单举个例子,没实现过)
buildStart 在执行build操作的时候执行,在这里是执行ctx.scanDirs()方法
buildEnd 在build操作执行完成后,生成打包文件前执行,在这里是执行ctx.writeConfigFiles()方法
vite 针对vite工程的特定钩子,也就是说在这里能使用vite特有的钩子函数,在使用vite工程时会一起执行

实例详解

我们以文章中的demo工程作为例子讲解,文本尽量做到通俗易懂。

vite.config.ts中,我们使用插件的姿势是:

import { defineConfig } from 'vite'
import AutoImport from 'unplugin-auto-import/vite'

export default defineConfig({
  plugins: [
    AutoImport({
      imports: ['vue'],
      dts: 'src/auto-imports.d.ts'
    })
  ]
})

所以插件中的options是:

// userOptions
const options = {
  imports: ['vue'],
  dts: 'src/auto-imports.d.ts'
}

...
// plugin usage
export default createUnplugin<Options>((options) => {
    let ctx = createContext(options)
})

我们重点看ctx的处理过程,希望看到这里的同学可以打开源码跟着我一起看下面的内容,不方便打开或者不想打开的也没关系,在最后我会把ctx生成的处理代码贴到文章最后

ctx实例细节分析

第一部分,处理预设

export function flattenImports(map: Options['imports'], overriding = false): Import[] {
  const flat: Record<string, Import> = {}

  /**
   * map = ['vue']
   */

  toArray(map).forEach((definition) => {
    if (typeof definition === 'string') {
      if (!presets[definition])
        throw new Error(`[auto-import] preset ${definition} not found`)
      const preset = presets[definition]
      definition = typeof preset === 'function' ? preset() : preset
    }

    /**
     * definition = {
     *    vue: [
     *      'ref',
     *      'computed'
     *    ]
     *  }
     */

    for (const mod of Object.keys(definition)) {
      // mod = vue

      for (const id of definition[mod]) {
        /**
         * id=ref
         * id=computed
         */

        const meta = {
          from: mod,
        } as Import
        let name: string
        if (Array.isArray(id)) {
          name = id[1]
          meta.name = id[0]
          meta.as = id[1]
        }
        else {
          name = id
          meta.name = id
          meta.as = id
        }

        /**
         * meta = {
         *  from: vue,
         *  name: ref,
         *  as: ref
         * }
         */

        // 用flat的原因是这里,避免重复引入
        if (flat[name] && !overriding)
          throw new Error(`[auto-import] identifier ${name} already defined with ${flat[name].from}`)

        flat[name] = meta

        /**
         * flat = {
         *  ref: {
         *    from: vue,
         *    name: ref,
         *    as: ref
         *  }
         * }
         */
      }
    }
  })

  /**
   * 循环完之后
   * flat = {
   *  ref: {
   *    from: vue,
   *    name: ref,
   *    as: ref
   *  },
   *  computed: {
   *    from: vue,
   *    name: computed,
   *    as: computed
   *  }
   * }
   */

  return Object.values(flat)
}

export function createContext(options: Options = {}, root = process.cwd()) {
  const imports = flattenImports(options.imports, options.presetOverriding)
  // ...
}

imports变量就是我们配置的预设,执行完flattenImprts处理后值为:

imports = [
 {
   from: vue,
   name: ref,
   as: ref
 },
 {
   from: vue,
   name: computed,
   as: computed
 }
]

第二部分,生成类型文件.d.ts的路径

export function createContext(options: Options = {}, root = process.cwd()) {
  /**
   * isPackageExists => _require.resolve(name, options) => require.resolve('typescript')
   * 检查工程中是否有typescript
   */
  const {
    dts: preferDTS = isPackageExists('typescript'),
  } = options
  
  // ...
  
  /**
   * dts文件生成路径,如果不传,默认生成在根目录下
   * 所以如果想要生成在src下,直接传入'./src'即可,因为这里帮你resolve处理了
   */
  const dts = preferDTS === false
    ? false
    : preferDTS === true
      ? resolve(root, 'auto-imports.d.ts')
      : resolve(root, preferDTS)
  
  // ...
  
  // 生成.d.ts处理
  function generateDTS(file: string) {
    const dir = dirname(file)
    return unimport.generateTypeDeclarations({
      resolvePath: (i) => {
        if (i.from.startsWith('.') || isAbsolute(i.from)) {
          const related = slash(relative(dir, i.from).replace(/\.ts(x)?$/, ''))
          return !related.startsWith('.')
            ? `./${related}`
            : related
        }
        return i.from
      },
    })
}

这里会先检查工程中是否有用typescript,如果有就会生成.d.ts类型文件,这样就能避免产生一些类型报错,比如xxx is not defined

第三部分,生成.eslintrc配置处理

export function createContext(options: Options = {}, root = process.cwd()) {
  // ...

  const eslintrc: ESLintrc = options.eslintrc || {}
  eslintrc.enabled = eslintrc.enabled === undefined ? false : eslintrc.enabled
  eslintrc.filepath = eslintrc.filepath || './.eslintrc-auto-import.json'
  eslintrc.globalsPropValue = eslintrc.globalsPropValue === undefined ? true : eslintrc.globalsPropValue
  /**
   * eslintrc = {
   *  enabled: false,
   *  filepath: './eslintrc-auto-import.json',
   *  globalsPropValue: true
   * }
   */
   
   // ...
   // 生成.eslintrc处理
  async function generateESLint() {
    return generateESLintConfigs(await unimport.getImports(), eslintrc)
  }
}

这里配置eslint的相关配置,默认是不会生成

第四部分,核心功能,创建unimport实例

export function createContext(options: Options = {}, root = process.cwd()) {
  // ...
  /**
   * 核心功能,创建unimport实例,将上面预设好的imports传入
   * 在生成的d.ts文件最后,加上// Generated by 'unplugin-auto-import'\n
   */
  const unimport = createUnimport({
    imports: imports as Import[],
    presets: [],
    addons: [
      ...(options.vueTemplate ? [vueTemplateAddon()] : []),
      resolversAddon(resolvers),
      {
        declaration(dts) {
          if (!dts.endsWith('\n'))
            dts += '\n'
          return `// Generated by 'unplugin-auto-import'\n${dts}`
        },
      },
    ],
  })
}

unimport是这个插件的核心内容,传入我们设定的预设imports,初始化unimport实例

原本自动注入功能是直接写在unplugin-auto-import里的,后续又将这部分功能抽取出一个更底层的插件unimport,也是这系列文章的核心,更具体的解析我会在下一篇文章中讲解,现在只需要理解成这是一个能自动注入的插件即可

第五部分,过滤器,用于识别文件是否自动注入目标文件

export function createContext(options: Options = {}, root = process.cwd()) {
  // ...  
  
  /**
   * 创建正则过滤文件,如果不传,默认排查node_modules和.git目录
   * 默认检查js jsx ts tsx vue svelte文件是否需要自动import
   * 最终生成filter是一个(id) => boolean函数,id为文件名,符合配置的返回true
   */
  const filter = createFilter(
    options.include || [/\.[jt]sx?$/, /\.vue$/, /\.vue\?vue/, /\.svelte$/],
    options.exclude || [/[\\/]node_modules[\\/]/, /[\\/]\.git[\\/]/],
  )
  
  // ...
}

filter的作用就是过滤需要自动注入的文件类型,默认是包含js jsx ts tsx vue svelte类型文件,可以通过include属性来控制注入文件类型,比如我只希望vue组件进行注入,那么就可以写成:

export default defineConfig({
  plugins: [
    AutoImport({
      include: [/\.vue$/]
    })
  ]
})

第六部分,生成配置文件方法节流处理

export function createContext(options: Options = {}, root = process.cwd()) {
// 节流处理dts和eslintrc文件的生成
  const writeConfigFilesThrottled = throttle(500, writeConfigFiles, { noLeading: false })

  let lastDTS: string | undefined
  let lastESLint: string | undefined
  async function writeConfigFiles() {
    const promises: any[] = []
    if (dts) {
      promises.push(
        generateDTS(dts).then((content) => {
          if (content !== lastDTS) {
            lastDTS = content
            return fs.writeFile(dts, content, 'utf-8')
          }
        }),
      )
    }
    if (eslintrc.enabled && eslintrc.filepath) {
      promises.push(
        generateESLint().then((content) => {
          if (content !== lastESLint) {
            lastESLint = content
            return fs.writeFile(eslintrc.filepath!, content, 'utf-8')
          }
        }),
      )
    }
    return Promise.all(promises)
  }
}

这部分需要结合第二和第三部分一起看,就是在生成配置文件的时候加上节流处理,能降低文件更新频率

第七部分,开始注入代码

export function createContext(options: Options = {}, root = process.cwd()) {
  // ...
  
  // 开始执行代码注入
  async function transform(code: string, id: string) {
    const s = new MagicString(code)

    // unimport实例中,已经包含我们配置的imports,这里injectImports就是将配置的imports注入到代码中
    // s就是每个命中文件的源码,比如HelloWorld.vue
    await unimport.injectImports(s, id)

    if (!s.hasChanged())
      return

    // 注入后节流生成配置文件
    writeConfigFilesThrottled()

    return {
      code: s.toString(),
      map: s.generateMap({ source: id, includeContent: true }),
    }
  
  // ...
}

这个方法中,入参code就是命中filter规则的文件,比如demo中的HelloWorld.vue文件

重点处理是执行配置好了的unimport实例的injectImports方法将api注入到s中,s就是经过MagicString处理过的HelloWorld.vue的源码,也是string,但操作api会更友好,感兴趣的可以点击这里看MagicString

经过这一步处理,虽然你的文件中没有手动引入api,但其实已经存在于代码中了,所以你就能使用自动引入的api了

回到插件本身

经过上面的分析,相信对ctx这个实例有一定的了解,是自动引入api的核心实例,插件的全部操作基本上都是围绕该实例进行的处理,我们回到插件内容,来看各个时期执行的操作

第一部分,enforce

export default createUnplugin<Options>((options) => {
  let ctx = createContext(options)
  return {
      enforce: 'post'
  }
}

enforce控制插件在插件流的执行位置,post表示该插件在插件流靠后部分执行,如果只有这个插件是post,那么就是最后执行的插件

因为是post在靠后执行,所以此时的命中代码已经被处理成js代码,此时将配置的api插入到代码的头部即可,这就是unimport的主要处理过程,会放在下一篇进行分析

第二部分,transformInclude

export default createUnplugin<Options>((options) => {
  let ctx = createContext(options)
  return {
      transformInclude(id) {
        return ctx.filter(id)
      },
  }
}

这里会调用ctx.filter方法,结合上面ctx分析第五部分,ctx.filter返回的是一个入参为id,返回值为boolean的函数,如果id符合条件,则返回true,否则false

transformInclude的入参id是文件名,比如main.ts App.vue HelloWorld.vue等,如果钩子返回true,则表示该文件需要进行transform钩子处理,如果我们AutoImport插件不传入include属性,默认会处理.vue.ts文件,所以这三个文件都会注入api

第三部分,transform

export default createUnplugin<Options>((options) => {
  let ctx = createContext(options)
  return {
      async transform(code, id) {
        return ctx.transform(code, id)
      },
  }
}

这里会调用ctx.transform方法,结合上面ctx分析第七部分,这里会调用unimport插件来完成api注入

到这步为止,我们也就能使用上unplugin-auto-import插件,并且生成d.ts类型文件了

总结

unplugin-auto-import为 Vite、Webpack、Rollup 和 esbuild 按需自动导入 api,我们可以使用该插件并且配置上内置的预设,达到不用手动引入vue或者react的api,也能正常运行工程并使用api。

unplugin-auto-import插件的自动注入api功能是使用了unimport插件,关于unimport插件的具体实现我们留到下一篇讲解。

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容