Vue3使用初探

568 阅读10分钟

文章内容可以结合着这个demo查看了解:vue3-pinia-demo

1. 基本用法

目前Vue3的用法主要有下面三种:

  • 一种是与vue2基本一致的写法,应该是为了方便vue2的项目迁移而保留的。
export default {
  name: 'TestOne',
  props: {
    msg: {
      type: String,
      default: ''
    }
  },
  data() {
    return {
      testList: [
        {name: '列表一'},
        {name: '列表二'},
        {name: '列表三'},
      ]
    }
  },
  computed: {
  
  },
  created() {
    
  },
  mounted() {
    
  },
  mothods: {

  }
};
  • 一种写法是js代码都写在setup函数中,然后通过vue3提供的各种钩子处理数据和逻辑。
//vue3的template中包裹的第一层节点,就不需要非得是个div了
<template>
  <ul>
    <li v-for="(item, idx) in list" :key="item.id"></li>
  </ul>
  <p>{{msg}}</p>
</template>

import { 
  defineComponent,
  onBeforeMount,
  onMounted,
  reactive,
  toRefs
} from 'vue';

type PageState = {
  list: any
}

export default defineComponent({
  name: 'TestTwo',
  setup(props) {
    const { msg } = toRefs(props);
  
    let state = reactive<PageState>({
      list: []
    });

    const getList = () => {
      state.list = [
        {id: '1',name: '列表一'}, 
        {id: '2',name: '列表二'}, 
        {id: '3',name: '列表三'},
      ];
    };

    onBeforeMount(() => {
      getList();
    });

    onMounted(() => {
      
    });

    return {
      msg,
      ...toRefs(state)
    }
  }
});
  • 一种是直接在script标签带上属性setup,表示内部执行的都是setup运行的代码。
<script lang="ts" setup>
import { defineProps, computed } from 'vue';
import { useStore } from 'vuex';
import { key } from '@src/store';

type Props = {
  msg: string
}
defineProps<Props>();
const store = useStore(key);
const count = computed(() => {
  return store.state.count;
});
const inCrement = () => {
  store.commit('increment');
};
</script>

2. 生命周期

这里只讲vue3的hooks用法,兼容vue2的用法就不再赘述。vue3的hooks是通过在setUp()中调用的,setUp()是在beforeCreate和created之间执行的,因此不需要给这两个生命周期专门提供hooks,也就是说,所有想要在beforeCreate和created两个生命周期执行的代码,直接写在setUp中就可以了

  • onBeforeMount,到这一步,已经根据模板生成了虚拟dom
  • onMounted,到这一步,已经把虚拟dom转换成了真实dom
  • onBeforeUpdate,响应式数据改变时触发
  • onUpdated,到这一步,虚拟dom已重新渲染和装载
  • onBeforeUnmount,组件销毁时触发
  • onUnmounted,到这一步,组件的代理、dom、事件均已卸载
  • onErrorCapture,在捕获一个来自后代组件的错误时被触发
  • onRenderTracked,虚拟dom重新渲染时触发,用来跟踪reactive中定义的响应式数据的变化
  • onRenderTriggered,虚拟dom重新渲染时触发,用来获得发生了变化的响应式数据的信息
  • onActivated,keep-alive的生命周期,当被其包裹的组件激活时触发
  • onDeactivated,keep-alive的生命周期,当被其包裹的组件关闭激活时触发

3. 生态支持

Vue3在去年(2020年)9月刚发布时,还几乎没什么生态支持,但在今年(2021年)一整年,生态发展的则较为快速,相继出现了vue-router4.X/vuex4.X/Pinia/vue-devtools最新版/VueUse/unplugin-vue-components/vite1.0/vite2.0等一系列用于Vue3的工具;并且像Taro/uni-app等用于开发小程序的多端框架,也开始支持用Vue3来编写。

目前如果让我搭配一个Vue3项目,那么我会这样搭配:

vue3 + vue-router4.X + Pinia2.X + vueuse + ts + vite2 + unplugin-auto-import

Pinia是一个类似于vuex的状态管理工具,名字是由西班牙语中对菠萝的称呼而来的,所以logo是一个菠萝。之所以使用Pinia2而不使用vuex4,是因为Pinia2的理念与下一代的vuex5相近,且API十分相似,因此使用Pinia2更容易平滑过渡到vuex5。详情可见掘友的这篇文章:浅谈Pinia(皮尼亚)--为什么vue3推荐使用Pinia

