12
头图

前言

欢迎关注同名公众号《熊的猫》,文章会同步更新,也可快速加入前端交流群!

最近在协助小伙伴解决问题时,在项目中都会遇到一些 “砍树型” 的写法,这些写法容易让后续 简单 的需求变得 复杂,都说 "前人栽树后人乘凉",但项目中有些写法是真的让后人乘不了凉的,甚至还得被迫加入 “砍树队伍”。

本篇文章就列举一些,在 vue 项目中的 “砍树型” 的写法,以及分析一下如何写才更合适 “栽树”,如果你有更好的方案,欢迎在评论区分享!!!

89DFA925.png

砍树 & 栽树

由于项目源码不便于直接展示,下面会使用同等的代码实例来替代。

其项目技术栈为:vue2 + vue-class-component + vue-property-decorator + typescript

滥用 watch

砍树型写法

@Watch('person', { deep: true })
doSomething(){}

@Watch('person.name', { deep: true })
doSomething(){}

@Watch('person.age', { deep: true })
doSomething(){}

@Watch('person.hobbies', { deep: true })
doSomething(){}

第一次看到这个写法我有点迷茫,但想了想好像也不难理解:

  • 首先 person.x 的部分监听 是为了处理针对不同属性值发生修改时要执行的特定逻辑
  • 而针对 person 的整体监听 是为了执行属于公共部分的逻辑

因此,上面的写法就只是相当于只是少写了几个 if 的条件分支罢了,更何况还都用了深度监听,而实际上这种 简化方式vue 内部会实例化出多个 Watcher 实例,如下:

image.png

image.png

image.png

栽树型写法

针对上述写法,如果说后续需要追加不同属性变更时的新逻辑,会有两种情况:

  • 看懂的人,会使用一样的 person.x 的部分监听 方式去添加新逻辑

    • 实际上一个 Watcher 就可以解决,没必要实例化多个 Watcher
  • 看不懂的人,可能会把新逻辑杂糅在 person 的整体监听 的公共逻辑中

    • 还得注意添加执行时机条件的判断,很容易出问题

总之,这两种情况都并不好,因此更推荐原本 if 的写法:

@Watch('person', { deep: true })
doSomething(newVal, oldVal){
  doSomethingCommon() // 公共逻辑

  if(newVal.name !== oldVal.name){
    doSomethingName() // 逻辑抽离
  }

  if(newVal.age !== oldVal.age){
    doSomethingAge() // 逻辑抽离
  }

  ...
}

946CB97F.gif

不合理使用 async/await

砍树型写法

记得当时有反馈前端视图更新太慢,因为后端通过日志查看接口响应速度还是很快的,于是查看前端代码时发现类似如下的使用:

 async mounted(){
    await this.request1(); // 耗时接口
    this.request2(); // request2 需要依赖 request1 的请求结果
    this.request3(); // request3 不需要依赖任何的请求结果
    this.request4(); // request4 不需要依赖任何请求结果
}

这种写法就导致了 request3request4 虽然不需要依赖前面异步请求结果,但是必须要等待耗时操作完成才能请求,而视图更新又必须等待接口调用完成。

上述写法可能在 开发 和 测试 环境没有太明显的影响,但是在 生产环境,这个影响就会被放大,因为不同环境数据量不同,所接口响应速度更不同,并且用户可能不会注意你的数据是否准备完成就进行相应操作,这个时候就有可能出现问题。

93DE32BE.gif

栽树型写法

为了更快的得到视图更新,针对以上写法可进行如下调整:

  • 将无关相互依赖的请求前置在 await 之前

    • 这种方式适合使用的场景就是 request1 本身还需要再其他地方单独调用,因此其内部不适合在存放额外的逻辑

       async mounted(){
      this.request3();
      this.request4();
      
      await this.request1(); // 耗时接口
      this.request2(); // request2 需要依赖 request1 的请求结果
      }
  • 将相互依赖的请求在统一在内部处理

    • 例如,将 request2 放置到 request1 的具体实现中,这种方式适用于 request1request2 间在任何情况下都有紧密联系的情况下,当然也可以在 request1 内通过 条件判断 决定是否要执行 request2

       async mounted(){
      this.request3();
      this.request4();
      this.request1(); // 耗时接口
      }
      
      async request1(){
      const res = await asyncReq();
      this.request2(res); // request2 需要依赖 request1 的请求结果
      }

