记一次 Vue 组件设计以及使用 computed 动态引入组件

本文涉及技术点:

  • 动态组件 & 异步组件
  • 内置组件 keep-alive & transition
  • 插槽 slot 及 v-slot

实际场景

多级 tabs 切换,tab 项不固定,灵活控制 tab 项内容的展示,如下图。

image.png

目录结构

目录结构大概像这样:

  • src
    • components - 公共组件
      • Tabs.vue - 封装的 Tabs 组件
      • EmptyView.vue - 空页面组件
      • *.vue - 其他公共组件
    • pages - 容器组件
      • Index.vue - 主要处理一级 tabs 数据及对应的内容渲染
      • VersionList.vue 主要处理二级 tabs 数据及对应的内容渲染
    • views - 视图组件,不固定需要动态引入,可以无限扩展
      • project-exercise
        • Index.vue

组件设计

从页面元素的可复用性角度考虑,我们将将组件按类型分为公众组件、容器组件和视图组件。

公共组件

根据对页面元素的分析,我们可以提取选项卡元素为公共组件,因为两个地方用到了选项卡切换,所以根据需求进行封装,代码如下。

1
<!--src/components/Tags.vue -->
2
<template>
3
  <el-tabs v-model="active" :type="type" @tab-click="handleClick">
4
    <el-tab-pane v-for="item in tabs" :key="item.id" :name="item.name" :label="item.label"></el-tab-pane>
5
    <transition name="component-fade" mode="out-in">
6
      <keep-alive>
7
        <slot :item="currentTab"></slot>
8
      </keep-alive>
9
    </transition>
10
  </el-tabs>
11
</template>

我们封装的组件 Tags 中,使用 elementUI中的 tabs 组件(类库可以随意选择,不要受工具限制)。

公共组件 Tags 由两部分构成:

  • tabs 切换栏 - 切换栏数据由外部控制,通过 props 注入。
  • 内容展示区域 - 内容展示区域由 slot 进行控制。

之所以 slot 外层包裹 keep-alive 是因为实际分发的组件内容是由动态组件控制的,起到缓存优化的作用。

一级容器组件 Index

容器组件分为: 一级选项卡容器和二级选项卡容器,一级选项卡内容展示区域又负责渲染二级选项卡及选项卡对应的内容区域。

1
<--! src/pages/Index.vue-->
2
<template>
3
  <div>
4
    <v-tags :activeName="activeName" :type="tabType" :tabs="tabs" v-slot="current">
5
      <component :is="getCurrentTab(current.item)" :tab="getCurrentTab(current.item)"></component>
6
    </v-tags>
7
  </div>
8
</template>
9
<script>
10
  import VTags from '@/components/Tabs';
11
  import EmptyView from '@/components/EmptyView';
12
  import VersionList from './VersionList';
13
  export default {
14
    components: {
15
      VTags,
16
      EmptyView,
17
      ProjectExercise: VersionList,
18
      FullCycle: VersionList
19
    },
20
    data() {
21
      return {
22
        tabType: 'card',
23
        activeName: 'project-exercise',
24
        tabs: [...]     
25
      }
26
    },
27
    methods: {
28
      // 根据 tabName 渲染不同组件
29
      getCurrentTab(name) {
30
        const tabName = [
31
          'project-exercise', 
32
          'full-cycle'
33
        ]
34
        return tabName.includes(name) ? name : 'empty-view';
35
      }
36
    }, 
37
  }
38
</script>

一级容器组件做的事情:

  • 负责告诉公共组件 Tabs 渲染哪些 tabs 数据。
  • 负责控制一级选项卡进行切换时,渲染对应的内容组件。

因此通过 props 传入给 Tabs 用来渲染的 tabs 数据可能像这样:

1
tabs: [
2
  { id: '0',
3
    label: '项目策划',
4
    name: 'project-exercise'
5
  },
6
  { id: '1',
7
    label: '需求分析',
8
    name: 'demand-analysis'
9
  },
10
  { id: '2',
11
    label: '设计编码',
12
    name: 'design-encoding'
13
  },
14
  { id: '3',
15
    label: '单元测试',
16
    name: 'unit-test'
17
  },
18
  { id: '4',
19
    label: '组装测试',
20
    name: 'assembly-test'
21
  },
22
  { id: '5',
23
    label: '确认测试',
24
    name: 'confirmation-test'
25
  },
26
  { id: '6',
27
    label: '全生命周期',
28
    name: 'full-cycle'
29
  }
30
]

一级选项卡渲染出来的结果像下图所示。

分发给 Tabs 组件的 slot 插槽的内容通过动态组件 component 控制。

1
<component :is="getCurrentTab(current.item)" :tab="getCurrentTab(current.item)"></component>

is 属性的值由公共组件 Tabs 传入,传入的值与 name 值对应,由 v-slot 接受。最后对处理传入的值进行匹配操作,如下代码。

1
methods: {
2
  // 根据 tabName 渲染不同组件
3
  getCurrentTab(name) {
4
    const tabName = [
5
      'project-exercise', 
6
      'full-cycle'
7
    ]
8
    return tabName.includes(name) ? name : 'empty-view';
9
  }
10
},

根据需求我们只渲染 project-exercisefull-cycle 两个选项中的内容,其他选项我们展示一个 EmptyView 组件,效果如下。

