一个小功能,用户体验大优化:表单滚动定位到校验出错的元素

3,991 阅读4分钟

前言

前段时间,产品在验收时给我们加了一个需求,用户提交表单时,要滚动定位到校验出错的地方,就像这样:

scroll.gif

我说:做这个应该会比较麻烦,主要是涉及到多个子表单组件通信,还要计算元素在页面的位置,还要写动画,大概 8 工时,要不不做了?

她柳眉皱起,考虑了很久,道:不行!用户体验至关重要,你尽力做嘛,多预估点开发时间就行。

我面露难色,很勉强地说:好吧,哎,那就上线时间往后面延一天吧,我尽量把这个功能做出来。

项目经理知道要加这个需求后,说:好,你加油!就延后一天上线吧,

我心道:小样儿,给我加需求,我一小时就做完的东西,骗你们说八小时你还真信,又多一天时间学(mo)习(yu)了,耶!

需求分析

这种需求主要是用在超级长的表单,长到超过一个页面。

要实现这个功能需要用到一个 Web API,Element.scrollIntoView

Element 接口的scrollIntoView()方法会滚动元素的父容器,使被调用scrollIntoView()的元素对用户可见。

很显然,我们只需要在表单提交校验不通过时,找到整个页面上第一个校验出错的元素,然后调这个元素的 scrollIntoView 方法,就能实现这个功能。

scrollIntoView 这个 api 帮我们把计算元素位置和动画的事情做了,但是怎么找到页面上第一个校验出错的元素呢?

element-uiel-form 组件为例,打开控制台发现,校验出错时元素会被加上 is-error class,很显然,我们可以调用元素的 getElementsByClassName方法,找到所有校验出错的元素,再取第一个就行。

image.png

现在就差最后一个问题了,多个子组件表单和父组件的通信如何实现?

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(),不然的话就会报错:

image.png

因为页面的is-error元素还没出现,程序就运行到 scrollIntoView 方法那里去了,所以才会出现 undefined 错误。所以要调一下 this.$nextTick(),等这一轮视图更新好了,再调后续的 scrollToError 方法。

总结

这种写法非常不vue,vue推荐的是,不到迫不得已,尽量不要写原生 DOM 事件,但我感觉我已经到迫不得已的情况了,如果你有更好的实现方法,一定要在评论区告诉我~

另外

前言除了产品验收完给我们加需求是真的,其他都是我编的~

现实是:

产品经理:这么简单的功能,哪来那么多理由?这个需求必须做。

项目经理:这么个小需求要开发8工时?不可能,晚上加个班,给我实现,临时加点小需求就不算工时了。

项目经理:上线时间延期一天?你在做梦,上线时间提前了算正常,延后了就扣绩效。

...

hhh,生活不易,阿林叹气,但是还是要保持乐观,努力学习呀!

希望我的文章能对前端路上的你有帮助。

更新

文章发出后,有小伙伴儿评论:

  • 如果是复杂表单被折叠了怎么处理比较好?
  • 表单在表格里,表格可滚动,怎么处理?
  • 直接找到第一个错误的表单元素focus不行吗?
  • scrollIntoview有兼容问题

复杂表单被折叠

实现效果就像这样:

scroll-expand.gif

这个时候就很坑了,因为直接调 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
    }
  })
}

虽然功能实现了,但说实话 ,折叠组件很受伤,因为折叠组件自己的职责只是处理展开收起而已,表单的校验啥的居然写到折叠组件里面去了,难受啊,但是我没啥别的解决方法了,如果更好的实现方法,一定在评论区告诉我~

表单在表格里,表格可滚动

实现效果就像这样:

table.gif

vxe-table

如果表格组件是用的 vxe-table,那就很舒服,因为 vxe-table 已经实现了这个功能。

image.png

代码很简单:

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

果然:

table-el.gif

代码如下:

<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

image.png

scrollIntoview有兼容问题

这个确实没想到,去查了一下 pc 端的浏览器

image.png

然后去测了一下部分支持的浏览器,发现调 scrollIntoView

就没有动画了,但是还是能定位的,跟直接调元素实例的 focus 方法效果一样。

总的来说,兼容性还行吧,2022年了啊,希望读了这篇文章的大哥们遇到的都是只用 chrome 的用户。 ^_^

所以说吧,产品经理的一个小需求,引出来这么多鬼问题,我预估八小时工时,还预估少了呢,啥时候咱能不被砍工时呀😄

如果我的文章对你有帮助,你的👍就是对我的最大支持^_^