前端项目基建思路——深入响应式系统,更灵活的视图渲染方案

95 阅读3分钟

前言

在vue的项目开发中,借助vue数据驱动视图的能力辅助视图开发和管理工作,能使视图开发的任务变得更轻松。

<ul id="example-1">  
  <li v-for="item in items" :key="item.message">  
    <span style="background:#000;color:#FFF" v-if="item.message==='A'">{{ item.message }}</span>
    <span v-else>{{ item.message }}</span>
  </li>  
</ul>
var example1 = new Vue({
  el: '#example-1',
  data: {
    items: [
      { message: 'A' },
      { message: 'B' },
      { message: 'C' },
      // ...
    ]
  }
})

如上,我们使用v-for循环渲染了一个列表、并使用条件语句为message属性为A的span标签设置了特别的样式。在日常开发中,我们经常会将这样的策略运用到各种视图实现和组件封装中。

但这种策略也有一些比较明显的缺点:

  1. 随着视图中需要呈现的内容增多,模板代码的内容会变得愈发臃肿;

  2. 随着业务发展,需要适应的场景增加,业务组件和视图组件内部的兼容性代码也会愈发臃肿;

  3. 当封装的视图组件需要适应各类存在结构性差异的布局时,组件的复用性会降低

下面列举一些情形作说明:

比如我们现在要实现下面这样一个表单

Snipaste_2023-02-16_20-23-59.png

如果不做任何抽象,代码实现起来可能就如下面这个样子

<template>
  <div style="padding: 50px;background:#FFF">
    <el-form 
      :model="value"
      :rules="rules"
      ref="productInfoForm"
      label-width="120px"
      class="form-inner-container"
      size="small"
    >
      <el-form-item label="商品分类:" prop="productCategoryId">
        <el-cascader
          v-model="formData.selectProductCateValue"
          :options="productCateOptions">
        </el-cascader>
      </el-form-item>
      <el-form-item label="商品名称:" prop="name">
        <el-input v-model="formData.name"></el-input>
      </el-form-item>
      <el-form-item label="副标题:" prop="subTitle">
        <el-input v-model="formData.subTitle"></el-input>
      </el-form-item>
      <el-form-item label="商品品牌:" prop="brandId">
        <el-select
          v-model="formData.brandId"
          @change="handleBrandChange"
          placeholder="请选择品牌">
          <el-option
            v-for="item in brandOptions"
            :key="item.value"
            :label="item.label"
            :value="item.value">
          </el-option>
        </el-select>
      </el-form-item>
      <el-form-item label="商品介绍:">
        <el-input
          :autoSize="true"
          v-model="formData.description"
          type="textarea"
          placeholder="请输入内容"></el-input>
      </el-form-item>
      <el-form-item label="商品货号:">
        <el-input v-model="formData.productSn"></el-input>
      </el-form-item>
      <el-form-item label="商品售价:">
        <el-input v-model="formData.price"></el-input>
      </el-form-item>
      <el-form-item label="市场价:">
        <el-input v-model="formData.originalPrice"></el-input>
      </el-form-item>
      <el-form-item label="商品库存:">
        <el-input v-model="formData.stock"></el-input>
      </el-form-item>
      <el-form-item label="计量单位:">
        <el-input v-model="formData.unit"></el-input>
      </el-form-item>
      <el-form-item label="商品重量:">
        <el-input v-model="formData.weight" style="width: 300px"></el-input>
        <span style="margin-left: 20px">克</span>
      </el-form-item>
      <el-form-item label="排序">
        <el-input v-model="formData.sort"></el-input>
      </el-form-item>
      <el-form-item style="text-align: center">
        <el-button type="primary" size="medium" >下一步,填写商品促销</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script>

export default {
  name: 'ProductInfoDetail',
  data () {
    return {
      formData: {},
      productCateOptions: [],
      brandOptions: [],
      rules: {}
    }
  },
  methods: {
    handleBrandChange (val) {
    }
  }
}
</script>

