简单轮子:网格系统及默认布局

1,301 阅读12分钟

一、网格系统

定义

什么是网格系统/栅格系统

就是把一个 div 分成 N 个部分(N = 12,16,24...),每个部分无空隙或者有空隙。

image.png

在css中grid布局之前,css只有2种布局,一种是横向布局一种是纵向布局,横向布局和纵向布局嵌套组合构成很多复杂的布局

设计API

假设我们是组件的使用者,那么他会怎么使用呢?

我们不妨参考现有的比较好的组件库是怎么做的,比如ant.design

从下图可以发现,如果要做一个网格系统,我们需要做一个Row行的的组件和一个Col列的组件

image.png

默认将一个div分成24份

如果分成2列,那么左边的跨度是12.右边的跨度是12

英文中跨度对应的英文是span

我们不妨将API设置为下面这样

<g-row gutter="12">
    <g-col span="12"></g-col>
    <g-col span="12"></g-col>
</g-row>

<g-row gutter="12">
    <g-col span="8"></g-col>
    <g-col span="8"></g-col>
    <g-col span="8"></g-col>
</g-row>

<g-row gutter="12">
    <g-col span="6"></g-col>
    <g-col span="6"></g-col>
    <g-col span="6"></g-col>
    <g-col span="6"></g-col>
</g-row>

<g-row gutter="12">
    <g-col span="2"></g-col>
    <g-col span="22"></g-col>
</g-row>

备注:

  • gutter就是间隙
  • span是每个栅格的跨度,和为24 即可

二、gitbranch & checkout 的使用

为什么要有gitbranch?

就是将代码按分支,保存为主分支中的一部分,如果别人只想要某一分支中的部分代码,我们只需要提供这个分支中的部分代码即可,而不是将主分支的代码全部提供

image.png

备注

  • 在git中当前提交的任务叫HEAD
  • 创建分支branch-1后,就有2个分支,一个是master分支,一个是branch-1分支。以前的代码就保存在branch-1分支中,现在提交的代码就提交到了master分支上了
  • 老版本git的默认分支叫master现在改成main

假如现在要创建一个button-and-input分支

git branch button-and-input

如果现在要新建2个组件g-row.vue 和 g-col.vue

git add .

git commit -m 'add row and col'

git push

我们目前的分支button-and-input还在本地的分支

我们需要将这个本地分支推送到远程的分支

git push origin button-and-input:button-and-input

image.png

我们打印提交记录可以看见主分支上是有刚刚提交的记录的

image.png

但是,如果我们切换到刚刚新建的分支上时:

git checkout button-and-input

image.png

发现就没有刚刚新建的提交记录add row and col

image.png

这样,相当于将我们提交的代码按照内容的不同进行了分类

我们可以在不同的分支中查看与之相对应的代码

主要操作:

  1. git branch xxx 新建xxx分支
  2. git checkout xxx 切换到xxx分支

最后git checkout main 切换到主分支,继续写代码,提交代码

三、用Vue钩子实现基本功能

实现栅格系统基本功能

我们先到index.html中实现基本的功能,再移动到对应的vue文件中

<style>
    .row{
        display: flex;
    }
    .col{
        width: 50%;
        height: 100px;
        background: #999;
        border: 1px solid red;
    }
    /*2/24*/
    .col[data-span="2"]{
        width: 8.333333%;
    }
    /*22/24*/
    .col[data-span="22"]{
        width: 91.666667%;
    }
</style>

<div id="app">
    <div class="row">
        <div class="col"></div>
        <div class="col"></div>
    </div>

    <div class="row">
        <div class="col"></div>
        <div class="col"></div>
        <div class="col"></div>
    </div>

    <div class="row">
        <div class="col"></div>
        <div class="col"></div>
        <div class="col"></div>
        <div class="col"></div>
    </div>

    <div class="row">
        <div class="col" data-span="2"></div>
        <div class="col" data-span="22"></div>
    </div>

写的过程中我们发现,如果想让栅格不平均分,col还需要一个支持data-span的属性,给有这个属性的col写单独的样式。css样式中我们要写24个样式并计算对应的宽度...

于是我们得用组件的写法结合scss(scss for loop)来解决这个问题,而不是用css

// g-col
<template>
  <div class="col">
    <slot></slot>
  </div>
</template>


<script>

</script>


<style scoped lang="scss">
  .col {
    width: 50%;
    height: 100px;
    background: #999;
    border: 1px solid red;

    $class-prefix: col-;
    //.col.col-1
    //.col.col-2
    @for $n from 1 through 24{
      &.#{$class-prefix}#{$n}{
        width: ($n / 24) * 100%;
      }
    }
  }

</style>

image.png

备注

  • $class-prefix: col- 是class的前缀
  • 从0开始的一般用i变量,否则用n

通过查看源代码发现,结果就是我们想要的

<g-row>
    <g-col>1</g-col>
    <g-col>2</g-col>
</g-row>
<g-row>
    <g-col>1</g-col>
    <g-col>2</g-col>
    <g-col>3</g-col>
</g-row>
<g-row>
    <g-col>1</g-col>
    <g-col>2</g-col>
    <g-col>3</g-col>
    <g-col>4</g-col>
</g-row>

image.png

目前我们只能实现均分的栅格,实现不了不均分的栅格,比如一个占22,一个占2

因此我们要给组件一个span的属性

备注

<g-col span="2">2</g-col>: 不加冒号,2就是字符串2

<g-col :span="2">2</g-col>: 加冒号,2就是数字2

<g-col :span="'2'">2</g-col>: 或者这样表示字符串也可以(外面的双引号是html的引号,里面的单引号是js的引号)

为什么每个col加了样式width: 50%;在三列的情况下确宽度没有超出容器呢?

因为display: flex;后默认flex-wrap:no-wrap;是不换行

如果想换行可以

给row加: flex-wrap:wrap;
给col加: flex-shrink: 0;

给col添加class

