Vue 3.0正式版发布有一段时间了,很多人已经开始在项目中使用。但大部分人可能只是用了基础的Composition API,还有很多进阶的技巧和特性没有用到。今天分享一些Vue 3.0的进阶技巧,包括响应式原理、性能优化、组合式函数、自定义渲染器等,帮你更深入地理解和使用Vue 3.0。

先说说背景。Vue 3.0是Vue.js的一个重大版本升级,于2020年9月正式发布(写这篇文章的时候是2020年3月,Vue 3.0还在RC阶段,但核心特性已经稳定了,正式版很快就会发布)。Vue 3.0带来了很多新特性和改进,比如Composition API、更好的TypeScript支持、更快的渲染速度、更小的打包体积、Teleport、Suspense、自定义渲染器等。

很多人学习Vue 3.0,只是学了基础的Composition API,比如ref、reactive、computed、watch这些,然后就开始写项目了。但Vue 3.0还有很多进阶的技巧和特性,能让你写出更优雅、更高效、更可维护的代码。今天就来分享一些我在使用Vue 3.0过程中总结的进阶技巧。

一、深入理解响应式系统

Vue 3.0的响应式系统,是用Proxy实现的,比Vue 2.0的Object.defineProperty更强大,性能也更好。但很多人只是会用ref和reactive,并不了解背后的原理。深入理解响应式系统的原理,能帮你更好地使用它,避免一些常见的坑。

1. ref和reactive的区别

很多人刚学Vue 3.0的时候,会搞不清楚ref和reactive的区别,不知道什么时候用哪个。简单来说:

  • ref用于基本类型(字符串、数字、布尔值等),也可以用于对象,但访问的时候需要加.value
  • reactive用于对象和数组,访问的时候不需要加.value,但不能直接替换整个对象

其实,ref的底层也是用reactive实现的。当你给ref传一个对象的时候,它内部会调用reactive来处理这个对象,然后通过.value来访问。所以,ref(obj)等价于reactive({value: obj})。

那什么时候用ref,什么时候用reactive呢?我的建议是:

  • 基本类型用ref
  • 对象和数组用reactive
  • 如果你不确定,或者想要统一的风格,都用ref也可以,只是访问的时候要加.value

需要注意的是,reactive有一个限制,就是不能直接替换整个对象。比如:

const state = reactive({ count: 0 })
// 这样是不行的,会丢失响应式
state = { count: 1 }

如果你需要替换整个对象,应该用ref,或者修改对象的属性,而不是替换整个对象。

2. 响应式的解构问题

这是一个很常见的坑。如果你从reactive对象中解构出属性,那么解构出来的属性就会丢失响应式。比如:

const state = reactive({ count: 0, name: 'Vue' })
// 这样解构出来的count和name不是响应式的
const { count, name } = state

这是因为,reactive的响应式是通过Proxy实现的,当你访问state.count的时候,Proxy会拦截这个访问,收集依赖。但当你解构的时候,你只是把值取出来赋给了一个新变量,这个新变量和Proxy没有关系了,所以就不是响应式的了。

如果你想解构出响应式的属性,可以用toRefs:

import { toRefs } from 'vue'

const state = reactive({ count: 0, name: 'Vue' })
// 这样解构出来的count和name是响应式的ref
const { count, name } = toRefs(state)

toRefs会把reactive对象的每个属性都转成ref,这样解构出来的就是响应式的了。需要注意的是,toRefs只能用于reactive对象,不能用于普通对象。

另外,Vue 3.3还引入了toRef的增强版,可以直接从对象中取出一个属性并转成ref,还支持默认值:

const count = toRef(state, 'count')
// 支持默认值(Vue 3.3+)
const count = toRef(state, 'count', 0)

3. shallowRef和shallowReactive

默认情况下,ref和reactive是深度响应式的,也就是说,对象的嵌套属性也是响应式的。但有时候,你可能不需要深度响应式,比如一个很大的对象,只有顶层属性需要响应式,嵌套属性不需要。这时候,可以用shallowRef和shallowReactive,它们是浅响应式的,只有顶层属性是响应式的,嵌套属性不是。

浅响应式的好处是性能更好,因为不需要递归地把所有嵌套属性都转成响应式的。对于很大的对象,性能提升会很明显。

