├── .eslintignore ├── .prettierignore ├── babel.config.js ├── dist ├── favicon.ico ├── fonts │ ├── element-icons.535877f5.woff │ └── element-icons.732389de.ttf ├── index.html └── css │ └── index.ea19c4a4.css ├── public ├── favicon.ico └── index.html ├── example ├── assets │ └── logo.png ├── components │ ├── virtual-table │ │ ├── README.md │ │ ├── Column.jsx │ │ ├── ColumnGroup.jsx │ │ ├── constants.js │ │ ├── helpers │ │ │ ├── store │ │ │ │ ├── createStore.js │ │ │ │ └── connect.js │ │ │ ├── dom │ │ │ │ ├── scrollbar-width.js │ │ │ │ └── index.js │ │ │ ├── withStoreConnect.js │ │ │ ├── ColumnManager.js │ │ │ ├── TreeDataAdapter.js │ │ │ └── utils │ │ │ │ └── index.js │ │ ├── TableBody.jsx │ │ ├── ColGroup.jsx │ │ ├── Table.jsx │ │ ├── TableHeader.jsx │ │ ├── TableHeaderRow.jsx │ │ ├── interface.js │ │ ├── TableBodyRow.jsx │ │ └── table.css │ ├── TableRowExpansionItem │ │ ├── styles.css │ │ └── index.jsx │ ├── FeatureList.vue │ ├── DescPannel.vue │ └── TableExpandAction │ │ └── index.vue ├── main.js └── App.vue ├── src ├── Column.jsx ├── ColumnGroup.jsx ├── utils │ ├── isValidElement.js │ ├── dom │ │ ├── scroll.js │ │ └── getScrollBarSize.js │ ├── debounce.js │ ├── rowKey.js │ ├── useRaf.js │ ├── type.js │ ├── expand.js │ └── column.js ├── ColGroup.jsx ├── types │ └── index.d.ts ├── mixins │ ├── withSelection.js │ ├── withVirtualization.js │ └── withExpansion.js ├── TableBody │ ├── ExpandedRow.jsx │ ├── index.jsx │ └── BodyRow.jsx ├── TableHeader │ ├── HeaderRow.jsx │ ├── FixedHeader.jsx │ └── index.jsx ├── TableCell │ └── index.jsx ├── interface.js └── table.css ├── vue.config.js ├── .gitignore ├── .prettierrc.js ├── .eslintrc.js ├── package.json ├── CHANGELOG.md ├── stylelint.config.js └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | /tests/unit/coverage/ 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.md 2 | **/*.svg 3 | **/*.ejs 4 | **/*.html 5 | package.json 6 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/cli-plugin-babel/preset'] 3 | } 4 | -------------------------------------------------------------------------------- /dist/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/givingwu/vue-virtualized-table/HEAD/dist/favicon.ico -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/givingwu/vue-virtualized-table/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /example/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/givingwu/vue-virtualized-table/HEAD/example/assets/logo.png -------------------------------------------------------------------------------- /dist/fonts/element-icons.535877f5.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/givingwu/vue-virtualized-table/HEAD/dist/fonts/element-icons.535877f5.woff -------------------------------------------------------------------------------- /dist/fonts/element-icons.732389de.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/givingwu/vue-virtualized-table/HEAD/dist/fonts/element-icons.732389de.ttf -------------------------------------------------------------------------------- /example/components/virtual-table/README.md: -------------------------------------------------------------------------------- 1 | # `` 2 | 3 | ## Features 4 | 5 | + 支持自定义单元格 6 | + 支持虚拟化滚动(暂不支持变量高度,仅支持固定高度) 7 | 8 | ## Usage 9 | 10 | -------------------------------------------------------------------------------- /example/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | 4 | Vue.config.productionTip = false 5 | 6 | new Vue({ 7 | render: (h) => h(App) 8 | }).$mount('#app') 9 | -------------------------------------------------------------------------------- /src/Column.jsx: -------------------------------------------------------------------------------- 1 | /* Forked from ant-design-vue 💖 */ 2 | /* eslint-disable vue/require-default-prop */ 3 | import { ColumnProps } from './interface' 4 | 5 | export default { 6 | name: 'TableColumn', 7 | props: ColumnProps 8 | } 9 | -------------------------------------------------------------------------------- /example/components/virtual-table/Column.jsx: -------------------------------------------------------------------------------- 1 | /* Forked from ant-design-vue 💖 */ 2 | /* eslint-disable vue/require-default-prop */ 3 | import { ColumnProps } from './interface' 4 | 5 | export default { 6 | name: 'TableColumn', 7 | props: ColumnProps 8 | } 9 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | publicPath: process.env.NODE_ENV === 'production' ? './' : '/', 3 | pages: { 4 | index: { 5 | entry: './example/main.js', 6 | template: './public/index.html', 7 | title: 'vue-virtualized-table example' 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/ColumnGroup.jsx: -------------------------------------------------------------------------------- 1 | /* Forked from ant-design-vue 💖 */ 2 | /* eslint-disable vue/require-default-prop */ 3 | export default { 4 | name: "TableColumnGroup", 5 | props: { 6 | title: { 7 | type: String, 8 | default: "", 9 | }, 10 | }, 11 | isTableColumnGroup: true, 12 | __TABLE_COLUMN_GROUP: true, 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | # local env files 5 | .env.local 6 | .env.*.local 7 | 8 | # Log files 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | package-lock.json 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | *.lock 23 | -------------------------------------------------------------------------------- /example/components/virtual-table/ColumnGroup.jsx: -------------------------------------------------------------------------------- 1 | /* Forked from ant-design-vue 💖 */ 2 | /* eslint-disable vue/require-default-prop */ 3 | import PropTypes from 'vue-types' 4 | 5 | export default { 6 | name: 'TableColumnGroup', 7 | props: { 8 | title: PropTypes.any 9 | }, 10 | isTableColumnGroup: true, 11 | __TABLE_COLUMN_GROUP: true 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/isValidElement.js: -------------------------------------------------------------------------------- 1 | /** 2 | * isValidElement 3 | * @param {Vue.VNode} element 4 | */ 5 | export function isValidElement(element) { 6 | return ( 7 | element && 8 | typeof element === "object" && 9 | "componentOptions" in element && 10 | "context" in element && 11 | element.tag !== undefined 12 | ) // remove text node 13 | } 14 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'always', 3 | bracketSpacing: true, 4 | htmlWhitespaceSensitivity: 'css', 5 | insertPragma: false, 6 | jsxBracketSameLine: false, 7 | jsxSingleQuote: false, 8 | printWidth: 80, 9 | proseWrap: 'never', 10 | quoteProps: 'as-needed', 11 | requirePragma: false, 12 | semi: false, 13 | singleQuote: true, 14 | tabWidth: 2, 15 | trailingComma: 'none', 16 | useTabs: false, 17 | vueIndentScriptAndStyle: true 18 | } 19 | -------------------------------------------------------------------------------- /example/components/TableRowExpansionItem/styles.css: -------------------------------------------------------------------------------- 1 | .expand-icon { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | min-width: 14px; 6 | min-height: 14px; 7 | width: 14px; 8 | height: 14px; 9 | margin-right: 8px; 10 | user-select: none; 11 | cursor: pointer; 12 | border: 1px solid #909399; 13 | } 14 | 15 | .expand-icon:hover { 16 | border: 1px solid #3d82e1; 17 | } 18 | 19 | .expand-icon > i.icon { 20 | color: #393939; 21 | font-size: 9px; 22 | } 23 | 24 | .expand-icon:hover > i.icon { 25 | color: #3d82e1; 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/dom/scroll.js: -------------------------------------------------------------------------------- 1 | /** 2 | * forceScroll 3 | * @param {number} scrollLeft 4 | * @param {HTMLDivElement} target 5 | */ 6 | export function forceScroll(scrollLeft, target) { 7 | if (target && target.scrollLeft !== scrollLeft) { 8 | target.scrollLeft = scrollLeft 9 | } 10 | } 11 | 12 | /** 13 | * forceScrollTop 14 | * @param {number} scrollTop 15 | * @param {HTMLDivElement} target 16 | */ 17 | export function forceScrollTop(scrollTop, target) { 18 | if (target && target.scrollTop !== scrollTop) { 19 | target.scrollTop = scrollTop 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /example/components/FeatureList.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | 19 | 34 | 35 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <%= htmlWebpackPlugin.options.title %> 10 | 11 | 12 | 13 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/utils/debounce.js: -------------------------------------------------------------------------------- 1 | export function debounce(func, wait, immediate) { 2 | let timeout 3 | function debounceFunc(...args) { 4 | const context = this 5 | 6 | // https://fb.me/react-event-pooling 7 | if (args[0] && args[0].persist) { 8 | args[0].persist() 9 | } 10 | 11 | const later = () => { 12 | timeout = null 13 | 14 | if (!immediate) { 15 | func.apply(context, args) 16 | } 17 | } 18 | 19 | const callNow = immediate && !timeout 20 | 21 | clearTimeout(timeout) 22 | 23 | timeout = setTimeout(later, wait) 24 | 25 | if (callNow) { 26 | func.apply(context, args) 27 | } 28 | } 29 | 30 | debounceFunc.cancel = function cancel() { 31 | if (timeout) { 32 | clearTimeout(timeout) 33 | timeout = null 34 | } 35 | } 36 | 37 | return debounceFunc 38 | } 39 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | vue-virtualized-table example
-------------------------------------------------------------------------------- /example/components/DescPannel.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 36 | 37 | 38 | 53 | 54 | -------------------------------------------------------------------------------- /src/utils/rowKey.js: -------------------------------------------------------------------------------- 1 | /** 2 | * getRowKeyFn 3 | * @param {import('../types/index').RowModel} row 4 | * @param {number|undefined} index 5 | * @param {string} rowKey 6 | * @returns {string|number} 7 | */ 8 | export const getRowKey = (row, index, rowKey = '') => { 9 | let key 10 | 11 | if (!rowKey) { 12 | throw new Error('rowKey is required when get row identity') 13 | } 14 | 15 | if (!row) throw new Error('row is required when get row identity') 16 | 17 | if (typeof rowKey === 'string') { 18 | if (rowKey.indexOf('.') < 0) { 19 | key = row[rowKey] 20 | key = +key || key // when number type 21 | } 22 | 23 | let keyStr = rowKey.split('.') 24 | let current = row 25 | 26 | for (let i = 0; i < keyStr.length; i++) { 27 | current = current[keyStr[i]] 28 | } 29 | 30 | key = current 31 | } else if (typeof rowKey === 'function') { 32 | key = rowKey(row, index) 33 | } 34 | 35 | return key === undefined ? index : key 36 | } 37 | -------------------------------------------------------------------------------- /example/components/virtual-table/constants.js: -------------------------------------------------------------------------------- 1 | export const SIDE_EFFECTS = [ 2 | 'expandDepth', 3 | 'expandedRowKeys', 4 | 'defaultExpandAllRows', 5 | 'rowSelectionType', 6 | 'selectedRowKeys' 7 | ] 8 | 9 | export const DEFAULT_COMPONENTS = { 10 | wrapper: 'div', 11 | table: 'table', 12 | header: { 13 | wrapper: 'thead', 14 | row: 'tr', 15 | cell: 'th' 16 | }, 17 | body: { 18 | wrapper: 'tbody', 19 | row: 'tr', 20 | cell: 'td' 21 | }, 22 | select: 'select', 23 | radio: 'radio', 24 | checkbox: 'checkbox', 25 | dropdown: 'select' 26 | } 27 | 28 | export const DEFAULT_LOCALE = { 29 | filterTitle: '过滤器', 30 | filterConfirm: '确定', 31 | filterReset: '重置', 32 | emptyText: '暂无数据', 33 | selectAll: '选择全部', 34 | selectInvert: '反选', 35 | sortTitle: '排序' 36 | } 37 | 38 | export const STYLE_WRAPPER = { 39 | position: 'relative' 40 | } 41 | 42 | export const STYLE_INNER = { 43 | position: 'relative', 44 | width: '100%', 45 | willChange: 'transform', 46 | WebkitOverflowScrolling: 'touch' 47 | } 48 | -------------------------------------------------------------------------------- /example/components/virtual-table/helpers/store/createStore.js: -------------------------------------------------------------------------------- 1 | /* Forked from `ant-design` */ 2 | 3 | /** 4 | * createStore 5 | * @param {{}} initialState 6 | */ 7 | export default function createStore(initialState) { 8 | let state = initialState 9 | const listeners = [] 10 | 11 | /** 12 | * setState 13 | * @param {{}} partial 14 | */ 15 | function setState(partial) { 16 | state = { ...state, ...partial } 17 | 18 | for (let i = 0; i < listeners.length; i++) { 19 | listeners[i]() 20 | } 21 | } 22 | 23 | /** 24 | * getState 25 | * @returns {{}} 26 | */ 27 | function getState() { 28 | return state 29 | } 30 | 31 | /** 32 | * subscribe 33 | * @param {(state: {}) => ()} listener 34 | * @returns {() => void} 35 | */ 36 | function subscribe(listener) { 37 | listeners.push(listener) 38 | 39 | return function unsubscribe() { 40 | const index = listeners.indexOf(listener) 41 | listeners.splice(index, 1) 42 | } 43 | } 44 | 45 | return { 46 | setState, 47 | getState, 48 | subscribe 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/useRaf.js: -------------------------------------------------------------------------------- 1 | // import raf from "raf" 2 | 3 | /** 4 | * @see {@link https://www.npmjs.com/package/raf} 5 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame} 6 | * 7 | * The method takes a callback as an argument to be invoked before the repaint. 8 | * 9 | * @param {Function} callback callback is the function to invoke in the next frame. 10 | * @returns {Function} 11 | * 12 | * @usage 13 | * ```js 14 | var raf = require('raf') 15 | 16 | raf(function tick() { 17 | // Animation logic 18 | raf(tick) 19 | }) 20 | * ``` 21 | */ 22 | export function useRaf(callback) { 23 | /** 24 | * handle is a long integer value that uniquely identifies the entry in the callback list. 25 | * This is a non-zero value, but you may not make any other assumptions about its value. 26 | */ 27 | const handle = requestAnimationFrame(callback) 28 | 29 | /** 30 | * handle is the entry identifier returned by raf(). 31 | * Removes the queued animation frame callback 32 | * (other queued callbacks will still be invoked unless cancelled). 33 | */ 34 | return function cancelUseRef() { 35 | return cancelAnimationFrame(handle) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/dom/getScrollBarSize.js: -------------------------------------------------------------------------------- 1 | /* Forked from `https://github.com/react-component/util/edit/master/src/getScrollBarSize.js` */ 2 | let cached 3 | 4 | export default function getScrollBarSize(fresh) { 5 | if (typeof document === "undefined") { 6 | return 0 7 | } 8 | 9 | if (fresh || cached === undefined) { 10 | const inner = document.createElement("div") 11 | inner.style.width = "100%" 12 | inner.style.height = "200px" 13 | 14 | const outer = document.createElement("div") 15 | const outerStyle = outer.style 16 | 17 | outerStyle.position = "absolute" 18 | outerStyle.top = 0 19 | outerStyle.left = 0 20 | outerStyle.pointerEvents = "none" 21 | outerStyle.visibility = "hidden" 22 | outerStyle.width = "200px" 23 | outerStyle.height = "150px" 24 | outerStyle.overflow = "hidden" 25 | 26 | outer.appendChild(inner) 27 | 28 | document.body.appendChild(outer) 29 | 30 | const widthContained = inner.offsetWidth 31 | outer.style.overflow = "scroll" 32 | let widthScroll = inner.offsetWidth 33 | 34 | if (widthContained === widthScroll) { 35 | widthScroll = outer.clientWidth 36 | } 37 | 38 | document.body.removeChild(outer) 39 | 40 | cached = widthContained - widthScroll 41 | } 42 | 43 | return cached 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/type.js: -------------------------------------------------------------------------------- 1 | import { isValidElement } from './isValidElement' 2 | /** 3 | * isFunction 4 | * @param {Function|any} fn 5 | */ 6 | export const isFunction = (fn) => typeof fn === 'function' 7 | 8 | /** 9 | * isObject 10 | * @param {any} obj 11 | */ 12 | export const isObject = (obj) => obj && typeof obj === 'object' 13 | 14 | export const isArray = Array.isArray 15 | 16 | /** 17 | * isValidArray 18 | * @param {Array|any} array 19 | */ 20 | export const isValidArray = (array) => isArray(array) && !!array.length 21 | 22 | /** 23 | * isString 24 | * @param {string|any}} string 25 | */ 26 | export const isString = (string) => typeof string === 'string' 27 | 28 | /** 29 | * isNumber 30 | * @param {number|any} number 31 | */ 32 | export const isNumber = (number) => typeof number === 'number' 33 | 34 | /** 35 | * isValidValue 36 | * @param {any} value 37 | */ 38 | export const isValidValue = (value) => 39 | value !== null && value !== undefined && value !== '' 40 | 41 | export const noop = () => {} 42 | 43 | export const toArray = (children) => { 44 | let ret = [] 45 | 46 | children.forEach((child) => { 47 | if (Array.isArray(child)) { 48 | ret = ret.concat(toArray(child)) 49 | } else if (isValidElement(child) && child.props) { 50 | ret = ret.concat(toArray(child.props.children)) 51 | } else { 52 | ret.push(child) 53 | } 54 | }) 55 | 56 | return ret 57 | } 58 | -------------------------------------------------------------------------------- /src/ColGroup.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable vue/require-default-prop */ 2 | // Forked form https://github.com/react-component/table/blob/master/src/ColGroup.tsx 3 | import { isNumber } from './utils/type' 4 | import { TableProps } from './interface' 5 | 6 | export default { 7 | name: 'ColGroup', 8 | functional: true, 9 | props: { 10 | colWidths: Array, 11 | columns: TableProps.columns 12 | }, 13 | 14 | render(h, { props }) { 15 | const { colWidths = [], columns = [] } = props 16 | const cols = [] 17 | const len = columns.length 18 | 19 | // Only insert col with width & additional props 20 | // Skip if rest col do not have any useful info 21 | let mustInsert = false 22 | for (let i = len - 1; i >= 0; i -= 1) { 23 | let width = colWidths[i] || (columns[i] || {}).width 24 | width = isNumber(width) || isNumber(+width) ? width + 'px' : width 25 | 26 | const column = columns && columns[i] 27 | // const additionalProps = column && column[INTERNAL_COL_DEFINE] 28 | 29 | if (width || /* additionalProps || */ mustInsert) { 30 | cols.unshift( 31 | 37 | ) 38 | mustInsert = true 39 | } 40 | } 41 | 42 | return {cols} 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /example/components/virtual-table/TableBody.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable vue/require-default-prop */ 2 | import PropTypes from 'vue-types' 3 | import TableBodyRow from './TableBodyRow' 4 | import { ColumnProps, TableComponents, TableProps } from './interface' 5 | 6 | export default { 7 | name: 'TableBody', 8 | 9 | inheritAttrs: false, 10 | 11 | props: { 12 | rows: TableProps.dataSource, 13 | fixed: ColumnProps.fixed.def(false), 14 | columns: PropTypes.arrayOf(ColumnProps), 15 | getRowKey: PropTypes.func.isRequired, 16 | prefixCls: TableProps.prefixCls, 17 | components: TableComponents 18 | }, 19 | 20 | render() { 21 | const { fixed, columns, prefixCls, components } = this.$props 22 | const TableBody = components.body.wrapper 23 | 24 | return ( 25 | 26 | {this.rows.map((row, index) => { 27 | const rowKey = this.getRowKey(row, index) 28 | const attrs = { 29 | inheritAttrs: false, 30 | key: rowKey, 31 | props: { 32 | fixed, 33 | row, 34 | rowKey, 35 | index, 36 | prefixCls, 37 | columns, 38 | components 39 | }, 40 | on: this.$listeners 41 | } 42 | 43 | return 44 | })} 45 | 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { VNode } from "vue"; 2 | 3 | interface ColumnConfig { 4 | /* 列配置 */ 5 | label?: string 6 | prop?: string 7 | key: string | number 8 | width: number | string /* % */ 9 | render: ( 10 | h: Function, 11 | param: ColumnParam 12 | ) => VNode | null /* must return a vnode instance */ 13 | // filter(h: Function, param: ColumnParam)?: void; /* 暂未实现 */ 14 | // sortBy(h: Function, param: ColumnParam)?: void; /* 暂未实现 */ 15 | } 16 | 17 | interface ColumnParam { 18 | /* 列参数对象 */ 19 | row: RowModel // 当前数据项 对应后端传入 list 的当前项的 model 20 | rows: Array 21 | index: number 22 | rowIndex: number 23 | column: ColumnConfig 24 | columnIndex: number 25 | store: TableStore 26 | } 27 | 28 | interface TableStore { 29 | isRowExpanded(row: RowModel): void /* 判断当前行是否是展开状态 */ 30 | toggleRowExpansion( 31 | row: RowModel, 32 | expanded?: boolean 33 | ) /* 切换当前行展开收起状态 */ 34 | isRowSelected(row: RowModel) /* 判断当前行是否选中状态 */ 35 | toggleRowSelection( 36 | row: RowModel, 37 | selected?: boolean 38 | ) /* 切换当前行是否选中状态 */ 39 | // filter /* 暂未实现 */ 40 | // sortBy /* 暂未实现 */ 41 | } 42 | 43 | interface TableProps { 44 | } 45 | 46 | type RowKey = number | string 47 | 48 | interface RowModel { 49 | rowKey: RowKey /* [RowModel]rowKey 的值必须在 Array 中唯一 */ 50 | children?: RowModel[] 51 | __parent?: RowModel | null /* 父节点 */ 52 | __index: number 53 | __depth: number 54 | [prop as string]: any 55 | } 56 | -------------------------------------------------------------------------------- /example/components/TableRowExpansionItem/index.jsx: -------------------------------------------------------------------------------- 1 | import './styles.css' 2 | 3 | export default { 4 | name: 'TableRowExpandItem', 5 | 6 | props: { 7 | store: { 8 | type: Object, 9 | required: true 10 | }, 11 | row: { 12 | type: Object, 13 | required: true 14 | }, 15 | column: { 16 | type: Object, 17 | required: true 18 | }, 19 | depth: { 20 | type: Number, 21 | required: true 22 | }, 23 | indentSize: { 24 | type: Number, 25 | default: 20, 26 | required: true 27 | } 28 | }, 29 | 30 | computed: { 31 | className() { 32 | return this.expanded ? 'el-icon-minus' : 'el-icon-plus' 33 | }, 34 | expanded() { 35 | return this.store.isRowExpanded(this.row) 36 | }, 37 | title() { 38 | return '点击' + (this.expanded ? '收起' : '展开') 39 | }, 40 | style() { 41 | return { 42 | marginLeft: this.indentSize * this.depth + 'px' 43 | } 44 | } 45 | }, 46 | 47 | methods: { 48 | handleExpand(e) { 49 | e.preventDefault() 50 | e.stopPropagation() 51 | 52 | this.store.toggleRowExpansion(this.row) 53 | } 54 | }, 55 | 56 | render() { 57 | return ( 58 |
64 | 65 |
66 | ) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /example/components/virtual-table/helpers/dom/scrollbar-width.js: -------------------------------------------------------------------------------- 1 | let scrollbarVerticalSize 2 | let scrollbarHorizontalSize 3 | 4 | // Measure scrollbar width for padding body during modal show/hide 5 | const scrollbarMeasure = { 6 | position: 'absolute', 7 | top: '-9999px', 8 | width: '50px', 9 | height: '50px' 10 | } 11 | 12 | export function measureScrollbar(direction = 'vertical') { 13 | if (typeof document === 'undefined' || typeof window === 'undefined') { 14 | return 0 15 | } 16 | 17 | const isVertical = direction === 'vertical' 18 | 19 | if (isVertical && scrollbarVerticalSize) { 20 | return scrollbarVerticalSize 21 | } else if (!isVertical && scrollbarHorizontalSize) { 22 | return scrollbarHorizontalSize 23 | } 24 | 25 | const scrollDiv = document.createElement('div') 26 | 27 | Object.keys(scrollbarMeasure).forEach((scrollProp) => { 28 | scrollDiv.style[scrollProp] = scrollbarMeasure[scrollProp] 29 | }) 30 | 31 | // Append related overflow style 32 | if (isVertical) { 33 | scrollDiv.style.overflowY = 'scroll' 34 | } else { 35 | scrollDiv.style.overflowX = 'scroll' 36 | } 37 | 38 | document.body.appendChild(scrollDiv) 39 | 40 | let size = 0 41 | 42 | if (isVertical) { 43 | size = scrollDiv.offsetWidth - scrollDiv.clientWidth 44 | scrollbarVerticalSize = size 45 | } else if (!isVertical) { 46 | size = scrollDiv.offsetHeight - scrollDiv.clientHeight 47 | scrollbarHorizontalSize = size 48 | } 49 | 50 | document.body.removeChild(scrollDiv) 51 | 52 | return size 53 | } 54 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parserOptions: { 4 | sourceType: 'module' 5 | }, 6 | extends: [ 7 | // https://github.com/vuejs/eslint-plugin-vue#bulb-rules 8 | 'plugin:vue/recommended', 9 | // https://github.com/standard/standard/blob/master/docs/RULES-en.md 10 | 'standard', 11 | // https://github.com/prettier/eslint-config-prettier 12 | 'prettier', 13 | 'prettier/standard', 14 | 'prettier/vue' 15 | ], 16 | rules: { 17 | // Only allow debugger in development 18 | 'no-debugger': process.env.PRE_COMMIT ? 'error' : 'off', 19 | // Only allow `console.log` in development 20 | 'no-console': process.env.PRE_COMMIT 21 | ? ['error', { allow: ['warn', 'error'] }] 22 | : 'off', 23 | 'vue/component-name-in-template-casing': [ 24 | 'error', 25 | 'PascalCase', 26 | { 27 | ignores: [ 28 | 'component', 29 | 'template', 30 | 'transition', 31 | 'transition-group', 32 | 'keep-alive', 33 | 'slot' 34 | ] 35 | } 36 | ] 37 | }, 38 | overrides: [ 39 | { 40 | files: ['src/**/*', 'tests/unit/**/*', 'tests/e2e/**/*'], 41 | excludedFiles: 'app.config.js', 42 | parserOptions: { 43 | parser: 'babel-eslint', 44 | sourceType: 'module' 45 | }, 46 | env: { 47 | browser: true 48 | } 49 | }, 50 | { 51 | files: ['**/*.unit.js'], 52 | parserOptions: { 53 | parser: 'babel-eslint', 54 | sourceType: 'module' 55 | }, 56 | env: { jest: true }, 57 | globals: { 58 | mount: false, 59 | shallowMount: false, 60 | shallowMountView: false, 61 | createComponentMocks: false, 62 | createModuleStore: false 63 | } 64 | } 65 | ] 66 | } 67 | -------------------------------------------------------------------------------- /example/components/virtual-table/helpers/withStoreConnect.js: -------------------------------------------------------------------------------- 1 | import { getOptionProps } from './utils' 2 | // import fastDeepEqual from 'fast-deep-equal' 3 | 4 | /** 5 | * Connect in Vue.js 6 | * @see 7 | * {@link https://gist.github.com/gaearon/1d19088790e70ac32ea636c025ba424e connect.js} 8 | * {@link https://github.com/vueComponent/ant-design-vue/blob/e63f9ea671/components/_util/store/connect.jsx ant-design-vue/connect.jsx} 9 | */ 10 | export const withStoreConnect = (mapStateToProps) => { 11 | const shouldSubscribe = !!mapStateToProps 12 | 13 | return { 14 | data() { 15 | this.preProps = getOptionProps(this) 16 | const state = mapStateToProps(this.store.getState(), this.$props) 17 | 18 | return { 19 | state 20 | } 21 | }, 22 | 23 | created() { 24 | this.trySubscribe() 25 | }, 26 | 27 | beforeDestroy() { 28 | this.tryUnsubscribe() 29 | }, 30 | 31 | methods: { 32 | handleChange() { 33 | if (!this.unsubscribe) { 34 | return 35 | } 36 | 37 | const props = getOptionProps(this) 38 | const nextState = mapStateToProps(this.store.getState(), props) 39 | 40 | /** 41 | * fix: 没有必要比对,比对反而降低性能?因为 Vue.js 本身 Reactive 的实现已经做了脏检测 42 | */ 43 | /* if (!this._q(this.preProps, props) || !this._q(this.state, nextState)) { 44 | this.state = nextState 45 | } */ 46 | this.state = nextState 47 | }, 48 | 49 | trySubscribe() { 50 | if (shouldSubscribe) { 51 | this.unsubscribe = this.store.subscribe(this.handleChange) 52 | this.handleChange() 53 | } 54 | }, 55 | 56 | tryUnsubscribe() { 57 | if (this.unsubscribe) { 58 | this.unsubscribe() 59 | this.unsubscribe = null 60 | } 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /example/components/virtual-table/ColGroup.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable vue/require-default-prop */ 2 | import PropTypes from 'vue-types' 3 | import { ColumnProps } from './interface' 4 | 5 | export default { 6 | name: 'ColGroup', 7 | 8 | props: { 9 | fixed: ColumnProps.fixed, 10 | columns: PropTypes.arrayOf(ColumnProps).isRequired 11 | }, 12 | 13 | inject: { 14 | table: { default: () => ({}) } 15 | }, 16 | 17 | watch: { 18 | // FIXME: columns 属性变化未触发 render,所以使用了 $forceUpdate,应该有更好的方法。 19 | // 比如说使用 createStore 20 | columns(val, oldVal) { 21 | if (oldVal !== val || oldVal.length !== val.length) { 22 | this.$forceUpdate() 23 | } 24 | } 25 | }, 26 | 27 | render() { 28 | const { fixed, table } = this 29 | const { prefixCls, expandIconAsCell, columnManager } = table 30 | 31 | let cols = [] 32 | 33 | if (expandIconAsCell && fixed !== 'right') { 34 | cols.push( 35 | 39 | ) 40 | } 41 | 42 | let leafColumns 43 | 44 | if (fixed === 'left') { 45 | leafColumns = columnManager.leftLeafColumns() 46 | } else if (fixed === 'right') { 47 | leafColumns = columnManager.rightLeafColumns() 48 | } else { 49 | leafColumns = columnManager.leafColumns() 50 | } 51 | 52 | cols = cols.concat( 53 | leafColumns.map((c) => { 54 | const width = typeof c.width === 'number' ? `${c.width}px` : c.width 55 | 56 | return ( 57 | 63 | ) 64 | }) 65 | ) 66 | 67 | return {cols} 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/mixins/withSelection.js: -------------------------------------------------------------------------------- 1 | import { SINGLE_SELECTION_MODE } from '../interface' 2 | 3 | export function data() { 4 | if (this.rowSelection) { 5 | const { selectedRowKeys = [] } = this.rowSelection || {} 6 | 7 | // The default select mode is `radio` 8 | if (!this.rowSelection.type) { 9 | this.rowSelection.type = SINGLE_SELECTION_MODE 10 | } 11 | 12 | return { 13 | // The below data will be passed in form component props 14 | selectedRowKeys 15 | } 16 | } 17 | } 18 | 19 | export const methods = { 20 | /** 21 | * 判断当前行是否是选中状态 22 | * @param {RowKey} rowKey 23 | */ 24 | isRowSelected(rowKey) { 25 | return this.selectedRowKeys.includes(this.adaptRowKey(rowKey)) 26 | }, 27 | 28 | /** 29 | * 当前行是否选中状态 30 | * @param {RowKey} rowKey 31 | */ 32 | toggleRowSelection(rowKey, nativeEvent) { 33 | if (!rowKey) return 34 | 35 | let record = this.adaptKeyToRow(rowKey) 36 | rowKey = this.adaptRowKey(record) 37 | 38 | if (rowKey) { 39 | const { type, selectedRowKeys = [] } = this.rowSelection || {} 40 | let isSelected = false 41 | 42 | if (type === 'radio') { 43 | isSelected = this.isRowSelected(rowKey) 44 | 45 | if (isSelected) { 46 | this.selectedRowKeys = [] 47 | } else { 48 | this.selectedRowKeys = [rowKey] 49 | } 50 | } 51 | 52 | if (type === 'checkbox') { 53 | isSelected = selectedRowKeys.includes(rowKey) 54 | 55 | if (isSelected) { 56 | selectedRowKeys.splice(selectedRowKeys.indexOf(rowKey), 1) 57 | } else { 58 | selectedRowKeys.push(rowKey) 59 | } 60 | 61 | this.selectedRowKeys = selectedRowKeys 62 | } 63 | 64 | const selected = !isSelected 65 | 66 | this.$emit( 67 | 'select-change', 68 | record, 69 | selected, 70 | this.selectedRowKeys, 71 | nativeEvent 72 | ) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/TableBody/ExpandedRow.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable vue/require-default-prop */ 2 | import Cell from '../TableCell/index' 3 | 4 | export default { 5 | name: 'ExpandedRow', 6 | 7 | functional: true, 8 | 9 | inheritAttrs: false, 10 | 11 | inject: ['scrollbarSize'], 12 | 13 | props: { 14 | prefixCls: String, 15 | component: String, 16 | cellComponent: String, 17 | fixHeader: Boolean, 18 | fixColumn: Boolean, 19 | horizonScroll: Boolean, 20 | componentWidth: Number, 21 | className: String, 22 | expanded: Boolean, 23 | children: [Object, Array], 24 | colSpan: Number 25 | }, 26 | 27 | render(h, { props, children, injections }) { 28 | const { 29 | prefixCls, 30 | children: propsChildren, 31 | component: Component, 32 | cellComponent, 33 | fixHeader, 34 | fixColumn, 35 | className, 36 | expanded, 37 | colSpan, 38 | scrollbarSize, 39 | componentWidth 40 | } = { 41 | ...props, 42 | ...injections 43 | } 44 | let contentNode = children || propsChildren 45 | 46 | if (fixColumn && componentWidth) { 47 | contentNode = ( 48 |
57 | {contentNode} 58 |
59 | ) 60 | } 61 | 62 | return ( 63 | 69 | 75 | {contentNode} 76 | 77 | 78 | ) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/TableHeader/HeaderRow.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable vue/require-default-prop */ 2 | import { getColumnsKey, getCellFixedInfo } from '../utils/column' 3 | import { TableProps, STRING_PROP } from '../interface' 4 | import Cell from '../TableCell/index' 5 | 6 | export default { 7 | name: 'HeaderRow', 8 | 9 | functional: true, 10 | 11 | inheritAttrs: false, 12 | 13 | inject: { 14 | prefixCls: TableProps.prefixCls, 15 | direction: STRING_PROP 16 | }, 17 | 18 | props: { 19 | cells: { 20 | type: Array, 21 | default: () => [] 22 | }, 23 | columns: Array, 24 | stickyOffsets: Object, 25 | flattenColumns: Array, 26 | rowComponent: String, 27 | cellComponent: String, 28 | index: Number 29 | }, 30 | 31 | render(h, ctx) { 32 | const { props, injections } = ctx 33 | const { prefixCls, direction } = injections 34 | const { 35 | cells, 36 | stickyOffsets, 37 | flattenColumns, 38 | rowComponent: RowComponent, 39 | cellComponent: CellComponent 40 | } = props 41 | 42 | const columnsKey = getColumnsKey(cells.map((cell) => cell.column)) 43 | 44 | return ( 45 | 46 | {cells.map((cell, cellIndex) => { 47 | const { column } = cell 48 | const fixedInfo = getCellFixedInfo( 49 | cell.colStart, 50 | cell.colEnd, 51 | flattenColumns, 52 | stickyOffsets, 53 | direction 54 | ) 55 | 56 | // fix: TableHeaderRow 不渲染 className 否则样式可能引起 table 渲染异常 57 | cell.column = { 58 | ...column, 59 | class: '', 60 | className: '' 61 | } 62 | 63 | const options = { 64 | props: { ...cell, ...fixedInfo, children: [column.label] } 65 | } 66 | 67 | return ( 68 | 77 | ) 78 | })} 79 | 80 | ) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /example/components/virtual-table/Table.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable vue/require-default-prop */ 2 | import PropTypes from 'vue-types' 3 | import ColGroup from './ColGroup' 4 | import TableHeader from './TableHeader' 5 | import TableBody from './TableBody' 6 | import { TableComponents, TableProps } from './interface' 7 | 8 | export default { 9 | name: 'Table', 10 | 11 | props: { 12 | fixed: TableProps.fixed, 13 | prefixCls: TableProps.prefixCls, 14 | showHeader: PropTypes.bool.def(true), 15 | components: TableComponents.isRequired, 16 | hasHead: PropTypes.bool.def(true), 17 | hasBody: PropTypes.bool.def(true), 18 | scroll: TableProps.scroll.isRequired, 19 | columns: TableProps.columns.isRequired 20 | }, 21 | 22 | render() { 23 | const { 24 | prefixCls, 25 | showHeader, 26 | components, 27 | hasHead, 28 | hasBody, 29 | fixed, 30 | scroll, 31 | columns 32 | } = this.$props 33 | const tableStyle = {} 34 | 35 | if (!fixed && scroll.x) { 36 | // not set width, then use content fixed width 37 | if (scroll.x === true) { 38 | tableStyle.tableLayout = 'fixed' 39 | } else { 40 | tableStyle.width = 41 | typeof scroll.x === 'number' ? `${scroll.x}px` : scroll.x 42 | } 43 | } 44 | 45 | const Table = hasBody ? components.table : 'table' 46 | 47 | return ( 48 | 49 | 50 | 51 | {hasHead && ( 52 | 60 | )} 61 | 62 | {hasBody && ( 63 | 73 | )} 74 |
75 | ) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-virtualized-table", 3 | "version": "0.0.3", 4 | "private": false, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "files": [ 11 | "src" 12 | ], 13 | "dependencies": { 14 | "vue-size-observer": "^0.0.3" 15 | }, 16 | "devDependencies": { 17 | "@vue/cli-plugin-babel": "^4.3.0", 18 | "@vue/cli-plugin-eslint": "^4.3.0", 19 | "@vue/cli-service": "^4.3.0", 20 | "@vue/eslint-config-prettier": "^5.0.0", 21 | "@vue/eslint-config-standard": "^4.0.0", 22 | "babel-eslint": "^10.1.0", 23 | "element-ui": "^2.13.2", 24 | "eslint": "^6.7.2", 25 | "eslint-plugin-prettier": "^3.1.3", 26 | "eslint-plugin-vue": "^6.2.2", 27 | "normalize.css": "^8.0.1", 28 | "vue": "^2.6.12", 29 | "vue-template-compiler": "^2.6.12" 30 | }, 31 | "eslintConfig": { 32 | "root": true, 33 | "env": { 34 | "node": true 35 | }, 36 | "extends": [ 37 | "plugin:vue/essential", 38 | "eslint:recommended" 39 | ], 40 | "parserOptions": { 41 | "parser": "babel-eslint" 42 | }, 43 | "rules": {} 44 | }, 45 | "browserslist": [ 46 | "> 1%", 47 | "last 2 versions", 48 | "not dead" 49 | ], 50 | "description": "The second version of implementation of `vue-virtual-table` component, it was inspired from [rc-table](https://github.com/react-component/table) and [ant-table](https://ant.design/components/table), API design is 60%+ consistent. Or you could think I translated them from React to Vue and added *virtualize scroll* feature.", 51 | "main": "src/index.jsx", 52 | "directories": { 53 | "example": "example" 54 | }, 55 | "repository": { 56 | "type": "git", 57 | "url": "git+https://github.com/givingwu/vue-virtualized-table.git" 58 | }, 59 | "keywords": [ 60 | "tree-table", 61 | "vue-tree-table", 62 | "vue-table-tree", 63 | "virtual-table", 64 | "vue-virtual-table", 65 | "vue-virtualized-table" 66 | ], 67 | "author": " ", 68 | "license": "MIT", 69 | "bugs": { 70 | "url": "https://github.com/givingwu/vue-virtualized-table/issues" 71 | }, 72 | "homepage": "https://github.com/givingwu/vue-virtualized-table#readme" 73 | } 74 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # vue-virtual-table 2 | 3 | ## Real virtualized table 4 | 5 | 初代版的 TableTree,因为当时使用 element-ui 的 table 组件其功能扩展能力有限,无法满足需求。于是我们自己实现了 TableTree 组件内置了多项扩展并支持自定义渲染 `{ render: ( store: TableStore, row: RowModel, column: ColumnConfig, value: any )}`,而这个 API 也一致延续至今。 6 | 7 | 而后在编写工期项目的时候,因为工期进度记录数据需要渲染的数据量非常大,当渲染上几百条数据时,网页开始变得卡顿。于是实现了第二版的 VirtualTable,即支持了 **virtual-scroll** 的 table 组件。 8 | 9 | 起初 VirtualTable V1 可能会渲染三个 table,即左固定列和右固定列会被独立渲染成 Table 以实现固定效果。出现了内存泄漏和性能问题,显得*虚拟滚动并不虚拟*。 10 | 11 | 而后看到了 antd V4 版本使用 `position: sticky` 实现列固定的效果,才想明白之前分开渲染 左固定列 和 右固定列 的目的是为了兼容 IE11 [CSS position:sticky](https://caniuse.com/#search=sticky) 。本来以为是渲染3个table造成的性能和内存问题,从算法角度看是 O(3),改用 sticky 后是应该变成 O(1) 才是,但是并不理想。 12 | 13 | 反思自己,从一开始的思路就出现了错误,不应该使用 元素来实现复杂的功能。理由如下: 14 | 15 | + table 灵活性限制 16 | + table 学习成本 17 | + table 扩展性差 18 | 19 | 但是在 TableTree V1 到 VirtualTable V1,VirtualTable V2 的过程,发现几点共性: 20 | 21 | 1. TableStateManager 用于维护 Table 本身公共状态,比如在渲染三个 table 时,FixedLeftTable, Table, FixedRightTable 的 hover 状态: currentHoveredRow. 22 | 2. ExpansionManager 用于维护 Table 扩展状态。在普通的 table 中渲染使用递归 `expandRender(item.children)` 渲染子元素,这也是第一版 TableTree 的做法。但是这对于**虚拟滚动**是行不通的。因为虚拟滚动的展开/收起状态更像是 *分段插入* 而非 *递归渲染*。 23 | 3. VirtualizeManager 用于维护 Table 虚拟化的相关状态,比如:virtualizedData = [], currentScrollTop, wrapperHeight 等等。 24 | 25 | 26 | ## Technologies 27 | 28 | 在调试和开发 VirtualTable 组件时,发现的问题: 29 | 30 | 1. 滚动速度过快时,超过了 VirtualDOM Diff + Patch 的执行时间,会明显掉帧,白屏。使用 rAF 也是一样的,因为即使当前 VirtualizeManager 中仅有 5 条 virtualizedData 数据,在每次滚动时执行 Diff + Patch 的时间依赖 TableCell 每个单元格内被渲染组件的复杂度。对于简单的 textNode 还将就,如果是复杂的组件,在我们项目中是 TableFormItem,即 O(n) * (diff + patch), n = TableFormItem 组件的数量。这种情况下 rAF 的 16ms 大于了 16ms。最终加上 debounce + chunk 分段更新,同样无法解决问题。 31 | 2. 后来看了 React-Virtualized 的实现,以及我之前实现的 vue-virtual-list 组件。才想通最好的做法是使用 div 模拟 table,用浏览器的 Repaint 代替 Vue 的 n * (createComponent + Diff + Patch),n = visibleRows.length。加上 GPU 加速和 CSS 分层,性能是最好的。在更新的时候使用 rAF 求出 diffIndex,然后更新 model 再 patch 当前 diffIndex 的 item,实现性能和体验的最优解。换言之,就是每次找到需要被更新的 index 执行 updateComponent 更新 CSS, Component,复杂度由原来的 O(n) 变成 O(1),即当前元素 virtualizedData[diffIndex]。 32 | 33 | ## Features 34 | 35 | 1. **高性能**的*虚拟滚动* 36 | 2. 支持**动态高度** 37 | 3. 支持**树形展开** 38 | 4. 支持**自定义渲染** 39 | 5. 不同实现 40 | 41 | 42 | # V2.0 43 | 44 | 由于依然存在内存泄漏问题,计划使用 div 重写。 45 | 46 | + 如何用 table 实现 table 那样的布局。 47 | + 左侧固定 48 | + 右侧固定 49 | + 头部固定 50 | -------------------------------------------------------------------------------- /example/components/virtual-table/TableHeader.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable vue/require-default-prop */ 2 | import PropTypes from 'vue-types' 3 | import TableHeaderRow from './TableHeaderRow' 4 | import { ColumnProps, TableComponents, TableProps } from './interface' 5 | 6 | function getHeaderRows(columns, currentRow = 0, rows) { 7 | rows = rows || [] 8 | rows[currentRow] = rows[currentRow] || [] 9 | 10 | columns.forEach((column) => { 11 | if (column.rowSpan && rows.length < column.rowSpan) { 12 | while (rows.length < column.rowSpan) { 13 | rows.push([]) 14 | } 15 | } 16 | 17 | const cell = { 18 | key: column.key, 19 | className: column.className || column.class || '', 20 | children: column.label || column.title, 21 | column 22 | } 23 | 24 | if (column.children) { 25 | getHeaderRows(column.children, currentRow + 1, rows) 26 | } 27 | 28 | if ('colSpan' in column) { 29 | cell.colSpan = column.colSpan 30 | } 31 | 32 | if ('rowSpan' in column) { 33 | cell.rowSpan = column.rowSpan 34 | } 35 | 36 | if (cell.colSpan !== 0) { 37 | rows[currentRow].push(cell) 38 | } 39 | }) 40 | 41 | return rows.filter((row) => row.length > 0) 42 | } 43 | 44 | export default { 45 | name: 'TableHeader', 46 | 47 | inheritAttrs: false, 48 | 49 | props: { 50 | fixed: ColumnProps.fixed.def(false), 51 | columns: PropTypes.arrayOf(ColumnProps).isRequired, 52 | prefixCls: TableProps.prefixCls, 53 | components: TableComponents 54 | }, 55 | 56 | render() { 57 | const { fixed, columns, prefixCls, components } = this 58 | const TableHeaderWrapper = components.header.wrapper 59 | const rows = getHeaderRows(columns) 60 | 61 | return ( 62 | 63 | {rows.map((row, index) => { 64 | const attrs = { 65 | key: index, 66 | props: { 67 | ...this.$attrs, 68 | row, 69 | index, 70 | prefixCls, 71 | components, 72 | fixed, 73 | columns, 74 | rows 75 | }, 76 | on: { 77 | ...this.$listeners 78 | }, 79 | attrs: { 80 | ...this.$attrs 81 | } 82 | } 83 | 84 | return 85 | })} 86 | 87 | ) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/TableHeader/FixedHeader.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable vue/require-default-prop */ 2 | import Header from './index' 3 | import ColGroup from '../ColGroup' 4 | import { TableProps, NUMBER_PROP } from '../interface' 5 | 6 | export default { 7 | name: 'FixedHeader', 8 | 9 | functional: true, 10 | 11 | inheritAttrs: false, 12 | 13 | inject: { 14 | prefixCls: TableProps.prefixCls, 15 | scrollbarSize: NUMBER_PROP 16 | }, 17 | 18 | props: { 19 | colWidths: Array, 20 | columCount: Number, 21 | direction: String, 22 | ...Header.props 23 | }, 24 | 25 | render(h, ctx) { 26 | const { props, injections } = ctx 27 | const { prefixCls, scrollbarSize } = injections 28 | const { 29 | columns, 30 | flattenColumns, 31 | colWidths, 32 | columCount, 33 | stickyOffsets, 34 | direction 35 | } = props 36 | 37 | // Add scrollbar column 38 | const lastColumn = flattenColumns[flattenColumns.length - 1] 39 | const ScrollBarColumn = { 40 | fixed: lastColumn ? lastColumn.fixed : null, 41 | class: `${prefixCls}-cell-scrollbar` 42 | } 43 | 44 | const columnsWithScrollbar = scrollbarSize 45 | ? [...columns, ScrollBarColumn] 46 | : columns 47 | const flattenColumnsWithScrollbar = scrollbarSize 48 | ? [...flattenColumns, ScrollBarColumn] 49 | : flattenColumns 50 | 51 | // Calculate the sticky offsets 52 | const { right, left } = stickyOffsets 53 | const headerStickyOffsets = { 54 | ...stickyOffsets, 55 | left: 56 | direction === 'rtl' 57 | ? [...left.map((width) => width + scrollbarSize), 0] 58 | : left, 59 | right: 60 | direction === 'rtl' 61 | ? right 62 | : [...right.map((width) => width + scrollbarSize), 0] 63 | } 64 | 65 | const cloneWidths = [] 66 | for (let i = 0; i < columCount; i += 1) { 67 | cloneWidths[i] = colWidths[i] 68 | } 69 | const columnWidthsReady = !colWidths.every((width) => !width) 70 | 71 | return ( 72 |
78 | 83 | 84 |
89 |
90 | ) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /stylelint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | // Use the Standard config as the base 4 | // https://github.com/stylelint/stylelint-config-standard 5 | 'stylelint-config-standard', 6 | // Enforce a standard order for CSS properties 7 | // https://github.com/stormwarning/stylelint-config-recess-order 8 | 'stylelint-config-recess-order', 9 | // Override rules that would interfere with Prettier 10 | // https://github.com/shannonmoeller/stylelint-config-prettier 11 | 'stylelint-config-prettier', 12 | // Override rules to allow linting of CSS modules 13 | // https://github.com/pascalduez/stylelint-config-css-modules 14 | 'stylelint-config-css-modules', 15 | ], 16 | plugins: [ 17 | // Bring in some extra rules for SCSS 18 | 'stylelint-scss', 19 | ], 20 | // Rule lists: 21 | // - https://stylelint.io/user-guide/rules/ 22 | // - https://github.com/kristerkari/stylelint-scss#list-of-rules 23 | rules: { 24 | // Allow newlines inside class attribute values 25 | 'string-no-newline': null, 26 | // Enforce camelCase for classes and ids, to work better 27 | // with CSS modules 28 | 'selector-class-pattern': /^[a-z][a-zA-Z]*(-(enter|leave)(-(active|to))?)?$/, 29 | 'selector-id-pattern': /^[a-z][a-zA-Z]*$/, 30 | // Limit the number of universal selectors in a selector, 31 | // to avoid very slow selectors 32 | 'selector-max-universal': 1, 33 | // Disallow allow global element/type selectors in scoped modules 34 | 'selector-max-type': [0, { ignore: ['child', 'descendant', 'compounded'] }], 35 | // === 36 | // PRETTIER 37 | // === 38 | // HACK: to compensate for https://github.com/shannonmoeller/stylelint-config-prettier/issues/4 39 | // Modifying setting from Standard: https://github.com/stylelint/stylelint-config-standard/blob/7b76d7d0060f2e13a331806a09c2096c7536b0a6/index.js#L6 40 | 'at-rule-empty-line-before': [ 41 | 'always', 42 | { 43 | except: ['blockless-after-same-name-blockless', 'first-nested'], 44 | ignore: ['after-comment'], 45 | ignoreAtRules: ['else'], 46 | }, 47 | ], 48 | // === 49 | // SCSS 50 | // === 51 | 'scss/dollar-variable-colon-space-after': 'always', 52 | 'scss/dollar-variable-colon-space-before': 'never', 53 | 'scss/dollar-variable-no-missing-interpolation': true, 54 | 'scss/dollar-variable-pattern': /^[a-z-]+$/, 55 | 'scss/double-slash-comment-whitespace-inside': 'always', 56 | 'scss/operator-no-newline-before': true, 57 | 'scss/operator-no-unspaced': true, 58 | 'scss/selector-no-redundant-nesting-selector': true, 59 | // Allow SCSS and CSS module keywords beginning with `@` 60 | 'at-rule-no-unknown': null, 61 | 'scss/at-rule-no-unknown': true, 62 | }, 63 | } 64 | -------------------------------------------------------------------------------- /example/components/TableExpandAction/index.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 109 | 110 | 112 | -------------------------------------------------------------------------------- /src/TableHeader/index.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable vue/require-default-prop */ 2 | import { TableProps } from '../interface' 3 | import { isNumber } from '../utils/type' 4 | import HeaderRow from './HeaderRow' 5 | 6 | function parseHeaderRows(rootColumns) { 7 | const rows = [] 8 | 9 | function fillRowCells(columns = [], colIndex, rowIndex = 0) { 10 | // Init rows 11 | rows[rowIndex] = rows[rowIndex] || [] 12 | 13 | let currentColIndex = colIndex 14 | const colSpans = columns.map((column) => { 15 | const cell = { 16 | key: column.key, 17 | className: column.className || '', 18 | children: column.title, 19 | column, 20 | colStart: currentColIndex 21 | } 22 | 23 | let colSpan = 1 24 | 25 | const subColumns = column.children 26 | if (subColumns && subColumns.length > 0) { 27 | colSpan = fillRowCells( 28 | subColumns, 29 | currentColIndex, 30 | rowIndex + 1 31 | ).reduce((total, count) => total + count, 0) 32 | cell.hasSubColumns = true 33 | } 34 | 35 | if ('colSpan' in column && isNumber(column.colSpan)) { 36 | // eslint-disable-next-line no-extra-semi 37 | ;({ colSpan } = column) 38 | } 39 | 40 | if ('rowSpan' in column && isNumber(column.rowSpan)) { 41 | cell.rowSpan = column.rowSpan 42 | } 43 | 44 | cell.colSpan = colSpan 45 | cell.colEnd = cell.colStart + colSpan - 1 46 | rows[rowIndex].push(cell) 47 | 48 | currentColIndex += colSpan 49 | 50 | return colSpan 51 | }) 52 | 53 | return colSpans 54 | } 55 | 56 | // Generate `rows` cell data 57 | fillRowCells(rootColumns, 0) 58 | 59 | // Handle `rowSpan` 60 | const rowCount = rows.length 61 | 62 | for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) { 63 | rows[rowIndex].forEach((cell) => { 64 | if (!('rowSpan' in cell) && !cell.hasSubColumns) { 65 | // eslint-disable-next-line no-param-reassign 66 | cell.rowSpan = rowCount - rowIndex 67 | } 68 | }) 69 | } 70 | 71 | return rows 72 | } 73 | 74 | export default { 75 | name: 'Header', 76 | 77 | functional: true, 78 | 79 | inheritAttrs: false, 80 | 81 | inject: { 82 | prefixCls: TableProps.prefixCls, 83 | components: TableProps.components 84 | }, 85 | 86 | props: { 87 | columns: TableProps.columns, 88 | flattenColumns: TableProps.columns, 89 | stickyOffsets: Object 90 | }, 91 | 92 | render(h, ctx) { 93 | const { props, injections } = ctx 94 | const { prefixCls, components } = injections 95 | const { columns, flattenColumns, stickyOffsets } = props 96 | 97 | const rows = parseHeaderRows(columns) 98 | const { 99 | wrapper: WrapperComponent, 100 | row: trComponent, 101 | cell: thComponent 102 | } = components.header 103 | 104 | return ( 105 | 106 | {rows.map((row, rowIndex) => { 107 | const rowNode = ( 108 | 117 | ) 118 | 119 | return rowNode 120 | })} 121 | 122 | ) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /example/components/virtual-table/helpers/store/connect.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import fastDeepEqual from 'fast-deep-equal' 3 | import { getOptionProps } from '../utils/index' 4 | 5 | /** 6 | * Connect in Vue.js 7 | * @see 8 | * 9 | * {@link https://gist.github.com/gaearon/1d19088790e70ac32ea636c025ba424e connect.js} 10 | * {@link https://github.com/vueComponent/ant-design-vue/blob/e63f9ea671/components/_util/store/connect.jsx ant-design-vue/connect.jsx} 11 | */ 12 | export default function connect(mapStateToProps = () => ({})) { 13 | const shouldSubscribe = !!mapStateToProps 14 | 15 | return function withConnectHOC(WrappedComponent) { 16 | const props = {} 17 | const tempProps = WrappedComponent.props 18 | 19 | /** 20 | * 在 connect 这层,取消 required 配置,阻止双重警告 21 | * ConnectComponent > Component 22 | */ 23 | Object.keys(tempProps).forEach((k) => { 24 | props[k] = { ...tempProps[k], required: false } 25 | }) 26 | 27 | return Vue.extend({ 28 | name: `Connect${WrappedComponent.name || 'Component'}`, 29 | 30 | inject: { 31 | table: { default: () => ({}) } 32 | }, 33 | 34 | props, 35 | 36 | data() { 37 | this.store = this.table.store 38 | this.preProps = getOptionProps(this) 39 | 40 | return { 41 | subscribed: mapStateToProps(this.store.getState(), this.$props) 42 | } 43 | }, 44 | 45 | mounted() { 46 | this.trySubscribe() 47 | }, 48 | 49 | beforeDestroy() { 50 | this.tryUnsubscribe() 51 | }, 52 | 53 | methods: { 54 | handleChange() { 55 | if (!this.unsubscribe) { 56 | return 57 | } 58 | 59 | const props = getOptionProps(this) 60 | const nextSubscribed = mapStateToProps(this.store.getState(), props) 61 | 62 | if ( 63 | !fastDeepEqual(this.preProps, props) || 64 | !fastDeepEqual(this.subscribed, nextSubscribed) 65 | ) { 66 | this.subscribed = nextSubscribed 67 | } 68 | }, 69 | 70 | trySubscribe() { 71 | if (shouldSubscribe) { 72 | this.unsubscribe = this.store.subscribe(this.handleChange) 73 | this.handleChange() 74 | } 75 | }, 76 | 77 | tryUnsubscribe() { 78 | if (this.unsubscribe) { 79 | this.unsubscribe() 80 | this.unsubscribe = null 81 | } 82 | }, 83 | 84 | getWrappedInstance() { 85 | return this.$refs.wrappedInstance 86 | } 87 | }, 88 | 89 | render() { 90 | const props = getOptionProps(this) 91 | const { 92 | $listeners, 93 | $slots = {}, 94 | $attrs, 95 | $scopedSlots, 96 | subscribed, 97 | store 98 | } = this 99 | 100 | this.preProps = { ...this.$props, ...props } 101 | 102 | const wrapProps = { 103 | props: { 104 | ...props, 105 | store, 106 | ...subscribed 107 | }, 108 | on: $listeners, 109 | attrs: { 110 | ...$attrs 111 | }, 112 | scopedSlots: $scopedSlots 113 | } 114 | 115 | return ( 116 | 117 | {Object.keys($slots).map((name) => { 118 | return 119 | })} 120 | 121 | ) 122 | } 123 | }) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /example/components/virtual-table/TableHeaderRow.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable vue/require-default-prop */ 2 | import PropTypes from "vue-types" 3 | import { TableComponents, Store, TableProps } from "./interface" 4 | import { withStoreConnect } from "./helpers/withStoreConnect" 5 | 6 | function getRowHeight(state, props) { 7 | const { fixedColumnsHeadRowsHeight } = state 8 | const { columns, rows, fixed } = props 9 | const headerHeight = fixedColumnsHeadRowsHeight[0] 10 | 11 | if (!fixed) { 12 | return null 13 | } 14 | 15 | if (headerHeight && columns) { 16 | if (headerHeight === "auto") { 17 | return "auto" 18 | } 19 | 20 | return `${headerHeight / rows.length}px` 21 | } 22 | 23 | return null 24 | } 25 | 26 | function connect(state, props) { 27 | return { 28 | height: getRowHeight(state, props), 29 | } 30 | } 31 | 32 | export default { 33 | name: "TableHeaderRow", 34 | 35 | mixins: [withStoreConnect(connect)], 36 | 37 | inheritAttrs: false, 38 | 39 | inject: { 40 | table: { default: () => ({}) }, 41 | }, 42 | 43 | props: { 44 | fixed: TableProps.fixed, 45 | store: Store.isRequired, 46 | rows: TableProps.columns, 47 | columns: TableProps.columns, 48 | row: PropTypes.array, 49 | prefixCls: PropTypes.string.isRequired, 50 | components: TableComponents, 51 | }, 52 | 53 | data() { 54 | const { stateManager } = this.table 55 | 56 | if (stateManager) { 57 | this.stateManager = stateManager 58 | } 59 | 60 | return {} 61 | }, 62 | 63 | render(h) { 64 | const { row, fixed, prefixCls, components } = this.$props 65 | const { row: TableHeaderRow, cell: TableHeaderRowCell } = components.header 66 | const { height } = this.state 67 | const style = { height } 68 | 69 | return ( 70 | 71 | {row.map((cell, index) => { 72 | // eslint-disable-next-line no-unused-vars 73 | const { column, children, className, ...cellProps } = cell 74 | const headerCellProps = { 75 | inheritAttrs: false, 76 | attrs: { 77 | ...cellProps, 78 | }, 79 | style, 80 | class: [ 81 | column.fixed && !fixed 82 | ? [`${prefixCls}-fixed-columns-in-body`] 83 | : "", 84 | ], 85 | } 86 | 87 | headerCellProps.class = [ 88 | ...headerCellProps.class, 89 | column.ellipsis 90 | ? `${prefixCls}-row-cell-ellipsis` 91 | : `${prefixCls}-row-cell-break-word`, 92 | ] 93 | 94 | if (column.align) { 95 | headerCellProps.style = { 96 | ...headerCellProps.style, 97 | textAlign: column.align, 98 | } 99 | } 100 | 101 | if (typeof TableHeaderRowCell === "function") { 102 | return TableHeaderRowCell(h, headerCellProps, children) 103 | } 104 | 105 | return ( 106 | 110 | 111 | {typeof column.renderHeader === "function" 112 | ? column.renderHeader(h, { 113 | store: this.stateManager, 114 | row, 115 | column, 116 | index, 117 | }) 118 | : children} 119 | 120 | 121 | ) 122 | })} 123 | 124 | ) 125 | }, 126 | } 127 | -------------------------------------------------------------------------------- /src/TableBody/index.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable vue/require-default-prop */ 2 | import { 3 | TableProps, 4 | FUNC_PROP, 5 | ARRAY_PROP, 6 | NUMBER_PROP, 7 | BOOLEAN_FALSE_PROP 8 | } from '../interface' 9 | import BodyRow from './BodyRow' 10 | import ExpandedRow from './ExpandedRow' 11 | import ResizeObserver from 'vue-size-observer' 12 | 13 | export default { 14 | name: 'Body', 15 | 16 | functional: true, 17 | 18 | inheritAttrs: false, 19 | 20 | inject: { 21 | getRowKey: FUNC_PROP, 22 | prefixCls: TableProps.prefixCls, 23 | components: TableProps.components, 24 | fixHeader: BOOLEAN_FALSE_PROP, 25 | horizonScroll: BOOLEAN_FALSE_PROP, 26 | onColumnResize: FUNC_PROP 27 | }, 28 | 29 | props: { 30 | data: TableProps.dataSource, 31 | emptyNode: Object, 32 | columnsKey: ARRAY_PROP, 33 | expandedKeys: ARRAY_PROP, 34 | fixedInfoList: ARRAY_PROP, 35 | flattenColumns: TableProps.columns, 36 | componentWidth: NUMBER_PROP, 37 | measureColumnWidth: Boolean, 38 | childrenColumnName: String 39 | }, 40 | 41 | render(h, { props, injections }) { 42 | const { 43 | data = [], 44 | emptyNode, 45 | columnsKey = [], 46 | expandedKeys, 47 | fixedInfoList, 48 | flattenColumns, 49 | componentWidth, 50 | measureColumnWidth, 51 | childrenColumnName 52 | } = props 53 | const { 54 | getRowKey, 55 | prefixCls, 56 | components, 57 | fixHeader, 58 | horizonScroll, 59 | onColumnResize 60 | } = injections 61 | 62 | const { 63 | wrapper: WrapperComponent, 64 | row: trComponent, 65 | cell: tdComponent 66 | } = components.body 67 | let rows = [] 68 | 69 | if (data.length) { 70 | rows = data.map((record, index) => { 71 | const key = getRowKey(record, index) 72 | 73 | return [ 74 | 89 | ] 90 | }) 91 | } else { 92 | rows = ( 93 | 106 | {emptyNode} 107 | 108 | ) 109 | } 110 | 111 | return ( 112 | 113 | {/* Measure body column width with additional hidden col */} 114 | {measureColumnWidth && ( 115 | 120 | {columnsKey.map((columnKey) => ( 121 | { 124 | requestAnimationFrame(() => { 125 | onColumnResize(columnKey, offsetWidth) 126 | }) 127 | }} 128 | > 129 | 130 | 131 | ))} 132 | 133 | )} 134 | 135 | {rows} 136 | 137 | ) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /example/components/virtual-table/helpers/ColumnManager.js: -------------------------------------------------------------------------------- 1 | /* Forked from ant-design 💖 */ 2 | export default class ColumnManager { 3 | constructor(columns) { 4 | this.columns = columns 5 | this._cached = {} 6 | } 7 | 8 | isAnyColumnsFixed() { 9 | return this._cache('isAnyColumnsFixed', () => { 10 | return this.columns.some((column) => !!column.fixed) 11 | }) 12 | } 13 | 14 | isAnyColumnsLeftFixed() { 15 | return this._cache('isAnyColumnsLeftFixed', () => { 16 | return this.columns.some( 17 | (column) => column.fixed === 'left' || column.fixed === true 18 | ) 19 | }) 20 | } 21 | 22 | isAnyColumnsRightFixed() { 23 | return this._cache('isAnyColumnsRightFixed', () => { 24 | return this.columns.some((column) => column.fixed === 'right') 25 | }) 26 | } 27 | 28 | leftColumns() { 29 | return this._cache('leftColumns', () => { 30 | return this.groupedColumns().filter( 31 | (column) => column.fixed === 'left' || column.fixed === true 32 | ) 33 | }) 34 | } 35 | 36 | rightColumns() { 37 | return this._cache('rightColumns', () => { 38 | return this.groupedColumns().filter((column) => column.fixed === 'right') 39 | }) 40 | } 41 | 42 | leafColumns() { 43 | return this._cache('leafColumns', () => this._leafColumns(this.columns)) 44 | } 45 | 46 | leftLeafColumns() { 47 | return this._cache('leftLeafColumns', () => 48 | this._leafColumns(this.leftColumns()) 49 | ) 50 | } 51 | 52 | rightLeafColumns() { 53 | return this._cache('rightLeafColumns', () => 54 | this._leafColumns(this.rightColumns()) 55 | ) 56 | } 57 | 58 | // add appropriate rowspan and colspan to column 59 | groupedColumns() { 60 | return this._cache('groupedColumns', () => { 61 | const _groupColumns = ( 62 | columns, 63 | currentRow = 0, 64 | parentColumn = {}, 65 | rows = [] 66 | ) => { 67 | // track how many rows we got 68 | rows[currentRow] = rows[currentRow] || [] 69 | const grouped = [] 70 | const setRowSpan = (column) => { 71 | const rowSpan = rows.length - currentRow 72 | if ( 73 | column && 74 | !column.children && // parent columns are supposed to be one row 75 | rowSpan > 1 && 76 | (!column.rowSpan || column.rowSpan < rowSpan) 77 | ) { 78 | column.rowSpan = rowSpan 79 | } 80 | } 81 | columns.forEach((column, index) => { 82 | const newColumn = { ...column } 83 | rows[currentRow].push(newColumn) 84 | parentColumn.colSpan = parentColumn.colSpan || 0 85 | if (newColumn.children && newColumn.children.length > 0) { 86 | newColumn.children = _groupColumns( 87 | newColumn.children, 88 | currentRow + 1, 89 | newColumn, 90 | rows 91 | ) 92 | parentColumn.colSpan += newColumn.colSpan 93 | } else { 94 | parentColumn.colSpan++ 95 | } 96 | // update rowspan to all same row columns 97 | for (let i = 0; i < rows[currentRow].length - 1; ++i) { 98 | setRowSpan(rows[currentRow][i]) 99 | } 100 | // last column, update rowspan immediately 101 | if (index + 1 === columns.length) { 102 | setRowSpan(newColumn) 103 | } 104 | grouped.push(newColumn) 105 | }) 106 | return grouped 107 | } 108 | 109 | return _groupColumns(this.columns) 110 | }) 111 | } 112 | 113 | reset(columns) { 114 | this.columns = columns 115 | this._cached = {} 116 | } 117 | 118 | _cache(name, fn) { 119 | if (name in this._cached) { 120 | return this._cached[name] 121 | } 122 | 123 | this._cached[name] = fn() 124 | 125 | return this._cached[name] 126 | } 127 | 128 | _leafColumns(columns) { 129 | const leafColumns = [] 130 | 131 | columns.forEach((column) => { 132 | if (!column.children) { 133 | leafColumns.push(column) 134 | } else { 135 | leafColumns.push(...this._leafColumns(column.children)) 136 | } 137 | }) 138 | 139 | return leafColumns 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /example/components/virtual-table/interface.js: -------------------------------------------------------------------------------- 1 | /* Forked from ant-design-vue */ 2 | import PropTypes from 'vue-types' 3 | import { DEFAULT_LOCALE, DEFAULT_COMPONENTS } from './constants' 4 | 5 | const LEFT = 'left' 6 | const CENTER = 'center' 7 | const RIGHT = 'right' 8 | 9 | export const ColumnProps = { 10 | label: PropTypes.string.isRequired, 11 | prop: PropTypes.string.isRequired, 12 | key: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, 13 | 14 | width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 15 | colSpan: PropTypes.number, 16 | className: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), 17 | 18 | fixed: PropTypes.oneOfType([PropTypes.bool, PropTypes.oneOf([LEFT, RIGHT])]), 19 | align: PropTypes.oneOf([LEFT, CENTER, RIGHT]), 20 | ellipsis: PropTypes.bool.def(false), 21 | 22 | renderHeader: PropTypes.func.def(null), // VNode|({ store, row, column }) => VNode 23 | 24 | sortable: PropTypes.bool.def(false), // TODO 25 | resizable: PropTypes.bool.def(false), // TODO 26 | expandable: PropTypes.bool.def(false) 27 | 28 | // TODO: filter 29 | // filterMultiple: PropTypes.bool, 30 | // filterDropdown: PropTypes.any, 31 | // filterDropdownVisible: PropTypes.bool, 32 | // filterIcon: PropTypes.any, 33 | // filteredValue: PropTypes.array, 34 | 35 | // TODO: sorter 36 | // sorter: PropTypes.oneOfType([PropTypes.boolean, PropTypes.func]), 37 | // defaultSortOrder: PropTypes.oneOf(['ascend', 'descend']), 38 | // sortOrder: PropTypes.oneOfType([ 39 | // PropTypes.bool, 40 | // PropTypes.oneOf(['ascend', 'descend']) 41 | // ]), 42 | // sortDirections: PropTypes.array 43 | } 44 | 45 | export const Store = PropTypes.shape({ 46 | setState: PropTypes.func.isRequired, 47 | getState: PropTypes.func.isRequired, 48 | subscribe: PropTypes.func.isRequired 49 | }) 50 | 51 | export const TableComponents = PropTypes.shape({ 52 | wrapper: PropTypes.string.def('table'), 53 | header: { 54 | wrapper: PropTypes.string.def('thead'), 55 | row: PropTypes.string.def('tr'), 56 | cell: PropTypes.string.def('th') 57 | }, 58 | body: { 59 | wrapper: PropTypes.string.def('tbody'), 60 | row: PropTypes.string.def('tr'), 61 | cell: PropTypes.string.def('td') 62 | }, 63 | 64 | // if use other UI framework, could instead of the following components 65 | // the select => ElSelect 66 | // the check-box => ElCheckBox 67 | // the radio => ElRadio 68 | // the scroll-bar => ElScrollbar 69 | select: PropTypes.string.def('select'), 70 | checkbox: PropTypes.string.def('checkbox'), 71 | radio: PropTypes.string.def('radio'), 72 | scrollbar: PropTypes.string 73 | }).loose 74 | 75 | export const TableLocale = PropTypes.shape({ 76 | filterTitle: PropTypes.string.def('过滤器'), 77 | filterConfirm: PropTypes.any.def('确定'), 78 | filterReset: PropTypes.any.def('重置'), 79 | emptyText: PropTypes.any.def('暂无数据'), 80 | selectAll: PropTypes.any.def('选择全部'), 81 | selectInvert: PropTypes.any.def('反选'), 82 | sortTitle: PropTypes.string.def('排序') 83 | }).loose 84 | 85 | export const TableProps = { 86 | size: PropTypes.oneOf(['default', 'large', 'middle', 'small']).def('default'), 87 | rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, 88 | prefixCls: PropTypes.string.def('vc-table'), 89 | 90 | // Fixed Columns 91 | scroll: PropTypes.object.def({}), 92 | 93 | // Customize 94 | locale: TableLocale.def(DEFAULT_LOCALE), 95 | bordered: PropTypes.bool.def(false), 96 | bodyStyle: PropTypes.any, 97 | showHeader: PropTypes.bool.def(true), 98 | rowClassName: PropTypes.func, // (record: RowModel, index: number) => string 99 | components: TableComponents.def(DEFAULT_COMPONENTS), 100 | 101 | // virtual-scroll 102 | useVirtual: PropTypes.bool.def(true), 103 | rowHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.func]), 104 | overscanCount: PropTypes.number.def(3), 105 | scrollToRowIndex: PropTypes.number.def(0), 106 | 107 | // Data 108 | columns: PropTypes.arrayOf(ColumnProps).isRequired, 109 | dataSource: PropTypes.array.isRequired, 110 | 111 | // Expandable 112 | indentSize: PropTypes.number.def(20), 113 | expandIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), 114 | expandDepth: PropTypes.number, 115 | expandedRowKeys: PropTypes.array, 116 | defaultExpandAllRows: PropTypes.bool, 117 | 118 | // Selection 119 | selectedRowKeys: PropTypes.array, 120 | rowSelectionType: PropTypes.oneOf(['checkbox', 'radio']), 121 | 122 | // Additional Part 123 | title: PropTypes.func, 124 | footer: PropTypes.func 125 | } 126 | -------------------------------------------------------------------------------- /src/mixins/withVirtualization.js: -------------------------------------------------------------------------------- 1 | import { isObject, isValidArray } from '../utils/type' 2 | import { forceScrollTop } from '../utils/dom/scroll' 3 | 4 | export function data() { 5 | return { 6 | scrollToRowIndex: this.scrollToRow, 7 | virtualizedData: [] 8 | } 9 | } 10 | 11 | export function created() { 12 | const { useVirtual, rowHeight, scroll = {} } = this 13 | 14 | if (useVirtual && !scroll.y) { 15 | throw new ReferenceError( 16 | `When open 'useVirtual' mode the property 'scroll.y' must be set as number to fixed header and calculates how many items should be render in table!` 17 | ) 18 | } 19 | 20 | if (useVirtual && !rowHeight) { 21 | throw new ReferenceError( 22 | `When open 'useVirtual' mode the property 'rowHeight' must be set as number to fix the height of per table row!` 23 | ) 24 | } 25 | 26 | this.__prevStartIndex = 0 27 | } 28 | 29 | export const computed = { 30 | virtualized() { 31 | return !!( 32 | this.useVirtual && 33 | this.rowHeight && 34 | this.scroll && 35 | this.scroll.y && 36 | typeof this.scroll.y === 'number' 37 | ) 38 | }, 39 | 40 | currentDataSource() { 41 | if (isObject(this.expandable)) { 42 | return this.entireDataSource || [] 43 | } 44 | 45 | return this.dataSource 46 | }, 47 | 48 | currentDataLength() { 49 | return isValidArray(this.currentDataSource) 50 | ? this.currentDataSource.length 51 | : 0 52 | }, 53 | 54 | wrapperSize() { 55 | return this.virtualized ? this.currentDataLength * this.rowHeight : 0 56 | }, 57 | 58 | virtualVisibleItemsSize() { 59 | return this.virtualized ? Math.ceil(this.scroll.y / this.rowHeight) : 0 60 | }, 61 | 62 | wrapperScrollTop() { 63 | return this.virtualized ? this.scrollToRowIndex * this.rowHeight : 0 64 | }, 65 | 66 | maxSliceBlockStep() { 67 | return this.virtualized 68 | ? Math.ceil( 69 | this.wrapperSize / (this.rowHeight * this.virtualVisibleItemsSize) 70 | ) 71 | : 0 72 | } 73 | } 74 | 75 | export const watch = { 76 | dataSource: { 77 | immediate: true, 78 | handler(data) { 79 | if (data && data.length) { 80 | if (~this.scrollToRow) { 81 | this.updateScrollToRowIndex() 82 | } 83 | 84 | this.updateVirtualizedData(true) 85 | } else { 86 | this.virtualizedData = [] 87 | this.entireDataSource = [] 88 | } 89 | } 90 | }, 91 | 92 | entireDataSource(data) { 93 | if (data && data.length) { 94 | this.updateVirtualizedData(true) 95 | } else { 96 | this.virtualizedData = [] 97 | } 98 | }, 99 | 100 | scrollToRow() { 101 | this.updateScrollToRowIndex() 102 | } 103 | } 104 | 105 | export const methods = { 106 | getVisibleRange(offset) { 107 | let start = Math.floor(offset / this.rowHeight) || 0 108 | const N = this.virtualVisibleItemsSize || 0 109 | const MIN_INDEX = 0 110 | let end = start + N || 0 111 | 112 | this.__CURRENT_N_STEP = Math.ceil( 113 | offset / (this.rowHeight * this.virtualVisibleItemsSize) 114 | ) 115 | 116 | if (start < N) { 117 | start = MIN_INDEX 118 | end += N 119 | } else { 120 | start = start - N 121 | end += N 122 | } 123 | 124 | return { 125 | start, 126 | end 127 | } 128 | }, 129 | 130 | updateVirtualizedData(scrollTop = 0, forceUpdate) { 131 | if (scrollTop === true) { 132 | scrollTop = this.__PREV_SCROLL_TOP 133 | forceUpdate = true 134 | } 135 | 136 | const { start, end } = this.getVisibleRange(scrollTop) 137 | const shouldUpdate = 138 | // this.__PREV_START_INDEX !== start || 139 | this.__CURRENT_N_STEP !== this.__PREV__CURRENT_N_STEP 140 | 141 | if (!shouldUpdate && !forceUpdate) { 142 | return 143 | } 144 | 145 | this.__PREV__CURRENT_N_STEP = this.__CURRENT_N_STEP 146 | this.__PREV_START_INDEX = start 147 | this.__PREV_SCROLL_TOP = scrollTop 148 | 149 | this.scrollToRowIndex = start 150 | this.virtualizedData = this.currentDataSource.slice(start, end) 151 | }, 152 | 153 | updateScrollToRowIndex() { 154 | this.$nextTick(() => { 155 | if (this.$ready && ~this.scrollToRow && this.rowHeight) { 156 | const { scrollBodyRef } = this.$refs 157 | 158 | if (scrollBodyRef) { 159 | forceScrollTop(this.scrollToRow * this.rowHeight, scrollBodyRef) 160 | } 161 | } 162 | }) 163 | }, 164 | 165 | renderVirtualizedWrapper(baseTable) { 166 | return ( 167 |
174 |
183 | {baseTable} 184 |
185 |
186 | ) 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/TableCell/index.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable vue/require-default-prop */ 2 | import { OBJECT_PROP } from '../interface' 3 | import { 4 | isArray, 5 | isString, 6 | isNumber, 7 | isObject, 8 | isFunction 9 | } from '../utils/type' 10 | 11 | export default { 12 | name: 'Cell', 13 | 14 | functional: true, 15 | 16 | inject: { 17 | store: OBJECT_PROP, 18 | rowHeight: { 19 | type: [String, Number] 20 | } 21 | }, 22 | 23 | props: { 24 | prefixCls: String, 25 | record: Object, 26 | column: Object, 27 | /** `record` index. Not `column` index. */ 28 | index: Number, 29 | prop: String, 30 | component: String, 31 | children: [Array, Object], 32 | colSpan: Number, 33 | rowSpan: Number, 34 | ellipsis: Boolean, 35 | align: String, 36 | 37 | shouldCellUpdate: Function, 38 | 39 | // Fixed 40 | fixLeft: [Number, Boolean], 41 | fixRight: [Number, Boolean], 42 | firstFixLeft: Boolean, 43 | lastFixLeft: Boolean, 44 | firstFixRight: Boolean, 45 | lastFixRight: Boolean, 46 | 47 | // Additional 48 | /** @private Used for `expandable` with nest tree */ 49 | appendNode: [Object, Array], 50 | rowType: String // 'header' | 'body' | 'footer', 51 | }, 52 | 53 | render(h, { props, injections }) { 54 | const { 55 | index, 56 | column, 57 | prefixCls, 58 | record = {}, 59 | prop = '', 60 | children, 61 | component: Component = 'td', 62 | colSpan, 63 | rowSpan, 64 | fixLeft, 65 | fixRight, 66 | firstFixLeft, 67 | lastFixLeft, 68 | firstFixRight, 69 | lastFixRight, 70 | appendNode, 71 | additionalProps = {}, 72 | ellipsis, 73 | align, 74 | rowType 75 | } = props 76 | const { store, rowHeight } = injections 77 | const isHeader = rowType === 'header' 78 | const cellPrefixCls = `${prefixCls}-cell` 79 | const value = record[prop] 80 | let childNode = children || value || '' 81 | 82 | if (isHeader) { 83 | if (isObject(column) && isFunction(column.renderHeader)) { 84 | childNode = column.renderHeader(h, { 85 | store: store, 86 | row: record, 87 | column, 88 | value, 89 | index 90 | }) 91 | } 92 | } else { 93 | if (isObject(column) && isFunction(column.render)) { 94 | childNode = column.render(h, { 95 | store: store, 96 | value, 97 | row: record, 98 | column, 99 | index 100 | }) 101 | } 102 | } 103 | 104 | if (ellipsis && (lastFixLeft || firstFixRight)) { 105 | childNode = {childNode} 106 | } 107 | 108 | const fixedStyle = {} 109 | const isFixLeft = typeof fixLeft === 'number' 110 | const isFixRight = typeof fixRight === 'number' 111 | 112 | if (isFixLeft) { 113 | fixedStyle.position = 'sticky' 114 | fixedStyle.left = fixLeft + 'px' 115 | } 116 | 117 | if (isFixRight) { 118 | fixedStyle.position = 'sticky' 119 | fixedStyle.right = fixRight + 'px' 120 | } 121 | 122 | const alignStyle = {} 123 | if (align) { 124 | alignStyle.textAlign = align 125 | } 126 | 127 | let title 128 | const ellipsisConfig = ellipsis === true ? { showTitle: true } : ellipsis 129 | 130 | if (ellipsisConfig && (ellipsisConfig.showTitle || isHeader)) { 131 | if (childNode && isArray(childNode) && childNode.length === 1) { 132 | childNode = childNode[0] 133 | } 134 | 135 | if (typeof childNode === 'string' || typeof childNode === 'number') { 136 | title = childNode.toString() 137 | } 138 | 139 | if (!isString('' + title)) { 140 | title = record[prop] || '' 141 | } 142 | } 143 | 144 | if (colSpan === 0 || rowSpan === 0) { 145 | return null 146 | } 147 | 148 | const componentProps = { 149 | attrs: { 150 | title, 151 | ...additionalProps, 152 | colSpan: colSpan !== 1 ? colSpan : null, 153 | rowSpan: rowSpan !== 1 ? rowSpan : null 154 | }, 155 | class: [ 156 | cellPrefixCls, 157 | column ? [column.class, column.className] : [], 158 | { 159 | [`${cellPrefixCls}-fix-left`]: isFixLeft, 160 | [`${cellPrefixCls}-fix-left-first`]: firstFixLeft, 161 | [`${cellPrefixCls}-fix-left-last`]: lastFixLeft, 162 | [`${cellPrefixCls}-fix-right`]: isFixRight, 163 | [`${cellPrefixCls}-fix-right-first`]: firstFixRight, 164 | [`${cellPrefixCls}-fix-right-last`]: lastFixRight, 165 | [`${cellPrefixCls}-ellipsis`]: ellipsis, 166 | [`${cellPrefixCls}-with-append`]: appendNode 167 | } 168 | ], 169 | style: { 170 | ...additionalProps.style, 171 | ...alignStyle, 172 | ...fixedStyle, 173 | height: !isHeader && isNumber(+rowHeight) ? rowHeight + 'px' : rowHeight 174 | }, 175 | ref: null 176 | } 177 | 178 | return ( 179 | 180 | {appendNode} 181 | {childNode} 182 | 183 | ) 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/TableBody/BodyRow.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable vue/require-default-prop */ 2 | import { 3 | noop, 4 | isObject, 5 | isNumber, 6 | isFunction, 7 | isValidArray 8 | } from '../utils/type' 9 | import TableCell from '../TableCell/index' 10 | import { renderExpandIcon } from '../utils/expand' 11 | import { TableProps, ARRAY_PROP, OBJECT_PROP } from '../interface' 12 | 13 | function getSpan(row, index, span) { 14 | if (isFunction(span)) { 15 | span = span(row, index) 16 | } 17 | 18 | if (isNumber(span)) { 19 | return span 20 | } 21 | 22 | return 1 23 | } 24 | 25 | export default { 26 | name: 'BodyRow', 27 | 28 | // functional: true, 29 | 30 | inheritAttrs: false, 31 | 32 | inject: { 33 | store: OBJECT_PROP, 34 | prefixCls: TableProps.prefixCls, 35 | expandable: OBJECT_PROP, 36 | rowSelection: OBJECT_PROP, 37 | rowClassName: TableProps.rowClassName 38 | // isSelectionMode: Boolean, 39 | // isExpansionMode: Boolean, 40 | }, 41 | 42 | props: { 43 | index: Number, 44 | rowKey: [String, Number], 45 | record: Object, 46 | columnsKey: ARRAY_PROP, 47 | recordKey: [String, Number], 48 | expandedKeys: Array, 49 | rowComponent: String, 50 | cellComponent: String, 51 | fixedInfoList: ARRAY_PROP, 52 | flattenColumns: TableProps.columns, 53 | childrenColumnName: String 54 | }, 55 | 56 | render(h /* { props, injections } */) { 57 | const { 58 | record, 59 | index, 60 | rowKey, 61 | columnsKey, 62 | expandedKeys, 63 | rowComponent: RowComponent, 64 | cellComponent, 65 | fixedInfoList, 66 | flattenColumns, 67 | childrenColumnName 68 | } = this.$props 69 | 70 | const { store, prefixCls, expandable, rowClassName, rowSelection } = this // .injections 71 | 72 | const hasNestChildren = 73 | childrenColumnName && record && isValidArray(record[childrenColumnName]) 74 | let expanded = false 75 | let onExpand = noop 76 | let indent = 0 77 | 78 | if (isObject(expandable)) { 79 | indent = record.__depth || 0 80 | expanded = expandedKeys.includes(rowKey) 81 | onExpand = (record, event) => { 82 | store.toggleRowExpansion(record, undefined, event) 83 | } 84 | } 85 | 86 | let selected = null 87 | let onClick = noop 88 | 89 | if (rowSelection) { 90 | selected = store.isRowSelected(rowKey) 91 | onClick = (event) => store.toggleRowSelection(record, event) 92 | } 93 | 94 | let computeRowClassName 95 | 96 | if (typeof rowClassName === 'string') { 97 | computeRowClassName = rowClassName 98 | } else if (typeof rowClassName === 'function') { 99 | computeRowClassName = rowClassName(record, index, indent) 100 | } 101 | 102 | return ( 103 | 113 | {flattenColumns.map((column, colIndex) => { 114 | const { prop, className: columnClassName } = column 115 | 116 | const key = (columnsKey || [])[colIndex] 117 | const fixedInfo = fixedInfoList[colIndex] 118 | 119 | // ============= Used for nest expandable ============= 120 | let appendCellNode 121 | 122 | if ( 123 | column.expandable && 124 | isObject(expandable) && 125 | isFunction(onExpand) 126 | ) { 127 | appendCellNode = [ 128 | , 134 | (isFunction(expandable.expandIcon) 135 | ? expandable.expandIcon 136 | : renderExpandIcon)(h, { 137 | prefixCls, 138 | expanded, 139 | expandable: isFunction(expandable.rowExpandable) 140 | ? expandable.rowExpandable(record, index, rowKey) 141 | : record.hasChildren || hasNestChildren, 142 | record, 143 | onExpand 144 | }) 145 | ] 146 | } 147 | 148 | return ( 149 | 166 | ) 167 | })} 168 | 169 | ) 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/utils/expand.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import { isArray, isValidArray, isObject } from './type' 3 | 4 | /** 5 | * findAllChildrenKeys 6 | * @param {Tree[]} data 7 | * @param {GetRowKey} getRowKey 8 | * @param {string} childrenColumnName 9 | * @returns {string[]} 10 | */ 11 | export function findValidChildrenKeys(data, getRowKey, childrenColumnName) { 12 | return data.reduce((keys, item, index) => { 13 | const key = getRowKey(item, index) 14 | const children = item[childrenColumnName] 15 | 16 | if (isValidArray(children)) { 17 | return [ 18 | key, 19 | ...findValidChildrenKeys(children, getRowKey, childrenColumnName), 20 | ...keys 21 | ] 22 | } 23 | 24 | return keys 25 | }, []) 26 | } 27 | 28 | /** 29 | * flatten tree data 展开树形数据 30 | * 31 | * @typedef {import("../types/index").RowModel} RowModel 32 | * @typedef {(item: object, index: number)=>string} GetRowKey 33 | * @typedef Tree 34 | * @property {string|number} [key] 35 | * @property {Tree[]} [children] 36 | * 37 | * @param {Tree[]} tree 38 | * @param {string} childrenColumnName 39 | * @param {number} [depth] 40 | * @param {RowModel[]} [flatten] 41 | * @param {RowModel} [parent] 42 | */ 43 | export function flattenData( 44 | tree, 45 | childrenColumnName, 46 | depth = 0, 47 | flatten = [], 48 | parent = null 49 | ) { 50 | for (let index = 0; index < tree.length; index++) { 51 | const item = tree[index] 52 | const children = item[childrenColumnName] 53 | const hasNestChildren = isValidArray(children) 54 | 55 | // record the depth number 56 | if (!item.__depth || item.__depth !== depth) { 57 | item.__depth = depth 58 | } 59 | 60 | // record the index number 61 | if (!item.__index || item.__index !== index) { 62 | item.__index = index 63 | } 64 | 65 | // record the parent ref 66 | if (parent && (!item.__parent || item.__parent !== parent)) { 67 | item.__parent = parent 68 | } 69 | 70 | flatten.push(item) 71 | 72 | if (hasNestChildren) { 73 | flattenData(children, childrenColumnName, depth + 1, flatten, item) 74 | } 75 | } 76 | 77 | return flatten 78 | } 79 | 80 | /** 81 | * flattenMap 记录当前 rowKey 的 tree-paths 82 | * 83 | * @param {Tree[]} tree 84 | * @param {(item: RowModel, index: number)=> string} getRowKey 85 | * @param {string} childrenColumnName 86 | * @param {string[]} prefix 87 | * @param {Object} preset 88 | */ 89 | export function flattenMap( 90 | tree = [], 91 | getRowKey, 92 | childrenColumnName, 93 | prefix = [], 94 | preset = {} 95 | ) { 96 | return tree.reduce((prev, item, index) => { 97 | const children = item[childrenColumnName] 98 | const hasNestChildren = isValidArray(children) 99 | 100 | if (hasNestChildren) { 101 | const key = getRowKey(item, index) 102 | const path = prefix.length ? [...prefix, key] : [key] 103 | 104 | prev[key] = path 105 | 106 | return flattenMap(children, getRowKey, childrenColumnName, path, prev) 107 | } 108 | 109 | return prev 110 | }, preset) 111 | } 112 | 113 | /** 114 | * genExpandedKeyPaths 115 | * @param {RowModel} record 116 | * @param {(row: object, index: number)=>string} getRowKey 117 | * @param {string} childrenColumnName 118 | * @param {*} rowKey 119 | */ 120 | export function genExpandedKeyPaths(record, getRowKey, childrenColumnName) { 121 | let parent = record.__parent 122 | let results = isValidArray(record[childrenColumnName]) 123 | ? [getRowKey(item)] 124 | : [] 125 | 126 | while (isObject(parent)) { 127 | results = [getRowKey(parent), ...results] 128 | parent = parent.__parent 129 | } 130 | 131 | return results 132 | } 133 | 134 | /** 135 | * insertDataFromStart 136 | * @param {[]} data 137 | * @param {number} startIndex 138 | * @param {[]} middle 139 | */ 140 | export function insertDataFromStart( 141 | data, // any[] 142 | startIndex = -1, // number 143 | middle = [] // any[] 144 | ) { 145 | if (!isArray(data) || !isArray(middle)) throw new TypeError() 146 | 147 | const left = data.slice(0, startIndex + 1) 148 | const right = data.slice(startIndex + 1) 149 | 150 | return [...left, ...middle, ...right] 151 | } 152 | 153 | /** 154 | * renderExpandIcon 155 | * @typedef {{ 156 | * prefixCls: string, 157 | record: object, 158 | onExpand: function, 159 | expanded: boolean, 160 | expandable: boolean, 161 | * }} ExpandConfig 162 | * @param {import("@/components/virtualized-table/utils/vue").CreateElement} h 163 | * @param {ExpandConfig} expandConfig 164 | */ 165 | export const renderExpandIcon = ( 166 | h, 167 | { prefixCls, record, onExpand, expanded, expandable } 168 | ) => { 169 | const iconPrefix = `${prefixCls}-row-expand-icon` 170 | 171 | return ( 172 |