{T}

Render表格组件

表格组件 Table 是中后台产品中最常用的组件之一,用于展示大量结构化的数据。本节分别使用 Render 函数slot-scope 两种方案来实现可自定义列的表格组件,并对比它们的优劣。

表格组件 Table 是中后台产品中最常用的组件之一,用于展示大量结构化的数据。大多数组件库都提供了表格组件,比如 iView,功能也是非常强大。正规的表格,是由 <table><thead><tbody><tr><th><td> 这些标签组成,一般分为表头 columns 和数据 data。本小节就来开发一个最基本的表格组件 Table,它支持使用 Render 函数来自定义某一列。

分析

如果表格只是呈现数据,是比较简单的,比如下图:

因为结构简单,我们甚至不需要组件,直接使用标准的 table 系列标签就可以。但有的时候,除了呈现数据,也会带有一些交互,比如有一列操作栏,可以编辑整行的数据:

写一个个的 table 系列标签是很麻烦并且重复的,而组件的好处就是省去这些基础的工作,我们直接给 Table 组件传递列的配置 columns 和行数据 data,其余的都交给 Table 组件做了。

开发 Table 组件前,有必要先了解上文说到的一系列 table 标签。一般的 table 结构是这样的:

html
<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>
  • table:定义 HTML 表格;
  • thead:定义表头;
  • tbody:定义表格主体;
  • tr:定义表格行;
  • th:定义表头单元格;
  • td:定义表格单元。

标准的表格系列标签,跟 div+css 实现是有很大区别的,比如表格在做单元格合并时,有提供原生属性,用 div 就很麻烦了;再比如渲染原理上也有一定的区别,table 会在内容全部下载完后加载。详细的介绍可以阅读文末的扩展阅读 1。

知道了表格的结构,再来分析如何定制 API。可以看到,表格分为了两部分,表头 thead 和数据 tbody,那 props 也定义两个:

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

    • title:列头显示文字;
    • key:对应列内容的字段名;
    • render:自定义渲染列,使用 Vue 的 Render 函数,不定义则直接显示为文本。

    比如:

    js
    [
      {
        title: '姓名',
        key: 'name'
      },
      {
        title: '年龄',
        key: 'age'
      }
    ]
  • data:显示的结构化数据,格式为数组,其中每一个对象,就是一行的数据,比如:

    js
    [
      {
        name: '王小明',
        age: 18
      },
      {
        name: '张小刚',
        age: 25
      }
    ]

column 定义的 key 值,与 data 是一一对应的,这是一种常见的数据接口定义规则,也是 Vue.js 组件中,用数据驱动而不是 slot 驱动的经典案例。

为什么 Table 组件要用数据驱动,而不是 slot 驱动呢?slot 在很多组件中的确很好用,不过 Table 组件包含了大量的基础表格标签,如果都交给使用者由 slot 承载的话,开发成本不亚于自己实现一个 table 了,而数据驱动就简单的多,数据一般从服务端获取后就可以直接使用(或简单处理),使用者主要来定义每列的配置 columns 就可以了。

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

完成基础表格

我们先来完成一个基础的表格组件,之后再接入 Render 来配置自定义列。

src/components 目录下新建 table-render 目录,并创建 table.vue 文件:

html
<!-- src/components/table-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>

props 中的 columns 和 data 的格式都是数组,这里要注意的是,如果 props 的类型是对象数组,它的默认值必须从一个工厂函数获取。

tbody 内嵌套使用了两次 v-for,外层循环数据 data,内层循环列 columns,这样就填充了每个单元格。

新建路由 table-render,并在 src/views/ 目录下新建页面 table-render.vue

html
<!-- src/views/table-render.vue -->
<template>
  <div>
    <table-render :columns="columns" :data="data"></table-render>
  </div>
</template>
<script>
  import TableRender from '../components/table-render/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: '北京市海淀区西二旗'
          },
          {
            name: '李小红',
            age: 30,
            birthday: '563472000000',
            address: '上海市浦东新区世纪大道'
          },
          {
            name: '周小伟',
            age: 26,
            birthday: '687024000000',
            address: '深圳市南山区深南大道'
          }
        ]
      }
    }
  }
