# v-element
**Repository Path**: ChaoYuanQiZero/v-element
## Basic Information
- **Project Name**: v-element
- **Description**: Vite + Vue3 + Typescript 仿ElementPlus组件库
- **Primary Language**: JavaScript
- **License**: Not specified
- **Default Branch**: main
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 1
- **Forks**: 0
- **Created**: 2023-12-02
- **Last Updated**: 2024-03-17
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# v-element
## 组件
### Button
#### **组件编码**
- props 的定义方式
- button部分的原生属性
- **defineExpose** 定义实例导出
#### **样式解决方案**
- 选择 PostCSS 作为预处理器
- 使用 CSS 变量添加颜色系统
- 添加Button 样式
- 善用变量覆盖
- 使用 PostCSS 插件
- 使用 PostCSS 动态生成主题颜色
#### PostCSS 依赖安装以及配置
> 插件按需加载
```bash
npm i postcss-color-mix postcss-each postcss-each-variables postcss-for postcss-nested --save-dev
```
#### 配置
> 注意插件顺序
```js
/* eslint-env node */
module.exports = {
plugins: [
require('postcss-each-variables'),
require('postcss-nested'),
require('postcss-each')({
plugins: {
beforeEach: [require('postcss-for'), require('postcss-color-mix')]
}
})
]
}
```
### Collapse
#### 需求分析
- Collapse & Collapse Item 组成(语义化)
- Item 负责标题以及内容(slot支持复杂内容展示)
- 支持 v-model
- 支持 accordion手风琴模式
#### 确定方案
- Slot组件利用Provide/Inject通信传递给子组件
- 属性以及方法统一定义在父组件
```vue
// Collapse.vue
// CollapseItem.vue
```
- 组件的v-model实现原理
- 属性 modelValue
- 事件@update:model-value的语法糖
- watch 监听一个响应式对象变化
```vue
```
> [!NOTE] > `v-model` 可以在组件上使用以实现双向绑定。
> 从 Vue 3.4 开始,推荐的实现方式是使用 defineModel() 宏:
https://cn.vuejs.org/guide/components/v-model.html#component-v-model
```vue
```
- 内置Transition组件实现动画交互
javaScript 钩子函数支持自定功能(解决height不固定无法使用动画)
```vue
```
### Icon
#### 确定方案
- [安装图标库Fontawesome](!https://fontawesome.com/docs/web/use-with/vue/)
- 二次开发组件
#### 安装Fontawesome依赖
```bash
# 1. Add SVG Core
npm i --save @fortawesome/fontawesome-svg-core
# 2. Add Icon Packages
npm i --save @fortawesome/free-solid-svg-icons
npm i --save @fortawesome/free-regular-svg-icons
npm i --save @fortawesome/free-brands-svg-icons
# 3. Add the Vue Component
# for Vue 3.x
npm i --save @fortawesome/vue-fontawesome@latest-3
```
#### 添加Fontawesome Icon组件
```ts
/* Set up using Vue 3 */
import { createApp } from 'vue'
import App from './App.vue'
/* import the fontawesome core */
import { library } from '@fortawesome/fontawesome-svg-core'
/* import font awesome icon component */
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
/* import specific icons */
import { faUserSecret } from '@fortawesome/free-solid-svg-icons'
/* add icons to the library */
library.add(faUserSecret)
createApp(App).component('font-awesome-icon', FontAwesomeIcon).mount('#app')
```
#### 二次封装组件
- inheritAttrs: false 不继承属性
- 使用 $props 访问所有属性
- 要注意不继承以后一些默认属性失效的问题 $attrs
- 添加 type/color 属性
- 过滤传递的属性 lodash omit
### Alert
- $slots 检测是否存在插槽
### Tooltip
- 安装 popper.js :https://popper.js.org/
```bash
npm i @popperjs/core
```
#### 需求分析
- 功能区
- 触发区
- 展示区
- 触发方式
- hover
- click
- 手动
#### 开发计划
- 最基本的实现
- 支持 click/hover 两种触发方式
- 支持 clickoutside 的时候隐藏
- 支持手动触发
- 支持 popper 参数
- 动画
- 支持延迟显示
- 样式
事件绑定v-on
```tsx
;
const events = reactive({
click: trigger,
mouseenter: open,
mouseleave: close
})
const trigger = () => {
isOpen.value = !isOpen.value
emits('visible-change', isOpen.value)
}
const open = () => {
isOpen.value = true
}
const close = () => {
isOpen.value = false
}
```
支持 clickoutside 的时候隐藏 ,重&难点判断鼠标是否点击到元素外部区域
Node.contains(): https://developer.mozilla.org/zh-CN/docs/Web/API/Node/contains
```ts
document.addEventListener('click', (e: MouseEvent) => {
if (popperContainerNode.value && e.target) {
if (!popperContainerNode.value.contains(e.target as HTMLElement)) {
if (props.trigger === 'click' && isOpen.value) {
close()
}
}
}
})
```
### Dropdown
- 使用 javascript 数据结构
- 在 vue 单文件组件 template 中渲染 Vnode 的方法
- **使用 jsx 编写组件**
通用组件渲染Vnode:https://cn.vuejs.org/api/general.html#definecomponent
```ts
// src/components/Common/ RenderVnode.ts
import { defineComponent } from 'vue'
const RenderVnode = defineComponent({
props: {
vNode: {
type: [String, Object],
required: true
}
},
setup(props) {
return () => props.vNode
}
})
export default RenderVnode
```
#### 动态&静态Class
```tsx
```
#### 渲染&传递插槽
在渲染函数中,可以通过 [this.$slots](https://cn.vuejs.org/api/component-instance.html#slots) 来访问插槽:
https://cn.vuejs.org/guide/extras/render-function.html#passing-slots
```tsx
export default {
props: ['message'],
render() {
return [
//
h('div', this.$slots.default()),
//
h(
'div',
this.$slots.footer({
text: this.message
})
)
]
}
}
// 单个默认插槽
h(MyComponent, () => 'hello')
// 具名插槽
// 注意 `null` 是必需的
// 以避免 slot 对象被当成 prop 处理
h(MyComponent, null, {
default: () => 'default slot',
foo: () => h('div', 'foo'),
bar: () => [h('span', 'one'), h('span', 'two')]
})
```
#### PropType
用于在用运行时 props 声明时给一个 prop 标注更复杂的类型定义
https://cn.vuejs.org/guide/typescript/options-api.html#typing-component-props
```tsx
import { defineComponent } from 'vue'
import type { PropType } from 'vue'
interface Book {
title: string
author: string
year: number
}
export default defineComponent({
props: {
book: {
// 提供相对 `Object` 更确定的类型
type: Object as PropType,
required: true
},
// 也可以标记函数
callback: Function as PropType<(id: number) => void>
},
mounted() {
this.book.title // string
this.book.year // number
// TS Error: argument of type 'string' is not
// assignable to parameter of type 'number'
this.callback?.('123')
}
})
```
### Message
- 将组件 Render 到 DOM 节点上
- 销毁组件实例
- 根据上一个Message实例的位置定位最新Message实例的位置
#### Render 到 DOM 节点&清除
```ts
// 一个 vue 内部神奇的函数,文档中都没有特别的记录
// 它负责将一个 vnode 渲染到 dom 节点上
// 它是一个很轻量级的解决方案
import { render } from 'vue'
const container = document.createElement('div')
const vnode = h(MessageConstructor, props)
render(vnode, container)
document.body.appendChild(container.firstElementChild!)
```
- 销毁组件实例
```ts
render(null, DOM节点)
```
- 组件动态构造并且传入属性
```ts
const newProps = {
...props,
id,
onDestory: destory,
zIndex
}
const vnode = h(MessageConstructor, newProps)
```
- 计算偏移量
- top:lastBottomOffset(上一个实例留下的底部的偏移)+ Offset
- 为下一个实例预留 bottomOffset:top + height
- messageRef.value!.getBoundingClientRect).height
- 使用 defineExpose 暴露
- 在函数中获取这个偏移量
- vnode.component - Componentlnternallnstance 组件内部实例
- 在组件内可以使用 getCurrentInstance()获取
- 在函数中使用 vnode.component.exposed.bottomOffset.value 获得
#### Omit
可以忽略某个类型的某些属性
https://www.typescriptlang.org/docs/handbook/utility-types.html
```ts
export interface MessageProps {
message?: string | VNode
duration?: number
showClose?: boolean
type?: 'success' | 'info' | 'warning' | 'error'
onDestory: () => void
}
export type CreateMessageProps = Omit
```
#### [函数体](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Functions/Arrow_functions#函数体)
箭头函数既可以使用*表达式体*(expression body),也可以使用通常的*块体*(block body)。
在表达式体中,只需指定一个表达式,它将成为隐式返回值。在块体中,必须使用显式的 `return` 语句。
```ts
const func = (x) => x * x
// 表达式体语法,隐含返回值
const func2 = (x, y) => {
return x + y
}
// 块体语法,需要明确返回值
```
使用表达式体语法 `(params) => { object: literal }` 返回对象字面量时,不能按预期工作。
这是因为只有当箭头后面的标记不是左括号时,JavaScript 才会将箭头函数视为表达式体,因此括号({})内的代码会被解析为一系列语句,其中 `foo` 是[标签](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/label),而不是对象文字中的键。
要解决这个问题,可以用括号将对象字面量包装起来:
```ts
const func = () => ({ foo: 1 })
```
#### shallowReactive
- 和 `reactive()` 不同,这里没有深层级的转换:一个浅层响应式对象里只有根级别的属性是响应式的。属性的值会被原样存储和暴露,这也意味着值为 ref 的属性**不会**被自动解包了。
- 假如是数组的话,创建一个浅层响应式的空数组,这意味着数组的元素不会被递归地转换成响应式对象。当我们对数组进行一些增删改操作时,Vue 会自动检测到这些变化,并更新对应的视图。
- 避免数组的大开销。
```ts
const instances: MessageContext[] = shallowReactive([])
```
### Form
#### 需求设计
- UI
- 整体可以自定义
- 表单元素可以自定义渲染
- 用户可以自定义提交区域内容
- 验证
- 验证规则
- 验证时机 (trigger的触发方式与Input/Select等组件联动)
#### 组件结构
```vue
```
#### 获取验证规则和被验证的值
* provide / inject
* 根据prop获取对应的验证规则和验证的值
* 创建验证
https://github.com/yiminghe/async-validator
**Form.vue**
```ts
import { provide } from 'vue';
import type { FormProps } from './types'
import { formContextKey } from './types'
defineOptions({
name: 'VForm'
})
const props = defineProps()
provide(formContextKey, props)
```
**FormItem.vue**
```ts
import Schema from 'async-validator';
import { inject, computed } from 'vue';
import { isNil } from 'lodash-es'
import type { FormItemProps } from './types'
import { formContextKey } from './types'
defineOptions({
name: 'VFormItem'
})
const formContext = inject(formContextKey)
const props = defineProps()
const innerValue = computed(() => {
const model = formContext?.model
if (model && props.prop && !isNil(model[props.prop])) {
return model[props.prop]
} else {
return null
}
})
const itemRules = computed(() => {
const rules = formContext?.rules
if (rules && props.prop && rules[props.prop]) {
return rules[props.prop]
} else {
return []
}
})
const validate = () => {
const modelName = props.prop
if (modelName) {
const validator = new Schema({
[modelName]: itemRules.value
});
validator.validate({ [modelName]: innerValue.value })
.then(() => { console.log('passed') })
.catch((e) => {
console.log(e.errors)
});
}
}
```
#### 自动验证
* 创建`fields: FormItemContext[] = []`的数组
* 添加`addField`,`removeField`的方法
* 通过provide / inject 传递给子组件
* 循环遍历`fields`数组的`validate`方法
* **Promise.allSettled() **
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled
```ts
const validate = async () => {
let validationErrors: ValidateFieldsError = {}
const filedsValidate = fields.map(field => field.validate(''))
const results = await Promise.allSettled(filedsValidate)
results.forEach((result) => {
if (result.status === 'rejected') {
const error = result.reason as FormValidateFailure
validationErrors = {
...validationErrors,
...error.fields
}
}
})
if (Object.keys(validationErrors).length === 0) return true
return Promise.reject(validationErrors)
}
```
## 文档
### Vitepress
- 安装 &初始化
```bash
npm add -D vitepress
npx vitepress init
```
- 运行
```json
//package.json
{
...
"scripts": {
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
},
...
}
```
```bash
npm run docs:dev
//or
npx vitepress dev docs
```
## 组件测试
### Vitest
- Vitest :https://vitest.dev/
- 安装
```bash
npm install -D vitest
```
- 回调测试和模拟第三方库的实现
- callback
- spy
- 第三方库
### 基于 Vue 的测试工具
- vue-test-utils:https://test-utils.vuejs.org/guide/
- 安装
```bash
npm install --save-dev @vue/test-utils
# or
yarn add --dev @vue/test-utils
```
- 运行测试文件
```bash
npm run test Collapse
#or
npx vitest Collapse
```
- Stub 子组件 :https://test-utils.vuejs.org/guide/advanced/stubs-shallow-mount.html
- Render 函数 : https://vuejs.org/guide/extras/render-function.html#basic-usage
```js
slots: {
default: [
h(CollapseItem, {
name: 'a',
title: 'title a',
}, { default: () => 'content a' }),
h(CollapseItem, {
name: 'b',
title: 'title b',
}, { default: () => 'content b' }),
h(CollapseItem, {
name: 'c',
title: 'title c',
disabled: true
}, { default: () => 'content c' })
]
},
```
- isVisible() :https://test-utils.vuejs.org/api/#isVisible
> `isVisible() only works correctly if the wrapper is attached to the DOM using attachTo`
```typescript
describe('Collapse.vue', () => {
beforeAll(() => {
wrapper = mount(Collapse, {
props: {
modelValue: ['a']
},
...
/*
https://test-utils.vuejs.org/api/#attachTo
isVisible() only works correctly if the wrapper is attached to the DOM using attachTo
*/
attachTo: document.body
})
})
test('点击标题展开/收缩内容', async () => {
await headerA.trigger('click')
expect(contentA.isVisible()).toBeFalsy()
await headerB.trigger('click')
expect(contentB.isVisible()).toBeTruthy()
})
```
- emitted
```
┌─────────┬─────────┐
│ (index) │ 0 │
├─────────┼─────────┤
│ 0 │ [] │
│ 1 │ [ 'b' ] │
└─────────┴─────────┘
test('发送emit事件', () => {
const emitted = wrapper.emitted()
expect(emitted).toHaveProperty('update:modelValue')
expect(emitted['update:modelValue']).toHaveLength(2)
expect(emitted['update:modelValue'][0]).toEqual([[]])
expect(emitted['update:modelValue'][1]).toEqual([['b']])
})
```
- JSX/TSX
```tsx
wrapper = mount(()=>
content a
content b
content c
})
```