虽然外部有vant、element、ant-design等一些UI组件库,但是对于一些自己业务内部的样式,没办法做到完全符合,为了提高开发效率,所以需要自己研制内部通用UI组件库。

比如这种情况,头像组件为例子:

image
image
image

以上头像列表如果要实现列表懒加载、国籍图片展示,头像宽度大小都不一样等样式重新会花费很多的时间和精力,在外部没有合适的UI框架之后,就需要使用我们自己去开发适合业务的组件去使用。

整体时间有限,主要讲解以打包为主。打包也有很多的实现方式,这里主要采用elementui+rollup的打包方式实现

要想做一个组件库,就要先知道,我们做出来的dist打包之后的产物是什么。
一般组件库打包之后的产物目录如下
image

我们先看一下package.json,分析项目源码也是从package.json看起

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
{
"name": "@frontend/ht-ui",
"version": "1.1.9",
"files": [
"*"
],
"scripts": {
"release":"semantic-release --debug"
},
"description": "A Component Library for Vue 3",
"keywords": [
"ui framework",
"ui",
"vue"
],
"publishConfig": {
"access": "public",
"@frontend:registry": "https://code.hellotalk.com/api/v4/projects/743/packages/npm/"
},
"bugs": {
"url": "https://code.hellotalk.com/frontend/base-dependence/ht-ui/-/issues"
},
"type": "module",
"license": "MIT",
"main": "lib/components/index.js",
"module": "es/components/index.mjs",
"types": "types/packages/components/index.d.ts",
"repository": {
"type": "git",
"url": "https://code.hellotalk.com/frontend/base-dependence/ht-ui"
},
"style": "theme-chalk/index.css",
"peerDependencies": {
"vue": "^3.2.0"
},
"sideEffects": [
"es/**/style/*",
"lib/**/style/*",
"*.css"
],
"browserslist": [
"> 1%",
"not ie 11",
"not op_mini all"
],
"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/exec",
{
"prepareCmd": "mv CHANGELOG.md ../ && cat package.json && mv package.json ../packages/ht-ui && cd ../ && ls && cat CHANGELOG.md && cat packages/ht-ui/package.json "
}
],
"@semantic-release/npm",
"@semantic-release/gitlab",
[
"@semantic-release/git",
{
"assets": ["CHANGELOG.md", "packages/ht-ui/package.json"]
}
]
]
}
}

可以看到,其中的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格式,可以通过这种方式引入

image
image

主要技术栈

如果要实现这样一个组件库,我们这里采用的主要技术栈如下:

  • 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
2
3
4
packages:
- 'packages/**'
- 'playground'
- 'site'

然后我们在项目中一般引入别的文件路径会使用软连接,比如

1
2
3
import { withInstall } from '@ht-ui/utils'

import '@ht-ui/theme-chalk/avatar/index.less'

所以我们这里要对一些组件库目录下常用的做成软连接形式,避免手动一层一层使用../../../这种方式,所以我们需要:

  • 进入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",
      }
  • 然后就可以在项目中进行软链接引入了,如果发现提示找不到路径,可能需要配置TS文件中的
    1
    2
    3
    "paths": {
    "@ht-ui/*": ["packages/*"]
    }

    packages.json命令

    在准备完成以上内容,这时候我们需要配置一下packages.json中的scripts
    这里的命令都是pnpm dev,pnpm xxx
1
2
3
4
5
6
7
8
9
"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", // 清理dist文件目录输出
"clean:dist": "rm -rf dist",
"test": "vitest", // 执行单元测试
"build:docs": "pnpm run -C site build" // 打包文档
},

组件开发

准备好以上内容就可以准备开发组件,我们以avatar组件为例子
首先,想一下我们需要打包的内容

  • 支持组件全量打包,通过script标签引入
  • 支持组件单独通过import { xx } from ‘ht-ui’两种方式引入
  • 样式文件和组件分离
    • 样式全量打包,采用script引入xx.css
    • 样式单独引入,比如单独引入import “ht-ui/es/components/nav-bar/style”

组件

所以,我们组件的目录定义为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
├─components // 组件
| | ├─index.ts. // 组件全量打包入口,所有的组件会被打包到index.js中
| | ├─package.json
| | ├─avatar
| | | ├─README.md // 组件文档
| | | ├─index.ts // 单组件入口
| | | ├─style // 样式文件引用
| | | | └index.ts
| | | ├─src // 组件
| | | | ├─avatar.ts // 组件类型定义
| | | | └avatar.vue // 组件源码
| | | ├─demo // 文档-移动端演示demo
| | | | └index.vue
| | | ├─__tests__ // 单元测试
| | | | └avatar.test.ts

