likes
comments
collection

Element Plus 组件库相关技术揭秘:2. 组件库工程化实战之 Monorepo 架构搭建

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

前言

工程化是目前前端领域比较热门的技术探讨领域,工程化具体是什么每人都有不同的定义,你工作年限的不同,在项目中担任的角色的不同,都会影响你对工程化的定义。工程化不是一门具体的技术,而是一系列技术的组合,总而言之就是通过一系技术的应用,为你的项目从开发到生产、到迭代各个环节能够顺利产出而服务。为什么每个人都对工程化的定义都有所不同?是因为不同的项目具体运营到的工程化技术也有所不同,所以每个人项目经验的不同也会影响其对工程化的定义。接下来将会详细了解一下一个组件库的项目结构、开发环境是如何搭建出来的,项目的搭建也是很多人对前端工程化最初的印象和定义,但这个不是前端工程化的全部,只是开始。

现在前端开发越来越多的项目和工程都采用 monorepo 的方式来组织管理代码。著名的 Vue3 及 Element Plus 组件库都是采用 monorepo 架构。所以我们有必要去了解和实践这种架构项目。

monorepo 指的是一种代码项目结构的组织方式,mono 指的是单个,repo 指的是仓库,总的就是说单个仓库。顾名思义就是把所有相关的项目都放到一个仓库中进行管理。

简单来说有以下优点:

  • 共用基础设置,不用重复配置
  • 有依赖的项目之间调试开发非常方便
  • 第三方库的版本管理更简单

monorepo 的实现方式有很多,我们这里只讲解 pnpm 的方式,因为 Vue3 以及 Element Plus 组件库最新的实现都是采用了 pnpm 的方式。

接下来就让我们更深入地了解这种架构模式吧。

从 Vue3 源码项目入门理解 pnpm 的 monorepo

在上一篇文章中我们已经了解到 Vue3 在模块的拆分和设计上做得非常合理,模块之间的耦合度非常低,很多模块可以独立安装使用,而不需要依赖完整的 Vue3 运行时。那么它是怎么做到的呢?除了在代码功能上的进行设计之外,其中最重的就是使用 monorepo 对项目代码进行组织管理。

Vue3 中的 monorepo

Vue3 已经从之前的 yarn 的 monorepo 换成了 pnpm 的 monorepo,所以我们这里重点了解 pnpm 的 monorepo。

pnpm 跟 npm、yarn 一样,也内置了对 monorepo 的支持,使用起来比较简单,在项目根目录中新建 pnpm-workspace.yaml 文件,并声明对应的工作区就可以了。

packages:
  - 'packages/*'