import { shallowRef, shallowReactive } from 'vue'

// shallowRef,只有.value的变化是响应式的,.value内部的变化不是
const state = shallowRef({ count: 0 })
// 这样会触发更新
state.value = { count: 1 }
// 这样不会触发更新
state.value.count = 1

// shallowReactive,只有顶层属性是响应式的
const state2 = shallowReactive({ 
  count: 0,
  nested: { value: 0 }
})
// 这样会触发更新
state2.count = 1
// 这样不会触发更新
state2.nested.value = 1

需要注意的是,使用浅响应式的时候,如果你想更新嵌套属性,需要替换整个对象,而不是修改嵌套属性。

4. triggerRef和自定义触发

对于shallowRef,修改内部属性不会触发更新。但如果你确实需要在修改内部属性之后触发更新,可以用triggerRef手动触发:

import { shallowRef, triggerRef } from 'vue'

const state = shallowRef({ count: 0 })
// 修改内部属性
state.value.count = 1
// 手动触发更新
triggerRef(state)

另外,你还可以用customRef创建自定义的ref,控制它的依赖收集和更新触发。比如,你可以创建一个带防抖的ref:

import { customRef } from 'vue'

function useDebouncedRef(value, delay = 200) {
  let timeout
  return customRef((track, trigger) => {
    return {
      get() {
        track() // 收集依赖
        return value
      },
      set(newValue) {
        clearTimeout(timeout)
        timeout = setTimeout(() => {
          value = newValue
          trigger() // 触发更新
        }, delay)
      }
    }
  })
}

// 使用
const text = useDebouncedRef('hello', 500)

customRef是一个很强大的API,能让你完全控制ref的行为,实现一些自定义的逻辑。

二、Composition API进阶技巧

Composition API是Vue 3.0最大的亮点,它让代码的逻辑复用和组织变得更灵活。但很多人只是用setup函数和一些基础的API,并没有发挥出Composition API的全部威力。这里分享一些进阶技巧。

1. 组合式函数(Composables)

组合式函数是Composition API的核心,它是利用Vue的响应式API来封装和复用有状态逻辑的函数。和Vue 2.0的mixins相比,组合式函数有很多优势:

  • 数据来源清晰,不会有命名冲突
  • 可以传递参数,更灵活
  • 可以返回任何你想暴露的东西
  • 更好的TypeScript支持

一个好的组合式函数,应该以use开头,比如useMouse、useFetch、useLocalStorage等。下面是一个简单的例子,封装一个获取鼠标位置的组合式函数:

import { ref, onMounted, onUnmounted } from 'vue'

function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function update(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  onMounted(() => {
    window.addEventListener('mousemove', update)
  })

  onUnmounted(() => {
    window.removeEventListener('mousemove', update)
  })

  return { x, y }
}

// 在组件中使用
export default {
  setup() {
    const { x, y } = useMouse()
    return { x, y }
  }
}

组合式函数可以嵌套使用,一个组合式函数可以调用其他组合式函数,这样可以把复杂的逻辑拆分成更小的、可复用的函数。比如,useFetch可以调用useLoading和useError,把加载状态和错误状态的逻辑封装起来。

在写组合式函数的时候,有一些最佳实践:

  • 以use开头命名
  • 返回一个对象,而不是返回响应式对象(这样在组件中解构的时候不会丢失响应式,或者用toRefs处理)
  • 在组合式函数内部处理好生命周期的清理(比如移除事件监听器、清除定时器等)
  • 给组合式函数写好TypeScript类型,方便使用

2. 依赖注入(provide/inject)

在Vue 2.0中,provide/inject就已经存在了,但在Vue 3.0中,它和Composition API结合起来,变得更强大了。provide/inject可以让你在祖先组件中提供数据,然后在后代组件中注入,不需要通过props一层层传递。

在Composition API中,provide和inject是这样用的:

// 祖先组件
import { provide, ref } from 'vue'

export default {
  setup() {
    const theme = ref('dark')
    // 提供数据
    provide('theme', theme)
    // 也可以提供方法
    provide('changeTheme', (newTheme) => {
      theme.value = newTheme
    })
  }
}

// 后代组件
import { inject } from 'vue'

