记录一次Vue.js组件开发过程中拆分的的思考和过程。

在朋友的带领(保驾护航)下,我们在最近开发的项目中选用了Vue.js —— 这款被誉为 “当今前端三驾马车之一” 的渐进式框架 来做前端开发。

虽然之前也接触并在项目中使用过Vue.js,但都不够深入,以至于未能体会到Vue的优势,就像一个盲目追星的小粉丝。

通过近期项目的开发,每天与Vue.js打交道,时间一长也能够渐渐领略到它的优势了。

选用Vue.js可以获得较快的开发速度,组件复用数据驱动 两大特性是让我在开发过程中感觉最爽的。

针对 组件复用 这一话题,记录一下自己在开发过程中因为了解不够深入走的一些弯路和思考。

项目背景

该项目与教育行业有关,旨在通过互联网来加强和丰富家长与老师或家长与学校的联系,载体是App。

APP的结构和设计不会像一般网页那样灵活(或者说是随意?),这为组件化开发提供了很好的施展拳脚的地方。

本文以正在开的这个项目中的 作业、通知、沟通和请假等模块需要的 卡片组件 为例来写作。

Vue.js组件拆分小记 - 图1

可以见得,这些卡片长的大同小异,无非就是字段和布局的小区别。

这样看来,每一个会节约时间提高效率(会聪明地偷懒)的开发人员脑子里都会出现四个字——组件复用。

相关知识

关于Vue的组件基础知识可以传送到Vue官方文档。

( 传送门 )

最初设计

最初的想法是仅用一个卡片组件,在不往下分层(没有子组件)的情况下来承接 所有模块的卡片的显示。

至于处理不同模块间“卡片”的字段、样式和子部分的差异性(解耦)和事情处理,主要做了如下(愚昧的)考虑:

  • 通过 Props 传入type参数,再通过 v-if-else 指令来写不同的代码
  • 通过 type参数 编写methods 通过函数来获取 不同模块对应的不同接口地址或接口参数
  • 为了更进一步偷懒,最初设计把事件的处理(包括网络接口数据的请求)也写在组件中,现在想来真是蠢萌蠢萌的。

Vue.js组件拆分小记 - 图2

这样写出来的卡片组件大概长成这个样子

Card.vue

  1. <style scoped rel="stylesheet/scss" lang="scss">
  2. //omit
  3. </style>
  4. <template>
  5. <div class="card-container">
  6. <div class="card-container-inner">
  7. <v-touch tag="div" class="card-header" @tap="enterItem(config.itemPath)">
  8. <div class="card-avatar">
  9. <Avatar v-if="data.avatar" :url="data.avatar"/>
  10. <Avatar v-else/>
  11. </div>
  12. <div class="card-header-right">
  13. <h2 class="card-title theme-primary-color">{{ data.name }} <span v-if="data.read!==1">未读</span></h2>
  14. <p class="card-date-author">
  15. <Date class="card-date" :time="data.create_time"></Date>
  16. <span class="card-author" v-if="data.sendor">来自 {{ data.sendor }}</span>
  17. </p>
  18. </div>
  19. </v-touch>
  20. <v-touch tag="div" class="card-content" @tap="enterItem(config.itemPath)">
  21. {{ getContentDesc(data.content) }}
  22. <section v-if="data.images.length >= 1" class="card-image-section"
  23. :class="[data.images.length > 1 ? 'on-multi' : 'on-single', data.images.length === 2 ? 'as-double' : '']">
  24. <div v-for="image in data.images" :key="image" class="card-image-item">
  25. <Pic :url="image"></Pic>
  26. </div>
  27. </section>
  28. <section v-if="data.files.length >= 1" class="card-section">
  29. <span class="card-section-tag theme-primary-color"><Icon name="attach" class="theme-primary-color" size="0.8rem"></Icon> 附件 ({{ data.files.length }})</span>
  30. </section>
  31. </v-touch>
  32. <div v-if="config.hasFooter" class="card-footer">
  33. <v-touch tag="a" class="card-footer-item" @tap="enterComment(config.formPath)">
  34. <Icon name="comment"></Icon> 评论
  35. </v-touch>
  36. <v-touch tag="a" class="card-footer-item" @tap="clickLikeBtn(data.liked)" :class="{active: data.liked}">
  37. <Icon name="like"></Icon>
  38. </v-touch>
  39. </div>
  40. </div>
  41. </div>
  42. </template>
  43. <script>
  44. export default {
  45. name: 'card',
  46. props: {
  47. data: {},
  48. type: {}
  49. },
  50. data() {
  51. return {};
  52. },
  53. methods: {
  54. requireApi(type) {
  55. const api = {
  56. homework: '/homework/view/like',
  57. notify: '/notify/view/like',
  58. mind: '/mind/view/like'
  59. };
  60. return api[type];
  61. },
  62. requireParam(type) {
  63. const param = {
  64. homework: {
  65. homeworkid: this.data.id,
  66. like: this.data.liked
  67. },
  68. notify: {
  69. notifyid: this.data.id,
  70. like: this.data.liked
  71. },
  72. mind: {
  73. mindid: this.data.id,
  74. like: this.data.liked
  75. }
  76. };
  77. return param[type];
  78. },
  79. getContentDesc(content = '') {
  80. content = this.Helper.string.strip_tags(content) || '';
  81. return content.length > 200 ? content.substr(0, 200) + '...' : content;
  82. },
  83. clickLikeBtn: function(status) {
  84. status === 0 ? this.data.liked = 1 : this.data.liked = 0;
  85. this.Api.get(this.requireApi(this.type), this.requireParam(this.type)).on('success', json => {
  86. this.data.liked = json.data.like;
  87. });
  88. },
  89. enterItem: function(itemPath) {
  90. this.Page.open(itemPath, {
  91. item: this.data
  92. });
  93. },
  94. enterComment: function(formPath = '') {
  95. if (!formPath) return false;
  96. this.Page.pop(formPath, {
  97. itemId: this.data.id
  98. });
  99. }
  100. }
  101. };
  102. </script>