<template>
  <div class="col" :class="[`col-${span}`]">
    <slot></slot>
  </div>
</template>


<script>
export default {
  name: 'g-col',
  props: {
    span: {
      type: [Number, String]
    }
  }
};

</script>


<style scoped lang="scss">
.col {
  width: 50%;
  height: 100px;
  background: #999;
  border: 1px solid red;

  $class-prefix: col-;
  //.col.col-1
  //.col.col-2
  @for $n from 1 through 24 {
    &.#{$class-prefix}#{$n} {
      width: calc($n / 24) * 100%;
    }
  }
}

</style>

image.png

这样我们的网格系统就基本实现了

如果不传span,就平均分配

如果传span,就可以实现不平均分配

添加新需求:支持offset

我们应该如何让同行不同列的栅格之间有间隙?

从API的角度考虑,用户如果使用我们的组件该如何使用呢?

<g-row>
    <g-col span="2">2</g-col>
    <g-col span="20">20</g-col>
</g-row>

image.png

目前的组件是实现不了的,因为默认是左对齐,空白间隙直接跑到最右边去了

参考antd组件库知道,应该在col上添加offset属性

<g-row>
    <g-col span="2">2</g-col>
    <g-col span="20" offset="2">20</g-col>
</g-row>

接下来让我们的组件接受offset属性

<template>
  <div class="col" :class="[`col-${span}`,offset && `offset-${offset}`]">
    <slot></slot>
  </div>
</template>


<script>
export default {
  name: 'g-col',
  props: {
    span: {
      type: [Number, String]
    },
    offset:{
      type: [Number, String]
    }
  }
};

</script>

image.png

接着使用scss将offset-1 ~ offset-24 的样式全部写一次

<g-row>
    <g-col>1</g-col>
    <g-col>2</g-col>
</g-row>
<g-row>
    <g-col>1</g-col>
    <g-col>2</g-col>
    <g-col>3</g-col>
</g-row>
<g-row>
    <g-col>1</g-col>
    <g-col>2</g-col>
    <g-col>3</g-col>
    <g-col>4</g-col>
</g-row>
<g-row>
    <g-col span="2">2</g-col>
    <g-col span="22">22</g-col>
</g-row>
<g-row>
    <g-col span="2">2</g-col>
    <g-col span="20" offset="2">20</g-col>
</g-row>
<g-row>
    <g-col span="2">2</g-col>
    <g-col span="4">4</g-col>
    <g-col span="16" offset="2">16</g-col>
</g-row>
<g-row>
    <g-col span="2">2</g-col>
    <g-col span="4">4</g-col>
    <g-col span="6" offset="2">6</g-col>
    <g-col span="8" offset="2">8</g-col>
</g-row>
$class-prefix: offset-;
@for $n from 1 through 24 {
  &.#{$class-prefix}#{$n} {
    margin-left:  calc($n / 24) * 100%;
  }
}

image.png

这样我们就实现了需求,想要多少空隙就可以多少空隙,只要span和offset的值为24即可

添加新需求:即使不写offset,也有默认的空隙gutter

可不可以给col加margin?如:margin: 20px;

image.png

我们发现样式乱了,因为和我们组件在加offset时候的margin-left样式冲突了

那么改成padding呢?

我们发现页面两端是多出了2个不需要的padding:20px

image.png

如何解决这个问题呢?

我们需要给row组件的.row样式加margin: -20px;

image.png

这个空隙应该是允许用户自己设置

因此我们在g-row组件上接收一个props,属性为gutter

如果有gutter就在g-row组件上添加一个样式

<template>
  <div class="row" :style="{marginLeft: -gutter/2 + 'px',marginRight: -gutter/2 + 'px'}">
    <slot></slot>
  </div>
</template>

刚刚在g-col组件上添加的padding是写死的,现在我们要重新写一下这个padding样式

但是,g-col怎么知道加在g-row上的gutter属性的值是多少,除非在g-col上也加gutter,然后用props接收,再写样式

<g-row gutter="20">
    <g-col gutter="20">1</g-col>
    <g-col gutter="20">2</g-col>
</g-row>

image.png

// g-col.vue
<template>
  <div class="col" :class="[`col-${span}`,offset && `offset-${offset}`]" :style="{paddingLeft: gutter/2 + 'px',paddingRight: gutter/2 + 'px'}">
    <div style="border: 2px solid green">
      <slot></slot>
    </div>
  </div>
</template>


<script>
export default {
  name: 'g-col',
  props: {
    span: {
      type: [Number, String]
    },
    offset:{
      type: [Number, String]
    },
    gutter:{
      type: [Number, String]
    }
  }
};

</script>

这样通过在col上加padding-left: 10px;padding-right: 10px;

在row上加margin-left: -10px;margin-left: -10px; 就实现了 gutter为20px的间隙

但是,我们不能让用户传到每个组件,用户只需要在g-row上加gutter属性即可,即

<g-row gutter="20">
    <g-col>1</g-col>
    <g-col>2</g-col>
</g-row>

g-col怎么拿到gutter的值呢?

我们可以让g-row将gutter的值传给g-col

尝试使用vue提供的钩子函数获取g-row的子组件

created() {
  console.log(this.$children);
}

2.gif

为什么从外面看是空的,点进去又有呢?

1.gif

因为我们大打印它的时候row没有children

打印完了,它生成了children,造成这个现象的原因是打印的时机的问题

我们应该将打印放在mounted钩子函数中

image.png

钩子函数createdmounted函数的区别是什么?

我们不妨拿使用原生的js生成div做类比

var div = document.createElement('div')   // created
document.body.appendChild(div)            // mounted

created只是在内存中生成了对象或者说组件(组件也是对象)

mounted就是把对象挂到页面中去

如果div还有子div,顺序应该是

var div = document.createElement('div')        // created
var childDiv = document.createElement('div')   // child created
div.appendChild(childDiv)                    // child mounted
document.body.appendChild(div)               // mounted

