ant-design-vue元件庫原始碼分析

darkCode發表於2021-10-26
阿里的前端整體水平可以說是國內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

相關文章