Vue3 组件二次封装 Element Plus El-Table

16,043 阅读7分钟

公司里后台系统用的 Element UI,有百来个表格(el-table),历史遗留原因都是直接使用 el-table 的。突然有一天,产品说表格要可以自定义列,让用户控制列的显隐固定排序,最好还能持久储存。使得我不得不进行二次封装来解决,那就顺便再轻微增强一下。

Demo:element-plus-table-proxy-demo

源码:aweikalee/element-plus-table-proxy-demo

Vue 2 + Element UI 请前往 《Vue2 组件二次封装 Element UI El-Table》

主要思路

对于 el-table 的二次封装,我希望是:

  1. 不对原有的表产生影响(过度阶段 不可能一次性改完所有表)。
  2. 尽可能保留 el-table 本身的灵活性。
  3. 增强表格功能的同时,尽可能少地动原先的代码。

对于第1点,则是保留 el-table 组件,创建新组件 MyTable,所有改动在这个新组件内部完成。

对于第2点,就是 MyTable 接受的 props(attrs)slot 应与 el-table 保持一致,且应悉数传递给 el-table

于是设计的调整方案如下:

<!-- 调整前 -->
<el-table :data="data">
  <el-table-column prop="name" label="名字" />
  <!-- 此处省略一万个 el-table-column -->
</el-table>

<!-- 调整后 -->
<MyToolbar :table="table" />
<MyTable :data="data" :ref="table">
  <el-table-column prop="name" label="名字" />
  <!-- 此处省略一万个 el-table-column -->
</MyTable>

新封装的组件 MyTable 所做的事很简单,就是对 slot 重新排序、筛选、修改属性之后,生成一个新的 slot 再交给 el-table 处理。

MyTable 会给 MyToolbar 暴露列的数据与修改列数据的接口。(当然你也可以将 MyToolbar 封装 MyTable 内)

MyTable 组件的实现

基本结构

首先是 template 部分(当然你可以用 render/JSX 代替),Vue 中默认会传递所有未识别的属性给最外层的标签,所以我们只需要传一个新的 slot 就可以了。

<el-table>
  <children />
</el-table>

children 就是我们实现的新的 slot,他是 MyTable 内部创建的子组件。他和 slots.default 一样是一个函数,里面返回了 VNode

const slotsOrigin = useSlots()
const children = () => slotsOrigin.default?.()

注:用了 setup 语法

至此,一个保留了 el-table 所有功能的二次封装,就完成了。接下来只需要再加亿点点细节完善一下。

对 VNode 分类

slot 中获取到的 VNode 除了我们要的内容外,还会有些其他东西,所以我们需要进行分类。

对于 el-table-columnVNode 的处理,将会以 prop 属性作为标识。没有 prop 属性的则不会作为自定义列做处理。

VNode 将会被分成3类:

  1. el-table-column 且有 prop 属性的
  2. el-table-column 但没有 prop 属性,但 fixed="left"
  3. 其他的 el-table-column 或不认识的 VNode

第2类,也可以并到第3类中,但我认为分成3类更符合实际需求。

const slotsOrigin = useSlots()

/* 对 slot 进行分类 */
const slots = computed(() => {
  const main = [] // 第1类
  const left = [] // 第2类
  const other = [] // 第3类

  slotsOrigin.default?.()?.forEach((vnode) => {
    if (isElTableColumn(vnode)) {
      // 是 el-table-column 组件

      const { prop, fixed } = vnode.props ?? {}

      // 存在 prop 属性,归第1类
      if (prop !== undefined) return main.push(vnode) 

      // 不存在 prop 属性,但 fixed="left",归第2类
      if (fixed === 'left') return left.push(vnode)
    }

    // 其他,归第3类
    other.push(vnode)
  })

  return {
    main,
    left,
    other,
  }
})

/* 用于判断 vnode 是否是 el-table-column 组件 */
function isElTableColumn(vnode) {
  return (vnode.type as Component)?.name === 'ElTableColumn'
}

