[Part 4] Frontend: Scaling Vue.js EP.1

[Part 4] Frontend: Scaling Vue.js EP.1

Photo by Mohammad Rahmani / Unsplash

Custom Events

Vue รองรับการจัดการ event ได้ง่ายผ่าน directive @ เช่น @click, @input, @submit ซึ่งสามารถผูกฟังก์ชันโดยตรงใน template แต่นอกจาก event ที่มีอยู่แล้วเราสามารถใช้ defineEmits() เพื่อส่ง custom events ได้

ส่ง Event จาก Child ไปยัง Parent

// /src/components/ClickCounter.vue
<script setup>
  import { ref, defineEmits, watch } from 'vue'

  const emit = defineEmits(['increment'])
  const count = ref(0)

  watch(count, (newVal) => {
    emit('increment', newVal)
  })

  const incremental = () => {
    count.value++
  }
</script>

<template>
  <div class="green-bg">
    <h2>Clicked: {{ count }} times</h2>
    <p v-if="count > 5">More than 5 times clicked!</p>
    <button @click="incremental">Click</button>
  </div>
</template>

ฟัง Event ใน Parent Component

<script setup>
  import ClickCounter from './components/ClickCounter.vue'

  const showClick = (count) => {
    alert(`You clicked ${count} times!`)
  }
</script>

<template>
  <main>
    <ClickCounter @increment="showClick" />
  </main>
</template>

Component Slot

Slot คือช่องทางที่ Parent component สามารถส่งเนื้อหาภายใน HTML เข้ามาแสดงผลใน component ลูกได้ โดย component ลูกสามารถกำหนด “ช่อง” ไว้ให้แสดง content จากภายนอก

ประกาศ <slot> ใน child component

<script setup>
  import { computed, defineProps } from 'vue'

  const props = defineProps({
    firstName: String,
    lastName: String
  })

  const fullName = computed(() => {
    return `${props.firstName} ${props.lastName}`
  })
</script>

<template>
  <div class="red-bg">
    <h2>Hello, {{ fullName }}!</h2>
    <div class="message">
      <slot></slot>
    </div>
    <button @click="incremental">Submit</button>
  </div>
</template>

เพิ่ม content html ตอนเรียกใช้ component ใน Parent

<script setup>
  import MyProfile from './components/MyProfile.vue'

  const firstName = 'Pathompat'
  const lastName = 'Sungpankhao'
</script>

<template>
  <main>
    <MyProfile :first-name="firstName" :last-name="lastName">
      <p>ข้อความพิเศษ</p>
    </MyProfile>
  </main>
</template>

✅ ข้อดีของ Slot

  • เพิ่มความยืดหยุ่นให้ component สามารถปรับ UI ได้ตาม context
  • ลดความซ้ำซ้อนของการสร้าง component ใหม่
  • เหมาะกับ UI component ที่ reusable เช่น Modal, Card, Layout

Composables

ฟังก์ชันที่ใช้ร่วมกับ Composition API เพื่อ แยก logic ที่สามารถนำกลับมาใช้ซ้ำ (Reusable) เช่น

  • จัดการ state
  • เรียก API
  • ตรวจจับขนาดหน้าจอ
  • จัดการ authentication
Composables เริ่มต้นชื่อด้วย use เช่น useCounter, useUser, useFetch

State management

เป็นการ centralized reactivity state ในระดับ global ได้ง่าย ๆ โดยใช้ Composables ช่วย

// src/stores/useCounter.js
import { ref } from 'vue'

const count = ref(0)

export function useCounter() {
  return {
    count,
    increment: () => count.value++,
    decrement: () => count.value--
  }
}

state นี้จะเป็น global ที่ทุก component ใช้ร่วมกันได้ ตัวอย่างวิธีเรียกใช้

<script setup>
  import { defineEmits, watch } from 'vue'
  import { useCounter } from '../composables/useCounter'

  const emit = defineEmits(['submit'])
  const { count, increment } = useCounter()

  watch(count, (newVal) => {
    emit('increment', newVal)
  })
</script>

<template>
  <div class="green-bg">
    <h2>Clicked: {{ count }} times</h2>
    <p v-if="count > 5">More than 5 times clicked!</p>
    <button @click="increment">Click</button>
  </div>
</template>
💡
ถ้าต้องการใช้ State Management กับ Project ที่ซับซ้อนแนะนำให้ใช้ Pinia หรือ VueX แทน

API with Composables

เราสามารถใช้ Composible ช่วยจัดการ state ต่างๆ ได้ โดยใช้ร่วมกัน axios

npm install axios
  • เพิ่ม useApi.js
import { ref } from 'vue'
import axios from 'axios'

