前端工程化-系列
Webpack
Rollup
Monorepo 单仓库多应用
集成 lint 代码规范工具
Git lint 相关

前言

最近想把之前在各个项目中封装好的 Vue 组件单独拿出来发布个组件库,但在原先的仓库中修修补补,总还有些问题。
使用 vite-plugin-dts 插件生成 dts 时会报错,TS写的组件库没有类型那可太糟糕了。

看了一圈,绝大部分组件库、各大开源软件都使用了一个叫 Menorepo 的东西,那就学学吧,少踩点坑。

示例仓库:qxchuckle/monorepo-test

参考:
初识Monorepo-转转技术团队
Monorepo 的前世今生
开源项目都在用 monorepo,但是你知道居然有那么多坑么?
【从 0 到 1 搭建 Vue 组件库框架】
带你了解更全面的 Monorepo - 优劣、踩坑、选型
为什么越来越多的项目选择 Monorepo?

没有前人的经验,摸索工程化的东西确实麻烦,毕竟这东西没有一个标准答案,项目结构、构建流程等,都需要根据实际情况来定制。

什么是 Menorepo

Menorepo单仓库多应用,是一种软件开发的策略模式、是管理项目代码的一种方式。

项目代码管理的演变:

  1. monolith(单仓库单应用):传统的单体式应用程序通常将所有的功能和模块打包在一起,形成一个单一的代码库和部署单元。这种单一的代码库包含了应用程序的所有部分,从前端界面到后端逻辑,甚至包括数据库模式和配置文件等。
    缺点:高度耦合,代码臃肿,难以实现部分更新和独立扩展的灵活性。
  2. multirepo(多仓库多应用):将功能模块、组件或服务分别存放在独立仓库中,可以实现独立的版本控制、构建、部署和发布,方便不同开发者进行并行开发和维护,减少代码冲突,简化协作流程。每个模块的发布与更新互不依赖,提高了开发和发布的灵活性。
    缺点:频繁发布、安装 npm 包过程繁琐,多仓维护成本高,同步依赖版本、统一环境存在问题。
  3. monorepo(单仓库多应用):保留了 monolith 单仓环境维护的便利性,同时满足 multirepo 多仓对于项目解耦的独立开发管理。在一个代码仓中,任意一个模块发生修改,另一个模块能够立即反馈而不用走繁琐的发布和依赖更新流程;各个模块之间也能够充分复用配置、CI 流程的脚本;各个包的版本和互相之间的依赖关系得到集中管理。

Menorepo 缺点:

  1. 单仓过大,git 管理耗时。
  2. 需要专门优化构建流程。
  3. 弱权限管理,git 不支持文件夹级权限管理。
  4. 强依赖第三方工具,需要选择一组工具实现。
  5. 弱隔离解耦,需避免产生网状依赖、保证每个包可以独立编译工作。
  6. 幽灵依赖。

适用场景

  1. 代码共享:当多个项目或模块之间需要共享代码、组件或工具库时。
  2. 统一版本管理:需要统一管理各个项目的版本依赖,确保一致性。
  3. 简化依赖管理:减少依赖安装和版本冲突,提高构建和部署效率。
  4. 协作与团队工作:团队成员可以更轻松地共享代码、协作开发和进行代码审查。
  5. 简化构建和部署:需要更方便地进行整体构建和部署,尤其对于有相互依赖关系的子项目。
  6. 敏捷开发和迭代:需要加快开发和迭代周期,避免在多个代码仓库之间切换和同步。

Menorepo 技术

前端没有大而统一的 Menorepo 框架,大部分工具以库形式提供。

核心技术:

  1. 包管理方案:npm、yarn、pnpm
  2. 包版本方案:LernaChangesets
  3. 包构建方案:Turborepo、Nx

辅助技术:

  1. 代码规范工具:Eslint、Prettier
  2. 提交规范工具:Commitlint、Commitizen

在后文,使用 pnpm Workspace 和 Lerna 实践 Menorepo。

项目结构

Menorepo 并没有统一的模板。下面是常见的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
├── packages/
│ ├── module1/
│ │ ├── src/
│ │ ├── tests/
│ │ ├── package.json
│ │ └── ...
│ └── module2/
│ ├── src/
│ ├── tests/
│ ├── package.json
│ └── ...
├── docs/
├── .gitignore
├── package.json
└── ...

并不只是简单地将各个项目都放在了同一个文件夹里。项目之间的关联、依赖、管理需要使用上述工具完成。

  1. 无需切换目录,在根目录下就可以给各个项目装包、运行。
  2. 项目间代码共享无需单独发包,package.json 中添加 ***: "workspace:*" 即可导入。
  3. 各个项目、模块依然可以独立发包。
  4. 自动版本控制、统一提交 commit。

在正式实践之前,不妨先看看成熟的组件库的代码组织形式,element-plus

Pnpm Workspace

Pnpm 通过 workspace(工作空间) 轻量化实现了 Monorepo,并能集成其它库进行扩展。pnpm 介绍

它要求在 root(代码仓根目录)中存在 pnpm-workspace.yaml 文件,用于指定哪些目录作为独立的工作空间(一个子模块或 npm 包)。pnpm 会读取这些目录下的 package.json 以确定一个模块。

