Vue3 - 老樣子做個 TodoList - 使用者功能

tags: Vue
category: Front-End
description: Vue3 - 老樣子做個 TodoList - 使用者功能
created_at: 2022/07/27 00:00:00

cover image


回到 手把手開始寫 Vue3


前言

前面又多提了一些功能,差不多可以做一個功能稍多一點點的 TodoList 了。

成品的長相,還是很陽春DEMO

cover image


功能

功能主要還是為了做而做,所以總會有不太合理的地方(?),不過反正功能會都可以自己調整。

  • 共通:
    • 查看代辦事項清單
      • 搜尋
      • 從狀態過濾
      • 切換狀態
      • 刪除代辦事項
  • 需登入:
    • 登出
    • 新增代辦事項
  • 不需登入:
    • 登入

其他功能可以當作自己的練習,例如為什麼沒登入可以刪代辦事項,又或者說為什麼可以改別人的之類的(無限延伸需求)。


初始化專案

老樣子還是要初始化專案,跟第一篇提到的一樣,弄好環境

建立專案

$ npm init vue@latest

而這次我的配置是讓他幫我設定好 ESLintPrettier 外,還要 RouterPinia

  • ✔ 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 出你輸入的帳號密碼,再來就可以做登入了,不過登入這邊目前沒提到後端,所以先做一個假的。

不過在做假的登入之前,先把一些組件抽出來,例如之後應該還會多次用到的 inputbutton

按鈕主要只是把共通樣式抽離出來,如果必要還可以抽得更細(但這邊刻意不做) 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 就可以嘗試去使用那個 isLogingetter , 記得可以把他想像成 computed , 那目前會用到的可能就是登入與登出的切換,所以要改一下 TheNavbar.vue

基本上就是加上 v-ifv-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 的部分打算先自己簡單做一個。

打算做成下面這樣:

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" />

然後上面要記得 importuse:

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 根本沒那兩個東西 initializedinit(),所以要去定義

// ...
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


總結

到這邊登入就完成了,沒想到一個登入可以寫這麼長,那還是分兩篇好了,下一篇再來完成代辦事項清單的功能。




最後更新時間: 2022年07月27日.