# Vue3開發筆記
2024-03-21
紀錄一些前端開發的過程,避免後續忘記自己在幹嘛
此紀錄對應erp-view專案
https://github.com/ryanimay/ERP-View
內容主要為Vue3開發的前後端分離專案(前端)
## 開始
### 指令
```bash=
#安裝Vue CLI
npm install -g @vue/cli
#創建vue專案
vue create projectName
#進入專案資料夾
cd projectName
#運行專案(需在專案根路徑下)
npm run serve
#安裝所有相關依賴
npm install
```
## 相關框架、插件
### Element-plus
舊專案套用了各種不同工具套件,還手動排layout
後面發現其實各家大同小異、都有差不多功能,使用也沒想像中複雜
後續統整畫面元件設計主要都統一使用element-plus
```bash=
#直接npm安裝
npm install element-plus --save
```
有中文官方文檔,非常棒
https://element-plus.org/zh-CN/guide/design.html
Vue3在使用很方便
```javascript=
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
const app = createApp(App);
app.mount('#app');
```
main.js直接引用,後續就直接文檔查詢標籤使用就可以了

### Transition過場
https://router.vuejs.org/zh/guide/advanced/transitions
基於Vue-Router做components的過場動畫
這邊是直接寫slot在App.vue套用全部的components做簡單效果,就不做區分了
### vue-i18n
切換多語系語言包,類似springboot中resourceMessage對應properties包的方式
設定語系然後依照當前語系去抓語言包的字串顯示
官方文檔:https://vue-i18n.intlify.dev/guide/introduction.html
其中分為幾種使用方式(legacy api、composition api)
這主要敘述Vue3基於自己理解的使用方式
先npm安裝
```bash=
npm install vue-i18n
```
建立i18n資料夾

底下建立兩個檔案分別為英文語系和中文語系(名稱隨便取)
內容放入json格式要顯示的內容


完成之後建立配置檔(全局共用的Instance)

createI18n內容說明:
* legacy:選擇哪種寫法,false代表使用新的composition方式
* locale:預設語系,初始化會使用對應語言包,
我是先抓localeStorage看有沒有配置持久化,如果沒有就用預設語系
* message:放入要使用的語系和對應語言包
* fallbackLocale:當今天語言包找不到對應key時,會切換從這個備用語系對應的語言包找
配置完之後再main.js全局引入
```javascript=
import { createApp } from 'vue'
import App from '@/App.vue'
import i18n from '@/config/i18nConfig.js'
const app = createApp(App);
app.use(i18n);
app.mount('#app');
```
#### 簡單使用
先說最簡單的使用方式,<font color="#f00">局限於在template使用</font>,使用方式為<font color="#f00">$t("放入key")</font>
不需要任何引入,可直接使用
範例如下:

顯示結果:

再來是切換語系的方式是使用<font color="#f00">$i18n.locale="語系key"</font>
範例如下:

畫面如下:

點擊按鈕就可以無縫做語系切換
#### (稍微)進階使用
使用i18n不可能都是在template鐘用,在script中呼叫使用的比率會更大,
但就沒辦法直接用\$t或\$locale呼叫
要<font color="#f00">import { useI18n } from 'vue-i18n'</font>
userI18n()方法預設不填入useScope的話就是會取全局的Instance
可以做基於全局的控制
用userI18n()中取出t和locale
<font color="#f00">const { t, locale } = useI18n();</font>
這邊取出的t就是\$t,呼叫就是t('key')

可以混用都會生效也都可以被全局更改

也可以用變數方式拿到

locale就是全局的loacale,如果做更改就連同所有components的一起做更改
這邊使用方式為

之後直接用selector展開供選擇

選項的allLocal我是直接用json引入

顯示結果如下

切換就可以把全局的locale做更改<font color="#f00">!!但重點是沒有持久化,重新整理就又會回到預設語系</font>
如果要持久化紀錄用戶使用語系,可以用store同步到lostorage或是直接記在lostorage,範例中是使用@change呼叫function

#### 一般JS檔案中使用
這邊遇到的問題是在router配置檔案中要使用到i18n的t找語言包
<font color="#f00">但一般js檔案中無法使用import { useI18n } from 'vue-i18n'
因為只能在setup中使用,會顯示SyntaxError: Must be called at the top of a `setup` function</font>
要在一般js中使用的方式,要直接import createI18n的配置檔(export default會是單例)
<font color="#f00">import i18n from '@/config/i18nConfig.js'</font>
之後使用<font color="#f00">i18n.global.t('key')</font>就能呼叫到全局的i18n

### Pinia
我的理解就是一個針對前端的本地儲存庫,可以在任意地方被調用,像是用來放登入用戶基本資料
也有類似的套件[Vuex](https://vuex.vuejs.org/)是官方推薦使用,但是實際使用時發現針對Vue3支援度較低,碰到很多問題所以後續都改用Pinia
Pinia官方:https://pinia.vuejs.org/zh/cookbook/
```bash=
npm install pinia
```
然後main.js引用,就可以使用了
```javascript=
import { createApp } from 'vue'
import App from '@/App.vue'
import { createPinia } from 'pinia';
const app = createApp(App);
const pinia = createPinia();//創建
app.use(pinia);
app.mount('#app');
```
其使用方式主要就是類似於OO的封裝,每個物件自己獨立做成一個store,
裡面包含相關屬性(state)和操作方式(action),其結構為:
```javascript=
import { defineStore } from 'pinia';
const userStore = defineStore(
'user',
{
state: () => ({
id: '',
username: '',//屬性:value
}),
actions: {
//我是拿來放相關操作的function
login(){
...
},
logout(){
...
}
}
}
)
//導出後,在其他地方引用就可以直接呼叫到
export default userStore;
```
實際使用:
```javascript=
<template>
<button @click="doLogin" id="btn">//假設為登入按鈕
</template>
<script setup>
import userStore from '@/config/store/user.js';//引用建立好的store
const doLogin = async () => {
const response = await userStore().login(loginForm);
handleResponse(response);
};
</script>
```
這樣程式邏輯就不會散落在code之中
<font color="#f00">但接下來會遇到另一個問題,就是持久化</font>
目前的配置,store生命週期是只會存活在瀏覽器當前會話,<font color="#f00">只要重新整理頁面就會消失</font>
會造成很多使用上的問題,這邊是做持久化到localStorage
引入
```bash=
npm install pinia-plugin-persistedstate
```
然後在先前main.js配置的地方加上
```javascript=
import { createApp } from 'vue'
import App from '@/App.vue'
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'//多引入這個
const app = createApp(App);
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);//加上這個,讓pinia使用
app.use(pinia);
app.mount('#app');
```
後續就是在每個store個別操作是否要做持久化
拿剛剛的範例來說就是
```javascript=
import { defineStore } from 'pinia';
const userStore = defineStore(
'user',
{
state: () => ({
...
}),
actions: {
...
}
},
//加上persist屬性true,之後只要有調用store賦值更動state,就會自動同步化到localstorage
persist: true,
)
export default userStore;
```
用途很多,像我還有另外創建websocketStore用來管理websocket連線相關操作
### Axios(網路請求)
一樣先npm install
```bash=
npm install axios
```
這邊做了兩個配置檔
1.axios配置檔:
```javascript=
import axios from "axios";//引用axios
let axiosInstance = null;
//用單例的方式做
export default function instance(){
if (!axiosInstance) {
axiosInstance = axios.create({
baseURL: '',//prefix
timeout: 15000,//請求逾時時長
headers: {
'Content-Type': 'application/json',//就發送type
}
});
}
return axiosInstance;
}
//請求攔截器,這邊主要就是做請求前JWT驗證,驗證失敗就拋出
instance().interceptors.request.use(
(config) => {
//標頭帶上語系
setUserLang(config);
const matchedRoute = findRoute(config.url);
//如果是要驗證的api再放token
if (matchedRoute.requiresAuth) {
const token = localStorage.getItem('token');
const refreshToken = localStorage.getItem('refreshToken');
//accessToken和refreshToken都未通過token驗證,才會轉跳登入頁
if (!verifyJWT(token) && (!refreshToken || !verifyJWT(refreshToken))) {
instance.defaults.router.push({ name: 'login' });
//往外丟,會被配置的請求封裝類處理掉
return Promise.reject({type: 'RequestRejectedError', message: i18n.global.t('axios.reLogin')});
}
config.headers['Authorization'] = `Bearer ${token}`;
if (refreshToken) {
config.headers['X-Refresh-Token'] = refreshToken;
}
}
return config;
}
)
//返回結果攔截器,用於刷新localstorage內的Token
instance().interceptors.response.use(
(response) => {
const token = response.headers['authorization'];
if (token) {
localStorage.setItem('token', token.replace('Bearer ', ''))
}
const refreshToken = response.headers['x-refresh-token'];
if (refreshToken) {
localStorage.setItem('refreshToken', refreshToken)
}
return response;
}
);
```
2.統整所有請求的配置:
```javascript=
import msg from '@/config/alterConfig.js'
import instance from '@/config/axios.js';
import path from '@/config/api/apiConfig.js';
//封裝所有網絡請求,把請求路徑也都做封裝
export default {
opValid: request(path.api.client.opValid),
register: request(path.api.client.register),
login: request(path.api.client.login),
logout: request(path.api.client.logout),
resetPassword: request(path.api.client.resetPassword),
update: request(path.api.client.update),
updatePassword: request(path.api.client.updatePassword),
allMenu: request(path.api.menu.all),
pMenu: request(path.api.menu.pMenu),
signIn: request(path.api.attend.signIn),
signOut: request(path.api.attend.signOut),
}
//拿剛剛配置的axios
const axios = instance();
//呼叫請求方法,連帶參數
function request(api){
return function passData(data){
return call(api, data);
}
}
//實際請求方法,請求路徑配置時有設置屬性是指定請求方法
//這邊會依照請求方法放請求參數,並送出請求
async function call(api, data){
let response;
try{
if (api.method === 'get' || api.method === 'delete') {
response = await axios({
url: api.path,
method: api.method,
params: data
});
} else {
response = await axios({
url: api.path,
method: api.method,
data: data
});
}
}catch(error){
handleError(error);
}
return response;
}
//後端api設計,只要有請求成功不論結果為何都是返回200
//RequestRejectedError是axios設置的攔截器驗證失敗拋出的類,這邊攔住,畫面彈出提示
function handleError(error){
if(error.type === 'RequestRejectedError'){
msg.error(error.message);
}else{
throw error;
}
}
```
### Vue-Router
Vue和傳統的網頁模式切換頁面不同,屬於[單頁應用(SPA)](https://zh.wikipedia.org/zh-tw/%E5%8D%95%E9%A1%B5%E5%BA%94%E7%94%A8)
簡單來說就是只使用一個頁面,透過刷新頁面內容(components元件)來實際運行
vue-router是官方的路由器,用來協助切換路由和畫面上的各個元件,如果要切換都是透過router
#### 配置
先安裝
```bash=
npm install vue-router
```
創建router配置檔
```javascript=
import { createRouter, createWebHistory } from 'vue-router';//引入vue-router
//這邊是把相關路徑存成物件變數
const routerPath = [
{
path: '/home',
name: 'home',
components: {
//這邊使用() => import(component)的方式做懶加載
//也可以在最上面import再使用變數import
header: () => import('../components/header/HalfHeader.vue'),
body: () => import('../components/body/HomePage.vue'),
},
meta: { requiresAuth: true },
},
]
//創建Router,放入createWebHistory()和routerPath(路徑配置)
const router = createRouter({
history: createWebHistory(),//儲存歷史紀錄用,可以使用this.$router.back()回上一頁
routes: routerPath
});
export default router;
```
其中routes內配置所有components和對應路徑屬性
主要有四個屬性
==path==:路徑,url會顯示該路徑,<font color="#f00">轉跳可以靠path導向</font>
==name==:該路由節點名稱,<font color="#f00">轉跳可以靠name導向</font>
==components==:配置要加載的元件,可以拆分為一個頁面多個元件,分別命名
==meta==:設定路由的變數,可以放任意自己想要紀錄使用的屬性,做到方便區分路由權限/是否需驗證之類的事
想像routes內每個路徑是一個頁面,設定的component會被投射到APP.vue這個根結點
像這邊是配置每個頁面拆分為兩個component(header和body)
如果沒有要拆分,直接寫==components:對應元件== 就好
以下是APP.vue
```bash=
<template>
<div id="container">
<router-view name="header"/>
<router-view name="body"/>
</div>
</template>
```
name要對應到components內的自訂名稱,
如果沒有拆分就直接寫<router-view>就會把對應元件投射到這
==配置完router後記得要讓main.js使用這個元件,不然後面調用不到==
```javascript=
import { createApp } from 'vue'
import App from '@/App.vue'
import router from '@/config/router/routerConfig';
const app = createApp(App);
app.use(router);//給app剛剛的配置
app.mount('#app');
```
#### 透過router轉跳頁面
<font color="#f00">要先配置完router並且配置給main.js</font>
主要使用有兩種方式
==在template中用<router-link :to="routerName">==
```javascript=
//匹配路由的name
<router-link :to="{ name: 'home' }">
<img src="@/assets/icon/svg/loginPage/Register.svg">
</router-link>
//匹配路由的path
<router-link to="/home">
<img src="@/assets/icon/svg/loginPage/Register.svg">
</router-link>
```
元件初始化掛載渲染後,就會變成`<a>`標籤導向到路由指向的路徑,點擊就會重新渲染頁面
==在script使用$router.push==
```javascript=
methods: {
doReset() {
proxy.$router.push({ name: "messagePage" });
}
}
```
調用配置的路由,用.push的方式導向頁面,可以[用query攜帶參數](###query)
這邊只能匹配路由對應的name
### 傳參
#### query
透過[router.push](####透過router轉跳頁面)攜帶參數並轉跳
==這邊的參數會顯示在url上類似Get請求==
```javascript=
methods: {
doReset() {
this.$router.push({ name: "messagePage" , query: {myData:'yourDataHere'},
});
}
}
```
並且路由修改
```javascript=
const routes = [
{
path: '',
name: '',
component: '',
props: true, // 開 props 接收
},
];
//或是配在路徑上
const routes = [
{
path: '/home:myData',
name: '',
component: '',
},
];
```
接收方式
```javascript=
mounted() {
//query後字段命名要相同名稱(data)
const receivedData = this.$route.query.myData;
}
```
#### params
~~原先有,類似上面query的傳參方式,目前是已廢棄無法使用~~
https://github.com/vuejs/router/blob/main/packages/router/CHANGELOG.md#important-note
看4.1.4

還有很多方式,這邊只先提有用到的
---
### Websocket
主要是針對連接[後端專案](https://github.com/ryanimay/ERP-Base/blob/master/src/main/java/com/erp/base/config/websocket/WebSocketConfig.java)的websocket配置
先說我的理解,WebSocket API是原生的websocket連線方式,是最簡單的連線方式,但當今天用戶端瀏覽器不支持ws協議,連線就會失效
這邊是改用<font color="#f00">SockJS + STOMP</font>的方式做連線
* SockJS: 類似WebSocket API,用於建立連線,但更靈活,當碰到瀏覽器不支援ws協議,會自動降為http輪詢,整體更容易適配各種瀏覽器環境
* STOMP: 一種文本協議,簡單來說就是用於文本交流(WS、MQ),統一規範傳輸格式,方便各種語言或平台間的無障礙擴展傳遞內容
「>>>」的段落就是STOMP的信息格式,[詳細看這](https://en.wikipedia.org/wiki/Streaming_Text_Oriented_Messaging_Protocol)
使用方式:
先安裝兩種庫
```bash=
npm install sockjs-client
npm install webstomp-client
```
這邊專案設計的使用方式是把websocket封裝在[pinia](###Pinia)中,結構這邊不多贅述,只講websocket相關方法,目前封裝了幾種方法<font color="#f00">連線、中斷連線、訂閱、發送信息</font>
1. 連線:
```javascript=
import SockJS from 'sockjs-client';
import Stomp from 'webstomp-client';
connect() {
//websocket連線url
const url = 'XXX'
//創建一個socket
const socket = new SockJS(url);
//可有可無,指定stomp版號,不指定的話console會有錯誤題示但不影響連線
const options = { protocols: ['v12.stomp'] }
//用STOMP包裝整個socket連線
var client = Stomp.over(socket, options);
//這邊是用Promise包裝整個連線,因為打算給外部調用這個連線方法,也可選擇不包
return new Promise((resolve, reject) => {
//開始進行連線
client.connect({}, () => {
//成功要做的事
console.log('Websocket Connected');
resolve(); // Resolve the promise when connected
}, error => {
//連線異常
console.error('Error connecting: ' + error);
reject(error); // Reject the promise if there is an error
});
});
}
```
2. 中斷連線
```javascript=
disconnect() {
if (this.client) {//如果存活不為空就斷開
this.client.disconnect(() => {
console.log('WebSocket disconnected');
});
}
}
```
3. 訂閱
```javascript=
//訂閱比較特別,因為一個連線可能會在多個不同component做不同topic的訂閱,
//所以用回調的方式讓外部可以操作返回值
subscribe(topic, callback) {
if (this.client && this.isConnected) {//檢查是否連線
//訂閱topic
this.client.subscribe(topic, (message) => {
callback(message.body);
});
} else {
console.warn('Client is not connected');
}
}
```
4. 發送信息
```javascript=
//外部調用時傳入(發送目標、標頭、內容)
send(destination, headers, body){
this.client.send(destination,
headers == null ? {} : headers,
body == null ? JSON.stringify({}) : body);
}
```
websocket相關配置很複雜需要客戶端和服務器配合,包含各個prefix,很難,有想到什麼再補充
---
### Sortablejs
專案中是用來作區塊拖曳排序,詳細建構可以直接看官方文檔
https://sortablejs.com/options
要做元件拖曳其實還可以用draggable,是基於Sortable.js封装的Vue组件
但因為我主要是要搭配element-plus建構,渲染後不是單純的標籤很難直接混用draggable的標籤,所以還是用從頭組建Sortablejs的方式去做
大致看文檔都沒什麼大問題,這邊只記錄一些過程踩的坑
先看問題:
```javascript=
new Sortable(el, {
animation: 150,//過渡動畫
group: 'sortGroup',
handle: ".handle",
sort: true,
ghostClass: 'sortable-ghost',//被選取樣式
dragClass: "ghost",
scrollSensitivity: 100,//觸發滾動距離
scrollSpeed: 15,//滾動速度
draggable: ".innerCard",
onStart: () => {
document.body.style.userSelect = 'none'; // 禁用文本選擇,避免拖曳反白
},
onEnd: (e) => {
document.body.style.userSelect = ''; // 恢復文本選擇
},
})
```
這是我的Sortablejs建構程式碼,屬性介紹這邊就不多說,要關注的是onEnd()區塊
Sortablejs拖曳是直接操作DOM默認拖過去畫面就會生效,onEnd()就算什麼都不寫也會生效
但實際測試遇到問題如下

這是一個多區塊間拖曳的組件,測試onEnd為空,拖曳結果

畫面過去了

但實際log物件對應v-model沒有被更改
把onEnd()內加上手動操作v-model物件的程式碼
```javascript=
onEnd: (e) => {
document.body.style.userSelect = ''; // 恢復文本選擇
//拿出被移動元素
const targetRow = jobModel.value[key].splice(e.oldIndex, 1)[0];
//放入對應位置
jobModel.value[toStatus].splice(e.newIndex, 0, targetRow);
console.log(jobModel.value);
},
})
```
實際操作

test3元件拖曳後,直接變兩個元素
log出來的model

可以看到拖曳區塊數量是對了,但畫面上就是多出一個元素(test3重複渲染)
原因是因為Sortablejs沒有成功操作model導致只有畫面渲染更動,就變成model和DOM不同步
當Sortablejs默認渲染一次DOM後,我又手動操作v-model導致渲染一次,就變成重複了
自己的解法是移除Sortablejs默認操作的DOM
onEnd改成
```javascript=
onEnd: (e) => {
//移除sortablejs默認的DOM操作,全部的渲染都由下面手動操作,不然sortablejs默認操作會和v-model衝突導致畫面多出一個DOM
e.to.removeChild(e.item);
document.body.style.userSelect = ''; // 恢復文本選擇
//拿出被移動元素
const targetRow = jobModel.value[key].splice(e.oldIndex, 1)[0];
//放入對應位置
jobModel.value[toStatus].splice(e.newIndex, 0, targetRow);
console.log(jobModel.value);
},
})
```
加上直接操作DOM的這行"e.to.removeChild(e.item);"移除默認渲染元素
就可以讓畫面不重複渲染,model也成功被更改