1
2
3
4
# a 目录、b 目录、c 目录下的所有子目录,都会各自被视为独立的模块。
packages:
- a
- b/*

命令操作

在 workspace 模式下,root 通常不会作为一个 npm 包,而是作为一个管理中枢,执行一些全局操作,安装一些共有的依赖。

  1. pnpm i 安装 root 依赖以及所有工作空间的依赖。
  2. -w 在 monorepo 模式下的根目录进行操作。每个子包都能访问根目录的依赖。
  3. —filter -F 过滤子模块,对各个工作空间进行精细化操作。
1
2
3
4
5
6
# 为 a 包安装 lodash
pnpm --filter a i -S lodash
# 安装公共依赖
pnpm install -w xxx
# 发布所有包名为 @a/ 开头的包
pnpm --filter @a/* publish

—filter 筛选出多个包时,会首先分析多个包之间的内部依赖关系,按照依赖关系拓扑排序的顺序对这些包执行指令,即按依赖树从叶到根的顺序。

—filter 拥有多种筛选方式,可以根据包名、包路径、包的依赖关系、git 提交记录进行筛选。

1
2
3
4
5
6
7
8
9
10
11
12
13
# 为 a 以及 a 的所有依赖项执行测试脚本
pnpm --filter a... run test
# 为 b 以及依赖 b 的所有包执行测试脚本
pnpm --filter ...b run test

# 找出自 origin/master 提交以来所有变更涉及的包
# 为这些包以及依赖它们的所有包执行构建脚本
# README.md 的变更不会触发此机制
pnpm --filter="...{packages/**}[origin/master]"
--changed-files-ignore-pattern="**/README.md" run build

# 找出自上次 commit 以来所有变更涉及的包
pnpm --filter "...[HEAD~1]" run build

基本环境

理论结束,接下来创建 Pnpm Workspace 的项目结构,并集成 vitetypescript 开发 vue 组件库。

1、全局安装 pnpm,新建一个文件夹作为 root,并初始化。

1
2
3
npm install pnpm -g
mkdir pnpmWorkSpaceTest && cd pnpmWorkSpaceTest
pnpm init -y

2、root 下新建 pnpm-workspace.yaml,pnpm 会读取这个文件,指定哪些目录作为独立的工作空间。

pnpm-workspace.yaml
1
2
3
4
5
6
7
packages:
# 文档
- "docs"
# 主包,存放各个子模块
- "packages/*"
# 示例
- "demo"

3、创建项目基本结构。并初始化每个子模块的 package.jsonvite.config.ts
src 为模块源码、src/index.ts 为模块总出口、dist 为构建产物。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
├───📁 node_modules/ # 公共依赖,例如 vue、eslint 等。
├───📁 demo/ # 示例模块,无需发布
├───📁 docs/ # 文档模块,无需发布
│ └───📄 package.json
├───📁 packages/ # 主包目录,里面的每个模块通常都需要独立构建、发布
│ ├───📁 button/ # vue 按钮组件模块
│ │ ├───📁 dist/ # 模块构建产物
│ │ ├───📁 node_modules/
│ │ ├───📁 src/ # 模块源码
│ │ │ └───📄 index.ts # 模块总出口
│ │ ├───📄 package.json
│ │ └───📄 vite.config.ts
│ ├───📁 list/ # vue 列表组件模块
│ ├───📁 ui/ # ui 模块作为各个组件的统一出口
│ └───📁 utils/ # 公共方法模块
├───📁 scripts/ # 构建脚本
├───📄 .gitignore
├───📄 package.json
├───📄 pnpm-lock.yaml
├───📄 pnpm-workspace.yaml

设置 package.json

配置正确的 package.json 方便后续构建、发布流程,是实现 Monorepo 的关键。

根目录 package.json

修改 root 的 package.json,最小化如下。

package.json
1
2
3
4
5
6
7
8
{
"name": "pnpm-workspace-test",
// root 作为管理中枢,不需要发布
"private": true,
"scripts": {}, // 各种构建脚本
"devDependencies": {},
"dependencies": {}
}

子模块 package.json

packages 目录下的子模块,其 package.json 都如下:

  1. name@<组织名>/<子模块名> 命名。子模块名通常和文件夹名相同。
  2. peerDependencies 定义项目需要的依赖环境。常用于表示插件、子项目和主框架的关系。
  3. dependencies 除了公共依赖,子模块的运行依赖都安装在这里。
  4. devDependencies 开发依赖通常安装在 root,子模块一般无需此字段。
packages\list\package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"name": "@qx/list",
"main": "./dist/qx-list.umd.js",
"module": "./dist/qx-list.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"exports": {
".": {
"require": "./dist/qx-list.umd.js",
"module": "./dist/qx-list.mjs"
}
},
"scripts": {},
"dependencies": {},
"peerDependencies": {}
}

内部模块依赖管理

Monorepo 策略的一个目的就是方便内部模块之间的依赖管理。

例如:@qx/list 依赖 @qx/utils,可以直接在 @qx/list 的 package.json 中添加 workspace:^ 依赖。

packages\list\package.json
1
2
3
4
5
6
{
"name": "@qx/list",
"dependencies": {
"@qx/utils": "workspace:^"
}
}

