likes
comments
collection
share

16. Element Plus 组件库的打包原理与实践详解

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

前言

所谓打包就是将开发阶段的代码处理成生产环境可以供给其他用户使用的产物的过程。一般我们开发的 web 应用,最终会在浏览器上给用户使用,而现代前端的开发环境一般是 Node.js,而 Node.js 环境的重要部分就是 CommonJS 规范,因为 Node.js 的模块系统,就是参照 CommonJS 规范实现的,遵循 CommonJS 规范的代码是不能直接在浏览器中使用的,所以我们需要通过打包工具把遵循 CommonJS 规范的代码转换成浏览器可以识别的代码。而 CommonJS 规范的模块,本质上可以看成是一个函数,只要提供对应的 CommonJS 规范独有的变量,例如 module、exports、require、global 等,浏览器就可以识别 CommonJS 规范的代码。这就是普通应用产品项目打包所需要做的事情,即将开发阶段在 Node.js 环境运行的代码转换成浏览器环境可以执行的代码。可能你会奇怪,明明在开发阶段也可以在浏览器进行执行预览,怎么还需要打包呢,这是因为开发环境的工具(例如 webpack、Vite)进行了预打包,而在发布阶段则是将所有的开发环境的代码转换成生产环境的代码并保存到 bundle 文件中。这是一般生产环境是浏览器的打包情况。

那么除了浏览器环境还有其他生产环境吗?当然有,常见的就是 Node.js 环境了,比如我们开发的一些类库,在现代开发过程中通常它们的运行环境就是 Node.js,比如 Vue 和 React 这种类库,它们开发完成之后会发布到 npm 上供给用户使用,而用户一般是在 Node.js 环境下使用它们进行应用开发,这样它们的生产环境就相当于 Node.js 了。此外还有服务器端渲染(SSR),也是在 Node.js 环境下运行的。Node.js 在 13.2.0(2019 年)之前都是只能识别 CommonJS 模块,在 Node.js 13.2.0 版本后,Node.js 也支持使用 ESM(ECMAScript Modules)模块。也就是现在我们的开发产物的生产环境是 Node.js 的话,我们需要提供两种格式的产物,分别是 CommonJS 的代码和 ESM 的代码。

除此之外,现在我们很多项目都使用 TypeScript 进行开发,我们引用的第三方库一般都有提供类型文件(.d.ts 结尾的文件),所以在我们开发类库的时候,我们也需要提供类型文件。像 Element Plus 这样的组件库也属于第三方类库,所以也需要提供 TypeScript 类型提示文件。

还有一点就是通常打包后的代码都是难以调试的,所以需要通过 source map 文件,将压缩后的代码映射回原始的、未压缩的代码,以便于开发者调试和定位代码出错的位置。Element Plus 也提供了 source map 文件。 最后 Element Plus 是一个 UI 库,所以还需要对 CSS 进行打包。 总的来说 Element Plus 的打包在代码处理方面主要需要做以下工作:

  1. 提供浏览器环境可以识别的代码(一般是 UMD 或 IIFE)
  2. 提供 Node.js 环境的 CommonJS 规范的代码
  3. 提供 Node.js 环境的 ESM 规范的代码
  4. 提供 TypeScript 类型文件 (.d.ts 文件)
  5. 提供 CSS 文件
  6. 提供 source map 文件

为什么要提供 ESM、CJS 两种代码

也许有同学会有疑问,为什么要同时提供 CommonJS 规范的代码和 ESM 规范的代码呢?

这是因为 Node.js 在 13.2.0(2019 年)之前都是只能识别 CommonJS 模块,在 Node.js 13.2.0 版本后,Node.js 也支持使用 ESM(ECMAScript Modules)模块。所以提供 CommonJS 代码是为了兼容旧版本的 Node.js,那么既然新老版本的 Node.js 都支持 CommonJS,可不可以只提供 CommonJS 一种代码呢,这样就省了 ESM 的代码。

但又因为 ESM 规范的代码更利于 Tree shaking。关于 Tree shaking,以下是 webpack 中文网的对 Tree shaking 的描述:

tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块语法的 静态结构 特性,例如 import 和 export。这个术语和概念实际上是由 ES2015 模块打包工具 rollup 普及起来的。原文链接

在最后 webpack 中文网中还提到:如果要利用 tree shaking 的优势,你必须使用 ES2015 模块语法(即 import 和 export),确保没有编译器将你的 ES2015 模块语法转换为 CommonJS。

简单来说就是 Tree shaking 就是可以让打包后的代码体积更小。那么既然 ESM 规范这么好,可不可以只提供 ESM 规范的代码就可以了,这样就减少了打包 CommonJS 规范的代码的工作量以及代码体积,但在某些场景下并不能使用 ESM 模块,比如在一些需要依赖低版本的 Node.js 的项目(服务器端渲染 [SSR] )中,也就是开头说的 CommonJS 代码是为了兼容旧版本的 Node.js,又或者说一些旧工具只依赖 CommonJS,比如说 gulp,gulp 就只提供了 CommonJS 的使用方式。

值得注意的是,Vue3 源码在处理 ESM 规范的代码时,还进行细分,分别是浏览器环境的 ESM 文件,例如:reactivity.esm-browser.js,和 Node.js 环境的 ESM 文件,例如:reactivity.esm-bundler.js,它们的区别就是浏览器环境的 ESM 文件是全量打包,Node.js 环境的 ESM 文件是只打包开发文件本身的代码,也就是浏览器环境的 ESM 文件会把引用到的 node_modules 文件中的包也会打包进去,而 Node.js 环境的 ESM 文件则不会,这样 Node.js 环境的 ESM 文件体积会变得更小,但只能在 Node.js 环境中运行,到时在开发应用产品的时候,在打包成浏览器环境的产物时,会配合 webpack、rollup、vite 等打包工具将 node_modules 文件中的包打包进去。

此外,尤雨溪对此的看法是:未来的发展趋势是逐渐慢慢地淡化 CommonJS 规范,所有的 npm 包都逐渐只提供 ESM 规范的代码,比如说 Vite5 就废弃了 CommonJS API。

16. Element Plus 组件库的打包原理与实践详解

尤雨溪观点出处视频链接:# Vue & Vite:现状与未来 - 尤雨溪

提供全量打包的入口文件

这是一个专栏文章,如果您看到这感到突兀,可以先了解一下本专栏之前的相关文章。

我们在此之前的组件库的引入是在测试环境 play 目录下进行手动引入的,代码如下:

main.ts 文件

import { createApp } from "vue";
import ElIcon from "@cobyte-ui/components/icon";
import ElButton, { ElButtonGroup } from "@cobyte-ui/components/button";
import ElInput from "@cobyte-ui/components/input";
import { ElForm, ElFormItem } from "@cobyte-ui/components/form1";
import "@cobyte-ui/theme-chalk/src/index.scss";
import App from "./src/App.vue";

// 组件库
const components = [
  ElIcon,
  ElButton,
  ElButtonGroup,
  ElInput,
  ElForm,
  ElFormItem,
];
// 是否已安装标识
const INSTALLED_KEY = Symbol("INSTALLED_KEY");
// 组件库插件
const ElementPlus = {
  install(app: any) {
    // 如果该组件库已经安装过了,则不进行安装
    if (app[INSTALLED_KEY]) return;
    // 将标识值设置为 true,表示已经安装了
    app[INSTALLED_KEY] = true;
    // 循环组件库中的每个组件进行安装
    components.forEach((c) => app.use(c));
  },
};

const app = createApp(App);
// 安装组件库
app.use(ElementPlus);
app.mount("#app");

以上的引用方式非常的繁琐和笨重,那么给到用户使用,我们希望简单操作就可以引用了,比如以下的方式:

import { createApp } from "vue";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import App from "./App.vue";

const app = createApp(App);

app.use(ElementPlus);
app.mount("#app");

那么我们需要将组件库相关的代码进行封装。我们在 package 目录下新建一个 cobyte-ui 目录,之后创建 index.ts 文件,再把上面组件库相关的代码已到 index.ts 文件中。

./packages/cobyte-ui/index.ts 代码:

import ElIcon from "@cobyte-ui/components/icon";
import ElButton, { ElButtonGroup } from "@cobyte-ui/components/button";
import ElInput from "@cobyte-ui/components/input";
import { ElForm, ElFormItem } from "@cobyte-ui/components/form1";

// // 组件库
const components = [
  ElIcon,
  ElButton,
  ElButtonGroup,
  ElInput,
  ElForm,
  ElFormItem,
];
// 是否已安装标识
const INSTALLED_KEY = Symbol("INSTALLED_KEY");
// 组件库插件
const ElementPlus = {
  install(app: any) {
    // 如果该组件库已经安装过了,则不进行安装
    if (app[INSTALLED_KEY]) return;
    // 将标识值设置为 true,表示已经安装了
    app[INSTALLED_KEY] = true;
    // 循环组件库中的每个组件进行安装
    components.forEach((c) => app.use(c));
  },
};

