Pull to refresh

Пишем компонент — таблицу, не совсем обычным способом

Reading time 6 min
Views 26K
Еще одна небольшая статейка попроще вдогонку. Расскажу, как я рисую таблицы во Vue.

Компонентов-таблиц для Vue наделано немало. С различными возможностями. И везде по-разному таблица собирается в template страницы или какого-то компонента.

В основном происходит это как-то так:

<template>
  <cmp-table :items="items" :columns="columns"/>
</template>

<script>
export default {
  name: 'page',
  data() {
    return {
      items: [ 
        { id: 1, name: 'Sony' } , 
        { id: 2, name: 'Apple' }, 
        { id: 3, name: 'Samsung' } ],
      columns: [ 
        { prop: 'id', title: 'ID' }, 
        { prop: 'name', title: 'Name' } ]
    }
  }
}
</script>

Тут мы передаем в компонент cmp-table данные (items) и настройки колонок (columns). А сам компонент уже рендерит таблицу по этим настройкам.
Настройки бывают организованны по всякому. Просто отдельно настройки колонок или вообще все в кучу — настройки колонок, таблицы, каких-то действий и т.п.

Мне в таком подходе не нравится то, как организована настройка рендера колонок. Как их названий в шапке thead таблицы, так и самого содержимого колонок.
Этот функционал хочется видеть в самом template, там строить колонки (их содержимое и шапку). Так нагляднее и удобнее. Как по мне.

Такой подход, например, используется в самом популярном вроде на сегодняшний день сборнике компонентов — Element.
Там это выглядит так:

<template>
  <el-table :data="tableData" style="width: 100%">
    <el-table-column prop="date" label="Date" width="180"/>
    <el-table-column prop="name" label="Name" width="180"/>
    <el-table-column prop="address" label="Address"/>
  </el-table>
</template>

<script>
export default {
  name: 'page',
  data() {
    return {
      tableData: [{
        date: '2016-05-03',
        name: 'Tom',
        address: 'No. 189, Grove St, Los Angeles'
      }, {
        date: '2016-05-02',
        name: 'Tom',
        address: 'No. 189, Grove St, Los Angeles'
      }, {
        date: '2016-05-04',
        name: 'Tom',
        address: 'No. 189, Grove St, Los Angeles'
      }]
    }
  }
}
</script>

Здесь все наглядно. Мы сразу представляем себе общий вид колонок и ячеек. А в компонент el-table передаем лишь данные и настройки самой таблицы.

И все бы ничего. Нравится мне в общем Element. Но часто замечал, что их таблицы подтормаживают. Полез в их код. И офигел от того, как заморочисто там происходит рендер строк, ячеек и всего остального. Просто тонны кода. И стал я думать, как можно сделать построение таблицы так-же как у них, только проще. Намного проще.

Сейчас расскажу и покажу, что у меня получилось.

Сразу о том, что в итоге будет представлять собой наш компонент. Чтобы происходящее дальше проще понималось:

  • построение колонок аналогично Element (el-table)
  • возможность кастомизации вида ячеек
  • очень мало кода
  • и возможность расширения функционала без особых проблем

Состоять наша таблица будет из двух частей:

  1. компонента TableColumn — с его помощью мы будем формировать вид шапки таблицы и ячеек
  2. компонента Table — в нем будет все собираться и рендериться

Компонент TableColumn — или краткость наше все


table-column.js:

export default {
  name: 'vu-table-column',
  props: ['prop', 'title']
};

Это компонент Vue c именем vu-table-column и парой входящих параметров:

  • prop — в него передаем наименование свойства строки переданных данных в таблицу.
  • title — название этого свойства, которое будет отображаться в шапке таблицы.

Да — это все, что нам нужно, для реализации текущих требований к нашему компоненту. Дальше будет понятно, почему здесь все так просто.

Компонент Table — или зачем писать больше кода, если можно меньше


table.js
import './style.scss'
import { get } from 'lodash'

export default {
  name: 'vu-table',
  props: {
    rows: {
      type: Array,
      required: true
    }
  },
  methods: {
    renderColumns(h, row, columnsOptions) {
      return columnsOptions.map((column, index) => {
        return h('td', { class: 'vu-table__tbody-td' }, [
          column.scopedSlot ? column.scopedSlot({row, items: this.rows}) : row[column.prop]
        ])
      })
    }
  },
  render(h) {
    const columnsOptions = this.$slots.default.filter(item => {
      return (item.componentOptions && item.componentOptions.tag === 'vu-table-column')
    }).map(column => {
      return Object.assign({}, column.componentOptions.propsData, {
          scopedSlot: get(column, 'data.scopedSlots.default')
        }
      )
    })

    const columnsHead = columnsOptions.map((column, index) => {
      return h('th', { class: 'vu-table__thead-th', key: index}, column.title)
    })

    const rows = this.rows.map((row, index) => {
      return h('tr', { key: index }, [ this.renderColumns(h, row, columnsOptions) ])
    })

    return h('table', { class: 'vu-table' }, [
      h('thead', { class: 'vu-table__thead' }, [
        h('tr', [ columnsHead ])
      ]),
      h('tbody', { class: 'vu-table__tbody' }, [ rows ])
    ])
  }
};


И пройдемся по коду.

import './style.scss'
import { get } from 'lodash'

