likes
comments
collection
share

【从 0 到 1 搭建 Vue 组件库框架】2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下

作者站长头像
站长
· 阅读数 16

导航

导航:0. 导论

上一章节: 2. 在 monorepo 模式下集成 Vite 和 TypeScript - 上

下一章节:(编写中)3. 集成 lint 代码规范工具

本章节示例代码仓:Github

上半部分,我们通过集成构建工具 Vite 使组件库能构建出产物。下半部分,我们将要集成 TypeScript,为组件库的开发注入类型系统的支持。

TypeScript 选型与简介

TypeScript 是基于 JavaScript 之上构建的强类型编程语言。

选择 TypeScript 的理由已经老生常谈了,总结概述如下:

  1. 为 js 添加静态类型检查,提前发现运行时出现的类型错误,将大量错误扼杀在编译阶段,提高代码健壮性。

【从 0 到 1 搭建 Vue 组件库框架】2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下

  1. 与编辑器结合,获得更好的代码提示,甚至实现“代码即文档”的效果(配合注释),代码可读性的提高可以大幅减少后人上手成本。

【从 0 到 1 搭建 Vue 组件库框架】2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下

我们发现,TypeScript 开始发力、获得收益的场景,都是在项目开发的中后期,而前期往往需要我们更多的努力与投入,这就决定了适合使用 TypeScript 的项目往往要有频繁迭代,长期维护的特点。组件库往往期望得到长期维护,若使用率很高的话,也避免不了频繁迭代。

另外,我个人比起静态类型检查,更加青睐 TypeScript 类型对理解代码提供的帮助。很多第三方库缺少文档,但是如果它有相对可靠的类型声明文件,其中的类型注解和接口声明就足以帮助我理解使用方法。从我个体的感受出发,推及到用户的角度,不难想到如果我们的组件库有完善的类型定义,就能隐性地给使用者带来很多帮助。

(实战环节开始)TypeScript 集成

完成了 Vite 的集成,我们要进入 TypeScript 集成部分。在已经安装好 typescript 公共依赖的情况下,所谓集成其实就是填写 tsconfig.json 文件。大部分项目都用着相似的 tsconfig 预设,且稳定之后在迭代过程中很少修改,因此我们不会对配置项做太多介绍。关于 tsconfig.json 的各个配置项,建议直接查阅 官方说明

tsconfig 到底为了谁?

我们注意到,在先前集成 Vite 过程中,我们没有做任何一点 TypeScript 配置,甚至无视了 IDE 的相关报错,但是丝毫没有影响 Vite 成功解析了我们的 ts 文件,并且构建出了产物。如果你平时习惯用 Vite 脚手架生成 ts 项目,可能会感到有点反直觉,以为没有配好 ts 应该会导致构建过程出错。

其实,在 Vite 官方文档中,是这样 介绍TypeScript 的关系的:

Vite 天然支持引入 .ts 文件。请注意,Vite 仅执行 .ts 文件的转译工作,并不执行任何类型检查。并假定类型检查已经被你的 IDE 或构建过程处理了。

Vite 本质上是双引擎架构——内部除了 Rollup 之外,还集成了另一个构建工具 EsbuildEsbuild 有着超快的编译速度,它在其中负责第三方库构建和 TS/JSX 语法编译。

无论是构建模式还是开发服务器模式,Vite 都通过 Esbuild 来将 ts 文件转译为 js,对这个过程的细节感兴趣的同学,可以前往 Vite 源码 - Esbuild 插件 分析。

我们可以理解为,Vite 为了保证构建效率,内部并没有执行完整的 tsc 编译过程,而是每当遇到一个 ts 文件,就组装出一个最小化的、剔除了所有与类型检查相关配置的 tsconfig,交由 Esbuild 做转译工作——这个转译只确保生成对应的 js 产物,不做任何多余的事情。因此,仅仅做单文件的转译几乎不需要多少 tsconfig 配置,以至于在没有 tsconfig.json 的情况下,Vite 的转译工作都能在绝大多数情况下获得正确预期结果。

在源码中可以看到,tsconfig.json 只有极其有限的几个字段可能对构建结果产生影响。

【从 0 到 1 搭建 Vue 组件库框架】2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下

