(文章中包含源码和原理分析, 需要有一定的基础, 如果看不懂可以直接翻到最底部, 有现成的库可以解决问题)
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
$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 的新特性.