export function useApi(url) {
  const data = ref(null)
  const loading = ref(true)
  const error = ref(null)

  const fetchData = async () => {
    loading.value = true
    try {
      const res = await axios.get(url)
      data.value = res.data
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }

  fetchData()

  return { data, loading, error, refetch: fetchData }
}
  • เรียกใช้ api ที่ต้องการใน component
<script setup>
  import { useApi } from '@/composables/useApi'

  const { data, loading, error } = useApi('https://api.open-meteo.com/v1/forecast?latitude=13.75&longitude=100.5&current_weather=true')
  console.log(data, loading, error)
</script>

<template>
  <div v-if="loading">กำลังโหลด...</div>
  <div v-else-if="error">เกิดข้อผิดพลาด: {{ error.message }}</div>
  <ul v-else class="weather-list">
    <li>lat: {{ data.latitude }}</li>
    <li>long: {{ data.longitude }}</li>
    <li>temp: {{ data.current_weather.temperature }}</li>
  </ul>
</template>

Class และ Style Binding

Vue ทำให้การจัดการ class และ style บน HTML element เป็นเรื่องง่ายและยืดหยุ่นผ่าน binding ด้วย :class และ :style

<script setup>
  import { defineEmits, watch } from 'vue'
  import { useCounter } from '../composables/useCounter'

  const { count, increment } = useCounter()
</script>

<template>
  <div :class="{ 'green-bg': count <= 5, 'red-bg': count > 5 }">
    <h2>Clicked: {{ count }} times</h2>
    <p v-if="count > 5">More than 5 times clicked!</p>
    <button @click="increment">Click</button>
  </div>
</template>

<style scoped>
  .green-bg {
    background-color: green;
    color: white;
    padding: 20px;
    border-radius: 5px;
  }

  .red-bg {
    background-color: #e03d3d;
    padding: 20px;
    border-radius: 5px;
    margin: 5px 0;
  }
</style>

Plugin

Plugin คือชุดของฟีเจอร์หรือฟังก์ชันที่สามารถติดตั้ง (install) เข้าไปในแอป Vue ได้ทั้งโปรเจกต์ เช่น

  • เพิ่ม global component
  • เพิ่ม global function หรือ property
  • ใช้ library ภายนอก เช่น Vuetify, Pinia, Axios

Plugin Vuetify

  1. ติดตั้ง Vuetify (Vue 3)
npm install vuetify@next
npm install sass sass-loader -D
npm i @mdi/font
  1. สร้างไฟล์ vuetify.js ใน folder ใหม่ชื่อ ./src/plugins
import 'vuetify/styles'
import { createVuetify } from 'vuetify'
import { aliases, mdi } from 'vuetify/iconsets/mdi'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import '@mdi/font/css/materialdesignicons.css'

export const vuetify = createVuetify({
  icons: {
    defaultSet: 'mdi',
    aliases,
    sets: { mdi },
  },
  components,
  directives,
  theme: {
    defaultTheme: 'light'
  }
})
  1. เพิ่ม plugin ใน main.js
import { createApp } from 'vue'
import App from './App.vue'
import { vuetify } from './plugins/vuetify'

const app = createApp(App)
app.use(vuetify)
app.mount('#app')
  1. ทดสอบใช้งาน Vuetify ตาม doc https://vuetifyjs.com/en/
  • สร้าง AppLayout.vue เพื่อครอบทั้งหน้าเว็บ
<script setup>
  import { ref } from 'vue'

  const drawer = ref(false)
  function toggleDrawer() {
    drawer.value = !drawer.value
  }
</script>

<template>
  <v-app>
    <v-app-bar app color="primary" dark>
      <v-app-bar-nav-icon @click="toggleDrawer" />

      <v-toolbar-title>My Website</v-toolbar-title>

      <v-spacer />

      <v-btn icon>
        <v-icon>mdi-bell</v-icon>
      </v-btn>

      <v-btn icon>
        <v-icon>mdi-account</v-icon>
      </v-btn>
    </v-app-bar>

    <v-navigation-drawer v-model="drawer" app>
      <v-list>
        <v-list-item to="/">
          <v-list-item-title>Home</v-list-item-title>
        </v-list-item>
        <v-list-item to="/about">
          <v-list-item-title>About</v-list-item-title>
        </v-list-item>
      </v-list>
    </v-navigation-drawer>

    <v-main>
      <slot />
    </v-main>
  </v-app>
</template>
  • update App.vue
<script setup>
  import ClickCounter from './components/ClickCounter.vue'
  import MyProfile from './components/MyProfile.vue'
  import ShowWeather from './components/ShowWeather.vue'
  import AppLayout from './components/AppLayout.vue'
</script>

<template>
  <AppLayout>
    <MyProfile :first-name="firstName" :last-name="lastName">
      <p>ข้อความพิเศษ</p>
    </MyProfile>
    <ClickCounter @increment="showClick" />
    <ShowWeather />
  </AppLayout>
</template>

Assignment

  1. ใช้ API จาก https://newsdata.io/search-news โดยต้องขอ API Key เพื่อใช้ฟรี 200 ครั้ง
  2. ใช้ API กรองข่าวทั้งหมดที่เกียวกับปัญหากัมพูชาและไทยล่าสุด ตัวอย่าง API Endpoint: https://newsdata.io/api/1/latest?apikey=pub_xxxx&q=Cambodia Thailand Conflict
  3. สร้าง Vue Application โดยมีหน้าจอ 1 หน้า แสดงผลดังนี้
    1. App bar, Navbar
    2. หน้าจอหลักส่วน Content แสดงผลข่าวทั้งหมด
      1. ถ้ามีรูปแสดงผลด้านบนของการ์ด (ตามตัวอย่างข้างล่าง) จาก field result.image_url
      2. Card Title: results.title
      1. Card Description: results.description ถ้า description ของ news ยาวเกิน 4 บรรทัด ให้ตัดการแสดงผลหลังจากนั้นให้ แสดงผล ...
    1. มีปุ่มโหลดข้อมูลใหม่ บนขวาของหน้าจอ

Figma: https://www.figma.com/design/C5PbMbvLf8KmL6rJlgpBuc/Design-web-Learning-course?node-id=5512-81&t=zBDfd4VrJbyV1EXg-4


Reference

Vue.js
Vue.js - The Progressive JavaScript Framework
Get started with Vuetify 3 — Vuetify
Details for v3 release - faq, changes, and upgrading.