Vue3周边之Router4和Pinia

568 阅读9分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第17天,点击查看活动详情

目标

✔ Vue Router4。

✔ Vuex4 和 Pinia。

Vue Router4

Vue 升级 3.x 之后,配套的 Vue Router 也升级为 4.x 的版本,Vue Router4 的语法和 3 的版本基本一致,但是有一些细微的修改。

# vue@2 + vue-router@3 + vuex@3    options api
# vue@3 + vue-router@4 + vuex@4    composition api

基本使用

  1. 创建项目。
npm create vite
  1. 安装 vue-router
// 如果安装时出现错误,请先把当前正在运行的项目停止后(ctrl + c)再安装
yarn add vue-router
  1. 配置路由并导出路由实例,router/index.js
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'// 创建路由实例
const router = createRouter({
    // 创建history模式的路由
    // history: createWebHistory(),
    // 创建hash模式的路由
    history: createWebHashHistory(),
    // 配置路由规则
    routes: [
        { path: '/home', component: () => import('../pages/Home.vue') },
        { path: '/login', component: () => import('../pages/Login.vue') },
    ],
})
​
export default router
  1. 引入 router 实例,main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'createApp(App).use(router).mount('#app')
  1. 组件中指定路由入口和出口,App.vue
<template>
    <ul>
        <li>
            <router-link to="/home">首页</router-link>
        </li>
        <li>
            <router-link to="/login">登陆</router-link>
        </li>
    </ul>
​
    <!-- 路由出口 -->
    <router-view></router-view>
</template>

组件中使用 Route 与 Router

由于组件中无法访问 this,因为无法使用 this.routethis.route 与 this.router。

  1. 通过 useRoute() 可以获取 route 信息,Home.vue
<script setup>
    import { useRoute } from 'vue-router'
​
    const route = useRoute()
    console.log(route.path)
    console.log(route.fullPath)
</script>
<template>
    <div>Home</div>
</template>
  1. 通过 useRouter() 可以获取 router 信息,Login.vue
<script setup>
    import { useRouter } from 'vue-router'
​
    const router = useRouter()
​
    const handleClick = () => {
        router.push('/home')
    }
</script>
<template>
    <div>
        <h3>Login</h3>
        <button @click="handleClick">Go Home</button>
    </div>
</template>

Vuex4

基本使用

  1. 安装依赖包。
yarn add vuex
  1. 创建 Vuex 实例并导出,store/index.js
import { createStore } from 'vuex'const store = createStore({
    state: {
        money: 100,
    },
    mutations: {
        changeMoney(state) {
            state.money += 10
        },
    },
    actions: {
        changeMoneyAsync(context) {
            setTimeout(() => {
                context.commit('changeMoney')
            }, 1000)
        },
    },
    getters: {
        double(state) {
            return state.money * 2
        },
    },
})
​
export default store
  1. 关联 store,main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
const app = createApp(App)
​
app.use(router)
app.use(store)
app.mount('#app')
  1. 在组件中使用 Vuex,Home.vue
<script setup>
    import { computed } from 'vue'
    import { useStore } from 'vuex'
    const store = useStore()
    const money = computed(() => store.state.money)
    const double = computed(() => store.getters.double)
</script><template>
    <div>
        <h3>Home</h3>
        <p>money: {{ money }}</p>
        <p>double: {{ double }}</p>
        <button @click="store.commit('changeMoney')">change money</button>
        <button @click="store.dispatch('changeMoneyAsync')">change money async</button>
    </div>
</template>

🤔 mapState、mapMutations、mapActions、mapGetters 等辅助方法需要配合 Options API 才能使用,可见 Vuex4 在 Vue3 项目中并不好用。

Pinia

image.png

基本介绍

Pinia 是应用与 Vue.js 的轻量级状态管理库。

Pinia的优势

  • Pinia 和 Vuex4 一样,也是 Vue 官方的状态管理工具(作者是 Vue 核心团队成员)。
  • Pinia 相比 Vuex4,对于 Vue3 的兼容性更好。
  • Pinia 相比 Vuex4,具备完善的类型推导。
  • Pinia 同样支持 Vue 开发者工具,最新的开发者工具对 Vuex4 支持不好。
  • Pinia 的 API 设计非常接近 Vuex5 的提案

Pinia 核心概念?

  • state: 状态。
  • actions: 修改状态(包括同步和异步,Pinia 中没有 mutations)。
  • getters: 计算属性。