vueuse是一个基于Composition API的工具函数包,作用类似于lodash,里面有用于vue3的鼠标动作监听函数、本地缓存函数、防抖、节流等等工具函数。详情可见官网和掘友的这篇文章:Vue新玩具VueUse

unplugin-auto-import是一个用于做hooks自动引入的插件,就是说通过在vite或webpack中做相应配置,可以不需要再在组件中手动使用import引入hooks,插件会根据代码内容自动判断需要引入的hooks。当然,还有好几个类似的插件,都是用来实现各种自动化引入的,详情可见尤大推荐的神器unplugin-vue-components,解放双手!以后再也不用呆呆的手动引入(组件,ui(Element-ui)库,vue hooks等)。个人理解这种插件主要是用来方便vue3 hooks、UI框架自定义组件之类的使用,因为这类东西需要经常在各组件中使用,本身名字辨识度又足够高,所以不想总是手动书写import来明确引入来源,也不想在全局引入导致对tree shaking不友好,那么使用这种插件则正合适。

4. 项目搭建流程

下面搭建一个vue3 + vue-router4.X + Pinia2.X + vueuse + ts + vite2的项目

先在终端执行如下命令,进入vite2项目生成流程:

npm init vite

输入项目名称:

image.png

选择要使用的框架:

image.png

选择是用js编写还是用ts编写:

image.png

回车后就生成出了一个vue3 + typescript + vite2的项目

此时目录中没有状态机、没有路由,因此还需要在终端执行:

npm install vue-router@next pinia@next --save 或 yarn add vue-router@next pinia@next

这样,状态机和路由就有了,下面看一下状态机和路由的逻辑代码我是怎么写的吧

先说路由的,在src目录下创建一个名叫router的文件夹,然后在文件夹下分别创建index.ts和routes.ts两个文件

src/router/routes.ts(路由配置):

import TestOne from '@src/views/TestOne.vue';

export default [
  { 
    path: '/', 
    component: TestOne
  },
  { 
    path: '/home', 
    component: () => import('@src/views/Home.vue') 
  }
];

src/router/index.ts(创建路由):

import { createRouter, createWebHashHistory } from 'vue-router';
import routes from './routes';

const router = createRouter({
  history: createWebHashHistory(),
  routes: routes
});

export default router;

然后在入口文件main.ts中引入路由的index.ts:

import { createApp } from 'vue';
import router from "@src/router";
import App from '@src/App.vue';

createApp(App)
.use(router)
.mount('#app');

此时启动vite的代理服务,就能看到routes.ts中配置的路由页面。

再说状态机的,在src目录下创建一个叫store的文件夹,然后在文件夹下创建index.ts和test.ts两个文件

src/store/test.ts(test模块的状态机):

需要注意的是,Pinia中没有mutations这个概念,这一点与vuex3和vuex4不一样,与未来将要出现的vuex5一致

import { defineStore } from 'pinia';

// state数据类型
type State = {
  count: number,
  accessToken: string
};

// 创建状态机模块
export const useTestStore = defineStore({
  // 模块唯一id
  id: 'storeTest',
  // 状态机的state
  state: (): State => ({
    count: 0,
    accessToken: ''
  }),
  // 状态机的action
  actions: {
    increment() {
      this.count++;
    },
    setToken(token: string) {
      this.accessToken = token;
    }
  },
  // 状态机数据持久化配置,基于pinia-plugin-persist
  persist: {
    enabled: true,
    strategies: [
      {
        storage: localStorage,
        paths: ['accessToken']
      },
    ],
  }
});

src/store/index.ts(创建状态机):

// 引入创建方法
import { createPinia } from 'pinia';
// 引入数据持久化工具
import piniaPersist from 'pinia-plugin-persist'

const pinia = createPinia();
export default pinia.use(piniaPersist);

然后在入口文件main.ts中引入路由的index.ts:

import { createApp } from 'vue';
import router from "@src/router";
import store from "@src/store";
import App from '@src/App.vue';

createApp(App)
.use(router)
.use(store)
.mount('#app');

在入口文件注册好状态机后,像下面这样使用即可:

<template>
  <h1>{{ msg }}</h1>
  <button type="button" @click="inCrement">count is: {{ count }}</button>
</template>

<script lang="ts" setup>
import { defineProps, computed, onMounted } from 'vue';
import { useTestStore } from '@src/store/test';
// 定义props的数据类型
type Props = {
  msg: string
}

