likes
comments
collection
share

Vue独立组件开发:封装一个可编辑的Table组件

作者站长头像
站长
· 阅读数 16

表格组件 Table 是中后台产品中最常用的组件之一,用于展示大量结构化的数据。正规的表格,是由 <table><thead><tbody><tr><th><td> 这些标签组成,一般分为表头 columns 和数据 data

本文就来开发一个能进行编辑的表格组件 Table,如下图:

Vue独立组件开发:封装一个可编辑的Table组件

定义 Props

一般的 table 结构是这样的:

<table>
  <thead>
    <tr>
      <th>姓名</th>
      <th>年龄</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>王小明</td>
      <td>18</td>
    </tr>
    <tr>
      <td>张小刚</td>
      <td>25</td>
    </tr>
  </tbody>
</table>

表格分为了两部分,表头 thead 和数据 tbody,那 props 也定义两个:

  • columns:列配置,格式为数组,其中每一列 column 是一个对象,用来描述这一列的信息,它的具体说明如下:

    • title:列头显示文字;
    • key:对应列内容的字段名;
    • render:自定义渲染列,使用 Vue 的 Render 函数,不定义则直接显示为文本。
[
  {
    title: '姓名',
    key: 'name'
  },
  {
    title: '年龄',
    key: 'age'
  }
]
  • data:显示的结构化数据,格式为数组,其中每一个对象,就是一行的数据,比如:
[
  {
    name: '张三',
    age: 20
  },
  {
    name: '李四',
    age: 18
  }
]

column 定义的 key 值,与 data 是一一对应的,这是一种常见的数据接口定义规则。

因为不确定使用者要对某一列做什么交互,所以不能在 Table 内来实现自定义列。使用 Render 函数可以将复杂的自定义列模板的工作交给使用者来配置,Table 内只用一个 Functional Render 做中转。

表格结构

先来创建 table.vue 文件:

<template>
  <table>
    <thead>
      <tr>
        <th v-for="col in columns">{{ col.title }}</th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="row in data">
        <td v-for="col in columns">{{ row[col.key] }}</td>
      </tr>
    </tbody>
  </table>
</template>
<script>
  export default {
    props: {
      columns: {
        type: Array,
        default () {
          return [];
        }
      },
      data: {
        type: Array,
        default () {
          return [];
        }
      }
    }
  }
</script>
<style>
  table{
    width: 100%;
    border-collapse: collapse;
    border-spacing: 0;
    empty-cells: show;
    border: 1px solid #e9e9e9;
  }
  table th{
    background: #f7f7f7;
    color: #5c6b77;
    font-weight: 600;
    white-space: nowrap;
  }
  table td, table th{
    padding: 8px 16px;
    border: 1px solid #e9e9e9;
    text-align: left;
  }
</style>

使用刚刚创建的组件:

<template>
  <div>
    <table-render :columns="columns" :data="data"></table-render>
  </div>
</template>
<script>
  import TableRender from '../components/table.vue'

  export default {
    components: { TableRender },
    data() {
      return {
        columns: [
          {
            title: '姓名',
            key: 'name'
          },
          {
            title: '年龄',
            key: 'age'
          },
          {
            title: '出生日期',
            key: 'birthday'
          },
          {
            title: '地址',
            key: 'address'
          },
          {
            title: '操作'
          }
        ],
        data: [
          {
            name: '王小明',
            age: 18,
            birthday: '919526400000',
            address: '北京市朝阳区芍药居'
          },
          {
            name: '张小刚',
            age: 25,
            birthday: '696096000000',
            address: '北京市海淀区西二旗'
          }
        ]
      }
    }
  }
</script>

表格已经能渲染出来了。

Vue独立组件开发:封装一个可编辑的Table组件

但现在的单元格只是将 data 当作纯文本来显示,所以出生日期列显示为时间戳。如果要显示正常的日期(如1991-5-14),目前可以另写一个计算属性(computed),手动将时间戳换算为标准日期格式后,来动态修改 data 里的 birthday 字段。

这样做对于出生日期这样的数据还好,但对于操作这一列就不可取了,因为它带有业务逻辑,点击编辑按钮,是可以对当前行数据进行修改的。这时就要用到 Render 函数。

Render 自定义列

在上一篇文章中Vue独立组件开发:换一种思路写Vue——Render 函数与 Functional Render我们已经介绍过函数式组件 Functional Render 的用法,它没有状态和上下文,主要用于中转一个组件,用在本文的 Table 组件非常合适。

新建 render.js 文件:

export default {
  functional: true,
  props: {
    row: Object,
    column: Object,
    index: Number,
    render: Function
  },
  render: (h, ctx) => {
    const params = {
      row: ctx.props.row,
      column: ctx.props.column,
      index: ctx.props.index
    };

    return ctx.props.render(h, params);
  }
};

render.js 定义了 4 个 props:

  • row:当前行的数据;
  • column:当前列的数据;
  • index:当前是第几行;
  • render:具体的 render 函数内容。

这里的 render 选项并没有渲染任何节点,而是直接返回 props 中定义的 render,并将 h 和当前的行、列、序号作为参数传递出去。

