使用 render 函數封裝高擴展的組件

需求:

後臺管理中常常有如下佈局的數據展示需求:

像表格又不是表格,像表單又不是表單,實際上樣子像表格,呈現的數據是一個對象,和 form 的綁定的值一樣,我將其稱為表單式表格。

樣式深的列是標題,淺的列是標題對應的取值,數據往往是服務器返回的,標題往往是定寬的,取值可能各種各樣,比如顯示一張圖片,值為 01,需要顯示是與否,有時候需要添加一個修改按鈕,讓用戶能修改某些值,還需要設置某一列跨越幾列。

先來看看一個基於 element ui 的實現

不好的實現:

在接手的項目看到一個實現,先看使用方式

<FormTable :data="lessonPackageArr" :fleldsInfo="lessonPackageInfo" :maxColumn="3" label-width="120px">
  <template #presentedHours="{ data }">
    <div class="flex-box between">
      <span>
        {{ data.presentedHours }}
      </span>
      <span class="column-btn" @click="editPresentedHours(data)">修改</span>
    </div>
  </template>
  <template #gifts="{ data }">
    <div class="flex-box between">
      <span>
        {{ data.gifts }}
      </span>
      <span class="column-btn" @click="editPresentedHours(data)">修改</span>
    </div>
  </template>
</FormTable>


lessonPackageInfo 對象如下結構:

// 一個對象,用於配置標題列和標題列對應的字段
// type 指定值的類型,現在組件內部設置可能顯示哪些類型的值瞭
// 對於服務其返回 1 0 需要顯示 是否的數,提供一個 map_data 來映射
// column 屬性設置跨列
// 需要自定義顯示內容 提供 slot
lessonPackageInfo: {
    orderType: { type: 'option', desc: '課時包類別', map_data: { 1: '首單', 2: '續費', 5: '贈課' } },
    combo: { type: 'text', desc: '套餐名稱' },
    presentedHours: { type: 'text', desc: '贈送課時', slot: true },
    price: { type: 'text', desc: '標準價格' },
    gifts: { type: 'text', desc: '贈送禮物', column: 3, slot: true },
  }


  • props 不夠直觀,配置項多
  • 不是完全數據驅動

為何組件的配置項多不好?

對於這種需求很固定,組件的輸入即 props 應該要最小化,組件功能要最大化,盡量給 props 提供默認值,這樣才能提高團隊的開發效率。

為何不是完全的數據驅動不好?

這個組件不是完全數據驅動的,需要自定義顯示列是,需要編寫模板。

如果需要自定義的列很多,就要寫很多模板代碼,想要再提取,隻能再次封裝組件,不提取,模板代碼可能會膨脹,你可能經常看到動輒 500 行一行的 template ?而膨脹的模板代碼,讓組件維護變得困難,需要 template 和 js 代碼之間來回切換。再者,增加一列自定義的數據,起碼要修改兩個地方。

為何需要完全的數據驅動?

雖然有 slot 來擴展組件,但是我們在寫業務組件時候應該少用,而是盡量使用數據驅動模板。因為數據是 js 代碼,當組件代碼膨脹時,很容易把 js 代碼提取成單獨的文件, 而想要提取 slot 的代碼,隻能再封裝組件。

三大前端框架的設計理念都是數據驅動模板,這是它們區別於 jQuery 的重要特征,也是我們封裝業務組件時優先遵循的原則。

看瞭組件使用的問題,再看組件的代碼:

<template>
  <div v-if="tableData.length" class="form-table">
    <div v-for="(data, _) in tableData" :key="_" class="table-border">
      <el-row v-for="(row, index) in rows" :key="index">
        <el-col v-for="(field, key) in row" :key="key" :span="getSpan(field.column)">
          <div v-if="(field.disabled && data[key]) || !field.disabled" class="column-content flex-box between">
            <div class="label" :style="'width:' + labelWidth">
              <span v-if="field.required" class="required">*</span>
              {{ field.desc }}
            </div>
            <div class="text flex-item" :title="data[key]">
              <template v-if="key === 'minAge'">
                <span>{{ data[key] }}</span>
                -
                <span>{{ data['maxAge'] }}</span>
              </template>
              <template v-else-if="key === 'status'">
                <template v-if="field.statusList">
                  <span v-if="data[key] == 0" :class="field.statusList[2]">{{ field.map_data[data[key]] }}</span>
                  <span v-else-if="data[key] == 10 || data[key] == 34" :class="field.statusList[1]">
                    {{ field.map_data[data[key]] }}
                  </span>
                  <span v-else :class="field.statusList[0]">{{ field.map_data[data[key]] }}</span>
                </template>
                <span v-else>{{ field.map_data[data[key]] }}</span>
              </template>

              <slot v-else :name="key" v-bind:data="data">
                <TableColContent
                  :dataType="field.type"
                  :metaData="data[key]"
                  :mapData="field.map_data"
                  :text="field.text"
                />
              </slot>
            </div>
          </div>
        </el-col>
      </el-row>
    </div>
  </div>
  <div v-else class="form-table empty">暫無數據</div>
</template>

<script>
  import TableColContent from '@/components/TableColContent'
  export default {
    name: 'FormTable',
    components: {
      TableColContent,
    },
    props: {
      // 數據
      data: {
        required: true,
        type: [Object, Array, null],
      },
      // 字段信息
      fleldsInfo: {
        required: true,
        type: Object,
        // className: { type: "text", desc: "班級名稱", column: 3 },
      },
      // 最多顯示列數
      maxColumn: {
        required: false,
        type: Number,
        default: 2,
      },
      labelWidth: {
        required: false,
        type: String,
        default: '90px',
      },
    },
    data() {
      return {}
    },
    computed: {
      tableData() {
        if (!this.data) {
          return []
        }
        if (this.data instanceof Array) {
          return this.data
        } else {
          return [this.data]
        }
      },
      rows() {
        const returnArray = []
        let total = 0
        let item = {}
        for (const key in this.fleldsInfo) {
          const nextTotal = total + this.fleldsInfo[key].column || 1
          if (nextTotal > this.maxColumn) {
            returnArray.push(item)
            item = {}
            total = 0
          }
          total += this.fleldsInfo[key].column || 1
          item[key] = this.fleldsInfo[key]
          if (total === this.maxColumn) {
            returnArray.push(item)
            item = {}
            total = 0
          }
        }
        if (total) {
          returnArray.push(item)
        }
        return returnArray
      },
    },
    methods: {
      getSpan(column) {
        if (!column) {
          column = 1
        }
        return column * (24 / this.maxColumn)
      },
    },
  }
</script>

有哪些問題?

  • 模板有太多的條件判斷,不優雅
  • 自定義顯示列,還需要在引入 TableColContent,增加瞭組件復雜性

TableColContent 內部還是對配置項的 type 進行條件判斷

部分代碼:

<span v-else-if="dataType === 'image' || dataType === 'cropper'" :class="className">
  <el-popover placement="right" title="" trigger="hover">
    <img :src="metaData" style="max-width: 600px;" />
    <img slot="reference" :src="metaData" :alt="metaData" width="44" class="column-pic" />
  </el-popover>
</span>


分析完以上實現的問題,看看好的實現:

好的實現:

先看使用方式:

<template>
  <ZmFormTable :titleList="titleList" :data="data" />
</template>
<script>
  export default {
    name: 'Test',
    data() {
      return {
        data: {}, // 從服務器獲取
        titleList: [
          { title: '姓名', prop: 'name', span: 3 },
          {
            title: '課堂作品',
            prop: (h, data) => {
              const img =
                (data.workPic && (
                  <ElImage
                    style='width: 100px; height: 100px;'
                    src={data.workPic}
                    preview-src-list={[data.workPic]}
                  ></ElImage>
                )) ||
                ''
              return img
            },
            span: 3,
          },
          { title: '作品點評', prop: 'workComment', span: 3 },
        ],
      }
    },
  }
</script>


組件說明: titleList是組件的列配置,一個數組,元素 title 屬性是標題,prop 指定從 data 裡取值的字段,span 指定這列值跨越的行數。

prop 支持 string ,還支持函數,這是實現自定義顯示的方式,當這個函數很大時,可提取到獨立的 js 文件中,也可以把整個 titleList 提取單獨的 js 文件中。

參數 h 和 data 是如何傳遞進來的?或者 這函數在哪調用呢?

h 是 createElement 函數,data 是從組件內部的 data,和父組件傳入的 data 是同一個值。

當普通函數的第一個參數是 h 是,它就是一個 render 函數。

這種方式使用起來簡單多瞭。

看看內部實現:

<template>
  <div class="form-table">
    <ul v-if="titleList.length">
      <!-- titleInfo 是經過轉化的titleList-->
      <li
        v-for="(item, index) in titleInfo"
        :key="index"
        :style="{ width: ((item.span || 1) / titleNumPreRow) * 100 + '%' }"
      >
        <div class="form-table-title" :style="`width: ${titleWidth}px;`">
          <Container v-if="typeof item.title === 'function'" :renderContainer="item.title" :data="data" />
          <span v-else>
            {{ item.title }}
          </span>
        </div>
        <div class="form-table-key" :style="`width:calc(100% - ${titleWidth}px);`">
          <Container v-if="typeof item.prop === 'function'" :renderContainer="item.prop" :data="data" />
          <span v-else>
            {{ ![null, void 0].includes(data[item.prop] && data[item.prop]) || '' }}
          </span>
        </div>
      </li>
    </ul>
    <div v-else class="form-table-no-data">暫無數據</div>
  </div>
</template>

<script>
  import Container from './container.js'
  export default {
    name: 'FormTable',
    components: {
      Container,
    },
    props: {
      titleWidth: {
        type: Number,
        default: 120,
      },
      titleNumPreRow: {
        type: Number,
        default: 3,
        validator: value => {
          const validate = [1, 2, 3, 4, 5, 6].includes(value)
          if (!validate) {
            console.error('titleNumPreRow 表示一行有標題字段對,隻能時 1 -- 6 的偶數,默認 3')
          }
          return validate
        },
      },
      titleList: {
        type: Array,
        default: () => {
          return []
        },
        validator: value => {
          const validate = value.every(item => {
            const { title, prop } = item
            return title && prop
          })
          if (!validate) {
            console.log('傳入的 titleList 屬性的元素必須包含 title  和 prop 屬性')
          }
          return validate
        },
      },
      data: {
        type: Object,
        default: () => {
          return {}
        },
      },
    },
  }
</script>
<!-- 樣式不是關鍵,省略 -->

實現自定義顯示的方式,沒有使用動態插槽,而是用一個函數組件Container,該組件接收一個 render 函數作為 prop

export default {
  name: 'Container',
  functional: true,
  render(h, { props }) {
    return props.renderContainer(h, props.data)
  },
}

Container 內部調用 titleList 傳入的函數。

總結:

  • 封裝組件時優先考慮數據驅動
  • 普通函數的第一個參數是 h,就是渲染函數
  • 可能有一些人不習慣寫 JSX, 可兼容兩種寫法

到此這篇關於使用 render 函數封裝高擴展的組件的文章就介紹到這瞭,更多相關 render 函數封裝高擴展組件內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: