阿里的前端整体水平可以说是国内top
级别的,相关开源的组件库,尤其ant-design
(react
版本),在国内外有着较高的使用率。在随着前端技术栈的不断完善,相应匹配的组件库也伴随着版本的迭代。而这些组件库的迭代流程以及内部组件的实现是怎么样的,是值得每一位前端开发人员去学习借鉴的。尤其是一些非常优秀的组件库。
ant-design-vue组件库目录结构
通过在github
上对该开源组件库项目进行clone
或download
后,项目的目录结构与我们日常开发的项目的目录结构有不同之处。几个核心的结构如下:
antd-tools
:该目录结构中包含结合webpack
、gulp
等热门构建工具配置的针对ant-design-vue
这个组件库进行打包构建发布等的工具类components
:该目录是整改ant-design-vue
库组件的结合集,所有的公共组件的封装都在这个文件夹下面examples
:对于封装的组件库在开发过程中进行测试的包(Demo)scripts
:脚本集合,用于处理该库一些打包构建的配置脚本typings
:为该库编写的一些声明文件的集合
组件源码解析
Button
组件:按钮组件,也是我们日常开发中最常用到的组件之一
Button组件源码解析
一个组件的封装,从基本点来看。关注组件自身的输出、输入,这里的输入、输出指的是自身的属性、行为或从父级传递传递过来的属性、行为。
我们可以在components/button
目录下看到button
组件封装的结构,结构如下:
__tests__
:用于对该组件进行单元测试style
:封装该组件需要编写的相关less
样式文件.tsx
:对应的组件文件
接下来,根据我们日常项目开发过程中使用该button
组件的情况,从以下几个问题去阅读源码:
- 该组件的
type
(如:default
、primary
、dashed
等)属性在内部是怎么进行处理的? - 该组件的
icon
属性在内部是怎么进行处理的? - 该组件的
loading
属性在内部是怎么进行处理的,来达到类似防抖的效果?
Button
组件的type
属性
从ant-design-vue
的官网可以看到,Button
组件有'default', 'primary', 'ghost', 'dashed', 'link', 'text'
这6种类型。当我们设置不同的type
的时候,Button
组件的外观也会随着进行改变。
打开button.tsx
文件,可以看到如下代码:
import buttonTypes from './buttonTypes';
const props = buttonTypes();
export default defineComponent({
name: 'AButton',
inheritAttrs: false,
__ANT_BUTTON: true,
props,
...
})
- 导入了
buttonTypes
模块 - 定义一个
props
变量接收buttonTypes
模块导出的函数的返回值 - 将该
props
变量赋值给Button
组件的props
属性
接下来看下buttonTypes.ts
文件的相关代码:
第1行代码:import { tuple } from '../_util/type';
从_tuil
的type
文件中导入了tuple
函数,该函数声明定义如下:
export const tuple = <T extends string[]>(...args: T) => args;
利用了ts
中的泛型约束了泛型T
的类型只能是字符串数组(也就是该数组中的元素只能是字符串类型),函数的返回字是某个具体的字符串元素。
第7
行代码:const ButtonTypes = tuple('default', 'primary', 'ghost', 'dashed', 'link', 'text');
,定义了一个元组类型的变量ButtonTypes
,该元组中包含了6个元素。
第8
行代码:export type ButtonType = typeof ButtonTypes[number];
,这里定义了ButtonType
这种类型并进行导出;
第23行代码:定义了一个buttonProps
函数,也就是整个Button
组件的props
属性结合,函数代码如下:
const buttonProps = () => ({
prefixCls: PropTypes.string,
type: PropTypes.oneOf(ButtonTypes),
loading: {
type: [Boolean, Object],
default: (): boolean | { delay?: number } => false,
},
disabled: PropTypes.looseBool,
ghost: PropTypes.looseBool,
block: PropTypes.looseBool,
danger: PropTypes.looseBool,
icon: PropTypes.VNodeChild,
});
可以看出type
这里通过vue-types
这个插件来约束只能为上面提到了6种之一。从上面的分析我们能够得出,Button
组件的type
的由来,接下来看它是怎么与Button
组件的样式进行对应关联的。比如设置type: danger
,那么Button
组件的背景就是红色、文字是白色。这种联系关系内部是怎么处理的?
返回到Button.tsx
中的代码。第75行定义了一个计算属性classes
。代码如下:
const classes = computed(() => {
const { type, shape, size, ghost, block, danger } = props;
const pre = prefixCls.value;
return {
[`${pre}`]: true,
[`${pre}-${type}`]: type,
[`${pre}-${shape}`]: shape,
[`${pre}-${sizeCls}`]: sizeCls,
[`${pre}-loading`]: innerLoading.value,
[`${pre}-background-ghost`]: ghost && !isUnborderedButtonType(type),
[`${pre}-two-chinese-chars`]: hasTwoCNChar.value && autoInsertSpace.value,
[`${pre}-block`]: block,
[`${pre}-dangerous`]: !!danger,
[`${pre}-rtl`]: direction.value === 'rtl',
};
});
这里就可以看出type
的不同,按钮样式表现的不同点,主要在于pre
及type
这两个变量,其中pre
表示对应组件的class
类名的前缀,如button
组件的前缀ant-btn
。
最后看下Button
组件的return
返回了一个方法,代码如下:
return () => {
const buttonProps = {
class: [
classes.value,
attrs.class,
],
onClick: handleClick,
};
const buttonNode = (
<button {...buttonProps} ref={buttonNodeRef} type={htmlType}>
{iconNode}
{kids}
</button>
);
if (isUnborderedButtonType(type)) {
return buttonNode;
}
return <Wave ref="wave">{buttonNode}</Wave>;
};
该方法中buttonProps
对象类型的变量,该对象中用一个class
属性来接收自身的class
属性及上述提到的classes
计算属性;然后定义了buttonNode
一个node
节点,该node
节点的内容是html
中button
标签,并通过jsx
语法将buttonProps
进行解构处理。
<a-button type="primary">A</a-button>
如上面A
这个按钮组件,我们可以知道它的类名是:ant-btn ant-btn-primary
。
在结合style
文件夹下,编写的对应样式。就可以知道了type
的不同对应的Button
组件外观不同。
Button
组件的icon
属性
我们在使用Button
组件的时候,比如该组件需要展示图标与文字的结合,我们的可能可能是这样写:
<a-button type="primary">
<template #icon><SearchOutlined /></template>
查询
</a-button>
那么该组件内部是怎样去处理<template #icon><SearchOutlined /></template>
这种代码的?
对上述Button
组件的type
属性进行分析后,发现该组件的所有属性的定义处理都是在buttonTypes.ts
中,因此我们先来看这个文件中关于icon
相关的代码。
const buttonProps = () => ({
prefixCls: PropTypes.string,
type: PropTypes.oneOf(ButtonTypes),
icon: PropTypes.VNodeChild,
...
});
可以看到,该函数返回的对象中有一个icon
属性,其类型是PropTypes
对象上VNodeChild
类型,那么这个VNodeChild
具体是个什么东西呢?最终在vue-types
文件夹下有一个type.ts
文件,定义了VueNode
的类型。
export type VueNode = VNodeChild | JSX.Element;
所以最终可以知道icon
就是Vuejs
中的一个虚拟子节点或者是JSX
中的一个元素。
接下来回到button.tsx
组件本身。
export default defineComponent({
name: 'AButton',
slots: ['icon'],
setup(props, { slots, attrs, emit }) {}
}
)
可以看到对该组件进行定义的时候,通过vuejs
自身的slots
属性接收了icon
这个元素。
接着看return
返回的方法里面的代码:
const icon = getPropsSlot(slots, props, 'icon');
??这里返回的是一个对象还是?
该方法里面定义了icon
变量,接下来看下getPropsSlot
方法的作用是什么?在_util/props-util
文件里,可以看到该方法的实现,代码如下:
function getPropsSlot(slots, props, prop = 'default') {
return props[prop] ?? slots[prop]?.();
}
可以看出该方法的作用用于对slot
的处理。如果props
上存在对应的prop
则直接返回,否则从slots
取到对应的prop
进行方法的执行,最后返回一个数组,数组中是对应的虚拟节点元素,元素的属性大致如下:
anchor: null
appContext: null
children: "我是icon"
component: null
dirs: null
dynamicChildren: null
dynamicProps: null
el: text
key: null
patchFlag: 0
props: null
ref: null
scopeId: "data-v-c33314ea"
shapeFlag: 8
ssContent: null
ssFallback: null
staticCount: 0
suspense: null
target: null
targetAnchor: null
transition: null
type: Symbol(Text)
__v_isVNode: true
__v_skip: true
接下来就是:
const iconNode = innerLoading.value ? <LoadingOutlined /> : icon;
const buttonNode = (
<button {...buttonProps} ref={buttonNodeRef} type={htmlType}>
{iconNode}
{kids}
</button>
);
定义了一个icon
节点,并在buttonNode
节点中直接通过slot
的方式将该icon
节点作用于button
标签中(成为button
标签的一个子元素)。
所以从以下分析看出,Button
组件对于icon
的处理主要是结合vuejs
的slots
或props
属性对对应符合条件的prop
进行虚拟化(生成一个虚拟子节点)。
Button
组件的loading
属性
在项目开发过程中,对于Button
组件的使用频率是非常之高的,比如通过其点击事件向后端传递一些数据经过处理后存储在数据库中。这是一个很常见的业务开发点,但如果我们在1或2s
内连续点击多次按钮,若不做任何处理的话,数据库中最终会存储很多重复的数据。要解决这个问题,就得使用到javascript
中的节流这个知识点来处理,但ant-design-vue
中对于button
的处理内部封装了节流的处理,只要我们在使用该组件的时候,加上一个loading
属性就可以了。那在其组件内部的封装是怎么实现的呢?
依然先看buttonTypes.ts
这个文件与loading
相关的代码,代码如下:
const buttonProps = () => ({
... // other code
loading: {
type: [Boolean, Object],
default: (): boolean | { delay?: number } => false,
},
...// other code
onClick: {
type: Function as PropType<(event: MouseEvent) => void>,
},
});
可以看到在buttonProps
这个方法返回的对象中,有一个loading
属性、及一个onClick
方法。
loading
属性的值可以是一个boolean
类型,或者是一个对象类型。其默认值为false
onClick
方法是参数是一个鼠标事件的事件对象,该方法无返回值
接下来回到button.tsx
组件本身。
在该文件的第22行有一个类型的定义type Loading = boolean | number;
定义了Loading
的类型为布尔或是数值类型
然后在setup
方法中定义了一个变量,第46行const innerLoading: Ref<Loading> = ref(false);
innerLoading
变量是一个值为布尔或数值的响应式变量。那么定义这个变量的作用是什么呢?继续看与其相关的代码。
在第52行定义了一个loadingOrDelay
计算属性。用来接收loading
这个prop
的更新
const loadingOrDelay = computed(() =>
typeof props.loading === 'object' && props.loading.delay
? props.loading.delay || true
: !!props.loading,
);
在第58行通过watch
对计算属性loadingOrDelay
进行值改变的监听,并进行相关逻辑的处理:
watch(
loadingOrDelay,
val => {
clearTimeout(delayTimeoutRef.value);
if (typeof loadingOrDelay.value === 'number') {
delayTimeoutRef.value = window.setTimeout(() => {
innerLoading.value = val;
}, loadingOrDelay.value);
} else {
innerLoading.value = val;
}
},
{
immediate: true,
},
);
如果loadingOrDelay
的值是number
类型,则设置一个定义,在loadingOrDelay
秒后把loadingOrDelay
最新的值赋值给innerLoading
变量。反之直接将loadingOrDelay
的值赋值给innerLoading
。
const delayTimeoutRef = ref(undefined);
由于设置了定时器,所以在该组件将要被销毁(卸载)的时候,需要对定时器进行清除操作。
onBeforeUnmount(() => {
delayTimeoutRef.value && clearTimeout(delayTimeoutRef.value);
});
最后在第121行对点击事件进行了逻辑的处理:
const handleClick = (event: Event) => {
// https://github.com/ant-design/ant-design/issues/30207
if (innerLoading.value || props.disabled) {
event.preventDefault();
return;
}
emit('click', event);
};
可以看出ant-design-vue
对Button
组件中的loading
的实现其实是比较巧妙和简单的。通过props
接收到loading
属性,并没有直接通过该属性去进行一系列的值改变的处理。而是内部定义了一个innerLoading
变量和loadingOrDelay
计算属性去进行相应逻辑的处理,这是因为loading
是在外部组件传递过来的,不能直接对其进行修改。
Button
组件的引用
方式一,可以直接引入该Button.tsx
组件进行使用,但只能在项目内部使用。另一种可以通过vuejs
给组件提供的install
方法对组件进行处理。
import type { App, Plugin } from 'vue';
import Button from './button';
/* istanbul ignore next */
Button.install = function (app: App) {
app.component(Button.name, Button);
return app;
};
export default Button as typeof Button & Plugin;
相关知识tips
TypeScript
中的元组数组合并了相同类型的对象,而元组(Tuple)合并了不同类型的对象
let tom: [string, number] = ['Tom', 25];
防抖与节流
节流:假如你是一个7岁的孩子,一天你妈妈正在做巧克力蛋糕。但这蛋糕不是给你的而是给客人的,这时你一直问她要蛋糕。最终她给了你一块,但是你继续问她要更多的蛋糕。她同意给你更多的蛋糕,前提是一个小时后给你一块。这是你依然继续问她要蛋糕,但她这时没理会你。终于一个小时以后,你得到了更多的蛋糕。如果你要得到更多的蛋糕,无论要多少次,你都会在一个小时后才得到更多蛋糕。
对于节流,无论用户触发事件多少次,在给定的时间间隔内,附加的函数都只会执行一次。
防抖:考虑相同的蛋糕示例。这一次你不断地向你妈妈要蛋糕,她很生气,并告诉你,只有你保持沉默一小时,她才会给你蛋糕。这意味着如果你不断地问她,你就得不到蛋糕——你只会在上次问后一小时得到蛋糕。
对于防抖,无论用户触发事件多少次,一旦用户停止触发事件,附加函数将仅在指定时间后执行。
子组件
prop
校验可以看到,在整个
ant-design-vue
组件中,关于子组件props
的校验都是通过vue-types
这个插件去处理的。第一点是减少了代码量,第二点是有利于阅读及扩展。vue-types
插件提供了createTypes
方法,我们可以通过该方法来扩展更多的类型,如ant-design-vue
中的做法如下:import { createTypes } from 'vue-types'; const PropTypes = createTypes({ func: undefined, bool: undefined, string: undefined, number: undefined, array: undefined, object: undefined, integer: undefined, }); PropTypes.extend([ { name: 'looseBool', getter: true, type: Boolean, default: undefined, }, { name: 'style', getter: true, type: [String, Object], default: undefined, }, { name: 'VNodeChild', getter: true, type: null, }, ]);
组件封装规则
- 组件是拿来用的:应该从使用者(程序员)的感受出发
- 没有"最好怎么做":需要考虑项目的特点
- 好组件不是设计出来的,是改出来的:经常调整,有时还要重构
- 组件的功能应该单一、简单:不要试图把众多功能塞到一个组件中。体现单一职责原则
- ...
封装的组件给别人用
对于封装的公共组件,在封装组件的时候,要考虑到怎样让别人做到引入使用。目前较为流行的做法是将组件库通过
npm
进行管理,然后使用者可针对对应的组件进行按需引入使用。这就需要在封装组件的时候对某个组件进行"安装"及"导出"操作。/* istanbul ignore next */ Button.install = function (app: App) { app.component(Button.name, Button); return app; }; export default Button
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。