看下顺序

  1. 创建父div
  2. 创建子div
  3. 把子div添加到父div身上
  4. 把父div添加到body

在这里先创建div还是先创建childDiv的顺序是无所谓的

但是类比到vue中,是先创建子组件还是父组件呢?这时候vue是要做选择的,我们怎么知道vue是怎么选择的呢?

我们只需要通过简单的log就可以知道,不需要看源代码

g-row组件和g-col组件中打印

created() {
  console.log('col created');
},
mounted() {
  console.log('col mounted');
}

created() {
  console.log('row created');
},
mounted() {
  console.log('row mounted');
}

image.png

我们看见了顺序

  1. 创建一个row(这里是父组件)
  2. 创建两个col(这里是子组件)
  3. 将2个col挂载到row上
  4. row挂载到页面上

这就是vue中父子组件中钩子函数的触发顺序

从上图可以知道,当执行row mounted之后所有子子孙孙都已经挂载到row身上了,这时在g-row的mounted钩子函数中已经可以取到它们了

因此,我们就可以在父组件g-rowmounted钩子函数中获取到它的子组件,给它们添加gutter属性

看下完整的代码

// g-row
<template>
  <div class="row" :style="{marginLeft: -gutter/2 + 'px',marginRight: -gutter/2 + 'px'}">
    <slot></slot>
  </div>
</template>

<script>
export default {
  name: 'g-row',
  props:{
    gutter:{
      type: [Number,String]
    }
  },
  mounted() {
   this.$children.forEach((vm)=>{
     vm.gutter = this.gutter
   })
  }
}

</script>

<style scoped lang="scss">
  .row{
    display: flex;
  }
</style>
// g-col
<template>
  <div class="col" :class="[span &&`col-${span}`,offset && `offset-${offset}`]" :style="{paddingLeft: gutter/2 + 'px',paddingRight: gutter/2 + 'px'}">
    <div style="border: 2px solid green">
      <slot></slot>
    </div>
  </div>
</template>


<script>
export default {
  name: 'g-col',
  props: {
    span: {
      type: [Number, String]
    },
    offset:{
      type: [Number, String]
    },
  },
  data(){
    return{
      gutter:0
    }
  },
};

</script>


<style scoped lang="scss">
.col {
  width: 50%;
  height: 100px;
  $class-prefix: col-;
  //.col.col-1
  //.col.col-2
  @for $n from 1 through 24 {
    &.#{$class-prefix}#{$n} {
      width:  calc($n / 24) * 100%;
    }
  }
  $class-prefix: offset-;
  @for $n from 1 through 24 {
    &.#{$class-prefix}#{$n} {
      margin-left:  calc($n / 24) * 100%;
    }
  }

}

</style>

image.png

四、重构和重写

定义

image.png

重构: 通过微小的调整让我们的代码变得更好,并且是每天持续的做这细微的调整。重构是每天要做的事,只要完成一个小功能,就要对代码进行重构。不要相信,等有空了再进行重构,重构是每天都在做的事

重写: 大的调整,只要是隔一段时间去写就是重写,因为已经积累了很多bug。如果是完成了100个功能,再对代码进行重构,那就不是重构,一定是重写。隔一段时间都忘了当初写的时候的具体功能,还怎么重构,一定是重写

什么代码需要重构

  1. 重复2次及以上的代码

比如在A文件写了一段代码,在B文件写了一样的代码,如果你发现B文件的这段代码有bug,于是就改了,但是你忘了在A文件也写了一样的代码,但是你忘了去修改。也就是说一旦2个地方有一样的代码,改了一个地方,忘了改另外一个地方,那么这段代码就是潜在的bug

如果存在重复的代码,应该将这段代码写在另外一个文件并导出函数,然后在A文件和B文件引入这个函数,如果函数需要更新就直接更新这个函数就可以了,A文件和B文件引入的都是这个函数,不会存在潜在的bug

如果不改,那么就会因为需求的变更,而变成 1/2/、1/3、1/4的bug

  1. 一眼看不懂的代码

比如:下面组件g-col中的一段代码

image.png

这段代码在理解上本身没有什么难度,但是代码太长了,读起来就不想读,如果属性再多,就会读起来麻烦,而且也很丑,原因是排版太复杂了

这时候需要减轻眼睛的负担,让用户一眼就知道我们在干什么

我们不妨加一个中间变量,比如:style的值就是一个对象,为什么不把这个对象放到data里呢?

image.png

有的时候重构并没有什么逻辑上的优化,只是为了让用户一眼就能看出对象的结构是什么

重构也是有风险的,有时候重构完发现出问题了,我们发现styley样式并没有成功添加,为什么呢?

因为gutter是在mounted之后赋值的,而colStyle写在data里,只会在created的时候读一次,后面就不会再读它的值了

如果后面变量gutter的值变了,colStyle是不会随着发生变化的

如果想让gutter一改变,colStyle跟着改变,我们应该使用计算属性computed

这就是计算属性的应用场景 :如果一个属性的值是要随着另外一个属性值的变化而变化的,那么不应该将这个属性的值放到data里面,而是应该放到计算属性里

接着把class也重构一下,它也是计算属性

<template>
  <div class="col" :class="colClass"
       :style="colStyle">
      <slot></slot>
  </div>
</template>


<script>
export default {
  name: 'g-col',
  props: {
    span: {
      type: [Number, String]
    },
    offset: {
      type: [Number, String]
    },
  },
  data() {
    return {
      gutter: 0
    };
  },
  computed: {
    colClass(){
      let {span,offset} = this;
      return [
          span &&`col-${span}`,
          offset && `offset-${offset}`
      ]
    },
    colStyle() {
      return {
        paddingLeft: this.gutter / 2 + 'px',
        paddingRight: this.gutter / 2 + 'px',
      };
    }
  }
};

</script>

五、手动测试

添加新需求

让栅格支持左对齐、居中对齐、右对齐

image.png