/* 分类好的 slot 按如下顺序挂载 */
const children = () => [slots.value.left, slots.value.main, slots.value.other]

收集列数据

列数据的一手来源,就是 slots.main。因此需要从 VNode 中提取出我们需要的属性和排列顺序。

const columns = reactive({
  slot: computed(() => 
    slots.value.main.map(({ props }) => ({
      prop: props.prop, // 标识
      label: props.label, // 列名称
      fixed: props.fixed, // 固定位置
      visiable: props.visiable ?? true // 是否可见
    })),
    
    storage: [],
  ),
})

除了 visiable 外都是 el-table-column 原有的属性。 columns.slot 只保存最原始的列数据,我们对于列的修改,需要保存在另外的地方,后续还要做持久化储存,所以就存在了 columns.storage 中。

对外提供一个修改 columns.storage 的方法。

function updateColumns(value) {
  columns.storage = value
}

合并列数据

现在我们有两个数据 columns.slotcolumns.storage,考虑到持久化储存,储存的列的信息可能不准确(如后期新增/删除了列),取长补短,获得一个渲染时用的完整的列数据。

const columns = reactive({
  // 其他同上 略

  render: computed(() => {
    const slot = [...columns.slot]
    const storage = [...columns.storage]

    const res = []
    storage.forEach((props) => {
      const index = slot.findIndex(({ prop }) => prop === props.prop)
      if (~index) {
        const propsFromSlot = slot[index]
        res.push({
          ...propsFromSlot, // 可能新增属性 所以用 slot 的数据打个底
          ...props, 
        })
        slot.splice(index, 1) // storage 里不存在的列
      }
      // slot 中没有找到的 则会被过滤掉
    })
    res.push(...slot)

    return res
  })
})

生成新的 VNode

前期准备都做好了,现在需要创建传给 el-tableslot 了。

我们需要以 columns.render 的数据创建 refactorSlot 代替 slots.main

const refactorSlot = computed(() => {
  const { main } = slots.value

  const refactorySlot = []

  columns.render.forEach(({ prop, visiable, fixed }) => {
    // 设置为不可见的 则跳过(即不渲染)
    if (!visiable) return

    // 从 slots.main 中寻找对应 prop 的 VNode
    const vnode = main.find((vnode) => prop === vnode.props?.prop)
    if (!vnode) return

    // 克隆 VNode 并修改部分属性
    const cloned = cloneVNode(vnode, {
      fixed,
      // 这里可以根据需求 修改属性,非常灵活
    })

    refactorySlot.push(cloned)
  })

  return refactorySlot
})

最后合并所有 slot ,就完成了 children 的创建

const children = () => [slots.value.left, refactorSlot.value, slots.value.other]

更新列数据

el-table-column 是通过 onMountedonUnmounted 两个生命周期将列数据同步给 el-table 的。但 Vue 会尽可能利用旧的实例,只会更新实例上的数据,而不是销毁重新创建。这就导致 onMountedonUmmounted 无法运行,从而会产生 el-table 中的列数据与 el-table-column 不一致。

故此处通过更新 key 来强制重新创建 el-table-column

<el-table>
  <children :key="key" />
</el-table>
const key = ref(0)
watch(refactorSlot, () => (key.value += 1))

暴露接口

<el-table ref="table">
  <children />
</el-table>
const table = ref()
defineExpose({
  // 提供访问 el-table 途径
  table,

  // 列的数据
  columns: computed(() => readonly(columns.render)),

  // 修改列的数据(要求全覆盖)
  updateColumns(value) {
    columns.storage = value
  }
})

至此,我们主体结构就搭完了,完整代码可以到 aweikalee/element-plus-table-proxy-demo 查看。

追加功能

接下来就是追加各种功能。

MyToolbar 组件的实现