其中index.js,对外暴露了组件以及类型

1
2
3
4
5
6
7
8
9
10
// 组件安装入口
import { withInstall } from '@ht-ui/utils';
import avatar from './src/avatar.vue'

// 组件安装
export const Avatar = withInstall(avatar)
// 导出组件
export default Avatar
// 导出类型定义等
export * from './src/avatar'

install.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { camelize } from './format'

import type { App, Component, AppContext, Plugin } from 'vue';
// 组件安装
export function withInstall<T>(options: T) {
(options as SFCWithInstall<T>).install = (app: App) => {
const { name } = options as any;
if (name) {
app.component(camelize(name), options);
}
};
return options as SFCWithInstall<T>;
}

export type SFCWithInstall<T> = T & Plugin

export type SFCInstallWithContext<T> = SFCWithInstall<T> & {
_context: AppContext | null
}

类型定义文件

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
import {
type ExtractPropTypes,
} from 'vue';

// Utils
import {
makeNumericProp,
makeStringProp,
truthProp
} from '@ht-ui/utils'

export const avatarProps = {
avatarSrc: String,
flagSrc: String,
avatarWidth: makeNumericProp(60),
flagWidth: makeNumericProp(16),
avatarAlt: makeStringProp(''),
defaultSrc: makeStringProp(''),
lazyLoad: truthProp
};

export const clickEmits = {
click: (evt: MouseEvent) => evt instanceof MouseEvent
}

export const errorEmits = {
error: (evt: Event) => evt instanceof Event
}

export type avatarProps = ExtractPropTypes<typeof avatarProps>;

组件class命名

采用BEM命名,封装createNamespace方法
https://bemcss.com/
在组件中使用:class=”bem()”,:class=”bem(‘img’)”实现class命名

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
<template>
<div :class="bem()" @click.stop="emit('click')" :style="avatarStyle">
<img
v-if="avatarSrc !== undefined"
ref="imgRef"
:class="bem('img')"
:src="imgSrc"
:alt="avatarAlt"
:data-src="defaultSrc"
/>
<div v-if="flagSrc" :class="bem('flag')" :style="flagWrapStyle">
<img :class="bem('flag-img')" :style="flagStyle" :src="flagSrc" ondragstart="return false;"/>
</div>
</div>
</template>


// 定义组件名称
defineOptions({
name: "HtAvatar",
});

import { createNamespace } from "@ht-ui/utils";
const [name, bem] = createNamespace("avatar");

BEM方法主要如下

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
export function createNamespace(name: string) {
const prefixedName = `ht-${name}`;
return [
prefixedName,
createBEM(prefixedName),
createTranslate(prefixedName),
] as const;
}

/**
*bem 创建函数 helper
bem() // 'ht-avatar' // 块
bem('element') // 'ht-avatar__element' // 元素
bem(['disabled', 'primary']) // 'ht-avatar ht-avatar--disabled ht-avatar--primary'
bem({ disabled }) // 'ht-avatar ht-avatar--disabled'
bem('text', { disabled: 'disabled' }) // 'ht-avatar__text ht-avatar__text--disabled'
*/
export function createBEM(name: string) {
return (el?: Mods, mods?: Mods): Mods => {
if (el && typeof el !== 'string') {
mods = el;
el = '';
}

el = el ? `${name}__${el}` : name;

return `${el}${genBem(el, mods)}`;
};
}

组件样式

有了组件,但是还缺少样式,这里样式文件我们和组件分离,不会写在组件中,方便样式单独打包。最终使用后引入方式为

1
2
import { NavBar, Icon, Avatar } from "ht-ui"; // 引入组件
import "ht-ui/es/components/nav-bar/style"; // 引入样式

所以需要在原来的组件目录下新建一个style/index.ts

1
import '@ht-ui/theme-chalk/avatar/index.less'