export default {
  setup() {
    // 注入数据
    const theme = inject('theme')
    const changeTheme = inject('changeTheme')
    // 可以设置默认值
    const locale = inject('locale', 'zh-CN')
    
    return { theme, changeTheme, locale }
  }
}

provide/inject的一个常见用途是创建全局状态管理。对于中小型项目,你可能不需要引入Vuex,用provide/inject就能实现简单的全局状态管理。比如,你可以在根组件中提供一个全局的store,然后在任何组件中注入使用。

需要注意的是,如果你提供的是响应式数据(比如ref或reactive),那么注入的组件中也是响应式的,修改的时候会触发更新。但如果你提供的是普通对象,那就不是响应式的。所以,一般建议提供ref或reactive对象。

另外,为了避免key的冲突和更好的TypeScript支持,建议用Symbol作为key:

// keys.js
export const ThemeKey = Symbol('theme')

// 祖先组件
import { provide } from 'vue'
import { ThemeKey } from './keys'

provide(ThemeKey, theme)

// 后代组件
import { inject } from 'vue'
import { ThemeKey } from './keys'

const theme = inject(ThemeKey)

3. 生命周期钩子的使用

在Composition API中,生命周期钩子是以函数的形式使用的,比如onMounted、onUpdated、onUnmounted等。这些钩子只能在setup函数中调用,或者在组合式函数中调用。

大部分生命周期钩子和Vue 2.0的是对应的,比如:

  • beforeCreate -> setup(setup本身就相当于beforeCreate和created)
  • created -> setup
  • beforeMount -> onBeforeMount
  • mounted -> onMounted
  • beforeUpdate -> onBeforeUpdate
  • updated -> onUpdated
  • beforeDestroy -> onBeforeUnmount
  • destroyed -> onUnmounted
  • errorCaptured -> onErrorCaptured

另外,Vue 3.0还新增了两个调试用的钩子:

  • onRenderTracked:每次渲染收集依赖的时候触发,用于调试
  • onRenderTriggered:每次渲染触发更新的时候触发,用于调试

这两个钩子在开发调试的时候很有用,可以帮你找出是什么导致了组件的重新渲染。比如:

import { onRenderTriggered } from 'vue'

onRenderTriggered((event) => {
  console.log('渲染触发了', event)
  // event包含了触发渲染的依赖信息
})

在使用生命周期钩子的时候,有一个常见的坑:在异步回调中调用生命周期钩子是无效的。比如:

import { onMounted } from 'vue'

export default {
  setup() {
    // 这样是有效的
    onMounted(() => {
      console.log('mounted')
    })

    // 这样是无效的,因为onMounted在异步回调中调用,此时组件已经挂载完了
    setTimeout(() => {
      onMounted(() => {
        console.log('mounted') // 不会执行
      })
    }, 1000)
  }
}

所以,一定要在setup函数的同步执行过程中调用生命周期钩子,不要在异步回调中调用。

三、性能优化技巧

Vue 3.0本身已经做了很多性能优化,比如编译时优化、PatchFlag、静态提升、缓存事件处理函数等,渲染速度比Vue 2.0快了很多。但在实际项目中,还是有一些可以优化的地方。这里分享一些性能优化的技巧。

1. 合理使用v-if和v-show

v-if和v-show的区别,大家应该都知道:v-if是真正的条件渲染,会销毁和重建组件;v-show只是切换CSS的display属性,组件一直存在。

所以,对于频繁切换的场景,用v-show性能更好,因为不需要销毁和重建组件。对于不频繁切换的场景,用v-if更好,因为可以减少初始渲染的开销,而且不显示的时候不占DOM。

但在Vue 3.0中,v-if还有一个额外的优化:配合<template>标签和v-if,可以在编译时做静态分析,减少运行时的开销。所以,在可能的情况下,尽量用v-if而不是v-show,除非切换非常频繁。

2. 避免不必要的响应式

Vue 3.0的响应式系统虽然性能很好,但如果把不需要响应式的数据也做成响应式的,还是会有一些性能开销。所以,对于不需要响应式的数据,就不要用ref或reactive,直接用普通变量就好。

比如,一些常量、配置项、不需要在模板中使用的数据,就不需要做成响应式的。只有那些需要在模板中显示,或者变化时需要触发更新的数据,才需要做成响应式的。

