阅读 1114

[译]Vue.js 3:面向未来编程(function-based API)

原文:Vue.js 3: Future-Oriented Programming,by Taras Batenkov

—— function-based API 是如何解决逻辑重用问题的

如果你在使用 Vue.js,那么可能知道这个框架的第 3 版就要出来了(如果你是在本篇文章发布后的一段时间看到这段话的话,我希望我的说法还是中肯的😉)。新版本目前正在积极开发中,所以可能要加入的特性都可以在官方的 RFC(request for comments)仓库中看到:github.com/vuejs/rfcs 。其中有一个特性 function-api,将会在很大程度上影响我们未来 Vue 项目的编写方式。

本篇文章旨在帮助那些有 JavaScript 和 Vue 编写经验的人们。

当前 API 存在的问题

最好的学习方式就是看例子了,假设我们需要实现如下页面中的组件。这个组件根据当前的滚动偏移,拉取用户数据、展示加载状态、显隐顶部的 topbar。

线上 demo 地址看 这里

把出现在多个组件中的相同逻辑提炼出来是个不错的实践。如果使用 Vue 2.x API 的话,有两个我们可以使用的模式:

  1. Mixin(通过 mixins 选项)🍹
  2. 高阶组件(Higher-order components,即 HOC)🎢

接下来,我们将追踪滚动的逻辑封装在 Mixin 中,获取数据的逻辑封装在高阶组件中。下面是通用实现方式:

滚动 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
        }
    }
}
复制代码

此 Mixin 中注册了 scroll 事件监听器,追踪当前页面的滚动偏移,并将偏移值记录在 pageOffset 中。

高阶函数逻辑如下:

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
            }
        })
    }
})
复制代码

这里的 isLoadingposts 表示初始加载状态和获取到的文章列表。每当组件实例创建或 props.id 发生改变时,就会调用 fetchPosts 方法,来获取新 id 对应的文章内容。

这并非是一个完整 HOC 的例子,但对于本文说明,已经足够了。这里只是简单地封装了目标组件,并将获取相关的 props 与原始的 props 一起传给了目标组件。

目标组件长这样:

// ...
<script>
export default {
    name: 'PostsPage',
    mixins: [scrollMixin],
    props: {
        id: Number,
        isLoading: Boolean,
        posts: Array,
        count: Number
    }
}
</script>
// ...
复制代码

为了获得 props,我们使用上面的高阶组件进行封装:

const PostsPage = withPostsHOC(PostsPage)
复制代码

完整组件代码 查看这里

OK!我们已经用 Mixin 配合高阶组件完成了我们的任务。当然,它们还可以在其他组件里使用。但并非一切都那么美好,还是存在一些问题的。

1. 命名冲突⚔️

假设我们组件里新添了一个 update 方法:

// ...
<script>
export default {
    name: 'PostsPage',
    mixins: [scrollMixin],
    props: {
        id: Number,
        isLoading: Boolean,
        posts: Array,
        count: Number
    },
    methods: {
        update() {
            console.log('some update logic here')
        }
    }
}
</script>
// ...
复制代码

现在打开页面开始滚动,发现 topbar 不出现了。这是因为 Mixin 中的 update 被组件的同名方法覆盖了。同样的问题在高阶组件中也存在。比如,我们把 fetchedPosts 改成 posts 就有问题:

const withPostsHOC = WrappedComponent => ({
    props: WrappedComponent.props, // ['posts', ...]
    data() {
        return {
            postsIsLoading: false,
            posts: [] // fetchedPosts -> posts
        }
    },
    // ...
复制代码

会报错:

错误原因是目标组件中已经声明了一个叫 posts 的 prop 了。

2. 来源不明📦

随着业务逻辑的增加,组件中增加了一个 Mixin——mouseMixin

// ...
<script>
export default {
    name: 'PostsPage',
    mixins: [scrollMixin, mouseMixin],
// ...
复制代码

现在你还能分得清楚 pageOffset 是来自哪个 Mixin 吗?或者换个场景,两个 Mixin 中都有可能包含一个 yOffset。那么后一个 Mixin 中定义的将会覆盖前一个的。这不太行,而且还会触发意料之外的 Bug。😕

3. 性能⏱

高级组件带来的问题是,我们为了重用逻辑,多了一个额外包装组件的开销。

来,一起“setup”!🏗

为了解决上面的问题,Vue.js 3 引入了一个可选方案 function-based API。

Vue.js 3 尚未发布,不过现在可以以插件的形式引入此功能——vue-function-api。此插件让 Vue2.x 能提前使用下一代方案解决问题。

首先安装:

$ 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 功能 setup 我们的组件逻辑。现在,让我们实现一个根据滚动偏移量显示 topbar 的功能吧。

// ...
<script>
export default {
  setup(props) {
    const pageOffset = 0
    return {
      pageOffset
    }
  }
}
</script>
// ...
复制代码

注意,setup 函数接收一个解析 props 对象作为参数,并且是响应式的。我们返回了一个包含 pageOffset 属性的对象,暴露给模板渲染上下文,这个属性也是响应式的,但仅在渲染上下文中是这样。我们可以这样使用:

<div class="topbar" :class="{ open: pageOffset > 120 }">...</div>
复制代码

但是这个属性应该在每一次发生 scroll 事件的时候被修改。为了实现这个效果,我们需要在组件挂载的时候添加 scroll 事件监听器,而在卸载的时候,移除此事件监听器。为了达到这些目的,需要用到 function-based API 为我们提供的 valueonMountedonMounted 函数:


// ...
<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 版本中提供的所有生命周期钩子函数,在这里都有 onXXX 函数与之对应,并可以在 setup() 中使用。

你可能注意到了,变量 pageOffset 变量包含一个响应式属性 .value。之所以使用包装对象(Value wrappers),是因为 JavaScript 中像数值、字符串这样的基本类型值不是通过引用传递的,而是通过值赋值的方式。而包装对象就提供了为任何类型值提供可修改和可响应的方案。

pageOffset 对象看起来是这样的:

下一步是实现获取用户数据。同样,与基于选项的 API 类似,function-based API 同样提供了声明计算属性和 Watcher 的 API:

// ...
<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>
// ...
复制代码

此处的计算属性与 2.x 中的提供的计算属性行为类似:会跟踪依赖,如果依赖的值发生改变,就会重新计算。传递给 watch 的第一个参数称为“源”,可以为以下类型之一:

  • 一个 getter 函数
  • 一个包装对象
  • 一个包含以上两种类型成员的数组

第二个参数是一个回调函数,当第一个参数返回值发生改变后,就会调用。

我们现在使用 function-based API 实现了目标组件。下一步再来看如何使逻辑能够重用吧。

分解🎻 ✂️

这一部分非常有趣,将与某块逻辑相关的代码提炼出来并重用,需要用到“组合函数”(composition function),我们在函数中返回响应式状态变量。

// ...
<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>
// ...
复制代码

现在我们使用 useFetchPostsuseScroll 函数返回响应式属性。这些函数可以被存储在单独的文件中,可以被任何需要的组件使用,与基于选项的方式比较发现:

  • 暴露给模板的属性来源清晰,因为这些属性都是从组合函数中返回的;
  • 组合函数里的可以任意命名,而不用担心会跟外部的命名冲突;
  • 为了重用逻辑,我们无需再额外提供一个组件实例了。

官方 RFC 页面 我们还可以看到更多的关于使用此方案给我们带来的好处。

本篇文章的所有代码 在这里 可以找到。

在线组件实例可以 在这里 找到。

总结

如你所见,Vue function-based API 相较于基于选项的 API,是一种更加干净和灵活的在组件内部和组件之间组合逻辑的方案。想象一下组合功能对于任何类型的项目——从小型到大型或复杂的 Web 应用程序,是真的强大。🚀

希望本篇文章能帮到你🎓,如果你有任何想法或问题,请在下方回复和评论!我将乐意解答🙂,谢谢!

(完)