1. Pengenalan Vue.js
Vue.js adalah framework JavaScript progresif untuk membangun antarmuka pengguna (UI) di browser. Dibuat oleh Evan You pada tahun 2014, Vue dirancang untuk bisa diadopsi secara bertahap — dari memperkaya halaman HTML sederhana hingga membangun Single Page Application (SPA) yang kompleks.
Vue.js menggunakan pendekatan reactive data binding yang secara otomatis memperbarui DOM ketika data berubah. Ini menghilangkan kebutuhan untuk memanipulasi DOM secara manual — Anda cukup mengubah data, dan Vue akan memperbarui tampilan.
Mengapa Memilih Vue.js?
| Keunggulan | Penjelasan |
|---|---|
| Progressive | Bisa diadopsi bertahap — dari script tag biasa hingga SPA kompleks |
| Reactive | Data binding otomatis — ubah data, tampilan berubah sendiri |
| Komponen-Based | UI dibangun dari komponen kecil yang bisa digunakan ulang |
| SFC (Single File Component) | HTML, CSS, dan JS dalam satu file .vue yang rapi |
| Composition API | API modern untuk logika yang lebih terorganisir di komponen besar |
| Ringan | Ukuran kecil (~33KB min+gzip) — loading cepat |
| Ekosistem Kaya | Vue Router, Pinia (state management), Vite (build tool), Nuxt (SSR) |
Vue.js vs Framework Lain
| Aspek | Vue.js | React | Angular |
|---|---|---|---|
| Dikembangkan Oleh | Komunitas (Evan You) | Meta (Facebook) | |
| Ukuran | ~33KB | ~42KB | ~143KB |
| Learning Curve | 🟢 Mudah | 🟡 Sedang | 🔴 Curam |
| Template | HTML template | JSX | HTML template + directives |
| State Management | Pinia (official) | Redux / Zustand | NgRx / Services |
| Build Tool | Vite (official) | Vite / CRA | Angular CLI |
| Cocok untuk | Semua skala, cepat mulai | Ekosistem besar, fleksibel | Enterprise, struktur ketat |
Instalasi Vue.js
# ===== Cara 1: CDN (untuk eksperimen cepat) ===== # <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> # ===== Cara 2: Vite + Vue (REKOMENDASI) ===== npm create vue@latest # Ikuti wizard: # ✔ Project name: my-vue-app # ✔ Add TypeScript? No / Yes # ✔ Add Vue Router? Yes # ✔ Add Pinia? Yes # ✔ Add Vitest? No cd my-vue-app npm install npm run dev # Jalankan development server # Output: # VITE v5.x.x ready in 300 ms # ➜ Local: http://localhost:5173/ # ===== Cara 3: Vue CLI (alternatif) ===== npm install -g @vue/cli vue create my-project cd my-project npm run serve
┌─────────────────────────────────────────────────────────────────┐ │ EKOSISTEM VUE.JS │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌────────────────────┐ │ │ │ Vue Router │ │ Pinia │ │ Vite │ │ │ │ (Routing) │ │ (State Mgmt) │ │ (Build Tool) │ │ │ └──────┬───────┘ └──────┬───────┘ └────────┬───────────┘ │ │ │ │ │ │ │ └──────────────────┼─────────────────────┘ │ │ ▼ │ │ ┌──────────────────┐ │ │ │ Vue.js Core │ ← Reactivity System │ │ │ (Vue 3.x) │ Template Compiler │ │ └────────┬─────────┘ Virtual DOM │ │ ▼ │ │ ┌──────────────────┐ │ │ │ Single File │ ← .vue files │ │ │ Components (SFC)│ <template> + │ │ └──────────────────┘ <script setup> + │ │ <style scoped> │ └─────────────────────────────────────────────────────────────────┘
2. Vue Instance & Composition API
Vue 3 memperkenalkan Composition API sebagai alternatif dari Options API yang lebih lama. Composition API menggunakan fungsi setup() atau <script setup> untuk mendefinisikan logika komponen.
Vue Instance Pertama (CDN)
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<title>Vue.js Pertama</title>
</head>
<body>
<div id="app">
<h1>{{ pesan }}</h1>
<p>Nama: {{ nama }}</p>
<button @click="ubahNama">Ubah Nama</button>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp, ref } = Vue;
createApp({
setup() {
const pesan = ref('Halo, Vue.js!');
const nama = ref('Budi');
function ubahNama() {
nama.value = nama.value === 'Budi' ? 'Ani' : 'Budi';
}
return { pesan, nama, ubahNama };
}
}).mount('#app');
</script>
</body>
</html>
Composition API dengan <script setup>
<template>
<div class="counter">
<h2>Penghitung: {{ count }}</h2>
<p>Genap/Ganjil: {{ isEven ? 'Genap' : 'Ganjil' }}</p>
<button @click="increment">Tambah (+)</button>
<button @click="decrement">Kurang (-)</button>
<button @click="reset">Reset</button>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
// Reactive state dengan ref()
const count = ref(0);
// Computed property
const isEven = computed(() => count.value % 2 === 0);
// Methods
function increment() {
count.value++;
}
function decrement() {
count.value--;
}
function reset() {
count.value = 0;
}
</script>
<style scoped>
.counter {
text-align: center;
padding: 20px;
}
button {
margin: 0 5px;
padding: 8px 16px;
cursor: pointer;
}
</style>
Reactivity: ref() vs reactive()
<script setup>
import { ref, reactive, toRefs } from 'vue';
// ===== ref() — Untuk nilai tunggal =====
// Menggunakan .value untuk mengakses/mengubah nilai
const nama = ref('Budi'); // nama.value = 'Budi'
const umur = ref(25); // umur.value = 25
const isActive = ref(true); // isActive.value = true
// Di template, .value TIDAK perlu ditulis
// <template>{{ nama }}</template> (bukan {{ nama.value }})
// ===== reactive() — Untuk object =====
// Tidak perlu .value, langsung akses property
const user = reactive({
name: 'Budi Santoso',
age: 25,
address: {
city: 'Jakarta',
province: 'DKI Jakarta'
}
});
// Mengubah reactive object langsung
user.name = 'Ani Wijaya';
user.address.city = 'Bandung';
// JANGAN destructure reactive — hilang reaktivitas!
// const { name, age } = user; // ❌ name dan age TIDAK reaktif
// Gunakan toRefs jika perlu destructure
const { name, age } = toRefs(user); // ✅ name.value dan age.value reaktif
</script>
Gunakan ref() untuk nilai tunggal (string, number, boolean) dan saat Anda ingin me-replace seluruh nilai. Gunakan reactive() untuk object dan array yang kompleks. Secara umum, ref lebih fleksibel dan bisa digunakan di mana saja, termasuk sebagai argumen fungsi.
3. Template Syntax
Vue menggunakan sintaks berbasis HTML untuk mengikat data ke DOM. Ini termasuk text interpolation, expressions, dan HTML binding.
<template>
<div>
<!-- ===== TEXT INTERPOLATION ===== -->
<h1>{{ judul }}</h1>
<p>Halo, {{ nama.toUpperCase() }}!</p>
<p>{{ jumlah > 10 ? 'Banyak' : 'Sedikit' }}</p>
<!-- ===== EXPRESSIONS ===== -->
<p>Total: {{ harga * jumlah }}</p>
<p>Nama terbalik: {{ nama.split('').reverse().join('') }}</p>
<!-- ===== RAW HTML (v-html) ===== -->
<!-- HATI-HATI: bisa XSS jika input dari user! -->
<div v-html="kontenHtml"></div>
<!-- ===== ATTRIBUTE BINDING (v-bind / :) ===== -->
<img :src="gambarUrl" :alt="gambarAlt">
<a :href="linkTujuan" :class="{ aktif: isActive }">Klik</a>
<button :disabled="isLoading">
{{ isLoading ? 'Loading...' : 'Simpan' }}
</button>
<!-- ===== DYNAMIC ATTRIBUTE ===== -->
<div :[attrName]="nilai">Dynamic attr</div>
<!-- attrName = "id" → <div id="nilai"> -->
<!-- ===== CLASS BINDING ===== -->
<div :class="{ aktif: isActive, error: hasError }">
Object syntax
</div>
<div :class="[isActive ? 'aktif' : '', 'base-class']">
Array syntax
</div>
<!-- ===== STYLE BINDING ===== -->
<div :style="{ color: warnaText, fontSize: ukuran + 'px' }">
Inline style
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const judul = ref('Belajar Vue.js');
const nama = ref('BeebaneLabs');
const jumlah = ref(5);
const harga = ref(150000);
const kontenHtml = ref('<strong>Teks tebal</strong>');
const gambarUrl = ref('/images/vue-logo.png');
const gambarAlt = ref('Logo Vue.js');
const linkTujuan = ref('https://vuejs.org');
const isActive = ref(true);
const hasError = ref(false);
const isLoading = ref(false);
const attrName = ref('id');
const nilai = ref('app-container');
const warnaText = ref('#42b883');
const ukuran = ref(18);
</script>
4. Directives
Directives adalah atribut khusus yang diawali v-. Directives memberikan fungsionalitas reaktif ke DOM — conditional rendering, list rendering, event handling, dan two-way binding.
v-if, v-else-if, v-else (Conditional Rendering)
<template>
<div>
<!-- v-if: elemen benar-benar ditambah/dihapus dari DOM -->
<div v-if="isLoggedIn">
<p>Selamat datang, {{ user.name }}!</p>
<button @click="logout">Logout</button>
</div>
<!-- v-else-if dan v-else -->
<div v-else-if="isLoading">
<p>⏳ Memuat data...</p>
</div>
<div v-else>
<p>Silakan login untuk melanjutkan.</p>
<button @click="login">Login</button>
</div>
<!-- v-show: elemen selalu ada di DOM, hanya display:none -->
<div v-show="showNotifikasi">
<p>🔔 Ada pesan baru!</p>
</div>
<!-- Perbedaan: v-if vs v-show -->
<!-- v-if → cocok untuk kondisi jarang berubah (hemat DOM) -->
<!-- v-show → cocok untuk toggle cepat (hemat render ulang) -->
</div>
</template>
<script setup>
import { ref } from 'vue';
const isLoggedIn = ref(false);
const isLoading = ref(false);
const showNotifikasi = ref(true);
const user = ref({ name: 'Budi' });
function login() { isLoggedIn.value = true; }
function logout() { isLoggedIn.value = false; }
</script>
v-for (List Rendering)
<template>
<div>
<!-- v-for dengan array -->
<ul>
<li v-for="(buah, index) in buahList" :key="buah.id">
{{ index + 1 }}. {{ buah.nama }} - Rp {{ buah.harga.toLocaleString() }}
</li>
</ul>
<!-- v-for dengan object -->
<div v-for="(nilai, kunci, index) in profil" :key="kunci">
<strong>{{ index }}. {{ kunci }}:</strong> {{ nilai }}
</div>
<!-- v-for dengan range -->
<span v-for="n in 5" :key="n">{{ n }} </span>
<!-- Output: 1 2 3 4 5 -->
<!-- v-for + v-if (HATI-HATI!) -->
<!-- Vue 3: v-if punya prioritas lebih tinggi dari v-for -->
<li v-for="item in items" :key="item.id" v-if="item.aktif">
{{ item.nama }}
</li>
<!-- Lebih baik gunakan computed property untuk filter -->
<li v-for="item in activeItems" :key="item.id">
{{ item.nama }}
</li>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const buahList = ref([
{ id: 1, nama: 'Apel', harga: 15000 },
{ id: 2, nama: 'Mangga', harga: 20000 },
{ id: 3, nama: 'Jeruk', harga: 12000 },
{ id: 4, nama: 'Pisang', harga: 8000 }
]);
const profil = ref({
nama: 'Budi Santoso',
email: 'budi@beebane.com',
kota: 'Jakarta'
});
const items = ref([
{ id: 1, nama: 'Item A', aktif: true },
{ id: 2, nama: 'Item B', aktif: false },
{ id: 3, nama: 'Item C', aktif: true }
]);
// Filter dengan computed property (lebih baik dari v-if di dalam v-for)
const activeItems = computed(() =>
items.value.filter(item => item.aktif)
);
</script>
v-on (@) & v-model
<template>
<div>
<!-- v-on / @ untuk event handling -->
<button @click="handleClick">Klik Saya</button>
<button @click="handleClick($event)">Dengan Event Object</button>
<button @click.prevent="handleSubmit">Prevent Default</button>
<button @click.once="hanyaSekali">Hanya Sekali</button>
<!-- Event modifiers -->
<!-- .stop → event.stopPropagation() -->
<!-- .prevent → event.preventDefault() -->
<!-- .once → handler hanya jalan sekali -->
<!-- .capture → gunakan capture phase -->
<!-- .self → hanya trigger jika event dari elemen ini -->
<!-- Key modifiers -->
<input @keyup.enter="submitForm">
<input @keyup.esc="cancel">
<input @keyup.ctrl.s="save">
<!-- v-model: Two-way binding -->
<input v-model="nama" placeholder="Masukkan nama">
<p>Nama: {{ nama }}</p>
<!-- v-model dengan textarea -->
<textarea v-model="bio" placeholder="Bio"></textarea>
<!-- v-model dengan checkbox -->
<input type="checkbox" v-model="setuju" id="agree">
<label for="agree">Saya setuju</label>
<!-- v-model dengan select -->
<select v-model="kota">
<option disabled value="">Pilih kota</option>
<option>Jakarta</option>
<option>Bandung</option>
<option>Surabaya</option>
</select>
<!-- v-model dengan radio -->
<input type="radio" v-model="gender" value="L"> Laki-laki
<input type="radio" v-model="gender" value="P"> Perempuan
<!-- v-model modifiers -->
<input v-model.trim="search"> <!-- Trim otomatis -->
<input v-model.number="umur"> <!-- Konversi ke number -->
<input v-model.lazy="query"> <!-- Update saat blur, bukan saat ketik -->
</div>
</template>
<script setup>
import { ref } from 'vue';
const nama = ref('');
const bio = ref('');
const setuju = ref(false);
const kota = ref('');
const gender = ref('');
const search = ref('');
const umur = ref(0);
const query = ref('');
function handleClick(event) {
console.log('Diklik!', event.target);
}
function handleSubmit() { console.log('Submit!'); }
function hanyaSekali() { console.log('Hanya jalan sekali!'); }
function submitForm() { console.log('Enter ditekan!'); }
function cancel() { console.log('Escape ditekan!'); }
function save() { console.log('Ctrl+S ditekan!'); }
</script>
5. Computed Properties & Watchers
Computed properties adalah nilai yang dihitung otomatis dari data reaktif. Watchers memungkinkan Anda menjalankan side effect ketika data berubah.
<template>
<div>
<h2>Belanja Online</h2>
<input v-model="searchQuery" placeholder="Cari barang...">
<div v-for="item in filteredItems" :key="item.id">
<span>{{ item.name }} - Rp {{ item.price.toLocaleString() }}</span>
<button @click="addToCart(item)">+ Keranjang</button>
</div>
<hr>
<h3>Keranjang ({{ cartCount }} item)</h3>
<ul>
<li v-for="item in cart" :key="item.id">
{{ item.name }} x{{ item.qty }}
</li>
</ul>
<p><strong>Total: Rp {{ cartTotal.toLocaleString() }}</strong></p>
<p>Diskon: {{ discountText }}</p>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const searchQuery = ref('');
const items = ref([
{ id: 1, name: 'Laptop ASUS', price: 12000000 },
{ id: 2, name: 'Keyboard Mekanik', price: 850000 },
{ id: 3, name: 'Mouse Logitech', price: 350000 },
{ id: 4, name: 'Monitor 27"', price: 4500000 }
]);
const cart = ref([]);
// Computed: Filter items berdasarkan search
const filteredItems = computed(() => {
const query = searchQuery.value.toLowerCase();
return items.value.filter(item =>
item.name.toLowerCase().includes(query)
);
});
// Computed: Hitung jumlah item di keranjang
const cartCount = computed(() =>
cart.value.reduce((sum, item) => sum + item.qty, 0)
);
// Computed: Hitung total harga
const cartTotal = computed(() =>
cart.value.reduce((sum, item) => sum + item.price * item.qty, 0)
);
// Computed: Teks diskon
const discountText = computed(() => {
const total = cartTotal.value;
if (total >= 10000000) return 'Diskon 15%! 🎉';
if (total >= 5000000) return 'Diskon 10%!';
if (total >= 1000000) return 'Diskon 5%';
return 'Belum ada diskon';
});
function addToCart(item) {
const existing = cart.value.find(c => c.id === item.id);
if (existing) {
existing.qty++;
} else {
cart.value.push({ ...item, qty: 1 });
}
}
</script>
Watchers
<script setup>
import { ref, watch, watchEffect } from 'vue';
const query = ref('');
const results = ref([]);
const isLoading = ref(false);
const page = ref(1);
// ===== watch() — Mengawasi satu ref =====
watch(query, (newVal, oldVal) => {
console.log(`Query berubah: "${oldVal}" → "${newVal}"`);
// Reset halaman saat query berubah
page.value = 1;
// Panggil API jika query tidak kosong
if (newVal.length >= 3) {
searchAPI(newVal);
}
});
// ===== watch() dengan immediate =====
watch(page, (newPage) => {
console.log(`Halaman: ${newPage}`);
searchAPI(query.value);
}, { immediate: false }); // immediate: true → jalankan saat mount
// ===== watch() dengan deep =====
const user = ref({ name: 'Budi', settings: { theme: 'dark' } });
watch(user, (newVal) => {
console.log('User berubah:', JSON.stringify(newVal));
}, { deep: true }); // Deep watch — mendeteksi perubahan nested
// ===== watchEffect() — Auto-track dependencies =====
watchEffect(() => {
// Otomatis mengawasi semua ref yang digunakan di dalam
console.log(`Mencari: ${query.value}, Halaman: ${page.value}`);
// Tidak perlu menyebutkan dependency secara eksplisit
});
// ===== Contoh: Debounced API search =====
let timeout;
watch(query, (newVal) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
if (newVal.length >= 3) {
searchAPI(newVal);
}
}, 300); // Tunggu 300ms setelah user berhenti mengetik
});
async function searchAPI(q) {
isLoading.value = true;
// Simulasi API call
await new Promise(r => setTimeout(r, 500));
results.value = [`Hasil untuk "${q}" - 1`, `Hasil untuk "${q}" - 2`];
isLoading.value = false;
}
</script>
Gunakan computed untuk menghitung nilai baru dari data reaktif (harus mengembalikan nilai). Gunakan watch untuk menjalankan side effect saat data berubah (API call, logging, localStorage). Jika logika Anda hanya menghitung nilai, gunakan computed — lebih efisien karena caching otomatis.
6. Komponen Vue
Single File Component (SFC) adalah fitur unggulan Vue — menggabungkan template, script, dan style dalam satu file .vue. Ini membuat komponen lebih mudah dipelihara dan diorganisir.
Anatomi SFC
<!-- File: src/components/UserCard.vue -->
<template>
<!-- Template: Struktur HTML komponen (WAJIB ada) -->
<div class="user-card">
<img :src="avatar" :alt="name" class="avatar">
<h3>{{ name }}</h3>
<p class="email">{{ email }}</p>
<span :class="['badge', role]">{{ role }}</span>
<button @click="$emit('select', { id, name })">
Pilih
</button>
</div>
</template>
<script setup>
// Script: Logika komponen (Composition API)
// Props dan emits didefinisikan dengan defineProps / defineEmits
const props = defineProps({
id: { type: Number, required: true },
name: { type: String, required: true },
email: { type: String, default: '' },
avatar: { type: String, default: '/images/default-avatar.png' },
role: {
type: String,
default: 'user',
validator: (value) => ['admin', 'editor', 'user'].includes(value)
}
});
const emit = defineEmits(['select', 'delete']);
</script>
<style scoped>
/* Style: CSS khusus komponen */
/* scoped = hanya berlaku di komponen ini */
.user-card {
border: 1px solid #ddd;
border-radius: 12px;
padding: 20px;
text-align: center;
}
.avatar {
width: 80px;
height: 80px;
border-radius: 50%;
}
.badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.badge.admin { background: #ff6b6b; color: white; }
.badge.editor { background: #4ecdc4; color: white; }
.badge.user { background: #95afc0; color: white; }
</style>
Menggunakan Komponen
<!-- File: src/App.vue -->
<template>
<div id="app">
<h1>Daftar Pengguna</h1>
<!-- Menggunakan UserCard component -->
<div class="user-grid">
<UserCard
v-for="user in users"
:key="user.id"
:id="user.id"
:name="user.name"
:email="user.email"
:avatar="user.avatar"
:role="user.role"
@select="handleSelect"
/>
</div>
<!-- Komponen tanpa props -->
<Footer />
</div>
</template>
<script setup>
import UserCard from './components/UserCard.vue';
import Footer from './components/Footer.vue';
import { ref } from 'vue';
const users = ref([
{
id: 1,
name: 'Budi Santoso',
email: 'budi@beebane.com',
avatar: '/images/budi.jpg',
role: 'admin'
},
{
id: 2,
name: 'Ani Wijaya',
email: 'ani@beebane.com',
avatar: '/images/ani.jpg',
role: 'editor'
},
{
id: 3,
name: 'Citra Dewi',
email: 'citra@beebane.com',
avatar: '',
role: 'user'
}
]);
function handleSelect(user) {
console.log('User dipilih:', user);
}
</script>
7. Props & Events
Props adalah cara mengirim data dari parent ke child component (one-way flow). Events adalah cara child mengirim sinyal ke parent. Ini membentuk pola komunikasi yang terstruktur dan mudah di-debug.
<!-- Parent: TodoApp.vue -->
<template>
<div class="todo-app">
<h1>📝 Todo List</h1>
<!-- Kirim data ke child melalui props -->
<!-- Tangkap event dari child -->
<TodoInput @add="addTodo" />
<TodoList
:todos="todos"
:filter="currentFilter"
@toggle="toggleTodo"
@delete="deleteTodo"
/>
<TodoFilter
:current="currentFilter"
:counts="filterCounts"
@change="currentFilter = $event"
/>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import TodoInput from './TodoInput.vue';
import TodoList from './TodoList.vue';
import TodoFilter from './TodoFilter.vue';
const todos = ref([
{ id: 1, text: 'Belajar Vue.js', done: false },
{ id: 2, text: 'Buat proyek', done: false },
{ id: 3, text: 'Baca dokumentasi', done: true }
]);
const currentFilter = ref('all');
let nextId = 4;
const filterCounts = computed(() => ({
all: todos.value.length,
active: todos.value.filter(t => !t.done).length,
done: todos.value.filter(t => t.done).length
}));
function addTodo(text) {
todos.value.push({ id: nextId++, text, done: false });
}
function toggleTodo(id) {
const todo = todos.value.find(t => t.id === id);
if (todo) todo.done = !todo.done;
}
function deleteTodo(id) {
todos.value = todos.value.filter(t => t.id !== id);
}
</script>
<!-- Child: TodoList.vue -->
<template>
<ul class="todo-list">
<li
v-for="todo in filteredTodos"
:key="todo.id"
:class="{ done: todo.done }"
>
<input
type="checkbox"
:checked="todo.done"
@change="$emit('toggle', todo.id)"
>
<span>{{ todo.text }}</span>
<button @click="$emit('delete', todo.id)">🗑️</button>
</li>
<li v-if="filteredTodos.length === 0" class="empty">
Tidak ada todo 🎉
</li>
</ul>
</template>
<script setup>
import { computed } from 'vue';
// Terima props dari parent
const props = defineProps({
todos: { type: Array, required: true },
filter: { type: String, default: 'all' }
});
// Emit events ke parent
const emit = defineEmits(['toggle', 'delete']);
// Filter todo berdasarkan filter yang dipilih
const filteredTodos = computed(() => {
switch (props.filter) {
case 'active': return props.todos.filter(t => !t.done);
case 'done': return props.todos.filter(t => t.done);
default: return props.todos;
}
});
</script>
<style scoped>
.todo-list { list-style: none; padding: 0; }
.todo-list li {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border-bottom: 1px solid #eee;
}
.todo-list li.done span {
text-decoration: line-through;
opacity: 0.6;
}
.todo-list li.empty {
justify-content: center;
color: #888;
font-style: italic;
}
</style>
Slots — Content Distribution
<!-- Modal.vue — Komponen dengan slot -->
<template>
<div class="modal-overlay" @click.self="$emit('close')">
<div class="modal">
<div class="modal-header">
<h2><slot name="title">Judul Default</slot></h2>
<button @click="$emit('close')">×</button>
</div>
<div class="modal-body">
<!-- Default slot -->
<slot>Konten default jika tidak ada slot</slot>
</div>
<div class="modal-footer">
<slot name="footer">
<button @click="$emit('close')">Tutup</button>
</slot>
</div>
</div>
</div>
</template>
<!-- Menggunakan Modal dengan slots -->
<Modal v-if="showModal" @close="showModal = false">
<template #title>
Konfirmasi Hapus
</template>
<p>Apakah Anda yakin ingin menghapus item ini?</p>
<template #footer>
<button @click="showModal = false">Batal</button>
<button @click="confirmDelete">Hapus</button>
</template>
</Modal>
8. Vue Router
Vue Router adalah library routing resmi untuk Vue.js. Vue Router memungkinkan Anda membangun SPA (Single Page Application) dengan navigasi tanpa reload halaman.
Instalasi & Konfigurasi
# Instal Vue Router 4 (untuk Vue 3) npm install vue-router@4
import { createRouter, createWebHistory } from 'vue-router';
// Import komponen halaman
import Home from '../views/Home.vue';
import About from '../views/About.vue';
import Products from '../views/Products.vue';
import ProductDetail from '../views/ProductDetail.vue';
import NotFound from '../views/NotFound.vue';
// Definisi routes
const routes = [
{
path: '/',
name: 'Home',
component: Home,
meta: { title: 'Beranda' }
},
{
path: '/about',
name: 'About',
component: About,
meta: { title: 'Tentang Kami' }
},
{
path: '/products',
name: 'Products',
component: Products,
meta: { title: 'Produk' }
},
{
// Dynamic route dengan parameter
path: '/products/:id',
name: 'ProductDetail',
component: ProductDetail,
props: true, // Pass params sebagai props
meta: { title: 'Detail Produk' }
},
{
// Lazy loading — komponen di-load saat diakses
path: '/dashboard',
name: 'Dashboard',
component: () => import('../views/Dashboard.vue'),
meta: { title: 'Dashboard', requiresAuth: true }
},
{
// Catch-all 404
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFound
}
];
// Buat router instance
const router = createRouter({
history: createWebHistory(), // Mode HTML5 History
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition;
return { top: 0 };
}
});
// Navigation Guard — cek autentikasi
router.beforeEach((to, from, next) => {
document.title = to.meta.title
? `${to.meta.title} | BeebaneLabs`
: 'BeebaneLabs';
if (to.meta.requiresAuth) {
const isLoggedIn = localStorage.getItem('token');
if (!isLoggedIn) {
next({ name: 'Home' });
return;
}
}
next();
});
export default router;
Menggunakan Router di Komponen
<!-- App.vue -->
<template>
<div id="app">
<nav>
<!-- RouterLink: navigasi tanpa reload halaman -->
<router-link to="/">Beranda</router-link>
<router-link to="/about">Tentang</router-link>
<router-link to="/products">Produk</router-link>
<router-link :to="{ name: 'Dashboard' }">Dashboard</router-link>
</nav>
<!-- RouterView: tempat komponen halaman dirender -->
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</template>
<!-- ProductDetail.vue — Menggunakan route params -->
<template>
<div>
<h1>Detail Produk #{{ id }}</h1>
<!-- id berasal dari props: true di router -->
<button @click="goBack">← Kembali</button>
</div>
</template>
<script setup>
import { useRouter, useRoute } from 'vue-router';
// useRoute: mengakses informasi route saat ini
const route = useRoute();
const router = useRouter();
// Props dari router (karena props: true)
defineProps({ id: [String, Number] });
// Atau ambil dari route langsung:
// const id = route.params.id;
// const query = route.query.search;
function goBack() {
router.back(); // Navigasi kembali
// Atau: router.push('/products');
// Atau: router.push({ name: 'Products' });
}
</script>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
Browser URL Vue Router Komponen Halaman
│ │ │
│ /products/42 │ │
│ ──────────────────────► │ │
│ │ Match route: │
│ │ /products/:id │
│ │ ──────────────────────► │
│ │ │ ProductDetail
│ │ │ { id: 42 }
│ │ ◄──── Component ────── │
│ ◄──── render ──────── │ │
│ │ │
│ Navigasi ke /about │ │
│ ──────────────────────► │ beforeEach guard ✓ │
│ │ match: /about │
│ │ ──────────────────────► │
│ │ │ About.vue
│ ◄──── render ──────── │ ◄──── Component ────── │
9. Studi Kasus: Aplikasi Todo Lengkap
Mari kita gabungkan semua konsep yang telah dipelajari ke dalam aplikasi Todo yang lebih lengkap — menggunakan localStorage untuk persistensi data.
<template>
<div class="todo-app">
<h1>📝 Aplikasi Todo</h1>
<!-- Input todo baru -->
<form @submit.prevent="addTodo" class="todo-form">
<input
v-model="newTodo"
type="text"
placeholder="Tambah todo baru..."
class="todo-input"
>
<button type="submit" :disabled="!newTodo.trim()">
Tambah
</button>
</form>
<!-- Filter buttons -->
<div class="filters">
<button
v-for="f in filters"
:key="f.value"
:class="{ active: currentFilter === f.value }"
@click="currentFilter = f.value"
>
{{ f.label }} ({{ f.count }})
</button>
</div>
<!-- Daftar todo -->
<transition-group name="list" tag="ul" class="todo-list">
<li
v-for="todo in filteredTodos"
:key="todo.id"
:class="{ done: todo.done }"
>
<label class="todo-label">
<input
type="checkbox"
v-model="todo.done"
>
<span v-if="editingId !== todo.id">{{ todo.text }}</span>
<input
v-else
v-model="editText"
@keyup.enter="saveEdit(todo)"
@keyup.escape="cancelEdit"
@blur="saveEdit(todo)"
class="edit-input"
ref="editInput"
>
</label>
<div class="todo-actions">
<button @click="startEdit(todo)" title="Edit">✏️</button>
<button @click="deleteTodo(todo.id)" title="Hapus">🗑️</button>
</div>
</li>
</transition-group>
<p v-if="filteredTodos.length === 0" class="empty">
Tidak ada todo di kategori ini 🎉
</p>
<!-- Statistik -->
<div class="stats">
<span>Total: {{ todos.length }}</span>
<span>Selesai: {{ doneCount }}</span>
<span>Sisa: {{ activeCount }}</span>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
// ===== STATE =====
const newTodo = ref('');
const currentFilter = ref('all');
const editingId = ref(null);
const editText = ref('');
const editInput = ref(null);
// Load dari localStorage
const savedTodos = localStorage.getItem('todos');
const todos = ref(savedTodos ? JSON.parse(savedTodos) : [
{ id: 1, text: 'Belajar Vue.js Composition API', done: true },
{ id: 2, text: 'Buat proyek portfolio', done: false },
{ id: 3, text: 'Deploy ke Vercel', done: false }
]);
let nextId = todos.value.length
? Math.max(...todos.value.map(t => t.id)) + 1
: 1;
// ===== COMPUTED =====
const doneCount = computed(() => todos.value.filter(t => t.done).length);
const activeCount = computed(() => todos.value.filter(t => !t.done).length);
const filters = computed(() => [
{ value: 'all', label: 'Semua', count: todos.value.length },
{ value: 'active', label: 'Aktif', count: activeCount.value },
{ value: 'done', label: 'Selesai', count: doneCount.value }
]);
const filteredTodos = computed(() => {
switch (currentFilter.value) {
case 'active': return todos.value.filter(t => !t.done);
case 'done': return todos.value.filter(t => t.done);
default: return todos.value;
}
});
// ===== WATCHERS =====
// Simpan ke localStorage setiap kali todos berubah
watch(todos, (newTodos) => {
localStorage.setItem('todos', JSON.stringify(newTodos));
}, { deep: true });
// ===== METHODS =====
function addTodo() {
const text = newTodo.value.trim();
if (!text) return;
todos.value.push({ id: nextId++, text, done: false });
newTodo.value = '';
}
function deleteTodo(id) {
todos.value = todos.value.filter(t => t.id !== id);
}
async function startEdit(todo) {
editingId.value = todo.id;
editText.value = todo.text;
await nextTick();
editInput.value?.focus();
}
function saveEdit(todo) {
const text = editText.value.trim();
if (text) {
todo.text = text;
}
editingId.value = null;
}
function cancelEdit() {
editingId.value = null;
}
</script>
<style scoped>
.todo-app { max-width: 600px; margin: 0 auto; padding: 20px; }
.todo-form { display: flex; gap: 8px; margin-bottom: 20px; }
.todo-input { flex: 1; padding: 10px; border-radius: 8px; border: 1px solid #ccc; }
.todo-list { list-style: none; padding: 0; }
.todo-list li {
display: flex; justify-content: space-between; align-items: center;
padding: 12px; border-bottom: 1px solid #eee;
}
.todo-list li.done span { text-decoration: line-through; opacity: 0.5; }
.filters { display: flex; gap: 8px; margin-bottom: 16px; }
.filters button.active { background: #42b883; color: white; }
.stats { display: flex; gap: 20px; margin-top: 16px; color: #888; }
.list-enter-active, .list-leave-active { transition: all 0.3s ease; }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(30px); }
</style>
10. Quiz: Uji Pemahamanmu!
Setelah membaca tutorial di atas, jawablah 5 pertanyaan berikut untuk menguji pemahamanmu tentang Vue.js: