IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    前人在 vue 项目中的 “砍树型“ 写法,让后人乘不了凉!

    熊的猫发表于 2023-10-11 10:27:52
    love 0

    前言

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

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

    本篇文章就列举一些,在 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 不需要依赖任何请求结果
    }

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

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

    93DE32BE.gif

    栽树型写法

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

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

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

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

      • 例如,将 request2 放置到 request1 的具体实现中,这种方式适用于 request1 和 request2 间在任何情况下都有紧密联系的情况下,当然也可以在 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.data 做 deepClone 深克隆 且修改后在往下层传递。

    缺点很明显了:

    • 重复定义 props

      • 需要分别在 Grandpa、Parents、Son 三个组件中定义相关的 props 和 event
    • 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> 中属实不合适,应该要尽量精简,修改条件时也不应该跑到模版中去修改。

    栽树型写法

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

    针对 v-if 和 v-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

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

    栽树型写法

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

    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 的时候,不同的开发者写法各式各样,重点是还能绕过规范检查工具,后期自己或他人维护时的难易程度可想而知,所以大家还是多多 “栽树” 吧!

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



沪ICP备19023445号-2号
友情链接