表示 packages/* 这个目录下面所有的文件为 workspace 的内容。

Element Plus 组件库相关技术揭秘:2. 组件库工程化实战之 Monorepo 架构搭建

从上图可以看到 Vue3 源码整体是通过 monorepo 方式进行管理,并根据功能的不同在 packages 目录下进行划分不同的模块目录。我们可以看到每一个目录下面都一个 package.json 文件,代表每一个目录都是一个 npm 包,每个包有各自的 API、类型定义和测试模块以及 Readme 文档。这样就可以将模块拆分得更细的颗粒度,职责划分也更明确。

Vue3 源码的目录结构设计

根据功能的不同在 packages 目录下进行划分不同的模块目录,具体如下:

  • compiler-core 模板解析核心,与具体环境无关,主要生成 AST,并根据 AST 生成 render() 函数
  • compiler-dom 浏览器环境中的模板解析逻辑,如处理 HTML 转义、处理 v-model 等指令
  • compiler-sfc 负责解析 Vue 单文件组件
  • compiler-ssr 服务端渲染环境中的模板解析逻辑
  • reactivity 响应式数据相关逻辑
  • runtime-core 与平台无关的运行时核心
  • runtime-dom 浏览器环境中的运行时核心
  • runtime-test 用于自动化测试的相关配套
  • server-renderer 用于 SSR 服务端渲染的逻辑
  • shared 一些各个包之间共享的公共工具
  • size-check 一个用于测试 tree shaking 后代码大小的示例库
  • template-explorer 用于检查模板编译后的输出,主要用于开发调试
  • vue Vue3 的主要入口,包含不同版本的包

Vue3 源码包最核心的依赖关系图(来自官方):

Element Plus 组件库相关技术揭秘:2. 组件库工程化实战之 Monorepo 架构搭建

我们可以看到 Vue3 源码的模块之间的依赖关系是非常清晰的,使得开发人员更容易阅读、理解和更改所有的模块内容,提高了代码的可维护性。

由于所有的项目放在一个仓库当中,代码逻辑复用是非常方便,如果有依赖的代码变动,那么用到这个依赖的项目当中会立马感知到。这又是怎么做到的呢?普通项目可以通过相对路径进行引用,但我们这里的设想是每一个包都是独立的,如果是通过相对路径进行引用的话,那么就很耦合了。我们可以通过 Workspace 协议进行模块之间的相互依赖,这样就达到了解耦的目的了。

此外 Vue 是分运行时编译时,从上图的源码目录结构上我们就可以看成端倪来了。@vue/runtime-xxx 系列的包是不直接引用 @vue/compiler-xxx 系列的包,这样在就保证了它们之间不存在强关联,仅从入口文件 vue 进行关联,从而达到区分运行时编译时

Workspace 协议,模块之间的相互依赖

我们在上一章《Element Plus 组件库相关技术揭秘:1. Vue3 组件库的设计和实现原理》中的手写 Vue3 组件原理的时候,响应式方面的功能都是使用 @vue/reactivity 包的,是需要从 npm registry 进行独立安装的。当在本地的时候,只需要进行以下设置:

{
    "@vue/reactivity": "workspace:*",
    "@vue/runtime-core": "workspace:*",
    "@vue/runtime-dom": "workspace:*",
}

本地 workspace 包只要进行标注 workspace:* 协议,这样依赖就本地的包了,而不需要从 npm registry 进行安装。

通过 monorepo 方式进行管理的项目,每一个模块都可以说是一个独立的项目,同时和其他项目复用一套标准的工具和规范,无需切换开发环境。 比如我今天只修改了 reactivity 项目,那么我就可以只对 reactivity 项目进行打包处理。

pnpm run build 是打包所有模块,在后面加模块名称则是具体打包所加的模块名称的模块。

单独打包 reactivity 模块:

pnpm run build reactivity

打包后的结果:

Element Plus 组件库相关技术揭秘:2. 组件库工程化实战之 Monorepo 架构搭建

这样就可以把打包出来的内容进行单独发布了。

而对 reactivity 项目进行打包处理的持续集成(CI)流程、构建和发布流程都是和其他项目共用的有的基建流程,即便将来有新的项目接入,依然可以复用现在的基建逻辑代码,这样维护和开发成本就大大降低了。

Workspace 包的版本

workspace:*后面的 * 表示任意版本,除了 * 还有其他:~ 、^ 符号。

workspace 包打包发布时,将会动态替换这些 workspace: 依赖。

假设我们上面的包的版本都是 1.0.0 ,它们的 workspace 配置如下:

{
  "dependencies": {
    "@vue/reactivity": "workspace:*",
    "@vue/runtime-core": "workspace:~",
    "@vue/runtime-dom": "workspace:^"
  },
}

将来发布的时候将会被转化为:

{
  "dependencies": {
    "@vue/reactivity": "workspace:1.0.0",
    "@vue/runtime-core": "workspace:~1.0.0",
    "@vue/runtime-dom": "workspace:^1.0.0"
  },
}

通过 Vue3 源码库项目的剖析,我们对 monorepo 有了一定的认识了,接下来我们就开始实战吧。

Vue3 组件库 monorepo 环境搭建

以 pnpm 构建 monorepo

通过上文对 Vue3 源码库项目的剖析,我们知道使用 monorepo 的好处就是可以在一个代码仓库中管理多个项目,可以达到项目之间的资源共享。比如我们在新建组件的时候,可能有组件的文档,包括组件的测试,那他们可能都是一个个的 npm 包,那这样的话,我们可以在一个项目下,管理这些 npm 包,可以达到 npm 包之间的一个资源共享。包括我们还可以,在一个项目下,管理所有的这个 npm 包进行发布和部署。而且我们每一个组件,也是可以单独发布和部署的,也正因为如此 Element Plus 采用这种 monorepo 的方式来搭建的组件库。 Element Plus 最新是采用 pnpm 方式来进行 monorepo 环境搭建。pnpm 的特点,主要就是速度快,还有就是磁盘利用率高,而且使用这个 pnpm 来搭建的 monorepo 环境,是非常容易的。通过 pnpm 我们可以快速的去搭建出一个 monorepo 环境。

首先进行全局安装 pnpm

npm install pnpm -g

然后在项目下使用 pnpm init 进行 package.json 的初始化。这跟 npm init 是一样的。

pnpm init

得到 package.json 初始内容,然后把 package.json 中的 name 属性删掉,并且添加一个 "private": true 属性,因为它是不需要发布的。

{
  "private": true,
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

配置 pnpm 的 monorepo 工作区

在我们这个仓库下,我们需要管理多个项目,就可以采用 pnpm 的 monorepo。我们在仓库的根目录下创建一个 pnpm-workspace.yaml 文件,可以在 pnpm-workspace.yaml 配置文件中指定这个仓库中有多少个项目。

packages:
  - play # 存放组件测试的代码
  - docs # 存放组件文档
  - packages/* # packages 目录下都是组件包

可以在 play 目录中运行我们写好的组件,相当于一个测试环境,在开发的时候可以知道效果是否达到预期;还需要一个组件说明文档的项目目录:docs; packages 目录则是所有组件的项目目录了,在 packages 目录中又可以放很多包的项目目录,比如,组件包目录:components、主题包目录:theme-chalk、工具包目录:utils 等。然后每一个包目录里面也需要一个 package.json 文件进行声明这是一个 NPM 包目录。所以我们需要进入每个包目录进行初始一个 package.json 文件。

以 components 包为例,我们进入到 components 目录底下初始化一个 package.json 文件,更改包名:@cobyte-ui/components。文件内容如下:

{
  "name": "@cobyte-ui/components",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

其他两个的包名则分别为:@cobyte-ui/theme-chalk@cobyte-ui/utils,创建过程同上。

至此我们一个初步搭建的项目目录结构如下:

├── README.md
├── package.json
├── packages
│   ├── components
│   │   └── package.json
│   ├── theme-chalk
│   │   └── package.json
│   └── utils
│       └── package.json
├── play
└── pnpm-workspace.yaml

仓库项目内的包相互调用

@cobyte-ui/components@cobyte-ui/theme-chalk@cobyte-ui/utils 这几个包要互相进行调用呢,就需要把它们安装到仓库根目录下的 node_modules 目录中。

然后我们在根目录下进行安装操作。

pnpm install @cobyte-ui/components -w
pnpm install @cobyte-ui/theme-chalk -w
pnpm install @cobyte-ui/utils -w

-w 表示安装到共公模块的 packages.json 中,也就是根目录下的 packages.json。

安装后根目录下的 package.json 的内容为:

{
  "dependencies": {
    "@cobyte-ui/components": "workspace:*",
    "@cobyte-ui/theme-chalk": "workspace:*",
    "@cobyte-ui/utils": "workspace:*"
  },
}

注意:workspace:* 将来发布的时候会被转换成具体的版本号。

TypeScript 初始化配置文件

接下来继续安装一些我们开发时所需要的依赖。

pnpm install vue typescript @types/node -D -w

因为 vuetypescript@types/node 只是开发环境需要的,所以安装的时候需要添加一个 -D 参数表示安装到开发环境,-w 表示安装到共公模块的 packages.json 中,也就是根目录下的 packages.json。

因为我们使用了 TypeScript,这样我们想要去校验我们的代码,让我们代码有提示,并且可以按照一些规则来解析我们的语法,给我们更友好的提示,我们就需要去初始化一下这个 TypeScript 配置命令。 又因为我们安装了 typescript,所以在 node_modules 目录下 bin 目录里面就会存在一个 tsc 的命令,这个命令,就可以帮我们进行初始化,我们可以使用 npm tsc --init 来初始化,也可以使用 pnpm tsc --init 那么执行这个命令,它就会去 node_modules 目录下 bin 目录找这个 tsc 命令进行执行。

pnpm tsc --init

实现组件 play 环境

后续我们在 packages 目录下的 components 目录编写的组件希望在 play 中直接进行运行的。那么我们就需要在 play 目录下创建一个开发环境,可以正常引用 components 目录中组件,运行并查看组件编写是否正常。那么我们就直接使用 Vite 来创建一个项目就可以了。

我们可以在根目录下运行以下命令进行安装:

pnpm create vite play --template vue-ts

在根目录下通过以上命令创建了一个 Vue3 + TS 的项目了。

接着我们进入 play 目录进行安装项目依赖:

pnpm install

接着我们可以在 play 目录下运行 npm run dev 运行 play 项目,但这样每次运行都需要进入到 play 目录下的话,太麻烦了,我们希望在根目录下就可以运行 play 项目,我们可以在根目录的 package.json 文件的 scripts 选项进行以下配置:

{
"scripts": {
    "dev": "pnpm -C play dev",
  },
}

这样我们就可以在根目录通过 pnpm run dev 启动 play 项目里面 package.json 文件中 scripts 选项中对应的 dev 命令了。

TypeScript 的 Monorepo 设置

我们上面通过 pnpm 提供的通过构建 pnpm-workspace.yaml 文件,进行声明对应的工作区的方式构建整个项目的结构。这种方式主要是以功能模块进行划分目录结构的,比如说,一个功能包目录里面包含了测试模块,但在最终进行生产编译打包的时候,我们是不希望对测试模块的文件进行打包的,所以我们需要在 TypeScript 编译进行划分模块,让生产的时候只进行核心模块进行编译打包。

此外 TypeScript 的编译速度与项目的规模大小成正相关的,而我们整个组件库的项目规模是十分庞大的,那么怎么提高 TypeScript 的编译速度呢?

TypeScript 项目引用 (project references)

tsconfig.json 文件有一个顶级属性"references",它支持将 TypeScript  的程序项目分割成更小的组成部分,进而可以提高类型检查和编译的速度。我们的组件库已经采取 monorepo 的方式进行管理,也就是将项目根据功能的不同拆分成多个子项目,原则上各子项目之间尽量减少耦合度。比如说我们上面初步把组件库分成组件部分(packages)和展示部分(play),还有将来的测试部分(__tests__),测试和展示部分都是依赖组件部分的,但测试和展示部分是没有关联,所以测试或展示任何一部分发生了改变,应该只编译发生改变的部分,另外没有发生变化的部分不应该进行编译才对。 那么通过 tsconfig.json 文件的顶级属性"references",就可以将我们的组件库再进行划分,从而变得更加的合理和编译性能的进一步提升。

tsconfig.json 文件:

{
  "files": [],
  "references": [
    { "path": "./tsconfig.web.json" }, // 组件包部分
    { "path": "./tsconfig.play.json" }, // 组件 play 部分
    { "path": "./tsconfig.vitest.json" } // 组件测试部分
  ]
}

每个引用的 path 属性可以指向包含 tsconfig.json 文件的目录,也可以指向配置文件本身。经过上面的设置,就等于是在 TypeScript 层又把我们的组件库项目分成了三个部分。然后我们通过具体配置文件进行具体每个部分的 TypeScript 编译项设置。而每个部分都有一些公共的配置项,所以我们又可以把公共的配置项进行抽离设置到一个公众配置文件中,再通过 extends 进行引用,这样一来就可以大大减少相同的配置代码。

公共配置项 tsconfig.base.json 文件:

{
  "compilerOptions": {
    "outDir": "dist", // 指定输出目录
    "target": "es2018", // 目标语言的版本
    "module": "esnext", // 生成代码的模板标准
    "baseUrl": ".", // 解析非相对模块的基地址,默认是当前目录
    "sourceMap": false, // 是否生成相应的Map映射的文件,默认:false
    "moduleResolution": "node", // 指定模块解析策略,node或classic
    "allowJs": false, // 是否允许编译器编译JS,JSX文件
    "strict": true, // 是否启动所有严格检查的总开关,默认:false,启动后将开启所有的严格检查选项
    "noUnusedLocals": true, // 是否检查未使用的局部变量,默认:false
    "resolveJsonModule": true, // 是否解析 JSON 模块,默认:false
    "allowSyntheticDefaultImports": true, // 是否允许从没有默认导出的模块中默认导入,默认:false
    "esModuleInterop": true, // 是否通过为所有导入模块创建命名空间对象,允许CommonJS和ES模块之间的互操作性,开启改选项时,也自动开启allowSyntheticDefaultImports选项,默认:false
    "removeComments": false, // 删除注释
    "rootDir": ".", // 指定输出文件目录(用于输出),用于控制输出目录结构
    "types": [],
    "paths": { // 路径映射,相对于baseUrl
      "@cobyte-ui/*": ["packages/*"]
    }
  }
}

组件包部分配置项 tsconfig.web.json 文件:

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "composite": true, // 是否开启项目编译,开启该功能,将会生成被编译文件所在的目录,同时开启declaration、declarationMap和incremental,默认:false
    "jsx": "preserve", // 指定JSX代码生成用于的开发环境
    "lib": ["ES2018", "DOM", "DOM.Iterable"], // 指定项目运行时使用的库
    "types": ["unplugin-vue-define-options"], // 用来指定需要包含的模块,并将其包含在全局范围内
    "skipLibCheck": true // 是否跳过声明文件的类型检查,这可以在编译期间以牺牲类型系统准确性为代价来节省时间,默认:false
  },
  "include": ["packages",],// 使用 include 来指定应从绝对类型中使用哪些类型
  "exclude": [ // 提供用于禁用 JavaScript 项目中某个模块的类型获取的配置
    "node_modules",
    "**/dist",
    "**/__tests__/**/*",
    "**/gulpfile.ts",
    "**/test-helper",
    "packages/test-utils",
    "**/*.md"
  ]
}

