本文基于早先写的 《Vue3 组件二次封装 Element Plus El-Table》。再用 Vue 2
+ Element UI
重新实现一遍。实现思路不变,主要针对 Vue 2
缺少的特性和坑进行处理。存在较多的奇技淫巧,实践需谨慎。
Demo:element-ui-table-proxy-demo
源码:aweikalee/element-ui-table-proxy-demo
Vue 3
+ Element Plus
请前往 《Vue3 组件二次封装 Element Plus El-Table》
主要思路
对于 el-table 的二次封装,我希望是:
- 不对原有的表产生影响(过度阶段 不可能一次性改完所有表)。
- 尽可能保留 el-table 本身的灵活性。
- 增强表格功能的同时,尽可能少地动原先的代码。
对于第1点,则是保留 el-table 组件,创建新组件 MyTable,所有改动在这个新组件内部完成。
对于第2点,就是 MyTable 接受的 props(attrs)
和 slot
应与 el-table 保持一致,且应悉数传递给 el-table。
于是设计的调整方案如下:
1 2 3 4 5 6 7 8 9 10 11 12
| <el-table :data="data"> <el-table-column prop="name" label="名字" /> </el-table>
<MyToolbar :columns.sync="columns" /> <MyTable :data="data" :columns.sync="columns"> <el-table-column prop="name" label="名字" /> </MyTable>
|
新封装的组件 MyTable 所做的事很简单,就是对 slot
重新排序、筛选、修改属性之后,生成一个新的 slot
再交给 el-table 处理。
MyTable 与 MyToolbar 通过父组件上 columns
同步数据。
MyTable 组件的实现
基本结构
template
无法满足需求,需要上 render
。
另外需要将 inheritAttrs
设为 true
,并主要将 $attrs
传给 el-table 组件。否则 $attrs
将会直接绑定在根 DOM 上,不会传给 el-table。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import Table from 'element-ui/lib/table' import 'element-ui/lib/theme-chalk/table.css'
export default { name: 'MyTable', inheritAttrs: false,
render(h) { const children = this.$slots.default
return h( Table, { attrs: { ...this.$attrs, }, }, children ) } }
|
对 VNode 分类
从 slot
中获取到的 VNode
除了我们要的内容外,还会有些其他东西,所以我们需要进行分类。
对于 el-table-column 的 VNode
的处理,将会以 prop
属性作为标识。没有 prop
属性的则不会作为自定义列做处理。
VNode
将会被分成3类:
- el-table-column 且有
prop
属性的 - el-table-column 但没有
prop
属性,但 fixed="left"
的 - 其他的 el-table-column 或不认识的
VNode
第2类,也可以并到第3类中,但我认为分成3类更符合实际需求。
Vue 3
版本封装中使用了计算属性进行实现,但 Vue 2
中 slots
并不具有响应,所以基于 slots
的操作,都需要在 render
中进行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| import TableColumn from 'element-ui/lib/table-column'
export default { render(h) { const slots = { left: [], main: [], other: [], }
this.$slots.default?.forEach((vnode) => { if (isElTableColumn(vnode)) { const { prop, fixed } = getColumnData(vnode) if (prop !== undefined) return slots.main.push(vnode) if (fixed === 'left') return slots.left.push(vnode) } slots.other.push(vnode) })
const children = [slots.left, slots.main, slots.other]
return } }
function isElTableColumn(vnode) { return vnode?.componentOptions?.Ctor?.options?.name === TableColumn.name }
function getColumnData(child: any) { const props = child.componentOptions.propsData ?? {} return { prop: props.prop, label: props.label, fixed: props.fixed, visiable: props.visiable ?? true, } }
|
getColumnData
中除了 visiable
外都是 el-table-column 原有的属性。
/* ... */
代表省略的未做改动的代码
收集列数据
列数据的一手来源,就是 slots.main
。因此需要从 VNode
中提取出我们需要的属性和排列顺序。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| export default {
data() { return { columnsFromSlot: [], columnsFromStorage: [] } },
render(h) { const columnsFromSlot = slots.main.map((vnode) => getColumnData(vnode)) const isSame = isSameColumns(this.columnsFromSlot, columnsFromSlot) if (!isSame) { this.columnsFromSlot = columnsFromSlot }
return } }
function isSameColumns(a, b) { if (a.length !== b.length) return false
const keys = a[0] ? Object.keys(a[0]) : [] for (let i = 0; i < a.length; i += 1) { const _a = a[i] const _b = b[i] const isSame = keys.every((key) => _a[key] === _b[key]) if (!isSame) return false } return true }
|
columnsFromSlot
只保存最原始的列数据,我们对于列的修改,需要保存在另外的地方,后续还要做持久化储存,所以就存在了 columnsFromStorage
中。
由于 Vue 2
的 slots
没有响应,所以我们需要在 render
中收集列数据,并将列数据储存到 data
中。
render
中修改 data
的操作需要小心,任何 data
变更,都会触发 render
重新执行,处理不慎就会陷入死循环。
这里我通过 isSameColumns
来判断是否需要更新数据,有必要更新时,才进行赋值操作。整个过程就和 虚拟 DOM 似的,只不过我们这是 虚拟 DOM 上抽离出来的更精简的 虚拟 DOM。
注:当 isSameColumns
返回 true
时,更新 data
,这会重新执行 render
。
合并列数据
现在我们有两个数据 columnsFromSlot
与 columnsFromStorage
,考虑到持久化储存,储存的列的信息可能不准确(如后期新增/删除了列),取长补短,获得一个渲染时用的完整的列数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| export default {
data() { return { columnsFromSlot: [], columnsFromStorage: [], columnsRender: [] } },
computed: { watchColumns() { return [this.columnsFromSlot, this.columnsFromStorage] }, },
watch: { watchColumns() { const slot = [...this.columnsFromSlot] const storage = [...this.columnsFromStorage]
let res = [] storage.forEach((props) => { const index = slot.findIndex(({ prop }) => prop === props.prop) if (~index) { const propsFromSlot = slot[index] res.push({ ...propsFromSlot, ...props, }) slot.splice(index, 1) } }) this.columnsRender = slot.concat(res) }, }, }
|
生成新的 VNode
前期准备都做好了,现在需要创建传给 el-table 的 slot
了。
我们需要以 columnsRender
的数据创建 refactorSlot
代替 slots.main
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| export default { render(h) {
const refactorySlot = () => { const { main } = slots const columnsProp = main.map((vnode) => getColumnData(vnode).prop)
const refactorySlot = [] this.columnsRender.forEach(({ prop, visiable, fixed }) => { if (!visiable) return
let vnode = main.find((_, index) => prop === columnsProp[index])
if (!vnode) return vnode = cloneVNode(vnode)
vnode.componentOptions = { ...vnode.componentOptions } vnode.componentOptions.propsData = { ...vnode.componentOptions.propsData, }
const propsData = vnode.componentOptions.propsData
if (fixed !== undefined) propsData.fixed = fixed
refactorySlot.push(vnode) })
return refactorySlot }
const children = [slots.left, refactorySlot(), slots.other]
return } }
|
VNode 与 cloneVNode
Vue 2
并没有像 Vue 3
一样直接暴露了 VNode
和 cloneVNode
。所以需要些手段。
源码中存在些许 x instanof VNode
的判断,为避免副作用,所以我们要拿到原始的 VNode
。可以从原型下手,获取 VNode
的构造函数(类)。
cloneVNode
直接从源码里拷一份就行,没啥副作用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| let VNode new Vue({ el: document.createElement('div'), render(h) { const vnode = h('div') VNode = Object.getPrototypeOf(vnode).constructor this.$destroy() }, })
export function cloneVNode(vnode) { const cloned = new VNode( vnode.tag, vnode.data, vnode.children && vnode.children.slice(), vnode.text, vnode.elm, vnode.context, vnode.componentOptions, vnode.asyncFactory ) cloned.ns = vnode.ns cloned.isStatic = vnode.isStatic cloned.key = vnode.key cloned.isComment = vnode.isComment cloned.fnContext = vnode.fnContext cloned.fnOptions = vnode.fnOptions cloned.fnScopeId = vnode.fnScopeId cloned.asyncMeta = vnode.asyncMeta cloned.isCloned = true return cloned }
|
更新列数据
el-table-column 是通过 mounted
与 destroyed
两个生命周期将列数据同步给 el-table 的。但 Vue
会尽可能利用旧的实例,只会更新实例上的数据,而不是销毁重新创建。这就导致 mounted
与 destroyed
无法运行,从而会产生 el-table 中的列数据与 el-table-column 不一致。
故此处通过更新 key
来强制重新创建 el-table。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| export function { data() { return { key: 0,
} },
watch: { columnsRender() { this.key += 1 },
},
render(h) {
return h( Table, { attrs: { ...this.$attrs, key: this.key }, }, children ) } }
|
理想状态是给 children
加 key
,但 Vue 2
缺少的特性与 Element UI
本身机制共同作用下,没法加到 children
上。所以退而求其次加到了 el-table 上。
追加功能
接下来是追加各种功能
Vue 2
中 $refs
并不具有响应,实现自由度远不如 Vue3
。
我选择了将数据同步至父组件的形式,关联 MyTable 与 MyToolbar。虽然这不利于后期对 MyToolbar 进行扩展,但比在 Vue 2
中使用 $refs
靠谱得多。
父组件
1 2
| <MyTable :columns.sync="columns" /> <MyToolbar :columns.sync="columns" />
|
1 2 3 4 5
| export default { data() { columns: [] } }
|
MyTable
接收 columns
,但不直接使用,而是在 columns
产生变更时,覆盖到 columnsFromStorage
上。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| export default { props: { columns: Array },
data() { return {
columnsFromStorage: [], columnsRender: [] } },
watch: { columns(value) { if (value === this.columnsRender) return this.columnsFromStorage = value },
watchColumns() {
this.$emit('update:columns', this.columnsRender) } },
destroyed() { this.$emit('update:columns', []) }, }
|
有人就肯定会问,为什么要绕这么大圈子,直接使用 columns
代替 columnsFormStorage
不就好了吗?答:我希望 columns
不是必须设置的。
注:每次修改 columns
必须整个替换,如果想改 columns
任意值触发更新,需要给在 watch
时加上 deep: true
,并且需要深度对比 columns
与 columnsRender
是否一致。
MyToolbar 只要使用 columns
渲染,有改动通过 $emit('update:columns', value)
进行更新即可。就不细说了。
aweikalee/element-ui-table-proxy-demo 中有简单的实现可以参考。
列数据持久化储存
只要让 columnsStorage
初始化时从 localStorage
中获取,修改时写入 localStorage
即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const storage = { set(key, value) { localStorage.setItem(key, JSON.stringify(value)) },
get(key) { try { return JSON.parse(localStorage.getItem(key)) } catch (error) { return } } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| export default { data() { return { columnsFromStorage: storage.get('columns') ?? [] } },
watch: { columns(value) { if (value === this.columnsRender) return this.columnsFromStorage = value storage.set('columns', value) }, }
}
|
这边 stroage.get('columns')
并没有对表格进行区分储存。可以为 MyTable 增加一个属性 name
,储存与读取时以 name
做为标识以区分。
当然列的设置是可以存服务器,意味着储存都是异步的,读取时请求返回之前,会进行一次渲染,请求返回后会再次渲染,这是需要特别注意的。我选择了请求完成前不渲染 children
,而是使用加载的状态代替。上传则采用了防抖的方式减少与服务器交互。
KeepAlive 保留滚动条位置
尽管 KeepAlive 会缓存 DOM,但 DOM 会从文档上移除。而离开文档的 DOM 是没有 offsetTop
, offsetLeft
, offsetWidth
, offsetHeight
, scrollTop
, scrollWidth
, scrollHeight
, clientWidth
, clientHeight
的,此时访问到的也都是 0
。
在 KeepAlive 中最受影响的就是 scrollTop
和 scrollLeft
,即使重新添加到文档中也无法恢复。所以我们需要在离开文档前保存它们,重新添加到文档后将保存的值再赋值到 DOM 上。
下面介绍两种方法。
方法一
监听 DOM 的 scroll
事件,scroll
事件中记录当前的滚动位置。然后在 onActivated
时重新给 DOM 赋值。
直接拿 Vue 3
版本中实现的 useKeepScroll
改了改。所以看起来这个实现思路并不符合 Vue 2
常规思路。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| export default { mounted() { const { setElement } = useKeepScroll(this) setElement(this.$refs.table?.$refs.bodyWrapper) },
render(h) { return h( Table, { ref: 'table' } children ) } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| function useKeepScroll(instance) { let scrollTop = 0 let scrollLeft = 0 let el
function save() { if (!el) return
scrollTop = el.scrollTop scrollLeft = el.scrollLeft }
function restore() { if (!el) return
el.scrollTop = scrollTop el.scrollLeft = scrollLeft }
onActivated(restore)
let listenedEl = null function removeEventListener() { listenedEl?.removeEventListener('scroll', save) listenedEl = null } function addEventListener() { if (!el) return if (listenedEl === el) return removeEventListener()
listenedEl = el listenedEl?.addEventListener('scroll', save) }
instance.$on('hook:activated', addEventListener) instance.$on('hook:deactivated', removeEventListener)
instance.$on('hook:activated', restore)
return { setElement(value) { el = value addEventListener() } } }
|
setElement
方法是为了万一 DOM 没有复用时,重新设置 DOM。
方法二
KeepAlive 为我们提供了 deactivated
,但它定义就是 DOM 停用后的生命周期,所以 deactivated
运行的时候 DOM 已经从文档中移除了。
我们可能更需要 beforeDeactivate
,但是很可惜,这个 RFC 连 Vue 3
都还没有实装。
当前的代替方案,有那么点取巧。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| function useKeepScroll(instance) { let scrollTop = 0 let scrollLeft = 0 let el
function save() { if (!el) return
scrollTop = el.scrollTop scrollLeft = el.scrollLeft } function restore() { if (!el) return
el.scrollTop = scrollTop el.scrollLeft = scrollLeft }
instance.$on('hook:activated', restore) instance.$on('hook:deactivated', save)
return { setElement(value) { el = value addEventListener() } } }
|
接下来是关键了!
1 2 3 4 5
| <transition> <keep-alive> </keep-alive> </transition>
|
找到使用 KeepAlive 的地方,在外面套一层 Transition 组件,此时 deactivated
就等同于 beforeDeactivate
了。
若你的项目只存在一个 KeepAlive,就非常适合用这种解决方法。
简单解释一下原理:
KeepAlive 组件的 deactivate
方法中,会先将 DOM 从文档中移除,再创建微任务调用组件的 deactivated
。若 VNode
上存在 transition
,移除将会是变为宏任务,那么就会变成先执行微任务中的 onDeactivated
再从文档中移除了。
解决 KeepAlive 恢复时布局错位
el-table 碰上 KeepAlive 时,时不时会出现表格布局错位或是固定列无法渲染的问题。
官方解决方法是,恢复时调用 doLayout
。那么完全可以集成到 MyTable。
Element Plus
没有这个问题
1 2 3 4 5 6 7 8 9 10 11 12
| export function { mounted() { let firstActivated = true this.$on('hook:activated', () => { if (firstActivated) { firstActivated = false return } this.$refs.table?.doLayout() }) } }
|
mounted
后会执行一次 activated
,不必调用 doLayout
。