</script>

运行后的效果如下图:

表格已经能渲染出来了,但现在的单元格只是将 data 当作纯文本来显示,所以出生日期列显示为时间戳,因为服务端对日期有时会保存为时间戳格式。如果要显示正常的日期(如1991-5-14),目前可以另写一个计算属性(computed),手动将时间戳换算为标准日期格式后,来动态修改 data 里的 birthday 字段。这样做对于出生日期这样的数据还好,但对于操作这一列就不可取了,因为它带有业务逻辑,点击编辑按钮,是可以对当前行数据进行修改的。这时就要用到 Render 函数。

使用 Render 自定义列模板

上一节我们已经介绍过函数式组件 Functional Render 的用法,它没有状态和上下文,主要用于中转一个组件,用在本节的 Table 组件非常合适。

先在 src/components/table-render 目录下新建 render.js 文件:

js
// src/components/table-render/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 组件:

html
<!-- table.vue,部分代码省略 -->
<template>
  <table>
    <thead>
      <tr>
        <th v-for="col in columns">{{ col.title }}</th>
      </tr>
    </thead>
    <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>
  </table>
</template>
<script>
  import Render from './render.js';

  export default {
    components: { Render },
    props: {
      columns: {
        type: Array,
        default () {
          return [];
        }
      },
      data: {
        type: Array,
        default () {
          return [];
        }
      }
    }
  }
</script>

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

js
// src/views/table-render.vie,部分代码省略
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 个参数得到任意想要的结果。由于是自定义列了,显示什么都是使用者决定的,因此在使用了 render 的 column 里可以不用写字段 key

如果你真正理解了,应该知道 columns 里定义的 render 字段,它仅仅是名字叫 render 的一个普通函数,并非 Vue.js 实例的 render 选项,只是我们恰巧把它叫做 render 而已,如果愿意,也可以改为其它名字,比如 renderRow。真正的 Render 函数只有一个地方,那就是 render.js 中的 render 选项,只是它代理了 column 中的 render。这里有点绕,理清这个关系,就对 Functional Render 彻底理解了。

修改当前行

有了 render,Table 组件就已经完成了,剩余工作都是使用者来配置 columns 完成各种复杂的业务逻辑。本例来介绍最常见的表格中对整行数据编辑的功能。

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

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

js
// table-render.vue,部分代码省略
{
  data () {
    return {
      // ...
      editName: '',  // 第一列输入框
      editAge: '',  // 第二列输入框
      editBirthday: '',  // 第三列输入框
      editAddress: '',  // 第四列输入框
    }
  }
}

同时还要知道是在修改第几行的数据,所以再加一个数据标识当前正在修改的行序号(从 0 开始):

js
// table-render.vue,部分代码省略
{
  data () {
    return {
      // ...
      editIndex: -1,  // 当前聚焦的输入框的行数
    }
  }
}

editIndex 默认给了 -1,也就是一个不存在的行号,当点击修改按钮时,再将它置为正确的行号。我们先定义操作列的 render:

js
// table-render.vue,部分代码省略
{
  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。当点击修改按钮时,把 render 中第二个参数 { row } 中的各列数据赋值给了之前在 data 中声明的 4 个数据,这样做是因为之后点击取消按钮时,editName 等值已经修改了,还没有还原,所以在开启编辑状态的同时,初始化各输入框的值(当然也可以在取消时重置)。最后再把 editIndex 置为了对应的行序号 { index },此时 render 的 if 条件 this.editIndex === index 为真,编辑列变成了两个按钮:保存和取消。点击保存,直接修改表格源数据 data 中对应的各字段值,并将 editIndex 置为 -1,退出编辑状态;点击取消,不保存源数据,直接退出编辑状态。

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

  1. 当 editIndex 等于当前行号 index 时,呈现输入框状态;
  2. 当 editIndex 不等于当前行号 index 时,呈现默认数据。

以姓名为例:

js
// table-render.vue,部分代码省略
{
  data () {
    columns: [
      // ...
      {
        title: '姓名',
        key: 'name',
        render: (h, { row, index }) => {
          let edit;

          // 当前行为聚焦行时
          if (this.editIndex === index) {
            edit = [h('input', {
              domProps: {
                value: row.name
              },
              on: {
                input: (event) => {
                  this.editName = event.target.value;
                }
              }
            })];
          } else {
            edit = row.name;
          }

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

变量 edit 根据 editIndex 呈现不同的节点,还是先看 else,直接显示了对应字段的数据。在聚焦时(this.editIndex === index),渲染一个 input 输入框,初始值 value 通过 render 的 domProps 绑定了 row.name(这里也可绑定 editName),并监听了 input 事件,将输入的内容,实时缓存在数据 editName 中,供保存时使用。事实上,这里绑定的 value 和事件 input 就是语法糖 v-model 在 Render 函数中的写法,在 template 中,经常写作 <input v-model="editName">

其它列与姓名类似,只是对于的字段不同:

js
// table-render.vue,部分代码省略
{
  data () {
    return {
      columns: [
        // ...
        {
          title: '年龄',
          key: 'age',
          render: (h, { row, index }) => {
            let edit;

            // 当前行为聚焦行时
            if (this.editIndex === index) {
              edit = [h('input', {
                domProps: {
                  value: row.age
                },
                on: {
                  input: (event) => {
                    this.editAge = event.target.value;
                  }
                }
              })];
            } else {
              edit = row.age;
            }

            return h('div', [
              edit
            ]);
          }
        },
        {
          title: '出生日期',
          render: (h, { row, index }) => {
            let edit;

            // 当前行为聚焦行时
            if (this.editIndex === index) {
              edit = [h('input', {
                domProps: {
                  value: row.birthday
                },
                on: {
                  input: (event) => {
                    this.editBirthday = event.target.value;
                  }
                }
              })];
            } else {
              const date = new Date(parseInt(row.birthday));
              const year = date.getFullYear();
              const month = date.getMonth() + 1;
              const day = date.getDate();

              edit = `${year}-${month}-${day}`;
            }

            return h('div', [
              edit
            ]);
          }
        },
        {
          title: '地址',
          key: 'address',
          render: (h, { row, index }) => {
            let edit;

            // 当前行为聚焦行时
            if (this.editIndex === index) {
              edit = [h('input', {
                domProps: {
                  value: row.address
                },
                on: {
                  input: (event) => {
                    this.editAddress = event.target.value;
                  }
                }
              })];
            } else {
              edit = row.address;
            }

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

完整的代码见:https://github.com/icarusion/vue-component-book/blob/master/src/views/table-render.vue

这样,可编辑行的表格示例就完成了:


方案二:slot-scope 自定义列

前文,我们基于 Render 函数实现了在表格中自定义列模板的组件 Table,虽说 Render 函数能够完全发挥 JavaScript 的编程能力,实现几乎所有的自定义工作,但本质上,使用者写的是一个庞大的 JS 对象,它不具备 DOM 结构,可读性和可维护性都比较差。对于大部分写 Vue.js 的开发者来说,更倾向于使用 template 的语法,毕竟它是 Vue.js 独有的特性。本文则在前文的 Table 组件基础上修改,实现一种达到同样渲染效果,但对使用者更友好的 slot-scope 写法。

什么是 slot-scope

slot(插槽)我们都很熟悉,它是 Vue.js 组件的 3 个 API 之一,用于分发内容。那 slot-scope 是什么呢?先来看一个场景,比如某组件拥有下面的模板:

html
<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 封装:

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

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

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

使用 slot-scope 指定的参数 slotProps 就是这个 slot 的全部参数,它是一个对象,在 slot-scope 中是可以传递多个参数的,上例我们只写了一个参数 book,所以访问它就是 slotProps.book。这里推荐使用 ES6 的解构,能让参数使用起来更方便:

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

除了可以传递参数,其它用法跟 slot 是一样的,比如也可以“具名”:

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

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

为了把 Render 函数和 slot-scope 理解透彻,下面我们用 3 种方法来改写 Table,实现 slot-scope 自定义列模板。

方案一

第一种方案,用最简单的 slot-scope 实现,同时也兼容 Render 函数的旧用法。拷贝前文的 Table 组件目录,更名为 table-slot,同时也拷贝路由,更名为 table-slot.vue。为了兼容旧的 Render 函数用法,在 columns 的列配置 column 中,新增一个字段 slot 来指定 slot-scope 的名称:

html
<!-- src/components/table-slot/table.vue -->
<template>
  <table>
    <thead>
      <tr>
        <th v-for="col in columns">{{ col.title }}</th>
      </tr>
    </thead>
    <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>
  </table>
</template>

相比原先的文件,只在 'render' in col 的条件下新加了一个 template 的标签,如果使用者的 column 配置了 render 字段,就优先以 Render 函数渲染,然后再判断是否用 slot-scope 渲染。在定义的作用域 slot 中,将行数据 row、列数据 column 和第几行 index 作为 slot 的参数,并根据 column 中指定的 slot 字段值,动态设置了具名 name。使用者在配置 columns 时,只要指定了某一列的 slot,那就可以在 Table 组件中使用 slot-scope。我们以前文的可编辑整行数据为例,用 slot-scope 的写法实现完全一样的效果:

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

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

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

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

      <template slot-scope="{ row, index }" slot="action">
        <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>
<script>
  import TableSlot from '../components/table-slot/table.vue';

  export default {
    components: { TableSlot },
    data () {
      return {
        columns: [
          {
            title: '姓名',
            slot: 'name'
          },
          {
            title: '年龄',
            slot: 'age'
          },
          {
            title: '出生日期',
            slot: 'birthday'
          },
          {
            title: '地址',
            slot: 'address'
          },
          {
            title: '操作',
            slot: 'action'
          }
        ],
        data: [
          {
            name: '王小明',
            age: 18,
            birthday: '919526400000',
            address: '北京市朝阳区芍药居'
          },
          {
            name: '张小刚',
            age: 25,
            birthday: '696096000000',
            address: '北京市海淀区西二旗'
          },
          {
            name: '李小红',
            age: 30,
            birthday: '563472000000',
            address: '上海市浦东新区世纪大道'
          },
          {
            name: '周小伟',
            age: 26,
            birthday: '687024000000',
            address: '深圳市南山区深南大道'
          }
        ],
        editIndex: -1,  // 当前聚焦的输入框的行数
        editName: '',  // 第一列输入框,当然聚焦的输入框的输入内容,与 data 分离避免重构的闪烁
        editAge: '',  // 第二列输入框
        editBirthday: '',  // 第三列输入框
        editAddress: '',  // 第四列输入框
      }
    },
    methods: {
      handleEdit (row, index) {
        this.editName = row.name;
        this.editAge = row.age;
        this.editAddress = row.address;
        this.editBirthday = row.birthday;
        this.editIndex = index;
      },
      handleSave (index) {
        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;
      },
      getBirthday (birthday) {
        const date = new Date(parseInt(birthday));
        const year = date.getFullYear();
        const month = date.getMonth() + 1;
        const day = date.getDate();

        return `${year}-${month}-${day}`;
      }
    }
  }
</script>

示例中在 <table-slot> 内的每一个 <template> 就对应某一列的 slot-scope 模板,通过配置的 slot 字段,指定具名的 slot-scope。可以看到,基本是把 Render 函数还原成了 html 的写法,这样看起来直接多了,渲染效果是完全一样的。在 slot-scope 中,平时怎么写组件,这里就怎么写,Vue.js 所有的 API 都是可以直接使用的。

方案一是最优解,一般情况下,使用这种方案就可以了,其余两种方案是基于 Render 的。

方案二

第二种方案,不需要修改原先的 Table 组件代码,只是在使用层面修改即可。先来看具体的使用代码,然后再做分析。注意,这里使用的 Table 组件,仍然是前文 src/components/table-render 的组件,它只有 Render 函数,没有定义 slot-scope:

html
<!-- src/views/table-render.vue 的改写 -->
<template>
  <div>
    <table-render ref="table" :columns="columns" :data="data">
      <template slot-scope="{ row, index }" slot="name">
        <input type="text" v-model="editName" v-if="editIndex === index" />
        <span v-else>{{ row.name }}</span>
      </template>

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

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

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

      <template slot-scope="{ row, index }" slot="action">
        <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-render>
  </div>
</template>
<script>
  import TableRender from '../components/table-render/table.vue';

  export default {
    components: { TableRender },
    data () {
      return {
        columns: [
          {
            title: '姓名',
            render: (h, { row, column, index }) => {
              return h(
                'div',
                this.$refs.table.$scopedSlots.name({
                  row: row,
                  column: column,
                  index: index
                })
              )
            }
          },
          {
            title: '年龄',
            render: (h, { row, column, index }) => {
              return h(
                'div',
                this.$refs.table.$scopedSlots.age({
                  row: row,
                  column: column,
                  index: index
                })
              )
            }
          },
          {
            title: '出生日期',
            render: (h, { row, column, index }) => {
              return h(
                'div',
                this.$refs.table.$scopedSlots.birthday({
                  row: row,
                  column: column,
                  index: index
                })
              )
            }
          },
          {
            title: '地址',
            render: (h, { row, column, index }) => {
              return h(
                'div',
                this.$refs.table.$scopedSlots.address({
                  row: row,
                  column: column,
                  index: index
                })
              )
            }
          },
          {
            title: '操作',
            render: (h, { row, column, index }) => {
              return h(
                'div',
                this.$refs.table.$scopedSlots.action({
                  row: row,
                  column: column,
                  index: index
                })
              )
            }
          }
        ],
        data: [],
        editIndex: -1,  // 当前聚焦的输入框的行数
        editName: '',  // 第一列输入框,当然聚焦的输入框的输入内容,与 data 分离避免重构的闪烁
        editAge: '',  // 第二列输入框
        editBirthday: '',  // 第三列输入框
        editAddress: '',  // 第四列输入框
      }
    },
    methods: {
      handleEdit (row, index) {
        this.editName = row.name;
        this.editAge = row.age;
        this.editAddress = row.address;
        this.editBirthday = row.birthday;
        this.editIndex = index;
      },
      handleSave (index) {
        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;
      },
      getBirthday (birthday) {
        const date = new Date(parseInt(birthday));
        const year = date.getFullYear();
        const month = date.getMonth() + 1;
        const day = date.getDate();

        return `${year}-${month}-${day}`;
      }
    },
    mounted () {
      this.data = [
        {
          name: '王小明',
          age: 18,
          birthday: '919526400000',
          address: '北京市朝阳区芍药居'
        },
        {
          name: '张小刚',
          age: 25,
          birthday: '696096000000',
          address: '北京市海淀区西二旗'
        },
        {
          name: '李小红',
          age: 30,
          birthday: '563472000000',
          address: '上海市浦东新区世纪大道'
        },
        {
          name: '周小伟',
          age: 26,
          birthday: '687024000000',
          address: '深圳市南山区深南大道'
        }
      ];
    }
  }
</script>

在 slot-scope 的使用上(即 template 的内容),与方案一是完全一致的,可以看到,在 column 的定义上,仍然使用了 render 字段,只不过每个 render 都渲染了一个 div 节点,而这个 div 的内容,是指定来在 <table-render> 中定义的 slot-scope:

js
render: (h, { row, column, index }) => {
  return h(
    'div',
    this.$refs.table.$scopedSlots.name({
      row: row,
      column: column,
      index: index
    })
  )
}

这正是 Render 函数灵活的一个体现,使用 $scopedSlots 可以访问 slot-scope,所以上面这段代码的意思是,name 这一列仍然是使用 Functional Render,只不过 Render 的是一个预先定义好的 slot-scope 模板。

有一点需要注意的是,示例中的 data 默认是空数组,而在 mounted 里才赋值的,是因为这样定义的 slot-scope,初始时读取 this.$refs.table.$scopedSlots 是读不到的,会报错,当没有数据时,也就不会去渲染,也就避免了报错。

这种方案虽然可行,但归根到底是一种 hack,不是非常推荐,之所以列出来,是为了对 Render 和 slot-scope 有进一步的认识。

方案三

第 3 中方案的思路和第 2 种是一样的,它介于方案 1 与方案 2 之间。这种方案要修改 Table 组件代码,但是用例与方案 1 完全一致。

在方案 2 中,我们是通过修改用例使用 slot-scope 的,也就是说 Table 组件本身没有支持 slot-scope,是我们“强加”上去的,如果把强加的部分,集成到 Table 内,那对使用者就很友好了,同时也避免了初始化报错,不得不把 data 写在 mounted 的问题。

保持方案 1 的用例不变,修改 src/components/table-render 中的代码。为了同时兼容 Render 与 slot-scope,我们在 table-render 下新建一个 slot.js 的文件:

js
// src/components/table-render/slot.js
export default {
  functional: true,
  inject: ['tableRoot'],
  props: {
    row: Object,
    column: Object,
    index: Number
  },
  render: (h, ctx) => {
    return h('div', ctx.injections.tableRoot.$scopedSlots[ctx.props.column.slot]({
      row: ctx.props.row,
      column: ctx.props.column,
      index: ctx.props.index
    }));
  }
};

它仍然是一个 Functional Render,使用 inject 注入了父级组件 table.vue(下文改写) 中提供的实例 tableRoot。在 render 里,也是通过方案 2 中使用 $scopedSlots 定义的 slot,不过这是在组件级别定义,对用户来说是透明的,只要按方案 1 的用例来写就可以了。

table.vue 也要做一点修改:

html
<!-- src/components/table-slot/table.vue 的改写,部分代码省略 -->
<template>
  <table>
    <thead>
      <tr>
        <th v-for="col in columns">{{ col.title }}</th>
      </tr>
    </thead>
    <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-scope :row="row" :column="col" :index="rowIndex"></slot-scope>
          </template>
          <template v-else>{{ row[col.key] }}</template>
        </td>
      </tr>
    </tbody>
  </table>
</template>
<script>
  import Render from './render.js';
  import SlotScope from './slot.js';

  export default {
    components: { Render, SlotScope },
    provide () {
      return {
        tableRoot: this
      };
    },
    props: {
      columns: {
        type: Array,
        default () {
          return [];
        }
      },
      data: {
        type: Array,
        default () {
          return [];
        }
      }
    }
  }
</script>

因为 slot-scope 模板是写在 table.vue 中的(对使用者来说,相当于写在组件 <table-slot></table-slot> 之间),所以在 table.vue 中使用 provide 向下提供了 Table 的实例,这样在 slot.js 中就可以通过 inject 访问到它,继而通过 $scopedSlots 获取到 slot。需要注意的是,在 Functional Render 是没有 this 上下文的,都是通过 h 的第二个参数临时上下文 ctx 来访问 prop、inject 等的。

方案 3 也是推荐使用的,当 Table 的功能足够复杂,层级会嵌套的比较深,那时方案 1 的 slot 就不会定义在第一级组件中,中间可能会隔许多组件,slot 就要一层层中转,相比在任何地方都能直接使用的 Render 就要麻烦了。所以,如果你的组件层级简单,推荐用第一种方案;如果你的组件已经成型(某 API 基于 Render 函数),但一时间不方便支持 slot-scope,而使用者又想用,那就选方案 2;如果你的组件已经成型(某 API 基于 Render 函数),但组件层级复杂,要按方案 1 那样支持 slot-scope 可能改动较大,还有可能带来新的 bug,那就用方案 3,它不会破坏原有的任何内容,但会额外支持 slot-scope 用法,关键是改动简单。