<style scoped>
</style>

这种实现方式的优点是,字段和视图元素是一一对应的,你可以很直接的为每一个视图元素绑定事件或属性。但缺点是在维护和复用(从一个文件拷贝到另一个文件)时,它十分容易产生一些冗余的代码,并且随着功能和视图内容增加,代码会变得十分的臃肿。

意识到上面的问题后,我们经常会使借助v-for、条件语句以及vue的响应式特性实现下面这种渲染方案

<template>
  <div style="padding: 50px;background:#FFF">
    <el-form 
      :model="value"
      :rules="rules"
      ref="productInfoForm"
      label-width="120px"
      class="form-inner-container"
      size="small"
    >
      <el-form-item v-for="(item,index) in formViewFields" 
        :key="index" :label="item.label" :prop="item.key"
      >
        <template v-if="item.type==='input'">
          <el-input v-model="formData[item.key]"></el-input>
        </template>
        <template v-else-if="item.type==='textarea'">
          <el-input v-model="formData[item.key]"></el-input>
        </template>
        <!-- 省略一部分模板代码 -->
        <template v-else>
          <el-cascader
            v-model="formData[item.key]"
            :options="productCateOptions">
          </el-cascader>
        </template>
      </el-form-item>
      <el-form-item style="text-align: center">
        <el-button type="primary" size="medium" >下一步,填写商品促销</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script>

export default {
  name: 'ProductInfoDetail',
  data () {
    return {
      formData: {},
      formViewFields: [
        { type: 'input', key: 'name', label: '商品名称' },
        { type: 'input', key: 'subTitle', label: '副标题' }
        // ... 省略一部分字段信息
      ],
      productCateOptions: [],
      brandOptions: [],
      rules: {}
    }
  },
  methods: {
    handleBrandChange (val) {
    }
  }
}
</script>

<style scoped>
</style>

这样处理后,极大的减少了模板代码中的内容,也变得更好复用,在封装一些视图组件时,我们也经常会用到这样的方式去控制视图呈现。但它依然有一些显示的问题有待解决。

序号问题描述
1随着页面需要显示的视图元素增加,模板代码中的条件语句会增多,会产生一些不必要的计算开销
2如何适应各种更复杂的视图布局,如在一开始项目中出现的各类表单的布局都采取了一列展示一个字段对应视图的规范,但是后续出现了一些每一列需要展示的视图数量不均衡时,模板代码或者组件内的内容也会变复杂
3当一些视图元素在一个视图中被多次使用,但其对应的字段又存在一些属性和事件交互上的差异时,如何保持条件语句在模板代码和js中仍然占一个比较低的比重

要减少条件语句的使用,我首先想到的是使用vue内置的component特殊元素,官方对其的定义为:一个用于渲染动态组件或元素的“元组件”。

要增加视图组件对各类布局的适应性,那么需要更细粒度的组件封装,把视图布局的控制逻辑交给页面而不是组件。

通过当前页面中的管理对象将要渲染的视图元素依次告诉“元组件”,“元组件”最终渲染成对应的视图元素

处理视图元素的属性和事件绑定时,使用v-onv-bind这两个内置指令以及$attrslisteners这两个内置对象(在v3中listeners的内容被合并到attrs中)


视图组件加载器

在熟悉vue内置特殊元素component的特性后,首先封装一个视图组件加载器,视图组件加载器的用途和运行流程如下:

Snipaste_2023-02-16_22-04-30.png 代码实现如下:

<template>
  <div>
    <component
      :is="renderView"
      v-bind="$attrs"
      v-model:data="val"
    />
  </div>
