原文:vuejs-course.com/blog/vuejs-…
译注:原文作者著有 “Vue Testing Handbook”,其中文版已授权本公众号翻译为 《Vue 测试指南》中文版,欢迎参阅!
Options API、Composition API、JavaScript,以及 TypeScript -- 这些 API 和语言真能混在一起用?
本文会将使用 JavaScript 和 Options API 构建的传统结构 Vue 3 组件,重构为使用 TypeScript 和 Composition API 的版本。我们将看到一些不同之处,以及可能带来的益处。
同时因为这些既有组件拥有单元测试,我们也将观察这些测试在重构过程中是否仍有效、我们要不要改进它们。至少经验告诉我们,如果只是进行不改变组件对外行为的单纯重构,是不用改变测试的;而如果需要的话,说明你的测试并不理想,它们关注了实现细节。
1. 既有组件
我们将重构 FilterPosts
组件。鉴于 Vue Test Utils 和 Jest 尚无对 Vue.js 3 组件的官方支持,该组件使用了 render 函数编写。为照顾对其不太熟悉的读者,我将其对应的 HTML 写在了注释里。因为源码过长,先来看看其生成的基本模板结构:
<div> <h1>Posts from {{ selectedFilter }}</h1> <Filter v-for="filter in filters" @select="filter => selectedFilter = filter" :filter="filter" /> <NewsPost v-for="post in filteredPosts" :post="post" /> </div>
这个片段用渲染 <NewsPost />
子组件来展示若干新闻。用户也可以通过 <Filter />
子组件来配置他们要以何种时间优先级来浏览新闻,如点击 “Today”、“This Week” 等按钮。
并且假设有如下 mock 数据:
const posts = [ { id: 1, title: 'In the news today...', created: moment() }, { id: 2, title: 'In the news this week...', created: moment().add(4 ,'days') } ]
在重构过程中,我将介绍每个组件。在此之前,先通过测试用例来了解一下用户的交互:
describe('FilterPosts', () => { it('renders today posts by default', async () => { const wrapper = mount(FilterPosts) expect(wrapper.find('.post').text()).toBe('In the news today...') expect(wrapper.findAll('.post')).toHaveLength(1) }) it('toggles the filter', async () => { const wrapper = mount(FilterPosts) wrapper.findAll('button')[1].trigger('click') await nextTick() expect(wrapper.findAll('.post')).toHaveLength(2) expect(wrapper.find('h1').text()).toBe('Posts from this week') expect(wrapper.findAll('.post')[0].text()).toBe('In the news today...') expect(wrapper.findAll('.post')[1].text()).toBe('In the news this week...') }) })
对该组件,将讨论如下改变:
- 使用 Composition API 的
ref
和computed
代替data
及computed
- 使用 TypeScript 将
posts
、filters
等改为强类型 - JS 和 TS 的优缺点对比
2. 断言 filter
的类型并重构 Filter
组件
从最简单的组件开始并逐步推进,是很好的方式。Filter
组件如下:
const filters = ['today', 'this week'] export const Filter = defineComponent({ props: { filter: { type: String, required: true } }, render(h, ctx) { // <button @click="$emit('select', filter)>{{ filter }}/<button> return h( 'button', { onClick: () => this.$emit('select', this.filter) }, this.filter ) } })
这里主要要做的就是声明 filter
属性的类型。可以使用 TS 中的 type
(用 enum
也行) 来实现:
type FilterPeriod = 'today' | 'this week' const filters: FilterPeriod[] = ['today', 'this week'] export const Filter = defineComponent({ props: { filter: { type: String as () => FilterPeriod, required: true } }, // ... )
译注 - 关于
String as () => FilterPeriod
类型断言:考察 Vue 2 中的相关类型定义:
type Prop<T> = { (): T } | { new(...args: never[]): T & object } | { new(...args: string[]): Function }
以及 Vue 3 中类似的定义:
type PropConstructor<T = any> = | { new (...args: any[]): T & object } | { (): T } | PropMethod<T>
其实不难发现,在 Prop<T> 的 TypeScript 静态检查阶段,对于 FilterPeriod 这类 type 或 interface,因为其并非包含构造函数(new)的完整类型,所以就要用符合类型签名
{ (): T }
的形式。而之所以不能直接写
String as FilterPeriod
,因为这不符合 TS 定义, FilterPeriod 类型本身并非完整兼容 String 的,没有包含其所有方法,会报错;而用() => FilterPeriod
得到的,会被 TS 认为是合法的、并限定在定义取值范围内的字符串类型实例。同理,形如
interface User { name: string }
之于 Object,也是一样的。
相比于要代码的阅读者去搞清所谓的 String
实际仅限于合法的 filter
来说,这已经是个很大的改善了;并且结合利用 IDE 的提示功能,这也能在运行测试或运行应用之前就找到可能的输入错误。
下面把 render
函数的逻辑移入 setup
函数;通过这种方式,我们获得了对于 this.filter
和 this.$emit
更好的类型推断:
setup(props, context) { return () => h( // import { h } from 'vue' 'button', { onClick: () => context.emit('select', props.filter) }, props.filter ) }
能够获得上述类型推断改善的主要原因,就在于摆脱了 JS 中高度动态化的 this
。
听说 VSCode 的 Vue 组件插件 “Vetur” 也为 Vue 3 进行了升级,在 <template>
中都能得到类型推断,这可真棒!
经过上面的改动,测试依然通过了。下面来着手 NewsPost
组件的重构。
3. 断言 post
类型并重构 NewsPost
组件
作为另一个很简单的组件,NewsPost
长这个样子:
export const NewsPost = defineComponent({ props: { post: { type: Object, required: true } }, render() { return h('div', { className: 'post' }, this.post.title) } })
经过刚才的重构,你应该注意到了 this.post.title
是未标记准确类型的 -- 如果在 VSCode 中打开这个组件,它会提示 this.post
是 any
的。这是因为在 JavaScript 推断 this
难于登天。并且,type: Object
对于大部分类型定义也是不准确的。它都有什么属性?让我们通过定义一个 Post
接口来解决这个问题:
interface Post { id: number title: string created: Moment }
把接口用上,然后将 render
函数逻辑迁移到 setup
:
export const NewsPost = defineComponent({ props: { post: { type: Object as () => Post, required: true }, }, setup(props) { return () => h('div', { className: 'post' }, props.post.title) } })
再用 VSCode 打开的话,就能看到 props.post.title
被推断出它正确的类型了。
4. 更新 FilterPosts
组件
现在只剩下一个组件了 -- 顶层的 FilterPosts
。组件代码如下:
export const FilterPosts = defineComponent({ data() { return { selectedFilter: 'today' } }, computed: { filteredPosts() { // 译注:此处的 posts 即文章开头的 mock 数据,不必深究 return posts.filter(post => { if (this.selectedFilter === 'today') { return post.created.isSameOrBefore(moment().add(0, 'days')) } if (this.selectedFilter === 'this week') { return post.created.isSameOrBefore(moment().add(1, 'week')) } return post }) } }, // <h1>Posts from {{ selectedFilter }}</h1> // <Filter // v-for="filter in filters" // @select="filter => selectedFilter = filter // :filter="filter" // /> // <NewsPost v-for="post in posts" :post="post" /> render() { return ( h('div', [ h('h1', `Posts from ${this.selectedFilter}`), filters.map(filter => h(Filter, { filter, onSelect: filter => this.selectedFilter = filter })), this.filteredPosts.map(post => h(NewsPost, { post })) ], ) ) } })
先从移除 data
函数,并在 setup
中将 selectedFilter
定义为一个 ref
开始。ref
支持泛型,可以用 <>
传入一个类型。这样 ref
就能知道何种值可以被赋给 selectedFilter
了:
setup() { const selectedFilter = ref<FilterPeriod>('today') return { selectedFilter } }
测试通过,然后将 computed
函数 filteredPosts
移入 setup
:
const filteredPosts = computed(() => { return posts.filter(post => { if (selectedFilter.value === 'today') { return post.created.isSameOrBefore(moment().add(0, 'days')) } if (selectedFilter.value === 'this week') { return post.created.isSameOrBefore(moment().add(1, 'week')) } return post }) })
这部分可改动的不大 -- 唯一变化的只是用 selectedFilter.value
代替了 this.selectedFilter
。通过 value
实际上访问到的是 Proxy
对象,这是 Vue 3 中被用来实现反应式特性的 ES6 JavaScript API。
延伸阅读:全面梳理JS对象的访问控制及代理反射
假设这里做了错误的比较: selectedFilter.value === 'this year'
,并在 VSCode 中打开组件,将看到一个编译错误提示。正是因为之前用泛型限定了 FilterPeriod
类型,所以这类错误才能被 IDE 或编译器捕获。
最终的 setup
如下:
return () => h('div', [ h('h1', `Posts from ${selectedFilter.value}`), filters.map(filter => h(Filter, { filter, onSelect: filter => selectedFilter.value = filter })), filteredPosts.value.map(post => h(NewsPost, { post })) ], )
我们从 setup
中 return 了一个渲染函数,因此也就不需要再暴露 selectedFilter
和 filteredPosts
了 -- 因为都定义在同一个局部作用域中,直接在渲染逻辑中引用就行了。
所有测试通过,重构完成。
5. 讨论
值得注意的一点是我完全没为此次重构改变原先的单元测试。这是因为测试聚焦于组件公开行为,而非内部实现逻辑。好处就在于此。
确实这样的重构并非特别有趣,且并不为用户直接带来任何商业收益,但其确实能对开发者引发一些有意思的讨论点:
- 我们该使用 Composition API 还是 Options API?
- 我们该使用 JS 还是 TS?
Composition API vs. Options API
这可能是从 Vue 2 转换至 Vue 3 时最大一个问题了。尽管你可以坚守 Options API,但自然会出现两个问题:“哪一种是解决某问题的最佳方案?” 以及 “哪一种适于我的团队”。
我并不想厚此薄彼。个人来说,我发现 Options API 更直观,易于教授给 JavaScript 框架的初学者。毕竟要理解 ref
、reactive
,还有在使用 ref
时需要引用 .value
,都要去一个个学。而 Options API 让你仅聚焦于结构化的 computed
、methods
和 data
等等就好了。
也不得不说,使用 Options API 时很难发挥出 TypeScript 的完整威力 -- 这也是引入 Composition API 的原因之一。这也引申出了下一个我想讨论的点......
Typescript vs. JavaScript
我感觉 TypeScript 初期的学习曲线可是有点高,但现在用 TS 写应用时我已经乐在其中。TS 帮助我捕获了很多 bugs,也让事情变得更简单,原因在于 -- 仅知道 prop
是一个 Object
而不知道对象具体有哪些属性,和什么都不知道也差不离,特别是当它还可以为空的时候。
另一方面,在学习一个新概念、构建一个原型,或只是尝试一个新工具库的时候,我仍然爱用 JavaScript。其不用什么构建步骤就能在浏览器中编写并运行的能力非常实用,并且在尝试某些东西时我也不是很关心特殊类型或泛型等。刚开始学 Composition API 时就是这样 -- 只要在一个 <script>
标签中构建一些小原型就好了。
一旦熟习了某个工具库或设计模式,并对要解决的问题心中有数,我就更倾向于使用 TypeScript 了。考虑到 TypeScript 的广泛应用、和其他流行的强类型语言的相似性,以及其带来的若干好处的话,再去用 JavaScript 编写大型、复杂的应用似乎就显得缺乏专业性了。TypeScript 益处良多,特别是定义复杂商业逻辑或在团队中扩展代码库时。
如果构建一些主要使用 CSS 动画的操作、SVG,或只是使用 Vue 完成 Transition
、基本数据绑定、动画钩子之类的事情,常规的 JavaScript 还是合适的。
总之,我更喜欢 TypeScript 多一点,由此带来对 Composition API 也更推崇 -- 并非因其比之于 Options API 更直观简介,而是它能让我更有效地运用 TypeScript。
6. 总结
本文展示并讨论了:
- 渐进地向一个常规 JS 组件中添加类型
- 好的测试聚焦行为而非实现细节
- TypeScript 的好处
- Options API vs. Composition API
7. 翻译参考资料
- frontendsociety.com/using-a-typ…
- juejin.cn/post/684490…
- chuchencheng.com/2019/07/01/…
- github.com/vuejs/vue-n…