从0搭建vue3组件库,并引入 Element Plus

B站影视 电影资讯 2025-09-01 12:10 1

摘要:Monorepo 是一个管理项目代码的策略,指在单一的代码库(repository)中维护多个项目或组件。这种方式与多个小型仓库(多 repo)相对,后者将不同的项目或组件分别放在独立的仓库中管理。

搭建自己的组件库是提升开发效率、确保一致性和提高代码质量的重要方式,尤其是在团队合作和大型项目中尤为明显

Monorepo 是一个管理项目代码的策略,指在单一的代码库(repository)中维护多个项目或组件。这种方式与多个小型仓库(多 repo)相对,后者将不同的项目或组件分别放在独立的仓库中管理。

使用 Monorepo 的好处包括:

#技术分享统一的版本管理 :所有项目或组件共享同一套版本历史,便于跟踪更改和回滚操作。简化的依赖管理 :所有项目或组件共享相同的依赖库,减少了版本冲突的可能性。更容易的代码重用 :在同一仓库中,不同项目之间共享代码变得更加容易。集中化的工具和脚本 :构建、测试等工具可以集中配置和管理,所有项目都可以利用这些共享的资源。更方便的跨项目更改 :修改一个共享组件的代码时,可以立即看到这一更改对所有依赖该组件的项目的影响。

然而,Monorepo 也有其挑战,包括:

规模问题 :随着代码库的不断增长,管理和构建项目可能变得更复杂和耗时。工具的挑战 :需要强大的工具支持来有效地处理大量的代码和频繁的集成。权限管理 :在一个庞大的代码库中管理不同团队或项目的访问权限可能较为复杂。

大公司如 Google、Facebook 和 Twitter 等都采用了 Monorepo 方式来管理他们庞大的代码库,显示出这种策略在处理大规模开发项目时的有效性。

在前端开发中,使用 Monorepo 结构来管理多个项目或包是一种常见的做法。这种结构有助于统一管理依赖项、版本控制和跨项目的代码共享。下面是一些流行的前端 Monorepo 工具:

LernaLerna 是一个优化使用git和npm管理多包仓库的工作流的工具。它可以自动化版本控制和包的发布过程,使得维护多个npm包变得更加容易。主要特性:自动化版本管理、独立或统一的版本号管理、自动化的包发布流程。Yarn WorkspacesYarn Workspaces 是Yarn内置的一个功能,允许用户在单个仓库中设置多个包。它可以有效地链接各个子包的依赖,并共享node_modules,从而避免了重复安装相同的依赖。主要特性:依赖项管理优化、结构简洁、易于跨包操作。Nx (Nrwl Extensions)Nx 是一个强大的、可扩展的Monorepo工具,专为现代前端应用和大型团队设计。它支持Angular、React、Node等多种框架,并提供了构建优化、代码生成和依赖图等高级功能。主要特性:跨框架支持、代码生成、影响分析、构建加速。RushRush 是一个用于管理JavaScript项目的Monorepo工具,支持npm和pnpm作为包管理器。它提供了高级的版本控制策略和并行构建能力,适合于大型企业级应用。主要特性:高度可配置的构建、发布和安装流程,支持自定义脚本和并行化操作。pnpm Workspacespnpm是一种npm替代工具,它使用硬链接和符号链接的方式来节省磁盘空间并提高安装速度。pnpm的工作区功能允许在单个仓库中管理多个包,与Yarn Workspaces类似。主要特性:高效的存储空间利用、快速的依赖安装过程。

这些工具各有其独特的功能和优势,适合不同大小和复杂度的项目。选择合适的工具通常取决于项目需求、团队的熟悉度以及现有的技术栈。

pnpm 是一种流行的包管理器,它在处理 Monorepo 项目时提供了一些独特的优势,尤其是与其他如 npm 、Yarn 或 Lerna 等工具相比。以下是 pnpm 在 Monorepo 管理方面的几个主要优势:

高效的存储方式 :pnpm 使用一种名为 内容寻址存储 的机制来管理依赖。这意味着它会在全局存储所有下载的包,并通过硬链接(hard links)和符号链接(symbolic links)将它们链接到项目的 node_modules 目录。这种方法不仅节省了磁盘空间,还减少了安装时间。更快的安装速度 :由于 pnpm 使用硬链接和符号链接来引用已存在的文件,而不是复制文件,这使得安装过程比传统的 npm 或 Yarn 更快。尤其是在大型 Monorepo 中,这种效率的提升尤为明显。严格的依赖隔离 :pnpm 创建的 node_modules 目录结构保证了依赖的完全隔离。每个包只能访问其声明的依赖,这有助于避免意外依赖和隐式依赖的问题。这种隔离策略提高了项目的稳定性和可预测性。对 Monorepo 的原生支持 :pnpm 的 Workspaces 功能支持 Monorepo 结构的原生管理,这意味着不需要额外的工具来管理多个项目或包。它可以自动处理多个项目间的依赖关系,使得跨项目的开发更为便捷。优化的资源利用 :在 Monorepo 环境中, pnpm 通过重用已有的包版本来最大化资源利用,从而减少不必要的网络请求和磁盘写入。这对于持续集成和持续部署(CI/CD)环境尤其有利,可以显著缩短构建和部署时间。灵活的配置选项 :pnpm 允许用户对依赖的安装方式进行详细配置,如选择是否使用软链接或硬链接,这为需要特定配置的大型项目或企业提供了更多灵活性。

总之,pnpm 的设计哲学和特点使其在处理大型 Monorepo 项目时表现出色,特别是在效率、安全性和资源利用方面。如果你正在考虑为你的项目选择一个 Monorepo 工具,pnpm 可以是一个非常值得考虑的选择。

