Code前端首页关于Code前端联系我们

vue+大表单解决元素(三)-锚定组件(第一部分)

terry 2年前 (2023-09-08) 阅读数 125 #Vue

系列文章:

  • vue+大表单解决方案元素(1) – 概述
  • vue+大形状解决元素(二)——分体形状

前言

上一篇文章提到了如何拆分表格。事实上,拆分表单不仅是技术要求,也是业务需要。业务形态分为几个章节。每章 (section) 都有自己的标题。此时需要锚点(anchor)来快速找到章节。锚组件本身与形式无关,它只是这个解决方案中的一个辅助工具,所以我在设计中必须考虑锚组件的独立性和通用性。

参考文献和配方要求

寻找参考

ui-element没有anchor组件; antd 没有,但是看起来很好,风格不太好。我只是设法找到一种自己制作的方法。我需要找到成品以供参考。百度百科的锚点组件看起来很不错,交互也比较合理,所以选择了它作为参考。如下图:

image.pngimage.png

确定您的需求

锚点分为两级:主节点和子节点,没有递归实现多级节点(多级节点没有实际意义,缩进和深层次节点样式是一个问题)。当页面滚动时,锚点组件会自动找到合适的节点。当节点在锚面板范围内时,该节点会自动移动到可见范围内;单击某个节点,页面会自动滚动到相应的章节。

具体实施

UI图片

创建锚点组件并将其引入到页面中。由于我从一开始就决定只实现两级节点,因此数据结构不使用子递归。锚点模板如下:

<div class="anchor">
    <div v-for="node in sections" :key="node.label" :label="node.index"
         :class="[node.ismain?'anchor-main-node':'anchor-sub-node']">
      {{ node.label }}
    </div>
</div>

 

数据部分如下:

sections: [
    { label: '基础信息', ismain: true, index: '1' },
    { label: '个人信息', index: '1.1' },
    { label: '其他信息', index: '1.2' },
    { label: '高级信息', ismain: true, index: '2' },
    { label: 'xxx信息', index: '2.1' }
]
 

scss如下:

.anchor-main-node {
  position: relative;
  margin: 8px 0;
  font-size: 14px;
  font-weight: bold;
  color: #555;
  cursor: pointer;
  &::before {
    content: attr(label);
    margin-left: 6px;
    margin-right: 6px;
  }
}
.anchor-sub-node {
  position: relative;
  margin: 8px 0;
  padding-left: 22px;
  font-size: 14px;
  color: #666;
  cursor: pointer;
  &::before {
    content: attr(label);
    margin-right: 4px;
  }
}
 

这次的效果如下:

image.pngimage.png

其实很满意,虽然离最终效果还有很长的路要走。别担心,更重要的问题稍后会解决。

    组件
  1. 如何与锚组件共享数据sections
  2. 表单组件滚动时如何通知锚点?
  3. 锚点点击后如何通知表单?

定义参数

为了解决上述问题,如果按照常规的组件通信思维,表单组件指定sections数据用于渲染自身并传递锚点组件;表单组件绑定滚动事件,并计算滚动时现在应该处于活动状态的锚节点。传递 activeNode 作为锚点组件的 prop 来渲染活动状态;锚点组件绑定到单击事件,并通过 $emit 通知表单组件滚动到单击节点的相应章节。

这样的设计,最大的问题就是代码逻辑比较分散。表单组件必须绑定scroll和scroll事件来响应锚点的点击事件。这与表格的业务无关。所有这些活动都可以在锚组件中完成吗?

我的解决方案是将表单 dom 作为 prop 传递给锚组件。所有工作都是在锚组件上完成的。 form组件只负责引入anchor组件并传递dom结构。由于在表单组件中翻译sections数据不太方便(无法将子表单组件传递并插入到合适的位置),所以我放弃使用sections数据,定义了一套规则来增加特异性。 div 的元素。属性data-data-section表明这是一个章节(section),并且节点anchor必须在锚点中给出。 data-ismain表示是父节点,没有该属性是子节点。就变量名称而言,section表示章节,anchor表示锚点。两者一一对应,数据一致。

表单组件中的实现代码如下:

<div ref="pageBlock" class="form-wrapper">
    <el-button type="primary" @click="handleSave">保存</el-button>
    <div data-section="基础信息" data-ismain></div>
    <div data-section="个人信息"></div>
    <form1 ref="form1" :data="formDataMap.form1" />
    <div data-section="其他信息"></div>
    <div style="width:300px;height:100px;backgoround:#ccc;">占位符</div>
    <div data-section="高级信息" data-ismain></div>
    <div data-section="公司信息"></div>
    <form2 ref="form2" :data="formDataMap.form2" />
    <div class="anchor-wrapper">
        <anchor :page-block="pageBlock" />
    </div>
</div>
 

scss如下:

.form-wrapper {
  position: relative;
  width: 100%;
  // 设置小的高度,为了更容易产生滚动进行测试
  height: 280px; 
  padding: 16px;
  overflow-y: auto;
  ::v-deep input {
    width: 280px;
  }
}
.anchor-wrapper {
  position: fixed;
  right: 0px;
  width: 220px;
  height: 300px;
  top: 30%;
  transform: translate(0, -50%);
}
div[data-section] {
  position: relative;
  font-size: 14px;
  font-weight: bold;
  color: #5c658d;
  padding: 14px 0;
  margin-left: 34px;
  &::before {
    content: attr(data-section);
  }
}
div[data-ismain] {
  font-size: 16px;
  font-weight: bold;
  margin-left: 28px;
  &::after {
    content: '';
    position: absolute;
    left: -16px;
    top: 14px;
    width: 4px;
    height: 16px;
    background: #5c658d;
    border-radius: 2px;
}
 

JS部分代码如下:

// data部分增加pageBlock:null
mounted() {
    this.pageBlock = this.$refs['pageBlock']
}
 

数据分析

anchor 组件接收 pageBlockprop,在附加时解析 pageBlock 中的 data-section 元素,并将 scroll 事件绑定到页面。然而,由于父子组件生命周期的顺序,当锚组件安装完毕后,主窗体还没有安装。此时传递的pageBlock为零,无法解析绑定数据和事件。这里我们做了一个简单的处理,就是将主窗体 <anchor :page-block="pageBlock" />改为 <anchor v-if="pageBlock" :page-block="pageBlock" />,并保证pageBlock在渲染anchor组件之前引用了dom结构。
接下来,修改锚点组件以接受 pageBlock 属性并添加 mounted 钩子函数。代码如下:

props: {
    pageBlock: HTMLElement
},
data() {
    return {
      sections: []
    }
},
mounted() {
    this.sections = this.getSectionsData(this.pageBlock)
    this.pageBlock.addEventListener('scroll', this.handlePageScroll)
},
beforeDestroy() {
    this.pageBlock.removeEventListener('scroll', this.handlePageScroll)
},
methods: {
    // 从pageBlock中获取章节信息
    getSectionsData(pageBlock) {
        
    },
    // 页面的滚动事件处理函数
    handlePageScroll(e) {
      e.stopPropagation()
      this.currentSection = this.getCurrentSection()
    },
    // 计算出当前滚动到的章节
    getCurrentSection() {

    }
}
 

接下来要做的就是实现功能getSectionsData。该函数的任务是从 dom 结构中取出包含 data-section 的元素,并提取创建锚点所需的信息。代码如下:

测试一下效果,如下图:

image.pngimage.png

没问题。现在函数getCurrentSection仍然为空,它是当前突出显示的锚节点。当锚节点突出显示时?

  1. 主动点击锚点,锚点会高亮显示
  2. 页面滚动到特定章节,该章节对应的锚点突出显示

事件处理

现在将data响应数据添加到currentSection: '',一起修改模板代码,添加高亮样式和点击锚点事件绑定。代码如下:

<div class="anchor">
    <div v-for="node in sections" :key="node.label" :label="node.index"
         :class="[node.ismain?'anchor-main-node':'anchor-sub-node',{'anchor-node-active':currentSection===node.label}]"
         @click="handleClick(node.label)">
      {{ node.label }}
    </div>
</div>

.anchor-node-active {
    color: #38f;
}
 

对应点击事件处理函数如下:

handleClick(label) {
  // 设置当前锚点对应章节
  this.currentSection = label
  // 查找到到该章节的dom
  const section = this.pageBlock.querySelector(`[data-section=${label}]`)
  // 平滑滚动至该章节
  section.scrollIntoView({
      behavior: 'smooth',
      block: 'start'
  })
}
 

测试结果正常,如下图:

image.pngimage.png

接下来,使用函数getCurrentSection,这是一个事件处理函数,可将表单向左滚动。首先,我们如何确保当前章节位于窗口顶部?我们可以从当前页面获取scrollTop,即页面滚动的距离,然后将其与距离每章顶部的原始距离(offsetTop)进行比较,以确定当前谁在顶部。因此,在函数getSectionsData的最后一个返回值中,添加上述属性,代码如下:

return {
      ismain,
      index: ismain ? mainIndex : `${mainIndex}.${subIndex}`,
      label: item.dataset.section,
      // 增加top属性
      top: item.offsetTop
}
 

特殊惩罚代码如下,注释为逻辑描述:

getCurrentSection() {
  // 当前表单的的scrollTop
  const currentScrollTop = this.pageBlock.scrollTop
  const sections = this.sections
  const length = sections.length
  let currentSection
  // 依次和各节点原先的offsetTop进行比较
  for (let i = 0; i < length; i++) {
    // 如果scrollTop正好和某节点的offsetTop相等
    // 或者scrollTop介于当前判断的节点和下一个节点之间
    // 由于需要下一个节点,所以当前节点不能是最后一个节点
    if (currentScrollTop === sections[i].top ||
      (i < length - 1 &&
        currentScrollTop > sections[i].top &&
        currentScrollTop < sections[i + 1].top)) {
      currentSection = sections[i].label
      break
    } else if (i === length - 1) {
      // 如果判断到一个节点,只要 scrollTop大于节点的offsetTop即可
      if (currentScrollTop > sections[i].top) {
        currentSection = sections[i].label
        break
      }
    }
  }
  return currentSection
}
 
性能优化

因为滚动事件的触发非常频繁,而且scrollIntoView在锚点点击时也会产生事件,所以事件处理函数必须是防抖的,这里使用了lodash.debounce。替换以下代码:

mounted() {
    this.sections = this.getSectionsData(this.pageBlock)
    // 初始化时就尝试获取当前章节
    this.currentSection = this.getCurrentSection()
    this.debouncedPageScrollHandler = debounce(this.handlePageScroll, 100)
    this.pageBlock.addEventListener('scroll', this.debouncedPageScrollHandler)
},
beforeDestroy() {
    this.pageBlock.removeEventListener('scroll', this.debouncedPageScrollHandler)
},
 

今天就先到这里,以后还有一些优化升级,留到下一篇文章吧。 感谢阅读,有更正!

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

热门