【第1721期】Element-UI 技术揭秘 — Layout布局组件的设计与实现

前言

Element-UI 技术揭秘系列第二篇。今日早读文章由Zoom架构师@黄轶授权分享。

@黄轶,慕课网明星讲师,better-scroll 作者,Vue.js 布道师,曾就职于百度和滴滴,目前在 Zoom 担任前端架构师,推进前后端分离架构方案,同时负责 Zoom 自研组件库。公号ID:老黄的前端私房菜

正文从这开始~~

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

早在 Bootstrap 一统江湖的时代,栅格布局的概念就已深入人心,整个布局就是一个二维结构,包括列和行, Bootstrap 会把屏幕分成 12 列,还提供了一些非常方便的 CSS 名让我们来指定每列占的宽度百分比,并且还通过媒体查询做了不同屏幕尺寸的适应。element-ui 也实现了类似 Bootstrap 的栅格布局系统,那么基于 Vue 技术栈,它是如何实现的呢?

需求分析

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

  1. <el-row>

  2. <el-col>aaa</el-col>

  3. <el-col>bbb</el-col>

  4. </el-row>

  5. <el-row>

  6. ...

  7. </el-row>

这就是二维布局的雏形,我们会把每个列的内容写在之间,除此之外,我们还需要支持控制每个 <el-col> 所占的宽度自由组合布局;支持分栏之间存在间隔;支持偏移指定的栏数;支持分栏不同的对齐方式等。

了解了 element-ui Layout 布局组件的需求后,我们来分析它的设计和实现。

设计和实现

组件的渲染

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

  1. <div class="el-row">

  2. <div class="el-col">aaa</div>

  3. <div class="el-col">bbb</div>

  4. </div>

  5. <div class="el-row">

  6. ...

  7. </div>

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

el-row 组件模板代码如下:

  1. <div class="el-row">

  2. <slot></slot>

  3. </div>

el-col 组件代码如下:

  1. <div class="el-col">

  2. <slot></slot>

  3. </div>

这个时候,新需求来了,我希望 el-row 和 el-col 组件不仅能渲染成 div,还可以渲染成任意我想指定的标签。

那么除了我们要支持一个 tag 的 prop 之外,仅用模板是难以实现了。

我们知道 Vue 的模板最终会编译成 render 函数,Vue 的组件也支持直接手写 render 函数,那这个需求用 render 函数实现就非常简单了。

el-row 组件:

  1. render(h) {

  2. return h(this.tag, {

  3. class: [

  4. 'el-row',

  5. ]

  6. }, this.$slots.default);

  7. }

el-col 组件:

  1. render(h) {

  2. return h(this.tag, {

  3. class: [

  4. 'el-col',

  5. ]

  6. }, this.$slots.default);

  7. }

其中,tag 是定义在 props 中的,h 是 Vue 内部实现的 $createElement 函数,如果对 render 函数语法还不太懂的同学,建议去看 Vue 的官网文档 render 函数部分。

了解了组件是如何渲染之后,我们来给 Layout 组件扩展一些 feature 。

分栏布局

Layout 布局的主要目标是支持 24 分栏,即一行能被切成 24 份,那么对于每一个 el-col ,我们想要知道它的占比,只需要指定它在 24 份中分配的份数即可。

于是我们给刚才的示例加上一些配置:

  1. <el-row>

  2. <el-col :span="8">aaa</el-col>

  3. <el-col :span="16">bbb</el-col>

  4. </el-row>

  5. <el-row>

  6. ...

  7. </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 如下:

  1. .el-col-12 {

  2. width: 50%

  3. }

为了满足 24 种情况,element-ui 使用了 sass 的控制指令,配合基本的计算公式:

  1. .el-col-0 {

  2. display: none;

  3. }


  4. @for $i from 0 through 24 {

  5. .el-col-#{$i} {

  6. width: (1 / 24 * $i * 100) * 1%;

  7. }

  8. }

所以当我们给 el-col 组件传入了 span 属性的时候,只需要给对应的节点渲染生成对应的 CSS 即可,于是我们可以扩展 render 函数:

  1. render(h) {

  2. let classList = [];

  3. classList.push(`el-col-${this.span}`);


  4. return h(this.tag, {

  5. class: [

  6. 'el-col',

  7. classList

  8. ]

  9. }, this.$slots.default);

  10. }

这样只要指定 span 属性的列就会添加 el-col-${span} 的样式,实现了分栏布局的需求。

分栏间隔

对于栅格布局来说,列与列之间有一定间隔空隙是常见的需求,这个需求的作用域是行,所以我们应该给 el-row 组件添加一个 gutter 的配置,如下:

  1. <el-row :gutter="20">

  2. <el-col :span="8">aaa</el-col>

  3. <el-col :span="16">bbb</el-col>

  4. </el-row>

  5. <el-row>

  6. ...

  7. </el-row>

有了配置,接下来如何实现间隔呢?实际上非常简单,想象一下,2 个列之间有 20 像素的间隔,如果我们每列各往一边收缩 10 像素,是不是看上去就有 20 像素了呢。

先看一下 el-col 组件的实现:

  1. computed: {

  2. gutter() {

  3. let parent = this.$parent;

  4. while (parent && parent.$options.componentName !== 'ElRow') {

  5. parent = parent.$parent;

  6. }

  7. return parent ? parent.gutter : 0;

  8. }

  9. },

  10. render(h) {

  11. let classList = [];

  12. classList.push(`el-col-${this.span}`);


  13. let style = {};


  14. if (this.gutter) {

  15. style.paddingLeft = this.gutter / 2 + 'px';

  16. style.paddingRight = style.paddingLeft;

  17. }


  18. return h(this.tag, {

  19. class: [

  20. 'el-col',

  21. classList

  22. ]

  23. }, this.$slots.default);

  24. }

这里使用了计算属性去计算 gutter,其实是比较有趣的,它通过 $parent 往外层查找 el-row,获取到组件的实例,然后获取它的 gutter 属性,这样就建立了依赖关系,一旦 el-row 组件的 gutter 发生变化,这个计算属性再次被访问的时候就会重新计算,获取到新的 gutter。

其实,想在子组件去获取祖先节点的组件实例,我更推荐使用 provide/inject 的方式去把祖先节点的实例注入到子组件中,这样子组件可以非常方便地拿到祖先节点的实例,比如我们在 el-row 组件编写 provide:

  1. provide() {

  2. return {

  3. row: this

  4. };

  5. }

然后在 el-col 组件注入依赖:

  1. inject: ['row']

这样在 el-col 组件中我们就可以通过 this.row 访问到 el-row 组件实例了。

使用 provide/inject 的好处在于不论组件层次有多深,子孙组件可以方便地访问祖先组件注入的依赖。当你在编写组件库的时候,遇到嵌套组件并且子组件需要访问父组件实例的时候,避免直接使用 this. $parent,尽量使用 provide/inject,因为一旦你的组件嵌套关系发生变化,this.$parent 可能就不符合预期了,而 provide/inject 却不受影响(只要祖先和子孙的关系不变)。

在 render 函数中,我们会根据 gutter 计算,给当前列添加了 paddingLeft 和 paddingRight 的样式,值是 gutter 的一半,这样就实现了间隔 gutter 的效果。

那么这里能否用 margin 呢,答案是不能,因为设置 margin 会占用外部的空间,导致每列的占用空间变大,会出现折行的情况。

render 过程也是有优化的空间,因为 style 是根据 gutter 计算的,那么我们可以把 style 定义成计算属性,这样只要 gutter 不变,那么 style 就可以直接拿计算属性的缓存,而不用重新计算,对于 classList 部分,我们同样可以使用计算属性。组件 render 过程的一个原则就是能用计算属性就用计算属性。

再来看一下 el-col 组件的实现:

  1. computed: {

  2. style() {

  3. const ret = {};


  4. if (this.gutter) {

  5. ret.marginLeft = `-${this.gutter / 2}px`;

  6. ret.marginRight = ret.marginLeft;

  7. }


  8. return ret;

  9. }

  10. },

  11. render(h) {

  12. return h(this.tag, {

  13. class: [

  14. 'el-row',

  15. ],

  16. style: this.style

  17. }, this.$slots.default);

  18. }

由于我们是通过给每列添加左右 padding 的方式来实现列之间的间隔,那么对于第一列和最后一列,左边和右边也会多出来 gutter/2 大小的间隔,显然是不符合预期的,所以我们可以通过设置左右负 margin 的方式填补左右的空白,这样就完美实现了分栏间隔的效果。

偏移指定的栏数

如图所示,我们也可以指定某列的偏移,由于作用域是列,我们应该给 el-col 组件添加一个 offset 的配置,如下:

  1. <el-row :gutter="20">

  2. <el-col :offset="8" :span="8">aaa</el-col>

  3. <el-col :span="8">bbb</el-col>

  4. </el-row>

  5. <el-row>

  6. ...

  7. </el-row>

直观上我们应该用 margin 来实现偏移,并且 margin 也是支持百分比的,因此实现这个需求就变得简单了。