另外,对于很大的对象,如果只有顶层属性需要响应式,可以用shallowReactive,减少深度响应式的开销。

3. 合理使用computed

computed是计算属性,它会缓存计算结果,只有依赖变化的时候才会重新计算。合理使用computed,可以避免重复计算,提升性能。

比如,在模板中需要用到一个经过复杂计算的值,不要直接在模板中写计算逻辑,也不要用方法,应该用computed。因为computed会缓存结果,而方法每次渲染都会重新计算。

// 不好的做法,每次渲染都会重新计算
export default {
  setup() {
    const list = ref([1, 2, 3, 4, 5])
    function getFilteredList() {
      return list.value.filter(item => item > 2).map(item => item * 2)
    }
    return { list, getFilteredList }
  }
}

// 好的做法,用computed缓存结果
import { computed } from 'vue'

export default {
  setup() {
    const list = ref([1, 2, 3, 4, 5])
    const filteredList = computed(() => {
      return list.value.filter(item => item > 2).map(item => item * 2)
    })
    return { list, filteredList }
  }
}

需要注意的是,computed应该是纯函数,不应该有副作用。不要在computed中修改其他响应式数据,或者发起异步请求。有副作用的逻辑应该放在watch中。

4. 列表渲染的优化

列表渲染是性能优化的重点。在Vue 3.0中,列表渲染已经做了很多优化,但还是有一些可以注意的地方。

首先,一定要给v-for加key,而且key要稳定、唯一。不要用index作为key,因为当列表顺序变化的时候,index也会变化,导致不必要的DOM操作。应该用数据的唯一ID作为key。

其次,对于很长的列表,可以考虑虚拟滚动。虚拟滚动只渲染可视区域内的元素,而不是渲染所有元素,这样可以大大减少DOM节点数量,提升性能。Vue 3.0本身没有内置虚拟滚动,但可以用第三方库,比如vue-virtual-scroller。

另外,对于不需要响应式的列表项,可以用v-memo(Vue 3.2+)来缓存渲染结果,避免不必要的重新渲染。v-memo可以指定一个依赖数组,只有当依赖变化的时候,才会重新渲染这个元素或组件。

<!-- 只有当item.id变化的时候,才会重新渲染这个div -->
<div v-for="item in list" :key="item.id" v-memo="[item.id]">
  {{ item.name }}
</div>

5. 组件懒加载

对于一些不常用的组件,或者首屏不需要的组件,可以用懒加载,只有在需要的时候才加载。这样可以减少首屏的打包体积,加快首屏加载速度。

在Vue 3.0中,可以用defineAsyncComponent来定义异步组件:

import { defineAsyncComponent } from 'vue'

const AsyncComponent = defineAsyncComponent(() => {
  return import('./components/AsyncComponent.vue')
})

export default {
  components: {
    AsyncComponent
  }
}

也可以在路由中使用懒加载,这是更常见的做法:

const routes = [
  {
    path: '/about',
    component: () => import('./views/About.vue')
  }
]

另外,还可以配合Suspense组件,在异步组件加载的时候显示loading状态,提升用户体验。

四、其他进阶特性

除了上面说的这些,Vue 3.0还有很多其他的进阶特性,这里简单介绍几个。

1. Teleport

Teleport(传送门)可以让你把组件的DOM渲染到DOM树的其他位置,而不是当前组件的位置。这对于模态框、弹窗、通知等组件特别有用,因为这些组件通常需要渲染到body的末尾,避免被父组件的overflow或z-index影响。

<template>
  <button @click="showModal = true">打开弹窗</button>
  <!-- 弹窗会被渲染到body的末尾 -->
  <teleport to="body">
    <div v-if="showModal" class="modal">
      这是一个弹窗
      <button @click="showModal = false">关闭</button>
    </div>
  </teleport>
</template>

Teleport的to属性可以是任何有效的CSS选择器,比如"body"、"#app"、".modal-container"等。需要注意的是,目标元素必须在Teleport渲染之前就存在,否则会报错。

2. Suspense

Suspense是一个实验性的特性,用于处理异步依赖。它可以让你的组件在等待异步依赖(比如异步组件、异步setup)的时候,显示一个loading状态,等异步依赖加载完成之后,再显示组件的内容。