基本使用与 state

目标:掌握 Pinia 的使用步骤。

  1. 安装 pinia。
yarn add pinia
# or
npm i pinia
  1. 挂载 Pinia,main.js
import { createApp } from 'vue'
import App from './App.vue'// #1
import { createPinia } from 'pinia'
// #2
const pinia = createPinia()
​
// #3
createApp(App).use(pinia).mount('#app')
  1. 创建模块,store/counter.js
import { defineStore } from 'pinia'
// 创建 store,命名规则:useXxxxStore
// 参数 1:store 的唯一表示
// 参数 2:对象,可以提供 state actions getters
const useCounterStore = defineStore('counter', {
    // 推荐函数:避免服务端渲染导致的数据状态污染
    // 箭头函数:为了更好的 TS 类型推导
    state: () => {
        return {
            count: 0,
        }
    },
    getters: {},
    actions: {},
})
​
export default useCounterStore
  1. 在组件中使用,App.vue
<script setup>
    import useCounterStore from './store/counter'
    const counter = useCounterStore()
</script>
<template>
    <div>{{ counter.count }}</div>
</template>

actions 的使用

在 Pinia 中没有 mutations,只有 actions,不管是同步还是异步的代码,都可以在 actions 中完成。

  1. 在 actions 中提供方法并且修改数据,store/counter.js
import { defineStore } from 'pinia'
const useCounterStore = defineStore('counter', {
    state: () => {
        return {
            count: 0,
        }
    },
    actions: {
        increment() {
            this.count++
        },
        incrementAsync() {
            setTimeout(() => {
                this.count++
            }, 1000)
        },
    },
})

export default useCounterStore
  1. 在组件中使用,App.vue
<script setup>
    import useCounterStore from './store/counter'
    const counter = useCounterStore()
</script>
<template>
    <div>counter: {{ counter.count }}</div>
    <button @click="counter.increment">add 1</button>
    <button @click="counter.incrementAsync">async add 1</button>
</template>

getters 的使用

Pinia 和 Vuex 中的 getters 基本是一样的,也带有缓存的功能。

  1. 在 getters 中提供计算属性,store/counter.js
import { defineStore } from 'pinia'
const useCounterStore = defineStore('counter', {
    state: () => {
        return {
            count: 0,
        }
    },
    actions: {
        increment() {
            this.count++
        },
        incrementAsync() {
            setTimeout(() => {
                this.count++
            }, 1000)
        },
    },
    getters: {
        double() {
            return this.count * 2
        },
    },
})

export default useCounterStore
  1. 在组件中使用,App.vue
<script setup>
    import useCounterStore from './store/counter'
    const counter = useCounterStore()
</script>
<template>
    <div>counter: {{ counter.count }}</div>
    <div>double: {{ counter.double }}</div>
    <button @click="counter.increment">add 1</button>
    <button @click="counter.incrementAsync">async add 1</button>
</template>

storeToRefs 的使用

如果直接从 Pinia 中解构数据,会丢失响应式,使用 storeToRefs 可以保证解构出来的数据也是响应式的。

<script setup>
    import { storeToRefs } from 'pinia'
    import useCounterStore from './store/counter'
    const counter = useCounterStore()
    // !数据解构会丢失响应式
    // const { count, double } = counter
    // 方法可以正常解构
    const { increment, incrementAsync } = counter
    // 被 storeToRefs 包裹后的 counter,解构出来的数据是响应式的
    const { count, double } = storeToRefs(counter)
</script>
<template>
    <div>counter: {{ count }}</div>
    <div>double: {{ double }}</div>
    <button @click="increment">add 1</button>
    <button @click="incrementAsync">async add 1</button>
</template>

Pinia 模块化

在复杂项目中,一般来说应该每一个功能模块对应一个 store,最后通过一个根 store 进行整合。

  1. user 模块,store/user.js
import { defineStore } from 'pinia'

const useUserStore = defineStore('user', {
    state: () => {
        return {
            name: 'ifer',
            age: 18,
        }
    },
})

export default useUserStore
  1. 关联 user 和 counter 模块到根 store,store/index.js
import useCounterStore from './counter'
import useUserStore from './user'

export default function useStore() {
    return {
        user: useUserStore(),
        counter: useCounterStore(),
    }
}
  1. 在组件中使用,App.vue