我们继续扩展 el-col 组件:

  1. render(h) {

  2. let classList = [];

  3. classList.push(`el-col-${this.span}`);

  4. classList.push(`el-col-offset-${this.offset}`);


  5. let style = {};


  6. if (this.gutter) {

  7. style.paddingLeft = this.gutter / 2 + 'px';

  8. style.paddingRight = style.paddingLeft;

  9. }


  10. return h(this.tag, {

  11. class: [

  12. 'el-col',

  13. classList

  14. ]

  15. }, this.$slots.default);

  16. }

其中 offset 是定义在 props 中的,我们根据传入的 offset 生成对应的 CSS 添加到 DOM 中。element-ui 同样使用了 sass 的控制指令,配合基本的计算公式来实现这些 CSS 的定义:

  1. @for $i from 0 through 24 {

  2. .el-col-offset-#{$i} {

  3. margin-left: (1 / 24 * $i * 100) * 1%;

  4. }

  5. }

对于不同偏移的分栏数,会有对应的 margin 百分比,就很好地实现分栏偏移需求。

对齐方式

当一行分栏的总占比和没有达到 24 的时候,我们是可以利用 flex 布局来对分栏做灵活的对齐。

对于不同的对齐方式 flex 布局提供了 justify-content 属性,所以对于这个需求,我们可以对 flex 布局做一层封装即可实现。

由于对齐方式的作用域是行,所以我们应该给 el-row 组件添加 type 和 justify 的配置,如下:

  1. <el-row type="flex" justify="center">

  2. <el-col :span="8">aaa</el-col>

  3. <el-col :span="8">bbb</el-col>

  4. </el-row>

  5. <el-row>

  6. ...

  7. </el-row>

由于我们是对 flex 布局的封装,我们只需要根据传入的这些 props 去生成对应的 CSS,在 CSS 中定义 flex 的布局属性即可。

我们继续扩展 el-row 组件:

  1. render(h) {

  2. return h(this.tag, {

  3. class: [

  4. 'el-row',

  5. this.justify !== 'start' ? `is-justify-${this.justify}` : '',

  6. { 'el-row--flex': this.type === 'flex' }

  7. ],

  8. style: this.style

  9. }, this.$slots.default);

  10. }

其中 type 和 justify 是定义在 props 中的,我们根据它们传入的值生成对应的 CSS 添加到 DOM 中,接着我们需要定义对应的 CSS 样式:

  1. @include b(row) {

  2. position: relative;

  3. box-sizing: border-box;

  4. @include utils-clearfix;


  5. @include m(flex) {

  6. display: flex;

  7. &:before,

  8. &:after {

  9. display: none;

  10. }


  11. @include when(justify-center) {

  12. justify-content: center;

  13. }

  14. @include when(justify-end) {

  15. justify-content: flex-end;

  16. }

  17. @include when(justify-space-between) {

  18. justify-content: space-between;

  19. }

  20. @include when(justify-space-around) {

  21. justify-content: space-around;

  22. }

  23. }

  24. }

element-ui 在编写 sass 的时候主要遵循的是 BEM 的命名规则,并且编写了很多自定义 @mixin 来配合样式名的定义。

这里我们来花点时间来学习一下它们,element-ui 的自定义 @mixin 定义在 pacakages/theme-chalk/src/mixins/ 目录中,我并不会详细解释这里面的关键字,如果你对 sass 还不熟悉,我建议在学习这部分内容的时候配合 sass 的官网文档看。

mixins/config.scss 中定义了一些全局变量:

  1. $namespace: 'el';

  2. $element-separator: '__';

  3. $modifier-separator: '--';

  4. $state-prefix: 'is-';

mixins/mixins.scss 中定义了 BEM 的自定义 @mixin,先来看一下定义组件样式的 @mixin b:

  1. @mixin b($block) {

  2. $B: $namespace+'-'+$block !global;


  3. .#{$B} {

  4. @content;

  5. }

  6. }

这个 @mixin 很好理解, $B 是内部定义的变量,它的值通过 $namespace+'-'+$block 计算得到,注意这里有一个 !global 关键字,它表示把这个局部变量变成全局的,意味着你也可以在其它 @mixin 中引用它。

通过 @include 我们就可以去引用这个 @mixin,结合我们的 case 来看:

  1. @include b(row) {

  2. // xxx content

  3. }

会编译成:

  1. .el-row {

  2. // xxx content

  3. }

再来看表示修饰符的 @mixin m:

  1. @mixin m($modifier) {

  2. $selector: &;

  3. $currentSelector: "";

  4. @each $unit in $modifier {

  5. $currentSelector: #{$currentSelector + & + $modifier-separator + $unit + ","};

  6. }


  7. @at-root {

  8. #{$currentSelector} {

  9. @content;

  10. }

  11. }

  12. }

