关于Vue3的一些使用心得

最近在学习Spring与Vue3,于是使用二者实现了一个简易的放置游戏,本文主要整理了使用Vue3的一些使用心得,主要包括与Vue2差异和新的概念。

<!--more-->

这个放置游戏的前后端源码都放在github上面了,目前还没有完全实现...

参考

1. 逻辑复用

1.1. 从Mixin到到Composition API

在Vue2中的组件生命周期函数存在一个比较影响逻辑复用的地方是:如果某个被多个组件公用逻辑需要在特定的时机调用(如mounted时),

  • 一种方案是每个组件自己控制公共方法的调用(不符合DRY)
  • 第二种方案是通过Mixin,将公共调用的逻辑封装在mixin中,然后每个组件注入该mixin

mixin的问题在于:每个 mixin 都可以定义自己的 props、data,它们之间是无感的,所以很容易定义相同的变量,导致命名冲突;此外组件的部分逻辑交给mixin,导致组件本身的数据来源不清晰

如果为了高度的复用,将各业务逻辑都拆分到不同的mixin中,势必会造成每个组件被注入了大量的mxin,对于后期维护而言十分麻烦。

更合理的做法是由这些被复用的公共接口自己决定相关的逻辑调用时机,而不是让每个组件负责调用。

Vue3的Composition API提供了一个新的复用逻辑的功能,最直观的好处是可以把业务逻辑放在一个地方,通过纯JS的方式实现,而不需要考虑这堆业务逻辑之外的东西,

下面是一个通过Composition API实现用户管理的接口,包含:获取用户列表、删除用户、编辑用户、新增用户等操作

// api/userList.js
import {onMounted, ref} from 'vue'

export default function () {
    const list = ref([])

    // 获取用户列表
    const fetchUserList = () => {
        return new Promise((resolve) => {
            // 模拟接口获取数据
            const arr = [
                {id: 1, name: 'x 1'},
                {id: 2, name: 'x 2'},
                {id: 3, name: 'x 3'},
            ]
            setTimeout(() => {
                resolve(arr)
            })
        })
    }
    // 删除
    const removeUser = (row) => {
        return new Promise((resolve) => {
            setTimeout(() => {
                let index = list.value.indexOf(row)
                list.value.splice(index, 1)
                // ... 调用api删除
                resolve(true)
            })
        })
    }

    // ... 省略其他操作如添加、编辑等
    onMounted(() => {
        fetchUserList().then(arr => {
            list.value = arr
        })
    })

    return {
        list,
        removeUser
    }
}

而在Vue2通过选项实现的Mixin(或组件)中,我们必须把list放在data选项中,把fetchUserList和removeUser等方法放在methods中,然后在mounted的声明周期钩子中调用this.fetchUserList啥的实现一个完整的业务逻辑。每段代码逻辑上是相关的,但在物理层面上可能相隔的非常远。

Vue3将组件的声明周期函数拆分成了onMountedonUpdated等接口(参考:Vue3 Lifecycle Hooks),以及watch()computed()等接口替代之前的选项监视器和计算属性。这为我们在接口内部控制逻辑调用时机提供了极大的便利。这样一来,每个接口都只需要像各个组件暴露他们所需的数据和方法即可。

从上面的userList接口实现中可以看出,这个接口不包含特定组件相关的任何代码,因此理论是是可以在多个地方进行复用的。所以每个组件在使用组合API时,只需要拿到对应的数据和接口,组装模板代码即可

<template>
  <ul>
    <li v-for="item in list" :key="item.id">
      {{ item.name }}
      <button @click="removeUser(item)">x</button>
    </li>
  </ul>
</template>

<script>
import {toRef} from 'vue'
import useUserList from './api/userList'

export default {
  name: 'App',
  setup() {
    const {list, removeUser} = useUserList()
    return {
      list,
      removeUser
    }
  }
}
</script>

这样一来,公共API中都是相同关注点的业务逻辑,每个组件自己的数据来源也非常清晰

1.2. 在Vue2使用Composition API

目前Vue3发布的版本较新,可能不太稳定,因此不是很推荐直接把较稳定的Vue2项目直接升级到Vue3;反之,可以在现有的项目中逐步使用composition-api,实现渐进式升级

首先安装@vue/composition-api,然后通过插件的形式注册到Vue2上

import compositionApi from '@vue/composition-api'
Vue.use(compositionApi)

接下来就可以现有项目中使用大部分新特性了。后续如果想要升级到vue3,根据文档描述,直接将@vue/composition-api替换成vue库即可

When you migrate to Vue 3, just replacing @vue/composition-api to vue and your code should just work.

1.3. 在其他项目中使用Composition API

Composition API 是一组低侵入式的、函数式的 API,使得我们能够更灵活地组合业务逻辑,其暴露的接口如refreactivewatchcomputed等,完全可以用在非Vue的环境下,作为一个响应式代理库

2. ref与Reactive

参考Ref vs. Reactive

ref主要的功能是将基础类型的值具有响应性

reactive主要的功能是将一个对象转变成可响应式的,其缺点在于响应式的对象如果被解构或展开,则相关的属性会丢失响应性,可以通过toRefs(reactive(xxx))来处理

使用ref风格的接口

import { onMounted, onUnmounted, ref } from "vue";

function useMouse() {
  const x = ref(0);
  const y = ref(0);
  const update = (e) => {
    x.value = e.pageX;
    y.value = e.pageY;
  };

  onMounted(() => {
    window.addEventListener("mousemove", update);
  });
  onUnmounted(() => {
    window.removeEventListener("mousemove", update);
  });

  return { x, y }
};

export default useMouse;

使用

