Element源码分析系列1一Layout(布局)

8,197 阅读6分钟

动机

感觉现在的业务开发,如果不是很特殊的需求,基本都能在对应的组件库内找到组件使用,这样编写代码就成了调用组件,但是却隐藏了组件内的思想,因此弱化了编程能力,所以我想写这么个分析系列来鞭策自己深入分析组件的原理,提高代码阅读理解能力,我觉得一定要记下点什么来,如果只是看不动笔感觉很快就忘了,因此准备持续写这么个分析

Element源码结构

官网传送门点此, 主要目录如下图

其中组件的源码放在package目录下,src中是一些工具函数(某些组件都会使用这些函数)和国际化相关的代码,进入package目录里,则是所有组件的源码

注意这些文件夹里只包含js或者vue,而所有组件的样式文件在最下面的theme-chalk文件夹里,整个项目结构还是很清晰

Layout(布局)源码分析

  • <el-row>源码分析

首先进入打开官网查看Layout相关部分的说明,发现主要的组件就2个: el-row,el-col,这2个分别代表行的容器和里面列的容器,类似于bootstrapcolrow,首先我们查看el-row的实现,进入package里面的row文件夹,里面是一个src文件夹和index.js文件

打开index.js,这里最后一句导出Row供我们import,而中间的install方法则是把这个组件当成一个Vue的插件来使用,通过Vue.use()来使用该组件,install方法传递一个Vue的构造器,Element的所有组件都是一个对象{...},里面有个render函数来创建组件的html结构,render方法的好处很大,使得创建html模板的代码更加简洁高效,而不是冗长的各种div标签堆叠,更类似于一种配置形式来创建html. 最后通过export default导出,而不是常用的单文件组件形式,因此必须提供install方法

import Row from './src/row';
/* istanbul ignore next */
Row.install = function(Vue) {
  //全局注册该组件(常用的组件最好全局注册)
  Vue.component(Row.name, Row);
};
export default Row;

这里其实有2种方法使用组件,一是当做插件,而是直接import后注册组件,官网示例代码如下,也可以不注册成全局组件

import Vue from 'vue';
import { Button, Select } from 'element-ui';
import App from './App.vue';
Vue.component(Button.name, Button);
Vue.component(Select.name, Select);
/* 或写为
 * Vue.use(Button)
 * Vue.use(Select)
 */
new Vue({
  el: '#app',
  render: h => h(App)
});

下面进入src/row.js中一探究竟,首先代码的整体结构如下,直接导出一个对象,里面是组件的各种配置项

export default {
    ...
}

整个组件的代码量不多,下面是给出了详细注释

export default {
  //组件名称,注意是驼峰命名法,这使得实际使用组件时短横线连接法<el-row>和驼峰法<ElRow>都可以使用
  name: 'ElRow',
  //自定义属性(该属性不是component必需属性),重要,用于后面<el-col>不断向父级查找该组件
  componentName: 'ElRow',
  //组件的props
  props: {
    //组件渲染成html的实际标签,默认是div
    tag: {
      type: String,
      default: 'div'
    },
    //该组件的里面的<el-col>组件的间隔
    gutter: Number,
    /* 组件是否是flex布局,将 type 属性赋值为 'flex',可以启用 flex 布局,
    *  并可通过 justify 属性来指定 start, center, end, space-between, space-around
    *  其中的值来定义子元素的排版方式。
    */ 
    type: String,
    //flex布局的justify属性
    justify: {
      type: String,
      default: 'start'
    },
    //flex布局的align属性
    align: {
      type: String,
      default: 'top'
    }
  },

  computed: {
    //row的左右margin,用于抵消col的padding,后面详细解释,注意是计算属性,这里通过gutter计算出实际margin
    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',
        this.justify !== 'start' ? `is-justify-${this.justify}` : '',
        this.align !== 'top' ? `is-align-${this.align}` : '',
        { 'el-row--flex': this.type === 'flex' }
      ],
      style: this.style
    }, this.$slots.default);
  }
};

