Vue3 - 老樣子做個 TodoList - 使用者功能
tags: Vue
category: Front-End
description: Vue3 - 老樣子做個 TodoList - 使用者功能
created_at: 2022/07/27 00:00:00
回到 手把手開始寫 Vue3
前言
前面又多提了一些功能,差不多可以做一個功能稍多一點點的 TodoList
了。
成品的長相,還是很陽春。 DEMO
功能
功能主要還是為了做而做,所以總會有不太合理的地方(?),不過反正功能會都可以自己調整。
- 共通:
- 查看代辦事項清單
- 搜尋
- 從狀態過濾
- 切換狀態
- 刪除代辦事項
- 查看代辦事項清單
- 需登入:
- 登出
- 新增代辦事項
- 不需登入:
- 登入
其他功能可以當作自己的練習,例如為什麼沒登入可以刪代辦事項,又或者說為什麼可以改別人的之類的(無限延伸需求)。
初始化專案
老樣子還是要初始化專案,跟第一篇提到的一樣,弄好環境
建立專案
$ npm init vue@latest
而這次我的配置是讓他幫我設定好 ESLint
、 Prettier
外,還要 Router
與 Pinia
。
- ✔ Add Vue Router for Single Page Application development? … No /
Yes
- ✔ Add Pinia for state management? … No /
Yes
- √ Add ESLint for code quality? ... No /
Yes
- √ Add Prettier for code formatting? ... No /
Yes
之後就把你的專案打開,安裝相依套件 (也可以使用 yarn
)
$ npm install
在來安裝 tailwindcss
$ npx lai-cmd init vue-tailwindcss
然後老樣子的把專案清理一下。
假設前置作業都做好了。
開始
一開始總是應該要有一個 Navbar
,所以來建立一個 TheNavbar
的組件
<script setup>
import { RouterLink } from "vue-router";
</script>
<template>
<nav
class="bg-purple-700 text-white flex justify-between items-center p-2 mb-4"
>
<h1 class="text-2xl">
<RouterLink :to="{ name: 'home' }">TodoList</RouterLink>
</h1>
<ul>
<li>
<RouterLink
:to="{ name: 'login' }"
class="opacity-80 hover:opacity-100"
active-class="opacity-100"
>Login</RouterLink
>
</li>
</ul>
</nav>
</template>
然後記得在 App.vue
去使用他
<script setup>
import TheNavbar from "./components/TheNavbar.vue";
</script>
<template>
<TheNavbar />
</template>
這時會噴掉,因為路由還沒定義一個 name
叫做 login
的東西,所以要先去改一下路由,並且開個頁面給登入用
src/views/LoginView.vue
<script setup>
import { reactive } from "vue";
const user = reactive({
username: "",
password: "",
});
const onLogin = () => {
const { username, password } = user;
console.log("onLogin: ", username, password);
};
</script>
<template>
<div class="mx-4">
<h2 class="text-xl font-black mb-2">Login</h2>
<form @submit.prevent="onLogin">
<div class="grid grid-cols-[100px_1fr] gap-2 items-center mx-4">
<label for="username-input">Username: </label>
<input
class="border border-gray-700 rounded-sm p-2"
id="username-input"
type="text"
v-model="user.username"
/>
<label for="password-input">Password: </label>
<input
class="border border-gray-700 rounded-sm p-2"
id="password-input"
type="text"
v-model="user.password"
/>
</div>
<div class="text-center mt-2">
<button
class="bg-green-500 text-white px-4 py-2 rounded-sm transition hover:brightness-105"
>
Login
</button>
</div>
</form>
</div>
</template>
然後路由也要加上這一段:
{
path: "/login",
name: "login",
component: () => import("../views/LoginView.vue"),
},
之後就不會噴錯了,但是你會發現戳了 Login
確實轉址了,但是沒有畫面。
那是因為沒有放 <RouterView />
,所以這時你要把它放進 App.vue
裡面才會出現內容。
<template>
<TheNavbar />
<div>
<RouterView />
</div>
</template>
有畫面之後去戳 Login
按鈕,應該會看到 console.log
出你輸入的帳號密碼,再來就可以做登入了,不過登入這邊目前沒提到後端,所以先做一個假的。
不過在做假的登入之前,先把一些組件抽出來,例如之後應該還會多次用到的 input
、 button
。
按鈕主要只是把共通樣式抽離出來,如果必要還可以抽得更細(但這邊刻意不做)
TheButton.vue
<script setup></script>
<template>
<button
class="px-4 py-2 rounded-sm transition hover:brightness-105"
v-bind="$attrs"
>
<slot></slot>
</button>
</template>
輸入框的部分這邊比較複雜一些,因為還要處理 v-model
與其他 input
可能自帶的屬性。 關於 attrs
的詳細可看這裡
TheInput.vue
<script>
export default {
inheritAttrs: false,
};
</script>
<script setup>
import { useAttrs } from "vue";
defineProps({
modelValue: String,
});
defineEmits(["update:modelValue"]);
const { label, text, class: className, ...rest } = useAttrs();
</script>
<template>
<label v-if="text" :for="label">{{ text }}</label>
<input
v-bind="rest"
:class="`border border-gray-700 rounded-sm p-2 ${className}`"
:id="label"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
抽離出組件之後,稍微小改一下登入頁面(僅節錄表單的程式)
<form @submit.prevent="onLogin">
<div class="grid grid-cols-[100px_1fr] gap-2 items-center mx-4">
<TheInput
label="username-input"
text="Username:"
type="text"
v-model="user.username"
/>
<TheInput
label="password-input"
text="Password:"
type="password"
v-model="user.password"
/>
</div>
<div class="text-center mt-2">
<TheButton class="bg-green-500 text-white"> Login </TheButton>
</div>
</form>
雖然看起來沒有少多少,不過未來重複使用如果要改,至少只需要改一次。
製作假的登入
先建立一個 js
檔案,裡面放 users
資訊,假設我建立一個 const/data/users.json
export default [
{
id: 1,
username: "user01",
password: "1234",
},
{
id: 2,
username: "user02",
password: "1234",
},
];
那就是代表兩個使用者,然後這時上一篇提到的中央狀態管理 pinia
就要做事了,因為使用者狀態可能是全域到處都需要的,所以先建立一個 stores/user.js
。
以做個最簡單的當範例,只存 username
,然後登入成功就設定進去並回傳 true
否則 false
import { defineStore } from "pinia";
import userData from "../const/data/users";
export const useUserStore = defineStore({
id: "user",
state: () => ({
username: null,
}),
getters: {
isLogin: (state) => {
return state.username !== null;
},
},
actions: {
login(username, password) {
if (
userData.find((u) => u.username === username && u.password === password)
) {
this.username = username;
return true;
} else {
return false;
}
},
logout() {
this.username = null;
},
},
});
這時候就可以在登入畫面使用這個 store
,並呼叫登入的方法。
import { reactive } from "vue";
import TheInput from "../components/TheInput.vue";
import TheButton from "../components/TheButton.vue";
import { useUserStore } from "../stores/user";
import { useRouter } from "vue-router";
const user = reactive({
username: "",
password: "",
});
const userStore = useUserStore();
const router = useRouter();
const onLogin = () => {
const { username, password } = user;
console.log("onLogin: ", username, password);
if (userStore.login(username, password)) {
console.log("Success");
router.push({ name: "home" });
} else {
console.log("Failure");
}
};
然後到前端去戳一戳,在 console
就可以看到類似這樣的訊息,然後登入成功還會被導向回到首頁
onLogin: user 1234
Failure
onLogin: user01 1234
Success
然後要驗證他確實有存入你登入成功的 username
就可以嘗試去使用那個 isLogin
的 getter
, 記得可以把他想像成 computed
, 那目前會用到的可能就是登入與登出的切換,所以要改一下 TheNavbar.vue
基本上就是加上 v-if
與 v-else
決定要顯示誰,然後登出之後還要導向回首頁。
<script setup>
import { RouterLink, useRouter } from "vue-router";
import { useUserStore } from "../stores/user";
const userStore = useUserStore();
const router = useRouter();
const onLogout = () => {
userStore.logout();
router.push({ name: "home" });
};
</script>
<template>
<nav
class="bg-purple-700 text-white flex justify-between items-center p-2 mb-4"
>
<h1 class="text-2xl">
<RouterLink :to="{ name: 'home' }">TodoList</RouterLink>
</h1>
<ul>
<li
class="opacity-80 cursor-pointer hover:opacity-100"
v-if="userStore.isLogin"
@click="onLogout"
>
Logout
</li>
<li v-else>
<RouterLink
:to="{ name: 'login' }"
class="opacity-80 hover:opacity-100"
active-class="opacity-100"
>Login</RouterLink
>
</li>
</ul>
</nav>
</template>
到這邊基本的登入登出就完成了,但是太假了,所以讓他稍微延遲一下,並加入 loading
動畫,這邊 loading
的部分打算先自己簡單做一個。
打算做成下面這樣:
src/components/TheLoading.vue
<template>
<div
class="fixed top-0 left-0 flex items-center justify-center w-screen h-screen z-50"
>
<div class="bg-gray-700 opacity-50 w-full h-full fixed top-0 left-0"></div>
<span></span>
<span></span>
<span></span>
</div>
</template>
<style scoped>
span {
background-color: #39f;
width: 25px;
height: 25px;
margin: 0 5px;
border-radius: 50%;
z-index: 40;
animation: loading 1s alternate infinite;
}
span:nth-child(1) {
animation-delay: -0.3s;
}
span:nth-child(2) {
animation-delay: -0.66s;
}
@keyframes loading {
from {
transform: translateY(-20px);
}
to {
transform: translateY(0);
}
}
</style>
基本上就是把三顆圓圈丟去中間,然後稍微一點間距,然後三顆動畫起始時間不同就可以做到。
有了 Loading
動畫之後就可以開始做登入的延遲了(模擬打 API
的延遲)。
但是我們需要先想一下什麼情形要顯示這個動畫,而這邊使用的做法是再新增一個 store
負責放一些全域的狀態。
import { defineStore } from "pinia";
export const useGlobalStore = defineStore({
id: "global",
state: () => ({
loading: false,
}),
getters: {},
actions: {
setLoading(loading) {
this.loading = loading;
},
},
});
基本上很單純,就是設定 loading
的值,然後如果 true
那就要顯示,所以 App.vue
也要加入一行。
<TheLoading v-if="globalStore.loading" />
然後上面要記得 import
跟 use
:
import { useGlobalStore } from "./stores/global";
import TheLoading from "./components/TheLoading.vue";
const globalStore = useGlobalStore();
這時就可以去 user store
改登入的行為。(最上面記得引入 useGlobalStore
)
// 改為非同步函數
async login(username, password) {
const globalStore = useGlobalStore();
// 一開始先讓他 loading
globalStore.setLoading(true);
return new Promise((resolve) =>
// 計時 500 毫秒後做
setTimeout(() => {
if (
userData.find(
(u) => u.username === username && u.password === password
)
) {
this.username = username;
// 回傳 true
resolve(true);
} else {
// 回傳 false
resolve(false);
}
}, 500)
).finally(() => {
// 不管怎樣 最後都要結束 loading
globalStore.setLoading(false);
});
},
既然變成了 async function
那使用的地方也要改,所以要到登入頁面:
const onLogin = async () => {
const { username, password } = user;
console.log("onLogin: ", username, password);
if (await userStore.login(username, password)) {
console.log("Success");
router.push({ name: "home" });
} else {
console.log("Failure");
}
};
這時候再去登入就會發現好像真有這麼一回事?他有先跑載入動畫然後才回傳結果。
再來下一步是登入失敗的訊息,總不可能什麼都不跳給使用者知道,那這邊使用的彈跳視窗是 sweetalert2
安裝 sweetalert2
npm install sweetalert2
之後在登入頁面引入並使用:
import Swal from "sweetalert2";
const onLogin = async () => {
const { username, password } = user;
console.log("onLogin: ", username, password);
if (await userStore.login(username, password)) {
console.log("Success");
router.push({ name: "home" });
} else {
console.log("Failure");
// 主要是加了下面這個
Swal.fire({
text: "Wrong username or password.",
icon: "error",
});
}
};
這樣就完成彈跳視窗了。
記住我
再來到最後一個登入相關的功能了,就是希望使用者登入狀態可以被記住,但是現在沒有後端 API
所以也沒有 token
,那就做一個假的,對又是假的XD
這邊弄得很簡化,我假設 token
是帳號,然後帳號在我就信任他 (實務上不能這樣做喔!),這只是 DEMO
XD
所以這時就需要每次剛進來網頁需要執行的一個 action
,假設叫他 refreshUser
一樣要去先把 loading
打開,因為實際上這可能要去後端驗證 token
是否有效,只是這邊先寫死XD
async refreshUser(token) {
const globalStore = useGlobalStore();
globalStore.setLoading(true);
return new Promise((resolve) =>
setTimeout(() => {
if (userData.find((u) => u.username === token)) {
this.username = token;
resolve(true);
} else {
// token 無效,所以清除
localStorage.removeItem("token");
resolve(false);
}
}, 500)
).finally(() => {
globalStore.setLoading(false);
});
},
那既然要記住 token
總要有地方放,這邊是放在 localStorage
,所以登入成功要去存,就簡單像這樣:
localStorage.token = username;
把這行放到登入成功的地方,另外登出記得要把它清掉,也就是加上這行。
localStorage.removeItem("token");
之後進到每個畫面之前,驗一下初始化了沒,要是還沒初始化(還沒恢復登入狀態)就驗一次,所以需要路由守衛(Guard
)了。
import { useGlobalStore } from "../stores/global";
import { useUserStore } from "../stores/user";
// ...
router.beforeEach(async () => {
const userStore = useUserStore();
const globalStore = useGlobalStore();
if (!globalStore.initialized) {
await userStore.refreshUser(localStorage.token);
globalStore.init();
}
});
而這時候會壞掉,因為 globalStore
根本沒那兩個東西 initialized
與 init()
,所以要去定義
// ...
state: () => ({
initialized: false,
}),
actions: {
init() {
this.initialized = true;
},
},
// ...
到這邊其實基本的登入功能就完成了。
最後一個防呆
再來是最後一個額外的小防呆,有時候會希望使用者登入後不顯示登入連結外,就算他敲網址也不該進去,這時又是路由守衛要做的事了。
只是比起在特定 path
指定守衛,倒不如加一個 meta
資料決定那個路由需不需要被驗證。
所以首先是幫登入的路由加入 meta
:
{
path: "/login",
name: "login",
component: () => import("../views/LoginView.vue"),
meta: {
requireAuth: false,
},
},
而下面對所有路由的路由守衛就要稍微修改一下:
// 這個 to 是目標路由的物件
router.beforeEach(async (to) => {
// ...
// 如果目標路由的 meta 有設定 requireAuth 才會進去
if (to.meta.requireAuth !== undefined) {
// 這裡比較特殊,下面解釋
if (userStore.isLogin ^ to.meta.requireAuth) {
return { name: "home" };
}
}
});
這邊的邏輯應該是這篇相對複雜的地方,其實那個 ^
是 XOR
的簡寫,這個還真的滿少用的。
XOR
就是像下面這樣:
A | B | A XOR B |
---|---|---|
T | T | F |
T | F | T |
F | T | T |
F | F | F |
簡單來說就是不同的才會是 True
。
那為什麼這邊要這樣設計呢?
因為我打算做到一個邏輯是:
requireAuth
=true
: 必須登入才能看requireAuth
=false
: 必須沒登入才能看requireAuth
=undefined
: 登入沒登入都可以
這樣就可以呈現三種不同邏輯。(雖然也可以拆成多行寫成 if-else
,但這樣就是精簡一些)
到這邊你如果是登入狀態再去敲網址 /login
,應該是會被踢回首頁的XD
總結
到這邊登入就完成了,沒想到一個登入可以寫這麼長,那還是分兩篇好了,下一篇再來完成代辦事項清單的功能。