從Dialog管理談到Vue渲染原理

作爲一箇中後臺表單&表格工程師,經常需要在一個頁面中處理多個彈窗。我自己的項目中,一個複雜的審覈頁面中的彈窗數量超過了30個,如何管理大量的彈窗就成爲了一個需要考慮的問題。

大量的彈窗有什麼問題

假設你有一個彈窗組件,類似於element-ui的Dialog,如果簡單粗暴的每一個彈窗都寫一個dialog,那麼會有以下問題:

  • 模板過長,且大量冗餘
  • 命名困難,每一個彈窗需要一個變量去控制顯示,通常每一個彈窗裏面也是一個表單,又需要一個變量保存表單數據,每個彈窗也有自己的邏輯(method),都要寫在這個頁面,要絞盡腦汁去取名
  • 非常的不優雅,簡直就是Repeat yourself反模式的示範。。。

把每個彈窗抽成模塊

一個很容易想到的優化方法就是把一個彈窗作爲一個組件抽離出去,每個彈窗的邏輯單獨寫在組件中。

這樣通過組件拆分做很好的解決了模板過長的問題,也基本解決了命名困難的問題,不過還是需要很多的變量去控制每個組件的顯示。

使用動態Component

第一個辦法本質上並沒有減少重複的代碼和邏輯(彈窗顯示/關閉),只是把代碼放在了不同的文件當中。

顯然,我並不需要寫那麼多的Dialog,Dialog本身並沒有變,作爲一個「包裹」組件,變的只是內容。

所以,只需要寫一個dialog,配合Vue的動態組件Component,切換不同的組件就行了。

全局Dialog

使用Component,我們做到了一個頁面只需要一個Dialog,但其實整個網頁,也只需要一個全局的Dialog。

我們在根組件下掛一個Dialog組件,組件內容依然使用動態component,組件的數據流轉,component傳遞等使用Vuex進行。

使用函數創建組件

作爲單個項目的解決方案,全局Dialog加動態Component其實已經足夠好了,使用一個函數調用就可以顯示彈窗。

this.$dialog({
  title: '我是彈窗',
  component: Test,
  props: { props }, // Test的props通過這樣傳遞
})

但是想要作爲通用解決方案,還不夠:

  • 引入不方便,需要手動在跟組件下引入並寫上封裝好的彈窗組件
  • 必須使用Vuex進行數據流轉,而並不是每個Vue項目都使用Vuex的
  • 沒法監聽事件,只能傳入回調
  • props的傳遞方式不夠優雅,不夠聲明式

在我心中,一個理想的彈窗組件,需要是這樣的:

  • 引入方便,Vue.use(Dialog)就行了
  • 使用簡潔

      this.$dialog({
        title: '哎呀不錯哦',
        component: () => <Test onDone={ this.fetchData } name={ this.name }/>
      })

Let's go.

使用$mount

Vue作爲一個視圖層的框架,核心其實就是渲染函數,所以一定有一個辦法,可以把一個Vue組件渲染成一個DOM,這個方法就是$mount。

// 這個Dialog組件就是寫好的彈窗組件
import Dialog from './Dialog'

// dialog是一個單例,不需要重複創建
let dialog
export default function createDialog(Vue, { store = {}, router = {} }, options) {
  if (dialog) {
    dialog.options = {
      ...options,
    }

    dialog.$children[0].visible = true
  } else {
    dialog = new Vue({
      name: 'Root-Dialog',
      router,
      store,
      data() {
        return {
          options: { ...options },
        }
      },
      render(h) {
        return h(Dialog, {
          props: this.options,
        })
      },
    })

    // 渲染出DOM並手動插入到body
    dialog.$mount()
    document.body.appendChild(dialog.$el)
  }

  // 暴露close方法
  return {
    close: () => dialog.$children[0].close(),
  }
}

Dialog組件

基於element-ui的Dialog組件二次封裝,在原有的props之外,添加一個component,使用動態Component渲染上去就行了。
思路很簡單,但是有幾個問題需要考慮。

生命週期問題

如果不做任何處理,當彈窗消失的時候component並不會銷燬;當再次顯示彈窗時,會傳入一個新的組件,這個時候,上一個組件才銷燬,這非常不合理。所以我們需要在彈窗消失的時候手動銷燬傳入的component。

注入事件

Vue的動態Component組件的is屬性接受的值有3種類型:

  • string,在當前組件內註冊過的組件的名稱
  • ComponentDefinition,就是一個組件的選項對象,new Vue時傳的那個對象
  • ComponentConstructor,返回一個ComponentDefinition的函數,比如動態import函數

而我們希望的調用形式裏,component是一個返回jsx的函數,而它會被babel插件babel-plugin-transform-vue-jsx轉換爲調用createElement函數的結果,也就是說

() => <Test >

這個函數最終返回的是一個Virtual Node。
而Vue的選項裏面,render最終返回的也是一個VNode。
也就是說,() => <Test >這個函數可以作爲一個Vue組件的render選項,所以,我們需要構造一個完整的Vue選項對象,然後將這個對象作爲動態component的is屬性,這樣就可以渲染出這個Test組件了。

在這個過程中,我們可以在這個Vnode裏面做一些有趣的事情,比如注入事件。

爲什麼要注入事件

首先,這裏有一個剛需:彈窗內的組件需要可以關閉彈窗,也就是它的父組件。
通常有兩個辦法可以做到:

  • 通過props接收一個函數,調用它可以關閉彈窗
  • 主動拋出一個事件,dialog組件監聽這個事件,然後把自己關了

略微比較一下就可以發現,拋出事件的方法優於回調函數的辦法(通常來說,「事件」都優於「回調」):

  • 代碼少, $emit('complete')就行了,使用回調需要添加一個props,調用的時候還需要判斷它是否存在
  • 通用性更好,這個組件可能不僅僅只在彈窗內調用,它可以在其它任何地方被調用,使用事件只需要簡單的拋出一個事件,表示我完成了,調用它的組件根據自身的邏輯來進行接下來的工作,這樣組件本身做到了低耦合。

但是,拋出事件的實現卻要比傳入回調難很多,需要對VNode比較熟悉。

在Dialog組件內,我們觸及不到組件的模板,所以簡單的在動態component模板上添加 @done 並不能完成事件監聽。因爲事件監聽其實是在render的過程中進行的,而我們的render是通過jsx的方式在調用$dialog函數時傳入的,所以只能手動在生成的VNode上添加事件監聽:

在 vNode.componentOptions.listeners中,添加我們需要監聽的事件和事件處理函數:

let listeners = vNode.componentOptions.listeners

if (!listeners) {
  listeners = {}
  vNode.componentOptions.listeners = listeners
}

// 添加done
const orginDoneHandler = listeners.done
listeners.done = function () {
  if (orginDoneHandler) orginDoneHandler()
  doneHandler()
}

// 添加cancel
const orginCancelHandler = listeners.cancel
listeners.cancel = function () {
  if (orginCancelHandler) orginCancelHandler()
  cancelHandler()
}

在Dialog中,監聽了動態component的donecancel事件,在任一事件觸發後都會關閉Dialog,組件$emit('done')表示完成了自己的業務,$emit('cancel)表示取消了自己的業務

主動收集依賴

到這裏,還有一個問題沒有解決:這個組件還不是響應式的,比如說,你在一個index組件中通過$dialog顯示一個彈窗

this.$dialog({
  title: '響應式',
  component: () => <Test text={ this.text }/>
})

當text更新時,彈窗中的內容並沒有更新,也就說,組件沒有重新渲染。

Vue的渲染流程與依賴收集

這裏就要涉及到一些Vue的原理了,比如說渲染流程,依賴收集,一兩句話也講不清楚,我試着大概的說一下:

首先,頁面上顯示的數據變了,一定是觸發了重新渲染,this.text = '新的text' 之所以會更新頁面,可以理解爲一個渲染函數在this.text的setter中執行了。

那麼,this.text的getter怎麼樣才能知道要執行哪些函數,就是通過所謂的依賴收集。簡單來說,依賴收集是在渲染函數(渲染Vnode的函數)中進行的,在createElement中一旦通過this.text使用了這個變量,通過這個變量的getter就收集到了正在執行的渲染函數這一個依賴。

所以,粗暴的講,需要把this.text的訪問放在一個render函數(Vue選項對象的render)中進行。平常用的模板其實也是這樣,因爲它最終都被Vue-loader編譯成了render。

_component() {
  // 這一步很重要,讓component收集到了這個計算屬性的依賴,否則當component變化時不會重新渲染組件
  const fn = this.component
  let vNode

  // 返回vue選項對象
  const that = this
  return {
    name: 'dynamic-wrapper',

    render() {
      // fn的運行一定要在render函數中,也是爲了掛載依賴
      vNode = fn()
      ...
    }
}

所以,這就是爲什麼一定要使用一個返回jsx的函數作爲,而不是直接美滋滋的使用jsx。因爲,臣妾實在是做不到響應式呀~

this.$dialog({
  title: '臣妾做不到啊~',
  component: <Text text={ this.text }/>,
})

等於

// this.text的值爲text
this.$dialog({
  title: '臣妾做不到啊~',
  component: createElement(
    Text,
    props: {
      text: 'text',
    }
  )
})

完整代碼,拍着胸脯保證可用,已經在生產環境大量使用超過3個月的時間了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章