• 作者:老汪软件技巧
  • 发表时间:2024-08-31 21:02
  • 浏览量:

当你用vue + ts 开发一个vue组件库时,你需要在打包产物中包含类型声明文件,vite提供了丰富的打包功能,但是却没有提供生成类型声明文件的功能(其实也有相关的插件,如:vite-plugin-dts),使用tsc可以实现.d.ts的生成,但是无法为vue组件生成.d.ts

看了一下element-plus的源码以及 这篇文章,他们采用的方案都是提取vue文件中的ts代码,使用ts-morph进行打包,这种方案虽然可行,可是还是比较麻烦,但是 这篇文章中有提到,大佬将生成.d.ts文件的功能作为vite插件上传到了npm(vite-plugin-dts)

注:vite-plugin-dts后来改用vue-tsc来生成.d.ts文件,我最终参考了该方案,自己用vue-tsc写了生成.d.ts的逻辑接下来,该文章中提到的各种工具,都还在不停的进行迭代,所以你需要特别注意他们的版本,你可以参考示例,校对你使用的各种工具版本是否相同。文章中部分描述得不够清楚的地方,也能从示例中找到。如何编写生成.d.ts的脚本?为什么是脚本?

vue-tsc提供了和tsc相同功能的命令:vue-tsc,以下是一个示例

# 目录结构
│  package.json
│  pnpm-lock.yaml
│  tsconfig.json
│
├─dist # 打包产物
│
└─src # 源码

# 命令
vue-tsc

// tsconfig.json
{
  "include": [
    "./src"
  ],
  "compilerOptions": {
    "outDir": "./dist",
    "baseUrl": ".",
    "lib": [
      "ESNext",
      "dom"
    ],
    "target": "ES5",
    "declaration": true,
    "emitDeclarationOnly": true,
    "noEmitOnError": false,
    "module": "ES6",
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "forceConsistentCasingInFileNames": true,
    "removeComments": true,
    "strict": true,
    "moduleResolution": "node"
  }
}

你可以直接在package.json的scripts中拼接vue-tsc命令。但是,作为一个“库”,这种方案并不够“优雅”(也不够灵活,你可能需要对生成的文件做其他处理,后面会提到)

作为一个“库”,打包涉及多个流程,我自己是使用gulp对各个流程做管理。既然用上了gulp,那就把vue-tsc作为一个task

import {exec}    from "child_process"
export function tsc() {
  return new Promise((resolve, reject) => {
    exec("vue-tsc", (error, stdout, stderr) => {
      if (error) reject(error)
      else if (stderr) reject(stderr)
      else resolve(stdout)
    })
  })
}

这样做,虽然把vue-tsc放到了gulp中,但依然不够优雅,其实,vue-tsc提供了一些Api,可以让你编程式地生成.d.ts

像这样:

import path, {resolve} from "path"
// 注意:你可能会发现vue-tsc没有导出createProgram,因为从vue-tsc@2.0.0开始,其api做出了较大改动。我这里使用的是1.x的最后一个版本1.8.27
import {createProgram} from "vue-tsc"
import {createCompilerHost, CompilerOptions} from "typescript"
const ROOT = "./"
const OUT_DIR = "dist"
const ENTRY_DIR = "src"
export function tsc() {
  const compilerOptions: CompilerOptions = {
    outDir: path.join(ROOT, OUT_DIR),
    allowJs: true,
    declaration: true,
    incremental: true,
    skipLibCheck: true,
    strictNullChecks: true,
    emitDeclarationOnly: true,
  }
  createProgram({
    rootNames: [resolve(ROOT, ENTRY_DIR, "index")],
    options: compilerOptions,
    host: createCompilerHost(compilerOptions)
  }).emit()
  return Promise.resolve()
}

以上脚本可以生成.d.ts,但是我在项目中使用了pnpm的workspace实现路径别名的功能,这里就会有个问题,这里通过rootNames配置,以一个index.ts作为入口,此时,它会通过代码中的import语句找到其他文件。如果此时有一个文件是通过workspace的别名导入的,该文件不会被识别到,也就是说,vue-tsc不会为其生成.d.ts。

解决workspace别名路径无法识别问题

createProgram的rootNames配置接收的是一个数组,也就是说,他可以支持同时有多个入口文件,那么,只要把所有需要生成.d.ts的文件路径传入,就可以了。

为此,我写了一个辅助函数:

import path from "path"
import fs from "fs-extra"
/**
 * 返回一个目录下的所有文件路径
 * @param dir - 目标目录路径
 * @param include - 包含的文件后缀- [.ts,.vue,.tsc]
 */
async function readFilesRecursive({dir, include}: { dir: string, include: string[] }) {
  const result: string[] = []
  const files = await fs.readdir(dir)
  for (const file of files) {
    const filepath = path.join(dir, file)
    const stat = await fs.stat(filepath)
    const isInclude = include.some(fileSuffix => file.endsWith(fileSuffix))
    if (stat.isFile() && !isInclude || file === "node_modules") continue
    if (stat.isDirectory()) {
      result.push(...await readFilesRecursive({dir: filepath, include}))
    } else {
      result.push(filepath)
    }
  }
  return result
}

然后,你就可以:

const rootNames = await readFilesRecursive({dir: path.resolve(ROOT, ENTRY_DIR), include: [".vue", ".ts", ".tsx"]})

路径别名的转化

typescript生成的.d.ts并不会对import的路径做处理(即使你在tsconfig.json中配置了路径别名)。因此,如果你有这样的需求,就只能自己编写脚本实现。

为此,我查阅了vexip-ui和element-plus的方案。 vexip-ui的路径别名和npm包名是一致的,所以其类型声明文件并不需要做修改,就可以被识别。element-plus则是在生成.d.ts后,在文件输出前,通过String.prototype.replace()将所有别名替换成了npm包名。\

element-plus别名替换:

/**
 * 文件路径 - https://github.com/element-plus/element-plus/blob/2.8.1/internal/build/src/utils/pkg.ts
 */
import { PKG_NAME, PKG_PREFIX } from '@element-plus/build-constants'
import { buildConfig } from '../build-info'
import type { Module } from '../build-info'
/** used for type generator */
export const pathRewriter = (module: Module) => {
  const config = buildConfig[module]
  return (id: string) => {
    id = id.replaceAll(`${PKG_PREFIX}/theme-chalk`, `${PKG_NAME}/theme-chalk`)
    id = id.replaceAll(`${PKG_PREFIX}/`, `${config.bundle.path}/`)
    return id
  }
}

使用vue-tsc实现:

import path from "path"
import fs from "fs-extra"
import * as vueTsc from "vue-tsc"
import ts from "typescript"
export async function tsc() {
  const options: ts.CompilerOptions = {
    outDir: path.resolve(ROOT, OUT_DIR),
    allowJs: true,
    declaration: true,
    incremental: true,
    strict: true,
    emitDeclarationOnly: true,
    jsx: ts.JsxEmit.Preserve,
    jsxImportSource: "vue"
  }
  const host = ts.createCompilerHost(options)
  const include = [".vue", ".ts", ".tsx"]
  const dir = path.resolve(ROOT, ENTRY_DIR)
  const rootNames = await readFilesRecursive({dir, include})
  // 不调用program的emit,而是从中获取打包结果,并对其结果做各种必要的处理
  const program = vueTsc.createProgram({rootNames, options, host})
  const sourceFiles = program.getSourceFiles()
  const outputFiles: ts.OutputFile[] = []
  for (const sourceFile of sourceFiles) {
    const files = program.__vue.languageService.getEmitOutput(sourceFile.fileName, true).outputFiles
    outputFiles.push(...files)
  }
  // 替换路径别名,并输出.d.ts文件
  for (const outputFile of outputFiles) {
    /**
     * DEV_PKG_NAME - 开发时使用的路径别名
     * PKG_NAME - npm包名
     */
    const content = outputFile.text.replaceAll(DEV_PKG_NAME, PKG_NAME)
    // 我写了一个强制创建文件的函数
    forceCreateFile(outputFile.name)
    await fs.writeFile(outputFile.name, content, "utf-8")
  }
}

用replaceAll虽然实现了路径别名的转换,但是灵活度不够,你需要保证你的别名有足够的辨识度,其不能在import语句意外的地方出现,否则可能会出现一些bug。同时,如果你的包不作为npm包上传,那么这种转换后的别名也是无法被识别的。为了解决这两个问题,我的方案是利用ast做别名转换,同时,将路径别名转为相对路径如何处理ast?

复杂的ast操作,可以参考vite-plugin-dts,使用ts-morph进行,但是这里仅仅只需要转化路径别名,用typescript提供的工具也可以轻易实现。

生成.d.ts的语法树、转换路径别名、写入

