Element-UI 技術揭祕(3)— Layout 佈局組件的設計與實現

前言

當咱們拿到一個 PC 端頁面的設計稿的時候,每每會發現頁面的佈局並非隨意的,而是遵循的必定的規律:行與行之間會以某種方式對齊。對於這樣的設計稿,咱們可使用柵格佈局來實現。css

早在 Bootstrap 一統江湖的時代,柵格佈局的概念就已深刻人心,整個佈局就是一個二維結構,包括列和行, Bootstrap 會把屏幕分紅 12 列,還提供了一些很是方便的 CSS 名讓咱們來指定每列佔的寬度百分比,而且還經過媒體查詢作了不一樣屏幕尺寸的適應。html

element-ui 也實現了相似 Bootstrap 的柵格佈局系統,那麼基於 Vue 技術棧,它是如何實現的呢?前端

需求分析

和 Bootstrap 12 分欄不一樣的是,element-ui 目標是提供的是更細粒度的 24 分欄,迅速簡便地建立佈局,寫法大體以下:vue

<el-row>
  <el-col>aaa</el-col>
  <el-col>bbb</el-col>
</el-row>
<el-row>
  ...
</el-row>
複製代碼

這就是二維佈局的雛形,咱們會把每一個列的內容寫在 <el-col></el-col> 之間,除此以外,咱們還須要支持控制每一個 <el-col> 所佔的寬度自由組合佈局;支持分欄之間存在間隔;支持偏移指定的欄數;支持分欄不一樣的對齊方式等。element-ui

瞭解了 element-ui Layout 佈局組件的需求後,咱們來分析它的設計和實現。緩存

設計和實現

組件的渲染

回顧前面的例子,從寫法上看,咱們須要設計 2 個組件,el-rowel-col 組件,分別表明行和列;從 Vue 的語法上看,這倆組件都要支持插槽(由於在自定義組件標籤內部的內容都分發到組件的 slot 中了);從 HTML 的渲染結果上看,咱們但願模板會渲染成:sass

<div class="el-row">
  <div class="el-col">aaa</div>
  <div class="el-col">bbb</div>
</div>
<div class="el-row">
  ...
</div>
複製代碼

想達到上述需求,組件的模板能夠很是簡單。ide

el-row 組件模板代碼以下:函數

<div class="el-row">
  <slot></slot>
</div>
複製代碼

el-col 組件代碼以下:佈局

<div class="el-col">
  <slot></slot>
</div>
複製代碼

這個時候,新需求來了,我但願 el-rowel-col 組件不只能渲染成 div,還能夠渲染成任意我想指定的標籤。 那麼除了咱們要支持一個 tagprop 以外,僅用模板是難以實現了。

咱們知道 Vue 的模板最終會編譯成 render 函數,Vue 的組件也支持直接手寫 render 函數,那這個需求用 render 函數實現就很是簡單了。

el-row 組件:

render(h) {
  return h(this.tag, {
    class: [
      'el-row',
    ]
  }, this.$slots.default);
}
複製代碼

el-col 組件:

render(h) {
  return h(this.tag, {
    class: [
      'el-col',
    ]
  }, this.$slots.default);
}
複製代碼

其中,tag 是定義在 props 中的,h 是 Vue 內部實現的 $createElement 函數,若是對 render 函數語法還不太懂的同窗,建議去看 Vue 的官網文檔 render 函數部分

瞭解了組件是如何渲染以後,咱們來給 Layout 組件擴展一些 feature 。

分欄佈局

Layout 佈局的主要目標是支持 24 分欄,即一行能被切成 24 份,那麼對於每個 el-col ,咱們想要知道它的佔比,只須要指定它在 24 份中分配的份數便可。

因而咱們給剛纔的示例加上一些配置:

<el-row>
  <el-col :span="8">aaa</el-col>
  <el-col :span="16">bbb</el-col>
</el-row>
<el-row>
  ...
</el-row>
複製代碼

來看第一行,第一列 aaa 佔 8 份,第二列 bbb 佔 16 份。總共寬度是 24 份,通過簡單的數學公式計算,aaa 佔總寬度的 1/3,而 bbb 佔總寬度的 2/3,進而推導出每一列指定 span 份就是佔總寬度的 span/24