</template>
<script>
import Input from './async-components/input.vue'
// ... 引入各类封装好的视图组件
export default {
  name: 'DynamicViewLoader',
  props: {
    data: {
      type: [Array, String, Number, Boolean, Object, Date],
      default: () => null
    },
    type: {
      type: String,
      default: 'input'
    }
  },
  setup () {
    const rtn = {
      components: {
        input: Input,
        // ... 注册
      }
    }
    return rtn
  },
  computed: {
    val: {
      get () {
        return this.data
      },
      set (value) {
        this.$emit('update:data', value)
      }
    },
    renderView () {
    //   return () => import(`${this.path}`) // 项目环境支持时,可以直接通过import动态加载组件渲染
      return this.components[this.type] || this.components['unRegister']
    }
  }
}
</script>


视图组件封装及其注意事项

  1. 进行视图组件封装时,按能够拆分的最小的视图单位进行抽象封装即可,拆得越细越利于结合重组。
  2. 数据需要能在上下级组件中及时同步。
  3. 组件内的一些预设属性需要写在v-bind="$attr"之前,否则由页面传入组件的同名属性会被覆盖。
  4. 由于事件冒泡机制,使用上面的视图加载器载入视图后,部分事件会多次触发,需要在事件监听函数中过滤非目标元素导致的事件触发。

视图组件实现的示例如下:

<template>
  <div>
    <el-input v-model.trim="val" clearable v-bind="$attrs" />
  </div>
</template>
<script>
export default {
  props: {
    data: {
      type: [String, Number],
      default: ''
    },

    options: {
      type: [Array, String, Object],
      default: () => {
        return []
      }
    }
  },

  methods: {},

  computed: {
    val: {
      get () {
        return this.data
      },
      set (value) {
        this.$emit('update:data', value)
      }
    }
  }
}
</script>


一个完整的用例

<template>
  <el-card class="page-container is-always-shadow">
  <!-- 根据配置项渲染搜索条件视图 -->
    <el-card>
      <h5 class="demo-title">基础用例:根据配置项渲染搜索条件视图</h5>
      <div class="filter-wrap">
        <el-form
          ref="headerFilter"
          :inline="true"
          :model="filterViewData"
          size="small"
          class="searchbar"
        >
          <el-form-item
            v-for="field in filterViewFields"
            :key="field.name"
            :prop="field.name"
            :label="field.label"
          >
            <dynamic-view-loader
              v-model:data="filterViewData[field.name]"
              :type="field.type"
              v-bind="fieldsAttrs[field.name]||{}"
              v-on="fieldsEvents[field.name]||{}"
            ></dynamic-view-loader>
          </el-form-item>
        </el-form>
        <div>{{`当前搜索条件:${JSON.stringify(filterViewData)}`}}</div>
      </div>
    </el-card>
  </el-card>
</template>

<script>

import { reactive, toRefs } from 'vue'
import DynamicViewLoader from '@/components/DynamicViewLoader.vue'
export default {
  name: 'filterDemo',
  components: {
    DynamicViewLoader
  },
  setup () {
    const handleBookSnInput = (val) => {
      // 处理事件非目标视图的事件穿透
      if(typeof val === 'object' && val.srcElement){
        return
      }
      console.log(val)
    }
    const state = reactive({
      filterViewFields: [
        { type: 'input', label: '书籍编号', name: 'bookSn' },
        { type: 'input', label: '书籍名称', name: 'bookName' },
        { type: 'select', label: '书籍分类', name: 'categories' },
        { type: 'datePicker', label: '创建时间', name: 'createTime' },
        { type: 'select', label: '书籍状态', name: 'status' }],
      filterViewData: {},
      fieldsAttrs: { // 设置要通过视图组件控制器传递给视图组件的一些属性
        bookSn: {
          clearable: false,
          maxlength: 10,
          'show-word-limit': true
        },
        categories: {
          options:[
            { code: 1 , name: '人文社科'},
            { code: 2 , name: '建筑施工'}
          ]
        }
      },
      fieldsEvents: { // 设置要通过视图组件控制器传递给视图组件的一些属性
        bookSn: {'input': handleBookSnInput}
      }
    })
    return {
      ...toRefs(state)
    }
  }
}
</script>

Snipaste_2023-02-16_22-46-40.png


写在结尾:

微信图片_20230216225904.jpg