接下來是一段很痛苦的時間!!html
今天詳細分析 Select 源碼,在看到源碼足足有九百行時,我整我的都是懵的,這是迄今爲止讀的最多的一篇源碼,大概瀏覽了以後發現它裏面有不少不少的知識點,光導入的模塊就有 16 個,裏面包含着各類組件、混入以及工具函數,因此鑑於本文篇幅有限,我打算分兩部分來寫,分別爲:「模板篇」和「方法篇」,模板篇主要是分析 Select 模板以及一些比較簡單的屬性,方法篇着重分析 methods
裏面的各類方法,如今就讓咱們一塊兒來看一下 Select 組件到底作了哪些事,建議先點贊/收藏再觀看。前端
瞭解一個 UI 組件首先要從它的功能入手,只有瞭解了組件的具體功能以後纔會知道爲何要封裝?怎樣封裝?在 ElementUI 官方文檔上有詳細使用功能及方法,不熟悉的同窗能夠先研究一下。Select 主要有如下用法:vue
能夠看到,一個 Select 的功能有這麼多,基本上把咱們平常需求中須要使用到的功能所有考慮進來了,既然功能這麼多,那麼封裝起來確定就特別麻煩了,畢竟人家九百行代碼不是白寫的!!node
理清了功能這對於咱們看源碼是頗有幫助的,咱們能夠根據功能來找對應的代碼實現,接下來直接上源碼:git
看結構最好不要在源碼裏直接看,這樣很容易懵圈,首先咱們看一下最基礎的用法渲染出來的 HTML 結構:github
首先最外層,是一個類名爲 el-select
的 div
,若是聲明瞭 size
,還會根據 size
添加 el-select-size
類,裏面包裹着的是 el-select__tags
的 div
,咱們暫時先不看與 tags
相關的,先看一下 el-input
。web
這個 el-input
渲染出來就是 ElementUI 封裝的 input 組件,若是你沒有看過,能夠先移步超詳細 ElementUI 源碼分析系列仔細閱讀一下 input 源碼。數組
<el-input>
<template slot="prefix" v-if="$slots.prefix">
<slot name="prefix"></slot>
</template>
<template slot="suffix">
<i v-show="!showClose" :class="['el-select__caret', 'el-input__icon', 'el-icon-' + iconClass]"></i>
<i v-if="showClose" class="el-select__caret el-input__icon el-icon-circle-close" @click="handleClearClick"></i>
</template>
</el-input>
複製代碼
input 組件裏包含了兩個插槽,前綴和後綴。prefix
插槽用於展現 Select 組件頭部內容(若是有的話),而 suffix
是用來顯示後面的清空按鈕和小箭頭的。能夠看到這裏有一個細節就是小箭頭用了 v-show
而清空按鈕用了 v-if
,這裏簡單介紹一下二者的區別:瀏覽器
v-show
操做的是 DOM 元素的 display: none
屬性,不會改變 DOM 樹的結構v-if
操做的是 DOM 樹,直接加入或者刪除控制的 DOM 元素v-if
在「初始條件爲假時不會渲染」,直到第一次爲真時纔開始渲染v-if
有更高的「切換開銷」,v-show
有更高的「初始渲染開銷」v-show
適用於須要頻繁操做 DOM,而 v-if
則用於你不會去頻繁操做它的 DOM 結構的時候它所觸發的事件也留到下一期再和你們分析,記得準時閱讀。服務器
因爲後面的結構用到了不少之前沒有看過的組件,因此接下來先對 Select 引用的組件進行分析。
首先是 el-select-menu
,查看導入的模塊可知使用的是 select-dropdown.vue
文件,進去瞅一眼。這個組件的結構很簡單,只有一個 div
,裏面包含一個插槽,這個 div
的 class
是 "el-select-dropdown el-popper"
,它渲染到頁面上是下面這種結構:
能夠看到這是一個下拉框的結構,它是被添加到 body 節點上了,經過 position
定位到了輸入框的上方或者下方,而且能夠根據輸入框的位置進行調整。這個組件自己不是很複雜,可是它混入了 vue-popper
,而在 vue-popper
中又引入了 popper-manager
,同時 vue-popper
又引入了第三方的定位庫 popper.js
,因此這裏面的關係很複雜,看下面這張圖:
先來講一下每個模塊的做用:
vue-popper
:用於管理組件的彈出框,何時建立、在哪一個位置建立、何時又須要銷燬以及怎麼銷燬popup
:主要是作彈出框的打開和關閉操做popup-manager
:用來管理頁面中全部的 modal 層popper.js
:第三方庫,主要是用來定位彈出框的對於
popper.js
的分析參考了這篇 CSDN 博客
因爲每一個模塊的內容都很是多,這裏只挑和 Select 組件有關的分析一下,若是想看具體的,能夠移步個人 github 上查看。
咱們再回過頭來看 Select 組件,el-select-menu
中包裹着 el-scrollbar
用於下拉框的內容滾動,那麼接下來的內容就是 el-scrollbar
的分析了。
先來看一下入口文件 index.js
它導入的 ScrollBar
是src/main
,這纔是 el-scrollbar
的組件的文件,官方說了這個文件整個思路是參考了 gemini-scrollbar,我去對比了一下,發現思路果真同樣,連命名都是同樣的,不過別人的作了兼容。
這裏面導入的文件主要是:
utils/resize-event.js
:resize
事件的綁定與解除utils/scrollbar-width.js
:計算滾動條的寬度toObject
:將數組裏面的全部對象合併到一個對象上去Bar
:自定義的滾動條組件對於每個文件的源碼我都進行了分析,先看 main.js
裏面的源碼:
// main.js
render(h) {
// 獲取系統自帶的滾動條的寬度
// scrollbarWidth() 看後文
let gutter = scrollbarWidth();
let style = this.wrapStyle;
// 若是滾動條存在
if (gutter) {
// 我以爲這地方應該是 `gutterWidth` 不過不重要了
const gutterWith = `-${gutter}px`;
const gutterStyle = `margin-bottom: ${gutterWith}; margin-right: ${gutterWith};`;
if (Array.isArray(this.wrapStyle)) {
// toObject 看後文
style = toObject(this.wrapStyle);
style.marginRight = style.marginBottom = gutterWith;
} else if (typeof this.wrapStyle === 'string') {
style += gutterStyle;
} else {
style = gutterStyle;
}
}
// 這是最外層的 ul
const view = h(
this.tag,
{
class: ['el-scrollbar__view', this.viewClass],
style: this.viewStyle,
ref: 'resize'
},
// 子虛擬節點數組
this.$slots.default
);
// ul 外層包裹的 div
const wrap = (
<div ref='wrap' style={style} onScroll={this.handleScroll} class={[ this.wrapClass, 'el-scrollbar__wrap', gutter ? '' : 'el-scrollbar__wrap--hidden-default' ]} > {[view]} </div>
);
let nodes;
// 是否使用元素滾動條,默認是 false
// 使用自定義的 Bar 組件
if (!this.native) {
nodes = [
wrap,
<Bar move={this.moveX} size={this.sizeWidth}></Bar>,
<Bar vertical move={this.moveY} size={this.sizeHeight}></Bar>
];
} else {
nodes = [
<div ref='wrap' class={[this.wrapClass, 'el-scrollbar__wrap']} style={style} > {[view]} </div>
];
}
return h('div', { class: 'el-scrollbar' }, nodes);
},
複製代碼
能夠看到,這個下拉框的滾動部分主要是使用 render
渲染函數來構建一個 DOM 結構的,整個渲染出來的結構如圖所示:
關於 li
標籤的渲染是由 el-option
完成的,稍後再分析。在渲染函數裏給外層的 wrap
綁定了一個 onscroll
事件,監聽方法在 methods
裏面定義了:
// onscroll 事件處理函數
handleScroll() {
const wrap = this.wrap;
// 計算出滾動條須要滾動的距離(百分比)
this.moveY = (wrap.scrollTop * 100) / wrap.clientHeight;
this.moveX = (wrap.scrollLeft * 100) / wrap.clientWidth;
},
複製代碼
當內部的列表滾動時,計算出滾動條須要滾動的距離,這裏是使用的百分比,而後在 Bar
組件裏使用。這個 Bar
是官方自定義的一個滾動條組件,也放在下文分析。咱們注意到組件接收了一個 native
屬性,這個屬性表示是否使用瀏覽器自帶的滾動條,默認是 false
也就是不使用,而是去使用 Bar
組件,而後把整個結構放進 h
裏交給 Vue 解析。
methods
裏定義了兩個方法:
handleScroll
:onscroll
事件處理函數update
:當觸發 resize
事件時,改變滾動條的大小update() {
// 寬高百分比
let heightPercentage, widthPercentage;
const wrap = this.wrap;
if (!wrap) return;
// 求出可視區域佔內容總大小的百分比,這就是滾動條相對於內容的百分比
heightPercentage = (wrap.clientHeight * 100) / wrap.scrollHeight;
widthPercentage = (wrap.clientWidth * 100) / wrap.scrollWidth;
// 滾動條的大小
// 若是可視區域比內容總大小要小,證實須要滾動,把百分比賦值給 sizeXXX
// 若是不須要滾動 clientHeight = scrollHeight
this.sizeHeight = heightPercentage < 100 ? heightPercentage + '%' : '';
this.sizeWidth = widthPercentage < 100 ? widthPercentage + '%' : '';
}
複製代碼
當下拉框組件被掛載時,調用 update
方法,值得說明的是在組件的 prop
屬性裏有一個屬性爲 noresize
,這個屬性是禁止框架調整大小的,官方給的註釋是「若是 container
尺寸不會發生變化,最好設置它能夠優化性能」,優化性能在後面能夠看出來,組件掛載和被銷燬前都調用了 update
方法,而頻繁調用 update
會消耗必定的性能,因此咱們不想要調整框架大小時,儘可能聲明 noresize
屬性。
mounted() {
if (this.native) return;
// update 須要用到更新後的 DOM,因此放在 $nextTick 裏
this.$nextTick(this.update);
// 若是能夠調整框架的大小,就給元素添加一個 resize 監聽事件
!this.noresize && addResizeListener(this.$refs.resize, this.update);
},
beforeDestroy() {
if (this.native) return;
// 移除元素的 resize 監聽事件
!this.noresize && removeResizeListener(this.$refs.resize, this.update);
}
複製代碼
關於
addResizeListener
方法,官方是借用了第三方包 resize-observer-polyfill 來處理resize
事件的,ResizeObserver
是新出的 API,有很是好的性能,具體去 MDN 瞭解一下吧。
接下來咱們看 Bar
組件的調用:
<Bar move={this.moveX} size={this.sizeWidth}></Bar>,
<Bar vertical move={this.moveY} size={this.sizeHeight}></Bar>
複製代碼
傳遞了兩個或者三個參數:
move
:水平或者垂直移動的距離size
:滾動條的大小vertical
:是不是垂直滾動條,不是就爲水平的滾動條bar.js
文件就是 Bar
組件,裏面導入了兩個工具類的對象,關於工具類的分析,我打算後期再專門寫一個專欄,這裏先簡單的看一下相關的方法。
/** * 封裝 on 方法給指定元素綁定事件 * @param {HTMLElement} element 要綁定事件的元素 * @param {String} event 要綁定的事件 * @param {Function} handler 事件觸發時執行的函數 */
export const on = (function() {
if (!isServer && document.addEventListener) {
return function(element, event, handler) {
if (element && event && handler) {
// false 表示在冒泡階段執行
// true 表示在捕獲階段執行
element.addEventListener(event, handler, false);
}
};
} else {
// IE 中使用 attachEvent 添加事件監聽
return function(element, event, handler) {
if (element && event && handler) {
element.attachEvent('on' + event, handler);
}
};
}
})();
複製代碼
這個 on
方法是用於給指定元素綁定事件用的,仔細看源碼發現,它使用的是一個當即執行函數(IIEF),而後將執行的結果導出,它執行的結果仍是一個函數。這裏有出現兩個疑問:
先說第二個,返回一個函數明顯是使用了「閉包」,閉包的好處是可以「訪問到外層做用域」,好比說這裏的 isServer
就是 dom.js
裏面定義的變量。可是使用閉包會形成「內存泄漏」,若是不銷燬的話,咱們的內存將不堪重負,因此這裏纔會使用「當即執行函數」來消除閉包帶來的反作用,
再回過來看 Bar
組件,裏面仍然是使用了 render
函數來渲染組件的:
render(h) {
const { size, move, bar } = this;
return (
<div class={ ['el-scrollbar__bar', 'is-' + bar.key] } onMousedown={ this.clickTrackHandler } > <div ref="thumb" class="el-scrollbar__thumb" onMousedown={ this.clickThumbHandler } style={ renderThumbStyle({ size, move, bar }) }> </div> </div>
);
},
複製代碼
渲染出來的結構在上一張圖中能夠看出來,就是兩個嵌套的 div
,這兩個 div
上都綁定了 onmousedown
事件,用來處理鼠標按下的事件,在 style
中還有一個 renderThumbStyle
函數,咱們先看一個這個函數的做用:
export function renderThumbStyle({ move, size, bar }) {
const style = {};
// 平移多少距離
const translate = `translate${bar.axis}(${ move }%)`;
// 設置滾動條的寬/高
style[bar.size] = size;
style.transform = translate;
style.msTransform = translate;
style.webkitTransform = translate;
return style;
};
複製代碼
每當滑動列表時,滾動條也會跟着變化,它的移動就是這個函數控制的,看一下滾動先後,它的 style
的變化:
translateY
發生了變化,也就是說它是靠平移來模擬滾動的,而具體的數值是有父組件(這裏是el-scrollbar
)傳過來的。
接下來看它裏面的幾個方法,都是跟事件綁定有關的:
// 鼠標按鈕在 滾動條上 被按下時的事件處理方法
clickThumbHandler(e) {
// prevent click event of right button
// ctrlKey 事件屬性可返回一個布爾值,指示當事件發生時,Ctrl 鍵是否被按下並保持住
// e.button = 2 表示鼠標右鍵
if (e.ctrlKey || e.button === 2) {
return;
}
this.startDrag(e);
this[this.bar.axis]
= (e.currentTarget[this.bar.offset]
- (e[this.bar.client]
- e.currentTarget.getBoundingClientRect()[this.bar.direction]));
},
複製代碼
若是點擊的時候按下了 Ctrl
或者按下的是鼠標右鍵直接中止事件的執行,按下時執行拖動方法 startDrag
// 點擊並拖拽滾動條
startDrag(e) {
// 拖動的時候當前元素剩下的監聽函數將不會執行
e.stopImmediatePropagation();
this.cursorDown = true;
// 給 document 綁定鼠標移動事件 和 鼠標按鈕擡起事件
on(document, 'mousemove', this.mouseMoveDocumentHandler);
on(document, 'mouseup', this.mouseUpDocumentHandler);
// 禁止文字被選中
// 參考 https://www.jianshu.com/p/701cc19d2c5a
document.onselectstart = () => false;
}
複製代碼
解釋一下
e.stopImmediatePropagation()
方法,平時咱們用到的比較少。當一個元素上綁定了不少同類型的事件時,它會按照綁定時的順序依次執行回調函數,可是當咱們在事件處理函數中聲明瞭這個方法時,那麼當前元素剩下的監聽函數將不會執行。
// 鼠標按鈕在 滾動條所在的區域 被按下時的事件處理方法
// 當鼠標點擊滾動條 `上方空白處` 時,滾動條向上滾動
// 當鼠標點擊滾動條 `下方空白處` 時,滾動條向下滾動
clickTrackHandler(e) {
// 獲取點擊的位置距離元素上邊距的距離
// 即 IE 下的 offsetX/offsetY 屬性
const offset
= Math.abs(e.target.getBoundingClientRect()[this.bar.direction]
- e[this.bar.client]);
// 滾動條寬/高的一半
const thumbHalf = (this.$refs.thumb[this.bar.offset] / 2);
const thumbPositionPercentage
= ((offset - thumbHalf) * 100 / this.$el[this.bar.offset]);
// 舉個例子
// wrap.scrollTop = -10(假數據) * wrap.scrollHeight / 100
this.wrap[this.bar.scroll]
= (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
}
複製代碼
這個方法用來處理滾動條外層被點擊的事件,實現了點擊空白處就能滾動的效果。 startDrag
裏對於 mousemove
和 mouseup
事件的監聽計算方法和上述相似,這裏節省篇幅再也不介紹了。
看了鼠標事件的綁定,咱們要注意事件有綁定就必定要有取消監聽,特別是鼠標移動時的事件,在源碼裏使用的是
off
方法,具體和on
相似。
繼續來解決咱們在 main.js/render
裏面挖下的坑 scrollbarWidth
和 toObject
這個文件很簡單,就是爲了計算出系統自帶的滾動條的寬度,我看了一下,網上基本上都是這種方法。
export default function() {
if (Vue.prototype.$isServer) return 0;
// 若是存在 scrollBarWidth 就直接返回
if (scrollBarWidth !== undefined) return scrollBarWidth;
const outer = document.createElement('div');
outer.className = 'el-scrollbar__wrap';
outer.style.visibility = 'hidden';
outer.style.width = '100px';
outer.style.position = 'absolute';
outer.style.top = '-9999px';
document.body.appendChild(outer);
// 沒有滾動條時的寬度 = 元素的 offsetWidth
const widthNoScroll = outer.offsetWidth;
// 使外層可滾動而且出現滾動條
outer.style.overflow = 'scroll';
const inner = document.createElement('div');
// 設置 width 爲 100% 時,強制子元素的內容寬度等於父元素內容寬度
// 當子元素內容寬度大於父元素的內容寬度時,就會出現滾動條
inner.style.width = '100%';
outer.appendChild(inner);
const widthWithScroll = inner.offsetWidth;
outer.parentNode.removeChild(outer);
scrollBarWidth = widthNoScroll - widthWithScroll;
return scrollBarWidth;
};
複製代碼
不過這裏我以爲它計算的時候用錯了屬性,inner
不該該使用 offsetWidth
而應該使用 clientWidth
,由於 offsetWidth
是包含了滾動條在內的,這樣根本計算不出來,不知道是他們寫錯了仍是我理解錯了,反正這個方法最後獲得的結果都是 0,由於我在 Mac 的谷歌瀏覽器上跑了一遍,都不能拿到滾動條寬度,設置了 overflow
也沒辦法一直讓滾動條存在,不知道在 Windows
系統上會怎麼樣。麻煩各位跑一遍這個方法,而後在評論區告訴我,謝謝。
function extend(to, _from) {
// _from 若是是基本數據類型就不會循環
for (let key in _from) {
to[key] = _from[key];
}
return to;
}
// 把數組裏面的全部對象轉成一個對象
export function toObject(arr) {
var res = {};
for (let i = 0; i < arr.length; i++) {
if (arr[i]) {
extend(res, arr[i]);
}
}
return res;
}
複製代碼
這裏面的方法很簡單,就是經過遍歷將數組上的全部對象的屬性都轉到一個新對象上來,若是數組中有基本數據類型會直接跳過。
到此,咱們的 scrollbar
已經所有分析完畢,可是還沒到撒花完結的時候,接下來還有 el-option
組件。
el-option
部分包含組件自己和一個 el-option-group
組件,option
是真正渲染下拉框列表的組件,渲染到頁面中就是 <li>
標籤,這 option
的模板結構裏有一個默認的插槽用於顯示列表項的文本內容。因爲 option
裏面不少是和 select
組件的方法有關,因此我打算放在下一篇來分析,先看一些簡單的:
// 判斷兩個參數是否相等
isEqual(a, b) {
if (!this.isObject) {
return a === b;
} else {
// 拿到 select 組件實例的 valueKey
// valueKey 是做爲 value 惟一標識的鍵名,綁定值爲對象類型時必填
const valueKey = this.select.valueKey;
return getValueByPath(a, valueKey) === getValueByPath(b, valueKey);
}
}
複製代碼
getValueByPath
是 util
裏面導入的,這個方法主要是用來訪問對象指定的屬性的:
/** * 深層次訪問對象的屬性 * @param {Object} object 目標對象 * @param {string} prop 屬性名 xxx.xxx.xxx 形式 */
export const getValueByPath = function(object, prop) {
prop = prop || '';
// paths => [xxx, xxx, xxx]
// object: {
// xxx: {
// xxx: {
// xxx: 'xxx'
// }
// }
// }
const paths = prop.split('.');
// 把對象保存起來,以避免改變了原有對象
let current = object;
let result = null;
for (let i = 0, j = paths.length; i < j; i++) {
const path = paths[i];
if (!current) break;
// 當到達指定的屬性名時,返回它的屬性值
if (i === j - 1) {
result = current[path];
break;
}
// 不然繼續往下遍歷
current = current[path];
}
return result;
};
複製代碼
可是在我看來,官方的實現還能夠簡單一點,由於既然把屬性名保存到了數組裏,用數組的方法豈不是更好,而後再用一個 while
循環幾行代碼就能實現:
function getValByPath(obj, path) {
const paths = path.split('.')
let res = obj
let prop
while ((prop = paths.shift())) {
res = res[prop]
}
return res
}
複製代碼
再看一個方法:
// 鼠標移動時觸發的事件監聽方法
hoverItem() {
// 若是當前項沒有被禁用,就設置 select 組件的 `hoverIndex`
// 它的值爲當前列表項在 options 數組裏的索引
if (!this.disabled && !this.groupDisabled) {
this.select.hoverIndex = this.select.options.indexOf(this);
}
}
複製代碼
主要是當鼠標移動到列表項上時顯示出 hover
的狀態,具體實現是放在了 select
裏面。這裏面其餘的就須要後續再分析了,真的肝不動了...至於 option-group
裏面的內容很簡單,和 option
的相差不大,本身稍微掃一眼就行,這裏就不寫了。
至此,咱們總算是把 select
組件的模板部分分析完了,請注意,這纔是模板部分,真正的大頭來沒有來,在 select
裏面方法佔了大多數,大概 400 行的樣子,其餘的都是一些屬性和生命週期鉤子,關於方法的等我下一篇文章,先來總結一下 select
模板:
select
組件是由一個輸入框和一個下拉框組成el-input
組件,下拉框使用的是 el-select-menu
組件el-select-menu
是添加到 body 節點上的,經過 v-show
切換顯示與隱藏el-scrollbar
Bar
組件el-option
和 el-option-group
渲染出來的 ul
標籤和 li
標籤針對本文還有一些未解決的問題:
el-tag
組件未詳細分析transition
組件未分析最後總結一下我看 select
組件的感覺吧,看源碼加寫這篇文章足足花了我一個周的時間,還只是看了一小部分,不得不說裏面涉及到的知識點太多太多了,對於我這樣一個前端小白來講實在是難度太大了,這一個星期常常會有看不下去的時候,有一些知識點我歷來就沒有見過,經過不斷地看文檔,不斷地查博客,漸漸地進入了一種享受的狀態,你把部分代碼拿到瀏覽器中跑一下,打個斷點一會兒就能明白原理(我真的不懂如何把整個項目跑起來,我試了不少方法都沒有成功)。這期間我也看了不少 Vue 的教程和 API,也在慢慢更新我對 Vue 的認知,我相信在之後開發中使用 Vue 必定會更加熟練,由於有了實際的項目去理解概念會變得很容易,慢慢地當你的積累足夠時你就能造成一個完整的知識閉環。在看源碼的時候「多去問幾個爲何」,真的很可以幫助你理解它。另一個就是組件的設計思想,它是一個很抽象的東西,光靠你看一兩個組件是沒有辦法理解的,你須要大量的閱讀組件源碼,而且知道它解決了什麼問題,有什麼功能,爲何要這樣設計,當你看一個組件的時候能很快搞明白這三個問題那麼組件的思想你也就具備了。這是必定要大量閱讀和實踐的狀況下才能有的,光靠你看幾篇博客,看兩個組件是沒有辦法實現的。
好了,期待下一篇的文章吧,若是你喜歡這篇文章,不妨點個贊讓更多人看見,若是文章中有分析的不對的地方,歡迎指出,也能夠加個人微信【Liu472362746】一塊兒討論,同時詳細代碼我也會推送至 Github 倉庫。
【2020.3.15】超詳細 ElementUI 源碼分析 —— Input
【2020.3.16】超詳細 ElementUI 源碼分析 —— Layout
【2020.3.18】超詳細 ElementUI 源碼分析 —— Radio