默認狀況下 div 的寬度是 100% 獨佔一行的,爲了讓多個 el-col 在一行顯示,咱們只須要讓每一個 el-col 的寬佔必定的百分比,即實現了分欄效果。設置不一樣的寬度百分比只須要設置不一樣的 CSS 便可實現,好比當某列佔 12 份的時候,那麼它對應的 CSS 以下:

.el-col-12 {
  width: 50%
}
複製代碼

爲了知足 24 種狀況,element-ui 使用了 sass 的控制指令,配合基本的計算公式:

.el-col-0 {
  display: none;
}

@for $i from 0 through 24 {
  .el-col-#{$i} {
    width: (1 / 24 * $i * 100) * 1%;
  }
}
複製代碼

因此當咱們給 el-col 組件傳入了 span 屬性的時候,只須要給對應的節點渲染生成對應的 CSS 便可,因而咱們能夠擴展 render 函數:

render(h) {
  let classList = [];
  classList.push(`el-col-${this.span}`);
  
  return h(this.tag, {
    class: [
      'el-col',
       classList
    ]
  }, this.$slots.default);
}
複製代碼

這樣只要指定 span 屬性的列就會添加 el-col-${span} 的樣式,實現了分欄佈局的需求。

分欄間隔

對於柵格佈局來講,列與列之間有必定間隔空隙是常見的需求,這個需求的做用域是行,因此咱們應該給 el-row 組件添加一個 gutter 的配置,以下:

<el-row :gutter="20">
  <el-col :span="8">aaa</el-col>
  <el-col :span="16">bbb</el-col>
</el-row>
<el-row>
  ...
</el-row>
複製代碼

有了配置,接下來如何實現間隔呢?實際上很是簡單,想象一下,2 個列之間有 20 像素的間隔,若是咱們每列各往一邊收縮 10 像素,是否是看上去就有 20 像素了呢。

先看一下 el-col 組件的實現:

computed: {
  gutter() {
    let parent = this.$parent;
    while (parent && parent.$options.componentName !== 'ElRow') {
      parent = parent.$parent;
    }
    return parent ? parent.gutter : 0;
  }
},
render(h) {
  let classList = [];
  classList.push(`el-col-${this.span}`);
  
  let style = {};
  
  if (this.gutter) {
    style.paddingLeft = this.gutter / 2 + 'px';
    style.paddingRight = style.paddingLeft;
  }
  
  return h(this.tag, {
    class: [
      'el-col',
       classList
    ]
  }, this.$slots.default);
}
複製代碼

這裏使用了計算屬性去計算 gutter,實際上是比較有趣的,它經過 $parent 往外層查找 el-row,獲取到組件的實例,而後獲取它的 gutter 屬性,這樣就創建了依賴關係,一旦 el-row 組件的 gutter 發生變化,這個計算屬性再次被訪問的時候就會從新計算,獲取到新的 gutter

其實,想在子組件去獲取祖先節點的組件實例,我更推薦使用 provide/inject 的方式去把祖先節點的實例注入到子組件中,這樣子組件能夠很是方便地拿到祖先節點的實例,好比咱們在 el-row 組件編寫 provide

provide() {
  return {
    row: this
  };
}
複製代碼

而後在 el-col 組件注入依賴:

inject: ['row']
複製代碼

這樣在 el-col 組件中咱們就能夠經過 this.row 訪問到 el-row 組件實例了。

使用 provide/inject 的好處在於不論組件層次有多深,子孫組件能夠方便地訪問祖先組件注入的依賴。當你在編寫組件庫的時候,遇到嵌套組件而且子組件須要訪問父組件實例的時候,避免直接使用 this.$parent,儘可能使用 provide/inject,由於一旦你的組件嵌套關係發生變化,this.$parent 可能就不符合預期了,而 provide/inject 卻不受影響(只要祖先和子孫的關係不變)。

render 函數中,咱們會根據 gutter 計算,給當前列添加了 paddingLeftpaddingRight 的樣式,值是 gutter 的一半,這樣就實現了間隔 gutter 的效果。