export default {
  name: 'vu-table',
  props: {
    rows: {
      type: Array,
      required: true
    }
  }
  ...
}

В начале импортируем стили таблицы.

И еще я тут использую get-функцию lodash-а. Это не обязательно. Она здесь чтобы код в итоге был короче.

Дальше входящий параметр rows, куда передаем наши данные, в виде массива строк.

Теперь по render-функции:

render(h) {
    const columnsOptions = this.$slots.default.filter(item => {
      return (item.componentOptions && item.componentOptions.tag === 'vu-table-column')
    }).map(column => {
      return Object.assign({}, column.componentOptions.propsData, {
          scopedSlot: get(column, 'data.scopedSlots.default')
        }
      )
    })

    const columnsHead = columnsOptions.map((column, index) => {
      return h('th', { class: 'vu-table__thead-th', key: index}, column.title)
    })

    const rows = this.rows.map((row, index) => {
      return h('tr', { key: index }, [ this.renderColumns(h, row, columnsOptions) ])
    })

    return h('table', { class: 'vu-table' }, [
      h('thead', { class: 'vu-table__thead' }, [
        h('tr', [ columnsHead ])
      ]),
      h('tbody', { class: 'vu-table__tbody' }, [ rows ])
    ])
  }

В columnOptions мы формируем настройки наших колонок и ячеек.
Для этого сначала собираем, фильтруя, все элементы с тегом vu-table-column ( компонент TableColumn) из дефолтного слота (this.$slots.default) компонента Table.

Компонент TableColumn нам нужен пока только для того, чтобы передать настройки колонки удобным и наглядным образом. Вот почему в TableColumn нет render-функции. Потому-что мы не рендерим этот компонент. Только забираем данные.

И пробегаемся по массиву отфильтрованных vu-table-column, формируем массив объектов с входящими props-ами из vu-table-column и добавляем свойство scopedSlot. В нем будет храниться дефолтный слот с ограниченной областью видимости, если таковой передан в vu-table-column в template страницы. Представляет он собой функцию, которая рендерит содержимое этого слота. И в нее можно передать любые параметры, которые используются в шаблоне этого слота. Этот слот мы и будем использовать для кастомного вида ячеек.

Дальше собираем columnsHead (ячейки шапки таблицы) — пробегаемся по выше определенным настройкам колонок (columnOptions) выдергивая title — название колонки, которое мы передали в vu-table-column.

Формируем массив rows (собственно итоговых строк таблицы) — пробегаемся по нашим входящим rows, и в каждом элементе tr выводим ячейки с помощью метода renderColumns:

renderColumns(h, row, columnsOptions) {
      return columnsOptions.map((column, index) => {
        return h('td', { class: 'vu-table__tbody-td' }, [
          column.scopedSlot ? 
            column.scopedSlot({row, items: this.rows}) : row[column.prop]
        ])
      })
    }

В метод мы передаем h-функцию (псевдоним функции $createElement, которая рендерит vNode), данные строки и массив настроек колонок columnsOptions.

И собираем массив ячеек, в которых рендерим:

  • либо кастомный вид, если в настройках колонки есть слот с ограниченной видимостью, запуская функцию scopedSlot с параметрами row (содержащим объект строки) и items (данные, переданные в vu-table). Здесь мы можем передать все, что нам надо
  • либо просто значение свойства с именем column.prop из строки row
при рендере массивов-елементов, не забывайте каждому елементу присваивать параметр key. Иначе, при обновлении данных, могут возникнуть коллизии в отображаемых данных
И в конце render-функции выводим итоговую таблицу, с вставленными в нее ячейками шапки и отрендеренными строками.
Все!

Вот и весь код, для рисования ячеек и шапки.
Дальше уже можно прилеплять фильтрацию, скрытие/показ колонок, сортировку и все, что душа пожелает. Это уже следующая история. И будет в следующей статье. Дабы не писать бесконечные тексты.

И пример использования написанного компонента:

example.vue
<template lang="html">
  <div>
    <vu-table :rows="rows">

      <vu-table-column prop="id" title="ID">
        <template slot-scope="{ row }"> 
          <b>{{ row.id }}</b>
        </template>
      </vu-table-column>

      <vu-table-column prop="name" title="Name"/>

      <vu-table-column prop="rating" title="Rating">
        <template slot-scope="{ row, items }"> 
          {{ row.rating }} 
          <b v-if="items.every(item => item.rating <= row.rating)">Best choiсe!</b>
        </template>
      </vu-table-column>

    </vu-table>
  </div>
</template>

<script>
export default {
  name: 'example-page',
  data() {
    return {
      rows: [
        { id: 1,
          name: 'Sony',
          rating: 777 },
        { id: 2,
          name: 'Apple',
          rating: 555 },
        { id: 3,
          name: 'Samsung',
          rating: 333 }
      ]
    }
  }
};
</script>


Тут мы используем как обычные title, так и кастомный вид ячеек.

И в ячейках Rating мы, используя данные items (которые передали в функцию scopedSlot и содержащие входящий массив со строками) и значение свойства rating определяем, является ли текущая строка с наибольшим рейтингом. Если да, выводим жирным текстом 'Best choice!'

И готовый результат:



Вот такой компонент в итоге получился.

В данный момент я его пилю. Добавляю функционал. И в следующей статье опишу уже расширение возможностей.
Tags:
Hubs:
+15
Comments 12
Comments Comments 12

Articles