通过 include 属性进行限制组件部分的范围

组件 play 部分配置项 tsconfig.play.json 文件:

{
  "extends": "./tsconfig.web.json",
  "compilerOptions": {
    "allowJs": true, // 是否允许编译器编译JS,JSX文件
    "lib": ["ESNext", "DOM", "DOM.Iterable"] // 指定项目运行时使用的库
  },
  "include": [ // 使用 include 来指定应从绝对类型中使用哪些类型
    "packages",
    "typings/components.d.ts",
    "typings/env.d.ts",

    // playground
    "play/main.ts",
    "play/env.d.ts",
    "play/src/**/*"
  ]
}

通过 include 属性进行限制 play 部分的范围

这样设置之后就可以进行编译优化了,那么它的原理是什么呢? 其实关键在于 "composite": true 这个选项,这个选项设置为 true 之后,TypeScript 就会进行增量编译,所谓增量编译指的是生成 .d.ts 和 .tsbuildinfo 文件,其中 .tsbuildinfo 文件的内容就是记录所编译的项目的文件信息,主要是记录每个文件的 hash 值,下一次编译的时候,就会对比每个文件的 hash 值,如果没有变化那么就不进行编译,从而实现了编译性能的优化。

TypeScript 的类型检查

Element Plus 组件库是采用 rollup-plugin-esbuild 来进行打包的,此插件的基本原理就是结合使用 ESBuild 和 Rollup 来编译 ESNext 和 TypeScript 代码,而 ESbuild 在编译的时候是不会进行 TypeScript 的类型检查的,所以我们需要在编译之前就进行 TypeScript 的类型检查。