也可以使用命令完成依赖的添加。
--workspace 不能简写,意思是在工作空间中找到依赖,而不是去 npm 仓库上查找。

1
pnpm --filter @qx/list i -S --workspace @qx/utils

workspace 版本号规则:major.minor.patch(主版本号.次版本号.修订号),在实际发布 npm 包时,会被替换成内部模块对应版本号。

  1. workspace:^ major 版本依赖,将被转为 ^x.x.x,会安装最新的 minor 版本。
  2. workspace:~ minor 版本依赖,将被转为 ~x.x.x,会安装最新的 patch 版本。
  3. workspace:* 依赖最新版本,将被转为 x.x.x,固定版本。

其它模块,如文档、示例等,通常是一个完整的项目,按正常配置即可。

集成 vite、typescript(上)

使用 vite 构建各个子模块。除了根目录,每个子模块都需要 vite.config.ts

除了 vite,后续还会在 scripts目录下写一些辅助构建的脚本,例如生成 dts、发布 npm 包等。
随着项目体量增大,可能还需要集成其它工具进行构建,如 Gulp、Lerna 等。

安装公共依赖

公共开发依赖统一安装在根目录下,各个子模块可以直接使用。
目标是开发一个 vue 组件库,所以还需要编译 vue 单文件组件 SFC的 vite 插件 @vitejs/plugin-vue
此外,按个人喜好,这里使用 sass 处理 CSS。

1
pnpm i -wD vite typescript @vitejs/plugin-vue sass

resolve-peers-from-workspace-root 默认为 true,使用根工作区项目的 dependencies 解析工作区中任何项目的 peer dependencies 。在该机制下,可以将公共生产依赖也安装在 root,并可以确保工作区中的所有项目都使用相同版本的依赖。

1
pnpm i -wS vue

编写、构建模块

下面以简单的 vue 组件与 utils 公共方法模块为例,演示 Monorepo 下的模块编写、构建流程。

公共方法模块

先为 @qx/utils 安装自有依赖。这里使用 lodash 作为示例,演示引入外部依赖的工程能力。

1
pnpm --filter @qx/utils i -S lodash @types/lodash

编写两个 hello 和 useLodash 两个公共方法文件,并统一在 src/index.ts 中导出。

@qx/utils 模块结构 packages\utils
1
2
3
4
5
6
7
8
├───📁 src/
│ ├───📄 hello.ts
│ ├───📄 index.ts
│ └───📄 useLodash.ts
├───📁 dist/
├───📁 node_modules/
├───📄 package.json
└───📄 vite.config.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// packages\utils\src\hello.ts
export function hello(to: string = "World") {
const txt = `Hello ${to}!`;
alert(txt);
return txt;
}

// packages\utils\src\useLodash.ts
import lodash from "lodash";
export function useLodash() {
return lodash;
}

// packages\utils\src\index.ts
export * from "./hello";
export * from "./useLodash";

编写 vite.config.ts
构建工具打包时默认行为,会将所有涉及模块的代码都一并集合到产物中。
如果是为 web 应用构建,这样做是合理的。但对于 npm 库来说,则会导致产物体积过大,且会引入一些不必要的依赖。在工程环境下,构建工具是可以识别模块引入语法的,无需将模块的外部依赖打包进产物

所以,需要配置 rollupOption.external,告诉构建工具哪些模块是外部依赖,不需要打包进产物,保留 import 语句

packages\utils\vite.config.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { defineConfig } from "vite";

export default defineConfig({
build: {
outDir: 'dist', // 产物输出目录,默认值 dist。
// 参考:https://cn.vitejs.dev/config/build-options.html#build-lib
lib: {
entry: "./src/index.ts", // 构建的入口文件
formats: ['es', 'umd'], // 产物的生成格式,默认为 ['es', 'umd']。
name: "QXUtils", // 当产物为 umd、iife 格式时,该模块暴露的全局变量名称
fileName: "qx-utils", // 产物文件名称
},
minify: false, // 是否压缩代码,在编写插件、库时通常无需压缩
rollupOptions: {
// 确保外部化处理那些你不想打包进库的依赖
external: [/lodash.*/],
output: {
// 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量。即使不设置,构建工具也会为我们自动生成。
/*
globals: {
lodash: 'lodash'
}
*/
},
},
},
});

外部化处理依赖对于库的开发者而言是一件非常严肃的事情,产物的大小会直接影响下游用户的使用体验。
在为 库 / npm 包 构建产物时,通常会将依赖项 dependencies、peerDependencies 字段下的依赖声明为 external(外部依赖),使这个依赖相关的源码不被整合进产物,而是保留着 import xxx from ‘pkg’ 的导入语句。

修改 package.json 编写构建脚本:

1
2
3
"scripts": {
"build": "vite build"
},

现在,可以构建 @qx/utils 模块了。
构建产物中 qx-utils.mjs 是 es 模块,qx-utils.umd.js 是 umd 模块。

1
2
3
4
5
6
7
> pnpm --filter @qx/utils run build
vite v5.4.3 building for production...
✓ 4 modules transformed.
dist/qx-utils.mjs 1.24 kB │ gzip: 0.45 kB
No name was provided for external module "lodash" in "output.globals" – guessing "lodash".
dist/qx-utils.umd.js 1.89 kB │ gzip: 0.67 kB
✓ built in 99ms