// index.html
<style>
    .demoBox {min-height: 50px;background: #e7e7e7;border: 1px solid cornflowerblue;}
    .logo-wrapper {
        padding: 10px;
    }
</style>
<div id="app">
    <g-row class="topbar">
        <g-col class="demoBox" span="9">
            <g-row align="left">
                <g-col>1</g-col>
                <g-col>2</g-col>
                <g-col>3</g-col>
                <g-col>4</g-col>
            </g-row>
        </g-col>
        <g-col class="demoBox" span="15">
            <g-row align="right">
                <g-col>1</g-col>
                <g-col>2</g-col>
                <g-col>3</g-col>
                <g-col>4</g-col>
                <g-col>5</g-col>
                <g-col>6</g-col>
                <g-col>7</g-col>
            </g-row>
        </g-col>
    </g-row>
    <g-row class="logo-and-search-and-qrcode">
        <g-col class="demoBox" span="4">
            <g-row align="center">
                <g-col>
                    <div class="logo-wrapper">
                        <img width="50" class="logo"
                             src="https://img.alicdn.com/imgextra/i1/O1CN01BqHq2Z28Ns0s0KLHa_!!6000000007921-2-tps-126-54.png">
                    </div>
                </g-col>
            </g-row>
        </g-col>
        <g-col class="demoBox" span="14"></g-col>
        <g-col class="demoBox" span="6"></g-col>
    </g-row>
    <hr>
</html>

// g-row组件
<template>
  <div class="row" :style="rowStyle" :class="rowClass">
    <slot></slot>
  </div>
</template>

<script>
export default {
  name: 'g-row',
  props:{
    gutter:{
      type: [Number,String]
    },
    align:{
      type: String,
      validator(value){
        return ['left','center','right'].includes(value)
      }
    }
  },
  computed:{
    rowStyle(){
      let {gutter} = this
      return {marginLeft: -gutter/2 + 'px',marginRight: -gutter/2 + 'px'}
    },
    rowClass(){
      let {align} = this
      return [align && `align-${align}`]
    }
  },
  mounted() {
   this.$children.forEach((vm)=>{
     vm.gutter = this.gutter
   })
  }
}

</script>

<style scoped lang="scss">
  .row{
    display: flex;
    &.align-left {
     justify-content: flex-start;
    }
    &.align-center {
     justify-content: center;
    }
    &.align-right {
      justify-content: flex-end;
    }
  }
</style>

image.png

思路:

  1. props接收align属性值为left center right
  2. 添加样式class,class名为 align + ${align}
  3. style中定义这个样式

备注

  • 使用g-rowg-col嵌套使用也可以实现居中
  • 理论上使用g-rowg-col嵌套的方式可以实现所有的布局

六、实现响应式

灵活? 限制?

我们的组件只能实现简单的响应式,仅仅是做到宽度和相对位置的改变,因为我们目前的栅格系统已经基本可以实现所有的布局,只要按照规定给响应的属性即可

灵活是设计师想要的需求,限制是工程师想要的需求。

工程师永远想限制设计师,但是设计师永远不想被限制,如果我们是工程师,我们一定要限制设计师,这样工作量就会大幅度下降,因为不需要给特殊风格的页面单独写样式,只用复用组件即可

但是在灵活和限制之间我们也要做权衡

比如:要做一个后台管理系统,就可以使用grid布局,自己内部人看,无所谓。但是,如果要做活动页就不能使用grid布局

响应式

API如何设计

Antd中实现了响应式,我们可以参考它的API设计

image.png

image.png

我们要实现的响应式要达到在不同设备尺寸下,页面元素的大小比例以及位置发生变化

<g-row>
    <g-col span="4" phone-span="12" offset="1" phone-offset="2"></g-col>
    <g-col span="20" phone-span="12"></g-col>
</g-row>

站在使用组件者的角度可能会这么使用响应式

默认情况下是 4 20

但是在手机设备下就变成 12 12

但是这样做的问题是:如果组件上使用了很多属性,那么每次都要默认情况下写一次,不同设备下再写一次

可不可以简化成下面这样,让这个属性接受一个对象,它里面的属性的值会覆盖默认的属性的值

<g-row>
    <g-col span="4" offset="1" :phone="{span:12,offset:2}"></g-col>
    <g-col span="20" phone-span="12"></g-col>
</g-row>

实现

// index.html
<g-row gutter="20">
    <g-col span="2"  :phone="{span:24}" :ipad="{span:8}" :narrow-pc="{span:4}" :pc="{span:2}" :wide-pc="{span:1}">2</g-col>
    <g-col span="22" :phone="{span:24}" :ipad="{span:16}" :narrow-pc="{span:20}" :pc="{span:22}" :wide-pc="{span:23}">22</g-col>
</g-row>

// g-col组件
<template>
  <div class="col" :class="colClass"
       :style="colStyle">
      <slot></slot>
  </div>
</template>


<script>
let validator = (value) => {
  let keys = Object.keys(value)
  let valid = true
  keys.forEach( key => {
    if(!['span','offset'].includes(key)){
      valid = false
    }
  })
  return valid
}
export default {
  name: 'g-col',
  props: {
    span: {type: [Number, String]},
    offset: {type: [Number, String]},
    phone:{type: Object, validator},
    ipad:{type: Object, validator},
    narrowPc:{type: Object, validator},
    pc:{type: Object, validator},
    widePc:{type: Object, validator}
  },
  data() {
    return {
      gutter: 0
    };
  },
  computed: {
    colClass(){
      let {span,offset,phone,ipad,narrowPc,pc,widePc} = this;
      return [
          span &&`col-${span}`,
          offset && `offset-${offset}`,
          ...(phone && [`col-phone-${phone.span}`]),
          ...(ipad && [`col-ipad-${ipad.span}`]),
          ...(narrowPc && [`col-narrowPc-${narrowPc.span}`]),
          ...(pc && [`col-pc-${pc.span}`]),
          ...(widePc && [`col-widePc-${widePc.span}`]),
      ]
    },
    colStyle() {
      return {
        paddingLeft: this.gutter / 2 + 'px',
        paddingRight: this.gutter / 2 + 'px',
      };
    }
  }
};

</script>
<style scoped lang="scss">
.col {
  width: 50%;
  min-height: 80px;
  $class-prefix: col-;
  //.col.col-1
  //.col.col-2
  @for $n from 1 through 24 {
    &.#{$class-prefix}#{$n} {
      width: calc($n / 24) * 100%;
    }
  }
  $class-prefix: offset-;
  @for $n from 1 through 24 {
    &.#{$class-prefix}#{$n} {
      margin-left: calc($n / 24) * 100%;
    }
  }
  //手机端
  @media (max-width: 576px) {
    $class-prefix: col-phone-;
    @for $n from 1 through 24 {
      &.#{$class-prefix}#{$n} {
        width: calc($n / 24) * 100%;
      }
    }
    $class-prefix: offset-phone-;
    @for $n from 1 through 24 {
      &.#{$class-prefix}#{$n} {
        margin-left: calc($n / 24) * 100%;
      }
    }
  }
  //ipad端
  @media (min-width: 577px) and (max-width: 768px) {
    $class-prefix: col-ipad-;
    @for $n from 1 through 24 {
      &.#{$class-prefix}#{$n} {
        width: calc($n / 24) * 100%;
      }
    }
    $class-prefix: offset-ipad-;
    @for $n from 1 through 24 {
      &.#{$class-prefix}#{$n} {
        margin-left: calc($n / 24) * 100%;
      }
    }
  }
  //窄屏幕
  @media (min-width: 769px) and (max-width: 992px) {
    $class-prefix: col-narrow-pc-;
    @for $n from 1 through 24 {
      &.#{$class-prefix}#{$n} {
        width: calc($n / 24) * 100%;
      }
    }
    $class-prefix: offset-narrow-pc-;
    @for $n from 1 through 24 {
      &.#{$class-prefix}#{$n} {
        margin-left: calc($n / 24) * 100%;
      }
    }
  }
  //PC
  @media (min-width: 993px) and (max-width: 1200px) {
    $class-prefix: col-pc-;
    @for $n from 1 through 24 {
      &.#{$class-prefix}#{$n} {
        width: calc($n / 24) * 100%;
      }
    }
    $class-prefix: offset-pc-;
    @for $n from 1 through 24 {
      &.#{$class-prefix}#{$n} {
        margin-left: calc($n / 24) * 100%;
      }
    }
  }
  // 宽PC
  @media (min-width: 1201px){
    $class-prefix: col-wide-pc-;
    @for $n from 1 through 24 {
      &.#{$class-prefix}#{$n} {
        width: calc($n / 24) * 100%;
      }
    }
    $class-prefix: offset-wide-pc-;
    @for $n from 1 through 24 {
      &.#{$class-prefix}#{$n} {
        margin-left: calc($n / 24) * 100%;
      }
    }
  }
}

