@dzzi/slim-element
v1.0.0
Published
## 简介
Readme
slim-element
简介
一个仿 element-plus 的组件库。选用的 CSS 解决方案为 PostCSS。
使用的包管理工具为 [email protected]。
配置 PostCSS
由于项目中使用了 each 语法,除了安装 postcss,还需要安装 postcss-each。
pnpm i postcss postcss-each -D然后可以在 vite.config.ts 中添加 postcss 的配置。
由于 postcss 没有 .d.ts 文件,还需要自己手动创建 types/postcss-each.d.ts 文件并添加到 tsconfig.node.json 的 include 中。
CSS 用途
使用 index.css 来作为一个集合导入所有的 css 文件:
- reset.css:清除默认的样式,并添加一些自定义的样式。
- vars.css:定义整个项目需要用到的 CSS 自定义属性(包括但不限于背景颜色,边框颜色,边框宽度等属性)。使用
color-mix结合特定的几个比例混合出一个颜色的几个不同亮度,然后再结合postcss-each对主色和辅助色进行混色。
Icon
安装 fontawesome
基于 FontAwesome 封装 Icon 组件,后续的 Button 组件都需要用到 Icon 组件。
首先安装需要的包。
1. 添加 Vue 组件
pnpm i @fontawesome/vue-fontawesome@latest2. 添加 SVG 核心
pnpm i @fontawesome/fontawesome-svg-core3. 添加图标包
pnpm install @fortawesome/free-solid-svg-icons封装思想
Icon 组件的封装结构是 <i> 标签内部封装一个 <font-awesome-icon>。
通过给 <i> 绑定不同的类名,实现添加不同 type 的样式。在使用 <Icon> 组件的时候,传入自定义的 color。组件内部会通过 compute 计算出一个 style 对象,绑定到 <i> 的 style 属性上,即可实现颜色的自定义。
并且 <Icon> 的参数是继承自 FontAwesomeProps,因此可以直接将 IconProps 的属性使用 omit 过滤一下,直接使用 v-bind 绑定到 <font-awesome-icon> 上。
如何在保留已有组件功能的情况下实现二次封装是我学习完这个组件最大的收获。
测试
测试了能否根据传入的类型自动添加 CSS 类名,以及自定 color 能否生效。
Button
概要
感觉封装上还是比较简单。结构上最外层是一个 button 标签,里面有存放有两个 <SeIcon>,一个用来展示当前是否是 loading 状态,一个用于传入自定义的 icon。还有一个 <span><slot></slot><span> 用来自定义显示内容。然后通过类名控制样式:type 使用的是模板字符串用传入的变量替换。plain, round, circlr, disabled, loadding 都需要外部调用时传入对应的 bool 值。disabled 会受到两个值的影响,如果 disabled 本身为 true 的话,自然不必多说;如果处于 loading 状态,也是需要将按钮置为 diabled 状态的。还有两个属性,定义在 <button> 上的 type 需要外部给 <SeButton> 传入 nativeType。autofocus 需要外部传入布尔值,用来控制是否自动获取焦点。
此外,设置了向外部暴露了 <SeButton> 组件的 <button> 标签,方便用户操作具体的 DOM 节点。
这个组件不论是结构还是行为都比较简单,但是样式相对来讲比较复杂,大体的思路就是先设定好基础的样式,不同的 type 之间需要改动的样式给抽离出来。不同的形状需要改动的样式也抽离出来,形成一个单独的 CSS 类名,然后在应用的时候挂载这个类名。
测试
Button 组件的测试其实比较简单,主要就是看传入的参数有没有转化成对应的类名。比如说传入 type="primary",CSS 类名就应该多一个 se-button--primary。然后 trigger click 之后,emitted 中有没有出现 click。
接下来在测试 disabled button,它的测试过程应该是先 trigger click,然后再去检查 emitted 中是否存在 click 事件。
再之后测试 icon button,也就是在 mount 的时候,给配置对象的 props 属性设定 icon,可以 stubs 掉 <font-awesome-icon>。mount 之后在wrapper 上找 font-awesome-icon 组件,并该段该组件上的 icon 属性是不是我们设定的 icon 属性。
最后测试 loading button,它的测试过程相当于是 icon button 和 disabled button 的结合。既要判断 trigger 不生效,也要出现 icon。
Collapse
Collapse
Collapse 的模板结构非常简单,就是一个 <div> 套一个 <slot>,因为需要填入的 CollapseItem 都靠外部往 Collapse 里面填充内容。
Collapse 需要外部传入两个参数:
- modelValue:这是一个
string[],如果一个Item的 name 存在于这个列表,那么这个 Item 就是打开状态,否则就是关闭状态。同时修改这个列表的时候,应该触发update:model事件。 - accordion:这是一个选填参数,控制手风琴模式是否开启。
同时,Collapse 定义了两个事件:
update:model:这个事件能够使得自定义组件的 v-model 生效。change:表明列表发生变化。
上述这两个事件都应该在一个 item 打开或者关闭的时候触发。
还定义了一个 InjectionKey,用来表明要提供参数的类型。这样,在子组件使用这个 InjectionKey 拿到提供的属性的时候,就能够提示类型。
接着定义了一个响应式的 activeNames,它的 value 就是外部传入的 modelValue。这里之所以要定义成响应式,是为了使用 provide 传递的时候,子组件能够同步的接收到修改,而不是只拿到一个一次性的值。
随后定义了一个 handleItemClick 函数,这个函数的调用者是子组件,子组件调用时需要传入自己这个 item 的 name,然后函数就会根据当前列表是否存在 name 和 accordion 这两个因素关闭或者打开 item。当然,在函数的最后,我们需要触发 update:model 和 change 这两个事件。
最后,使用 provide 传递出 activeNames 和 handleItemClick 两个属性。
CollapseItem
CollapseItem 的参数如下:
- name:每一个 item 的唯一标识,要作为参数传递给 handleItemClick 这个函数。
- title:可选,如果标题比较简单(纯文本),可以使用参数的方式传递,否则可以使用具名插槽 。
- disabled:可选,如果传递了这个参数,对应的 Item 就不能被点击打开。
然后通过 inject(collapseContextKey) 拿到上下文,用这个上下文去调用对应的方法,操作对应的变量。
使用 computed 根据 collapseContext 中的 activeNames 来计算当前这个 item 的 isActive 值。
在 handleClick 这个函数中判断当前处理点击事件,首先判断当前这个 item 是不是 disabled。如果不是的话,就调用 collapseContext 中的 handleItemClick 这个函数,并传入当前 item 的 name。
最后定义,transition 的生命周期钩子:用来该元素刚进入过渡动画的时候设置对应的高度,以及在元素离开过渡动画后清楚高度,让浏览器自己计算。
Item 的模板相对来说要复杂一些,最外层是一个 div,内层一个 div,一个 Transition。外层 div 可以通过是否传入了 disabled 属性控制 is-disabled 样式的添加。
内层的 div 是 header,header 有一个固定的类名:se-collapse-item__header,命名符合 BEM(Block-Element-Modifier)规范。还有几个动态的类名,比如 is-disabled,is-active 这两个属性会根据 disabled 参数和 isActive 进行动态的添加和移除。header 还有一个 id,id 的形式是 item-header-${name},并绑定了一个 handleClick 事件。div 内部有两个标签,一个 slot,一个 SeIcon。这两个标签的意义都非常明确,一个是用来展示复杂结构的标题,一个是用来显示图标。
transition 里面包裹了 两层结构,一层 wrapper,一层 content,便于添加动画。实际上展示内容的结构是 content,使用 wrapper 是为了有更好的动画效果,因此 v-show 也在 wrapper 的身上,便于控制显示与隐藏。content 的内部是一个 <slot>,用于显示内容。使用了 before-enter 这些钩子为 transition 绑定了事件。
Tooltip
Tooltip 是一个工具组件。常常会被这样使用,光标悬浮在元素上方,出现提示。另一种触发方式:光标点击目标元素,出现 tooltip,再次点击目标元素或者目标元素以外的部分,tooltip 消失。
要实现这个组件的功能,可以使用 popperjs 这个第三方库来帮助我们计算 tooltip 的位置。popperInstance 能够根据我们的配置信息,来准确的计算 tooltip 应该出现的位置,并且当窗口更新的时候,他也能够即使的更新位置信息。
类型定义
先看一下这个组件的类型定义:
import type { Options, Placement } from '@popperjs/core'
export type triggerType = 'hover' | 'click'
export interface TooltipProps {
content?: string
trigger?: triggerType
placement?: Placement
manual?: boolean
popperOptions?: Partial<Options>
transition?: string
openDelay?: number
closeDelay?: number
}分别描述一下 tooltip 的参数:
- 如果 tip 的内容比较简单,就用一个字符串就可以表示的话,就可以直接通过外部传入 content 这个参数来表示 tip。
- trigger: 两个取值,分别表示两种触发方式。
- placement: 直接引入
@popperjs/core中Placement的定义。 - manual: 是否手动控制 tooltip 的出现与消失。
- popperOptions: 如果需要对 popperjs 进行更为详细的控制,就可以传入一个 popperjs 的配置对象,这个配置对象使用类型工具 Partial 转换成了所有参数都可选的配置对象。
- transition: 通过传入不同的 transition 为 tip 的出现和消失使用不同的样式(虽然我只定义了 fade)。
- openDelay/closeDelay: 控制 tip 出现和消失的延时。
然后是 tooltip 定义的事件以及暴露的方法
export interface TooltipEmits {
(e: 'visible-change', value: boolean): void
(e: 'click-outside', value: boolean): void
}
export interface TooltipInstance {
show: () => void
hide: () => void
}事件和方法:
- visible-change: 当 tip 的可见性发生变化的时候,会触发这个事件。
- click-outside: 当点击 triggerNode 外部的时候,会触发这个事件。
- show: 暴露给外界的方法,用于显示 tip。
- hide: 暴露给外界的方法,用于隐藏 tip。
组件的外部看完了,看一看内部的实现。
定义了一个响应式变量 isOpen 用于控制 popperNode 的展示和关闭。
定义了 open 函数和 close 函数用于设置 isOpen 的值,也就是控制 popperNode 的展示和关闭,并且触发 visible-change 事件。
使用 lodash-es 中的 dobunce 函数,获取防抖版本的 openDebounce 和 closeDebounce,两个 debounce 的 delay 由传入的 props 决定。
但是即便是这样,openDebounce 和 closeDebounce 还是不完善的。试想这样一个场景,我快速滑动之后,最后移入移出然后静止,出现的现象是,一定延时之后先打开,后关闭。这种行为明显是不符合实际需要的,由于最后我的光标已经在外边儿了,所以它应该直接不显示,而不是先展示再消失。
所以需要创建 openFinal 和 openClose,它的逻辑是这样的,在调用 openDebounce 之前,先 cancel 掉 closeDebounce。
几个函数定义完了就需要将这几个函数绑定为事件,先看下模板大致长什么样:
<template>
<div
class="se-tooltip"
v-on="outerEvents"
>
<div
class="se-tooltip__trigger"
ref="triggerNode"
v-on="events"
>
<!-- ... -->
</div>
<transition :name="transition">
<div
class="se-tooltip__popper"
ref="popperNode"
>
<!-- ··· -->
</div>
</transition>
</div>
</template>这里面需要注意的点就是移入和移出的判定范围是不一样的,当光标显示出来之后,总不能我光标悬浮在 popperNode 上的时候,光标反而消失了是吧,所以移出的事件应该绑定在 outerEvents 上面,而 outerEvents 有应该绑定在包裹 triggerNode 和 popperNode 两个节点的父结点上。
attachEvent 这个函数就是单纯地根据 trigger 的值绑定事件用的,至于什么时候应该绑定,还需要看后文。
const events: Record<string, () => void> = ({})
const outerEvents: Record<string, () => void> = ({})
function attachEvent() {
if (props.trigger === 'click') {
events.click = togglePopper
}
else if (props.trigger === 'hover') {
events.mouseenter = openFinal
outerEvents.mouseleave = closeFinal
}
}在第一次 setup 函数执行的时候,也就是组件创建的时候,我们需要根据 manual 的状态决定是否绑定 event,同时也需要对 trigger 和 manual 进行侦听,以便在外部传入参数变化的时候,能够及时重新绑定事件。
接下来,需要将单独的 placement 和 popperOptions 合并。popper-options 中的参数优先级是更高的。
获取到了 popper-options 之后就可以着手创建 popperInstance 示例了,示例的创建时机和销毁时机应该和 popperNode 的显示与隐藏时机有关,所以我们可以在 tansition 的 onEnter 这个声明周期钩子内创建 popperInstance,因为这个时候 triggerNode 和 popperNode 都已经存在了。销毁的时机我们可以放在 onAfterLeave,这个时候 popperNode 也已经移除了,就不用担心销毁 popperInstance 让 popperNode 重回文档流。
然后再绑定一个钩子 onUnmounted,如果这个 Tooltip 组件被卸载了,还需要销毁 popperInstance 示例。
Tooltip 还有一个功能,“点击 Tooltip 外部时,关闭 popperNode 节点”这个功能,仔细分析,其实这个功能可以抽离出来一个 hooks useClickOutside。
export function useClickOutside(elementRef: Ref<undefined | HTMLElement>, callback: (e: MouseEvent) => void) {
const handler = (e: MouseEvent) => {
if (elementRef.value && e.target) {
if (!elementRef.value.contains(e.target as HTMLElement)) {
callback(e)
}
}
}
onMounted(() => {
document.addEventListener('click', handler)
})
onUnmounted(() => {
document.removeEventListener('click', handler)
})
}useClickOutside 是一个函数,需要传入 elementRef 和 callback 两个参数,一个是要绑定事件的元素,一个是要执行的回调。
接下来定义了一个函数 handler,这个函数的接受一个参数:MouseEvent。函数的内部逻辑会判定 elementRef 是否存在,并且点击事件的 target 是否位于 element 的外部,如果是的话,会执行 callback 这个回调函数。接下来就是在 onMounted 的时候给 elementRef 的 click 事件绑定 handler,在 onUnmounted 的时候,卸载 handler。
判断点击的模板是否在 elementRef 内部使用的是 contains 这个 DOM API。
Dropdown
Dropdown 是一个下拉菜单组件,移入或点击 triggerNode 之后,会出现一个下拉菜单,提供选项。Dropdown 是基于 Tooltip 开发的一个组件。
vue 版
类型定义
作为一个下拉菜单,必然需要菜单项,我们先定义菜单项的类型:
import type { VNode } from 'vue'
export interface MenuOption {
label: string | VNode
key: string | number
disabled?: boolean
divided?: boolean
}简单介绍一下参数:
- label 就是可选项需要显示的内容,既可以是 string,也可以是 VNode(VNode 需要通过 h 函数创建)。
- key 是每个 option 的标志符,独一无二。
- disabled 可以用于配置该选项是否禁用。
- divided 可以控制该选项的上方是否出现分隔符。
这样定义 Options 之后,我们就可以在外部传入一个类似下面这样的数组用来表示可选项:
const options: MenuOption[] = [
{ key: 1, label: h('b', 'this is bold') },
{ key: 2, label: 'item2', disabled: true },
{ key: 3, label: 'item3', divided: true },
{ key: 4, label: 'item4' }
]Dropdown 组件的参数大部分继承自 Tooltip,但也有一些 Dropdown 的自有参数。
export interface DropdownProps extends TooltipProps {
menuOptions: MenuOption[]
hideAfterClick?: boolean
}比如:
- menuOptions 就是上文提到的可选项,需要由父组件传入。
- hideAfterClick 控制点击选项之后,是否自动关闭下拉菜单。
Dropdown 的事件同样继承自 Tooltip,并扩展了一个 select 事件用于在选项被选择时触发:
export interface DropdownEmits extends TooltipEmits {
(e: 'select', value: MenuOption): void
};
export interface DropdownInstance extends TooltipInstance {
// 未来扩展
};组件行为
首先定义了一个 visibleChange 函数,这个函数绑定在 Tooltip 的 visible-change 事件上,这样当 tooltip 触发 visible-change 的时候,visibleChange 这个函数就会被调用。而这个函数的作用又是触发 dropdown 这个组件自身的 visible-change 事件,这样父组件就可以根据 dropdown 为 dropdown 的 visible-change 事件触发绑定处理函数,本质上是事件的转发。
接下来定义了 itemClick 这个函数,这个函数接受 item(menuOption) 作为参数,如果 item 是禁用状态,那就什么也不做,否则就会触发 select 事件,并传入 item。
最后,还暴露了两个方法,show 和 hidden。
VNode 渲染
由于 lable 可以传入 VNode,所以还要使得组件拥有渲染 VNode 的能力。
可以定义一个 RenderVNode组件,专门渲染 VNode:
import { defineComponent } from 'vue'
export default defineComponent({
props: {
VNode: {
type: [String, Object],
required: true
}
},
setup(props) {
return () => props.VNode
}
})这个组件的作用就是接受一个 VNode,并将这个 VNode 作为 render 函数的返回值。然后我们在 Dropdown 组件内部需要渲染 VNode 的地方使用这个组件,即可实现渲染 VNode。
组件模板
模板组外层是一个 div.se-dropdown,是 Dropdown 组件的容器。
内部是一个 Tooltip 组件,组件上绑定了 trigger, placement 在内的许多参数,由于 Dropdown 的参数本身就是继承自 Tooltip,所以父组件
Tooltip 组件内部首先是一个 slot,用于父组件向 dropdown 内部填充内容,实际上填充到这个位置的节点,又会被 tooltip 内部的 slot 接受。
Tooltip 组件内部的第二个节点是一个带 name 的 template,也就是填充到 tooltip 内部的 content slot。
这个 template 内部是一个 ul.se-dropdown__menu 节点,内部是使用 v-for 循环生成的 template 节点。遍历的数组是 menuOptions,key 是 item.key。
template 内部有两个 li 节点,一个是为分隔符准备的,如果该 item 的 divided 属性为 true,这个 li 标签就会渲染。
第二个 li 标签就是选项本体,标签上绑定了 itemClick 这个函数,并传入 item 作为参数,标签体内则有一个 RenderVNode 组件,这个组件需要传入 item.label,可以是 String 也可以是 VNode,他的职责就是把 VNode 或 String 渲染成真实的节点。至此就实现了 Dropdown 的全部功能。
tsx 模式
tsx 这种写法对我来说其实比较陌生,因为我根本没有写过。
这种模式模式下的组件就是一个单独的 tsx 文件,里面完全是 ts 和 tsx 代码,没有模板。
整个文件默认导出 defineComponent({}),我们需要在 {} 内部定义我们的组件。
首先定义组件的 name,然后定义组件的参数,组件的参数和平常使用 defineProps 加泛型的写法有些不一样了。type 位置使用的是 JS 基本类型的构造器再结合 PropsType 断言实现较为严格的类型限制。
然后使用 emits 字段定义事件。
整个组件的逻辑主要集中再 setup 中:
首先定义了 tooltipRef 用来获取 tooltip 的示例,同样还是要先定义一个 Ref 变量,但是 tsx 里面不再使用 ref="tooltipRef" 去绑定 tooltip 示例,而是使用 ref={tooltipRef} 也就是 {} 的方式去使用变量。
接下就是定义 itemClick 和 visibleChange 两个函数,这两个函数的主要作用和 vue 版本基本一致。
然后使用 expose 方法暴露了两个方法,这块儿和 vue 版本里面也是一样的。
再然后是 Options 的定义,options 的定义看起来比较复杂,我们一点一点拆解,computed 里面是一个箭头函数,options 的值就是这个箭头函数的返回值。这个箭头函数的返回值是 menuOptions.map(...),就也是一个数组,也就是说 options 的值是一个数组,接下来再看这个数组的每一项到底是什么?这个数组的每一项都是一个 Fragment,里面是由 item 生成的 li 标签,标签内部使用了 { item.label } 生成具体的内容节点,可以是 string 也可以是 VNode。
因此,Options 其实就是一个 VNode 数组。
最后,返回了一个 div.se-dropdown,这个标签大部分地方都很常规,只有传递插槽的位置需要注意一下,tsx 中传递插槽插槽。
项目难点
在这个项目中,其实没有特别难以让人理解的点,但是它帮助我巩固到了很多以前学习过程中不扎实的东西,比如说外边距折叠的条件(BFC)。
