我是如何做前端项目代码优化的

本文记录了我工作中如何对后台管理项目代码进行优化的。项目使用了 Vue 和 Element UI 框架。

持续小重构可以在提测阶段或是在其他没有开发任务的时候进行,甚至当你在开发时发现需要修改的代码难以修改的时候。我觉得一个合格的开发人员,应该有持续优化的意识,切不可到代码烂到没法维护的时候才着手进行大重构,那将举步维艰。

一、减少魔法数的使用

看一下项目中的下面的代码:

1
2
3
4
5
6
  computed: {
    // 是否可设置定金
    depositable() {
      return [1, 9].includes(this.order.status)
    },
  }

通过代码,我们可以知道当订单状态为 1 和 9 时,可以设置定金。1 和 9 虽然不知道是什么,但它们确实让代码正确运行了,这毫无疑问是魔法,人类称之为 魔法数。可以看到,魔法数降低了代码的可读性。现代人类社会是不需要魔法这种东西。

那么如何如何解决问题呢?最简单但不是很好的办法是在代码后面加点注释: // 1: 待支付,9:定金待支付 ,这样我们就可以马上知道 1 和 9 代码什么。但不可能每个魔法数后面都加注释吧,尤其是文件内魔法数多次出现的情况下。

一般来说,我们是通过声明可以可读性良好的变量名解决的:

1
2
3
const toPayStatus = 1
const depositToPayStatus = 9
return [toPayStatus, toPayStatus].includes(this.order.status)

可读性好多了。不过这里的订单状态其实是一组枚举值。最好的做法是在全局维护唯一的枚举值对象,从里面取需要的状态。如果不知道怎么命名枚举值,可以找后端要枚举值,因为枚举值本来就是后端定义和约定的。

首先我们需要用一个存放全局枚举值的模块文件。

1
2
3
4
5
6
7
// enums.js
export const ENUM_ORDER_STATUS = {
	ordering: 1, // 待支付
	// ...
	depositOrdering: 9, // 定金待支付
	// ...
}

在需要用到的地方引入:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import { ENUM_ORDER_STATUS } from 'enums.js'

export default {
  // ...
  data() {
  	return {
    	ENUM_ORDER_STATUS, // 如果 template 里要用到该枚举值,在这里注入
    }
  },
  computed: {
    // 是否可设置定金
    depositable() {
      return [
        ENUM_ORDER_STATUS.ordering,
        ENUM_ORDER_STATUS.depositOrdering
      ].includes(this.order.status)
    },
  }
  // ...
}

如果枚举值只是局部使用,可以不必放到公共目录下,直接在当前文件下定义即可。

二、删除无用代码

经常可以发现一些 定义了却未被使用的变量和方法,乃至文件,其实可以直接删了。

无用变量和方法出现的几个原因:

  1. 复制类似模块后在上面修改,一些多余代码遗留下来了。有时候会加一个 tab,和旁边的 tab 的功能类似,但去掉并添加了一些功能。一般的做法是,复制 tab 对应的代码,并在上面做修改,但改完能正常运行了可能就不管了,文件里可能遗留一些多余代码。
  2. 产品希望暂时去掉一些功能,可能以后要加回来。注释了 Vue 单组件文件中 template 里的 HTML,但相关的方法没有注释掉
  3. 纯粹改着改着忘记了自己定义了这么一个方法。

解决方法:

  1. 文件内搜索变量名和函数名,看看出现的次数和位置。如果只出现一次,那多数是多余的代码了。但也要注意方法是否可能被其他组件通过 ref 调用,以及通过 inject / provide 注册的方法。
  2. 对于要暂时隐藏的功能,除了注释掉 template 里的内容,建议也注释掉涉及到的变量和方法,并简单地加上哪个版本注释掉什么功能的注释。此外,如果产品如果没说以后可能要加回来,我还是建议直接删了,因为大概率不会加回来,而且我们还有版本控制工具。如果是某些文件或目录暂时不用了,后面可能会用回来,建议修改改名字,在文件名后面加上 ([版本]废弃) 标记,如 index.vue 可以改为 index(6.5废弃).vue。如果多个版本过去后还处于废弃状态,就可以考虑将其删除了。
  3. 提交前务必检查一下代码,确保没有多余的代码才提交,方法可以用前面提到的第一个方法。

无用 CSS 样式同样处理

三、组件拆分

解耦是提高代码可读性、可维护性的最有效方式。人读取信息的容量是有限的,如果一个类(或组件)有大量的变量的方法,我们称之为 上帝类。掌控一切,全知全能的类,人类想要从中获取自己想要的信息并尝试控制它,必然不会简单。我们尝试阅读将很多功能糅合在一起的代码时,就会接受到大量的信息,其中小部分是我们想要知道,绝大部分是不需要也不想知道的。我们解耦、模块化的根本原因,本质上其实是为了提高可读性,你看不懂甚至误解,你就必然会犯错误。

通过组件拆分,我们不仅可以更专注于特定的小模块,还可以降低误改其他模块的代码几率。

项目中,还是有不少组件比较庞大的,尤其是一些比较老的、改动较少的、复杂(一开始就没设计好)的组件。