</style>

3.gif

备注:

  • phone的类型是对象,同时它的属性只能包含span 和 offset
  • 把传给组件的phone属性体现在html标签上并以class的方式展示出来,class一发生变化,style就产生相应的变化,我们得让g-col组件再pc端和phone端有不同的class image.png
  • 同时要使用@media划分屏幕宽度的几个范围段,针对不同的范围段设置样式,同时由于@media媒体查询中的css写在下面,因此当它触发时,会覆盖上面的样式 2.gif
  • antd不同的屏幕尺寸叫xs sm md lg xl xxl 从名字上我们还是无法知道当前设备是什么设备还不如叫phone ipad
  • 如果想让栅格变成上下结构,就让span都占24份,同时g-rowflex-wrap:wrap:设置为允许换行,如上图所示当在phone屏幕下就变成了上下布局了
<g-row gutter="20">
    <g-col span="2"  :phone="{span:24}">2</g-col>
    <g-col span="22" :phone="{span:24}">22</g-col>
</g-row>

目前还存在一个问题:如果五种设备的样式都写了,那么span=2就没有意义了

<g-col span="2"  :phone="{span:24}" :ipad="{span:8}" :narrow-pc="{span:4}" :pc="{span:2}" :wide-pc="{span:1}">2</g-col>

因此我们要选择一种,作为我们的默认样式。假如我们的组件定位就是以手机用户为主,那么就把phone作为默认,也就不要再传phone了,把和phone相关的代码删除即可

如果传的时候没传ipadnarrow-pc,但是当前的屏幕尺寸在ipad上,那么样式是使用ipad的样式?还是使用narrow-pc的样式?

image.png

这还是要根据需求来定

如果你觉得你面向的是手机的用户,那么如果不属于手机设备,都使用手机设备的样式

或者就近使用样式,这就需要我们做出权衡

我们不妨让响应式的规则往下走,往下走的意思是如如果传了phonepc,当前的设备是ipad,但是没传ipad属性,那么就走pc的样式(phone -> ipad -> narrow pc -> pc -> wide pc)

七、完善测试用例

解决没有传ipad但是屏幕尺寸是ipad的问题

我们不应该将媒体查询的规则写的那么死板,我们可不可以只写一个最低值或者一个最、最高值

//屏幕宽度 = width

min-width > 0      span = 12     phone

min-width > 576    span = 10     ipad
 
min-width >768    span = 6       narrow-pc

min-width >992    span = 4       pc

min-width >1200   span = 2       wide-pc

image.png

也就是说如果屏幕宽度落在phone和ipad,那么按照向下走的原则,就使用ipad的样式

image.png

如果没写传ipad,但是屏幕尺寸是ipad,那么它的尺寸也在iphone尺寸的区间,那么就使用phone的样式

回到代码中我们就要调整一下媒体查询样式,自上而下按照phone -> ipad -> narrow pc -> pc -> wide pc的顺序

同时尺寸的范围只要给一个起点范围就行了,不需要区间

