介绍
青训营大项目:
- 基于 NextJS 开发仿掘金站点
- 组件库
- 关于新冠疫情的数据可视化作品
我们团队做的是组件库项目。
项目需求
虽然业界已经有非常多知名组件库(antd/iview/material design 等),但实际工作中各团队通常也会应设计规范要求,自行开发属于团队内部的基础/业务组件库,对于高阶前端,开发一个属于自己的组件库已经是一种普遍但重要的基本技能。
实际开发中有许多需要考虑的细节:
使用何种语言开发组件库?
- ts/es6 + babel + flow
- less/sass/stylus/postcss/atomic-css
Sass/Less | Atomic CSS | css-in-js | |
---|---|---|---|
完全支持样式覆盖 | ✅ | ✅ | ❗(需要使用className支持) |
支持rem布局 | ✅ | ✅ | ❗(大部分库支持) |
可读性 | 强 | 稍弱 | 强 |
上手成本 | 低 | 中 | 高 |
是否支持SSR | 天然支持 | 天然支持 | 需要额外支持 |
是否支持流式渲染 | 天然支持 | 天然支持 | 需要额外支持 |
支持postcss | ✅ | ✅ | ❗(有自己的plugin生态) |
如何保证组件库质量(工程化)?
单测、e2e 测试
- 组件库的质量保障从流程上来说,主要是 code review 和严格的 UI 验收、QA 测试等流程。从技术层面来说可以收敛发包权限,以及在 CI/CD 中实现自动发包,杜绝研发过程中在非 master 分支上随意发包的危险操作。还有单元测试、快照测试、e2e 测试等常用的技术手段。
尽可能接入测试工具,包括:
- jest、chai、enzyme、karma
- @testing-library/react
- benchmark
- lint、lint-staged、prettier、style-lint 等。
制定规范的目的在于保证质量、方便业务方使用和增加组件库的可扩展性。比如上文提到的对于样式的封装、常用 mixin 封装,强制使用颜色变量等。还有设计统一的组件库 API 风格规范,能降低业务方的使用成本。
拆解来看:
- 代码提交:
- husky
- commitlint
- lint-staged
- 代码风格:
- eslint + prettier
- stylelint
- commit-lint
- 文档风格:
- remark-lint
- 组件模板:
- plop.js
- 依赖管理:
- lint-deps
- 目录规范
如何编写文档站?
组件库一般有一个演示站点。
- 对于移动端组件库,可以通过 webpack 别名的方法重写它们的组件,以支持移动端预览,方便 UI 验收。
- 对于国际化的组件,可以提供类似 vconsole 形式的 devtools,可视化切换 dark/light Mode、rtl/lrt 等能力,提高开发和测试流程中的效率。
常见的文档站技术选型:
- docz:一个非常成熟的 md 文档站工具,同样支持嵌入 react 组件
- dumi:还能支持组件调试
- changelog
- Github Pages
- vitepress
- storybook
- remark
- docsearch
核心需求
- 通用型组件: 比如 Button, Icon 等
- 布局型组件: 比如 Grid, Layout 布局等
- 导航型组件: 比如面包屑 Breadcrumb, 下拉菜单 Dropdown, 菜单 Menu 等
- 数据录入型组件: 比如 form 表单, Switch 开关, Upload 文件上传,日期选择,下拉选择等
- 数据展示型组件: 比如 Avator 头像, Table 表格, List 列表等
- 反馈型组件: 比如 Progress 进度条, Drawer 抽屉, Modal 对话框等
具体效果,可参考 antd。
技术选用
核心技术栈
- ts +vue3
- sass
- vite
测试工具
- vitest
规范工具
- 代码提交规范:husky(提交时自动检查)+ commitlint(提交信息样式检查)
- 代码风格:eslint(语法)+ prettier(格式)+ husky(提交时自动检查)
文档工具
- vitepress
包管理工具
- pnpm
项目结构
1 | . |
组件目录结构
以 button 组件为例:
1 | ├── src # 组件代码 |
- 包名:小写 + 中划线;
- 统一入口文件: index.ts;
- 组件代码: 大驼峰;
- 测试用例代码 : 测试对象名+ .spec.ts。
以 button 组件为例子的测试:
- 定义测试文件
src/button/__tests__/Button.test.ts
。
1. 在测试文件中创建一个 `describe` 分组。
2. 在第一个参数中输入 `'Button'`,表明是针对 `Button` 组件的测试。
3. 编写测试用例 `test`。
4. 使用 `shallowMount` 初始化组件,测试按钮是否工作正常,只需要断言判断按钮中的字符串是否正确就可以了。
- 配置 package.json。
- 在控制台启动测试命令,并查看结果。
1
pnpm test
在代码编写阶段,建议只对重点功能进行测试,没必要一定追求过高的测试覆盖率。
monorepo
pnpm 原生支持 monorepo 方案:
- pnpm-workspace.yaml 定义了工作空间的根目录。
- 如果我们要单独对某一个项目下安装一些包时,可以到该项目目录下安装,或者在根目录下使用
pnpm i 包 --filter 项目名
。 - 当我们要在根目录下安装某些包,需要加上
-w
后缀。
1 | # pnpm-workspace.yaml |
git flow
- 代码提交形式为:
type: 提交信息
。
- 主要有以下分支:
main
分支:跟线上发布版本保持一致develop
分支:开发的主分支,保证最新的代码- 其他分支:完成一个新的功能时用到的分支
- 需要注意的是:
main
分支不要动!- 当功能完成之后需要合并时,合并到
develop
分支,注意合并之前先拉取develop
分支,合并时建议多使用rebase
指令,保证提交记录的简洁。
文档
在
packages/components/index.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
47import { App } from 'vue'
import DateTimePicker from './DatePicker/DateTimePicker/DateTimePicker.vue'
import Input from './input/Input.vue'
import Pagination from './Pagination/index'
import WvTable from './Table/index'
import WvRadio from './Table/radio'
import Button from "./Button/src/Button.vue";
import Col from './Layout/src/col.vue';
import Row from './Layout/src/row.vue'
import Notification from './notification/Notification.vue'
import { Dropdown, DropdownItem, DropdownMenu } from './Dropdown'
import WVcarousel from './carousel/WV-carousel.vue'
// 导出单独组件
export {
DateTimePicker,
Input,
Pagination,
Button,
Col,
Row,
Notification,
WvTable,
WvRadio,
Dropdown,
DropdownMenu,
DropdownItem
}
// 编写一个插件,实现一个install方法
export default {
install(app: App): void {
app.component('DateTimePicker', DateTimePicker);
app.component('Input', Input);
app.component('Pagination', Pagination);
app.component('WvTable', WvTable)
app.component('WvRadio', WvRadio)
app.component('WButton', Button);
app.component('WRow', Row);
app.component('WCol', Col);
app.component(Notification.name, Notification)
app.component('WDropdown', Dropdown)
app.component('WDropdownMenu', DropdownMenu)
app.component('WDropdownItem', DropdownItem)
app.component("WVcarousel", WVcarousel);
},
}配置
docs/.vitepress/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
49
50
51
52
53
54
55
56
57const sidebar = {
'/': [
{ text: '快速开始', link: '/' },
{
text: '通用',
children: [
{ text: 'Button 按钮', link: '/components/Button/' },
{ text: 'Layout 布局', link: '/components/Layout/' },
]
},
{
text: 'Form表单组件',
children: [
{ text: 'DateTimePicker 日期选择器', link: '/components/DateTimePicker/' },
{ text: 'Input 输入框', link: '/components/Input/' },
]
},
{
text: 'Data 数据展示',
children: [
{ text: 'Table 表格', link: '/components/Table/' },
{ text: 'Pagination 分页', link: '/components/Pagination/' }
]
},
{
text: 'Feedback 反馈组件',
children: [
{ text: 'Notification 通知', link: '/components/Notification/' }
]
},
{
text: 'Navigation 导航',
children: [
{ text: 'Dropdown 下拉菜单', link: '/components/Dropdown/' }
]
},
{
text: 'Others 其他',
children: [
{ text: 'carousel 轮播图', link: '/components/carousel/' }
]
},
]
}
export default {
title: "🔨 weView",
themeConfig: {
sidebar,
},
markdown: {
config: (md) => {
const { demoBlockPlugin } = require('vitepress-theme-demoblock')
md.use(demoBlockPlugin)
}
}
}新建主题配置文件
docs/.vitepress/theme/index.ts
,导入默认主题、已安装主题、在packages/components/index.ts
中导出的组件和需要的字体样式文件。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17import DefaultTheme from 'vitepress/theme'
// 主题样式
import 'vitepress-theme-demoblock/theme/styles/index.css'
// 插件的组件,主要是demo组件
import Demo from 'vitepress-theme-demoblock/components/Demo.vue'
import DemoBlock from 'vitepress-theme-demoblock/components/DemoBlock.vue'
import weView from '../../../packages/components/index.ts'
import '../../../packages/fonts/iconfont.css'
export default {
...DefaultTheme,
enhanceApp({ app }) {
app.use(weView)
app.component('Demo', Demo)
app.component('DemoBlock', DemoBlock)
},
}在
docs/components/
下面新建组件名称文件夹,名字为link
属性值对应路径/components/
后的单词。
- 在组件名称文件夹中新建
index.md
文件,可以在里面引入和使用外部 Vue 组件。例如:会渲染出以下内容:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20:::demo 基础使用
```vue
<template>
<Input v-model="inputText2" placeholder="请输入..." clearable/>
</template>
<script>
import { ref } from 'vue'
export default {
setup() {
const inputText2 = ref('')
return {
inputText2
}
},
}
</script>
\```
:::
分页和表格
因为我负责的是 Pagination
和 Table
组件,但 Ant Design Vue 和 Element Plus 的表格组件都太复杂了,所以我参考的是较简单的 layui - vue。
Pagination
HTML 结构
1 | <template> |
最外层是分页组件容器(
div.pager
),内容从左到右为:总条数和总页数文本(span
)、上一页链接(a
)、页码(template
>span
>em
)、下一页链接(a
)、每页数量选择框(span
>select
)、刷新按钮图标(a
>i
)、跳转文本、跳转输入框(input
)、跳转确定按钮(button
)。有以下插槽:
插槽 描述 默认值 prev
上一页 上一页 next
下一页 下一页
JS 代码
props
,有以下属性和默认值: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
26interface PageProps {
total: number
limit: number
theme?: string
showPage?: boolean
showSkip?: boolean
showCount?: boolean
showLimit?: boolean
showRefresh?: boolean
pages?: number
limits?: number[]
modelValue?: number
}
const props = withDefaults(defineProps < PageProps > (), {
limit: 10, // 每页数量
pages: 10, // 页码链接最大数量
modelValue: 1, // 当前页
theme: 'blue', // 主题色
showPage: false, // 显示页码
showSkip: false, // 显示跳转
showCount: false, // 显示总数
showLimit: true, // 显示每页数量选择框
showRefresh: false, // 显示刷新按钮图标
limits: () => [10, 20, 30, 40, 50], // 每页数量选择框的选项
})emits
,有以下事件:1
2
3
4
5const emit = defineEmits([
'update:modelValue', // 当前页变化
'update:limit', // 每页数量选择框的选项变化
'change', // 当前页和每页数量选择框的选项变化
])watch
,有以下在数据更改时调用的侦听回调:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24watch( // 监听传入每页数量参数时,赋值给 inlimit
() => props.limit,
() => {
inlimit.value = props.limit
}
)
watch(inlimit, () => { // 监听每页数量选择框的选项变化时,触发事件
emit('update:limit', inlimit.value)
})
watch(currentPage, () => { // 监听当前页码变化时,更新 currentPage、currentPageShow 的值,触发事件
const min = totalPage.value[0]
const max = totalPage.value[totalPage.value.length - 1]
if (currentPage.value > max) currentPage.value = max
if (currentPage.value < min) currentPage.value = min
currentPageShow.value = currentPage.value
emit('update:modelValue', currentPage.value)
})
watch( // 监听传入当前页码参数时,更新 currentPage、currentPageShow 的值
() => props.modelValue,
() => {
currentPage.value = props.modelValue
currentPageShow.value = currentPage.value
}
)
Table
HTML 结构
1 | <template> |
最外层是表格组件容器(
div.wv-table-view
),内容从上到下为:工具栏(div.wv-table-tool
)、header插槽(div.wv-table-box-header
)、表格(div.wv-table-box
)、分页(div.wv-table-page
)。有以下插槽:
插槽 描述 参数 toolbar
自定义工具栏 – header
顶部扩展 – footer
底部扩展 – column.titleSlot
列标题 expand
嵌套面板 { row }
customSlot
自定义列插槽 { row,rowIndex,column,columnIndex }
JS 代码
props
,有以下属性和默认值: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
51interface TableProps {
id?: string
skin?: string
size?: string
page?: Recordable
columns: Recordable[]
dataSource: Recordable[]
defaultToolbar?: boolean | any[]
selectedKey?: string
selectedKeys?: Recordable[]
indentSize?: number
childrenColumnName?: string
height?: number
maxHeight?: string
even?: boolean
expandIndex?: number
rowClassName?: string | Function
cellClassName?: string | Function
rowStyle?: string | Function
cellStyle?: string | Function
spanMethod?: Function
defaultExpandAll?: boolean
expandKeys?: Recordable[]
loading?: boolean
getCheckboxProps?: Function
getRadioProps?: Function
}
const props = withDefaults(defineProps<TableProps>(), {
id: 'id', // 主键
size: 'md', // 尺寸
indentSize: 30, // 树表行级缩进
childrenColumnName: 'children', // 树节点字段
dataSource: () => [], // 数据源
selectedKeys: () => [], // 选中项 (多选)
defaultToolbar: false, // 工具栏
selectedKey: '', // 选中项 (单选)
maxHeight: 'auto', // 表格最大高度
even: false, // 斑马条纹
rowClassName: '', // 行类名称
cellClassName: '', // 列类名称
expandIndex: 0, // 展开所在列
rowStyle: '', // 行样式
cellStyle: '', // 列样式
defaultExpandAll: false, // 默认展开所有列
spanMethod: () => {}, // 合并算法
expandKeys: () => [], // 展开的列
loading: false, // 加载动画
getCheckboxProps: () => {}, // 多选行属性
getRadioProps: () => {}, // 单选行属性
})emits
,有以下事件:1
2
3
4
5
6
7
8
9
10
11const emit = defineEmits([
'update:current', // 当前页变化
'update:limit', // 每页数量选择框的选项变化
'change', // 当前页和每页数量选择框的选项变化
'update:expandKeys', // 展开树节点
'update:selectedKeys', // 多选行选中
'update:selectedKey', // 单选行选中
'row-contextmenu', // 行右击
'row-double', // 行双击
'row', // 行单击
])watch
,有以下在数据更改时调用的侦听回调: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
74watch( // 监听传入高度、数据源参数变化时,更新表格滚动宽度
() => [props.height, props.maxHeight, props.dataSource],
() => {
nextTick(() => {
getScrollWidth()
})
}
)
watch( // 监听 columns 变化时,计算列内容
tableColumns,
() => {
tableColumnKeys.value = []
tableBodyColumns.value = []
tableHeadColumns.value = []
findFindNode(tableColumns.value)
findFindNodes(tableColumns.value)
findFinalNode(0, tableColumns.value)
},
{ immediate: true }
)
watch( // 监听传入多选行选中参数变化时,更新 tableSelectedKeys
() => props.selectedKeys,
() => {
tableSelectedKeys.value = props.selectedKeys
},
{ deep: true }
)
watch( // 监听传入树节点展开参数变化时,更新 tableExpandKeys
() => props.expandKeys,
() => {
tableExpandKeys.value = props.expandKeys
},
{ deep: true }
)
watch( // 监听传入数据源参数变化时,更新 tableDataSource、tableSelectedKeys、tableSelectedKey
() => props.dataSource,
() => {
tableDataSource.value = [...props.dataSource]
tableSelectedKeys.value = []
tableSelectedKey.value = s
},
{ deep: true }
)
watch( // 监听多选行选中变化时,更新 allChecked、hasChecked(图标改变),触发事件
tableSelectedKeys,
() => {
if (tableSelectedKeys.value.length === props.dataSource.length) {
allChecked.value = true
} else {
allChecked.value = false
}
if (tableSelectedKeys.value.length > 0) {
hasChecked.value = true
} else {
hasChecked.value = false
}
emit('update:selectedKeys', tableSelectedKeys.value)
},
{ deep: true, immediate: true }
)
watch( // 监听展开树节点时,触发事件
tableExpandKeys,
() => {
emit('update:expandKeys', tableExpandKeys.value)
},
{ deep: true, immediate: true }
)