一次 Vue 项目改版多标签页的实录

2020 年 01 月 22 日

阅读量:0

正文共 3382 字,预计阅读时间 17 分钟

banner

问题来源:来自甲方的需求整改表

临近年假,又接到一份来自甲方长长的需求整改表。

紧接着参加了一场完全听不懂的会,大多数都是业务上的问题,基本上没插上嘴,加上大屏幕光线太亮,整场会议处于昏昏欲睡的状态。

由于我本人没参与过这个项目的具体开发,之前负责这个项目的同事因为一些原因离开了公司,所以属于临时救火。

前端方面有几个问题,其中一项较大的改动是要加上多标签页。用户是从旧系统迁移到新系统,交互思维被旧有的操作习惯所限制,无法适应新系统,这能理解。

乙方存在的意义就是解决甲方的问题,员工存在的意义就是解决公司的问题。

所以作为乙方公司的员工,虽然很久没写过 Vue,也不太懂这个项目的框架设计和具体业务,仍要解决这些问题。

问题分析:同页面中的多标签页

原来的项目界面大体上都是这个样子。

home

可以看到上面导航栏下面连面包屑都没有。

甲方最初的需求是想要看到这个列表页是属于哪个模块,我想这很简单啊,加个面包屑就可以做到。但我隐约觉得没那么简单,在我刨根问底的再三追问下,发现甲方的真实需求是要改成多页面间的切换。有时候就是这样,用户对自身的真实需求是不能精准表达出来的,需要我们去引导。

记得在 2017 年左右,由于 SPA 框架大行其道,多家 UI 库百花齐放。印象中 iview-ui 是其中最早做 admin 版本的(也有些组件库称为 Pro 版本)。那时还流行多标签页的设计。但后来大家慢慢发现这个东西使用率并没有想象中的高,而且还存在严重的性能问题,一直没有好的解决方案。现在我再去看那些 admin 版本的 UI 库,竟然没有任何一个还保留着多标签页的设计,甚至很多连面包屑都没有了。

时间再早一些,很多网站都是采用 window.open() 的方式直接在浏览器中打开新的标签页。

后来有些聪明的开发者研究出来在同一个页面内实现多标签页的方法,但同页面内的多标签页流行的时代仍然比单页面应用要早。他们也比较简单粗暴,基本上都是直接用 el.innerHTML 替换掉 html 文本,但这样就把页面的原有状态一起替换掉了。

真正意义上的同页面多标签页,是指切换标签页后,其它标签页仍然存活,并且保持原有状态。

这种玩法,注定会有很大的内存占用开销,特别是在单页面中。

代码分析:修改功能的思路

项目所使用的框架是之前的一个同事通过封装 Vue 和一大堆 Vue 生态圈的三方库而成的 jboot,目的是为了简化 Vue 开发,现已开源。但由于和原生 Vue 有些差别,如果不熟悉框架的话用起来会比较吃力。

我首先找到了 Menu 组件(菜单组件),从中找到一个 menuItemClick 方法。

/**
* 菜单点击事件
* @param event
* @param menu
* @param type
*/
menuItemClick(event, menu, type) {
    if (!menu) return;
    this.currentSelectedMenu = menu;
    if (this.childrenMenuNotEmp(menu)) {
        this.menuIsClick = true;
    } else if (type === "click") {
        let permission = routerTable().permission;
        this.$jump({ name: menu.name });
        this.$busBroadcast("menu.event.all-close");
    }
}

通过调试,确定这个地方就是跳转页面的地方。核心方法就是 this.$jump。这个 API 是框架提供的,虽然可以改它的行为,但我却不打算改。在不熟悉框架的情况下,直接动手修改 API 非常容易引发更多的问题,其它用到它的地方都会受到影响。现在选择去阅读源码也费时费力,干脆就不动它,再想其他办法。

然后我要找到右侧区域在哪里,它在一个叫做 layout 的组件(布局组件)中。

<div class="content">
  <router-view></router-view>
</div>

找到关键点,现在要做的事情很清晰了。我要把点击菜单的事件行为改成打开一个标签页。

代码实现:基本功能

实现的思路就是在 layout 组件中维护一个数组,通过这个数组来渲染多标签页。