<g-row>
    <g-col span="24"  :narrow-pc="{span: 8}">
        <div class="demo" ></div>
    </g-col>
    <g-col span="24"  :narrow-pc="{span: 8}">
        <div class="demo" ></div>
    </g-col>
    <g-col span="24"  :narrow-pc="{span: 8}">
        <div class="demo" ></div>
    </g-col>
    <g-col span="24"  :narrow-pc="{span: 8}">
        <div class="demo" ></div>
    </g-col>
    <g-col span="24"  :narrow-pc="{span: 8}">
        <div class="demo" ></div>
    </g-col>
    <g-col span="24"  :narrow-pc="{span: 8}">
        <div class="demo" ></div>
    </g-col>
</g-row>
<style scoped lang="scss">
.col {
  width: 50%;
  min-height: 80px;
  $class-prefix: col-;
  //.col.col-1
  //.col.col-2
  //默认是phone
  @for $n from 1 through 24 {
    &.#{$class-prefix}#{$n} {
      width: calc($n / 24) * 100%;
    }
  }
  $class-prefix: offset-;
  @for $n from 1 through 24 {
    &.#{$class-prefix}#{$n} {
      margin-left: calc($n / 24) * 100%;
    }
  }
  //ipad端
  @media (min-width: 577px) {
    $class-prefix: col-ipad-;
    @for $n from 1 through 24 {
      &.#{$class-prefix}#{$n} {
        width: calc($n / 24) * 100%;
      }
    }
    $class-prefix: offset-ipad-;
    @for $n from 1 through 24 {
      &.#{$class-prefix}#{$n} {
        margin-left: calc($n / 24) * 100%;
      }
    }
  }
  //窄屏幕
  @media (min-width: 769px){
    $class-prefix: col-narrow-pc-;
    @for $n from 1 through 24 {
      &.#{$class-prefix}#{$n} {
        width: calc($n / 24) * 100%;
      }
    }
    $class-prefix: offset-narrow-pc-;
    @for $n from 1 through 24 {
      &.#{$class-prefix}#{$n} {
        margin-left: calc($n / 24) * 100%;
      }
    }
  }
  //PC
  @media (min-width: 993px){
    $class-prefix: col-pc-;
    @for $n from 1 through 24 {
      &.#{$class-prefix}#{$n} {
        width: calc($n / 24) * 100%;
      }
    }
    $class-prefix: offset-pc-;
    @for $n from 1 through 24 {
      &.#{$class-prefix}#{$n} {
        margin-left: calc($n / 24) * 100%;
      }
    }
  }
  // 宽PC
  @media (min-width: 1201px){
    $class-prefix: col-wide-pc-;
    @for $n from 1 through 24 {
      &.#{$class-prefix}#{$n} {
        width: calc($n / 24) * 100%;
      }
    }
    $class-prefix: offset-wide-pc-;
    @for $n from 1 through 24 {
      &.#{$class-prefix}#{$n} {
        margin-left: calc($n / 24) * 100%;
      }
    }
  }
}

</style>

3.gif

这样就达到了我们想要的效果,默认是phone手机,当尺寸在ipad,但是又没有写phone的属性的时候,就使用phone的样式,当屏幕尺寸在窄屏幕就又使用了窄屏幕的样式narrow-pc,这种适配的策略叫Mobile First,中文叫移动端优先

重构下代码

优化前

// 往数组中添加class
colClass(){
  let {span,offset,ipad,narrowPc,pc,widePc} = this;
  return [
      span &&`col-${span}`,
      offset && `offset-${offset}`,
      ...(ipad ? [`col-ipad-${ipad.span}`]:[]),
      ...(narrowPc ?[`col-narrow-pc-${narrowPc.span}`]:[]),
      ...(pc ? [`col-pc-${pc.span}`]:[]),
      ...(widePc ? [`col-wide-pc-${widePc.span}`]:[]),
  ]
},

优化后

  methods: {
    createClasses(obj, str = '') {
      if (!obj) {
        return [];
      }
      let array = [];
      if (obj.span) array.push(`col-${str}${obj.span}`);
      if (obj.offset) array.push(`offset-${str}${obj.offset}`);
      return array;
    }
  },
  computed: {
    colClass() {
      let {span, offset, ipad, narrowPc, pc, widePc} = this;
      let createClasses = this.createClasses
      return [
        ...createClasses({span, offset}),
        ...createClasses(ipad, 'ipad-'),
        ...createClasses(narrowPc, 'narrow-pc-'),
        ...createClasses(pc, 'pc-'),
        ...createClasses(widePc, 'wide-pc-')
      ];
    },
    colStyle() {
      return {
        paddingLeft: this.gutter / 2 + 'px',
        paddingRight: this.gutter / 2 + 'px',
      };
    }
  }
};

总结

目前组件完成的功能有:

  1. 24列栅格布局
  2. 可以加空隙gutter
  3. 可以设置offset
  4. 可以用span指定占的列数
  5. 它是响应式的

添加测试用例

每个组件要写单独的测试文件

新建 test/col.test.jstest/row.test.js

测g-row组件

我们该如何测试g-row.vue文件呢?

我们知道这个组件接收2个参数,一个是gutter,另外一个是align

it('接收 gutter 属性', () => {  
    Vue.component('g-row', Row);  
    Vue.component('g-col', Col);  
    const div = document.createElement('div');  
    document.body.appendChild(div)  
    div.innerHTML = `  
        <g-row gutter="20">  
            <g-col span="12"></g-col>  
            <g-col span="12"></g-col>  
        </g-row>  
    `  
    const vm = new Vue({  
        el: div  
    })  
    console.log(vm.$el.outerHTML)  
});

image.png

打印的时候padding为0,但是并不代表它真的padding为0

只是我们打印的时候是0,后来padding的值变了,变的过程是异步的过程