<script setup>
    import { storeToRefs } from 'pinia'
    import useStore from './store'
    const { user } = useStore()
    const { name, age } = storeToRefs(user)
</script>
<template>
    <div>name: {{ name }}</div>
    <div>age: {{ age }}</div>
</template>

TodoMVC

练习

使用Vue3制作TodoMVC

image.png

静态结构

main.js

import { createApp } from 'vue'
import './styles/base.css'
import './styles/index.css'
import App from './App.vue'

createApp(App).mount('#app')

App.vue

<template>
    <section class="todoapp">
        <header class="header">
            <h1>todos</h1>
            <input class="new-todo" placeholder="What needs to be done?" autofocus />
        </header>

        <section class="main">
            <input id="toggle-all" class="toggle-all" type="checkbox" />
            <label for="toggle-all">Mark all as complete</label>
            <ul class="todo-list">
                <li class="completed">
                    <div class="view">
                        <input class="toggle" type="checkbox" checked />
                        <label>Taste JavaScript</label>
                        <button class="destroy"></button>
                    </div>
                    <input class="edit" value="Create a TodoMVC template" />
                </li>
                <li>
                    <div class="view">
                        <input class="toggle" type="checkbox" />
                        <label>Buy a unicorn</label>
                        <button class="destroy"></button>
                    </div>
                    <input class="edit" value="Rule the web" />
                </li>
            </ul>
        </section>

        <footer class="footer">
            <span class="todo-count"><strong>0</strong> item left</span>
            <ul class="filters">
                <li>
                    <a class="selected" href="#/">All</a>
                </li>
                <li>
                    <a href="#/active">Active</a>
                </li>
                <li>
                    <a href="#/completed">Completed</a>
                </li>
            </ul>
            <button class="clear-completed">Clear completed</button>
        </footer>
    </section>
</template>

styles/base.css

hr {
    margin: 20px 0;
    border: 0;
    border-top: 1px dashed #c5c5c5;
    border-bottom: 1px dashed #f7f7f7;
}

.learn a {
    font-weight: normal;
    text-decoration: none;
    color: #b83f45;
}

.learn a:hover {
    text-decoration: underline;
    color: #787e7e;
}

.learn h3,
.learn h4,
.learn h5 {
    margin: 10px 0;
    font-weight: 500;
    line-height: 1.2;
    color: #000;
}

.learn h3 {
    font-size: 24px;
}

.learn h4 {
    font-size: 18px;
}

.learn h5 {
    margin-bottom: 0;
    font-size: 14px;
}

.learn ul {
    padding: 0;
    margin: 0 0 30px 25px;
}

.learn li {
    line-height: 20px;
}

.learn p {
    font-size: 15px;
    font-weight: 300;
    line-height: 1.3;
    margin-top: 0;
    margin-bottom: 0;
}

#issue-count {
    display: none;
}

.quote {
    border: none;
    margin: 20px 0 60px 0;
}

.quote p {
    font-style: italic;
}

.quote p:before {
    content: '“';
    font-size: 50px;
    opacity: 0.15;
    position: absolute;
    top: -20px;
    left: 3px;
}

.quote p:after {
    content: '”';
    font-size: 50px;
    opacity: 0.15;
    position: absolute;
    bottom: -42px;
    right: 3px;
}

.quote footer {
    position: absolute;
    bottom: -40px;
    right: 0;
}

.quote footer img {
    border-radius: 3px;
}

.quote footer a {
    margin-left: 5px;
    vertical-align: middle;
}

.speech-bubble {
    position: relative;
    padding: 10px;
    background: rgba(0, 0, 0, 0.04);
    border-radius: 5px;
}

.speech-bubble:after {
    content: '';
    position: absolute;
    top: 100%;
    right: 30px;
    border: 13px solid transparent;
    border-top-color: rgba(0, 0, 0, 0.04);
}

.learn-bar > .learn {
    position: absolute;
    width: 272px;
    top: 8px;
    left: -300px;
    padding: 10px;
    border-radius: 5px;
    background-color: rgba(255, 255, 255, 0.6);
    transition-property: left;
    transition-duration: 500ms;
}

@media (min-width: 899px) {
    .learn-bar {
        width: auto;
        padding-left: 300px;
    }

    .learn-bar > .learn {
        left: 8px;
    }
}

styles/index.css

html,
body {
    margin: 0;
    padding: 0;
}