Vue 组件模块

编写 @qx/button 按钮组件模块,并依赖 @qx/utils 公共方法模块。

首先当然是声明所需要的依赖,并 pnpm i

1
2
3
4
5
6
7
8
9
10
11
12
{
"name": "@qx/button",
"scripts": {
"build": "vite build"
},
"dependencies": {
"@qx/utils": "workspace:^"
},
"peerDependencies": {
"vue": ">=3.0.0"
}
}

编写 button.vue 组件文件,引入 @qx/utils 模块,并使用其中的方法。
由于还没有配置 tsconfig.json 所以 IDE 会在导入处报错,但不影响构建。在集成完 TS 后会报错都会消失。

packages\button\src\button.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<script setup lang="ts">
import { hello } from "@qx/utils";

const props = withDefaults(
defineProps<{
text?: string;
}>(),
{
text: "world",
}
);

function clickHandler() {
hello(props.text);
}
</script>

<template>
<button class="qx-button" @click="clickHandler">
<slot></slot>
</button>
</template>

<style>
.qx-button {
color: red;
}
</style>

在 index.ts 中导出组件。

packages\button\src\index.ts
1
2
import Button from "./button.vue";
export { Button };

编写 vite.config.ts,配置构建选项。
使用正则将 @qx 开头的内部依赖项处理掉,便于未来可能增加的内部模块依赖。

packages\button\vite.config.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
plugins: [vue()], // 增加插件的使用
build: {
lib: {
entry: "./src/index.ts",
name: "QXButton",
fileName: "qx-button",
},
minify: false,
rollupOptions: {
external: [
// 除了 @qx/utils,未来可能还会依赖其他内部模块,不如用正则表达式将 @qx 开头的依赖项一起处理掉
/@qx.*/,
"vue",
],
},
},
});

构建信息:
CSS 被打包为了独立的 style.css 文件,若是每个组件都有各自的 CSS 文件,且散落在各个组件包内,是不便于导入的,后续会额外编写个构建脚本,统一处理所有组件的 CSS,现在就先这样吧。

1
2
3
4
5
6
7
8
9
vite v5.4.3 building for production...
✓ 4 modules transformed.
dist/style.css 0.03 kB │ gzip: 0.05 kB
dist/qx-button.mjs 0.60 kB │ gzip: 0.34 kB
No name was provided for external module "vue" in "output.globals" – guessing "vue".
No name was provided for external module "@qx/utils" in "output.globals" – guessing "utils".
dist/style.css 0.03 kB │ gzip: 0.05 kB
dist/qx-button.umd.js 1.06 kB │ gzip: 0.54 kB
✓ built in 236ms

ui 模块

除了 button 组件,可能还有 list、input 等组件,这些组件除了可以独立发布供用户安装外,还可以统一打包成一个 ui 组件库。

ui 模块要做的很简单,就是导入各个组件并导出。

packages\ui\src\index.ts
1
2
3
export * from "@qx/button";
export * from "@qx/utils";
export * from "@qx/list";
packages\ui\package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{
"name": "@qx/ui",
"main": "./dist/qx-ui.umd.js",
"module": "./dist/qx-ui.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"exports": {
".": {
"require": "./dist/qx-ui.umd.js",
"module": "./dist/qx-ui.mjs"
},
"./*": "./*"
},
"style": "dist/index.css",
"scripts": {
"build": "vite build"
},
"sideEffects": [
"dist/*"
],
"dependencies": {
"@qx/button": "workspace:^",
"@qx/utils": "workspace:^",
"@qx/list": "workspace:^"
},
"peerDependencies": {
"vue": ">=3.0.0"
}
}
packages\ui\vite.config.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { defineConfig } from "vite";

export default defineConfig({
build: {
lib: {
entry: "./src/index.ts",
name: "QXUI",
fileName: "qx-ui",
},
minify: false,
rollupOptions: {
external: [/@qx.*/],
},
},
});

构建信息:

1
2
3
4
5
6
7
8
vite v5.4.3 building for production...
✓ 1 modules transformed.
dist/qx-ui.mjs 0.08 kB │ gzip: 0.07 kB
No name was provided for external module "@qx/button" in "output.globals" – guessing "button".
No name was provided for external module "@qx/utils" in "output.globals" – guessing "utils".
No name was provided for external module "@qx/list" in "output.globals" – guessing "list".
dist/qx-ui.umd.js 1.24 kB │ gzip: 0.43 kB
✓ built in 71ms

整体构建

现在,各个模块的构建流程已经完成,可以一键构建所有模块了。

1
2
3
4
# 构建 packages 下的所有模块
pnpm --filter "./packages/**" run build
# 构建 @qx/ui 及其依赖的所有模块
pnpm --filter @qx/ui... run build

编写 root 的 package.json 脚本。

1
2
3
"scripts": {
"build:ui": "pnpm --filter ./packages/** run build"
},

从构建信息可以看到,构建顺序为:utils -> button & list(并行) -> ui,符合依赖树的拓扑排序。
由于使用了 rollupOptions.external 外部化了依赖,这个特性现在对我们而言无关紧要,但在未来定制完善的打包体系,需要研究全量构建时,拓扑排序的特性就会变得非常关键。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
> pnpm --filter ./packages/** run build
Scope: 4 of 7 workspace projects
packages/utils build$ vite build
│ The CJS build of Vite's Node API is deprecated. See https://vitejs.dev/guide/troubleshooting.html#vite-cjs-node-api-deprecated for more details.
│ vite v5.4.3 building for production...
│ transforming...
│ ✓ 4 modules transformed.
│ rendering chunks...
│ computing gzip size...
│ dist/qx-utils.mjs 1.24 kB │ gzip: 0.45 kB
│ No name was provided for external module "lodash" in "output.globals" – guessing "lodash".
│ dist/qx-utils.umd.js 1.89 kB │ gzip: 0.67 kB
│ ✓ built in 94ms
└─ Done in 519ms
packages/button build$ vite build
│ The CJS build of Vite's Node API is deprecated. See https://vitejs.dev/guide/troubleshooting.html#vite-cjs-node-api-deprecated for more details.
│ vite v5.4.3 building for production...
│ transforming...
│ ✓ 4 modules transformed.
[1 lines collapsed]
│ computing gzip size...
│ dist/style.css 0.03 kB │ gzip: 0.05 kB
│ dist/qx-button.mjs 0.60 kB │ gzip: 0.34 kB
│ No name was provided for external module "vue" in "output.globals" – guessing "vue".
│3No name was provided for external module "@qx/utils" in "output.globals" – guessing "utils".
│ ✓ 4 modules transformed.
│ rendering chunks...
│ computing gzip size...
│ dist/style.css 0.03 kB │ gzip: 0.05 kB
│ dist/qx-button.mjs 0.60 kB │ gzip: 0.34 kB kB
└─ Done in 791msovided for external module "vue" in "output.globals" – guessing "vue".
│ No name was provided for external module "@qx/utils" in "output.globals" – guessing "utils".
[3 lines collapsed]
│ ✓ 13 modules transformed.
│ rendering chunks...
│ computing gzip size...
│ dist/style.css 2.75 kB │ gzip: 0.55 kB
│ dist/qx-list.mjs 30.15 kB │ gzip: 6.00 kB
│ No name was provided for external module "vue" in "output.globals" – guessing "vue".
│ No name was provided for external module "@qx/utils" in "output.globals" – guessing "utils".
│ dist/style.css 2.75 kB │ gzip: 0.55 kB
│ dist/qx-list.umd.js 32.56 kB │ gzip: 6.20 kB
│ ✓ built in 655ms
└─ Done in 1.2s
packages/ui build$ vite build
[2 lines collapsed]
│ transforming...
│ ✓ 1 modules transformed.
│ rendering chunks...
│ computing gzip size...
│ dist/qx-ui.mjs 0.08 kB │ gzip: 0.07 kB
│ No name was provided for external module "@qx/button" in "output.globals" – guessing "button".
│ No name was provided for external module "@qx/utils" in "output.globals" – guessing "utils".
│ No name was provided for external module "@qx/list" in "output.globals" – guessing "list".
│ dist/qx-ui.umd.js 1.24 kB │ gzip: 0.43 kB
│ ✓ built in 66ms
└─ Done in 516ms

demo 演示模块

demo 模块是一个完整的 vue web 应用,用于演示我们的组件库。

这个项目结构,可再熟悉不过了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
├───📁 node_modules/
├───📁 src/
│ ├───📁 assets/
│ ├───📁 router/
│ ├───📁 views/
│ ├───📄 App.vue
│ └───📄 main.ts
├───📄 auto-imports.d.ts
├───📄 components.d.ts
├───📄 env.d.ts
├───📄 index.html
├───📄 package.json
├───📄 tsconfig.json
└───📄 vite.config.ts

这里将开发依赖都装到了 root 中,毕竟之后可能还有其它模块会用到。

demo\package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"name": "@qx/demo",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build"
},
"dependencies": {
"@qx/ui": "workspace:^"
},
"devDependencies": {},
"peerDependencies": {
"vue": ">=3.0.0"
}
}

编写 vite.config.ts

demo\vite.config.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { join } from "node:path";

import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import ElementPlus from "unplugin-element-plus/vite";
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";

export default defineConfig({
plugins: [
vue(),
ElementPlus({}),
AutoImport({
resolvers: [ElementPlusResolver()],
imports: ["vue", "vue-router"],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
});

至于源码,就是一个简单的 vue 应用,引入 @qx/ui 组件库,然后使用其中的组件。这里不多赘述。

样式丢失与热更新

当然现在还有个问题,样式丢了,且没有热更新(HMR)。

  1. 样式问题前面有提到过,CSS 都被打包为了独立的 style.css 文件,且 JS 并没有导入它,自然丢失了。
  2. vite 默认将 @qx/* 当做 npm 模块,在 node_modules 中查找,并根据模块的 package.json 定位到了 dist 下的构建产物,而不是源码,所以热更新失效了。

在修改了子模块后,demo 并不能实时更新,需要重新构建子模块,这是繁琐且不可接受的。Monorepo 的一个巨大优势就是模块的修改能够得到即刻反馈,我们需要通过配置实现该功能。

可以设置 vite.config 的 resolve.alias 路径别名(路径别名解析的优先级要高于 npm 模块解析),将 @qx/* 重定向到源码目录,这样就可以实现热更新了,且样式也不再丢失,因为 CSS 也是源码,同样在 vue SFC 中。

demo\vite.config.ts
1
2
3
4
5
6
7
8
9
10
11
export default defineConfig({
// ...
resolve: {
alias: [
{
find: /^@qx\/(.+)$/,
replacement: join(__dirname, "..", "packages", "$1", "src"),
},
],
},
});

需要如此设置 alias 别名的,只有需要及时热更新的 web 应用模块,如 demo、docs 等。对于库模块则不需要:

  1. 子模块的依赖在打包时都外部化处理了,依赖项实际上并不会被 Vite 读取到。
  2. 即使 Vite 可能读取到依赖项,但我们批量打包组件时,pnpm 会做好拓扑排序处理,永远确保被依赖者先完成打包,依赖者后完成打包。

集成 vite、typescript(下)

在已经安装好 typescript 公共依赖的情况下,集成 TS 就是编写 tsconfig.json 文件

TypeScript 本身内容,详见 TypeScript 笔记

TS 在工程中的位置

在之前的内容中,我们仅是安装好了相关的依赖,且无视了导入内部模块的 IDE 报错,甚至 tsconfig.json 文件都没有,但仍然能够顺利完成构建。

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

Vite 采用 Rollup、Esbuild 双引擎架构,Esbuild 有着超快的编译速度,它在其中负责第三方库构建和 TS/JSX 语法编译。无论是构建模式还是开发服务器模式,Vite 都通过 Esbuild 将 ts 文件转译为 js。Vite 总是将去除了所有类型检查配置的最小 tsconfig 交给 Esbuild,只确保生成对应的 js 产物,所以在没有 tsconfig.json 的情况下,Vite 也能正常工作,而 tsconfig 中也只有少数几个配置项能够影响到 Vite 的构建行为。

“假定类型检查已经被你的 IDE 或构建过程处理了。”现在目标就很清晰了:

  1. IDE 会根据 tsconfig.json 文件进行类型检查,但并不会影响构建。
  2. 我们需要在构建脚本中,额外使用 tsc 等工具进行类型检查,以确保代码质量。

tsconfig 文件

每个 tsconfig.[name].json 都管理着一个文件集合

  1. include 字段声明文件集合,除了 node_modules 中的三方依赖,每个被引用的源码文件都要被包含进来。
  2. exclude 声明集合中需要排除的文件。
  3. include 与 exclude 字段都通过 glob 语法进行文件匹配
  4. compilerOptions 是编译选项,控制 TypeScript 编译器处理该文件集合的策略与行为。

IDE 只读取 tsconfig.json 文件,tsconfig.[name].json 最终都需要集成到一个 tsconfig.json 中。

通过项目引用(Project References),可以将多个 compilerOptions.composite = true 的 tsconfig 聚合在一起。

tsconfig.json 聚合 tsconfig
1
2
3
4
5
6
7
8
"references": [
{
"path": "./tsconfig.src.json"
},
{
"path": "./tsconfig.node.json"
}
],

项目引用功能提供了为项目的不同部分应用不同 tsconfig 的能力,为 Monorepo 项目提供了很大的便利,形成 tsconfig 分治策略。

通过该命令,可以查看最终的 TypeScript 编译器配置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
> npx tsc -p tsconfig.src.json --showConfig

{
"compilerOptions": {
# ...
},
# 可以清晰看到文件集合包含了哪些文件
"files": [
"./packages/button/src/index.ts",
"./packages/list/src/index.ts",
"./packages/ui/src/index.ts",
"./packages/utils/src/functionControlUtils.ts",
"./packages/utils/src/hello.ts",
"./packages/utils/src/index.ts",
"./packages/utils/src/useLodash.ts"
],
"include": [
"env.d.ts",
"packages/**/src"
],
"exclude": [
"C:/chuckle/qx/study_demo/Monorepo/pnpmWorkspaceTest/dist"
]
}

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