其中@ht-ui/theme-chalk是软连接到了packages/theme-chalk目录下,主要目录结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|    ├─theme-chalk // 组件样式
| | ├─.gitignore
| | ├─gulpfile.js // 打包样式的文件
| | ├─package.json //
| | ├─src
| | | ├─animation.less // 动画文件
| | | ├─base.less // 一些全局设置
| | | ├─css-variables.less // 全部都是css变量文件
| | | ├─index.css
| | | ├─index.less // 入口文件,引入了其他的所有文件
| | | ├─normalize.less // 初始化设置的文件
| | | ├─mixins // 通用的函数文件夹
| | | | ├─clearfix.less
| | | | ├─ellipsis.less
| | | | ├─flex.less
| | | | ├─hairline.less
| | | | ├─index.less
| | | | └rtl.less
| | | ├─avatar // 组件样式
| | | | └index.less

其中组件样式也采用对等的BEM规范来开发
avatar/index.less

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
@import "../base.less";

:root {
--ht-avatar-border: none;
}

.ht-avatar {
position: relative;
&__img {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
border: var(--ht-avatar-border);
}
&__flag {
position: absolute;
bottom: -1px;
left: -1px;
.ht-flex();
border-radius: 50%;
background: #fff;
&-img {
display: block;
border-radius: 50%;
border: var(--ht-avatar-border);
}
}
}

调试组件

开发中调试

如果我们想在开发中调试当前组件,可以在项目的目录下新建一个playground目录,我们在这个目录下执行pnpm install @ht-ui/components,方便后续引入组件开发。

1
2
3
4
5
6
7
|─playground // 本地开发演示文档
| ├─App.vue
| ├─README.md
| ├─index.html
| ├─main.ts
| ├─package.json
| └vite.config.js

其中vite.config.js为

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
import { defineConfig } from 'vite'
import VueMacros from 'unplugin-vue-macros/vite'
import Vue from '@vitejs/plugin-vue'
import esbuild from 'rollup-plugin-esbuild'

const esbuildPlugin = () => ({
...esbuild({
target: 'chrome64',
include: /\.vue$/,
loaders: {
'.vue': 'js',
},
}),
enforce: 'post',
})

export default defineConfig({
plugins:[
VueMacros({
setupComponent: false,
setupSFC: false,
plugins: {
vue: Vue(),
// vueJsx: VueJsx(), // if needed
},
}),
esbuildPlugin(),
],
esbuild: {
target: 'chrome64',
},
})

main.ts为
这里需要注意的是,样式文件可以通过@ht-ui/theme-chalk/src/index.less来一次性引入的。

1
2
3
4
5
6
7
8
9
10
11
12
13
import {createApp} from 'vue'
import App from './App.vue'
import '@ht-ui/theme-chalk/src/index.less'
import { Avatar } from '@ht-ui/components/avatar'
import { NavBar } from '@ht-ui/components/nav-bar'
import { Share } from '@ht-ui/components/share'
import { Icon } from '@ht-ui/components/icon'
const app = createApp(App)
app.use(Avatar)
app.use(NavBar)
app.use(Share)
app.use(Icon)
app.mount('#app')

随后在App.vue就可以自己调试组件了。

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<div>
<!-- 启动测试 -->
<ht-avatar
avatar-src="1https://ali-global-cdn.hellotalk8.com/avatar/211209/0_63648be53df5c0064f13945f65c10eff.jpg?x-oss-process=style/small&"
flag-src="https://ali-global-cdn.hellotalk8.com/pub/flags/China@2x.png"
avatar-width="400"
flag-width="100"
error-src="https://ali-global-cdn.hellotalk8.com/avatar/211209/0_63648be53df5c0064f13945f65c10eff.jpg?x-oss-process=style/small&"
>
</ht-avatar>
<div>
<template>

项目中调试

还有一种情况就是,想在真实的项目中去调试一下组件,按照传统的方式就是本地打包,然后执行npm link。这里我使用yalc去实现,具体可以参考https://segmentfault.com/a/1190000039658156

组件打包

打包这里的主要思路是

  1. 通过gulp定义流式任务,依次执行,打包分包,打包全量包,打包样式文件,生成类型定义文件和拷贝文件到dist
  2. 分包打包,通过rollup,使用插件对.vue、.ts文件解析,生成es/.mjs和lib/.js文件
  3. 全量打包,打包之前遍历组件库目录,生成组件index.js入口文件,通过rollup生成index.js和index.esm.js文件
  4. 样式打包,读取theme-chalk目录下所有的.less文件,通过gulp-less编译为.css文件
  5. 类型定义,遍历.vue文件,通过ts-morph和vue/compiler-sfc解析,生成类型定义文件

主要代码实现为

  1. 通过gulp定义流式任务
    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
    import 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";
    以上代码,主要的作用是,通过series定义了一系列的串行任务,串行任务中定义了一系列的并行任务。

withTaskName是一个自定义函数,主要作用是自定义每一个task的name

1
2
3
4

// 自定义每个task的name
export const withTaskName = (name,fn) =>
Object.assign(fn, { displayName: name });

run函数也是一个自定义任务,主要是对传入的命令进行分割。通过创建node子进程,启动一个子进程来执行命令。

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
import { spawn } from 'child_process'
export const run = async (command, cwd) => {
return new Promise((resolve, reject) => {
/**
* 比如,将pnpm run clean 拆分为
* cmd => pnpm
* args => run clear
*/
const [cmd, ...args] = command.split(' ')
// consola.info(`run: ${chalk.green(`${cmd} ${args.join(' ')}`)}`)
// 创建node子进程,启动一个子进程来执行命令
/**
* command:要执行的命令
* args:字符串参数列表
*/
const app = spawn(cmd, args, {
cwd,// 子进程的当前工作目录。
stdio: 'inherit',// 子进程的 stdio 配置
shell: process.platform === 'win32',// 如果为 true,则在一个 shell 中运行 command
})

const onProcessExit = () => app.kill('SIGHUP')

app.on('close', (code) => {
process.removeListener('exit', onProcessExit)

if (code === 0) resolve()
else
reject(
new Error(`Command failed. \n Command: ${command} \n Code: ${code}`)
)
})
process.on('exit', onProcessExit)
})
}

其中第一个执行任务名为buildModules,其对应的函数在下方通过export * from “./tasks/build-modules.js”进行导出,然后再通过自定义的run函数执行。

实际上,这里当我们执行”pnpm run build buildModules”时候,这里会先执行到根目录的package.json中的build

1
2
3
4
5
6
7
8
9
"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"
}

然后将参数拼接为gulp -f scripts/gulpfile.js buildModules ,这样就执行到了buildModules这个方法。

commonjs 与 es module格式

这里的parallel定义的第一个打包命令是打包分包,即打包commonjs 与 es module格式的代码,来看一下具体实现,代码在tasks/build-modules.js中

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
import { PACKAGE } from '../constant/index.js'
import { rollup } from 'rollup'
import glob from 'fast-glob'
import { nodeResolve } from '@rollup/plugin-node-resolve' // 解析@ht-ui/utils转换为相对路径
import VueMacros from 'unplugin-vue-macros/rollup'
import Vue from '@vitejs/plugin-vue'
import esbuild from 'rollup-plugin-esbuild'
import commonjs from '@rollup/plugin-commonjs'
import { excludeFiles } from '../utils/helper.js'
import { buildConfig } from '../config.js'
import { HtPlusAlias } from '../utils/helper.js'
import { generateExternal } from '../utils/helper.js'


