當 vue-function-api 趕上 vuex / vue-router

(文章中包含源碼和原理分析, 須要有必定的基礎, 若是看不懂能夠直接翻到最底部, 有現成的庫能夠解決問題)javascript

2019年05月30日, Vue 的建立者尤雨溪發佈了一個請求意見稿(RFC), 內容是在即將發佈的 Vue 3.0 中使用函數式風格來編寫 Vue 組件.html

接着 Vue 開發團隊放出了能夠在 Vue 2.0 中使用這個特性的插件 vue-function-plugin.vue

這一次的變化引發了不少質疑, 與之相比當 Facebook 發佈 React hooks 的時候獲得了很大的好評. 那麼 vue-function-api 到底好很差, 相似的改變在 vue 和 react 上爲了獲得了不一樣的反饋 ? 我也是抱着這個好奇心來親自嘗試一下.java

初步對比

通過短暫的嘗試, 簡單總結了 vue-function-api 和 react hooks 的一些區別, 由於接觸時間還短, 可能會有遺漏或不許確的地方, 還請指正.react

先直觀看一下區別:git

React 寫法github

import React, { useState, useEffect } from 'react'

export function Demo () {
  const [count, setCount] = useState(0)
  const [time, setTime] = useState(new Date())

  useEffect(() => {
    const timer = setInterval(() => {
      setTime(new Date())
    }, 1000)
    return () => {
      clearInterval(timer)
    }
  })

  return (
    <span> <span>{count}</span> <button onClick={() => setCount(count + 1)}>+1</button> <span>{time.toString()}</span> </span>
  )
}
複製代碼

Vue 寫法vue-router

<template>
    <span>
        <span>{{ count }}</span>
        <button @click="addCount">+1</button>
        <span>{{ time.toString() }}</span>
    </span>
</template>

<script> import { value, onCreated, onBeforeDestroy } from 'vue-function-api' export default { name: 'Demo', components: {}, props: {}, setup(props, context) { const count = value(0) const addCount = () => { count.value++ } const time = value(new Date()) let timer = 0 onCreated(() => { timer = setInterval(() => { time.value = new Date() }) }) onBeforeDestroy(() => { clearInterval(timer) }) return { addCount, timer, } }, } </script>

<style scoped> </style>

複製代碼

代碼風格

React 的代碼更加純粹, 整個組件變成了一個函數, state 和 set 方法直接被用於渲染, 整個代碼表現很是的一致.vuex

Vue 大致依然保留 template, script, style 基本三段的寫法, 固然這也是 vue 的一大優點. Vue 把原來的 data, computed, lifecycle, watch 等融合在一個 setup 函數中完成. 整體上是模板, 對象, 函數式融合的風格.typescript

state / data

React 將原來大的 state 拆分紅一個一個小的 state, 每一個 state 是一個包含 value 和 set 方法的組合.

Vue 將整個 data 拆分紅一個一個的 value, value 的返回時是一個包裝對象, 經過讀取和修改對象的 value 屬性進行狀態的操做, 這種作法的緣由大概是 Vue 自己就是是基於對象的 setter 和 getter 特性而構建的.

lifecycle

React 提供 useEffect 方法, 用於組件初始化, 更新以及銷燬時作一些帶有反作用的方法, 這個方法簡化了本來須要三個生命週期函數才能完成的事情. 固然對原有的改動也比較大.

Vue 基本是將原來的 lifecycle 方法原封不動移植, 每個 lifecycle 都有對應的方法進行包裝.

其餘

  • 因爲 react 的純函數式特性, 致使使用 hooks 有一些特殊的限制, 如不能修改 hooks 順序, hooks 不能再 if 和 循環中 ..., 而 vue 的 setup 返回的對象對每一個元素都有命名, 不存在這個問題.
  • React 和 Vue 在函數中都建議不可以使用 this, 可是 Vue 中在 setup 中提供了 context 對象, 能夠訪問 slots, refs 等

看到這有同窗就要問了: 說了這麼一大堆, 怎麼還沒進入正題 ?

emmmmmm, 寫跑題了, 進入正題吧.

vue-function-api 遇到 vuex / vue-router

事情是這樣, 因爲業務規劃, 原有的一個大系統中的一部分須要拆分出來獨立成一個新系統. 這個老系統整個的結構仍是基於好久以前的腳手架作的, 而新的腳手架已經有了翻天覆地的變化. 此次遷移須要創建在新腳手架之上進行開發.

既然是新腳手架, 新的環境, 新的代碼, 那咱們爲何不進行新的嘗試呢. 因而乎, 打算在項目的一個小角落裏使用 vue-function-api, 和其餘組件共存.

初識大坑

當時這個頁面大概是這樣 (列出了核心部分):

const menuMaxHeight = () => {
    const userInfoHeight = this.$refs['sidebar-userInfo'] && this.$refs['sidebar-userInfo'].$el.clientHeight
    const bannerHeight = this.$refs['sidebar-banner'] && this.$refs['sidebar-banner'].$el.clientHeight
    this.menuMaxHeight = window.innerHeight - userInfoHeight - bannerHeight
}

export default {
    // ...
    data() {
        return {
            menuMaxHeight: 400,
        }
    },
    computed: {
        ...mapGetters(['menu']),
        userInfo() {
            const info = this.$store.getters.userInfo
            const env = window.ENVIRONMENT === 'preview'
                ? 'preview'
                : process.env.NODE_ENV === 'development'
                    ? 'local'
                    : process.env.NODE_ENV === 'test'
                        ? 'test'
                        : 'online'
            return {
                userName: `${info.name || ''} (${env})`,
            }
        },
    },
    mounted() {
        window.addEventListener('resize', menuMaxHeight)
        menuMaxHeight()
    },
    beforeDestroyed(){
        window.removeEventListener('resize', menuMaxHeight)
    }
    // ...
}
複製代碼

首先修改的是 menuMaxHeight, 這是一個動態獲取元素高度的而且實時同步到模板中的一個功能, 用到了 mounted, beforeDestroyed, 對 window 註冊和解綁 resize 事件.

const useMenuHeigth = (initValue, context) => {
    const menuMaxHeight = value(400)
    const calcHeight = () => {
        const userInfoHeight = context.refs['sidebar-userInfo'] && context.refs['sidebar-userInfo'].$el.clientHeight
        const bannerHeight = context.refs['sidebar-banner'] && context.refs['sidebar-banner'].$el.clientHeight
        menuMaxHeight.value = window.innerHeight - userInfoHeight - bannerHeight
    }
    onMounted(() => {
        window.addEventListener('resize', calcHeight)
    })
    onBeforeDestroy(() => {
        window.removeEventListener('resize', calcHeight)
    })
}

export default {
    // ...
    setup(props, context) {
        const menuMaxHeight = useMenuHeigth(400, context)
        return {
            menuMaxHeight
        }
    }
    computed: {
        ...mapGetters(['menu']),
        userInfo() {
            const info = this.$store.getters.userInfo
            const env = window.ENVIRONMENT === 'preview'
                ? 'preview'
                : process.env.NODE_ENV === 'development'
                    ? 'local'
                    : process.env.NODE_ENV === 'test'
                        ? 'test'
                        : 'online'
            return {
                userName: `${info.name || ''} (${env})`,
            }
        },
    },
    // ...
}
複製代碼

修改以後, 很驚喜的發現代碼清晰了不少, 原來分散到各處的代碼合併到了一個方法中, 一目瞭然.

接下來處理 userinfo, 代碼中用到了 vuex 中保存的 userInfo, 並對數據作一些轉換.

機智的我想起了, mapGetters 是須要綁定到 computed 的上, 既然 computed 寫法變了, 因此我也修改一下個人寫法, 因而代碼是這樣的:

import { mapGetters } from 'vuex'
    
    const useGetters = (getters) => {
        const computedObject = mapGetters(getters)
        Object.keys(computedObject).forEach((key) => {
            computedObject[key] = computed(computedObject[key])
        })
        return computedObject
    }
    
    // ...js
    setup(props, context) {
        const menuMaxHeight = useMenuHeigth(400, context)
        
        const { menu, userInfo: vUserInfo } = useGetters(['menu', 'userInfo'])

        const userInfo = computed(() => {
            const info = vUserInfo
            function getUsername(info) {
                const env = window.ENVIRONMENT === 'preview'
                    ? 'preview'
                    : process.env.NODE_ENV === 'development'
                        ? 'local'
                        : process.env.NODE_ENV === 'test'
                            ? 'test'
                            : 'online'
                return `${info.name || ''} (${env})`
            }
            return {
                userName: getUsername(info),
            }
        })
        
        return {
            menuMaxHeight,
            menu,
            userInfo,
        }
        
        
    }
    // ...
複製代碼

嗯, 看起來很合理

...

...

...

對方不想和你說話並拋出了一個異常

問題出在哪呢 ?

咱們知道 mapGetters 實際上是一個快捷方法, 那咱們不用快捷方法, 直接使用 this.$store 來獲取, 看看問題究竟出在哪.

const useGetters = (getters) => {
    const computedObject = mapGetters(getters)
    getters.forEach((key) => {
        computedObject[key] = computed(function getter() {
            return this.$store.getters[key]
        })
    })
    return computedObject
}
複製代碼

$store 丟了 ( router 也丟了 ) , 難怪不推薦使用 this, 既然不推薦 this, 又給咱們提供了 context, 或許在 context 裏吧, 不過仍是異想天開了, context 裏面也沒有.

爲何呢 ?

只有源碼才知道

分析大坑

看了一下源碼, 從初始化階段找到了 mixin 部分:

首先能夠看到 在 beforeCreate 階段, 判斷有沒有 setup 方法, 若是有, 則修改 data 屬性, 在讀取執行 data 的時候執行 initSetup 方法, 並傳遞了 vm, 這是 vm 中是存在 $store 的

繼續找:

setup 是直接調用的, 因此 this 確定不是 vm, ctx 是由 createSetupContext 建立

死心吧

全部屬性都是固定的, 沒有其餘拓展的方法.

再看 在 computed 執行的時候 this 裏爲何沒有 $store

initSetup 中找到 bingding 最後調用的 setVmProperty 方法進行設置.

咱們來看一下 computed 是如何建立的

咱們調用 computed(function getter() { return this.$store.getters[key] }) 的時候, getter 方法就會傳遞到 computed 這個方法中, 接下來經過 createComponentInstance 建立了一個 vue 實例, 並增長一個 $$state 的 computed 屬性.

接下來在 read 方法, 咱們猜想取 value 的時候就是調用的這個方法, 這個方法調用了 computedHost 這個對象的 $$state 屬性, 也就是說當咱們執行 getter 時, this 指向的是 computedHost 這個 vm.

因此關鍵就在 createComponentInstance

store 就在這丟了. 這個 vm 是新建出來的, 裏面除了$state 什麼都沒有 !!!!

撞牆了

另闢蹊徑

眼看着 vue-function-api 的代碼實現把路都封死了. 咱們還能怎麼辦呢.

靈光一閃, 既然 vue-function-api 能寫一個 mixin 篡改 data 方法, 我也能夠用 mixin 去篡改 setup 方法, 並把丟掉的 vm 找回來, 在執行 setup 的時候 vm 仍是完整的.

因而寫了一個 plugin

export const plugin: PluginObject<PluginOptions> = {
    install(Vue, options = {}) {
        if (curVue) {
            if (process.env.NODE_ENV !== 'production') {
                // eslint-disable-next-line no-console
                console.warn('Vue function api helper init duplicated !')
            }
        }

        function wrapperSetup(this: Vue) {
            let vm = this
            let $options = vm.$options
            let setup = $options.setup
            if (!setup) {
                return
            }
            if (typeof setup !== 'function') {
                // eslint-disable-next-line no-console
                console.warn('The "setup" option should be a function that returns a object in component definitions.', vm)
                return
            }
            // wapper the setup option, so that we can use prototype properties and mixin properties in context
            $options.setup = function wrappedSetup(props, ctx) {
                // to extend context
                
            }
        }

        Vue.mixin({
            beforeCreate: wrapperSetup,
        })
    },
}
複製代碼

這部分是否是和 vue-function-api 很像 ?

咱們要作的核心就在 wrappedSetup 這個方法裏, 在最開始咱們就經過 this 拿到了當前的 vm 對象, 因此在 wrappedSetup 咱們就能隨心所欲的使用 vm 中的屬性了.

$options.setup = function wrappedSetup(props, ctx) {
    // to extend context
    ctx.store = vm.$store
    return setup(props, ctx)
}
複製代碼

store 找回來了, 填坑成功!!!

完善

既然咱們能夠從 vm 中拿到全部丟掉的屬性, 那咱們是否是能夠作一個通用的方法, 將全部丟掉的屬性都追加到 context 中呢. 這樣既符合 vue-function-api 中 context 的使用預期, 又能夠追加以前插件丟失掉的屬性, 何樂而不爲呢.

大概想到了幾個對 vm 拓展的場景,

  • 經過 mixin 在 beforeCreate 階段像 vm 追加屬性
  • 直接經過 Vue.prototype.$xxx 賦值進行拓展

作法也很簡單, 在註冊時先遍歷 vm 和 Vue.prototype, 獲取到全部以 $ 開頭的屬性, 保存起來. 而後在 wrappedSetup 中, 對比當前 Vue.prototype 和 vm 多出來的屬性, 追加到 context 中.

export const plugin: PluginObject<PluginOptions> = {
    install(Vue, options = {}) {
        if (curVue) {
            if (process.env.NODE_ENV !== 'production') {
                // eslint-disable-next-line no-console
                console.warn('Vue function api helper init duplicated !')
            }
        }
        const pureVueProtoKeys = Object.keys(Vue.prototype)
        const pureVm = Object.keys(new Vue())

        const extraKeys = (options.extraKeys || []).concat(DEFAULT_EXTRA_KEYS)

        function wrapperSetup(this: Vue) {
            let vm = this
            let $options = vm.$options
            let setup = $options.setup
            if (!setup) {
                return
            }
            if (typeof setup !== 'function') {
                // eslint-disable-next-line no-console
                console.warn('The "setup" option should be a function that returns a object in component definitions.', vm)
                return
            }
            // wapper the setup option, so that we can use prototype properties and mixin properties in context
            $options.setup = function wrappedSetup(props, ctx) {
                // to extend context
                Object.keys(vm)
                    .filter(x => /^\$/.test(x) && pureVm.indexOf(x) === -1)
                    .forEach((x) => {
                        // @ts-ignore
                        ctx[x.replace(/^\$/, '')] = vm[x]
                    })
                Object.keys(vm.$root.constructor.prototype)
                    .filter(x => /^\$/.test(x) && pureVueProtoKeys.indexOf(x) === -1)
                    .forEach((x) => {
                        // @ts-ignore
                        ctx[x.replace(/^\$/, '')] = vm[x]
                    })
                // to extend context with router properties
                extraKeys.forEach((key) => {
                    // @ts-ignore
                    let value = vm['$' + key]
                    if (value) {
                        ctx[key] = value
                    }
                })
                // @ts-ignore
                return setup(props, ctx)
            }
        }

        Vue.mixin({
            beforeCreate: wrapperSetup,
        })
    },
}
複製代碼

中間遇到一個問題, $router 和 $route 是不可遍歷的, 會被漏掉, 因此提供 extraKeys 屬性, 默認爲['router', 'route'], 判斷 extraKeys 中全部 vm 中存在的屬性, 追加到 ctx 中.

helper

plugin 寫好以後安裝, 接下來就能夠從 context 中取咱們想要的屬性了. 不過當咱們使用 vuex 的 getter 時很麻煩, 由於 mapGetters 仍是用不了.

因而針對於 vuex 的場景封裝了 useGetters 的方法.

export function useGetters(context: SetupContext, getters: string[]) {
    const computedObject: AnyObject = {}
    getters.forEach((key) => {
        computedObject[key] = computed(() => context.store.getters[key])
    })
    return computedObject
}

複製代碼

接下來經過 useGetters(context, []) 就能夠愉快的使用 getter 了.

最後通過一系列的改造後, 在實際代碼中是這個樣子的:

const useMenuHeigth = (initValue, context) => {
    const menuMaxHeight = value(400)
    const calcHeight = () => {
        const userInfoHeight = context.refs['sidebar-userInfo'] && context.refs['sidebar-userInfo'].$el.clientHeight
        const bannerHeight = context.refs['sidebar-banner'] && context.refs['sidebar-banner'].$el.clientHeight
        menuMaxHeight.value = window.innerHeight - userInfoHeight - bannerHeight
    }
    onMounted(() => {
        window.addEventListener('resize', calcHeight)
    })
    onBeforeDestroy(() => {
        window.removeEventListener('resize', calcHeight)
    })
}

export default {
    name: 'app',
    components: {
        SkeMenu,
        SkeSideBar,
        SkeUserInfo,
        SkeSideBanner,
        breadcrumb,
    },
    setup(props, context) {
        const menuMaxHeight = useMenuHeigth(400, context)
        const { menu, userInfo: vUserInfo } = useGetters(context, ['menu', 'userInfo'])

        const userInfo = computed(() => {
            const info = vUserInfo.value
            function getUsername(info) {
                const env = window.ENVIRONMENT === 'preview'
                    ? 'preview'
                    : process.env.NODE_ENV === 'development'
                        ? 'local'
                        : process.env.NODE_ENV === 'test'
                            ? 'test'
                            : 'online'
                return `${info.name || ''} (${env})`
            }
            return {
                userName: getUsername(info),
            }
        })

        return {
            menuMaxHeight,
            menu,
            userInfo,
        }
    },
}
複製代碼

大功告成 !!!

先別急着走, 既然已經作了這麼多, 固然要封裝一個庫出來. 順便推廣一下本身, 哈哈

vue-function-api-extra

公佈一下, vue-function-api-extra 如今已經發布, 而且開源. 能夠經過 npm 或 yarn 進行安裝.

Github 地址: github.com/chrisbing/v… npm 地址: www.npmjs.com/package/vue…

歡迎下載和 star

使用方法

很簡單, 在入口的最前面, 注意必定要在其餘插件的前面安裝, 安裝 plugin, 就能夠從 context 得到全部拓展的屬性. 包括 store, router, 經過安裝組件庫得到的如 $confirm $message 等快捷方法, 本身經過 Vue.prototype 追加的變量, 均可以獲取到.

import Vue from 'vue'
import { plugin } from 'vue-function-api-extra'

Vue.use(plugin)
複製代碼
export default {
    setup(props, context){
        
        // use route
        const route = context.route
        
        // use store
        const store = context.store
        
        // use properties
        // if you run "Vue.prototype.$isAndroid = true" before
        const isAndroid = context.isAndroid
        
        return {
            
        }
    }
    
}
複製代碼

注意全部追加的屬性都必須以 "$" 開頭, 到 context 訪問的時候要去掉 $, 這一點和 vue-function-api 內置的 slots, refs 的規則保持一致

若是想要使用 vuex 中的 getters 方法, 則能夠引用 useGetters, 固然 plugin 是必定要安裝的.

import { useGetters } from 'vue-function-api-extra'

export default {
    setup(props, context){
        
        const getters = useGetters(context, ['userInfo', 'otherGetter'])

        return {
            ...getters
        }
    }
}
複製代碼

後續

後續會增長更多的 helper, 讓你們更愉快的使用 vue-function-api 的新特性.

相關文章
相關標籤/搜索