8
阿里的前端整体水平可以说是国内top级别的,相关开源的组件库,尤其ant-design(react版本),在国内外有着较高的使用率。在随着前端技术栈的不断完善,相应匹配的组件库也伴随着版本的迭代。而这些组件库的迭代流程以及内部组件的实现是怎么样的,是值得每一位前端开发人员去学习借鉴的。尤其是一些非常优秀的组件库。

ant-design-vue组件库目录结构

通过在github上对该开源组件库项目进行clonedownload后,项目的目录结构与我们日常开发的项目的目录结构有不同之处。几个核心的结构如下:

  1. antd-tools:该目录结构中包含结合webpackgulp等热门构建工具配置的针对ant-design-vue这个组件库进行打包构建发布等的工具类
  2. components:该目录是整改ant-design-vue库组件的结合集,所有的公共组件的封装都在这个文件夹下面
  3. examples:对于封装的组件库在开发过程中进行测试的包(Demo)
  4. scripts:脚本集合,用于处理该库一些打包构建的配置脚本
  5. typings:为该库编写的一些声明文件的集合

组件源码解析

  • Button组件:按钮组件,也是我们日常开发中最常用到的组件之一

Button组件源码解析

一个组件的封装,从基本点来看。关注组件自身的输出、输入,这里的输入、输出指的是自身的属性、行为或从父级传递传递过来的属性、行为。

我们可以在components/button目录下看到button组件封装的结构,结构如下:

  • __tests__:用于对该组件进行单元测试
  • style:封装该组件需要编写的相关less样式文件
  • .tsx:对应的组件文件

接下来,根据我们日常项目开发过程中使用该button组件的情况,从以下几个问题去阅读源码:

  1. 该组件的type(如:defaultprimarydashed等)属性在内部是怎么进行处理的?
  2. 该组件的icon属性在内部是怎么进行处理的?
  3. 该组件的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';_tuiltype文件中导入了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的不同,按钮样式表现的不同点,主要在于pretype这两个变量,其中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节点的内容是htmlbutton标签,并通过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的处理主要是结合vuejsslotsprops属性对对应符合条件的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-vueButton组件中的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
  1. TypeScript中的元组

    数组合并了相同类型的对象,而元组(Tuple)合并了不同类型的对象

    let tom: [string, number] = ['Tom', 25];

  2. 防抖与节流

    节流:假如你是一个7岁的孩子,一天你妈妈正在做巧克力蛋糕。但这蛋糕不是给你的而是给客人的,这时你一直问她要蛋糕。最终她给了你一块,但是你继续问她要更多的蛋糕。她同意给你更多的蛋糕,前提是一个小时后给你一块。这是你依然继续问她要蛋糕,但她这时没理会你。终于一个小时以后,你得到了更多的蛋糕。如果你要得到更多的蛋糕,无论要多少次,你都会在一个小时后才得到更多蛋糕。

    对于节流,无论用户触发事件多少次,在给定的时间间隔内,附加的函数都只会执行一次。

    防抖:考虑相同的蛋糕示例。这一次你不断地向你妈妈要蛋糕,她很生气,并告诉你,只有你保持沉默一小时,她才会给你蛋糕。这意味着如果你不断地问她,你就得不到蛋糕——你只会在上次问后一小时得到蛋糕。

    对于防抖,无论用户触发事件多少次,一旦用户停止触发事件,附加函数将仅在指定时间后执行。
  3. 子组件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,
      },
    ]);
  4. 组件封装规则

    • 组件是拿来用的:应该从使用者(程序员)的感受出发
    • 没有"最好怎么做":需要考虑项目的特点
    • 好组件不是设计出来的,是改出来的:经常调整,有时还要重构
    • 组件的功能应该单一、简单:不要试图把众多功能塞到一个组件中。体现单一职责原则
    • ...
  5. 封装的组件给别人用

    对于封装的公共组件,在封装组件的时候,要考虑到怎样让别人做到引入使用。目前较为流行的做法是将组件库通过npm进行管理,然后使用者可针对对应的组件进行按需引入使用。这就需要在封装组件的时候对某个组件进行"安装"及"导出"操作。

    /* istanbul ignore next */
    Button.install = function (app: App) {
      app.component(Button.name, Button);
      return app;
    };
    export default Button

前端扫地僧
2.5k 声望1.2k 粉丝