前端组件化开发指南(三)

455 阅读5分钟

如何写出高质量的前端代码》学习笔记

书接上文《前端组件化开发指南(二)》,这篇我们聊聊组件开发的基本流程

组件开发的基本流程

在日常组件开发中,遵循以下流程可以帮助我们写出高质量的组件:

  1. 明确组件定位:确定组件是通用组件还是业务组件,是纯UI组件还是带状态组件等。
  2. 列举组件使用示例:在编码前,假设组件已开发完成,给出使用示例并进行团队评审。
  3. 确定组件的接口:包括属性(props)、事件(events)、方法(methods)和插槽(slots)。
  4. 设计组件的内部数据:区分元数据(data)和派生数据(computed)。
  5. 梳理组件的交互逻辑:用图或文字描述组件内部交互流程。
  6. 编码:在详细设计文档的指导下进行编码。

1. 组件定位

组件定位决定了后续的设计和实现。以 chakra-uiTable 组件为例:

<TableContainer>
  <Table variant='simple'>
    <TableCaption>Imperial to metric conversion factors</TableCaption>
    <Thead>
      <Tr>
        <Th>To convert</Th>
        <Th>into</Th>
        <Th isNumeric>multiply by</Th>
      </Tr>
    </Thead>
    <Tbody>
      <Tr>
        <Td>inches</Td>
        <Td>millimetres (mm)</Td>
        <Td isNumeric>25.4</Td>
      </Tr>
      <!-- More rows -->
    </Tbody>
    <Tfoot>
      <Tr>
        <Th>To convert</Th>
        <Th>into</Th>
        <Th isNumeric>multiply by</Th>
      </Tr>
    </Tfoot>
  </Table>
</TableContainer>

如果每个业务表格都用这个组件写,确实会觉得代码量很大,易用性差。但如果我们用它作为基础组件,在其上封装一个定制的表格组件,就会变得容易得多。因为基础组件的每个部分都可以通过代码进行修改和扩展。因此,评价一个组件的好坏,首先要看它的定位。

  • 通用组件:更注重通用性,易用性可以适当让步,不应耦合业务逻辑,支持通用属性和事件;
  • 业务组件:更注重易用性,只为某个具体业务服务的。

组件使用示例

在开发组件时,先给出使用示例:

<!--组件支持传递log内容-->
<CommonLog :log="log"/>

<!--组件支持自行获取log内容, 需要传递一个函数-->
<CommonLog :get-log="getLogFunction"/>

<script>
export default {
  methods: {
    getLogFunction() {
      return '';
    }
  }
}
</script>

<!--组件支持自动刷新数据,间隔为2000ms-->
<CommonLog :get-log="getLogFunction" auto-refresh :interval="2000"/>

2. 确定组件的接口

组件接口包括:

  • 属性(props) :如 type, value / v-model
  • 事件(events) :如 blur, focus
  • 方法(methods) :如 focus, blur
  • 插槽(slots) :如 prefix, suffix

3. 设计组件的内部数据

组件的内部数据分为元数据和派生数据:

export default {
  data() {
    return {
      loading: false,
      list: []
    }
  },
  computed: {
    vipList() {
      return this.list.filter(item => item.isVip);
    },
    vipCount() {
      return this.vipList.length;
    }
  }
}

4. 梳理组件的交互逻辑

描述组件内部交互流程:

/**
 * 组件挂载时:请求某某接口,初始化某某数据
 * 点击删除按钮时: 校验某某,然后二次确认,确认后将loading设为true,调用某某接口,关闭loading,toast删除成功
 * 点击保存按钮时:表单校验,不通过滚动到表单失败处;通过后loading设为true,调用某某接口,关闭loading,toast添加成功
 */

5. 编码

编码是最后一个流程,基于详细的设计文档进行实现。

实战示例

组件定位

实现一个通用的 SearchForm 组件,不耦合业务逻辑,支持自定义配置。

使用示例

  • 示例1:通过数组配置表单项,暴露 search 事件。

将这个数组命名为fields,在fields中配置每个搜索项的key、label和组件component,component可以为字符串,如inputselectradioswitch等常见的表单类型。