button {
    margin: 0;
    padding: 0;
    border: 0;
    background: none;
    font-size: 100%;
    vertical-align: baseline;
    font-family: inherit;
    font-weight: inherit;
    color: inherit;
    -webkit-appearance: none;
    appearance: none;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}

body {
    font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
    line-height: 1.4em;
    background: #f5f5f5;
    color: #111111;
    min-width: 230px;
    max-width: 550px;
    margin: 0 auto;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    font-weight: 300;
}

:focus {
    outline: 0;
}

.hidden {
    display: none;
}

.todoapp {
    background: #fff;
    margin: 130px 0 40px 0;
    position: relative;
    box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1);
}

.todoapp input::-webkit-input-placeholder {
    font-style: italic;
    font-weight: 300;
    color: rgba(0, 0, 0, 0.4);
}

.todoapp input::-moz-placeholder {
    font-style: italic;
    font-weight: 300;
    color: rgba(0, 0, 0, 0.4);
}

.todoapp input::input-placeholder {
    font-style: italic;
    font-weight: 300;
    color: rgba(0, 0, 0, 0.4);
}

.todoapp h1 {
    position: absolute;
    top: -140px;
    width: 100%;
    font-size: 80px;
    font-weight: 200;
    text-align: center;
    color: #b83f45;
    -webkit-text-rendering: optimizeLegibility;
    -moz-text-rendering: optimizeLegibility;
    text-rendering: optimizeLegibility;
}

.new-todo,
.edit {
    position: relative;
    margin: 0;
    width: 100%;
    font-size: 24px;
    font-family: inherit;
    font-weight: inherit;
    line-height: 1.4em;
    color: inherit;
    padding: 6px;
    border: 1px solid #999;
    box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
    box-sizing: border-box;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}

.new-todo {
    padding: 16px 16px 16px 60px;
    border: none;
    background: rgba(0, 0, 0, 0.003);
    box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03);
}

.main {
    position: relative;
    z-index: 2;
    border-top: 1px solid #e6e6e6;
}

.toggle-all {
    width: 1px;
    height: 1px;
    border: none; /* Mobile Safari */
    opacity: 0;
    position: absolute;
    right: 100%;
    bottom: 100%;
}

.toggle-all + label {
    width: 60px;
    height: 34px;
    font-size: 0;
    position: absolute;
    top: -52px;
    left: -13px;
    -webkit-transform: rotate(90deg);
    transform: rotate(90deg);
}

.toggle-all + label:before {
    content: '❯';
    font-size: 22px;
    color: #e6e6e6;
    padding: 10px 27px 10px 27px;
}

.toggle-all:checked + label:before {
    color: #737373;
}

.todo-list {
    margin: 0;
    padding: 0;
    list-style: none;
}

.todo-list li {
    position: relative;
    font-size: 24px;
    border-bottom: 1px solid #ededed;
}

.todo-list li:last-child {
    border-bottom: none;
}

.todo-list li.editing {
    border-bottom: none;
    padding: 0;
}

.todo-list li.editing .edit {
    display: block;
    width: calc(100% - 43px);
    padding: 12px 16px;
    margin: 0 0 0 43px;
}

.todo-list li.editing .view {
    display: none;
}

.todo-list li .toggle {
    text-align: center;
    width: 40px;
    /* auto, since non-WebKit browsers doesn't support input styling */
    height: auto;
    position: absolute;
    top: 0;
    bottom: 0;
    margin: auto 0;
    border: none; /* Mobile Safari */
    -webkit-appearance: none;
    appearance: none;
}

.todo-list li .toggle {
    opacity: 0;
}

.todo-list li .toggle + label {
    /*
		Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433
		IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/
	*/
    background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
    background-repeat: no-repeat;
    background-position: center left;
}

.todo-list li .toggle:checked + label {
    background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E');
}

.todo-list li label {
    word-break: break-all;
    padding: 15px 15px 15px 60px;
    display: block;
    line-height: 1.2;
    transition: color 0.4s;
    font-weight: 400;
    color: #4d4d4d;
}

.todo-list li.completed label {
    color: #cdcdcd;
    text-decoration: line-through;
}

.todo-list li .destroy {
    display: none;
    position: absolute;
    top: 0;
    right: 10px;
    bottom: 0;
    width: 40px;
    height: 40px;
    margin: auto 0;
    font-size: 30px;
    color: #cc9a9a;
    margin-bottom: 11px;
    transition: color 0.2s ease-out;
}

