前言
上节我们用rollup打包了组件,这节我们主要用ts-morph生成类型以及优化一下之前的一部分逻辑。ts-morph是一个适用于Javascript、Typescript的AST处理工具库。通过 ts-morph,你可以实现诸如自动化代码重构、自动生成文件或接口、动态插入和修改代码等复杂任务,极大地提高了开发效率。
整合部分代码
因为我们在打包类型时,gulp执行任务时,会用到很多重复的地址,我们跟ele一样整合出来。在internal/build-utils中新建目录文件如下
├── index.ts
├── package.json
└── src
├── index.ts
├── path.ts
└── pkg.ts
在path.ts中,我们整合出地址
import { resolve } from 'path'
export const projRoot = resolve(__dirname, '..', '..', '..')
export const pkgRoot = resolve(projRoot, 'packages')
//dist
export const buildOutput = resolve(projRoot, 'dist')
export const epOutput = resolve(buildOutput, 'lbl-test')
在pkg.ts中,导出过滤文件的函数
export const excludeFiles = (files: string[]) => {
const excludes = ['node_modules', 'mock', 'gulpfile', 'dist']
return files.filter(path => !excludes.some(exclude => path.includes(exclude)))
}
然后将build-utils的包名改为@lbl-test/build-utils
切到根目录全局安装 pnpm install @lbl-test/build-utils --workspace -Dw (名字后来改了,图片没更新)
这样我们在所有的项目中都可以使用这个包, 我们将之前modules.ts的内容替换一下,路径换成从我们的包引入。
import { pkgRoot,epOutput,excludeFiles} from '@lbl-test/build-utils'
//项目根目录
//const projRoot = resolve(__dirname, '..', '..', '..','..')
//包目录
//const pkgRoot = resolve(projRoot, 'packages')
//输出目录
//const epOutput = resolve(projRoot,'dist')
//打包入口文件过滤
//export const excludeFiles = (files: string[]) => {
// const excludes = ['node_modules', 'mock', 'gulpfile', 'dist']
// return files.filter(path => !excludes.some(exclude => path.includes(exclude)))
//}
定义tsconfig
我们可以看下ele源码中的tsconfig。references是一个新的顶层属性从而实现更好的模块化
{
"files": [],
"references": [
{ "path": "./tsconfig.web.json" },
{ "path": "./tsconfig.play.json" },
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.vite-config.json" },
{ "path": "./tsconfig.vitest.json" }
]
}
我们这里只写web,node,play就可以了,vitest跟vite我没用到。
我们看下base的配置
定义tsconfig.base.json
{
"compilerOptions": {
"outDir": "dist", //输出目录
"target": "es2018", // 指定编译的目标 ECMAScript 版本为 ES2018
"module": "esnext", //支持最新的 JavaScript 模块特性
"baseUrl": ".", //指定项目根目录为模块解析的基础路径,所有相对路径的模块解析都会基于此目录。
"sourceMap": false, //不生成 source map 文件
"moduleResolution": "node",//指定模块解析策略为 node
"allowJs": false,//禁止编译 JavaScript 文件。这个配置确保项目中只允许 TypeScript 文件参与编译
"strict": true, //开启严格模式
"noUnusedLocals": true, //禁止定义但未使用的本地变量
"resolveJsonModule": true, // 允许从 TypeScript 中导入 JSON 文件
"allowSyntheticDefaultImports": true, //: 允许默认导入非 ES 模块。用于兼容 CommonJS 模块
"esModuleInterop": true, // 启用对 CommonJS 和 ES 模块之间的互操作性支持。这个选项可以让 TypeScript 更好地处理两者之间的默认导入和命名空间导入。
"removeComments": false, //保留代码中的注释
"rootDir": ".", // 设定 TypeScript 打包输出的根文件目录。
"types": [],
"paths": {
"@element-plus/*": ["packages/*"] //配置路径映射,这里可以使用我们自己的包名
},
"preserveSymlinks": true // 保持符号链接的解析方式与 Node.js 一致
}
}
这段基本可以照搬过来,映射路径改为我们自己的就好,我们默测试包定义为@lbl-test
然后我们在这个基础上完善我们的ts,web跟node的配置,play的我们可以暂时搁置,因为我们用vite搭建的也默认生成了ts配置,跟ele的还是有点差别,这两个文件,我们也是直接搬来做下修改。
定义tsconfig.web.json
{
"extends": "./tsconfig.base.json", //继承自 tsconfig.base.json
"compilerOptions": {
"composite": true, //启用项目引用,允许 TypeScript 增量编译。
"jsx": "preserve", //保留 JSX 语法
"lib": ["ES2018", "DOM", "DOM.Iterable"], //支持最新的 JavaScript 语法和特性。DOM API,DOM 对象的迭代
"types": ["unplugin-vue-macros/macros-global"], //包含 unplugin-vue-macros 插件的全局类型定义,unplugin-vue-macros 是一个用于增强 Vue 3 开发体验的插件,提供了宏功能(如自动导入、自动重命名等)
"skipLibCheck": true //跳过对所有声明文件(.d.ts 文件)的类型检查
},
"include": ["packages", "typings/env.d.ts"],
"exclude": [
"node_modules",
"**/dist",
"**/__tests__/**/*", //可删掉
"**/gulpfile.ts",
"**/test-helper", //可删掉
"packages/test-utils", //可删掉
"**/*.md"
]
}
include定义了要包含在编译过程中的文件和目录。
exclude定义了要排除在编译过程之外的文件和目录
tsconfig.web.json 是为浏览器环境量身定制的 TypeScript 配置。它针对项目的核心代码(packages 目录)以及一些特定的类型声明(如 unplugin-vue-macros)进行了配置。
定义tsconfig.node.json
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"composite": true,
"lib": ["ESNext"],
"types": ["node"],
"skipLibCheck": true
},
"include": [
"internal/**/*",
"internal/**/*.json",
"scripts/**/*", //可删掉
"packages/theme-chalk/*",
"packages/element-plus/version.ts", //可删掉
"packages/element-plus/package.json"
],
"exclude": ["**/__tests__/**", "**/tests/**", "**/dist"]
}
tsconfig.node.json,将这些与 Node.js 相关的工具和脚本隔离到一个单独的配置文件中,保持主配置文件的简洁,同时也让开发者更清晰地了解哪些部分是 Node.js 特定。特定的 tsconfig 设置来确保这些脚本能够正确编译和运行。
unplugin-vue-macros的一点题外话
unplugin-vue-macros这个插件是一个 Vue.js 库,它提供了一系列宏来增强 Vue 的开发体验。官网是这个 vue-macros.dev/zh-CN/macro…
比如我在开发的时候碰到一个问题,在给组件定义name时,一开始在网上查的需要写两个script,一个不用setup用vue2之前那种定义name。然后在生成类型的时候会报错,因为我有两个默认导出的script,后来在setup中用defineOptions()来定义组件名,类似下面代码这样。
defineOptions({
name: 'testTable'
})
这个已经在vue3.3+被支持了,因为我安装的vue是3.4版本,所以我没有安装这个插件。如果你跟ele一样用vue3.2,则需要unplugin-vue-macros这个插件来拓展宏。
使用ts-morph 生成类型
先安装 ts-morph consola chalk 。在build目录下pnpm install ts-morph chalk consola
编写类型生成任务,tasks下新建types-definitions.ts,
这个是完整的代码
大概流程是这样:
- 通过 ts-morph 创建 TypeScript 项目,指定 tsconfig 文件路径和编译选项。
- 使用 glob 搜索 .js, .jsx, .ts, .tsx, 和 .vue 文件,排除一些不需要的文件。
- 对于 .vue 文件,使用 vue/compiler-sfc 解析 template, script, scriptSetup 等内容。
- 创建 TypeScript 源文件,并将其添加到 ts-morph 项目中。
- 调用 project.getPreEmitDiagnostics() 进行类型检查。如果存在类型错误,记录并抛出异常。
- 使用 project.emit() 生成 .d.ts 类型定义文件。
- 写入生成的 .d.ts 文件到适当位置
import { projRoot, pkgRoot, buildOutput, excludeFiles, epRoot } from '@hfn-components/build-utils'
import chalk from 'chalk'
import { mkdir, readFile, writeFile } from 'fs/promises'
import { resolve, relative, dirname } from 'path'
import { Project } from 'ts-morph'
import * as vueCompiler from 'vue/compiler-sfc'
import glob from 'fast-glob'
import type { CompilerOptions, SourceFile } from 'ts-morph'
import { pathRewriter } from '../utils/pkg'
import consola from 'consola'
const TSCONFIG_PATH = resolve(projRoot, 'tsconfig.web.json')
const outDir = resolve(buildOutput, 'types')
export const generateTypesDefinitions = async () => {
const compilerOptions: CompilerOptions = {
emitDeclarationOnly: true,
outDir,
baseUrl: projRoot,
preserveSymlinks: true,
skipLibCheck: true,
noImplicitAny: false
}
const project = new Project({
compilerOptions,
tsConfigFilePath: TSCONFIG_PATH,
skipAddingFilesFromTsConfig: true
})
//生成类型文件
const sourceFiles = await addSourceFiles(project)
consola.success('Added source files')
//生成类型报错
typeCheck(project)
consola.success('Type check passed!')
//发出类型文件
await project.emit({
emitOnlyDtsFiles: true
})
const tasks = sourceFiles.map(async sourceFile => {
const relativePath = relative(pkgRoot, sourceFile.getFilePath())
consola.trace(chalk.yellow(`Generating definition for file: ${chalk.bold(relativePath)}`))
const emitFiles = sourceFile.getEmitOutput().getOutputFiles()
if (emitFiles.length === 0) {
throw new Error(`Emit no file: ${chalk.bold(relativePath)}`)
}
const subTasks = emitFiles.map(async outputFile => {
const filepath = outputFile.getFilePath()
await mkdir(dirname(filepath), {
recursive: true
})
await writeFile(filepath, pathRewriter()(outputFile.getText()), 'utf8')
consola.success(chalk.green(`Definition for file: ${chalk.bold(relativePath)} generated`))
})
await Promise.all(subTasks)
})
await Promise.all(tasks)
}
async function addSourceFiles(project: Project) {
project.addSourceFileAtPath(resolve(projRoot, 'typings/env.d.ts'))
const globSourceFile = '**/*.{js?(x),ts?(x),vue}'
const filePaths = excludeFiles(
await glob([globSourceFile, '!hfn-components/**/*'], {
cwd: pkgRoot, // 读取 packages 目录下除了 hfn-components 目录的文件
absolute: true, // 读取绝对路径
onlyFiles: true // 只读取文件
})
)
const epPaths = excludeFiles(
await glob(globSourceFile, {
cwd: epRoot, // 读取 ./packages/hfn-components 目录下的文件
onlyFiles: true // 只读取文件
})
)
const sourceFiles: SourceFile[] = []
await Promise.all([
...filePaths.map(async file => {
if (file.endsWith('.vue')) {
// 处理 .vue 文件
const content = await readFile(file, 'utf-8')
// 初步解析出 template、script、scriptSetup、style 模块
const sfc = vueCompiler.parse(content)
const { script, scriptSetup } = sfc.descriptor
if (script || scriptSetup) {
let content = script?.content ?? ''
if (scriptSetup) {
// 如果存在 scriptSetup 则需要通过 compileScript 方法编译
const compiled = vueCompiler.compileScript(sfc.descriptor, {
id: 'xxx'
})
content += compiled.content
}
// 创建 TypeScript 源文件
const lang = scriptSetup?.lang || script?.lang || 'js'
const source = project.createSourceFile(`${relative(process.cwd(), file)}.${lang}`, content)
sourceFiles.push(source)
}
} else {
// 如果不是 .vue 文件则 addSourceFileAtPath 添加文件路径的方式添加 ts-morph 项目的 TypeScript 源文件
const source = project.addSourceFileAtPath(file)
sourceFiles.push(source)
}
}),
...epPaths.map(async file => {
// 读取hfn-components目录下的文件 生成TypeScript 源文件
const content = await readFile(resolve(epRoot, file), 'utf-8')
sourceFiles.push(project.createSourceFile(resolve(pkgRoot, file), content))
})
])
return sourceFiles
}
function typeCheck(project: Project) {
const diagnostics = project.getPreEmitDiagnostics()
if (diagnostics.length > 0) {
consola.error(project.formatDiagnosticsWithColorAndContext(diagnostics))
const err = new Error('Failed to generate dts.')
consola.error(err)
throw err
}
}
里面有一些其他的操作我们后面再讲,我先把他删减部分,变成只生成类型的,内容如下
import { projRoot, pkgRoot, buildOutput, excludeFiles } from '@lbl-test/build-utils'
import { readFile } from 'fs/promises'
import { resolve, relative } from 'path'
import { Project } from 'ts-morph'
import * as vueCompiler from 'vue/compiler-sfc'
import glob from 'fast-glob'
import type { CompilerOptions, SourceFile } from 'ts-morph'
import consola from 'consola'
const TSCONFIG_PATH = resolve(projRoot, 'tsconfig.web.json')
const outDir = resolve(buildOutput, 'types')
export const generateTypesDefinitions = async () => {
const compilerOptions: CompilerOptions = {
emitDeclarationOnly: true,
outDir,
baseUrl: projRoot,
preserveSymlinks: true,
skipLibCheck: true,
noImplicitAny: false
}
const project = new Project({
compilerOptions,
tsConfigFilePath: TSCONFIG_PATH,
skipAddingFilesFromTsConfig: true
})
//生成类型文件
await addSourceFiles(project)
consola.success('Added source files')
//生成类型报错
typeCheck(project)
consola.success('Type check passed!')
//发出类型文件
await project.emit({
emitOnlyDtsFiles: true
})
}
// await addSourceFiles(project);
async function addSourceFiles(project: Project) {
// project.addSourceFileAtPath(resolve(projRoot, 'typings/env.d.ts'))
// 读取的文件类型 .js .jsx .ts .tsx .vue
const globSourceFile = '**/*.{js?(x),ts?(x),vue}'
const filePaths = excludeFiles(
await glob([globSourceFile], {
cwd: pkgRoot, // 读取 packages 目录
absolute: true, // 读取绝对路径
onlyFiles: true // 只读取文件
})
)
const sourceFiles: SourceFile[] = []
await Promise.all([
...filePaths.map(async file => {
if (file.endsWith('.vue')) {
// 处理 .vue 文件
const content = await readFile(file, 'utf-8')
// 初步解析出 template、script、scriptSetup、style 模块
const sfc = vueCompiler.parse(content)
const { script, scriptSetup } = sfc.descriptor
if (script || scriptSetup) {
let content = script?.content ?? ''
if (scriptSetup) {
// 如果存在 scriptSetup 则需要通过 compileScript 方法编译
const compiled = vueCompiler.compileScript(sfc.descriptor, {
id: 'xxx'
})
content += compiled.content
}
// 创建 TypeScript 源文件
const lang = scriptSetup?.lang || script?.lang || 'js'
const source = project.createSourceFile(`${relative(process.cwd(), file)}.${lang}`, content)
sourceFiles.push(source)
}
} else {
// 如果不是 .vue 文件则 addSourceFileAtPath 添加文件路径的方式添加 ts-morph 项目的 TypeScript 源文件
const source = project.addSourceFileAtPath(file)
sourceFiles.push(source)
}
}),
])
return sourceFiles
}
function typeCheck(project: Project) {
const diagnostics = project.getPreEmitDiagnostics()
if (diagnostics.length > 0) {
consola.error(project.formatDiagnosticsWithColorAndContext(diagnostics))
const err = new Error('Failed to generate dts.')
consola.error(err)
throw err
}
}
在tasks目录的index.ts导出
export * from './modules'
export * from './types-definitions'
将gulpfile.ts修改为如下
import { series } from 'gulp'
import { buildModules ,generateTypesDefinitions} from './src/index'
const allTask = series(buildModules,generateTypesDefinitions)
export default allTask
在build目录下执行npm run start
现在我们可以看到打包已经可以生成出来对应的类型文件了
结语
这节我们主要讲的是生成类型,把一些路径替换,以及主目录的完善,放在后面来去搞,这里我们已经成功了一大部分,打包代码,生成对应类型。有问题欢迎大家交流,有错误也可以批评指正