Vue 组件继承实践:扩展分隔条(MySplitter)组件

引子

刚入坑 Vue 不到一个月,为了开发一个能跨平台的桌面 App,千挑万选之下,最终选了Quasar这套框架,觉得各方面都很适合,内置支持 Electron,UI 样式也很不错,于是乎就开工做了起来。

Quasar 的组件库虽然已经算很成熟很全面了,但实际应用中也难免碰上一些不足之处。比如在用到分隔条(QSplitter)组件的时候,就发现它只支持针对单边区域的最大最小限制。而通常 App 中需要的却是对两边区域都有最小限制(比如 VSCode 这样,左边功能区不小于 170px,右边编辑区不小于 300px),最大限制倒很少用到。虽然在百分比模式(unit='%')下,若想要限制右侧区域的下限为 10%,可以通过设置左侧区域的上限为 90% 来实现,但在像素模式(unit='px')下,就没有代替方案了。更何况通常 App 里我们希望限制的下限必须是像素为单位的(不然大小不确定),这就很尴尬了。

由于并不是特别赶工,本着解决问题就是最好的学习方法的思路,我开始了对 QSplitter 组件进行二次开发的研究。

填坑之路

由于还是个 Vue 新手,虽然已经啃了几周的文档,但实际操作起来还是碰到了各种问题。

碰到的第一个坑,就是

如何实现已有 Vue 组件的继承扩展?

网上看了好多资料都提到了三种继承方式:

  1. 使用<基类>.extend(options) 直接创建继承类

这种方式网上很少深入去讲,只放个很简单的例子来说明,甚至基本上讲的都是以 Vue.extend(<基类>) 的方式来调用,而不是 <基类>.extend(options) 。结果生成的只是基类的一个拷贝,而想要加入扩展内容,只能在 new 实例的时候传入额外的 options 选项表来实现(还特别强调了要用 propsData 来代替 props)。那我要这个“继承”类来干嘛呢?还不如直接 new 一个基类的实例,不也一样可以传 options 么?所以网上大多数讲的都是错的,用 <基类>.extend(options) 才是正确的写法。但需要注意的是,用 extend 函数来定义组件(无论是否继承),返回的是组件的构造函数,而不是组件定义的选项表(像.vue 单文件模板那样)。

  1. 在继承组件的定义中添加extends: <基类>

这种方式是利用了 Vue 组件注册时自动实现的继承处理,而且并不要求必须用 Vue.extend(options) 的方式直接定义成组件类,而是可以和.vue 单文件模板一样,只定义成选项表的形式(也就是单纯的 options 对象)。我认为这样更为统一规范一些,因此最终我选用的就是这种方法。而且这样带来的额外好处是全局注册的时候可以直接取 name 字段作为组件名,也可以很灵活的进行定制化处理(因为本身就只是个 options 对象,只需要用 { ...options, ... } 这样的方式就可以实现定制扩展)。

  1. 把基类组件放在继承类组件的模板中,并把继承类组件的属性和事件监听作为参数传给基类组件:v-bind="$attrs" v-on="$listeners"

这种方式虽然写起来很简洁,也方便添加额外的参数和子元素,但漏洞比较多,不能算是一种真正的继承,而更像是把基类组件打了个包(叮咚,有你的快递 ~),在 Vue 调试器里会多出一层组件节点。另外对于插槽的处理也不友好,需要手动全部重新封装一遍传进去。因此我不是很推荐使用这种方法,它可能会破坏组件的接口和数据流向,产生不可预知的 bug。

决定下来用第二种方式继承之后,我就开始着手编写我的扩展分隔条(MySplitter)组件了:

import { QSplitter } from 'quasar'

// 扩展分隔条
export default {
  name: 'my-splitter',
  extends: QSplitter,
  props: {
  },
  computed: {
  },
  watch: {
  },
  methods: {
  },
}

由于我要增加的功能,是能够指定分隔条两侧区域的最小像素范围,而 QSplitter 组件本身已经有一个 limits 属性了,我并不想覆盖它的功能,因此我参考了 QSplitter 的源码,增加了一个 limits2 属性,定义如下:

  props: {
    // [ 一区最小像素范围, 二区最小像素范围 ],若设置则limits无效
    limits2: {
      type: Array,
      validator: v => {
        if (v.length !== 2) return false
        if (typeof v[0] !== 'number' || typeof v[1] !== 'number') return false
        return v[0] >= 0 && v[1] >= 0
      }
    }
  }