export default ElementPlus;

这样 play 目录下的 main.ts 文件引入组件库则可以变成以下方式:

import { createApp } from "vue";
import ElementPlus from "../packages/cobyte-ui";
import "@cobyte-ui/theme-chalk/src/index.scss";
import App from "./src/App.vue";

const app = createApp(App);
// 安装组件库
app.use(ElementPlus);
app.mount("#app");

这样就变得非常的简单和方便了。更重要的是进行全量打包的时候,需要提供一个文件入口,我们进行了组件库封装之后,组件库的文件入口则变成了 ./packages/cobyte-ui/index.ts

生成 UMD 模块代码(全量打包)

我们普通业务项目应用打包的本质就是将开发环境的代码转换成浏览器环境可以识别的代码,一般可以通过 webpack、vite 这些打包工具进行打包。现代的类库则一般都是使用 rollup 进行打包,主要是 rollup 配置相对比较简单。Element Plus 和 Vue3 的源码都是使用 rollup 进行打包的。如果大家对 rollup 了解不多,可以先到 rollup 官网 进行基础的了解。 rollup 有两种使用方式,一种是配置文件 + 命令行方式,一种是通过 JavaScript API 的方式,一般业务项目都是通过配置文件 + 命令行的方式,但在类库中我们则是通过 JavaScript API 的方式,主要原因是:

虽然配置文件提供了一种简单的配置 Rollup 的方式,但它们也限制了 Rollup 可以被调用和配置的方式。特别是如果你正在将 Rollup 嵌入到另一个构建工具中,或者想将其集成到更高级构建流程中,直接从脚本中以编程方式调用 Rollup 可能更好。

上述描述来自 rollup 中文官网。

我们为了方便管理,我们将打包相关的操作全放到一个文件夹内。我们在根目录下创建 ./internal/build 的打包项目目录。接着在命令终端我们进到 ./internal/build 目录,并且通过命令:npm init -y 初始化一个 package.json 文件,这样打包所用到的工具包都放在这个项目中,比如安装 rollup 包。

相关项目结构如下:

├── internal
│   ├── build             # 打包设置项目
│   │   └── package.json  # package.json 配置文件
│   ├── eslint-config     # 之前设置的 Eslint 配置项目
├── packages

我们知道 Vue 项目打包时候主要做的工作就是将 .vue 文件编译成 JavaScript 文件,如果存在 TypeScript,则也需要将 TypeScript 转换成 JavaScript。我们知道要做什么之后,我们接下来要做的就是去查找和了解相关资料,在 rollup 中是如何做到这些的。在 webpack 中是通过 vue-loader 来识别 .vue 文件的,在 rollup 中则是通过插件来识别的,通常是 rollup-plugin-vue 插件,但因为 rollup-plugin-vue 已经不再维护,推荐使用 @vitejs/plugin-vue;此外 rollup 默认是不能解析 npm 包的,需要通过 @rollup/plugin-node-resolve 插件;TypeScript 代码则是通过 rollup-plugin-esbuild 来进行编译成 JavaScript 的,此插件是一个基于 Esbuild 的插件,速度极快。

我们把相关插件进行安装。

pnpm install rollup @vitejs/plugin-vue @rollup/plugin-node-resolve rollup-plugin-esbuild -D

我们在 ./internal/build 目录下新建一个 full-bundle.js 用于进行全量打包的执行文件。我们上面也说到了 rollup 的打包设置是非常简单,就三个过程:配置入口文件、配置插件、配置输出文件格式。所以我们把代码架构搭建起来,为了顾名思义,我们把打包的函数命名为:buildFullEntry。

import { rollup } from "rollup";
import vue from "@vitejs/plugin-vue";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import esbuild from "rollup-plugin-esbuild";
// 全量打包任务函数
const buildFullEntry = async () => {
  const bundle = await rollup({
    input: "", // 配置入口文件
    plugins: [
      // 配置插件
      vue(),
      nodeResolve(),
      esbuild(),
    ],
    // 排除不进行打包的 npm 包,例如 Vue,以便减少包的体积
    external: ["vue"],
  });
  // 配置输出文件格式
  bundle.write({});
};
buildFullEntry();

以上便是 rollup 的 JavaScript API 方式的基本使用方式,在 rollup 官网 上你也可以查看到相关介绍。

接着我们配置入口文件,根据上文我们知道入口文件是 ./packages/cobyte-ui/index.ts,我们需要将它配置成绝对路径。

相关代码设置如下:

import { fileURLToPath } from "url";
import { resolve, dirname } from "path";

const __filenameNew = fileURLToPath(import.meta.url);
const __dirnameNew = dirname(__filenameNew);
// 确定根目录,目前执行目录是在 ./internal/build,所以需要跳出两层
const projRoot = resolve(__dirnameNew, "..", "..");
// 拼接 ./packages 目录路径
const pkgRoot = resolve(projRoot, "packages");
// 拼接 ./packages/cobyte-ui 目录路径
const epRoot = resolve(pkgRoot, "cobyte-ui");
// 拼接 ./packages/cobyte-ui/index.ts 目录路径
// resolve(epRoot, 'index.ts')

根据上面的代码我们可以知道 ./packages/cobyte-ui/index.ts 目录路径的绝对路径是 resolve(epRoot, 'index.ts')

接着我们配置输出文件的格式,接着根据上面我可以配置输入文件目录路径

// 拼接打包根目录
const buildOutput = resolve(projRoot, "dist");
// 拼接包目录
const epOutput = resolve(buildOutput, "cobyte-ui");

那么我们可以设置输出文件的路径为:resolve(epOutput, 'dist', 'index.full.js')。 因为我们需要提供浏览器可以识别的代码,那么打包的格式一般都是 umd,因为 UMD 格式可以兼容 CommonJS、AMD、CMD 模块规范,关于 UMD 模块规范原理我们下一小节再进行展开讨论。根据 UMD 模块规范我们需要将整个组件库要设置一个变量名称:CobyteUI,最终会挂载到全局变量上,浏览器环境是 globalThis 上,Node.js 环境则是模块 exports 对象上,此外我们需要告诉 Rollup,Vue 是外部依赖,vue 模块的 ID 为全局变量 Vue。我们姑且先这么设置,至于为什么要这么设置,我们还需要对 UMD 的模块规范原理进行了解之后才可以理解为什么要这么设置。 根据 rollup 官网提供的资料我们可以设置输出文件的格式如下:

{
    format: 'umd',
    file: resolve(epOutput, 'dist', 'index.full.js'),
    name: 'CobyteUI', // 将整个组件库要设置一个变量名称:`CobyteUI`
    globals: {
      vue: 'Vue', // 组件库中需要使用到的全局变量 Vue
    },
}

此外 @rollup/plugin-node-resolve 插件默认是不认识 .ts 这些文件的,我们需要通过 extensions 选项进行手动配置,让它能识别相关文件。

  nodeResolve({
    extensions: ['.ts'],
  }),

整体代码则如下:

import { fileURLToPath } from "url";
import { resolve, dirname } from "path";
import { rollup } from "rollup";
import vue from "@vitejs/plugin-vue";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import esbuild from "rollup-plugin-esbuild";

const __filenameNew = fileURLToPath(import.meta.url);
const __dirnameNew = dirname(__filenameNew);
// 确定根目录,目前执行目录是在 ./internal/build,所以需要跳出两层
const projRoot = resolve(__dirnameNew, "..", "..");
// 拼接 ./packages 目录路径
const pkgRoot = resolve(projRoot, "packages");
// 拼接 ./packages/cobyte-ui 目录路径
const epRoot = resolve(pkgRoot, "cobyte-ui");

// 拼接打包根目录
const buildOutput = resolve(projRoot, "dist");
// 拼接包目录
const epOutput = resolve(buildOutput, "cobyte-ui");

// 全量打包任务函数
const buildFullEntry = async () => {
  const bundle = await rollup({
    input: resolve(epRoot, "index.ts"), // 配置入口文件
    plugins: [
      // 配置插件
      vue(),
      nodeResolve({
        extensions: [".ts"],
      }),
      esbuild(),
    ],
    // 排除不进行打包的 npm 包,例如 Vue,以便减少包的体积
    external: ["vue"],
  });
  // 配置输出文件格式
  bundle.write({
    format: "umd",
    file: resolve(epOutput, "dist", "index.full.js"),
    name: "CobyteUI",
    globals: {
      vue: "Vue",
    },
  });
};
buildFullEntry();

