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 技术揭秘》系列文章会第一时间在公众号更新和发布,除此以外,我还会常常分享一些前端进阶知识,干货,也会偶尔分享一些软素质技能,欢迎你们关注喔~

相关文章
相关标签/搜索