对于纯 TS 文件的项目,我们可以通过 tsc --noEmit 命令来进行类型检查,tsc --noEmit 的意思就是只进行 TypeScript 的语法检测,而不会进行编译。那么 Element Plus 组件库有哪些项目是纯 TS 文件的呢?就是我们的构建程序。在调用 tsc 命令时可以使用命令行参数--project(或-p)指定配置文件进行执行。

例如在 package.json 文件中 scripts 进行如下设置:

{
    "scripts":{
   		"typecheck:node": "tsc -p tsconfig.node.json --noEmit",
	}
}

包含 SFC 单文件组件的项目,我们则可以使用 Vue 官方提供的 vue-tsc 工具进行类型检查。

vue-tsc  提供的针对单文件组件的命令行类型检查和生成。vue-tsc 是一个基于 volar 的 Vue3 命令行类型检查工具,我们也是可以在执行 vue-tsc --noEmit 时使用命令行参数--project(或-p)指定配置文件进行配置需要检查的内容和方式。

{
    "scripts":{
   	 "typecheck:web": "vue-tsc -p tsconfig.web.json --composite false --noEmit",
         "typecheck:play": "vue-tsc -p tsconfig.play.json --composite false --noEmit",
	}
}

--composite false 不进行增量编译,增量编译指的是生成 .d.ts 和 tsconfig.tsbuildinfo 文件,使用 vue-tsc 法语检查时不能设置为 true。