既然 tsconfig 对于 Vite 构建的影响如此之小,那么我们配置它更多地是为了什么?其实 Vite 文档中的那句 “假定类型检查已经被你的 IDE 或构建过程处理了” 就很好地揭示了答案:

  • tsconfig 主要写给 IDE 看的,为了让 IDE 能够实现类型检查,提示我们代码中的类型错误。
  • Vite 不负责类型检查,并且推荐我们在构建过程中于另一个进程单独执行类型检查,那么 tsconfig 就应该提供给执行检查任务的编译器 tsc

规划 TypeScript 分治策略

下面我们开始规划整个项目的 tsconfig 配置。对于每个 tsconfig.json 文件,我们主要从以下两个角度理解:

  • 每个 tsconfig.json 将一个文件集合声明为一个 ts project(如果称为项目则容易产生概念混淆,故叫做 ts project),通过 include 描述集合中包含的文件、exclude 字段声明了集合中需要排除的文件。注意,除了 node_modules 中的三方依赖,每个被引用的源码文件都要被包含进来。
  • compilerOptions 是编译选项,决定了 TypeScript 编译器在处理该 ts project 包含的文件时所采取的策略与行为。
{
  "compilerOptions": {
    // 项目的编译选项
  },
  "include": [
    // 项目包含哪些文件
  ],
  "exclude": [
    // 在 include 包含的文件夹中需要排除哪些文件
  ]
}

includeexclude 字段通过 glob 语法进行文件匹配,不熟悉的同学可以通过以下文章简单了解:

前端工程化之强大的glob语法

glob 模式匹配简明教程

我们会将整个工程划分为多个 ts project,应该采用什么样的划分依据呢?我们可以参考 element-plus 的划分策略,不是将每个子模块划分为一个 ts project,分散在各个包中管理。而是将功能相似的代码划分到一个 ts project 中,集中在根目录下管理。

【从 0 到 1 搭建 Vue 组件库框架】2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下

对于每个 TypeScript 项目而言,编译选项 compilerOptions 大部分都是重复的,因此我们需要建立一个基础配置文件 tsconfig.base.json,供其他配置文件继承。

// tsconfig.base.json
{
  "compilerOptions": {
    // 项目的根目录
    "rootDir": ".",
    // 项目基础目录
    "baseUrl": ".",
    // tsc 编译产物输出目录
    "outDir": "dist",
    // 编译目标 js 的版本
    "target": "es2022",
    // 
    "module": "esnext",
    // 模块解析策略
    "moduleResolution": "node",
    // 是否生成辅助 debug 的 .map.js 文件。 
    "sourceMap": false,
    // 产物不消除注释
    "removeComments": false,
    // 严格模式类型检查,建议开启
    "strict": true,
    // 不允许有未使用的变量
    "noUnusedLocals": true,
    // 允许引入 .json 模块
    "resolveJsonModule": true,

    // 与 esModuleInterop: true 配合允许从 commonjs 的依赖中直接按 import XX from 'xxx' 的方式导出 default 模块。
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,

    // 在使用 const enum 或隐式类型导入时受到 TypeScript 的警告
    "isolatedModules": true,
    // 检查类型时是否跳过类型声明文件,一般在上游依赖存在类型问题时置为 true。
    "skipLibCheck": true,
    // 引入 ES 的功能库
    "lib": [],
    // 默认引入的模块类型声明
    "types": [],
    // 路径别名设置
    "paths": {
      "@openxui/*": ["packages/*/src"]
    }
  }
}

我们将所有 node 环境下执行的脚本、配置文件划分为一个 ts project,准备其配置文件 tsconfig.node.json

// tsconfig.node.json
{
  // 继承基础配置
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    // 该 ts project 将被视作一个部分,通过项目引用(Project References)功能集成到一个 tsconfig.json 中
    "composite": true,
    // node 脚本没有 dom 环境,因此只集成 esnext 库即可
    "lib": ["ESNext"],
    // 集成 Node.js 库函数的类型声明
    "types": ["node"],
    // 脚本有时会以 js 编写,因此允许 js
    "allowJs": true
  },
  "include": [
    // 目前项目中暂时只有配置文件,如 vite.config.ts,以后会逐步增加
    "**/*.config.*",
  ],
  "exclude": [
    // 暂时先排除产物目录,packages/xxx/dist/x.config.js 或者 node_modules/pkg/x.config.js 不会被包含进来
    "**/dist",
    "**/node_modules"
  ]
}

对于所有模块中 src 目录下的源码文件,它们几乎都是组件库的实现代码,大多要求浏览器环境下特有的 API(例如 DOM API),且相互之间存在依赖关系。我们创建 tsconfig.src.json 将它们划入同一个 ts project 中。

