从0构建前端UI组件库
虽然外部有vant、element、ant-design等一些UI组件库,但是对于一些自己业务内部的样式,没办法做到完全符合,为了提高开发效率,所以需要自己研制内部通用UI组件库。
比如这种情况,头像组件为例子:



以上头像列表如果要实现列表懒加载、国籍图片展示,头像宽度大小都不一样等样式重新会花费很多的时间和精力,在外部没有合适的UI框架之后,就需要使用我们自己去开发适合业务的组件去使用。
整体时间有限,主要讲解以打包为主。打包也有很多的实现方式,这里主要采用elementui+rollup的打包方式实现
要想做一个组件库,就要先知道,我们做出来的dist打包之后的产物是什么。
一般组件库打包之后的产物目录如下
我们先看一下package.json,分析项目源码也是从package.json看起
1 | |
可以看到,其中的es文件夹和lib文件夹,分别对应了main和module的入口,对代码进行两份格式打包:commonjs 与 es module。 types文件夹对应了types入口。
如果使用 import 对该库进行导入,则首次寻找 module 字段引入,否则引入 main 字段
- es:module 字段作为 es module 入口
- lib:main 字段作为 commonjs 入口
- theme-chalk:组件库样式入口
- types:类型定义文件
- index.esm.js:全量打包esm格式,es6 模块,通过import引入,import htui from ht-ui
- index.js:全量打包umd格式,可以通过这种方式引入


