# F_Vue3筆記 [TOC] ## intro to vue3 - git clone下來,在本地端建立分支並連線作者的遠端分支 ``` git checkout -b L2 origin/L2-start ``` - vscode安裝extension `es6-string-html` ## creating the vue app 課程用的是CDN,[官網](https://vuejs.org/guide/quick-start.html#using-vue-from-cdn)有提供 ```javascript! import { createApp } from 'vue' import App from './App.vue' const app = createApp(App) ``` - 每個 `Vue application instance` 都是經由 `createApp` 函式建立 - 傳入給 `createAPP` 的 APP 這個物件其實是一個 `root component` 根元件 - `root component`根元件可以包含其他元件作為他的子元件,也就是他是最頂層的元件 - 例如:APP.vue檔可以匯入NavBar.vue、Footer.vue等,而NavBar.vue 也可以再匯入 Form.vue等,形成元件樹 ### SFC 全名Single-File Components,就是專案看到的`.vue檔`,每個vue檔就是完整的元件,裡面包含三個區塊: - 邏輯`(JavaScript/<script>)` - 模板`(html/<template>)` - 樣式`(css/<style>)` SFC的結構: ```javascript! <template> <div> <!--HTML--> </div> </template> <script> // JavaScript </script> <style> /*CSS*/ </style> ``` 一個 `.vue` 檔中: - `<template>` 只能有一個 - `<script>` 只能有一個 (不包含`<script setup>`) - `<script setup>` 只能有一個 - `<style>` 可以多個 [參考資料](https://ithelp.ithome.com.tw/articles/10295418) --- 影片提到: - `{{}}`:moustache syntax - 裡面可寫 JS 表達式 - 可寫字串 - 可寫三元運算 ```javascript! <div id="app"> <h1>{{ product }}</h1> <p>{{ firstName + lastName }}</p> <span>{{ clicked ? true : false }}</span> </div> ``` reactivity system 響應式系統 ## vue directives ### v-bind 1. 用來綁定html屬性,例如:class、alt、src等 > 因為 `{{}}` 無法使用在html的屬性,所以才有v-bind 2. v-bind 的縮寫為 `:` ```html! <div id="app"> <div> <a v-bind:href="url">Google</a> <!-- 縮寫寫法 --> <a :href="url">Google</a> </div> </div> ``` ### v-if 、v-show - v-if - v-show - 實際渲染後tag還存在,只是style="display:none" ```html <p v-show="false">In Stock</p> <!-- 實際渲染樣子 --> <p style="display:none">In Stock</p> ``` ### v-if 、v-show - v-if - 根據綁定條件決定是否渲染或是銷毀 - v-else-if、v-else為可選,根據需求使用,但不能單獨使用,必須跟在 v-if 後面 - 會重新渲染,每次渲染就發一次API ```html! <p v-if="inventory >10">In Stock</p> <p v-else-if="inventory<10 && inventory>0">almost sold out</p> <p v-else>out of Stock</p> ``` - v-show - 透過css控制顯示。如判斷為true,是display;判斷為false,則display:none - 實際渲染後tag還存在,只是隱藏起來 - 不會重新渲染,只有第一次掛載發API ```html <p v-show="false">In Stock</p> <!-- 實際渲染是隱藏它 --> <p style="display:none">In Stock</p> ``` ![image](https://hackmd.io/_uploads/rJxSFCYtJl.png) [Day 8: 認識 Vue directive 和 v-if v.s v-show](https://ithelp.ithome.com.tw/articles/10297034) [vue doc - template syntax](https://vuejs.org/guide/essentials/template-syntax.html#template-syntax) [vue doc - Built-in Directives](https://vuejs.org/api/built-in-directives.html#built-in-directives) [vue doc - Conditional Rendering](https://vuejs.org/guide/essentials/conditional.html) ### v-for 可接受的value類型有:數字、字串、陣列(包含Map/Set)、物件 #### 數字 ```html! <div id="app"> <ul> <li v-for="number in 5">{{number}}</li> </ul> </div> ``` 從1開始,數字須為正整數 ![image](https://hackmd.io/_uploads/HydcHgRFJg.png) #### 字串 ```html! <div id="app"> <ul> <li v-for="char in myName">{{char}}</li> </ul> </div> ``` ```javascript! const app = Vue.createApp({ data(){ return { myName:"Fang" } } }) ``` ![image](https://hackmd.io/_uploads/By5oHx0Kkl.png) #### 陣列 ```html <ul> <li v-for="item in variants" :key="item.id">{{item.color}}</li> </ul> ``` 可以使用解構 ```html <ul> <li v-for="({id,color},index) in variants" :key="id">{{index}} : {{color}}</li> </ul> ``` ```javascript! // main.js const app = Vue.createApp({ data() { return { product: 'Socks', image: './assets/images/socks_blue.jpg', inStock: true, details: ['50% cotton', '30% wool', '20% polyester'], variants: [ { id: 1234, color: "green" }, { id: 1235, color: "blue" }, ], } } }) ``` #### 物件 可以拿到物件的 `value` 、 `key` 、 `index` ```html <ul> <li v-for="(value,key,index) in customersData">{{key}}: {{value}}</li> </ul> ``` #### key attribute - vue用來識別和追蹤元素,不會顯示在html上 - 建議綁定key屬性,並==使用唯一的ID==,讓vue能夠正確追蹤 #### 補充 `v-if`和`v-for`不能放在同一個元素上,`v-if`的優先權高於`v-for`,會報錯 ```html! <li v-for="todo in todos" v-if="!todo.isComplete"> {{ todo.name }} </li> ``` [Day 9: v-for 與他的坑 feat. key & v-if](https://ithelp.ithome.com.tw/articles/10297907) [Vue doc- special attribute](https://vuejs.org/api/built-in-special-attributes.html#key) ### v-on 1. 縮寫為`@` ```javascript! v-on:click="handler" // 縮寫 @click="handler" ``` click:監聽的事件 handler:觸發的處理器,有 `inline handler` 、`method handler`和 `Calling Methods in Inline Handlers` ```html! <-- inline handler 直接寫表達式--> <div> <button @click="count+=1"> <p>{{count}}</p> </div> <-- method handler --> <div> <button @click="addToCart"> <p>{{count}}</p> </div> ``` ```javascript! const count = ref(0); function addToCart(){ count.value += 1; } ``` 2. Event Modifiers 事件修飾符 - `.stop` - `.prevent` - `.self` - `.capture` - `.once` - `.passive` 範例: ```javascript! <form @submit.prevent = "onSubmit"></form> ``` 3. Key Modifiers 按鍵修飾符 ```html! <!-- 只有在 key 為 enter 時調用 submit --> <input @keyup.enter="submit" /> ``` [Vue Doc:Event Handling](https://vuejs.org/guide/essentials/event-handling.html) ### class & style bindings ```html <!-- css name為駝峰 --> <div :style="{backgroundColor:blue}"> blue </div> <!-- css name為烤肉串,須多加引號才不會報錯 --> <div :style="{'background-color':blue}"> blue </div> ``` 渲染出來長這樣 ```html <div style="background-color:blue"> blue </div> ``` 如果style比較多,可以包裝成物件傳入: ```html <div :style="styles"></div> ``` ```javascript! const styles = ref({fontSize:"18px",fontWeight:"bold"}) ``` 當out of stock時,add button按鈕要套反灰設計&不能增加cart數量: ```html <!-- inStock為false --> <!-- :disabled="!inStock" 意思disabled屬性為true--> <button class="button" :class="{disabledButton:!inStock}" :class="[{[disabledButton]:!inStock}]" // 陣列裡用物件語法 :class="[!inStock ? disabledButton:'']" // 三元寫法 @click="addToCart" :disabled="!inStock" > Add to Cart </button> ``` ```css! .disabledButton { background-color: #d8d8d8; cursor: not-allowed; } ``` 可以綁定陣列渲染多個class > Chris:vue 是如何做到抽換 class? 要知道這件事 ### computed properties 1. 依賴的響應式資料變動時,重新計算結果並回傳 2. 分成 唯讀 和 可讀可寫 情境: - `computed(() => someValue)` 傳入 getter ,計算新值 - `computed({ get, set })` 要手動賦值就要傳入 getter 和 setter 3. 會緩存資料 4. 適合單純加工資料 補充:getter function 1. 不會帶入參數 2. 唯一目的是返回計算後的值 3. 不會直接修改任何數據,只是讀取並計算 ```javascript! // composition api const { createApp, ref, reactive } = Vue; const app = createApp({ setup() { const cart = ref(0); const product = ref("Socks"); const brand = ref("Vue Mastery"); const image = ref("./assets/images/socks_blue.jpg"); const inStock = ref(true); const details = ref(["50% cotton", "30% wool", "20% polyester"]); const variants = ref([ { id: 2234, color: "green", image: "./assets/images/socks_green.jpg" }, { id: 2235, color: "blue", image: "./assets/images/socks_blue.jpg" }, ]); const styles = ref({ fontSize: "18px", fontWeight: "bold", }); function addToCart() { cart.value += 1; } function updateImage(variantImage) { image.value = variantImage; } return { cart, product, brand, image, inStock, details, variants, styles, addToCart, updateImage, }; }, }); ``` ```javascript! // option api const app = Vue.createApp({ data() { return { cart: 0, product: "Socks", brand: "Vue Mastery", selected: 0, details: ["50% cotton", "30% wool", "20% polyester"], variants: [ { id: 2234, color: "green", image: "./assets/images/socks_green.jpg", quantity: 50, }, { id: 2235, color: "blue", image: "./assets/images/socks_blue.jpg", quantity: 0, }, ], onSale: true, }; }, methods: { addToCart() { this.cart += 1; }, updateVariant(index) { this.selected = index; }, }, computed: { title() { return this.brand + " " + this.product; }, image() { return this.variants[this.selected].image; }, inStock() { return this.variants[this.selected].quantity; }, sale() { if (this.onSale) { return this.brand + " " + this.product + "is onsale."; } return ""; }, }, }); ``` ### components & Props encapsulate:封裝 元件有各自的獨立作用域,無法拿取外部的資料,如要使用到外部資料,用`props`來解決。 `props` : a custom attribute for passing data into a component (自訂屬性將資料傳遞給元件) 範例: ```javascript! // App.vue <script setup> import { ref } from 'vue' import PropsComponent from './PropsComponent.vue' const posts = ref([ { id: 1, title: 'My journey with Vue' }, { id: 2, title: 'Blogging with Vue' }, { id: 3, title: 'Why Vue is so fun' } ]) </script> <template> <PropsComponent v-for = "post in posts" :key = "post.id" :newTitle = "post.title" // 用newTitle來當作props名稱 /> </template> ``` 在父元件上引入子元件`PropsComponent.vue`,並用v-for渲染子元件的`<p>`內容 ```javascript! // PropsComponent.vue <script setup> const props = defineProps(['newTitle']) // 接收父元件上的newTitle </script> <template> <p>{{newTitle}}</p> </template> ``` 如果沒有使用`<script setup>`,寫法改成這樣: ```javascript! <script> export default { props: ['newTitle'], setup(props) { console.log(props.title) } } </script> ``` [vue doc-Components Basics](https://vuejs.org/guide/essentials/component-basics.html) ### communicating Events `emitting Events` : tell "parent" when events happens,意思是子元件執行父元件的方法用 `emit` 子元件的按鈕點擊時,觸發父元件的事件處理: ```html // index.html <product-display :premium="premium" @add-to-cart="updateCart" ></product-display> ``` ```javascript! // main.js const app = Vue.createApp({ data() { return { cart: [], premium: true, }; }, methods: { updateCart(id) { this.cart.push(id); }, }, }); ``` ```javascript! // 子元件 ProductDetails.js methods: { addToCart() { this.$emit("add-to-cart", this.variants[this.selectedVariant].id); }, } ``` ### Forms & v-model `payload` : 具有意義、有效的資料 `v-model` : 雙向綁定,可添加modifier修飾符 - `lazy` : 每次`change`事件後才更新數據,預設的話是每次`input`事件就更新數據 ```javascript! <input v-model.lazy="message" /> ``` - `number` : 輸入自動轉換為數字 ```javascript! <input v-model.number="age" /> ``` 如果 `<input type="number">`,`v-model` 會自動轉數字,不用 `.number` - `trim` : 自動去除輸入內容中兩端的空格 ```javascript! <input v-model.trim="message" /> ``` [vue doc:Form Input Bindings](https://vuejs.org/guide/essentials/forms.html) ### vue cli `cli` : command line interface Hot Module Replacement (HMR) 熱更新 > exchanges, adds, or removes modules while an application is running, without a full reload. > 當應用程式運行時,替換、新增或移除模組無須重整加載頁面。 ### single file components ```javascript! <style scoped></style> ``` `scoped` : applies styles to component only 僅將樣式套用至元件 定義一個vue元件,並指定元件的名稱: ```javascript! // vue3 option api寫法 // name 用來定義 vue 元件名稱 // components 屬性來手動註冊元件 <script> export default { name: "ParentComponent", components: { EventCard } }; </script> // vue 3 Composition API // 通常不需要手動定義 name // 用import元件方式,不需要手動註冊 <script setup> import EventCard from "./EventCard.vue"; </script> <template> <EventCard /> </template> ``` ```javascript! // EventCard.vue <script setup> const props = defineProps({ event: { type: Object, required: true, }, }) const { event } = props // 解構 </script> <template> <div class="event-card"> <span>@{{ event.time }} on {{ event.date }}</span> <h4>{{ event.title }}</h4> </div> </template> ``` [vue doc: Props](https://cn.vuejs.org/guide/components/props) ### vue router essential `SPA (Single-Page Application)`: 整個應用程式所有內容都在一個頁面中呈現(one `index.html` page) ```javascript! // router/index.js const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/', name: 'home', component: HomeView, }, { path: '/about', name: 'about', component: () => import('../views/AboutView.vue'), }, ], }) ``` `path`: url `name`: name of route `component`: which view component to render Vue Router 提供的內建元件:`router-link`、`router-view` ```javascript! <template> <header> <img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" /> <div class="wrapper"> <nav> <RouterLink to="/">Home</RouterLink> <RouterLink to="/about">About</RouterLink> </nav> </div> </header> <RouterView /> </template> ``` [vue router官網](https://router.vuejs.org/zh/guide/) #### server-side routing 伺服器端路由 - 過去使用這種方式 - 瀏覽器向 server 發 URL 請求,server 回傳 page 給瀏覽器 - 如果使用者點擊該頁面的連結,瀏覽器又會向 server 發URL 請求。**每次載入頁面瀏覽器都會刷新** #### client-side routing 客戶端路由 - 瀏覽器向 server 發 URL 請求,server 傳回該頁面 (index.html) - vue 藉由 vue router 幫忙,就**無需重新加載頁面**即可呈現 - `<router-view>` 就像是一個placeholder,會被route的元件程式碼取代 - 應用程式首次載入時,會載入完整應用程式的程式碼 ### API calls with Axios 如不要寫死程式碼,透過api call 來 fetch 外部資料 - 瀏覽器向 server 發 events 請求,server 傳回 `JSON` mock database: [My JSON Server](https://my-json-server.typicode.com/) #### lifecycle hooks 元件具有生命週期,並在生命週期中呼叫不同的鉤子(hook)或方法 `axios` 是基於 promise 且非同步 如果每個元件都有axios instance,API 程式碼會遍佈整個應用程式,這樣會很混亂且難debug 所以,在應用程式內有一個專門的位置,來處理 api 行為,在 src 建立一個 service folder ,並建立一個 new file 為 EventService.js,把 axios 包裝在這裡,要使用再各別 import 進去 ```javascript! // EventService.js import axios from 'axios' const apiClient = axios.create({ baseURL: 'https://my-json-server.typicode.com/Code-Pop/Real-World_Vue-3', withCredentials: false, headers: { Accept: 'application/json', 'Content-Type': 'application/json' } }) export default { getEvents() { return apiClient.get('/events') // 添加到 baseURL 上 } } ``` ### Dynamic Routing 將該 event 的 ID 新增至此URL的末尾 `https://my-json-server.typicode.com/Code-Pop/Real-World_Vue-3/events/123` 如何在 router 的 parameters 新增 event id ❓ ```javascript! // EventCard.vue <template> <router-link class="event-link" :to="{ name: 'EventDetails', params: { id: event.id } }" > <div class="event-card"> <span>{{ $route.params.id }}</span> <span>@{{ event.time }} on {{ event.date }}</span> <h4>{{ event.title }}</h4> </div> </router-link> </template> ``` `to="/event/123"` 原本route.path寫法會hard code `:to`: 用v-bind綁定物件,並裡面改成放物件 `:to="{ name:'EventDetails', params: { id: event.id } } "` ```javascript! // router/index.js const routes = [ { path: '/event/:id', // dynamic segment name: 'EventDetails', props: true, // 預設為false component: EventDetails }, ] ``` path 屬性把動態字段以「冒號」開始,改成`:id` `:id` :可以是 username 或是 user id 來進行更新 `props: true` : 將 `route.params` 自動作為 props 傳進元件(例如 EventDetails.vue) ```javascript! // EventDetails.vue export default { props: ['id'], // 直接拿到 route 中的 id created() { EventService.getEvent(this.id) // this.id 是從 props 來的,不用再從 $route.param.id 拿 } } ``` `props: ['id']` 是 option api 寫法,改成composition api寫法是用`defineProps` ```javascript! const props = defineProps(['id']) ``` ### Deploying with Render 使用渲染進行部署 部署前需要考慮的事情: - find a web hosting service 找網路託管服務 - get SSL for a secure domain 取得安全域的SSL - build the site locally 在本地建立網站 - drop those files into the server 將文件放入伺服器 - ensure its being served correctly 確保其正確服務 如何部署: - package.json中可以看到有一個script為build,指令下`npm run build` ![image](https://hackmd.io/_uploads/SJYPFg9lxe.png) - 完成後會出現一個 dist 資料夾,裡面有 html、css、js Render 是自動部署靜態網站,不需要把 `dist/` push 到 GitHub,須確定`src/`、`package.json` 等原始碼 push 上去 ![image](https://hackmd.io/_uploads/HJIOtlqegx.png) 遇到 deploy 報錯,原因是 node 版本的 openSSL 跟 webpack 不相容,但改了 node 版本為v16還是一樣 解法:在package.json加入 engines 欄位,Render就會跑正確版本 ``` "engines": { "node": "16" } ``` 重新部署成功了! ![image](https://hackmd.io/_uploads/SkPYKe5gxg.png) 影片說到,當點EventList的時候,把網址複製再次貼上會出現not found (如圖) ![image](https://hackmd.io/_uploads/B119txcgel.png) 解決方法: 1. 先到 Render 的 redirects/rewrites 設定 ![image](https://hackmd.io/_uploads/r1p5Kx5eee.png) 2. 再到 router 加上 not found的`路由` 和 `component` ```javascript! // index.js { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound } ``` `VueX`: storage center for data throughtout your app ## Vue Router ### receiving URL parameters 1. 讀取URL的查詢參數(query parameters): 例如:網址為 `http://localhost:8080/events?page=4` 要取得 query parameters,搭配錢字號 `$route.query.page`,會得到 `4` 的結果 2. 讓 parameter 成為URL的一部分: ```javascript! // src/router/index.js // 冒號後為 route parameter const routes = [ ... { path:'/events/:page', component:Events } ] // 元件 <template> <h1>you are on page{{ $route.params.page }}</h1> </template> ``` 用`$route.params.page`取得值,但這樣寫法會`高度耦合(hightly coupled)` 元件,也就是元件直接依賴 vue router 的 $route 物件。 :star: pure 寫法 ```javascript! // src/router/index.js const routes = [ ... { path:'/events/:page', component:Events, props:true } ] // 元件 <script> export default{ props:['page'] } /* composition api寫法 const props = defineProps({ page: String }) */ </script> <template> <h1>you are on page{{ page }}</h1> </template> ``` - 在 route 用 props 傳遞出去,元件再接收 - 元件更容易測試與重用 3. configure a component from the router 使用`Props Object Mode` ```javascript! // src/router/index.js const routes = [ { path: '/', name: 'Home', component: Home, props: { showExtra: true } } ] // src/views/Home.vue <template> <div class="home"> <h1>This is a home page</h1> <div v-if="showExtra"></div> </div> </template> <script> export default { props: ['showExtra'] } </script> ``` 4. transform query parameters 使用`Props function Mode`,函式可以做更複雜的轉換 網址為 `http://localhost:8080/?e=true` ```javascript! const routes = [ { path: '/', name: 'Home', component: Home, props: (route)=>({showExtra:route.query.e}) } ] ``` Vue Router 會將 URL 查詢字串中的 `e` 參數,轉成一個叫 `showExtra` 的 prop 傳給 `Home` 元件。 這樣做讓元件不需要知道路由的細節,只關心它收到的 `showExtra`,這樣元件的職責就更單一、也更方便重複使用。 ### buliding pagination 建立分頁 [mock JSON server](https://my-json-server.typicode.com/Code-Pop/Touring-Vue-Router) 例如有一個網址:`/events?_limit=2&_page=3` `_limit`:每頁多少項目 `_page`:正在哪一頁 1. 修改 EventService API 並帶入 perPage 和 page 參數 ```javascript! getEvents(perPage, page) { return apiClient.get(`/events?_limit=${perPage}&_page=${page}`) }, ``` 2. 在 router 使用 props function mode 設置當前頁 假如 query parameter 存在且是整數就取得,否則預設為`1` ```javascript! { path: '/', name: 'EventList', component: EventList, props: route => ({ page: parseInt(route.query.page) || 1 }) }, ``` `parseInt(route.query.page)`:因為 route.query.page 拿到是字串,須轉換成數字 `1`:表示預設為第一頁 > 用函式寫法是因為「要動態計算傳資料給props」。 3. 在 EventList.vue 呼叫 api 的地方修改參數 ```javascript! const props = defineProps(['page']) const page = computed(() => props.page) onMounted(() => { // 設定每頁2個item和當前頁的參數 EventService.getEvents(2, page.value) .then(response => { events.value = response.data }) .catch(error => { console.log(error) }) }) ``` 為什麼`props.page`要 computed :question: 因為 `defineProps(['page'])`原本是寫成 ```javascript! props: { page: { type: Any } } ``` `props` 是 reactive proxy,而computed會把`props.page`變成 `ref` 也就是 props 有包含 page 屬性,就可以 `props.page` 取值,因為需要響應式變數來追蹤變化,透過 computed 包住`props.page`,才會自動更新。 4. 在EventList.vue增加分頁連結 ```javascript! <template> <h1>Events for Good</h1> <div class="events"> <EventCard v-for="event in events" :key="event.id" :event="event" /> <router-link :to="{ name: 'EventList', query: { page: page - 1 } }" rel="prev" v-if="page != 1" > >Prev Page </router-link> </div> </template> ``` - `:to="{ name: 'EventList', query: { page: page - 1 } }` :導向 EventList ,並在網址加上 `?page=...` 的參數,query參數會出現在`?`的後面 網址長的樣子: ``` http://localhost:8080/?page=3 ``` - `rel='prev'`:為了SEO :star: 補充:params參數 和 query參數 使用時機 ``` inginging ``` 但因為 onMounted 生命週期僅在初始載入時調用,而不是在元件重用時調用,所以要透過 `watchEffect` 來監聽page更新 ```javascript! const events = ref(null) onMounted(() => { watchEffect(() => { // 清除頁面上的事件,以便使用者知道 API 已被調用 events.value = null EventService.getEvents(2, page.value) .then(response => { events.value = response.data }) .catch(error => { console.log(error) }) }) }) ``` watchEffect: - 第一次執行會自動跑一次(在上面程式碼會在 mounted 時跑一次,之後 page.value 改變也會再跑) - 寫法比較簡潔,不需要明確指定響應式資料來源 watch: - 要手動指定監聽的變數 - 不會自動在一開始執行,除非加上 `{ immediate: true }` 上面的程式碼改寫成 watch : ```javascript! onMounted(() => { watch(page,(newPage) => { events.value = null EventService.getEvents(2, newPage) .then(response => { events.value = response.data }) .catch(error => { console.log(error) }) }), { immediate: true }) }) ``` 5. 計算頁數 ```javascript! const totalEvents = ref(0) EventService.getEvents(2, page.value) .then(response => { events.value = response.data totalEvents.value = response.headers['x-total-count'] }) .catch(error => { console.log(error) }) ``` `x-total-count`: **HTTP 回應標頭(header)**,常用在 **分頁(pagination)API**,也就是資料庫中符合條件的**資料總筆數**。 從資料庫拿到總筆數後,就能計算總共會有幾頁: ```javascript! const hasNextPage = computed(() => { let totalPages = Math.ceil(totalEvents.value / 2) return page.value < totalPages // 代表不是最後一頁 }) ``` 最後排版一下prev page和 next page ![image](https://hackmd.io/_uploads/Hk2qBXg-xe.png) ### nested routes Lazy Loading Routes ```javascript! // 原本 import UserDetails from './views/UserDetails' // 改成 const UserDetails = () => import('./views/UserDetails.vue') ``` [Vue doc: Lazy Loading Routes](https://router.vuejs.org/guide/advanced/lazy-loading.html#%E8%B7%AF%E7%94%B1%E6%87%92%E5%8A%A0%E8%BD%BD) 建立一個 layout 元件給event底下所有的子元件,layout包含navigation和fetch events,這樣就不用每個子元件都寫。 ```javascript! { path: '/event/:id', name: 'EventLayout', props: true, component: () => import('../views/event/Layout.vue'), children: [ { path: '', name: 'EventDetails', component: () => import('../views/event/Details.vue') } ] }, ``` 嵌套路由:在父層寫 `children`,==子層的path為空==,表示載入父層的根路徑`/event/:id` :question: 如果沒有發送 :id ,它將尋找並使用存在的 :id 參數 ```javascript! // 把 params:{id} 刪掉,因為layout的route本身有:id,子層route的id就會來自父層 <template> <div v-if="event"> <h1>{{ event.title }}</h1> <div id="nav"> <router-link :to="{ name: 'EventDetails' }">Details</router-link> | <router-link :to="{ name: 'EventRegister' }">Register</router-link> | <router-link :to="{ name: 'EventEdit' }">Edit</router-link> </div> <router-view :event="event" /> </div> </template> ``` [vue doc:Nested Routes](https://router.vuejs.org/guide/essentials/nested-routes.html) ### Redirect & Alias 舊的 route 重新導向至新的 route 1. 如何改網址從 `/about` 到 `/about-us`? - 方法一:使用 `redirect` ```javascript! { path: '/about-us', // 將about改成about-us name: 'About', component: () => import('@/views/About.vue') }, { path: '/about', redirect: { name: 'About' } // 使用者輸入about會重新導向到name為About的網址,也就是about-us } ``` `redirect`: 將路由導向另一個指定的路由 可以有三種寫法: 1. 直接寫字串:`redirect: '/home'` 2. 寫成物件:`redirect: { path: '/home', query: { foo: 'bar' } }` 3. 寫成函式:`redirect: (to) => {...}` - 方法二:使用 `alias` 別名,但如果在意SEO搜尋就不建議 ```javascript! { path: '/about-us', name: 'About', component: () => import('@/views/About.vue') alias: '/about' }, ``` 可以把 alias 想成這個路由有小名叫做 `/about` 2. 如何改網址從 `/event/:id` 到 `/events/:id`? ```javascript! { path: '/event/:id', redirect: () => { return { name: 'EventDetails' } } }, // { // path: '/event/:id', // redirect: to => { // return { // name: 'EventDetails', // params:{ id:to.params.id } // } // } // }, ``` `to`參數: vue router 的 route 物件,包含使用者要前往的路由。透過to參數就能找到動態參數id。 瀏覽器的Vue可以查看 route 物件: ![image](https://hackmd.io/_uploads/ryMdpF-Zeg.png) 要改的路由本身底下如有嵌套路由的話: ```javascript! { path: '/event/:afterEvent(.*)', // afterEvent自定義參數 redirect: to => { return { path: '/events/' + to.params.afterEvent } } }, ``` `.*`: 正則表達式寫法,表示任何內容、任何長度(包括空字串) 也就是 `:afterEvent(.*)` 會**匹配 `/event/` 後面「所有的東西」** [vue doc:路徑參數正則表達式](https://router.vuejs.org/zh/guide/essentials/dynamic-matching.html#%E6%8D%95%E8%8E%B7%E6%89%80%E6%9C%89%E8%B7%AF%E7%94%B1%E6%88%96-404-Not-found-%E8%B7%AF%E7%94%B1) [Vue doc: Redirect and Alias](https://router.vuejs.org/guide/essentials/redirect-and-alias.html#Redirect-and-Alias) ### programmatic navigation > This is the method called internally when you click a `<router-link>`, so clicking `<router-link :to="...">` is the equivalent of calling `router.push(...)`. > 當點擊 router-link,內部會調用 router.push() #### router.push 用官網的範例 ```javascript! // 字串路徑 router.push('/users/eduardo') // 路徑的物件 router.push({ path: '/users/eduardo' }) // 用name,並帶上參數 router.push({ name: 'user', params: { username: 'eduardo' } }) // 帶查詢參數,结果 /register?plan=private router.push({ path: '/register', query: { plan: 'private' } }) // 带 hash,结果 /about#team // hash用來跳到頁面中某個元素的位置 router.push({ path: '/about', hash: '#team' }) ``` #### router.replace 和 `router.push` 一樣都是導向到另一個路由,差別在瀏覽器歷史紀錄history。 - `router.push`:正常頁面導航 範例:使用者從首頁點擊一篇文章 瀏覽器歷史紀錄:`home`、`/post/123` ```javascript! router.push('/post/123') ``` - `router.replace`:導入後不希望返回導航 範例:使用者登入後自動跳轉到dashboard,不希望使用者按返回回到登入頁。 瀏覽器歷史紀錄:`/dashboard`(取代掉 `/login`) ```javascript! router.replace('dashboard') ``` 也可以在 `router.push` 增加屬性 `replace:true` ```javascript! router.push({ path: '/home', replace: true }) // 等同於 router.replace({ path: '/home' }) ``` 補充: `router.go(1)`:向前一條紀錄,等同router.forward() `router.go(-1)`:向後一條紀錄,等同router.back() 等同於瀏覽器的前進 / 返回按鈕! [vue doc: Programmatic Navigation](https://router.vuejs.org/guide/essentials/navigation.html#%E7%BC%96%E7%A8%8B%E5%BC%8F%E5%AF%BC%E8%88%AA) [vue doc: Vue Router and the Composition API](https://router.vuejs.org/guide/advanced/composition-api.html) [MDN: History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API) ### error handling and 404s 假如使用者輸入一個不存在的路由,設計頁面導向 NotFound.vue 元件。通常用來處理 404 頁面,**匹配所有未被其他路由捕捉的網址** ```javascript! // router/index.js { path: '/:catchAll(.*)', name: 'NotFound', component: () => import('@/views/NotFound.vue') } ``` `catchAll`:是自訂參數,是習慣上的命名因為有語意,也可以取`path: '/:notFound(.*)'`之類 不論是頁面的 404 還是 event 的 404 ,都可以重用 NotFound.vue 元件! ```javascript! { path: '/404/:resource', name: '404Resource', component: () => import('@/views/NotFound.vue'), props: true } ``` ```javascript! // NotFound.vue // 這裡的resource可以是page或是event,props預設為page <template> <h1>Oops!</h1> <h3>The {{ resource }} you are looking for is not here.</h3> <router-link :to="{ name: 'EventList' }">Back to the home page</router-link> </template> <script setup> import { defineProps } from 'vue' defineProps({ resource: { type: String, required: true, default: 'page' } }) </script> ``` 在 NotFound.vue 加上 defineProps: resource屬性 ```javascript! onMounted(() => { EventService.getEvent(props.id) .then(response => { event.value = response.data }) .catch(error => { console.log(error) router.push({ name: '404Resource', params: { resource: 'event' } }) }) }) ``` 打 api 時,如果沒有這個 event,則跑 catch 區塊,router.push 到 404, params 直接設定為 `event` 網址為 `http://localhost:8080/404/event` ![image](https://hackmd.io/_uploads/H1h66R-Wxl.png) 範例: call api 的 EventService.js 假設 baseUrl 寫錯 ```javascript! const apiClient = axios.create({ baseURL: 'https://my-json-server.typicode.com/Code-Pops/Touring-Vue-Router', // 測試網址錯誤:pop後面多加s withCredentials: false, headers: { Accept: 'application/json', 'Content-Type': 'application/json' } }) ``` 建立一個 NetworkError 元件 ```javascript! <template> <div class="networkError"> <h1>Uh-Oh!</h1> <h3> It looks like you're experiencing some network issues, please take a breath and <a href="#" @click="router.go(-1)">click here</a> to try again. </h3> </div> </template> <script setup> import { useRouter } from 'vue-router' const router = useRouter() </script> ``` 建立 NetworkError 的 router ```javascript! { path: '/network-error', name: 'NetworkError', component: () => import('@/views/NetworkError.vue') } ``` ```javascript! // Layout.vue onMounted(() => { EventService.getEvent(props.id) .then(response => { event.value = response.data }) .catch(error => { if (error.response && error.response.status == 404) { router.push({ name: '404Resource', params: { resource: 'event' } }) } else { router.push({ name: 'NetworkError' }) } }) }) ``` 在 catch 寫 if 判斷,假入 event 不存在,載入 404 ;否則 network error ```javascript! // EventList.vue onMounted(() => { watchEffect(() => { events.value = null EventService.getEvents(2, page.value) .then(response => { events.value = response.data totalEvents.value = response.headers['x-total-count'] }) .catch(() => { router.push({ name: 'NetworkError' }) }) }) }) ``` 因為 EventList 也有call api,所以如有錯,則導入到network error ### flash message 全域儲存機制 創建一個 reactive 全域物件`GStore` ```javascript! // main.js import { createApp, reactive } from 'vue' import App from './App.vue' import router from './router' import store from './store' const GStore = reactive({ flashMessage: '' }) createApp(App) .use(store) .use(router) .provide('GStore', GStore) .mount('#app') ``` `provide`: 提供給後代元件注入值,通常會跟`inject`搭配一起使用。 ```javascript! // register.vue <script setup> import { defineProps, inject } from 'vue' import { useRouter } from 'vue-router' const router = useRouter() const props = defineProps(['event']) const GStore = inject('GStore') function register() { GStore.flashMessage = `You are successfully registered for ${props.event.title}` setTimeout(() => { GStore.flashMessage = '' }, 3000) router.push({ name: 'EventDetails' }) } </script> ``` 在register.vue 的register函式 `注入(inject)` GStore 當點擊 register 按鈕後 flash message 本來為空字串被改成`You are successfully registered for ${props.event.title}` ```javascript! // App.vue <div id="flashMessage" v-if="GStore.flashMessage"> {{ GStore.flashMessage }} </div> ``` 最後在 App.vue 的 template 中加入 GStore.flashMessage 的文字判斷和樣式。 ### in component route guards (組件內的全域路由守衛) `onBeforeRouteUpdate`:單一元件內,同一元件路由參數變動時執行。例如:`/user/1` ➜ `/user/2` `onBeforeRouteLeave`:單一元件內,要離開元件時(可攔下) `to`:即將導航到的路由 `from`:當前導航正要離開的路由 `next`:vue 2需要用,但vue 3可用回傳值來取代next。沒有回傳值或是回傳true,導航就繼續,如果有回傳false,導航就停止 有以下寫法: - `return true` 繼續導航 - `return false` 取消導航 - `return '/'` 重新導向 / path - `return {name:'event-list'}` 重新導向 event-list 路徑 範例: ```javascript! const unsavedChanges = ref(true) onBeforeRouteLeave((to, from, next) => { if (unsavedChanges.value) { const alert = window.confirm( 'Do you really want to leave? You have unsaved changed!' ) if (!alert) { return false } } }) ``` ### Global and Per-Route Guards TODO:需要把progress bar在每個頁面呈現 `beforeEach 路由守衛`:**全域**,每次導航前會先攔截 `beforeResolve` `afterEach`:導航完成之後調用,所以不能取消導航 加上 `return` 原因是讓 vue router 知道載入頁面之前等待 API 呼叫。 為什麼 `beforeRouteEnter` 不用 `return` 但 `beforeRouteUpdate` 就要? 因為`next`會告訴vue router等到API呼叫回傳後,才能進行路由 #### vue router 呼叫的順序 1. router.beforeEach() 2. beforeEnter() 3. beforeRouteEnter() 4. router.beforeResolve() 5. router.afterEach() 6. 最後執行vue元件的生命週期方法 ### route meta field meta 藉由子路由取得繼承,也就是父層路由有 meta field ,那子路由自動繼承 meta field 。 例如:父層是管理者頁面,設定 `meta: {requireAuth:true}`,其子路由也都會一同繼承 `meta: {requireAuth:true}` [vue doc:route meta field 路由元信息](https://router.vuejs.org/guide/advanced/meta.html#Route-Meta-Fields) ### lazy loading routes 路由懶加載 將路由對應的元件分割,當路由被訪問才加載。 ```javascript! { path: 'register', name: 'EventRegister', component: () => import('@/views/event/Register.vue') // 不載入程式碼除非有請求 } ``` [vue doc: lazy loading routes](https://router.vuejs.org/guide/advanced/lazy-loading.html#Lazy-Loading-Routes) ### scroll behavior 滾動行為 當進入頁面,瀏覽器預設行為會帶使用者到頁面最頂端。 例如:google 搜尋引擎,當滑到底部有分頁,點選第二頁時候,會到第二頁的頂端 但影片一開始的範例點選 next page,仍停留在底部,需要改善使用者介面,所以在 router 加上 `scrollBehavior` : ```javascript! const router = createRouter({ history: createWebHistory(process.env.BASE_URL), routes, // 滾動到頂端 scrollBehavior() { return { top: 0 } } }) ``` 按返回前一頁,要帶回到原本頁面的原本的位置: ```javascript! const router = createRouter({ history: createWebHistory(process.env.BASE_URL), routes, scrollBehavior(to, from, savedPosition) { if (savedPosition) { return savedPosition } else { return { top: 0 } } } }) ``` [vue doc:scroll behavior](https://router.vuejs.org/guide/advanced/scroll-behavior.html#Scroll-Behavior) ## vue3 Forms ### basic input 盡可能讓元件保持彈性和複用設計 `<pre></pre>`可以直接在模板中看資料 :face_holding_back_tears: 為什麼 input 沒有吃到樣式? :face_holding_back_tears: ![image](https://hackmd.io/_uploads/HJvuCsAXgx.png =75%x) 因為 multi-root component (多個根元素) 無法自動繼承 attribute(例如 `type="text"`),需要手動把 `$attrs` 綁到指定的元素例如 `<input>`,這樣父層傳入的屬性才能正確應用。 如果 single-root component(一個根元素),Vue 會自動把這些 attribute 加到 root 上。 解法:手動注入 attribute ,==綁定$attrs 物件== ```javascript! // BaseInput.vue <template> // label 和 input 這樣是兩個root <label>{{ label }}</label> <input v-bind="$attrs" // 在這裡加上$attrs :placeholder="label" :value="modelValue" class="field" @input="$emit('update:modelValue', $event.target.value)" /> </template> /* -------------------------*/ <template> // div這樣是一個root <div> </div> </template> ``` ```javascript! // SimpleForm.vue <BaseInput label="Title" v-model="event.title" type="text" /> ``` ![image](https://hackmd.io/_uploads/H1yARj07xl.png =75%x) BaseInput元件就能吃到父層給的type="text" [doc: $attrs](https://cn.vuejs.org/api/component-instance#attrs) 其他範例: ```javascript! <BaseInput label="Name" v-model="name" type="text" maxlength="20" /> ``` 父層傳來但沒有對應 prop 的所有屬性,例如`type` 和 `maxlength`沒有在 `defineProps` 中定義,就會被自動轉為 `$attrs` 用 `v-bind="$attrs"` 就能讓它們應用到 `<input>`。 ```javascript! @input="$emit('update:modelValue', $event.target.value)" ``` 1. `$emit('update:modelValue', $event.target.value)` 發送了一個事件: - 事件名稱:`update:modelValue` - 傳遞的值:`$event.target.value`(即使用者輸入的字串) 2. `$event.target.value` 將input 元素的輸入事件的 `value`,**作為參數傳遞給父層**。 ```javascript! // SimpleForm.vue <BaseInput label="Title" type="text" :modelValue="event.title" @update:modelValue="($event) => (event.title = $event)" /> ``` 所以 `@update:modelValue` 的 `$event` 接收到子元件的 `value`,並賦值並更新 `event.title` ```javascript! <BaseInput :modelValue="name" @update:modelValue="($event) => name = $event" // $event參數 就是 $event.target.value傳來的值 /> ``` #### 補充:Component v-model ```javascript! <BaseInput label="Title" v-model="event.title" type="text" /> ``` 等於下面寫法: ```javascript! <BaseInput label="Title" type="text" :modelValue="event.title" @update:modelValue="($event) => (event.title = $event)" /> ``` - 使用 `v-model` 綁定的時候,其實背後就是在監聽 `update:modelValue` 這個事件 - `modelValue` 是 Vue 3 的 `v-model` 預設接收 prop ### basic select 在vue 3,如果沒有使用`@`這個語法,事件將以關鍵字 `on` 作為前綴 例如:`@change ➜ onChange`、`@input ➜ onInput` 看不懂意思 :boom: :boom: :angry: :angry: 為什麼 onChange 是寫在 v-bind 裡面? > GPT解釋這樣做的目的是: > - 保留並傳遞外部父元件可能傳進來的 `$attrs`(例如 `id`、`class`、`name` 等) > - 但又要**控制自己的 `onChange` 行為** > - 如果你在 `$attrs` 裡有傳入 `onChange`,這邊就會**覆蓋它** ```javascript! // SimpleForm.vue <BaseSelect v-model="event.category" label="Select a category" :options="categories" /> ``` ```javascript! // BaseSelect.vue <template> <label v-if="label">{{ label }}</label> <select :value="modelValue" v-bind="{ ...$attrs, onChange: ($event) => { $emit('update:modelValue', $event.target.value); }, }" class="field" > <option v-for="option in options" :value="option" :key="option" :selected="option === modelValue" > {{ option }} </option> </select> </template> <script> export default { props: { label: { type: String, default: "", }, modelValue: { type: [String, Number], default: "", }, options: { type: Array, required: true, }, }, }; </script> ``` ### auto importing components ### base checkbox ```javascript! // BaseCheckbox.vue <template> <input type="checkbox" :checked="modelValue" @change="$emit('update:modelValue', $event.target.checked)" class="field" /> <label>{{ label }}</label> </template> <script> export default { props: { label: { type: String, default: "", }, modelValue: { type: Boolean, default: false, }, }, }; </script> ``` ```javascript! // SimpleForm.vue <BaseCheckbox label="Live music" v-model="event.extras.music" type="checkbox" /> ``` ### BaseRadio ```javascript! <template> <input type="radio" :checked="modelValue === value" <!-- 是否勾選 --> :value="value" <!-- radio 的值 --> v-bind="$attrs" @change="onChange" /> <label>{{ label }}</label> </template> <script setup> const { label, modelValue, value } = defineProps({ label: { type: String, default: "", }, modelValue: { type: [String, Number], default: "", }, value: { type: [String, Number], required: true, }, }); const emit = defineEmits(["update:modelValue"]); function onChange() { emit("update:modelValue", value); } </script> ``` ```javascript! // SimpleForm.vue <div> <BaseRadio v-model="event.pets" :value="1" name="pets" label="Yes" /> </div> <div> <BaseRadio v-model="event.pets" :value="0" name="pets" label="No" /> </div> ``` ### BaseRadioGroup ```javascript! // BaseRadioGroup.vue <template> <BaseRadio v-for="option in options" :key="option.value" :value="option.value" :label="option.label" :name="name" :modelValue="modelValue" @update:modelValue="$emit('update:modelValue', $event)" /> </template> <script setup> import BaseRadio from "./BaseRadio.vue"; const { options, name , modelValue } = defineProps({ options: { type: Array, required: true, }, name: { type: String, required: true, }, modelValue: { type: [String, Number], required: true, }, }); </script> ``` ```javascript! // SimpleForm.vue <div> <BaseRadioGroup :modelValue="event.pets" @update:modelValue="($event) => (event.pets = $event)" name="pets" :options="petOptions" /> </div> ``` ![image](https://hackmd.io/_uploads/r1fb1nC7xg.png =50%x) ![image](https://hackmd.io/_uploads/r1IMkh0Qxe.png =50%x) 在排版上,如果 radio group 要水平排版,需要用 `<span>` 包 ; 如果要垂直排版,需要用 `<div>` 包。這時候可以使用 `<component>` **動態渲染其他組件** 或 **HTML 元素**。 ``` <component :is /> ``` `:is` 屬性:決定渲染 哪個元件 或 標籤 範例: ```javascript! <component :is="href ? 'a' : 'span'"></component> ``` 意思是 href 為 true,則 `<component>` 會變成 `<a></a>` ,反之 `<component>` 會變成 `<span></span>` :star: 為什麼把 `v-for` 和 `:key` 放在 `<component>` 上? 因為要把每個 radio 的外層包容器元素 ```javascript! // v-for寫在component上 <div> <BaseRadio /> </div> <div> <BaseRadio /> </div> // v-for寫在BaseRadio上 <BaseRadio /> <BaseRadio /> ``` [doc: 動態組件](https://cn.vuejs.org/guide/essentials/component-basics.html#dynamic-components) [doc: <component>](https://cn.vuejs.org/api/built-in-special-elements.html#component) [doc: is](https://cn.vuejs.org/api/built-in-special-attributes.html#is) ### submittimg forms XMLHTTPRequests(XHR) > `XMLHttpRequest` (XHR) objects are used to interact with servers. You can retrieve data from a URL without having to do a full page refresh. This enables a Web page to update just part of a page without disrupting what the user is doing. > > JavaScript 提供的一個內建物件,**主要用來向伺服器發送請求、接收資料**,而不需要重新載入整個頁面。 - 表單預設行為:瀏覽器會重載入新頁面 - `submit.prevent` 這個修飾符會呼叫`e.preventDefault()` - 當點擊按鈕標籤有`type="submit"` ,`<form></form>`標籤會發送sumbit事件 安裝axios ``` npm i axios ``` 發出特定的請求,例如:POST ``` post(URL , payload 物件) ``` - 另外建議表單在前端做預先驗證或客戶端驗證 和 顯示加載 loading spinner [JSON server - vue form](https://my-json-server.typicode.com/Code-Pop/Vue-3-Forms) 到開發工具的`network`頁面可以觀察 fetch/XHR,Header可以看到為成功請求 ![image](https://hackmd.io/_uploads/rk4BJ207gx.png) Response則可以觀察伺服器回傳回來的資料 ![image](https://hackmd.io/_uploads/BkPIJnRmgg.png) 如果是在console.log頁面,則可以看到完整的回傳資料 ![image](https://hackmd.io/_uploads/HylPyhAQel.png) ### basic a11y for our component `<fieldset>`:**將一組相關的表單欄位包在一起**的容器,螢幕閱讀器會解釋欄位間的關係。 `<legend>`:`<legend>` 必須放在 `<fieldset>` 裡面,它提供這一組欄位的**說明標題**。這段文字會被螢幕閱讀器唸出來。 placeholder不能取代說明的標籤(label) ```javascript! // 最快的解決方式,確保input可以連接到label <label> Title <input> </label> ``` 通用唯一辨識碼(Universally Unique Identifier,縮寫:_UUID_) `aria-describedby`:提供補充說明,不取代label `aria-live`:某段內容變動時,自動唸出來通知使用者。例如:送出成功、3秒後自動跳轉等 ```javascript! aria-live="polite" // 有空再唸(不打斷使用者) aria-live="assertive" // 立即打斷並唸出內容 ``` `aria-invalid`:這個表單欄位目前是無效的(驗證失敗),但不會執行表單驗證! ```javascript! <input type="email" id="email" aria-invalid="true"> ``` submit button不要用disable,因為輔助科技不會取得到回饋而被忽略 [MDN: accessibility( a11y 無障礙 )](https://developer.mozilla.org/en-US/docs/Web/Accessibility) ## validating vue3 Forms ### setting up 安裝vee-validate ``` npm i vee-validate --save ``` #### useField() ```javascript! <script setup> import { useField } from 'vee-validate'; const { value, errorMessage } = useField('name', inputValue => !!inputValue); </script> ``` 因為 useField() 是回傳一個物件,可以用解構的方式取得物件裡的屬性,能使用的屬性很多,詳細看 [vee-validate:useField](https://vee-validate.logaretm.com/v4/api/use-field/)。 第一個屬性 `value`:是Vue ref,代表欄位的值 第二個屬性 `errorMessage`:是一個 `ref`,當驗證失敗時,它會包含錯誤訊息,否則為空字串 ![image](https://hackmd.io/_uploads/HysoJ30meg.png =75%x) ![image](https://hackmd.io/_uploads/H19213RXxg.png =75%x) :star: `!regex.test(String(value).toLowerCase()`為什麼轉成小寫? Email 使用者名稱(@ 前):技術上是區分大小寫的。 Email 網域名稱(@ 後):一律 **不區分大小寫**。 開發上會統一轉小寫做驗證,如果要做更嚴格的驗證,就不須加上轉小寫。 ### validating a form level 表單層級驗證 #### useForm() - 是初始化「整份表單狀態」的函式 - `validation schema(驗證規則)`:一個統一的結構(通常是一個物件)來定義整個表單的驗證規則。因為用`useField()`只能一個一個欄位設定驗證,而 schema 則可以一次定義表單中每個欄位的驗證規則。 範例: ```javascript! validationSchema: yup.object({ email: yup.string().email().required(), password: yup.string().min(6).required() }) ``` 以這個例子 validation schema 驗證規則為 - email 是 email 格式、必填 - password 至少 6 個字、必填 需要注意的地方: `useField("email")`和 validations schema 的 `email 屬性`名稱要一致!其他也是以此類推 ![image](https://hackmd.io/_uploads/HJPCJ2Cmgg.png) return true 代表驗證通過 [vee-validate:useForm](https://vee-validate.logaretm.com/v4/api/use-form/) [vee-validate:Typed Schemas](https://vee-validate.logaretm.com/v4/guide/composition-api/typed-schema#yup) ### submitting bigger forms ```javascript! password: value => { const requiredMessage = 'This field is required' if (value === undefined || value === null) return requiredMessage // 驗證失敗,顯示錯誤訊息 if (!String(value).length) return requiredMessage // 驗證失敗,顯示錯誤訊息 return true // 驗證成功 } ``` 處理各種「空」的情況: 因為 `.length` 無法用在 `null` 或 `undefined`,所以先判斷 `value === undefined || value === null` 是為了**避免錯誤發生** :star: `useForm({ validationSchema })` validationSchema 本身設定就是個物件,為什麼外層還要有`{}`? 因為 useForm 本身會包含多個屬性選項,validationSchema 只是其中一個,如下方設計: ```javascript! useForm({ initialValues: { ... }, validationSchema: { ... }, validateOnBlur: true, ...其他選項 }); ``` :star: undefined是什麼? ```javascript! const { value: pets, errorMessage: petsError } = useField("pets", undefined, { initialValue: 1, }); ``` 因為 useField 第一個參數是欄位 第二個參數 `validate`,`undefined`意思是 不需要驗證規則 第三個參數是可選擇性 如果省略第二個參數直接寫`{ initialValue: 1 }`,會誤以為是第二個參數而報錯。 #### Composable API - handleSubmit() ```! handleSubmit: (cb: SubmissionHandler) => (evt?: Event) => Promise<void> ``` 處理表單送出 - callback是「驗證成功」後要做的事情 - 這個callback 回傳的是一個 promise #### Composable API - errors 把欄位名稱對應的的錯誤訊息包成一個物件 ```javascript! const { errors } = useForm(); ``` 例如: category 和 title 是必填,當沒填寫就送出表單,跳出錯誤訊息 ![image](https://hackmd.io/_uploads/HkpSl20mlg.png =75%x) ![image](https://hackmd.io/_uploads/SJnLlnAQlg.png =90%x) 原想改寫把 useField 的 value 改成從 useForm 解構取得 valuse 來綁定`BaseSelect`等元件的v-model,結果失敗 :cry: 查詢發現 `useForm().values` 是 ==readonly的 reactive proxy==,也就是只能讀,不能雙向綁定(v-model不行,會出現 readonly 的錯誤) ![image](https://hackmd.io/_uploads/S1zOg2Cmxe.png) :100: 改寫成這樣就可以了:100: ```javascript! // 不要用 v-model <BaseInput :modelValue="values.title" @update:modelValue="(value) => setFieldValue('title', value)" label="Title" :error="errors.title" type="text" /> ``` ```javascript! // ComponentsForm.vue <script setup> import { ref } from "vue"; import { useField, useForm } from "vee-validate"; const categories = ref([ "sustainability", "nature", "animal welfare", "housing", "education", "food", "community", ]); const required = (value) => { const requiredMessage = "This field is required"; if (value === undefined || value === null) return requiredMessage; if (!String(value).length) return requiredMessage; return true; }; const minLength = (number, value) => { if (String(value).length < number) return "Please type at least " + number + " characters"; return true; }; const anything = () => { return true; }; const validationSchema = { category: required, title: (value) => { const req = required(value); if (req !== true) return req; const min = minLength(3, value); if (min !== true) return min; return true; }, description: anything, location: undefined, pets: anything, catering: anything, music: anything, }; const { handleSubmit, errors } = useForm({ validationSchema, initialValues: { pets: 0, catering: false, music: false, }, }); const onSubmit = handleSubmit( (values) => { console.log("驗證通過", values); }, (error) => { console.log("驗證失敗", error); }, ); const { value: category } = useField("category"); const { value: title } = useField("title"); const { value: description } = useField("description"); const { value: location } = useField("location"); const { value: pets } = useField("pets"); const { value: catering } = useField("catering"); const { value: music } = useField("music"); </script> ``` ### using YUP for validations vee-validate官網推薦使用第三方Yup函式庫,屬於 schema 驗證庫 基本安裝 ``` npm install @vee-validate/yup ``` 範例: ```javascript! import * as yup from 'yup'; // 設定規則 const schema = yup.object({ // 欄位是一個字串、格式是 email、欄位必填 email: yup.string().email().required(), // 欄位是一個字串、最少要 8 個字元、欄位必填 password: yup.string().min(8).required() }); schema.validate({ email: 'user@example.com', // 使用者實際輸入 password: '12345678' // 使用者實際輸入 }).then(validData => { console.log('驗證成功:', validData); }).catch(err => { console.log('驗證錯誤:', err.errors); }); ``` * `yup.object()`:建立整體資料的schema。範例中裡面包含email 和 password 兩個欄位,代表有各自的驗證條件。 * `yup.required()`:括號裡可以設置自訂訊息。例如:`yup.required("the title field is required")` * Yup 的 `.validate()` 是一個回傳 Promise 的非同步函式。例如:email 要連到後端確認 email 是否被註冊過 **tree shaking: 保留確定會用到的程式** ```javascript! // 原本寫法,引入所有 yup 的 package // 會影響打包程式的大小 import * as yup from "yup" // 修改,只留下有使用到的 import { object, stirng, number, boolean } from "yup" ``` [github文件 - yup](https://github.com/jquense/yup?tab=readme-ov-file#schemavalidatevalue-any-options-object-promiseinfertypeschema-validationerror) ### lazy validation #### Composable API - handleChange 原本 input 事件會每次輸入或刪除就會觸發,如果想要讓使用者體驗變好 1. 將 v-model 改成 屬性是 modelValue 和 change事件 2. 提取 useField 的 `handleChange`,更新欄位值、驗證欄位 ```javascript! <template> <form @submit.prevent="onSubmit"> <BaseInput label="Email" type="email" :error="emailError" :modelValue="email" @change="handleChange" /> <form /> </template> <script setup> const { value: email, errorMessage: emailError, handleChange, } = useField("email"); </script> ``` 那如果好幾個欄位都要使用`handleChange`,可以提取 useForm 的 `setFieldValue` 方法來設定 ```javascript! const { setFiedValue } = useForm({ validationSchema: validations, }); const handleChange = (event) => { setFiedValue("email", event.target.value); }; // useField就不用提取handleChange ```