阅读 5661

Element-UI 技术揭秘(3)— Layout 布局组件的设计与实现

前言

当我们拿到一个 PC 端页面的设计稿的时候,往往会发现页面的布局并不是随意的,而是遵循的一定的规律:行与行之间会以某种方式对齐。对于这样的设计稿,我们可以使用栅格布局来实现。

早在 Bootstrap 一统江湖的时代,栅格布局的概念就已深入人心,整个布局就是一个二维结构,包括列和行, Bootstrap 会把屏幕分成 12 列,还提供了一些非常方便的 CSS 名让我们来指定每列占的宽度百分比,并且还通过媒体查询做了不同屏幕尺寸的适应。

element-ui 也实现了类似 Bootstrap 的栅格布局系统,那么基于 Vue 技术栈,它是如何实现的呢?

需求分析

和 Bootstrap 12 分栏不同的是,element-ui 目标是提供的是更细粒度的 24 分栏,迅速简便地创建布局,写法大致如下:

<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 Layout 布局组件的需求后,我们来分析它的设计和实现。

设计和实现

组件的渲染

回顾前面的例子,从写法上看,我们需要设计 2 个组件,el-rowel-col 组件,分别代表行和列;从 Vue 的语法上看,这俩组件都要支持插槽(因为在自定义组件标签内部的内容都分发到组件的 slot 中了);从 HTML 的渲染结果上看,我们希望模板会渲染成:

<div class="el-row">
  <div class="el-col">aaa</div>
  <div class="el-col">bbb</div>
</div>
<div class="el-row">
  ...
</div>
复制代码

想达到上述需求,组件的模板可以非常简单。

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

关注下面的标签,发现更多相似文章
评论