15
头图

随着 Vue3 生态的不断扩展与日渐成熟,Vue3 已从最开始的尝鲜阶段步入到投入生产项目中。随之而来的还有开发脚手架的更新换代,全新的 Vite 脚手架,基于 esbuild 利用 go 语言的性能优势,相较 Webpack 有着不在一个量级的性能优势,打包方面基于 Rollup 拓展,继承了轻量化和明朗的插件 Api 的优点。

什么,你还不知道?你该抓紧了。

Vue3 官方中文文档

Vite 官方中文文档

废话不多说,开始进入的正题。

创建项目

本文重点讲述如何生成类型声明文件,因此项目创建部分只一些简单描述。

通过官方提供的模版快速搭建一个简单的项目:

yarn create @vitejs/app my-vue-app --template vue-ts

随后更名 src/main.tssrc/index.ts 并修改其内容:

export { default as App } from './App.vue'
不要在意 App 这个名字,我们只是假设我们写了一个组件,并且作为插件导出。

接着调整 vite.config.ts 的配置为库模式打包:

import { resolve } from 'path'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      name: 'Plugin',
      formats: ['es'],
      fileName: 'index'
    }
  },
  plugins: [vue()]
})

至此,一个简单的插件项目就完成了。

生成类型声明文件

以前在开发 Rollup 插件时,我们主要借助 rollup-plugin-typescript2 这个插件来实现根据源码生成 .d.ts 声明文件。

但是该插件存在几个问题,一是无法解析 .vue 文件,二是在 Vite + Vue3 的环境下,存在不兼容性(三是 Vite 内部支持 typescript,该插件存在很大部分的重复功能),说白了就是不太靠谱。

当然,也有人在 issue 中提出希望 Vite 内部支持在库模式打包时导出声明文件,但 Vite 官方表示不希望因此增加维护的负担和结构的复杂性。

因此在 Vite 开发中,我们要想一些其他办法来生成声明文件。

本文介绍的生成方式还是依赖一些现成的库,然后通过一些编程脚本以达到目的,毕竟从打包原理开始讲,那篇幅可能不太够。

安装生成声明文件用到的库:

yarn add ts-morph -D

其实 .vue 文件想要生成类型声明文件的核心点在于把 <script> 部分的内容提取出来进行解析,当明白了这个原理后,其实很多东西就很简单了。

新建 scripts/build-types.js 后开始编写我们的脚本。

const path = require('path')
const fs = require('fs')
const glob = require('fast-glob')
const { Project } = require('ts-morph')
const { parse, compileScript } = require('@vue/compiler-sfc')

let index = 1

main()

async function main() {
  // 这部分内容具体可以查阅 ts-morph 的文档
  // 这里仅需要知道这是用来处理 ts 文件并生成类型声明文件即可
  const project = new Project({
    compilerOptions: {
      declaration: true,
      emitDeclarationOnly: true,
      noEmitOnError: true,
      allowJs: true, // 如果想兼容 js 语法需要加上
      outDir: 'dist' // 可以设置自定义的打包文件夹,如 'types'
    },
    tsConfigFilePath: path.resolve(__dirname, '../tsconfig.json'),
    skipAddingFilesFromTsConfig: true
  })

  // 获取 src 下的 .vue 和 .ts 文件
  const files = await glob(['src/**/*.ts', 'src/**/*.vue'])
  const sourceFiles = []

  await Promise.all(
    files.map(async file => {
      if (/\.vue$/.test(file)) {
        // 对于 vue 文件,借助 @vue/compiler-sfc 的 parse 进行解析
        const sfc = parse(await fs.promises.readFile(file, 'utf-8'))
        // 提取出 script 中的内容
        const { script, scriptSetup } = sfc.descriptor

        if (script || scriptSetup) {
          let content = ''
          let isTs = false

          if (script && script.content) {
            content += script.content

            if (script.lang === 'ts') isTs = true
          }

          if (scriptSetup) {
            const compiled = compileScript(sfc.descriptor, {
              id: `${index++}`
            })

            content += compiled.content

            if (scriptSetup.lang === 'ts') isTs = true
          }

          sourceFiles.push(
            // 创建一个同路径的同名 ts/js 的映射文件
            project.createSourceFile(file + (isTs ? '.ts' : '.js'), content)
          )
        }
      } else {
        // 如果是 ts 文件则直接添加即可
        sourceFiles.push(project.addSourceFileAtPath(file))
      }
    })
  )

  const diagnostics = project.getPreEmitDiagnostics()

  // 输出解析过程中的错误信息
  console.log(project.formatDiagnosticsWithColorAndContext(diagnostics))

  project.emitToMemory()

  // 随后将解析完的文件写道打包路径
  for (const sourceFile of sourceFiles) {
    const emitOutput = sourceFile.getEmitOutput()

    for (const outputFile of emitOutput.getOutputFiles()) {
      const filePath = outputFile.getFilePath()

      await fs.promises.mkdir(path.dirname(filePath), { recursive: true })
      await fs.promises.writeFile(filePath, outputFile.getText(), 'utf8')
    }
  }
}

