# 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 }) ```