原来的点击事件要去掉,换成给数组加入一条新数据。

由于 layout 组件和 menu 组件是父子关系,layout 组件嵌套了 menu 组件。所以最简单的方式就是 layoutmenu 传递一个回调函数。但考虑到这种全局数据,我首先想到的是 Vuex。但奇怪的是我在 package.json 文件中没有发现 Vuex 的身影。在和这套框架使用时间最长的后端工程师沟通过后,得知该框架不能正常使用 Vuex。我本来想尝试修复一下,但考虑到项目时间问题,最后算了。先解决掉现有问题吧。

一个项目慢慢变得腐朽,其实就是从这里开始的。第一扇窗户被打破了,如果不修复,反而把它堵起来,就会有接二连三的窗户被打破,最后堵不过来为止。

可是保证业务功能正常运行,比代码的可维护性更加重要,我没得选。

在确定不能使用 Vuex 后,我立马又想到了 event bus

确定了组件间的接口,接下来要确定用于渲染多标签页的数据格式。

简单起见,我用了大约 5 秒钟写出了如下数组:

[
    {
        title: "首页",
        component: Home
    }
],

数组中每个对象作为一个标签页,标签页的标题属性是 title,标签页的渲染组件属性是 component

思路有了,接下来就是用代码把它们实现出来。

创建 bus.js

首先创建一个用于全局组件交互的通道。

bus.js 的用法非常简单,用过 Vue 的同学应该明白。

import Vue from "vue";
export default new Vue();

添加渲染数据

layout 组件中添加用于渲染多标签页的数组以及当前选中的标签页 title

import Home from "../home";

export default {
  // 省略其他代码
  data() {
    return {
      // 省略其他代码
      pageTabsValue: "首页",
      pageTabs: [
        {
          title: "首页",
          component: Home,
        },
      ],
    };
  },
};

自定义 menu-add 事件

菜单点击的行为,我称之为 menu-add 事件。

在 layout 组件中监听 menu-add 事件。添加以下代码:

import Bus from "./bus.js";

export default {
    // 省略其他代码
    methods: {
        // 省略其他代码
        // 点击菜单回调
        menuAddHandler() {
            Bus.$on("menu-add", component => {
                this.pageTabs.push(component);
                this.pageTabsValue = component.title;
            });
        },
        // 关闭标签页回调,先空着
        removeTab() {}
    },
    created: {
        // 省略其他代码
        this.menuAddHandler();// 初始化组件时监听 menu-add 事件
    }
}

menu 组件中派发 menu-add 事件,修改原来的代码如下:

import Bus from "./bus.js";

menuItemClick(event, menu, type) {
    if (!menu) return;
    this.currentSelectedMenu = menu;
    if (this.childrenMenuNotEmp(menu)) {
        this.menuIsClick = true;
    } else if (type === "click") {
        let permission = routerTable().permission;
        // 通过测试,在菜单点击回调中,menu.component是渲染的组件,menu.meta.title是页面标题
        Bus.$emit("menu-add", { component: menu.component, title: menu.meta.title });
        // this.$jump({ name: menu.name });
        // this.$busBroadcast("menu.event.all-close");
    }
},

渲染多标签

接下来就是最重要的一步,完成这一步,最基本的功能就完成了。

为了保持简单,我使用了框架内封装的 element-ui 组件库的 el-tabs 组件。

Vue 中有一个 component 组件,可以用于渲染组件用。用惯了 React,难免会觉得这种写法很不优雅,而且刻板。

<!--
<div class="content">
    <router-view></router-view>
</div>
原来的这三行代码删除掉,换成下面的代码,再改改样式即可。
-->
<el-tabs
  style="background-color: white; height: calc(100% - 55px);"
  v-model="pageTabsValue"
  closable
  @tab-remove="removeTab"
>
  <el-tab-pane
    style="height: 100%;"
    v-for="(item, index) in pageTabs"
    :key="item.name || index"
    :label="item.title"
    :name="item.title"
  >
    <component :is="item.component" />
  </el-tab-pane>
</el-tabs>

完成这一步,就能看到如下效果。经过测试,发现多标签页能够正常显示了。

newhome

实现关闭标签页方法

