关于Vue代码如何写的一些见解

3,596 阅读10分钟

前言

大家都知道Vue是目前前端三大流行框架之一,是中国人尤雨溪推出。或者基于这个原因,中国大部分中小公司都选择了Vue。我觉得应该还有另外一个重要原因,Vue比较容易入门。今天我不是要对比Vue和其它框架的优劣,我只是想谈谈Vue应该怎么写。之所以萌发这个想法,主要是因为最近接到了一个vue的中后台项目,这个项目让我发现很多自称会Vue的工程师实际上只是会用而已。

我的编程经历了三个阶段:

  • 第一阶段是实习那会,完全没有思考过代码应该怎么写,有的只是业务怎么实现,业务都是直接用代码堆积,常常会在一个文件中写出很多行代码。
  • 第二个阶段开始思考代码如何写,想尽可能的用最少的代码完成功能,于是尽可能抽出相像的部分,开始封装基础组件和通用业务组件,注意组件的划分,开始接触组件化编程。
  • 第三阶段开始体会到编程思想,并且在写代码时运用这些思想。如五大编程原则,对前端比较有用应该有两个,单一职责原则和开闭原则,另外前端还应该遵从关注点分离原则。我开始否定”代码要写的尽可能少“这个观点。开始注重代码可扩展性,注重组件的解耦,常常会问自己,这样写以后会不会很容易修改?这样写会不会比较容易扩展?

正文

下文谈谈我对Vue代码应该怎么写的思考。

什么变量应该写在data内?

很多程序员在写vue组件的时候,需要一个组件内共享的变量,或者模板中要用某一个变量的时候,就直接在data里面声明一个变量,我常常看到一个组件的data中拥有很多的变量,而且这些变量大部分都是用以配置的常量。很多人在写之前完全不考虑这个变量应不应该写在data中?

读过vue源码的人都知道,vue2的声明式渲染使用了观察者者模式,vue2通过观察data中的变量去做它事先设定好的事情,实现这个观察者模式的关键就在于object.defineproperty,关于这个的原理我就不多说了。只需要记住,data中的变量vue都会使用object.defineproperty设置set和get钩子,当data中的变量改变时,会直接或间接触发render,模板会重新计算一遍并渲染改变的DOM,也就是说data中的变量是控制模板重新render的开关,它的地位相当于React中的setState。这种变量应该越少越好。

那么对于那些我想在模板中使用的常量应该怎样声明呢?可以通过computed,computed中的变量只有它依赖的data中的变量改变时才会计算,当我们依赖的是常量时,只会执行一次运算。

强大的computed

vue中我最喜欢用computed,而且它的作用很大。它具备缓存功能,在它所依赖的data发生改变之前不会重新计算,这是大家所知道。但它的强大不是因为这个,那是因为它提供了一种数据转换的功能,这实在是太实用了。我们经常会遇到后端返回的数据不可读或者数据格式不对的情况,这个时候computed就派上用场了。下面举几个小例子:

 data() {
    return {
        state: 0,
        // 用以保存任务列表,我一般会统一将后端返回的数据保存在vuex中
        allTaskList: [],
        filterType: 0
    }   
 },
 computed: {
    // 后端返回一个state表示任务的状态,0为暂停, 1为进行中,2为完成
    taskState() {
        const {state} = this;
        let stateStr;
        switch(state) {
            case 0:
                stateStr = '暂停';
            break;
            case 1:
                stateStr = '进行中';
            break;
            case 2:
                stateStr = '完成';
            break;
        }
        return stateStr;
    },
    // 有一个场景,后端返回任务列表,前端除了显示所有的任务列表还需要提供过滤
    // 功能,用户可以选择只查看暂停中的任务或已完成的
    filterTaskList() {
        const {allTaskList, filterType} = this;
        // 这样子之后当用户选择了过滤功能,我们列表这边完全不需要其它操作,
        // 这里会自动重新计算,进而重新渲染
        return allTaskList.filter(task => task.state === filterType);
    }
     
 }

方便的filter

前端最基础的职能说白了还是显示,凡是和显示有关的都可以使用filter,vue提供了组件内filter和全局filter,它的功能和computed有点重叠,很多人不喜欢使用filter,但我要说定义一些全局filter还是能提供极大的便利。下面我贴出我常用的全局filter插件:

import moment from 'moment';
import _ from 'lodash';