这里是允许传入的 $modifier 有多个,所以内部用了 @each, & 表示父选择器, $selector$currentSelector 是内部定义的 2 个局部变量,结合我们的 case 来看:

  1. @mixin b(row) {

  2. @include m(flex) {

  3. // xxx content

  4. }

  5. }

会编译成:

  1. .el-row--flex {

  2. // xxx content

  3. }

有同学可能会疑问,难道不是:

  1. .el-row {

  2. .el-row--flex {

  3. // xxx content

  4. }

  5. }

其实并不是,因为我们在该 @mixin 的内部使用了 @at-root 指令,它会把样式规则定义在根目录下,而不是嵌套在其父选择器下。

最后来看一下表示同级样式的 @mixin when:

  1. @mixin when($state) {

  2. @at-root {

  3. &.#{$state-prefix + $state} {

  4. @content;

  5. }

  6. }

  7. }

这个 @mixin 也很好理解,结合我们的 case 来看:

  1. @mixin b(row) {

  2. @include m(flex) {

  3. @include when(justify-center) {

  4. justify-content: center;

  5. }

  6. }

  7. }

会编译成:

  1. .el-row--flex.is-justify-center {

  2. justify-content: center;

  3. }

关于 BEM 的 @mixin,常用的还有 @mixin e,用于定义组件内部一些子元素样式的,感兴趣的同学可以自行去看。

再回到我们的 el-row 组件的样式,我们定义了几种flex 布局的对齐方式,然后通过传入不同的 justify 来生成对应的样式,这样我们就很好地实现了灵活对齐分栏的需求。

响应式布局

element-ui 参照了 Bootstrap 的响应式设计,预设了五个响应尺寸:xs、sm、md、lg 和 xl。

允许我们在不同的屏幕尺寸下,设置不同的分栏配置,由于作用域是列,所以我们应该给 el-col 组件添加 xs xs、sm、md、lg 和 xl 的配置,如下:

  1. <el-row type="flex" justify="center">

  2. <el-col :xs="8" :sm="6" :md="4" :lg="3" :xl="1">aaa</el-col>

  3. <el-col :xs="4" :sm="6" :md="8" :lg="9" :xl="11">bbb</el-col>

  4. <el-col :xs="4" :sm="6" :md="8" :lg="9" :xl="11">ccc</el-col>

  5. <el-col :xs="8" :sm="6" :md="4" :lg="3" :xl="1">ddd</el-col>

  6. </el-row>

  7. <el-row>

  8. ...

  9. </el-row>

同理,我们仍然是通过这些传入的 props 去生成对应的 CSS,在 CSS 中利用媒体查询去实现响应式。

我们继续扩展 el-col 组件:

  1. render(h) {

  2. let classList = [];

  3. classList.push(`el-col-${this.span}`);

  4. classList.push(`el-col-offset-${this.offset}`);


  5. ['xs', 'sm', 'md', 'lg', 'xl'].forEach(size => {

  6. classList.push(`el-col-${size}-${this[size]}`);

  7. });


  8. let style = {};


  9. if (this.gutter) {

  10. style.paddingLeft = this.gutter / 2 + 'px';

  11. style.paddingRight = style.paddingLeft;

  12. }


  13. return h(this.tag, {

  14. class: [

  15. 'el-col',

  16. classList

  17. ]

  18. }, this.$slots.default);

  19. }

其中,xs、sm、md、lg 和 xl 是定义在 props 中的,实际上 element-ui 源码还允许传入一个对象,可以配置 span 和 offset,但这部分代码我就不介绍了,无非就是对对象的解析,添加对应的样式。

我们来看一下对应的 CSS 样式,以 xs 为例:

  1. @include res(xs) {

  2. .el-col-xs-0 {

  3. display: none;

  4. }

  5. @for $i from 0 through 24 {

  6. .el-col-xs-#{$i} {

  7. width: (1 / 24 * $i * 100) * 1%;

  8. }


  9. .el-col-xs-offset-#{$i} {

  10. margin-left: (1 / 24 * $i * 100) * 1%;

  11. }

  12. }

  13. }

这里又定义了表示响应式的 @mixin res,我们来看一下它的实现:

  1. @mixin res($key, $map: $--breakpoints) {

  2. // 循环断点Map,如果存在则返回

  3. @if map-has-key($map, $key) {

  4. @media only screen and #{inspect(map-get($map, $key))} {

  5. @content;

  6. }

  7. } @else {

  8. @warn "Undefeined points: `#{$map}`";

  9. }

  10. }

