文章内容可以结合着这个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
输入项目名称:
选择要使用的框架:
选择是用js编写还是用ts编写:
回车后就生成出了一个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的时候会报如下错误:
这个错误,我查看pinia-plugin-persist官方github时,发现已经有人提了Issues,并有了相关的解决方案:Incompatibility with new Pinia version #18,Changes 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,也没有polyfill,defineProperty能支持到IE9Object.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比对的时候,直接可以通过标记更精准的定位到发生变化的数据,节省了比对时间。