手把手带你仿照ele-puls实现自己的组件库(三)之类型生成

278 阅读7分钟

前言

上节我们用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 (名字后来改了,图片没更新)

image.png

这样我们在所有的项目中都可以使用这个包, 我们将之前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, 这个是完整的代码

大概流程是这样:

  1. 通过 ts-morph 创建 TypeScript 项目,指定 tsconfig 文件路径和编译选项。
  2. 使用 glob 搜索 .js, .jsx, .ts, .tsx, 和 .vue 文件,排除一些不需要的文件。
  3. 对于 .vue 文件,使用 vue/compiler-sfc 解析 template, script, scriptSetup 等内容。
  4. 创建 TypeScript 源文件,并将其添加到 ts-morph 项目中。
  5. 调用 project.getPreEmitDiagnostics() 进行类型检查。如果存在类型错误,记录并抛出异常。
  6. 使用 project.emit() 生成 .d.ts 类型定义文件。
  7. 写入生成的 .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

image.png

image.png

现在我们可以看到打包已经可以生成出来对应的类型文件了

结语

这节我们主要讲的是生成类型,把一些路径替换,以及主目录的完善,放在后面来去搞,这里我们已经成功了一大部分,打包代码,生成对应类型。有问题欢迎大家交流,有错误也可以批评指正