<template>
  <SearchForm :fields="fields" @search="search"/>
</template>

<script>
export default {
  data() {
    return {
      fields: [
        { key: 'rule', label: '规则名称', component: 'input' },
        { key: 'description', label: '描述', component: 'input' }
      ]
    }
  },
  methods: {
    search({ rule, description }) {
      // 根据搜索项的值进行查询
    }
  }
}
</script>
  • 示例2:组件样式支持配置。

支持在表单项之间设置间隔,参考ElementUI布局组件;默认间隔为20px,支持自定义。支持表单项宽度、label宽度、类名className,便于后续扩展自定义样式。

<template>
  <SearchForm :gutter="20" :labelWidth="'120px'" :fields="fields"/>
</template>

<script>
export default {
  data() {
    return {
      fields: [
        { key: 'rule', label: '规则名称', component: 'input', width: '300px', labelWidth: '100px', className: 'rule' },
        { key: 'description', label: '描述', component: 'input' }
      ]
    }
  }
}
</script>
  • 示例3:表单项支持自定义组件。

上面我们配置的component是一些常见表单项的名称,如果不满足用户需求,则可以传递过来一个自定义组件。

<template>
  <SearchForm :fields="fields" />
</template>

<script>
import CustomComponent from './CustomComponent.vue'

export default {
  data() {
    return {
      fields: [
        { key: 'rule', label: '规则名称', component: CustomComponent }
      ]
    }
  }
}
</script>
  • 示例4:支持通过插槽形式扩展表单项。

每个表单项提供两个插槽:label的插槽和表单项的插槽,label插槽名称为 ${key}-label,表单项插槽名和key值相同。

<template>
  <SearchForm :fields="fields">
    <template slot="rule-label">
      <span>规则名称<i class="el-icon-question"></i></span>
    </template>
    <template slot="rule" slot-scope="{formData}">
      <el-input v-model="formData.rule" />
    </template>
  </SearchForm>
</template>

<script>
export default {
  data() {
    return {
      fields: [{ key: 'rule' }]
    }
  }
}
</script>
  • 示例5:表单项支持设置默认值。

表单首次挂载时,支持设置每个表单项的默认值defaultValue,后续不再监听defaultValue值的变化,除非手动调用SearchForm的方法resetFields

<template>
  <SearchForm ref="SearchForm" :fields="fields" />
</template>

<script>
export default {
  data() {
    return {
      fields: [
        { key: 'rule', component: 'input', defaultValue: 'test' }
      ]
    }
  },
  methods: {
    resetFields() {
      this.$refs.SearchForm.resetFields();
    }
  }
}
</script>
  • 示例6:表单项支持设置属性和事件。

每个表单项可以配置其支持的所有属性props和事件events。需要注意一点就是,像Select这种组件,我们支持通过options来配置选项,这不是原生的Select或ElementUI 中的el-select的功能,所以需要实现一种特殊的Select子组件。

<template>
  <SearchForm :fields="fields"/>
</template>

<script>
export default {
  data() {
    return {
      fields: [
        {
          key: 'rule',
          label: '规则名称',
          component: 'input',
          componentProps: { placeholder: '请输入规则名称', maxlength: 30 },
          componentEvents: {
            change(value) {
              // 规则的change事件
            }
          }
        },
        {
          key: 'type',
          label: '规则名称',
          component: 'select',
          componentProps: {
            placeholder: '请选择类型',
            options: [
              { label: '类型1', value: 'type1' },
              { label: '类型2', value: 'type2' }
            ]
          }
        }
      ]
    }
  }
}
</script>
  • 示例7:获取 SearchForm 的当前值。
<template>
  <SearchForm ref="SearchForm"/>
</template>

<script>
export default {
  methods: {
    getSearchFormData() {
      this.$refs.SearchForm.getFields();
    }
  }
}
</script>

组件接口

SearchForm 属性

参数说明类型默认值
gutter间隔,单位像素number20
labelWidthlabel宽度string / number
fields表单项配置array[]

SearchForm 事件

事件名称说明回调参数
search触发搜索事件(values)

SearchForm 方法