备注

  • vue中不支持将Row的实力直接挂载到body上,因此我们要创建一个div,将Row的实例挂载到div上,最后将div挂载到body上
  • 当前的row中没有col因此要将Col引入进来一起测
  • 那么如何动态的往row里添加col,然后才将row挂载到div上?我们只知道html形式的将rol作为row的子组件,暂时不知道如何通过JS将一个组件放到另外一个组件中,因此我们构建html形式的往row里添加col
  • 打印的时候没有并不代表它真的没有,如:数组a一开始是空数组,当push数据后,从外面看还是空数组,但是点进去里面是有数据的

image.png

image.png

因此,我们只能进行异步的测试,为什么要异步的进行测试?

这就需要我们了解vue的渲染过程

vue的渲染过程

image.png

看下普通的DOM的渲染过程:

var div = document.createElement('div')
var child = document.createElement('div')
div.appendChild(child)
document.body.appendChild(div)

这是一个同步的过程

但是在vue中不是这样,vue中子组件放到父组件不是同步的(可能是基于性能的考虑,如果是同步的可能要等结果等很久),或者说钩子的调用不是同步的

在vue中组件嵌套子组件,类比div.appendChild(child)就会触发child组件的mounted钩子函数

那么mounted钩子函数中的代码是同步的触发还是异步的触发?

如果mounted钩子函数中有很多代码,并且涉及到很重的运算,如果是同步的触发,vue就要一直等mounted的结果,性能就会很差,因此是异步的

vue不能一直等上面的mounted钩子函数的结果,因此它要先去执行其他的代码,如:document.body.appendChild(div)此时又会触发div的mounted钩子

因此队列中就有2个mountend队列

如果此时测试用例中console.log(div.outerHTML)

那么结果是先执行console.log(div.outerHTML)还是先执行队列中的mounted钩子函数呢?

由于console.log(div.outerHTML)是同步代码,因此是先执行的

再回过头看下测试用例中的代码

it('接收 gutter 属性', () => {  
    Vue.component('g-row', Row);  
    Vue.component('g-col', Col);  
    const div = document.createElement('div');  
    document.body.appendChild(div)  
    div.innerHTML = `  
        <g-row gutter="20">  
            <g-col span="12"></g-col>  
            <g-col span="12"></g-col>  
        </g-row>  
    `  
    const vm = new Vue({  
        el: div  
    })  
    console.log(vm.$el.outerHTML)  
});

我们在加了gutter属性,会认为g-col组件上会有10px的padding

但是我们g-col组件的10px的padding样式是在g-row组件的moutend钩子函数里去改的

image.png

在我们console.log(vm.$el.outerHTML)之前异步的mounted还没有执行完,因此我们打印的结果中g-col组件中还没有样式

因此我们需要异步的调用console.log(vm.$el.outerHTML),给它加一个定时器

setTimeout(()=>console.log(vm.$el.outerHTML)   ,0)

这样做的结果就是会将上面的代码放到任务队列的第三项

等前2项的异步结果(child mounted 和 div mounted)执行完成之后再执行console.log(vm.$el.outerHTML)

it('接收 gutter 属性', (done) => {
    Vue.component('g-row', Row);
    Vue.component('g-col', Col);
    const div = document.createElement('div');
    document.body.appendChild(div)
    div.innerHTML =  `
        <g-row gutter="20">
            <g-col span="12"></g-col>
            <g-col span="12"></g-col>
        </g-row>
    `
    const vm = new Vue({
        el: div
    })
    setTimeout(()=>{
        const row = vm.$el.querySelector('.row')
        expect(getComputedStyle(row).marginLeft).to.eq('-10px')
        expect(getComputedStyle(row).marginRight).to.eq('-10px')
        const cols = vm.$el.querySelectorAll('.col')
        expect(getComputedStyle(cols[0]).paddingRight).to.eq('10px')
        expect(getComputedStyle(cols[1]).paddingLeft).to.eq('10px')
        done()
        vm.$el.remove()
        vm.$destroy()
    }   ,1000)
});

it('接收 align 属性',()=>{
    const div = document.createElement('div')
    document.body.appendChild(div)
    const Constructor = Vue.extend(Row)
    const vm = new Constructor({
        propsData:{
            align: 'center'
        }
    }).$mount(div)
    const element = vm.$el
    expect(getComputedStyle(element).justifyContent).to.equal('center')
    div.remove()
    vm.$destroy()
})

备注

  • 如果不调用done,默认测试用例中的代码都是同步代码,因此setTimeout中的代码就不会执行
  • 也就是说如果测试用例里面存在任何的异步,都需要在参数中加个done,并在异步任务完成后调用一下done,通知它我们的测试完成
  • 由于测试的时候karma默认会帮我们打开浏览器进行测试,因此每次我们测试完成后最好删除在页面中创建的元素,否则有可能会干扰下一次的测试

总结

  • 再想一想测试用例中造的结构,就是组件中嵌套组件,使用html模板的方式
  • 想一想为什么一开始console.log(vm.$el.outerHTML)为什么打印不出col的padding为10px的样式,原因是需要异步
  • 如果测试用例中用到异步,异步结束之后要调用一下done
  • 如果在vue中用到了钩子函数就会牵扯到异步代码

测g-row组件

import {doc} from 'mocha/lib/reporters';

const expect = chai.expect;
import Vue from 'vue';
import Row from '../src/g-row.vue';
import Col from '../src/g-col.vue';

Vue.config.productionTip = false;
Vue.config.devtools = false;

