天天看點

Vue元件繼承實踐:擴充分隔條(MySplitter)元件 引子填坑之路結語元件源碼

引子

填坑之路

如何實作已有Vue元件的繼承擴充?

怎麼才能調用基類元件的方法呢?

計算屬性究竟是怎麼判斷要不要重算的?

如何讓計算屬性手動強制重算?

如何在重載 render 方法中,往原有的虛拟節點清單中注入新的虛拟節點?

結語

元件源碼

引子

剛入坑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([email protected])

原文:https://www.yuque.com/fictiony/cs6lwq/nzrxtl

版權聲明:本文為原創文章,轉載請附原文連結,謝謝!

繼續閱讀