package.json 添加一个打包类型文件的命令:

{
  "scripts": {
    "build:types": "node scripts/build-types.js"
  }
}

在项目根路径下,执行以下命令:

yarn run build:types

大功告成,可以看到 dist 目录下已经有了 index.d.tsApp.vue.d.ts 等类型声明文件。

Vite 插件

其实,在 Vite 打包的过程中,@vitejs/plugin-vue 插件会将 .vue 文件编译并拆分成三个部分,包括模版,脚本和样式;我们只需要拿到编译后的脚本部分的内容,通过上面的方法,甚至不需要自己编译文件,就可以轻松生成类型声明文件。

创建 plugins/dts.ts,开始撸代码:

import { resolve, dirname } from 'path'
import fs from 'fs/promises'
import { createFilter } from '@rollup/pluginutils'
import { normalizePath } from 'vite'
import { Project } from 'ts-morph'

import type { Plugin } from 'vite'
import type { SourceFile } from 'ts-morph'

export default (): Plugin => {
  const filter = createFilter(['**/*.vue', '**/*.ts'], 'node_modules/**')
  const sourceFiles: SourceFile[] = []
  
  const project = new Project({
    compilerOptions: {
      declaration: true,
      emitDeclarationOnly: true,
      noEmitOnError: true,
      allowJs: true, // 如果想兼容 js 语法需要加上
      outDir: 'dist' // 可以设置自定义的打包文件夹,如 'types'
    },
    tsConfigFilePath: resolve(__dirname, '../tsconfig.json'),
    skipAddingFilesFromTsConfig: true
  })

  let root: string
  
  return {
    name: 'gen-dts',
    apply: 'build',
    enforce: 'pre', // 需要在 pre 才能正确拿到 ts 的 script 部分
    configResolved(config) {
      root = config.root
    },
    transform(code, id) {
      if (!code || !filter(id)) return null

      // 拆分后的文件 id 具有一些特征,可以用正则的方式来捕获
      if (/\.vue(\?.*type=script.*)$/.test(id)) {
        const filePath = resolve(root, normalizePath(id.split('?')[0]))

        sourceFiles.push(
          project.createSourceFile(filePath + (/lang.ts/.test(id) ? '.ts' : '.js'), code)
        )
      } else if (/\.ts$/.test(id)) {
        const filePath = resolve(root, normalizePath(id))

        sourceFiles.push(project.addSourceFileAtPath(filePath))
      }
    },
    async generateBundle() {
      const diagnostics = project.getPreEmitDiagnostics()

      // 输出解析过程中的错误信息
      console.log(project.formatDiagnosticsWithColorAndContext(diagnostics))

      project.emitToMemory()

      // 随后将解析完的文件写道打包路径
      for (const sourceFile of sourceFiles) {
        const emitOutput = sourceFile.getEmitOutput()

        for (const outputFile of emitOutput.getOutputFiles()) {
          const filePath = outputFile.getFilePath()

          await fs.mkdir(dirname(filePath), { recursive: true })
          await fs.writeFile(filePath, outputFile.getText(), 'utf8')
        }
      }
    }
  }
}

如此轻松,一个简单的 dts 插件就完成了。

我们只需要在 vite.config.ts 中引用插件:

import { resolve } from 'path'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from './plugins/dts'

export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      name: 'Plugin',
      formats: ['es'],
      fileName: 'index'
    }
  },
  plugins: [vue(), dts()]
})

然后执行原来的命令,就可以看到打包和生成类型声明文件一气呵成了:

yarn run build

image.png

写在最后

当然了,上述插件只包含了最基础的功能,笔者自己写了一个涵盖功能更加广泛的插件,源码已放在 github 上,同时 npm 也进行了发布。

yarn add vite-plugin-dts -D

欢迎大家进行使用和反馈,如果觉得这对你有所帮助,还请点赞、收藏,和赏一颗⭐。

插件地址:https://github.com/qmhc/vite-plugin-dts

最后偷偷安利一下自己撸的 Vue3 组件库,目前主要给公司项目使用中:vexip-ui

2021-10-25 补充

在几个月的更新中,setup-script 已成为正式特性,为此 compiler-sfc 的一些特性也发生了变化,在实际使用中和原来发生了出入,因此上文中有部分代码可能不完全正确,但插件 vite-plugin-dts 一直保持着更新与优化,如果有兴趣深入了解欢迎来阅读插件源码~

2023-07-24 补充

经过这些时间的发展,Volar 已能够比较好的生成 Vue 文件的类型文件,插件底层也替换为 Volar,如需生成类型文件请直接使用 vite-plugin-dts 插件,如果遇到问题可随时提交 issue。


未觉雨声
1.5k 声望69 粉丝

Vue3 组件库 VexipUI 作者,擅长 js 和 vue 系列技术,主攻前端(交互),稍微会一点点 Java(Spring Boot)。