方法名说明
resetFields重置搜索表单为初始值
getFields获取表单当前值

SearchForm 插槽

name说明
表单key-label每个表单项的label插槽
表单key每个表单项的插槽

组件内部数据

组件内部至少包含以下状态:

  • formData:存放表单项当前值。
  • componentsMap:存储 fields 中配置的 component 字符串值与真实组件的对应关系。
  • defaultValues:表单的默认值,基于 fields 的计算属性。
  • innerFields:经过处理的 fields,用于渲染所需的真正数据。

组件内部交互逻辑

  • mounted:初始化 formData 数据。
  • 表单项渲染逻辑:通过动态组件实现。
  • 点击搜索按钮逻辑:抛出 search 事件。
  • 点击重置按钮逻辑:将 formData 设为 defaultValues

编码实现

最终 SearchForm 组件的完整实现如下:

<template>
  <div class="search-form">
    <div class="search-form-fields">
      <div class="search-form-field" v-for="(field, index) in innerFields"
           :key="field.key"
           :style="{ width: field.width, marginRight: index < fields.length - 1 ? gutter + 'px' : 0 }">
        <span class="search-form-field-label" :style="{ width: field.labelWidth }">
          <slot :name="`${field.key}-label`">{{ field.label }}:</slot>
        </span>
        <div class="search-form-field-component">
          <slot :name="field.key" :formData="formData">
            <component
              :is="field.component"
              size="mini"
              v-model="formData[field.key]"
              v-bind="field.componentProps"
              v-on="field.componentEvents"
              style="width: 100%"/>
          </slot>
        </div>
      </div>
    </div>
    <div class="search-form-buttons">
      <el-button size="mini" @click="resetFields">重置</el-button>
      <el-button type="primary" size="mini" @click="search">查询</el-button>
    </div>
  </div>
</template>

<script>
import SearchFormSelect from "./SearchFormSelect";

export default {
  name: "SearchForm",
  props: {
    gutter: { type: Number, default: 20 },
    labelWidth: { type: [String, Number] },
    fields: { type: Array, default: () => [] }
  },
  data() {
    return {
      formData: {},
      componentsMap: { 'input': 'el-input', 'select': SearchFormSelect }
    };
  },
  computed: {
    defaultValues() {
      return this.fields.reduce((values, field) => {
        values[field.key] = field.defaultValue;
        return values;
      }, {});
    },
    innerFields() {
      return this.fields.map(field => {
        let width = field.width ? (typeof field.width === 'number' ? field.width + 'px' : field.width) : '250px';
        let labelWidth = field.labelWidth ? (typeof field.labelWidth === 'number' ? field.labelWidth + 'px' : field.labelWidth) : this.labelWidth;
        let component = typeof field.component === 'string' ? this.componentsMap[field.component] || 'el-input' : field.component;
        return { ...field, width, labelWidth, component };
      });
    }
  },
  mounted() {
    this.formData = { ...this.defaultValues };
  },
  methods: {
    search() {
      this.$emit('search', { ...this.formData });
    },
    resetFields() {
      this.formData = { ...this.defaultValues };
      this.$emit('search', { ...this.formData });
    },
    getFields() {
      return { ...this.formData };
    }
  }
};
</script>

<style scoped lang="less">
.search-form {
  display: flex;
  align-items: center;
  .search-form-fields {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: flex-end;
    margin-right: 10px;
    .search-form-field {
      display: flex;
      align-items: center;
      .search-form-field-label {
        display: inline-block;
        text-align: right;
        margin-right: 4px;
      }
      .search-form-field-component {
        flex: 1;
      }
    }
  }
}
</style>

前端组件化开发指南总结

组件化开发体现了分治思想,具有以下优点:

  • 技术层面

    • 提升代码可读性和复用性。
    • 提高UI一致性和可测试性。
  • 工作流程层面

    • 方便多人协作。
    • 降低业务开发门槛。

开发组件时需平衡复用性、扩展性、易用性、可读性和正交性。遵循单一职责原则,使用插槽和钩子函数扩展功能,减少组件间耦合。通过详细设计文档指导编码,确保组件设计合理,编码工作轻松。