同时还需要注意的是,虽然 request2 需要依赖 request1 的结果,但是对于视图更新来说,却没有必要等待 request2 请求完成后再去更新视图,也就是说,request1 请求结束后有需要更新视图的部分就可以先更新,这样视图更新时机就不会延后。

组件层层传参

砍树型写法

项目中有一个模版切换的功能,而这个模版功能封装成了一个组件,在外部看起来是 Grandpa 组件,实际上其内部包含了 Parents 组件,而最底层使用的是 Son 组件

// 顶层组件
<Grandpa :data="data" @customEvent="customEvent" />

// 中间层组件
<Parents :data="data" @customEvent="customEvent" />

// 底层组件
<Son :data="data" @customEvent="customEvent" />

由于底层的 Son 组件 需要使用到 props data自定义事件 customEvent,在代码中通过逐层传递的方式来实现,甚至在 Grandpa 组件Parents 组件 中都有对 props.datadeepClone 深克隆 且修改后在往下层传递。

缺点很明显了:

  • 重复定义 props

    • 需要分别在 Grandpa、Parents、Son 三个组件中定义相关的 propsevent
  • props 的修改来源不确定

    • 由于 Grandpa、Parents 组件都对 props.data 有修改,在出现问题需要排查时可能都要排查 Grandpa、Parents 组件

栽树型写法

上面的写法属实繁琐且不优雅,实际上可以通过 $attrs$listeners 来实现 属性和事件透传,如下:

// 顶层组件
<Grandpa :data="data" @customEvent="customEvent" />

// 中间层组件
<Parents v-bind="$attrs" v-on="$listeners" />

// 底层组件
<Son v-bind="$attrs" v-on="$listeners" />

而其中涉及到直接通过 deepClone 深克隆 的原因应该是为了便于 直接 增加/删除 props.data 中的属性,实际上应该在 props 提供层 提供修改的方法。

946B61BF.gif

没有必要的响应式数据

砍树型写法

很多时候在 Vue 中我们需要在 <template> 模版中使用 常量数据,但并不会对其进行修改,如果我们没有在 Vue 组件实例上定义,那么在是无法访问到的,于是就是有如下用法:

<template>
  <div class="app">
    <ul>
      <li v-for="num in constantData" :key="num">{{ num }}</li>
    </ul>
  </div>
</template>

<script lang="ts">
import Vue from 'vue';
import { Component } from 'vue-property-decorator';

@Component({})
export default class APP extends Vue {
  // 常量数据,只用不改,无需响应式,但会转成响应式
  ConstantData: number[] = [1, 2, 3, 4, 5];
}
</script>

然而这样的写法虽然可以达到目的,但是会将其转换为 无必要的响应式数据,而在 Vue2 中越复杂的对象转换为 响应式对象 就越繁琐,毕竟需要层层转换等,而以上给的例子还只是简单的内容。

栽树型写法

显然,我们根本不需要使用其响应式的特性,只需要 ConstantData 能够被模版正常访问到即可,那么我们可以使用如下的写法:

<script lang="ts">
import Vue from 'vue';
import { Component } from 'vue-property-decorator';

@Component({})
export default class APP extends Vue {
  // 常量数据,只用不改,无需响应式,不会转成响应式
  constantData: readonly number[] = Object.freeze([1, 2, 3, 4, 5]);
}
</script>

原理也很简单,在 vue 的源码中有如下的判断:

image.png

即只要保证目标对象属性描述符的 configurable = false,就能够保证其不会被转成响应式数据,而这我们就可以使用 Object.freeze / Object.seal 来实现,如下:

image.png

image.png

易变的 key

砍树型写法

在需要实现通过 v-for 实现 列表渲染 时,大多数人喜欢直接使用 index 作为唯一 key,如下:

<li v-for="(item, index) in mockData" :key="index">
  <!-- other content -->
  
  <button @click="deleteItem(index)">删除</button>
</li>