pnpm-workspace.yaml 文件是用来配置 pnpm 工作区(monorepo)的核心文件。它指定了哪些目录包含项目(也就是子包)

基本结构

packages: - 'packages/**'

packages 是一个列表,其中的每个条目都是一个 glob 模式,用于匹配包含 package.JSON 文件的目录。这表示 pnpm 将会把 packages 目录下的每个子目录都视为一个独立的包。

示例配置 如果你有一个更复杂的项目结构,比如多个子目录下都有多个包,你的 pnpm-workspace.yaml 可能看起来像这样:

packages: - 'packages/*' - 'services/*' - 'libs/*'

在这个配置中,packages、services、和 libs 目录下的每个子目录都将被视为独立的包。

安装 typescript、vue3、Less 我们是基于 Vite+Ts 开发的 Vue3 组件库,所以我们需要安装 typescript、Vue3,同时项目将采用 Less 进行组件库样式的管理

pnpm add vue@3.4.27 typescript less -D -wvue@3.4.27 :安装 Vue.js 。typescript :安装 TypeScript,这是 JavaScript 的一个超集,添加了类型系统。less :安装 Less,这是一个 CSS 预处理器,用于编写更易于管理的 CSS。-D 或 --save-dev :这个选项意味着将这些依赖安装为开发依赖,通常用于开发环境而不是生产环境。-w 或 --workspace-root :当你在一个使用工作区(monorepo)的项目中时,这个选项会把依赖添加到工作区的根目录下,而不是当前子项目中。

初始化 TS

npx tsc --init

tsconfig.json 修改为以下配置