二级容器组件 VersionList

二级容器组件是一级容器组件和视图组件的 中间桥梁,也就是说一级容器选项卡进行切换时都会渲染二级容器组件,二级容器组件主要负责渲染版本列表和版本对应的视图组件。

版本号作为二级选项卡存在,每一个一级选项卡的内容展示都会显示相同的版本列表。

1
<template>
2
  <div>
3
    <v-tags :type="tabType" :tabs="tabs" v-slot="current">
4
      <component :is="renderView" v-if="renderView" :planId="getPlanId(current.item)"></component>
5
      <!-- <own-view :planId="getPlanId(current.item)"></own-view> -->
6
    </v-tags>
7
  </div>
8
</template>
9
<script>
10
  //import OwnView from "../views/project-exercise/";
11
</script>

VersionListtemplate 类似一级容器组件,也是引入公共组件 Tags,并通过 props 向其传递 tabs ,告诉公共组件选显示什么样的项卡数据。

接下来,二级选项卡对应的视图组件,也是由动态组件 component 控制(分发传给 Tags 组件中 slot 插槽的内容)。

1
<component :is="renderViewName" v-if="renderViewName" :planId="getPlanId(current.item)"></component>

computed 动态引入异步组件

与一级容器组件不同的是,传入给 is 属性的值不是组件名,而是组件实例,这里渲染的视图组件不是通过固定路径引入,而是通过 import 动态引入的,这里也是本文的重点 computed 动态引入组件, 具体实现代码如下。

1
<template>
2
  ...
3
</template>
4
<script>
5
import VTags from "@/components/Tabs";
6
import { getProjectPlans } from "@/api";
7
export default {
8
  props: ["tab"],
9
  components: {
10
    VTags
11
  },
12
  data() {
13
    return {
14
      tabType: "border-card",
15
      tabs: [],
16
      renderView: null, 
17
      view: this.tab //tab 名
18
    };
19
  },
20
  watch: {
21
    tab() {
22
      this.view = this.tab;
23
      this.init()
24
    }
25
  },
26
  computed: {
27
    // 通过计算属性动态引入组件
28
    loaderWiew() {
29
      return () => import("../views/" + this.view + "/Index.vue");
30
    }
31
  },
32
33
  methods: {
34
    // 根据 name 获得 planId
35
    getPlanId(name) {
36
      let filterTabs = this.tabs.filter(item => item.name == name);
37
      if (filterTabs.length) {
38
        return filterTabs[0].id;
39
      }
40
    },
41
    init() {
42
      this.loaderWiew().then(() => {
43
        // 动态加载组件
44
        // this.loaderWiew() 为组件实例
45
        this.renderView = () => this.loaderWiew();
46
      }).catch(() => {
47
        // 组件不存在时处理
48
        this.renderView = () => import("@/components/EmptyView.vue");
49
      });
50
    }
51
  },
52
  mounted() {
53
    this.init();  
54
    // 省略通过接口获取版本列表数据的逻辑
55
  }
56
};
57
</script>

为什么使用 computed 去动态引入组件,而不是像这样:

1
<template>
2
  ...
3
</template>
4
<script>
5
import VTags from "@/components/Tabs";
6
const OwnView = import("../views/" + this.view + "/Index.vue");
7
export default {
8
  components: {
9
    VTags,
10
    OwnView
11
  },
12
}
13
</script>

要知道,在 export defaul {} 外部是无法获取 vue 实例的,因此就无法与外部进行通信,获取不到外部传入的 this.view 变量。

因此我们只能通过引入异步组件的概念,来动态引入组件。

首先我们在计算属性中创建异步组件的实例,返回 Promise

1
computed: {
2
  // 返回 Promise
3
  loaderWiew() {
4
    return () => import("../views/" + this.view + "/Index.vue");
5
  }
6
},

在组件挂载 mouted 阶段,处理 fulfilledrejected 两种状态,fulfilled 正常渲染,rejected 则渲染 EmptyView 组件。

1
init() {
2
  this.loaderWiew().then(() => {
3
    // 动态加载组件
4
    // this.loaderWiew() 为组件实例
5
    this.renderViewName = () => this.loaderWiew();
6
  }).catch(() => {
7
    // 组件不存在时处理
8
    this.renderViewName = () => import("@/components/EmptyView.vue");
9
  });
10
}
11
...
12
mounted() {
13
  this.init();
14
}

this.view 的值与 views 目录下的子目录匹配,匹配成功,代表成功引入。(后续开发中,视图组件可以无限扩展)

接着,通过 watch 监听数据变化,重新初始化组件。

1
watch: {
2
  tab() { // tab 通过 props 传入,传入的值与目录名称对应
3
    this.view = this.tab; // 
4
    this.init(); // 由变化时,说明二级 tab 进行了切换,重新渲染
5
  }
6
},

最后,视图组件引入成功,正常渲染。

引入失败,渲染 EmptyView 组件。

最后

一个完整的多级 tabs 切换组件就设计完成了,支持无限 view 层组件扩展,可以根据需求灵活控制 tab 项内容的展示。

1
getCurrentTab(name) {
2
    const tabName = [
3
      'project-exercise', 
4
      'full-cycle'
5
    ]
6
    return tabName.includes(name) ? name : 'empty-view';
7
}