下面说一下计算属性里面的sytle(),这里面通过gutter属性计算出了本组件的左右margin,且为负数,这里有点费解,下面上图解释,首先gutter的作用是让row里面的col产生出间隔来,但是注意容器的最左和最右侧是没有间隔的

上图就是最终示意图,黑框就是<el-row>的宽度范围,里面是<el-col>组件,下一节介绍, 这个组件的宽度其实按<el-row>百分比来计算,而且box-sizingborder-box,注意gutter属性是定义在父级的<el-row>上,子级的col通过$parent可以拿到该属性,然后给<el-col>分配padding-leftpadding-right,因此每个col都有左右padding,上图中每个col占宽25%,gutter的宽度就是col的padding的2倍,但是注意最左侧和最右侧是没有padding的,那么问题来了,怎么消去最左和最右的padding? 这里就是<el-row>负的margin起的作用,如果不设置上面的计算属性的style,那么左右2侧就会有col的padding,因此这里负的margin抵消了col的padding,且该值为 -gutter/2+'px'

注意如果初看上面的图,一般的想法是col之间用margin来间隔,其实是不行的,而用padding来间隔就很简单,width按百分比来分配就行(box-sizing要设置为border-box)

下面解释下最后返回的渲染函数render,这个函数有3个参数,第一个参数是html的tag名称(最终在网页中显示的标签名),第二个参数是一个包含模板相关属性的数据对象,里面有相当多模板相关的属性,如下

{
  // 和`v-bind:class`一样的 API
  // 接收一个字符串、对象或字符串和对象组成的数组
  'class': {
    foo: true,
    bar: false
  },
  // 和`v-bind:style`一样的 API
  // 接收一个字符串、对象或对象组成的数组
  style: {
    color: 'red',
    fontSize: '14px'
  },
  // 正常的 HTML 特性
  attrs: {
    id: 'foo'
  },
  // 组件 props
  props: {
    myProp: 'bar'
  },
  // DOM 属性
  domProps: {
    innerHTML: 'baz'
  },
  // 事件监听器基于 `on`
  // 所以不再支持如 `v-on:keyup.enter` 修饰器
  // 需要手动匹配 keyCode。
  on: {
    click: this.clickHandler
  },
  // 仅对于组件,用于监听原生事件,而不是组件内部使用
  // `vm.$emit` 触发的事件。
  nativeOn: {
    click: this.nativeClickHandler
  },
  // 自定义指令。注意,你无法对 `binding` 中的 `oldValue`
  // 赋值,因为 Vue 已经自动为你进行了同步。
  directives: [
    {
      name: 'my-custom-directive',
      value: '2',
      expression: '1 + 1',
      arg: 'foo',
      modifiers: {
        bar: true
      }
    }
  ],
  // 作用域插槽格式
  // { name: props => VNode | Array<VNode> }
  scopedSlots: {
    default: props => createElement('span', props.text)
  },
  // 如果组件是其他组件的子组件,需为插槽指定名称
  slot: 'name-of-slot',
  // 其他特殊顶层属性
  key: 'myKey',
  ref: 'myRef'
}

尤其注意第三个参数,它代表子节点,是一个String或者Array,当是String时代表文本节点的内容,此时这就是个文本节点,如果是Array,里面就是子节点,数组中每个值都是一个render的参数函数

[
    //文本节点
    '先写一些文字',
    createElement('h1', '一则头条'),
    createElement(MyComponent, {
      props: {
        someProp: 'foobar'
      }
    })
]

再看上面render函数的第三个参数是this.$slots.default,这里的意思就是获取该组件下面不是具名插槽的内容,default 属性包括了所有没有被包含在具名插槽中的节点,对于如下代码,该render函数就会把<el-row>以及<h1>test<h1>作为其子节点一起渲染出来

<el-row>
    <h1>test<h1>
    <slot name='t'>t1</slot>
</el-row>

最后解释下样式相关代码,row.scss的路径是packages/theme-chalk/src/row.scss,代码是scss类型,render里的class如下

class:[
        'el-row',
        this.justify !== 'start' ? `is-justify-${this.justify}` : '',
        this.align !== 'top' ? `is-align-${this.align}` : '',
        { 'el-row--flex': this.type === 'flex' }
      ],