const DATE_FORM = 'YYYY-MM-DD';
const TIME_FORM = 'HH:mm:ss';
const DATETIME_FORM = `${DATE_FORM} ${TIME_FORM}`;
const DATE_MINUTE_FORM = 'MM-DD HH:mm';
const DATE_YEAR_MINUTE_FORM = 'YYYY-MM-DD HH:mm';

const parseServiceStringToDate = (t) => moment(t).local();

const SECOND_SPAN = 60 * 1000;
const MIN_SPAN = 60 * SECOND_SPAN;
const DAY_SPAN = 24 * MIN_SPAN;
const WEEK_SPAN = 7 * DAY_SPAN;
const MONTH_SPAN = 30 * DAY_SPAN;
const YEAR_SPAN = 12 * MONTH_SPAN;

const secondToDate = (result, lng) => {
  const h = Math.floor(result / 3600);
  const m = Math.floor((result / 60) % 60);
  const s = Math.floor(result % 60);
  if (lng === 'en') {
    return `${h > 0 ? `${h} Hour` : ''}${m > 0 ? `${m} Min` : ''}${s > 0 ? `${s} Sec` : '0 Sec'}`;
  }
  return `${h > 0 ? `${h}小时` : ''}${m > 0 ? `${m}分钟` : ''}${s > 0 ? `${s}秒` : '0 秒'}`;
};

const canConvertDate = (t) => _.isDate(t) || !_.isNaN(Date.parse(t)) || _.isNumber(t);

export default {
  install(Vue) {
    Vue.filter(
      'toDateString',
      (t, defaultVal = '') => canConvertDate(t) ? parseServiceStringToDate(t).format(DATE_FORM) : defaultVal
    );
    Vue.filter(
      'toTimeString',
      (t, defaultVal = '') => canConvertDate(t) ? parseServiceStringToDate(t).format(TIME_FORM) : defaultVal
    );
    Vue.filter(
      'toDatetimeString',
      (t, defaultVal = '') => canConvertDate(t) ? parseServiceStringToDate(t).format(DATETIME_FORM) : defaultVal
    );
    Vue.filter(
      'toDateMinuteString',
      (t, defaultVal = '') => canConvertDate(t) ? parseServiceStringToDate(t).format(DATE_MINUTE_FORM) : defaultVal
    );
    Vue.filter(
      'toDateYearMinuteString',
      (t, defaultVal = '') => canConvertDate(t) ? parseServiceStringToDate(t).format(DATE_YEAR_MINUTE_FORM) : defaultVal
    );
    Vue.filter('timeSpan', (t, subfix = '前') => {
      const localTime = parseServiceStringToDate(t)
        .toDate()
        .getTime();
      const now = Date.now();
      const span = now - localTime;

      const timeSpanUnit = [
        [YEAR_SPAN, '年'],
        [MONTH_SPAN, '月'],
        [WEEK_SPAN, '周'],
        [DAY_SPAN, '天'],
        [MIN_SPAN, '分'],
        [SECOND_SPAN, '秒']
      ];
      for (let i = 0; i < timeSpanUnit.length; i++) {
        const s = span / timeSpanUnit[i][0];
        if (s > 1) {
          return `${s.toFixed(1)}${timeSpanUnit[i][1]}${subfix}`;
        }
      }
      return '刚刚';
    });
    Vue.filter('duration', (value, compare, lng) => {
      const t1 = parseServiceStringToDate(value);
      const t2 = _.isString(compare) ? parseServiceStringToDate(compare) : moment();
      const span = t1.toDate() - t2.toDate();
      const seconds = moment.duration(Math.abs(span)).asSeconds();
      return secondToDate(seconds, lng);
    });
  }
};

这个主要是用于时间的格式化,后端有可能返回的不是北京时间,或者返回的是时间戳,即便是正常的时间,有可能前端只需要显示日期或者时间,亦或者要显示是多少时间前进行操作,上面这个基本插件基本覆盖这些操作。使用的时候仅需要在模板中:

<template>
    {{date | toTimeString}}
</template>    

备受争议的mixin

有人说mixin是vue最大的败笔,它降低了代码的可读性。我却认为只要用得好,它还是一把好刀。mixin用于抽出公共的代码逻辑,减少代码。我认为如果单纯的想减少代码,还是不应该使用mixin,那什么时候应该用mixin?它应该用以定义一种接口。

上面每一种需求类型都对应一种表单,而且表单定制化太高,无法通过配置实现。很显然,一个表单对应一个组件,这样你会每个组件都有很多公共代码,一开始你只想抽出这些公共代码,但随即你会发现定义一种接口会是更好的选择:

提供一个表单入口,外界传入一个selectedType参数,根据这个参数选择不同的表单,外界不需要知道这个type和form是怎么映射的。

<template>
  <div class="demand-form">
    <component
      :is="currentComponent"
      v-on="$listeners"
      v-bind="{selectedType, ...$attrs}"/>
  </div>
</template>
<script>
  import GeneralForm from './General';
  import NewProductForm from './NewProduct';

  const BUSINESS_TYPE = {
    OEM: 5, 
    NEW_PRODUCT: 35, //
  };

  export default {
    name: 'DemandForm',
    components: {GeneralForm, NewProductForm},
    props: {
      selectedType: {type: Object, default: () => ({})},
    },
    computed: {
      currentComponent() {
        const {id} = this.selectedType;
        let component = GeneralForm;
        switch (id) {
          case BUSINESS_TYPE.NEW_PRODUCT:
            component = NewProductForm;
            break;
        }
        return component;
      }
    },
  };
</script>

定义一个接口,每一个具体的表单有应该实现下面的接口:

export default {
  props: {
    detailData: {type: Object, default: () => ({})},
    parentDetailData: {type: Object, default: () => ({})},
    selectedType: {type: Object, default: () => ({})},
    // 是否为创建子项目
    isCreateSonProject: {type: Boolean, default: false},
    // 是否为更新状态
    isEdit: {type: Boolean, default: false},
  },
  data() {
    return {
      uploadLoading: false,
    };
  },
  methods: {
    initFormData() {
      // 该方法用于表单更新时,表单数据的初始化
      console.error("'initFormatData' 方法必须定义");
    },
    uploadForm() {
      console.error("'uploadForm' 方法必须定义");
    },
    // 调用实际接口提交表单
    async doUpload(data) {
        // 在这里调用接口提交表单,lodaing那些东西都会放在这里
    },
  },
  created() {
    if (this.isEdit) {
      this.initFormData();
    }
    // 通过eventbus接收外界的确定时间
    this.bus.$on('onFormConfirm', () => {
      this.uploadForm();
    });
  },
  beforeDestroy() {
    this.bus.$off('onFormConfirm');
  },
};

具体的表单应该怎么实现接口?

<template>
  <div class="form" v-loading="uploadLoading">
    <ElForm
      ref="form"
      size="mini"
      label-position="top"
      :model="formData"
      :rules="rules">
      // 表单内容
    </ElForm>
  </div>
</template>
<script>
  export default {
    // 引入mixin
    mixins: [demandFormMixin],
    data() {
      return {
        formData: {
          name: ''
        },
      };
    },
    computed: {
      rules() {
        return {
        // 表单的验证规则
        };
      },
    },
    methods: {
      initFormData() {
        // 更新的时候将数据填入
        const {
          name
        } = this.detailData;
        const updateData = {
         name
        };
        this.formData = updateData;
      },
      async uploadForm() {
        await this.$refs.form.validate();
        const {
          formData: {
            name
          }
        } = this;
        // 提交表单的时候不要使用 ...对象混入魔法,应该让所有字段清晰可见
        const data = {name};
        this.doUpload(data);
      },
    },
  };
</script>

每一个表单只需要实现initFormData和uploadForm这两个接口,其中initFormData用于更新的时候填入数据,uploadForm用于提交数据,完成不用管其它东西,关注点得到了分离。表单仅需要关注更新时候插入数据,提交时准备好数据。在新增一个类型的表单时,代码量在得到减少的同时,而不造成困惑。

合理使用$attrs和$listeners

$attrs和$listeners在一定程度上也会降低组件的可读性,但有时候你应该使用它。 我经常在两种情况下用到:

  • 第一种是当要抽出某一个通用组件的时候,时常需要对第三方如element的组件进行包装,但包装不应该使原有的一些属性或事件丢失,这个时候用$attrs和$listeners是比较好的。
  • 第二种则是用于透传,有一些参数我当前组件不需要用到,但我的子组件需要用到,这个时候也是使用$attrs的一个比较好的时机。
<template>
  <ElDialog
    width="85%"
    :visible="show"
    :before-close="closeModal"
    :close-on-click-modal="false">
    <template  v-if="show">
      <component
        :is="contentComponent"
        @onSuccess="onSuccess"
        @onCancel="closeModal"
        v-bind="$attrs"/>
    </template>
  </ElDialog>