那麼這裏可否用 margin 呢,答案是不能,由於設置 margin 會佔用外部的空間,致使每列的佔用空間變大,會出現折行的狀況。

render 過程也是有優化的空間,由於 style 是根據 gutter 計算的,那麼咱們能夠把 style 定義成計算屬性,這樣只要 gutter 不變,那麼 style 就能夠直接拿計算屬性的緩存,而不用從新計算,對於 classList 部分,咱們一樣可使用計算屬性。組件 render 過程的一個原則就是能用計算屬性就用計算屬性。

再來看一下 el-row 組件的實現:

computed: {
  style() {
    const ret = {};

    if (this.gutter) {
      ret.marginLeft = `-${this.gutter / 2}px`;
      ret.marginRight = ret.marginLeft;
    }

    return ret;
  }
},
render(h) {
  return h(this.tag, {
    class: [
      'el-row',
    ],
    style: this.style
  }, this.$slots.default);
}
複製代碼

因爲咱們是經過給每列添加左右 padding 的方式來實現列之間的間隔,那麼對於第一列和最後一列,左邊和右邊也會多出來 gutter/2 大小的間隔,顯然是不符合預期的,因此咱們能夠經過設置左右負 margin 的方式填補左右的空白,這樣就完美實現了分欄間隔的效果。

偏移指定的欄數

如圖所示,咱們也能夠指定某列的偏移,因爲做用域是列,咱們應該給 el-col 組件添加一個 offset 的配置,以下:

<el-row :gutter="20">
  <el-col :offset="8" :span="8">aaa</el-col>
  <el-col :span="8">bbb</el-col>
</el-row>
<el-row>
  ...
</el-row>
複製代碼

直觀上咱們應該用 margin 來實現偏移,而且 margin 也是支持百分比的,所以實現這個需求就變得簡單了。

咱們繼續擴展 el-col 組件:

render(h) {
  let classList = [];
  classList.push(`el-col-${this.span}`);
  classList.push(`el-col-offset-${this.offset}`);
  
  let style = {};
  
  if (this.gutter) {
    style.paddingLeft = this.gutter / 2 + 'px';
    style.paddingRight = style.paddingLeft;
  }
  
  return h(this.tag, {
    class: [
      'el-col',
       classList
    ]
  }, this.$slots.default);
}
複製代碼

其中 offset 是定義在 props 中的,咱們根據傳入的 offset 生成對應的 CSS 添加到 DOM 中。element-ui 一樣使用了 sass 的控制指令,配合基本的計算公式來實現這些 CSS 的定義:

@for $i from 0 through 24 {
  .el-col-offset-#{$i} {
    margin-left: (1 / 24 * $i * 100) * 1%;
  }
}
複製代碼

對於不一樣偏移的分欄數,會有對應的 margin 百分比,就很好地實現分欄偏移需求。

對齊方式

當一行分欄的總佔比和沒有達到 24 的時候,咱們是能夠利用 flex 佈局來對分欄作靈活的對齊。

對於不一樣的對齊方式 flex 佈局提供了 justify-content 屬性,因此對於這個需求,咱們能夠對 flex 佈局作一層封裝便可實現。

因爲對齊方式的做用域是行,因此咱們應該給 el-row 組件添加 typejustify 的配置,以下:

<el-row type="flex" justify="center">
  <el-col :span="8">aaa</el-col>
  <el-col :span="8">bbb</el-col>
</el-row>
<el-row>
  ...
</el-row>
複製代碼

因爲咱們是對 flex 佈局的封裝,咱們只須要根據傳入的這些 props 去生成對應的 CSS,在 CSS 中定義 flex 的佈局屬性便可。

咱們繼續擴展 el-row 組件:

render(h) {
  return h(this.tag, {
    class: [
      'el-row',
      this.justify !== 'start' ? `is-justify-${this.justify}` : '',
      { 'el-row--flex': this.type === 'flex' }
    ],
    style: this.style
  }, this.$slots.default);
}
複製代碼

