關於vue事件監聽的一個問題

灰色的龍貓發表於2017-12-26

由於新工作需要用vue,所以最近接觸最多的也是vue,因為之前一直在用react,所以對於vue上手還是很快的。
我也儘量找一些他們兩個的異同點,除了多了一些輔助用的方法以外,最大的不同應該是對於元件間的通訊,不僅有props,還有一種事件監聽,也是可以通過元件間傳遞的。
我們知道vue的事件監聽是一個很方便的設計,程式碼上一目瞭然,而且給我們增加了多種修飾符(雖然我都沒怎麼用過)來簡化你的程式碼。可歸根結底,所謂事件監聽,通常都是一個需要預處理的過程,即在你初始化你的例項時就需要去為其註冊監聽。這當然沒什麼不好,我們之所以需要做事件監聽,就是為了當我們因為業務複雜而形成一個事件需要觸發多處回撥時,我們可以通過這種註冊監聽機制更好的管理他們。這一點上他比react做的更好,如果在react裡我們需要中途給某個事件新增更多回撥,我們需要手動維護更多的程式碼(這裡說的事件監聽機制單指vue的v-on方式,react本身dom上的事件是通過事件代理方式統一在document身上並通過每個dom的唯一id來維護一個hashMap來實現的)。
但是,在vue2.+中,vue引入了diff演算法和虛擬dom來提升效率。我們知道這些事為了處理頻繁更新dom元素所提出的一種優化方案,可頻繁變動更新以及事件監聽的初始化之間是否會有矛盾,當元件需要變動時,有沒有對註冊過的事件進行解綁? 我們來寫一些簡單的程式碼印證一下。

我們寫兩個div做的按鈕,一個是寫的html程式碼,一個是通過元件的形式插入,兩個按鈕完全一樣,但我們加一個disabled的屬性在外層,並通過if-else來判斷disabled從而顯示不同的按鈕(當然正常場景下我們不會這麼去寫程式碼,這裡只是通過這種方式模擬一種特殊場景,我們自行考慮在我們的業務中是否存在這種場景)。

<template>
  <div class="test">
    <div class="btn" v-if="disabled" @click="handleClick">可點選</div>
    <div class="btn" v-else >不可點選</div>
    <Button v-if="disabled" @clickTest="handleClick">可點選</Button>
    <Button v-else>不可點選</Button>
  </div>
</template>

<script>
import Button from `./Button`
export default {
  data () {
    return {
      disabled: true
    }
  },
  methods: {
    handleClick() {
      alert(`可點選`)
    }
  },
  components: {
    Button,
  },
  mounted() {
    setTimeout(() => {
      this.disabled = false
    }, 1000)
  }
}
</script>
<style>
.btn{
  margin: 100px auto;
  width: 200px;
  line-height: 50px;
  border: 1px solid #42b983;
  border-radius: 5px;
  color: #42b983;
}
</style>

我們加一點樣式,讓他儘量好看一點,看著很簡單,兩個按鈕,可點選時為他繫結一個點選事件,不可點選時不為他繫結。不同點是一個是直接寫的html程式碼,一個是元件。元件的程式碼如下:

<template>
    <div class="btn" @click="handleClick"><slot></slot></div>
</template>
<script>
    export default {
        methods: {
            handleClick() {
                this.$emit(`clickTest`)
            }
        }
    }
</script>

然後在mounted週期里加一個1秒的settimeout將disabled變為false,然後我們測試一下
(靠!傳圖片失敗····· 我們用語言描述吧)
總之就是,當disabled還是true得時候,兩個按鈕點選都會彈出可點選的alert。但當disebled變為false的時候,上面用html寫的不會再彈框,可下面用元件寫的就還是會彈窗。

這種問題出現時是非常不好定位的,因為程式碼上很顯然不會去調取這個clicktest事件,而在頁面上,我們也能確定按鈕已經變為不可點選的那一個了。那為什麼這個事件還是會被調取呢?

這先要從diff演算法說起,傳統的diff tree演算法的演算法複雜度是O(n^3),而react在引入diff演算法時,拋除了跨級移動的情況,即只比對同一級的節點異同,讓演算法複雜度降低到了O(n),讓我們可以肆無忌憚(當然也要適可而止)的頻繁重新整理整個頁面。
(呵呵,沒圖)
diff有一條策略是擁有相同類的兩個元件將會生成相似的樹形結構,擁有不同類的兩個元件將會生成不同的樹形結構。所以它的比對順序就是
1)tree diff
2)component diff
3)element diff
回到我們的程式碼上,我們在進行component diff時,認為他們是相同的元件,然後進行element diff,即進行新增 刪除和移動
所以問題就是發生在了這裡,在例項化元件的時候我們初始化了事件監聽,但在替換相同元件裡的dom時,vue並沒有對已新增到元件上的事件監聽做刪除。
我們看一下vue的程式碼,

Vue.prototype.$emit = function (event: string): Component {
    const vm: Component = this
    if (process.env.NODE_ENV !== `production`) {
      const lowerCaseEvent = event.toLowerCase()
      if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) {
        tip(
          `Event "${lowerCaseEvent}" is emitted in component ` +
          `${formatComponentName(vm)} but the handler is registered for "${event}". ` +
          `Note that HTML attributes are case-insensitive and you cannot use ` +
          `v-on to listen to camelCase events when using in-DOM templates. ` +
          `You should probably use "${hyphenate(event)}" instead of "${event}".`
        )
      }
    }
    let cbs = vm._events[event]
    if (cbs) {
      cbs = cbs.length > 1 ? toArray(cbs) : cbs
      const args = toArray(arguments, 1)
      for (let i = 0, l = cbs.length; i < l; i++) {
        try {
          cbs[i].apply(vm, args)
        } catch (e) {
          handleError(e, vm, `event handler for "${event}"`)
        }
      }
    }
    return vm
  }

vue是通過vdom裡的_events屬性下確定是否有繫結事件的。我們看一下不可點選的按鈕的_events

:
clickTest
:
Array(1)
0
:
ƒ invoker()
length
:
1

發現clicktest還在。這就是問題所在了。

那麼我們該如何去迴避這樣的問題呢,還是應從diff的比對方式來解決問題,還是看程式碼。

function sameVnode (a, b) {
  return (
    a.key === b.key && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

也就是對diff來說,所謂相同的第一判定原則是key。
key也是react引入diff時新增的一個屬性,用來判斷前後vdom樹上是否為統一元素(注意是同級關係上),所以我們只需要在程式碼上加key,就可以避免這個問題

<Button key="1" v-if="disabled" @clickTest="handleClick">可點選</Button>
<Button key="2" v-else>不可點選</Button>

這樣,我們在點選按鈕時,就不會再出彈框了。
key的作用很廣泛,當我們在遍歷陣列生成dom時,新增一個可確定的唯一id(注意不應該用陣列索引),會優化我們的比對效率以及更少的操作dom。我們也會在某個div上新增key以確保他不會因為兄弟元素的變動而被重新渲染(這類div一般會被繫結react或vue意外的事件或動作,如在這個div中生成了一個canvas等)。

那麼除了在元件上加這種不必要key值以外,還有別的方法解決嗎?

有的,這裡有一種很反vue但是類react的方式,就是把回撥事件通過props的方式傳遞,向下面著這樣,

<Button v-if="disabled" :clickTest="handleClick">可點選</Button>
<Button v-else>不可點選</Button>
        props: {
            `clickTest`: {
                type: Function
            }
        },
        methods: {
            handleClick() {
                //this.$emit(`clickTest`)
                this.clickTest && this.clickTest()
            }
        }

雖然vue給了我們更方便的事件傳遞的方式,但props裡是允許我們去傳遞任何型別的,我的期望是在真實的dom上或者在公共元件的入口處以外的地方,都是通過props的方式來傳遞結果的。雖然這種方式很不vue,而且也享受不到v-on給我們帶來的遍歷,但是這樣確實可以減少不必要的麻煩。
當然既然用了vue,更好的利用vue給我們帶來的遍歷也很重要,所以對於這種很少會出現的麻煩,我們有一個預期,並可以快速定位並修復問題,就可以了

相關文章