table.vue 里就可以使用 render.js 组件:

<tbody>
  <tr v-for="(row, rowIndex) in data">
    <td v-for="col in columns">
      <template v-if="'render' in col">
        <Render :row="row" :column="col" :index="rowIndex" :render="col.render"></Render>
      </template>
      <template v-else>{{ row[col.key] }}</template>
    </td>
  </tr>
</tbody>

<script> 
    import Render from './render.js';
    export default {
        components: { Render },
    }
</script>

如果 columns 中的某一列配置了 render 字段,那就通过 render.js 完成自定义模板,否则以字符串形式渲染。比如对出生日期这列显示为标准的日期格式,可以这样定义 column:

export default {
  data () {
    return {
      columns: [
        // ...
        {
          title: '出生日期',
          render: (h, { row, column, index }) => {
            const date = new Date(parseInt(row.birthday));
            const year = date.getFullYear();
            const month = date.getMonth() + 1;
            const day = date.getDate();
            
            const birthday = `${year}-${month}-${day}`;
            
            return h('span', birthday);
          }
        }
      ]
    }
  }
}

columns 里定义的 render,是有两个参数的,第一个是 createElement(即 h),第二个是从 render.js 传过来的对象,它包含了当前行数据(row)、当前列配置(column)、当前是第几行(index),使用者可以基于这 3 个参数得到任意想要的结果。由于是自定义列了,显示什么都是使用者决定的。

如果你真正理解了,应该知道 columns 里定义的 render 字段,它仅仅是名字叫 render 的一个普通函数,并非 Vue.js 实例的 render 选项,只是我们恰巧把它叫做 render 而已,如果愿意,也可以改为其它名字。

操作这一列,默认是一个修改按钮,点击后,变为保存取消两个按钮,同时本行其它各列都变为了输入框,并且初始值就是刚才单元格的数据。变为输入框后,可以任意修改单元格数据,点击保存按钮保存整行数据,点击取消按钮,还原至修改前的数据。

当进入编辑状态时,每一列的输入框都要有一个临时的数据使用 v-model 双向绑定来响应修改,所以在 data 里再声明四个数据:

{
  data () {
    return {
      editName: '',  // 第一列输入框
      editAge: '',  // 第二列输入框
      editBirthday: '',  // 第三列输入框
      editAddress: '',  // 第四列输入框
      editIndex: -1, // 当前聚焦的输入框的行数
    }
  }
}

先定义操作列的 render 函数:

{
  data () {
    columns: [
      {
        title: '操作',
        render: (h, { row, index }) => {
          // 如果当前行是编辑状态,则渲染两个按钮
          if (this.editIndex === index) {
            return [
              h('button', {
                on: {
                  click: () => {
                    this.data[index].name = this.editName;
                    this.data[index].age = this.editAge;
                    this.data[index].birthday = this.editBirthday;
                    this.data[index].address = this.editAddress;
                    this.editIndex = -1;
                  }
                }
              }, '保存'),
              h('button', {
                style: {
                  marginLeft: '6px'
                },
                on: {
                  click: () => {
                    this.editIndex = -1;
                  }
                }
              }, '取消')
            ];
          } else {  // 当前行是默认状态,渲染为一个按钮
            return h('button', {
              on: {
                click: () => {
                  this.editName = row.name;
                  this.editAge = row.age;
                  this.editAddress = row.address;
                  this.editBirthday = row.birthday;
                  this.editIndex = index;
                }
              }
            }, '修改');
          }
        }
      }
    ]
  }
}

render 里的 if / else 可以先看 else,因为默认是非编辑状态,也就是说 editIndex 还是 -1。

当点击修改按钮时,初始化各输入框的值,即表格行中的值。接着,再把 editIndex 置为了对应的行序号 { index },此时 render 的 if 条件 this.editIndex === index 为真,编辑列变成了两个按钮:保存和取消。

点击保存,直接修改表格源数据 data 中对应的各字段值,并将 editIndex 置为 -1,退出编辑状态;点击取消,不保存源数据,直接退出编辑状态。

除编辑列,其它各数据列都有两种状态:

  • editIndex 等于当前行号 index 时,呈现输入框状态;
  • editIndex 不等于当前行号 index 时,呈现默认数据。
{
  data () {
    columns: [
      {
        title: '姓名',
        key: 'name',
        render: (h, { row, index }) => {
          let edit;

          // 当前行为聚焦行时
          if (this.editIndex === index) {
            edit = [h('input', {
              // domProps表示html标签上的固有属性
              domProps: {
                value: row.name
              },
              on: {
                input: (event) => {
                  this.editName = event.target.value;
                }
              }
            })];
          } else {
            edit = row.name;
          }

          return h('div', [
            edit
          ]);
        }
      }
    ]
  }
}

this.editIndex === index,渲染一个 input 输入框,初始值 value 通过 render 的 domProps 绑定了 row.name,并监听了 input 事件,将输入的内容,实时缓存在数据 editName 中,供保存时使用。