export const buildModules = async () => {
const input = excludeFiles(await glob('**/*.{js,ts,vue}', {
cwd: PACKAGE,
absolute: true,
onlyFiles: true,
}))
const bundle = await rollup({
input,
plugins: [
// 解析样式路径
HtPlusAlias(),
VueMacros({
setupComponent: false,
setupSFC: false,
plugins: {
vue: Vue({
isProduction: false,
})
},
}),
nodeResolve({
extensions: ['.mjs', '.js', '.json', '.ts'],
}),
commonjs(),
esbuild({ //解析.vue和.ts
sourceMap: true,
target:'es2018',
loaders: {
'.vue': 'ts',
},
}),
],
external:await generateExternal({full:false}),
treeshake: false
})
const 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. 通过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'
    ...
    ]
  2. 使用rollup传入input参数,同时定义了plugins对input路径中的文件做解析,插件作用依次是
  • HtPlusAlias:自定义解析插件。在文件解析时候,nodeResolve插件会将软连接文件的路径进行转换,比如import { withInstall } from ‘@ht-ui/utils’会转换为import ‘../../utils/index.mjs’。但是对于样式文件中的import ‘@ht-ui/theme-chalk/avatar/index.less’没办法做转换,所以需要自定义的插件来支持
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    export 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',
    }
    },
    }
    }
    作用就是如果匹配到路径中包含theme-chalk,就将路径和文件名后缀进行替换。
  • VueMacros:对Vue3中的宏定义进行编译处理,比如定义的组件名称
    1
    2
    3
    defineOptions({
    name: "HtAvatar",
    });
  • nodeResolve:解析@ht-ui/utils转换为相对路径,会解析导入的模块路径,并查找其对应的实际文件路径。
  • commonjs:将 commonjs 模块转成 es6 模块
  • esbuild:解析 .vue文件 和 .ts 文件,并将其编译为 ES6 模块格式
  1. 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
    16
    export 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}/`)
    )
    }
    }
  2. 最后就是将处理好的文件通过调用.write方法,生成文件输出,其中配置preserveModules和preserveModulesRoot搭配使用,可以输出打包文件时候还保持原来的目录文件结构
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    const 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))
    );
    其中buildConfig如下,分别生成esm和cjs格式
    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
    import 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",
    },
    },
    };

    全量组件打包

    全量打包和上面的打包逻辑总体差不多,有一点小的区别,主要区别在于
  3. 只有一个入口,分包打包是具有多个入口的input数组
  4. 打包格式为umd和esm
    所以,针对以上规则,代码实现为
  5. 生成index.js入口文件,通过遍历文件夹生成
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    const 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)
    }
    最终结果为index.ts
    1
    2
    3
    4
    5
    6
    // 此文件是自动生成,无需改动
    export * from './avatar';
    export * from './icon';
    export * from './image';
    export * from './nav-bar';
    export * from './share';
  6. 修改打包入口为上面生成的index.ts文件
    1
    2
    3
    const bundle = await rollup({
    input: path.resolve(HT_UI, "index.ts"), // 打包入口
    })
  7. 这里这种包为了减小体积一般要采用压缩
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    import 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
    16
    const 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全量倒入
    },
    ];

    样式打包

    样式打包和上面的都略有不同,可以从命令看起。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    export 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)
    ),
    )
    ),
    ...
    )
    这里执行的命令为pnpm run -C packages/theme-chalk build。命令中-C表示,进入到packages/theme-chalk目录下执行build命令,上面已经说过目录结构从0构建前端UI组件库
    来看一下package.json中的build命令
    1
    2
    3
    4
    "scripts": {
    "clean": "rm -rf dist",
    "build": "gulp"
    }
    这里同样执行的是gulpfile.js文件,这里打包逻辑如下
  8. 通过gulp.src遍历当前目录下所有的.less文件,然后交给gulp-less插件和gulp-postcss来处理
  9. 通过cleanCSS压缩css,然后输出压缩前后的对比信息
  10. 通过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
    36
     import 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
    8
    export default series(
    ...
    parallel(
    ...
    ...
    ),
    parallel(genTypes, copyFiles)
    )
    其中主要实现为
  11. 引入ts-morph中的Project类,用于解析 TypeScript 项目并生成 AST(抽象语法树),传入配置
  12. 从项目根目录遍历后缀为.js、.jsx、.ts、.tsx、vue文件
  13. 将遍历到包含后缀为.vue文件的,读取文件内容,通过vue/compiler-sfc的vueCompiler.parse方法将文件内容解析为对象,在判断是否有写script脚本,接着,如果之前代码中有写过@ts-nocheck的话,那么,这里会把这个加上注释去除掉,再将 scriptSetup 的内容编译并加入 script 内容后重新生成一个 .js 或 .ts 文件,并将其作为 SourceFile 加入 project。这样,在后续处理 TypeScript 代码时,这些新生成的 .js 或 .ts 文件就会被当做普通的 TypeScript 源代码来处理,最后将文件保存在sourceFiles中
  14. 随后,通过project.emit方法配置,只输出类型定义文件
  15. 遍历sourceFiles,输出.d.ts文件然后做路径的重写

生成package.json

这里在packages新创建一个ht-ui目录,然后里面存放一个package.json,当我们完成打包任务,就把这个文件拷贝到打包之后的dist目录下即可。

1
2
3
4
5
6
7
8
9
// 拷贝package.json和README.md文件
export const copyFiles = () =>
Promise.all([
copyFile(epPackage, path.join(OUTPUT_DIR, 'package.json')),
copyFile(
path.resolve(projRoot, 'README.md'),
path.resolve(OUTPUT_DIR, 'README.md')
)
])

文档开发

因为组件库为移动端组件,所以文档这里想实现的效果为左侧为文档,右侧为移动端组件预览,最终效果为
images
首先在根目录下新建一个site文件夹,主要实现为:

  1. site文件夹是一个单独独立运行的项目,使用vite作为构建工具,其中目录结构为
    images
  • vite.config.ts为项目启动配置文件
  • docs:PC端的文档
  • mobile:移动端站点
  • index.html:PC入口文件,加载了docs/main.ts
  • mobile.html:移动端入口文件,加载了mobile/main.ts
  1. 在执行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
    23
    const 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()
    ]
    });
  2. 移动端主要是需要在docs/App.vue中通过iframe来引入,地址需要手动拼接,拼接出来格式路径为xxxxx/mobile.html#/avatar,然后在监听router.currentRoute事件,路由变化重新拼接
    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();
    });
    和docs/router获取路由有一点差别,就是这里需要获取组件目录下的demo/index.vue
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    const 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()
    });
  3. 解析README.md为DOM,因为我们的组件文档都是用markdown写的,所以需要将.md转换。这里主要在vite.config.ts中处理
    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
    import 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-plugin-md插件将.md转换为HTML,然后对转换后的样式增加了一些class类,然后通过markdownCardWrapper函数调整转换后的样式,配置了支持代码高亮等。

    文档打包

    打包这里只需要在vite.config.ts中增加build配置,同时指定入口为多个即可,然后输出到当前路径下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    export 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
这里发布以实例级别为例子。

本地发布

本地发布比较简单:

  1. 本地执行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
    images

  • _authToken:身份认证信息,可以在根群组目录下(比如frontend),点击左侧settings-repository,找到Deploy tokens去设置
    都配置好之后,就可以执行npm publish ./dist发布包了

CI/CD 发布

这里主要以CI/CD时候发布为例子,主要逻辑流程包括

  • 这里主要配置gitlab-ci.yml文件
    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
    stages:
    - 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
    主要逻辑是定义两个stage,第一个作用是仅仅在main分支可以触发,脚本script为
  • 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
      4
      image: node:latest

      variables:
      GITLAB_TOKEN: "$GL_TOKEN"
      这样就可以实现自动发布了
      images
  • build-docs阶段
    • 和上面的命令都差不多,主要是在最后,因为组件库文档打包之后在site/site目录下,所以通过mkdir public && cp -rf site/site/* public 将打包资源移动到了根目录下的public文件夹
    • 有了public,我们可以在Settings-Pages看到部署的文档
      images
      images

组件安装

当组件发布成功之后,可以进入到根群组目录下,点击左侧Package Registry,找到想要安装的包
images
在要安装的项目目录下执行上面红色框的代码,会生成一个.npmrc文件,随后写入

1
//code.hellotalk.com/api/v4/packages/npm/:_authToken = frontend-Repository Settings-Deploy tokens设置一个token

完成以上内容后,就可以安装组件库或者其他发布的包了,这里是一个scope下发布了多个包,可以用@frontend/ht-ui、@frontend/ht-tool安装不同的包
images

参与组件开发

  1. 新建开发分支

  2. 在packages/components下增加组件

  3. 完善__tests__单元测试

  4. 完善demo目录下的文件,用作组件库文档的移动端展示

  5. packages/components/src 下面一个是组件的源文件,另外一个是类型定义文件,即props,emit定义等

  6. style是样式文件,在组件目录下的index.ts写即可,注意这里是引入了 import ‘@ht-ui/theme-chalk/组件名/index.less’,组件的实际样式存放在packages/theme-chalk/组件名/index.less

  7. packages/components/index.ts是自动生成,无需修改,打包时候自动引入目录下的组件

  8. 在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

  9. 文档在site目录下,docs是整个文档,mobile是文档的右侧移动端展示,config.json在每次新增组件之后需要做修改才能看到文档的导航

  10. playground是普通的调试目录,可以自己随意调试组件

  11. 提交合并的主分支的merge request

    单元测试

  12. 组件可以正常渲染

  13. 事件可以正常触发

  14. 具体可以参考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

    后续开发计划

  • 自动生成文档库的配置目录
  • 接入SonarQube
  • 优化.gitlab-ci.yml配置
  • 打包成功通知到飞书
  • 组件打包体积大小优化

    其他

  • 组件库版本号不可以重复,否则会发布失败
  • 任何token不可以明文出现在代码中,请用环境变量替代