.todo-list li .destroy:hover {
    color: #af5b5e;
}

.todo-list li .destroy:after {
    content: '×';
}

.todo-list li:hover .destroy {
    display: block;
}

.todo-list li .edit {
    display: none;
}

.todo-list li.editing:last-child {
    margin-bottom: -1px;
}

.footer {
    padding: 10px 15px;
    height: 20px;
    text-align: center;
    font-size: 15px;
    border-top: 1px solid #e6e6e6;
}

.footer:before {
    content: '';
    position: absolute;
    right: 0;
    bottom: 0;
    left: 0;
    height: 50px;
    overflow: hidden;
    box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 0 8px 0 -3px #f6f6f6, 0 9px 1px -3px rgba(0, 0, 0, 0.2), 0 16px 0 -6px #f6f6f6, 0 17px 2px -6px rgba(0, 0, 0, 0.2);
}

.todo-count {
    float: left;
    text-align: left;
}

.todo-count strong {
    font-weight: 300;
}

.filters {
    margin: 0;
    padding: 0;
    list-style: none;
    position: absolute;
    right: 0;
    left: 0;
}

.filters li {
    display: inline;
}

.filters li a {
    color: inherit;
    margin: 3px;
    padding: 3px 7px;
    text-decoration: none;
    border: 1px solid transparent;
    border-radius: 3px;
}

.filters li a:hover {
    border-color: rgba(175, 47, 47, 0.1);
}

.filters li a.selected {
    border-color: rgba(175, 47, 47, 0.2);
}

.clear-completed,
html .clear-completed:active {
    float: right;
    position: relative;
    line-height: 20px;
    text-decoration: none;
    cursor: pointer;
}

.clear-completed:hover {
    text-decoration: underline;
}

.info {
    margin: 65px auto 0;
    color: #4d4d4d;
    font-size: 11px;
    text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
    text-align: center;
}

.info p {
    line-height: 1;
}

.info a {
    color: inherit;
    text-decoration: none;
    font-weight: 400;
}

.info a:hover {
    text-decoration: underline;
}

/*
	Hack to remove background from Mobile Safari.
	Can't use it globally since it destroys checkboxes in Firefox
*/
@media screen and (-webkit-min-device-pixel-ratio: 0) {
    .toggle-all,
    .todo-list li .toggle {
        background: none;
    }

    .todo-list li .toggle {
        height: 40px;
    }
}

@media (max-width: 430px) {
    .footer {
        height: 50px;
    }

    .filters {
        bottom: 10px;
    }
}

列表展示

  1. 引入 Pinia,main.js
import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
import './styles/base.css'
import './styles/index.css'

const pinia = createPinia()
createApp(App).use(pinia).mount('#app')
  1. 新建 todo 模块,store/modules/todos.js
import { defineStore } from 'pinia'

const useTodosStore = defineStore('todos', {
    // 注意这儿的小括号 ({ ... })
    state: () => ({
        list: [
            {
                id: 1,
                name: '吃饭',
                done: false,
            },
            {
                id: 2,
                name: '睡觉',
                done: true,
            },
            {
                id: 3,
                name: '打豆豆',
                done: false,
            },
        ],
    }),
})

export default useTodosStore
  1. 创建根 store,store/index.js
import useTodosStore from './modules/todos'

export default function useStore() {
    return {
        // Tip: 只有配置了 Pinia 之后,这个 useTodosStore 才能被执行调用
        todos: useTodosStore(),
    }
}
  1. 渲染列表,src/components/TodoMain.vue
<script setup>
    import useStore from '../store'

    const { todos } = useStore()
</script>

<template>
    <section class="main">
        <input id="toggle-all" class="toggle-all" type="checkbox" />
        <label for="toggle-all">Mark all as complete</label>
        <ul class="todo-list">
            <li :class="{ completed: item.done }" v-for="item in todos.list" :key="item.id">
                <div class="view">
                    <input class="toggle" type="checkbox" :checked="item.done" />
                    <label>{{ item.name }}</label>
                    <button class="destroy"></button>
                </div>
                <input class="edit" value="Create a TodoMVC template" />
            </li>
        </ul>
    </section>
</template>

修改状态

  1. 在 actions 中提供方法,store/modules/todos.js