其中 typejustify 是定義在 props 中的,咱們根據它們傳入的值生成對應的 CSS 添加到 DOM 中,接着咱們須要定義對應的 CSS 樣式:

@include b(row) {
  position: relative;
  box-sizing: border-box;
  @include utils-clearfix;

  @include m(flex) {
    display: flex;
    &:before,
    &:after {
      display: none;
    }

    @include when(justify-center) {
      justify-content: center;
    }
    @include when(justify-end) {
      justify-content: flex-end;
    }
    @include when(justify-space-between) {
      justify-content: space-between;
    }
    @include when(justify-space-around) {
      justify-content: space-around;
    }
  }
}
複製代碼

element-ui 在編寫 sass 的時候主要遵循的是 BEM 的命名規則,而且編寫了不少自定義 @mixin 來配合樣式名的定義。

這裏咱們來花點時間來學習一下它們,element-ui 的自定義 @mixin 定義在 pacakages/theme-chalk/src/mixins/ 目錄中,我並不會詳細解釋這裏面的關鍵字,若是你對 sass 還不熟悉,我建議在學習這部份內容的時候配合 sass 的官網文檔看。

mixins/config.scss 中定義了一些全局變量:

$namespace: 'el';
$element-separator: '__';
$modifier-separator: '--';
$state-prefix: 'is-';
複製代碼

mixins/mixins.scss 中定義了 BEM 的自定義 @mixin,先來看一下定義組件樣式的 @mixin b

@mixin b($block) {
  $B: $namespace+'-'+$block !global;

  .#{$B} {
    @content;
  }
}
複製代碼

這個 @mixin 很好理解,$B 是內部定義的變量,它的值經過 $namespace+'-'+$block 計算獲得,注意這裏有一個 !global 關鍵字,它表示把這個局部變量變成全局的,意味着你也能夠在其它 @mixin 中引用它。

經過 @include 咱們就能夠去引用這個 @mixin,結合咱們的 case 來看:

@include b(row) {
  // xxx content
}
複製代碼

會編譯成:

.el-row {
  // xxx content
}
複製代碼

再來看錶示修飾符的 @mixin m

@mixin m($modifier) {
  $selector: &;
  $currentSelector: "";
  @each $unit in $modifier {
    $currentSelector: #{$currentSelector + & + $modifier-separator + $unit + ","};
  }

  @at-root {
    #{$currentSelector} {
      @content;
    }
  }
}
複製代碼

這裏是容許傳入的 $modifier 有多個,因此內部用了 @each& 表示父選擇器,$selector$currentSelector 是內部定義的 2 個局部變量,結合咱們的 case 來看:

@mixin b(row) {
  @include m(flex) {
    // xxx content
  }
}  
複製代碼

會編譯成:

.el-row--flex {
  // xxx content
}
複製代碼

有同窗可能會疑問,難道不是:

.el-row {
  .el-row--flex {
    // xxx content
  }
}
複製代碼

其實並非,由於咱們在該 @mixin 的內部使用了 @at-root 指令,它會把樣式規則定義在根目錄下,而不是嵌套在其父選擇器下。

最後來看一下表示同級樣式的 @mixin when

@mixin when($state) {
  @at-root {
    &.#{$state-prefix + $state} {
      @content;
    }
  }
}
複製代碼

這個 @mixin 也很好理解,結合咱們的 case 來看:

@mixin b(row) {
  @include m(flex) {
    @include when(justify-center) {
      justify-content: center;
    }
  }
}
複製代碼

會編譯成:

.el-row--flex.is-justify-center {
  justify-content: center;
}
複製代碼

關於 BEM 的 @mixin,經常使用的還有 @mixin e,用於定義組件內部一些子元素樣式的,感興趣的同窗能夠自行去看。

再回到咱們的 el-row 組件的樣式,咱們定義了幾種flex 佈局的對齊方式,而後經過傳入不一樣的 justify 來生成對應的樣式,這樣咱們就很好地實現了靈活對齊分欄的需求。

響應式佈局

element-ui 參照了 Bootstrap 的響應式設計,預設了五個響應尺寸:xssmmdlgxl