tsconfig 分治策略

我们当然可以为每个子模块都建立一个 tsconfig 文件,分散在各个包中管理,但这样会导致大量重复的配置,且不利于统一管理。

应当参考 element-plus将功能相似的代码划分到同个 tsconfig 中,集中在根目录下管理

1、编译选项 compilerOptions 大部分都是重复的,新建 tsconfig.base.json 作为基础配置文件,供其他配置文件通过 extends 字段继承。

tsconfig.base.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
{
"compilerOptions": {
// 项目的根目录
"rootDir": ".",
// 项目基础目录
"baseUrl": ".",
// tsc 编译产物输出目录
"outDir": "dist",
// 编译目标 js 的版本
"target": "es2022",
// 生成代码的模板标准
"module": "esnext",
// 模块解析策略
"moduleResolution": "node",
// 是否生成辅助 debug 的 .map.js 文件。
"sourceMap": false,
// 产物不消除注释
"removeComments": false,
// 严格模式类型检查,建议开启
"strict": true,
// 不允许有未使用的变量
"noUnusedLocals": false,
// 允许引入 .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": {
"@qx/*": [
"packages/*/src"
]
}
}
}

2、将所有 node 环境下执行的脚本、配置文件划分到 tsconfig.node.json
因为集成了 Node.js 库函数的类型声明,"types": ["node"],所以需要 pnpm i -wD @types/node

