使用 Vue 3 与 TypeScript 构建 Web 应用: Todo
引言 界面: Vue.js 3 JavaScript 超集: TypeScript 包管理器: pnpm 前端工程化/打包: Vite 路由: Vue Router 状态管理: Pinia CSS 预处理器: Less 代码格式化: Prettier 代码质量: ESLint 预览
技术栈
详细
-
界面:
Vue.js 3
- GitHub: vuejs/core: 🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web .
- 链接: Vue.js - The Progressive JavaScript Framework | Vue.js
-
create-vue
-
注意: 本文使用
create-vue
, 此模版源拥有更多模板 -
模版
- 模版 来源 -1: create-vite
-
模版
来源
-2:
create-vue
, 即为
npm init vue@latest
, 基于Vite + Vue
-
模版
来源
3:
Vue CLI
-
补充
- 来自 Vue 的模版 来源 : Project Scaffolding | Vue.js
-
npm init vue@latest
等同于npm create vue@3
, 将使用create-vue
模版(基于 vite), 而不是Vue CLI
模版(基于 webpack)
-
注意: 本文使用
-
JavaScript 超集:
TypeScript
-
包管理器:
pnpm
-
前端工程化/打包:
Vite
-
路由:
Vue Router
-
状态管理:
Pinia
-
CSS 预处理器:
Less
-
代码格式化:
Prettier
-
代码质量:
ESLint
- 其它三方库
本地开发环境
🦄 node --version
v18.14.0
1. 安装 pnpm
PowerShell
iwr https://get.pnpm.io/install.ps1 -useb | iex
🦄 pnpm --version
8.6.5
2. 使用
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
3. 安装 less
参考:
pnpm add -D less
Vite 和 Webpack 不同,不需要 less-loader 等,只需安装 less
4. 创建组件
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>
<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">
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</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>
<TodoGroup />
</template>
预览
6. 修改组件
修改
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>
<h3>Pending</h3>
<li v-for="todo in pendingTodos" :key="todo.id">{{ todo.title }}</li>
</template>
修改
src/components/TodoList.vue
<script setup lang="ts">
import TodoGroup from './TodoGroup.vue';
</script>
<template>
<TodoGroup />
<TodoGroup />
<TodoGroup />
</template>
预览
7. 进一步整理
将 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>
<TodoGroup :status="TodoStatus.Pending" />
<TodoGroup :status="TodoStatus.InProgress" />
<TodoGroup :status="TodoStatus.Completed" />
</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>
<h3>{{ groupLabel }}</h3>
<li v-for="todo in pendingTodos" :key="todo.id">{{ todo.title }}</li>
</template>
预览
8. 利用
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>
<h3>{{ groupLabel }}</h3>
<li v-for="todo in todoList" :key="todo.id">{{ todo.title }}</li>
</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>
<h3>{{ groupLabel[props.status] }}</h3>
</template>
10. 添加样式
src/components/TodoList.vue
...
<template>
<div class="groups-wrapper">
<TodoGroup :status="TodoStatus.Pending" />
<TodoGroup :status="TodoStatus.InProgress" />
<TodoGroup :status="TodoStatus.Completed" />
</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>
<li v-for="todo in todoList" :key="todo.id">
{{ todo.title }}
<span class="todo-description">{{ todo.description }}</span>
</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>
预览
11. 可拖拽, 样式优化
参考:
-
SortableJS/vue.draggable.next: Vue 3 compatible drag-and-drop component based on Sortable.js
- Vue 3 可拖拽
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 }">
{{ todo.title }}
<span class="todo-description">{{ todo.description }}</span>
</template>
</Draggable>
</template>
<style lang="less" scoped>
.group-wrapper {
flex: 1;
padding: 20px;
background-color: rgb(56, 80, 103);
width: 300px;
color: rgb(207, 221, 234);
.draggable {
min-height: 200px;
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
12. 添加 Todo, 删除 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>
<h3 v-if="!shouldDisplayForm" @click="shouldDisplayForm = !shouldDisplayForm">Add New</h3>
<template v-else>
<form @submit.prevent="handleSubmit">
<input type="text" placeholder="Title" v-model="newTodo.title" />
<input type="text" placeholder="Description" v-model="newTodo.description" />
<button type="submit">Submit</button>
<button type="button" @click="resetForm">Cancel</button>
</form>
</template>
</template>
<style lang="less" scoped>
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 }">
{{ todo.title }}
{{ todo.status }}
<span class="icon-delete" @click="deleteTodo(todo)">x</span>
<span class="todo-description">{{ todo.description }}</span>
</template>
</Draggable>
<CreateTodo :status="props.status" />
</template>
<style lang="less" scoped>
.group-wrapper {
flex: 1;
padding: 20px;
background-color: rgb(56, 80, 103);
width: 300px;
color: rgb(207, 221, 234);
.draggable {
min-height: 200px;
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
Q: Use volar-service-vetur instead of Vetur 参考: services/packages/vetur at master · volarjs/services · GitHub vuejs/vetur: Vue tooling for VS Code.
可尝试先禁用 Vetur 插件
补充
pnpm: node_modules
npm vs pnpm
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 可以同时解决代码格式和代码质量,Prettier 没有使用的必要了?但其实 ESLint 主要解决的是代码质量的问题,代码格式这部分 ESLint 并没有全部做完。Prettier 就是接管了两类问题中的代码格式,并进行自动修复
Vue 中 @click
@
是
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>
</template>
在 React.js 中,只能传递一个函数值
<button type="button" className="bordered medium"
onClick={onCancel}
cancel
</button>
若需要在调用函数的同时传递参数的话,可以通过在其中将其包装为一个箭头函数
<button
className=" bordered"
onClick={() => {
handleEditClick(project);
<span className="icon-edit "></span>
</button>
同时可以发现, Vue 中使用
双引号
, 但并不代表其值为字符串类型, 而 React 中使用{}
表示其中为非字符串
Vue 3 中定义响应数据 (reactive/ref)
参考:
"响应数据" 就是值变化可以驱动dom变化的数据 , 我们之前在 " data " 中定义的数据就是响应数据. 但是在 " setup " 中如果我们要定义数据, 这里并没有 " data" 函数 , 取而代之的是 " reactive/ref " 函数 :
reactive
定义响应数据, 输入只能是对象类型 , 返回输入对象的响应版本.
ref
同样是定义响应数据, 和"reactive"的区别是返回值响应数据的格式不同, ref 返回的数据需要用".value"访问.
const n = ref(110);
console.log(n);
reactive 和 ref 的选择
重要 :
-
如果要监视的数据是引用型数据(object)那么就是用
reactive
-
如果是(number/boolean/string)等原始数据类型就用
ref
状态管理: Pinia
基础示例
// 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))
}
Pinia vs Vuex
参考:
- Pinia 实现了 Vuex 5 大部分, 最终决定 Pinia 为 Vuex 下一代
- 与 Vuex 相比,Pinia 提供了一个更简单的 API,具有更少的仪式,提供了 Composition API 风格的 API
- Pinia 与 TypeScript 一起使用时具有可靠的类型推断支持, Vuex 之前对 TS 的支持很不友好
-
Pinia: mutations 不再存在
- 它经常被认为是 非常 冗长
- 它最初带来了 devtools 集成,但这不再是问题
-
Pinia: 不再有 modules 的嵌套结构
- 你可以灵活使用每一个store,它们是通过 扁平化的方式来相互使用的
- 也不再有 命名空间的概念 ,不需要记住它们的复杂关系
概念
Store 有三个核心概念
- state
- getters
- actions
等同于组件的 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 }),
})
- 这个 name,也称为 id, 是必要的 ,Pinia 使用它来将 store 连接到 devtools。
- 第一个参数是应用程序中 store 的唯一 id
- 返回的函数统一使用 useXxxStore作为命名方案 ,这是 约定的规范 ;
-
xxxStore = useXxxStore()
Option Store
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
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
虽然我们前面定义了一个 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 的响应式
Pinia 在底层将 state 用 reactive 做了处理
Getter
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 只是幕后的 计算 属性,所以不可以向它们传递任何参数。 不过,你可以从 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
想要使用另一个 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>
使用选项式 API 的用法
// 示例文件路径:
// ./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
参考:
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>
访问其他 store 的 action
和 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')
})
使用选项式 API 的用法
// 示例文件路径:
// ./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()
订阅 action
参考:
Pinia: 热更新 HMR (Hot Module Replacement)
参考:
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 风格 vs Vue 2 风格
向下兼容: 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 响应式问题 与 Vue 3 改进
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
实现此效果需循环以及递归
Vue 3:
setup
语法糖插件:
unplugin-auto-import
自动帮助引入
ref
,reactive
, 甚至toRefs
等 无需再在组件内手动显式引用
npm i -D unplugin-auto-import
vite.config.js
Vue 3:
toRefs
从一个
reactive
中解构数据, 会导致解构出的数据为普通数据,不具有响应式特点
查看控制台,就是字符串 "张三"
修改也就不具有响应式特点,无法在界面得到同步更新
使用
toRefs(obj)
解决这个问题
Vue 2
与
Vue 3
中
computed
Vue 2
Vue 2 加上
get(){}
和set(val){}
使得计算属性可变
Vue 3
添加
get(){}
,
set(val){}
使其可变(可更新)
Vue:
watch
Vue 3
同时监听多个
发现不是很好判定是哪个数据发生了改变,
str
,
num
的改变都会调用此函数
初始化监听
即第一次初始化时,就触发一次, 即一开始就会触发一次
watch
watch
对象时, 例如
watch
被
reactive
的
Proxy
对象时,
TODO: 发现
oldVal
与
newVal
一致,这是为什么 ?
监听一个对象中的某属性
image-20230702211336878
注意,下方要用到
deep: true
否则监听不到变化
image-20230702211547353 立即执行监听函数 和初始化有点像
Vue: 路由
-
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: 生命周期
Vue 3
setup
image-20230702213812486
关于
setup
setup 执行的时机 > 在 beforeCreate 之前执行一次,this 是 undefined 。
beforeCreate(){
console.log('beforeCreate');
setup(){
console.log('setup',this);
}
Vue: 组件: 父传子
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: 组件: 子传父
Vue 3 选项式 API
image-20230702223613256
setup
组合式 API
Vue:
v-model
传值
Vue 3
Vue: 组件: 兄弟组件传值
用第三方包:
mitt
npm install -S mitt
src/plugins/Bus.js
A 组件
B 组件
Vue: 插槽
匿名插槽
具名插槽
PS:
v-slot:xxx
可以简写为#xxx
作用域插槽
Vue: Teleport
传送,在指定位置展示 可用于子组件内需要在父组件范围内定位某些元素, 有些时候,封装在子组件中更为合适,或者说父组件(宿主组件)行为无法确定, 你是在写组件库等时,但需要某些元素放在此组件外部,例如某些定位行为
如果此组件在父组件中,那么可以在父组件范围内传送
Vue: 动态组件
PS: 其中 组件 是没有必要响应式的,于是使用
markRaw(A)
可提高性能Vue: 异步组件
基本用法
在大型项目中,我们可能需要拆分应用为更小的块,并 仅在需要时再从服务器加载相关组件 。
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()
也支持在高级选项中处理这些状态:Vue: Mixin
Mixin 混入 分发 Vue 组件中的可复用功能 mixin.js
image-20230708165029366 A.vue
另一种写法: 选项式 mixin.js
image-20230708165544846 A.vue
Vue: Provide 与 Inject
依赖注入
Vuex
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
会首先被执行。 请查看 列表渲染指南 获取更多细节。TypeScript 与 组合式 API
为组件的 props 标注类型
使用
<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 解构默认值
当使用基于类型的声明时,我们失去了为 props 声明默认值的能力。
这可以通过
withDefaults
编译器宏解决:export interface Props { msg?: string labels?: string[]