弹窗组件封装

一般来说,弹窗是可以并建议拆出去的。尤其是一个页面有多个弹窗存在的情况。如果不封装,一个组件就会有一大堆弹窗的相关变量,而且拆分其实并不难。

项目中常见的弹窗分为 表单弹窗列表弹窗 两大类。对此我们可以将 ElementUI 的 el-dialog 组件封装为一个 有具体业务的弹窗组件,模板如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
<template>
  <el-dialog
    :visible="visible"
    title="弹窗示例"
    @closed="initData"
    @update:visible="hide"
  >
    <div>
      这里放 el-form  x-table
    </div>
    <div slot="footer">
      <el-button @click="hide"> </el-button>
      <el-button type="primary" @click="confirm"> </el-button>
    </div>
  </el-dialog>
</template>

<script>

export default {
  props: {
    visible: {
      type: Boolean,
      required: true,
    },
  },
  data() {
    return {
      formData: {
        name: '',
        introduction: '',
      },
      sending: false,
    }
  },
  watch: {
    visible(val) {
      // 如果是列表弹窗
      /* if (val) {
        this.fetchData()
      } */
    }
  },
  methods: {
    fetchData: _.debounce(async function(resetPage = true) {
      // 列表请求
    }, 500),
    confirm() {
      this.$refs['form'].validate(valid => {
        if (!valid) return

        if (this.sending) return
        this.sending = true

        // 这里修改为你要使用的后端接口
        api_someMethod(this.formData)
          .then(() => {
            this.$message.success('保存成功')
            this.hide()
            this.$emit('success')
          })
          .catch(err => {
            this.$errCatchHandler(err, '保存失败')
          })
          .finally(() => {
            this.sending = false
          })
      })
    },
    hide() {
      this.$emit('update:visible', false)
    },
    initData() {
      // 如果是表单弹窗,在关闭窗口时初始化数据和清空表单校验
      /* this.formData = {
        name: '',
        introduction: '',
      }
      this.$refs['form'].clearValidate() */
      // 或 清空列表弹窗的列表筛选项
    },
  }
}
</script>

弹窗 closed 时调用清空表单项数据(表单弹窗)或者清空列表筛选项(列表弹窗)。

实例:订单详情页的组件化思路

几个月前我对订单详细页做了组件上的拆分,这里简述一下我的组件化的思路。

  • 为什么拆分?原来弹窗和列表很多,里面涉及的变量也很多,全都塞到一个组件里,信息量太大,有组件化的必要。
  • 如何拆分?从视图上的分层来斟酌如何分组件,其实看一眼其实就大概知道怎么分了,当时第一级的组件拆分如下:
div.viewport
├─── order-info(订单信息)
├─── paid-info(支付信息)
├─── pay-action(支付操作栏,中间的按钮和倒计时)
├─── usage-info(使用信息)
├─── contract-info(使用信息)
└─── add-deposit-dialog(添加定金弹窗,应该放到订单信息里,因为懒,没做)

拆完这些第一级组件后,弹窗就根据分类放到对应的一级组件中就好了。另外,还要考虑数据的传递问题,一般是通过 props 传入数据来控制组件;当弹窗进行了操作后,通过自定义事件通知页面组件重新请求数据。

  • 效果如何?挺好,后面加弹窗,就不用再加到原来唯一的组件文件上了,可以找到相关的组件下面加弹窗。比如后来有一版在使用信息列表的操作栏中加入 冻结订单 的弹窗操作,直接在 usage-info 里加就好了,很解耦。

四、命名

  • 尽量和后端字段统一。可以看看后端的 api 接口中如何对一些事物命名的,比如刚加入外呼工作台时,术语 “外呼” 其实用的是 market, 但我一开始接入这个新功能时起的名是 dialog,以为就只是打电话的,在路由和方法中都用这个术语。后来发现有点不妥,还是 market(市场)比较贴切一些。如果对后台提供的术语无法理解或者觉得不妥,可以和后端交流一番(物理),提出你的建议,最后达成共识即可。
  • 不要起类似 editLog2 的变量名。可读性不好,editLog 是后端返回的数据结构,editLog2 为做了映射的结构。这个 2 完全没有体现这个变量的意思,可以考虑命名为 mappedEditLog。
  • 通过上下文简化命名。比如一个名为 allocated.vue 文件下,请求列表没有必要写 getAllocatedStudents 的方法名,使用 fetchData 即可。

五、一些技巧

推荐使用 optsMap 过滤器

写了个 optsMap 的过滤器,对于类似 [{id: num, name: ‘str’}, …] 的数组, 传入 id 返回对应 name。这个过滤器可以保证下拉筛选项(el-option)和列表返回值映射的 一致性,同时可以减少代码的书写。如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<template>
	<div>
		...
    <el-select v-model="query.consume_type" placeholder="扣费原因" clearable @change="fetchData()">
      <el-option
        v-for="item in consumeTypeOpts"
        :key="item.value"
        :value="item.value"
        :label="item.name"
      ></el-option>
    </el-select>
		...
    <el-table-column label="扣费原因">
      <template slot-scope="{ row }">
        {{ row.consume_type | optsMap(consumeTypeOpts) }}
      </template>
    </el-table-column>
		...
  </div>