{ "compilerOptions": { "baseUrl": ".", // 设置模块解析的基本目录,"." 表示当前目录。 "JSX": "preserve", // JSX 语法保留,不做编译时转换,通常用于 Vue 中。 "strict": true, // 启用 TypeScript 严格模式,确保更严密的类型检查。 "target": "ES2015", // 将 TypeScript 编译为 ES2015 标准(ES6)兼容的代码。 "module": "ESNext", // 生成 ESNext 模块,支持最新的 JavaScript 模块标准。 "skipLibCheck": true, // 跳过库文件的类型检查,可能提高编译速度。 "esModuleInterop": true, // 允许默认导入非 ES 模块,通常为 CommonJS 模块。 "moduleResolution": "Node", // 使用 Node.js 风格的模块解析机制。 "lib": ["esnext", "DOM"], // 包含最新的 ECMAScript 和 DOM 的类型定义。 "resolveJsonModule": true, // 允许从 JSON 文件中导入内容。 "types": ["unplugin-vue-define-options/macros-global"] // 为宏函数和插件提供全局类型支持,特别是 Vue 相关插件。 }}baseUrl : 设置为 "." 表示基础目录是当前目录。这个设置在解析非相对模块名称时使用,允许你在导入模块时有一个固定的参考目录。jsx : 设置为 "preserve" 表示不对 JSX 语法做任何转化。这通常用在开发环境中,最终的 JSX 转换将由另一个工具(如 Babel)处理。strict : 开启所有严格类型检查选项。这是推荐的做法,因为它可以帮助你更早地发现潜在的错误。target : 设置为 "ES2015" (即 ES6),表示生成的 JavaScript 代码将符合 ES2015 标准。这决定了 TypeScript 编译器将 TypeScript 转换为哪个版本的 JavaScript。module : 设置为 "ESNext" ,表示输出的模块格式为最新的 ES 模块标准。这使得你的代码可以利用最新的模块功能。skipLibCheck : 设置为 true 表示跳过所有声明文件( .d.ts 文件)的类型检查。这可以加速编译过程,尤其是在有大量类型定义时。esModuleInterop : 开启允许你在 TypeScript 中更自然地使用 CommonJS 模块。例如,你可以用 import foo from 'foo' 而不是 import * as foo from 'foo' 。moduleResolution : 设置为 "Node" ,指定模块解析策略遵循 Node.js 的方式,这对于在 Node.js 环境中运行的代码是必需的。lib : 指定编译过程中将包含哪些库的文件。这里包括了 "esnext" 和 "dom" ,意味着 TypeScript 将包括最新 ECMAScript 的特性以及浏览器 DOM 定义。resolveJsonModule : 允许从 JSON 文件中导入内容。types : 为宏函数和插件提供全局类型支持,特别是 Vue 相关插件。

如果你在开发 Node.js 应用,可能不需要 jsx 和 dom 配置,除非你是在开发涉及服务器端渲染的项目。

在根目录下创建一个基于 Vite 的 Vue3+ts 名字叫做 play 的项目,用于调试组件

pnpm init @vitejs/app

在 pnpm-workspace.yaml 添加 play

packages: - "packages/**" - "play"

在根目录执行以下命令创建文件

mkdir -p packages/components

进入 components 文件夹执行 pnpm init 生成 package.json 文件,并将 name 修改为 @test-ui/components

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

整体文件结构为

packages└── components └── src └── button ├── style │ ├── index.less ├── index.vue ├── index.ts └── index.ts └── package.json

index.vue 内容

测试按钮import "./style/index.less";

index.less 内容

button { color: border-radius: 8px; border: 1px solid transparent; padding: 0.6em 1.2em; font-size: 1em; font-weight: 500; font-family: inherit; background-color: cursor: pointer; transition: border-color 0.25s; } button:hover { background-color: } button:focus, button:focus-visible { outline: 4px auto -webkit-focus-ring-color; }

index.ts 内容

import Button from "./index.vue";export { Button };

src 中 index.ts

export * from "./button";

components 中 index.ts

export * from "./src/index";

执行以下命令安装

pnpm add @test-ui/components

在 App.vue 页面引入使用

import { Button } from "@test-ui/components";

运行项目,我可以可以看到页面上显示 button 组件

如果你希望直接使用 app.use 来挂载整个组件库,需要为组件添加 install 方法

创建一个通用的 withInstall 函数

在 packages/components 文件夹下面创建 utils 文件,并创建 index.ts

// utils/index.tsimport type { App, Plugin } from "vue";export type SFCWithInstall = T & Plugin;export const withInstall = (comp: T) => { (comp as SFCWithInstall).install = (app: App) => { const name = (comp as any).name; app.component(name, comp as SFCWithInstall); }; return comp as SFCWithInstall; };import { withInstall } from "../../utils";import _Button from "./index.vue";const Button = withInstall(_Button);export { Button }; export default Button;import { App } from "vue";import *// 也导出所有单个组件,支持按需引入 export * from "./src/index";export default { install: (app: App) => { for (let c in components) { app.use(components[c]); } }, };

在 button/index.vue 中定义 name 属性,以便在全局注册后作为组件名使用。方式一

测试按钮import "./style/index.less"; import { defineComponent } from "vue"; export default defineComponent({ name: "test-button", setup { return {}; }, });

方式二 使用 unplugin-vue-define-options 插件

首先全局安装 vite 和 @vitejs/plugin-vue,之后打包也会用到,然后安装 unplugin-vue-define-options 插件

pnpm add vite @vitejs/plugin-vue -D -wpnpm add unplugin-vue-define-options -D -w

然后在 packages/components 文件下新建 vite.config.ts 配置文件

// vite.config.tsimport { defineConfig } from 'vite';import vue from '@vitejs/plugin-vue';import DefineOptions from 'unplugin-vue-define-options/vite';export default defineConfig({ plugins: [ vue, DefineOptions, // 添加 DefineOptions 插件 ], });

这时候我们就可以直接使用 defineOptions 函数定义组件名了

测试按钮import "./style/index.less"; defineOptions({ name: "test-button" });

在 play 文件夹中的 main.ts 中全局挂载组件库

import { createApp } from 'vue'import './style.css'import App from './App.vue'import testUI from "@test-ui/components"; const app = createApp(App); app.use(testUI); app.mount("#app");

在 APP.vue 文件中就可以直接使用了

页面呈现效果如下

首先全局安装 vite 和 @vitejs/plugin-vue

pnpm add vite @vitejs/plugin-vue -D -w

在 components 文件下新建 vite.config.ts 配置文件

// vite.config.tsimport { defineConfig } from 'vite';import vue from '@vitejs/plugin-vue';import DefineOptions from 'unplugin-vue-define-options/vite';import { resolve } from 'path';export default defineConfig({ build: { lib: { entry: resolve(__dirname, './index.ts'), // 你的入口文件路径 name: 'TestUI', // UMD 构建模式下的全局变量名称 fileName: (format: string) => `test-ui.${format}.js`, // 输出文件的命名规则 formats: ["es", "umd", "cjs"] // 要生成的打包格式 }, rollupOptions: { // 确保外部化处理那些你不想打包进库的依赖 external: ['vue'], output: { // 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量 globals: { vue: 'Vue' }, // 自定义输出目录 dir: "../testUI", } } }, plugins: [ vue, DefineOptions, // 添加 DefineOptions 插件 ], });

在 components/package.json 添加打包命令 scripts

"build": "vite build"

执行 pnpm run build

执行完成后,我们可以看到 components 文件夹下生成了打包文件

这种打包方式最终会将整个组件库打包到一个文件中,我们需要修改为如下配置让打包后的结构和我们开发的结构一致

// vite.config.tsimport { defineConfig } from 'vite';import vue from '@vitejs/plugin-vue';import DefineOptions from 'unplugin-vue-define-options/vite';import { resolve } from 'path';export default defineConfig({ build: { lib: { entry: resolve(__dirname, './index.ts'), // 你的入口文件路径 fileName: (format: string) => `test-ui.${format}.js`, // 输出文件的命名规则 }, rollupOptions: { // 确保外部化处理那些你不想打包进库的依赖 external: ['vue'], output: [ { //打包成 ES 模块格式,适用于现代 JavaScript 环境 format: "es", //打包后文件名 entryFileNames: "[name].mjs", //让打包目录和我们目录对应 preserveModules: true, exports: "named", //配置打包根目录 dir: "../testUI/es", }, { //打包成 CommonJS 模块格式,适用于 Node.js 环境 format: "cjs", //打包后文件名 entryFileNames: "[name].js", //让打包目录和我们目录对应 preserveModules: true, exports: "named", //配置打包根目录 dir: "../testUI/lib", }, ], } }, plugins: [ vue, DefineOptions, // 添加 DefineOptions 插件 ], });

执行 pnpm run build ,生成文件目录如下

声明文件

目前打包的组件库中的组件缺少声明文件(.d.ts),我们需要在打包的库里加入声明文件(.d.ts)。

vite-plugin-dts 是一个用于 Vite 项目的插件,旨在自动生成 TypeScript 的声明文件(.d.ts)。这对于库开发者尤其重要,因为它确保了在发布的包中包含类型声明,便于 TypeScript 用户进行类型检查和代码补全。

vite-plugin-dts 通过分析项目中的 TypeScript 代码,自动生成相应的 .d.ts 文件。这不仅简化了声明文件的管理,还确保了类型声明的准确性和一致性。

安装 vite-plugin-dts

pnpm add vite-plugin-dts -D -w

在 vite.config.ts 引入(注意如果这里添加了组件命名插件 DefineOptions,需要写在 dts 后面,否则可能有误)

// vite.config.tsimport { defineConfig } from 'vite';import vue from '@vitejs/plugin-vue';import dts from "vite-plugin-dts";import DefineOptions from 'unplugin-vue-define-options/vite';import { resolve } from 'path';export default defineConfig({ plugins: [ vue, dts({ entryRoot: "./src", outputDir: ["../testUI/es/src", "../testUI/lib/src"], //指定使用的 tsconfig.json 为我们整个项目根目录下,如果不配置,你也可以在 components 下新建 tsconfig.json tsConfigFilePath: "../../tsconfig.json", }), DefineOptions, // 添加 DefineOptions 插件 ], });

再次打包就会发现打包后文件中出现了我们需要的声明文件

我们现在可以发现组件的样式文件依旧不是一个独立文件,打包的时候我们可以不让 vite 打包样式文件,样式文件将使用 gulp 进行打包。

删除打包文件

我们都知道,在打包之前是需要将前面打包的文件删除的,所以需要先写一个删除函数。

我们需要使用 Node.js 的内置模块 paths,所以需要安装@types/node

pnpm add @types/node -D -w

我们需要使用 ts 以及新的 es6 语法,而 gulp 是不支持的,所以我们需要安装一些依赖使得 gulp 支持这些,其中 sucras 让我们执行 gulp 可以使用最新语法并且支持 ts

pnpm i gulp @types/gulp sucrase -D -w

在 script/utils 中新建 paths.ts 用于维护组件库路径。

import { resolve } from "path"; //组件库根目录 export const componentPath = resolve(__dirname, "../../"); //pkg根目录 export const pkgPath = resolve(__dirname, "../../../");

在 script/utils 中新建 delpath.ts 放删除打包目录函数。

import fs from "fs"; import { resolve } from "path"; import { pkgPath } from "./paths"; //保留的文件 const stayFile = ["README.md"]; const delPath = async (path: string) => { let files: string = ; if (fs.existsSync(path)) { files = fs.readdirSync(path); files.forEach(async (file) => { let curPath = resolve(path, file); if (fs.statSync(curPath).isDirectory) { // recurse if (file != "node_modules") await delPath(curPath); } else { // delete file if (!stayFile.includes(file)) { fs.unlinkSync(curPath); } } }); if (path != `${pkgPath}/testUI`) fs.rmdirSync(path); } }; export default delPath;

在 script/build 中新建 index.ts,执行删除流程

import delPath from "../utils/delpath"; import { series, parallel, src, dest } from "gulp"; import { pkgPath, componentPath } from "../utils/paths"; import less from "gulp-less"; import autoprefixer from "gulp-autoprefixer"; import run from '../utils/run'; //删除testUI export const removeDist = => { return delPath(`${pkgPath}/testUI`); }; export default series( async => removeDist, );

在根目录 package.json 添加脚本

"scripts": { "build:testUI": "gulp -f packages/components/script/build/index.ts" }

根目录下执行 pnpm run build:test,就会发现 testUI 下的文件被删除了

因为我们用的是 less 写的样式,所以需要安装 gulp-less,同时在安装一个自动补全 css 前缀插件 gulp-autoprefixer 以及它们对应的上面文件

pnpm add gulp-less @types/gulp-less gulp-autoprefixer @types/gulp-autoprefixer -D -w

在 script/build/index.ts 中添加打包样式的函数

...//打包样式 export const buildStyle = => { return src(`${componentPath}/src/**/style/**.less`) .pipe(less) .pipe(autoprefixer) .pipe(dest(`${pkgPath}/easyest/lib/src`)) .pipe(dest(`${pkgPath}/easyest/es/src`)); };export default series( async => removeDist, parallel( async => buildStyle, ) );

根目录下执行 pnpm run build:test,就会发现 testUI 下生成了样式文件

执行完以上操作我们需要用 vite 完成剩下的打包流程;

我们已经用 gulp 处理样式文件了,vite 打包的时候需要忽略 less 文件,调整 components/vite.config.ts

export default defineConfig({ build: { ... rollupOptions: { //忽略打包vue和.less文件 external: ["vue", /\.less/], ... } });

我们已经用 gulp 将 less 文件打包成 css 文件了,所以我们需要将代码中的引入样式文件的后缀.less 换成.css 在 components/vite.config.ts 中的 plugins 中处理 less

plugins: [ vue, dts({ entryRoot: "./src", outputDir: ["../testUI/es/src", "../testUI/lib/src"], //指定使用的tsconfig.json为我们整个项目根目录下,如果不配置,你也可以在components下新建tsconfig.json tsConfigFilePath: "../../tsconfig.json", }), DefineOptions, // 添加 DefineOptions 插件 { name: 'style', generateBundle(config, bundle) { //这里可以获取打包后的文件目录以及代码code const keys = Object.keys(bundle); for (const key of keys) { const bundler: any = bundle[key as any]; //rollup内置方法,将所有输出文件code中的.less换成.css this.emitFile({ type: 'asset', fileName: key, //文件名名不变 source: bundler.code.replace(/\.less/g, '.css') }); } } } ],

这里需要写一个执行执行命令 pnpm run build 的工具函数

新建 utils/run.ts

import { spawn } from "child_process";export default async (command: string, path: string) => { //cmd 表示命令,args 代表参数,如 rm -rf rm 就是命令,-rf 就为参数 const [cmd, ...args] = command.split(" "); return new Promise((resolve, reject) => { const app = spawn(cmd, args, { cwd: path, //执行命令的路径 stdio: "inherit", //输出共享给父进程 shell: true, //mac 不需要开启,windows 下 git base 需要开启支持 }); //执行完毕关闭并 resolve app.on("close", resolve); }); };

script/build/index.ts 引入 run 函数

//打包组件export const buildComponent = async => { run("pnpm run build", componentPath);};

放在删除打包文件和处理完成样式文件后执行

export default series( async => removeDist, parallel( async => buildStyle, async => buildComponent ));

根目录下执行 pnpm run build:test,就可以可看到完整的打包文件

私有 npm 搭建文章可以看这篇:https://blog.csdn.net/hadry123/article/details/138910100

在构建结束时更新 package.json 文件的版本号,并将更新后的 package.json 写入指定的输出目录(在这里是 testUI 文件夹) 使用 semver 库来递增版本号

npm install semver @types/semver --save-dev

在 components/vite.config.ts 增加处理逻辑

import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';import path from 'path';import semver from 'semver'; plugins: [...{ name: 'update-package-json', closeBundle { // 确保读取路径正确 const packageJsonPath = path.resolve(__dirname, 'package.json');// 检查源 package.json 是否存在 if (!existsSync(packageJsonPath)) { console.error(`Error: package.json file not found at ${packageJsonPath}`); return; }// 读取并更新 package.json 的版本号 const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); packageJson.version = semver.inc(packageJson.version, 'patch'); // 更新输出文件的内容 const updatedPackageJson = { name: "testUI", version: packageJson.version, main: "lib/index.js", module: "es/index.mjs", files: ["es", "lib"], keywords: ["testUI", "vue3组件库"], sideEffects: ["**/*.css"], author: packageJson.author, license: packageJson.license, description: packageJson.description || "", typings: "lib/index.d.ts", }; // 输出目录路径,设置为 testUI const outputDir = path.resolve(__dirname, '../testUI'); const outputPackageJsonPath = path.join(outputDir, 'package.json');try { // 检查并创建 testUI 目录 if (!existsSync(outputDir)) { mkdirSync(outputDir, { recursive: true }); }// 写入更新后的 package.json 到 testUI 目录 writeFileSync(outputPackageJsonPath, JSON.stringify(updatedPackageJson, null, 2));// 更新源 package.json 文件的版本号 writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));console.log(`New version ${packageJson.version} updated and package.json written to testUI folder.`); } catch (err:any) { console.error(`Error writing package.json: ${err.message}`); } } }]

执行 pnpm run build:testUI 后 testUI 中会出现 package.json 文件

在根目录添加.npmrc 文件

registry=http://your-private-registry-url///http://your-private-registry-url/:_authToken=your-private-registry-token@public:registry=https://registry.npmjs.org/

如何获取 your-private-registry-token?

私有npm登录成功后,会自动在 ~/.npmrc 文件中生成一个认证令牌,并将其保存到 .npmrc 文件中。

在终端中输入cat ~/.npmrc,就可以查看

在根目录 package.json 中添加

"scripts": { ... "publish:testUI": " pnpm publish packages/testUI --no-git-checks"},

执行 pnpm run publish:testUI 就可以看到发布成功

首先新建 site 文件夹,并执行 pnpm init,然后安装 vitepress 和 vue

pnpm install -D vitepress vue

安装完成之后,新建 docs/index.md 文件

然后 package.json 中新增 scripts 命令

"scripts": { "dev": "vitepress dev docs", "build": "vitepress build docs", "preview": "vitepress preview docs" },

执行 pnpm run dev

在 pnpm 的工作空间 pnpm-workspace.yaml 新增一个 site 目录

- "packages/*" - "play" - "site"

引入组件库

site 目录下的 package.json,添加"test-ui": "workspace:^",然后在根目录执行 pnpm install, 这样做在 site/node_modules 中创建指向 packages/testUI 的符号链接,而不是从远程下载。

{ ... "dependencies": { ... "test-ui": "workspace:^" }}

在 docs 下新建 theme/index.js 引入我们的组件库

import DefaultTheme from "vitepress/theme";import testUI from "test-ui";export default { ...DefaultTheme, enhanceApp: async ({ app }) => { app.use(testUI); }, };

导航栏配置 在 docs/.vitepress 目录下新建 config.js

export default { themeConfig: { siteTitle: "vitepress", nav: [ { text: "组件", link: "/components/button/" }, ], sidebar: {}, },};

在 docs 目录下新建 components/button/index.md,

默认按钮::: details 显示代码```html默认按钮

保存项目刷新后就可以看到导航栏已经生效了 点击即可跳转对应页面

在根目录安装 Element Plus

pnpm install element-plus -w

在 index.ts 引入

import ElementPlus from 'element-plus';import 'element-plus/dist/index.css';export default { install: (app: App) => { app.use(ElementPlus as any); ... }, };

在/package/components/src 中新建 elbutton

index.vue

DangerdefineOptions({ name: "test-el-button" });

index.ts

import { withInstall } from "../../utils";import _ElButton from "./index.vue"const ElButton = withInstall(_ElButton);export { ElButton }; export default ElButton;

/src/index.ts 引入

export * from "./elbutton";

components.d.ts 添加

declare module "@vue/runtime-core" { export interface GlobalComponents { ... TestElButton: typeof components.ElButton; }}

自定义 button

测试按钮

以 elementplus 为基础封装的 button

页面呈现效果

正常情况下,我们的项目中会安装有自己的 element-plus 版本, 如果再将 element-plus 安装到组件库的话,那么项目安装依赖时会下载多个 element-plus 的版本 所以需要修改/package/components/vite.config.ts ,添加'element-plus', 'element-plus/dist/index.css'

排除 element-plus 有关的依赖

rollupOptions: {// 确保外部化处理那些你不想打包进库的依赖 external: ['vue', /\.less/, 'element-plus', 'element-plus/dist/index.css']}

在/site 目录下执行

npm add test-ui --registry=http://your-private-registry-url/

更新 site/docs/components/button/index.md

文档呈现效果

ESLint 是一个开源的 JavaScript 代码静态分析工具(linter),用于快速发现代码中的问题。它强制执行一致的编码风格,帮助捕获错误、潜在的漏洞和违反编码标准的地方,适用于 JavaScript(有时也包括 TypeScript)代码库。

ESLint 的主要特性:

高度可配置: 您可以根据项目需求自定义 ESLint,定义自己的规则或扩展来自流行风格指南(如 Airbnb、Google 或 Standard)的配置。插件支持: 通过插件,ESLint 的功能可以扩展,支持新的语言(如 TypeScript)或框架(如 React 或 Vue)。自动修复: 使用 --fix 选项,ESLint 检测到的许多问题都可以自动修复。编辑器集成: ESLint 可以与大多数代码编辑器集成,在编写代码时提供实时反馈。

为什么要使用 ESLint?

维护代码质量: 有助于在整个项目中保持一致的代码风格,使代码库更易于阅读和维护。及早发现错误: 在运行时出现问题之前,ESLint 就能检测出潜在的错误。团队协作: 强制执行统一的编码标准,帮助团队更高效地协作。npx eslint --init

安装那些插件的时候我们选择了 No,这里我们用 pnpm 手动安装一下

pnpm install --save-dev @eslint/eslintrc eslint eslint-plugin-vue @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-config-prettier vue-eslint-parser -w

我们会发现根目录出现了 ESlint 的配置文件.eslintrc.cjs,我们对这个文件进行了一些配置上的修改后如下

// eslint.config.mjsimport { FlatCompat } from '@eslint/eslintrc';import { fileURLToPath } from 'url';import { dirname } from 'path';// 定义 __filename 和 __dirname const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);// 创建兼容实例 const compat = new FlatCompat({ baseDirectory: process.cwd, resolvePluginsRelativeTo: __dirname, });// 导出平面配置数组 export default [ { ignores: [ 'node_modules/**', '**.d.ts', 'packages/testUI', 'packages/components/coverage', 'dist', 'node_modules', 'site/docs/.vitepress/cache/deps/**', '**/vite.config.ts', ], }, ...compat.config({ root: true, env: { node: true, }, parser: 'vue-eslint-parser', parserOptions: { parser: '@typescript-eslint/parser', ecmaVersion: 2020, sourceType: 'module', ecmaFeatures: { jsx: true, }, }, extends: [ 'plugin:vue/vue3-recommended', 'plugin:@typescript-eslint/recommended', 'prettier', ], rules: { 'no-console': 'error', 'no-debugger': 'error', 'vue/valid-define-emits': 'off', 'vue/multi-word-component-names': 'off', 'vue/valid-define-props': 'off', '@typescript-eslint/no-require-imports': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-empty-object-type': 'off', '@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/no-unused-vars': 'error', 'multiline-comment-style': ['error', 'starred-block'], }, }), ];Prettier

安装 Prettier

pnpm add prettier -D -w

新建文件.prettierrc

{ "singleQuote": false, "trailingComma": "none", "vueIndentScriptAndStyle": true, "printWidth": 80}

在 vscode 的设置中进行如下配置 这样设置完后按 ctrl+s 就能自动格式化代码了。

Vitest 相较于其他测试框架的优势

Vitest 是一个基于 Vite 构建的快速单元测试框架,旨在为现代 Web 开发提供高效、便捷的测试解决方案。相较于其他测试框架,Vitest 具有以下独特的优势:

极速的性能快速启动和执行 :利用 Vite 的高速构建能力,Vitest 在启动和执行测试时速度极快,特别是在大型项目中,这种性能优势更加明显。即时反馈 :支持热模块替换(HMR),在测试文件发生变化时能够立即反馈结果,提高开发效率。与 Vite 的深度集成共享配置 :Vitest 直接使用 Vite 的配置,无需为测试环境做额外的配置,减少了维护成本。兼容 Vite 插件 :可以利用 Vite 的插件生态,支持如 Vue、React、Svelte 等框架的组件测试。高度兼容 Jest相似的 API :Vitest 的 API 设计与 Jest 非常相似,包括测试函数( describe 、 it )、断言( expect )和模拟( mock )等。易于迁移 :如果项目原先使用 Jest,迁移到 Vitest 所需的改动非常小,学习成本低。原生支持 ESM 和 TypeScriptESM 支持 :Vitest 基于原生的 ES Modules,无需 Babel 或其他转换工具,适应现代 JavaScript 开发趋势。TypeScript 友好 :内置支持 TypeScript,无需额外配置,直接运行 .ts 和 .tsx 文件。更好的调试体验Source Map 支持 :提供准确的源代码映射,方便在测试中进行断点调试。清晰的错误信息 :详细的错误堆栈和日志信息,帮助快速定位问题。轻量级且灵活减少依赖 :Vitest 更加轻量,不需要安装大量的依赖包,降低了项目的体积和复杂性。可定制性 :提供灵活的配置选项,支持自定义测试环境、覆盖率报告等。现代化的特性支持快照测试 :支持组件和函数的快照测试,捕捉输出的变化。并行运行 :利用多线程并行执行测试,提高测试效率。内置 Mock 功能 :无需额外的模拟库,就能模拟模块和函数。活跃的社区和生态系统持续更新 :Vitest 社区活跃,定期发布更新和新功能,保持与前端技术发展的同步。丰富的资源 :提供完善的文档、教程和示例,帮助开发者快速上手。

对比总结

与 Jest 相比 :Vitest 在性能上有明显提升,特别是在冷启动和大规模测试套件中。同时,Vitest 更加轻量,配置更少。与 Mocha 等框架相比 :Vitest 提供开箱即用的体验,无需组合多个工具(如断言库、测试运行器等),而且性能更优。与 Cypress 等端到端测试工具相比 :Vitest 专注于单元测试和组件测试,速度更快,适用于更细粒度的测试场景。

适用场景

使用 Vite 构建的项目 :Vitest 是首选,能够最大化地利用 Vite 的优势。需要高性能测试的项目 :对于测试用例多、执行时间长的项目,Vitest 的性能优势非常明显。追求现代化开发体验的团队 :如果希望使用最新的技术栈和工具链,Vitest 提供了现代化的特性支持。

为什么需要 happy-dom

模拟浏览器环境:在 Node.js 中,没有 DOM、window、document 等浏览器特有的全局对象。happy-dom 提供了这些对象的模拟,实现了大部分浏览器的 API,使得在 Node.js 环境下可以运行依赖于浏览器环境的代码。轻量高效:相比于 jsdom,happy-dom 更加轻量和快速,适合在测试环境中使用。pnpm install --save-dev vitest happy-dom -w

在/package/components/package.json 中添加测试脚本

{ "scripts": { "test": "vitest" }}

Vitest 已经集成了 c8 作为其默认的覆盖率提供者,当使用 --coverage 参数运行测试时,Vitest 会自动调用 c8 来收集覆盖率数据。在 package.json 中添加测试脚本

{ "scripts": { "test": "vitest" "coverage": "vitest run --coverage" }}

因为我们测试的是 src 文件夹下的组件,我们需要在/package/components/package.json 中配置范围

test: { environment: "happy-dom", coverage: { include: ["src/**/*.{ts,tsx,vue}"], exclude: [ "src/index.ts", // 排除 src/index.ts "src/components.d.ts", // 排除 src/index.ts "src/**/*.test.*" // 排除测试文件 ] } },

因为我们项目是 vue 组件库,因此我们可以安装 Vue 推荐的测试库@vue/test-utils。@vue/test-utils 是 Vue.js 应用的官方单元测试工具库。它提供了一套实用工具,方便测试 Vue 组件,使您能够在受控环境中挂载组件、与之交互,并断言其行为。无论您使用的是 Vue 2 还是 Vue 3,@vue/test-utils 都提供了确保组件按预期运行所需的工具。

pnpm install @vue/test-utils -D -w

如果没有引入 Element Plus,忽略这一步 因为我们使用的组件以 Element Plus 为基础的二次封装,为了确保测试环境能够正确识别和处理 Element Plus 组件库及其样式,从而确保依赖于 Element Plus 的组件能够正常运行和渲染。故在测试环境中需要单独配置

先在 components 下新建 tests/setup.js

// vitest.setup.tsimport "element-plus/dist/index.css"; // 引入 Element Plus 样式import { config } from "@vue/test-utils"; // 从 Vue Test Utils 导入全局配置import ElementPlus from "element-plus"; // 导入 Element Plus 插件// 全局注册 Element Plus 插件 config.global.plugins = [ElementPlus];

在/package/components/package.json 中配置

test: { ... setupFiles: ["tests/setup.js"], ... },

setupFiles 是 Vitest 配置中的一个选项,用于指定在运行测试之前需要执行的脚本文件。"tests/setup.js" 会在所有测试运行之前被加载和执行。

我们修改一下 button/index.vue,我们来测试一下 button 组件 slot、disabled 和组件是否被正确导出

修改 button/index.vue

默认内容import "./style/index.less"; defineOptions({ name: "test-button" }); withDefaults( defineProps, { disabled: false } );

在 button 目录下新建__tests__/index.test.ts

// ButtonComponent.test.jsimport { mount } from "@vue/test-utils";import ButtonComponent from "../index.vue";import { describe, it, expect } from "vitest"; // Vitest 的测试函数import * as Components from "../index"; // 导入 index.ts 中导出的所有组件// 使用 describe 来分组相关的测试用例//index.vue describe("Button Component", => { // 测试组件是否正确渲染默认插槽内容 it("renders correctly with default slot content", => { // 挂载组件 const wrapper = mount(ButtonComponent); // 断言按钮文本是否为默认内容 expect(wrapper.text).toBe("默认内容"); });// 测试组件是否正确渲染自定义插槽内容 it("renders correctly with custom slot content", => { // 挂载组件,并传入自定义插槽内容 const wrapper = mount(ButtonComponent, { slots: { default: "Click Me" // 自定义插槽内容 } }); // 断言按钮文本是否为自定义内容 expect(wrapper.text).toBe("Click Me"); });// 测试传递 disabled 属性为 true 时,button 是否被禁用 it("sets the disabled attribute when disabled prop is true", => { const wrapper = mount(ButtonComponent, { props: { disabled: true // 禁用 button } }); const button = wrapper.find("button"); // 使用 DOM 属性进行断言 expect(button.element.disabled).toBe(true); });// 测试传递 disabled 属性为 false 时,button 是否未被禁用 it("does not set the disabled attribute when disabled prop is false", => { // 挂载组件,并传入 disabled 属性为 false const wrapper = mount(ButtonComponent, { props: { disabled: false // 不禁用 button } }); // 断言 button 元素不应存在 disabled 属性 expect(wrapper.attributes("disabled")).toBeUndefined; });// 测试当 button 未禁用时,点击是否会触发 click 事件 it("emits click event when clicked and not disabled", async => { // 挂载组件,并确保 disabled 为 false const wrapper = mount(ButtonComponent, { props: { disabled: false // 确保 button 未被禁用 } }); // 触发点击事件 await wrapper.trigger("click"); // 断言组件是否发出了 click 事件 expect(wrapper.emitted).toHaveProperty("click"); // 断言 click 事件触发的次数是否为 1 次 expect(wrapper.emitted("click")?.length).toBe(1); });// 测试当 button 被禁用时,点击是否不会触发 click 事件 it("does not emit click event when disabled", async => { // 挂载组件,并传入 disabled 属性为 true const wrapper = mount(ButtonComponent, { props: { disabled: true // 禁用 button } }); // 触发点击事件 await wrapper.trigger("click"); // 断言组件未发出 click 事件 expect(wrapper.emitted("click")).toBeUndefined; }); });// index.ts describe("Components Entry Point", => { // 测试 Button 组件是否被正确导出 it("should export Button component", => { // 断言 Components 对象中是否存在 Button expect(Components.Button).toBeDefined; // 断言 Button 是否具有 install 方法(由 withInstall 添加) expect(typeof Components.Button.install).toBe("function"); }); });

在 components 文件下执行 pnpm run test,我们可以看到测试通过了

在 components 文件下执行 pnpm run coverage,看到我们测试覆盖情况

以elbutton为例,以element plus为基础二次封装的组件

我们修改一下 button/index.vue,我们来测试一下 button 组件 slot、disabled、type 和组件是否被正确导出

修改 elbutton/index.vue

DangerdefineOptions({ name: "test-el-button" }); withDefaults( defineProps, { disabled: false, type: "danger" } );

在 elbutton 目录下新建__tests__/index.test.ts

// src/elbutton/__tests__/elbutton.test.ts// 导入必要的库和组件 import { mount } from "@vue/test-utils"; // Vue Test Utils 用于挂载组件 import { describe, it, expect } from "vitest"; // Vitest 的测试函数 import ElButton from "../index.vue"; // 导入要测试的 ElButton 组件 import * as Components from "../index";// 使用 describe 来分组相关的测试用例//index.vue describe("ElButton Component", => { // 测试组件是否正确渲染默认插槽内容 it("renders correctly with default slot content", => { // 挂载组件 const wrapper = mount(ElButton); // 断言按钮文本是否为默认内容 expect(wrapper.text).toBe("Danger"); });// 测试组件是否正确渲染自定义插槽内容 it("renders correctly with custom slot content", => { // 挂载组件,并传入自定义插槽内容 const wrapper = mount(ElButton, { slots: { default: "Submit" // 自定义插槽内容 } }); // 断言按钮文本是否为自定义内容 expect(wrapper.text).toBe("Submit"); });// 测试传递 disabled 属性为 true 时,el-button 是否被禁用 it("sets the disabled attribute when disabled prop is true", => { // 挂载组件,并传入 disabled 属性为 true const wrapper = mount(ElButton, { props: { disabled: true // 禁用 el-button } }); // 查找 el-button 元素 const elButton = wrapper.find("button"); // el-button 渲染为 button 元素 // 使用 DOM 属性进行断言 expect(elButton.element.disabled).toBe(true); });// 测试传递 disabled 属性为 false 时,el-button 是否未被禁用 it("does not set the disabled attribute when disabled prop is false", => { // 挂载组件,并传入 disabled 属性为 false const wrapper = mount(ElButton, { props: { disabled: false // 不禁用 el-button } }); // 查找 el-button 元素 const elButton = wrapper.find("button"); // el-button 渲染为 button 元素 // 使用 DOM 属性进行断言 expect(elButton.element.disabled).toBe(false); });// 测试传递 type 属性是否正确设置到 el-button 元素 it("sets the type attribute when type prop is provided", => { // 挂载组件,并传入 type 属性为 'primary' const wrapper = mount(ElButton, { props: { type: "primary" // 设置 el-button 的 type 为 'primary' } }); // 查找 el-button 元素 const elButton = wrapper.find("button"); // el-button 渲染为 button 元素 // 断言 el-button 的 type 属性是否为 'primary' expect(elButton.classes).toContain("el-button--primary"); });// 测试当 type 属性未提供时,el-button 使用默认类型 'danger' it("uses the default type attribute when type prop is not provided", => { // 挂载组件,不传 type 属性 const wrapper = mount(ElButton); // 查找 el-button 元素 const elButton = wrapper.find("button"); // el-button 渲染为 button 元素 // 断言 el-button 的 type 属性是否为默认值 'danger' expect(elButton.classes).toContain("el-button--danger"); });// 测试 el-button 是否应用了 round 属性 it("applies the round attribute correctly", => { // 挂载组件 const wrapper = mount(ElButton); // 查找 el-button 元素 const elButton = wrapper.find("button"); // el-button 渲染为 button 元素 // 断言 el-button 是否具有 round 属性(取决于 el-button 实现)// 通常,round 是一个布尔属性,存在即为 true expect(elButton.classes).toContain("is-round"); // Element Plus 的 round 会添加 'is-round' 类 });// 测试当 el-button 被点击且未禁用时,是否会触发 click 事件 it("emits click event when clicked and not disabled", async => { // 挂载组件,并确保 disabled 为 false const wrapper = mount(ElButton, { props: { disabled: false // 确保 el-button 未被禁用 } }); // 查找 el-button 元素 const elButton = wrapper.find("button"); // el-button 渲染为 button 元素 // 触发点击事件 await elButton.trigger("click"); // 断言组件是否发出了 click 事件 expect(wrapper.emitted).toHaveProperty("click"); // 断言 click 事件触发的次数是否为 1 次 expect(wrapper.emitted("click")?.length).toBe(1); });// 测试当 el-button 被禁用时,点击是否不会触发 click 事件 it("does not emit click event when disabled", async => { // 挂载组件,并传入 disabled 属性为 true const wrapper = mount(ElButton, { props: { disabled: true // 禁用 el-button } }); // 查找 el-button 元素 const elButton = wrapper.find("button"); // el-button 渲染为 button 元素 // 触发点击事件 await elButton.trigger("click"); // 断言组件未发出 click 事件 expect(wrapper.emitted("click")).toBeUndefined; }); }); // index.ts describe("Components Entry Point", => { // 测试 Button 组件是否被正确导出 it("should export Button component", => { // 断言 Components 对象中是否存在 Button expect(Components.ElButton).toBeDefined; // 断言 Button 是否具有 install 方法(由 withInstall 添加) expect(typeof Components.ElButton.install).toBe("function"); }); });

来源:墨码行者

相关推荐