接着我们在命令终端进入 ./internal/build 运行以下命令:

node ./full-bundle.js

我们就可以看到生成了以下代码:

16. Element Plus 组件库的打包原理与实践详解

我们可以在 play 项目下新建一个 test.html 文件,然后通过借助 script 标签直接通过 CDN 来使用 Vue 和我们刚刚打包出来的组件库。代码如下:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>浏览器引用组件库包</title>
  </head>
  <body>
    <div id="app">
      <el-button>{{ message }}</el-button>
    </div>
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
    <script src="../dist/cobyte-ui/dist/index.full.js"></script>
    <script>
      const { createApp, ref } = Vue;
      const App = {
        setup() {
          const message = ref("浏览器引用组件库包");
          return {
            message,
          };
        },
      };
      const app = Vue.createApp(App);
      app.use(CobyteUI);
      app.mount("#app");
    </script>
  </body>
</html>

接着我们通过 Live Server 进行启动。

16. Element Plus 组件库的打包原理与实践详解

我们发现报错了,运行失败。

16. Element Plus 组件库的打包原理与实践详解

我们点进源码看报错的代码:

16. Element Plus 组件库的打包原理与实践详解

这个时候我们通过查找资料知道需要通过 @rollup/plugin-replace 插件把 process.env.NODE_ENV 相关的代码替换掉。我们在命令终端进入 ./internal/build 运行以下命令安装 @rollup/plugin-replace 包。

pnpm install @rollup/plugin-replace -D

然后进行相关设置:

// ...
import replace from '@rollup/plugin-replace'

// ...
const buildFullEntry = async () => {
  const bundle = await rollup({
    input: resolve(epRoot, 'index.ts'),
    plugins: [
       // ...
+      replace({
+        'process.env.NODE_ENV': '"production"',
+        preventAssignment: true, // 这个选项用于防止在字符串后面紧跟一个等号时进行替换。可以用于避免错误的赋值操作
+      }),
      esbuild(),
    ],
    external: ['vue'],
  })
   // ...
}
buildFullEntry()

接着我们重新打包。我们发现产生了新的报错:

16. Element Plus 组件库的打包原理与实践详解

报错的意思是找不到 el-button 组件。主要是因为我们的组件名称声明在开发环境是通过全局宏 defineOptions 定义的,我们使用的 Vue 是 3.2.37 版本,通过 @vitejs/plugin-vue 插件打包 .vue 文件是不能识别宏 defineOptions 的。我们可以暂时通过两个 <script> 来解决这个问题,button.vue 文件的修改如下:

16. Element Plus 组件库的打包原理与实践详解

修改后重新打包,我们发现就可以正常把按钮组件渲染出来了。

16. Element Plus 组件库的打包原理与实践详解

Vue Macros 的作用

我们上述那种通过两个 script 标签来定义组件的 name 属性获取其他属性的方式的开发体验是非常差的。我们通过下图可以看到两个 script 标签造成了 ESLint 插件或 Volar 出现异常问题(莫名其妙的报错了),这会导致非常不好的开发体验。

16. Element Plus 组件库的打包原理与实践详解

我们可以通过 Element Plus 的核心开发者、Vue 团队成员三咲智子开发的插件 unplugin-vue-macros 来解决这个问题,文档地址:vue-macros.dev,Vue Macros 是一个库,用于实现尚未被 Vue 正式实现的提案或想法。而最新的 Vue3.3 版本已经从 Vue Macros 中吸收了 5、6 个新特性了。宏 defineOptions 就是其中一个,这些功能在 Vue 3.3 版本开始官方支持,在 Vue >= 3.3 中,Vue Macros 会默认关闭相关已经在 Vue 正式版实现了的功能,使用官方的实现。

我们在命令终端进入 ./internal/build,然后安装 unplugin-vue-macros

pnpm add -D unplugin-vue-macros
// ...
+ import VueMacros from 'unplugin-vue-macros/rollup'

// ...
const buildFullEntry = async () => {
  const bundle = await rollup({
    input: resolve(epRoot, 'index.ts'),
    plugins: [
+     VueMacros({
+        plugins: {
+          vue: vue(),
+        },
      }),
      // ...
    ],
    external: ['vue'],
  })
   // ...
}
buildFullEntry()

然后我们把 button.vue 文件的修改进行撤销,恢复如初。

然后重新打包,我们在命令终端进入 ./internal/build 运行以下命令:

node ./full-bundle.js

我们发现 Button 组件还是正常渲染出来了。

我们实现了提供浏览器环境可以识别的代码打包功能之后,接下来便到了第二步实现提供 Node.js 环境的 CommonJS 规范和 ESM 规范的代码的打包功能。

UMD 模块规范的实现原理

UMD 叫做通用模块定义规范(Universal Module Definition)。

我们上文主要讲述了如何将整个组件库进行全量打包,本质上是将整个组件库挂着到一个全局变量上,那么了解过 Jquery 的基本实现原理的话,我们就知道 Jquery 就是一个全局变量,是通过 IIFE 函数将 Jquery 的模块对象挂载到全局变量 Jquery 上。因为 IIFE 是旧时代进行模块化的主要手段,就是自执行函数可以返回一个模块。

JQuery 的精简源码如下:

var jQuery = (function() {
    var $
    $ = function(selector, context) {
        // ...
    }
    
    $.ajax = function () {
        // ...
    }
    return $
})()
window.jQuery = jQuery 

在 UMD 模块规范中是需要兼容 CommonJS、CMD 的,它的基本原理就是通过一个工厂函数创建一个模块对象,然后再根据当前环境赋值给不同的模块全局变量上。所以在 UMD 的基本代码结构中则是把上述像 jQuery 的构造函数放在外面当作参数传进去,然后再赋值给当前的全局变量:

(function (window, jQuery) {
  window.jQuery = window.$ = jQuery();
})(window, function () { // 生产模块对象的工厂函数
    var $ 
    $ = function(selector, context) { 
        // ... 
    } 
    $.ajax = function () { 
        // ... 
    } 
    // 需要返回模块对象
    return $
});

这个时候,这个匿名函数就是一个模块工厂函数,它只负责生产模块对象,那么为了更加顾名思义,我们修改相关命名:

(function (global, factory) {
  global.jQuery = global.$ = factory();
})(this, function () { // 生产模块对象的工厂函数
    var $ 
    $ = function(selector, context) { 
        // ... 
    } 
    $.ajax = function () { 
        // ... 
    } 
    // 需要返回模块对象
    return $
});

自执行函数可以通过 return 的方式返回的一个模块,也可以通过传入一个对象,把模块需要导出的对象设置在这个对象的属性上。UMD 则是采用了后一种方式,代码如下:

(function (global, factory) {
-   global.jQuery = factory();
+  factory((global.jQuery = {})); // jQuery 的全局变量
})(this, function (exports) { // 生产模块对象的工厂函数
    // ...
    
    // 此时 exports 就是 jQuery 全局对象
    exports.ajax = function() {}
});

那么换成我们的组件库则变成以下的模样:

(function (global, factory) {
  factory((global.ElementPlus = {})); // ElementPlus 的全局变量
})(this, function (exports) { // 生产模块对象的工厂函数
  // ...
  
  function install() {} // 组件安装函数
  // 需要返回模块对象
  exports.install = install;
});

又因为 window 对象在一些 JavaScript 的运行时中是不存在的,所以我们把 window 对象改成了 this,但即便是 this,仍然可能在某些场景下是不存在的,比如严格模式下的函数中的 this 是不存在的。然后不同平台的全局对象也是不一样的,比如 Node.js 的全局对象是 global,浏览器中则是 window,还有一些环境则是 self,所以为了抹平这些差异,ES2020 给我们带来了 globalThis 对象。以下是 MDN 对 globalThis 的相关介绍。

globalThis 提供了一个标准的方式来获取不同环境下的全局 this 对象(也就是全局对象自身)。不像 window 或者 self 这些属性,它确保可以在有无窗口的各种环境下正常工作。所以,你可以安心的使用 globalThis,不必担心它的运行环境。为便于记忆,你只需要记住,全局作用域中的 this 就是 globalThis。

(function (global, factory) {
  // 做全局变量的兼容处理
+  global = typeof globalThis !== "undefined" ? globalThis : global || self;
  factory((global.ElementPlus = {})); // ElementPlus 的全局变量
})(this, function (exports) { // 生产模块对象的工厂函数
  // ...
  
  function install() {} // 组件安装函数
  // 需要返回模块对象
  exports.install = install;
});

兼容 CommonJS 代码