MyTable 对外提供了 columnsupdateColumns,通过它们我们可以根据需求实现一个自定义列的显示、固定和排序。由于这边怎么实现都行,就不细说了。aweikalee/element-plus-table-proxy-demo 中有简单的实现可以参考。

列数据持久化储存

只要让 columns.storage 初始化时从 localStorage 中获取,修改时写入 localStorage 即可。

// 实现一个简易版本,意思一下。
const storage = {
  set(key, value) {
    localStorage.setItem(key, JSON.stringify(value))
  },

  get(key) {
    try {
      return JSON.parse(localStorage.getItem(key))
    } catch (error) {
      return
    }
  }
}
const columnsFormStorage = ref(
  storage.get('columns') ?? []
)

const columns = reactive({
  // 其他不变 略

  storage: computed({
    get() {
      return columnsFormStorage.value
    },
    set(value) {
      columnsFormStorage.value = 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 中最受影响的就是 scrollTopscrollLeft,即使重新添加到文档中也无法恢复。所以我们需要在离开文档前保存它们,重新添加到文档后将保存的值再赋值到 DOM 上。

下面介绍两种方法。

方法一

监听 DOM 的 scroll 事件,scroll 事件中记录当前的滚动位置。然后在 onActivated 时重新给 DOM 赋值。

<el-table ref="table"></el-table>
const table = ref()
const scrollRef = computed(() => {
  // el-table 中滚动的容器
  return table.value?.$refs.bodyWrapper
})
useKeepScroll(scrollRef)
function useKeepScroll(el) { // 这是一个 ref 对象
  let scrollTop = 0
  let scrollLeft = 0

  /* 保存滚动条位置 */
  function save() {
    if (!el.value) return

    scrollTop = el.value.scrollTop
    scrollLeft = el.value.scrollLeft
  }

  /* 恢复滚动条位置 */
  function restore() {
    if (!el.value) return

    el.value.scrollTop = scrollTop
    el.value.scrollLeft = scrollLeft
  }

  /* 在组件恢复时 恢复滚动条位置 */
  onActivated(restore)

  /* 添加、移除 scroll 的监听 */
  let listenedEl = null
  function removeEventListener() {
    listenedEl?.removeEventListener('scroll', save)
    listenedEl = null
  }
  function addEventListener() {
    if (!el.value) return
    if (listenedEl === el.value) return
    removeEventListener()

    listenedEl = el.value
    listenedEl?.addEventListener('scroll', save)
  }

  watch(el, addEventListener)
  onActivated(addEventListener)
  onDeactivated(removeEventListener)
}

方法二

KeepAlive 为我们提供了 onDeactivated ,但它定义就是 DOM 停用后的生命周期,所以 onDeactivated 运行的时候 DOM 已经从文档中移除了。

我们可能更需要 onBeforeDeactivate,但是很可惜,该 RFC 还没有实装。

当前的代替方案,有那么点取巧。

function useKeepScroll(el) {
  let scrollTop = 0
  let scrollLeft = 0

  function save() {
    if (!el.value) return

    scrollTop = el.value.scrollTop
    scrollLeft = el.value.scrollLeft
  }
  function restore() {
    if (!el.value) return

    el.value.scrollTop = scrollTop
    el.value.scrollLeft = scrollLeft
  }

  onActivated(restore) // 恢复
  onDeactivated(save) // 保存
}

接下来是关键了!

<Transition>
  <KeepAlive>
    <!-- 内容 略 -->
  </KeepAlive>
</Transition>

找到使用 KeepAlive 的地方,在外面套一层 Transition 组件,此时 onDeactivated 就等同于 onBeforeDeactivate 了。

若你的项目只存在一个 KeepAlive,就非常适合用这种解决方法。

简单解释一下原理:

KeepAlive 组件的 deactivate 方法中,会先将 DOM 从文档中移除,再创建微任务调用组件的 onDeactivated。若 VNode 上存在 transition,移除将会是变为宏任务,那么就会变成先执行微任务中的 onDeactivated 再从文档中移除了。