<template>
  <Suspense>
    <!-- 异步组件 -->
    <template #default>
      <AsyncComponent />
    </template>
    <!-- 加载中显示的内容 -->
    <template #fallback>
      <div>加载中...</div>
    </template>
  </Suspense>
</template>

Suspense还支持异步setup,也就是说,setup函数可以是async的,返回一个Promise。当setup中的异步操作完成之后,组件才会渲染。这对于需要在setup中获取数据的组件特别有用。

export default {
  async setup() {
    // 异步获取数据
    const data = await fetchData()
    return { data }
  }
}

需要注意的是,Suspense目前还是实验性的特性,API可能会变化,在生产环境中使用要谨慎。

3. 自定义渲染器

Vue 3.0的一个重大改进是,把渲染逻辑和核心逻辑分离了,你可以创建自定义的渲染器,把Vue的组件渲染到不同的平台上,而不仅仅是浏览器的DOM。比如,你可以创建一个渲染到Canvas的渲染器,或者渲染到原生应用的渲染器。

创建自定义渲染器,需要用createRenderer函数,传入一些平台相关的操作函数:

import { createRenderer } from 'vue'

const { createApp } = createRenderer({
  // 创建元素
  createElement(type) {
    // 返回平台对应的元素
  },
  // 插入元素
  insert(child, parent, anchor) {
    // 把child插入到parent中
  },
  // 设置元素的文本内容
  setElementText(el, text) {
    // 设置el的文本内容
  },
  // 设置属性
  patchProp(el, key, prevValue, nextValue) {
    // 设置el的属性
  },
  // 其他操作...
})

自定义渲染器是一个很强大的特性,它让Vue不再局限于Web平台,可以扩展到任何平台。比如,Weex、NativeScript等框架,就是用自定义渲染器把Vue渲染到原生平台的。当然,对于大部分前端开发者来说,可能不需要自己写自定义渲染器,但了解这个特性,能让你更深入地理解Vue的架构。

4. 更好的TypeScript支持

Vue 3.0是用TypeScript重写的,对TypeScript的支持比Vue 2.0好了很多。在Vue 3.0中,你可以很方便地给组件添加类型,包括props、emits、setup的返回值等。

比如,用defineProps和defineEmits来定义类型化的props和emits:

<script setup lang="ts">
import { defineProps, defineEmits } from 'vue'

interface Props {
  name: string
  age?: number
}

interface Emits {
  (e: 'update:name', value: string): void
  (e: 'submit'): void
}

const props = defineProps<Props>()
const emit = defineEmits<Emits>()
</script>

Vue 3.3还引入了更强大的类型特性,比如defineProps的响应式解构、defineOptions、defineSlots等,让TypeScript的使用更方便。

如果你还在用JavaScript,强烈建议你试试TypeScript。有了类型系统,你可以在编译时发现很多错误,代码的可维护性也会大大提升。而且,Vue 3.0的TypeScript支持已经很成熟了,使用起来很顺畅。

五、写在最后

Vue 3.0是一个很强大的框架,相比Vue 2.0,它在性能、开发体验、可维护性等方面都有很大的提升。Composition API让代码的组织和复用变得更灵活,响应式系统的改进让性能更好,TypeScript的支持让代码更可靠。

但很多人学习Vue 3.0,只是停留在基础的用法上,没有深入了解它的进阶特性和最佳实践。今天分享的这些技巧,希望能帮你更深入地理解和使用Vue 3.0,写出更优雅、更高效、更可维护的代码。

当然,Vue 3.0的特性还有很多,一篇文章不可能全部覆盖。比如模板编译优化、自定义指令、插件系统、服务端渲染等,都有很多可以深入的地方。建议大家在实际项目中多尝试、多探索,遇到问题多查官方文档,慢慢积累,你会越来越熟练。

最后,想说的是,框架只是工具,重要的是解决问题的思路和能力。不管用什么框架,写出好代码的核心都是一样的:清晰的逻辑、合理的架构、良好的命名、完善的测试。希望大家都能在使用Vue 3.0的过程中,不断提升自己的技术能力。

如果有什么问题或者不同的看法,欢迎在评论区交流。