program.getSourceFiles()
  // 这里的getSourceFiles返回的结果中,有一部分不是由源码生成的,这部分不需要做处理,
  // 我写了一个isPathInsideDirectory函数,过滤掉多余的文件
  .filter(sourceFile => isPathInsideDirectory(fullEntryDir, sourceFile.fileName))
  // 获取所有的.d.ts
  .map(sourceFile => program.__vue.languageService.getEmitOutput(sourceFile.fileName, true).outputFiles)
  .flat()
  // 为.d.ts创建语法树
  .map(outputFile => ts.createSourceFile(outputFile.name, outputFile.text, ts.ScriptTarget.Latest, true))
  // 处理语法树中的路径别名transformDtsAlias具体实现再往下看
  .map(sourceFile => transformDtsAlias(sourceFile))
  // 文件写入
  .forEach(sourceFile => {
    const fileName = sourceFile.fileName
    const content = ts.createPrinter().printNode(ts.EmitHint.Unspecified, sourceFile, sourceFile)
    forceCreateFile(fileName)
    fs.writeFile(fileName, content, "utf-8")
  })

遍历语法树并处理路径别名

ts提供了visitEachChild,可用来遍历并生成新的语法树,你可以写一个递归函数,找到你要处理的那个ast节点,然后对其进行处理。但是应该找哪个节点才对?

我用到了一个工具,你可以在这个工具中写入ts代码,然后实时观察语法树的变化

写入如下代码:

import useCarousel from "pkg"
export * from "pkg"

然后你会看到他输出了下面这段代码

// 这段代码是告诉你如何利用ts.factory这个工具生成语法树
[
  factory.createImportDeclaration(
    undefined,
    factory.createImportClause(
      false,
      factory.createIdentifier("useCarousel"),
      undefined
    ),
    factory.createStringLiteral("pkg"),
    undefined
  ),
  factory.createExportDeclaration(
    undefined,
    false,
    undefined,
    factory.createStringLiteral("pkg"),
    undefined
  )
];

通过上面的代码可以知道,导入和导出语句后面的路径的ast节点是通过createStringLiteral创建的,而其父节点则是createImportDeclaration和createExportDeclaration创建

ts提供了两中方法,一种是 create[AST类型] 用于创建ast节点,一种是 is[AST类型],用来判断一个节点是否为某个类型。

有了这些知识,你就可以写出如下代码:

function transformDtsAliasextends ts.Node>(node: T): T {
  if (
    ts.isStringLiteral(node) &&
    [ts.isImportDeclaration, ts.isExportDeclaration]
      .some(cb => cb(node.parent))
  ) {
    const url = node.getText().slice(1, -1)
    return ts.factory.createStringLiteral(transformAlias(url, node.getSourceFile().fileName)) as any
  }
  // 如果遍历到了叶子节点,不需要再递归了,直接返回
  if (ts.isIdentifier(node)) return node
  // @ts-ignore
  return ts.visitEachChild(node, node => transformDtsAlias(node))
}
/**
 * @param url - 导入语句的路径
 * @param filePath - 最终生成文件的路径
 */
function transformAlias(url: string, filePath: string) {
  if (!url.startsWith(DEV_PKG_NAME)) return url
  // 将路径别名替换为绝对路径
  url = url.replace(DEV_PKG_NAME, path.resolve(ROOT, OUT_DIR))
  // 获取当前文件与要导入的文件之间的相对路径
  return path.relative(filePath, url).replace(/\/g, "/").replace(/^..//, "./")
}

如果你看了其他的ts语法树解析的文章,你可能会有以下疑惑为什么是ts.factory.create而不是ts.create?

ts.create在某个版本被标记为了弃用,到了后续版本已经被正式删除了,我一开始也是调用ts.create,但是根本无法获取到对应的方法,后来查阅了源码,找到了有导出这个方法的版本。发现它被标记成了弃用,同时提示要从factory获取对应的方法.

ts.visitEachChild报错,缺少context?

visitEachChild有第三个参数“context”,我至今不知道这个context是啥,它在ts类型中,被标记为了必传,如果你使用的typescript是5.3.3及其之前的版本。那么,即使你屏蔽了ts错误,你的代码依然无法正常运行,到了5.3.3之后,context虽然依然被标记为必传,但是其已经有了默认值

// 文件地址:https://github.com/microsoft/TypeScript/blob/v5.4.2/src/compiler/visitorPublic.ts
// 行:597
// 修改:https://github.com/microsoft/TypeScript/commit/1592210673bfb37f5454852631376a5f6aaafedf#diff-044e53f5c31ee19a4e3fca606c4b4ab8d5618ec9b740aa2e01960a8fbbad6eb2
export function visitEachChildextends Node>(node: T | undefined, visitor: Visitor, context = nullTransformationContext, nodesVisitor = visitNodes, tokenVisitor?: Visitor, nodeVisitor: NodeVisitor = visitNode): T | undefined {
  if (node === undefined) {
    return undefined;
  }
  const fn = (visitEachChildTable as Record<SyntaxKind, VisitEachChildFunction<any> | undefined>)[node.kind];
  return fn === undefined ? node : fn(node, visitor, context, nodesVisitor, nodeVisitor, tokenVisitor);
}