</template>
<script>
  import EndApply from './EndApply';
  import WorkloadAssess from './WorkloadAssess';
  import ChangeProject from './change-project';

  const COMPONENT_MAP = {
    END_APPLY: 'EndApply', // 结项申请
    WORKLOAD_ASSESS: 'WorkloadAssess', // 工作量评估
    CHANGE_PROJECT: 'ChangeProject' // 变更申请
  };

  export default {
    components: {EndApply, WorkloadAssess, ChangeProject},
    props: {
      show: {type: Boolean, required: true},
      componentId: {type: String}
    },
    computed: {
      contentComponent() {
        return COMPONENT_MAP[this.componentId];
      }
    },
    methods: {
      closeModal() {
        this.$emit('update:show', false);
      },
      onSuccess() {
        this.$emit('onSuccess');
        this.closeModal();
      }
    }
  };
</script>
    <ProgressModal
      :show.sync="showProgressModal"
      @onSuccess="pauseRefresh"
      v-bind="{...progressModalParams}"/>

dialog的内容是可变的,当前这个容器组件仅需要处理好dialog的关闭即可,至于内容组件的参数与我无关,直接向下透传。这样写的好处是显而易见的,当前的dialog组件并没有其它的我没有用到的参数,这个组件只负责dialog的关闭即可,但是它同时没有阻塞向其子组件传递参数的通道,子组件可以是任何的内容。使用的时候可以在show为true之前配置好 progressModalParams参数即可。

组件化编程

很多人都认为自己懂组件化编程,我认为组件化编程的学问挺深的,我也仅仅是对这个东西有了自己的见解而已。在我接触的那个项目,动则一个组件上千行代码,整个组件充斥着ctrl+c和ctrl+v的味道,后续维护的同学真的很难受,改一而动全身,增加新功能也变得异常艰难。组件化编程的核心思想应该是关注点分离、单一职责原则和开闭原则。

  • 关注点分离:vue本身就很好运用了关注点分离,比如将template、script和style三者分开。在组件化编程还是得运用一下这个思想的。组件的各部分要根据其关注点合理地将其分开,这个分开不一定是分开为两个组件,当业务量比较大的时候,这两者会是组件群,在组件群里面再分离,最小的单元应该会是组件。运用关注点分离之后你会发现原来复杂的问题问题一下子变得简单了,更重要的是维护者可以很容易地接手项目。
  • 单一职责原则:很多同学写组件恨不得在一个组件就写完所有东西,生怕多一个组件。单一职责告诉你一个组件,一个函数应该只做一件事。比较官方的说法是一个组件只有一个理由去改动它。独立为一个组件有两种情况:
    1. 一种是这个组件是通用组件,我将通用组件又分为基础组件和通用业务组件,基础组件正是像elementUI和iView提供的这些组件,这些组件不能跟接口和vuex耦合,无论在哪个项目都可以使用;业务通用组件则是通用的业务块,这个组件可以跟接口和vuex耦合,但是它在整个项目中又是通用的。比如有一个输入搜索公司成员的组件,那我将这一块连同接口完整的封装起来并且提供对外接口v-model和一些参数。通用业务组件对整个项目的效率提高有很大帮助。
    2. 另外一种则是纯粹的业务组件,这个业务组件是不可通用的。那为什么要将其独立呢?是为了可维护性和可扩展性,我们将其独立为一个组件之后,业务的关注点也得到了分离。这一种组件独立的要求是其业务高度内聚,只有在业务高度内聚的情况下我们才能很好地把它抽离出来。高度内聚不懂的同学可以理解为这一块的东西跟其它的东西没有关系或者关系很小。
  • 开闭原则:对扩展开放对修改关闭,一个好的系统架构应该遵从这个原则。我们写前端组件的时候也不妨多考虑一下这个。多问问自己自己这样写会有好的扩展性。有时候面对两个或多个很相像的页面,我们一般会把它抽出来,这样做大部分时候是对的,但在需求多变的时候,这会造成麻烦。当业务变更的时候,这两个组件变得不同了,这时候很多人都会选择直接在里面加一下逻辑进行区分,但随着变更的加多,祖传代码在各位手中诞生了。以其留下这种烂代码,在第一次变更的时候就应该将其分开。有时候我们应该在代码量和扩展性之间做一些权衡。

总结

总的来说,vue是比较简单的框架,但写好vue是需要一个过程,需要多思考,多总结。更多时候,我们应该做正确的事,而不应该做方便的事。程序员最忌的是惰性,客服惰性才能一直进步。以上都是我在工作中的一些心得,有不足地方还请大家谅解和指正。