// tsconfig.src.json
{
  // 继承基础配置
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    // 组件库依赖浏览器的 DOM API
    "lib": ["ESNext", "DOM", "DOM.Iterable"],
    "types": ["node"],
  },
  "include": [
    "typings/env.d.ts",
    "packages/**/src"
  ],
}

到此,IDE 还是无法正常提供类型服务,我们最终还是要在根目录建立一个总的 tsconfig.json,通过 项目引用(Project References)功能 将多个 compilerOptions.composite = truets project 聚合在一起,这样 IDE 才能够识别。

// tsconfig.json
{
  "compilerOptions": {
    "target": "es2022",
    "moduleResolution": "node",

    // vite 会读取到这个 tsconfig 文件(位于工作空间根目录),按照其推荐配置这两个选项
    // https://cn.vitejs.dev/guide/features.html#typescript-compiler-options
    "isolatedModules": true,
    "useDefineForClassFields": true,
  },
  "references": [
    // 聚合 ts project
    { "path": "./tsconfig.src.json" },
    { "path": "./tsconfig.node.json" }
  ],
}

项目引用(Project References) 特性,简单理解就是为项目的不同部分应用不同 tsconfig 的能力,如果希望更详细地了解,除了官方文档外,推荐阅读以下文章:

探究 tsconfig.node.json 文件和 references 字段的作用

Nest.js 这么大的项目是怎么优化 ts 编译性能的?

完成配置后,若出现下图中的效果,且源代码中没有任何 ts 报错,则代表我们的配置是完全正确的——对于组件源码文件,IDE 准确地识别了它的归属 tsconfig.src.jsonVite 配置文件作为 Node.js 脚本,也被 IDE 划拨到 tsconfig.node.json

【从 0 到 1 搭建 Vue 组件库框架】2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下

【从 0 到 1 搭建 Vue 组件库框架】2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下

注意:VSCodeTypeScript 状态有时会有更新延迟。遇到这种情况,可以尝试通过 Ctrl + P 调出命令框,搜索 reload 关键字,执行 Developer: Reload Window 指令重载 IDE。

如果对 tsconfig 实际应用的编译选项或者包含的文件产生疑惑,可以通过以下命令去验证:

npx tsc -p tsconfig.src.json --showConfig

# 输出结果
{
    "compilerOptions": {
        # ...
        # 最终编译选项
    },
    "files": [
        # 实际包含的文件
        "./typings/env.d.ts",
        "./packages/button/src/index.ts",
        "./packages/input/src/index.ts",
        "./packages/shared/src/hello.ts",
        "./packages/shared/src/index.ts",
        "./packages/shared/src/useLodash.ts",
        "./packages/ui/src/index.ts"
    ],
    "include": [
        "typings/env.d.ts",
        "packages/**/src"
    ]
}

未来随着项目的增长,我们会根据实际情况不断更新这些 tsconfig,例如让已有的 ts project 包含更多的源码;或者划分出新的 ts project(比如测试专用的 tsconfig.test.json)。

最后,我们还要补充一些缺失的类型声明:

  • 我们在 tsconfig 文件中设置了 "types": ["node"],代表注入 Node.js 各种库函数的类型声明,这需要我们在根目录下补充安装 @types/node
pnpm i -wD @types/node
  • 我们在 tsconfig.src.jsoninclude 字段中包含了 typings/env.d.ts,这是为了让 TypeScript 对于 Vite 的一些特定功能提供类型定义(参考:TypeScript 的智能提示),我们应该实际创建这个文件。这个文件除了服务于 Vite,在后续可能将其他一些环境相关的类型定义放在这里。
// typings/env.d.ts
/// <reference types="vite/client" />

demo 应用的 TypeScript 配置

demo 应用(回顾:2. 在 monorepo 模式下集成 Vite 和 TypeScript - 上)由于相对独立,不涉及组件库的核心构建,因此我们在其目录下单独创建 tsconfig.json,并且不通过项目引用关联到根目录的 tsconfig 中:

// demo/tsconfig.json
{
  // 集成基础配置
  "extends": "../tsconfig.base.json",
  "compilerOptions": {
    "baseUrl": ".",
    // Web 应用需要 DOM 环境
    "lib": ["ESNext", "DOM", "DOM.Iterable"],
    // Web 应用不需要 node 相关方法
    "types": [],
    // baseUrl 改变了,基础配置中的 paths 也需要一并重写
    "paths": {
      "@/*": ["src/*"],
      "@openxui/*": ["../packages/*/src"]
    }
  },
  "include": [
    // demo 应用会引用其他子模块的源码,因此都要包含到 include 中 
    "../packages/*/src",
    "src"
  ]
}

完成配置后,demo 应用源码中的 IDE 报错信息应该全部解决了(重载 IDE 后)。

monorepo 即刻响应

先前我们提到 monorepo 的一个巨大优势就是模块的修改能够得到即刻反馈,为迭代提供了巨大便利性。在我们这个项目中,这个特点体现为:修改每个组件的源码能立即触发 demo 应用的热更新,每个改动都能立即呈现在展示网页上。

这个效果需要 TypeScriptVite 共同配合实现。这一节我们就来研究我们的方案是如何实现这一效果的。

tsconfig.json 中的 paths

在上一节我们配置的 tsconfig 中,paths 这项配置是非常值得关注的,它提供了别名转换的功能。例如:

// tsconfig.base.json
{
  "compilerOptions": {
    // ...
    "paths": {
      "@openxui/*": ["packages/*/src"]
    }
  }
}

上面的 paths 配置,会将我们代码中的 import 导入语句按照这样的规则转换:

// 示例为 @openxui/button 中引入 @openxui/shared

// 原语句
import { hello } from '@openxui/shared'

// ts 编译时转换为
import { hello } from '<rootPath>/<baseUrl>/packages/shared/src'

现在,我们来回顾之前集成 Vite 时(2. 在 monorepo 模式下集成 Vite 和 TypeScript - 上)的 packages/button/src/button.vue 文件。

当时 import { hello } from '@openxui/shared'; 这个语句是报了类型错误的,正确声明 paths 后,这个路径被 TypeScript 正确解析。

【从 0 到 1 搭建 Vue 组件库框架】2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下

如果你仔细思考过之前所有的操作,特别是联系起前面的一个结论:tsconfig 是写给 IDE 和 tsc 编译器看的,那么很自然会有下面的问题:

  • 为什么我们在 tsconfigpaths 中设置的路径别名,与我们的包名相同?
  • 对于同样的 @openxui/sharedtsc / IDE 理解的和构建工具 Vite 理解的是不是同一个东西?

分辨源码与产物

其实,tsc / IDE 和 Vite 对于同样的 @openxui/shared 的理解确实是不一样的:

  • tsc 根据 path 中设置的别名,将这个 id 解析成 <rootPath>/<baseUrl>/packages/shared/src,这个对应的是我们的源码文件。
  • 但是 Vite 在没有设置别名的情况下,将 @openxui/shared 看做一个 npm 模块,结合其 package.json 的入口字段,最终这个 id 实际被解析为 node_modules/@openxui/shared/dist/openxui-shared.mjs

简单来说,tsc 定位到了源码文件,因此没有报错。而 Vite 定位到了构建产物,也没有出错。不过 tsc 只负责类型检查,而实打实的执行者是 ViteVite 目前读取的是产物而不是源码,这样的机制会导致我们对子模块源码的修改无法立即同步,必须先执行子模块的打包命令,假若子模块的产物目录 dist 被删除,demo 应用甚至会报错崩溃。

【从 0 到 1 搭建 Vue 组件库框架】2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下

有什么办法能让 Vite 的理解与 tsc 一致吗?我们需要设置 Vite 配置中的 别名 alias,路径别名解析的优先级要高于 npm 模块解析。下面 demo 应用的 Vite 配置中,我们设置的 resolve.alias 可以将所有 import 语句中的 @openxui/xxx 替换为 ../packages/xxx/src,从而命中源码而非产物,这样源码的更新就会及时通过 HMR 机制反馈到页面上了。

// demo/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { join } from 'node:path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: [
      {
        find: /^@openxui\/(.+)$/, 
        replacement: join(__dirname, '..', 'packages', '$1', 'src') 
      },
    ]
  }
})

【从 0 到 1 搭建 Vue 组件库框架】2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下

需要如此设置 alias 别名的,只有需要及时热更新的模块,例如 demo 以及未来的文档 docs。对于子模块之间的互相依赖,就没有必要在对应的 vite.config 中设置 alias 了,因为:

  1. 子模块的 dependencies 在打包时都外部化处理了,依赖项实际上并不会被 Vite 读取到。
  2. 即使 Vite 可能读取到依赖项,但我们批量打包组件时,pnpm 会为我们做好拓扑排序处理(回顾:1. 基于 pnpm 搭建 monorepo 工程目录结构),永远确保被依赖者先完成打包,依赖者后完成打包。