特别是带有 删除/移动 操作的列表,使得 index 变成 易变的 key,如果此时还不使用 唯一 key 值,那么在更新阶段会进行一些 无意义的删除/创建,这会带来性能问题。

只要列表内容足够复杂,例如其中包含 Form 表单、Table 表格、ECharts 可视化图表 等等,在更新阶段的性能会表现得尤为重要,一个操作可能就导致 页面更新出现卡顿 等问题,虽然 key 并不是唯一原因,但还是尽量 使用唯一值来作为 key 值

栽树型写法

但是并不是每个数据都会有唯一值可咋整?

那我们可以自己生成 key 值,而还可以使用 index 来作为唯一值,让它不易变就好了:

function generateKey(data, keyIndex){
  data.forEach((item, index) => {
    item.key = keyIndex !== undefined ? keyIndex++ : index;
  });
}

new Vue({
  el: '#demo',
  data: {
    mockData: generateKey([
      {
        value: '1',
        ...
      },
      {
        value: '2',
        ...
      },
    ])
  },
  methods: {
    deleteItem(index){
      this.mockData.splice(index, 1);
    }
  }
})

template 模版中的复杂表达式

砍树型写法

这里以 v-if、v-show、class 等来进行演示,如下:

  // 情况一
  <div v-if="condition1 && condition2 && method3() && computed1">
    <!-- content -->
  </div>
  
  // 情况二
  <div v-show="condition1 && condition2 && method3() && computed1">
    <!-- content -->
  </div>
  
  // 情况三
  <div :class="['class1', condition1 ? 'class2' : '', condition1 && condition2 ? 'class3' : '']">
    <!-- content -->
  </div>

这种写法只会让这个条件判断写得越来越长,虽说支持在模版中写表达式,但是如此 长且复杂 的表达式直接写在 <template> 中属实不合适,应该要尽量精简,修改条件时也不应该跑到模版中去修改。

栽树型写法

在模版中的表达式要保持精简,换句话说就是把表达式的结果放在模版中就好了,那么可想而知就可以使用 computedmethod 了。

针对 v-ifv-show 可以使用如下方式:

image.png

针对 class 可以选择将 不变的可变的 进行 分开不分开 处理:

// 方式一:分开
<div 
class="class1"
:class="divConditionClass">
  <!-- content -->
</div>

new Vue({
  el: '#demo',
  data: {},
  computed: {
    divConditionClass(){
      return {
        'class2' : condition1, 
        'class3': condition1 && condition2
      };
    },
  }
})

// 方式二:不分开
<div 
:class="divClass">
  <!-- content -->
</div>

new Vue({
  el: '#demo',
  data: {},
  computed: {
    divClass(){
      return {
        'class1': true,
        'class2' : condition1, 
        'class3': condition1 && condition2
      };
    },
  }
})

v-if 和 v-for 共用

砍树型写法

v-if 和 v-for 一起使用已经老生常谈的问题了,但是还是会有人这样使用,如下:

image.png

先抛开 v-if 和 v-for 渲染优先级所带来的性能消耗不讲,单单是这个 红色波浪线 的提示难道还不够明显吗?

8FE6E7A4.jpg

不过值得注意的是,在 vue3v-if 和 v-for 同时使用时,v-if 的优先级会比 v-for 更高.

栽树型写法

v-if 和 v-for 无非就是为了控制 显示和隐藏,既然如此只要将不需要渲染的数据内容过滤出来即可,而这不就可以通过 computedmethod 来实现了,如下:

image.png

这里可能有人会说也可以使用 vue 提供的 filters 过滤器,但是过滤器只能用在两个地方:

  • 双花括号插值 {{ }}
  • v-bind 表达式

也就是说如果你要在 v-for 中使用 过滤器 就会发生错误,如下:

// 错误的使用过滤器
<li v-for="(item, index) in mockData | filterData" :key="item.key">
  {{ item.value }}
</li>

image.png

!important 重写样式

砍树型写法

当需要调整些样式问题时,发现改动无效,一排查发现 !important 卡得死死的,如下:

image.png