接下来就是想办法让这个 limits2 属性起效了。看了下 QSplitter 的源码,发现它实际上使用的是一个计算属性 computedLimits 来做最终的处理:

    computedLimits () {
      return this.limits !== void 0
        ? this.limits
        : (this.unit === '%' ? [ 10, 90 ] : [ 50, Infinity ])
    }

于是我就想要重载这个属性,增加对 limits2 的判断。即当定义了 limits2 时,根据 limits2 来计算,否则保持原样。

结果第二个坑来了,

怎么才能调用基类组件的方法呢?

我找遍了网上的资料,完全找不到一个调用 Vue 基类组件方法的例子,这不科学!既然没的参考,于是我只好自己研究。其实说白了也不难,无非就是 console.log(QSplitter) 一下,在开发者工具里看看里面都有些啥。一看发现它并不是个对象,而是个构造函数,于是继续点进去看源码,然后就看到 Vue 的源码里去了😅:

  Vue.extend = function (extendOptions) {
    extendOptions = extendOptions || {};
    var Super = this;
    // ...略

    var Sub = function VueComponent (options) { // <----------------构造函数在这里
      this._init(options);
    };
    Sub.prototype = Object.create(Super.prototype);
    Sub.prototype.constructor = Sub;
    Sub.cid = cid++;
    Sub.options = mergeOptions( // <-----------------继承类组件的选项表
      Super.options,
      extendOptions
    );
    // ...略

    Sub.superOptions = Super.options; // <-----------------基类组件的选项表
    // ...略

    return Sub
  };
}

我惊喜的发现,基类和继承类组件的选项表,都被妥妥的的安排在了继承类的构造函数上,还自动进行了合并。这不就好办了嘛,直接拿 QSplitter.options 下相应位置的方法来用就欧了。于是重载后的 computedLimits 就被我写成了这样:

  computed: {
    // 重载区域范围计算
    computedLimits() {
      if (this.$el) { // limits2依赖于整体大小来计算二区大小,故必须挂载后才能计算
        let v = this.limits2
        if (v !== undefined) {
          if (this.reverse) {
            v = [v[1], v[0]]
          }
          const total = this.$el.getBoundingClientRect()[this.prop]
          v = [v[0], Math.max(v[0], total - v[1])]
          return this.unit === '%' ? v.map(i => i / total * 100) : v
        }
      }
      // limits2未指定或未挂载时,保持原样
      return QSplitter.options.computed.computedLimits.call(this)
    }
  }

可是测试了下发现,在指定了 limits2 后,范围限制并没有起效,即使反复修改 limits2 也一样,再查了下 computedLimits 的值,竟然始终都是原始默认值!不信邪的又在计算函数开头加了一句日志打印,发现它居然只在组件创建时执行了两次,后面就不再执行了。这说明这个计算属性根本没有响应 limits2 的变化!

纳尼?!说好的计算属性会自动感知计算函数所涉及的组件属性值改变,并重新计算的呢?是我人品不好,还是我打开的方式有问题?带着疑问,我反复测试了半天,也搜了半天参考帖子,仍然没有头绪,奇了怪了。

这是我遇到的第三个坑,也是卡我时间最久的一个,

计算属性究竟是怎么判断要不要重算的?

在百度无果的情况下,我打算靠猜。先想了下如果让我自己来设计一个计算属性的响应重算逻辑,我会怎么去设计?我大致想出了两种方案:

  1. 靠静态语义分析,把计算函数中涉及到的响应式属性都识别出来,并一次性全加上监视。这种方案类似于人工判断,准确性没说的,但实现难度极大,而且对于嵌套函数的判断,也会是很大的麻烦,同时性能上也太费。
  2. 靠运行时动态标记,把计算过程中访问到的响应式属性一个个记下来,并加以监视。这种方案在实现难度和性能上都有明显的优势,但缺点就是准确度不高,无法保证一次性捕捉到计算函数中所有用到的响应式属性(因为计算过程中可能存在未执行到的分支流程)。

恰巧,我的计算函数里正好存在 IF 分支,在组件未挂载时(此时 $el 尚无取值)会绕过对 limits2 的取值。那么会不会就是因为这个原因,导致后面 limits2 的改变都无法被感知到了呢?

为了证实我的猜测,我又仔细的撸了一遍 Vue 的源码(此处省略数小时),总算大致搞明白了 Vue 里对于计算属性的处理逻辑,还真和我猜想的第 2 种方案基本类似,不过在具体实现上有很多高明之处,这里就不展开来细说了。这里我们只需要知道,Vue 的计算属性也是采用运行时动态标记要监视的响应式属性的,因此如果计算函数在首次运行时没有执行到包含某个响应式属性的分支流程,那么这个属性就不会被该计算属性所监视,也就无法感知到它的改变了。看来,计算属性虽然很智能,但用起来也要很小心呀,一个不当心,可能就掉坑里了,233333。

虽然找到了问题的原因,但想要解决也不是那么容易。毕竟我们的计算函数里,对于 $el 的判断是必不可少的,而 $el 本身又不是一个响应式属性,并不会因为组件挂载了之后 $el 变了,就能让 computedLimits 感知到并且重算。好在组件挂载是有 mounted 钩子函数的,那么能不能在组件挂载时,强制 computedLimits 重算呢?

碰上第四个坑了,

如何让计算属性手动强制重算?

好了,这又是个百度不到的万年大坑,我好难啊 ~

没说的,还是得要从 Vue 源码入手去找解决方案。于是我找到了计算属性的初始化方法:

function initComputed (vm, computed) {
  var watchers = vm._computedWatchers = Object.create(null); // <-----------------这里定义了一个监视表
  // ...略
  
  for (var key in computed) {
    // ...略
  
    watchers[key] = new Watcher( // <----------------每个计算属性都有一个同名的监视器
      vm,
      getter || noop,
      noop,
      computedWatcherOptions
    );

    // ...略
  }
}

如上所见,每个计算属性都在 _computedWatchers 下有一个同名的监视器(Watcher)对象,而这个监视器就是用来监视所有依赖的响应式属性的改变的,同时也记录了计算属性的缓存值(value)和是否需要重算的标记(dirty)。所以,只需要手动将同名监视器的重算标记设为 true ,就可以强制计算属性重算了(或者也可以调用监视器的 update 方法,效果是一样的)。下面就是我添加的 mounted 钩子函数:

  mounted() {
    this._computedWatchers.computedLimits.dirty = true // 手动强制重算
    this.__normalize(this.value, this.computedLimits) // 矫正value(__normalize方法的使用参考QSplitter的代码)
  }

这样就解决了组件挂载后,computedLimits 无法感知 $el 改变并重算的问题了,经过测试,效果完全符合预期。

等一下,为什么 $el 不是一个响应式属性呢?能吗?不能吗?我也不知道,也许 Vue 的作者有他自己的理由,至少目前我是不了解的。但这并不妨碍我把它变成一个响应式属性。因为这样一来,如果有多个计算属性都依赖 $el 的判断时,就可以不用在 mounted 里面加一堆难看的 this._computedWatchers.xxx.dirty = true 了。至于把 $el 设为响应式属性会带来什么不好的副作用,暂时我还没有发现。

Vue 官方 API 文档中只提到了 Vue.observable(object) 接口,用来将一个对象的内部属性转为响应式的,却没有提到如何将单个属性转为响应式的接口。不过这难不倒我,毕竟我手上有 Vue 源码。只需要挖一下 observable 的实现代码,就可以找到一个名为 defineReactive 的接口(这个接口也能百度到,但参考资料不多),它可以通过 Vue.util.defineReactive(obj, key, val, customSetter, shallow) 的格式来调用。我只需要用到前两个参数就够了,后面的参数不用管它。由于要在 $el 改变前,也就是组件挂载前就将它转为响应式属性,因此最好在 beforeCreate 钩子函数中调用,具体如下:

  beforeCreate() {
    Vue.util.defineReactive(this, '$el') // 转为响应式属性
  }

加了之后,mounted 钩子函数里的那句 this._computedWatchers.computedLimits.dirty = true 也就可以去掉了。测试了一下,初始状态、拖动、动态设置属性,都没啥问题。