容許咱們在不一樣的屏幕尺寸下,設置不一樣的分欄配置,因爲做用域是列,因此咱們應該給 el-col 組件添加 xs xssmmdlgxl 的配置,以下:

<el-row type="flex" justify="center">
  <el-col :xs="8" :sm="6" :md="4" :lg="3" :xl="1">aaa</el-col>
  <el-col :xs="4" :sm="6" :md="8" :lg="9" :xl="11">bbb</el-col>
  <el-col :xs="4" :sm="6" :md="8" :lg="9" :xl="11">ccc</el-col>
  <el-col :xs="8" :sm="6" :md="4" :lg="3" :xl="1">ddd</el-col>
</el-row>
<el-row>
  ...
</el-row>
複製代碼

同理,咱們仍然是經過這些傳入的 props 去生成對應的 CSS,在 CSS 中利用媒體查詢去實現響應式。

咱們繼續擴展 el-col 組件:

render(h) {
  let classList = [];
  classList.push(`el-col-${this.span}`);
  classList.push(`el-col-offset-${this.offset}`);
  
   ['xs', 'sm', 'md', 'lg', 'xl'].forEach(size => {
     classList.push(`el-col-${size}-${this[size]}`); 
   });
  
  let style = {};
  
  if (this.gutter) {
    style.paddingLeft = this.gutter / 2 + 'px';
    style.paddingRight = style.paddingLeft;
  }
  
  return h(this.tag, {
    class: [
      'el-col',
       classList
    ]
  }, this.$slots.default);
}
複製代碼

其中,xssmmdlgxl 是定義在 props 中的,實際上 element-ui 源碼還容許傳入一個對象,能夠配置 spanoffset,但這部分代碼我就不介紹了,無非就是對對象的解析,添加對應的樣式。

咱們來看一下對應的 CSS 樣式,以 xs 爲例:

@include res(xs) {
  .el-col-xs-0 {
    display: none;
  }
  @for $i from 0 through 24 {
    .el-col-xs-#{$i} {
      width: (1 / 24 * $i * 100) * 1%;
    }

    .el-col-xs-offset-#{$i} {
      margin-left: (1 / 24 * $i * 100) * 1%;
    }
  }
}
複製代碼

這裏又定義了表示響應式的 @mixin res,咱們來看一下它的實現:

@mixin res($key, $map: $--breakpoints) {
  // 循環斷點Map,若是存在則返回
  @if map-has-key($map, $key) {
    @media only screen and #{inspect(map-get($map, $key))} {
      @content;
    }
  } @else {
    @warn "Undefeined points: `#{$map}`";
  }
}
複製代碼

這個 @mixns 主要是查看 $map 中是否有 $key,若是有的話則定義一條媒體查詢規則,若是沒有則拋出警告。

$map 參數的默認值是 $--breakpoints,定義在 pacakges/theme-chalk/src/common/var.scss 中:

$--sm: 768px !default;
$--md: 992px !default;
$--lg: 1200px !default;
$--xl: 1920px !default;

$--breakpoints: (
  'xs' : (max-width: $--sm - 1),
  'sm' : (min-width: $--sm),
  'md' : (min-width: $--md),
  'lg' : (min-width: $--lg),
  'xl' : (min-width: $--xl)
);
複製代碼

結合咱們的 case 來看:

@include res(xs) {
  .el-col-xs-0 {
    display: none;
  }
  @for $i from 0 through 24 {
    .el-col-xs-#{$i} {
      width: (1 / 24 * $i * 100) * 1%;
    }

    .el-col-xs-offset-#{$i} {
      margin-left: (1 / 24 * $i * 100) * 1%;
    }
  }
}
複製代碼

會編譯成:

@media only screen and (max-width: 767px) {
  .el-col-xs-0 {
    display: none;
  }
  .el-col-xs-1 {
    width: 4.16667%
  }
  .el-col-xs-offset-1 {
    margin-left: 4.16667%
  }
  // 後面循環的結果太長,就不貼了
}
複製代碼

