其实这是我第一次搭建组件库时写的笔记,但是完成后和最终项目还是有所差异,但是按这个项目确实可以跑,就只是没有配一些 eslint
、stylelint
、commit
规范等。
作者目前是在参加某节的青训营,然后要完成一个大项目,我们团队选了组件库的搭建。
现在有两个好消息:
就是指在一个大的项目仓库中,管理多个模块/包(package),这种类型的项目大都在项目根目录下有一个packages文件夹,分多个项目管理。大概结构如下:
-- packages
-- pkg1
--package.json
-- pkg2
--package.json
--package.json
简单来说就是单仓库 多项目,目前 Vant,ElementUI,Vue3 等项目都是采用这种模式。打造一个Monorepo环境的工具有很多,如:lerna、pnpm、yarn等,这里我们将使用
pnpm
来开发我们的UI组件库
npm install pnpm -g
如果出现以下报错信息:
npm ERR! code EACCES
npm ERR! syscall mkdir
npm ERR! path /usr/local/lib/node_modules/pnpm
npm ERR! errno -13
npm ERR! Error: EACCES: permission denied, mkdir '/usr/local/lib/node_modules/pnpm'
npm ERR! [Error: EACCES: permission denied, mkdir '/usr/local/lib/node_modules/pnpm'] {
npm ERR! errno: -13,
npm ERR! code: 'EACCES',
npm ERR! syscall: 'mkdir',
npm ERR! path: '/usr/local/lib/node_modules/pnpm'
npm ERR! }
npm ERR!
npm ERR! The operation was rejected by your operating system.
npm ERR! It is likely you do not have the permissions to access this file as the current user
npm ERR!
npm ERR! If you believe this might be a permissions issue, please double-check the
npm ERR! permissions of the file and its containing directories, or try running
npm ERR! the command again as root/Administrator.
npm ERR! A complete log of this run can be found in:
npm ERR! /Users/xxx/.npm/_logs/2023-01-14T03_24_24_270Z-debug-0.log
✅ 解决方法是使用管理员权限安装,即使用
sudo npm install pnpm -g
pnpm init
shamefully-hoist = true
默认情况下 pnpm 安装的依赖是会解决幽灵依赖的问题,所谓什么是幽灵依赖你可以查看**这篇文章**。
文件名就叫
.npmrc
如果某些工具仅在根目录的 node_modules 时才有效,可以将其设置为 true 来提升那些不在根目录的 node_modules,就是将你安装的依赖包的依赖包的依赖包都放到同一级别(扁平化),说白了就是不设置为 true 有些包就有可能会出问题
在 「根目录」 下新建一个 pnpm-workspace.yaml
文件,内容如下:
packages:
- 'packages/**'
- 'examples'
如果想关联更多目录你只需要往里面添加即可,packages
文件夹存放开发的包,examples
用来调试组件
开发环境中的依赖一般全部安装在整个项目根目录下,方便每个包都可以引用,所以在安装的时候需要加个 -w
pnpm i vue@next typescript less -D -w
上面的命令是安装 vue3、TypeScript、Less
如果出现以下问题:
ERROR --workspace-root may only be used inside a workspace
✅ 解决方法是新建一个
pnpm-workspace.yaml
文件
如果安装了 TypeScript 那么需要在 「根目录」 下新建一个 tsconfig.json
文件,内容如下:
{
"compilerOptions": {
"baseUrl": ".",
"jsx": "preserve", // jsx 不转
"strict": true,
"target": "ES2015", // 遵循es5版本
"module": "ESNext", // 打包模块类型ESNext
"skipLibCheck": true, // 跳过类库检测
"esModuleInterop": true, // 支持es6,commonjs模块
"moduleResolution": "Node", // 按照node模块来解析
"lib": ["esnext", "dom"] // 编译时用的库
}
}
你可以使用命令
npx tsc --init
新建一个配置文件,也可以手动新建一个
vite 已经帮我们做了大部分事情
进入 examples
文件夹:
cd examples
初始化配置:
pnpm init
pnpm install vite @vitejs/plugin-vue -D -w
@vitejs/plugin-vue 用来支持
.vue
文件的转译,这里安装的插件都放在 「根目录」 下
也就是根目录下的 package.json
文件是这样的:
{
......
"devDependencies": {
......
"@vitejs/plugin-vue": "^4.0.0",
"vite": "^4.0.4",
}
}
在 examples
文件夹下新建 vite.config.ts
文件
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins:[vue()]
})
在 examples
文件夹下新建 index.html
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Documenttitle>
head>
<body>
<div id="app">div>
<script src="main.ts" type="module">script>
body>
html>
⚠️ @vitejs/plugin-vue 会默认加载
examples
下的index.html
,vite 是基于 esmodule 的,所以要在 script 标签中加上type="module"
在 examples
文件夹下新建 app.vue
<template>
<div>启动测试div>
template>
import {createApp} from 'vue'
import App from './app.vue' // 找不到模块“./app.vue”或其相应的类型声明
const app = createApp(App)
app.mount('#app')
⚠️ 因为直接引入
.vue
文件 TS 会找不到对应的类型声明,所以需要新建 typings(命名没有明确规定,TS 会自动寻找.d.ts
文件)文件夹来专门放这些声明文件
解决办法:
在 「根目录」 下新建 typings/vue-shim.d.ts
文件
首先新建
typings
文件夹,然后再新建vue-shim.d.ts
文件
TypeScript 默认只认 ES 模块,如果要导入 .vue
文件就要 declare module 把他们声明出来,在 vue-shim.d.ts
文件中写入如下内容:
declare module '*.vue' {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>
}
完成之后 import App from './app.vue' // 找不到模块“./app.vue”或其相应的类型声明
就不会报错了
最后在 「根目录」 下的 package.json
文件中配置 scripts 脚本
{
......
"scripts": {
"dev": "vite"
},
}
然后在终端输入熟悉的命令:
pnpm run dev
运行结果如下:
VITE v4.0.4 ready in 432 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h to show help
完成后在浏览器中打开链接 http://localhost:xxx/ 就会看到启动测试页面
‼️ 下面新建的文件都在
packages
文件夹下面
一般 packages
要有 utils
包来存放我们公共方法,工具函数等
既然它是一个包,所以我们新建 utils
目录后就需要初始化它:
utils
文件夹执行 pnpm init
然后会生成一个 package.json
文件;utils
包是属于 karl_fang 这个组织下的,所以记住发布之前要登录 npm 新建一个组织,例如 karl_fang{
"name": "@karl_fang/utils",
"version": "1.0.0",
"description": "",
"main": "index.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
因为使用 ts 写的,所以需要将入口文件 index.js
改为 index.ts
,并新建 index.ts
文件:
export const testfun = (a: number, b: number): number => {
return a + b
}
components
文件夹是用来存放各种 UI 组件的包
新建 components
文件夹并执行 pnpm init
生成 package.json
{
"name": "karl-ui",
"version": "1.0.0",
"description": "",
"main": "index.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
新建 index.ts
入口文件并引入 utils
包
import {testfun} from '@karl_fang/utils'
const result = testfun (1,1)
console.log(result)
运行 tsc index.ts
进行测试
由于组件库是基于 t s的,所以需要安装 esno 来执行 ts 文件便于测试组件之间的引入情况
控制台输入 esno xxx.ts
即可执行 ts 文件
npm i esno -g
进入 components
文件夹执行
pnpm install @karl_fang/utils
会发现 pnpm 会自动创建个软链接直接指向我们的 utils
包;此时 components
下的 packages.json
为:
{
"name": "karl-ui",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@karl_fang/utils": "workspace:^1.0.0"
}
}
会发现它的依赖
@karl_fang/utils
对应的版本为:workspace: ^1.0.0
;因为 pnpm 是由 workspace 管理的,所以有一个前缀 workspace 可以指向utils
下的工作空间从而方便本地调试各个包直接的关联引用
components
文件夹下新建 src
和 index.ts
文件,同时在 src
下新建 button
组件目录和 icon
组件目录button
下新建一个简单的 button.vue
,然后写入:<template>
<button>测试按钮button>
template>
button/index.ts
将其导出:import Button from './button.vue'
export default Button
button
,所以需要一个 components/index.ts
将开发的组件集中导出import Button from './button'
export {
Button
}
examples
执行 pnpm i karl-ui
,此时就会发现 packages.json
中的依赖多了个 "kitty-ui": "workspace:^1.0.0"
examples
下引入本地的 components
组件库了,在 examples/app.vue
直接引入 Button
<template>
<div>
<Button />
div>
template>
<script lang="ts" setup>
import { Button } from 'karl-ui'
script>
然后运行 npm run dev
即可
打包们这里选择 vite,它有一个库模式专门为我们来打包这种库组件的,前面已经安装过 vite 了,所以这里直接在 components
下直接新建 vite.config.ts
(配置参数文件中已经注释):
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue"
export default defineConfig(
{
build: {
target: 'modules',
//打包文件目录
outDir: "es",
//压缩
minify: false,
//css分离
//cssCodeSplit: true,
rollupOptions: {
//忽略打包vue文件
external: ['vue'],
input: ['index.ts'],
output: [
{
format: 'es',
//不用打包成.es.js,这里我们想把它打包成.js
entryFileNames: '[name].js',
//让打包目录和我们目录对应
preserveModules: true,
//配置打包根目录
dir: 'es',
preserveModulesRoot: 'src'
},
{
format: 'cjs',
entryFileNames: '[name].js',
//让打包目录和我们目录对应
preserveModules: true,
//配置打包根目录
dir: 'lib',
preserveModulesRoot: 'src'
}
]
},
lib: {
entry: './index.ts',
formats: ['es', 'cjs']
}
},
plugins: [
vue()
]
}
)
其实到这里就已经可以直接打包了,components
下执行 pnpm run build
就会发现打包了 es
和 lib
两个目录
⚠️ 记得在
components/package.json
中加入如下指令:{ ...... "scripts": { "build": "vite build" }, }
到这里其实打包的组件库只能给 js 项目使用,在 ts 项目下运行会出现一些错误,而且使用的时候还会失去代码提示功能,这样的话就失去了用 ts 开发组件库的意义了,所以需要在打包的库里加入声明文件(.d.ts),只需要引入vite-plugin-dts,然后修改一下的 vite.config.ts
引入这个插件:
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue"
import dts from 'vite-plugin-dts'
export default defineConfig(
{
build: {...},
plugins: [
vue(),
dts({
tsConfigFilePath: '../../tsconfig.json'
}),
dts({
// 指定使用的 tsconfig.json,如果不配置也可以在 components 下新建 tsconfig.json
tsConfigFilePath: '../../tsconfig.json',
// 因为这个插件默认打包到es下,我们想让lib目录下也生成声明文件需要再配置一个
outputDir: 'lib',
}),
]
}
)
然后执行打包命令 npm run build
就会发现 es
和 lib
下就有了 *.t.ts
声明文件
其实后面就可以进行发布了,发布之前更改一下 components
下的 package.json
如下:
{
"name": "karl-ui",
"version": "1.0.0",
"description": "",
"main": "lib/index.js",
"module": "es/index.js",
"files": [
"es",
"lib"
],
"scripts": {
"build": "vite build"
},
"keywords": [
"karl-ui",
"vue3组件库"
],
"author": "karl fang",
"license": "MIT",
"typings": "lib/index.d.ts"
}
pkg.module:组件库默认入口文件是传统的 CommonJS 模块,但是如果环境支持 ESModule 的话构建工具会优先使用 module 入口
pkg.files:files 是指需要发布到 npm 上的目录,因为不可能 components 下的所有目录都被发布上去
引入打包后的组件会发现没有样式,所以需要在全局引入 style.css
才行,那么需要的组件库是每个 css 样式放在每个组件其对应目录下,这样就不需要每次都全量导入 css 样式,下面就来看下如何把样式拆分打包
首先需要做的是将 less 打包成 css 然后放到打包后对应的文件目录下,在 components
下新建 build
文件夹来存放一些打包工具,然后新建 buildLess.ts
,首先需要先安装一些工具 cpy 和 fast-glob
pnpm i cpy fast-glob -D -w
cpy 可以直接复制规定的文件并将文件复制到指定目录,比如 buildLess.ts
:
import cpy from 'cpy'
import { resolve } from 'path'
const sourceDir = resolve(__dirname, '../src')
//lib文件
const targetLib = resolve(__dirname, '../lib')
//es文件
const targetEs = resolve(__dirname, '../es')
const buildLess = async () => {
await cpy(`${sourceDir}/**/*.less`, targetLib)
await cpy(`${sourceDir}/**/*.less`, targetEs)
}
buildLess()
然后在 components/package.json
中新增命令
{
......
"scripts": {
"build": "vite build",
"build:less": "esno build/buildLess"
},
}
终端执行 pnpm run build:less
就会发现 lib
和 es
文件对应目录下就出现了 less
文件
但是最终要的并不是 less 文件而是 css 文件,所以要将 less 打包成 css,所以需要用的 less 模块,在 ts 中引入less 因为它本身没有声明文件所以会出现类型错误,所以要先安装它的 @types/less
pnpm i --save-dev @types/less -D -w
buildLess.ts
如下(详细注释都在代码中)
import cpy from 'cpy'
import { resolve, dirname } from 'path'
import { promises as fs } from "fs"
import less from "less"
import glob from "fast-glob"
const sourceDir = resolve(__dirname, '../src')
//lib文件目录
const targetLib = resolve(__dirname, '../lib')
//es文件目录
const targetEs = resolve(__dirname, '../es')
//src目录
const srcDir = resolve(__dirname, '../src')
const buildLess = async () => {
//直接将less文件复制到打包后目录
await cpy(`${sourceDir}/**/*.less`, targetLib)
await cpy(`${sourceDir}/**/*.less`, targetEs)
//获取打包后.less文件目录(lib和es一样)
const lessFils = await glob("**/*.less", { cwd: srcDir, onlyFiles: true })
//遍历含有less的目录
for (let path in lessFils) {
const filePath = `${srcDir}/${lessFils[path]}`
//获取less文件字符串
const lessCode = await fs.readFile(filePath, 'utf-8')
//将less解析成css
const code = await less.render(lessCode, {
//指定src下对应less文件的文件夹为目录
paths: [srcDir, dirname(filePath)]
})
//拿到.css后缀path
const cssPath = lessFils[path].replace('.less', '.css')
//将css写入对应目录
await fs.writeFile(resolve(targetLib, cssPath), code.css)
await fs.writeFile(resolve(targetEs, cssPath), code.css)
}
}
buildLess()
执行打包命令之后会发现对应文件夹下多了 .css
文件,这个命令是单独打包处理 less 文件的,所以在 pnpm run build
后还要运行 pnpm run build:less
才行,那么可以更改 components/package.json
为如下:
{
......
"scripts": {
"build": "vite build && npm run build:less",
"build:less": "esno build/buildLess"
},
}
这样 pnpm run build
后就可以完成 less 文件的处理了
现在已经将 css 文件放入对应的目录下了,但是相关组件并没有引入这个 css 文件,所以需要的是每个打包后组件的 index.js
中出现如:
import "xxx/xxx.css"
之类的代码,那么 css 才会生效,所以需要对 vite.config.ts
进行相关配置
首先先将 .less
文件忽略
external: ['vue', /\.less/]
这时候打包后的文件中如 button/index.js
就会出现:
import "./style/index.less";
然后再将打包后代码的 .less
换成 .css
就大功告成了
......
plugins: [
......
{
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,因为我们当时没有打包less文件
this.emitFile({
type: 'asset',
fileName: key,//文件名名不变
source: bundler.code.replace(/\.less/g, '.css')
})
}
}
}
]
⚠️ 我们要在
/components/src/button/index.ts
中引入 less 文件,即import './style/button.less'
Vitest
旨在将自己定位为 Vite
项目的首选测试框架,在 Vite
项目中使用 Vitest
可以共享相同的插件 vite.config.js
,因为组件库是基于 Vite
开发的,所以选择了 Vitest
作为组件库的单元测试框架。
因为测试的是运行于 dom 上的组件库,所以不仅要安装 vitest
还有安装 happy-dom
以及 c8
pnpm add vitest happy-dom c8 -D -w
happy-dom
:模拟 Web 浏览器,以便用于测试的工具c8
:用来展示测试覆盖率在 vite.config.ts
的 test
属性下进行 vitest
的相关配置,配置之前需要在文件顶部配置三斜线命令告诉编译器在编译过程中要引入的额外的文件:
///
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue"
import dts from 'vite-plugin-dts'
export default defineConfig(
{
build: {......},
plugins: [......],
test: {
environment: "happy-dom"
},
}
)
然后在 components/package.json
中添加两条命令
{
......
"scripts": {
......
"test": "vitest",
"coverage": "vitest run --coverage"
}
}
举个栗子
执行 pnpm run test
的时候,vitest
会寻找 **/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}
形式的文件,所以在 components/button/src
文件夹下新建 button.test.ts
,写个简单的 2+2=4
的测试代码,其中:
describe
会形成一个作用域import { describe, expect, it } from "vitest";
describe("two plus two is four", () => {
it("should be 4", () => {
expect(2 + 2).toBe(4)
})
})
在 components
下运行 pnpm run test
的时候会出现如下信息,并且开启了热更新:
✓ src/button/button.test.ts (1)
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 20:52:32
Duration 518ms (transform 162ms, setup 0ms, collect 17ms, tests 1ms)
PASS Waiting for file changes...
press h to show help, press q to quit
components/src/button/button.vue
的内容如下:
<template>
<button :class="`k-button--${type}`">
<slot>Hello worldslot>
button>
template>
<script lang="ts">
export default {
props: {
type: {
type: String,
default: 'default'
}
}
}
script>
因为项目主要是组件库,所以组件是主要测试的东西,首先要安装 Vue 推荐的测试库 @vue/test-utils
pnpm add @vue/test-utils -D -w
这个工具主要提供了一个 mount
方法,通过 mount
实例化一个组件,传入不同参数来测试组件是否符合预期,比如 Button
组件写一段测试插槽的代码,一般组件测试文件会放在 __tests__
文件夹下,所以物品们在 src/button/__tests__
新建button.test.ts
:
import { describe, expect, it } from "vitest"; // test 方法的别名是 it
import { mount } from '@vue/test-utils'
import button from '../button.vue'
// The component to test
describe('test Button', () => {
it("should render slot", () => {
const wrapper = mount(button, {
slots: {
default: 'Hello world'
}
})
// Assert the rendered text of the component
expect(wrapper.text()).toContain('Hello world')
})
})
这段测试代码的含义是:当我们默认插槽为"Hello world"时,期望这个组件的text包含"Hello world"
然后执行 pnpm run test
会发现 Button
组件的默认 slot 测试通过了
如果要测试 Button
组件传入不同 type
展示不同样式,可以加一段个测试 type
的代码
import { describe, expect, it } from "vitest";
import { mount } from '@vue/test-utils'
import button from '../button.vue'
// The component to test
describe('test Button', () => {
......
it("should have class", () => {
const wrapper = mount(button, {
props: {
type: 'primary'
}
})
expect(wrapper.classes()).toContain('k-button--primary')
})
})
这段测试代码的含义是:当传入的组件的
type
为primary
,期望渲染出的组件包含k-button--primary
类名
然后执行 pnpm run test
会发现 Button
组件的默认 slot 测试通过了
执行结果如下:
✓ src/button/__test__/button.test.ts (2)
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 21:33:28
Duration 69ms
PASS Waiting for file changes...
press h to show help, press q to quit
其中关于组件的测试方式还有许多,这里就不再一一举例了,感兴趣的可以到官网 Vitest API 查看
最后可以执行 pnpm run coverage
来查看测试的覆盖情况:
% Coverage report from c8
------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
------------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 100 | 100 |
button.vue | 100 | 100 | 100 | 100 |
------------|---------|----------|---------|---------|-------------------
作者为了方便大家快速搭建属于自己的组件库,已经把模板加入了自己的脚手架里,大家可以下载脚手架,然后拉取模板后即可开始组件创作,帮助大家节约大部分的时间,让你们更专注于开发自己的组件库。
运行命令全局安装作者开发的脚手架 karl-cli
npm i karl-cli -g
更新版本:
npm update karl-cli -g
如果有报错的话,可能是权限不够,在命令行前加 sudo
即可。
运行命令全局安 pnpm
npm i pnpm -g
如果有报错的话,可能是权限不够,在命令行前加 sudo
即可。
在自己想要安装的目录下运行命令
karl create vue-component
然后控制方向键选择想要安装的模板,我们选择 component-template
,然后等待安装即可。
如果不想下载脚手架也可以直接克隆我的 GitHub 项目:
git clone -b component https://github.com/ox4f5da2/karl-cli-template.git
克隆后的步骤还是一样的
操作时截图如下:
安装完成时截图如下:
如果想知道如何实现自己的脚手架可以浏览:【开发一个类似 vue-cli 的脚手架工具】
运行以下命令
pnpm i
在 packages/components/src
中创建组件即可,然后在 src
下的 index.ts
中导出,如下所示:
import { default as sbButton } from './sbButton';
......
const components = [
sbButton,
......
];
export default {
install: (app: any) => {
for (const comkey in components) {
app.component(components[comkey].__name, components[comkey]);
}
}
};
export {
sbButton,
......
};
在 /examples/app.vue
中直接使用即可,不需要导入组件,如:
<template>
<div>
<sb-button>click<sb-button />
div>
template>
<script lang="ts" setup>
// js 代码
script>
<style scoped>
/* css 样式 */
<style>
然后运行命令:
pnpm run exm:dev
如果大家喜欢可以点赞➕收藏 ,最后参考的是掘金上【东方小月】的组件搭建博客。