引言 界面: Vue.js 3 JavaScript 超集: TypeScript 包管理器: pnpm 前端工程化/打包: Vite 路由: Vue Router 状态管理: Pinia CSS 预处理器: Less 代码格式化: Prettier 代码质量: ESLint 预览
详细
Vue.js 3
create-vue
, 此模版源拥有更多模板npm init vue@latest
, 基于 Vite + Vue
Vue CLI
npm init vue@latest
等同于 npm create vue@3
, 将使用 create-vue
模版(基于 vite), 而不是 Vue CLI
模版(基于 webpack)TypeScript
pnpm
Vite
Vue Router
Pinia
Less
Prettier
ESLint
🦄 node --version
v18.14.0
PowerShell
iwr https://get.pnpm.io/install.ps1 -useb | iex
🦄 pnpm --version
8.6.5
create-vue
的模版创建项目pnpm create vue@latest
cd vue-project
pnpm install
pnpm format
pnpm dev
pnpm install
会生成 新文件pnpm-lock.yaml
pnpm format
此模版版本不会造成文件修改
PS: VITE v4.3.9
参考:
pnpm add -D less
Vite 和 Webpack 不同,不需要 less-loader 等,只需安装 less
TodoList.vue
清空
src/components
创建
src/components/TodoList.vue
<template>
<div>TodoList</div>
</template>
修改
src/views/HomeView.vue
<script setup lang="ts">
import TodoList from '../components/TodoList.vue'
</script>
<template>
<main>
<TodoList />
</main>
</template>
修改
src/App.vue
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<header>
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
<div class="wrapper">
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
</div>
</header>
<RouterView />
</template>
<style scoped>
...
</style>
预览
5. 创建组件 TodoGroup.vue
创建新文件:
src/components/TodoGroup.vue
<template>
<div>TodoGroup</div>
</template>
修改文件:
src/components/TodoList.vue
<script setup lang="ts">
import TodoGroup from './TodoGroup.vue';
</script>
<template>
<div>
<TodoGroup />
</div>
</template>
预览
修改
src/components/TodoGroup.vue
<script setup lang="ts">
enum TodoStatus {
Pending = 'pending',
InProgress = 'InProgress',
Completed = 'completed'
}
interface Todo {
id: number
title: string
description: string
status: TodoStatus
}
const pendingTodos: Todo[] = [
{
id: 1,
title: '测试标题',
description: '测试描述',
status: TodoStatus.Pending
}
]
</script>
<template>
<div>
<h3>Pending</h3>
<ul>
<li v-for="todo in pendingTodos" :key="todo.id">{{ todo.title }}</li>
</ul>
</div>
</template>
修改
src/components/TodoList.vue
<script setup lang="ts">
import TodoGroup from './TodoGroup.vue';
</script>
<template>
<div>
<TodoGroup />
<TodoGroup />
<TodoGroup />
</div>
</template>
预览
将 TypeScript 公共自定义类型提取到
types.ts
src/types.ts
export enum TodoStatus {
Pending = 'pending',
InProgress = 'in progress',
Completed = 'completed'
}
export interface Todo {
id: number
title: string
description: string
status: TodoStatus
}
src/components/TodoList.vue
<script setup lang="ts">
import TodoGroup from './TodoGroup.vue'
import { TodoStatus } from '@/types'
</script>
<template>
<div>
<TodoGroup :status="TodoStatus.Pending" />
<TodoGroup :status="TodoStatus.InProgress" />
<TodoGroup :status="TodoStatus.Completed" />
</div>
</template>
src/components/TodoGroup.vue
<script setup lang="ts">
import { TodoStatus, type Todo } from '@/types'
import { computed } from 'vue'
interface Props {
status: TodoStatus
}
const props = defineProps<Props>()
const pendingTodos: Todo[] = [
{
id: 1,
title: '测试标题',
description: '测试描述',
status: TodoStatus.Pending
}
]
const groupLabel = computed(() => {
switch (props.status) {
case TodoStatus.Pending:
return 'Pending'
case TodoStatus.InProgress:
return 'In Progress'
case TodoStatus.Completed:
return 'Completed'
default:
return 'Todo Group'
}
})
</script>
<template>
<div>
<h3>{{ groupLabel }}</h3>
<ul>
<li v-for="todo in pendingTodos" :key="todo.id">{{ todo.title }}</li>
</ul>
</div>
</template>
预览
reactive
创建本地 stores/useTodos.ts
src/stores/useTodos.ts
import { TodoStatus, type Todo } from '@/types'
import { computed, reactive } from 'vue'
interface TodoStore {
// 行末尾的分号 ; 可省
[TodoStatus.Pending]: Todo[]
[TodoStatus.InProgress]: Todo[]
[TodoStatus.Completed]: Todo[]
}
const defaultVal = {
[TodoStatus.Pending]: [
{
id: 1,
title: '测试标题',
description: '测试描述',
status: TodoStatus.Pending
}
],
[TodoStatus.InProgress]: [],
[TodoStatus.Completed]: []
}
const todoStore = reactive<TodoStore>(defaultVal)
export default () => {
const getTodosByStatus = (todoStatus: TodoStatus) => {
return computed(() => todoStore[todoStatus])
}
return { getTodosByStatus }
}
src/components/TodoGroup.vue
<script setup lang="ts">
import { TodoStatus } from '@/types'
import { computed } from 'vue'
import useTodos from '@/stores/useTodos'
interface Props {
status: TodoStatus
}
const props = defineProps<Props>()
const { getTodosByStatus } = useTodos()
const todoList = getTodosByStatus(props.status)
const groupLabel = computed(() => {
switch (props.status) {
case TodoStatus.Pending:
return 'Pending'
case TodoStatus.InProgress:
return 'In Progress'
case TodoStatus.Completed:
return 'Completed'
default:
return 'Todo Group'
}
})
</script>
<template>
<div>
<h3>{{ groupLabel }}</h3>
<ul>
<li v-for="todo in todoList" :key="todo.id">{{ todo.title }}</li>
</ul>
</div>
</template>
预览
9. 简化 TodoGroup.vue 中 computed
src/components/TodoGroup.vue
<script setup lang="ts">
...
const groupLabel = {
[TodoStatus.Pending]: 'Pending',
[TodoStatus.InProgress]: 'In Progress',
[TodoStatus.Completed]: 'Completed'
}
</script>
<template>
<div>
<h3>{{ groupLabel[props.status] }}</h3>
...
</div>
</template>
src/components/TodoList.vue
...
<template>
<div class="groups-wrapper">
<TodoGroup :status="TodoStatus.Pending" />
<TodoGroup :status="TodoStatus.InProgress" />
<TodoGroup :status="TodoStatus.Completed" />
</div>
</template>
<style lang="less" scoped>
.groups-wrapper {
display: flex;
justify-content: space-around;
gap: 20px;
}
</style>
src/components/TodoGroup.vue
...
<template>
<div class="group-wrapper">
<h3>{{ groupLabel[props.status] }}</h3>
<ul>
<li v-for="todo in todoList" :key="todo.id">
{{ todo.title }}
<div>
<span class="todo-description">{{ todo.description }}</span>
</div>
</li>
</ul>
</div>
</template>
<style lang="less" scoped>
.group-wrapper {
flex: 1;
padding: 20px;
background-color: rgb(56, 80, 103);
width: 300px;
}
.group-wrapper li {
list-style-type: none;
background-color: aliceblue;
color: rgb(56, 80, 103);
padding: 2px 5px;
cursor: grab;
margin-bottom: 10px;
}
.todo-description {
font-size: 12px;
}
</style>
预览
参考:
pnpm add vuedraggable@next
src/components/TodoGroup.vue
<script setup lang="ts">
...
import Draggable from 'vuedraggable'
...
</script>
<template>
<div class="group-wrapper">
<h3>{{ groupLabel[props.status] }}</h3>
<Draggable class="draggable" :list="todoList" group="todos" item-key="id">
<template #item="{ element: todo }">
<li>
{{ todo.title }}
<div>
<span class="todo-description">{{ todo.description }}</span>
</div>
</li>
</template>
</Draggable>
</div>
</template>
<style lang="less" scoped>
.group-wrapper {
flex: 1;
padding: 20px;
background-color: rgb(56, 80, 103);
width: 300px;
h3 {
color: rgb(207, 221, 234);
}
.draggable {
min-height: 200px;
li {
list-style-type: none;
background-color: aliceblue;
color: rgb(56, 80, 103);
padding: 2px 5px;
cursor: grab;
margin-bottom: 10px;
.todo-description {
font-size: 12px;
}
}
}
}
</style>
预览, 此时即可拖拽单项 Todo
src/stores/useTodos.ts
import { TodoStatus, type Todo } from '@/types'
import { computed, reactive } from 'vue'
interface TodoStore {
// 行末尾的分号 ; 可省
[TodoStatus.Pending]: Todo[]
[TodoStatus.InProgress]: Todo[]
[TodoStatus.Completed]: Todo[]
}
const defaultVal = {
[TodoStatus.Pending]: [
{
id: 1,
title: '测试标题',
description: '测试描述',
status: TodoStatus.Pending
}
],
[TodoStatus.InProgress]: [],
[TodoStatus.Completed]: []
}
const todoStore = reactive<TodoStore>(defaultVal)
export default () => {
const getTodosByStatus = (todoStatus: TodoStatus) => {
return computed(() => todoStore[todoStatus])
}
const updateTodo = (todo: Todo, newStatus: TodoStatus) => {
todo.status = newStatus
}
const addNewTodo = (todo: Todo) => {
todoStore[todo.status].push(todo)
}
const deleteTodo = (todoToDelete: Todo) => {
todoStore[todoToDelete.status] = todoStore[todoToDelete.status].filter(
(todo) => todo.id != todoToDelete.id
)
}
return { getTodosByStatus, addNewTodo, deleteTodo, updateTodo }
}
src/components/CreateTodo.vue
<script setup lang="ts">
import { TodoStatus, type Todo } from '@/types'
import { reactive, ref } from 'vue'
import useTodos from '@/stores/useTodos'
interface Props {
status: TodoStatus
}
const props = defineProps<Props>()
const shouldDisplayForm = ref(false)
const { addNewTodo } = useTodos()
// id 属性对于初始化时不能指定, 通过 Omit id 去除 id 属性
const newTodo = reactive<Omit<Todo, 'id'>>({
title: '',
description: '',
status: props.status
})
const resetForm = () => {
shouldDisplayForm.value = false
newTodo.title = ''
newTodo.description = ''
}
const handleSubmit = () => {
// add new todo
addNewTodo({
id: Math.random() * 10000000000000000,
...newTodo
})
resetForm()
}
</script>
<template>
<div>
<h3 v-if="!shouldDisplayForm" @click="shouldDisplayForm = !shouldDisplayForm">Add New</h3>
<template v-else>
<form @submit.prevent="handleSubmit">
<div>
<input type="text" placeholder="Title" v-model="newTodo.title" />
</div>
<div>
<input type="text" placeholder="Description" v-model="newTodo.description" />
</div>
<button type="submit">Submit</button>
<button type="button" @click="resetForm">Cancel</button>
</form>
</template>
</div>
</template>
<style lang="less" scoped>
h3 {
color: rgb(207, 221, 234);
}
</style>
src/components/TodoGroup.vue
<script setup lang="ts">
import { TodoStatus } from '@/types'
import useTodos from '@/stores/useTodos'
import Draggable from 'vuedraggable'
import CreateTodo from './CreateTodo.vue'
interface Props {
status: TodoStatus
}
const props = defineProps<Props>()
const { getTodosByStatus, deleteTodo, updateTodo } = useTodos()
const todoList = getTodosByStatus(props.status)
const groupLabel = {
[TodoStatus.Pending]: 'Pending',
[TodoStatus.InProgress]: 'In Progress',
[TodoStatus.Completed]: 'Completed'
}
const onDraggableChange = (payload: any) => {
console.log('payload', payload)
if (payload?.added?.element) {
// update todo status
updateTodo(payload?.added?.element, props.status)
}
}
</script>
<template>
<div class="group-wrapper">
<h3>{{ groupLabel[props.status] }}</h3>
<Draggable
class="draggable"
:list="todoList"
group="todos"
item-key="id"
@change="onDraggableChange"
>
<template #item="{ element: todo }">
<li>
{{ todo.title }}
{{ todo.status }}
<span class="icon-delete" @click="deleteTodo(todo)">x</span>
<div>
<span class="todo-description">{{ todo.description }}</span>
</div>
</li>
</template>
</Draggable>
<CreateTodo :status="props.status" />
</div>
</template>
<style lang="less" scoped>
.group-wrapper {
flex: 1;
padding: 20px;
background-color: rgb(56, 80, 103);
width: 300px;
h3 {
color: rgb(207, 221, 234);
}
.draggable {
min-height: 200px;
li {
list-style-type: none;
background-color: aliceblue;
color: rgb(56, 80, 103);
padding: 2px 5px;
cursor: grab;
margin-bottom: 10px;
.icon-delete {
float: right;
cursor: pointer;
}
.todo-description {
font-size: 12px;
}
}
}
}
</style>
预览
13. 改为 Pinia 状态管理
src/stores/todo.ts
import { defineStore, acceptHMRUpdate } from 'pinia'
import { TodoStatus, type Todo } from '@/types'
interface TodoState {
// 行末尾的分号 ; 可省
[TodoStatus.Pending]: Todo[]
[TodoStatus.InProgress]: Todo[]
[TodoStatus.Completed]: Todo[]
}
export const useTodoStore = defineStore({
id: 'todo',
state: (): TodoState => ({
[TodoStatus.Pending]: [
{
id: 1,
title: '测试标题',
description: '测试描述',
status: TodoStatus.Pending
}
],
[TodoStatus.InProgress]: [],
[TodoStatus.Completed]: []
}),
getters: {
getTodosByStatus: (state) => {
return (todoStatus: TodoStatus): Todo[] => state[todoStatus]
}
},
actions: {
updateTodo(todo: Todo, newStatus: TodoStatus) {
console.log('updateTodo')
// 注意: 经过测试, 可以这么更新, 可以在 Chrome Vue Pinia 标签页看到被正确更新到目标状态下
// 不仅仅是 由于 getTodosByStatus 的原因 使其看起来像, 而是实际存储改变
// 感觉挺神奇, 发现改变单个 todo 的 status 居然使其也同步转移到了 state 的 对应属性 下
// TODO: 为什么不是仅仅改变了此 todo 的 status, 但在 state 中没有改变其所属属性, 导致属性与此 todo status 不匹配
// 我更新 a.status 从 Pending 到 InProgress,
// 最终居然还导致 state.Pending 中移除了 a, state.InProgress 添加了 a
todo.status = newStatus
},
addNewTodo(todo: Todo) {
console.log('addNewTodo')
this[todo.status].push(todo)
},
deleteTodo(todoToDelete: Todo) {
this[todoToDelete.status] = this[todoToDelete.status].filter(
(todo) => todo.id != todoToDelete.id
)
}
}
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useTodoStore, import.meta.hot))
}
src/components/CreateTodo.vue
<script setup lang="ts">
import { TodoStatus, type Todo } from '@/types'
import { reactive, ref } from 'vue'
-import useTodos from '@/stores/useTodos'
+// import useTodos from '@/stores/useTodos'
+import { useTodoStore } from '@/stores/todo'
interface Props {
status: TodoStatus
@@ -11,7 +12,8 @@ const props = defineProps<Props>()
const shouldDisplayForm = ref(false)
-const { addNewTodo } = useTodos()
+// const { addNewTodo } = useTodos()
+const todoStore = useTodoStore()
// id 属性对于初始化时不能指定, 通过 Omit id 去除 id 属性
const newTodo = reactive<Omit<Todo, 'id'>>({
@@ -28,7 +30,13 @@ const resetForm = () => {
const handleSubmit = () => {
// add new todo
- addNewTodo({
+ // addNewTodo({
+ // id: Math.random() * 10000000000000000,
+ // ...newTodo
+ // })
+
+ // pinia
+ todoStore.addNewTodo({
id: Math.random() * 10000000000000000,
...newTodo
})
src/components/TodoGroup.vue
<script setup lang="ts">
-import { TodoStatus } from '@/types'
-import useTodos from '@/stores/useTodos'
+import { TodoStatus, type Todo } from '@/types'
+// import useTodos from '@/stores/useTodos'
+import { useTodoStore } from '@/stores/todo'
import Draggable from 'vuedraggable'
import CreateTodo from './CreateTodo.vue'
+import { storeToRefs } from 'pinia';
interface Props {
status: TodoStatus
@@ -10,8 +12,13 @@ interface Props {
const props = defineProps<Props>()
-const { getTodosByStatus, deleteTodo, updateTodo } = useTodos()
-const todoList = getTodosByStatus(props.status)
+// const { getTodosByStatus, deleteTodo, updateTodo } = useTodos()
+const todoStore = useTodoStore()
+// const todoList = getTodosByStatus(props.status)
+// 错误
+// const todoList = todoStore.getTodosByStatus(props.status)
+// 正确
+const { getTodosByStatus } = storeToRefs(todoStore)
const groupLabel = {
[TodoStatus.Pending]: 'Pending',
@@ -23,9 +30,17 @@ const onDraggableChange = (payload: any) => {
console.log('payload', payload)
if (payload?.added?.element) {
// update todo status
- updateTodo(payload?.added?.element, props.status)
+ // updateTodo(payload?.added?.element, props.status)
+
+ // pinia
+ todoStore.updateTodo(payload?.added?.element, props.status)
}
}
+
+const deleteTodo = (todo: Todo) => {
+ console.log('deleteTodo', todo)
+ todoStore.deleteTodo(todo)
+}
</script>
<template>
@@ -34,7 +49,7 @@ const onDraggableChange = (payload: any) => {
<Draggable
class="draggable"
- :list="todoList"
+ :list="getTodosByStatus(props.status)"
group="todos"
item-key="id"
@change="onDraggableChange"
将
Pending
中的a
拖动到In Progress
中
拖动完成
即为索引为 1 的 In Progress 添加一项, 索引为 0 的 Pending 删除一项
Chrome 扩展 Vue
添加
watch
,
src/main.ts
import './assets/main.css'
-import { createApp } from 'vue'
+import { createApp, watch } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
+const pinia = createPinia()
-app.use(createPinia())
+// 你可以在 pinia 实例上使用 watch() 函数侦听整个 state。
+watch(
+ pinia.state,
+ (state) => {
+ // 每当状态发生变化时,将整个 state 持久化到本地存储。
+ console.log('watch: pinia.state', state)
+ localStorage.setItem('piniaState', JSON.stringify(state))
+ },
+ { deep: true }
+)
+
+app.use(pinia)
app.use(router)
app.mount('#app')
将
测试标题
从Pending
拖动到In Progress
,发现会神奇的触发两次, 而且两次输出都在In Progress
内
Q&A
可尝试先禁用 Vetur 插件
补充
npm command | pnpm equivalent |
---|---|
npm install | pnpm install |
npm i <pkg> | pnpm add <pkg> |
npm run <cmd> | pnpm <cmd> |
Command | Meaning |
---|---|
pnpm add sax | Save to dependencies |
pnpm add -D sax | Save to devDependencies |
pnpm add -O sax | Save to optionalDependencies |
pnpm add -g sax | Install package globally |
pnpm add sax@next | Install from the next tag |
pnpm add sax@3.0.0 | Specify version 3.0.0 |
ESLint 可以同时解决代码格式和代码质量,Prettier 没有使用的必要了?但其实 ESLint 主要解决的是代码质量的问题,代码格式这部分 ESLint 并没有全部做完。Prettier 就是接管了两类问题中的代码格式,并进行自动修复
@
是 v-on
的语法糖,
@click
为点击事件
在原生 DOM 对象中,有
onclick
, 传递一个函数调用, 即onclick="add()"
在 vue 中,即可传递函数,也可以传递函数调用(只有当事件触发时,才会执行)
<button @click="add">+1</button>
methods: {
add() {
// 如果在方法中要修改 data 中的数据,可以通过 this 访问到
this.count += 1
}
}
或者
<!-- vue 提供了内置变量,名字叫 $event(固定写法),它就是原生 DOM 的事件对象 e -->
<button @click="add(3, $event)"></button>
methods: {
add(n, e) {
// 如果在方法中要修改 data 中的数据,可以通过 this 访问到
this.count += 1
}
}
在 Vue.js 中,其中值甚至可以是一个表达式, 如 下方
<script setup>
import { ref } from 'vue'
const open = ref(false)
</script>
<template>
<button @click="open = true">Open Modal</button>
<div v-if="open" class="modal">
<p>Hello from the modal!</p>
<button @click="open = false">Close</button>
</div>
</template>
在 React.js 中,只能传递一个函数值
<button type="button" className="bordered medium"
onClick={onCancel}
>
cancel
</button>
若需要在调用函数的同时传递参数的话,可以通过在其中将其包装为一个箭头函数
<button
className=" bordered"
onClick={() => {
handleEditClick(project);
}}
>
<span className="icon-edit "></span>
Edit
</button>
同时可以发现, Vue 中使用
双引号
, 但并不代表其值为字符串类型, 而 React 中使用{}
表示其中为非字符串
参考:
"响应数据" 就是值变化可以驱动dom变化的数据, 我们之前在 "data" 中定义的数据就是响应数据. 但是在 "setup" 中如果我们要定义数据, 这里并没有 "data" 函数, 取而代之的是 "reactive/ref"函数:
定义响应数据, 输入只能是对象类型, 返回输入对象的响应版本.
同样是定义响应数据, 和"reactive"的区别是返回值响应数据的格式不同, ref 返回的数据需要用".value"访问.
const n = ref(110);
console.log(n);
重要:
reactive
ref
基础示例
// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => {
return { count: 0 }
},
// ☆★☆★ 也可以这样定义 ★☆★☆
// state: () => ({ count: 0 })
actions: {
increment() {
this.count++
},
},
})
在一个组件中使用该 store
<script setup>
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
// 1.
counter.count++
// 2.
// 自动补全! ✨
counter.$patch({ count: counter.count + 1 })
// 3.
// 或使用 action 代替
counter.increment()
</script>
<template>
<!-- 直接从 store 中访问 state -->
<div>Current Count: {{ counter.count }}</div>
</template>
为实现更多高级用法,你甚至可以使用一个函数 (与组件
setup()
类似) 来定义一个 Store:
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
function increment() {
count.value++
}
// ☆★☆★ 注意: 将 状态 与 操作 都封装到一个对象中返回 ★☆★☆
return { count, increment }
})
更真实的示例
这是一个更完整的 Pinia API 示例
import { defineStore } from 'pinia'
export const useTodos = defineStore('todos', {
state: () => ({
/** @type {{ text: string, id: number, isFinished: boolean }[]} */
todos: [],
/** @type {'all' | 'finished' | 'unfinished'} */
filter: 'all',
// 类型将自动推断为 number
nextId: 0,
}),
getters: {
finishedTodos(state) {
// 自动补全! ✨
return state.todos.filter((todo) => todo.isFinished)
},
unfinishedTodos(state) {
return state.todos.filter((todo) => !todo.isFinished)
},
/**
* @returns {{ text: string, id: number, isFinished: boolean }[]}
*/
filteredTodos(state) {
if (this.filter === 'finished') {
// 调用其他带有自动补全的 getters ✨
return this.finishedTodos
} else if (this.filter === 'unfinished') {
return this.unfinishedTodos
}
return this.todos
},
},
actions: {
// 接受任何数量的参数,返回一个 Promise 或不返回
addTodo(text) {
// 你可以直接变更该状态
this.todos.push({ text, id: this.nextId++, isFinished: false })
},
},
})
购物车示例
stores/cart.ts
import { defineStore, acceptHMRUpdate } from 'pinia'
import { useUserStore } from './user'
export const useCartStore = defineStore({
id: 'cart',
state: () => ({
rawItems: [] as string[],
}),
getters: {
items: (state): Array<{ name: string; amount: number }> =>
state.rawItems.reduce((items, item) => {
const existingItem = items.find((it) => it.name === item)
if (!existingItem) {
items.push({ name: item, amount: 1 })
} else {
existingItem.amount++
}
return items
}, [] as Array<{ name: string; amount: number }>),
},
actions: {
addItem(name: string) {
this.rawItems.push(name)
},
removeItem(name: string) {
const i = this.rawItems.lastIndexOf(name)
if (i > -1) this.rawItems.splice(i, 1)
},
async purchaseItems() {
const user = useUserStore()
if (!user.name) return
console.log('Purchasing', this.items)
const n = this.items.length
this.rawItems = []
return n
},
},
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useCartStore, import.meta.hot))
}
stores/user.ts
// @ts-check
import { defineStore, acceptHMRUpdate } from 'pinia'
/**
* Simulate a login
*/
function apiLogin(a: string, p: string) {
if (a === 'ed' && p === 'ed') return Promise.resolve({ isAdmin: true })
if (p === 'ed') return Promise.resolve({ isAdmin: false })
return Promise.reject(new Error('invalid credentials'))
}
export const useUserStore = defineStore({
id: 'user',
state: () => ({
name: 'Eduardo',
isAdmin: true,
}),
actions: {
logout() {
this.$patch({
name: '',
isAdmin: false,
})
// we could do other stuff like redirecting the user
},
/**
* Attempt to login a user
*/
async login(user: string, password: string) {
const userData = await apiLogin(user, password)
this.$patch({
name: user,
...userData,
})
},
},
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useUserStore, import.meta.hot))
}
参考:
Store 有三个核心概念
等同于组件的 data、computed、methods
import { defineStore } from 'pinia'
// 你可以对 `defineStore()` 的返回值进行任意命名,但最好使用 store 的名字,同时以 `use` 开头且以 `Store` 结尾。(比如 `useUserStore`,`useCartStore`,`useProductStore`)
// 第一个参数是你的应用中 Store 的唯一 ID。
export const useMainStore = defineStore('main',{
// id:'main', // 如果 defineStore 没有传入第一个参数 name, 而是直接传入一个对象,那么我们可以在这里设置 id;是等价的
state: () => ({ counter: 0 }),
})
xxxStore = useXxxStore()
Option Store
与 Vue 的选项式 API 类似,我们也可以传入一个带有 state
、actions
与 getters
属性的 Option 对象
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: {
double: (state) => state.count * 2,
},
actions: {
increment() {
this.count++
},
},
})
可以认为 state
是 store 的数据 (data
),getters
是 store 的计算属性 (computed
),而 actions
则是方法 (methods
)。
Setup Store
也存在另一种定义 store 的可用语法。
与 Vue 组合式 API 的 setup 函数 相似,我们可以传入一个函数,该函数定义了一些响应式属性和方法,
并且返回一个带有我们想暴露出去的属性和方法的对象。
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
function increment() {
count.value++
}
return { count, increment }
})
在 Setup Store 中:
ref()
就是 state
属性computed()
就是 getters
function()
就是 actions
Setup store 比 Option Store 带来了更多的灵活性,
因为你可以在一个 store 内创建侦听器,并自由地使用任何组合式函数。
不过,请记住,使用组合式函数会让 SSR 变得更加复杂。
虽然我们前面定义了一个 store, 但在我们使用
<script setup>
调用useStore()
(或者使用setup()
函数, 像所有的组件那样) 之前,store 实例是不会被创建的:
<script setup>
import { useCounterStore } from '@/stores/counter'
// 可以在组件中的任意位置访问 `store` 变量 ✨
const store = useCounterStore()
</script>
请注意,
store
是一个用reactive
包装的对象, 这意味着不需要在 getters 后面写.value
,就像setup
中的props
一样, 如果你写了,我们也不能解构它:
<script setup>
const store = useCounterStore()
// ❌ 这将不起作用,因为它破坏了响应性
// 这就和直接解构 `props` 一样
const { name, doubleCount } = store
name // 将始终是 "Eduardo"
doubleCount // 将始终是 0
setTimeout(() => {
store.increment()
}, 1000)
// ✅ 这样写是响应式的
// 💡 当然你也可以直接使用 `store.doubleCount`
const doubleValue = computed(() => store.doubleCount)
</script>
注意其中的下方为 错误 示范
const { name, doubleCount } = store
name // 将始终是 "Eduardo"
doubleCount // 将始终是 0
为了从 store 中提取属性时保持其响应性,你需要使用
storeToRefs()
。 它将为每一个响应式属性创建引用。 当你只使用 store 的状态而不调用任何 action 时,它会非常有用。 请注意,你可以直接从 store 中解构 action,因为它们也被绑定到 store 上:
<script setup>
import { storeToRefs } from 'pinia'
const store = useCounterStore()
// `name` 和 `doubleCount` 是响应式的 ref
// 同时通过插件添加的属性也会被提取为 ref
// 并且会跳过所有的 action 或非响应式 (不是 ref 或 reactive) 的属性
const { name, doubleCount } = storeToRefs(store)
// 作为 action 的 increment 可以直接解构
const { increment } = store
</script>
Pinia 在底层将 state 用 reactive 做了处理
Getter 完全等同于 store 的 state 的计算值。 可以通过
defineStore()
中的getters
属性来定义它们。 推荐使用箭头函数,并且它将接收state
作为第一个参数:大多数时候,getter 仅依赖 state, 不过,有时它们也可能会使用其他 getter。 因此,即使在使用常规函数定义 getter 时,我们也可以通过
this
访问到整个 store 实例, 但 (在 TypeScript 中) 必须定义返回类型。 这是为了避免 TypeScript 的已知缺陷, 不过这不影响用箭头函数定义的 getter,也不会影响不使用this
的 getter。
export const useStore = defineStore('main', {
state: () => ({
count: 0,
}),
getters: {
// 自动推断出返回类型是一个 number
doubleCount(state) {
return state.count * 2
},
// 返回类型**必须**明确设置
doublePlusOne(): number {
// 整个 store 的 自动补全和类型标注 ✨
return this.doubleCount + 1
},
},
})
直接访问 store 实例上的 getter
<script setup>
import { useCounterStore } from './counterStore'
const store = useCounterStore()
</script>
<template>
<p>Double count is {{ store.doubleCount }}</p>
</template>
Getter 只是幕后的计算属性,所以不可以向它们传递任何参数。 不过,你可以从 getter 返回一个函数,该函数可以接受任意参数:
export const useStore = defineStore('main', {
getters: {
getUserById: (state) => {
// 在内部再返回一个箭头函数
return (userId) => state.users.find((user) => user.id === userId)
},
},
})
在组件中使用:
<script setup>
import { useUserListStore } from './store'
const userList = useUserListStore()
const { getUserById } = storeToRefs(userList)
// 请注意,你需要使用 `getUserById.value` 来访问
// <script setup> 中的函数
</script>
<template>
<p>User 2: {{ getUserById(2) }}</p>
</template>
请注意,当你这样做时,getter 将不再被缓存,它们只是一个被你调用的函数。 不过,你可以在 getter 本身中缓存一些结果,虽然这种做法并不常见,但有证明表明它的性能会更好:
export const useStore = defineStore('main', {
getters: {
getActiveUserById(state) {
const activeUsers = state.users.filter((user) => user.active)
return (userId) => activeUsers.find((user) => user.id === userId)
},
},
})
想要使用另一个 store 的 getter 的话,那就直接在 getter 内使用就好:
import { useOtherStore } from './other-store'
export const useStore = defineStore('main', {
state: () => ({
// ...
}),
getters: {
otherGetter(state) {
// 就像是 在组件中访问 getter 一样
const otherStore = useOtherStore()
return state.localData + otherStore.data
},
},
})
setup()
时的用法作为 store 的一个属性,你可以直接访问任何 getter( 与 state 属性完全一样 ):
<script setup>
const store = useCounterStore()
store.count = 3
store.doubleCount // 6
</script>
// 示例文件路径:
// ./src/stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
}),
getters: {
doubleCount(state) {
return state.count * 2
},
},
})
setup()
<script>
import { useCounterStore } from '../stores/counter'
export default defineComponent({
setup() {
const counterStore = useCounterStore()
return { counterStore }
},
computed: {
quadrupleCounter() {
return this.counterStore.doubleCount * 2
},
},
})
</script>
setup()
你可以使用前一节的 state 中的
mapState()
函数来将其映射为 getters:
import { mapState } from 'pinia'
import { useCounterStore } from '../stores/counter'
export default {
computed: {
// 允许在组件中访问 this.doubleCount
// 与从 store.doubleCount 中读取的相同
...mapState(useCounterStore, ['doubleCount']),
// 与上述相同,但将其注册为 this.myOwnName
...mapState(useCounterStore, {
myOwnName: 'doubleCount',
// 你也可以写一个函数来获得对 store 的访问权
double: store => store.doubleCount,
}),
},
}
参考:
Action 相当于组件中的 method。 它们可以通过
defineStore()
中的actions
属性来定义,并且它们也是定义业务逻辑的完美选择。类似 getter,action 也可通过
this
访问整个 store 实例, 不同的是,action
可以是异步的, 你可以在它们里面await
调用任何 API,以及其他 action!下面是一个使用 Mande 的例子。 请注意,你使用什么库并不重要,只要你得到的是一个Promise
, 你甚至可以 (在浏览器中) 使用原生fetch
函数:
import { mande } from 'mande'
const api = mande('/api/users')
export const useUsers = defineStore('users', {
state: () => ({
userData: null,
// ...
}),
actions: {
async registerUser(login, password) {
try {
this.userData = await api.post({ login, password })
showTooltip(`Welcome back ${this.userData.name}!`)
} catch (error) {
showTooltip(error)
// 让表单组件显示错误
return error
}
},
},
})
Action 可以像函数或者通常意义上的方法一样被调用:
<script setup>
const store = useCounterStore()
// 将 action 作为 store 的方法进行调用
store.randomizeCounter()
</script>
<template>
<!-- 即使在模板中也可以 -->
<button @click="store.randomizeCounter()">Randomize</button>
</template>
和 getter 一样, 直接在 action 中用
import { useAuthStore } from './auth-store'
export const useSettingsStore = defineStore('settings', {
state: () => ({
preferences: null,
// ...
}),
actions: {
async fetchUserPreferences() {
// 同样调用方式, 和在组件中用一样
// 不过这里 isAuthenticated 目测不是 action 啊
const auth = useAuthStore()
if (auth.isAuthenticated) {
this.preferences = await fetchPreferences()
} else {
throw new Error('User must be authenticated')
}
},
},
})
// 示例文件路径:
// ./src/stores/counter.js
import { defineStore } from 'pinia'
const useCounterStore = defineStore('counter', {
state: () => ({
count: 0
}),
actions: {
// 普通函数 method
increment() {
// 直接用 this 访问 state 内的 count
this.count++
}
}
})
setup()
setup()
参考:
参考:
Pinia 支持热更新, 所以你可以编辑你的 store,并直接在你的应用中与它们互动,而不需要重新加载页面, 允许你保持当前的 state、并添加甚至删除 state、action 和 getter。
目前,只有 Vite 被官方支持
比方说,你有三个 store:
auth.js
、cart.js
和chat.js
, 你必须在每个 store 声明后都添加(和调整)这段代码。
// auth.js
import { defineStore, acceptHMRUpdate } from 'pinia'
const useAuth = defineStore('auth', {
// 配置...
})
// 确保传递正确的 store 声明,本例中为 `useAuth`
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useAuth, import.meta.hot))
}
向下兼容: Vue 3 可以部分兼容 Vue 2 写法
Vue 2 写法 + Vue 3 特性
纯 Vue 3: setup 语法糖
Vue 3 setup 语法糖,使 data , methods 直接写在了 <script setup> 内
Vue 2 : 选项式 API Vue 3 : setup: 组合式 API 注意: Pinia 定义 store 即可选项式 API,也可组合式 API 个人理解: 本质上 Vue 2 , Vue 3 都属于 申明式, 而 Vue 3 与 React 新版一样都趋向于 函数式 编程
Vue 2 响应式
当点击按钮时, 对 obj.a 进行更新,可以立即在界面上看到变化, 然而对 data 中本不存在的 obj.b 进行新增, 就会发现在界面上没有变化, 当然实际上成功在 obj 上新增了 b, 只是没有在界面上产生(响应式)更新/变化 而已。 查看一下 this
查看控制台, 发现 b 没有对应的 get b, set b
在 Vue 2 中,我们可以这么解决
再次查看控制台, 发现 b 已拥有对应 get b, set b
Vue 3 响应式
死数据: 界面不会随着数据的更新而更新, 始终显示初始值
响应式:
ref
使用时需要x.value
其实
ref
里也可以放对象, 甚至在 对象 中新增属性, 也会在界面上更新(响应式), 而 Vue 2 直接用就不行
响应式
reactive
接收一个对象作为参数 无需x.value
在 对象 上新增属性也没问题
若 给 reactive
传递一个 非对象/数组 值,例如: reactive('1')
cannot be made reactive
这时会发现点击按钮后,界面上 str
并没有更新到 2
传递数组, 修改数组某项值, 成功更新到界面
3+2
写法
Vue 2
与 Vue 3
数据拦截 不同点Vue 2.x
Object.defineProperty
Vue 3.x
new Proxy
Object.defineProperty
查看控制台
但是注意
添加一个 obj.x
后
但是发现 obj.x
没有 get
, set
new Proxy
查看控制台
发现全部都成功 尝试修改
查看控制台
修改 m.obj.x
会发现
Proxy
实现起来性能 高于Object.defineProperty
Object.defineProperty
实现此效果需循环以及递归
setup
语法糖插件: unplugin-auto-import
自动帮助引入
ref
,reactive
, 甚至toRefs
等 无需再在组件内手动显式引用
npm i -D unplugin-auto-import
vite.config.js
toRefs
从一个
reactive
中解构数据, 会导致解构出的数据为普通数据,不具有响应式特点
查看控制台,就是字符串 "张三"
修改也就不具有响应式特点,无法在界面得到同步更新
使用 toRefs(obj)
解决这个问题
Vue 2
与 Vue 3
中 computed
Vue 2
Vue 2 加上
get(){}
和set(val){}
使得计算属性可变
Vue 3 计算属性
添加 get(){}
, set(val){}
使其可变(可更新)
watch
Vue 3
同时监听多个
发现不是很好判定是哪个数据发生了改变, str
, num
的改变都会调用此函数
初始化监听
即第一次初始化时,就触发一次, 即一开始就会触发一次 watch
watch
对象时, 例如 watch
被 reactive
的 Proxy
对象时,
TODO: 发现 oldVal
与 newVal
一致,这是为什么 ?
监听一个对象中的某属性
image-20230702211336878注意,下方要用到 deep: true
否则监听不到变化
image-20230702211547353立即执行监听函数 和初始化有点像
Vue 3
中 useRouter
等价于 Vue 2
中 this.$router
Vue 3
中 UseRoute
等价于 Vue 2
中 this.$route
PS: Vue 3: 若使用了
unplugin-auto-import
插件,并配置了vue-router
,则无需手动导入Vue 3 监听路由
Vue Router
Vue 3
setup
image-20230702213812486关于 setup
setup 执行的时机 > 在 beforeCreate 之前执行一次,this 是 undefined 。
beforeCreate(){
console.log('beforeCreate');
},
setup(){
console.log('setup',this);
}
Vue 3
image-20230702221509664子组件接收 props
image-20230702222330271还有一种选项式 API 写法
如果你没有使用 <script setup>
,
props 必须以 props
选项的方式 声明 ,props 对象会作为 setup()
函数的第一个参数被传入:
export default {
props: ['title'],
setup(props) {
console.log(props.title)
}
}
Vue 3 选项式 API
image-20230702223613256setup
组合式 API
子
v-model
传值Vue 3
用第三方包:
mitt
npm install -S mitt
src/plugins/Bus.js
A 组件
B 组件
匿名插槽
具名插槽
PS:
v-slot:xxx
可以简写为#xxx
作用域插槽
动态插槽
传送,在指定位置展示 可用于子组件内需要在父组件范围内定位某些元素, 有些时候,封装在子组件中更为合适,或者说父组件(宿主组件)行为无法确定, 你是在写组件库等时,但需要某些元素放在此组件外部,例如某些定位行为
如果此组件在父组件中,那么可以在父组件范围内传送
PS: 其中 组件 是没有必要响应式的,于是使用
markRaw(A)
可提高性能
在大型项目中,我们可能需要拆分应用为更小的块,并 仅在需要时再从服务器加载相关组件。
Vue 提供了 defineAsyncComponent
方法来实现此功能:
import { defineAsyncComponent } from 'vue'
const AsyncComp = defineAsyncComponent(() => {
return new Promise((resolve, reject) => {
// ...从服务器获取组件
resolve(/* 获取到的组件 */)
})
})
// ... 像使用其他一般组件一样使用 `AsyncComp`
如你所见,defineAsyncComponent
方法接收一个返回 Promise 的加载函数。
这个 Promise 的 resolve
回调方法应该在从服务器获得组件定义时调用。
你也可以调用 reject(reason)
表明加载失败。
ES 模块动态导入也会返回一个 Promise,所以多数情况下我们会将它和 defineAsyncComponent
搭配使用。
类似 Vite 和 Webpack 这样的构建工具也支持此语法 (并且会将它们作为打包时的代码分割点),
因此我们也可以用它来导入 Vue 单文件组件:
import { defineAsyncComponent } from 'vue'
const AsyncComp = defineAsyncComponent(() =>
import('./components/MyComponent.vue')
)
最后 得到的 AsyncComp
是一个外层包装过的组件,仅在页面需要它渲染时才会调用加载内部实际组件的函数。
它会将接收到的 props 和插槽传给内部组件,所以你可以使用这个异步的包装组件无缝地替换原始组件,同时实现延迟加载。
与普通组件一样,异步组件可以使用 app.component()
全局注册:
app.component('MyComponent', defineAsyncComponent(() =>
import('./components/MyComponent.vue')
))
也可以直接在父组件中直接定义它们:
<script setup>
import { defineAsyncComponent } from 'vue'
const AdminPage = defineAsyncComponent(() =>
import('./components/AdminPageComponent.vue')
)
</script>
<template>
<AdminPage />
</template>
例子
异步操作不可避免地会涉及到加载和错误状态,因此 defineAsyncComponent()
也支持在高级选项中处理这些状态:
Mixin 混入 分发 Vue 组件中的可复用功能 mixin.js
另一种写法: 选项式 mixin.js
依赖注入
v-if
vs. v-show
v-if
是“真实的”按条件渲染,因为它确保了在切换时,条件区块内的事件监听器和子组件都会被 销毁与重建。v-if
也是惰性的:如果在初次渲染时条件值为 false,则不会做任何事。条件区块只有当条件首次变为 true 时才被渲染。 相比之下,v-show
简单许多,元素无论初始条件如何,始终会被渲染,只有 CSSdisplay
属性会被切换。 总的来说,v-if
有更高的切换开销,而v-show
有更高的初始渲染开销。 因此,如果需要频繁切换,则使用v-show
较好;如果在运行时绑定条件很少改变,则v-if
会更合适。
v-if
和 v-for
警告 同时使用
v-if
和v-for
是不推荐的,因为这样二者的优先级不明显。 请查看风格指南获得更多信息。当
v-if
和v-for
同时存在于一个元素上的时候,v-if
会首先被执行。 请查看列表渲染指南获取更多细节。
<script setup>
当使用 <script setup>
时,defineProps()
宏函数支持从它的参数中推导类型:
<script setup lang="ts">
const props = defineProps({
foo: { type: String, required: true },
bar: Number
})
props.foo // string
props.bar // number | undefined
</script>
这被称之为“运行时声明”,因为传递给 defineProps()
的参数会作为运行时的 props
选项使用。
然而,通过 泛型参数 来定义 props 的类型通常更直接:
<script setup lang="ts">
// 用 bar? 表示 bar 可为 undefined,
// 注意和 C# 不同,C# 表示类型可 null 是在类型后加 '?' , eg: int?
const props = defineProps<{
foo: string
bar?: number
}>()
</script>
这被称之为 “基于类型的声明”。
编译器会尽可能地尝试根据类型参数推导出等价的运行时选项。
在这种场景下,我们第二个例子中编译出的运行时选项和第一个是完全一致的。
基于类型的声明 或者 运行时声明 可以择一使用,但是不能同时使用。
我们也可以将 props 的类型移入一个单独的接口中:
<script setup lang="ts">
interface Props {
foo: string
bar?: number
}
const props = defineProps<Props>()
</script>
当使用基于类型的声明时,我们失去了为 props 声明默认值的能力。
这可以通过 withDefaults
编译器宏解决:
export interface Props {
msg?: string
labels?: string[]
}
const props = withDefaults(defineProps<Props>(), {
msg: 'hello',
labels: () => ['one', 'two']
})
这将被编译为等效的运行时 props default
选项。
此外,withDefaults
帮助程序为默认值提供类型检查,并确保返回的 props 类型删除了已声明默认值的属性的可选标志。
ref()
标注类型ref 会根据初始化时的值推导其类型:
感谢帮助!