- 作者:老汪软件技巧
- 发表时间: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);
}