(文章中包含源碼和原理分析, 須要有必定的基礎, 若是看不懂能夠直接翻到最底部, 有現成的庫能夠解決問題)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
React 將原來大的 state 拆分紅一個一個小的 state, 每一個 state 是一個包含 value 和 set 方法的組合.
Vue 將整個 data 拆分紅一個一個的 value, value 的返回時是一個包裝對象, 經過讀取和修改對象的 value 屬性進行狀態的操做, 這種作法的緣由大概是 Vue 自己就是是基於對象的 setter 和 getter 特性而構建的.
React 提供 useEffect 方法, 用於組件初始化, 更新以及銷燬時作一些帶有反作用的方法, 這個方法簡化了本來須要三個生命週期函數才能完成的事情. 固然對原有的改動也比較大.
Vue 基本是將原來的 lifecycle 方法原封不動移植, 每個 lifecycle 都有對應的方法進行包裝.
看到這有同窗就要問了: 說了這麼一大堆, 怎麼還沒進入正題 ?
emmmmmm, 寫跑題了, 進入正題吧.
事情是這樣, 因爲業務規劃, 原有的一個大系統中的一部分須要拆分出來獨立成一個新系統. 這個老系統整個的結構仍是基於好久以前的腳手架作的, 而新的腳手架已經有了翻天覆地的變化. 此次遷移須要創建在新腳手架之上進行開發.
既然是新腳手架, 新的環境, 新的代碼, 那咱們爲何不進行新的嘗試呢. 因而乎, 打算在項目的一個小角落裏使用 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 拓展的場景,
作法也很簡單, 在註冊時先遍歷 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 中.
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 如今已經發布, 而且開源. 能夠經過 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 的新特性.