TypeScript 类型检查

通过 tsc 命令指定对应的 tsconfig 文件,我们就能对该 ts project 所包含的所有文件进行类型检查。例如我们想对所有源码文件进行类型检查,通过以下命令即可实现。

# 根目录执行
npx tsc -p tsconfig.src.json --noEmit --composite false

在上面的命令中,-p 指定对应的 tsconfig 文件,--noEmit 使构建产物不被输出,--composite false 使得 buildInfo 文件不被输出。

但是,由于源码是 Vue 组件,所以 tsc 命令会报错,我们需要借助 vue-tsc 来支持:

pnpm i -wD vue-tsc

npx vue-tsc -p tsconfig.src.json --noEmit --composite false

【从 0 到 1 搭建 Vue 组件库框架】2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下

接下来,我们把类型检查相关的命令在根目录 package.json 中声明。因为 Node.js 脚本不涉及 Vue 框架,对 tsconfig.node.json 的类型检查就无需 vue-tsc 了。另外,我们也给组件的统一构建指令加上了限制,要求类型检查必须通过才能执行构建。

// package.json
{
  // ...
  "scripts": {
+   "type:node": "tsc -p tsconfig.node.json --noEmit --composite false",
+   "type:src": "vue-tsc -p tsconfig.src.json --noEmit --composite false",
-   "build:ui": "pnpm --filter ./packages/** run build"
+   "build:ui": "pnpm run type:src && pnpm --filter ./packages/** run build"
  },
}

生成 d.ts 类型声明产物

到目前为止,我们的组件产物还是有一个比较大的遗憾——缺少 d.ts 类型声明文件。这会导致用户在引用我们的包时,无法获得类型提示信息。

Vite 的体系下,生成 d.ts 文件可以借助于插件 vite-plugin-dts,然而这次我们并不打算使用这个插件,这个插件在迭代的过程中做了太多兼容以及细节处理,已经过于复杂,特别是在 monorepo 模式下,它内部的路径解析总是出现各种各样的问题。在 3.0.0 主版本更新后,其内部生成 d.ts 的机制已经改为 vue-tsc 实现,我们不如直接使用 vue-tsc

在先前的类型检查命令的基础上,补充 --declaration--emitDeclarationOnly 选项就可以为所有的包生成 d.ts 文件。

npx vue-tsc -p tsconfig.src.json --composite false --declaration --emitDeclarationOnly

所有的产物都会被生成到 outDir 字段指定的根目录下的 dist。这和我们的需求有所不合,我们希望对应产物能在每个子模块自己的 dist 目录下。

📦dist
 ┗ 📂packages
 ┃ ┣ 📂button
 ┃ ┃ ┗ 📂src
 ┃ ┃ ┃ ┣ 📜button.vue.d.ts
 ┃ ┃ ┃ ┗ 📜index.d.ts
 ┃ ┣ 📂input
 ┃ ┃ ┗ 📂src
 ┃ ┃ ┃ ┣ 📜index.d.ts
 ┃ ┃ ┃ ┗ 📜input.vue.d.ts
 ┃ ┣ 📂shared
 ┃ ┃ ┗ 📂src
 ┃ ┃ ┃ ┣ 📜hello.d.ts
 ┃ ┃ ┃ ┣ 📜index.d.ts
 ┃ ┃ ┃ ┗ 📜useLodash.d.ts
 ┃ ┗ 📂ui
 ┃ ┃ ┗ 📂src
 ┃ ┃ ┃ ┗ 📜index.d.ts

幸运的是,d.ts 产物目录的内部结构与 packages 的结构是一致的,我们可以很容易实现移动产物的脚本。在根目录下建立 scripts 目录,专门用于存放构建相关的脚本,记得在 tsconfig.node.json 里面补充这个新的脚本目录。

// tsconfig.node.json
{
  // ...
  "include": [
    "**/*.config.*",
+   "scripts"
  ],
}

之后在 scripts 目录下创建 dts-mv.ts 脚本实现这个功能。

// scripts/dts-mv.ts
import { join } from 'node:path';
import { readdir, cp } from 'node:fs/promises';