</template>

export default {
  data() {
    return {
      consumeTypeOpts: [
        { value: 1, name: '使用券' },
        { value: 2, name: '券过期' },
      ],
    }
}

consumeTypeOpts 是一个 options 数组,用在 “扣费原因” 下拉筛选器里。然后我们发现请求的列表的返回值也带了一个“扣费原因”的枚举值,我们要对其进行 code 指向文本内容的映射。我们可以使用 {{ row.consume_type | optsMap(consumeTypeOpts) }} 复用这个 consumeTypeOpts 里的数据,而不用专门写 { 1: '使用券', 2: '券过期' }[id] || '--' 这种多余且需要人工对照保证一致性的代码。

扩展公用方法

如果一些公用方法无法使用新需求,我们可以在上面追加一些参数,进行向下兼容的扩展。

比如 formatQuery,本来是将传入的对象进行深拷贝,并去掉其中为零值(如 undefined,空字符串、空数组)的键的方法,用于发起请求时处理筛选项对象。因为有一个版本出现了大量时间范围的筛选项,清空时间范围筛选项时,会导致数据变成 [null, null],且后端开始规范,不允许 get 请求中在请求字符串用空值(如 ?a=&b=3,这是 a = null 格式化导致的),所以我决定对其进行扩展。

formatQuery 不会去掉值为 null 和 [null, null] 或 [undefined, undefined] 的属性,于是我给这个过滤器加上了第二个配置项参数 { nullVal: Boolean, nullArray: Boolean } ,来处理这两种情况。很好地解决了问题。

现在也有一些过滤器,比如 validSubjectFormat 筛选器,是可以进行扩展的,这种过滤器会把数组的 id 映射到用逗号分隔的对应文本,这个逗号其实可以定为默认值为逗号的分隔符,而不是写死。我们只要多提供一个参数 sep 来指定分隔符即可。

需要注意的点:确保不会因为修改代码,导致原来的代码受到影响,即保证向后兼容。

使用一个状态变量取代多个布尔值变量

如果多个状态(用布尔值表示开启和关闭)中,一个状态为开启时,其他状态必须为关闭状态,应该用一个枚举来保存状态。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
let statusA = true
let statusB = false
let statusC = false

function openC() {
  statusA = false
  statusB = false
  statusC = true
}

// 可以改为
const ENUM_STATUS = {
  a: 1,
  b: 2,
  c: 3,
}
let currentStatus = ENUM_STATUS.a

function openC() {
	currentStatus = ENUM_STATUS.c
}

门面模式(外观模式)

如果有一些常用的调用多个方法的逻辑,我们可以将其封装成粗粒度的新方法,提高方法的易用性。这就是门面模式。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
methods: {
  redirectLogin() { /* ... */},
  logout() { /* ... */ },
  logoutAndRedirectLogin() { // 将细颗粒的方法组装为颗粒度更大些的方法
  	this.logout()
    this.redirectLogin()
  }
  
  getOrderDetail() {
  	//...
    if (err) {
    	this.logoutAndRedirectLogin() // 比分别调用多个方法要更好,不容易写漏
    }
  } 
}

另外,如果某个地方要同时调用多个接口,可以考虑让后端合并为一个接口,尤其是移动端用户使用不稳定的移动网络的情况。使用更少的接口请求,响应的时间就更快。

请求用的 sending 变量的操作的正确位置

为了防止用户对一个按钮连续点击多次,导致一些不可预料的问题。我们会在必要的时候,在请求的时候使用一个 sending 布尔变量来解决这种问题。具体做法为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
let sending = false

function postMsg() {
  if (sending) return
  sending = true
  apiSomething()
    .then(res => {
      // ...
    })
    .catch(err => {
      // ...
    }) 
    .finally(() => {
      sending = false
    })
}

sending 应该紧靠请求 api 的方法,

防止中间过程因为一些原因抛出错误,导致 sending 变为 true,但请求没发生,从而不可能完成请求将 sending 变为 false, 最终导致按钮一直处于 sending 为 true 状态,请求完后的 then、catch 和 finally 同样道理,要第一时间将 sending 设置为 false。

业务代码与非业务代码分离

如列表请求对象,应该把(某种意义上)与业务无关的 start、length、sort_name、sort_order 和业务代码分开来。

query: {
  // 非业务代码
  start: 0,
  length: 20,
  sort_name: '',
  sort_order: '',
  
  // 业务代码
  name: '',
  grade: [],
}

另外也建议属性顺序按照筛选项 UI 的顺序排布,方便对照查漏。

在 el-table 中使用 slot-scope="{ row }"

在使用 Element 的 el-table 时,在 el-table-column 组件中使用的往往是 slot-scope="scope",其实完全可以将 scope 改为使用 { row } ,这样可以写更短的 row.name,而不是 scope.row.name,减少单词拼写错误的风险,语义也没有损失。除非要用到 scope.$index 之类的变量。