这里的el-row类其实没有定义,可以自己在写代码时补充,官网就是这么用的,后面几个都是控制flex布局的,由此可见<el-row>默认占满父容器宽度且高度auto自适应

  • <el-col>源码分析
    col的使用也很简单,如下,有span,offset,pull,push等属性
<el-col :span="6" :offset="6"><div class="grid-content bg-purple"></div></el-col>

进入package/col查看,col的代码稍长,主要多出来的逻辑是控制自适应(@media screen)

export default {
  //组件名称
  name: 'ElCol',
  props: {
    //组件占父容器的列数,总共24列,如果设置为0则渲染出来display为none
    span: {
      type: Number,
      default: 24
    },
    //最终渲染出的标签名,默认div
    tag: {
      type: String,
      default: 'div'
    },
    //通过制定 col 组件的 offset 属性可以指定分栏向右偏移的栏数
    offset: Number,
    //栅格向右移动格数
    pull: Number,
    //栅格向左移动格数
    push: Number,
    //响应式相关
    xs: [Number, Object],
    sm: [Number, Object],
    md: [Number, Object],
    lg: [Number, Object],
    xl: [Number, Object]
  },

  computed: {
    //获取el-row的gutter值
    gutter() {
      let parent = this.$parent;
      //不断通过获取父元素直到找到el-row元素位置,注意这里的技巧,componentName实际
      //是el-row组件设置的一个自定义属性,用来判断是否是el-row组件
      while (parent && parent.$options.componentName !== 'ElRow') {
        parent = parent.$parent;
      }
      return parent ? parent.gutter : 0;
    }
  },
  render(h) {
    let classList = [];
    let style = {};
    //通过gutter计算自己的左右2个padding,达到分隔col的目的
    if (this.gutter) {
      style.paddingLeft = this.gutter / 2 + 'px';
      style.paddingRight = style.paddingLeft;
    }
    //处理布局相关,后面详细介绍
    ['span', 'offset', 'pull', 'push'].forEach(prop => {
      if (this[prop] || this[prop] === 0) {
        classList.push(
          prop !== 'span'
            ? `el-col-${prop}-${this[prop]}`
            : `el-col-${this[prop]}`
        );
      }
    });
    //处理屏幕响应式相关
    ['xs', 'sm', 'md', 'lg', 'xl'].forEach(size => {
      if (typeof this[size] === 'number') {
        classList.push(`el-col-${size}-${this[size]}`);
      } else if (typeof this[size] === 'object') {
        let props = this[size];
        Object.keys(props).forEach(prop => {
          classList.push(
            prop !== 'span'
              ? `el-col-${size}-${prop}-${props[prop]}`
              : `el-col-${size}-${props[prop]}`
          );
        });
      }
    });

    return h(this.tag, {
      class: ['el-col', classList],
      style
    }, this.$slots.default);
  }
};

下面解释下['span', 'offset', 'pull', 'push']这几个的作用,span很好理解,占父容器的列数,对应scss代码如下

[class*="el-col-"] {
  float: left;
  box-sizing: border-box;
}

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

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

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

  .el-col-pull-#{$i} {
    position: relative;
    right: (1 / 24 * $i * 100) * 1%;
  }

  .el-col-push-#{$i} {
    position: relative;
    left: (1 / 24 * $i * 100) * 1%;
  }
}

注意上面的[attribute*=value] 选择器,它选择了所有类名以el-col-开头的类,加上float和border-box,水平布局float肯定不可少,再看for循环,这里scss的威力就发挥了,如果只用css,那代码量要乘以24,el-col-数字类型的类的宽度就是百分比,下面的offset实际上是margin-left,这可能会导致一行排列不下所有的col,会导致换行出现,而el-col-pull则不同,仅仅只是相对原来的位置移动,不会造成挤下去换行的情况,而会造成不同col互相覆盖

注意上面的js部分大量使用模板字符串而不是字符串拼接,达到简化代码的目的,这个值得学习