系列文章:
- vue+大表单解决方案元素(1) – 概述
- vue+大形状解决元素(二)——分体形状
前言
上一篇文章提到了如何拆分表格。事实上,拆分表单不仅是技术要求,也是业务需要。业务形态分为几个章节。每章 (section
) 都有自己的标题。此时需要锚点(anchor
)来快速找到章节。锚组件本身与形式无关,它只是这个解决方案中的一个辅助工具,所以我在设计中必须考虑锚组件的独立性和通用性。
参考文献和配方要求
寻找参考
ui-element没有anchor组件; antd 没有,但是看起来很好,风格不太好。我只是设法找到一种自己制作的方法。我需要找到成品以供参考。百度百科的锚点组件看起来很不错,交互也比较合理,所以选择了它作为参考。如下图:
确定您的需求
锚点分为两级:主节点和子节点,没有递归实现多级节点(多级节点没有实际意义,缩进和深层次节点样式是一个问题)。当页面滚动时,锚点组件会自动找到合适的节点。当节点在锚面板范围内时,该节点会自动移动到可见范围内;单击某个节点,页面会自动滚动到相应的章节。
具体实施
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;
}
}
这次的效果如下:
其实很满意,虽然离最终效果还有很长的路要走。别担心,更重要的问题稍后会解决。
-
组件
- 如何与锚组件共享数据
sections
? - 表单组件滚动时如何通知锚点?
- 锚点点击后如何通知表单?
定义参数
为了解决上述问题,如果按照常规的组件通信思维,表单组件指定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 组件接收 pageBlock
prop,在附加时解析 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
的元素,并提取创建锚点所需的信息。代码如下:
测试一下效果,如下图:
没问题。现在函数getCurrentSection
仍然为空,它是当前突出显示的锚节点。当锚节点突出显示时?
- 主动点击锚点,锚点会高亮显示
- 页面滚动到特定章节,该章节对应的锚点突出显示
事件处理
现在将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'
})
}
测试结果正常,如下图:
接下来,使用函数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)
},
今天就先到这里,以后还有一些优化升级,留到下一篇文章吧。 感谢阅读,有更正!
发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。