defineProps<Props>();
// 获取test模块的状态机
const store = useTestStore();
// 获取状态机中的count
const count = computed(() => {
  return store.count;
});
const inCrement = () => {
  // 改变状态机中的count
  store.increment();
};
// mounted生命周期
onMounted(() => {
  // 使用action设置state中的accessToken
  store.setToken('666');
});

</script>

<style lang="scss" scoped>
a {
  color: #42b983;
}

label {
  margin: 0 0.5em;
  font-weight: bold;
}

code {
  background-color: #eee;
  padding: 2px 4px;
  border-radius: 4px;
  color: #304455;
}
</style>

pinia-plugin-persist这个做Pinia数据持久化的插件,目前还不成熟,还有一些很明显的bug,但暂时也没有更好用的类似插件,所以做Pinia状态数据持久化,目前还是推荐自己实现。

我的demo示例中目前使用了pinia-plugin-persist,但build的时候会报如下错误:

image.png

这个错误,我查看pinia-plugin-persist官方github时,发现已经有人提了Issues,并有了相关的解决方案:Incompatibility with new Pinia version #18Changes to support Pinia version 2 #19,不过这个解决需要改动插件源码,很不方便,所以暂时也只能等待官方出新的版本解决这个bug了。根据issues/18的回答,目前要解决build的问题,需要将node_modules\pinia-plugin-persist\dist\index.d.ts中的:

declare module 'pinia' {
    interface DefineStoreOptions<Id extends string, S extends StateTree, G extends GettersTree<S>, A> {
        persist?: PersistOptions;
    }
}

修改为

declare module 'pinia' {
    interface DefineStoreOptionsBase< S, Store> {
        persist?: PersistOptions;
    }
}

然后再说一下vite配置项,我是这样配置的:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue()
  ],
  resolve: {
    alias: {
      '@src': resolve('src')
    }
  },
  server: {
    //服务端口号
    port: 8002,
    //是否自动在浏览器打开
    open: true,
    //代理服务
    proxy: {
      '/api': {
        target: 'http://127.0.0.1:3000',
        changeOrigin: true,
        rewrite: (path: string) => path.replace('\/api', '')
      }
    }
  }
})

这样,一个最基本的vue3项目结构就有了。

5. webpack向vite2迁移

  • vite是与webpack类似作用的构建工具,最新版本的vue-cli已经支持构建配置使用vite来完成,当然也依然可以选择使用webpack。基于webpack的项目,构建工具是通过entry(入口文件)配合HtmlWebpackPlugin将生成的js路径注入到指定的html中,而vite是通过直接解析html文件来找到入口文件的,跟webpack有点儿不一样,因此需要找到入口页面index.html,然后在其中加上入口文件的地址引用,如下面这样:
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <div id="app"></div>
    <!-- 此为项目的入口文件,vite会解析html的script标签来找到入口文件 -->
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
  • 如果项目是使用的vue2,且不想马上升级到vue3,只想先迁移构建工具,那么可以使用vite-plugin-vue2插件,让vite可以支持构建vue2项目,vite.config.ts的内容可以如下配置:
import { defineConfig } from 'vite';
import { createVuePlugin } from 'vite-plugin-vue2';

export default defineConfig({
  plugins: [
    createVuePlugin()
  ]
})
  • webpack中的环境变量是通过process.env获取的,而vite中是通过import.meta.env获取的,比如:
// 相当于webpack中的process.env.NODE_ENV === 'development'
import.meta.env.NODE_ENV === 'development'

import.meta.env在ts中可能会报类型错误,可以在d.ts中添加如下声明:

declare global {
  interface ImportMeta {
    env: Record<string, unknown>;
  }
}

这样就不会报错了。

6. vuex3/4向Pinia迁移

vuex代码:

// Vuex module in the 'auth/user' namespace
import { Module } from 'vuex'
import { api } from '@/api'
import { RootState } from '@/types' // if using a Vuex type definition

interface State {
  firstName: string
  lastName: string
  userId: number | null
}