不过虽然看上去好像一切都满意了,但别忘了,limits2 和 limits 不一样的地方在于,它是会受 DOM 元素大小影响的,一旦 DOM 元素大小改变了(这很常见,窗口缩放一下就会遇到,或者分隔条嵌套使用也会),而其他相关属性却没变时,computedLimits 可不会自动重算,于是就会出现分隔条拖拽范围不正确的 bug。因此,我还需要增加 resize 事件的处理。

由于 <div> 元素并不能在大小改变时抛出 resize 事件,因而需要用一些变通的方法来解决。好在 Quasar 早已经考虑到了这个问题,提供了 QResizeObserver组件专门来解决。不过对于我这个继承的组件来说,情况就有点复杂了。因为并没有现成的 <template> 定义可以供我方便的嵌入额外组件,而若要通过重载 render 方法来实现嵌入,又没办法直接嵌入组件本身,只能手动合并组件渲染生成的虚拟节点列表。对于我这个 Vue 新手来说,这着实算是个难度颇高的任务了。

终极大坑:

如何在重载 render 方法中,往原有的虚拟节点列表中注入新的虚拟节点?

老实说,这个坑我并不算真正填平了,我只是针对 QSplitter 组件的 render 方法做了一个针对性的解决方案。考虑到 QSplitter 组件本身提供了 4 个插槽,其中 default 插槽正好用来加入 QResizeObserver 组件。不过分析了下 render 的代码发现,通过简单的函数注入或列表注入方法,似乎是行不太通的,那么就只能劫持插槽本身了,也就是 vm.$scopedSlots.default

由于 vm.$scopedSlots.default 是一个返回虚拟节点列表的函数,因此我可以直接将其替换成新的函数,并将原函数和要注入的虚拟节点列表都记下来,以便在新函数执行时重新进行组合。考虑到注入插槽的操作可能在扩展组件时经常会用到,为了能更好的复用,我就把它写成了通用的方法: injectSlot(vm, slot, id, nodes, before)(其中 id 和 before 参数是为扩展性目的而加,涉及了一些常用的列表注入技巧,此处按下不表)。同时为了调用方便,我还把它直接加到了 Vue.prototype 下面(规范起见,函数名前多加一个 $,以免命名冲突),变成组件的成员方法。这样调用时就无需再 import 了,且还能省去一个 vm 参数,直接写成 this.$injectSlot(slot, id, nodes, before) 即可。具体代码这里就不贴了,感兴趣的童鞋可以去组件源码中查看。

于是,渲染时注入 QResizeObserver 组件的 render 方法就可以这么写了:

  render(h) {
    this.$injectSlot('default', 'QResizeObserver', [ // 解决div无法监听resize事件的问题
      h(QResizeObserver, {
        props: { debounce: 0 },
        on: { resize: this.__onResize }
      })
    ])
    return QSplitter.options.render.call(this, h)
  }

对应的事件处理函数就简单多了,和前面写过的 mounted 几乎一样,代码如下:

  methods: {
    __onResize() {
      this._computedWatchers.computedLimits.dirty = true // 由于DOM元素大小不可响应,故需手动强制重算
      this.__normalize(this.value, this.computedLimits) // 矫正value
    }
  }

由于 QResizeObserver 组件本身就会在挂载时抛出 resize 事件,而我们的 resize 事件处理函数中又已经进行了 computedLimits 的强制重算和 value 的矫正,于是 beforeCreate 和 mounted 这两个钩子函数也都可以去掉了,简直不要太爽!赶紧测试一下看看——完全 OK,毫无 bug!

结语

写到这里,这个扩展分隔条组件才算是真正完成了我预期的目标(此处应有掌声😂)。

不过目前还并不算很完美,毕竟还是有一些瑕疵的。比如当窗口缩小时,分隔条由于受范围限制而被动矫正了位置,这个位置就会被保留下来,导致窗口放大回原来大小时,分隔条无法回到原来的位置,这在使用体验上会带来些许问题。当然,这些都属于优化的范畴了,这里就不再展开讨论,裹脚布已经够长的了。

最后,奉上完整的组件源码,希望能给大家带来启发和帮助。

组件源码

https://gitee.com/fictiony/mysplitter


作者:Fictiony( fictiony@qq.com
原文:https://www.yuque.com/fictiony/cs6lwq/nzrxtl
版权声明:本文为原创文章,转载请附原文链接,谢谢!