直接使用 !important 强制覆盖样式虽然方便,但是这卡得也太死了,而且还是不限制对应组件内容,直接全局覆盖,这样其他人想去改动估计都得掂量掂量,会不会影响某个页面的视图效果。

9478872A.jpg

栽树型写法

调整样式时先区分是 局部样式 还是 全局样式

  • 如果是 局部样式 那么最好使用 scoped,或者是 页面顶层选择器 限定一下范围

    <style lang="less" scoped>
      @import './index.less';
    </style>
  • 如果是 全局样式 不要偷懒写在 当前组件 中,因为这样使得全局样式分离了,将来修改时也不好查找,如下:

    // 注意没有 scoped
    
    <style lang="less">
      @import './index.less';
    </style>

当然还有一种情况是 B 组件 的样式,在 A 组件 中需要调整,在其他地方使用 B 组件 时不需要调整,但此时如果直接在 A 组件scoped 样式中修改时可能不生效,这个时候需要在 scoped 限制外去调整 B 组件 的样式,那么可以这么写:

// A 组件中

<style lang="less" scoped>
  @import './index.less';
</style>

<style lang="less">
  // 也可以抽离出去
  .A {
      .B {
       ...
      }
  }
</style>

单文件内容过多

砍树型写法

一个单文件的内容实在是太多、太长,如下:

image.png

太长的内容 出现问题不好排查、新增逻辑不好加,更何况是那些涉及到上下联动的逻辑。

那么是什么原因导致这么长呢?

因为 该抽离的内容没有抽离,例如:

  • <template> 模版的内容中 表单、表格、弹窗等内容没有很好抽离
  • <script> 部分

    • state 的初始化声明赋值等过长,例如与 表单、表格 相关的 state
    • 相同的方法,总是要在不同的 .vue 文件重复声明

    栽树型写法

    那怎么抽离呢?

<template> 模版部分抽离

  • 复杂或结构长的部分可以封装成组件

    • 例如,表单、表格 的渲染就没必要一个结构一个结构的写,应该要通过 JSON Schema 的方式来实现,在 Vue 中正好可以配合 slot 插槽 实现自定义内容
  • 复杂的弹窗内容也可以组件化

    • 在上述的文件中,发现和弹窗相关的内容有 10 个,也就是写了 10 个 Dialog,如果后续还有其他不同的弹窗内容,结果可想而知,除此之外和 Dialog 相关的 state 是不是又得分别定义,因此十分不推荐这种方式
    • 仔细想想,不同弹窗变的是弹窗内容和一些附带属性而已,因此 Dialog 可以只写一个,这意味着和 Dialog 相关的 state 也只需定义一个,针对不同的弹窗内容可以封装成组件,根据情况来渲染,也可以配合 <Component :is="name" /> 实现

<script> 部分抽离

  • 组件化

    • 这一点不难理解,封装成组件后对应的逻辑也从当前组件中可以抽离出去
  • mixins 混入

    • 虽说多个 mixins 会导致数据来源的不确定性,但不影响它做逻辑的抽离
  • 自定义指令 directives

    • 如果有一段逻辑是需要操作 真实 DOM,并且这段逻辑整体上是值得被复用的,那么可以将其封装成全局自定义指令
  • extend 继承

    • 由于这个项目支持使用 class 的形式,那么一些 重复的属性、方法 就可以通过 extend 关键字来继承,无非就是定义某个公共的类,然后其他组件去继承这个类即可
  • 抽离过长的 state 初始值

    • 对于 state 的初始化赋值比较长的内容,可以将其抽离到相关独立文件中,简化主文件中不必要的代码长度,更清晰只观

最后

欢迎关注同名公众号《熊的猫》,文章会同步更新,也可快速加入前端交流群!

好了,暂时先写到这里吧!毕竟是写不完的!

948F627C.jpg

虽说做 Code Review 的时候,大家都说影响开发速度,毕竟开发时间限制在那,但是不做 Code Review 的时候,不同的开发者写法各式各样,重点是还能绕过规范检查工具,后期自己或他人维护时的难易程度可想而知,所以大家还是多多 “栽树” 吧!

希望本文对你有所帮助!!!


熊的猫
966 声望339 粉丝

业精于勤立不易方,而后鹏程万里!