主要技术栈
如果要实现这样一个组件库,我们这里采用的主要技术栈如下:
- PNPM:包管理工具、通过workspace+monorepo(含多个不同项目的单个存储库)方式管理代码结构
- Gulp:实现文件流式操作(使用其他插件实现编译less等)
- Rollup:作为主要的组件库打包工具
- Vitest:单元测试工具
- semantic-release:实现自动发布到npm或者gitlab的Package Registry
- rollup-plugin-esbuild:解析.vue和.ts,转换为js文件
- less:样式编写
- ts-morph:编译ts的库。是一个适用于Javascript、Typescript的AST处理工具库
- markdown-it-container、vite-plugin-md:将md文件转换为html,文档库使用
- unplugin-vue-define-options:通过defineOptions定义组件名称
- vite:作为启动演示项目,打包组件库文档
目录结构
如果要做一个组件库,其中考虑到的文件目录有
- 组件存放目录
- 需要有组件的入口文件
- 一个类型声明文件
- 一个组件源码
- 单元测试文件
- 演示demo(移动端需要可以直接演示)
- 配套文档
- 样式文件
- 需要有组件的入口文件
- 组件调试演示目录,用于开发时候调试
- 可以单独启动的项目目录,这里采用vitest
- 组件库打包目录
- 常量存放文件
- 执行打包的文件目录
- 存放一些公共方法的文件
- 组件库文档目录,需要单独可以运行和打包部署
- 类型声明目录
最终的文件目录结构如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129├─.gitignore
├─.gitlab-ci.yml
├─.npmrc
├─.nvmrc
├─CHANGELOG.md
├─Makefile
├─README.md
├─package.json
├─pnpm-lock.yaml
├─pnpm-workspace.yaml
├─tsconfig.base.json
├─tsconfig.json
├─tsconfig.node.json
├─tsconfig.vitest.json
├─tsconfig.web.json
├─vitest.config.ts
├─typings // 类型定义
| ├─README.md
| ├─env.d.ts
| ├─vue-shim.d.ts
| └vue-test-utils.d.ts
├─site // 文档部署
| ├─.DS_Store
| ├─README.md
| ├─config.json
| ├─index.html
| ├─mobile.html
| ├─package.json
| ├─vite.config.ts
| ├─style
| | ├─base.less
| | └vars.less
| ├─mobile // 移动端文档
| | ├─App.vue
| | ├─main.ts
| | ├─views
| | | └index.vue
| | ├─router
| | | └index.ts
| | ├─components
| | | └DemoBlock.vue
| ├─docs // PC文档
| | ├─App.vue
| | ├─main.ts
| | ├─views
| | | ├─Header.vue
| | | └index.vue
| | ├─router
| | | └index.ts
| | ├─markdown
| | | ├─home
| | | | └README.md
| | ├─components
| | | ├─Body.vue
| | | ├─Header.vue
| | | └Nav.vue
├─scripts // 打包脚本
| ├─.DS_Store
| ├─README.md
| ├─config.js
| ├─gulpfile.js
| ├─utils // 工具方法
| | ├─gulp.js
| | ├─helper.js
| | └process.js
| ├─tasks // 打包任务
| | ├─build-modules.js
| | ├─full-bundle.js
| | └gen-types.js
| ├─constant //常量
| | └index.js
├─playground // 本地开发演示文档
| ├─App.vue
| ├─README.md
| ├─index.html
| ├─main.ts
| ├─package.json
| └vite.config.js
├─packages // 组件目录
| ├─.DS_Store
| ├─utils // 组件公共方法
| | ├─basic.ts
| ├─theme-chalk // 组件样式
| | ├─.gitignore
| | ├─gulpfile.js
| | ├─package.json
| | ├─src
| | | ├─animation.less
| | | ├─base.less
| | | ├─css-variables.less
| | | ├─index.css
| | | ├─index.less
| | | ├─normalize.less
| | | ├─assets
| | | | ├─iconfont.css
| | | | └iconfont.woff2
| ├─test-utils // 单元测试的数据mock
| | └mock.ts
| ├─locale // 本地化
| | ├─README.zh-CN.md
| | ├─index.ts
| | ├─package.json
| | ├─lang
| | | ├─arabic.ts
| ├─ht-ui // ht-ui打包入口
| | ├─index.ts
| | └package.json
| ├─hooks // 常用hook
| | ├─index.ts
| | ├─package.json
| | ├─useWindowSize
| | | └index.ts
| | ├─__test__
| ├─components // 组件
| | ├─.DS_Store
| | ├─index.ts
| | ├─package.json
| | ├─avatar
| | | ├─README.md
| | | ├─index.ts
| | | ├─style
| | | | └index.ts
| | | ├─src
| | | | ├─avatar.ts
| | | | └avatar.vue
| | | ├─demo
| | | | └index.vue
| | | ├─__tests__
| | | | └avatar.test.ts
这里我们将每一个文件目录做成单独的pnpm包进行安装,pnpm-workspace.yaml配置如下
1 | |
然后我们在项目中一般引入别的文件路径会使用软连接,比如
1 | |
所以我们这里要对一些组件库目录下常用的做成软连接形式,避免手动一层一层使用../../../这种方式,所以我们需要:
- 进入packages目录下创建文件夹,比如创建一个hook文件夹,创建一个utils文件夹等。
- 然后在这个目录下执行pnpm init,这时候会生成一个packages.json,然后我们修改这个packages.json里面的name为@ht-ui/hook
- 然后,我们在项目根目录执行pnpm install @ht-ui/hook,此时就可以看到在根目录package.json的dependencies增加为
1
2
3"dependencies": {
"@ht-ui/hooks": "workspace:^0.0.5",
}
- 然后,我们在项目根目录执行pnpm install @ht-ui/hook,此时就可以看到在根目录package.json的dependencies增加为
- 然后就可以在项目中进行软链接引入了,如果发现提示找不到路径,可能需要配置TS文件中的
1
2
3"paths": {
"@ht-ui/*": ["packages/*"]
}packages.json命令
在准备完成以上内容,这时候我们需要配置一下packages.json中的scripts
这里的命令都是pnpm dev,pnpm xxx
1 | |
组件开发
准备好以上内容就可以准备开发组件,我们以avatar组件为例子
首先,想一下我们需要打包的内容
- 支持组件全量打包,通过script标签引入
- 支持组件单独通过import { xx } from ‘ht-ui’两种方式引入
- 样式文件和组件分离
- 样式全量打包,采用script引入xx.css
- 样式单独引入,比如单独引入import “ht-ui/es/components/nav-bar/style”
组件
所以,我们组件的目录定义为
1 | |
其中index.js,对外暴露了组件以及类型
1 | |
install.js
1 | |
类型定义文件
1 | |
组件class命名
采用BEM命名,封装createNamespace方法
https://bemcss.com/
在组件中使用:class=”bem()”,:class=”bem(‘img’)”实现class命名
1 | |
BEM方法主要如下
1 | |
组件样式
有了组件,但是还缺少样式,这里样式文件我们和组件分离,不会写在组件中,方便样式单独打包。最终使用后引入方式为
1 | |
所以需要在原来的组件目录下新建一个style/index.ts
1 | |
其中@ht-ui/theme-chalk是软连接到了packages/theme-chalk目录下,主要目录结构如下
1 | |
其中组件样式也采用对等的BEM规范来开发
avatar/index.less
1 | |
调试组件
开发中调试
如果我们想在开发中调试当前组件,可以在项目的目录下新建一个playground目录,我们在这个目录下执行pnpm install @ht-ui/components,方便后续引入组件开发。
1 | |
其中vite.config.js为
1 | |
main.ts为
这里需要注意的是,样式文件可以通过@ht-ui/theme-chalk/src/index.less来一次性引入的。
1 | |
随后在App.vue就可以自己调试组件了。
1 | |
项目中调试
还有一种情况就是,想在真实的项目中去调试一下组件,按照传统的方式就是本地打包,然后执行npm link。这里我使用yalc去实现,具体可以参考https://segmentfault.com/a/1190000039658156
组件打包
打包这里的主要思路是
- 通过gulp定义流式任务,依次执行,打包分包,打包全量包,打包样式文件,生成类型定义文件和拷贝文件到dist
- 分包打包,通过rollup,使用插件对.vue、.ts文件解析,生成es/.mjs和lib/.js文件
- 全量打包,打包之前遍历组件库目录,生成组件index.js入口文件,通过rollup生成index.js和index.esm.js文件
- 样式打包,读取theme-chalk目录下所有的.less文件,通过gulp-less编译为.css文件
- 类型定义,遍历.vue文件,通过ts-morph和vue/compiler-sfc解析,生成类型定义文件
主要代码实现为
- 通过gulp定义流式任务以上代码,主要的作用是,通过series定义了一系列的串行任务,串行任务中定义了一系列的并行任务。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27import gulp from "gulp";
/**
* series,parallel
* 串行(series)任务 并行(parallel)任务
*/
const { series, parallel } = gulp
export default series(
parallel(
// 执行build命令时会调用rollup,给rollup传参数buildModules,那么就会执行导出任务叫buildModules
withTaskName("buildModules", () =>
run("pnpm run build buildModules",projRoot)
),
withTaskName("buildFullBundle", () =>
run("pnpm run build buildFullBundle")
), // 会生成index.esm.js和index.js 合并打包 esm模块和umd模块
series(
withTaskName('buildThemeChalk', () =>
run('pnpm run -C packages/theme-chalk build',projRoot)
),
)
),
parallel(genTypes, copyFiles)
)
export * from "./tasks/build-modules.js";
export * from "./tasks/full-bundle.js";
withTaskName是一个自定义函数,主要作用是自定义每一个task的name
1 | |
run函数也是一个自定义任务,主要是对传入的命令进行分割。通过创建node子进程,启动一个子进程来执行命令。
1 | |
其中第一个执行任务名为buildModules,其对应的函数在下方通过export * from “./tasks/build-modules.js”进行导出,然后再通过自定义的run函数执行。
实际上,这里当我们执行”pnpm run build buildModules”时候,这里会先执行到根目录的package.json中的build
1 | |
然后将参数拼接为gulp -f scripts/gulpfile.js buildModules ,这样就执行到了buildModules这个方法。
commonjs 与 es module格式
这里的parallel定义的第一个打包命令是打包分包,即打包commonjs 与 es module格式的代码,来看一下具体实现,代码在tasks/build-modules.js中
1 | |
以上代码主要流程为
- 通过fast-glob库遍历packages所有的.js、.ts,.vue文件作为入口文件input,input是一个数组路径。
1
2
3
4
5
6
7[
'/Users/mac20211105/Desktop/工具库/组件库/packages/components/share/utils/index.ts',
'/Users/mac20211105/Desktop/工具库/组件库/packages/components/share/utils/native.ts',
'/Users/mac20211105/Desktop/工具库/组件库/packages/theme-chalk/src/icon/config.d.ts',
'/Users/mac20211105/Desktop/工具库/组件库/packages/theme-chalk/src/icon/config.js'
...
] - 使用rollup传入input参数,同时定义了plugins对input路径中的文件做解析,插件作用依次是
- HtPlusAlias:自定义解析插件。在文件解析时候,nodeResolve插件会将软连接文件的路径进行转换,比如import { withInstall } from ‘@ht-ui/utils’会转换为import ‘../../utils/index.mjs’。但是对于样式文件中的import ‘@ht-ui/theme-chalk/avatar/index.less’没办法做转换,所以需要自定义的插件来支持作用就是如果匹配到路径中包含theme-chalk,就将路径和文件名后缀进行替换。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16export function HtPlusAlias() {
const themeChalk = 'theme-chalk'
const sourceThemeChalk = `${PKG_PREFIX}/${themeChalk}`
const bundleThemeChalk = `${PKG_NAME}/${themeChalk}`
return {
name: 'ht-ui-alias-plugin',
resolveId(id) {
if (!id.startsWith(sourceThemeChalk)) return
return {
id: id.replaceAll(sourceThemeChalk, bundleThemeChalk).replaceAll('.less','.css'),
external: 'absolute',
}
},
}
} - VueMacros:对Vue3中的宏定义进行编译处理,比如定义的组件名称
1
2
3defineOptions({
name: "HtAvatar",
}); - nodeResolve:解析@ht-ui/utils转换为相对路径,会解析导入的模块路径,并查找其对应的实际文件路径。
- commonjs:将 commonjs 模块转成 es6 模块
- esbuild:解析 .vue文件 和 .ts 文件,并将其编译为 ES6 模块格式
- external:排除外部依赖,将@vue和ht-ui中的packages依赖排除,否则会解析到路径为../../node_modules中。如果.vue文件代码中有引入了比如@vue/xxx,或者依赖包中的东西是需要排除的。注意这里获取排除的的peerDependencies依赖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16export const generateExternal = async (options) => {
// 路径转换,将vue和ht-ui中的packages依赖排除
// 获取package/ht-ui下的packages.json
const { dependencies, peerDependencies } = getPackageDependencies(epPackage)
return (id) => {
const packages = peerDependencies
if (!options.full) {
// 因为有可能直接引入@vue里的东西
packages.push('@vue', ...dependencies)
}
return [...new Set(packages)].some(
(pkg) => id === pkg || id.startsWith(`${pkg}/`)
)
}
} - 最后就是将处理好的文件通过调用.write方法,生成文件输出,其中配置preserveModules和preserveModulesRoot搭配使用,可以输出打包文件时候还保持原来的目录文件结构其中buildConfig如下,分别生成esm和cjs格式
1
2
3
4
5
6
7
8
9
10
11
12
13const options = Object.values(buildConfig).map((config) => ({
format: config.format,
// 该选项用于指定所有生成 chunk 文件所在的目录。如果生成多个 chunk,则此选项是必须的。否则,可以使用 file 选项代替。
dir: config.output.path,
exports:config.format === 'cjs' ? 'named' : undefined,
preserveModules: true,
preserveModulesRoot: 'packages',
sourcemap: true,
entryFileNames: `[name].${config.ext}`
}));
await Promise.all(
options.map((option) => bundle.write(option))
);1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28import path from "path";
import { OUTPUT_DIR } from './constant/index.js'
export const buildConfig = {
esm: {
module: "ESNext", // tsconfig输出的结果es6模块
format: "esm", // 需要配置格式化化后的模块规范
ext: 'mjs',
output: {
name: "es", // 打包到dist目录下的那个目录
path: path.resolve(OUTPUT_DIR, "es"),
},
bundle: {
path: "ht-ui/es",
},
},
cjs: {
module: "CommonJS",
format: "cjs",
ext: 'js',
output: {
name: "lib",
path: path.resolve(OUTPUT_DIR, "lib"),
},
bundle: {
path: "ht-ui/lib",
},
},
};全量组件打包
全量打包和上面的打包逻辑总体差不多,有一点小的区别,主要区别在于 - 只有一个入口,分包打包是具有多个入口的input数组
- 打包格式为umd和esm
所以,针对以上规则,代码实现为 - 生成index.js入口文件,通过遍历文件夹生成最终结果为index.ts
1
2
3
4
5
6
7
8
9
10const getComponents = () => readdirSync(COMPONENTS).filter((name) => lstatSync(join(COMPONENTS,name)).isDirectory()&& name !== 'node_modules')
async function generateComponentsEntry () {
const componentsName = getComponents()
const tip = '// 此文件是自动生成,无需改动\n'
const code = tip + componentsName.map((name) => {
return `export * from './${name}';`
}).join('\n');
outputFileSync(`${COMPONENTS}/index.ts`, code)
}1
2
3
4
5
6// 此文件是自动生成,无需改动
export * from './avatar';
export * from './icon';
export * from './image';
export * from './nav-bar';
export * from './share'; - 修改打包入口为上面生成的index.ts文件
1
2
3const bundle = await rollup({
input: path.resolve(HT_UI, "index.ts"), // 打包入口
}) - 这里这种包为了减小体积一般要采用压缩打包格式修改为
1
2
3
4
5
6
7
8
9
10
11
12import esbuild,{ minify as minifyPlugin } from 'rollup-plugin-esbuild'
const bundle = await rollup({
input: path.resolve(HT_UI, "index.ts"), // 打包入口
plugins: [
...
minifyPlugin({
target:'es2018',
sourceMap: true,
})
...
]
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16const buildConfig = [
{
format: "umd", // 打包的格式
file: path.resolve(OUTPUT_DIR, "index.js"),
name: "htui", // 全局变量名字
exports: "named", // 导出的名字 用命名的方式导出 libaryTarget:"" name:""
globals: {
// 表示使用的vue是全局的
vue: "Vue",
},
},
{
format: "esm",
file: path.resolve(OUTPUT_DIR, "index.esm.js"), // esm全量倒入
},
];样式打包
样式打包和上面的都略有不同,可以从命令看起。这里执行的命令为pnpm run -C packages/theme-chalk build。命令中-C表示,进入到packages/theme-chalk目录下执行build命令,上面已经说过目录结构从0构建前端UI组件库1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17export default series(
withTaskName("clean", async () => run("pnpm run clean")), // 删除dist目录
parallel(
// 执行build命令时会调用rollup,给rollup传参数buildModules,那么就会执行导出任务叫buildModules
withTaskName("buildModules", () =>
run("pnpm run build buildModules",projRoot)
),
...
// 会生成index.esm.js和index.js 合并打包 esm模块和umd模块
series(
withTaskName('buildThemeChalk', () =>
run('pnpm run -C packages/theme-chalk build',projRoot)
),
)
),
...
)
来看一下package.json中的build命令这里同样执行的是gulpfile.js文件,这里打包逻辑如下1
2
3
4"scripts": {
"clean": "rm -rf dist",
"build": "gulp"
} - 通过gulp.src遍历当前目录下所有的.less文件,然后交给gulp-less插件和gulp-postcss来处理
- 通过cleanCSS压缩css,然后输出压缩前后的对比信息
- 通过gulp.dest将处理好的文件输出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36import gulp from 'gulp'
import autoprefixer from 'autoprefixer'
import postcss from 'gulp-postcss'
import less from 'gulp-less'
import { OUTPUT_DIR } from '../../scripts/constant/index.js'
import path,{ dirname } from 'path'
import { fileURLToPath } from 'url';
const { series,dest } = gulp
import cleanCSS from 'gulp-clean-css'
import consola from 'consola'
import chalk from 'chalk'
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const distBundle = path.resolve(OUTPUT_DIR, 'theme-chalk')
const buildStyle = async () => {
return gulp.src(path.resolve(__dirname, 'src/**/*.less'))
.pipe(less())
.pipe(postcss([
autoprefixer({overrideBrowserslist: ['last 1 version']})
]))
.pipe(
cleanCSS({}, (details) => {
consola.success(
`${chalk.cyan(details.name)}: ${chalk.yellow(
details.stats.originalSize / 1000
)} KB -> ${chalk.green(details.stats.minifiedSize / 1000)} KB`
)
})
)
.pipe(dest(distBundle))
}
export default series(buildStyle);生成文件类型定义
类型定义会在打包组件时候并行执行其中主要实现为1
2
3
4
5
6
7
8export default series(
...
parallel(
...
...
),
parallel(genTypes, copyFiles)
) - 引入ts-morph中的Project类,用于解析 TypeScript 项目并生成 AST(抽象语法树),传入配置
- 从项目根目录遍历后缀为.js、.jsx、.ts、.tsx、vue文件
- 将遍历到包含后缀为.vue文件的,读取文件内容,通过vue/compiler-sfc的vueCompiler.parse方法将文件内容解析为对象,在判断是否有写script脚本,接着,如果之前代码中有写过@ts-nocheck的话,那么,这里会把这个加上注释去除掉,再将 scriptSetup 的内容编译并加入 script 内容后重新生成一个 .js 或 .ts 文件,并将其作为 SourceFile 加入 project。这样,在后续处理 TypeScript 代码时,这些新生成的 .js 或 .ts 文件就会被当做普通的 TypeScript 源代码来处理,最后将文件保存在sourceFiles中
- 随后,通过project.emit方法配置,只输出类型定义文件
- 遍历sourceFiles,输出.d.ts文件然后做路径的重写
生成package.json
这里在packages新创建一个ht-ui目录,然后里面存放一个package.json,当我们完成打包任务,就把这个文件拷贝到打包之后的dist目录下即可。
1 | |
文档开发
因为组件库为移动端组件,所以文档这里想实现的效果为左侧为文档,右侧为移动端组件预览,最终效果为
首先在根目录下新建一个site文件夹,主要实现为:
- site文件夹是一个单独独立运行的项目,使用vite作为构建工具,其中目录结构为

- vite.config.ts为项目启动配置文件
- docs:PC端的文档
- mobile:移动端站点
- index.html:PC入口文件,加载了docs/main.ts
- mobile.html:移动端入口文件,加载了mobile/main.ts
- 在执行vite时候,默认读取vite.config.ts,会加载index.html中引入的资源,在/docs/main.ts引入的路由文件中,遍历组件库目录下的.md文件(已经约定每一个组件文件夹下面的README.md为PC文档,每个组件目录demo下为移动端演示文档),匹配到组件文件夹的目录名称和路径作为参数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23const routes: Array<RouteRecordRaw> = [];
const getPageRouter = ():Array<RouteRecordRaw> => {
const modulesPageTaro = import.meta.glob("@/components/**/*.md"); // 获取到目录下组件的MD文档
for( let path in modulesPageTaro) {
// 匹配到组件文件夹的目录名称作为路由名称
const name = (/components(.*)\/README.md/.exec(path) as any[])[1];
routes.push({
path:name,
component:modulesPageTaro[path]
})
}
return routes
}
const router = createRouter({
history: createWebHashHistory(),
routes:[
{
path: '/', redirect: '/avatar'
},
...getPageRouter()
]
}); - 移动端主要是需要在docs/App.vue中通过iframe来引入,地址需要手动拼接,拼接出来格式路径为xxxxx/mobile.html#/avatar,然后在监听router.currentRoute事件,路由变化重新拼接和docs/router获取路由有一点差别,就是这里需要获取组件目录下的demo/index.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14// docs/App.vue
<div class="content">
<iframe :src="mobileIframeUrl" frameborder="0"></iframe>
</div>
const watchUrl = () => {
const { origin, pathname, hash } = window.location;
mobileIframeUrl.value = `${origin}${pathname}mobile.html${hash}`;
};
watch(router.currentRoute, () => {
watchUrl();
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18const routes: Array<RouteRecordRaw> = [];
const getPageRouter = ():Array<RouteRecordRaw> => {
const modulesPageTaro = import.meta.glob("@/components/**/demo/index.vue");
for( let path in modulesPageTaro) {
// 匹配到组件文件夹的目录名称作为路由名称
const name = (/components(.*)\/demo\/index.vue/.exec(path) as any[])[1];
routes.push({
path:name,
component:modulesPageTaro[path]
})
}
return routes
}
const router = createRouter({
history: createWebHashHistory(),
routes:getPageRouter()
}); - 解析README.md为DOM,因为我们的组件文档都是用markdown写的,所以需要将.md转换。这里主要在vite.config.ts中处理主要逻辑是通过vite-plugin-md插件将.md转换为HTML,然后对转换后的样式增加了一些class类,然后通过markdownCardWrapper函数调整转换后的样式,配置了支持代码高亮等。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48import vue from '@vitejs/plugin-vue';
import DefineOptions from 'unplugin-vue-define-options/vite'
import Markdown from 'vite-plugin-md'
import MarkdownPreview, { transformer } from 'vite-plugin-md-preview'
function markdownCardWrapper(code: string) {
const group = code
.replace(/<h3/g, ':::<h3')
.replace(/<h2/g, ':::<h2')
.split(':::');
let result = group
.map((fragment) => {
if (fragment.indexOf('<h3') !== -1) {
if (fragment.indexOf('</template>') !== -1) {
return `<div class="ht-doc-card">${fragment.replace('</template>','')}</div></template>`;
}
return `<div class="ht-doc-card">${fragment}</div>`;
}
return fragment;
})
.join('');
return result
}
export default defineConfig({
base: "",
plugins: [
vue({
include: [/\.vue$/, /\.md$/]
}),
DefineOptions(),
Markdown({
wrapperClasses: 'ht-doc-markdown-body', // 文档部分,整个页面容器的class
transforms: {
// 自定义转换以在markdown转换之前和/或之后应用,before就是原始的md文件,after是转换之后的html结构
// 这里需要为组件文档添加一个容器来让展示的样式正常
// 转换之后添加样式
after: markdownCardWrapper,
},
markdownItSetup(md) {
// https://www.npmjs.com/package/markdown-it-container
md.use(Shiki, { theme: 'github-light' }) // 支持代码高亮
},
}),
MarkdownPreview()
]
})文档打包
打包这里只需要在vite.config.ts中增加build配置,同时指定入口为多个即可,然后输出到当前路径下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19export default defineConfig({
//.......
build: {
// 设置最终构建的浏览器兼容目标。默认值是一个 Vite 特有的值——'modules',这是指 支持原生 ES 模块、原生 ESM 动态导入 和 import.meta 的浏览器。
target: 'es2015',
outDir: './site/',
// assetsDir: "./site/assets",
// 启用/禁用 CSS 代码拆分。当启用时,在异步 chunk 中导入的 CSS 将内联到异步 chunk 本身,并在其被加载时插入。如果禁用,整个项目中的所有 CSS 将被提取到一个 CSS 文件中。
cssCodeSplit: true,
// 此选项允许用户为 CSS 的压缩设置一个不同的浏览器 target,此处的 target 并非是用于 JavaScript 转写目标。
cssTarget: ['chrome61'],
rollupOptions: {
input: {
docs: path.resolve(__dirname, "index.html"),
mobile: path.resolve(__dirname, "mobile.html"),
}
},
}
})多语言
…
组件发布
token为group frontend目录下的token
这里发布以实例级别为例子。
本地发布
本地发布比较简单:
- 本地执行pnpm run build打包,然后根目录创建一个.npmrc文件,输入这里需要说明的就是
1
2
3
4
5
6
@frontend:registry=https://code.hellotalk.com/api/v4/projects/743/packages/npm/
//code.hellotalk.com/api/v4/projects/743/packages/npm/:_authToken=gWHyY-HAr-oxyPh9CZGd
//code.hellotalk.com/api/v4/packages/npm/:_authToken = gWHyY-HAr-oxyPh9CZGd
Always-auth=true
@frontend,代码组件库所在的根路径,具体可以看gitlab包命名约定
743,代表项目所在的Project ID

_authToken:身份认证信息,可以在根群组目录下(比如frontend),点击左侧settings-repository,找到Deploy tokens去设置
都配置好之后,就可以执行npm publish ./dist发布包了
CI/CD 发布
这里主要以CI/CD时候发布为例子,主要逻辑流程包括
- 这里主要配置gitlab-ci.yml文件主要逻辑是定义两个stage,第一个作用是仅仅在main分支可以触发,脚本script为
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38stages:
- deploy
- build-docs
deploy:
stage: deploy
tags:
- public
only:
- main
script:
- npm install -g pnpm@latest
- pnpm install
- echo "frontend:registry=https://${CI_SERVER_HOST}/api/v4/projects/${CI_PROJECT_ID}/packages/npm/" >>.npmrc #一个> 表示已经存在的文件重写,两个>> 表示追加。实例级配置
- echo "//${CI_SERVER_HOST}/api/v4/projects/${CI_PROJECT_ID}/packages/npm/:_authToken=${CI_JOB_TOKEN}" >>.npmrc
- echo "//${CI_SERVER_HOST}/api/v4/packages/npm/:_authToken = ${CI_JOB_TOKEN}" >>.npmrc
- echo "Always-auth=true" >>.npmrc
- pnpm run test
- pnpm run build
- pnpm run release
pages:
stage: build-docs
tags:
- public
only:
- main
script:
- node -v
- npm install -g pnpm@latest
- pnpm -v
- pnpm i
- npm run build:docs
- rm -rf public # 删除 public 目录及目录下的文件。
- mkdir public && cp -rf site/site/* public
artifacts:
paths:
- public - deploy阶段
- 全局安装最新的pnpm
- 安装项目依赖
- echo的三行为项目发布的实例级别配置和配置授权toekn,创建写入到根目录下的.npmrc文件中,其中
- ${CI_SERVER_HOST}、${CI_PROJECT_ID}、${CI_JOB_TOKEN}是gitlab已经提供的变量,可以直接使用
- 执行单元测试
- 执行打包组件,生成dist目录
- 执行发布命令,这里发布我们使用了semantic-release,主要是去用来生成CHANGELOG.md和自动更新组件库版本号,自动打tag,并且将修改推送回git仓库中,操作配置如下
- 安装插件@semantic-release/changelog、@semantic-release/git、@semantic-release/gitlab、@semantic-release/npm、semantic-release
- 根目录配置打包命令”release”:”semantic-release –debug”
- 根目录的package.json增加配置”release”,其中@semantic-release/npm指定了发布目录为dist,里面的tagFormat要和自己的版本号格式匹配
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37{
"scripts": {
"dev": "pnpm -C playground dev",
"build": "gulp -f scripts/gulpfile.js",
"docs:dev": "pnpm -C site dev",
"clean": "pnpm run clean:dist && pnpm run -r --parallel clean",
"clean:dist": "rm -rf dist",
"test": "vitest",
"build:docs": "pnpm run -C site build",
"release":"semantic-release --debug"
},
"release": {
"branches": ["main"],
"repositoryUrl":"https://code.hellotalk.com/frontend/base-dependence/ht-ui",
"tagFormat":"${version}",
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@semantic-release/changelog",
{
"changelogFile": "./CHANGELOG.md"
}
],
["@semantic-release/npm",{
"pkgRoot": "dist"
}],
"@semantic-release/gitlab",
[
"@semantic-release/git",
{
"assets": ["CHANGELOG.md", "packages/ht-ui/package.json"]
}
]
]
}
}
- 配置token,因为semantic-release将代码反向push回仓库时候需要身份信息,
- 配置GITLAB_TOKEN,组件库目录下Settings-Access Tokens 配置,勾选对应权限和角色,名称可以自己定义,点击确定之后,这里的token只会出现一次,复制出来
- 还是在组件库目录下Settings-CI/CD-Variables配置,注意勾选Expanded,name为GL_TOKEN,配置值为上面的Access Tokens
- 随后在gitlab-ci.yml文件顶部增加GITLAB_TOKEN: “$GL_TOKEN”这样就可以实现自动发布了
1
2
3
4image: node:latest
variables:
GITLAB_TOKEN: "$GL_TOKEN"
- build-docs阶段
- 和上面的命令都差不多,主要是在最后,因为组件库文档打包之后在site/site目录下,所以通过mkdir public && cp -rf site/site/* public 将打包资源移动到了根目录下的public文件夹
- 有了public,我们可以在Settings-Pages看到部署的文档


组件安装
当组件发布成功之后,可以进入到根群组目录下,点击左侧Package Registry,找到想要安装的包
在要安装的项目目录下执行上面红色框的代码,会生成一个.npmrc文件,随后写入
1 | |
完成以上内容后,就可以安装组件库或者其他发布的包了,这里是一个scope下发布了多个包,可以用@frontend/ht-ui、@frontend/ht-tool安装不同的包
参与组件开发
新建开发分支
在packages/components下增加组件
完善__tests__单元测试
完善demo目录下的文件,用作组件库文档的移动端展示
packages/components/src 下面一个是组件的源文件,另外一个是类型定义文件,即props,emit定义等
style是样式文件,在组件目录下的index.ts写即可,注意这里是引入了 import ‘@ht-ui/theme-chalk/组件名/index.less’,组件的实际样式存放在packages/theme-chalk/组件名/index.less
packages/components/index.ts是自动生成,无需修改,打包时候自动引入目录下的组件
在packages下面的几个包components,hooks,theme-chalk,utils通过pnpm做了软连接引用,需要使用的话引入,@ht-ui/components、@ht-ui/hooks,@ht-ui/theme-chalk,@ht-ui/utils,如果有另外的文件夹想增加,可以在目录下新增一个比如constant,然后在这个目录下执行pnpm init,修改生成的package.json的name为@ht-ui/constant,然后在根目录执行pnpm install @ht-ui/constant
文档在site目录下,docs是整个文档,mobile是文档的右侧移动端展示,config.json在每次新增组件之后需要做修改才能看到文档的导航
playground是普通的调试目录,可以自己随意调试组件
提交合并的主分支的merge request
单元测试
组件可以正常渲染
事件可以正常触发
具体可以参考Vue DevUI 公开测试参考指南 https://github.com/DevCloudFE/vue-devui/wiki/Vue-DevUI-%E5%85%AC%E5%BC%80%E6%B5%8B%E8%AF%95%E5%8F%82%E8%80%83%E6%8C%87%E5%8D%97
后续开发计划
本文标题:从0构建前端UI组件库
文章作者:Niuhk
发布时间:2023-01-06
最后更新:2024-10-15
原始链接:https://www.niuhk.cn/2023/01/06/从0构建前端UI组件库/
版权声明:转载请注明出处!
分享