最后实现上面写的 removeTab 方法。el-tabs 组件的 @tab-remove 事件会默认附带一个 targetName 参数,就是要关闭的标签页 title。做法也简单粗暴,找到它,然后删掉它。

removeTab(targetName) {
    const removeIndex = this.pageTabs.findIndex(
        item => item.title === targetName
    );
    this.pageTabs.splice(targetName, 1);
}

试了一下,确实可以关闭。

至此,基本的功能已经实现。

代码优化:处理边界问题

接下来才是重点。虽然基本功能已经实现,但还存在好多问题需要解决。我简单罗列了一下:

  • 首页永远不可以被关闭。
  • 关闭某个标签页时,不能让其它标签页的状态丢失。就是不会触发任何函数。
  • 关闭当前标签页时,选中的标签页应该变成上一个标签页。

经过尝试与思考,我确定不能使用 splice 来操作 pageTabs,因为 Vue 的 DOM 更新策略,导致被删除的节点后面所有节点都会被强制刷新。如果刷新的话,就会执行各个生命周期,每个标签页的状态自然就无法保存了。

为了解决这个问题,我想到了另一个办法,给 pageTabs 数组中的每个元素添加一个 show 属性,用于区别该标签是否显示。

首先将数组的默认显示的元素添加一个 show 属性。

pageTabs: [
    {
        title: "首页",
        component: Home,
        show: true
    }
],

然后在渲染标签的地方添加一个 v-if

<el-tab-pane
  style="height: 100%;"
  v-for="(item, index) in pageTabs"
  v-if="item.show"
  :key="item.name||index"
  :label="item.title"
  :name="item.title"
>
  <component :is="item.component" />
</el-tab-pane>

修改 removeTab 的逻辑,关闭标签不再直接操作数组,而是将 show 属性改为 false。并且将首页设置成不可关闭。

removeTab(targetName) {
    const removeIndex = this.pageTabs.findIndex(
        item => item.title === targetName
    );
    const currentIndex = this.pageTabs.findIndex(
        item => item.title === this.pageTabsValue
    );
    if (removeIndex === 0) {
        this.$message("首页不可以关闭");
        return;
    } else {
        this.pageTabs[removeIndex].show = false; // 隐藏页面
        if (removeIndex === currentIndex) {
            for (let i = 1; i < this.pageTabs.length; i++) {
                if (this.pageTabs[removeIndex - i].show) {
                    this.pageTabsValue = this.pageTabs[removeIndex - i].title;
                    return;
                }
            }
        }
    }
}

对应的,打开页面的方法也要变动。这里进行一个判断,如果这个标签页之前被打开过,那么意味着这个页面组件仍然存在于内存中,只需要将 show 属性改为 true,它就会自动显出出来。如果这个标签页第一次被打开,就需要再给这个对象添加 show 属性,并设置为 true

menuAddHandler() {
    Bus.$on("menu-add", component => {
        const isExist = this.pageTabs.some(tab => {
            if (tab.title === component.title) {
                return tab.show = true;
            }
        });
        if (isExist) {
            this.pageTabsValue = component.title;
        } else {
            this.pageTabs.push(Object.assign({ show: true }, component));
            this.pageTabsValue = component.title;
        }
    });
},

问题解决

至此,问题已经被解决,可以提交给测试了。

当然还有优化空间,比如封装成独立的组件可能是更好的选择;性能表现实在太差,多开几个标签页就会明显卡顿,如果明年有时间的话可以做一下性能优化。

这篇文章想表达的思想,主要是解决问题的思路和具体实现的步骤。最重要的不是细节,而是思路。

说实话,我们工作中遇到的绝大多数问题都可以通过搜索引擎和书本上的知识来解决。

但是我们要通过几个问题让自己给出一个答案。

用户真正的问题是什么?

解决问题的宏观思路是什么?

解决问题的微观思路又是什么?

有几种解决方案?

哪种解决方案是最优的?

当前场景最适合哪种方案?

该方案能把问题解决到什么程度?

这种程度能否满足用户要求?

解决问题的效率如何?

...

这些问题的答案加起来,就是我们要的那个答案。

独立解决问题,特别是解决自己不熟悉、不擅长的问题,是一个工程师最基本的能力体现。


你好,我是 卢振千,一名软件工程师。欢迎你来到我的网站。