describe('Col', () => {
    it('存在', () => {
        expect(Col).to.exist;
    });
    it('接收 span 属性',()=>{
        const div = document.createElement('div')
        document.body.appendChild(div)
        const Constructor = Vue.extend(Col)
        const vm = new Constructor({
            propsData:{
                span: 1
            }
        }).$mount(div)
        console.log(vm.$el)
        expect(vm.$el.classList.contains('col-1')).to.equal(true)
        div.remove()
        vm.$destroy()
    })
    it('接收 offset 属性',()=>{
        const div = document.createElement('div')
        document.body.appendChild(div)
        const Constructor = Vue.extend(Col)
        const vm = new Constructor({
            propsData:{
                offset: 1
            }
        }).$mount(div)
        expect(vm.$el.classList.contains('offset-1')).to.equal(true)
        div.remove()
        vm.$destroy()
    })
    it('接收 ipad 属性',()=>{
        const div = document.createElement('div')
        document.body.appendChild(div)
        const Constructor = Vue.extend(Col)
        const vm = new Constructor({
            propsData:{
                ipad: {span:1,offset:2}
            }
        }).$mount(div)
        expect(vm.$el.classList.contains('col-ipad-1')).to.equal(true)
        expect(vm.$el.classList.contains('offset-ipad-2')).to.equal(true)
        div.remove()
        vm.$destroy()
    })
    it('接收 narrowPc 属性',()=>{
        const div = document.createElement('div')
        document.body.appendChild(div)
        const Constructor = Vue.extend(Col)
        const vm = new Constructor({
            propsData:{
                narrowPc: {span: 4,offset: 1}
            }
        }).$mount(div)
        expect(vm.$el.classList.contains('col-narrow-pc-4')).to.equal(true)
        expect(vm.$el.classList.contains('offset-narrow-pc-1')).to.equal(true)
        div.remove()
        vm.$destroy()
    })
    it('接收 pc 属性',()=>{
        const div = document.createElement('div')
        document.body.appendChild(div)
        const Constructor = Vue.extend(Col)
        const vm = new Constructor({
            propsData:{
                pc: {span:6,offset:2}
            }
        }).$mount(div)
        expect(vm.$el.classList.contains('col-pc-6')).to.equal(true)
        expect(vm.$el.classList.contains('offset-pc-2')).to.equal(true)
        div.remove()
        vm.$destroy()
    })
    it('接收 widePc 属性',()=>{
        const div = document.createElement('div')
        document.body.appendChild(div)
        const Constructor = Vue.extend(Col)
        const vm = new Constructor({
            propsData:{
                widePc: {span:8,offset:4}
            }
        }).$mount(div)
        console.log(vm.$el)
        expect(vm.$el.classList.contains('col-wide-pc-8')).to.equal(true)
        expect(vm.$el.classList.contains('offset-wide-pc-4')).to.equal(true)
        div.remove()
        vm.$destroy()
    })
});

备注

  • 测试css一般只要expect它有没有某个class

八、默认布局

Ant.design中的默认布局及API

Ant.design

基本上所有的网站都可以使用这四种布局中的一种

因此,我们可以把常用的布局做成组件

Tips: Amend本次提交合并到上次提交

image.png

明确需求

如果是用户会怎么使用布局组件?

<g-layout>
    <g-header></g-header>
    <g-content></g-content>
    <g-footer></g-footer>
</g-layout>

<g-layout>
    <g-header></g-header>
    <g-layout>
        <g-slider></g-slider>
        <g-content></g-content>
    </g-layout>
    <g-footer></g-footer>
</g-layout>

<g-layout>
    <g-header></g-header>
    <g-layout>
        <g-content></g-content>
        <g-slider></g-slider>
    </g-layout>
    <g-footer></g-footer>
</g-layout>

<g-layout>
    <g-slider></g-slider>
    <g-layout>
        <g-header></g-header>
        <g-content></g-content>
        <g-footer></g-footer>
    </g-layout>
</g-layout>

备注

  • layout标签就是没有意义的div标签

布局一

需求

image.png

实现

这里我们可以使用git branch grid将之前的代码保存到新的分支,这样就像主干分了一支分支

接下来可以继续提交代码到主分支中

新建 g-header g-content g-side g-footer g-layout组件

在app.js全局注册这些组件

// g-layout组件
.layout{
  display: flex;
  flex-direction: column;
  border: 1px solid red;
}

// g-content组件
.content{
  flex-grow: 1;
}

image.png

布局二

image.png

image.png

目前的layout是垂直布局的,但是中间的layout需要横向布局,难道要加一个额外的属性?

我们不参考下ant.design是怎么实现的

image.png

我们发现它只要发现g-layout组件下有sider,就给layout添加一个ant-layout-has-sider类,在这个样式中将flex布局的方向变成fiex-direction:row;

因此我们也可以按照这个思路来做

我们要在g-layout组件中借助回调钩子函数mounted,发现子组件(相当于DOM中子元素的概念)里有sider之后去加class

在vue中是先创建父组件,再创建子组件,将子组件添加到父组件中,最后再将父组件添加到页面中,因此我们应该要在mounted钩子函数中去取,如果是在created钩子函数去取,时机就不对,此时子组件还没有放到父组件中

单文件组件name的第一个作用是使用vue的插件,在开发者工具里可以清晰的知道当前页面中组件的组织结构。

image.png

第二个作用是只有单文件组件写了name,我们才能知道g-layout组件下有没有sider组件

// g-layout组件
mounted() {
  console.log(this.$children);
}

image.png

export default {
  name: 'g-layout',
  mounted() {
    this.$children.forEach(vm=>{
      console.log(vm.$options.name);
    })
  }
};

image.png

image.png

从上图可以看出第一个g-layout组件的直接子组件有g-header g-layout g-footer,第二个g-layout组件的直接子组件有g-sider 和 g-content

这样我们就知道g-layout组件下有g-layout组件,我们就可以添加class了

mounted() {
  this.$children.forEach(vm=>{
   if(vm.$options.name === 'g-sider'){
      this.layoutClass.hasSider = true
   };
  })
}

image.png

了解了一个套路之后,后面都可以经常用到这个套路:g-layout组件mounted的时候看它的$children子组件有没有一个叫g-sider,如果有就给g-layout组件添加一个hasSider的class(速记:mounted之后看儿子)

后面添加.hasSider样式即可

image.png

布局三

image.png

布局三就是把布局二中的Content和Sider换一下顺序

image.png

布局四

image.png

只要给g-layout组件添加一个flex-grow: 1;即可

image.png