# 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直接引用,後續就直接文檔查詢標籤使用就可以了 ![image](https://hackmd.io/_uploads/H1AuFmFCa.png) ### 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資料夾 ![image](https://hackmd.io/_uploads/HkhUiiOfC.png) 底下建立兩個檔案分別為英文語系和中文語系(名稱隨便取) 內容放入json格式要顯示的內容 ![image](https://hackmd.io/_uploads/r1xijiuG0.png) ![image](https://hackmd.io/_uploads/By2ojsdM0.png) 完成之後建立配置檔(全局共用的Instance) ![image](https://hackmd.io/_uploads/HJqaMaYzA.png) 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> 不需要任何引入,可直接使用 範例如下: ![image](https://hackmd.io/_uploads/B16DpjOfR.png) 顯示結果: ![image](https://hackmd.io/_uploads/H1gMKpoOMR.png) 再來是切換語系的方式是使用<font color="#f00">$i18n.locale="語系key"</font> 範例如下: ![image](https://hackmd.io/_uploads/rJnTaiOGC.png) 畫面如下: ![image](https://hackmd.io/_uploads/ByzJAsuMC.png) 點擊按鈕就可以無縫做語系切換 #### (稍微)進階使用 使用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') ![image](https://hackmd.io/_uploads/BJkMfTFMR.png) 可以混用都會生效也都可以被全局更改 ![image](https://hackmd.io/_uploads/S1yNMpKMC.png) 也可以用變數方式拿到 ![image](https://hackmd.io/_uploads/rJyUfpYzA.png) locale就是全局的loacale,如果做更改就連同所有components的一起做更改 這邊使用方式為 ![image](https://hackmd.io/_uploads/SkZg-aFMA.png) 之後直接用selector展開供選擇 ![image](https://hackmd.io/_uploads/SkEzbptG0.png) 選項的allLocal我是直接用json引入 ![image](https://hackmd.io/_uploads/BkzEWaYfR.png) 顯示結果如下 ![image](https://hackmd.io/_uploads/r1VIbaKfC.png) 切換就可以把全局的locale做更改<font color="#f00">!!但重點是沒有持久化,重新整理就又會回到預設語系</font> 如果要持久化紀錄用戶使用語系,可以用store同步到lostorage或是直接記在lostorage,範例中是使用@change呼叫function ![image](https://hackmd.io/_uploads/H1D5ZTtMA.png) #### 一般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 ![image](https://hackmd.io/_uploads/Sy-cKZ9zC.png =80%x) ### 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 ![image](https://hackmd.io/_uploads/r1RzByyDp.png) 還有很多方式,這邊只先提有用到的 --- ### 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),統一規範傳輸格式,方便各種語言或平台間的無障礙擴展傳遞內容 ![image](https://hackmd.io/_uploads/B1RifI-UC.png)「>>>」的段落就是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()就算什麼都不寫也會生效 但實際測試遇到問題如下 ![image](https://hackmd.io/_uploads/BkyE47Wkkx.png) 這是一個多區塊間拖曳的組件,測試onEnd為空,拖曳結果 ![image](https://hackmd.io/_uploads/ByAJvmWJ1x.png) 畫面過去了 ![image](https://hackmd.io/_uploads/HJl28X-Jyg.png) 但實際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); }, }) ``` 實際操作 ![image](https://hackmd.io/_uploads/HkCsEmZ1yg.png) test3元件拖曳後,直接變兩個元素 log出來的model ![image](https://hackmd.io/_uploads/BJSX_mWJ1g.png) 可以看到拖曳區塊數量是對了,但畫面上就是多出一個元素(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也成功被更改