(function (global, factory) {
+  // 兼容 CommonJS 代码
+  if (typeof exports === "object" && typeof module !== "undefined") {
+    factory(exports);
+  } else {
    // 做全局变量的兼容处理
    global = typeof globalThis !== "undefined" ? globalThis : global || self;
    factory((global.ElementPlus = {})); 
+  }
})(this, function (exports) {
  function install() {} // jQuery 的模块代码
  // 需要返回模块对象
  exports.install = install;
});

兼容 AMD 代码

也是判断运行环境是否满足 AMD 规范。如果满足,则使用 require.js 提供的 define 函数定义模块。

(function (global, factory) {
  if (typeof exports === "object" && typeof module !== "undefined") {
    // 兼容 CommonJS 代码
    factory(exports);
+  } else if (typeof define === "function" && define.amd) {
+    // 兼容 AMD 代码
+    define(["exports"], factory);
  } else {
    // 做全局变量的兼容处理
    global = typeof globalThis !== "undefined" ? globalThis : global || self;
    factory((global.ElementPlus = {}));
  }
})(this, function (exports) {
  function install() {} // jQuery 的模块代码
  // 需要返回模块对象
  exports.install = install;
});

那么我们知道 Element Plus 组件库是需要依赖 Vue 的,所以我们需要将全局中的 Vue 变量传递到工厂函数中去,这样工厂函数中也就是组件库中就可以使用 Vue 相关的 API 了。

  (function (global, factory) {
    if (typeof exports === "object" && typeof module !== "undefined") {
      // 兼容 CommonJS 代码
+     factory(exports, require('vue'));
    } else if (typeof define === "function" && define.amd) {
      // 兼容 AMD 代码
+     define(["exports", "vue"], factory);
    } else {
      // 做全局变量的兼容处理
      global = typeof globalThis !== "undefined" ? globalThis : global || self;
+     factory(global.ElementPlus = {}, global.Vue); // 将全局中的 Vue 变量传递到工厂函数中去,这样工厂函数中也就是组件库中就可以使用 Vue 相关的 API 了
    }
+ })(this, function (exports, vue) {
+    const { ref } = vue // 组件库中需要使用到的 Vue 的 API
    function install() {} // jQuery 的模块代码
    // 需要返回模块对象
    exports.install = install;
  });

所以我们再回去看在 rollup 打包的输出配置相关设置时,我们就很容易理解为什么要那么设置了。

{
    format: 'umd',
    file: resolve(epOutput, 'dist', 'index.full.js'),
    name: 'CobyteUI', // 将整个组件库要设置一个变量名称:`CobyteUI`,`CobyteUI` 变量对应的就是 ElementPlus 全局变量。
    globals: {
      vue: 'Vue', // 组件库中需要使用到的全局变量 Vue
    },
}

总的来说 UMD 格式就是一种既可以在浏览器环境下使用,也可以在 Node.js 环境下使用的代码格式。它将 CommonJS、AMD 以及普通的全局定义模块三种模块规范进行了整合。

生成 Node.js 环境的 CJS 和 ESM 规范的代码

首先我们这里强调是 Node.js 环境,是因为也存在浏览器环境的 CommonJS 规范和 ESM 规范,当然所谓浏览器环境的 CommonJS 规范是不存在的,只是打包工具将 Node.js 环境的 CommonJS 规范的代码转换成浏览器能识别的代码而已,而浏览器环境的 ESM 规范代码确实是有,毕竟 ESM 规范本身就是浏览器环境的模块规范,但与 Node.js 环境的 ESM 规范不同的是,浏览器环境不能识别 node_modules 中的代码,而 Node.js 环境可以,所以浏览器环境的 ESM 规范则需要把 node_modules 中的代码也打包进去,而 Node.js 环境的则不需要,从而可以减少打包体积。

我们在 ./internal/build 目录下新建一个 modules.js 用于进行模块化打包的执行文件。我们的 rollup 部分的代码架构还是跟全量打包是一样,为了顾名思义,我们把打包的函数改成:buildModules。

import { rollup } from "rollup";
import vue from "@vitejs/plugin-vue";
import VueMacros from "unplugin-vue-macros/rollup";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import esbuild from "rollup-plugin-esbuild";
import replace from "@rollup/plugin-replace";

// 模块化打包任务函数
const buildModules = async () => {
  const bundle = await rollup({
    input: "", // 配置入口文件
    plugins: [
      // 配置插件
      VueMacros({
        plugins: {
          vue: vue(),
        },
      }),
      nodeResolve({
        extensions: [".ts"],
      }),
      replace({
        "process.env.NODE_ENV": '"production"',
        preventAssignment: true, // 这个选项用于防止在字符串后面紧跟一个等号时进行替换。可以用于避免错误的赋值操作
      }),
      esbuild(),
    ],
    // 排除不进行打包的 npm 包,例如 Vue,以便减少包的体积
    external: ["vue"],
  });
  // 配置输出文件格式
  bundle.write({});
};
buildModules();

我们可以看到跟全量打包功能的 rollup 插件配置部分是一致的,不同的是入口文件和输出文件的配置

我们提供的 CommonJS 规范和 ESM 规范的代码都需要是模块化的,这样有利于实现按需加载以及打包时候的 Tree shaking 优化。那么要模块化,我们只要打包后保持原来开发环境下的模块结构即可。那么 rollup 打包怎么做到这个功能呢?在 rollup 中文官网查到了如下一段话:

如果你想将一组文件转换为另一种格式,并同时保持文件结构和导出签名,推荐的方法是将每个文件变成一个入口文件。

这样我们可以通过 fast-glob 包,动态地处理入口文件。所以我们先安装 fast-glob。

pnpm install fast-glob -D

关于 fast-glob 的教程文档,可以看 fast-glob 的 GitHub 地址: github.com/mrmlnc/fast…

我们可以通过一下方式读取 package 目录下的所有文件。

const input = await glob("**/*.{js,ts,vue}", {
  cwd: pkgRoot,
  absolute: true, // 返回绝对路径
  onlyFiles: true, // 只返回文件的路径
});

但这样会把目录中 node_modules 目录中的文件,以及一些测试目录的文件也会读取进去,所以我们需要过滤一下,过滤函数如下:

const excludeFiles = (files) => {
  const excludes = ["node_modules"];
  return files.filter(
    (path) => !excludes.some((exclude) => path.includes(exclude))
  );
};

最终我们读取 package 目录下的文件代码变成以下方式:

const input = excludeFiles(
  await glob("**/*.{js,ts,vue}", {
    cwd: pkgRoot,
    absolute: true, // 返回绝对路径
    onlyFiles: true, // 只返回文件的路径
  })
);

接下来我们进行配置输出文件信息,我们主要设置输出的代码格式,输出文件的目录,并且需要把源码中的目录结构完整输出,等于是复制一遍。那么我们只需进行以下配置即可。

bundle.write({
  format: "esm", // 配置输出格式
  dir: resolve(epOutput, "es"), // 配置输出目录
  preserveModules: true, // 该选项将使用原始模块名作为文件名,为所有模块创建单独的 chunk
  entryFileNames: `[name].mjs`, // [name]:入口文件的文件名(不包含扩展名),也就是生产 .mjs 结尾的文件
});
bundle.write({
  format: "cjs", // 配置输出格式
  dir: resolve(epOutput, "lib"), // 配置输出目录
  preserveModules: true, // 该选项将使用原始模块名作为文件名,为所有模块创建单独的 chunk
  entryFileNames: `[name].cjs`, // [name]:入口文件的文件名(不包含扩展名),也就是生产 .cjs 结尾的文件
});

开启 preserveModules 选项,就是为了输入的产物结构与输入一致,简单来说就是与源码目录结构保持一致。

我们在命令终端进入 ./internal/build 运行以下命令:

node ./modules.js

这个时候我们看到打包目录下成功出现了 es 和 lib 的两个目录:

16. Element Plus 组件库的打包原理与实践详解

// 排除不进行打包的 npm 包,例如 Vue,以便减少包的体积
external: [
  'vue',
  '@vue/shared',
  '@element-plus/icons-vue',
  '@vueuse/core',
  'async-validator',
],

我们删掉 dist 目录重新打包之后,我们就发现 es 和 lib 的两个目录下都没有了 node_modules 目录。

16. Element Plus 组件库的打包原理与实践详解

此外我们希望把 ./dist/cobyte-ui/es/cobyte-ui 下的文件提取到 ./dist/cobyte-ui/es/ 下,以及把 ./dist/cobyte-ui/lib/cobyte-ui 下的文件提取到 ./dist/cobyte-ui/lib/ 下。我们需要做以下修改:

+  const epRoot = resolve(pkgRoot, 'cobyte-ui')

  bundle.write({

    preserveModules: true, // 该选项将使用原始模块名作为文件名,为所有模块创建单独的 chunk
+   preserveModulesRoot: epRoot,
    entryFileNames: `[name].mjs`, // [name]:入口文件的文件名(不包含扩展名),也就是生产 .mjs 结尾的文件
  });
  bundle.write({

    preserveModules: true, // 该选项将使用原始模块名作为文件名,为所有模块创建单独的 chunk
+   preserveModulesRoot: epRoot,
    entryFileNames: `[name].cjs`, // [name]:入口文件的文件名(不包含扩展名),也就是生产 .cjs 结尾的文件
  });

preserveModulesRoot 选项简单来理解就是将设置的目录作为根目录进行输出,其他结构不变。

重新打包后,我们就可以看到 ./dist/cobyte-ui/es/cobyte-ui 目录和 ./dist/cobyte-ui/lib/cobyte-ui 目录不见了。

16. Element Plus 组件库的打包原理与实践详解

完整代码配置如下:

import { fileURLToPath } from "url";
import { resolve, dirname } from "path";
import { rollup } from "rollup";
import vue from "@vitejs/plugin-vue";
import VueMacros from "unplugin-vue-macros/rollup";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import esbuild from "rollup-plugin-esbuild";
import replace from "@rollup/plugin-replace";
import glob from "fast-glob";

const __filenameNew = fileURLToPath(import.meta.url);
const __dirnameNew = dirname(__filenameNew);

const projRoot = resolve(__dirnameNew, "..", "..");
const pkgRoot = resolve(projRoot, "packages");

/** `/dist` */
const buildOutput = resolve(projRoot, "dist");
/** `/dist/cobyte-ui` */
const epOutput = resolve(buildOutput, "cobyte-ui");

const epRoot = resolve(pkgRoot, "cobyte-ui");

const excludeFiles = (files) => {
  const excludes = ["node_modules"];
  return files.filter(
    (path) => !excludes.some((exclude) => path.includes(exclude))
  );
};

// 模块化打包任务函数
const buildModules = async () => {
  const input = excludeFiles(
    await glob("**/*.{js,ts,vue}", {
      cwd: pkgRoot,
      absolute: true, // 返回绝对路径
      onlyFiles: true, // 只返回文件的路径
    })
  );
  const bundle = await rollup({
    input, // 配置入口文件
    plugins: [
      // 配置插件
      VueMacros({
        plugins: {
          vue: vue(),
        },
      }),
      nodeResolve({
        extensions: [".ts"],
      }),
      replace({
        "process.env.NODE_ENV": '"production"',
        preventAssignment: true, // 这个选项用于防止在字符串后面紧跟一个等号时进行替换。可以用于避免错误的赋值操作
      }),
      esbuild(),
    ],
    // 排除不进行打包的 npm 包,例如 Vue,以便减少包的体积
    external: [
      "vue",
      "@vue/shared",
      "@element-plus/icons-vue",
      "@vueuse/core",
      "async-validator",
    ],
  });
  // 配置输出文件格式
  bundle.write({
    format: "esm", // 配置输出格式
    dir: resolve(epOutput, "es"), // 配置输出目录
    preserveModules: true, // 该选项将使用原始模块名作为文件名,为所有模块创建单独的 chunk,也就是打包后保留目录结构
    preserveModulesRoot: epRoot,
    entryFileNames: `[name].mjs`, // [name] 表示入口文件的文件名(不包含扩展名),也就是生产 .mjs 结尾的文件
  });
  bundle.write({
    format: "cjs", // 配置输出格式
    dir: resolve(epOutput, "lib"), // 配置输出目录
    preserveModules: true, // 该选项将使用原始模块名作为文件名,为所有模块创建单独的 chunk,也就是打包后保留目录结构
    preserveModulesRoot: epRoot,
    entryFileNames: `[name].cjs`, // [name] 表示入口文件的文件名(不包含扩展名),也就是生产 .cjs 结尾的文件
  });
};
buildModules();

NPM 包 package.json 的设置原理

我们打包提供 Node.js 环境的 CommonJS 和 ESM 模块规范代码本质就是为了提供 npm 包,npm 包的详细信息都储存在 npm 包的 package.json 文件中。每个通过 package.json 文件来描述项目或软件信息的 JavaScript 项目,无论是 Node.js 还是浏览器应用程序都可以被当作 npm 软件包。

在这里我们再次简单复述一下:

执行 npm install 的时候会检查项目中有没有 package-lock.json 文件,有则检查 package-lock.json 文件和 package.json 文件的依赖声明版本是否一致。如果没有 package-lock.json 文件,则会根据 package.json 文件递归构建依赖树,然后按照构建好的依赖树下载完整的依赖资源。

这里值得注意的是在 Node.js 应用程序的根目录下的 package.json 文件中的依赖,不管是 dependencies 依赖还是 devDependencies 依赖还是 peerDependencies 依赖都会被安装,它们的区别是在当 package.json 是子依赖中的话就只会安装 dependencies 选项中的依赖。

比如我们在项目中安装 Element Plus 组件时,Element Plus 组件库中的 dependencies 选项中的依赖就同时被下载。devDependencies 选项的依赖则不会被下载。因为 devDependencies 选项依赖一般只在开发环境中被使用到。但如果我们从 GitHub 上克隆 Element Plus 组件库源码项目下来,进入到 Element Plus 项目中进行 npm install 时则 dependencies 和 devDependencies 选项都会被下载,因为此时是在开发 Element Plus 组件库阶段了,此时 package.json 文件的作用是用来描述整个应用项目的,而 npm 包中的 package.json 文件则是描述 npm 包的。

基于此原理,那么我们在打包 npm 包的时候我们就将一些生产环境的的依赖不进行打包,以便减少包体积。那么这些不进行打包的依赖我们就要把它们设置到 package.json 文件中的 dependencies 选项中,这样将来它们作为依赖中依赖就会在 npm install 的时候被安装了。

我们从命令终端进入到 ./dist/cobyte-ui 目录,然后通过命令 npm init -y 初始化一个 package.json 文件。

然后补充以下内容:

{
  "name": "cobyte-ui",
  "version": "0.0.0-dev.1",
  "description": "A Component Library for Vue 3",
  "license": "MIT",
  "main": "lib/index.cjs",
  "module": "es/index.mjs",
  "peerDependencies": {
    "vue": "^3.2.0"
  },
  "dependencies": {
    "@element-plus/icons-vue": "^2.0.6",
    "@vueuse/core": "^9.1.0",
    "async-validator": "^4.2.5"
  },
  "devDependencies": {}
}

当在一个项目中进行 npm install 时,项目根目录的 package.json 中的 dependencies、devDependencies、peerDependencies 中的依赖都会被安装,而在子依赖中的 npm 包则只会安装 dependencies 中的依赖,也正是基于此原理,我们在打包 npm 包的时候我们就将一些生产环境的的依赖不进行打包,而那些不进行打包的依赖则需要设置到 package.json 中的 dependencies 选项中。

本地模拟测试 NPM 包

这样 ./dist/cobyte-ui 目录下的文件内容就成了一个 npm 包的内容。这个时候我们可以通过 npm link 命令进行本地 npm 包测试。npm link 可以帮助我们模拟 npm 包安装后的状态,它会在系统中做一个快捷方式映射,让本地的包就好像 install 过一样,可以直接使用。

我们在命令终端进入到 ./dist/cobyte-ui 目录,然后运行命令 npm link,这操作相当于在全局 node_modules 目录下创建一个 cobyte-ui 的超链接,相当于全局安装了一个 npm 包。

接着我们在命令终端进入到 ./play 目录,然后运行命令 npm link cobyte-ui,此操作则相当于本地安装 cobyte-ui 包。

这样我们就可以在 ./play/main.ts 文件中进行以下的方式引用我们打包好的组件库了

import ElementPlus from "cobyte-ui";

上述的引用方式就像一个正常的发布后的 npm 包的引用方式了。

接着我们启动 play 项目

pnpm dev

我们发现正常启动了,说明我们的打包是成功的。

16. Element Plus 组件库的打包原理与实践详解

上述方式是 ESM 模块的测试,至于 CommonJS 模块的测试,本文不展会开讨论。

打包组件库的 package.json

我们现在的组件库的 package.json 文件是手动在 ./dist/cobyte-ui 下创建的,但在后续我们需要重新打包的时候,我们是会先把 dist 目录删除掉的,这样又要手动在 ./dist/cobyte-ui 下手动重新创建 package.json 文件了,这是不可接受的。我们希望 package.json 文件先放在 ./packages/cobyte-ui 目录下,等组件库打包完成之后,再从 ./packages/cobyte-ui 目录下复制到 ./dist/cobyte-ui 目录下。

