前言
前段时间,产品在验收时给我们加了一个需求,用户提交表单时,要滚动定位到校验出错的地方,就像这样:
我说:做这个应该会比较麻烦,主要是涉及到多个子表单组件通信,还要计算元素在页面的位置,还要写动画,大概 8 工时,要不不做了?
她柳眉皱起,考虑了很久,道:不行!用户体验至关重要,你尽力做嘛,多预估点开发时间就行。
我面露难色,很勉强地说:好吧,哎,那就上线时间往后面延一天吧,我尽量把这个功能做出来。
项目经理知道要加这个需求后,说:好,你加油!就延后一天上线吧,
我心道:小样儿,给我加需求,我一小时就做完的东西,骗你们说八小时你还真信,又多一天时间学(mo)习(yu)了,耶!
需求分析
这种需求主要是用在超级长的表单,长到超过一个页面。
要实现这个功能需要用到一个 Web API,Element.scrollIntoView
Element
接口的scrollIntoView()方法会滚动元素的父容器,使被调用scrollIntoView()的元素对用户可见。
很显然,我们只需要在表单提交校验不通过时,找到整个页面上第一个校验出错的元素,然后调这个元素的 scrollIntoView
方法,就能实现这个功能。
scrollIntoView 这个 api 帮我们把计算元素位置和动画的事情做了,但是怎么找到页面上第一个校验出错的元素呢?
以 element-ui
的 el-form
组件为例,打开控制台发现,校验出错时元素会被加上 is-error
class,很显然,我们可以调用元素的 getElementsByClassName
方法,找到所有校验出错的元素,再取第一个就行。
现在就差最后一个问题了,多个子组件表单和父组件的通信如何实现?
vue 开发中,表单的校验一般是写到子组件的 validate 方法上,然后在父组件上给子组件定义 ref,然后通过
this.$refs
调用子组件的 validate 方法(element-ui就是这么做的)。
代码实现
以上面 gif 图 为例,假设这个页面有五个子表单组件。
父组件:
<ComponentA ref="componentAEle" />
<ComponentB ref="componentBEle" />
<ComponentC ref="componentCEle" />
<ComponentD ref="componentDEle" />
<ComponentE ref="componentEEle" />
<button @click="validate">
async validate() {
const validRefs = [
this.$refs.componentAEle,
this.$refs.componentBEle,
this.$refs.componentCEle,
this.$refs.componentDEle,
this.$refs.componentEEle
]
for (const i in validRefs) {
const res = await validRefs[i]?.validate()
if (!res) {
return false
}
}
return true
}
子组件,以 componentA 为例,其他几个子组件同理:
<el-form ref="componentAFormEle">
...
</el-form>
validate() {
return new Promise(resolve => {
this.$refs.componentAFormEle.validate(async valid => {
if (!valid) {
await this.$nextTick()
this.$utils.scrollToError(this.$refs.componentAFormEle.$el)
}
resolve(valid)
})
})
}
utils.js 封装的scrollToError方法
/**
* 自动滚动到错误的校验框
*
* @param {*} el 包裹的元素
* @param {string} [scrollOption={
* behavior: 'smooth',
* block: 'center'
* }] 滚动参数
*/
const scrollToError = (
el,
scrollOption = {
behavior: 'smooth',
block: 'center'
}
) => {
const isError = el.getElementsByClassName('is-error')
isError[0].scrollIntoView(scrollOption)
}
这里有一点要特别注意,调用子组件的 validate 方法时,一定要先调一次 this.$nextTick()
,不然的话就会报错:
因为页面的is-error元素还没出现,程序就运行到 scrollIntoView 方法那里去了,所以才会出现 undefined
错误。所以要调一下 this.$nextTick()
,等这一轮视图更新好了,再调后续的 scrollToError 方法。
总结
这种写法非常不vue,vue推荐的是,不到迫不得已,尽量不要写原生 DOM 事件,但我感觉我已经到迫不得已的情况了,如果你有更好的实现方法,一定要在评论区告诉我~
另外
前言除了产品验收完给我们加需求是真的,其他都是我编的~
现实是:
产品经理:这么简单的功能,哪来那么多理由?这个需求必须做。
项目经理:这么个小需求要开发8工时?不可能,晚上加个班,给我实现,临时加点小需求就不算工时了。
项目经理:上线时间延期一天?你在做梦,上线时间提前了算正常,延后了就扣绩效。
...
hhh,生活不易,阿林叹气,但是还是要保持乐观,努力学习呀!
希望我的文章能对前端路上的你有帮助。
更新
文章发出后,有小伙伴儿评论:
- 如果是复杂表单被折叠了怎么处理比较好?
- 表单在表格里,表格可滚动,怎么处理?
- 直接找到第一个错误的表单元素focus不行吗?
- scrollIntoview有兼容问题
复杂表单被折叠
实现效果就像这样:
这个时候就很坑了,因为直接调 scrollIntoView
实现不了,要手动地先把折叠组件展开,再调 scrollIntoView
。
这样就只能emit事件到折叠组件内部去处理了。
先给折叠组件加个 ref
。
表单所在组件
<el-form>
...
<ExpandComponent ref="expandComponentEle">
<el-form-item>
...
<el-form-item/>
<ExpandComponent/>
...
</el-form>
然后在点击提交的时候,先调一次 this.$nextTick()
,等视图更新,页面中的 is-error
元素被渲染,然后给折叠组件emit一个 validate
事件,在折叠组件内部去处理展开事件。
validate() {
return new Promise(resolve => {
this.$refs.componentAFormEle.validate(async valid => {
if (!valid) {
await this.$nextTick()
this.$refs.expandComponentEle.$emit('validate')
await this.$nextTick()
this.$utils.scrollToError(this.$refs.componentAFormEle.$el)
}
resolve(valid)
})
})
}
折叠组件内部接收到 validate
事件之后,判断有没有出错的元素,有的话就展开。
折叠组件内部
mounted() {
this.$on('validate', () => {
const hasError = this.$el.getElementsByClassName('is-error').length
if (hasError) {
this.renderExpand = true
}
})
}
虽然功能实现了,但说实话 ,折叠组件很受伤,因为折叠组件自己的职责只是处理展开收起而已,表单的校验啥的居然写到折叠组件里面去了,难受啊,但是我没啥别的解决方法了,如果更好的实现方法,一定在评论区告诉我~
表单在表格里,表格可滚动
实现效果就像这样:
vxe-table
如果表格组件是用的 vxe-table,那就很舒服,因为 vxe-table 已经实现了这个功能。
代码很简单:
async validate() {
const errMap = await this.$refs.vxeGrid.validate(true).catch(errMap => errMap)
return !errMap
},
都不用配置,调一下 validate
方法就完了,因为 autoPos
默认为 true。
不过因为没调 scrollIntoView
,页面滚动动画没了,到时候得想个办法说服产品接受😄
题外话,没用过 vxe-table
的同学,强烈安利一波,能够让你应付基本所有开发表格要用的场景了,最关键的一点是,性能好。
曾经为了满足产品的变态需求,我把 element-ui 的 el-table
组件那是狠狠地封装啊,各种乱七八糟的功能都要支持,代码写得很乱,结果最后发现有性能瓶颈,数据多了就卡顿,最后不得不放弃 el-table
,我太难了。
直到我知道了还有 vxe-table
这种表格开发神器,我是欲哭无泪啊,为什么没人早点告诉我...
el-table
如果用的是 el-table
,在动手写代码之前我以为如果校验出错元素不在可视区,会很难实现,会不会要手动处理一些 scroll 事件之类的啊,一想到可能会很复杂就不想写了。
后来仔细一想,调一下scrollIntoView
这个 API 应该可以直接实现吧,毕竟只要能找到页面的 is-error
class 就行了,表格里的元素又不像折叠组件里的元素是 display:none
。
果然:
代码如下:
<div class="scroll-into-view">
<el-form ref="paramsList" :model="paramsList">
<el-table :data="paramsList.watchList" border style="width: 100%">
<el-table-column prop="sampleName" label="样品名称" width="180"> </el-table-column>
<el-table-column prop="sampleLot" label="批号" width="150"> </el-table-column>
<el-table-column prop="lastNum" label="剩余数量" width="80"> </el-table-column>
<el-table-column prop="takeNum" label="调出数量" width="80">
<template slot-scope="scope">
<el-form-item
:prop="'watchList.' + scope.$index + '.takeNum'"
:rules="{
required: true,
message: 'Required',
trigger: 'blur'
}"
>
<el-input-number
style="width: 100%"
v-model="scope.row.takeNum"
:controls="false"
:min="0"
:max="scope.row.lastNum"
></el-input-number>
</el-form-item>
</template>
</el-table-column>
</el-table>
</el-form>
</div>
<button @click="submit">提交</button>
submit() {
this.$refs.paramsList.validate()
this.$nextTick(() => {
const isError = this.$refs.paramsList.$el.getElementsByClassName('is-error')
isError[0].scrollIntoView({
behavior: 'smooth',
block: 'center'
})
})
}
事实证明,想那么多不如直接动手写代码啊,唯唯诺诺在那儿想半天,还不如先动手试试,万一实现了呢😂
直接找到第一个错误的表单元素 focus 不行吗?
当然可以,但是产品要页面滚动的动画啊,focus 没动画效果,难受。
还有一点就是想调 focus 方法,得拿到第一个错误的表单元素的组件实例才行啊。
比起 getElementsByClassName
,真的很麻烦,得去遍历父组件的 $children
,然后找到 $vnode
下面的 elm
,去匹配有没有 is-error
。
scrollIntoview有兼容问题
这个确实没想到,去查了一下 pc 端的浏览器
然后去测了一下部分支持的浏览器,发现调 scrollIntoView
就没有动画了,但是还是能定位的,跟直接调元素实例的 focus
方法效果一样。
总的来说,兼容性还行吧,2022年了啊,希望读了这篇文章的大哥们遇到的都是只用 chrome 的用户。 ^_^
所以说吧,产品经理的一个小需求,引出来这么多鬼问题,我预估八小时工时,还预估少了呢,啥时候咱能不被砍工时呀😄
如果我的文章对你有帮助,你的👍就是对我的最大支持^_^