const storeModule: Module<State, RootState> = {
  namespaced: true,
  state: {
    firstName: '',
    lastName: '',
    userId: null
  },
  getters: {
    firstName: (state) => state.firstName,
    fullName: (state) => `${state.firstName} ${state.lastName}`,
    loggedIn: (state) => state.userId !== null,
    // combine with some state from other modules
    fullUserDetails: (state, getters, rootState, rootGetters) => {
      return {
        ...state,
        fullName: getters.fullName,
        // read the state from another module named `auth`
        ...rootState.auth.preferences,
        // read a getter from a namespaced module called `email` nested under `auth`
        ...rootGetters['auth/email'].details
      }
    }
  },
  actions: {
    async loadUser ({ state, commit }, id: number) {
      if (state.userId !== null) throw new Error('Already logged in')
      const res = await api.user.load(id)
      commit('updateUser', res)
    }
  },
  mutations: {
    updateUser (state, payload) {
      state.firstName = payload.firstName
      state.lastName = payload.lastName
      state.userId = payload.userId
    },
    clearUser (state) {
      state.firstName = ''
      state.lastName = ''
      state.userId = null
    }
  }
}

export default storeModule

使用Pinia后的代码:

// Pinia Store
import { defineStore } from 'pinia'
import { useAuthPreferencesStore } from './auth-preferences'
import { useAuthEmailStore } from './auth-email'

interface State {
  firstName: string
  lastName: string
  userId: number | null
}

export const useAuthUserStore = defineStore('auth/user', {
  // convert to a function
  state: (): State => ({
    firstName: '',
    lastName: '',
    userId: null
  }),
  getters: {
    // firstName getter removed, no longer needed
    fullName: (state) => `${state.firstName} ${state.lastName}`,
    loggedIn: (state) => state.userId !== null,
    // must define return type because of using `this`
    fullUserDetails (state): FullUserDetails {
      // import from other stores
      const authPreferencesStore = useAuthPreferencesStore()
      const authEmailStore = useAuthEmailStore()
      return {
        ...state,
        // other getters now on `this`
        fullName: this.fullName,
        ...authPreferencesStore.$state,
        ...authEmailStore.details
      }
    }
  },
  actions: {
    // no context as first argument, use `this` instead
    async loadUser (id: number) {
      if (this.userId !== null) throw new Error('Already logged in')
      const res = await api.user.load(id)
      this.updateUser(res)
    },
    // mutations can now become actions, instead of `state` as first argument use `this`
    updateUser (payload) {
      this.firstName = payload.firstName
      this.lastName = payload.lastName
      this.userId = payload.userId
    },
    // easily reset state using `$reset`
    clearUser () {
      this.$reset()
    }
  }
})

上例可以看出,Pinia不再是将好几个module合并放入到一个庞大的数据对象中,而是每一个module都是独立的,可以通过import单独引用,结构更加扁平清晰,对tree shaking也更加友好。同时彻底抛弃了mutations,所有修改状态的动作都通过actions来完成。

7. Vue3比Vue2好在哪?

7.1 Vue3使用Proxy实现数据的双向绑定(hooks中的reactive/ref都是Proxy实现的),可以监听整个对象的变化,而不只是监听对象的属性变化,且内部的属性Api对应反射Api(Reflect),因此比Object.defineProperty更方便、强大

  • Proxy 作为新标准将受到浏览器厂商重点持续的性能优化
  • Proxy 能观察的类型比 defineProperty 更丰富
  • Proxy 不兼容IE,也没有 polyfilldefineProperty 能支持到IE9
  • Object.defineProperty 是劫持对象的属性,新增元素需要再次 defineProperty。而 Proxy 劫持的是整个对象,不需要做特殊处理
  • 使用 defineProperty 时,我们修改原来的 obj 对象就可以触发拦截,而使用 proxy,就必须修改代理对象,即 Proxy 的实例才可以触发拦截

7.2 Vue3的Composition API更有利于typescript的自动类型推断,而Vue2即便使用了Vue.extend或class+装饰器写法,很多情况下也会对typescript的类型推断产生困扰,导致推断出过于宽泛的类型,比如any。

7.3 对tree shaking(摇树优化)来说,Vue3的Composition API方式让生命周期和各种属性配置可以自由引用,比Vue2的Options API要友好的多。

7.4 Vue3会标记所有静态节点(没有绑定变量的节点),静态节点会在首次渲染时使用h函数创建一个vnode,并且这个创建过程是被提升到render函数外部的,所以下次渲染时,会检查静态节点是否生成了vnode,如果有现成的vnode则直接拿来复用,节省了大量的静态节点渲染时间。

7.5 Vue3的diff算法,与vue2相比,也进行了一定程度的策略调整来保证效率提升,比如给动态节点进行标记,来区分不同类型的属性变化,比如props属性变化和text属性变化的标记就有所不同,这样做diff比对的时候,直接可以通过标记更精准的定位到发生变化的数据,节省了比对时间。