--noEmit 不进行编译,只进行语法检测。

--composite false --noEmit 不进行编译,也不进行增量编译,只进行语法检测。

--composite false 只能设置为 false,不能设置为 true。

串行/并行执行脚本

我们上面进行 TypeScript 类型检查的时候在 package.json 的 script 中配置了多个模块的命令,如果需要同时全部执行所有的命令,我们需要进行以下的设置:

{
    "scripts":{
        "runall":"pnpm run typecheck:web && pnpm run typecheck:play && pnpm run typecheck:node && pnpm run typecheck:vitest"
    }
 }

以上方式属于通过 && 符号来串行执行脚本。

既然有串行那么也就有并行,如果需要并行执行脚本,可以使用 & 符号,示例如下:

{
    "scripts":{
        "runall":"pnpm run typecheck:web & pnpm run typecheck:play & pnpm run typecheck:node & pnpm run typecheck:vitest"
    }
 }

此外社区里也封装了很多串行/并行执行脚本的公共包供开发者选用,比如还可以使用 npm-run-all 进行更优雅的设置。npm-run-all 是一个可并行或串行运行多个 npm-scripts 的 CLI 工具。

安装 npm-run-all 工具:

pnpm install npm-run-all -D -w

npm-run-all 提供三个命令,分别是  npm-run-all run-s run-p,后两个是  npm-run-all  带参数的简写,分别对应串行和并行。