我们在 ./internal/build/ 下创建一个 copyFiles.js 文件,内容如下:

import { copyFile } from "fs/promises";
import { fileURLToPath } from "url";
import { resolve, dirname, join } from "path";

const __filenameNew = fileURLToPath(import.meta.url);
const __dirnameNew = dirname(__filenameNew);
const projRoot = resolve(__dirnameNew, "..", "..");

const pkgRoot = resolve(projRoot, "packages");
const epRoot = resolve(pkgRoot, "cobyte-ui");
const epPackage = resolve(epRoot, "package.json");

/** `/dist` */
const buildOutput = resolve(projRoot, "dist");
/** `/dist/cobyte-ui` */
const epOutput = resolve(buildOutput, "cobyte-ui");

// 复制
const copyFiles = () => copyFile(epPackage, join(epOutput, "package.json"));

copyFiles();

这样我们在打包完毕之后再执行 node ./copyFiles.js 即可把打包后的组件库的 package.json 文件从 ./packages/cobyte-ui 目录下复制到 ./dist/cobyte-ui 目录下了。

为什么要提供 .mjs.cjs 的文件?

是因为 .mjs 文件总是以 ESM 模块加载,.cjs 文件总是以 CommonJS 模块加载,在 Node.js 环境中 .js 文件的加载取决于 package.json 里面 type 字段的设置,默认情况下是以 CommonJS 模块加载。 此外 ESM 模块与 CommonJS 模块尽量不要混用。require 命令不能加载 .mjs 文件,否则会报错,只有 import 命令才可以加载 .mjs 文件。同样地,.mjs 文件里面也不能使用 require 命令,必须使用 import。这样则更加有利于 Tree shaking。

更多详情可查看阮一峰老师的这篇文章 Node.js 如何处理 ES6 模块