其它尺寸內部的樣式定義規則也是相似,這樣咱們就經過媒體查詢定義了各個屏幕尺寸下的樣式規則了。經過傳入 xssm 這些屬性的值不一樣,從而生成不一樣樣式,這樣在不一樣的屏幕尺寸下,能夠作到分欄的佔寬不一樣,很好地知足了響應式需求。

基於斷點的隱藏類

Element 額外提供了一系列類名,用於在某些條件下隱藏元素,這些類名能夠添加在任何 DOM 元素或自定義組件上。

咱們能夠經過引入單獨的 display.css

import 'element-ui/lib/theme-chalk/display.css';
複製代碼

它包含的類名及其含義以下:

  • hidden-xs-only - 當視口在 xs 尺寸時隱藏
  • hidden-sm-only - 當視口在 sm 尺寸時隱藏
  • hidden-sm-and-down - 當視口在 sm 及如下尺寸時隱藏
  • hidden-sm-and-up - 當視口在 sm 及以上尺寸時隱藏
  • hidden-md-only - 當視口在 md 尺寸時隱藏
  • hidden-md-and-down - 當視口在 md 及如下尺寸時隱藏
  • hidden-md-and-up - 當視口在 md 及以上尺寸時隱藏
  • hidden-lg-only - 當視口在 lg 尺寸時隱藏
  • hidden-lg-and-down - 當視口在 lg 及如下尺寸時隱藏
  • hidden-lg-and-up - 當視口在 lg 及以上尺寸時隱藏
  • hidden-xl-only - 當視口在 xl 尺寸時隱藏

咱們來看一下它的實現,看一下 display.scss

.hidden {
  @each $break-point-name, $value in $--breakpoints-spec {
    &-#{$break-point-name} {
      @include res($break-point-name, $--breakpoints-spec) {
        display: none !important;
      }
    }
  }
}
複製代碼

實現很簡單,對 $--breakpoints-spec 遍歷,生成對應的 CSS 規則,$--breakpoints-spec 定義在 pacakges/theme-chalk/src/common/var.scss 中:

$--breakpoints-spec: (
  'xs-only' : (max-width: $--sm - 1),
  'sm-and-up' : (min-width: $--sm),
  'sm-only': "(min-width: #{$--sm}) and (max-width: #{$--md - 1})",
  'sm-and-down': (max-width: $--md - 1),
  'md-and-up' : (min-width: $--md),
  'md-only': "(min-width: #{$--md}) and (max-width: #{$--lg - 1})",
  'md-and-down': (max-width: $--lg - 1),
  'lg-and-up' : (min-width: $--lg),
  'lg-only': "(min-width: #{$--lg}) and (max-width: #{$--xl - 1})",
  'lg-and-down': (max-width: $--xl - 1),
  'xl-only' : (min-width: $--xl),
);
複製代碼

咱們以 xs-only 爲例,編譯後生成的 CSS 規則以下:

.hidden-xs-only {
  @media only screen and (max-width:767px) {
    display: none !important;
  }
}
複製代碼

本質上仍是利用媒體查詢定義了這些 CSS 規則,實現了在某些屏幕尺寸下隱藏的功能。

總結

其實 Layout 佈局還支持了其它一些特性,我不一一列舉了,感興趣的同窗能夠自行去看。Layout 佈局組件充分利用了數據驅動的思想,經過數據去生成對應的 CSS,本質上仍是經過 CSS 知足各類靈活的佈局。

學習完這篇文章,你應該完全弄懂 element-ui Layout 佈局組件的實現原理,而且對 sass@mixin 以及相關使用到的特性有所瞭解,對組件實現過程當中能夠優化的部分,應該有本身的思考。

把不會的東西學會了,那麼你就進步了,若是你以爲這類文章有幫助,也歡迎把它推薦給你身邊的小夥伴。

下一篇預告 :Element-UI 技術揭祕(4)— Container 佈局容器組件的設計與實現。

另外,我最近剛開了公衆號「老黃的前端私房菜」,《Element-UI 技術揭祕》系列文章會第一時間在公衆號更新和發佈,除此以外,我還會常常分享一些前端進階知識,乾貨,也會偶爾分享一些軟素質技能,歡迎你們關注喔~

相關文章
相關標籤/搜索