有了这个包,我们就可以进行以下设置了:

{
    "scripts":{
        "typecheck": "run-p typecheck:web typecheck:play typecheck:node typecheck:vitest",
    }
 }

这样看起来就优雅多了,更多关于 npm-run-all 包的使用可以查看其官方文档 。

串行命令和并行命令执行规则:

  • 一个 & 是代表并行执行左指令和右侧指令
  • 两个 && 是代表串行执行,先执行左侧指令,再执行右侧命令
  • 如果一个命令中既包含 & ,也包含 &&&并行的级别要高一些,首先会执行&左右两侧的命令,然后再根据左右两侧指令情况进行执行。

总结

至此一个通过 pnpm 方式配置的 monorepo 组件库基础环境就搭建好了。

我们的标题是组件库工程化实战,我们已经初步完成了一个组件库的开发环境配置,那么这个过程中到底什么是工程化的含义呢?在配置这个组件库的开发环境的过程中,我们好像只是使用了一堆工具进行各种配置,那么是否意味着前端工程化就是工具化呢?

其实不是,工程化的核心并非工具,而是以工具为实现媒介进行规范工作流程。而我们在上述文章中讨论的是组件库项目文件组织结构的规范,TypeScript 编译和类型检测流程的规范,还有工具链的统一。可以看到我们是通过工具流程规范具体化在项目结构中,这样就可以在一定程度上将开发者限定使用统一的工具链、遵循统一的规范进行业务代码的编写,这样便有利于多人协作和项目代码的维护。

也就是通过工具表达你的思想,通过工具规范你的项目,通过工具管理写代码的人员。

此文章的实现代码仓库:github.com/amebyte/ele…

本专栏文章:

1. Vue3 组件库的设计和实现原理

2. 组件库工程化实战之 Monorepo 架构搭建

3. ESLint 核心原理剖析

4. ESLint 技术原理与实战及代码规范自动化详解

5. 从终端命令解析器说起谈谈 npm 包管理工具的运行原理

6. CSS 架构模式之 BEM 在组件库中的实践