Vue单文件组件开发的基本套路--以常用的Modal模态框组件开发为例

在实际开发工程化vue项目时,基本都是使用单文件组件形式,即每个.vue文件都是一个组件。最基本的就是UI组件了,我们一般会根据自己的喜好和项目的需要选择一个成熟的组件库来使用,iView组件库、饿了么的Element组件库都是很优秀很全面的组件库,虽然都能满足我们的需求,可众口难调,总有一些特殊的需求不能满足,这个时候就需要我们自己去开发组件实现特殊的需求了。而且我们的目标不仅仅是使用轮子,更要有造轮子的能力。接下来我们就以常用的弹窗Modal组件为例一步一坑的搞一搞vue单文件组件开发。

一个vue组件除了HTML和css结构,最重要的三个部分是slot、props和events,所有的vue组件都逃不离这3个部分。

目标

Modal组件很常见,就是弹出一个带有遮罩的对话框,我们这一次的目标就以iView组件库的Modal为例吧:

iView的Modal组件.gif

1、第一步:基础-单文件组件模式

首先我们先创建一个vue工程,还不清楚的朋友可自行看一下vue官网教程搭建一个。单文件组件顾名思义一个.vue文件就是一个组件,所以我们新建一个Modal.vue文件表示我们的目标Modal组件,再创建一个父组件HelloWorld.vue来调用这个Modal组件,父组件是HelloWorld.vue子组件是Modal.vue代码分别为:
HelloWorld.vue

<template>
  <div>
    <button @click="toggleModal">打开Modal对话框</button>
    <Modal v-show="showModal"></Modal>
  </div>
</template>

<script>
import Modal from './Modal.vue'
export default {
  data () {
    return {
      showModal:false
    }
  },
  components:{
    'Modal':Modal //声明组件
  },
  methods:{   
    toggleModal() {
      this.showModal = !this.showModal; //切换showModal 的值来切换弹出与收起
    },
  }
}
</script>

<style>
</style>

Modal.vue:

<template>
  <div>
     我是Modal里的内容
  </div>
</template>

<style>
</style>

<script>
export default {
  name: 'Modal',
  props: {     //props里面准备写上父组件HelloWorld要传进来的数据
  },
  data() {
      return {        
      }
  },
  methods: {
  }
}
</script>