生成类型声明文件(.d.ts

我们要生成 TypeScript 的类型声明文件,本质是要对 TypeScript 的代码进行操作,而 TypeScript 则是提供了很多 Compiler API,使得我们可以通过代码的方式访问和操作 TypeScript 抽象语法树(AST),这样我们就可以分析、修改和生成新的 TypeScript 代码,也可以单独生成声明文件。但 TypeScript Compiler API 上手比较难,所以技术社区就基于 TypeScript Compiler API 的基础上开发出新的操作 TypeScript 代码的库,降低使用难度。ts-morph(原 ts-simple-ast )就是其中一个,Element Plus 也是使用了该库。

安装 ts-morph

在命令终端进到 ./internal/build 目录运行以下命令

pnpm install ts-morph -D

然后新建一个 types-definitions.js 文件,用来通过 ts-morph 写生成 TypeScript 的类型声明文件逻辑代码。

然后我们通过 ts-morph 方式创建一个 TypeScript 项目,代码如下:

import { Project } from "ts-morph";
const project = new Project();

我们平时设置 TypeScript 项目的配置是通过根目录的 tsconfig.json 文件来设置的,我们现在可以通过给 Project 传递一个对象,在对象中进行相关的设置。

import { Project } from "ts-morph";
const project = new Project({
  compilerOptions: {
    target: "es2018",
  },
});

因为我们本身就已经在项目中 tsconfig.json 文件设置了相关的 TypeScript 项目的配置,我们可以通过 tsConfigFilePath 字段设置本地项目中的 tsconfig.json 文件作为 ts-morph 项目的 TypeScript 配置,具体设置如下:

import { fileURLToPath } from "url";
import { dirname, resolve } from "path";
import { Project } from "ts-morph";

const __filenameNew = fileURLToPath(import.meta.url);
const __dirnameNew = dirname(__filenameNew);
const projRoot = resolve(__dirnameNew, "..", "..");
// `tsconfig.json` 文件绝对路径
const TSCONFIG_PATH = resolve(projRoot, "tsconfig.web.json");

const project = new Project({
  compilerOptions: {
    target: "es2018",
  },
  tsConfigFilePath: TSCONFIG_PATH, // 手动指定 tsconfig.json 文件作为 ts-morph 项目的 TypeScript 配置
});

而在项目根目录中的 tsconfig.json 文件中设置的 baseUrl 路径是根目录和编译文件输出的目录也是根目录下的 dist 目录。

16. Element Plus 组件库的打包原理与实践详解

而我们现在 ts-morph 项目运行的根目录却是 ./internal/build 目录,所以我们需要修改这些设置。而重新更改 tsconfig.json 文件中设置也很简单,就是重新在 compilerOptions 选项中重新配置即可。

重新调整之后的代码如下:

import { fileURLToPath } from "url";
import { dirname, resolve } from "path";
import { Project } from "ts-morph";

const __filenameNew = fileURLToPath(import.meta.url);
const __dirnameNew = dirname(__filenameNew);

const projRoot = resolve(__dirnameNew, "..", "..");
// dist 目录地址
const buildOutput = resolve(projRoot, "dist");
// `tsconfig.json` 文件绝对路径
const TSCONFIG_PATH = resolve(projRoot, "tsconfig.web.json");
// 声明文件输出目录
const outDir = resolve(buildOutput, "types");

const project = new Project({
  compilerOptions: {
    emitDeclarationOnly: true, // 是否只输出类型文件 .d.ts
    outDir, // 输出目录
    baseUrl: projRoot, // 用于解析非相对模块名称的目录
    preserveSymlinks: true, // 它对应了 Node.js 中 --preserve-symlinks 选项的行为,Node.js 有这样一个选项:–preserve-symlinks,可以设置成按照软链所在的位置查找依赖
    skipLibCheck: true, // 跳过.d.ts类型声明文件的类型检查。这样可以加快编译速度
    noImplicitAny: false, // 是否允许隐式声明 any 类型了
  },
  tsConfigFilePath: TSCONFIG_PATH, // 手动指定 tsconfig.json 文件作为 ts-morph 项目的 TypeScript 配置
});

project.emit();

最后我们通过实例对象中的 emit() 方法进行把 TypeScript 代码编译成 JavaScript 代码或者声明文件(.d.ts

在命令终端进到 ./internal/build 目录运行以下命令

node .\types-definitions.js

然后我们就可以看到在根目录下的 dist 目录中的 types 目录中生成了相关的类型文件(.d.ts

16. Element Plus 组件库的打包原理与实践详解

目前上面的方式是通过 tsconfig.json 文件配置读取的源文件,这种方式是无法读取 .vue 文件的,所以我们放弃这种方式读取源文件,而是通过手动添加源文件的方式。首先我们通过设置 skipAddingFilesFromTsConfig 属性为 true 来取消从 tsconfig.json 文件中添加 TypeScript 源文件。具体设置如下:

// ...
const project = new Project({
  // ...
  skipAddingFilesFromTsConfig: true, // 取消从 tsconfig.json 文件中添加 TypeScript 源文件
});

我们设置 skipAddingFilesFromTsConfig 属性为 true 后,我们删除 dist 目录内容,重新执行 node .\types-definitions.js 命令后,我们发现 dist 目录相关的声明文件不再生成了。

我们可以以下方式手动添加 TypeScript 源文件:

// ...
const project = new Project({
  // ...
  skipAddingFilesFromTsConfig: true, // 取消从 tsconfig.json 文件中添加 TypeScript 源文件
});

project.createSourceFile("index.ts", "type s = string");

还可以通过 addSourceFileAtPath 方式

手动添加 TypeScript 源文件的方式还有其他方式,详细可以查看 ts-morph 文档。

我们可以像上面生成模块代码那样通过  fast-glob  包,动态地读取需要添加 TypeScript 源文件。我们需要动态读取两部分的代码,一部分是读取 ./packages 目录下除了 cobyte-ui 目录的文件,另一部份则是读取 ./packages/cobyte-ui 目录下的文件。我们把手动添加 TypeScript 源文件的逻辑代码封装到一个 addSourceFiles 函数中。而整体生成类型文件的逻辑代码也封装到一个 generateTypesDefinitions 函数中,所以重构后的代码结构如下:

//...

// 整体生成类型文件的函数
const generateTypesDefinitions = async () => {
  const project = new Project({
    // ...
  });
  // 手动添加 TypeScript 源文件
  await addSourceFiles(project);

  project.emit();
};

async function addSourceFiles(project) {
  // 读取的文件类型 .js .jsx .ts .tsx .vue
  const globSourceFile = "**/*.{js?(x),ts?(x),vue}";
  // excludeFiles 函数上文有介绍,也就是过滤一些不需要的文件
  const filePaths = excludeFiles(
    await glob([globSourceFile, "!cobyte-ui/**/*"], {
      cwd: pkgRoot, // 读取 packages 目录下除了 cobyte-ui 目录的文件
      absolute: true, // 读取绝对路径
      onlyFiles: true, // 只读取文件
    })
  );
  const epPaths = excludeFiles(
    await glob(globSourceFile, {
      cwd: epRoot, // 读取 ./packages/cobyte-ui 目录下的文件
      onlyFiles: true, // 只读取文件
    })
  );
  console.log("filePaths", filePaths, "epPaths", epPaths);
}
// 执行创建生成声明文件
generateTypesDefinitions();

然后我们通过打印 filePaths 和 epPaths 变量可以看到 filePaths 变量是一个 packages 目录下除了 cobyte-ui 目录的文件的绝对路径的数组,epPaths 变量则是 ./packages/cobyte-ui 目录下的文件相对路径,之所以这么设计是因为我们要把 ./packages/cobyte-ui 目录下的文件生产的 .d.ts 文件移动到 ./packages 目录下而不是 ./packages/cobyte-ui 目录下。

16. Element Plus 组件库的打包原理与实践详解

我们看到 filePaths 变量中的文件有 .ts 文件,也有 .vue 文件,我们知道 TypeScript 只能处理 .ts 文件,对于 .ts 文件,我们只需要通过 addSourceFileAtPath 方法添加文件路径的方式添加 ts-morph 项目的 TypeScript 源文件即可,而对 .vue 文件,则需要先编译,然后获取编译后的代码再通过 createSourceFile 方法创建 ts-morph 项目的 TypeScript 源文件。而 epPaths 变量中的文件,则需要通过读取文件内容再通过 createSourceFile 方法创建 ts-morph 项目的 TypeScript 源文件,因为这样可以构建新的文件路径以达到移动的目的。

async function addSourceFiles(project) {
  // ...

  await Promise.all([
    ...filePaths.map((file) => {
      if (file.endsWith(".vue")) {
        // 处理 .vue 文件
      } else {
        // 如果不是 .vue 文件则 addSourceFileAtPath 添加文件路径的方式添加 ts-morph 项目的 TypeScript 源文件
        project.addSourceFileAtPath(file);
      }
    }),
    ...epPaths.map(async (file) => {
      // 读取 ./packages/cobyte-ui 目录下的文件,并手动通过 createSourceFile 方法添加 ts-morph 项目的 TypeScript 源文件
      const content = await readFile(path.resolve(epRoot, file), "utf-8");
      // 以构建新的文件路径以达到移动的目的
      project.createSourceFile(path.resolve(pkgRoot, file), content);
    }),
  ]);
}

我们删除 dist 目录内容,重新执行 node .\types-definitions.js 命令后,我们发现 dist 目录相关的声明文件再次生成了,这次我们是通过手动添加 TypeScript 源文件的方式。

16. Element Plus 组件库的打包原理与实践详解

我们可以看到 cobyte-ui 目录不见了,而原来的 cobyte-ui 目录中的 .d.ts 文件已经移到了 ./packages 目录下了。

接着我们来处理 .vue 文件生成 .d.ts 文件,在这之前,我们需要先了解怎么编译 .vue 文件的。

如何编译 .vue 文件

其实 Vue3 提供了 @vue/compiler-sfc 包对 .vue 文件进行编译。例如我们创建一个文件名称为:script.vue 内容如下的一个 .vue 文件。

<template>
  <div>{{ name }}</div>
</template>
<script>
  import { defineComponent } from "vue";
  export default defineComponent({
    name: "CompilerSfcTest",
    setup() {
      const name = ref("如何编译 .vue 文件");
      return {
        name,
      };
    },
  });
</script>
<style>
  div {
    color: red;
  }
</style>

我们要对 script.vue 文件进行编译,先要读取到它的文件内容。

import { readFile } from "fs/promises";
const content = await readFile("./script.vue", "utf-8");

然后用 @vue/compiler-sfc 提供的 parse 解析函数,对代码进行解析。

import { readFile } from "fs/promises";
+ import { parse } from "@vue/compiler-sfc";
const content = await readFile("./script.vue", "utf-8");
+ const { descriptor } = parse(content);

通过 parse 解析的结果会放在 descriptor 属性中,我们查看 descriptor 属性我们可以看到如下的信息。

16. Element Plus 组件库的打包原理与实践详解

其中重要的就是我们可以看到熟悉的字段: script、scriptSetup、styles、template;其实就是 .vue 文件中的三个模块:script 模块、template 模块、style 模块,其中 scriptSetup 就是 <script setup> 语法糖的标签内容。在这一步中是还没有对代码进行编译的,只是把 .vue 文件的不同模块进行拆分,拆分的目的就是获得各模块的代码,然后分别进行解析编译。而值得注意的是我们从上图可以看到 styles 属性是一个数组,也就是说 style 模块是可以存在多个的,而其他属性是对象,则是说其他模块只允许存在一个。

我们都知道 template 标签是需要被编译成 render 函数的,把 template 标签编译成 render 函数是通过 @vue/compiler-sfc 包提供的 compileTemplate 函数

import { compileTemplate } from "@vue/compiler-sfc";

// 这个 id 是 scopeId,用于 css scope,保证唯一即可
const id = Date.now().toString();
const scopeId = `data-v-${id}`;

// 编译模板,转换成 render 函数
const render = compileTemplate({
  source: descriptor.template.content,
  filename: "script.vue",
  id: scopeId,
});

当 script 标签使用 <script setup> 语法糖的标签的时候,也需要对其内容进行编译,script setup 的代码是不能直接运行的,需要进行转换,比如编译宏那些需要进行转换。

import { compileScript } from "@vue/compiler-sfc";

// 编译 script setup,编译宏那些需要进行转换
const script = compileScript(descriptor, { id: scopeId });

经过上面的处理我们就得到了 render 函数和一个 Vue 组件对象,我们只需要把他们组合在一起即可完成一个完整的 Vue 组件对象了。

script.render = render;

此外还有 style 标签则是通过 @vue/compiler-sfc 包提供的 compileStyle 函数的处理,因为先要处理 Vue 特有的功能 style scopev-bind():deep() 等,然后再交给其他预处理器 less、sass 进行后续的处理,这里就不再过多深入探讨了,日后有时间再深入跟大家探讨。

生成 .vue 文件的 .d.ts 文件

通过上文我们了解了如何编译 .vue 文件之后,我们就清楚如何生成 .vue 文件的 .d.ts 文件了。我们只需要对 .vue 文件初步编译后获取 script 部分代码,再对 <script setup> 标签部分代码再进行编译,再把编译后的内容通过 ts-morph 的实例对象的 createSourceFile 方法创建 ts-morph 项目的 TypeScript 源文件即可。

async function addSourceFiles(project) {
  // ...
  await Promise.all([
    ...filePaths.map(async (file) => {
      if (file.endsWith(".vue")) {
        // 处理 .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;
          }

          const lang = scriptSetup.lang || script.lang || "js";
          // 创建 TypeScript 源文件
          // process.cwd():获取当前进程工作目录
          // path.relative() 方法根据当前工作目录返回从 from 到 to 的相对路径
          project.createSourceFile(
            `${path.relative(process.cwd(), file)}.${lang}`,
            content
          );
        }
      } else {
        // ...
      }
    }),
    // ...
  ]);
}

我们重新在 ./internal/build 目录下执行 node .\types-definitions.js

我们发现生成了 .vue.d.ts 的声明文件了。

16. Element Plus 组件库的打包原理与实践详解

如果文件中存在类型错误会造成的 TypeScript 错误,所以在生成 .d.ts 文件前,我们需要进行类型检查。

function typeCheck(project) {
  const diagnostics = project.getPreEmitDiagnostics();
  if (diagnostics.length > 0) {
    console.error(project.formatDiagnosticsWithColorAndContext(diagnostics));
    const err = new Error("Failed to generate dts.");
    console.error(err);
    throw err;
  }
}

如果存在类型错误,则直接报错。

最后 types-definitions.js 文件内容如下:

import process from "process";
import path, { resolve } from "path";
import { fileURLToPath } from "url";
import { readFile } from "fs/promises";
import { Project } from "ts-morph";
import glob from "fast-glob";
import * as vueCompiler from "vue/compiler-sfc";

const __filenameNew = fileURLToPath(import.meta.url);
const __dirnameNew = path.dirname(__filenameNew);
const projRoot = resolve(__dirnameNew, "..", "..");
const pkgRoot = resolve(projRoot, "packages");
const epRoot = resolve(pkgRoot, "cobyte-ui");
const buildOutput = resolve(projRoot, "dist");

const TSCONFIG_PATH = path.resolve(projRoot, "tsconfig.web.json");
const outDir = path.resolve(buildOutput, "types");

const excludeFiles = (files) => {
  const excludes = ["node_modules"];
  return files.filter(
    (path) => !excludes.some((exclude) => path.includes(exclude))
  );
};

const generateTypesDefinitions = async () => {
  const project = new Project({
    compilerOptions: {
      emitDeclarationOnly: true, // 是否只输出类型文件 .d.ts
      outDir, // 输出目录
      baseUrl: projRoot, // 用于解析非相对模块名称的目录
      preserveSymlinks: true, // 它对应了 Node.js 中 --preserve-symlinks 选项的行为,Node.js 有这样一个选项:–preserve-symlinks,可以设置成按照软链所在的位置查找依赖
      skipLibCheck: true, // 跳过.d.ts类型声明文件的类型检查。这样可以加快编译速度
      noImplicitAny: false, // 是否允许隐式声明 any 类型了
    },
    tsConfigFilePath: TSCONFIG_PATH, // 手动指定 tsconfig.json 文件作为 ts-morph 项目的 TypeScript 配置
    skipAddingFilesFromTsConfig: true, // 取消从 tsconfig.json 文件中添加 TypeScript 源文件
  });
  // 手动添加 TypeScript 源文件
  await addSourceFiles(project);
  // 进行类型检查
  typeCheck(project);
  // 进行代码生成
  project.emit();
};

async function addSourceFiles(project) {
  // 读取的文件类型 .js .jsx .ts .tsx .vue
  const globSourceFile = "**/*.{js?(x),ts?(x),vue}";
  // excludeFiles 函数上文有介绍,也就是过滤一些不需要的文件
  const filePaths = excludeFiles(
    await glob([globSourceFile, "!cobyte-ui/**/*"], {
      cwd: pkgRoot, // 读取 packages 目录下除了 cobyte-ui 目录的文件
      absolute: true, // 读取绝对路径
      onlyFiles: true, // 只读取文件
    })
  );
  const epPaths = excludeFiles(
    await glob(globSourceFile, {
      cwd: epRoot, // 读取 ./packages/cobyte-ui 目录下的文件
      onlyFiles: true, // 只读取文件
    })
  );

  await Promise.all([
    // eslint-disable-next-line array-callback-return
    ...filePaths.map(async (file) => {
      if (file.endsWith(".vue")) {
        // 处理 .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";
          // process.cwd():获取当前进程工作目录
          // path.relative() 方法根据当前工作目录返回从 from 到 to 的相对路径
          project.createSourceFile(
            `${path.relative(process.cwd(), file)}.${lang}`,
            content
          );
        }
      } else {
        // 如果不是 .vue 文件则 addSourceFileAtPath 添加文件路径的方式添加 ts-morph 项目的 TypeScript 源文件
        project.addSourceFileAtPath(file);
      }
    }),
    ...epPaths.map(async (file) => {
      // 读取 ./packages/cobyte-ui 目录下的文件,并手动通过 createSourceFile 方法添加 ts-morph 项目的 TypeScript 源文件
      const content = await readFile(path.resolve(epRoot, file), "utf-8");
      project.createSourceFile(path.resolve(pkgRoot, file), content);
    }),
  ]);
}
// 进行类型检查
function typeCheck(project) {
  const diagnostics = project.getPreEmitDiagnostics();
  if (diagnostics.length > 0) {
    console.error(project.formatDiagnosticsWithColorAndContext(diagnostics));
    const err = new Error("Failed to generate dts.");
    console.error(err);
    throw err;
  }
}