import useMouse from "./use/mouse";
export default {
  setup() {
    const {x, y} = useMouse()
    return {
      x,y
    }
  }
}

使用reactive风格的接口

import {onMounted, onUnmounted, ref, toRefs, reactive} from "vue"

function useMouse2() {
    const pos = reactive({
        x: 0,
        y: 0
    })
    const update = (e) => {
        pos.x =e.pageX
        pos.y = e.pageY
    };

    onMounted(() => {
        window.addEventListener("mousemove", update);
    });
    onUnmounted(() => {
        window.removeEventListener("mousemove", update);
    });

    // return pos // 如果使用这种方式,则不能对pos使用解构赋值
    return toRefs(pos)
}

export default useMouse2;

使用方式


import useMouse from "./use/mouse";
export default {
  setup() {
    const pos = useMouse2()
    // const {x, y} = pos // 因为useMouse2返回的是toRefs包装的对象,因此可以使用解构赋值
    return {
      pos
    }
  }
}

可以看见,这两个接口影响着组合API的返回值,也影响着我们组织composition api的风格

  • 使用ref声明单个数据,更新时需要通过x.value来更新,语法上略显繁琐
  • 使用reactive可以批量响应化多个数据,但必须小心翼翼地保持对响应式对象的引用,不能使用解构赋值;或者使用toRefs来包裹

二者均有各自的使用场景,关于他们的使用时机,我的理解是不应该影响在setup中使用这些api的组件,因此如果使用reactive,尽量都使用toRefs来包裹对象

3. 组件封装

3.1. 无法使用this

Vue2中由于组件模板存在渲染上下文,组件的属性、方法等都会隐式地绑定到this上,这在某些时候会造成一些困惑和错误,比如箭头函数的使用

{
  methods:{
    add:()=>{
      this.count++ // this绑定到外层而不是vm组件
    }
  }
}

Vue3避免了这种做法,在setup方法中无法通过this.xxx访问到datamethods设置propsattrs等属性,以及this.$emitthis.$slots等接口;setup第二个参数传入了一个简化版context,可以访问emitattrsslots等接口

export default {
  setup(props, {attrs, emit, slots}){
    emit('init', 123)
  },
}

3.2. 全局的render函数

Vue中有一类特定封装组件的技巧是:直接通过render函数(或JSX)来动态编写组件。在Vue2中,render函数接收的第一个参数hthis.$createElement简写,但在Vue3中,h方法需要通过Vue模块直接引入

import { h } from 'vue'

参考:vue3:render function

同时通过该方法的第二个配置参数发生了一些变化,不再需要attrsprops等配置项,而是直接将所有属性放在该属性上

h('div', {
  title: 'this is title',
  attrs: {},
}, 'Hello')

比如上面这种写法会被渲染成

<div title="this is title" attrs="[object Object]">Hello</div>

此外接收的事件等方法貌似也要添加on前缀,比如组件抛出了finish事件,需要通过onFinish才能监听到。可以从vue-next源码上看见参数的改动

export const createVNode = (__DEV__
  ? createVNodeWithArgsTransform
  : _createVNode) as typeof _createVNode

function _createVNode(
  type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
  props: (Data & VNodeProps) | null = null,
  children: unknown = null,
  patchFlag: number = 0,
  dynamicProps: string[] | null = null,
  isBlockNode = false
): VNode {

  const vnode: VNode = {
    // ...省略其他配置项
    type,
    props,
    key: props && normalizeKey(props),
    ref: props && normalizeRef(props),
  }
  return vnode
}

可以看见,现在第二个参数会直接作为vnode上面的props配置项,在后续mounted时再参与真正的解析。

此外,由于Vue3改写了整个Vue对象,因此也无法通过this.$parentthis.children等方法来获取相关的组件实例和属性等操作,这导致之前使用$slots封装诸如Tab选项卡、FormFormItem表单校验等组件的方式需要调整。

4. 使用Vuex

一些观点认为在Vue3中,我们甚至不需要使用Vuex之类的数据流管理工具了,

其原因是Vue3引入的[composition-api provide与inject](https://v3.vuejs.org/guide/composition-api-provide-inject.html#scenario-background),与组件配置项的provide/reject不同,组合式API的provideinject`函数

  • 在根组件的setup方法中通过provide函数声明需要共享的状态
  • 在后代组件的setup方法中通过inject获取对应的状态即可

这种方式确实能帮助我们共享数据,但也存在一些缺点

  • 无法使用现有Vuex的时间旅行和快照等功能,
  • 需要自己通过添加setter等方式修改共享状态,自己保证数据可预测地更改,无法统一每个团队的开发风格
  • 无法在除了后代组件之外的其他地方使用共享状态(如全局独立的弹窗组件等),如果使用,则需要共享状态继续向上挪动到他们公共的祖先组件,扩展性较差
  • WebStrom最新版已经支持根据actionType直接跳转到对应的store(貌似也只支持vue2形式的vuex),这一点非常影响开发和调试效率

因此我认为在Vue3中还是有继续使用vuex的必要,除了适应一小部分改动

首先需要将this.$store修改为useStore风格的代码

import {useStore} from 'vuex'

const store = useStore()

然后访问state等数据还是通过计算属性的方式

const userList = computed(()=>{
  return store.state.userList
})

更新数据还是使用store.commitstore.dispatch,这种方式基本与Vue2保持一致。

Vue-router使用改动基本相同,除了将this.$router修改为const router = useRouter()之外区别很小,这里不再赘述。

5. 写在最后

感觉只有动手写代码才能了解最佳的实践,Vue3写起来确实有一些比较新鲜的体验,接下来继续多写点代码,再看看几个主流的UI库是如何升级到Vue3的吧~