/** 以根目录为基础解析路径 */
const fromRoot = (...paths: string[]) => join(__dirname, '..', ...paths);

/** 包的 d.ts 产物目录 */
const PKGS_DTS_DIR = fromRoot('dist/packages');

/** 包的目录 */
const PKGS_DIR = fromRoot('packages');

/** 单个包的 d.ts 产物相对目录 */
const PKG_DTS_RELATIVE_DIR = 'dist';

/** 包的代码入口相对目录 */
const PKG_ENTRY_RELATIVE_DIR = 'src';

async function main() {
  const pkgs = await match();
  const tasks = pkgs.map(resolve);
  await Promise.all(tasks);
}

/** 寻找所有需要移动 dts 的包 */
async function match() {
  const res = await readdir(PKGS_DTS_DIR, { withFileTypes: true });
  return res.filter((item) => item.isDirectory()).map((item) => item.name);
}

/** 
 * 处理单个包的 dts 移动
 * @param pkgName 包名
 */
async function resolve(pkgName: string) {
  try {
    const sourceDir = join(PKGS_DTS_DIR, pkgName, PKG_ENTRY_RELATIVE_DIR);
    const targetDir = join(PKGS_DIR, pkgName, PKG_DTS_RELATIVE_DIR);
    const sourceFiles = await readdir(sourceDir);
    const cpTasks = sourceFiles.map((file) => {
      const source = join(sourceDir, file);
      const target = join(targetDir, file);
      console.log(`[${pkgName}]: moving: ${source} => ${target}`);
      return cp(source, target, {
        force: true,
        recursive: true,
      })
    })
    await Promise.all(cpTasks);
    console.log(`[${pkgName}]: moved successfully!`);  
  } catch (e) {
    console.log(`[${pkgName}]: failed to move!`);
  }
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
})

ts 脚本不能直接执行,要借助额外的工具,例如:tsxts-node。个人比较倾向于用 tsx,相较而言免配置,问题少。此外,由于 tsc 不具备清空输出目录的功能,为了避免混淆输出产物,我们可以选择安装工具 rimraf 来负责清空产物目录。

pnpm i -wD tsx

pnpm i -wD rimraf

具备了一切基础条件后,我们修改 package.json 里的相关脚本操作,将清空产物目录、构建类型、构建产物三个主要步骤按照合理的流程组合起来。只需执行一条 pnpm run build:ui 就可以完成整套构建流程。

失败
成功
失败
成功
开始
结束
清空类型产物目录 clean:type
清空各模块产物目录 vite默认行为
构建/检查类型 type:src
构建产物 build:ui
移动类型产物 mv-type

// package.json
{
  // ...
  "scripts": {
    // ...
+   "clean:type": "rimraf ./dist",
    "type:node": "tsc -p tsconfig.node.json --noEmit --composite false",
-   "type:src": "vue-tsc -p tsconfig.src.json --noEmit --composite false",
+   "type:src": "pnpm run clean:type && vue-tsc -p tsconfig.src.json --composite false --declaration --emitDeclarationOnly",
+   "mv-type": "tsx ./scripts/dts-mv.ts",
-   "build:ui": "pnpm run type:src && pnpm --filter ./packages/** run build",
+   "build:ui": "pnpm run type:src && pnpm --filter ./packages/** run build && pnpm run mv-type"
  },
}

当然,不要忘记给所有子包补充类型声明文件入口字段,这里以 button 组件为例:

// packages/button/package.json
{
  // ...
  "main": "./dist/openxui-button.umd.js",
  "module": "./dist/openxui-button.mjs",
- "types": "",
+ "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "require": "./dist/openxui-button.umd.js",
      "module": "./dist/openxui-button.mjs",
-     "types": ""
+     "types": "./dist/index.d.ts"
    }
  },
}

演示类型错误导致构建失败:

【从 0 到 1 搭建 Vue 组件库框架】2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下

演示构建流程成功:

【从 0 到 1 搭建 Vue 组件库框架】2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下

集成相关 IDE 插件

最后,推荐大家安装 Vue 官方推荐的 IDE 插件TypeScript Vue PluginVolar,分别对 Vue 开发提供了类型支持和语言特性支持,在插件市场中能够直接搜索到。

【从 0 到 1 搭建 Vue 组件库框架】2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下

安装完成后,为了让 VueTypeScript 配合地更好,支持导出组件的实例类型,建议按照官方推荐开启 Takeover 模式,相关阅读内容:

为组件模板引用标注类型

Volar Takeover 模式

既然结合了 IDE 插件,我们也要设身处地地为我们潜在的贡献者着想,将我们正在享受的优质体验也分享给他们。这就又需要先前提到的 .vscode 目录了,这次我们在其中建立两个新文件:

  • 建立 settings.json 文件,用与指定 IDE 配置,这些配置只会在本项目中生效。我们让 Volar 使用项目中安装的新版本 ts 而非全局安装或者 IDE 中内置的老版本。
  • 建立 extensions.json 文件,将两款新插件的 id 加入到推荐列表,IDE 会主动询问打开项目的新用户是否安装这些插件。
  • 后续涉及到其他 IDE 插件的使用时,我们还会回来频繁配置这两个文件。
// .vscode/settings.json
{
  "typescript.tsdk": "node_modules/typescript/lib"
}

// .vscode/extensions.json
{
  "recommendations": [
    "vue.volar",
    "vue.vscode-typescript-vue-plugin",
  ]
}

结尾与资料汇总

本以为集成两个构建工具是非常简单的事情,但是实际上却发散出了如此多的细节。在本文的最后,我们再梳理一下思路:

  1. 首先我们要在项目的根目录安装公共依赖,公司内网的项目可以使用 .npmrc 文件指定特殊网络环境下的 npm 配置,并提交到仓库中方便他人安装依赖。
  2. 接着我们为每一个子包预设了源码,填写了 vite.config 文件,在 package.json 中配置 build 构建脚本。添加 @vitejs/plugin-vue 插件可以使 Vite 识别 Vue SFC 语法;用 pnpm 过滤器选中所有子包执行 build 命令,可以达到整体构建的目的。
  3. 之后,我们在 monorepo 项目下搭建了一个 web 应用作为临时样例,展示我们的组件。
  4. 我们发现即使没有配置 TypeScript,仅仅 Vite 也能够成功构建 ts 代码。经过研究后,我们明确了 Vite 只负责转译,tsconfig 的配置大部分对于 Vite 是不生效的,这些配置主要影响 IDE 语言服务以及 tsc 的类型检查。
  5. 我们没有采用每个子项目一个 tsconfig 的组织方式,而是按照代码用途的区别(node脚本和源码)划分不同的 tsconfig 配置,在根目录下集中管理。但对于 demo 样例应用,由于其不参与集中构建,我们独立为其设置了 tsconfig.json
  6. 我们通过将 tsconfigpaths 路径别名设置得与 monorepo 下的包名一致,使得 IDE 将内部依赖解析到对应的源码而非产物,又对 Viteresolve.alias 别名做了同样的设置,最终我们的 demo 样例项目实现了热更新——修改其依赖的组件源码,能够立即反馈在页面上。
  7. vue-tsc 是 vue 语言服务的核心模块之一,我们用它实现了类型检查和声明文件 d.ts 导出。为了适应 monorepo 项目的目录结构,我们实现了一个脚本将集中的声明文件移动到对应模块的产物目录下。
  8. 完成 TypeScript 的集成后,我们进一步优化了先前的整体构建流程,通过 npm script 加入了清理产物、类型检查、导出类型声明的步骤,至此一个比较完善的组件库构建模式成型了。
  9. 最后,我们集成了 IDE 插件 VolarTypeScript Vue Plugin,开启了 takeover 模式,获得了编写 vue - ts 代码的最佳体验。还通过在 .vscode 目录下加入项目级 IDE 配置文件 settings.jsonextensions.json,引导其他贡献者安装插件,获取推荐的预设。

最后给大家留一个问题:我们导出的类型是否足够靠谱?能不能支持复杂的类型,例如作用域插槽的类型?甚至更进一步,能不能支持泛型组件:<script setup lang="ts" generic="T">?希望有好奇的同学能够在已有样例的基础上进一步尝试一下。

本章涉及到的相关资料汇总如下:

官网与文档:

Vue

Vite

Esbuild

TypeScript

tsconfig.json 选项说明

glob

vite-plugin-dts

vue-tsc

tsx

ts-node

rimraf

TypeScript Vue Plugin

Volar

分享博文:

前端工程化之强大的glob语法

glob 模式匹配简明教程

探究 tsconfig.node.json 文件和 references 字段的作用

Nest.js 这么大的项目是怎么优化 ts 编译性能的?

转载自:https://juejin.cn/post/7257517788205367351
评论
请登录