事实上,这里绑定的 value 和事件 input 就是语法糖 v-model 在 Render 函数中的写法,在 template 中,经常写作 <input v-model="editName">

其它列与姓名类似,只是字段不同,这里就不展开了。

这样就完成了本文开头的表格效果。但是,发现table的使用和elementui中表格使用还是有很大不同,这里完成是采用h函数来写的,这样比较难度,而且维护性也比template差。

下面,我们来继续改造,采用slot的方式来写。

template模式

对于大部分写 Vue 的开发者来说,更倾向于使用 template 的语法,毕竟它是 Vue 独有的特性。现在在原有 Table 组件基础上修改,实现一种达到同样渲染效果,但对使用者更友好的 slot-scope 写法。

作用域插槽

slot插槽都很熟悉,用于分发内容。那什么是作用域插槽呢?

先来看一个场景,比如某组件拥有下面的模板:

<ul>
  <li v-for="book in books" :key="book.id">
    {{ book.name }}
  </li>
</ul>

使用者传递一个数组 books,由组件内的 v-for 循环显示,这里的 {{ book.name }} 是纯文本输出,如果想自定义它的模板(即内容分发),就要用到 slot,但 slot 只能是固定的模板,没法自定义循环体中的一个具体的项,事实上这跟上面的 Table 场景是类似的。

常规的 slot 无法实现对组件循环体的每一项进行不同的内容分发,这就要用到 slot-scope,它本质上跟 slot 一样,只不过可以传递参数。比如上面的示例,使用 slot-scope 封装:

<ul>
  <li v-for="book in books" :key="book.id">
    <slot :book="book">
      <!-- 默认内容 -->
      {{ book.name }}
    </slot>
  </li>
</ul>

slot 上,传递了一个自定义的参数 book,它的值绑定的是当前循环项的数据 book,这样在父级使用时,就可以在 slot 中访问它了:

<book-list :books="books">
  <template v-slot="slotProps">
    <span v-if="slotProps.book.sale">限时优惠</span>
    {{ slotProps.book.name }}
  </template>
</book-list>

这就是作用域 slot(slot-scope),能够在组件的循环体中做内容分发,有了它,Table 组件的自定义列模板就不用写一长串的 Render 函数了。

第一次改造

为了兼容旧的 Render 函数用法,在 columns 的列配置 column 中,新增一个字段 slot 来指定 slot-scope 的名称:

<tbody>
  <tr v-for="(row, rowIndex) in data">
    <td v-for="col in columns">
      <template v-if="'render' in col">
        <Render :row="row" :column="col" :index="rowIndex" :render="col.render"></Render>
      </template>
      <template v-else-if="'slot' in col">
        <slot :row="row" :column="col" :index="rowIndex" :name="col.slot"></slot>
      </template>
      <template v-else>{{ row[col.key] }}</template>
    </td>
  </tr>
</tbody>

此时columns修改为:

columns: [
  {
    title: '姓名',
    slot: 'name'
  },
  {
    title: '年龄',
    slot: 'age'
  },
  {
    title: '出生日期',
    slot: 'birthday'
  },
  {
    title: '地址',
    slot: 'address'
  },
  {
    title: '操作',
    slot: 'action'
  }
]

在定义的作用域 slot 中,将行数据 row、列数据 column 和第几行 index 作为 slot 的参数,并根据 column 中指定的 slot 字段值,动态设置了具名 name

<template>
  <div>
    <table-slot :columns="columns" :data="data">
      <template v-slot:name="{ row, index }">
        <input type="text" v-model="editName" v-if="editIndex === index" />
        <span v-else>{{ row.name }}</span>
      </template>

      <template v-slot:age="{ row, index }">
        <input type="text" v-model="editAge" v-if="editIndex === index" />
        <span v-else>{{ row.age }}</span>
      </template>

      <template v-slot:birthday="{ row, index }">
        <input type="text" v-model="editBirthday" v-if="editIndex === index" />
        <span v-else>{{ getBirthday(row.birthday) }}</span>
      </template>

      <template v-slot:address="{ row, index }" slot="address">
        <input type="text" v-model="editAddress" v-if="editIndex === index" />
        <span v-else>{{ row.address }}</span>
      </template>

      <template v-slot:action="{ row, index }">
        <div v-if="editIndex === index">
          <button @click="handleSave(index)">保存</button>
          <button @click="editIndex = -1">取消</button>
        </div>
        <div v-else>
          <button @click="handleEdit(row, index)">操作</button>
        </div>
      </template>
    </table-slot>
  </div>
</template>

示例中在 <table-slot> 内的每一个 <template> 就对应某一列的 slot-scope 模板,通过配置的 name 字段,指定具名的 slot-scope

现在,基本是把 Render 函数还原成了 html 的写法,这样看起来直接多了,渲染效果是完全一样的。

总结

为了自定义列,我们使用了render函数,这样可以让用户自由的定义展示的内容。

但是通过render的写法不好看,也不好维护,于是利用作用域插槽来代替render,这样也可以满足用户自定义内容。

通过上面两种写法的对比,各有优劣,需要我们在不同场景中灵活使用。