介绍
青训营大项目:
- 基于 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
 47- import { 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
 57- const 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
 17- import 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
 26- interface 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
 5- const 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
 24- watch( // 监听传入每页数量参数时,赋值给 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
 51- interface 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
 11- const 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
 74- watch( // 监听传入高度、数据源参数变化时,更新表格滚动宽度 
 () => [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 }
 )