generateTypesDefinitions();

因为有 ESM 与 CJS 两种格式都需要类型声明文件,所以我们一开始把声明文件只生成到一个文件夹中,生成之后,我们再把所有声明文件复制到 dist/cobyte-ui/es 与 dist/cobyte-ui/lib 中。

我们创建一个 copyTypesDefinitions.js 文件,内容如下:

import path, { resolve } from "path";
import { fileURLToPath } from "url";
import { copy } from "fs-extra";

const __filenameNew = fileURLToPath(import.meta.url);
const __dirnameNew = path.dirname(__filenameNew);

const projRoot = resolve(__dirnameNew, "..", "..");
/** `/dist` */
const buildOutput = resolve(projRoot, "dist");
const epOutput = resolve(buildOutput, "cobyte-ui");

const copyTypesDefinitions = () => {
  const src = path.resolve(buildOutput, "types", "packages");
  // 将 ./dist/types/packages 的内容复制到 ./dist/cobyte-ui/es 目录下, recursive 为 true 表示递归复制
  copy(src, resolve(epOutput, "es"), { recursive: true });
  // 将 ./dist/types/packages 的内容复制到 ./dist/cobyte-ui/es 目录下, recursive 为 true 表示递归复制
  copy(src, resolve(epOutput, "lib"), { recursive: true });
};

copyTypesDefinitions();

我们还需要安装 fs-extra

pnpm add fs-extra

接着我们删除 dist 目录,接着在 ./internal/build 目录下 先执行 node ./modules.js 生成 ESM 和 CJS 代码 再执行 node .\types-definitions.js 生成声明文件 最后执行 node ./copyTypesDefinitions.js 将声明文件复制到对应的 ESM 和 CJS 模块中。

执行的结果如下:

16. Element Plus 组件库的打包原理与实践详解

我们可以看到相关声明文件都被复制到对应的组件目录中了。

通过 gulp 自动执行多项任务

我们上面是通过手动的方式,一步一步地执行不同的命令进行操作的,这样太麻烦了,我们希望可以只执行一次命令操作,然后后台就可以自动帮我们完成上述的各种操作。这样我们就可以采用 gulp 进行编排任务进行执行了。

我们现在安装 gulp:

pnpm add gulp

gulp 默认不支持 esm 和 ts,我们需要通过 @esbuild-kit/cjs-loader 实时将 esmts 转换为 CommonJS,所以我们还需要安装 @esbuild-kit/cjs-loader

pnpm add @esbuild-kit/cjs-loader -D

gulp 的使用还是比较简单的,我们先把 ./full-bundle.js 中的 buildFullEntry 函数,./modules.js 中的 buildModules 函数,./types-definitions.js 中的 generateTypesDefinitions 函数,./copyTypesDefinitions.js 中的 copyTypesDefinitions 函数通过 export 进行导出,并且注释原来的执行函数。

然后在 ./internal/build 目录下创建 gulpfile.js 文件,在 gulpfile.js 文件中编写任务代码。

import { parallel, series } from "gulp";
import { buildFullEntry } from "./full-bundle.js";
import { buildModules } from "./modules.js";
import { generateTypesDefinitions } from "./types-definitions.js";
import { copyTypesDefinitions } from "./copyTypesDefinitions.js";

export default series(
  parallel(buildFullEntry, buildModules, generateTypesDefinitions),
  copyTypesDefinitions
);

series 函数是按顺序执行任务,parallel 则是同时执行任务。那么很明显构建浏览器的代码和构建 ESM、CJS 的代码以及构建声明文件是可以同时执行的,所以他们放在 parallel 执行,而复制声明文件到 ESM 和 CJS 模块中,则需要等声明文件构建之后才可以,所以这两个任务是不能同时执行的。

之后我们在 ./internal/build 目录下的 package.json 文件的 script 选项中设置以下命令:

  "scripts": {
    "start": "gulp --require @esbuild-kit/cjs-loader -f gulpfile.js"
  }

直接我们运行命令行中运行: pnpm run start

我们就可以看到正常打包出了相关的文件,而且我们通过 gulp 只需要执行一次命令即可,不用手动执行多次命令进行打包。

构建组件样式

最后再简单说说,CSS 的构建流程。CSS 模块单纯分析打包流程也是很简单的,就是进入 ./packages/theme- chalk/ 中通过 gulp 将 Sass 源代码构建为 CSS 产物,然后再将产物从  packages/theme-chalk/dist  复制到  dist/element-plus/theme-chalk,以及把完整的 CSS 样式复制到完整构建产物的目录中,就是把 dist/element-plus/theme-chalk/index.css  复制到  dist/element-plus/dist/index.css

样式构建的过程是很简单的,重要的是背后的设计思想,为什么要这么做,我们下一篇再详细探讨。

总结

至此我们整个 Element Plus 的打包流程大体上分析得差不多了,剩下的一些细节以及 CSS 模块,本篇限于篇幅就不再作过多的深入分析了,后续有机会再另起篇章再作分析。

整个组件库的打包过程涉及到的知识还是比较过的,首先是 ESM、CommonJS、UMD 等模块规范需要熟悉了解,其次是 Rollup 的了解,熟悉了解不同的使用模式,比如 Rollup 的 API 使用模式、接着是 .vue 文件的编译原理,还有 npm 包的原理以及 npm install 的原理等。

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

欢迎关注本专栏,了解更多 Element Plus 组件库知识

本专栏文章: