当 vue-function-api 遇上 vuex / vue-router

1,885 阅读6分钟

(文章中包含源码和原理分析, 需要有一定的基础, 如果看不懂可以直接翻到最底部, 有现成的库可以解决问题)

2019年05月30日, Vue 的创建者尤雨溪发布了一个请求意见稿(RFC), 内容是在即将发布的 Vue 3.0 中使用函数式风格来编写 Vue 组件.

接着 Vue 开发团队放出了可以在 Vue 2.0 中使用这个特性的插件 vue-function-plugin.

这一次的变化引起了很多质疑, 与之相比当 Facebook 发布 React hooks 的时候得到了很大的好评. 那么 vue-function-api 到底好不好, 类似的改变在 vue 和 react 上为了得到了不同的反馈 ? 我也是抱着这个好奇心来亲自尝试一下.

初步对比

经过短暂的尝试, 简单总结了 vue-function-api 和 react hooks 的一些区别, 因为接触时间还短, 可能会有遗漏或不准确的地方, 还请指正.

先直观看一下区别:

React 写法

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 写法

<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 方法直接被用于渲染, 整个代码表现非常的一致.

Vue 大体依然保留 template, script, style 基本三段的写法, 当然这也是 vue 的一大优势. Vue 把原来的 data, computed, lifecycle, watch 等融合在一个 setup 函数中完成. 总体上是模板, 对象, 函数式融合的风格.

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 的新特性.