vue+element大型表单解决方案(3)–锚点组件(上)

系列文章:

  • vue+element大型表单解决方案(1)–概览
  • vue+element大型表单解决方案(2)–表单拆分

前言

上一篇提到如何拆分表单,事实上拆分表单不仅仅是技术上的需求,也是业务上的需要。业务上将表单分成多个章节,每个章节(section)有自己的标题,此时就需要有锚点(anchor)能快速定位到该章节上。锚点组件本身和表单并无关系,只是在这个解决方案中是一个辅助工具,因此设计上我要考虑锚点组件的独立性和通用性。

参考和制定需求

寻找参考

element-ui并没有锚点组件;antd有但是看着比较弱,样式也不太行,只能想办法自己造,需要寻找一个成品参考借鉴一下。百度百科的锚点组件看着挺不错,交互也很合理,因此选它作为参考。如下图所示:

image.png

确定需求

锚点分为主节点和子节点两级,没有递归实现多级节点(多级节点实用意义并不大,且深层次的节点缩进和样式都是问题)。当页面滚动时,锚点组件会自动定位到相应的节点上去,当节点处于锚点面板的边界时,节点还会自动向可视范围内移动;点击节点,页面自动滚动至相应的章节。

具体实现

绘制UI

创建anchor组件,在页面引入。由于一开始我就决定只实现两层节点,因此数据结构并没有使用children递归。anchor的template如下:

<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>

 

data部分如下:

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.png

基本满意,虽然离最终效果还有不少差距,先别着急,下面要解决更重要的问题。

  1. 表单组件如何与anchor组件共用sections数据?
  2. 表单组件滚动如何通知anchor?
  3. anchor点击后怎么通知表单?

确定传参

要解决上述问题,如果按常规组件通信思维,表单组件定义sections数据供自身渲染以及传给anchor组件;表单组件绑定滚动事件,滚动时计算出当前应当active的锚点节点,将该activeNode作为props传递给anchor组件进行激活状态的渲染;anchor组件绑定点击事件,通过$emit通知表单组件滚动至点击节点相应的章节。

这么设计的话,最大的问题就是代码逻辑分散,表单组件里要绑定滚动事件,还要响应锚点的点击事件进行滚动,这些与表单自身业务并不相关。可不可以这些活全部在锚点组件内实现呢?

我的解决方案是将表单的dom作为props传给anchor组件,所有工作全部在anchor组件内完成,表单组件只负责引入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组件接收pageBlockprops,在mounted的时候解析pageBlock中的data-section元素,并且给页面绑定scroll事件。但是由于父子组件生命周期先后的原因,anchor组件mounted的时候,主表单还没有mounted,此时传递过来的pageBlock是null,无法从中解析数据和绑定事件。这里先做一个简单的处理,即主表单 <anchor :page-block="pageBlock" />改为 <anchor v-if="pageBlock" :page-block="pageBlock" />,确保pageBlock引用了表单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的元素,并提取出渲染锚点需要的信息,代码如下:

getSectionsData(pageBlock) {
  let mainIndex = 0 // 主节点的数字序号
  let subIndex = 0 // 子节点的数字序号
  // 查询出data-section的节点,并转化成数组
  const sections = Array.from(pageBlock.querySelectorAll('[data-section]'))
  // map转化节点数组为最终的数据
  return sections.map((item, index) => {
    let ismain = false
    if ('ismain' in item.dataset) {
      ismain = true
      mainIndex++
      // 遇到新的主节点,重置subIndex
      subIndex = 0
    } else {
      subIndex++
    }
    return {
      ismain,
      index: ismain ? mainIndex : `${mainIndex}.${subIndex}`,
      label: item.dataset.section
    }
  })
}
 

测试下效果,如下图:

image.png

没有问题。现在还空着getCurrentSection函数没有实现,也就是当前高亮的锚点节点。锚点节点什么时候高亮呢?

  1. 主动点击某个锚点,该锚点高亮
  2. 页面滚动处于某个章节,该章节对应的锚点高亮

事件处理

现在给data中增加currentSection: ''响应式数据,同时修改template代码,增加高亮的样式和绑定点击锚点事件,代码如下:

<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.png

下面实现getCurrentSection函数,即左侧表单滚动的事件处理函数。首先要弄清楚,怎么确定当前的章节处于视窗的顶部?我们可以获取到当前页面的scrollTop,即页面滚动卷起来的距离,然后依次和各章节原本距离顶部的距离(offsetTop)进行比较,从而确定当前谁处于顶部位置。因此在getSectionsData函数最后返回值里,增加top属性,代码如下:

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)
},
 

今天先到这里,后续还有一些优化和可升级的地方,留在下一篇完成。谢谢您的阅读,欢迎提出指正意见!

(0)
上一篇 2021年5月26日 下午6:44
下一篇 2021年5月26日 下午7:00

相关推荐

发表回复

登录后才能评论