我们这里为了更纯粹的介绍组件间的关系就把HelloWorld.vue通过 vue-router配置成首页,小伙伴们大可根据自己工程的情况而定,反正就是一父一子的关系。运行命令npm run dev(不熟悉的小伙伴可以再回头看看vue官网的教程把工程跑起来,可以看到组件间的调用关系就完成了,当然她还不是个真正的弹出框,但却是所有vue组件的基础:

2、第二步:组件个性化

有了第一步单文件组件结构的基础,接下来就可以在这基础上创造出各种组件来,想做Button组件就做Button组件、想做Loading组件就做Loading组件、想做Modal组件就做Modal组件,直到把所有用到的组件都做完了,就形成了自己的组件库,然后再打包托管到npm上...额慢慢来。接下来花一点时间让Modal.vue看上去有她该有的样子:

2.1Modal组件结构搭建


可以看到一个iView的Modal最基本的内容有遮罩层、弹出框、header头部、body内容区和footer尾部5个部分,各自都有自己的样式,接下来我们就要为Modal.vue组件加上这样的HTML结构和css样式,Modal.vue代码更新为:
Modal.vue

<template>
  <div class="modal-backdrop">
     <div class="modal" :style="mainStyles">
        <div class="modal-header">
          <h3>我是一个Modal的标题</h3>
        </div>
        <div class="modal-body">
            <p>我是一个Modal的内容</p>
        </div>
        <div class="modal-footer">
            <button type="button" class="btn-close" @click="closeSelf">关闭</button>
            <button type="button" class="btn-confirm" @click="confirm">确认</button>
        </div>
    </div>

  </div>
</template>

<style>
.modal-backdrop { 
    position: fixed; 
    top: 0; 
    right: 0; 
    bottom: 0; 
    left: 0; 
    background-color: rgba(0,0,0,.3); 
    display: flex; 
    justify-content: center; 
    align-items: center; 
}
.modal { 
    background-color: #fff; 
    box-shadow: 2px 2px 20px 1px; 
    overflow-x:auto; 
    display: flex; 
    flex-direction: column;
    border-radius: 16px;
    width: 700px;
} 
.modal-header { 
    border-bottom: 1px solid #eee; 
    color: #313131; 
    justify-content: space-between;
    padding: 15px; 
    display: flex; 
} 
.modal-footer { 
    border-top: 1px solid #eee; 
    justify-content: flex-end;
    padding: 15px; 
    display: flex; 
} 
.modal-body { 
    position: relative; 
    padding: 20px 10px; 
}
.btn-close, .btn-confirm {    
    border-radius: 8px;
    margin-left:16px;
    width:56px;
    height: 36px;
    border:none;
    cursor: pointer;
}
.btn-close {
    color: #313131;
    background-color:transparent;
}
.btn-confirm {
    color: #fff; 
    background-color: #2d8cf0;
}


</style>

<script>
export default {
  name: 'Modal',
  props: {   
  },
  data() {
    return {       
    }
  }, 
  methods: {    
    closeSelf() {      
    }
  }
}
</script>

先看一下更新后Modal组件的样子:


2.2 组件通信

再仔细看一下Modal.vue组件的代码,发现有两个问题。
1、一是我们这里为弹出框写了一个width=700px,显然用户每打开一个Modal都是700宽,这样做组件的可重用性就太低了。接下来要做到宽度能由父组件来决定。
2、二是Modal组件里的关闭Button事件还没起作用,它的本意是点击它就关闭整个弹出框。接下来要做到实现此事件。
借由上面这两个问题,我们就要用到组件通信了,在vue组件中,父组件给子组件传数据是单向数据流,父组件通过定义属性的方式传递,子组件用props接收,如父组件传一个width参数给子组件:
父组件:<Modal width="700" ></Modal>
子组件:props: { width:{ type:[Number,String], default:520 } }
而子组件向父组件传递数据的方式为事件传递,如要在子组件关闭Modal弹出框,则要传递一个双方约定的事件名给父组件,如:
父组件:<Modal @on-cancel="cancel" ></Modal>
子组件:this.$emit('on-cancel');
接下来就更新一下HelloWorld.vue和Modal.vue的代码,来解决这两个问题,实现父组件自定义弹出框宽度为200px,以及关闭弹出框功能,更新代码如下:
HelloWorld.vue

<template>
  <div>
    <button @click="toggleModal">打开Modal对话框</button>
    <!--定义width属性,和on-cancel事件-->
    <Modal 
      v-show="showModal" 
      width="200"
      @on-cancel="cancel"
    ></Modal>
  </div>
</template>

<script>
import Modal from './Modal.vue'
export default {
  data () {
    return {
      showModal:false
    }
  },
  components:{
    'Modal':Modal
  },
  methods:{
    toggleModal() {
      this.showModal = !this.showModal; 
    },
    //响应on-cancel事件,来把弹出框关闭
    cancel() {
      this.showModal = false;
    }
  }
}
</script>

<style>
</style>

Modal.vue

<template>
  <div class="modal-backdrop">
     <div class="modal" :style="mainStyles">
        <div class="modal-header">
          <h3>我是一个Modal的标题</h3>
        </div>
        <div class="modal-body">
            <p>我是一个Modal的内容</p>
        </div>
        <div class="modal-footer">
            <button type="button" class="btn-close" @click="closeSelf">关闭</button>
            <button type="button" class="btn-confirm" @click="confirm">确认</button>
        </div>
    </div>

  </div>
</template>

<style>
.modal-backdrop { 
    position: fixed; 
    top: 0; 
    right: 0; 
    bottom: 0; 
    left: 0; 
    background-color: rgba(0,0,0,.3); 
    display: flex; 
    justify-content: center; 
    align-items: center; 
}
.modal { 
    background-color: #fff; 
    box-shadow: 2px 2px 20px 1px; 
    overflow-x:auto; 
    display: flex; 
    flex-direction: column;
    border-radius: 16px;
} 
.modal-header { 
    border-bottom: 1px solid #eee; 
    color: #313131; 
    justify-content: space-between;
    padding: 15px; 
    display: flex; 
} 
.modal-footer { 
    border-top: 1px solid #eee; 
    justify-content: flex-end;
    padding: 15px; 
    display: flex; 
} 
.modal-body { 
    position: relative; 
    padding: 20px 10px; 
}
.btn-close, .btn-confirm {    
    border-radius: 8px;
    margin-left:16px;
    width:56px;
    height: 36px;
    border:none;
    cursor: pointer;
}
.btn-close {
    color: #313131;
    background-color:transparent;
}
.btn-confirm {
    color: #fff; 
    background-color: #2d8cf0;
}
</style>

<script>
export default {
  name: 'Modal',
  //接收父组件传递的width属性
  props: {
    width:{
        type:[Number,String],//类型检测
        default:300 //父组件没传width时的默认值
    }
  },
  data() {
    return {       
    }
  },
  computed:{
     //计算属性来响应width属性,实时绑定到相应DOM元素的style上
      mainStyles() {
          let style = {};
          style.width = `${parseInt(this.width)}px`;
          return style;
      }      
  },
  methods: {
    //响应关闭按钮点击事件,通过$emit api通知父组件执行父组件的on-cancel方法
    closeSelf() {
        this.$emit('on-cancel');
    }
  }
}
</script>

新增的代码在图中都做了注释说明,此时就可看到效果:


可以看到iView的Modal组件定义了很多属性和事件,都是日积月累不断优化而来的,我们的例子只写了一个width属性和on-cancel事件,但其他的基本是大同小异,套路掌握了都可以一一实现的。
iView中Modal的props、events一览

2.3 slot插槽

props、events都实现了,三部分中就剩slot了。slot的作用也是为了解决组件可重用的问题的。

props解决的是组件参数的传递、events解决的是组件事件的传递、slot解决的就是组件内容的传递。

首先发现问题,例子中Modal的标题和body里的内容是写死的一个<h3>一个<p>标签,但真正使用起来弹出框里的内容都是自定义的五花八门的,一个p标签是搞不定的。接下来我们再更新一下代码,Modal.vue的改动为把<h3>标签替换成了一个name=header的<slot>插槽,body里的<p>标签替换成了一个name=body的<slot>插槽(这里用的是具名slot插槽,单个插槽、作用于插槽等就不展开了还请小伙伴们查看vue官网文档):

<template>
  <div class="modal-backdrop">
     <div class="modal" :style="mainStyles">
        <div class="modal-header">
          <slot name="header">我是子组件定义的header</slot>
        </div>
        <div class="modal-body">
            <slot name="body">我是子组件定义的body</slot>
        </div>
        <div class="modal-footer">
            <button type="button" class="btn-close" @click="closeSelf">关闭</button>
            <button type="button" class="btn-confirm" >确认</button>
        </div>
    </div>

  </div>
</template>

HelloWorld.vue的改动为,在<Modal>里定义了一个具有属性slot的div,slot的值为header<slot name="header">对应,div里是一个图片和一个<h3>标题。并且这里特意只定义了一个具有slot的div:

<template>
  <div>
    <button @click="toggleModal">打开Modal对话框</button>
    <Modal  v-show="showModal"  width="700"  @on-cancel="cancel">
       <div slot="header">
         <div class="myHeader">
           <img src="../assets/logo.png" width="40px" height="40px"/>
           <h3>我是父组件定义的标题</h3>          
         </div>         
       </div>
    </Modal>
  </div>
</template>

前面说到slot解决的组件内容的传递,它就好像是子组件定义一个占位符,父组件有对应的内容传进来就替换掉它,没有传就默认显示子组件自己定义的内容,所以上面代码运行起来会是:


完整的HelloWorld.vue和Modal.vue代码如下:
HelloWorld.vue

<template>
  <div>
    <button @click="toggleModal">打开Modal对话框</button>
    <Modal  v-show="showModal"  width="700"  @on-cancel="cancel">
       <div slot="header">
         <div class="myHeader">
           <img src="../assets/logo.png" width="40px" height="40px"/>
           <h3>我是父组件定义的标题</h3>          
         </div>         
       </div>
    </Modal>
  </div>
</template>

<script>
import Modal from './Modal.vue'
export default {
  data () {
    return {
      showModal:false
    }
  },
  components:{
    'Modal':Modal
  },
  methods:{
    toggleModal() {
      this.showModal = !this.showModal; 
    },
    cancel() {
      this.showModal = false;
    }
  }
}
</script>

<style>
.myHeader{
    justify-content: flex-start;
    padding: 15px; 
    display: flex; 
}
</style>

Modal.vue

<template>
  <div class="modal-backdrop">
     <div class="modal" :style="mainStyles">
        <div class="modal-header">
          <slot name="header">我是子组件定义的header</slot>
        </div>
        <div class="modal-body">
            <slot name="body">我是子组件定义的body</slot>
        </div>
        <div class="modal-footer">
            <button type="button" class="btn-close" @click="closeSelf">关闭</button>
            <button type="button" class="btn-confirm" >确认</button>
        </div>
    </div>

  </div>
</template>

<style>
.modal-backdrop { 
    position: fixed; 
    top: 0; 
    right: 0; 
    bottom: 0; 
    left: 0; 
    background-color: rgba(0,0,0,.3); 
    display: flex; 
    justify-content: center; 
    align-items: center; 
}
.modal { 
    background-color: #fff; 
    box-shadow: 2px 2px 20px 1px; 
    overflow-x:auto; 
    display: flex; 
    flex-direction: column;
    border-radius: 16px;
} 
.modal-header { 
    border-bottom: 1px solid #eee; 
    color: #313131; 
    justify-content: space-between;
    padding: 15px; 
    display: flex; 
} 
.modal-footer { 
    border-top: 1px solid #eee; 
    justify-content: flex-end;
    padding: 15px; 
    display: flex; 
} 
.modal-body { 
    position: relative; 
    padding: 20px 10px; 
}
.btn-close, .btn-confirm {    
    border-radius: 8px;
    margin-left:16px;
    width:56px;
    height: 36px;
    border:none;
    cursor: pointer;
}
.btn-close {
    color: #313131;
    background-color:transparent;
}
.btn-confirm {
    color: #fff; 
    background-color: #2d8cf0;
}


</style>

<script>
export default {
  name: 'Modal',
  props: {
    width:{
        type:[Number,String],
        default:300
    }
  },
  data() {
    return {       
    }
  },
  computed:{
      mainStyles() {
          let style = {};
          style.width = `${parseInt(this.width)}px`;
          return style;
      }      
  },
  methods: {
    closeSelf() {
        this.$emit('on-cancel');
    }
  }
}
</script>
3、后续

有几点需要说明一下。首先,毋庸置疑的是几乎所有的vue组件都是围绕着props、slot和events这三大件,每个部分都有不少的内容需要学习使用,就像前面说的slot还有单个插槽、作用域插槽等等,events的api也不止emit还有parent、chlidren、dispatch等等针对各种情形应运而生的。其次,看到iViewUI组件库源码的小伙伴会觉得我们这个例子的代码和它的代码有出入,这是肯定的,例子总是单薄的。最后,我发现在写HTML结构和css时漏了一个transtions过渡效果,不过无伤大雅,周五了加班狗先闪为敬~~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266