这个 @mixns 主要是查看 $map 中是否有 $key,如果有的话则定义一条媒体查询规则,如果没有则抛出警告。

$map 参数的默认值是 $--breakpoints,定义在 pacakges/theme-chalk/src/common/var.scss 中:

  1. $--sm: 768px !default;

  2. $--md: 992px !default;

  3. $--lg: 1200px !default;

  4. $--xl: 1920px !default;


  5. $--breakpoints: (

  6. 'xs' : (max-width: $--sm - 1),

  7. 'sm' : (min-width: $--sm),

  8. 'md' : (min-width: $--md),

  9. 'lg' : (min-width: $--lg),

  10. 'xl' : (min-width: $--xl)

  11. );

结合我们的 case 来看:

  1. @include res(xs) {

  2. .el-col-xs-0 {

  3. display: none;

  4. }

  5. @for $i from 0 through 24 {

  6. .el-col-xs-#{$i} {

  7. width: (1 / 24 * $i * 100) * 1%;

  8. }


  9. .el-col-xs-offset-#{$i} {

  10. margin-left: (1 / 24 * $i * 100) * 1%;

  11. }

  12. }

  13. }

会编译成:

  1. @media only screen and (max-width: 767px) {

  2. .el-col-xs-0 {

  3. display: none;

  4. }

  5. .el-col-xs-1 {

  6. width: 4.16667%

  7. }

  8. .el-col-xs-offset-1 {

  9. margin-left: 4.16667%

  10. }

  11. // 后面循环的结果太长,就不贴了

  12. }

其它尺寸内部的样式定义规则也是类似,这样我们就通过媒体查询定义了各个屏幕尺寸下的样式规则了。通过传入 xs、sm 这些属性的值不同,从而生成不同样式,这样在不同的屏幕尺寸下,可以做到分栏的占宽不同,很好地满足了响应式需求。

基于断点的隐藏类

Element 额外提供了一系列类名,用于在某些条件下隐藏元素,这些类名可以添加在任何 DOM 元素或自定义组件上。

我们可以通过引入单独的 display.css:

  1. 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:

  1. .hidden {

  2. @each $break-point-name, $value in $--breakpoints-spec {

  3. &-#{$break-point-name} {

  4. @include res($break-point-name, $--breakpoints-spec) {

  5. display: none !important;

  6. }

  7. }

  8. }

  9. }

实现很简单,对 $--breakpoints-spec 遍历,生成对应的 CSS 规则,$--breakpoints-spec 定义在 pacakges/theme-chalk/src/common/var.scss 中:

  1. $--breakpoints-spec: (

  2. 'xs-only' : (max-width: $--sm - 1),

  3. 'sm-and-up' : (min-width: $--sm),

  4. 'sm-only': "(min-width: #{$--sm}) and (max-width: #{$--md - 1})",

  5. 'sm-and-down': (max-width: $--md - 1),

  6. 'md-and-up' : (min-width: $--md),

  7. 'md-only': "(min-width: #{$--md}) and (max-width: #{$--lg - 1})",

  8. 'md-and-down': (max-width: $--lg - 1),

  9. 'lg-and-up' : (min-width: $--lg),

  10. 'lg-only': "(min-width: #{$--lg}) and (max-width: #{$--xl - 1})",

  11. 'lg-and-down': (max-width: $--xl - 1),

  12. 'xl-only' : (min-width: $--xl),

  13. );

我们以 xs-only 为例,编译后生成的 CSS 规则如下:

  1. .hidden-xs-only {

  2. @media only screen and (max-width:767px) {

  3. display: none !important;

  4. }

  5. }

本质上还是利用媒体查询定义了这些 CSS 规则,实现了在某些屏幕尺寸下隐藏的功能。

总结

其实 Layout 布局还支持了其它一些特性,我不一一列举了,感兴趣的同学可以自行去看。Layout 布局组件充分利用了数据驱动的思想,通过数据去生成对应的 CSS,本质上还是通过 CSS 满足各种灵活的布局。

学习完这篇文章,你应该彻底弄懂 element-ui Layout 布局组件的实现原理,并且对 sass 的 @mixin 以及相关使用到的特性有所了解,对组件实现过程中可以优化的部分,应该有自己的思考。

关于本文 作者:@黄轶 链接:https://mp.weixin.qq.com/s/8dEAnWIn5faKXQY06eKiYw

为你推荐


【第1715期】Element-UI 技术揭秘 - 组件库的整体设计


【第1106期】Element 中的键盘可访问性