有没有一种看到一个又臭又长假组件的感觉?

在父组件(页面)中要调用它的时候这样写:

  1. //Template部分
  2. <card
  3. v-for="(item,index) in homeworkList"
  4. :key="index"
  5. :data="item"
  6. :type="type"
  7. :config="config">
  8. </card>
  9. //Script部分
  10. <script>
  11. export default {
  12. data() {
  13. return {
  14. type: 'homework',
  15. data: {
  16. homeworkList: []
  17. },
  18. config: {
  19. hasFooter: 'true',
  20. formPath: '/module/homework/form',
  21. itemPath: '/module/homework/item'
  22. }
  23. }
  24. }
  25. </script>

说它是假组件的原因主要在于,开发到后面的模块的时候感觉无法再复用这样的so-called卡片组件了,因为我们遇到了下面很典型的问题:

  • 从对应功能上看:并不是所有卡片Footer中两个按钮都是 点赞 和 评论 的功能,在一个新的模块中它不再需要点赞而是需要收藏了
  • 从数据结构上看:并不是后端返回的所有接口都包含预期的字段、返回相同的格式(在这个项目中我们至少前后端随时可以交流、可以商量、可以将就、可以妥协,可以保证一致性,但以后做开发很难保证)
  • 从页面结构上看:并不是所有卡片都要显示Footer部分,并不是所有卡片头部都是白色,万一某一个卡片是个五彩斑斓的黑呢

虽然通过绑Class、传props值、在代码里写分支结构可以解决这样的问题,但这样代码看起来 很臃肿 很杂乱 很不优雅,对于我这种强迫症而言很难受。

实在不想再多写分支结构代码来做判断解耦的时候后面就出现了 card-activity(活动模块的卡片)、card-leave(请假模块的卡片),简直违背初心。 = =

后面挣扎了下,刚好想起最近在哪里看过一句话 “代码写不下去了,一定是最初设计出问题了”,那就改吧!

重新设计

好吧,让我来重新思考一下。

这一次,设计 一组 组件,在父组件的基础上往下分子组件。

模块间卡片组件的解耦和事件处理采用如下构思:

  • 通过 Slot插槽 做主要解耦工作(灵活性高,可直接写“块状代码”和嵌入其他组件)
  • 通过 props传值 做不必采用插槽传值的数据传递
  • 一般情况下不在卡片内部处理事件,通过$emit将事件“发散”出去在父组件中进行监听再做对应处理

Vue.js组件拆分小记 - 图3

Card.vue

  1. <style scoped rel="stylesheet/scss" lang="scss">
  2. //omit
  3. </style>
  4. <template>
  5. <div class="card-container">
  6. <div class="card-container-inner">
  7. <slot></slot>
  8. </div>
  9. </div>
  10. </template>
  11. <script>
  12. export default {
  13. name: 'card'
  14. };
  15. </script>

slot插槽为子组件的插入留下空间。

card-header.vue (卡片组件的 头部子组件)

  1. <template>
  2. <v-touch tag="div" class="card-header" @tap="clickHeader">
  3. <div class="card-avatar">
  4. <Avatar v-if="avatar" :url="avatar"/>
  5. <Avatar v-else/>
  6. </div>
  7. <div class="card-text">
  8. <h2 class="card-title theme-primary-color">{{title}}</h2>
  9. <p class="card-info">
  10. <slot name="info">*xcard-header-info</slot>
  11. </p>
  12. </div>
  13. <div class="card-tag">
  14. <slot name="tag"></slot>
  15. </div>
  16. </v-touch>
  17. </template>
  18. <script>
  19. export default {
  20. name: 'card-header',
  21. props: {
  22. title: {
  23. default: '*xcard-header-title'
  24. },
  25. avatar: {}
  26. },
  27. data() {
  28. return {};
  29. },
  30. methods: {
  31. clickHeader() {
  32. this.$emit('click');
  33. }
  34. }
  35. };
  36. </script>

card-content (卡片组件的 正文内容子组件)

  1. <style scoped rel="stylesheet/scss" lang="scss">
  2. //omit
  3. </style>
  4. <template>
  5. <v-touch tag="div" @tap="clickContent">
  6. <div class="card-content">{{ getContentDesc(content) }}</div>
  7. <slot></slot>
  8. </v-touch>
  9. </template>
  10. <script>
  11. export default {
  12. name: 'card-content',
  13. props: {
  14. content: {}
  15. },
  16. data() {
  17. return {};
  18. },
  19. methods: {
  20. clickContent() {
  21. this.$emit('click');
  22. },
  23. getContentDesc(content = '') {
  24. content = this.Helper.string.strip_tags(content) || '';
  25. return content.length > 200 ? content.substr(0, 200) + '...' : content;
  26. }
  27. }
  28. };
  29. </script>

card-footer (卡片组件的 尾部组件)

  1. <style scoped rel="stylesheet/scss" lang="scss">
  2. //omit
  3. </style>
  4. <template>
  5. <div class="card-footer">
  6. <v-touch tag="a" class="card-footer-item" @tap="clickLeftBtn()" :class="{'theme-active-color': leftBtnActive}">
  7. <slot name="leftBtn">
  8. <Icon name="comment"/> 评论
  9. </slot>
  10. </v-touch>
  11. <v-touch tag="a" class="card-footer-item" @tap="clickRightBtn()" :class="{'theme-active-color': rightBtnActive}">
  12. <slot name="rightBtn">
  13. <Icon name="like"/>
  14. </slot>
  15. </v-touch>
  16. </div>
  17. </template>
  18. <script>
  19. export default {
  20. name: 'card-footer',
  21. props: {
  22. type: {},
  23. leftBtnActive: {
  24. default: false
  25. },
  26. rightBtnActive: {
  27. default: false
  28. }
  29. },
  30. data() {
  31. return {};
  32. },
  33. methods: {
  34. clickLeftBtn() {
  35. this.$emit('clickLeftBtn');
  36. },
  37. clickRightBtn() {
  38. this.$emit('clickRightBtn');
  39. }
  40. }
  41. };
  42. </script>

还有card-section-image 用于展示四宫格和九宫格的配图、card-section-attach 用于展示附件……

现在在父组件(页面)中如果要调用卡片组件并做相关配置就像搭积木一样:

  1. // 一个精简的结构
  2. <Card>
  3. <xcard-header></xcard-header>
  4. <xcard-content></xcard-content>
  5. <xcard-footer></xcard-footer>
  6. </Card>
  7. // 一个具体例子
  8. <Card class="card-theme-flat" v-for="(item,index) in homeworkList" :key="index">
  9. <xcard-header
  10. :title="item.name"
  11. :avatar="item.avatar"
  12. @click="enterItem(item)">
  13. <span slot="info"><Date :time="item.create_time"></Date> 来自 {{item.sendor}}</span>
  14. </xcard-header>
  15. <xcard-content
  16. :content="item.content"
  17. @click="enterItem(item)">
  18. <xcard-section
  19. v-if="item.images.length >= 1"
  20. icon="image"
  21. :name="'图片'+' ( '+item.images.length+' ) '">
  22. <ImageGallery :imageList="item.images"></ImageGallery>
  23. </xcard-section>
  24. <xcard-section
  25. v-if="item.files.length >= 1"
  26. icon="attach"
  27. :name="'附件'+' ( '+item.files.length+' ) '">
  28. </xcard-section>
  29. </xcard-content>
  30. <xcard-footer
  31. @clickLeftBtn="enterForm(item.id)"
  32. @clickRightBtn="likeTrigger(item)"
  33. :rightBtnActive="item.liked ===1?true:false">
  34. </xcard-footer>
  35. </Card>

总结思考

这段时间深入使用Vue.js让我更进一步地喜欢上了这个迷人的框架,希望以后能用它开许多迷人的作品。

以前以为实现同样的效果,代码写的少就是节约时间,现在越来越发现,要节约时间前期的架构很重要,不然后期会带着烦躁的心情同时耗费很多时间去改旧的代码。

就像用新的技术好的地基来盖一栋楼房和推到地基不稳的旧楼来盖一栋楼房一样的。