阿里的前端整體水平可以說是國內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