专栏首页云前端[译] 用 Typescript + Composition API 重构 Vue 3 组件

[译] 用 Typescript + Composition API 重构 Vue 3 组件

原文:https://vuejs-course.com/blog/vuejs-3-typescript-options-composition-api

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 的 refcomputed 代替 datacomputed
  • 使用 TypeScript 将 postsfilters 等改为强类型
  • 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.filterthis.$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.postany 的。这是因为在 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。

假设这里做了错误的比较: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 了一个渲染函数,因此也就不需要再暴露 selectedFilterfilteredPosts 了 -- 因为都定义在同一个局部作用域中,直接在渲染逻辑中引用就行了。

所有测试通过,重构完成。

5. 讨论

值得注意的一点是我完全没为此次重构改变原先的单元测试。这是因为测试聚焦于组件公开行为,而非内部实现逻辑。好处就在于此。

确实这样的重构并非特别有趣,且并不为用户直接带来任何商业收益,但其确实能对开发者引发一些有意思的讨论点:

  • 我们该使用 Composition API 还是 Options API?
  • 我们该使用 JS 还是 TS?

Composition API vs. Options API

这可能是从 Vue 2 转换至 Vue 3 时最大一个问题了。尽管你可以坚守 Options API,但自然会出现两个问题:“哪一种是解决某问题的最佳方案?” 以及 “哪一种适于我的团队”。

我并不想厚此薄彼。个人来说,我发现 Options API 更直观,易于教授给 JavaScript 框架的初学者。毕竟要理解 refreactive,还有在使用 ref 时需要引用 .value,都要去一个个学。而 Options API 让你仅聚焦于结构化的 computedmethodsdata 等等就好了。

也不得不说,使用 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. 翻译参考资料

  • https://frontendsociety.com/using-a-typescript-interfaces-and-types-as-a-prop-type-in-vuejs-508ab3f83480
  • https://juejin.im/post/5b3d4135e51d4519962e7a1e
  • https://chuchencheng.com/2019/07/01/%E8%AE%B0%E5%BD%95%EF%BC%9AVue%E5%A6%82%E4%BD%95%E6%AD%A3%E7%A1%AE%E6%8E%A8%E5%AF%BCProps%E7%B1%BB%E5%9E%8B/
  • https://github.com/vuejs/vue-next/blob/master/packages/runtime-core/src/h.ts

--End--

本文分享自微信公众号 - 云前端(fewelife),作者:云前端

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2020-07-08

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • Vue3 深度解析

    距离尤雨溪首次公开 Vue3 (vue-next)源码有一个多月了。青笔观察到,刚发布国庆期间,出现不少解读 Vue3 源码的文章。当然不少有追风蹭热之嫌,文章...

    我是一条小青蛇
  • 还学的动吗? 盘点下Vue.js 3.0.0 那些让人激动的功能

    路漫漫其修远兮,吾将上下而求索。——献给所有为 Vue的发展而默默付出的开发者们。

    葡萄城控件
  • 抄笔记:尤雨溪在Vue3.0 Beta直播里聊到了这些…

    在 4 月 21 日晚,Vue 作者尤雨溪在哔哩哔哩直播分享了Vue.js 3.0 Beta最新进展。以下是直播内容整理

    前端劝退师
  • Vuejs 3.0 正式版发布!One Piece. 代号:海贼王

    Vue 团队于 2020 年 9 月 18 日晚 11 点半发布了 Vue 3.0 版本。

    夜尽天明
  • 尤雨溪:重头来过的 Vue 3 带来了什么?

    在过去的一年里,Vue团队一直在开发Vue.js的下一个主要版本Vue 3,我们希望能在2020年上半年将其发布(在撰写本文时,这项开发工作正在进行中)。

    前端达人
  • 尤雨溪自述:打造Vue 3.0背后的故事

    在过去的一年中,Vue 团队一直都在开发 Vue.js 的下一个主要版本,我们希望能在今年上半年发布它(本文完成时这项工作尚在进行)。Vue 新版本的理念成型于...

    苏南
  • VUE 3.0 搞起来!

    javascript艺术
  • Vue3 + TypeScript 开发实践总结

    迟来的Vue3文章,其实早在今年3月份时就把Vue3过了一遍。 在去年年末又把 TypeScript 重新学了一遍,为了上Vue3 的车,更好的开车。 在上家公...

    小阿新
  • 「中文翻译」Vue3 的诞生之路

    因时间有限,文中翻译不对的地方还请指出,望海涵。想感受原汁原味还请移步上方链接。致敬尤大!

    童欧巴
  • vue3.0 Composition API 翻译版(超长)

    Composition API 一组基于功能的附加API,允许灵活地组成组件逻辑。

    公众号---人生代码
  • 实用!最新的几个 Vue 3 重要特性提案

    在几天前开启的 SFC Improvements #182 中,yyx990803 提交了 3 个改进开发者体验的征求意见稿。

    江米小枣
  • 《Vue3.0抢先学》系列之:网友们都惊呆了!

    今天开始,我想给大家讲点新东西。大家不用大喊学不动,请放松心情随意观看,我也讲不出什么很深奥难学的东西,本系列文章都会是些比较浅显易懂的家常内容。

    一斤代码
  • Vue 3 源码导读

    https://juejin.im/post/5d977f47e51d4578453274b3 来源:掘金

    前端达人
  • vue 随记(3):“新时代”的姿势

    •性能上:最多比vue2 快2倍•静态标记提升•proxy取代defineProperty•tree shaking:按需编译打包代码•composition ...

    一粒小麦
  • Vue 开发团队的战斗力到底有多强,让我们看看这个 PR

    事情起源于 4 月 7 号晚上,尤雨溪在推特说,Vue2 收到了一个将整个代码库迁移到 TypeScript 的 PR。

    用户3806669
  • Element UI for Vue 3.0 来了!【官方总结】

    第一个使用 TypeScript + Vue 3.0 Composition API 重构的组件库 Element Plus 发布了 ? ~

    coder_koala
  • 那个男人 他带着Vue3来了~

    其实Vue3.0版本发布的消息,我是昨天晚上刷朋友圈看到的(已经差不多凌晨 1 点了),然后我就立刻起来,打开电脑,看了一下github,把官方发布文档过了一遍...

    前端森林
  • 立等可取的 Vue + Typescript 函数式组件实战

    不同于面向对象编程(OOP)中通过抽象出各种对象并注重其间的解耦问题等,函数式编程(FP) 聚焦最小的单项操作,将复杂任务变成一次次 f(x) = y 式的函数...

    江米小枣
  • Vue3.0 beta版学习笔记

    https://github.com/vuejs/vue-next

    用户7572539

扫码关注云+社区

领取腾讯云代金券