前言 最近想把之前在各个项目中封装好的 Vue 组件单独拿出来发布个组件库,但在原先的仓库中修修补补,总还有些问题。 使用 vite-plugin-dts 插件生成 dts 时会报错,TS写的组件库没有类型那可太糟糕了。
看了一圈,绝大部分组件库、各大开源软件都使用了一个叫 Menorepo 的东西,那就学学吧,少踩点坑。
示例仓库:qxchuckle/monorepo-test
参考: 初识Monorepo-转转技术团队 Monorepo 的前世今生 开源项目都在用 monorepo,但是你知道居然有那么多坑么? 【从 0 到 1 搭建 Vue 组件库框架】 带你了解更全面的 Monorepo - 优劣、踩坑、选型 为什么越来越多的项目选择 Monorepo?
没有前人的经验,摸索工程化的东西确实麻烦,毕竟这东西没有一个标准答案,项目结构、构建流程等,都需要根据实际情况来定制。
什么是 Menorepo Menorepo 即单仓库多应用 ,是一种软件开发的策略模式 、是管理项目代码的一种方式。
项目代码管理的演变:
monolith (单仓库单应用):传统的单体式应用程序通常将所有的功能和模块打包在一起,形成一个单一的代码库和部署单元。这种单一的代码库包含了应用程序的所有部分,从前端界面到后端逻辑,甚至包括数据库模式和配置文件等。 缺点:高度耦合,代码臃肿,难以实现部分更新和独立扩展的灵活性。
multirepo (多仓库多应用):将功能模块、组件或服务分别存放在独立仓库中,可以实现独立的版本控制、构建、部署和发布,方便不同开发者进行并行开发和维护,减少代码冲突,简化协作流程。每个模块的发布与更新互不依赖,提高了开发和发布的灵活性。 缺点:频繁发布、安装 npm 包过程繁琐,多仓维护成本高,同步依赖版本、统一环境存在问题。
monorepo (单仓库多应用):保留了 monolith 单仓环境维护的便利性,同时满足 multirepo 多仓对于项目解耦的独立开发管理。在一个代码仓中,任意一个模块发生修改,另一个模块能够立即反馈而不用走繁琐的发布和依赖更新流程;各个模块之间也能够充分复用配置、CI 流程的脚本;各个包的版本和互相之间的依赖关系得到集中管理。
Menorepo 缺点:
单仓过大,git 管理耗时。
需要专门优化构建流程。
弱权限管理,git 不支持文件夹级权限管理。
强依赖第三方工具,需要选择一组工具实现。
弱隔离解耦,需避免产生网状依赖、保证每个包可以独立编译工作。
幽灵依赖。
适用场景
代码共享:当多个项目或模块之间需要共享代码、组件或工具库时。
统一版本管理:需要统一管理各个项目的版本依赖,确保一致性。
简化依赖管理:减少依赖安装和版本冲突,提高构建和部署效率。
协作与团队工作:团队成员可以更轻松地共享代码、协作开发和进行代码审查。
简化构建和部署:需要更方便地进行整体构建和部署,尤其对于有相互依赖关系的子项目。
敏捷开发和迭代:需要加快开发和迭代周期,避免在多个代码仓库之间切换和同步。
Menorepo 技术 前端没有大而统一的 Menorepo 框架,大部分工具以库形式提供。
核心技术:
包管理方案:npm、yarn、pnpm
包版本方案:Lerna 、Changesets
包构建方案:Turborepo、Nx
辅助技术:
代码规范工具:Eslint、Prettier
提交规范工具: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 └── ...
并不只是简单地将各个项目都放在了同一个文件夹里。项目之间的关联、依赖、管理需要使用上述工具完成。
无需切换目录,在根目录下就可以给各个项目装包、运行。
项目间代码共享无需单独发包,package.json 中添加 ***: "workspace:*"
即可导入。
各个项目、模块依然可以独立发包。
自动版本控制、统一提交 commit。
在正式实践之前,不妨先看看成熟的组件库的代码组织形式,element-plus 。
Pnpm Workspace Pnpm 通过 workspace(工作空间) 轻量化实现了 Monorepo,并能集成其它库进行扩展。pnpm 介绍
它要求在 root(代码仓根目录) 中存在 pnpm-workspace.yaml 文件,用于指定哪些目录作为独立的 工作空间(一个子模块或 npm 包)。pnpm 会读取这些目录下的 package.json 以确定一个模块。
命令操作 在 workspace 模式下,root 通常不会作为一个 npm 包,而是作为一个管理中枢 ,执行一些全局操作,安装一些共有的依赖。
pnpm i
安装 root 依赖以及所有工作空间的依赖。
-w
在 monorepo 模式下的根目录进行操作。每个子包都能访问根目录的依赖。
—filter -F
过滤子模块,对各个工作空间进行精细化操作。
1 2 3 4 5 6 pnpm --filter a i -S lodash pnpm install -w xxx pnpm --filter @a/* publish
当 —filter 筛选出多个包时,会首先分析多个包之间的内部依赖关系,按照依赖关系拓扑排序 的顺序对这些包执行指令,即按依赖树从叶到根 的顺序。
—filter 拥有多种筛选方式,可以根据包名、包路径、包的依赖关系、git 提交记录进行筛选。
1 2 3 4 5 6 7 8 9 10 11 12 13 pnpm --filter a... run test pnpm --filter ...b run test pnpm --filter="...{packages/**}[origin/master]" --changed-files-ignore-pattern="**/README.md" run build pnpm --filter "...[HEAD~1]" run build
基本环境 理论结束,接下来创建 Pnpm Workspace 的项目结构,并集成 vite 、typescript 开发 vue 组件库。
1、 全局安装 pnpm,新建一个文件夹作为 root ,并初始化。
1 2 3 npm install pnpm -g mkdir pnpmWorkSpaceTest && cd pnpmWorkSpaceTestpnpm init -y
2、 在 root 下新建 pnpm-workspace.yaml ,pnpm 会读取这个文件,指定哪些目录作为独立的工作空间。
pnpm-workspace.yaml 1 2 3 4 5 6 7 packages: - "docs" - "packages/*" - "demo"
3、 创建项目基本结构。并初始化每个子模块的 package.json 、vite.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/ ├───📁 demo/ ├───📁 docs/ │ └───📄 package.json ├───📁 packages/ │ ├───📁 button/ │ │ ├───📁 dist/ │ │ ├───📁 node_modules/ │ │ ├───📁 src/ │ │ │ └───📄 index.ts │ │ ├───📄 package.json │ │ └───📄 vite.config.ts │ ├───📁 list/ │ ├───📁 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" , "private" : true , "scripts" : { } , "devDependencies" : { } , "dependencies" : { } }
子模块 package.json packages 目录下的子模块,其 package.json 都如下:
name 以 @<组织名>/<子模块名>
命名。子模块名通常和文件夹名相同。
peerDependencies 定义项目需要的依赖环境。常用于表示插件、子项目和主框架的关系。
dependencies 除了公共依赖,子模块的运行依赖都安装在这里。
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 包时,会被替换成内部模块对应版本号。
workspace:^
major 版本依赖,将被转为 ^x.x.x,会安装最新的 minor 版本。
workspace:~
minor 版本依赖,将被转为 ~x.x.x,会安装最新的 patch 版本。
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 ,并可以确保工作区中的所有项目都使用相同版本的依赖。
编写、构建模块 下面以简单的 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 export function hello (to: string = "World" ) { const txt = `Hello ${to} !` ; alert (txt); return txt; } import lodash from "lodash" ;export function useLodash ( ) { return lodash; } 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' , lib : { entry : "./src/index.ts" , formats : ['es' , 'umd' ], name : "QXUtils" , fileName : "qx-utils" , }, minify : false , rollupOptions : { external : [/lodash.*/ ], output : { }, }, }, });
外部化处理依赖对于库的开发者而言是一件非常严肃的事情,产物的大小会直接影响下游用户的使用体验。 在为 库 / 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.*/ , "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 pnpm --filter "./packages/**" run build 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 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)。
样式问题前面有提到过,CSS 都被打包为了独立的 style.css 文件,且 JS 并没有导入它,自然丢失了。
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 等。对于库模块则不需要:
子模块的依赖在打包时都外部化处理了,依赖项实际上并不会被 Vite 读取到。
即使 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 或构建过程处理了。” 现在目标就很清晰了:
IDE 会根据 tsconfig.json 文件进行类型检查,但并不会影响构建。
我们需要在构建脚本中,额外使用 tsc 等工具进行类型检查,以确保代码质量。
tsconfig 文件 每个 tsconfig.[name].json
都管理着一个文件集合 :
include
字段声明文件集合 ,除了 node_modules 中的三方依赖,每个被引用的源码文件都要被包含进来。
exclude
声明集合中需要排除 的文件。
include 与 exclude 字段都通过 glob 语法进行文件匹配
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" : "." , "outDir" : "dist" , "target" : "es2022" , "module" : "esnext" , "moduleResolution" : "node" , "sourceMap" : false , "removeComments" : false , "strict" : true , "noUnusedLocals" : false , "resolveJsonModule" : true , "allowSyntheticDefaultImports" : true , "esModuleInterop" : true , "isolatedModules" : true , "skipLibCheck" : true , "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" : { "composite" : true , "lib" : [ "ESNext" ] , "types" : [ "node" ] , "allowJs" : true } , "include" : [ "**/*.config.*" , "scripts" ] , "exclude" : [ "**/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 , "lib" : [ "ESNext" , "DOM" , "DOM.Iterable" ] , "types" : [ "node" ] , } , "include" : [ "env.d.ts" , "packages/**/src" ] , }
env.d.ts
存放了一些全局类型声明。
env.d.ts
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" , "isolatedModules" : true , "useDefineForClassFields" : true } , "types" : [ ] , "files" : [ ] , "references" : [ { "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' 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" : "." , "lib" : [ "ESNext" , "DOM" , "DOM.Iterable" ] , "types" : [ "element-plus/global" ] , "paths" : { "@/*" : [ "src/*" ] , "@qx/*" : [ "../packages/*/src" ] } } , "include" : [ "../packages/*/src" , "./*.d.ts" , "env.d.ts" , "src" ] }
需要注意,由于 baseUrl 改变了,基础配置中的 paths 也需要一并重写。
TypeScript 类型检查 通过 tsc 命令指定对应的 tsconfig 文件,就能对其文件集合进行类型检查。
1 2 3 npx tsc -p tsconfig.src.json --noEmit --composite false
由于源码是 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 { "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" );export const PKGS_DTS_DIR = fromRoot ("dist/packages" );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 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); } async function match ( ) { const res = await readdir (PKGS_DTS_DIR , { withFileTypes : true }); return res.filter ((item ) => item.isDirectory ()).map ((item ) => item.name ); } 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 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" ;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 ; } }; const removeCharsetDeclaration = async (cssContent: string ) => { const charsetPattern = /@charset "[^"]+";\s*/g ; return cssContent.replace (charsetPattern, "" ); }; 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" } ,