tsconfig.node.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
// 继承基础配置
"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.*",
"scripts"
],
"exclude": [
// 暂时先排除产物目录,packages/xxx/dist/x.config.js 或者 node_modules/pkg/x.config.js 不会被包含进来
"**/dist",
"**/node_modules"
]
}

3、对于所有模块中 src 目录下的源码文件,几乎都是组件库的实现代码,大多要求浏览器环境下特有的 API(例如 DOM API),且相互之间存在依赖关系。都划分到 tsconfig.src.json

tsconfig.src.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
// 继承基础配置
"extends": "./tsconfig.base.json",
"compilerOptions": {
"composite": true,
// 组件库依赖浏览器的 DOM API
"lib": [
"ESNext",
"DOM",
"DOM.Iterable"
],
"types": [
"node"
],
},
"include": [
"env.d.ts",
"packages/**/src"
],
}

env.d.ts 存放了一些全局类型声明。

env.d.ts
1
2
3
// 让 TypeScript 对于 Vite 的一些特定功能提供类型定义
// https://cn.vitejs.dev/guide/env-and-mode#intellisense
/// <reference types="vite/client" />

4、tsconfig.[name].json 聚合到 tsconfig.json 中。

tsconfig.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"compilerOptions": {
"target": "es2022",
"moduleResolution": "node",
// vite 会读取到这个 tsconfig 文件(位于工作空间根目录),按照其推荐配置这两个选项
// https://cn.vitejs.dev/guide/features.html#typescript-compiler-options
"isolatedModules": true,
"useDefineForClassFields": true
},
"types": [],
"files": [],
"references": [
// 聚合 ts project
{
"path": "./tsconfig.src.json"
},
{
"path": "./tsconfig.node.json"
}
],
}

compilerOptions.paths

在 IDE 在读取到 tsconfig.json 文件后,原本的导入报错应该都消失了。这是因为正确设置了 compilerOptions.paths

tsconfig.base.json
1
2
3
4
5
6
7
8
{
"compilerOptions": {
// ...
"paths": {
"@qx/*": ["packages/*/src"]
}
}
}

paths 提供了别名转换的功能,可以将 import 导入语句如下转换:

1
2
3
4
// 原语句
import { hello } from '@qx/utils'
// ts 编译时转换为
import { hello } from '<rootPath>/<baseUrl>/packages/utils/src'

对于内部模块的依赖,配置 paths,将其转为源码路径是很有必要的
在没有设置 paths 时,typescript 会去 node_modules 中找模块,若还未构建该模块,则自然会报错找不到模块的导出,并且现在我们还没有生成 d.ts 文件,也会报错找不到该模块的类型。而在设置 paths 后,typescript 定位到了源码路径,自然没有上述的问题了。

结合之前 vite 的 resolve.alias 配置,我们可以得出结论,对于内部模块,最好都在构建工具、tsconfig中,将其路径别名设置为源码路径

demo 模块的 tsconfig

demo、docs 等模块是一个完整的 web 应用,相对独立,可以拥有独立的 tsconfig。

demo\tsconfig.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
// 集成基础配置
"extends": "../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
// Web 应用需要 DOM 环境
"lib": [
"ESNext",
"DOM",
"DOM.Iterable"
],
// Web 应用不需要 node 相关方法
"types": [
"element-plus/global"
],
// baseUrl 改变了,基础配置中的 paths 也需要一并重写
"paths": {
"@/*": [
"src/*"
],
"@qx/*": [
"../packages/*/src"
]
}
},
"include": [
// demo 应用会引用其他子模块的源码,因此都要包含到 include 中
"../packages/*/src",
"./*.d.ts",
"env.d.ts",
"src"
]
}

需要注意,由于 baseUrl 改变了,基础配置中的 paths 也需要一并重写。

TypeScript 类型检查

通过 tsc 命令指定对应的 tsconfig 文件,就能对其文件集合进行类型检查。

1
2
3
# 指定 tsconfig.src.json 检查源码文件。
npx tsc -p tsconfig.src.json --noEmit --composite false
# --noEmit 使构建产物不被输出,--composite false 使得 buildInfo 文件不被输出。

由于源码是 Vue 组件,使用 tsc 命令会报错,需要使用 vue-tsc。

1
2
pnpm i -wD vue-tsc
npx vue-tsc -p tsconfig.src.json --noEmit --composite false

编写 package.json 脚本。

package.json
1
2
3
4
5
6
7
8
9
// 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 run type:src && pnpm --filter ./packages/** run build"
},
}

生成 d.ts 类型声明文件

这里就是前言中所说的,令我头疼的地方了。
看了别人的文章后才知道 vite-plugin-dts 插件并不适合 Monorepo 环境。该插件在迭代的过程中做了太多兼容以及细节处理,已经过于复杂,内部的路径解析总是出现各种各样的问题。
在 3.0.0 版本后,其内部生成 d.ts 的机制已经改为 vue-tsc 实现,不如直接使用 vue-tsc。

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

1
"type:src": "vue-tsc -p tsconfig.src.json --composite false --declaration --emitDeclarationOnly",

