系列文章:
前言
上一篇提到如何拆分表单,事实上拆分表单不仅仅是技术上的需求,也是业务上的需要。业务上将表单分成多个章节,每个章节(section
)有自己的标题,此时就需要有锚点(anchor
)能快速定位到该章节上。锚点组件本身和表单并无关系,只是在这个解决方案中是一个辅助工具,因此设计上我要考虑锚点组件的独立性和通用性。
参考和制定需求
寻找参考
element-ui并没有锚点组件;antd有但是看着比较弱,样式也不太行,只能想办法自己造,需要寻找一个成品参考借鉴一下。百度百科的锚点组件看着挺不错,交互也很合理,因此选它作为参考。如下图所示:
确定需求
锚点分为主节点和子节点两级,没有递归实现多级节点(多级节点实用意义并不大,且深层次的节点缩进和样式都是问题)。当页面滚动时,锚点组件会自动定位到相应的节点上去,当节点处于锚点面板的边界时,节点还会自动向可视范围内移动;点击节点,页面自动滚动至相应的章节。
具体实现
绘制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;
}
}
复制代码
此时的效果如下:
基本满意,虽然离最终效果还有不少差距,先别着急,下面要解决更重要的问题。
- 表单组件如何与anchor组件共用
sections
数据? - 表单组件滚动如何通知anchor?
- 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组件接收pageBlock
props,在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
}
})
}
复制代码
测试下效果,如下图:
没有问题。现在还空着getCurrentSection
函数没有实现,也就是当前高亮的锚点节点。锚点节点什么时候高亮呢?
- 主动点击某个锚点,该锚点高亮
- 页面滚动处于某个章节,该章节对应的锚点高亮
事件处理
现在给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'
})
}
复制代码
测试效果正常,如下图:
下面实现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)
},
复制代码
今天先到这里,后续还有一些优化和可升级的地方,留在下一篇完成。谢谢您的阅读,欢迎提出指正意见!