[VUEJS] 關於 Functional Component 的邪門歪道

上一回有提到動態載入的事情,既然提到了動態的元件載入,就不得不提一下 functional component 這個東西。

是說,也沒有太多神奇的地方,只是借用了 React 的東西拿來班門弄斧一下而已。


Functional Component

官方文件 有提到詳細的功能,有興趣的人可以先去看看。基本上他就是一個無狀態、不屬於任何生命週期的一種元件。

用這種元件的好處是什麼?

  1. 預先載入其他的元件
  2. 預先處理邏輯

官方的範例當中,就是提及使用他依據不同的條件,來載入不同的元件。這一點很有趣,因為我們可以做在 Vue-Route 裡面的事情,就能搬到函式化的元件裡面來做。當然,這麼做也不是沒有缺點,因為對於整個 Router 來說,你相對應的 Router Name 就會一樣,這一點可能在操作 <route-link> 的時候會造成困擾。

搭配 Vue-Route

剛剛提及了,原本在 Router 裡面做的事情,可以用函式化元件取代。舉一個簡單的例子來說,

// 原本我們的 Routes 是這個樣子
routes: [
    {
        path: '/hello/world',
        name: 'World',
        component: World
    }
]

如果我們用函式化元件來取代的話,可能會變這個樣子,

// 把原本的 World 用函式化元件取代
routes: [
    {
        path: '/:path1?/:path2?',
        name: 'FunctionalComponent',
        component: FunctionalComponent,
        // 請務必確認有加這一行,用來將路徑資料傳入元件中
        props: true
    }
]

然後你的函式化元件基本結構會長這樣,

export default {
  name: 'container',
  functional: true,
  render (createElement, context) {
      // ... 中略
  },
  props: {
      path1: {
          type: String,
          default: ''
      },
      path2: {
          type: String,
          default: ''
      }
  }
}

接著我們看 render 那邊要做什麼事情,

render (createElement, context) {
    // 簡單來說就是直接把 ./path1/path2.vue 檔案拉進來用
    return createElement(
      require(`./${context.props.path1}/${context.props.path2}.vue`).default,
      context.data,
      context.children
    )
}

當然,上述例子如果找不到檔案就會噴錯誤,所以例外還是要處理一下。例如說,沒有檔案的話就做一個 404 的元件給他顯示之類的。

壞處

這樣你的 Routes 可能永遠都只有一條,然後剛剛提到了,你的 Router Name 就只會是 FunctionalComponent 而已,其實對於之後元件內部要使用 <route-link> 的時候會是一個麻煩的地方。

邪魔歪道的實作

由於你不知道真正的路徑到底會有多少,所以你的 Routes 可能會變成這樣,

// 偷偷用 MCV 的路徑命名規則
routes: [
    {
        path: '/:component?/:action?/:params(.*)?',
        name: 'FunctionalComponent',
        component: FunctionalComponent,
        props: true
    }
]

然後你在函式化元件當中就會有 component, actionparams 三種數值可以使用,而這三個可以分別代表你要載入的元件、執行的方法以及其他參數。

實際上的作法大概會是,

// NotFound 是外部引入的,當作是找不到畫面的元件
// Homepage 也是外部引入,當作首頁
render (createElement, context) {
    const component = context.props.component
    const action = context.props.action
    
    const renderComponent () => {
        if (component === '') {
            return Homepage
        } else {
            try {
                if (action === '') {
                    return require(`./${component}.vue`).default
                } else {
                    return require(`./${component}/${action}.vue`).default
                }
            } catch (e) {
                return NotFound
            }
        }
    }
    
    return createElement(
      renderComponent(),
      context.data,
      context.children
    )
}

然後,在你的 ${component}.vue 或是 ${action}.vue 都可以拿到 props 的數值。請注意最後一個 params 的地方,由於他的正規表示式是 (.*)?,所以你會拿到類似這樣的東西,

/category/1/page/2

當然這對我們來說很難使用,所以可以用 reduce 處理一下,讓他變成陣列比較好取用。所以我們可以在元件內做一些處理,例如這樣,

export default {
    name: 'hello',
    props: {
        params: {
            type: String,
            default: ''
        }
    },
    beforeMount () {
        // 用來處理傳入的 params
        if (this.params !== '') {
            // 把他變成 { key: 0, value: 'category' } 這種格式
            this.parameters = this.params.split('/')
                .reduce((carry, item, index) => {
                    carry = carry.concat([
                        {
                            key: index,
                            value: item
                        }
                    ])
                    return carry
                }, [])
        }
    },
    data () {
        return {
            parameters: []
        }
    }
}

但是這裡有一件事情要處理,由於 beforeMount 只會發生在元件還沒被綁到 DOM 上面之前,所以,如果你的元件是可覆用的,那麼 beforeMount 就有機會不會被觸發。

所以,需要處理 沒有被觸發 的情形。

搭配 :is

這個就更邪魔了,其實不建議這樣用!但是我還是實作出了一個範例(抹臉

上一回有提到 :is 這個東西,也是可以動態更換元件。那麼,如果說我們在 ${action}.vue 裡面,將第一個 parameters 當作更細的子元件來操作,那麼我們就能用 :is 來進行。

先說缺點:

  1. :is 要呼叫方法,但是這個方法可能不會被觸發
  2. 可以再用一次 require 來把元件載入,但要處理元件失效的問題

所以如果要用 :is 的話該怎麼做?首先,我們需要一個元件,

<component :is="myComponent"></component>

接著,只要這個 ${action}.vue 被載入,然後如果第一個參數存在,就可以指定成元件,

import MyComponent from './MyComponent.vue'

export default {
    name: 'b',
    components: {
        helloWorld: MyComponent
    },
    // ... 中略
    beforeMount () {
        // ... 處理 parameters

        if (this.parameters.length > 0) {
            if (this.parameters[0] !== '') {
                // 把第一個數值指定成元件的名字
                this.myComponent = this.parameters[0] 
            }
        }
    }
}

所以這樣,當你訪問 /a/b/hello-world 的時候,就會動態載入 MyComponent.vue 了。

問題在於,如果你訪問其他路徑,再次回到 /a/b/hello-world 的時候,你會發現畫面不會更新。原因就在於 beforeMount 並不會重新被呼叫一次,也就是上一點說的 沒有被觸發 的情形。

為何 beforeMount 不會被觸發?

原因在於,你的 hello-world 元件是在 b 元件內被載入,而 b 元件本來就已經載入了,所以當你切換路徑的時候,本來就不會觸發 beforeMount,這是合理的。

所以,你可以去偷聽 $route 來做到一些改變。

export default {
    name: 'b',
    // ... 中略
    watch: {
        $route (route) {
            if (typeof route.params.params !== 'undefined') {
                // 這邊要重新處理一次 parameters
                //
                // 最後把 parameters 第一個數值取出來
                this.myComponent = this.parameters[0]
            }
        }
    }
}

但是每次都 watch 其實也不太好,最好是 beforeDestroy 的時候 unwatch 比較好。

// 所以可以在 created 的時候綁定 $watch
created () {
    // 把 $watch 指定給 this.routerWatcher
    // 這樣我們在元件消滅之前可以先 unwatch
    this.routerWatcher = this.$watch(() => this.$route, route => {
        // 剛剛上面的東西放到這裡面來
    }
},
// ... 中略
beforeDestroy () {
    // unwatch 我們剛剛的東西,或許可以省一點資源?
    this.routerWatcher()
}

結論

其實沒有比較好用,都只是一些奇技淫巧,小朋友不要學(欸

Hina Chen
偏執與強迫症的患者,算不上是無可救藥,只是我已經遇上我的良醫了。
Taipei