所有的产物都会被生成到 outDir 字段指定的根目录下的 dist。但我们需要的是对应的 d.ts 文件放到对应模块的 dist 目录中。

dist
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
└───📁 packages/
├───📁 button/
│ └───📁 src/
│ ├───📄 button.vue.d.ts
│ └───📄 index.d.ts
├───📁 list/
│ └───📁 src/
│ ├───📄 EstimatedVirtualList.vue.d.ts
│ ├───📄 index.d.ts
│ ├───📄 VirtualList.vue.d.ts
│ ├───📄 VirtualWaterFallList.vue.d.ts
│ └───📄 WaterFallList.vue.d.ts
├───📁 ui/
│ └───📁 src/
│ └───📄 index.d.ts
└───📁 utils/
└───📁 src/
├───📄 functionControlUtils.d.ts
├───📄 hello.d.ts
├───📄 index.d.ts
└───📄 useLodash.d.ts

观察产物可以发现,其目录结构与 packages 的结构是一致的,所以很容易可以移动它们到正确位置。

scripts\utils.ts 将通用的内容提取出来。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { join } from "node:path";

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

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

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

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

/** 包的代码入口相对目录 */
export const PKG_ENTRY_RELATIVE_DIR = "src";
scripts\dts-mv.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 将 vue—tsc 编译产物 d.ts 移动到 packages 对应模块的 dist 目录下
import { join } from "node:path";
import { readdir, cp } from "node:fs/promises";
import {
fromRoot,
PKGS_DIR,
PKGS_DTS_DIR,
PKG_ENTRY_RELATIVE_DIR,
PKG_DTS_RELATIVE_DIR,
} from "./utils";

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);
});

修改 package.json 完善脚本。
使用 tsx 执行该 ts 文件,使用 rimraf 清除原本产物。

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

现在,只需要运行 build:ui 脚本,即可完成项目构建。

处理 CSS 文件

虽然在前面通过设置了 vite 的路径别名,使得样式不再丢失,但本质上是因为定位到了源码,而安装组件库的用户并不会直接引入源码,而是引入构建产物,所以仍然会丢失样式。

解决起来也简单,仿照 d.ts 的处理,将 CSS 文件都移动到 ui 模块的 dist 目录下,并重命名为各自模块名方便用户按需导入,最后还需要组装一个 index.css 文件,提供完整导入。

scripts\css-mv.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// 将 packages 中的各个包(除了ui文件夹)的 dist 目录下的 style.css 移动并改名到 packages/ui/dist/<对应包名>.css
import { join } from "node:path";
import { readdir, cp, stat, readFile } from "node:fs/promises";
import { createWriteStream } from "node:fs";
import { fromRoot, PKGS_DIR } from "./utils";

/** UI 包的目录 */
export const UI_DIST_DIR = join(PKGS_DIR, "ui", "dist");

/** 获取所有包的名称 */
const getPackages = async () => {
const files = await readdir(PKGS_DIR);
return files.filter((file) => file !== "ui");
};

/** 检查文件是否存在 */
const fileExists = async (path: string) => {
try {
await stat(path);
return true;
} catch {
return false;
}
};

// 去除 css 文件中的 @charset 声明
const removeCharsetDeclaration = async (cssContent: string) => {
const charsetPattern = /@charset "[^"]+";\s*/g;
return cssContent.replace(charsetPattern, "");
};

/** 移动并重命名 style.css 文件、生成 index.css */
const moveAndRenameCss = async () => {
const packages = await getPackages();
const writeStream = createWriteStream(
join(UI_DIST_DIR, "index.css"),
"utf-8"
);
writeStream.write(`@charset "UTF-8";\n`);

const promises = packages.map(async (pkg) => {
const src = join(PKGS_DIR, pkg, "dist", "style.css");
if (!(await fileExists(src))) {
return;
}
const css = await readFile(src, "utf-8");
const cssWithoutCharset = await removeCharsetDeclaration(css);
writeStream.write(cssWithoutCharset);
const dest = join(UI_DIST_DIR, `${pkg}.css`);
await cp(src, dest);
console.log(`[${pkg}.css]: moved successfully!`);
});
await Promise.all(promises);
console.log("[index.css]: created successfully!");
writeStream.end();
writeStream.close();
};

moveAndRenameCss().catch(console.error);

最终 ui 模块的构建产物如下:

1
2
3
4
5
6
├───📄 button.css
├───📄 index.css
├───📄 index.d.ts
├───📄 list.css
├───📄 qx-ui.mjs
└───📄 qx-ui.umd.js

在需要时,如下导入 CSS 即可。

1
import "@qx/ui/dist/index.css";

修改完善 package.json 脚本。

package.json
1
2
3
4
5
6
7
8
"scripts": {
"clean:type": "rimraf ./dist",
"type:node": "tsc -p tsconfig.node.json --noEmit --composite false",
"type:src": "pnpm run clean:type && vue-tsc -p tsconfig.src.json --composite false --declaration --emitDeclarationOnly",
"dts-mv": "tsx ./scripts/dts-mv.ts",
"css-mv": "tsx ./scripts/css-mv.ts",
"build:ui": "pnpm run type:src && pnpm --filter ./packages/** run build && pnpm run dts-mv && pnpm run css-mv"
},