actions: {
    changeDone(id) {
        const todo = this.list.find((item) => item.id === id)
        todo.done = !todo.done
    },
},
  1. 在组件中注册事件,components/TodoMain.vue
<input class="toggle" type="checkbox" :checked="item.done" @change="todos.changeDone(item.id)" />

删除任务

  1. 在 actions 中提供方法,store/modules/todo.js
actions: {
    delTodo(id) {
        this.list = this.list.filter((item) => item.id !== id)
    },
},
  1. 在组件中注册事件,components/TodoMain.vue
<button class="destroy" @click="todos.delTodo(item.id)"></button>

添加任务

  1. 在 actions 中提供方法,store/modules/todo.js
actions: {
    addTodo(name) {
        this.list.unshift({
            id: Date.now(),
            name,
            done: false,
        })
    },
},
  1. 在组件中注册事件,components/TodoHeader.vue
<script setup>
    import { ref } from 'vue'
    import useStore from '../store'
    const { todos } = useStore()
    // # 用于收集数据的变量
    const todoName = ref('')
    const add = (e) => {
        if (e.key === 'Enter' && todoName.value) {
            todos.addTodo(todoName.value)
            // 清空
            todoName.value = ''
        }
    }
</script>

<template>
    <header class="header">
        <h1>todos</h1>
        <input class="new-todo" placeholder="What needs to be done?" autofocus v-model="todoName" @keydown="add" />
    </header>
</template>

全选反选

  1. 在 getters 中提供计算属性,在 actions 中提供方法,store/modules/todos.js
const useTodosStore = defineStore('todos', {
    actions: {
        checkAll(value) {
            this.list.forEach((item) => (item.done = value))
        },
    },
    getters: {
        // 根据所有的单选按钮,先确定全选按钮的状态
        isCheckAll() {
            return this.list.every((item) => item.done)
        },
    },
})
  1. 在组件中使用,components/TodoMain.vue
<input id="toggle-all" class="toggle-all" type="checkbox" :checked="todos.isCheckAll" @change="todos.checkAll(!todos.isCheckAll)" />

底部功能

  1. 在 getters 中提供计算属性,store/modules/todos.js
const useTodosStore = defineStore('todos', {
    actions: {
        clearCompleted() {
            this.list = this.list.filter((item) => !item.done)
        },
    },
    getters: {
        leftCount() {
            return this.list.filter((item) => !item.done).length
        },
    },
})
  1. 在组件中使用,components/TodoFooter.vue
<script setup>
    import useStore from '../store'

    const { todos } = useStore()
</script>
<template>
    <footer class="footer">
        <span class="todo-count"><strong>{{ todos.leftCount }}</strong> item left</span>
        <ul class="filters">
            <li>
                <a class="selected" href="#/">All</a>
            </li>
            <li>
                <a href="#/active">Active</a>
            </li>
            <li>
                <a href="#/completed">Completed</a>
            </li>
        </ul>
        <button class="clear-completed" @click="todos.clearCompleted">Clear completed</button>
    </footer>
</template>

筛选功能

  1. 提供数据,store/modules/todo.js
state: () => ({
    filters: ['All', 'Active', 'Completed'],
    active: 'All',
}),
  1. 提供 actions,store/modules/todo.js
actions: {
    changeActive(active) {
        this.active = active
    },
},
  1. 在 footer 中渲染,components/TodoFooter.vue
<ul class="filters">
    <li v-for="item in todos.filters" :key="item" @click="todos.changeActive(item)">
        <a :class="{ selected: item === todos.active }" href="#/">{{ item }}</a>
    </li>
</ul>
  1. 提供计算属性,store/modules/todo.js
showList() {
    if (this.active === 'Active') {
        return this.list.filter((item) => !item.done)
    } else if (this.active === 'Completed') {
        return this.list.filter((item) => item.done)
    } else {
        return this.list
    }
},
  1. 组件中渲染,components/TodoMain.vue
<ul class="todo-list">
    <li :class="{ completed: item.done }" v-for="item in todos.showList" :key="item.id"></li>
</ul>

持久化

  1. 订阅 store 中数据的变化,src/components/TodoMain.vue
const { todos } = useStore()
todos.$subscribe(() => {
    localStorage.setItem('todos', JSON.stringify(todos.list))
})
  1. 获取数据时从本地缓存中获取,src/store/modules/todos.js
state: () => ({
    list: JSON.parse(localStorage.getItem('todos')) || [],
}),