基于函数的api是怎样来解决逻辑复用问题
如果你对Vue.js感兴趣,那么你应该知道Vue3马上就要发布了(如果你在将来来读我这篇文章,那我希望他仍然是有用的)。新版本仍在积极开发中,不过所有新功能都能在RFC仓库中找到。其中有一项是function-api,这将会较大地改变开发vue app的“姿势”。
阅读这篇文章的读者应该要有点javascript和vue经验
当前的api有些什么问题?
最好的方法是用一个例子来说明问题。比如我们现在需要实现一个组件,它能够获取数据,显示loading状态以及一个会根据页面滚动而变化的topbar。效果如下:
demo演示
一个比较好的做法是把公用的逻辑提取出来给别的组件重用。使用当前Vue2的API,比较常用的做法是:
- Mixins (通过mixins选项)
- Higher-order components (HOCs) 高阶组件
我们把跟踪滚动的逻辑放到mixin里,把获取数据的逻辑放到高阶组件里。一个典型的实现如下。
Scroll mixin:
const scrollMixin = {
data() {
return {
pageOffset: 0
}
},
mounted() {
window.addEventListener('scroll', this.update)
},
destroyed() {
window.removeEventListener('scroll', this.update)
},
methods: {
update() {
this.pageOffset = window.pageYOffset
}
}
}
在这里我们添加scroll事件监听,和一个用来保存页面滚动值的pageOffset。
higher-order component:
import { fetchUserPosts } from '@/api'
const withPostsHOC = WrappedComponent => ({
props: WrappedComponent.props,
data() {
return {
postsIsLoading: false,
fetchedPosts: []
}
},
watch: {
id: {
handler: 'fetchPosts',
immediate: true
}
},
methods: {
async fetchPosts() {
this.postsIsLoading = true
this.fetchedPosts = await fetchUserPosts(this.id)
this.postsIsLoading = false
}
},
computed: {
postsCount() {
return this.fetchedPosts.length
}
},
render(h) {
return h(WrappedComponent, {
props: {
...this.$props,
isLoading: this.postsIsLoading,
posts: this.fetchedPosts,
count: this.postsCount
}
})
}
})
这里两个属性isLoading,posts分别用来初始化loading状态以及posts数据。fetchPosts方法在组件创建后每次props.id变化的时候会被调用,以便获取新id的数据。
虽然这不算是个很完整的高阶组件,不过对于这个例子来说已经够用了。现在我们就来包装一个目标组件,并且传入这个目标组件的props。
目标组件是这样的:
// ...
<script>
export default {
name: 'PostsPage',
mixins: [scrollMixin],
props: {
id: Number,
isLoading: Boolean,
posts: Array,
count: Number
}
}
</script>
// ...
然后需要通过刚才的HOC来包装一下,这样就能获取到需要的props:
PostsPageOptions: withPostsHOC(PostsPageOptions)
所有源代码能在这里找到,【译注】注意这里面是整个项目的源代码,还包括了后面改进版本的代码,这里的组件scrollMixin是在那个src/components/PostsPageOptions.vue里,HOC是在src/App.vue里的withPostsHOC,这里的目标组件就是PostsPageOptions
好了,我们刚刚使用mixin和HOC来实现了我们的任务,并且mixin和HOC还能够被别的组件通用。但是并不是一切都是那么美好,仍有一些问题在里面。
1.命名冲突
想象一下我们在目标组件里添加update方法的时候:
如果你再打开页面,并且滚动的时候,这个topbar不会再显示了。这是因为我们重写了mixin里的update方法(【译注】而且还不会有报错)。同样的事情也会在那个高阶组件里发生,如果你在data里把fetchedPosts改成posts:
。。。你将会得到这样的错误:
这是因为目标组件已经有posts了。
2.来源不明确
如果你以后在目标组件中想用另一个mixin的话:
// ...
export default {
name: 'PostsPage',
mixins: [scrollMixin, mouseMixin],
// ...
你能说清楚pageOffset这个属性是由哪个mixin带来的么?或者还有一个场景,例如两个mixins都能有yOffset这个属性或方法,那么后一个mixin将会覆盖掉前一个的yOffset。这样就不太好了,而且还会带来许多不可预料的bugs。
3.性能
高阶组件的另一个问题是,我们仅仅为了逻辑重用,就需要为每个目标组件来创建一个高阶组件实例,这样的创建会带来性能损失。
让我们“setup”
让我们看看下一代Vue能给我们提供什么样的替代方案,以及我们该如何使用function-based API来解决这个问题。
虽然Vue 3还没有发布,不过有个helper插件已经有了-vue-function-api。这样就能够在Vue2中使用Vue3的函数api,来开发下一代Vue应用。
第一步,我们需要装一下:
$ npm install vue-function-api
以及在代码里显式地通过Vue.use()来使用:
import Vue from 'vue'
import { plugin } from 'vue-function-api'
Vue.use(plugin)
这个添加的function-based API提供了一个新的组件方法setup()。顾名思义,在这个函数里我们就能够使用新的API来设置我们组件的逻辑。来,让我们来实现一下刚刚那个能够按照滚动来变化的topbar。简单的组件例子如下:
// ...
<script>
export default {
setup(props) {
const pageOffset = 0
return {
pageOffset
}
}
}
</script>
// ...
注意:setup函数的第一个参数是解析过的并且是响应式的props对象。我们在这里还返回了包含pageOffset属性的对象,暴露给render的作用域使用(简单理解为可以在写dom时候当作绑定变量使用)。这个pageOffset属性也是响应式的,不过只在render的作用域内有效。我们就能像以前一样写模板:
<div class="topbar" :class="{ open: pageOffset > 120 }">...</div>
不过这个属性应该在每次页面滚动时都会变化。所以我们还需要在组件mounted的时候添加一个滚动事件监听,并且在unmounted的时候删除监听。而value,onMounted,onUnmounted这3个API就是做这件事的:
// ...
<script>
import { value, onMounted, onUnmounted } from 'vue-function-api'
export default {
setup(props) {
const pageOffset = value(0)
const update = () => {
pageOffset.value = window.pageYOffset
}
onMounted(() => window.addEventListener('scroll', update))
onUnmounted(() => window.removeEventListener('scroll', update))
return {
pageOffset
}
}
}
</script>
// ...
其实可以注意到,2.x版本中的每个生命周期函数在setup()函数里都有一个onXXX与其对应。
你可能也注意到了pageOffset变量(【译注】被value api函数初始化后)仅有一个响应式属性:.value。因为像number和string这类基本类型在js中不是按照引用传递的,所以我们需要用这个value来包一下。这个value包装器能够为任何可变的类型提供响应式功能。(【译注】这里的响应式应该就是指一但值有变化,就能够做出响应,比如dom改变等)
这里是包装后的pageOffset的样子:
接下来实现一下数据获取。就像使用普通选项类api(option-based API)一样,你能够(在setup里)使用function-based API来申明computed values和watchers
// ...
<script>
import {
value,
watch,
computed,
onMounted,
onUnmounted
} from 'vue-function-api'
import { fetchUserPosts } from '@/api'
export default {
setup(props) {
const pageOffset = value(0)
const isLoading = value(false)
const posts = value([])
const count = computed(() => posts.value.length)
const update = () => {
pageOffset.value = window.pageYOffset
}
onMounted(() => window.addEventListener('scroll', update))
onUnmounted(() => window.removeEventListener('scroll', update))
watch(
() => props.id,
async id => {
isLoading.value = true
posts.value = await fetchUserPosts(id)
isLoading.value = false
}
)
return {
isLoading,
pageOffset,
posts,
count
}
}
}
</script>
// ...
这个computed value和2.x中的computed属性一样:只有在他依赖的值发生变化的时候才会重新计算。watch的第一个参数被称为"source",可以是以下这些值:
- 一个getter函数
- 一个value包装器
- 一个包含以上两种类型的array(【译注】应该是用来监听多个值时使用的吧)
第二个参数是一个回调函数,这个函数会在第一个参数里的值有变化后回调。
我们刚刚使用function-based API来实现了这个目标组件。接下来我们要将这个逻辑变的可重用。
拆分解耦
这里比较有意思,为了逻辑重用,我们可以把一些逻辑提取出来放进所谓的“composition function”并且返回可响应的state。
// ...
<script>
import {
value,
watch,
computed,
onMounted,
onUnmounted
} from 'vue-function-api'
import { fetchUserPosts } from '@/api'
function useScroll() {
const pageOffset = value(0)
const update = () => {
pageOffset.value = window.pageYOffset
}
onMounted(() => window.addEventListener('scroll', update))
onUnmounted(() => window.removeEventListener('scroll', update))
return { pageOffset }
}
function useFetchPosts(props) {
const isLoading = value(false)
const posts = value([])
watch(
() => props.id,
async id => {
isLoading.value = true
posts.value = await fetchUserPosts(id)
isLoading.value = false
}
)
return { isLoading, posts }
}
export default {
props: {
id: Number
},
setup(props) {
const { isLoading, posts } = useFetchPosts(props)
const count = computed(() => posts.value.length)
return {
...useScroll(),
isLoading,
posts,
count
}
}
}
</script>
// ...
注意这里我们用了useFetchPosts and useScroll这两个函数并返回了可响应的属性。这两个函数能够独立保存成文件并且给其他组件使用。我们和之前option-based的方案对比一下:
- 暴露给外面使用的属性具有明确的来源,因为它们是作为组合函数的返回值返回出来的。
- 在组合函数里返回的值都可以随便命名,不会存在命名冲突。(【译注】从上面两个函数可以看出来,在函数里的命名如pageOffset不会对外面造成污染,而只要保证目标组件使用的时候没有冲突就行了)
- 代码重用之后也不需要有new组件实例。(【译注】对应上面说的高阶组件的额外开销)
还有很多其他优点能在官方RFC找到
文章所有源代码在这里
demo演示地址在这里
总结
可以看到,Vue的function-based API可以干净、灵活地来开发组成组件之间或组件内部的逻辑,而不会有传统基于option-based API开发带来的副作用。想象一下,基于composition functions的这种开发是十分强大的,并且能够应对任何类型的项目——从小到大,组成复杂的web应用。
我希望这篇文章能够起到点作用。如果你有任何想法和疑问,请在下面留言!我很乐意回答。谢谢。