feat: Complete fleet — 94 skills across 10+ domains
Pulled ALL skills from 15 source repositories: - anthropics/skills: 16 (docs, design, MCP, testing) - obra/superpowers: 14 (TDD, debugging, agents, planning) - coreyhaines31/marketingskills: 25 (marketing, CRO, SEO, growth) - better-auth/skills: 5 (auth patterns) - vercel-labs/agent-skills: 5 (React, design, Vercel) - antfu/skills: 16 (Vue, Vite, Vitest, pnpm, Turborepo) - Plus 13 individual skills from various repos Mosaic Stack is not limited to coding — the Orchestrator and subagents serve coding, business, design, marketing, writing, logistics, analysis, and more. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
---
|
||||
title: Use Class-based Animations for Non-Enter/Leave Effects
|
||||
impact: LOW
|
||||
impactDescription: Class-based animations are simpler and more performant for elements that remain in the DOM
|
||||
type: best-practice
|
||||
tags: [vue3, animation, css, class-binding, state]
|
||||
---
|
||||
|
||||
# Use Class-based Animations for Non-Enter/Leave Effects
|
||||
|
||||
**Impact: LOW** - For animations on elements that are not entering or leaving the DOM, use CSS class-based animations triggered by Vue's reactive state. This is simpler than `<Transition>` and more appropriate for feedback animations like shake, pulse, or highlight effects.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use class-based animations for elements staying in the DOM
|
||||
- [ ] Use `<Transition>` only for enter/leave animations
|
||||
- [ ] Combine CSS animations with Vue's class bindings (`:class`)
|
||||
- [ ] Consider using `setTimeout` to auto-remove animation classes
|
||||
|
||||
**When to Use Class-based Animations:**
|
||||
- User feedback (shake on error, pulse on success)
|
||||
- Attention-grabbing effects (highlight changes)
|
||||
- Hover/focus states that need more than CSS transitions
|
||||
- Any animation where the element stays mounted
|
||||
|
||||
**When to Use Transition Component:**
|
||||
- Elements entering/leaving the DOM (v-if/v-show)
|
||||
- Route transitions
|
||||
- List item additions/removals
|
||||
|
||||
## Basic Pattern
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div :class="{ shake: showError }">
|
||||
<button @click="submitForm">Submit</button>
|
||||
<span v-if="showError">This feature is disabled!</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const showError = ref(false)
|
||||
|
||||
function submitForm() {
|
||||
if (!isValid()) {
|
||||
// Trigger shake animation
|
||||
showError.value = true
|
||||
|
||||
// Auto-remove class after animation completes
|
||||
setTimeout(() => {
|
||||
showError.value = false
|
||||
}, 820) // Match animation duration
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.shake {
|
||||
animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
|
||||
transform: translate3d(0, 0, 0); /* Enable GPU acceleration */
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
10%, 90% { transform: translate3d(-1px, 0, 0); }
|
||||
20%, 80% { transform: translate3d(2px, 0, 0); }
|
||||
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
|
||||
40%, 60% { transform: translate3d(4px, 0, 0); }
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Common Animation Patterns
|
||||
|
||||
### Pulse on Success
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<button
|
||||
@click="save"
|
||||
:class="{ pulse: saved }"
|
||||
>
|
||||
{{ saved ? 'Saved!' : 'Save' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const saved = ref(false)
|
||||
|
||||
async function save() {
|
||||
await saveData()
|
||||
saved.value = true
|
||||
setTimeout(() => saved.value = false, 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.pulse {
|
||||
animation: pulse 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Highlight on Change
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
:class="{ highlight: justUpdated }"
|
||||
>
|
||||
Value: {{ value }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const value = ref(0)
|
||||
const justUpdated = ref(false)
|
||||
|
||||
watch(value, () => {
|
||||
justUpdated.value = true
|
||||
setTimeout(() => justUpdated.value = false, 1000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.highlight {
|
||||
animation: highlight 1s ease-out;
|
||||
}
|
||||
|
||||
@keyframes highlight {
|
||||
0% { background-color: yellow; }
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Bounce Attention
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
:class="{ bounce: needsAttention }"
|
||||
@animationend="needsAttention = false"
|
||||
>
|
||||
<BellIcon />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const needsAttention = ref(false)
|
||||
|
||||
function notifyUser() {
|
||||
needsAttention.value = true
|
||||
// No setTimeout needed - using animationend event
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bounce {
|
||||
animation: bounce 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Using animationend Event
|
||||
|
||||
Instead of `setTimeout`, use the `animationend` event for cleaner code:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
:class="{ animate: isAnimating }"
|
||||
@animationend="isAnimating = false"
|
||||
>
|
||||
Content
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const isAnimating = ref(false)
|
||||
|
||||
function triggerAnimation() {
|
||||
isAnimating.value = true
|
||||
// Class is automatically removed when animation ends
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Composable for Reusable Animations
|
||||
|
||||
```javascript
|
||||
// composables/useAnimation.js
|
||||
import { ref } from 'vue'
|
||||
|
||||
export function useAnimation(duration = 500) {
|
||||
const isAnimating = ref(false)
|
||||
|
||||
function trigger() {
|
||||
isAnimating.value = true
|
||||
setTimeout(() => {
|
||||
isAnimating.value = false
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return {
|
||||
isAnimating,
|
||||
trigger
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useAnimation } from '@/composables/useAnimation'
|
||||
|
||||
const shake = useAnimation(820)
|
||||
const pulse = useAnimation(500)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:class="{ shake: shake.isAnimating.value }"
|
||||
@click="shake.trigger()"
|
||||
>
|
||||
Shake me
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="{ pulse: pulse.isAnimating.value }"
|
||||
@click="pulse.trigger()"
|
||||
>
|
||||
Pulse me
|
||||
</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Animation Techniques - Class-based Animations](https://vuejs.org/guide/extras/animation.html#class-based-animations)
|
||||
- [CSS Animations MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Animations)
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
title: Use Key Attribute to Force Re-render Animations
|
||||
impact: MEDIUM
|
||||
impactDescription: Without key attributes, Vue reuses DOM elements and animation libraries like AutoAnimate cannot detect changes to animate
|
||||
type: gotcha
|
||||
tags: [vue3, animation, key, autoanimate, rerender, dom]
|
||||
---
|
||||
|
||||
# Use Key Attribute to Force Re-render Animations
|
||||
|
||||
**Impact: MEDIUM** - Vue optimizes performance by reusing DOM elements when possible. However, this optimization can prevent animation libraries (like AutoAnimate) from detecting changes, because the element is updated in place rather than re-created. Adding a `:key` attribute forces Vue to treat changed elements as new, triggering proper animations.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Add `:key` to elements that should animate when their content changes
|
||||
- [ ] Use unique, changing values for keys (not indices)
|
||||
- [ ] For route transitions, add `:key="$route.fullPath"` to `<router-view>`
|
||||
- [ ] Apply `v-auto-animate` to the parent element of keyed children
|
||||
|
||||
**Problematic Code:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- BAD: Text changes but no animation occurs -->
|
||||
<div v-auto-animate>
|
||||
<p>{{ message }}</p> <!-- No key - element is reused -->
|
||||
</div>
|
||||
|
||||
<!-- BAD: Image source changes but no animation -->
|
||||
<div v-auto-animate>
|
||||
<img :src="imageUrl" /> <!-- No key - element is reused -->
|
||||
</div>
|
||||
|
||||
<!-- BAD: Route changes don't animate -->
|
||||
<router-view v-auto-animate /> <!-- No key -->
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const message = ref('Hello')
|
||||
const imageUrl = ref('/images/photo1.jpg')
|
||||
|
||||
// Changing these won't trigger animations because
|
||||
// Vue updates the existing elements rather than replacing them
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct Code:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- GOOD: Key forces re-render, triggering animation -->
|
||||
<div v-auto-animate>
|
||||
<p :key="message">{{ message }}</p>
|
||||
</div>
|
||||
|
||||
<!-- GOOD: Image animates when source changes -->
|
||||
<div v-auto-animate>
|
||||
<img :key="imageUrl" :src="imageUrl" />
|
||||
</div>
|
||||
|
||||
<!-- GOOD: Route changes animate properly -->
|
||||
<router-view :key="$route.fullPath" v-auto-animate />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const message = ref('Hello')
|
||||
const imageUrl = ref('/images/photo1.jpg')
|
||||
|
||||
// Now changing these will trigger animations
|
||||
function updateMessage() {
|
||||
message.value = 'World' // Triggers enter animation for new <p>
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Why This Works
|
||||
|
||||
When Vue sees a `:key` change:
|
||||
1. It considers the old element and new element as different
|
||||
2. The old element is removed (triggering leave animation)
|
||||
3. A new element is created (triggering enter animation)
|
||||
|
||||
Without `:key`:
|
||||
1. Vue sees the same element type in the same position
|
||||
2. It updates the element's properties in place
|
||||
3. No DOM addition/removal occurs, so no animation triggers
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### Animating Text Content Changes
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div v-auto-animate>
|
||||
<h1 :key="title">{{ title }}</h1>
|
||||
<p :key="description">{{ description }}</p>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Animating Dynamic Components
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div v-auto-animate>
|
||||
<component :is="currentComponent" :key="currentComponent" />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Animating Route Transitions
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<div v-auto-animate>
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</div>
|
||||
</router-view>
|
||||
</template>
|
||||
```
|
||||
|
||||
## With Vue's Built-in Transition
|
||||
|
||||
The same principle applies to Vue's `<Transition>` component:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- GOOD: Key triggers transition on content change -->
|
||||
<Transition name="fade" mode="out-in">
|
||||
<p :key="message">{{ message }}</p>
|
||||
</Transition>
|
||||
|
||||
<!-- GOOD: Different keys for conditional content -->
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="isLoading" key="loading">Loading...</div>
|
||||
<div v-else key="content">{{ content }}</div>
|
||||
</Transition>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Caution: Performance Implications
|
||||
|
||||
Using `:key` forces full component re-creation. For frequently changing data:
|
||||
- The entire component tree under the keyed element is destroyed and recreated
|
||||
- Any component state is lost
|
||||
- Consider whether the animation is worth the performance cost
|
||||
|
||||
```vue
|
||||
<!-- Be cautious with complex components -->
|
||||
<ComplexDashboard :key="refreshTrigger" />
|
||||
<!-- This destroys and recreates the entire dashboard! -->
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Animation Techniques](https://vuejs.org/guide/extras/animation.html)
|
||||
- [AutoAnimate with Vue](https://auto-animate.formkit.com/#usage-vue)
|
||||
- [Vue.js v-for with key](https://vuejs.org/guide/essentials/list.html#maintaining-state-with-key)
|
||||
@@ -0,0 +1,295 @@
|
||||
---
|
||||
title: State-driven Animations with CSS Transitions and Style Bindings
|
||||
impact: LOW
|
||||
impactDescription: Combining Vue's reactive style bindings with CSS transitions creates smooth, interactive animations
|
||||
type: best-practice
|
||||
tags: [vue3, animation, css, transition, style-binding, state, interactive]
|
||||
---
|
||||
|
||||
# State-driven Animations with CSS Transitions and Style Bindings
|
||||
|
||||
**Impact: LOW** - For responsive, interactive animations that react to user input or state changes, combine Vue's dynamic style bindings with CSS transitions. This creates smooth animations that interpolate values in real-time based on state.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use `:style` binding for dynamic properties that change frequently
|
||||
- [ ] Add CSS `transition` property to smoothly animate between values
|
||||
- [ ] Consider using `transform` and `opacity` for GPU-accelerated animations
|
||||
- [ ] For complex value interpolation, use watchers with animation libraries
|
||||
|
||||
## Basic Pattern
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
@mousemove="onMousemove"
|
||||
:style="{ backgroundColor: `hsl(${hue}, 80%, 50%)` }"
|
||||
class="interactive-area"
|
||||
>
|
||||
<p>Move your mouse across this div...</p>
|
||||
<p>Hue: {{ hue }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const hue = ref(0)
|
||||
|
||||
function onMousemove(e) {
|
||||
// Map mouse X position to hue (0-360)
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
hue.value = Math.round((e.clientX - rect.left) / rect.width * 360)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.interactive-area {
|
||||
transition: background-color 0.3s ease;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### Following Mouse Position
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
class="container"
|
||||
@mousemove="onMousemove"
|
||||
>
|
||||
<div
|
||||
class="follower"
|
||||
:style="{
|
||||
transform: `translate(${x}px, ${y}px)`
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const x = ref(0)
|
||||
const y = ref(0)
|
||||
|
||||
function onMousemove(e) {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
x.value = e.clientX - rect.left
|
||||
y.value = e.clientY - rect.top
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.follower {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: blue;
|
||||
border-radius: 50%;
|
||||
/* Smooth following with transition */
|
||||
transition: transform 0.1s ease-out;
|
||||
/* Prevent the follower from triggering mousemove */
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Progress Animation
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="progress-container">
|
||||
<div
|
||||
class="progress-bar"
|
||||
:style="{ width: `${progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="progress"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const progress = ref(0)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.progress-container {
|
||||
height: 20px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4CAF50, #8BC34A);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Scroll-based Animation
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
class="hero"
|
||||
:style="{
|
||||
opacity: heroOpacity,
|
||||
transform: `translateY(${scrollOffset}px)`
|
||||
}"
|
||||
>
|
||||
<h1>Scroll Down</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const scrollY = ref(0)
|
||||
|
||||
const heroOpacity = computed(() => {
|
||||
return Math.max(0, 1 - scrollY.value / 300)
|
||||
})
|
||||
|
||||
const scrollOffset = computed(() => {
|
||||
return scrollY.value * 0.5 // Parallax effect
|
||||
})
|
||||
|
||||
function handleScroll() {
|
||||
scrollY.value = window.scrollY
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* Note: No transition for scroll-based animations - they should be instant */
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Color Theme Transition
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
class="app"
|
||||
:style="themeStyles"
|
||||
>
|
||||
<button @click="toggleTheme">Toggle Theme</button>
|
||||
<p>Current theme: {{ isDark ? 'Dark' : 'Light' }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const isDark = ref(false)
|
||||
|
||||
const themeStyles = computed(() => ({
|
||||
'--bg-color': isDark.value ? '#1a1a1a' : '#ffffff',
|
||||
'--text-color': isDark.value ? '#ffffff' : '#1a1a1a',
|
||||
backgroundColor: 'var(--bg-color)',
|
||||
color: 'var(--text-color)'
|
||||
}))
|
||||
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
transition: background-color 0.5s ease, color 0.5s ease;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Advanced: Numerical Tweening with Watchers
|
||||
|
||||
For smooth number animations (counters, stats), use watchers with animation libraries:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<input v-model.number="targetNumber" type="number" />
|
||||
<p class="counter">{{ displayNumber.toFixed(0) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import gsap from 'gsap'
|
||||
|
||||
const targetNumber = ref(0)
|
||||
const tweened = reactive({ value: 0 })
|
||||
|
||||
// Computed for display
|
||||
const displayNumber = computed(() => tweened.value)
|
||||
|
||||
watch(targetNumber, (newValue) => {
|
||||
gsap.to(tweened, {
|
||||
duration: 0.5,
|
||||
value: Number(newValue) || 0,
|
||||
ease: 'power2.out'
|
||||
})
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
```vue
|
||||
<style>
|
||||
/* GOOD: GPU-accelerated properties */
|
||||
.element {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
/* AVOID: Properties that trigger layout recalculation */
|
||||
.element {
|
||||
transition: width 0.3s ease, height 0.3s ease, margin 0.3s ease;
|
||||
}
|
||||
|
||||
/* For high-frequency updates, consider will-change */
|
||||
.frequently-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Animation Techniques - State-driven Animations](https://vuejs.org/guide/extras/animation.html#state-driven-animations)
|
||||
- [CSS Transitions MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Transitions)
|
||||
@@ -0,0 +1,241 @@
|
||||
---
|
||||
title: TransitionGroup Performance with Large Lists and CSS Frameworks
|
||||
impact: MEDIUM
|
||||
impactDescription: TransitionGroup can cause noticeable DOM update lag when animating list changes, especially with CSS frameworks
|
||||
type: gotcha
|
||||
tags: [vue3, transition-group, animation, performance, list, css-framework]
|
||||
---
|
||||
|
||||
# TransitionGroup Performance with Large Lists and CSS Frameworks
|
||||
|
||||
**Impact: MEDIUM** - Vue's `<TransitionGroup>` can experience significant DOM update lag when animating list changes, particularly when:
|
||||
- Using CSS frameworks (Tailwind, Bootstrap, etc.)
|
||||
- Performing array operations like `slice()` that change multiple items
|
||||
- Working with larger lists
|
||||
|
||||
Without TransitionGroup, DOM updates occur instantly. With it, there can be noticeable delay before the UI reflects changes.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] For frequently updated lists, consider if transition animations are necessary
|
||||
- [ ] Use CSS `content-visibility: auto` for long lists to reduce render cost
|
||||
- [ ] Minimize CSS framework classes on list items during transitions
|
||||
- [ ] Consider virtual scrolling for very large animated lists
|
||||
- [ ] Profile with Vue DevTools to identify transition bottlenecks
|
||||
|
||||
**Problematic Pattern:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- Potentially slow with large lists or complex CSS -->
|
||||
<TransitionGroup name="list" tag="ul">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="p-4 m-2 rounded-lg shadow-md bg-gradient-to-r from-blue-500 to-purple-600
|
||||
hover:shadow-lg transition-all duration-300 ease-in-out transform hover:scale-105
|
||||
border border-gray-200 flex items-center justify-between"
|
||||
>
|
||||
{{ item.name }}
|
||||
</li>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const items = ref([/* many items */])
|
||||
|
||||
// Operations like slice can cause visible lag
|
||||
function removeItems() {
|
||||
items.value = items.value.slice(5) // May lag with TransitionGroup
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.list-move,
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
**Optimized Approach:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- Simpler classes, shorter transitions -->
|
||||
<TransitionGroup name="list" tag="ul" class="relative">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="list-item"
|
||||
>
|
||||
{{ item.name }}
|
||||
</li>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const items = ref([/* items */])
|
||||
|
||||
// For large batch operations, consider disabling animations temporarily
|
||||
const isAnimating = ref(true)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Keep transition CSS simple and specific */
|
||||
.list-item {
|
||||
/* Minimal styles during animation */
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.list-move {
|
||||
transition: transform 0.3s ease; /* Shorter duration */
|
||||
}
|
||||
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.list-enter-from,
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
/* Use will-change sparingly */
|
||||
.list-enter-active {
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
/* Absolute positioning for leaving elements prevents layout thrashing */
|
||||
.list-leave-active {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Performance Optimization Strategies
|
||||
|
||||
### 1. Skip Animations for Bulk Operations
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<TransitionGroup v-if="animationsEnabled" name="list" tag="ul">
|
||||
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- Instant update without animations -->
|
||||
<ul v-else>
|
||||
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick } from 'vue'
|
||||
|
||||
const animationsEnabled = ref(true)
|
||||
|
||||
async function bulkUpdate(newItems) {
|
||||
// Disable animations for bulk operations
|
||||
animationsEnabled.value = false
|
||||
items.value = newItems
|
||||
await nextTick()
|
||||
animationsEnabled.value = true
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 2. Virtual Scrolling for Large Lists
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Use a virtual list library for large datasets -->
|
||||
<RecycleScroller
|
||||
:items="items"
|
||||
:item-size="50"
|
||||
key-field="id"
|
||||
v-slot="{ item }"
|
||||
>
|
||||
<div class="list-item">{{ item.name }}</div>
|
||||
</RecycleScroller>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { RecycleScroller } from 'vue-virtual-scroller'
|
||||
</script>
|
||||
```
|
||||
|
||||
### 3. Reduce CSS Complexity During Transitions
|
||||
|
||||
```vue
|
||||
<style>
|
||||
/* Move complex styles to a stable wrapper */
|
||||
.list-item-wrapper {
|
||||
@apply p-4 m-2 rounded-lg shadow-md bg-gradient-to-r from-blue-500 to-purple-600;
|
||||
}
|
||||
|
||||
/* Keep animated element styles minimal */
|
||||
.list-item {
|
||||
/* Only essential layout styles */
|
||||
}
|
||||
|
||||
.list-move,
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
/* Only animate transform/opacity - GPU accelerated */
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### 4. Use CSS content-visibility
|
||||
|
||||
```css
|
||||
/* For very long lists, defer rendering of off-screen items */
|
||||
.list-item {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 0 50px; /* Estimated height */
|
||||
}
|
||||
```
|
||||
|
||||
## When to Avoid TransitionGroup
|
||||
|
||||
Consider alternatives when:
|
||||
- List updates are frequent (real-time data)
|
||||
- List contains 100+ items
|
||||
- Items have complex CSS or nested components
|
||||
- Performance is critical (mobile, low-end devices)
|
||||
|
||||
```vue
|
||||
<!-- Simple alternative: CSS-only animations on individual items -->
|
||||
<ul>
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="animate-in"
|
||||
>
|
||||
{{ item.name }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: fadeIn 0.3s ease forwards;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js TransitionGroup](https://vuejs.org/guide/built-ins/transition-group.html)
|
||||
- [GitHub Issue: transition-group DOM update lag](https://github.com/vuejs/vue/issues/5845)
|
||||
- [Vue Virtual Scroller](https://github.com/Akryum/vue-virtual-scroller)
|
||||
@@ -0,0 +1,142 @@
|
||||
# Async Component Lazy Hydration Strategies (Vue 3.5+)
|
||||
|
||||
## Rule
|
||||
|
||||
In Vue 3.5+, use hydration strategies with async components to control when SSR-rendered components become interactive. Import hydration strategies individually for tree-shaking.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
In SSR applications, hydrating all components immediately can block the main thread and delay interactivity. Lazy hydration allows non-critical components to become interactive only when needed, improving Time to Interactive (TTI) metrics.
|
||||
|
||||
## Available Hydration Strategies
|
||||
|
||||
### hydrateOnIdle
|
||||
|
||||
Hydrates when the browser is idle using `requestIdleCallback`:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { defineAsyncComponent, hydrateOnIdle } from 'vue'
|
||||
|
||||
// Good for non-critical, below-the-fold content
|
||||
const AsyncFooter = defineAsyncComponent({
|
||||
loader: () => import('./Footer.vue'),
|
||||
hydrate: hydrateOnIdle()
|
||||
})
|
||||
|
||||
// With max timeout (in case idle never occurs)
|
||||
const AsyncSidebar = defineAsyncComponent({
|
||||
loader: () => import('./Sidebar.vue'),
|
||||
hydrate: hydrateOnIdle(5000) // Max 5 seconds
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### hydrateOnVisible
|
||||
|
||||
Hydrates when element enters the viewport via `IntersectionObserver`:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { defineAsyncComponent, hydrateOnVisible } from 'vue'
|
||||
|
||||
// Good for content below the fold
|
||||
const AsyncComments = defineAsyncComponent({
|
||||
loader: () => import('./Comments.vue'),
|
||||
hydrate: hydrateOnVisible()
|
||||
})
|
||||
|
||||
// With root margin for earlier hydration
|
||||
const AsyncRelatedPosts = defineAsyncComponent({
|
||||
loader: () => import('./RelatedPosts.vue'),
|
||||
hydrate: hydrateOnVisible({ rootMargin: '100px' })
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### hydrateOnMediaQuery
|
||||
|
||||
Hydrates when a media query matches:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { defineAsyncComponent, hydrateOnMediaQuery } from 'vue'
|
||||
|
||||
// Only hydrate on mobile devices
|
||||
const AsyncMobileMenu = defineAsyncComponent({
|
||||
loader: () => import('./MobileMenu.vue'),
|
||||
hydrate: hydrateOnMediaQuery('(max-width: 768px)')
|
||||
})
|
||||
|
||||
// Only hydrate on desktop
|
||||
const AsyncDesktopSidebar = defineAsyncComponent({
|
||||
loader: () => import('./DesktopSidebar.vue'),
|
||||
hydrate: hydrateOnMediaQuery('(min-width: 1024px)')
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### hydrateOnInteraction
|
||||
|
||||
Hydrates when user interacts with the component:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { defineAsyncComponent, hydrateOnInteraction } from 'vue'
|
||||
|
||||
// Hydrate on click
|
||||
const AsyncDropdown = defineAsyncComponent({
|
||||
loader: () => import('./Dropdown.vue'),
|
||||
hydrate: hydrateOnInteraction('click')
|
||||
})
|
||||
|
||||
// Hydrate on multiple events
|
||||
const AsyncTooltip = defineAsyncComponent({
|
||||
loader: () => import('./Tooltip.vue'),
|
||||
hydrate: hydrateOnInteraction(['mouseover', 'focus'])
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**Note**: The triggering event is replayed after hydration completes, so user interaction is not lost.
|
||||
|
||||
## Custom Hydration Strategy
|
||||
|
||||
```typescript
|
||||
import { defineAsyncComponent, type HydrationStrategy } from 'vue'
|
||||
|
||||
const hydrateAfterAnimation: HydrationStrategy = (hydrate, forEachElement) => {
|
||||
// Wait for page load animation to complete
|
||||
const timeout = setTimeout(hydrate, 1000)
|
||||
|
||||
return () => clearTimeout(timeout) // Cleanup
|
||||
}
|
||||
|
||||
const AsyncWidget = defineAsyncComponent({
|
||||
loader: () => import('./Widget.vue'),
|
||||
hydrate: hydrateAfterAnimation
|
||||
})
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
1. Hydration strategies only apply to SSR - they have no effect in client-only apps
|
||||
2. Import strategies individually: `import { hydrateOnIdle } from 'vue'`
|
||||
3. `hydrateOnInteraction` replays the triggering event after hydration
|
||||
4. Use `hydrateOnVisible` for below-the-fold content
|
||||
5. Use `hydrateOnIdle` for non-critical components
|
||||
6. Use `hydrateOnMediaQuery` for device-specific components
|
||||
|
||||
## Strategy Selection Guide
|
||||
|
||||
| Component Type | Recommended Strategy |
|
||||
|----------------|---------------------|
|
||||
| Footer, related content | `hydrateOnIdle` |
|
||||
| Below-the-fold sections | `hydrateOnVisible` |
|
||||
| Interactive widgets | `hydrateOnInteraction` |
|
||||
| Mobile-only components | `hydrateOnMediaQuery` |
|
||||
| Critical above-the-fold | No strategy (immediate) |
|
||||
|
||||
## References
|
||||
|
||||
- [Vue.js Async Components Documentation](https://vuejs.org/guide/components/async)
|
||||
@@ -0,0 +1,76 @@
|
||||
# Async Component Loading Delay for Flicker Prevention
|
||||
|
||||
## Rule
|
||||
|
||||
Use the `delay` option (default 200ms) when configuring async components with a `loadingComponent`. This prevents UI flicker on fast networks where the component loads quickly.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Without a delay, the loading component briefly appears and immediately disappears when the async component loads quickly. This creates a jarring "flash" effect that degrades user experience. The 200ms default is chosen because loads faster than this are perceived as instant.
|
||||
|
||||
## Bad Code
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import LoadingSpinner from './LoadingSpinner.vue'
|
||||
|
||||
const AsyncDashboard = defineAsyncComponent({
|
||||
loader: () => import('./Dashboard.vue'),
|
||||
loadingComponent: LoadingSpinner,
|
||||
delay: 0 // Loading spinner flashes immediately, causing flicker
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Good Code
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import LoadingSpinner from './LoadingSpinner.vue'
|
||||
|
||||
const AsyncDashboard = defineAsyncComponent({
|
||||
loader: () => import('./Dashboard.vue'),
|
||||
loadingComponent: LoadingSpinner,
|
||||
// delay: 200 is the default, but you can adjust based on your UX needs
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import LoadingSpinner from './LoadingSpinner.vue'
|
||||
import ErrorDisplay from './ErrorDisplay.vue'
|
||||
|
||||
// For slower expected loads, consider a shorter delay
|
||||
const AsyncHeavyChart = defineAsyncComponent({
|
||||
loader: () => import('./HeavyChart.vue'),
|
||||
loadingComponent: LoadingSpinner,
|
||||
errorComponent: ErrorDisplay,
|
||||
delay: 100, // Show loading sooner for components known to be heavy
|
||||
timeout: 30000 // Longer timeout for complex components
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Choosing the Right Delay
|
||||
|
||||
| Scenario | Recommended Delay |
|
||||
|----------|-------------------|
|
||||
| Fast network, small component | 200ms (default) |
|
||||
| Known heavy component | 100ms |
|
||||
| Interactive element user is waiting for | 50-100ms |
|
||||
| Background content load | 300-500ms |
|
||||
|
||||
## Key Points
|
||||
|
||||
1. The default 200ms delay is a good choice for most cases
|
||||
2. Never set `delay: 0` unless you explicitly want the loading state visible immediately
|
||||
3. Pair `delay` with `timeout` for complete loading state management
|
||||
4. Consider your network conditions and component size when tuning delay
|
||||
|
||||
## References
|
||||
|
||||
- [Vue.js Async Components Documentation](https://vuejs.org/guide/components/async)
|
||||
@@ -0,0 +1,74 @@
|
||||
# Async Components Are Suspensible by Default
|
||||
|
||||
## Rule
|
||||
|
||||
Async components created with `defineAsyncComponent` are automatically treated as async dependencies of any parent `<Suspense>` component. When wrapped by `<Suspense>`, the async component's own `loadingComponent`, `errorComponent`, `delay`, and `timeout` options are ignored.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
This behavior causes confusion when developers configure loading and error states on their async components but these states never appear because a parent `<Suspense>` takes over control. The component's options are silently ignored, leading to unexpected behavior.
|
||||
|
||||
## Bad Code
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
// These options will be IGNORED if a parent Suspense exists
|
||||
const AsyncDashboard = defineAsyncComponent({
|
||||
loader: () => import('./Dashboard.vue'),
|
||||
loadingComponent: LoadingSpinner, // Won't show!
|
||||
errorComponent: ErrorDisplay, // Won't show!
|
||||
timeout: 3000 // Ignored!
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- If this is inside a Suspense somewhere up the tree -->
|
||||
<AsyncDashboard />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Good Code
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
// Use suspensible: false to keep control of loading/error states
|
||||
const AsyncDashboard = defineAsyncComponent({
|
||||
loader: () => import('./Dashboard.vue'),
|
||||
loadingComponent: LoadingSpinner,
|
||||
errorComponent: ErrorDisplay,
|
||||
timeout: 3000,
|
||||
suspensible: false // Component controls its own loading state
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AsyncDashboard />
|
||||
</template>
|
||||
```
|
||||
|
||||
## When to Use Each Approach
|
||||
|
||||
**Keep suspensible (default)** when:
|
||||
- You want centralized loading/error handling at a layout level
|
||||
- The parent `<Suspense>` provides appropriate feedback
|
||||
- Multiple async components should show a unified loading state
|
||||
|
||||
**Use `suspensible: false`** when:
|
||||
- You need component-specific loading indicators
|
||||
- The component should handle its own error states
|
||||
- You want fine-grained control over the UX
|
||||
|
||||
## Key Points
|
||||
|
||||
1. Check if your component tree has a `<Suspense>` ancestor before relying on async component options
|
||||
2. Use `suspensible: false` explicitly when you need the component to manage its own states
|
||||
3. The `<Suspense>` component's `#fallback` slot and `onErrorCaptured` take precedence over async component options
|
||||
|
||||
## References
|
||||
|
||||
- [Vue.js Async Components Documentation](https://vuejs.org/guide/components/async)
|
||||
- [Vue.js Suspense Documentation](https://vuejs.org/guide/built-ins/suspense)
|
||||
@@ -0,0 +1,109 @@
|
||||
# Do Not Use defineAsyncComponent with Vue Router
|
||||
|
||||
## Rule
|
||||
|
||||
Never use `defineAsyncComponent` when configuring Vue Router route components. Vue Router has its own lazy loading mechanism using dynamic imports directly.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Vue Router's lazy loading is specifically designed for route-level code splitting. Using `defineAsyncComponent` for routes adds unnecessary overhead and can cause unexpected behavior with navigation guards, loading states, and route transitions.
|
||||
|
||||
## Bad Code
|
||||
|
||||
```javascript
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
// WRONG: Don't use defineAsyncComponent here
|
||||
component: defineAsyncComponent(() =>
|
||||
import('./views/Dashboard.vue')
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
// WRONG: This also won't work as expected
|
||||
component: defineAsyncComponent({
|
||||
loader: () => import('./views/Profile.vue'),
|
||||
loadingComponent: LoadingSpinner
|
||||
})
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## Good Code
|
||||
|
||||
```javascript
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
// CORRECT: Use dynamic import directly
|
||||
component: () => import('./views/Dashboard.vue')
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
// CORRECT: Simple arrow function with import
|
||||
component: () => import('./views/Profile.vue')
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## Handling Loading States with Vue Router
|
||||
|
||||
For route-level loading states, use Vue Router's navigation guards or a global loading indicator:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const isLoading = ref(false)
|
||||
|
||||
router.beforeEach(() => {
|
||||
isLoading.value = true
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
isLoading.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoadingBar v-if="isLoading" />
|
||||
<RouterView />
|
||||
</template>
|
||||
```
|
||||
|
||||
## When to Use defineAsyncComponent
|
||||
|
||||
Use `defineAsyncComponent` for:
|
||||
- Components loaded conditionally within a page
|
||||
- Heavy components that aren't always needed
|
||||
- Modal dialogs or panels that load on demand
|
||||
|
||||
Use Vue Router's lazy loading for:
|
||||
- Route-level components (views/pages)
|
||||
- Any component configured in route definitions
|
||||
|
||||
## Key Points
|
||||
|
||||
1. Vue Router and `defineAsyncComponent` are separate lazy loading mechanisms
|
||||
2. Route components should use direct dynamic imports: `() => import('./View.vue')`
|
||||
3. Use navigation guards for route-level loading indicators
|
||||
4. `defineAsyncComponent` is for component-level lazy loading within pages
|
||||
|
||||
## References
|
||||
|
||||
- [Vue Router Lazy Loading Routes](https://router.vuejs.org/guide/advanced/lazy-loading.html)
|
||||
- [Vue.js Async Components Documentation](https://vuejs.org/guide/components/async)
|
||||
@@ -0,0 +1,186 @@
|
||||
# Accessing Hyphenated Attributes in $attrs
|
||||
|
||||
## Rule
|
||||
|
||||
Fallthrough attributes preserve their original casing in JavaScript. Hyphenated attribute names (like `data-testid` or `aria-label`) must be accessed using bracket notation. Event listeners are exposed as camelCase functions (e.g., `@click` becomes `$attrs.onClick`).
|
||||
|
||||
## Why This Matters
|
||||
|
||||
- JavaScript identifiers cannot contain hyphens
|
||||
- Using dot notation with hyphenated names causes syntax errors or undefined values
|
||||
- Event listener naming follows a different convention than attribute naming
|
||||
- Common source of "undefined" errors when working with attrs programmatically
|
||||
|
||||
## Bad Code
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useAttrs } from 'vue'
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
// WRONG: Syntax error - hyphen interpreted as minus
|
||||
console.log(attrs.data-testid) // Error!
|
||||
|
||||
// WRONG: This accesses a different property
|
||||
console.log(attrs.dataTestid) // undefined (camelCase doesn't work for attrs)
|
||||
|
||||
// WRONG: Expecting hyphenated event name
|
||||
console.log(attrs['on-click']) // undefined
|
||||
console.log(attrs['@click']) // undefined
|
||||
</script>
|
||||
```
|
||||
|
||||
## Good Code
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useAttrs } from 'vue'
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
// CORRECT: Use bracket notation for hyphenated attributes
|
||||
console.log(attrs['data-testid']) // "my-button"
|
||||
console.log(attrs['aria-label']) // "Submit form"
|
||||
console.log(attrs['foo-bar']) // "baz"
|
||||
|
||||
// CORRECT: Event listeners use camelCase with 'on' prefix
|
||||
console.log(attrs.onClick) // function
|
||||
console.log(attrs.onCustomEvent) // function (from @custom-event)
|
||||
console.log(attrs.onMouseEnter) // function (from @mouseenter or @mouse-enter)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Attribute vs Event Naming Reference
|
||||
|
||||
| Parent Usage | $attrs Access |
|
||||
|--------------|---------------|
|
||||
| `class="foo"` | `attrs.class` |
|
||||
| `data-id="123"` | `attrs['data-id']` |
|
||||
| `aria-label="..."` | `attrs['aria-label']` |
|
||||
| `foo-bar="baz"` | `attrs['foo-bar']` |
|
||||
| `@click="fn"` | `attrs.onClick` |
|
||||
| `@custom-event="fn"` | `attrs.onCustomEvent` |
|
||||
| `@update:modelValue="fn"` | `attrs['onUpdate:modelValue']` |
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Checking for specific attributes
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useAttrs, computed } from 'vue'
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
// Check if data attribute exists
|
||||
const hasTestId = computed(() => 'data-testid' in attrs)
|
||||
|
||||
// Get aria attribute with default
|
||||
const ariaLabel = computed(() => attrs['aria-label'] ?? 'Default label')
|
||||
</script>
|
||||
```
|
||||
|
||||
### Filtering attributes by type
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useAttrs, computed } from 'vue'
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
// Separate event listeners from other attributes
|
||||
const { listeners, otherAttrs } = computed(() => {
|
||||
const listeners = {}
|
||||
const otherAttrs = {}
|
||||
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (key.startsWith('on') && typeof value === 'function') {
|
||||
listeners[key] = value
|
||||
} else {
|
||||
otherAttrs[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return { listeners, otherAttrs }
|
||||
}).value
|
||||
</script>
|
||||
```
|
||||
|
||||
### Extracting data attributes
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useAttrs, computed } from 'vue'
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
// Get all data-* attributes
|
||||
const dataAttrs = computed(() => {
|
||||
const result = {}
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (key.startsWith('data-')) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-bind="dataAttrs">
|
||||
<!-- Only data attributes are bound -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Forwarding specific events
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useAttrs } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
// Call parent's click handler with custom logic
|
||||
function handleClick(event) {
|
||||
console.log('Internal handling first')
|
||||
|
||||
// Then forward to parent if handler exists
|
||||
if (attrs.onClick) {
|
||||
attrs.onClick(event)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button @click="handleClick">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## TypeScript Considerations
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useAttrs } from 'vue'
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
// attrs is typed as Record<string, unknown>
|
||||
// You may need to cast for specific usage
|
||||
|
||||
const testId = attrs['data-testid'] as string | undefined
|
||||
const onClick = attrs.onClick as ((e: MouseEvent) => void) | undefined
|
||||
</script>
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Fallthrough Attributes - Accessing in JavaScript](https://vuejs.org/guide/components/attrs.html#accessing-fallthrough-attributes-in-javascript)
|
||||
- [Vue 3 $attrs Documentation](https://vuejs.org/api/component-instance.html#attrs)
|
||||
162
skills/vue-best-practices/reference/attrs-not-reactive.md
Normal file
162
skills/vue-best-practices/reference/attrs-not-reactive.md
Normal file
@@ -0,0 +1,162 @@
|
||||
---
|
||||
title: useAttrs() Object Is Not Reactive
|
||||
impact: MEDIUM
|
||||
impactDescription: Watching attrs directly does not trigger - use onUpdated() or convert to props
|
||||
type: gotcha
|
||||
tags: [vue3, attrs, reactivity, composition-api]
|
||||
---
|
||||
|
||||
# useAttrs() Object Is Not Reactive
|
||||
|
||||
**Impact: MEDIUM** - The object returned by `useAttrs()` is NOT reactive. While it always reflects the latest fallthrough attributes, you cannot use `watch()` or `watchEffect()` to observe its changes. Watchers on attrs properties will NOT trigger when attributes change.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Never use `watch()` to observe attrs changes - it won't trigger
|
||||
- [ ] Use `onUpdated()` lifecycle hook for side effects based on attrs
|
||||
- [ ] Convert frequently-accessed attrs to props if you need reactivity
|
||||
- [ ] Remember attrs ARE always current in templates and event handlers
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { watch, useAttrs } from 'vue'
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
// WRONG: This watcher will NEVER trigger when attrs change!
|
||||
watch(
|
||||
() => attrs.someAttr,
|
||||
(newValue) => {
|
||||
console.log('Attribute changed:', newValue)
|
||||
// This callback never runs on attr changes
|
||||
}
|
||||
)
|
||||
|
||||
// WRONG: watchEffect also doesn't track attrs
|
||||
watchEffect(() => {
|
||||
console.log(attrs.class) // Only runs on mount, NOT when class changes
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { onUpdated, useAttrs } from 'vue'
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
// CORRECT: Use onUpdated to access latest attrs
|
||||
onUpdated(() => {
|
||||
console.log('Current attrs:', attrs)
|
||||
// Perform side effects with latest attrs here
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
|
||||
// CORRECT: If you need reactivity, declare it as a prop
|
||||
const props = defineProps({
|
||||
someAttr: String
|
||||
})
|
||||
|
||||
// Now you can watch it
|
||||
watch(
|
||||
() => props.someAttr,
|
||||
(newValue) => {
|
||||
console.log('Attribute changed:', newValue)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Why Attrs Are Not Reactive
|
||||
|
||||
Vue's official documentation states:
|
||||
|
||||
> "Note that although the attrs object here always reflects the latest fallthrough attributes, it isn't reactive (for performance reasons). You cannot use watchers to observe its changes."
|
||||
|
||||
This is a deliberate design decision for performance - making attrs reactive would add overhead to every component that uses fallthrough attributes.
|
||||
|
||||
## When Attrs DO Reflect Current Values
|
||||
|
||||
Despite not being reactive, attrs always have current values in these contexts:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useAttrs } from 'vue'
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
// In event handlers - attrs are current when handler executes
|
||||
function handleClick() {
|
||||
console.log(attrs.class) // Current value at click time
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- In templates - always current -->
|
||||
<div :class="attrs.class">
|
||||
{{ attrs.title }}
|
||||
</div>
|
||||
|
||||
<!-- Using v-bind to spread all attrs -->
|
||||
<input v-bind="attrs" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Computed Properties with Attrs
|
||||
|
||||
Computed properties that reference attrs will update when the component re-renders:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { computed, useAttrs } from 'vue'
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
// This DOES update - because computed re-evaluates on render
|
||||
const hasCustomClass = computed(() => {
|
||||
return !!attrs.class
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-bind="attrs">
|
||||
<span v-if="hasCustomClass">Has custom styling</span>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
Note: The computed updates because the component re-renders when props/attrs change, not because attrs is reactive.
|
||||
|
||||
## Alternative: Use getCurrentInstance() (Advanced)
|
||||
|
||||
For advanced use cases, you can access attrs through the component instance:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { watch, getCurrentInstance } from 'vue'
|
||||
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
// This may work in some cases, but is NOT officially supported
|
||||
// getCurrentInstance() is an internal API and may change
|
||||
watch(
|
||||
() => instance?.attrs.someAttr,
|
||||
(newValue) => {
|
||||
console.log('Attribute changed:', newValue)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
> **Warning:** `getCurrentInstance()` is an internal API. Prefer `onUpdated()` or converting to props.
|
||||
|
||||
## Reference
|
||||
- [Fallthrough Attributes - Accessing in JavaScript](https://vuejs.org/guide/components/attrs.html#accessing-fallthrough-attributes-in-javascript)
|
||||
- [Vue 3 Reactivity Fundamentals](https://vuejs.org/guide/essentials/reactivity-fundamentals.html)
|
||||
@@ -0,0 +1,249 @@
|
||||
---
|
||||
title: Avoid Prop Drilling - Use Provide/Inject for Deep Component Trees
|
||||
impact: MEDIUM
|
||||
impactDescription: Passing props through many layers creates maintenance burden and tight coupling between intermediate components
|
||||
type: best-practice
|
||||
tags: [vue3, props, provide-inject, component-design, state-management, architecture]
|
||||
---
|
||||
|
||||
# Avoid Prop Drilling - Use Provide/Inject for Deep Component Trees
|
||||
|
||||
**Impact: MEDIUM** - Prop drilling occurs when you pass props through multiple component layers just to reach a deeply nested child. This creates tight coupling, makes refactoring difficult, and clutters intermediate components with props they don't use.
|
||||
|
||||
Vue's provide/inject API allows ancestor components to share data with any descendant, regardless of nesting depth.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Identify when props pass through 2+ intermediate components unchanged
|
||||
- [ ] Use provide/inject for data needed by deeply nested descendants
|
||||
- [ ] Use Pinia for global state shared across unrelated component trees
|
||||
- [ ] Keep props for direct parent-child relationships
|
||||
- [ ] Document provided values at the provider level
|
||||
|
||||
## The Problem: Prop Drilling
|
||||
|
||||
```vue
|
||||
<!-- App.vue -->
|
||||
<template>
|
||||
<MainLayout :user="user" :theme="theme" :locale="locale" />
|
||||
</template>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- MainLayout.vue - Doesn't use these props, just passes them -->
|
||||
<template>
|
||||
<Sidebar :user="user" :theme="theme" />
|
||||
<Content :user="user" :locale="locale" />
|
||||
</template>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- Sidebar.vue - Still drilling... -->
|
||||
<template>
|
||||
<UserMenu :user="user" />
|
||||
<ThemeToggle :theme="theme" />
|
||||
</template>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- UserMenu.vue - Finally uses user prop -->
|
||||
<template>
|
||||
<div>{{ user.name }}</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Problems:**
|
||||
1. `MainLayout` and `Sidebar` are cluttered with props they don't use
|
||||
2. Adding a new shared value requires updating every component in the chain
|
||||
3. Removing a deeply nested component requires updating all ancestors
|
||||
4. Difficult to trace where data originates
|
||||
|
||||
## Solution: Provide/Inject
|
||||
|
||||
**Correct - Provider (ancestor):**
|
||||
```vue
|
||||
<!-- App.vue -->
|
||||
<script setup>
|
||||
import { provide, ref, readonly } from 'vue'
|
||||
|
||||
const user = ref({ name: 'John', role: 'admin' })
|
||||
const theme = ref('dark')
|
||||
const locale = ref('en')
|
||||
|
||||
// Provide to all descendants
|
||||
provide('user', readonly(user)) // readonly prevents mutations
|
||||
provide('theme', theme)
|
||||
provide('locale', locale)
|
||||
|
||||
// Provide update functions if needed
|
||||
provide('updateTheme', (newTheme) => {
|
||||
theme.value = newTheme
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainLayout />
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct - Intermediate components are now clean:**
|
||||
```vue
|
||||
<!-- MainLayout.vue - No props needed -->
|
||||
<template>
|
||||
<Sidebar />
|
||||
<Content />
|
||||
</template>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- Sidebar.vue - No props needed -->
|
||||
<template>
|
||||
<UserMenu />
|
||||
<ThemeToggle />
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct - Consumer (descendant):**
|
||||
```vue
|
||||
<!-- UserMenu.vue -->
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
|
||||
// Inject from any ancestor
|
||||
const user = inject('user')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>{{ user.name }}</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- ThemeToggle.vue -->
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
|
||||
const theme = inject('theme')
|
||||
const updateTheme = inject('updateTheme')
|
||||
|
||||
function toggleTheme() {
|
||||
updateTheme(theme.value === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button @click="toggleTheme">
|
||||
Current: {{ theme }}
|
||||
</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Best Practices for Provide/Inject
|
||||
|
||||
### 1. Use Symbol Keys for Large Apps
|
||||
|
||||
Avoid string key collisions with symbols:
|
||||
|
||||
```js
|
||||
// keys.js
|
||||
export const UserKey = Symbol('user')
|
||||
export const ThemeKey = Symbol('theme')
|
||||
```
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { provide } from 'vue'
|
||||
import { UserKey, ThemeKey } from './keys'
|
||||
|
||||
provide(UserKey, user)
|
||||
provide(ThemeKey, theme)
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
import { UserKey } from './keys'
|
||||
|
||||
const user = inject(UserKey)
|
||||
</script>
|
||||
```
|
||||
|
||||
### 2. Provide Default Values
|
||||
|
||||
Handle cases where no ancestor provides the value:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
|
||||
// With default value
|
||||
const theme = inject('theme', 'light')
|
||||
|
||||
// With factory function for objects (avoids shared reference)
|
||||
const config = inject('config', () => ({ debug: false }), true)
|
||||
</script>
|
||||
```
|
||||
|
||||
### 3. Use Readonly for Data Safety
|
||||
|
||||
Prevent descendants from mutating provided data:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { provide, ref, readonly } from 'vue'
|
||||
|
||||
const user = ref({ name: 'John' })
|
||||
|
||||
// Descendants can read but not mutate
|
||||
provide('user', readonly(user))
|
||||
|
||||
// Provide separate method for updates
|
||||
provide('updateUser', (updates) => {
|
||||
Object.assign(user.value, updates)
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### 4. Provide Computed Values for Reactivity
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { provide, computed } from 'vue'
|
||||
|
||||
const items = ref([1, 2, 3])
|
||||
|
||||
// Descendants will reactively update
|
||||
provide('itemCount', computed(() => items.value.length))
|
||||
</script>
|
||||
```
|
||||
|
||||
## When to Use What
|
||||
|
||||
| Scenario | Solution |
|
||||
|----------|----------|
|
||||
| Direct parent-child | Props |
|
||||
| 1-2 levels deep | Props (drilling is acceptable) |
|
||||
| Deep nesting, same component tree | Provide/Inject |
|
||||
| Unrelated component trees | Pinia (state management) |
|
||||
| Cross-app global state | Pinia |
|
||||
| Plugin configuration | Provide/Inject from plugin install |
|
||||
|
||||
## Provide/Inject vs Pinia
|
||||
|
||||
**Provide/Inject:**
|
||||
- Scoped to component subtree
|
||||
- Great for component library internals
|
||||
- No DevTools support
|
||||
- Ancestor-descendant relationships only
|
||||
|
||||
**Pinia:**
|
||||
- Global, accessible anywhere
|
||||
- Excellent DevTools integration
|
||||
- Better for application state
|
||||
- Works across unrelated components
|
||||
|
||||
## Reference
|
||||
- [Vue.js Provide/Inject](https://vuejs.org/guide/components/provide-inject.html)
|
||||
- [Vue.js - Prop Drilling](https://vuejs.org/guide/components/provide-inject.html#prop-drilling)
|
||||
- [Pinia Documentation](https://pinia.vuejs.org/)
|
||||
@@ -0,0 +1,252 @@
|
||||
---
|
||||
title: Component Events Don't Bubble
|
||||
impact: MEDIUM
|
||||
impactDescription: Vue component events only reach direct parent - sibling and grandparent communication requires alternative patterns
|
||||
type: gotcha
|
||||
tags: [vue3, events, emit, event-bubbling, provide-inject, state-management]
|
||||
---
|
||||
|
||||
# Component Events Don't Bubble
|
||||
|
||||
**Impact: MEDIUM** - Unlike native DOM events, Vue component events do NOT bubble up the component tree. When a child emits an event, only its direct parent can listen for it. Grandparent components and siblings never receive the event.
|
||||
|
||||
This is a common source of confusion for developers coming from vanilla JavaScript where events naturally bubble up the DOM.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Only expect events from direct child components
|
||||
- [ ] Use provide/inject for deeply nested communication
|
||||
- [ ] Use state management (Pinia) for complex cross-component communication
|
||||
- [ ] Re-emit events at each level if manual bubbling is needed
|
||||
- [ ] Consider whether your component hierarchy is too deep
|
||||
|
||||
## The Problem
|
||||
|
||||
```vue
|
||||
<!-- GrandParent.vue -->
|
||||
<template>
|
||||
<!-- This handler NEVER fires for grandchild events -->
|
||||
<Parent @item-selected="handleSelect" />
|
||||
</template>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- Parent.vue -->
|
||||
<template>
|
||||
<Child />
|
||||
</template>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- Child.vue -->
|
||||
<script setup>
|
||||
const emit = defineEmits(['item-selected'])
|
||||
|
||||
function selectItem(item) {
|
||||
// This event reaches Parent, but NOT GrandParent
|
||||
emit('item-selected', item)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Solution 1: Re-emit at Each Level (Simple Cases)
|
||||
|
||||
Manually forward events through each component.
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<!-- GrandParent.vue -->
|
||||
<template>
|
||||
<Parent @item-selected="handleSelect" />
|
||||
</template>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- Parent.vue -->
|
||||
<script setup>
|
||||
const emit = defineEmits(['item-selected'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Re-emit to grandparent -->
|
||||
<Child @item-selected="(item) => emit('item-selected', item)" />
|
||||
</template>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- Child.vue -->
|
||||
<script setup>
|
||||
const emit = defineEmits(['item-selected'])
|
||||
|
||||
function selectItem(item) {
|
||||
emit('item-selected', item)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**Drawback:** Becomes tedious with deeply nested components.
|
||||
|
||||
## Solution 2: Provide/Inject (Ancestor Communication)
|
||||
|
||||
For deeply nested components, provide a callback from the ancestor.
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<!-- GrandParent.vue -->
|
||||
<script setup>
|
||||
import { provide } from 'vue'
|
||||
|
||||
function handleItemSelected(item) {
|
||||
console.log('Item selected:', item)
|
||||
}
|
||||
|
||||
// Provide the callback to all descendants
|
||||
provide('onItemSelected', handleItemSelected)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Parent />
|
||||
</template>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- Parent.vue - No changes needed -->
|
||||
<template>
|
||||
<Child />
|
||||
</template>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- Child.vue -->
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
|
||||
// Inject the callback from any ancestor
|
||||
const onItemSelected = inject('onItemSelected', () => {})
|
||||
|
||||
function selectItem(item) {
|
||||
onItemSelected(item)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**Advantages:**
|
||||
- Skips intermediate components
|
||||
- No prop drilling or re-emitting
|
||||
- Works at any nesting depth
|
||||
|
||||
## Solution 3: State Management (Complex Applications)
|
||||
|
||||
For cross-component communication, especially between siblings or unrelated components, use Pinia.
|
||||
|
||||
**Correct:**
|
||||
```js
|
||||
// stores/selection.js
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useSelectionStore = defineStore('selection', () => {
|
||||
const selectedItem = ref(null)
|
||||
|
||||
function selectItem(item) {
|
||||
selectedItem.value = item
|
||||
}
|
||||
|
||||
return { selectedItem, selectItem }
|
||||
})
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- DeepChild.vue - Updates state -->
|
||||
<script setup>
|
||||
import { useSelectionStore } from '@/stores/selection'
|
||||
|
||||
const store = useSelectionStore()
|
||||
|
||||
function handleSelect(item) {
|
||||
store.selectItem(item)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- SiblingComponent.vue - Reacts to state -->
|
||||
<script setup>
|
||||
import { useSelectionStore } from '@/stores/selection'
|
||||
|
||||
const store = useSelectionStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="store.selectedItem">
|
||||
Selected: {{ store.selectedItem.name }}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Solution 4: Event Bus (Use Sparingly)
|
||||
|
||||
For truly decoupled components, a simple event bus can work:
|
||||
|
||||
```js
|
||||
// eventBus.js
|
||||
import mitt from 'mitt'
|
||||
export const emitter = mitt()
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- ComponentA.vue -->
|
||||
<script setup>
|
||||
import { emitter } from './eventBus'
|
||||
|
||||
function notify() {
|
||||
emitter.emit('custom-event', { data: 'value' })
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- ComponentB.vue -->
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { emitter } from './eventBus'
|
||||
|
||||
function handleEvent(data) {
|
||||
console.log('Received:', data)
|
||||
}
|
||||
|
||||
onMounted(() => emitter.on('custom-event', handleEvent))
|
||||
onUnmounted(() => emitter.off('custom-event', handleEvent))
|
||||
</script>
|
||||
```
|
||||
|
||||
**Warning:** Event buses make data flow hard to trace. Prefer provide/inject or state management.
|
||||
|
||||
## Comparison Table
|
||||
|
||||
| Method | Best For | Complexity |
|
||||
|--------|----------|------------|
|
||||
| Re-emit | 1-2 levels deep | Low |
|
||||
| Provide/Inject | Deep nesting, ancestor communication | Medium |
|
||||
| Pinia/State | Complex apps, sibling communication | Medium |
|
||||
| Event Bus | Truly decoupled, rare cases | Low (but risky) |
|
||||
|
||||
## Native Events DO Bubble
|
||||
|
||||
Note that native DOM events attached to elements still bubble normally:
|
||||
|
||||
```vue
|
||||
<!-- GrandParent.vue -->
|
||||
<template>
|
||||
<!-- Native click bubbles up from button inside Child -->
|
||||
<div @click="handleClick">
|
||||
<Parent />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
Only Vue component events (those emitted with `emit()`) don't bubble.
|
||||
|
||||
## Reference
|
||||
- [Vue.js Component Events](https://vuejs.org/guide/components/events.html)
|
||||
- [Vue.js Provide/Inject](https://vuejs.org/guide/components/provide-inject.html)
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
title: Use PascalCase for Component Names
|
||||
impact: LOW
|
||||
impactDescription: Improves code clarity and IDE support, but both cases work
|
||||
type: best-practice
|
||||
tags: [vue3, component-registration, naming-conventions, pascalcase, ide-support]
|
||||
---
|
||||
|
||||
# Use PascalCase for Component Names
|
||||
|
||||
**Impact: LOW** - Vue supports both PascalCase (`<MyComponent>`) and kebab-case (`<my-component>`) in templates, but PascalCase is recommended. It provides better IDE support, clearly distinguishes Vue components from native HTML elements, and avoids confusion with web components (custom elements).
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Name component files in PascalCase (e.g., `UserProfile.vue`)
|
||||
- [ ] Use PascalCase when referencing components in templates
|
||||
- [ ] Use kebab-case only when required (in-DOM templates)
|
||||
- [ ] Be consistent across the entire codebase
|
||||
|
||||
**Less Ideal:**
|
||||
```vue
|
||||
<script setup>
|
||||
import userProfile from './user-profile.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Works but harder to distinguish from HTML elements -->
|
||||
<user-profile :user="currentUser" />
|
||||
<header>Native HTML</header>
|
||||
<footer>Native HTML</footer>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Recommended:**
|
||||
```vue
|
||||
<script setup>
|
||||
import UserProfile from './UserProfile.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Clear visual distinction: components vs HTML elements -->
|
||||
<UserProfile :user="currentUser" />
|
||||
<header>Native HTML</header>
|
||||
<footer>Native HTML</footer>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Why PascalCase?
|
||||
|
||||
### 1. Visual Distinction
|
||||
```vue
|
||||
<template>
|
||||
<!-- Immediately clear what's a component vs native HTML -->
|
||||
<PageHeader /> <!-- Component -->
|
||||
<header>...</header> <!-- Native HTML -->
|
||||
|
||||
<NavigationMenu /> <!-- Component -->
|
||||
<nav>...</nav> <!-- Native HTML -->
|
||||
|
||||
<UserAvatar /> <!-- Component -->
|
||||
<img src="..." /> <!-- Native HTML -->
|
||||
</template>
|
||||
```
|
||||
|
||||
### 2. IDE Auto-completion
|
||||
PascalCase names are valid JavaScript identifiers, enabling better IDE support:
|
||||
- Auto-import suggestions
|
||||
- Go-to-definition
|
||||
- Refactoring tools
|
||||
|
||||
### 3. Avoids Web Component Confusion
|
||||
Web Components (custom elements) require kebab-case with a hyphen. Using PascalCase for Vue components avoids any confusion:
|
||||
```vue
|
||||
<template>
|
||||
<!-- Vue component -->
|
||||
<MyButton @click="handle" />
|
||||
|
||||
<!-- Web component (custom element) -->
|
||||
<my-custom-element></my-custom-element>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Exception: In-DOM Templates
|
||||
|
||||
When using in-DOM templates (HTML files without build step), you MUST use kebab-case because HTML is case-insensitive:
|
||||
|
||||
```html
|
||||
<!-- index.html - in-DOM template -->
|
||||
<div id="app">
|
||||
<!-- PascalCase won't work in HTML files -->
|
||||
<!-- <UserProfile></UserProfile> --> <!-- WRONG -->
|
||||
|
||||
<!-- Must use kebab-case -->
|
||||
<user-profile :user="currentUser"></user-profile>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Vue's Automatic Resolution
|
||||
|
||||
Vue automatically resolves PascalCase components to both casings:
|
||||
```vue
|
||||
<script setup>
|
||||
import MyComponent from './MyComponent.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Both work, but PascalCase is preferred -->
|
||||
<MyComponent />
|
||||
<my-component />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Component Registration - Component Name Casing](https://vuejs.org/guide/components/registration.html#component-name-casing)
|
||||
@@ -0,0 +1,208 @@
|
||||
---
|
||||
title: Avoid Hidden Side Effects in Composables
|
||||
impact: HIGH
|
||||
impactDescription: Side effects hidden in composables make debugging difficult and create implicit coupling between components
|
||||
type: best-practice
|
||||
tags: [vue3, composables, composition-api, side-effects, provide-inject, global-state]
|
||||
---
|
||||
|
||||
# Avoid Hidden Side Effects in Composables
|
||||
|
||||
**Impact: HIGH** - Composables should encapsulate stateful logic, not hide side effects that affect things outside their scope. Hidden side effects like modifying global state, using provide/inject internally, or manipulating the DOM directly make composables unpredictable and hard to debug.
|
||||
|
||||
When a composable has unexpected side effects, consumers can't reason about what calling it will do. This leads to bugs that are difficult to trace and composables that can't be safely reused.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Avoid using provide/inject inside composables (make dependencies explicit)
|
||||
- [ ] Don't modify Pinia/Vuex store state internally (accept store as parameter instead)
|
||||
- [ ] Don't manipulate DOM directly (use template refs passed as arguments)
|
||||
- [ ] Document any unavoidable side effects clearly
|
||||
- [ ] Keep composables focused on returning reactive state and methods
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
// WRONG: Hidden provide/inject dependency
|
||||
export function useTheme() {
|
||||
// Consumer has no idea this depends on a provided theme
|
||||
const theme = inject('theme') // What if nothing provides this?
|
||||
|
||||
const isDark = computed(() => theme?.mode === 'dark')
|
||||
return { isDark }
|
||||
}
|
||||
|
||||
// WRONG: Modifying global store internally
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
export function useLogin() {
|
||||
const userStore = useUserStore()
|
||||
|
||||
async function login(credentials) {
|
||||
const user = await api.login(credentials)
|
||||
// Hidden side effect: modifying global state
|
||||
userStore.setUser(user)
|
||||
userStore.setToken(user.token)
|
||||
// Consumer doesn't know the store was modified!
|
||||
}
|
||||
|
||||
return { login }
|
||||
}
|
||||
|
||||
// WRONG: Hidden DOM manipulation
|
||||
export function useFocusTrap() {
|
||||
onMounted(() => {
|
||||
// Which element? Consumer has no control
|
||||
document.querySelector('.modal')?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
// WRONG: Hidden provide that affects descendants
|
||||
export function useFormContext() {
|
||||
const form = reactive({ values: {}, errors: {} })
|
||||
// Components calling this have no idea it provides something
|
||||
provide('form-context', form)
|
||||
return form
|
||||
}
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
// CORRECT: Explicit dependency injection
|
||||
export function useTheme(injectedTheme) {
|
||||
// If no theme passed, consumer must handle it
|
||||
const theme = injectedTheme ?? { mode: 'light' }
|
||||
|
||||
const isDark = computed(() => theme.mode === 'dark')
|
||||
return { isDark }
|
||||
}
|
||||
|
||||
// Usage - dependency is explicit
|
||||
const theme = inject('theme', { mode: 'light' })
|
||||
const { isDark } = useTheme(theme)
|
||||
|
||||
// CORRECT: Return actions, let consumer decide when to call them
|
||||
export function useLogin() {
|
||||
const user = ref(null)
|
||||
const token = ref(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function login(credentials) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await api.login(credentials)
|
||||
user.value = response.user
|
||||
token.value = response.token
|
||||
return response
|
||||
} catch (e) {
|
||||
error.value = e
|
||||
throw e
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { user, token, isLoading, error, login }
|
||||
}
|
||||
|
||||
// Consumer decides what to do with the result
|
||||
const { user, token, login } = useLogin()
|
||||
const userStore = useUserStore()
|
||||
|
||||
async function handleLogin(credentials) {
|
||||
await login(credentials)
|
||||
// Consumer explicitly updates the store
|
||||
userStore.setUser(user.value)
|
||||
userStore.setToken(token.value)
|
||||
}
|
||||
|
||||
// CORRECT: Accept element as parameter
|
||||
export function useFocusTrap(targetRef) {
|
||||
onMounted(() => {
|
||||
targetRef.value?.focus()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// Cleanup focus trap
|
||||
})
|
||||
}
|
||||
|
||||
// Usage - consumer controls which element
|
||||
const modalRef = ref(null)
|
||||
useFocusTrap(modalRef)
|
||||
|
||||
// CORRECT: Separate composable from provider
|
||||
export function useFormContext() {
|
||||
const form = reactive({ values: {}, errors: {} })
|
||||
return form
|
||||
}
|
||||
|
||||
// In parent component - explicit provide
|
||||
const form = useFormContext()
|
||||
provide('form-context', form)
|
||||
```
|
||||
|
||||
## Acceptable Side Effects (With Documentation)
|
||||
|
||||
Some side effects are acceptable when they're the core purpose of the composable:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Tracks mouse position globally.
|
||||
*
|
||||
* SIDE EFFECTS:
|
||||
* - Adds 'mousemove' event listener to window (cleaned up on unmount)
|
||||
*
|
||||
* @returns {Object} Mouse coordinates { x, y }
|
||||
*/
|
||||
export function useMouse() {
|
||||
const x = ref(0)
|
||||
const y = ref(0)
|
||||
|
||||
// This side effect is the whole point of the composable
|
||||
// and is properly cleaned up
|
||||
onMounted(() => window.addEventListener('mousemove', update))
|
||||
onUnmounted(() => window.removeEventListener('mousemove', update))
|
||||
|
||||
function update(event) {
|
||||
x.value = event.pageX
|
||||
y.value = event.pageY
|
||||
}
|
||||
|
||||
return { x, y }
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern: Dependency Injection for Flexibility
|
||||
|
||||
```javascript
|
||||
// Composable accepts its dependencies
|
||||
export function useDataFetcher(apiClient, cache = null) {
|
||||
const data = ref(null)
|
||||
|
||||
async function fetch(url) {
|
||||
if (cache) {
|
||||
const cached = cache.get(url)
|
||||
if (cached) {
|
||||
data.value = cached
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data.value = await apiClient.get(url)
|
||||
cache?.set(url, data.value)
|
||||
}
|
||||
|
||||
return { data, fetch }
|
||||
}
|
||||
|
||||
// Usage - dependencies are explicit and testable
|
||||
const apiClient = inject('apiClient')
|
||||
const cache = inject('cache', null)
|
||||
const { data, fetch } = useDataFetcher(apiClient, cache)
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Composables](https://vuejs.org/guide/reusability/composables.html)
|
||||
- [Common Mistakes Creating Composition Functions](https://www.telerik.com/blogs/common-mistakes-creating-composition-functions-vue)
|
||||
@@ -0,0 +1,236 @@
|
||||
---
|
||||
title: Compose Composables for Complex Logic
|
||||
impact: MEDIUM
|
||||
impactDescription: Building composables from other composables creates reusable, testable building blocks
|
||||
type: best-practice
|
||||
tags: [vue3, composables, composition-api, patterns, code-organization]
|
||||
---
|
||||
|
||||
# Compose Composables for Complex Logic
|
||||
|
||||
**Impact: MEDIUM** - Composables can (and should) call other composables. This composition pattern allows you to build complex functionality from smaller, focused, reusable pieces. Each composable handles one concern, and higher-level composables combine them.
|
||||
|
||||
This is one of the key advantages of the Composition API over mixins - dependencies are explicit and traceable.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Extract reusable logic into focused, single-purpose composables
|
||||
- [ ] Build complex composables by combining simpler ones
|
||||
- [ ] Ensure each composable has a single responsibility
|
||||
- [ ] Pass data between composed composables via parameters or refs
|
||||
|
||||
**Example: Building a Mouse Tracker from Smaller Composables**
|
||||
|
||||
```javascript
|
||||
// composables/useEventListener.js - Low-level building block
|
||||
import { onMounted, onUnmounted, toValue } from 'vue'
|
||||
|
||||
export function useEventListener(target, event, callback) {
|
||||
onMounted(() => {
|
||||
const el = toValue(target)
|
||||
el.addEventListener(event, callback)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
const el = toValue(target)
|
||||
el.removeEventListener(event, callback)
|
||||
})
|
||||
}
|
||||
|
||||
// composables/useMouse.js - Composes useEventListener
|
||||
import { ref } from 'vue'
|
||||
import { useEventListener } from './useEventListener'
|
||||
|
||||
export function useMouse() {
|
||||
const x = ref(0)
|
||||
const y = ref(0)
|
||||
|
||||
function update(event) {
|
||||
x.value = event.pageX
|
||||
y.value = event.pageY
|
||||
}
|
||||
|
||||
// Reuse the event listener composable
|
||||
useEventListener(window, 'mousemove', update)
|
||||
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
// composables/useMouseInElement.js - Composes useMouse
|
||||
import { ref, computed } from 'vue'
|
||||
import { useMouse } from './useMouse'
|
||||
|
||||
export function useMouseInElement(elementRef) {
|
||||
const { x, y } = useMouse()
|
||||
|
||||
const elementX = computed(() => {
|
||||
if (!elementRef.value) return 0
|
||||
const rect = elementRef.value.getBoundingClientRect()
|
||||
return x.value - rect.left
|
||||
})
|
||||
|
||||
const elementY = computed(() => {
|
||||
if (!elementRef.value) return 0
|
||||
const rect = elementRef.value.getBoundingClientRect()
|
||||
return y.value - rect.top
|
||||
})
|
||||
|
||||
const isOutside = computed(() => {
|
||||
if (!elementRef.value) return true
|
||||
const rect = elementRef.value.getBoundingClientRect()
|
||||
return x.value < rect.left || x.value > rect.right ||
|
||||
y.value < rect.top || y.value > rect.bottom
|
||||
})
|
||||
|
||||
return { x, y, elementX, elementY, isOutside }
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern: Composable Dependency Chain
|
||||
|
||||
```javascript
|
||||
// Layer 1: Primitives
|
||||
export function useEventListener(target, event, callback) { /* ... */ }
|
||||
export function useInterval(callback, delay) { /* ... */ }
|
||||
export function useTimeout(callback, delay) { /* ... */ }
|
||||
|
||||
// Layer 2: Building on primitives
|
||||
export function useWindowSize() {
|
||||
const width = ref(window.innerWidth)
|
||||
const height = ref(window.innerHeight)
|
||||
|
||||
useEventListener(window, 'resize', () => {
|
||||
width.value = window.innerWidth
|
||||
height.value = window.innerHeight
|
||||
})
|
||||
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
export function useOnline() {
|
||||
const isOnline = ref(navigator.onLine)
|
||||
|
||||
useEventListener(window, 'online', () => isOnline.value = true)
|
||||
useEventListener(window, 'offline', () => isOnline.value = false)
|
||||
|
||||
return { isOnline }
|
||||
}
|
||||
|
||||
// Layer 3: Complex features combining multiple composables
|
||||
export function useAutoSave(dataRef, saveFunction, options = {}) {
|
||||
const { debounce = 1000, onlyWhenOnline = true } = options
|
||||
|
||||
const { isOnline } = useOnline()
|
||||
const isSaving = ref(false)
|
||||
const lastSaved = ref(null)
|
||||
|
||||
let timeoutId = null
|
||||
|
||||
watch(dataRef, (newData) => {
|
||||
if (onlyWhenOnline && !isOnline.value) return
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = setTimeout(async () => {
|
||||
isSaving.value = true
|
||||
try {
|
||||
await saveFunction(newData)
|
||||
lastSaved.value = new Date()
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}, debounce)
|
||||
}, { deep: true })
|
||||
|
||||
return { isSaving, lastSaved, isOnline }
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern: Code Organization with Composition
|
||||
|
||||
Extract inline composables when a component gets complex:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// BEFORE: All logic mixed together
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filters = ref({ category: null, minPrice: 0 })
|
||||
const products = ref([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
const sortBy = ref('name')
|
||||
const sortOrder = ref('asc')
|
||||
|
||||
// ...50 more lines of mixed concerns
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// AFTER: Separated into focused composables
|
||||
import { useProductSearch } from './composables/useProductSearch'
|
||||
import { useProductFilters } from './composables/useProductFilters'
|
||||
import { useProductSort } from './composables/useProductSort'
|
||||
|
||||
const { searchQuery, debouncedQuery } = useProductSearch()
|
||||
const { filters, activeFilters, clearFilters } = useProductFilters()
|
||||
const { sortBy, sortOrder, sortedProducts } = useProductSort()
|
||||
|
||||
// Each composable is focused, testable, and potentially reusable
|
||||
</script>
|
||||
```
|
||||
|
||||
## Passing Data Between Composed Composables
|
||||
|
||||
```javascript
|
||||
// Composables can accept refs from other composables
|
||||
export function useFilteredProducts(products, filters) {
|
||||
return computed(() => {
|
||||
let result = toValue(products)
|
||||
|
||||
if (filters.value.category) {
|
||||
result = result.filter(p => p.category === filters.value.category)
|
||||
}
|
||||
|
||||
if (filters.value.minPrice > 0) {
|
||||
result = result.filter(p => p.price >= filters.value.minPrice)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
export function useSortedProducts(products, sortConfig) {
|
||||
return computed(() => {
|
||||
const items = [...toValue(products)]
|
||||
const { field, order } = sortConfig.value
|
||||
|
||||
return items.sort((a, b) => {
|
||||
const comparison = a[field] > b[field] ? 1 : -1
|
||||
return order === 'asc' ? comparison : -comparison
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Usage - composables are chained through their outputs
|
||||
const { products, isLoading } = useFetch('/api/products')
|
||||
const { filters } = useFilters()
|
||||
const filteredProducts = useFilteredProducts(products, filters)
|
||||
const { sortConfig } = useSortConfig()
|
||||
const sortedProducts = useSortedProducts(filteredProducts, sortConfig)
|
||||
```
|
||||
|
||||
## Advantages Over Mixins
|
||||
|
||||
| Composables | Mixins |
|
||||
|-------------|--------|
|
||||
| Explicit dependencies via imports | Implicit dependencies |
|
||||
| Clear data flow via parameters | Unclear which mixin provides what |
|
||||
| No namespace collisions | Properties can conflict |
|
||||
| Easy to trace and debug | Hard to track origins |
|
||||
| TypeScript-friendly | Poor TypeScript support |
|
||||
|
||||
## Reference
|
||||
- [Vue.js Composables](https://vuejs.org/guide/reusability/composables.html)
|
||||
- [Vue.js Composables vs Mixins](https://vuejs.org/guide/reusability/composables.html#comparisons-with-other-techniques)
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
title: Follow Composable Naming Convention and Return Pattern
|
||||
impact: MEDIUM
|
||||
impactDescription: Inconsistent composable patterns lead to confusing APIs and reactivity issues when destructuring
|
||||
type: best-practice
|
||||
tags: [vue3, composables, composition-api, naming, conventions, refs]
|
||||
---
|
||||
|
||||
# Follow Composable Naming Convention and Return Pattern
|
||||
|
||||
**Impact: MEDIUM** - Vue composables should follow established conventions: prefix names with "use" and return plain objects containing refs (not reactive objects). Returning reactive objects causes reactivity loss when destructuring, while inconsistent naming makes code harder to understand.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Name composables with "use" prefix (e.g., `useMouse`, `useFetch`, `useAuth`)
|
||||
- [ ] Return a plain object containing refs, not a reactive object
|
||||
- [ ] Allow both destructuring and object-style access
|
||||
- [ ] Document the returned refs for consumers
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
// WRONG: No "use" prefix - unclear it's a composable
|
||||
export function mousePosition() {
|
||||
const x = ref(0)
|
||||
const y = ref(0)
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
// WRONG: Returning reactive object - destructuring loses reactivity
|
||||
export function useMouse() {
|
||||
const state = reactive({
|
||||
x: 0,
|
||||
y: 0
|
||||
})
|
||||
// When consumer destructures: const { x, y } = useMouse()
|
||||
// x and y become plain values, not reactive!
|
||||
return state
|
||||
}
|
||||
|
||||
// WRONG: Returning single ref directly - inconsistent API
|
||||
export function useCounter() {
|
||||
const count = ref(0)
|
||||
return count // Consumer must use .value everywhere
|
||||
}
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
// CORRECT: "use" prefix and returns plain object with refs
|
||||
export function useMouse() {
|
||||
const x = ref(0)
|
||||
const y = ref(0)
|
||||
|
||||
function update(event) {
|
||||
x.value = event.pageX
|
||||
y.value = event.pageY
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('mousemove', update))
|
||||
onUnmounted(() => window.removeEventListener('mousemove', update))
|
||||
|
||||
// Return plain object containing refs
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
// Consumer can destructure and keep reactivity
|
||||
const { x, y } = useMouse()
|
||||
watch(x, (newX) => console.log('x changed:', newX)) // Works!
|
||||
|
||||
// Or use as object if preferred
|
||||
const mouse = useMouse()
|
||||
console.log(mouse.x.value)
|
||||
```
|
||||
|
||||
## Using reactive() Wrapper for Auto-Unwrapping
|
||||
|
||||
If consumers prefer auto-unwrapping (no `.value`), they can wrap the result:
|
||||
|
||||
```javascript
|
||||
import { reactive } from 'vue'
|
||||
import { useMouse } from './composables/useMouse'
|
||||
|
||||
// Wrapping in reactive() links the refs
|
||||
const mouse = reactive(useMouse())
|
||||
|
||||
// Now access without .value
|
||||
console.log(mouse.x) // Auto-unwrapped, still reactive
|
||||
|
||||
// But DON'T destructure from this!
|
||||
const { x } = reactive(useMouse()) // WRONG: loses reactivity again
|
||||
```
|
||||
|
||||
## Pattern: Returning Both State and Actions
|
||||
|
||||
```javascript
|
||||
// Composable with state AND methods
|
||||
export function useCounter(initialValue = 0) {
|
||||
const count = ref(initialValue)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
function decrement() {
|
||||
count.value--
|
||||
}
|
||||
|
||||
function reset() {
|
||||
count.value = initialValue
|
||||
}
|
||||
|
||||
// Return all refs and functions in plain object
|
||||
return {
|
||||
count,
|
||||
doubleCount,
|
||||
increment,
|
||||
decrement,
|
||||
reset
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const { count, doubleCount, increment, reset } = useCounter(10)
|
||||
```
|
||||
|
||||
## Naming Convention Examples
|
||||
|
||||
| Good Name | Bad Name | Reason |
|
||||
|-----------|----------|--------|
|
||||
| `useFetch` | `fetch` | Conflicts with native fetch |
|
||||
| `useAuth` | `authStore` | "Store" implies Pinia/Vuex |
|
||||
| `useLocalStorage` | `localStorage` | Conflicts with native API |
|
||||
| `useFormValidation` | `validateForm` | Sounds like a one-shot function |
|
||||
| `useWindowSize` | `getWindowSize` | "get" implies synchronous getter |
|
||||
|
||||
## Reference
|
||||
- [Vue.js Composables - Conventions and Best Practices](https://vuejs.org/guide/reusability/composables.html#conventions-and-best-practices)
|
||||
- [Vue.js Composables - Return Values](https://vuejs.org/guide/reusability/composables.html#return-values)
|
||||
@@ -0,0 +1,209 @@
|
||||
---
|
||||
title: Use Options Object Pattern for Composable Parameters
|
||||
impact: MEDIUM
|
||||
impactDescription: Long parameter lists are error-prone and unclear; options objects are self-documenting and extensible
|
||||
type: best-practice
|
||||
tags: [vue3, composables, composition-api, api-design, typescript, patterns]
|
||||
---
|
||||
|
||||
# Use Options Object Pattern for Composable Parameters
|
||||
|
||||
**Impact: MEDIUM** - When a composable accepts multiple parameters (especially optional ones), use an options object instead of positional arguments. This makes the API self-documenting, prevents argument order mistakes, and allows easy extension without breaking changes.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use options object when composable has more than 2-3 parameters
|
||||
- [ ] Always use options object when most parameters are optional
|
||||
- [ ] Provide sensible defaults via destructuring
|
||||
- [ ] Type the options object for better IDE support
|
||||
- [ ] Required parameters can be positional; optional ones in options
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
// WRONG: Many positional parameters - unclear and error-prone
|
||||
export function useFetch(url, method, headers, timeout, retries, onError) {
|
||||
// What was the 4th parameter again?
|
||||
}
|
||||
|
||||
// Usage - which boolean is which?
|
||||
const { data } = useFetch('/api/users', 'GET', null, 5000, 3, handleError)
|
||||
|
||||
// WRONG: Easy to get order wrong
|
||||
export function useDebounce(value, delay, immediate, maxWait) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Is 500 the delay or maxWait? Is true immediate?
|
||||
const debounced = useDebounce(searchQuery, 500, true, 1000)
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
// CORRECT: Options object pattern
|
||||
export function useFetch(url, options = {}) {
|
||||
const {
|
||||
method = 'GET',
|
||||
headers = {},
|
||||
timeout = 30000,
|
||||
retries = 0,
|
||||
onError = null,
|
||||
immediate = true
|
||||
} = options
|
||||
|
||||
// Implementation...
|
||||
}
|
||||
|
||||
// Usage - clear and self-documenting
|
||||
const { data } = useFetch('/api/users', {
|
||||
method: 'POST',
|
||||
timeout: 5000,
|
||||
retries: 3,
|
||||
onError: handleError
|
||||
})
|
||||
|
||||
// CORRECT: With TypeScript for better IDE support
|
||||
interface UseFetchOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
headers?: Record<string, string>
|
||||
timeout?: number
|
||||
retries?: number
|
||||
onError?: (error: Error) => void
|
||||
immediate?: boolean
|
||||
}
|
||||
|
||||
export function useFetch(url: MaybeRefOrGetter<string>, options: UseFetchOptions = {}) {
|
||||
const {
|
||||
method = 'GET',
|
||||
headers = {},
|
||||
timeout = 30000,
|
||||
retries = 0,
|
||||
onError = null,
|
||||
immediate = true
|
||||
} = options
|
||||
|
||||
// TypeScript now provides autocomplete for options
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern: Required + Options
|
||||
|
||||
Keep truly required parameters positional, bundle optional ones:
|
||||
|
||||
```javascript
|
||||
// url is always required, options are not
|
||||
export function useFetch(url, options = {}) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Both key and storage are required for this to make sense
|
||||
export function useStorage(key, storage, options = {}) {
|
||||
const { serializer = JSON, deep = true } = options
|
||||
// ...
|
||||
}
|
||||
|
||||
// Usage
|
||||
useStorage('user-prefs', localStorage, { deep: false })
|
||||
```
|
||||
|
||||
## Pattern: Reactive Options
|
||||
|
||||
Options can also be reactive for dynamic behavior:
|
||||
|
||||
```javascript
|
||||
export function useFetch(url, options = {}) {
|
||||
const {
|
||||
refetch = ref(true), // Can be a ref!
|
||||
interval = null
|
||||
} = options
|
||||
|
||||
watchEffect(() => {
|
||||
if (toValue(refetch)) {
|
||||
// Perform fetch
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Usage with reactive option
|
||||
const shouldFetch = ref(true)
|
||||
const { data } = useFetch('/api/data', { refetch: shouldFetch })
|
||||
|
||||
// Later, disable fetching
|
||||
shouldFetch.value = false
|
||||
```
|
||||
|
||||
## Pattern: Returning Configuration
|
||||
|
||||
Options objects also work well for return values:
|
||||
|
||||
```javascript
|
||||
export function useCounter(options = {}) {
|
||||
const { initial = 0, min = -Infinity, max = Infinity, step = 1 } = options
|
||||
|
||||
const count = ref(initial)
|
||||
|
||||
function increment() {
|
||||
count.value = Math.min(count.value + step, max)
|
||||
}
|
||||
|
||||
function decrement() {
|
||||
count.value = Math.max(count.value - step, min)
|
||||
}
|
||||
|
||||
function set(value) {
|
||||
count.value = Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
return { count, increment, decrement, set }
|
||||
}
|
||||
|
||||
// Clear, readable usage
|
||||
const { count, increment, decrement } = useCounter({
|
||||
initial: 10,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 5
|
||||
})
|
||||
```
|
||||
|
||||
## VueUse Convention
|
||||
|
||||
VueUse uses this pattern extensively:
|
||||
|
||||
```javascript
|
||||
import { useDebounceFn, useThrottleFn, useLocalStorage } from '@vueuse/core'
|
||||
|
||||
// All use options objects
|
||||
const debouncedFn = useDebounceFn(fn, 1000, { maxWait: 5000 })
|
||||
|
||||
const throttledFn = useThrottleFn(fn, 1000, { trailing: true, leading: false })
|
||||
|
||||
const state = useLocalStorage('key', defaultValue, {
|
||||
deep: true,
|
||||
listenToStorageChanges: true,
|
||||
serializer: {
|
||||
read: JSON.parse,
|
||||
write: JSON.stringify
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Anti-pattern: Boolean Trap
|
||||
|
||||
Options objects prevent the "boolean trap":
|
||||
|
||||
```javascript
|
||||
// BAD: What do these booleans mean?
|
||||
useModal(true, false, true)
|
||||
|
||||
// GOOD: Self-documenting
|
||||
useModal({
|
||||
closable: true,
|
||||
backdrop: false,
|
||||
keyboard: true
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Composables](https://vuejs.org/guide/reusability/composables.html)
|
||||
- [VueUse Composables](https://vueuse.org/) - Examples of options pattern
|
||||
- [Good Practices for Vue Composables](https://dev.to/jacobandrewsky/good-practices-and-design-patterns-for-vue-composables-24lk)
|
||||
221
skills/vue-best-practices/reference/composable-readonly-state.md
Normal file
221
skills/vue-best-practices/reference/composable-readonly-state.md
Normal file
@@ -0,0 +1,221 @@
|
||||
---
|
||||
title: Return State as Readonly with Explicit Update Methods
|
||||
impact: MEDIUM
|
||||
impactDescription: Exposing mutable state directly allows uncontrolled mutations scattered throughout the codebase
|
||||
type: best-practice
|
||||
tags: [vue3, composables, composition-api, readonly, encapsulation, state-management]
|
||||
---
|
||||
|
||||
# Return State as Readonly with Explicit Update Methods
|
||||
|
||||
**Impact: MEDIUM** - When a composable manages state that should only be modified in controlled ways, return the state as `readonly` and provide explicit methods for updates. This prevents scattered, uncontrolled mutations and makes state changes traceable and predictable.
|
||||
|
||||
Exposing raw refs allows any consumer to modify state directly, leading to bugs that are hard to track because mutations can happen anywhere in the codebase.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use `readonly()` to wrap state that shouldn't be directly modified
|
||||
- [ ] Provide explicit methods for all valid state transitions
|
||||
- [ ] Document the intended ways to update state
|
||||
- [ ] Consider returning `shallowReadonly()` for performance with large objects
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
// WRONG: State is fully mutable by any consumer
|
||||
export function useCart() {
|
||||
const items = ref([])
|
||||
const total = computed(() =>
|
||||
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||
)
|
||||
|
||||
return { items, total } // Anyone can mutate items directly!
|
||||
}
|
||||
|
||||
// Consumer code - mutations scattered everywhere
|
||||
const { items, total } = useCart()
|
||||
|
||||
// In component A
|
||||
items.value.push({ id: 1, name: 'Widget', price: 10, quantity: 1 })
|
||||
|
||||
// In component B - different mutation pattern
|
||||
items.value = items.value.filter(item => item.id !== 1)
|
||||
|
||||
// In component C - direct modification
|
||||
items.value[0].quantity = 5
|
||||
|
||||
// Hard to track: where did this item come from? Why did quantity change?
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
|
||||
export function useCart() {
|
||||
const items = ref([])
|
||||
|
||||
const total = computed(() =>
|
||||
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||
)
|
||||
|
||||
const itemCount = computed(() =>
|
||||
items.value.reduce((sum, item) => sum + item.quantity, 0)
|
||||
)
|
||||
|
||||
// Explicit, controlled mutations
|
||||
function addItem(product, quantity = 1) {
|
||||
const existing = items.value.find(item => item.id === product.id)
|
||||
if (existing) {
|
||||
existing.quantity += quantity
|
||||
} else {
|
||||
items.value.push({ ...product, quantity })
|
||||
}
|
||||
}
|
||||
|
||||
function removeItem(productId) {
|
||||
const index = items.value.findIndex(item => item.id === productId)
|
||||
if (index > -1) {
|
||||
items.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function updateQuantity(productId, quantity) {
|
||||
const item = items.value.find(item => item.id === productId)
|
||||
if (item) {
|
||||
item.quantity = Math.max(0, quantity)
|
||||
if (item.quantity === 0) {
|
||||
removeItem(productId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearCart() {
|
||||
items.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
// State is readonly - can't be mutated directly
|
||||
items: readonly(items),
|
||||
total,
|
||||
itemCount,
|
||||
// Only these methods can modify state
|
||||
addItem,
|
||||
removeItem,
|
||||
updateQuantity,
|
||||
clearCart
|
||||
}
|
||||
}
|
||||
|
||||
// Consumer code - controlled mutations only
|
||||
const { items, total, addItem, removeItem, updateQuantity } = useCart()
|
||||
|
||||
// items.value.push(...) // TypeScript error: readonly!
|
||||
// items.value = [] // TypeScript error: readonly!
|
||||
|
||||
// Correct way - through explicit methods
|
||||
addItem({ id: 1, name: 'Widget', price: 10 })
|
||||
updateQuantity(1, 3)
|
||||
removeItem(1)
|
||||
```
|
||||
|
||||
## Pattern: Internal vs External State
|
||||
|
||||
Keep internal state private, expose readonly view:
|
||||
|
||||
```javascript
|
||||
export function useAuth() {
|
||||
// Internal, fully mutable
|
||||
const _user = ref(null)
|
||||
const _token = ref(null)
|
||||
const _isLoading = ref(false)
|
||||
const _error = ref(null)
|
||||
|
||||
async function login(credentials) {
|
||||
_isLoading.value = true
|
||||
_error.value = null
|
||||
|
||||
try {
|
||||
const response = await api.login(credentials)
|
||||
_user.value = response.user
|
||||
_token.value = response.token
|
||||
} catch (e) {
|
||||
_error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
_user.value = null
|
||||
_token.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// Readonly views of internal state
|
||||
user: readonly(_user),
|
||||
isAuthenticated: computed(() => !!_user.value),
|
||||
isLoading: readonly(_isLoading),
|
||||
error: readonly(_error),
|
||||
// Methods for state changes
|
||||
login,
|
||||
logout
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## When to Use readonly vs Not
|
||||
|
||||
| Use `readonly` | Don't Use `readonly` |
|
||||
|----------------|----------------------|
|
||||
| State with specific update rules | Simple two-way binding state |
|
||||
| Shared state between components | Form input values |
|
||||
| State that needs validation on change | Local component state |
|
||||
| When debugging mutation sources matters | When consumers need full control |
|
||||
|
||||
```javascript
|
||||
// Form input - consumers SHOULD mutate directly
|
||||
export function useForm(initial) {
|
||||
const values = ref({ ...initial })
|
||||
return { values } // No readonly - it's meant to be mutated
|
||||
}
|
||||
|
||||
// Counter with min/max - needs controlled mutations
|
||||
export function useCounter(min = 0, max = 100) {
|
||||
const _count = ref(min)
|
||||
|
||||
function increment() {
|
||||
if (_count.value < max) _count.value++
|
||||
}
|
||||
|
||||
function decrement() {
|
||||
if (_count.value > min) _count.value--
|
||||
}
|
||||
|
||||
return {
|
||||
count: readonly(_count),
|
||||
increment,
|
||||
decrement
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance: shallowReadonly
|
||||
|
||||
For large objects, use `shallowReadonly` to avoid deep readonly conversion:
|
||||
|
||||
```javascript
|
||||
export function useLargeDataset() {
|
||||
const data = ref([/* thousands of items */])
|
||||
|
||||
return {
|
||||
// shallowReadonly - only top level is readonly
|
||||
// Nested properties are still technically mutable
|
||||
// but the ref itself can't be reassigned
|
||||
data: shallowReadonly(data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Reactivity API - readonly](https://vuejs.org/api/reactivity-core.html#readonly)
|
||||
- [13 Vue Composables Tips](https://michaelnthiessen.com/13-vue-composables-tips/)
|
||||
@@ -0,0 +1,193 @@
|
||||
---
|
||||
title: Don't Wrap Utility Functions as Composables
|
||||
impact: MEDIUM
|
||||
impactDescription: Wrapping stateless utility functions as composables adds unnecessary complexity without any benefit
|
||||
type: best-practice
|
||||
tags: [vue3, composables, composition-api, utilities, patterns]
|
||||
---
|
||||
|
||||
# Don't Wrap Utility Functions as Composables
|
||||
|
||||
**Impact: MEDIUM** - Not every function needs to be a composable. Composables are specifically for encapsulating **stateful logic** that uses Vue's reactivity system. Pure utility functions that just transform data or perform calculations should remain as regular JavaScript functions.
|
||||
|
||||
Wrapping utility functions as composables adds unnecessary abstraction, makes code harder to understand, and provides no benefits since there's no reactive state to manage.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Identify if the function manages reactive state or uses Vue lifecycle hooks
|
||||
- [ ] Keep pure transformation/calculation functions as regular utilities
|
||||
- [ ] Export utilities directly, not wrapped in a function that returns them
|
||||
- [ ] Reserve the "use" prefix for actual composables
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
// WRONG: These are just utility functions wrapped unnecessarily
|
||||
|
||||
// Adds no value - no reactive state
|
||||
export function useFormatters() {
|
||||
const formatDate = (date) => {
|
||||
return new Intl.DateTimeFormat('en-US').format(date)
|
||||
}
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
const capitalize = (str) => {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
|
||||
return { formatDate, formatCurrency, capitalize }
|
||||
}
|
||||
|
||||
// WRONG: Pure calculation, no reactive state
|
||||
export function useMath() {
|
||||
const add = (a, b) => a + b
|
||||
const multiply = (a, b) => a * b
|
||||
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
|
||||
|
||||
return { add, multiply, clamp }
|
||||
}
|
||||
|
||||
// Usage adds ceremony for no benefit
|
||||
const { formatDate, formatCurrency } = useFormatters()
|
||||
const { clamp } = useMath()
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
// CORRECT: Export as regular utility functions
|
||||
|
||||
// utils/formatters.js
|
||||
export function formatDate(date) {
|
||||
return new Intl.DateTimeFormat('en-US').format(date)
|
||||
}
|
||||
|
||||
export function formatCurrency(amount) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export function capitalize(str) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
|
||||
// utils/math.js
|
||||
export function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
// Usage - simple and direct
|
||||
import { formatDate, formatCurrency } from '@/utils/formatters'
|
||||
import { clamp } from '@/utils/math'
|
||||
```
|
||||
|
||||
## When to Use Composables vs Utilities
|
||||
|
||||
| Use Composable When... | Use Utility When... |
|
||||
|------------------------|---------------------|
|
||||
| Managing reactive state (`ref`, `reactive`) | Pure data transformation |
|
||||
| Using lifecycle hooks (`onMounted`, `onUnmounted`) | Stateless calculations |
|
||||
| Setting up watchers (`watch`, `watchEffect`) | String/array manipulation |
|
||||
| Creating computed properties | Formatting functions |
|
||||
| Needs cleanup on component unmount | Validation functions |
|
||||
| State changes over time | Mathematical operations |
|
||||
|
||||
## Examples: Composables vs Utilities
|
||||
|
||||
```javascript
|
||||
// COMPOSABLE: Has reactive state and lifecycle
|
||||
export function useWindowSize() {
|
||||
const width = ref(window.innerWidth)
|
||||
const height = ref(window.innerHeight)
|
||||
|
||||
function update() {
|
||||
width.value = window.innerWidth
|
||||
height.value = window.innerHeight
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('resize', update))
|
||||
onUnmounted(() => window.removeEventListener('resize', update))
|
||||
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
// UTILITY: Pure transformation, no state
|
||||
export function parseQueryString(queryString) {
|
||||
return Object.fromEntries(new URLSearchParams(queryString))
|
||||
}
|
||||
|
||||
// COMPOSABLE: Manages form state over time
|
||||
export function useForm(initialValues) {
|
||||
const values = ref({ ...initialValues })
|
||||
const errors = ref({})
|
||||
const isDirty = computed(() =>
|
||||
JSON.stringify(values.value) !== JSON.stringify(initialValues)
|
||||
)
|
||||
|
||||
function reset() {
|
||||
values.value = { ...initialValues }
|
||||
errors.value = {}
|
||||
}
|
||||
|
||||
return { values, errors, isDirty, reset }
|
||||
}
|
||||
|
||||
// UTILITY: Stateless validation
|
||||
export function validateEmail(email) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
||||
}
|
||||
|
||||
export function validateRequired(value) {
|
||||
return value !== null && value !== undefined && value !== ''
|
||||
}
|
||||
```
|
||||
|
||||
## Mixed Pattern: Composable Using Utilities
|
||||
|
||||
It's perfectly fine for composables to use utility functions:
|
||||
|
||||
```javascript
|
||||
// utils/validators.js
|
||||
export function validateEmail(email) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
||||
}
|
||||
|
||||
// composables/useEmailInput.js
|
||||
import { ref, computed } from 'vue'
|
||||
import { validateEmail } from '@/utils/validators'
|
||||
|
||||
export function useEmailInput(initialValue = '') {
|
||||
const email = ref(initialValue)
|
||||
const isValid = computed(() => validateEmail(email.value))
|
||||
const error = computed(() =>
|
||||
email.value && !isValid.value ? 'Invalid email format' : null
|
||||
)
|
||||
|
||||
return { email, isValid, error }
|
||||
}
|
||||
```
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
src/
|
||||
composables/ # Stateful reactive logic
|
||||
useAuth.js
|
||||
useFetch.js
|
||||
useLocalStorage.js
|
||||
utils/ # Pure utility functions
|
||||
formatters.js
|
||||
validators.js
|
||||
math.js
|
||||
strings.js
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Composables - What is a Composable](https://vuejs.org/guide/reusability/composables.html#what-is-a-composable)
|
||||
- [Common Mistakes Creating Composition Functions](https://www.telerik.com/blogs/common-mistakes-creating-composition-functions-vue)
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: Composition API Produces Smaller, More Efficient Bundles
|
||||
impact: LOW
|
||||
impactDescription: Understanding this helps justify Composition API adoption for performance-sensitive projects
|
||||
type: efficiency
|
||||
tags: [vue3, composition-api, bundle-size, minification, performance]
|
||||
---
|
||||
|
||||
# Composition API Produces Smaller, More Efficient Bundles
|
||||
|
||||
**Impact: LOW** - The Composition API is more minification-friendly than the Options API, resulting in smaller production bundles and less runtime overhead. This is a beneficial side-effect rather than a primary reason to choose Composition API.
|
||||
|
||||
In `<script setup>`, variables and functions can have their names safely shortened by minifiers because they're local to the component scope. Options API properties (methods, computed, data) are accessed via string keys on `this`, which cannot be minified.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Prefer `<script setup>` for optimal minification
|
||||
- [ ] Understand that Composition API avoids the `this` proxy overhead
|
||||
- [ ] For Options API-free projects, consider enabling the compile-time flag to drop Options API code
|
||||
- [ ] Be aware that libraries using Options API will include that code regardless
|
||||
|
||||
**Bundle Size Difference:**
|
||||
```javascript
|
||||
// OPTIONS API - Before minification
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
userAuthenticated: false,
|
||||
currentUserProfile: null,
|
||||
navigationMenuOpen: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
displayUserName() {
|
||||
return this.currentUserProfile?.name || 'Guest'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleUserAuthentication() {
|
||||
this.userAuthenticated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIONS API - After minification (property names preserved)
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
userAuthenticated: false, // Cannot shorten
|
||||
currentUserProfile: null, // Cannot shorten
|
||||
navigationMenuOpen: false // Cannot shorten
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
displayUserName() { // Cannot shorten
|
||||
return this.currentUserProfile?.name || 'Guest'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleUserAuthentication() { // Cannot shorten
|
||||
this.userAuthenticated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// COMPOSITION API - Before minification
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const userAuthenticated = ref(false)
|
||||
const currentUserProfile = ref(null)
|
||||
const navigationMenuOpen = ref(false)
|
||||
|
||||
const displayUserName = computed(() =>
|
||||
currentUserProfile.value?.name || 'Guest'
|
||||
)
|
||||
|
||||
function handleUserAuthentication() {
|
||||
userAuthenticated.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
// COMPOSITION API - After minification
|
||||
<script setup>
|
||||
import { ref as r, computed as c } from 'vue'
|
||||
|
||||
const a = r(false) // userAuthenticated -> a
|
||||
const b = r(null) // currentUserProfile -> b
|
||||
const d = r(false) // navigationMenuOpen -> d
|
||||
|
||||
const e = c(() => b.value?.name || 'Guest') // displayUserName -> e
|
||||
|
||||
function f() { a.value = true } // handleUserAuthentication -> f
|
||||
</script>
|
||||
```
|
||||
|
||||
## Runtime Performance
|
||||
|
||||
```javascript
|
||||
// OPTIONS API - Every property access goes through proxy
|
||||
export default {
|
||||
methods: {
|
||||
doSomething() {
|
||||
// this.count triggers proxy get trap
|
||||
// this.items.push triggers proxy get trap
|
||||
console.log(this.count) // Proxy overhead
|
||||
this.items.push(item) // Proxy overhead
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// COMPOSITION API - Direct variable access
|
||||
<script setup>
|
||||
const count = ref(0)
|
||||
const items = ref([])
|
||||
|
||||
function doSomething() {
|
||||
// Direct variable access - no proxy indirection for the variable itself
|
||||
console.log(count.value)
|
||||
items.value.push(item)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Dropping Options API for Pure Composition API Projects
|
||||
|
||||
```javascript
|
||||
// vite.config.js - Only for projects using exclusively Composition API
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
define: {
|
||||
// This drops Options API support from the bundle
|
||||
__VUE_OPTIONS_API__: false
|
||||
}
|
||||
})
|
||||
|
||||
// WARNING: This will break any component (including from libraries)
|
||||
// that uses Options API. Only use if you're certain all components
|
||||
// use Composition API.
|
||||
```
|
||||
|
||||
## When This Matters
|
||||
|
||||
The bundle size difference is typically:
|
||||
- **Small components**: Negligible difference
|
||||
- **Large applications**: 10-15% smaller with Composition API
|
||||
- **With Options API flag disabled**: Additional 5-10% savings
|
||||
|
||||
Choose Composition API primarily for its code organization and logic reuse benefits. The bundle size improvement is a nice bonus, not the main reason to switch.
|
||||
|
||||
## Reference
|
||||
- [Composition API FAQ - Smaller Production Bundle](https://vuejs.org/guide/extras/composition-api-faq.html#smaller-production-bundle-and-less-overhead)
|
||||
- [Vue 3 Build Feature Flags](https://github.com/vuejs/core/tree/main/packages/vue#bundler-build-feature-flags)
|
||||
@@ -0,0 +1,213 @@
|
||||
---
|
||||
title: Organize Composition API Code by Logical Concern, Not Option Type
|
||||
impact: MEDIUM
|
||||
impactDescription: Poor code organization in Composition API leads to spaghetti code worse than Options API
|
||||
type: best-practice
|
||||
tags: [vue3, composition-api, code-organization, refactoring, maintainability]
|
||||
---
|
||||
|
||||
# Organize Composition API Code by Logical Concern, Not Option Type
|
||||
|
||||
**Impact: MEDIUM** - The Composition API removes the "guard rails" of Options API that force code into data/methods/computed buckets. Without intentional organization, Composition API code can become more disorganized than Options API. Group related code together by feature or logical concern.
|
||||
|
||||
The key insight is that Composition API gives you flexibility - which requires discipline. Apply the same code organization principles you would use for any well-structured JavaScript code.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Group related state, computed, and methods together by feature
|
||||
- [ ] Extract related logic into composables when it grows
|
||||
- [ ] Don't scatter related code throughout the script section
|
||||
- [ ] Use comments or regions to delineate logical sections in larger components
|
||||
- [ ] Consider splitting large components into smaller ones or composables
|
||||
|
||||
**Disorganized (Bad):**
|
||||
```vue
|
||||
<script setup>
|
||||
// Scattered code - hard to understand what relates to what
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
|
||||
const searchQuery = ref('')
|
||||
const items = ref([])
|
||||
const selectedItem = ref(null)
|
||||
const isModalOpen = ref(false)
|
||||
const sortOrder = ref('asc')
|
||||
const filterCategory = ref('all')
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
return items.value.filter(i => i.category === filterCategory.value)
|
||||
})
|
||||
|
||||
function openModal() { isModalOpen.value = true }
|
||||
|
||||
const sortedItems = computed(() => {
|
||||
return [...filteredItems.value].sort(/* ... */)
|
||||
})
|
||||
|
||||
function closeModal() { isModalOpen.value = false }
|
||||
|
||||
watch(searchQuery, async (query) => { /* fetch */ })
|
||||
|
||||
function selectItem(item) { selectedItem.value = item }
|
||||
|
||||
const searchResults = computed(() => {
|
||||
return items.value.filter(i => i.name.includes(searchQuery.value))
|
||||
})
|
||||
|
||||
onMounted(() => { fetchItems() })
|
||||
|
||||
async function fetchItems() { /* ... */ }
|
||||
</script>
|
||||
```
|
||||
|
||||
**Organized by Concern (Good):**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
|
||||
// ============================================
|
||||
// DATA FETCHING & STATE
|
||||
// ============================================
|
||||
const items = ref([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function fetchItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
items.value = await api.getItems()
|
||||
} catch (e) {
|
||||
error.value = e
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => fetchItems())
|
||||
|
||||
// ============================================
|
||||
// SEARCH
|
||||
// ============================================
|
||||
const searchQuery = ref('')
|
||||
|
||||
const searchResults = computed(() =>
|
||||
items.value.filter(i =>
|
||||
i.name.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
)
|
||||
)
|
||||
|
||||
watch(searchQuery, async (query) => {
|
||||
if (query.length > 2) {
|
||||
await fetchItems({ search: query })
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// FILTERING & SORTING
|
||||
// ============================================
|
||||
const filterCategory = ref('all')
|
||||
const sortOrder = ref('asc')
|
||||
|
||||
const filteredItems = computed(() =>
|
||||
searchResults.value.filter(i =>
|
||||
filterCategory.value === 'all' || i.category === filterCategory.value
|
||||
)
|
||||
)
|
||||
|
||||
const sortedItems = computed(() =>
|
||||
[...filteredItems.value].sort((a, b) =>
|
||||
sortOrder.value === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)
|
||||
)
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// SELECTION & MODAL
|
||||
// ============================================
|
||||
const selectedItem = ref(null)
|
||||
const isModalOpen = ref(false)
|
||||
|
||||
function selectItem(item) {
|
||||
selectedItem.value = item
|
||||
openModal()
|
||||
}
|
||||
|
||||
function openModal() { isModalOpen.value = true }
|
||||
function closeModal() {
|
||||
isModalOpen.value = false
|
||||
selectedItem.value = null
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**Best: Extract to Composables:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { useItems } from '@/composables/useItems'
|
||||
import { useSearch } from '@/composables/useSearch'
|
||||
import { useModal } from '@/composables/useModal'
|
||||
|
||||
// Each composable encapsulates a logical concern
|
||||
const { items, isLoading, error, fetchItems } = useItems()
|
||||
const { searchQuery, searchResults } = useSearch(items)
|
||||
const {
|
||||
selectedItem,
|
||||
isOpen: isModalOpen,
|
||||
open: openModal,
|
||||
close: closeModal
|
||||
} = useModal()
|
||||
|
||||
function selectItem(item) {
|
||||
selectedItem.value = item
|
||||
openModal()
|
||||
}
|
||||
</script>
|
||||
|
||||
// composables/useItems.js
|
||||
export function useItems() {
|
||||
const items = ref([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function fetchItems(params = {}) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
items.value = await api.getItems(params)
|
||||
} catch (e) {
|
||||
error.value = e
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => fetchItems())
|
||||
|
||||
return { items, isLoading, error, fetchItems }
|
||||
}
|
||||
```
|
||||
|
||||
## Signs Your Component Needs Refactoring
|
||||
|
||||
1. **Scrolling between related code** - If you're jumping around to understand one feature
|
||||
2. **300+ lines in script setup** - Consider extracting composables
|
||||
3. **Multiple unrelated features** - Each should be its own composable
|
||||
4. **Similar patterns repeated** - Extract to shared composable
|
||||
|
||||
## When to Extract to Composables
|
||||
|
||||
```javascript
|
||||
// Extract when:
|
||||
// - Logic is reused across components
|
||||
// - A feature is self-contained (search, pagination, form handling)
|
||||
// - Component is getting too large (>200 lines)
|
||||
// - You want to test logic in isolation
|
||||
|
||||
// Keep inline when:
|
||||
// - Logic is simple and component-specific
|
||||
// - Extracting would add more complexity than it removes
|
||||
// - The component is already small and focused
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Composition API FAQ - More Flexible Code Organization](https://vuejs.org/guide/extras/composition-api-faq.html#more-flexible-code-organization)
|
||||
- [Composables](https://vuejs.org/guide/reusability/composables.html)
|
||||
@@ -0,0 +1,208 @@
|
||||
---
|
||||
title: Use Composables Instead of Mixins for Logic Reuse
|
||||
impact: HIGH
|
||||
impactDescription: Mixins cause naming conflicts, unclear data origins, and inflexible logic - composables solve all these problems
|
||||
type: best-practice
|
||||
tags: [vue3, composition-api, composables, mixins, refactoring, code-reuse]
|
||||
---
|
||||
|
||||
# Use Composables Instead of Mixins for Logic Reuse
|
||||
|
||||
**Impact: HIGH** - Mixins, the primary logic reuse mechanism in Options API, have fundamental flaws that make code hard to maintain. Composables (Composition API functions) solve all mixin drawbacks: unclear property origins, naming conflicts, and inability to parameterize.
|
||||
|
||||
The ability to create clean, reusable logic through composables is the primary advantage of the Composition API.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Migrate existing mixins to composables when refactoring
|
||||
- [ ] Never create new mixins - use composables instead
|
||||
- [ ] Use explicit imports to make data origins clear
|
||||
- [ ] Parameterize composables to make them flexible
|
||||
- [ ] Prefix composables with "use" (useAuth, useFetch, useForm)
|
||||
|
||||
**Problems with Mixins:**
|
||||
```javascript
|
||||
// userMixin.js
|
||||
export const userMixin = {
|
||||
data() {
|
||||
return {
|
||||
user: null,
|
||||
loading: false // Conflict waiting to happen!
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchUser() { /* ... */ }
|
||||
}
|
||||
}
|
||||
|
||||
// authMixin.js
|
||||
export const authMixin = {
|
||||
data() {
|
||||
return {
|
||||
token: null,
|
||||
loading: false // NAME CONFLICT with userMixin!
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
login() { /* ... */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Component using mixins - PROBLEMATIC
|
||||
export default {
|
||||
mixins: [userMixin, authMixin],
|
||||
|
||||
mounted() {
|
||||
// PROBLEM 1: Where does 'user' come from? Have to check mixins
|
||||
console.log(this.user)
|
||||
|
||||
// PROBLEM 2: Which 'loading'? Last mixin wins, silently!
|
||||
console.log(this.loading) // Is this user loading or auth loading?
|
||||
|
||||
// PROBLEM 3: Can't customize behavior per-component
|
||||
this.fetchUser() // Always fetches the same way
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Composables Solution:**
|
||||
```javascript
|
||||
// composables/useUser.js
|
||||
import { ref } from 'vue'
|
||||
|
||||
export function useUser(userId) { // Can accept parameters!
|
||||
const user = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function fetchUser() {
|
||||
loading.value = true
|
||||
try {
|
||||
user.value = await api.getUser(userId)
|
||||
} catch (e) {
|
||||
error.value = e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { user, loading, error, fetchUser }
|
||||
}
|
||||
|
||||
// composables/useAuth.js
|
||||
import { ref } from 'vue'
|
||||
|
||||
export function useAuth() {
|
||||
const token = ref(null)
|
||||
const loading = ref(false) // No conflict - it's scoped!
|
||||
|
||||
async function login(credentials) { /* ... */ }
|
||||
function logout() { /* ... */ }
|
||||
|
||||
return { token, loading, login, logout }
|
||||
}
|
||||
|
||||
// Component using composables - CLEAR AND FLEXIBLE
|
||||
<script setup>
|
||||
import { useUser } from '@/composables/useUser'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
|
||||
// SOLUTION 1: Clear where everything comes from
|
||||
const { user, loading: userLoading, fetchUser } = useUser(123)
|
||||
const { token, loading: authLoading, login } = useAuth()
|
||||
|
||||
// SOLUTION 2: Rename to avoid any conflicts
|
||||
// userLoading vs authLoading - explicit!
|
||||
|
||||
// SOLUTION 3: Parameterize behavior
|
||||
const adminUser = useUser(adminId)
|
||||
const currentUser = useUser(currentUserId)
|
||||
// Each has its own state!
|
||||
|
||||
onMounted(() => {
|
||||
fetchUser() // Explicitly from useUser
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Migrating from Mixins
|
||||
|
||||
```javascript
|
||||
// BEFORE: Mixin with options
|
||||
export const formMixin = {
|
||||
data() {
|
||||
return { errors: {}, submitting: false }
|
||||
},
|
||||
methods: {
|
||||
validate() { /* ... */ },
|
||||
submit() { /* ... */ }
|
||||
}
|
||||
}
|
||||
|
||||
// AFTER: Composable with flexibility
|
||||
export function useForm(initialValues, validationSchema) {
|
||||
const values = ref({ ...initialValues })
|
||||
const errors = ref({})
|
||||
const submitting = ref(false)
|
||||
const touched = ref({})
|
||||
|
||||
function validate() {
|
||||
errors.value = validationSchema.validate(values.value)
|
||||
return Object.keys(errors.value).length === 0
|
||||
}
|
||||
|
||||
async function submit(onSubmit) {
|
||||
if (!validate()) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await onSubmit(values.value)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
values.value = { ...initialValues }
|
||||
errors.value = {}
|
||||
touched.value = {}
|
||||
}
|
||||
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
submitting,
|
||||
touched,
|
||||
validate,
|
||||
submit,
|
||||
reset
|
||||
}
|
||||
}
|
||||
|
||||
// Usage - now parameterizable and explicit
|
||||
const loginForm = useForm(
|
||||
{ email: '', password: '' },
|
||||
loginValidationSchema
|
||||
)
|
||||
|
||||
const registerForm = useForm(
|
||||
{ email: '', password: '', name: '' },
|
||||
registerValidationSchema
|
||||
)
|
||||
```
|
||||
|
||||
## Composition Over Mixins Benefits
|
||||
|
||||
| Aspect | Mixins | Composables |
|
||||
|--------|--------|-------------|
|
||||
| Property origin | Unclear | Explicit import |
|
||||
| Naming conflicts | Silent overwrites | Explicit rename |
|
||||
| Parameters | Not possible | Fully supported |
|
||||
| Type inference | Poor | Excellent |
|
||||
| Reuse instances | One per component | Multiple allowed |
|
||||
| Tree-shaking | Not possible | Fully supported |
|
||||
|
||||
## Reference
|
||||
- [Composition API FAQ - Better Logic Reuse](https://vuejs.org/guide/extras/composition-api-faq.html#better-logic-reuse)
|
||||
- [Composables](https://vuejs.org/guide/reusability/composables.html)
|
||||
- [VueUse - Collection of Composables](https://vueuse.org/)
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: Composition API Uses Mutable Reactivity, Not Functional Programming
|
||||
impact: MEDIUM
|
||||
impactDescription: Misunderstanding the paradigm leads to incorrect state management patterns
|
||||
type: gotcha
|
||||
tags: [vue3, composition-api, reactivity, functional-programming, paradigm]
|
||||
---
|
||||
|
||||
# Composition API Uses Mutable Reactivity, Not Functional Programming
|
||||
|
||||
**Impact: MEDIUM** - Despite being function-based, the Composition API follows Vue's mutable, fine-grained reactivity paradigm—NOT functional programming principles. Treating it like a functional paradigm leads to incorrect patterns like unnecessary cloning, immutable-style updates, or avoiding mutation when mutation is the intended pattern.
|
||||
|
||||
Vue's Composition API leverages imported functions to organize code, but the underlying model is based on mutable reactive state that Vue tracks and responds to. This is fundamentally different from functional programming with immutability (like Redux reducers).
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Mutate reactive state directly - don't create new objects for every update
|
||||
- [ ] Don't apply immutability patterns unnecessarily (spreading, Object.assign for updates)
|
||||
- [ ] Understand that `ref()` and `reactive()` enable mutable state tracking
|
||||
- [ ] Use Vue's reactivity as intended: direct mutation with automatic tracking
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { ref } from 'vue'
|
||||
|
||||
const todos = ref([])
|
||||
|
||||
// WRONG: Treating Vue like Redux/functional - unnecessary immutability
|
||||
function addTodo(todo) {
|
||||
// Creating a new array every time is wasteful in Vue
|
||||
todos.value = [...todos.value, todo]
|
||||
}
|
||||
|
||||
function updateTodo(id, updates) {
|
||||
// Unnecessary spread - Vue tracks mutations directly
|
||||
todos.value = todos.value.map(t =>
|
||||
t.id === id ? { ...t, ...updates } : t
|
||||
)
|
||||
}
|
||||
|
||||
const user = ref({ name: 'John', age: 30 })
|
||||
|
||||
// WRONG: Creating new object for simple update
|
||||
function updateName(newName) {
|
||||
user.value = { ...user.value, name: newName }
|
||||
}
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
const todos = ref([])
|
||||
|
||||
// CORRECT: Mutate directly - Vue tracks the change
|
||||
function addTodo(todo) {
|
||||
todos.value.push(todo) // Direct mutation is the Vue way
|
||||
}
|
||||
|
||||
function updateTodo(id, updates) {
|
||||
const todo = todos.value.find(t => t.id === id)
|
||||
if (todo) {
|
||||
Object.assign(todo, updates) // Direct mutation
|
||||
}
|
||||
}
|
||||
|
||||
const user = ref({ name: 'John', age: 30 })
|
||||
|
||||
// CORRECT: Mutate the property directly
|
||||
function updateName(newName) {
|
||||
user.value.name = newName // Vue tracks this!
|
||||
}
|
||||
|
||||
// Or with reactive():
|
||||
const state = reactive({ name: 'John', age: 30 })
|
||||
|
||||
function updateNameReactive(newName) {
|
||||
state.name = newName // Direct mutation, reactivity preserved
|
||||
}
|
||||
```
|
||||
|
||||
## When Immutability Patterns Make Sense
|
||||
|
||||
```javascript
|
||||
// Immutability IS appropriate when:
|
||||
|
||||
// 1. Replacing the entire state (e.g., from API response)
|
||||
const users = ref([])
|
||||
async function fetchUsers() {
|
||||
users.value = await api.getUsers() // Complete replacement is fine
|
||||
}
|
||||
|
||||
// 2. When you need a snapshot for comparison
|
||||
const previousState = { ...currentState } // For undo/redo
|
||||
|
||||
// 3. When passing data to external libraries expecting immutable data
|
||||
const chartData = computed(() => [...rawData.value]) // Copy for chart lib
|
||||
```
|
||||
|
||||
## The Vue Mental Model
|
||||
|
||||
```javascript
|
||||
// Vue's reactivity is like a spreadsheet:
|
||||
// - Cell A1 contains a value (ref)
|
||||
// - Cell B1 has a formula referencing A1 (computed)
|
||||
// - Change A1, and B1 automatically updates
|
||||
|
||||
const a1 = ref(10)
|
||||
const b1 = computed(() => a1.value * 2)
|
||||
|
||||
// You CHANGE A1 (mutate), you don't create a new A1
|
||||
a1.value = 20 // b1 automatically becomes 40
|
||||
|
||||
// This is fundamentally different from:
|
||||
// state = reducer(state, action) // Functional/Redux pattern
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Composition API FAQ](https://vuejs.org/guide/extras/composition-api-faq.html)
|
||||
- [Reactivity Fundamentals](https://vuejs.org/guide/essentials/reactivity-fundamentals.html)
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
title: Composition and Options API Can Coexist in Same Component
|
||||
impact: LOW
|
||||
impactDescription: Understanding coexistence helps gradual migration and library integration
|
||||
type: best-practice
|
||||
tags: [vue3, composition-api, options-api, migration, interoperability]
|
||||
---
|
||||
|
||||
# Composition and Options API Can Coexist in Same Component
|
||||
|
||||
**Impact: LOW** - Vue 3 allows using both APIs in the same component via the `setup()` option. This is useful for gradual migration of existing Options API codebases or integrating Composition API libraries into Options API components.
|
||||
|
||||
However, this should be a transitional pattern. For new code, pick one API style and stick with it.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Only mix APIs when migrating existing code or integrating libraries
|
||||
- [ ] Use `setup()` option (not `<script setup>`) when mixing with Options API
|
||||
- [ ] Return refs/reactive from setup to make them available to Options API code
|
||||
- [ ] Avoid mixing long-term - plan to fully migrate eventually
|
||||
- [ ] Understand that Options API `this` is NOT available in `setup()`
|
||||
|
||||
**Using Composition API in Options API Component:**
|
||||
```javascript
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useExternalLibrary } from 'some-composition-library'
|
||||
|
||||
export default {
|
||||
// Options API parts
|
||||
data() {
|
||||
return {
|
||||
legacyData: 'from options api'
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
legacyComputed() {
|
||||
// Can access both Options API data AND setup() returned values
|
||||
return this.legacyData + ' - ' + this.newFeatureData
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
legacyMethod() {
|
||||
// Can call methods from both APIs
|
||||
this.composableMethod()
|
||||
}
|
||||
},
|
||||
|
||||
// Composition API via setup()
|
||||
setup() {
|
||||
// Use composition library that doesn't have Options API equivalent
|
||||
const { data: libraryData, doSomething } = useExternalLibrary()
|
||||
|
||||
// Create new reactive state with Composition API
|
||||
const newFeatureData = ref('from composition api')
|
||||
|
||||
const newComputed = computed(() =>
|
||||
newFeatureData.value.toUpperCase()
|
||||
)
|
||||
|
||||
function composableMethod() {
|
||||
newFeatureData.value = 'updated'
|
||||
}
|
||||
|
||||
// IMPORTANT: Return values to make them available to Options API
|
||||
return {
|
||||
libraryData,
|
||||
doSomething,
|
||||
newFeatureData,
|
||||
newComputed,
|
||||
composableMethod
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Common Migration Pattern:**
|
||||
```javascript
|
||||
// Step 1: Original Options API component
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
users: [],
|
||||
loading: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchUsers() {
|
||||
this.loading = true
|
||||
this.users = await api.getUsers()
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchUsers()
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Extract logic to composable, use via setup()
|
||||
import { useUsers } from '@/composables/useUsers'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
// Keep some Options API data during migration
|
||||
selectedUserId: null
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
selectedUser() {
|
||||
// Mix Options API computed with Composition API data
|
||||
return this.users.find(u => u.id === this.selectedUserId)
|
||||
}
|
||||
},
|
||||
|
||||
setup() {
|
||||
// New logic in Composition API
|
||||
const { users, loading, fetchUsers } = useUsers()
|
||||
return { users, loading, fetchUsers }
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.fetchUsers() // Available from setup()
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Fully migrate to <script setup>
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useUsers } from '@/composables/useUsers'
|
||||
|
||||
const { users, loading, fetchUsers } = useUsers()
|
||||
const selectedUserId = ref(null)
|
||||
|
||||
const selectedUser = computed(() =>
|
||||
users.value.find(u => u.id === selectedUserId.value)
|
||||
)
|
||||
|
||||
onMounted(() => fetchUsers())
|
||||
</script>
|
||||
```
|
||||
|
||||
**Important Limitations:**
|
||||
```javascript
|
||||
export default {
|
||||
data() {
|
||||
return { optionsData: 'hello' }
|
||||
},
|
||||
|
||||
setup(props, context) {
|
||||
// WRONG: 'this' is NOT available in setup()
|
||||
console.log(this.optionsData) // undefined!
|
||||
|
||||
// CORRECT: Access props and context via parameters
|
||||
console.log(props.someProp)
|
||||
console.log(context.attrs)
|
||||
console.log(context.emit)
|
||||
|
||||
// To access Options API data from setup,
|
||||
// you generally can't - they're in separate scopes
|
||||
// The Options API CAN access setup's returned values though
|
||||
|
||||
return { /* ... */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## When to Use This Pattern
|
||||
|
||||
- **Migrating large codebase**: Migrate piece by piece without rewriting everything
|
||||
- **Integrating libraries**: Some libraries (like VueUse) are Composition API only
|
||||
- **Team transition**: Let teams learn Composition API gradually
|
||||
- **Options API components that need one composable**: Quick integration
|
||||
|
||||
## When NOT to Use This Pattern
|
||||
|
||||
- **New components**: Just use `<script setup>` from the start
|
||||
- **Simple components**: Not worth the mental overhead
|
||||
- **Long-term**: Plan to fully migrate; mixing adds complexity
|
||||
|
||||
## Reference
|
||||
- [Composition API FAQ - Using Both APIs](https://vuejs.org/guide/extras/composition-api-faq.html#can-i-use-both-apis-in-the-same-component)
|
||||
- [setup() option](https://vuejs.org/api/composition-api-setup.html)
|
||||
@@ -0,0 +1,156 @@
|
||||
---
|
||||
title: Vue Composition API Runs Once, Unlike React Hooks
|
||||
impact: MEDIUM
|
||||
impactDescription: Understanding this difference prevents over-engineering and React patterns that don't apply
|
||||
type: gotcha
|
||||
tags: [vue3, composition-api, react-hooks, setup, stale-closure]
|
||||
---
|
||||
|
||||
# Vue Composition API Runs Once, Unlike React Hooks
|
||||
|
||||
**Impact: MEDIUM** - Vue's `setup()` or `<script setup>` executes only once per component instance, while React Hooks run on every render. Developers coming from React often apply patterns (dependency arrays, excessive memoization, useCallback) that are unnecessary and counterproductive in Vue.
|
||||
|
||||
Understanding this fundamental difference is crucial for writing idiomatic Vue code. Vue's approach eliminates entire categories of bugs (stale closures, exhaustive deps) that plague React applications.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Don't implement "dependency arrays" - Vue tracks dependencies automatically
|
||||
- [ ] Don't wrap functions in "useCallback" equivalents - not needed in Vue
|
||||
- [ ] Don't use "useMemo" patterns - Vue's `computed()` handles this automatically
|
||||
- [ ] Understand that closures in Vue don't go "stale" like in React
|
||||
- [ ] Don't worry about "call order" - Vue composables can be conditional
|
||||
|
||||
**React Patterns to Avoid in Vue:**
|
||||
```javascript
|
||||
// These patterns are UNNECESSARY in Vue - they solve React-specific problems
|
||||
|
||||
// WRONG: Trying to implement dependency arrays (React pattern)
|
||||
watch(
|
||||
[dep1, dep2, dep3], // Vue tracks deps automatically in watchEffect
|
||||
() => {
|
||||
// ...
|
||||
}
|
||||
)
|
||||
// Unless you specifically WANT to control which deps trigger the watcher,
|
||||
// prefer watchEffect() which auto-tracks
|
||||
|
||||
// WRONG: Memoizing callbacks like useCallback
|
||||
const memoizedHandler = computed(() => {
|
||||
return () => doSomething(state.value)
|
||||
})
|
||||
// In Vue, just define the function normally - no memoization needed
|
||||
|
||||
// WRONG: Worrying about stale closures
|
||||
function useData() {
|
||||
const data = ref(null)
|
||||
|
||||
// In React, this could capture stale 'data' - NOT in Vue!
|
||||
// Vue refs are always current
|
||||
const handler = () => {
|
||||
console.log(data.value) // Always gets current value
|
||||
}
|
||||
|
||||
return { data, handler }
|
||||
}
|
||||
```
|
||||
|
||||
**Correct Vue Patterns:**
|
||||
```javascript
|
||||
import { ref, computed, watchEffect } from 'vue'
|
||||
|
||||
// CORRECT: Auto-dependency tracking with watchEffect
|
||||
const query = ref('')
|
||||
const filter = ref('all')
|
||||
|
||||
watchEffect(() => {
|
||||
// Vue automatically detects that this depends on query and filter
|
||||
// No dependency array needed!
|
||||
fetchResults(query.value, filter.value)
|
||||
})
|
||||
|
||||
// CORRECT: computed() handles memoization automatically
|
||||
const expensiveResult = computed(() => {
|
||||
// Only recalculates when dependencies actually change
|
||||
return heavyComputation(data.value)
|
||||
})
|
||||
|
||||
// CORRECT: Functions don't need memoization
|
||||
function handleClick() {
|
||||
count.value++
|
||||
}
|
||||
// Just use it directly - no useCallback wrapper needed
|
||||
// <button @click="handleClick">
|
||||
|
||||
// CORRECT: Closures always access current values
|
||||
const count = ref(0)
|
||||
const message = ref('')
|
||||
|
||||
function logState() {
|
||||
// This always logs CURRENT values, never stale ones
|
||||
console.log(`Count: ${count.value}, Message: ${message.value}`)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
logState() // Gets current values even if called later
|
||||
}, 5000)
|
||||
```
|
||||
|
||||
## Vue's Advantages Over React Hooks
|
||||
|
||||
```javascript
|
||||
// 1. No stale closure problems
|
||||
const count = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
setInterval(() => {
|
||||
// In React: would need useRef or deps array to avoid stale value
|
||||
// In Vue: count.value is always current
|
||||
console.log(count.value)
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
// 2. Composables can be conditional
|
||||
if (featureEnabled) {
|
||||
const { data } = useSomeFeature() // This is FINE in Vue!
|
||||
}
|
||||
// In React: "Hooks cannot be conditional" - not a problem in Vue
|
||||
|
||||
// 3. No exhaustive-deps linting headaches
|
||||
watchEffect(() => {
|
||||
// Use any reactive values - Vue tracks them all automatically
|
||||
// No ESLint rule yelling about missing dependencies
|
||||
doSomething(a.value, b.value, c.value)
|
||||
})
|
||||
|
||||
// 4. Child components don't need memoization by default
|
||||
// Vue's reactivity system only updates what actually changed
|
||||
// No need for React.memo() equivalents in most cases
|
||||
```
|
||||
|
||||
## When Vue Patterns Differ
|
||||
|
||||
```javascript
|
||||
// Setup runs once - so initialization happens once
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
// This code runs ONCE when component is created
|
||||
const data = ref(null)
|
||||
console.log('Setup running') // Only logs once
|
||||
|
||||
onMounted(() => {
|
||||
console.log('Mounted') // Only logs once
|
||||
})
|
||||
|
||||
// If you need something to run on every reactive change,
|
||||
// use watch or watchEffect
|
||||
watchEffect(() => {
|
||||
// This runs when dependencies change
|
||||
console.log('Data changed:', data.value)
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Composition API FAQ - Relationship with React Hooks](https://vuejs.org/guide/extras/composition-api-faq.html#relationship-with-react-hooks)
|
||||
- [Reactivity Fundamentals](https://vuejs.org/guide/essentials/reactivity-fundamentals.html)
|
||||
148
skills/vue-best-practices/reference/computed-array-mutation.md
Normal file
148
skills/vue-best-practices/reference/computed-array-mutation.md
Normal file
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: Avoid Mutating Methods on Arrays in Computed Properties
|
||||
impact: HIGH
|
||||
impactDescription: Array mutating methods in computed modify source data causing unexpected behavior
|
||||
type: capability
|
||||
tags: [vue3, computed, arrays, mutation, sort, reverse]
|
||||
---
|
||||
|
||||
# Avoid Mutating Methods on Arrays in Computed Properties
|
||||
|
||||
**Impact: HIGH** - JavaScript array methods like `reverse()`, `sort()`, `splice()`, `push()`, `pop()`, `shift()`, and `unshift()` mutate the original array. Using them directly on reactive arrays inside computed properties will modify your source data, causing unexpected side effects and bugs.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Always create a copy of arrays before using mutating methods
|
||||
- [ ] Use spread operator `[...array]` or `slice()` to copy arrays
|
||||
- [ ] Prefer non-mutating alternatives when available
|
||||
- [ ] Be aware which array methods mutate vs return new arrays
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const items = ref([3, 1, 4, 1, 5, 9, 2, 6])
|
||||
const users = ref([
|
||||
{ name: 'Alice', age: 30 },
|
||||
{ name: 'Bob', age: 25 }
|
||||
])
|
||||
|
||||
// BAD: sort() mutates the original array!
|
||||
const sortedItems = computed(() => {
|
||||
return items.value.sort((a, b) => a - b)
|
||||
})
|
||||
|
||||
// BAD: reverse() mutates the original array!
|
||||
const reversedItems = computed(() => {
|
||||
return items.value.reverse()
|
||||
})
|
||||
|
||||
// BAD: Both arrays now point to the same mutated data
|
||||
// items.value and sortedItems.value are the SAME array
|
||||
// items.value and reversedItems.value are the SAME array
|
||||
|
||||
// BAD: Chained mutations
|
||||
const sortedUsers = computed(() => {
|
||||
return users.value.sort((a, b) => a.age - b.age)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Original array is corrupted! -->
|
||||
<div>Original: {{ items }}</div>
|
||||
<div>Sorted: {{ sortedItems }}</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const items = ref([3, 1, 4, 1, 5, 9, 2, 6])
|
||||
const users = ref([
|
||||
{ name: 'Alice', age: 30 },
|
||||
{ name: 'Bob', age: 25 }
|
||||
])
|
||||
|
||||
// GOOD: Spread operator creates a copy first
|
||||
const sortedItems = computed(() => {
|
||||
return [...items.value].sort((a, b) => a - b)
|
||||
})
|
||||
|
||||
// GOOD: slice() also creates a copy
|
||||
const reversedItems = computed(() => {
|
||||
return items.value.slice().reverse()
|
||||
})
|
||||
|
||||
// GOOD: Copy before sorting objects
|
||||
const sortedUsers = computed(() => {
|
||||
return [...users.value].sort((a, b) => a.age - b.age)
|
||||
})
|
||||
|
||||
// GOOD: Use toSorted() (ES2023) - non-mutating
|
||||
const sortedItemsModern = computed(() => {
|
||||
return items.value.toSorted((a, b) => a - b)
|
||||
})
|
||||
|
||||
// GOOD: Use toReversed() (ES2023) - non-mutating
|
||||
const reversedItemsModern = computed(() => {
|
||||
return items.value.toReversed()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Original array stays intact -->
|
||||
<div>Original: {{ items }}</div>
|
||||
<div>Sorted: {{ sortedItems }}</div>
|
||||
<div>Reversed: {{ reversedItems }}</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Mutating vs Non-Mutating Array Methods
|
||||
|
||||
| Mutating (Avoid in Computed) | Non-Mutating (Safe) |
|
||||
|------------------------------|---------------------|
|
||||
| `sort()` | `toSorted()` (ES2023) |
|
||||
| `reverse()` | `toReversed()` (ES2023) |
|
||||
| `splice()` | `toSpliced()` (ES2023) |
|
||||
| `push()` | `concat()` |
|
||||
| `pop()` | `slice(0, -1)` |
|
||||
| `shift()` | `slice(1)` |
|
||||
| `unshift()` | `[item, ...array]` |
|
||||
| `fill()` | `map()` with new values |
|
||||
|
||||
## ES2023 Non-Mutating Alternatives
|
||||
|
||||
Modern JavaScript (ES2023) provides non-mutating versions of common array methods:
|
||||
|
||||
```javascript
|
||||
// These return NEW arrays, safe for computed properties
|
||||
const sorted = array.toSorted((a, b) => a - b)
|
||||
const reversed = array.toReversed()
|
||||
const spliced = array.toSpliced(1, 2, 'new')
|
||||
const withReplaced = array.with(0, 'newFirst')
|
||||
```
|
||||
|
||||
## Deep Copy for Nested Arrays
|
||||
|
||||
For arrays of objects where you might mutate nested properties:
|
||||
|
||||
```javascript
|
||||
const items = ref([{ name: 'A', values: [1, 2, 3] }])
|
||||
|
||||
// Shallow copy - nested arrays still shared
|
||||
const copied = computed(() => [...items.value])
|
||||
|
||||
// Deep copy if you need to mutate nested structures
|
||||
const deepCopied = computed(() => {
|
||||
return JSON.parse(JSON.stringify(items.value))
|
||||
// Or use structuredClone():
|
||||
// return structuredClone(items.value)
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Computed Properties - Avoid Mutating Computed Value](https://vuejs.org/guide/essentials/computed.html#avoid-mutating-computed-value)
|
||||
- [MDN Array Methods](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array)
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
title: Ensure All Dependencies Are Accessed in Computed Properties
|
||||
impact: HIGH
|
||||
impactDescription: Conditional logic can prevent dependency tracking causing stale computed values
|
||||
type: capability
|
||||
tags: [vue3, computed, reactivity, dependency-tracking, gotcha]
|
||||
---
|
||||
|
||||
# Ensure All Dependencies Are Accessed in Computed Properties
|
||||
|
||||
**Impact: HIGH** - Vue tracks computed property dependencies by monitoring which reactive properties are accessed during execution. If conditional logic prevents a property from being accessed on the first run, Vue won't track it as a dependency, causing the computed property to not update when that property changes.
|
||||
|
||||
This is a subtle but common source of bugs, especially with short-circuit evaluation (`&&`, `||`) and early returns.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Access all reactive dependencies before any conditional logic
|
||||
- [ ] Be cautious with short-circuit operators (`&&`, `||`) that may skip property access
|
||||
- [ ] Store all dependencies in variables at the start of the computed getter
|
||||
- [ ] Test computed properties with different initial states
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const isEnabled = ref(false)
|
||||
const data = ref('important data')
|
||||
|
||||
// BAD: If isEnabled is false initially, data.value is never accessed
|
||||
// Vue won't track 'data' as a dependency!
|
||||
const result = computed(() => {
|
||||
if (!isEnabled.value) {
|
||||
return 'disabled'
|
||||
}
|
||||
return data.value // This dependency may not be tracked
|
||||
})
|
||||
|
||||
// BAD: Short-circuit prevents second access
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
|
||||
const isValid = computed(() => {
|
||||
// If password is empty, confirmPassword is never accessed
|
||||
return password.value && password.value === confirmPassword.value
|
||||
})
|
||||
|
||||
// BAD: Early return prevents dependency access
|
||||
const user = ref(null)
|
||||
const permissions = ref(['read', 'write'])
|
||||
|
||||
const canEdit = computed(() => {
|
||||
if (!user.value) {
|
||||
return false // permissions.value never accessed when user is null
|
||||
}
|
||||
return permissions.value.includes('write')
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const isEnabled = ref(false)
|
||||
const data = ref('important data')
|
||||
|
||||
// GOOD: Access all dependencies first
|
||||
const result = computed(() => {
|
||||
const enabled = isEnabled.value
|
||||
const currentData = data.value // Always accessed
|
||||
|
||||
if (!enabled) {
|
||||
return 'disabled'
|
||||
}
|
||||
return currentData
|
||||
})
|
||||
|
||||
// GOOD: Access both values before comparison
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
|
||||
const isValid = computed(() => {
|
||||
const pwd = password.value
|
||||
const confirm = confirmPassword.value // Always accessed
|
||||
|
||||
return pwd && pwd === confirm
|
||||
})
|
||||
|
||||
// GOOD: Access all reactive sources upfront
|
||||
const user = ref(null)
|
||||
const permissions = ref(['read', 'write'])
|
||||
|
||||
const canEdit = computed(() => {
|
||||
const currentUser = user.value
|
||||
const currentPermissions = permissions.value // Always accessed
|
||||
|
||||
if (!currentUser) {
|
||||
return false
|
||||
}
|
||||
return currentPermissions.includes('write')
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## The Dependency Tracking Mechanism
|
||||
|
||||
Vue's reactivity system works by tracking which reactive properties are accessed when a computed property runs:
|
||||
|
||||
```javascript
|
||||
// How Vue tracks dependencies (simplified):
|
||||
// 1. Start tracking
|
||||
// 2. Run the getter function
|
||||
// 3. Record every .value or reactive property access
|
||||
// 4. Stop tracking
|
||||
|
||||
const computed = computed(() => {
|
||||
// Vue starts tracking here
|
||||
if (conditionA.value) { // conditionA is tracked
|
||||
return valueB.value // valueB is ONLY tracked if conditionA is true
|
||||
}
|
||||
return 'default' // If conditionA is false, valueB is NOT tracked!
|
||||
})
|
||||
```
|
||||
|
||||
## Pattern: Destructure All Dependencies First
|
||||
|
||||
```javascript
|
||||
// GOOD PATTERN: Destructure/access everything at the top
|
||||
const result = computed(() => {
|
||||
// Access all potential dependencies
|
||||
const { user, settings, items } = toRefs(store)
|
||||
const userVal = user.value
|
||||
const settingsVal = settings.value
|
||||
const itemsVal = items.value
|
||||
|
||||
// Now use conditional logic safely
|
||||
if (!userVal) return []
|
||||
if (!settingsVal.enabled) return []
|
||||
return itemsVal.filter(i => i.active)
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Reactivity in Depth](https://vuejs.org/guide/extras/reactivity-in-depth.html)
|
||||
- [GitHub Discussion: Dependency collection gotcha with conditionals](https://github.com/vuejs/Discussion/issues/15)
|
||||
159
skills/vue-best-practices/reference/computed-no-parameters.md
Normal file
159
skills/vue-best-practices/reference/computed-no-parameters.md
Normal file
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: Computed Properties Cannot Accept Parameters
|
||||
impact: MEDIUM
|
||||
impactDescription: Attempting to pass arguments to computed properties fails or defeats caching
|
||||
type: capability
|
||||
tags: [vue3, computed, methods, parameters, common-mistake]
|
||||
---
|
||||
|
||||
# Computed Properties Cannot Accept Parameters
|
||||
|
||||
**Impact: MEDIUM** - Computed properties are designed to derive values from reactive state without parameters. Attempting to pass arguments defeats the caching mechanism or causes errors. Use methods or computed properties that return functions instead.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use methods when you need to pass parameters
|
||||
- [ ] Consider if the parameter can be reactive state instead
|
||||
- [ ] If you must parameterize, understand that returning a function loses caching benefits
|
||||
- [ ] Prefer method calls in templates for parameterized operations
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- BAD: Computed properties don't accept parameters like this -->
|
||||
<p>{{ filteredItems('active') }}</p>
|
||||
<p>{{ formattedPrice(100, 'USD') }}</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const items = ref([/* ... */])
|
||||
|
||||
// BAD: This won't work as expected
|
||||
// Computed is called once, not per parameter
|
||||
const filteredItems = computed((status) => { // status will be undefined or previous value
|
||||
return items.value.filter(i => i.status === status)
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return { items: [/* ... */] }
|
||||
},
|
||||
computed: {
|
||||
// BAD: Computed doesn't receive arguments
|
||||
filteredItems(status) { // 'status' is actually 'this' or undefined
|
||||
return this.items.filter(i => i.status === status)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- GOOD: Use method for parameterized operations -->
|
||||
<p>{{ getFilteredItems('active') }}</p>
|
||||
<p>{{ formatPrice(100, 'USD') }}</p>
|
||||
|
||||
<!-- GOOD: Or use computed with reactive filter state -->
|
||||
<select v-model="statusFilter">
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
<p>{{ filteredItems }}</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const items = ref([/* ... */])
|
||||
const statusFilter = ref('active')
|
||||
|
||||
// GOOD: Method for parameterized operations
|
||||
function getFilteredItems(status) {
|
||||
return items.value.filter(i => i.status === status)
|
||||
}
|
||||
|
||||
function formatPrice(amount, currency) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
// GOOD: Computed with reactive parameter
|
||||
const filteredItems = computed(() => {
|
||||
return items.value.filter(i => i.status === statusFilter.value)
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Workaround: Computed Returning a Function
|
||||
|
||||
If you need something computed-like with parameters, you can return a function. **However, this defeats the caching benefit:**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<p>{{ getItemsByStatus('active') }}</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const items = ref([/* ... */])
|
||||
|
||||
// This works but provides NO caching benefit
|
||||
// The inner function runs every time it's called
|
||||
const getItemsByStatus = computed(() => {
|
||||
return (status) => items.value.filter(i => i.status === status)
|
||||
})
|
||||
|
||||
// This is essentially equivalent to just using a method
|
||||
// Only useful if you need to compose with other computed properties
|
||||
</script>
|
||||
```
|
||||
|
||||
## When to Use Each Approach
|
||||
|
||||
| Scenario | Approach | Caching |
|
||||
|----------|----------|---------|
|
||||
| Fixed filter based on reactive state | Computed | Yes |
|
||||
| Dynamic filter passed as argument | Method | No |
|
||||
| Filter options from user selection | Computed + reactive param | Yes |
|
||||
| Formatting with variable parameters | Method | No |
|
||||
| Composed derivation with argument | Computed returning function | Partial |
|
||||
|
||||
## Make Parameters Reactive
|
||||
|
||||
The best pattern is often to make the "parameter" a reactive value:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const items = ref([/* ... */])
|
||||
|
||||
// Instead of passing 'status' as a parameter:
|
||||
const currentStatus = ref('active')
|
||||
|
||||
// Make a computed that uses the reactive status
|
||||
const filteredItems = computed(() => {
|
||||
return items.value.filter(i => i.status === currentStatus.value)
|
||||
})
|
||||
|
||||
// Change the filter by updating the ref
|
||||
function filterByStatus(status) {
|
||||
currentStatus.value = status
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Computed Properties](https://vuejs.org/guide/essentials/computed.html)
|
||||
- [Vue.js Methods](https://vuejs.org/guide/essentials/reactivity-fundamentals.html#declaring-methods)
|
||||
107
skills/vue-best-practices/reference/computed-no-side-effects.md
Normal file
107
skills/vue-best-practices/reference/computed-no-side-effects.md
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: Computed Property Getters Must Be Side-Effect Free
|
||||
impact: HIGH
|
||||
impactDescription: Side effects in computed getters break reactivity and cause unpredictable behavior
|
||||
type: efficiency
|
||||
tags: [vue3, computed, reactivity, side-effects, best-practices]
|
||||
---
|
||||
|
||||
# Computed Property Getters Must Be Side-Effect Free
|
||||
|
||||
**Impact: HIGH** - Computed getter functions should only perform pure computation. Side effects in computed getters break Vue's reactivity model and cause bugs that are difficult to trace.
|
||||
|
||||
Computed properties are designed to declaratively describe how to derive a value from other reactive state. They are not meant to perform actions or modify state.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Never mutate other reactive state inside a computed getter
|
||||
- [ ] Never make async requests or API calls inside a computed getter
|
||||
- [ ] Never perform DOM mutations inside a computed getter
|
||||
- [ ] Use watchers for reacting to state changes with side effects
|
||||
- [ ] Use event handlers for user-triggered actions
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const items = ref([])
|
||||
const count = ref(0)
|
||||
const lastFetch = ref(null)
|
||||
|
||||
// BAD: Mutates other state
|
||||
const doubledCount = computed(() => {
|
||||
count.value++ // Side effect - modifying state!
|
||||
return count.value * 2
|
||||
})
|
||||
|
||||
// BAD: Makes async request
|
||||
const userData = computed(async () => {
|
||||
const response = await fetch('/api/user') // Side effect - API call!
|
||||
return response.json()
|
||||
})
|
||||
|
||||
// BAD: Modifies DOM
|
||||
const highlightedItems = computed(() => {
|
||||
document.title = `${items.value.length} items` // Side effect - DOM mutation!
|
||||
return items.value.filter(i => i.highlighted)
|
||||
})
|
||||
|
||||
// BAD: Writes to external state
|
||||
const processedData = computed(() => {
|
||||
lastFetch.value = new Date() // Side effect - modifying state!
|
||||
return items.value.map(i => i.name)
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
|
||||
const items = ref([])
|
||||
const count = ref(0)
|
||||
const userData = ref(null)
|
||||
|
||||
// GOOD: Pure computation only
|
||||
const doubledCount = computed(() => {
|
||||
return count.value * 2
|
||||
})
|
||||
|
||||
// GOOD: Use lifecycle hook for initial fetch
|
||||
onMounted(async () => {
|
||||
const response = await fetch('/api/user')
|
||||
userData.value = await response.json()
|
||||
})
|
||||
|
||||
// GOOD: Pure filtering
|
||||
const highlightedItems = computed(() => {
|
||||
return items.value.filter(i => i.highlighted)
|
||||
})
|
||||
|
||||
// GOOD: Use watcher for side effects
|
||||
watch(items, (newItems) => {
|
||||
document.title = `${newItems.length} items`
|
||||
}, { immediate: true })
|
||||
|
||||
// Increment count through event handler, not computed
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## What Counts as a Side Effect
|
||||
|
||||
| Side Effect Type | Example | Alternative |
|
||||
|-----------------|---------|-------------|
|
||||
| State mutation | `otherRef.value = x` | Use watcher |
|
||||
| API calls | `fetch()`, `axios()` | Use watcher or lifecycle hook |
|
||||
| DOM manipulation | `document.title = x` | Use watcher |
|
||||
| Console logging | `console.log()` | Remove or use watcher |
|
||||
| Storage access | `localStorage.setItem()` | Use watcher |
|
||||
| Timer setup | `setTimeout()` | Use lifecycle hook |
|
||||
|
||||
## Reference
|
||||
- [Vue.js Computed Properties - Getters Should Be Side-Effect Free](https://vuejs.org/guide/essentials/computed.html#getters-should-be-side-effect-free)
|
||||
@@ -0,0 +1,121 @@
|
||||
# Use Computed Properties for Complex Class Logic
|
||||
|
||||
## Rule
|
||||
|
||||
When class bindings involve multiple conditions or complex logic, extract them into computed properties rather than writing inline expressions in templates.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
- Inline class expressions quickly become unreadable with multiple conditions
|
||||
- Computed properties are cached and only re-evaluate when dependencies change
|
||||
- Logic in computed properties is easier to test and debug
|
||||
- Keeps templates focused on structure, not logic
|
||||
|
||||
## Bad Code
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Hard to read, error-prone -->
|
||||
<div :class="{
|
||||
'btn': true,
|
||||
'btn-primary': type === 'primary' && !disabled,
|
||||
'btn-secondary': type === 'secondary' && !disabled,
|
||||
'btn-disabled': disabled,
|
||||
'btn-loading': isLoading,
|
||||
'btn-large': size === 'large',
|
||||
'btn-small': size === 'small'
|
||||
}">
|
||||
{{ label }}
|
||||
</div>
|
||||
|
||||
<!-- Even worse: string concatenation -->
|
||||
<div :class="'btn btn-' + type + (disabled ? ' btn-disabled' : '') + (isLoading ? ' btn-loading' : '')">
|
||||
{{ label }}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Good Code
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
type: { type: String, default: 'primary' },
|
||||
size: { type: String, default: 'medium' },
|
||||
disabled: Boolean,
|
||||
isLoading: Boolean,
|
||||
label: String
|
||||
})
|
||||
|
||||
const buttonClasses = computed(() => ({
|
||||
'btn': true,
|
||||
[`btn-${props.type}`]: !props.disabled,
|
||||
'btn-disabled': props.disabled,
|
||||
'btn-loading': props.isLoading,
|
||||
'btn-large': props.size === 'large',
|
||||
'btn-small': props.size === 'small'
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="buttonClasses">
|
||||
{{ label }}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Style Bindings Too
|
||||
|
||||
The same principle applies to style bindings:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
color: String,
|
||||
fontSize: Number,
|
||||
isHighlighted: Boolean
|
||||
})
|
||||
|
||||
const textStyles = computed(() => ({
|
||||
color: props.color,
|
||||
fontSize: `${props.fontSize}px`,
|
||||
backgroundColor: props.isHighlighted ? 'yellow' : 'transparent',
|
||||
fontWeight: props.isHighlighted ? 'bold' : 'normal'
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :style="textStyles">Styled text</span>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Combining Static and Dynamic Classes
|
||||
|
||||
Use array syntax to combine static classes with computed dynamic classes:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const dynamicClasses = computed(() => ({
|
||||
'is-active': isActive.value,
|
||||
'is-disabled': isDisabled.value
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Static 'card' class + dynamic classes -->
|
||||
<div :class="['card', dynamicClasses]">
|
||||
Content
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Class and Style Bindings](https://vuejs.org/guide/essentials/class-and-style.html)
|
||||
- [Computed Properties](https://vuejs.org/guide/essentials/computed.html)
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
title: Never Mutate Computed Property Return Values
|
||||
impact: HIGH
|
||||
impactDescription: Mutating computed values causes silent failures and lost changes
|
||||
type: capability
|
||||
tags: [vue3, computed, reactivity, immutability, common-mistake]
|
||||
---
|
||||
|
||||
# Never Mutate Computed Property Return Values
|
||||
|
||||
**Impact: HIGH** - The returned value from a computed property is derived state - a temporary snapshot. Mutating this value leads to bugs that are difficult to debug.
|
||||
|
||||
**Important:** Mutations DO persist while the computed cache remains valid, but are lost when recomputation occurs. The danger lies in unpredictable cache invalidation timing - any change to the computed's dependencies triggers recomputation, silently discarding your mutations. This makes bugs intermittent and hard to reproduce.
|
||||
|
||||
Every time the source state changes, a new snapshot is created. Mutating a snapshot is meaningless because it will be discarded on the next recalculation.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Treat computed return values as read-only
|
||||
- [ ] Update the source state instead of the computed value
|
||||
- [ ] Use writable computed properties if bidirectional binding is needed
|
||||
- [ ] Avoid array mutating methods (push, pop, splice, reverse, sort) on computed arrays
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const books = ref(['Vue Guide', 'React Handbook'])
|
||||
|
||||
const publishedBooks = computed(() => {
|
||||
return books.value.filter(book => book.includes('Guide'))
|
||||
})
|
||||
|
||||
function addBook() {
|
||||
// BAD: Mutating computed value - change will be lost!
|
||||
publishedBooks.value.push('New Book')
|
||||
}
|
||||
|
||||
// BAD: Mutating computed array
|
||||
const sortedBooks = computed(() => books.value.filter(b => b))
|
||||
|
||||
function reverseBooks() {
|
||||
// BAD: This mutates the computed snapshot
|
||||
sortedBooks.value.reverse()
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
author: {
|
||||
name: 'John',
|
||||
books: ['Book A', 'Book B']
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
authorBooks() {
|
||||
return this.author.books
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addBook() {
|
||||
// BAD: Mutating computed value
|
||||
this.authorBooks.push('New Book')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const books = ref(['Vue Guide', 'React Handbook'])
|
||||
|
||||
const publishedBooks = computed(() => {
|
||||
return books.value.filter(book => book.includes('Guide'))
|
||||
})
|
||||
|
||||
function addBook(bookName) {
|
||||
// GOOD: Update the source state
|
||||
books.value.push(bookName)
|
||||
}
|
||||
|
||||
// GOOD: Create a copy before mutating for display
|
||||
const sortedBooks = computed(() => {
|
||||
return [...books.value].sort() // Spread to create copy before sort
|
||||
})
|
||||
|
||||
const reversedBooks = computed(() => {
|
||||
return [...books.value].reverse() // Spread to create copy before reverse
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
author: {
|
||||
name: 'John',
|
||||
books: ['Book A', 'Book B']
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
authorBooks() {
|
||||
return this.author.books
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addBook(bookName) {
|
||||
// GOOD: Update source state
|
||||
this.author.books.push(bookName)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Writable Computed for Bidirectional Binding
|
||||
|
||||
If you genuinely need to "set" a computed value, use a writable computed property:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const firstName = ref('John')
|
||||
const lastName = ref('Doe')
|
||||
|
||||
// Writable computed with getter and setter
|
||||
const fullName = computed({
|
||||
get() {
|
||||
return `${firstName.value} ${lastName.value}`
|
||||
},
|
||||
set(newValue) {
|
||||
// Update source state based on the new value
|
||||
const parts = newValue.split(' ')
|
||||
firstName.value = parts[0] || ''
|
||||
lastName.value = parts[1] || ''
|
||||
}
|
||||
})
|
||||
|
||||
// Now this is valid:
|
||||
fullName.value = 'Jane Smith' // Updates firstName and lastName
|
||||
</script>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Computed Properties - Avoid Mutating Computed Value](https://vuejs.org/guide/essentials/computed.html#avoid-mutating-computed-value)
|
||||
- [Vue.js Computed Properties - Writable Computed](https://vuejs.org/guide/essentials/computed.html#writable-computed)
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
title: Use Computed Properties for Cached Reactive Derivations
|
||||
impact: MEDIUM
|
||||
impactDescription: Methods recalculate on every render while computed properties cache results
|
||||
type: efficiency
|
||||
tags: [vue3, computed, methods, performance, caching]
|
||||
---
|
||||
|
||||
# Use Computed Properties for Cached Reactive Derivations
|
||||
|
||||
**Impact: MEDIUM** - Computed properties are cached based on their reactive dependencies and only re-evaluate when dependencies change. Methods run on every component re-render, causing performance issues for expensive operations.
|
||||
|
||||
When you need to derive a value from reactive state, prefer computed properties over methods for automatic caching and optimized re-renders.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use computed properties for values derived from reactive state
|
||||
- [ ] Use methods only when you need to pass parameters or don't want caching
|
||||
- [ ] Never use computed for non-reactive values like `Date.now()`
|
||||
- [ ] Consider performance impact of expensive operations in methods vs computed
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- BAD: Method runs on every re-render -->
|
||||
<p>{{ getFilteredItems() }}</p>
|
||||
<p>{{ calculateTotal() }}</p>
|
||||
<p>{{ getCurrentTime() }}</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const items = ref([/* large array */])
|
||||
const prices = ref([100, 200, 300])
|
||||
|
||||
// BAD: Expensive operation runs every render
|
||||
function getFilteredItems() {
|
||||
return items.value
|
||||
.filter(item => item.active)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
// BAD: Calculation runs every render even if prices unchanged
|
||||
function calculateTotal() {
|
||||
return prices.value.reduce((sum, price) => sum + price, 0)
|
||||
}
|
||||
|
||||
// This looks like a computed use case, but Date.now() is non-reactive
|
||||
function getCurrentTime() {
|
||||
return Date.now() // Will appear to work but won't update reactively
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- GOOD: Computed only recalculates when items change -->
|
||||
<p>{{ filteredItems }}</p>
|
||||
<p>{{ total }}</p>
|
||||
<!-- GOOD: Method for non-reactive current time -->
|
||||
<p>{{ getCurrentTime() }}</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const items = ref([/* large array */])
|
||||
const prices = ref([100, 200, 300])
|
||||
|
||||
// GOOD: Cached - only recalculates when items.value changes
|
||||
const filteredItems = computed(() => {
|
||||
return items.value
|
||||
.filter(item => item.active)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
|
||||
// GOOD: Cached - only recalculates when prices change
|
||||
const total = computed(() => {
|
||||
return prices.value.reduce((sum, price) => sum + price, 0)
|
||||
})
|
||||
|
||||
// GOOD: Use method for non-reactive values
|
||||
// (or use setInterval to update a ref)
|
||||
function getCurrentTime() {
|
||||
return Date.now()
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## When to Use Each
|
||||
|
||||
| Scenario | Use Computed | Use Method |
|
||||
|----------|--------------|------------|
|
||||
| Derived from reactive state | Yes | No |
|
||||
| Expensive calculation | Yes | No |
|
||||
| Need to pass parameters | No | Yes |
|
||||
| Non-reactive value (Date.now()) | No | Yes |
|
||||
| Don't want caching | No | Yes |
|
||||
| Triggered by user action | No | Yes |
|
||||
|
||||
## Non-Reactive Values Warning
|
||||
|
||||
Computed properties only track reactive dependencies. Non-reactive values like `Date.now()` will cause the computed to be evaluated once and never update:
|
||||
|
||||
```javascript
|
||||
// BAD: Date.now() is not reactive - computed will never update
|
||||
const now = computed(() => Date.now())
|
||||
|
||||
// GOOD: Use a ref with setInterval for live time
|
||||
const now = ref(Date.now())
|
||||
setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 1000)
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Computed Properties - Computed Caching vs Methods](https://vuejs.org/guide/essentials/computed.html#computed-caching-vs-methods)
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
title: defineModel Creates Hidden Modifier Props - Avoid Naming Conflicts
|
||||
impact: MEDIUM
|
||||
impactDescription: defineModel automatically adds hidden *Modifiers props that can conflict with your prop names
|
||||
type: gotcha
|
||||
tags: [vue3, v-model, defineModel, modifiers, props, naming]
|
||||
---
|
||||
|
||||
# defineModel Creates Hidden Modifier Props - Avoid Naming Conflicts
|
||||
|
||||
**Impact: MEDIUM** - When using `defineModel()`, Vue automatically creates hidden props with the suffix `Modifiers` for each model. For example, a model named `title` will create both a `title` prop AND a hidden `titleModifiers` prop. This can cause unexpected conflicts if you have other props ending in "Modifiers".
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Don't create props that end with "Modifiers" when using defineModel
|
||||
- [ ] Be aware that each defineModel creates an associated *Modifiers prop
|
||||
- [ ] When using multiple models, avoid names where one model could conflict with another's modifier prop
|
||||
- [ ] Document custom modifiers to help consumers understand available options
|
||||
|
||||
**Problem - Hidden props created automatically:**
|
||||
```vue
|
||||
<script setup>
|
||||
// This creates TWO props: modelValue and modelValueModifiers
|
||||
const model = defineModel()
|
||||
|
||||
// This creates TWO props: title and titleModifiers
|
||||
const title = defineModel('title')
|
||||
|
||||
// CONFLICT: This prop name collides with the hidden titleModifiers
|
||||
const props = defineProps({
|
||||
titleModifiers: Object // WRONG: Conflicts with defineModel's hidden prop
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**Parent using modifiers:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- Vue passes modifiers via the hidden *Modifiers prop -->
|
||||
<MyComponent v-model:title.capitalize.trim="text" />
|
||||
|
||||
<!-- Child receives titleModifiers = { capitalize: true, trim: true } -->
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct - Accessing modifiers in child:**
|
||||
```vue
|
||||
<script setup>
|
||||
// Access modifiers via destructuring
|
||||
const [title, titleModifiers] = defineModel('title', {
|
||||
set(value) {
|
||||
// Apply modifiers to the value
|
||||
if (titleModifiers.capitalize) {
|
||||
value = value.charAt(0).toUpperCase() + value.slice(1)
|
||||
}
|
||||
if (titleModifiers.trim) {
|
||||
value = value.trim()
|
||||
}
|
||||
return value
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Multiple Models and Potential Conflicts
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// These are OK - no conflicts
|
||||
const name = defineModel('name') // Creates: name, nameModifiers
|
||||
const age = defineModel('age') // Creates: age, ageModifiers
|
||||
|
||||
// PROBLEM: If you had a model named 'model', it creates:
|
||||
// - 'model' prop
|
||||
// - 'modelModifiers' prop
|
||||
// But 'modelValue' also creates 'modelValueModifiers'!
|
||||
// The names are similar and can cause confusion
|
||||
|
||||
// AVOID: Don't name a model something that would conflict
|
||||
const model = defineModel('model') // Creates 'model' and 'modelModifiers'
|
||||
// This is confusing alongside the default modelValue/modelValueModifiers
|
||||
</script>
|
||||
```
|
||||
|
||||
**Best Practice - Clear, distinct model names:**
|
||||
```vue
|
||||
<script setup>
|
||||
// Good: Clear, distinct names that won't conflict
|
||||
const firstName = defineModel('firstName') // firstNameModifiers
|
||||
const lastName = defineModel('lastName') // lastNameModifiers
|
||||
const email = defineModel('email') // emailModifiers
|
||||
|
||||
// Avoid ambiguous names
|
||||
// Bad: const value = defineModel('value') // valueModifiers - too generic
|
||||
// Bad: const data = defineModel('data') // dataModifiers - too generic
|
||||
</script>
|
||||
```
|
||||
|
||||
## Documenting Custom Modifiers
|
||||
|
||||
When creating components with custom modifier support, document them clearly:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
/**
|
||||
* @prop {string} title - The title text
|
||||
* @modifiers
|
||||
* - capitalize: Capitalizes first letter
|
||||
* - uppercase: Converts entire string to uppercase
|
||||
* - trim: Removes leading/trailing whitespace
|
||||
*/
|
||||
const [title, modifiers] = defineModel('title', {
|
||||
set(value: string) {
|
||||
let result = value
|
||||
if (modifiers.trim) result = result.trim()
|
||||
if (modifiers.capitalize) {
|
||||
result = result.charAt(0).toUpperCase() + result.slice(1)
|
||||
}
|
||||
if (modifiers.uppercase) {
|
||||
result = result.toUpperCase()
|
||||
}
|
||||
return result
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input v-model="title" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Component v-model - Modifiers](https://vuejs.org/guide/components/v-model.html#handling-v-model-modifiers)
|
||||
- [Vue.js RFC - defineModel](https://github.com/vuejs/rfcs/discussions/503)
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
title: defineModel Value Changes Apply After Next Tick
|
||||
impact: MEDIUM
|
||||
impactDescription: Reading model.value immediately after setting it returns the old value, not the new one
|
||||
type: gotcha
|
||||
tags: [vue3, v-model, defineModel, reactivity, timing, nextTick]
|
||||
---
|
||||
|
||||
# defineModel Value Changes Apply After Next Tick
|
||||
|
||||
**Impact: MEDIUM** - When you assign a new value to a `defineModel()` ref, the change doesn't take effect immediately. Reading `model.value` right after assignment still returns the previous value. The new value is only available after Vue's next tick.
|
||||
|
||||
This can cause bugs when you need to perform operations with the updated value immediately after changing it.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Don't read model.value immediately after setting it expecting the new value
|
||||
- [ ] Use the value you assigned directly instead of re-reading from model
|
||||
- [ ] Use nextTick() if you must read the updated value after assignment
|
||||
- [ ] Consider batching related updates together
|
||||
|
||||
**Incorrect - Expecting immediate value update:**
|
||||
```vue
|
||||
<script setup>
|
||||
const model = defineModel<string>()
|
||||
|
||||
function updateAndLog() {
|
||||
model.value = 'new value'
|
||||
|
||||
// WRONG: This still logs the OLD value!
|
||||
console.log(model.value) // Logs previous value, not 'new value'
|
||||
|
||||
// WRONG: Computation uses stale value
|
||||
const length = model.value.length // Length of OLD value
|
||||
|
||||
// WRONG: Conditional check on stale value
|
||||
if (model.value === 'new value') {
|
||||
// This block may not execute!
|
||||
doSomething()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct - Use the value directly:**
|
||||
```vue
|
||||
<script setup>
|
||||
const model = defineModel<string>()
|
||||
|
||||
function updateAndLog() {
|
||||
const newValue = 'new value'
|
||||
model.value = newValue
|
||||
|
||||
// CORRECT: Use the value you just assigned
|
||||
console.log(newValue) // Logs 'new value'
|
||||
|
||||
// CORRECT: Compute from the known value
|
||||
const length = newValue.length
|
||||
|
||||
// CORRECT: Check the known value
|
||||
if (newValue === 'new value') {
|
||||
doSomething()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**Alternative - Use nextTick for deferred operations:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const model = defineModel<string>()
|
||||
|
||||
async function updateAndProcess() {
|
||||
model.value = 'new value'
|
||||
|
||||
// Wait for Vue to apply the update
|
||||
await nextTick()
|
||||
|
||||
// NOW model.value reflects the new value
|
||||
console.log(model.value) // 'new value'
|
||||
processUpdatedValue(model.value)
|
||||
}
|
||||
|
||||
// Or using callback style
|
||||
function updateWithCallback() {
|
||||
model.value = 'new value'
|
||||
|
||||
nextTick(() => {
|
||||
// Safe to read updated value here
|
||||
console.log(model.value) // 'new value'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Why This Happens
|
||||
|
||||
`defineModel` uses Vue's internal synchronization mechanism (`watchSyncEffect`) to sync with the parent. When you assign to `model.value`:
|
||||
|
||||
1. The local ref updates
|
||||
2. An `update:modelValue` event is emitted to parent
|
||||
3. Parent updates its ref
|
||||
4. Vue syncs back to child in the next tick
|
||||
|
||||
During this cycle, the child's local value briefly differs from what's been committed.
|
||||
|
||||
## Pattern: Object Updates with Immediate Access
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const model = defineModel<{ name: string; validated: boolean }>()
|
||||
|
||||
async function validateAndUpdate(newName: string) {
|
||||
// Build the new object
|
||||
const updated = {
|
||||
...model.value,
|
||||
name: newName,
|
||||
validated: true
|
||||
}
|
||||
|
||||
// Assign to model
|
||||
model.value = updated
|
||||
|
||||
// Use 'updated' for immediate operations, not model.value
|
||||
saveToServer(updated) // CORRECT: Use local reference
|
||||
|
||||
// If you need model.value specifically (e.g., for DOM sync):
|
||||
await nextTick()
|
||||
focusValidatedField() // Now safe to assume DOM updated
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Watch Callbacks Also See Updated Values
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
|
||||
const model = defineModel<string>()
|
||||
|
||||
// Watch callback receives the new value
|
||||
watch(model, (newValue, oldValue) => {
|
||||
// 'newValue' is reliable here
|
||||
console.log('Changed from', oldValue, 'to', newValue)
|
||||
})
|
||||
|
||||
function update() {
|
||||
model.value = 'new value'
|
||||
// watch callback will fire with correct 'new value'
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Reactivity - nextTick](https://vuejs.org/api/general.html#nexttick)
|
||||
- [Vue.js Component v-model](https://vuejs.org/guide/components/v-model.html)
|
||||
- [SIMPL Engineering: Vue defineModel Pitfalls](https://engineering.simpl.de/post/vue_definemodel/)
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
title: Treat Directive Hook Arguments as Read-Only
|
||||
impact: MEDIUM
|
||||
impactDescription: Modifying directive arguments causes unpredictable behavior and breaks Vue's internal state
|
||||
type: gotcha
|
||||
tags: [vue3, directives, hooks, read-only, dataset]
|
||||
---
|
||||
|
||||
# Treat Directive Hook Arguments as Read-Only
|
||||
|
||||
**Impact: MEDIUM** - Apart from `el`, you should treat all directive hook arguments (`binding`, `vnode`, `prevVnode`) as read-only and never modify them. Modifying these objects can cause unpredictable behavior and interfere with Vue's internal workings.
|
||||
|
||||
If you need to share information across hooks, use the element's `dataset` attribute or a WeakMap.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Never mutate `binding`, `vnode`, or `prevVnode` arguments
|
||||
- [ ] Use `el.dataset` to share primitive data between hooks
|
||||
- [ ] Use a WeakMap for complex data that needs to persist across hooks
|
||||
- [ ] Only modify `el` (the DOM element) directly
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
// WRONG: Mutating binding object
|
||||
const vBadDirective = {
|
||||
mounted(el, binding) {
|
||||
// DON'T DO THIS - modifying binding
|
||||
binding.value = 'modified' // WRONG!
|
||||
binding.customData = 'stored' // WRONG!
|
||||
binding.modifiers.custom = true // WRONG!
|
||||
},
|
||||
updated(el, binding) {
|
||||
// These modifications may be lost or cause errors
|
||||
console.log(binding.customData) // undefined or error
|
||||
}
|
||||
}
|
||||
|
||||
// WRONG: Mutating vnode
|
||||
const vAnotherBadDirective = {
|
||||
mounted(el, binding, vnode) {
|
||||
// DON'T DO THIS
|
||||
vnode.myData = 'stored' // WRONG!
|
||||
vnode.props.modified = true // WRONG!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
// CORRECT: Use el.dataset for simple data
|
||||
const vWithDataset = {
|
||||
mounted(el, binding) {
|
||||
// Store data on the element's dataset
|
||||
el.dataset.originalValue = binding.value
|
||||
el.dataset.mountedAt = Date.now().toString()
|
||||
},
|
||||
updated(el, binding) {
|
||||
// Access previously stored data
|
||||
console.log('Original:', el.dataset.originalValue)
|
||||
console.log('Current:', binding.value)
|
||||
console.log('Mounted at:', el.dataset.mountedAt)
|
||||
},
|
||||
unmounted(el) {
|
||||
// Clean up dataset if needed
|
||||
delete el.dataset.originalValue
|
||||
delete el.dataset.mountedAt
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT: Use WeakMap for complex data
|
||||
const directiveState = new WeakMap()
|
||||
|
||||
const vWithWeakMap = {
|
||||
mounted(el, binding) {
|
||||
// Store complex state
|
||||
directiveState.set(el, {
|
||||
originalValue: binding.value,
|
||||
config: binding.arg,
|
||||
mountedAt: Date.now(),
|
||||
callbacks: [],
|
||||
observers: []
|
||||
})
|
||||
},
|
||||
updated(el, binding) {
|
||||
const state = directiveState.get(el)
|
||||
if (state) {
|
||||
console.log('Original:', state.originalValue)
|
||||
console.log('Current:', binding.value)
|
||||
// Can safely modify state object
|
||||
state.updateCount = (state.updateCount || 0) + 1
|
||||
}
|
||||
},
|
||||
unmounted(el) {
|
||||
// WeakMap auto-cleans when element is garbage collected
|
||||
// but explicit cleanup is good for observers/listeners
|
||||
const state = directiveState.get(el)
|
||||
if (state) {
|
||||
state.observers.forEach(obs => obs.disconnect())
|
||||
directiveState.delete(el)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Using Element Properties
|
||||
|
||||
```javascript
|
||||
// CORRECT: Use element properties with underscore prefix convention
|
||||
const vTooltip = {
|
||||
mounted(el, binding) {
|
||||
// Store on element with underscore prefix to avoid conflicts
|
||||
el._tooltipInstance = createTooltip(el, binding.value)
|
||||
el._tooltipConfig = { ...binding.modifiers }
|
||||
},
|
||||
updated(el, binding) {
|
||||
// Access and update stored instance
|
||||
if (el._tooltipInstance) {
|
||||
el._tooltipInstance.update(binding.value)
|
||||
}
|
||||
},
|
||||
unmounted(el) {
|
||||
// Clean up
|
||||
if (el._tooltipInstance) {
|
||||
el._tooltipInstance.destroy()
|
||||
delete el._tooltipInstance
|
||||
delete el._tooltipConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## What You CAN Modify
|
||||
|
||||
You are allowed to modify the `el` (DOM element) itself:
|
||||
|
||||
```javascript
|
||||
const vHighlight = {
|
||||
mounted(el, binding) {
|
||||
// CORRECT: Modifying el directly is allowed
|
||||
el.style.backgroundColor = binding.value
|
||||
el.classList.add('highlighted')
|
||||
el.setAttribute('data-highlighted', 'true')
|
||||
el.textContent = 'Modified content'
|
||||
},
|
||||
updated(el, binding) {
|
||||
// CORRECT: Update el when binding changes
|
||||
el.style.backgroundColor = binding.value
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Binding Object Properties (Read-Only Reference)
|
||||
|
||||
The `binding` object contains:
|
||||
- `value` - Current value passed to directive (read-only)
|
||||
- `oldValue` - Previous value (only in beforeUpdate/updated) (read-only)
|
||||
- `arg` - Argument passed (e.g., `v-dir:arg`) (read-only)
|
||||
- `modifiers` - Object of modifiers (e.g., `v-dir.foo.bar`) (read-only)
|
||||
- `instance` - Component instance (read-only)
|
||||
- `dir` - Directive definition object (read-only)
|
||||
|
||||
```javascript
|
||||
const vExample = {
|
||||
mounted(el, binding) {
|
||||
// READ these properties, don't modify them
|
||||
console.log(binding.value) // Read: OK
|
||||
console.log(binding.arg) // Read: OK
|
||||
console.log(binding.modifiers) // Read: OK
|
||||
console.log(binding.instance) // Read: OK
|
||||
|
||||
// Store what you need for later
|
||||
el.dataset.directiveArg = binding.arg || ''
|
||||
el.dataset.hasModifierFoo = binding.modifiers.foo ? 'true' : 'false'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Custom Directives - Hook Arguments](https://vuejs.org/guide/reusability/custom-directives#hook-arguments)
|
||||
- [MDN - HTMLElement.dataset](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset)
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
title: Avoid Using Custom Directives on Components
|
||||
impact: HIGH
|
||||
impactDescription: Custom directives on multi-root components are silently ignored, causing unexpected behavior
|
||||
type: gotcha
|
||||
tags: [vue3, directives, components, multi-root, best-practices]
|
||||
---
|
||||
|
||||
# Avoid Using Custom Directives on Components
|
||||
|
||||
**Impact: HIGH** - Using custom directives on components is not recommended and can lead to unexpected behavior. When applied to a multi-root component, the directive will be ignored and a warning will be thrown. Unlike attributes, directives cannot be passed to a different element with `v-bind="$attrs"`.
|
||||
|
||||
Custom directives are designed for direct DOM manipulation on native HTML elements, not Vue components.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Only apply custom directives to native HTML elements, not components
|
||||
- [ ] If a component needs directive-like behavior, consider making it part of the component's API
|
||||
- [ ] For components, use props and events instead of directives
|
||||
- [ ] If you must use a directive on a component, ensure it has a single root element
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- WRONG: Directive on a component - may be ignored -->
|
||||
<MyComponent v-focus />
|
||||
|
||||
<!-- WRONG: Multi-root component - directive is ignored with warning -->
|
||||
<MultiRootComponent v-highlight />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MyComponent from './MyComponent.vue'
|
||||
import MultiRootComponent from './MultiRootComponent.vue'
|
||||
|
||||
// MultiRootComponent.vue has:
|
||||
// <template>
|
||||
// <div>First root</div>
|
||||
// <div>Second root</div>
|
||||
// </template>
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- CORRECT: Directive on native HTML element -->
|
||||
<input v-focus />
|
||||
|
||||
<!-- CORRECT: Use props/events for component behavior -->
|
||||
<MyComponent :should-focus="true" />
|
||||
|
||||
<!-- CORRECT: Wrap component in element if directive is needed -->
|
||||
<div v-highlight>
|
||||
<MyComponent />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MyComponent from './MyComponent.vue'
|
||||
</script>
|
||||
```
|
||||
|
||||
## When a Directive on Component Works
|
||||
|
||||
Directives only work reliably on components with a **single root element**. The directive applies to the root node, similar to fallthrough attributes:
|
||||
|
||||
```vue
|
||||
<!-- SingleRootComponent.vue -->
|
||||
<template>
|
||||
<div>I am the only root</div>
|
||||
</template>
|
||||
|
||||
<!-- Parent.vue -->
|
||||
<template>
|
||||
<!-- This works because SingleRootComponent has one root -->
|
||||
<SingleRootComponent v-my-directive />
|
||||
</template>
|
||||
```
|
||||
|
||||
However, this is still not recommended because:
|
||||
1. It's fragile - refactoring to multi-root breaks the directive silently
|
||||
2. It's unclear which element receives the directive
|
||||
3. The component author may not expect external DOM manipulation
|
||||
|
||||
## Better Patterns
|
||||
|
||||
### Option 1: Component Prop
|
||||
```vue
|
||||
<!-- FocusableInput.vue -->
|
||||
<template>
|
||||
<input ref="inputRef" v-bind="$attrs" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
autofocus: Boolean
|
||||
})
|
||||
|
||||
const inputRef = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autofocus) {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- Usage -->
|
||||
<FocusableInput autofocus />
|
||||
```
|
||||
|
||||
### Option 2: Exposed Method
|
||||
```vue
|
||||
<!-- FocusableInput.vue -->
|
||||
<template>
|
||||
<input ref="inputRef" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const inputRef = ref(null)
|
||||
|
||||
const focus = () => inputRef.value?.focus()
|
||||
|
||||
defineExpose({ focus })
|
||||
</script>
|
||||
|
||||
<!-- Parent.vue -->
|
||||
<template>
|
||||
<FocusableInput ref="myInput" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const myInput = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
myInput.value?.focus()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Custom Directives - Usage on Components](https://vuejs.org/guide/reusability/custom-directives#usage-on-components)
|
||||
@@ -0,0 +1,219 @@
|
||||
---
|
||||
title: Clean Up Side Effects in Directive unmounted Hook
|
||||
impact: HIGH
|
||||
impactDescription: Failing to clean up intervals, event listeners, and subscriptions in directives causes memory leaks
|
||||
type: gotcha
|
||||
tags: [vue3, directives, memory-leak, cleanup, unmounted, event-listeners]
|
||||
---
|
||||
|
||||
# Clean Up Side Effects in Directive unmounted Hook
|
||||
|
||||
**Impact: HIGH** - A common and critical mistake when creating custom directives is forgetting to clean up intervals, event listeners, and other side effects in the `unmounted` hook. This causes memory leaks and ghost handlers that continue running after the element is removed from the DOM.
|
||||
|
||||
The key to avoiding such bugs is always implementing the `unmounted` hook to clean up any resources created in `mounted` or other lifecycle hooks.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Always pair resource creation in `mounted` with cleanup in `unmounted`
|
||||
- [ ] Store references to intervals, timeouts, and listeners for later cleanup
|
||||
- [ ] Use `el.dataset` or WeakMap to share data between directive hooks
|
||||
- [ ] Test that directives properly clean up when elements are removed (v-if toggling)
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
// WRONG: No cleanup - memory leak!
|
||||
const vPoll = {
|
||||
mounted(el, binding) {
|
||||
// This interval runs forever, even after element is removed!
|
||||
setInterval(() => {
|
||||
console.log('polling...')
|
||||
binding.value?.()
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// WRONG: Event listener persists after unmount
|
||||
const vClickOutside = {
|
||||
mounted(el, binding) {
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!el.contains(e.target)) {
|
||||
binding.value()
|
||||
}
|
||||
})
|
||||
// No cleanup - listener stays attached to document!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
// CORRECT: Store reference and clean up
|
||||
const vPoll = {
|
||||
mounted(el, binding) {
|
||||
// Store interval ID on the element for later cleanup
|
||||
el._pollInterval = setInterval(() => {
|
||||
console.log('polling...')
|
||||
binding.value?.()
|
||||
}, 1000)
|
||||
},
|
||||
unmounted(el) {
|
||||
// Clean up the interval
|
||||
if (el._pollInterval) {
|
||||
clearInterval(el._pollInterval)
|
||||
delete el._pollInterval
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT: Named function for proper removal
|
||||
const vClickOutside = {
|
||||
mounted(el, binding) {
|
||||
el._clickOutsideHandler = (e) => {
|
||||
if (!el.contains(e.target)) {
|
||||
binding.value()
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', el._clickOutsideHandler)
|
||||
},
|
||||
unmounted(el) {
|
||||
if (el._clickOutsideHandler) {
|
||||
document.removeEventListener('click', el._clickOutsideHandler)
|
||||
delete el._clickOutsideHandler
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Using WeakMap for Cleaner State Management
|
||||
|
||||
```javascript
|
||||
// BEST: Use WeakMap to avoid polluting element properties
|
||||
const pollIntervals = new WeakMap()
|
||||
const clickHandlers = new WeakMap()
|
||||
|
||||
const vPoll = {
|
||||
mounted(el, binding) {
|
||||
const intervalId = setInterval(() => {
|
||||
binding.value?.()
|
||||
}, binding.arg || 1000)
|
||||
pollIntervals.set(el, intervalId)
|
||||
},
|
||||
unmounted(el) {
|
||||
const intervalId = pollIntervals.get(el)
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
pollIntervals.delete(el)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const vClickOutside = {
|
||||
mounted(el, binding) {
|
||||
const handler = (e) => {
|
||||
if (!el.contains(e.target)) {
|
||||
binding.value()
|
||||
}
|
||||
}
|
||||
clickHandlers.set(el, handler)
|
||||
document.addEventListener('click', handler)
|
||||
},
|
||||
unmounted(el) {
|
||||
const handler = clickHandlers.get(el)
|
||||
if (handler) {
|
||||
document.removeEventListener('click', handler)
|
||||
clickHandlers.delete(el)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example with Multiple Resources
|
||||
|
||||
```javascript
|
||||
const vAutoScroll = {
|
||||
mounted(el, binding) {
|
||||
const state = {
|
||||
intervalId: null,
|
||||
resizeObserver: null,
|
||||
scrollHandler: null
|
||||
}
|
||||
|
||||
// Set up polling
|
||||
state.intervalId = setInterval(() => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}, binding.value?.interval || 100)
|
||||
|
||||
// Set up resize observer
|
||||
state.resizeObserver = new ResizeObserver(() => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
})
|
||||
state.resizeObserver.observe(el)
|
||||
|
||||
// Set up scroll listener
|
||||
state.scrollHandler = () => {
|
||||
// Track user scroll
|
||||
}
|
||||
el.addEventListener('scroll', state.scrollHandler)
|
||||
|
||||
// Store all state for cleanup
|
||||
el._autoScrollState = state
|
||||
},
|
||||
|
||||
unmounted(el) {
|
||||
const state = el._autoScrollState
|
||||
if (!state) return
|
||||
|
||||
// Clean up everything
|
||||
if (state.intervalId) {
|
||||
clearInterval(state.intervalId)
|
||||
}
|
||||
if (state.resizeObserver) {
|
||||
state.resizeObserver.disconnect()
|
||||
}
|
||||
if (state.scrollHandler) {
|
||||
el.removeEventListener('scroll', state.scrollHandler)
|
||||
}
|
||||
|
||||
delete el._autoScrollState
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Directive Cleanup
|
||||
|
||||
```javascript
|
||||
// Test that cleanup works properly
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { ref, nextTick } from 'vue'
|
||||
|
||||
const vTrackInterval = {
|
||||
mounted(el) {
|
||||
el._interval = setInterval(() => {}, 100)
|
||||
window.__activeIntervals = (window.__activeIntervals || 0) + 1
|
||||
},
|
||||
unmounted(el) {
|
||||
clearInterval(el._interval)
|
||||
window.__activeIntervals--
|
||||
}
|
||||
}
|
||||
|
||||
test('directive cleans up interval on unmount', async () => {
|
||||
const show = ref(true)
|
||||
const wrapper = mount({
|
||||
template: `<div v-if="show" v-track-interval></div>`,
|
||||
directives: { 'track-interval': vTrackInterval },
|
||||
setup: () => ({ show })
|
||||
})
|
||||
|
||||
expect(window.__activeIntervals).toBe(1)
|
||||
|
||||
show.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(window.__activeIntervals).toBe(0)
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Custom Directives - Directive Hooks](https://vuejs.org/guide/reusability/custom-directives#directive-hooks)
|
||||
- [Vue School - The Directive's unmounted Hook](https://vueschool.io/lessons/vue-3-the-directive-s-unmounted-hook)
|
||||
@@ -0,0 +1,189 @@
|
||||
---
|
||||
title: Use Function Shorthand for Simple Directives
|
||||
impact: LOW
|
||||
impactDescription: Function shorthand reduces boilerplate for directives with identical mounted/updated behavior
|
||||
type: best-practice
|
||||
tags: [vue3, directives, shorthand, code-style]
|
||||
---
|
||||
|
||||
# Use Function Shorthand for Simple Directives
|
||||
|
||||
**Impact: LOW** - It's common for a custom directive to have the same behavior for `mounted` and `updated`, with no need for other hooks. In such cases, you can define the directive as a function instead of an object, reducing boilerplate and improving readability.
|
||||
|
||||
The function will be called for both `mounted` and `updated` lifecycle hooks.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use function shorthand when mounted and updated behavior is identical
|
||||
- [ ] Use object syntax when you need beforeMount, beforeUpdate, or unmounted hooks
|
||||
- [ ] Use object syntax when mounted and updated have different logic
|
||||
|
||||
**Verbose (when not needed):**
|
||||
```javascript
|
||||
// VERBOSE: Full object when behavior is identical
|
||||
const vColor = {
|
||||
mounted(el, binding) {
|
||||
el.style.color = binding.value
|
||||
},
|
||||
updated(el, binding) {
|
||||
el.style.color = binding.value // Same as mounted
|
||||
}
|
||||
}
|
||||
|
||||
const vHighlight = {
|
||||
mounted(el, binding) {
|
||||
el.style.backgroundColor = binding.value || 'yellow'
|
||||
},
|
||||
updated(el, binding) {
|
||||
el.style.backgroundColor = binding.value || 'yellow' // Duplicated
|
||||
}
|
||||
}
|
||||
|
||||
// Global registration - verbose
|
||||
app.directive('pin', {
|
||||
mounted(el, binding) {
|
||||
el.style.position = 'fixed'
|
||||
el.style.top = binding.value + 'px'
|
||||
},
|
||||
updated(el, binding) {
|
||||
el.style.position = 'fixed'
|
||||
el.style.top = binding.value + 'px'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Concise (function shorthand):**
|
||||
```javascript
|
||||
// CONCISE: Function shorthand
|
||||
const vColor = (el, binding) => {
|
||||
el.style.color = binding.value
|
||||
}
|
||||
|
||||
const vHighlight = (el, binding) => {
|
||||
el.style.backgroundColor = binding.value || 'yellow'
|
||||
}
|
||||
|
||||
// Global registration - concise
|
||||
app.directive('pin', (el, binding) => {
|
||||
el.style.position = 'fixed'
|
||||
el.style.top = binding.value + 'px'
|
||||
})
|
||||
```
|
||||
|
||||
## With script setup
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// Function shorthand for local directives
|
||||
const vFocus = (el) => {
|
||||
el.focus()
|
||||
}
|
||||
|
||||
const vColor = (el, binding) => {
|
||||
el.style.color = binding.value
|
||||
}
|
||||
|
||||
const vPin = (el, binding) => {
|
||||
el.style.position = binding.modifiers.absolute ? 'absolute' : 'fixed'
|
||||
const position = binding.arg || 'top'
|
||||
el.style[position] = binding.value + 'px'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input v-focus />
|
||||
<p v-color="'red'">Colored text</p>
|
||||
<div v-pin:left.absolute="100">Positioned element</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## When to Use Object Syntax
|
||||
|
||||
Use the full object syntax when:
|
||||
|
||||
### 1. You Need Cleanup (unmounted hook)
|
||||
```javascript
|
||||
// Need object syntax for cleanup
|
||||
const vClickOutside = {
|
||||
mounted(el, binding) {
|
||||
el._handler = (e) => {
|
||||
if (!el.contains(e.target)) binding.value(e)
|
||||
}
|
||||
document.addEventListener('click', el._handler)
|
||||
},
|
||||
unmounted(el) {
|
||||
document.removeEventListener('click', el._handler)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Different Logic for mounted vs updated
|
||||
```javascript
|
||||
// Need object syntax for different behavior
|
||||
const vLazyLoad = {
|
||||
mounted(el, binding) {
|
||||
// Initial setup - create observer
|
||||
el._observer = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting) {
|
||||
el.src = binding.value
|
||||
el._observer.disconnect()
|
||||
}
|
||||
})
|
||||
el._observer.observe(el)
|
||||
},
|
||||
updated(el, binding, vnode, prevVnode) {
|
||||
// Only update if value actually changed
|
||||
if (binding.value !== binding.oldValue) {
|
||||
el.src = binding.value
|
||||
}
|
||||
},
|
||||
unmounted(el) {
|
||||
el._observer?.disconnect()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. You Need beforeMount or beforeUpdate
|
||||
```javascript
|
||||
// Need object syntax for early lifecycle hooks
|
||||
const vAnimate = {
|
||||
beforeMount(el) {
|
||||
el.style.opacity = '0'
|
||||
},
|
||||
mounted(el) {
|
||||
requestAnimationFrame(() => {
|
||||
el.style.transition = 'opacity 0.3s'
|
||||
el.style.opacity = '1'
|
||||
})
|
||||
},
|
||||
beforeUpdate(el) {
|
||||
el.style.opacity = '0.5'
|
||||
},
|
||||
updated(el) {
|
||||
el.style.opacity = '1'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Object Literal Values with Function Shorthand
|
||||
|
||||
Function shorthand works well with object literal values:
|
||||
|
||||
```javascript
|
||||
const vDemo = (el, binding) => {
|
||||
console.log(binding.value.color) // => "white"
|
||||
console.log(binding.value.text) // => "hello!"
|
||||
|
||||
el.style.color = binding.value.color
|
||||
el.textContent = binding.value.text
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div v-demo="{ color: 'white', text: 'hello!' }"></div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Custom Directives - Function Shorthand](https://vuejs.org/guide/reusability/custom-directives#function-shorthand)
|
||||
192
skills/vue-best-practices/reference/directive-naming-v-prefix.md
Normal file
192
skills/vue-best-practices/reference/directive-naming-v-prefix.md
Normal file
@@ -0,0 +1,192 @@
|
||||
---
|
||||
title: Use v-prefix Naming Convention for Local Directives
|
||||
impact: LOW
|
||||
impactDescription: Proper naming enables automatic directive recognition in script setup
|
||||
type: best-practice
|
||||
tags: [vue3, directives, naming, script-setup, conventions]
|
||||
---
|
||||
|
||||
# Use v-prefix Naming Convention for Local Directives
|
||||
|
||||
**Impact: LOW** - In `<script setup>`, any camelCase variable starting with the `v` prefix can automatically be used as a custom directive. This convention enables seamless local directive registration without explicit configuration.
|
||||
|
||||
Following this naming pattern ensures Vue correctly recognizes and registers your local directives.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Name local directive variables with `v` prefix in camelCase (e.g., `vFocus`, `vHighlight`)
|
||||
- [ ] Use the directive in templates without the `v` prefix lowercase (e.g., `v-focus`, `v-highlight`)
|
||||
- [ ] For multi-word directives, use camelCase in script and kebab-case in template
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<script setup>
|
||||
// WRONG: No v prefix - won't be recognized as directive
|
||||
const focus = {
|
||||
mounted: (el) => el.focus()
|
||||
}
|
||||
|
||||
// WRONG: Wrong casing
|
||||
const VFocus = {
|
||||
mounted: (el) => el.focus()
|
||||
}
|
||||
|
||||
// WRONG: Kebab-case in script
|
||||
const 'v-focus' = { // Syntax error
|
||||
mounted: (el) => el.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- These won't work -->
|
||||
<input focus />
|
||||
<input v-Focus />
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<script setup>
|
||||
// CORRECT: v prefix with camelCase
|
||||
const vFocus = {
|
||||
mounted: (el) => el.focus()
|
||||
}
|
||||
|
||||
const vHighlight = {
|
||||
mounted: (el) => {
|
||||
el.classList.add('is-highlight')
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT: Multi-word directive
|
||||
const vClickOutside = {
|
||||
mounted(el, binding) {
|
||||
el._handler = (e) => {
|
||||
if (!el.contains(e.target)) binding.value(e)
|
||||
}
|
||||
document.addEventListener('click', el._handler)
|
||||
},
|
||||
unmounted(el) {
|
||||
document.removeEventListener('click', el._handler)
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT: Function shorthand with v prefix
|
||||
const vColor = (el, binding) => {
|
||||
el.style.color = binding.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Use kebab-case in template -->
|
||||
<input v-focus />
|
||||
<p v-highlight>Highlighted text</p>
|
||||
<div v-click-outside="closeMenu">Dropdown</div>
|
||||
<span v-color="'red'">Colored text</span>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Template Casing Rules
|
||||
|
||||
In templates, directives should use kebab-case:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const vMyLongDirectiveName = (el) => { /* ... */ }
|
||||
const vAutoFocusInput = (el) => el.focus()
|
||||
const vLazyLoadImage = { /* ... */ }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- camelCase in script -> kebab-case in template -->
|
||||
<div v-my-long-directive-name></div>
|
||||
<input v-auto-focus-input />
|
||||
<img v-lazy-load-image />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Options API Registration
|
||||
|
||||
Without `<script setup>`, directives need explicit registration:
|
||||
|
||||
```javascript
|
||||
// Local registration with Options API
|
||||
export default {
|
||||
directives: {
|
||||
// Key is directive name (without v- prefix)
|
||||
focus: {
|
||||
mounted: (el) => el.focus()
|
||||
},
|
||||
highlight: {
|
||||
mounted: (el) => el.classList.add('is-highlight')
|
||||
},
|
||||
// Multi-word uses camelCase key
|
||||
clickOutside: {
|
||||
mounted(el, binding) { /* ... */ },
|
||||
unmounted(el) { /* ... */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Global Registration
|
||||
|
||||
For global directives, register on the app instance:
|
||||
|
||||
```javascript
|
||||
// main.js
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Global directive - name without v- prefix
|
||||
app.directive('focus', {
|
||||
mounted: (el) => el.focus()
|
||||
})
|
||||
|
||||
// Multi-word directive
|
||||
app.directive('click-outside', {
|
||||
mounted(el, binding) { /* ... */ },
|
||||
unmounted(el) { /* ... */ }
|
||||
})
|
||||
|
||||
// Function shorthand
|
||||
app.directive('color', (el, binding) => {
|
||||
el.style.color = binding.value
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
```
|
||||
|
||||
## Importing Directives
|
||||
|
||||
When importing directives, rename to add v prefix:
|
||||
|
||||
```javascript
|
||||
// directives/focus.js
|
||||
export const focus = {
|
||||
mounted: (el) => el.focus()
|
||||
}
|
||||
|
||||
// In component
|
||||
<script setup>
|
||||
import { focus as vFocus } from '@/directives/focus'
|
||||
// Now usable as v-focus in template
|
||||
</script>
|
||||
|
||||
// Or export with v prefix already
|
||||
// directives/focus.js
|
||||
export const vFocus = {
|
||||
mounted: (el) => el.focus()
|
||||
}
|
||||
|
||||
// In component
|
||||
<script setup>
|
||||
import { vFocus } from '@/directives/focus'
|
||||
// Directly usable as v-focus
|
||||
</script>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Custom Directives - Introduction](https://vuejs.org/guide/reusability/custom-directives#introduction)
|
||||
@@ -0,0 +1,220 @@
|
||||
---
|
||||
title: Prefer Built-in Directives Over Custom Directives
|
||||
impact: MEDIUM
|
||||
impactDescription: Custom directives are less efficient than built-in directives and not SSR-friendly
|
||||
type: best-practice
|
||||
tags: [vue3, directives, performance, ssr, best-practices]
|
||||
---
|
||||
|
||||
# Prefer Built-in Directives Over Custom Directives
|
||||
|
||||
**Impact: MEDIUM** - Custom directives should only be used when the desired functionality can only be achieved via direct DOM manipulation. Declarative templating with built-in directives such as `v-bind`, `v-show`, `v-if`, and `v-on` is recommended when possible because they are more efficient and server-rendering friendly.
|
||||
|
||||
Before creating a custom directive, consider if the same result can be achieved with Vue's built-in reactivity and templating features.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Before creating a custom directive, check if built-in directives can solve the problem
|
||||
- [ ] Consider if a composable function would be more appropriate
|
||||
- [ ] For SSR applications, evaluate if the directive will work on the server
|
||||
- [ ] Only use custom directives for low-level DOM manipulation that can't be done declaratively
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- WRONG: Custom directive for something v-show does -->
|
||||
<div v-visibility="isVisible">Content</div>
|
||||
|
||||
<!-- WRONG: Custom directive for class binding -->
|
||||
<div v-add-class="{ active: isActive }">Content</div>
|
||||
|
||||
<!-- WRONG: Custom directive for style binding -->
|
||||
<div v-set-color="textColor">Content</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const isVisible = ref(true)
|
||||
const isActive = ref(false)
|
||||
const textColor = ref('blue')
|
||||
|
||||
// Unnecessary custom directives
|
||||
const vVisibility = {
|
||||
mounted(el, binding) {
|
||||
el.style.display = binding.value ? '' : 'none'
|
||||
},
|
||||
updated(el, binding) {
|
||||
el.style.display = binding.value ? '' : 'none'
|
||||
}
|
||||
}
|
||||
|
||||
const vAddClass = {
|
||||
mounted(el, binding) {
|
||||
Object.entries(binding.value).forEach(([cls, active]) => {
|
||||
el.classList.toggle(cls, active)
|
||||
})
|
||||
},
|
||||
updated(el, binding) {
|
||||
Object.entries(binding.value).forEach(([cls, active]) => {
|
||||
el.classList.toggle(cls, active)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const vSetColor = (el, binding) => {
|
||||
el.style.color = binding.value
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- CORRECT: Use built-in v-show -->
|
||||
<div v-show="isVisible">Content</div>
|
||||
|
||||
<!-- CORRECT: Use built-in class binding -->
|
||||
<div :class="{ active: isActive }">Content</div>
|
||||
|
||||
<!-- CORRECT: Use built-in style binding -->
|
||||
<div :style="{ color: textColor }">Content</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const isVisible = ref(true)
|
||||
const isActive = ref(false)
|
||||
const textColor = ref('blue')
|
||||
// No custom directives needed!
|
||||
</script>
|
||||
```
|
||||
|
||||
## When Custom Directives ARE Appropriate
|
||||
|
||||
Custom directives are appropriate when you need:
|
||||
|
||||
### 1. Direct DOM API Access
|
||||
```javascript
|
||||
// GOOD: Focus management requires DOM API
|
||||
const vFocus = {
|
||||
mounted(el) {
|
||||
el.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// Usage: Works on dynamic insertion, not just page load
|
||||
// <input v-focus />
|
||||
```
|
||||
|
||||
### 2. Third-Party Library Integration
|
||||
```javascript
|
||||
// GOOD: Integrating with external libraries
|
||||
const vTippy = {
|
||||
mounted(el, binding) {
|
||||
el._tippy = tippy(el, {
|
||||
content: binding.value,
|
||||
...binding.modifiers
|
||||
})
|
||||
},
|
||||
updated(el, binding) {
|
||||
el._tippy?.setContent(binding.value)
|
||||
},
|
||||
unmounted(el) {
|
||||
el._tippy?.destroy()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Event Handling Outside Vue's Scope
|
||||
```javascript
|
||||
// GOOD: Global event that Vue doesn't provide
|
||||
const vClickOutside = {
|
||||
mounted(el, binding) {
|
||||
el._clickOutside = (e) => {
|
||||
if (!el.contains(e.target)) {
|
||||
binding.value(e)
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', el._clickOutside)
|
||||
},
|
||||
unmounted(el) {
|
||||
document.removeEventListener('click', el._clickOutside)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Intersection/Mutation/Resize Observers
|
||||
```javascript
|
||||
// GOOD: IntersectionObserver requires DOM API
|
||||
const vLazyLoad = {
|
||||
mounted(el, binding) {
|
||||
el._observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
el.src = binding.value
|
||||
el._observer.disconnect()
|
||||
}
|
||||
})
|
||||
el._observer.observe(el)
|
||||
},
|
||||
unmounted(el) {
|
||||
el._observer?.disconnect()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Consider Composables Instead
|
||||
|
||||
For complex logic, a composable might be better than a directive:
|
||||
|
||||
```javascript
|
||||
// Composable approach - more flexible and testable
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export function useClickOutside(elementRef, callback) {
|
||||
const handler = (e) => {
|
||||
if (elementRef.value && !elementRef.value.contains(e.target)) {
|
||||
callback(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', handler))
|
||||
onUnmounted(() => document.removeEventListener('click', handler))
|
||||
}
|
||||
|
||||
// Usage in component
|
||||
const dropdownRef = ref(null)
|
||||
useClickOutside(dropdownRef, () => {
|
||||
isOpen.value = false
|
||||
})
|
||||
```
|
||||
|
||||
## SSR Considerations
|
||||
|
||||
Custom directives don't run on the server, which can cause hydration issues:
|
||||
|
||||
```javascript
|
||||
// PROBLEM: This directive modifies DOM, causing hydration mismatch
|
||||
const vHydrationProblem = {
|
||||
mounted(el) {
|
||||
el.textContent = 'Client-side only text'
|
||||
}
|
||||
}
|
||||
|
||||
// SOLUTION: Use built-in directives or ensure server/client match
|
||||
// Or handle hydration explicitly:
|
||||
const vSafeForSSR = {
|
||||
mounted(el, binding) {
|
||||
// Only add behavior, don't modify content
|
||||
el.addEventListener('click', binding.value)
|
||||
},
|
||||
unmounted(el, binding) {
|
||||
el.removeEventListener('click', binding.value)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Custom Directives - Introduction](https://vuejs.org/guide/reusability/custom-directives#introduction)
|
||||
- [Vue.js Composables](https://vuejs.org/guide/reusability/composables.html)
|
||||
@@ -0,0 +1,230 @@
|
||||
---
|
||||
title: Know When to Use Directives vs Components
|
||||
impact: MEDIUM
|
||||
impactDescription: Using directives when components are more appropriate leads to harder maintenance and testing
|
||||
type: best-practice
|
||||
tags: [vue3, directives, components, architecture, best-practices]
|
||||
---
|
||||
|
||||
# Know When to Use Directives vs Components
|
||||
|
||||
**Impact: MEDIUM** - Accessing the component instance from within a custom directive is often a sign that the directive should rather be a component itself. Directives are designed for low-level DOM manipulation, while components are better for encapsulating behavior that involves state, reactivity, or complex logic.
|
||||
|
||||
Choosing the wrong abstraction leads to code that's harder to maintain, test, and reuse.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use directives for simple, stateless DOM manipulations
|
||||
- [ ] Use components when you need encapsulated state or complex logic
|
||||
- [ ] If accessing `binding.instance` frequently, consider using a component instead
|
||||
- [ ] If the behavior needs its own template, use a component
|
||||
- [ ] Consider composables for stateful logic that doesn't need a template
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
| Requirement | Use Directive | Use Component | Use Composable |
|
||||
|-------------|--------------|---------------|----------------|
|
||||
| DOM manipulation only | Yes | - | - |
|
||||
| Needs own template | - | Yes | - |
|
||||
| Encapsulated state | - | Yes | Maybe |
|
||||
| Reusable behavior | Yes | Yes | Yes |
|
||||
| Access to parent instance | Avoid | - | Yes |
|
||||
| SSR support needed | Avoid | Yes | Yes |
|
||||
| Third-party lib integration | Yes | - | Maybe |
|
||||
| Complex reactive logic | - | Yes | Yes |
|
||||
|
||||
## Directive-Appropriate Use Cases
|
||||
|
||||
```javascript
|
||||
// GOOD: Simple DOM manipulation
|
||||
const vFocus = {
|
||||
mounted: (el) => el.focus()
|
||||
}
|
||||
|
||||
// GOOD: Third-party library integration
|
||||
const vTippy = {
|
||||
mounted(el, binding) {
|
||||
el._tippy = tippy(el, binding.value)
|
||||
},
|
||||
updated(el, binding) {
|
||||
el._tippy?.setProps(binding.value)
|
||||
},
|
||||
unmounted(el) {
|
||||
el._tippy?.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
// GOOD: Event handling that Vue doesn't provide
|
||||
const vClickOutside = {
|
||||
mounted(el, binding) {
|
||||
el._handler = (e) => {
|
||||
if (!el.contains(e.target)) binding.value(e)
|
||||
}
|
||||
document.addEventListener('click', el._handler)
|
||||
},
|
||||
unmounted(el) {
|
||||
document.removeEventListener('click', el._handler)
|
||||
}
|
||||
}
|
||||
|
||||
// GOOD: Intersection Observer
|
||||
const vLazyLoad = {
|
||||
mounted(el, binding) {
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
el.src = binding.value
|
||||
observer.disconnect()
|
||||
}
|
||||
})
|
||||
observer.observe(el)
|
||||
el._observer = observer
|
||||
},
|
||||
unmounted(el) {
|
||||
el._observer?.disconnect()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Component-Appropriate Use Cases
|
||||
|
||||
```vue
|
||||
<!-- GOOD: Component with template and state -->
|
||||
<!-- Tooltip.vue -->
|
||||
<template>
|
||||
<div class="tooltip-wrapper" @mouseenter="show" @mouseleave="hide">
|
||||
<slot></slot>
|
||||
<Transition name="fade">
|
||||
<div v-if="isVisible" class="tooltip-content">
|
||||
{{ content }}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
content: String
|
||||
})
|
||||
|
||||
const isVisible = ref(false)
|
||||
const show = () => isVisible.value = true
|
||||
const hide = () => isVisible.value = false
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- GOOD: Component with complex logic -->
|
||||
<!-- InfiniteScroll.vue -->
|
||||
<template>
|
||||
<div ref="container">
|
||||
<slot></slot>
|
||||
<div v-if="loading" class="loading-indicator">
|
||||
<slot name="loading">Loading...</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
threshold: { type: Number, default: 100 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['load-more'])
|
||||
const container = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// Complex scroll logic with state management
|
||||
const handleScroll = () => {
|
||||
if (loading.value) return
|
||||
const { scrollHeight, scrollTop, clientHeight } = container.value
|
||||
if (scrollHeight - scrollTop - clientHeight < props.threshold) {
|
||||
loading.value = true
|
||||
emit('load-more', () => { loading.value = false })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => container.value?.addEventListener('scroll', handleScroll))
|
||||
onUnmounted(() => container.value?.removeEventListener('scroll', handleScroll))
|
||||
</script>
|
||||
```
|
||||
|
||||
## Composable-Appropriate Use Cases
|
||||
|
||||
```javascript
|
||||
// GOOD: Reusable stateful logic without template
|
||||
// useClickOutside.js
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
export function useClickOutside(elementRef, callback) {
|
||||
const isClickedOutside = ref(false)
|
||||
|
||||
const handler = (e) => {
|
||||
if (elementRef.value && !elementRef.value.contains(e.target)) {
|
||||
isClickedOutside.value = true
|
||||
callback?.(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', handler))
|
||||
onUnmounted(() => document.removeEventListener('click', handler))
|
||||
|
||||
return { isClickedOutside }
|
||||
}
|
||||
|
||||
// Usage in component
|
||||
const dropdownRef = ref(null)
|
||||
const { isClickedOutside } = useClickOutside(dropdownRef, () => {
|
||||
isOpen.value = false
|
||||
})
|
||||
```
|
||||
|
||||
## Anti-Pattern: Directive Accessing Instance Too Much
|
||||
|
||||
```javascript
|
||||
// ANTI-PATTERN: Directive relying heavily on component instance
|
||||
const vBadPattern = {
|
||||
mounted(el, binding) {
|
||||
// Accessing instance too much = should be a component
|
||||
const instance = binding.instance
|
||||
instance.someMethod()
|
||||
instance.someProperty = 'value'
|
||||
instance.$watch('someProp', (val) => {
|
||||
el.textContent = val
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BETTER: Use a component or composable
|
||||
// Component version
|
||||
<template>
|
||||
<div>{{ someProp }}</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps(['someProp'])
|
||||
</script>
|
||||
```
|
||||
|
||||
## When Instance Access is Acceptable
|
||||
|
||||
```javascript
|
||||
// OK: Minimal instance access for specific needs
|
||||
const vPermission = {
|
||||
mounted(el, binding) {
|
||||
// Checking a global permission - acceptable
|
||||
const userPermissions = binding.instance.$store?.state.user.permissions
|
||||
if (!userPermissions?.includes(binding.value)) {
|
||||
el.style.display = 'none'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Custom Directives](https://vuejs.org/guide/reusability/custom-directives)
|
||||
- [Vue.js Composables](https://vuejs.org/guide/reusability/composables.html)
|
||||
- [Vue.js Components Basics](https://vuejs.org/guide/essentials/component-basics.html)
|
||||
@@ -0,0 +1,210 @@
|
||||
---
|
||||
title: Vue 3 Directive Hooks Renamed from Vue 2
|
||||
impact: HIGH
|
||||
impactDescription: Using Vue 2 hook names in Vue 3 causes directives to silently fail
|
||||
type: gotcha
|
||||
tags: [vue3, vue2, migration, directives, hooks, breaking-change]
|
||||
---
|
||||
|
||||
# Vue 3 Directive Hooks Renamed from Vue 2
|
||||
|
||||
**Impact: HIGH** - Vue 3 renamed all custom directive lifecycle hooks to align with component lifecycle hooks. Using Vue 2 hook names will cause your directives to silently fail since the hooks won't be called. Additionally, the `update` hook was removed entirely.
|
||||
|
||||
This is a breaking change that requires updating all custom directives when migrating from Vue 2 to Vue 3.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Rename `bind` to `beforeMount`
|
||||
- [ ] Rename `inserted` to `mounted`
|
||||
- [ ] Replace `update` with `beforeUpdate` or `updated` (update was removed)
|
||||
- [ ] Rename `componentUpdated` to `updated`
|
||||
- [ ] Rename `unbind` to `unmounted`
|
||||
- [ ] Add `beforeUpdate` if you need the old `update` behavior
|
||||
|
||||
## Hook Name Mapping
|
||||
|
||||
| Vue 2 | Vue 3 |
|
||||
|-----------------|----------------|
|
||||
| `bind` | `beforeMount` |
|
||||
| `inserted` | `mounted` |
|
||||
| `update` | **removed** |
|
||||
| `componentUpdated` | `updated` |
|
||||
| `unbind` | `unmounted` |
|
||||
| (none) | `created` |
|
||||
| (none) | `beforeUpdate` |
|
||||
| (none) | `beforeUnmount`|
|
||||
|
||||
**Vue 2 (old):**
|
||||
```javascript
|
||||
// Vue 2 directive - WILL NOT WORK IN VUE 3
|
||||
Vue.directive('demo', {
|
||||
bind(el, binding, vnode) {
|
||||
// Called when directive is first bound to element
|
||||
},
|
||||
inserted(el, binding, vnode) {
|
||||
// Called when element is inserted into parent
|
||||
},
|
||||
update(el, binding, vnode, oldVnode) {
|
||||
// Called on every VNode update (REMOVED in Vue 3)
|
||||
},
|
||||
componentUpdated(el, binding, vnode, oldVnode) {
|
||||
// Called after component and children update
|
||||
},
|
||||
unbind(el, binding, vnode) {
|
||||
// Called when directive is unbound from element
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Vue 3 (new):**
|
||||
```javascript
|
||||
// Vue 3 directive - Correct hook names
|
||||
app.directive('demo', {
|
||||
created(el, binding, vnode) {
|
||||
// NEW: called before element's attributes or event listeners are applied
|
||||
},
|
||||
beforeMount(el, binding, vnode) {
|
||||
// Was: bind
|
||||
},
|
||||
mounted(el, binding, vnode) {
|
||||
// Was: inserted
|
||||
},
|
||||
beforeUpdate(el, binding, vnode, prevVnode) {
|
||||
// NEW: called before the element itself is updated
|
||||
},
|
||||
updated(el, binding, vnode, prevVnode) {
|
||||
// Was: componentUpdated
|
||||
// Note: 'update' was removed - use this or beforeUpdate instead
|
||||
},
|
||||
beforeUnmount(el, binding, vnode) {
|
||||
// NEW: called before element is unmounted
|
||||
},
|
||||
unmounted(el, binding, vnode) {
|
||||
// Was: unbind
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Migration Examples
|
||||
|
||||
### Simple Focus Directive
|
||||
```javascript
|
||||
// Vue 2
|
||||
Vue.directive('focus', {
|
||||
inserted(el) {
|
||||
el.focus()
|
||||
}
|
||||
})
|
||||
|
||||
// Vue 3
|
||||
app.directive('focus', {
|
||||
mounted(el) {
|
||||
el.focus()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Directive with Cleanup
|
||||
```javascript
|
||||
// Vue 2
|
||||
Vue.directive('click-outside', {
|
||||
bind(el, binding) {
|
||||
el._handler = (e) => {
|
||||
if (!el.contains(e.target)) binding.value(e)
|
||||
}
|
||||
document.addEventListener('click', el._handler)
|
||||
},
|
||||
unbind(el) {
|
||||
document.removeEventListener('click', el._handler)
|
||||
}
|
||||
})
|
||||
|
||||
// Vue 3
|
||||
app.directive('click-outside', {
|
||||
beforeMount(el, binding) { // or mounted
|
||||
el._handler = (e) => {
|
||||
if (!el.contains(e.target)) binding.value(e)
|
||||
}
|
||||
document.addEventListener('click', el._handler)
|
||||
},
|
||||
unmounted(el) {
|
||||
document.removeEventListener('click', el._handler)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Directive with Updates
|
||||
```javascript
|
||||
// Vue 2 - using update hook
|
||||
Vue.directive('color', {
|
||||
bind(el, binding) {
|
||||
el.style.color = binding.value
|
||||
},
|
||||
update(el, binding) {
|
||||
// Called on every VNode update
|
||||
el.style.color = binding.value
|
||||
}
|
||||
})
|
||||
|
||||
// Vue 3 - update removed, use function shorthand or updated
|
||||
app.directive('color', (el, binding) => {
|
||||
// Function shorthand: called for both mounted AND updated
|
||||
el.style.color = binding.value
|
||||
})
|
||||
|
||||
// Or with object syntax
|
||||
app.directive('color', {
|
||||
mounted(el, binding) {
|
||||
el.style.color = binding.value
|
||||
},
|
||||
updated(el, binding) {
|
||||
// Use updated instead of update
|
||||
el.style.color = binding.value
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Why `update` Was Removed
|
||||
|
||||
In Vue 2, `update` was called on every VNode update (before children updated), while `componentUpdated` was called after. The distinction was confusing and rarely needed. In Vue 3:
|
||||
|
||||
- `beforeUpdate` is called before the element updates
|
||||
- `updated` is called after the element and all its children have updated
|
||||
|
||||
```javascript
|
||||
// Vue 3 - if you need both before and after
|
||||
app.directive('track-updates', {
|
||||
beforeUpdate(el, binding) {
|
||||
console.log('Before update, old value:', binding.oldValue)
|
||||
},
|
||||
updated(el, binding) {
|
||||
console.log('After update, new value:', binding.value)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## vnode Structure Changes
|
||||
|
||||
In Vue 3, the `vnode` and `prevVnode` arguments also have different structure:
|
||||
|
||||
```javascript
|
||||
// Vue 2
|
||||
{
|
||||
update(el, binding, vnode, oldVnode) {
|
||||
// vnode.context was the component instance
|
||||
console.log(vnode.context)
|
||||
}
|
||||
}
|
||||
|
||||
// Vue 3
|
||||
{
|
||||
updated(el, binding, vnode, prevVnode) {
|
||||
// Use binding.instance instead of vnode.context
|
||||
console.log(binding.instance)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue 3 Migration Guide - Custom Directives](https://v3-migration.vuejs.org/breaking-changes/custom-directives)
|
||||
- [Vue.js Custom Directives - Directive Hooks](https://vuejs.org/guide/reusability/custom-directives#directive-hooks)
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
title: Use import.meta.glob for Dynamic Component Registration in Vite
|
||||
impact: MEDIUM
|
||||
impactDescription: require.context from Webpack doesn't work in Vite projects
|
||||
type: gotcha
|
||||
tags: [vue3, component-registration, vite, dynamic-import, migration, webpack]
|
||||
---
|
||||
|
||||
# Use import.meta.glob for Dynamic Component Registration in Vite
|
||||
|
||||
**Impact: MEDIUM** - When migrating from Webpack to Vite or starting a new Vite project, the `require.context` pattern for dynamically registering components won't work. Vite uses `import.meta.glob` instead. Using the wrong approach will cause build errors or runtime failures.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Replace `require.context` with `import.meta.glob` in Vite projects
|
||||
- [ ] Update component registration patterns when migrating from Vue CLI to Vite
|
||||
- [ ] Use `{ eager: true }` for synchronous loading when needed
|
||||
- [ ] Handle async components appropriately with `defineAsyncComponent`
|
||||
|
||||
**Incorrect (Webpack pattern - doesn't work in Vite):**
|
||||
```javascript
|
||||
// main.js - WRONG for Vite
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// This Webpack-specific API doesn't exist in Vite
|
||||
const requireComponent = require.context(
|
||||
'./components/base',
|
||||
false,
|
||||
/Base[A-Z]\w+\.vue$/
|
||||
)
|
||||
|
||||
requireComponent.keys().forEach(fileName => {
|
||||
const componentConfig = requireComponent(fileName)
|
||||
const componentName = fileName
|
||||
.split('/')
|
||||
.pop()
|
||||
.replace(/\.\w+$/, '')
|
||||
|
||||
app.component(componentName, componentConfig.default || componentConfig)
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
```
|
||||
|
||||
**Correct (Vite pattern):**
|
||||
```javascript
|
||||
// main.js - Correct for Vite
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Vite's glob import - eager loading for synchronous registration
|
||||
const modules = import.meta.glob('./components/base/Base*.vue', { eager: true })
|
||||
|
||||
for (const path in modules) {
|
||||
// Extract component name from path: './components/base/BaseButton.vue' -> 'BaseButton'
|
||||
const componentName = path.split('/').pop().replace('.vue', '')
|
||||
app.component(componentName, modules[path].default)
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
```
|
||||
|
||||
## Lazy Loading with Async Components
|
||||
|
||||
```javascript
|
||||
// main.js - Lazy loading variant
|
||||
import { createApp, defineAsyncComponent } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Without { eager: true }, returns functions that return Promises
|
||||
const modules = import.meta.glob('./components/base/Base*.vue')
|
||||
|
||||
for (const path in modules) {
|
||||
const componentName = path.split('/').pop().replace('.vue', '')
|
||||
// Wrap in defineAsyncComponent for lazy loading
|
||||
app.component(componentName, defineAsyncComponent(modules[path]))
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
```
|
||||
|
||||
## Glob Pattern Examples
|
||||
|
||||
```javascript
|
||||
// All .vue files in a directory (not recursive)
|
||||
import.meta.glob('./components/*.vue', { eager: true })
|
||||
|
||||
// All .vue files recursively
|
||||
import.meta.glob('./components/**/*.vue', { eager: true })
|
||||
|
||||
// Specific naming pattern
|
||||
import.meta.glob('./components/Base*.vue', { eager: true })
|
||||
|
||||
// Multiple patterns
|
||||
import.meta.glob([
|
||||
'./components/Base*.vue',
|
||||
'./components/App*.vue'
|
||||
], { eager: true })
|
||||
|
||||
// Exclude patterns
|
||||
import.meta.glob('./components/**/*.vue', {
|
||||
eager: true,
|
||||
ignore: ['**/*.test.vue', '**/*.spec.vue']
|
||||
})
|
||||
```
|
||||
|
||||
## TypeScript Support
|
||||
|
||||
```typescript
|
||||
// main.ts - with proper typing
|
||||
import { createApp, Component } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
const modules = import.meta.glob<{ default: Component }>(
|
||||
'./components/base/Base*.vue',
|
||||
{ eager: true }
|
||||
)
|
||||
|
||||
for (const path in modules) {
|
||||
const componentName = path.split('/').pop()!.replace('.vue', '')
|
||||
app.component(componentName, modules[path].default)
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
```
|
||||
|
||||
## Migration Checklist (Webpack to Vite)
|
||||
|
||||
| Webpack | Vite |
|
||||
|---------|------|
|
||||
| `require.context(dir, recursive, regex)` | `import.meta.glob(pattern, options)` |
|
||||
| Synchronous by default | Use `{ eager: true }` for sync |
|
||||
| `.keys()` returns array | Returns object with paths as keys |
|
||||
| Returns module directly | Access via `.default` for ES modules |
|
||||
|
||||
## Reference
|
||||
- [Vite - Glob Import](https://vitejs.dev/guide/features.html#glob-import)
|
||||
- [Vue.js Component Registration](https://vuejs.org/guide/components/registration.html)
|
||||
@@ -0,0 +1,233 @@
|
||||
---
|
||||
title: Use KeepAlive to Preserve Dynamic Component State
|
||||
impact: MEDIUM
|
||||
impactDescription: Dynamic component switching destroys and recreates components, losing all internal state unless wrapped in KeepAlive
|
||||
type: best-practice
|
||||
tags: [vue3, dynamic-components, keepalive, component-is, state-preservation, performance]
|
||||
---
|
||||
|
||||
# Use KeepAlive to Preserve Dynamic Component State
|
||||
|
||||
**Impact: MEDIUM** - When switching between components using `<component :is="...">`, Vue destroys the old component and creates a new one. All internal state (form inputs, scroll position, fetched data) is lost. Wrapping dynamic components in `<KeepAlive>` caches them and preserves their state.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Wrap `<component :is>` with `<KeepAlive>` when state preservation is needed
|
||||
- [ ] Use `include` and `exclude` to control which components are cached
|
||||
- [ ] Use `max` to limit cache size and prevent memory issues
|
||||
- [ ] Implement `onActivated`/`onDeactivated` hooks for cache-aware logic
|
||||
- [ ] Consider NOT using KeepAlive when fresh state is desired
|
||||
|
||||
## The Problem: State Loss
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, shallowRef } from 'vue'
|
||||
import TabA from './TabA.vue'
|
||||
import TabB from './TabB.vue'
|
||||
|
||||
const currentTab = shallowRef(TabA)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button @click="currentTab = TabA">Tab A</button>
|
||||
<button @click="currentTab = TabB">Tab B</button>
|
||||
|
||||
<!-- State is lost when switching tabs! -->
|
||||
<component :is="currentTab" />
|
||||
</template>
|
||||
```
|
||||
|
||||
If TabA has a form with user input, switching to TabB and back resets all input.
|
||||
|
||||
## Solution: KeepAlive
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<button @click="currentTab = TabA">Tab A</button>
|
||||
<button @click="currentTab = TabB">Tab B</button>
|
||||
|
||||
<!-- State is preserved when switching -->
|
||||
<KeepAlive>
|
||||
<component :is="currentTab" />
|
||||
</KeepAlive>
|
||||
</template>
|
||||
```
|
||||
|
||||
Now TabA's state persists even when TabB is displayed.
|
||||
|
||||
## Controlling What Gets Cached
|
||||
|
||||
### Include/Exclude by Name
|
||||
|
||||
Only cache specific components:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Only cache components named 'TabA' or 'TabB' -->
|
||||
<KeepAlive include="TabA,TabB">
|
||||
<component :is="currentTab" />
|
||||
</KeepAlive>
|
||||
|
||||
<!-- Cache all except 'HeavyComponent' -->
|
||||
<KeepAlive exclude="HeavyComponent">
|
||||
<component :is="currentTab" />
|
||||
</KeepAlive>
|
||||
|
||||
<!-- Using regex -->
|
||||
<KeepAlive :include="/^Tab/">
|
||||
<component :is="currentTab" />
|
||||
</KeepAlive>
|
||||
|
||||
<!-- Using array -->
|
||||
<KeepAlive :include="['TabA', 'TabB', 'Settings']">
|
||||
<component :is="currentTab" />
|
||||
</KeepAlive>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Important:** Components must have a `name` option to be matched:
|
||||
|
||||
```vue
|
||||
<!-- TabA.vue -->
|
||||
<script>
|
||||
export default {
|
||||
name: 'TabA' // Required for include/exclude matching
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup>
|
||||
// ...composition API code
|
||||
</script>
|
||||
```
|
||||
|
||||
Or in Vue 3.3+ with `<script setup>`:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: 'TabA'
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### Limit Cache Size
|
||||
|
||||
Prevent memory issues with many cached components:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Only keep last 5 components in cache -->
|
||||
<KeepAlive :max="5">
|
||||
<component :is="currentTab" />
|
||||
</KeepAlive>
|
||||
</template>
|
||||
```
|
||||
|
||||
When cache exceeds `max`, the least recently accessed component is destroyed.
|
||||
|
||||
## Lifecycle Hooks: onActivated and onDeactivated
|
||||
|
||||
Cached components need special lifecycle hooks:
|
||||
|
||||
```vue
|
||||
<!-- TabA.vue -->
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, onActivated, onDeactivated } from 'vue'
|
||||
|
||||
// Only called on first mount, NOT when switching back
|
||||
onMounted(() => {
|
||||
console.log('TabA mounted (once)')
|
||||
})
|
||||
|
||||
// Only called when truly destroyed, NOT when switching away
|
||||
onUnmounted(() => {
|
||||
console.log('TabA unmounted')
|
||||
})
|
||||
|
||||
// Called EVERY time component becomes visible
|
||||
onActivated(() => {
|
||||
console.log('TabA activated')
|
||||
// Refresh data, resume timers, etc.
|
||||
fetchLatestData()
|
||||
})
|
||||
|
||||
// Called EVERY time component is hidden (but kept in cache)
|
||||
onDeactivated(() => {
|
||||
console.log('TabA deactivated')
|
||||
// Pause timers, save draft, etc.
|
||||
pauseAutoRefresh()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**Common use cases for activation hooks:**
|
||||
- Refresh stale data when returning to a tab
|
||||
- Resume/pause video or audio playback
|
||||
- Reconnect/disconnect WebSocket connections
|
||||
- Save/restore scroll position
|
||||
- Track analytics for tab views
|
||||
|
||||
## KeepAlive with Vue Router
|
||||
|
||||
For route-based caching:
|
||||
|
||||
```vue
|
||||
<!-- App.vue -->
|
||||
<template>
|
||||
<router-view v-slot="{ Component }">
|
||||
<KeepAlive include="Dashboard,Settings">
|
||||
<component :is="Component" />
|
||||
</KeepAlive>
|
||||
</router-view>
|
||||
</template>
|
||||
```
|
||||
|
||||
**With transition:**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<router-view v-slot="{ Component }">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<KeepAlive>
|
||||
<component :is="Component" :key="$route.fullPath" />
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
</router-view>
|
||||
</template>
|
||||
```
|
||||
|
||||
## When NOT to Use KeepAlive
|
||||
|
||||
Don't cache when:
|
||||
|
||||
```vue
|
||||
<!-- Fresh data needed each time -->
|
||||
<template>
|
||||
<!-- NO KeepAlive - want fresh search results each visit -->
|
||||
<component :is="currentView" />
|
||||
</template>
|
||||
```
|
||||
|
||||
- Form should reset between visits
|
||||
- Data must be fresh (real-time dashboards)
|
||||
- Component has significant memory footprint
|
||||
- Security-sensitive data should be cleared
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { shallowRef } from 'vue'
|
||||
import TabA from './TabA.vue'
|
||||
import TabB from './TabB.vue'
|
||||
|
||||
// Use shallowRef for component references
|
||||
// Regular ref would deeply track the component object unnecessarily
|
||||
const currentTab = shallowRef(TabA)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js KeepAlive](https://vuejs.org/guide/built-ins/keep-alive.html)
|
||||
- [Vue.js Dynamic Components](https://vuejs.org/guide/essentials/component-basics.html#dynamic-components)
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
title: Use kebab-case for Event Listeners in Templates
|
||||
impact: LOW
|
||||
impactDescription: Vue auto-converts camelCase emits to kebab-case listeners but consistency improves readability
|
||||
type: best-practice
|
||||
tags: [vue3, events, emit, naming-convention, templates]
|
||||
---
|
||||
|
||||
# Use kebab-case for Event Listeners in Templates
|
||||
|
||||
**Impact: LOW** - Vue automatically converts event names between camelCase and kebab-case. You can emit in camelCase (`emit('someEvent')`) and listen with kebab-case (`@some-event`). However, following consistent conventions improves code readability and matches HTML attribute conventions.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Emit events using camelCase in JavaScript: `emit('updateValue')`
|
||||
- [ ] Listen to events using kebab-case in templates: `@update-value`
|
||||
- [ ] Be consistent across your codebase
|
||||
- [ ] Understand Vue's automatic case conversion
|
||||
|
||||
## The Convention
|
||||
|
||||
**Recommended pattern:**
|
||||
```vue
|
||||
<!-- ChildComponent.vue -->
|
||||
<script setup>
|
||||
const emit = defineEmits(['updateValue', 'itemSelected', 'formSubmit'])
|
||||
|
||||
function handleChange(value) {
|
||||
// Emit in camelCase (JavaScript convention)
|
||||
emit('updateValue', value)
|
||||
}
|
||||
|
||||
function selectItem(item) {
|
||||
emit('itemSelected', item)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- ParentComponent.vue -->
|
||||
<template>
|
||||
<!-- Listen in kebab-case (HTML attribute convention) -->
|
||||
<ChildComponent
|
||||
@update-value="handleUpdate"
|
||||
@item-selected="handleSelect"
|
||||
@form-submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Vue's Automatic Conversion
|
||||
|
||||
Vue handles these automatically **in template syntax only**:
|
||||
|
||||
| Emitted (camelCase) | Listener (kebab-case) | Works? |
|
||||
|---------------------|----------------------|--------|
|
||||
| `emit('updateValue')` | `@update-value` | Yes |
|
||||
| `emit('itemSelected')` | `@item-selected` | Yes |
|
||||
| `emit('formSubmit')` | `@form-submit` | Yes |
|
||||
|
||||
```vue
|
||||
<!-- All of these work equivalently in Vue 3 templates -->
|
||||
<Child @updateValue="handler" /> <!-- camelCase listener -->
|
||||
<Child @update-value="handler" /> <!-- kebab-case listener (preferred) -->
|
||||
```
|
||||
|
||||
### Important: Template-Only Behavior
|
||||
|
||||
This auto-conversion **only works in template syntax** (`@event-name`). It does **NOT** work in render functions or programmatic event listeners:
|
||||
|
||||
```ts
|
||||
// In render functions, use camelCase with 'on' prefix
|
||||
import { h } from 'vue'
|
||||
|
||||
// CORRECT - camelCase event name with 'on' prefix
|
||||
h(ChildComponent, {
|
||||
onUpdateValue: (value) => handleUpdate(value),
|
||||
onItemSelected: (item) => handleSelect(item)
|
||||
})
|
||||
|
||||
// WRONG - kebab-case does NOT work in render functions
|
||||
h(ChildComponent, {
|
||||
'onUpdate-value': (value) => handleUpdate(value), // Won't work!
|
||||
'on-update-value': (value) => handleUpdate(value) // Won't work!
|
||||
})
|
||||
```
|
||||
|
||||
```ts
|
||||
// Programmatic listeners also require camelCase
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const childRef = ref<ComponentPublicInstance | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
// CORRECT - camelCase
|
||||
childRef.value?.$on?.('updateValue', handler)
|
||||
|
||||
// WRONG - kebab-case won't match
|
||||
childRef.value?.$on?.('update-value', handler) // Won't work!
|
||||
})
|
||||
```
|
||||
|
||||
**Summary:**
|
||||
- **Templates**: Auto-conversion works (`@update-value` matches `emit('updateValue')`)
|
||||
- **Render functions**: Must use `onEventName` format (camelCase with `on` prefix)
|
||||
- **Programmatic listeners**: Must use the exact emitted event name (typically camelCase)
|
||||
|
||||
## Why kebab-case in Templates?
|
||||
|
||||
1. **HTML convention**: HTML attributes are case-insensitive and traditionally kebab-case
|
||||
2. **Consistency with props**: Props follow the same pattern (`props.userName` -> `user-name="..."`)
|
||||
3. **Readability**: `@user-profile-updated` is easier to read than `@userProfileUpdated`
|
||||
4. **Vue style guide**: Vue's official style guide recommends this pattern
|
||||
|
||||
## TypeScript Declarations
|
||||
|
||||
When using TypeScript, define emits in camelCase:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
updateValue: [value: string] // camelCase
|
||||
itemSelected: [item: Item] // camelCase
|
||||
'update:modelValue': [value: string] // Special v-model syntax (with colon)
|
||||
}>()
|
||||
</script>
|
||||
```
|
||||
|
||||
## v-model Events
|
||||
|
||||
For v-model, the `update:` prefix uses a colon, not kebab-case:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// Correct: colon separator for v-model updates
|
||||
const emit = defineEmits(['update:modelValue', 'update:firstName'])
|
||||
|
||||
function updateValue(newValue) {
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- Parent - v-model handles the event automatically -->
|
||||
<CustomInput v-model="value" />
|
||||
<CustomInput v-model:first-name="firstName" />
|
||||
```
|
||||
|
||||
## Vue 2 Difference
|
||||
|
||||
In Vue 2, event names did NOT have automatic case conversion. This caused issues:
|
||||
|
||||
```js
|
||||
// Vue 2 - camelCase events couldn't be listened to in templates
|
||||
this.$emit('updateValue') // Emitted as 'updateValue'
|
||||
|
||||
// Template converts to lowercase
|
||||
<child @updatevalue="handler"> // Listened as 'updatevalue' - NO MATCH!
|
||||
```
|
||||
|
||||
Vue 3 fixed this with automatic camelCase-to-kebab-case conversion.
|
||||
|
||||
## Reference
|
||||
- [Vue.js Component Events](https://vuejs.org/guide/components/events.html)
|
||||
- [Vue.js Style Guide - Event Names](https://vuejs.org/style-guide/)
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
title: Use Event Validation for Complex Payloads
|
||||
impact: LOW
|
||||
impactDescription: Event validation catches payload errors early with console warnings during development
|
||||
type: best-practice
|
||||
tags: [vue3, emits, defineEmits, validation, debugging]
|
||||
---
|
||||
|
||||
# Use Event Validation for Complex Payloads
|
||||
|
||||
**Impact: LOW** - Vue allows you to validate event payloads using object syntax for `defineEmits`. When a validation function returns `false`, Vue logs a console warning. This helps catch bugs early during development, especially for events with complex payload requirements.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use object syntax for `defineEmits` when validation is needed
|
||||
- [ ] Return `true` for valid payloads, `false` for invalid
|
||||
- [ ] Add meaningful console warnings in validators
|
||||
- [ ] Consider TypeScript for compile-time validation instead
|
||||
|
||||
## Basic Validation
|
||||
|
||||
**Using object syntax with validators:**
|
||||
```vue
|
||||
<script setup>
|
||||
const emit = defineEmits({
|
||||
// No validation - just declaration
|
||||
cancel: null,
|
||||
|
||||
// Validate that email is present
|
||||
submit: (payload) => {
|
||||
if (!payload || !payload.email) {
|
||||
console.warn('Submit event requires an email field')
|
||||
return false
|
||||
}
|
||||
if (!payload.email.includes('@')) {
|
||||
console.warn('Submit event requires a valid email')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
||||
// Validate ID is a number
|
||||
select: (id) => {
|
||||
if (typeof id !== 'number') {
|
||||
console.warn('Select event requires a numeric ID')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
function handleSubmit(formData) {
|
||||
// If validation fails, Vue logs warning but event still emits
|
||||
emit('submit', formData)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## What Happens on Validation Failure
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const emit = defineEmits({
|
||||
submit: (data) => {
|
||||
if (!data?.email) {
|
||||
return false // Validation fails
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
function badSubmit() {
|
||||
emit('submit', {}) // Missing email
|
||||
// Console: [Vue warn]: Invalid event arguments: event validation failed for event "submit"
|
||||
// Event STILL fires - validation is advisory only
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**Important:** Validation failure only logs a warning. The event still emits. This is intentional for development debugging, not runtime enforcement.
|
||||
|
||||
## Common Validation Patterns
|
||||
|
||||
### Required Fields
|
||||
```vue
|
||||
const emit = defineEmits({
|
||||
'user-created': (user) => {
|
||||
const required = ['id', 'name', 'email']
|
||||
const missing = required.filter(field => !user?.[field])
|
||||
if (missing.length) {
|
||||
console.warn(`user-created missing fields: ${missing.join(', ')}`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Type Checking
|
||||
```vue
|
||||
const emit = defineEmits({
|
||||
'page-change': (page) => typeof page === 'number' && page > 0,
|
||||
|
||||
'items-selected': (items) => Array.isArray(items),
|
||||
|
||||
'filter-applied': (filter) => {
|
||||
return filter && typeof filter.field === 'string' && filter.value !== undefined
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Range Validation
|
||||
```vue
|
||||
const emit = defineEmits({
|
||||
'rating-change': (rating) => {
|
||||
if (typeof rating !== 'number' || rating < 1 || rating > 5) {
|
||||
console.warn('Rating must be a number between 1 and 5')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## TypeScript Alternative
|
||||
|
||||
For compile-time validation, prefer TypeScript types over runtime validators:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
interface SubmitPayload {
|
||||
email: string
|
||||
password: string
|
||||
rememberMe?: boolean
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: SubmitPayload]
|
||||
cancel: []
|
||||
'page-change': [page: number]
|
||||
}>()
|
||||
|
||||
// TypeScript catches errors at compile time
|
||||
emit('submit', { email: 'test@test.com' })
|
||||
// Error: Property 'password' is missing
|
||||
</script>
|
||||
```
|
||||
|
||||
TypeScript validation is:
|
||||
- Caught at compile time, not runtime
|
||||
- Provides IDE autocompletion
|
||||
- Zero runtime overhead
|
||||
|
||||
## When to Use Runtime Validation
|
||||
|
||||
Use object syntax validation when:
|
||||
- You're not using TypeScript
|
||||
- You need to validate values that can't be expressed in types (ranges, formats)
|
||||
- You want runtime debugging help during development
|
||||
- You're building a component library and want helpful dev warnings
|
||||
|
||||
## Combining Both Approaches
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
interface FormData {
|
||||
email: string
|
||||
age: number
|
||||
}
|
||||
|
||||
// TypeScript handles type structure
|
||||
// Runtime validator handles value constraints
|
||||
const emit = defineEmits({
|
||||
submit: (data: FormData) => {
|
||||
if (data.age < 0 || data.age > 150) {
|
||||
console.warn('Age must be between 0 and 150')
|
||||
return false
|
||||
}
|
||||
if (!data.email.includes('@')) {
|
||||
console.warn('Invalid email format')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Component Events - Events Validation](https://vuejs.org/guide/components/events.html#events-validation)
|
||||
@@ -0,0 +1,213 @@
|
||||
---
|
||||
title: Use .once Modifier for Single-Use Event Handlers
|
||||
impact: LOW
|
||||
impactDescription: The .once modifier auto-removes event listeners after first trigger, preventing repeated handler calls
|
||||
type: best-practice
|
||||
tags: [vue3, events, modifiers, once, event-handling]
|
||||
---
|
||||
|
||||
# Use .once Modifier for Single-Use Event Handlers
|
||||
|
||||
**Impact: LOW** - Vue provides a `.once` modifier for event listeners that automatically removes the listener after it fires once. This is useful for one-time events like initialization callbacks, first-interaction tracking, or one-time animations.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use `.once` for events that should only fire once
|
||||
- [ ] Consider `.once` for analytics first-interaction tracking
|
||||
- [ ] Use `.once` for initialization events
|
||||
- [ ] Remember `.once` works on both native and component events
|
||||
|
||||
## Basic Usage
|
||||
|
||||
**Component events:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- Handler fires only on first emit, then stops listening -->
|
||||
<ChildComponent @initialized.once="handleInit" />
|
||||
|
||||
<!-- First-time user interaction tracking -->
|
||||
<UserForm @submit.once="trackFirstSubmit" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
function handleInit(data) {
|
||||
console.log('Component initialized:', data)
|
||||
// This only runs once, even if child emits 'initialized' multiple times
|
||||
}
|
||||
|
||||
function trackFirstSubmit() {
|
||||
analytics.track('first_form_submit')
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**Native DOM events:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- First click only -->
|
||||
<button @click.once="showWelcomeMessage">
|
||||
Click to see welcome (once)
|
||||
</button>
|
||||
|
||||
<!-- First scroll only -->
|
||||
<div @scroll.once="loadMoreContent">
|
||||
Scroll to load more
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### One-Time Initialization
|
||||
```vue
|
||||
<template>
|
||||
<ThirdPartyChart
|
||||
@ready.once="onChartReady"
|
||||
:data="chartData"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
function onChartReady(chartInstance) {
|
||||
// Store reference, configure chart
|
||||
// Only need to do this once
|
||||
chartRef.value = chartInstance
|
||||
chartInstance.setOption(customOptions)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### First Interaction Analytics
|
||||
```vue
|
||||
<template>
|
||||
<article @click.once="trackEngagement">
|
||||
<h2>{{ article.title }}</h2>
|
||||
<p>{{ article.excerpt }}</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
function trackEngagement() {
|
||||
analytics.track('article_first_click', {
|
||||
articleId: article.id
|
||||
})
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### Lazy Loading Trigger
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
ref="lazyContainer"
|
||||
@mouseenter.once="loadHeavyContent"
|
||||
>
|
||||
<template v-if="loaded">
|
||||
<HeavyComponent :data="data" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<Placeholder />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const loaded = ref(false)
|
||||
const data = ref(null)
|
||||
|
||||
async function loadHeavyContent() {
|
||||
data.value = await fetchData()
|
||||
loaded.value = true
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### One-Time Animation
|
||||
```vue
|
||||
<template>
|
||||
<Transition
|
||||
@after-enter.once="onFirstAppearance"
|
||||
>
|
||||
<div v-if="show" class="animated-content">
|
||||
Content
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
function onFirstAppearance() {
|
||||
// Track first time user sees this element
|
||||
// Won't fire again if element leaves and re-enters
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Combining with Other Modifiers
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Once + prevent default -->
|
||||
<form @submit.once.prevent="handleFirstSubmit">
|
||||
...
|
||||
</form>
|
||||
|
||||
<!-- Once + stop propagation -->
|
||||
<div @click.once.stop="handleClick">
|
||||
...
|
||||
</div>
|
||||
|
||||
<!-- Once + key modifier -->
|
||||
<input @keyup.enter.once="submitOnFirstEnter" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Equivalent Manual Implementation
|
||||
|
||||
Without `.once`, you'd need to manually track and remove:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const hasHandled = ref(false)
|
||||
|
||||
function handleClickManually() {
|
||||
if (hasHandled.value) return
|
||||
hasHandled.value = true
|
||||
|
||||
// Do one-time action
|
||||
doSomething()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Manual approach - more verbose -->
|
||||
<button @click="handleClickManually">Click</button>
|
||||
|
||||
<!-- .once approach - cleaner -->
|
||||
<button @click.once="doSomething">Click</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## When NOT to Use .once
|
||||
|
||||
Don't use `.once` when:
|
||||
- You need the event to fire multiple times
|
||||
- You want to conditionally allow repeated fires
|
||||
- The "once" logic is complex (use manual ref tracking instead)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- DON'T: User expects multiple submissions to work -->
|
||||
<form @submit.once.prevent="handleSubmit">
|
||||
...
|
||||
</form>
|
||||
|
||||
<!-- DO: Allow repeated submissions -->
|
||||
<form @submit.prevent="handleSubmit">
|
||||
...
|
||||
</form>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Event Handling - Event Modifiers](https://vuejs.org/guide/essentials/event-handling.html#event-modifiers)
|
||||
- [Vue.js Component Events](https://vuejs.org/guide/components/events.html)
|
||||
@@ -0,0 +1,155 @@
|
||||
---
|
||||
title: Use .exact Modifier for Precise Keyboard/Mouse Shortcuts
|
||||
impact: MEDIUM
|
||||
impactDescription: Without .exact, shortcuts fire even when additional modifier keys are pressed, causing unintended behavior
|
||||
type: best-practice
|
||||
tags: [vue3, events, keyboard, modifiers, shortcuts, accessibility]
|
||||
---
|
||||
|
||||
# Use .exact Modifier for Precise Keyboard/Mouse Shortcuts
|
||||
|
||||
**Impact: MEDIUM** - By default, Vue's modifier key handlers (`.ctrl`, `.alt`, `.shift`, `.meta`) fire even when other modifier keys are also pressed. Use `.exact` to require that ONLY the specified modifiers are pressed, preventing accidental triggering of shortcuts.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use `.exact` when you need precise modifier combinations
|
||||
- [ ] Without `.exact`: `@click.ctrl` fires for Ctrl+Click AND Ctrl+Shift+Click
|
||||
- [ ] With `.exact`: `@click.ctrl.exact` fires ONLY for Ctrl+Click
|
||||
- [ ] Use `@click.exact` for plain clicks with no modifiers
|
||||
|
||||
**Incorrect:**
|
||||
```html
|
||||
<!-- WRONG: Fires even with additional modifiers -->
|
||||
<template>
|
||||
<button @click.ctrl="copyItem">Copy</button>
|
||||
<!-- Also fires on Ctrl+Shift+Click, Ctrl+Alt+Click, etc. -->
|
||||
|
||||
<button @click.ctrl.shift="copyAll">Copy All</button>
|
||||
<!-- User expects Ctrl+Shift, but also fires on Ctrl+Shift+Alt -->
|
||||
</template>
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- WRONG: Conflicting shortcuts without .exact -->
|
||||
<template>
|
||||
<div>
|
||||
<button @click.ctrl="copy">Copy (Ctrl+Click)</button>
|
||||
<button @click.ctrl.shift="copyAll">Copy All (Ctrl+Shift+Click)</button>
|
||||
<!-- Both fire when user does Ctrl+Shift+Click! -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```html
|
||||
<!-- CORRECT: Precise modifier matching with .exact -->
|
||||
<template>
|
||||
<button @click.ctrl.exact="copyItem">Copy (Ctrl only)</button>
|
||||
<!-- Only fires on Ctrl+Click, not Ctrl+Shift+Click -->
|
||||
|
||||
<button @click.ctrl.shift.exact="copyAll">Copy All (Ctrl+Shift only)</button>
|
||||
<!-- Only fires on Ctrl+Shift+Click, not Ctrl+Shift+Alt+Click -->
|
||||
</template>
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- CORRECT: Plain click without any modifiers -->
|
||||
<template>
|
||||
<button @click.exact="selectItem">Select</button>
|
||||
<!-- Only fires when NO modifier keys are pressed -->
|
||||
<!-- Ctrl+Click, Shift+Click, etc. will NOT trigger this -->
|
||||
</template>
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- CORRECT: Non-conflicting shortcuts -->
|
||||
<template>
|
||||
<div class="editor">
|
||||
<div
|
||||
@click.exact="selectItem"
|
||||
@click.ctrl.exact="addToSelection"
|
||||
@click.shift.exact="extendSelection"
|
||||
@click.ctrl.shift.exact="selectRange"
|
||||
>
|
||||
Click, Ctrl+Click, Shift+Click, or Ctrl+Shift+Click
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Behavior Comparison
|
||||
|
||||
```javascript
|
||||
// WITHOUT .exact
|
||||
@click.ctrl="handler"
|
||||
// Fires when: Ctrl+Click, Ctrl+Shift+Click, Ctrl+Alt+Click, Ctrl+Shift+Alt+Click
|
||||
// Does NOT fire: Click (without Ctrl)
|
||||
|
||||
// WITH .exact
|
||||
@click.ctrl.exact="handler"
|
||||
// Fires when: ONLY Ctrl+Click
|
||||
// Does NOT fire: Ctrl+Shift+Click, Ctrl+Alt+Click, Click
|
||||
|
||||
// ONLY .exact (no other modifiers)
|
||||
@click.exact="handler"
|
||||
// Fires when: Plain click with NO modifiers
|
||||
// Does NOT fire: Ctrl+Click, Shift+Click, Alt+Click
|
||||
```
|
||||
|
||||
## Practical Example: File Browser Selection
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<ul class="file-list">
|
||||
<li
|
||||
v-for="file in files"
|
||||
:key="file.id"
|
||||
@click.exact="selectSingle(file)"
|
||||
@click.ctrl.exact="toggleSelection(file)"
|
||||
@click.shift.exact="selectRange(file)"
|
||||
@click.ctrl.shift.exact="addRangeToSelection(file)"
|
||||
:class="{ selected: isSelected(file) }"
|
||||
>
|
||||
{{ file.name }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Each click type has distinct, non-overlapping behavior
|
||||
function selectSingle(file) {
|
||||
// Clear selection and select only this file
|
||||
}
|
||||
|
||||
function toggleSelection(file) {
|
||||
// Add or remove this file from current selection
|
||||
}
|
||||
|
||||
function selectRange(file) {
|
||||
// Select all files from last selected to this one
|
||||
}
|
||||
|
||||
function addRangeToSelection(file) {
|
||||
// Add range to existing selection
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Keyboard Shortcuts with .exact
|
||||
|
||||
```html
|
||||
<template>
|
||||
<div
|
||||
tabindex="0"
|
||||
@keydown.ctrl.s.exact.prevent="save"
|
||||
@keydown.ctrl.shift.s.exact.prevent="saveAs"
|
||||
@keydown.ctrl.z.exact.prevent="undo"
|
||||
@keydown.ctrl.shift.z.exact.prevent="redo"
|
||||
>
|
||||
<!-- Each shortcut is precisely defined -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Event Handling - .exact Modifier](https://vuejs.org/guide/essentials/event-handling.html#exact-modifier)
|
||||
@@ -0,0 +1,218 @@
|
||||
---
|
||||
title: KeepAlive Include/Exclude Requires Component Name
|
||||
impact: MEDIUM
|
||||
impactDescription: The include and exclude props match against component name option, which must be explicitly declared
|
||||
type: gotcha
|
||||
tags: [vue3, keepalive, component-name, include, exclude, sfc]
|
||||
---
|
||||
|
||||
# KeepAlive Include/Exclude Requires Component Name
|
||||
|
||||
**Impact: MEDIUM** - When using `include` or `exclude` props on KeepAlive, the matching is done against the component's `name` option. Components without an explicit name will not match and caching behavior will be unexpected.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Declare `name` option on components used with include/exclude
|
||||
- [ ] Use `defineOptions({ name: '...' })` in `<script setup>`
|
||||
- [ ] Note: Since Vue 3.2.34, SFC filename is auto-inferred as name
|
||||
- [ ] Verify names match exactly (case-sensitive)
|
||||
|
||||
## The Problem
|
||||
|
||||
```vue
|
||||
<!-- App.vue -->
|
||||
<template>
|
||||
<KeepAlive include="TabA,TabB">
|
||||
<component :is="currentTab" />
|
||||
</KeepAlive>
|
||||
</template>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- TabA.vue - NO NAME DECLARED -->
|
||||
<script setup>
|
||||
// No name option - will NOT match "TabA" in include!
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>Count: {{ count }}</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Result:** TabA is NOT cached because it has no `name` option to match against.
|
||||
|
||||
## Solutions
|
||||
|
||||
### Solution 1: Use defineOptions (Vue 3.3+)
|
||||
|
||||
```vue
|
||||
<!-- TabA.vue -->
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'TabA' // Now matches include="TabA"
|
||||
})
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>Count: {{ count }}</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Solution 2: Dual Script Block
|
||||
|
||||
```vue
|
||||
<!-- TabA.vue -->
|
||||
<script>
|
||||
export default {
|
||||
name: 'TabA'
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>Count: {{ count }}</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Solution 3: Rely on Auto-Inference (Vue 3.2.34+)
|
||||
|
||||
Since Vue 3.2.34, SFCs using `<script setup>` automatically infer their name from the filename:
|
||||
|
||||
```
|
||||
TabA.vue -> name is "TabA"
|
||||
UserProfile.vue -> name is "UserProfile"
|
||||
my-component.vue -> name is "my-component" (kebab-case preserved)
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- Works automatically since Vue 3.2.34+ -->
|
||||
<!-- File: TabA.vue -->
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>Count: {{ count }}</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- App.vue -->
|
||||
<template>
|
||||
<!-- Matches filename "TabA" -->
|
||||
<KeepAlive include="TabA">
|
||||
<component :is="currentTab" />
|
||||
</KeepAlive>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### Mistake 1: Name Doesn't Match
|
||||
|
||||
```vue
|
||||
<script>
|
||||
export default {
|
||||
name: 'tab-a' // lowercase kebab-case
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- DOESN'T MATCH - include uses PascalCase -->
|
||||
<KeepAlive include="TabA">
|
||||
<component :is="currentTab" />
|
||||
</KeepAlive>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Fix:** Ensure names match exactly:
|
||||
|
||||
```vue
|
||||
<KeepAlive include="tab-a"> <!-- or change component name to 'TabA' -->
|
||||
```
|
||||
|
||||
### Mistake 2: Dynamic Components Without Names
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
// Async component - might not have name!
|
||||
const AsyncTab = defineAsyncComponent(() => import('./Tab.vue'))
|
||||
</script>
|
||||
```
|
||||
|
||||
**Fix:** Ensure the imported component has a name declared.
|
||||
|
||||
### Mistake 3: Using Props in Options API
|
||||
|
||||
```vue
|
||||
<script>
|
||||
export default {
|
||||
// This doesn't set the component name!
|
||||
props: ['name'] // 'name' prop is different from component name option
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Debugging Name Issues
|
||||
|
||||
Check what name Vue sees for your component:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { getCurrentInstance, onMounted } from 'vue'
|
||||
|
||||
onMounted(() => {
|
||||
const instance = getCurrentInstance()
|
||||
console.log('Component name:', instance?.type?.name)
|
||||
console.log('Component __name:', instance?.type?.__name)
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Using Different Match Formats
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- String (comma-delimited) -->
|
||||
<KeepAlive include="TabA,TabB,TabC">
|
||||
<component :is="current" />
|
||||
</KeepAlive>
|
||||
|
||||
<!-- RegExp -->
|
||||
<KeepAlive :include="/^Tab/">
|
||||
<component :is="current" />
|
||||
</KeepAlive>
|
||||
|
||||
<!-- Array -->
|
||||
<KeepAlive :include="['TabA', 'TabB']">
|
||||
<component :is="current" />
|
||||
</KeepAlive>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
1. **Name must match exactly** - Case-sensitive string matching
|
||||
2. **Vue 3.2.34+ auto-infers name** - From filename for `<script setup>` SFCs
|
||||
3. **Use `defineOptions`** - Clean way to set name in `<script setup>`
|
||||
4. **Debug with getCurrentInstance** - Check what name Vue actually sees
|
||||
5. **Multiple formats available** - String, RegExp, or Array for include/exclude
|
||||
|
||||
## Reference
|
||||
- [Vue.js KeepAlive - Include/Exclude](https://vuejs.org/guide/built-ins/keep-alive.html#include-exclude)
|
||||
- [Vue.js SFC `<script setup>`](https://vuejs.org/api/sfc-script-setup.html)
|
||||
- [defineOptions documentation](https://vuejs.org/api/sfc-script-setup.html#defineoptions)
|
||||
@@ -0,0 +1,186 @@
|
||||
---
|
||||
title: KeepAlive Memory Management - Prevent Memory Leaks
|
||||
impact: HIGH
|
||||
impactDescription: Unbounded KeepAlive caching can cause severe memory issues, especially with large or numerous components
|
||||
type: gotcha
|
||||
tags: [vue3, keepalive, memory-leak, performance, cache, max-prop]
|
||||
---
|
||||
|
||||
# KeepAlive Memory Management - Prevent Memory Leaks
|
||||
|
||||
**Impact: HIGH** - KeepAlive caches component instances in memory. Without proper limits and cleanup, this can lead to memory exhaustion, especially in applications with many routes or large component trees.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Always use the `max` prop to limit cached instances
|
||||
- [ ] Clean up resources in `onDeactivated` hook
|
||||
- [ ] Monitor memory usage when using KeepAlive extensively
|
||||
- [ ] Be cautious with KeepAlive on memory-heavy components
|
||||
- [ ] Test on memory-constrained devices (mobile, low-spec laptops)
|
||||
|
||||
## The Problem: Unbounded Cache Growth
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- DANGEROUS: No limit on cached components -->
|
||||
<KeepAlive>
|
||||
<component :is="currentView" />
|
||||
</KeepAlive>
|
||||
</template>
|
||||
```
|
||||
|
||||
When users navigate through many different components, each instance stays in memory:
|
||||
- Chrome memory grows continuously
|
||||
- VueComponent count keeps increasing
|
||||
- App becomes sluggish or crashes
|
||||
|
||||
## Solution: Set Cache Limits
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- LRU cache: keeps max 10, evicts least recently used -->
|
||||
<KeepAlive :max="10">
|
||||
<component :is="currentView" />
|
||||
</KeepAlive>
|
||||
</template>
|
||||
```
|
||||
|
||||
**How `max` works:**
|
||||
- KeepAlive uses an LRU (Least Recently Used) cache algorithm
|
||||
- When cache exceeds `max`, the oldest unused component is destroyed
|
||||
- This caps memory usage to a predictable maximum
|
||||
|
||||
### Choose `max` Based on Your Use Case
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Tab-based UI: usually 3-5 tabs max -->
|
||||
<KeepAlive :max="5">
|
||||
<component :is="currentTab" />
|
||||
</KeepAlive>
|
||||
|
||||
<!-- Route-based caching: limit to frequently visited pages -->
|
||||
<router-view v-slot="{ Component }">
|
||||
<KeepAlive :max="3" include="Dashboard,Settings,Profile">
|
||||
<component :is="Component" />
|
||||
</KeepAlive>
|
||||
</router-view>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Clean Up Resources in Deactivated Hook
|
||||
|
||||
Even with `max`, cached components hold resources. Clean up when deactivated:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, onActivated, onDeactivated, onUnmounted } from 'vue'
|
||||
|
||||
const chartInstance = ref(null)
|
||||
const websocket = ref(null)
|
||||
const intervalId = ref(null)
|
||||
|
||||
onActivated(() => {
|
||||
// Resume or recreate resources
|
||||
websocket.value = new WebSocket('...')
|
||||
intervalId.value = setInterval(fetchData, 5000)
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
// IMPORTANT: Clean up to reduce memory footprint while cached
|
||||
chartInstance.value?.destroy()
|
||||
chartInstance.value = null
|
||||
|
||||
websocket.value?.close()
|
||||
websocket.value = null
|
||||
|
||||
clearInterval(intervalId.value)
|
||||
intervalId.value = null
|
||||
})
|
||||
|
||||
// Final cleanup when truly destroyed
|
||||
onUnmounted(() => {
|
||||
// Same cleanup for when component is evicted from cache
|
||||
chartInstance.value?.destroy()
|
||||
websocket.value?.close()
|
||||
clearInterval(intervalId.value)
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Third-Party Library Cleanup
|
||||
|
||||
Libraries that manipulate the DOM outside Vue need explicit cleanup:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, onMounted, onDeactivated, onUnmounted } from 'vue'
|
||||
|
||||
const mapContainer = ref(null)
|
||||
let mapInstance = null
|
||||
|
||||
onMounted(() => {
|
||||
mapInstance = new MapLibrary(mapContainer.value)
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
// Some map libraries hold significant memory
|
||||
// Destroy and recreate on reactivation if needed
|
||||
mapInstance?.remove()
|
||||
mapInstance = null
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
mapInstance?.remove()
|
||||
mapInstance = null
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Avoid KeepAlive for Memory-Heavy Components
|
||||
|
||||
Some components should NOT be cached:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const heavyComponents = ['VideoPlayer', 'LargeDataGrid', 'MapView']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Exclude memory-heavy components from cache -->
|
||||
<KeepAlive :exclude="heavyComponents" :max="5">
|
||||
<component :is="currentView" />
|
||||
</KeepAlive>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Monitor Memory in Development
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { onActivated, onDeactivated } from 'vue'
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
onActivated(() => {
|
||||
console.log('Activated - Memory:', performance.memory?.usedJSHeapSize)
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
console.log('Deactivated - Memory:', performance.memory?.usedJSHeapSize)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
1. **Always set `max`** - Never use KeepAlive without a reasonable limit
|
||||
2. **Clean up in `onDeactivated`** - Don't wait for unmount to release resources
|
||||
3. **Exclude heavy components** - Large data grids, media players, maps
|
||||
4. **Test on target devices** - Mobile users have less memory
|
||||
5. **Monitor in development** - Watch for growing memory usage
|
||||
|
||||
## Reference
|
||||
- [Vue.js KeepAlive - Max Cached Instances](https://vuejs.org/guide/built-ins/keep-alive.html#max-cached-instances)
|
||||
- [Vue.js Avoiding Memory Leaks](https://v2.vuejs.org/v2/cookbook/avoiding-memory-leaks.html)
|
||||
- [GitHub Issue: Memory leak with keep-alive](https://github.com/vuejs/vue/issues/6759)
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: Vue 3 KeepAlive Has No Direct Cache Removal API
|
||||
impact: MEDIUM
|
||||
impactDescription: Unlike Vue 2, there is no way to programmatically remove a specific component from KeepAlive cache in Vue 3
|
||||
type: gotcha
|
||||
tags: [vue3, keepalive, cache, migration, vue2-to-vue3]
|
||||
---
|
||||
|
||||
# Vue 3 KeepAlive Has No Direct Cache Removal API
|
||||
|
||||
**Impact: MEDIUM** - Vue 3 removed the `$destroy()` method that Vue 2 developers used to indirectly clear KeepAlive cache entries. There is no direct API to remove a specific cached component in Vue 3.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Do not rely on programmatic cache removal from Vue 2 patterns
|
||||
- [ ] Use `include`/`exclude` props for dynamic cache control
|
||||
- [ ] Use key changes to force cache invalidation
|
||||
- [ ] Set appropriate `max` prop to auto-evict old entries
|
||||
|
||||
## The Problem
|
||||
|
||||
### Vue 2 Pattern (No Longer Works)
|
||||
|
||||
```javascript
|
||||
// Vue 2: Could destroy specific component instance
|
||||
this.$children[0].$destroy()
|
||||
```
|
||||
|
||||
### Vue 3: No Equivalent API
|
||||
|
||||
```javascript
|
||||
// Vue 3: $destroy() does not exist
|
||||
// There is NO direct way to remove a specific cached instance
|
||||
```
|
||||
|
||||
## Solutions
|
||||
|
||||
### Solution 1: Dynamic Include/Exclude
|
||||
|
||||
Control cache membership via reactive props:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const cachedViews = ref(['Dashboard', 'Settings', 'Profile'])
|
||||
|
||||
function removeFromCache(viewName) {
|
||||
cachedViews.value = cachedViews.value.filter(v => v !== viewName)
|
||||
}
|
||||
|
||||
function addToCache(viewName) {
|
||||
if (!cachedViews.value.includes(viewName)) {
|
||||
cachedViews.value.push(viewName)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KeepAlive :include="cachedViews">
|
||||
<component :is="currentView" />
|
||||
</KeepAlive>
|
||||
</template>
|
||||
```
|
||||
|
||||
When a component is removed from `include`, it will be destroyed on next switch.
|
||||
|
||||
### Solution 2: Key-Based Cache Invalidation
|
||||
|
||||
Change the key to force a fresh instance:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
const currentView = ref('Dashboard')
|
||||
const viewKeys = reactive({
|
||||
Dashboard: 0,
|
||||
Settings: 0,
|
||||
Profile: 0
|
||||
})
|
||||
|
||||
function invalidateCache(viewName) {
|
||||
viewKeys[viewName]++
|
||||
}
|
||||
|
||||
function switchView(viewName) {
|
||||
currentView.value = viewName
|
||||
}
|
||||
|
||||
// Force fresh Dashboard on next visit
|
||||
function refreshDashboard() {
|
||||
invalidateCache('Dashboard')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KeepAlive>
|
||||
<component
|
||||
:is="currentView"
|
||||
:key="`${currentView}-${viewKeys[currentView]}`"
|
||||
/>
|
||||
</KeepAlive>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Solution 3: Conditional KeepAlive
|
||||
|
||||
Wrap or unwrap based on cache need:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const currentView = ref('Dashboard')
|
||||
const shouldCache = ref(true)
|
||||
|
||||
function clearCacheAndSwitch(viewName) {
|
||||
shouldCache.value = false
|
||||
currentView.value = viewName
|
||||
// Re-enable caching after the switch
|
||||
nextTick(() => {
|
||||
shouldCache.value = true
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KeepAlive v-if="shouldCache">
|
||||
<component :is="currentView" />
|
||||
</KeepAlive>
|
||||
<component v-else :is="currentView" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### Solution 4: Use Max for Automatic Eviction
|
||||
|
||||
Let LRU cache handle cleanup:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Automatically evicts least recently used when cache is full -->
|
||||
<KeepAlive :max="5">
|
||||
<component :is="currentView" />
|
||||
</KeepAlive>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Vue Router: Clear Cache on Certain Navigations
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const cachedRoutes = ref(['Dashboard', 'Settings'])
|
||||
|
||||
// Clear specific route from cache when navigating from certain paths
|
||||
watch(() => route.name, (newRoute, oldRoute) => {
|
||||
if (oldRoute === 'Login') {
|
||||
// User just logged in - clear and refresh Dashboard
|
||||
cachedRoutes.value = cachedRoutes.value.filter(r => r !== 'Dashboard')
|
||||
nextTick(() => {
|
||||
cachedRoutes.value.push('Dashboard')
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view v-slot="{ Component }">
|
||||
<KeepAlive :include="cachedRoutes">
|
||||
<component :is="Component" />
|
||||
</KeepAlive>
|
||||
</router-view>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
1. **No `$destroy()` in Vue 3** - Cannot directly remove cached instances
|
||||
2. **Use dynamic `include`** - Reactively control which components are cached
|
||||
3. **Use key changes** - Changing key creates a new cache entry
|
||||
4. **Use `max` prop** - LRU eviction handles cleanup automatically
|
||||
5. **Plan cache strategy** - Design around these constraints upfront
|
||||
|
||||
## Reference
|
||||
- [Vue.js KeepAlive Documentation](https://vuejs.org/guide/built-ins/keep-alive.html)
|
||||
- [Vue 3 Migration Guide](https://v3-migration.vuejs.org/)
|
||||
- [Vue RFC Discussion #283: Custom cache strategy for KeepAlive](https://github.com/vuejs/rfcs/discussions/283)
|
||||
@@ -0,0 +1,226 @@
|
||||
---
|
||||
title: KeepAlive Router Navigation Fresh vs Cached Problem
|
||||
impact: MEDIUM
|
||||
impactDescription: When using KeepAlive with Vue Router, users may get cached pages when they expect fresh content
|
||||
type: gotcha
|
||||
tags: [vue3, keepalive, vue-router, navigation, cache, ux]
|
||||
---
|
||||
|
||||
# KeepAlive Router Navigation Fresh vs Cached Problem
|
||||
|
||||
**Impact: MEDIUM** - When using KeepAlive with Vue Router, navigation from menus or breadcrumbs may show cached (stale) content when users expect a fresh page. This creates confusing UX where the page appears "stuck" on old data.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Define clear rules for when to use cached vs fresh pages
|
||||
- [ ] Use route keys strategically to control freshness
|
||||
- [ ] Implement `onActivated` to refresh stale data
|
||||
- [ ] Consider navigation source when deciding cache behavior
|
||||
|
||||
## The Problem
|
||||
|
||||
```vue
|
||||
<!-- App.vue -->
|
||||
<template>
|
||||
<nav>
|
||||
<router-link to="/products">Products</router-link>
|
||||
</nav>
|
||||
|
||||
<router-view v-slot="{ Component }">
|
||||
<KeepAlive>
|
||||
<component :is="Component" />
|
||||
</KeepAlive>
|
||||
</router-view>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Scenario:**
|
||||
1. User visits `/products?category=shoes` - sees shoes
|
||||
2. User navigates to `/products?category=hats` - sees hats
|
||||
3. User clicks "Products" nav link (to `/products`)
|
||||
4. **Expected:** Fresh products page or default category
|
||||
5. **Actual:** Still shows hats (cached state)!
|
||||
|
||||
Users clicking navigation expect a "fresh start" but get the cached state.
|
||||
|
||||
## Solutions
|
||||
|
||||
### Solution 1: Use Route Full Path as Key
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<KeepAlive>
|
||||
<!-- Different query params = different cache entry -->
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</KeepAlive>
|
||||
</router-view>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Tradeoff:** Creates separate cache entry for each unique URL. May increase memory usage.
|
||||
|
||||
### Solution 2: Refresh Data on Activation
|
||||
|
||||
```vue
|
||||
<!-- Products.vue -->
|
||||
<script setup>
|
||||
import { ref, onActivated, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const products = ref([])
|
||||
const lastParams = ref(null)
|
||||
|
||||
async function fetchProducts() {
|
||||
const params = route.query
|
||||
products.value = await api.getProducts(params)
|
||||
lastParams.value = JSON.stringify(params)
|
||||
}
|
||||
|
||||
// Initial fetch
|
||||
fetchProducts()
|
||||
|
||||
// Refresh when re-activated if params changed
|
||||
onActivated(() => {
|
||||
const currentParams = JSON.stringify(route.query)
|
||||
if (currentParams !== lastParams.value) {
|
||||
fetchProducts()
|
||||
}
|
||||
})
|
||||
|
||||
// Also watch for changes while component is active
|
||||
watch(() => route.query, fetchProducts, { deep: true })
|
||||
</script>
|
||||
```
|
||||
|
||||
### Solution 3: Navigation-Aware Cache Control
|
||||
|
||||
Different behavior based on how user navigated:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, onActivated } from 'vue'
|
||||
import { useRoute, useRouter, onBeforeRouteUpdate } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const shouldRefresh = ref(false)
|
||||
|
||||
// Mark for refresh when coming from nav link (no query params)
|
||||
onBeforeRouteUpdate((to, from) => {
|
||||
// If navigating to base path without params, user wants fresh view
|
||||
if (Object.keys(to.query).length === 0 && Object.keys(from.query).length > 0) {
|
||||
shouldRefresh.value = true
|
||||
}
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
if (shouldRefresh.value) {
|
||||
resetToDefaultState()
|
||||
shouldRefresh.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function resetToDefaultState() {
|
||||
// Reset filters, clear search, show default view
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### Solution 4: Don't Cache Route-Dependent Pages
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// Don't cache pages where query params significantly change content
|
||||
const noCacheRoutes = ['ProductSearch', 'SearchResults', 'FilteredList']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<KeepAlive :exclude="noCacheRoutes">
|
||||
<component :is="Component" />
|
||||
</KeepAlive>
|
||||
</router-view>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Solution 5: Use Route Meta for Fresh Navigation
|
||||
|
||||
```javascript
|
||||
// router.js
|
||||
const routes = [
|
||||
{
|
||||
path: '/products',
|
||||
component: Products,
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
refreshOnDirectNavigation: true
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- App.vue -->
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const forceRefreshKey = ref(0)
|
||||
|
||||
// Watch for navigation to routes that want fresh state
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
(newPath, oldPath) => {
|
||||
// Direct navigation to base path = user wants fresh
|
||||
if (route.meta.refreshOnDirectNavigation && !route.query.length) {
|
||||
forceRefreshKey.value++
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view v-slot="{ Component }">
|
||||
<KeepAlive>
|
||||
<component
|
||||
:is="Component"
|
||||
:key="`${route.name}-${forceRefreshKey}`"
|
||||
/>
|
||||
</KeepAlive>
|
||||
</router-view>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Best Practice: Be Explicit About Cache Behavior
|
||||
|
||||
Document your caching rules:
|
||||
|
||||
```javascript
|
||||
// cacheRules.js
|
||||
export const CACHE_RULES = {
|
||||
// Always cached - static content, user preferences
|
||||
ALWAYS: ['Dashboard', 'Settings', 'Profile'],
|
||||
|
||||
// Never cached - dynamic search/filter results
|
||||
NEVER: ['SearchResults', 'FilteredProducts'],
|
||||
|
||||
// Cached but refreshes on activation
|
||||
STALE_WHILE_REVALIDATE: ['Notifications', 'Messages']
|
||||
}
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
1. **User expectation mismatch** - Nav links often imply "fresh" but get cached
|
||||
2. **Use `fullPath` key carefully** - Prevents reuse but increases memory
|
||||
3. **Implement `onActivated` refresh** - Check if data needs updating
|
||||
4. **Don't cache filter/search pages** - These are highly query-dependent
|
||||
5. **Document cache behavior** - Make rules explicit for your team
|
||||
|
||||
## Reference
|
||||
- [Vue.js KeepAlive Documentation](https://vuejs.org/guide/built-ins/keep-alive.html)
|
||||
- [Vue Router Navigation](https://router.vuejs.org/guide/essentials/navigation.html)
|
||||
- [Stack Keep-Alive Library](https://github.com/Zippowxk/stack-keep-alive)
|
||||
@@ -0,0 +1,222 @@
|
||||
---
|
||||
title: KeepAlive with Nested Routes Double Mount Issue
|
||||
impact: HIGH
|
||||
impactDescription: Using KeepAlive with nested Vue Router routes can cause child components to mount twice
|
||||
type: gotcha
|
||||
tags: [vue3, keepalive, vue-router, nested-routes, double-mount, bug]
|
||||
---
|
||||
|
||||
# KeepAlive with Nested Routes Double Mount Issue
|
||||
|
||||
**Impact: HIGH** - When using `<KeepAlive>` with nested Vue Router routes, child route components may mount twice. This is a known issue that can cause duplicate API calls, broken state, and confusing behavior.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Test nested routes thoroughly when using KeepAlive
|
||||
- [ ] Avoid mixing KeepAlive with deeply nested route structures
|
||||
- [ ] Use workarounds if double mount is observed
|
||||
- [ ] Consider alternative caching strategies for nested routes
|
||||
|
||||
## The Problem
|
||||
|
||||
```vue
|
||||
<!-- App.vue -->
|
||||
<template>
|
||||
<router-view v-slot="{ Component }">
|
||||
<KeepAlive>
|
||||
<component :is="Component" />
|
||||
</KeepAlive>
|
||||
</router-view>
|
||||
</template>
|
||||
```
|
||||
|
||||
```javascript
|
||||
// router.js
|
||||
const routes = [
|
||||
{
|
||||
path: '/parent',
|
||||
component: Parent,
|
||||
children: [
|
||||
{
|
||||
path: 'child',
|
||||
component: Child // This may mount TWICE!
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Symptoms:**
|
||||
- `onMounted` called twice in child component
|
||||
- Duplicate API requests
|
||||
- State initialization runs twice
|
||||
- Console logs appear doubled
|
||||
|
||||
## Diagnosis
|
||||
|
||||
Add logging to confirm the issue:
|
||||
|
||||
```vue
|
||||
<!-- Child.vue -->
|
||||
<script setup>
|
||||
import { onMounted, onActivated } from 'vue'
|
||||
|
||||
let mountCount = 0
|
||||
|
||||
onMounted(() => {
|
||||
mountCount++
|
||||
console.log('Child mounted - count:', mountCount)
|
||||
// If you see "count: 2", you have the double mount issue
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
console.log('Child activated')
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Workarounds
|
||||
|
||||
### Option 1: Use `useActivatedRoute` Pattern
|
||||
|
||||
Don't use `useRoute()` directly with KeepAlive:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, onActivated } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
// Problem: useRoute() can cause issues with KeepAlive
|
||||
// const route = useRoute()
|
||||
|
||||
// Solution: Get route info in onActivated
|
||||
const routeParams = ref({})
|
||||
|
||||
onActivated(() => {
|
||||
const route = useRoute()
|
||||
routeParams.value = { ...route.params }
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### Option 2: Avoid KeepAlive for Nested Route Parents
|
||||
|
||||
Only cache leaf routes, not parent layouts:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// Only cache specific leaf routes
|
||||
const cachedRoutes = computed(() => {
|
||||
// Don't cache parent routes that have children
|
||||
return ['UserProfile', 'UserSettings'] // Only leaf components
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view v-slot="{ Component, route: currentRoute }">
|
||||
<KeepAlive :include="cachedRoutes">
|
||||
<component :is="Component" :key="currentRoute.fullPath" />
|
||||
</KeepAlive>
|
||||
</router-view>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Option 3: Guard Against Double Initialization
|
||||
|
||||
Protect your component from double mount effects:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const isInitialized = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
if (isInitialized.value) {
|
||||
console.warn('Double mount detected, skipping initialization')
|
||||
return
|
||||
}
|
||||
isInitialized.value = true
|
||||
|
||||
// Safe to initialize
|
||||
fetchData()
|
||||
setupEventListeners()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### Option 4: Use Route-Level Cache Control
|
||||
|
||||
```vue
|
||||
<!-- App.vue -->
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// Define which routes should be cached in route meta
|
||||
const shouldCache = computed(() => {
|
||||
return route.meta.keepAlive !== false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view v-slot="{ Component }">
|
||||
<KeepAlive v-if="shouldCache">
|
||||
<component :is="Component" />
|
||||
</KeepAlive>
|
||||
<component v-else :is="Component" />
|
||||
</router-view>
|
||||
</template>
|
||||
```
|
||||
|
||||
```javascript
|
||||
// router.js
|
||||
const routes = [
|
||||
{
|
||||
path: '/parent',
|
||||
component: Parent,
|
||||
meta: { keepAlive: false }, // Don't cache parent routes
|
||||
children: [
|
||||
{
|
||||
path: 'child',
|
||||
component: Child,
|
||||
meta: { keepAlive: true } // Cache leaf routes
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Option 5: Flatten Route Structure
|
||||
|
||||
Avoid nesting if possible:
|
||||
|
||||
```javascript
|
||||
// Instead of nested routes
|
||||
const routes = [
|
||||
// Flat structure avoids the issue
|
||||
{ path: '/users', component: UserList },
|
||||
{ path: '/users/:id', component: UserDetail },
|
||||
{ path: '/users/:id/settings', component: UserSettings }
|
||||
]
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
1. **Known Vue Router issue** - Double mount with KeepAlive + nested routes
|
||||
2. **Watch for symptoms** - Duplicate API calls, doubled logs
|
||||
3. **Avoid caching parent routes** - Only cache leaf components
|
||||
4. **Add initialization guards** - Protect against double execution
|
||||
5. **Test thoroughly** - This issue may not appear immediately
|
||||
|
||||
## Reference
|
||||
- [Vue Router Issue #626: keep-alive in nested route mounted twice](https://github.com/vuejs/router/issues/626)
|
||||
- [GitHub: vue3-keep-alive-component workaround](https://github.com/emiyalee1005/vue3-keep-alive-component)
|
||||
- [Vue.js KeepAlive Documentation](https://vuejs.org/guide/built-ins/keep-alive.html)
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: KeepAlive with Transition Memory Leak
|
||||
impact: MEDIUM
|
||||
impactDescription: Combining KeepAlive with Transition can cause memory leaks in certain Vue versions
|
||||
type: gotcha
|
||||
tags: [vue3, keepalive, transition, memory-leak, animation]
|
||||
---
|
||||
|
||||
# KeepAlive with Transition Memory Leak
|
||||
|
||||
**Impact: MEDIUM** - There is a known memory leak when using `<Transition>` and `<KeepAlive>` together. Component instances may not be properly freed from memory when combining these features.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Test memory behavior when using KeepAlive + Transition together
|
||||
- [ ] Consider if transition animation is necessary with cached components
|
||||
- [ ] Use browser DevTools Memory tab to verify no leak
|
||||
- [ ] Keep Vue updated to get latest bug fixes
|
||||
|
||||
## The Problem
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Known memory leak combination in some Vue versions -->
|
||||
<Transition name="fade">
|
||||
<KeepAlive>
|
||||
<component :is="currentView" />
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
</template>
|
||||
```
|
||||
|
||||
When switching between components repeatedly:
|
||||
- Component instances accumulate in memory
|
||||
- References prevent garbage collection
|
||||
- Memory usage grows with each switch
|
||||
|
||||
## Diagnosis
|
||||
|
||||
Use Chrome DevTools to detect the leak:
|
||||
|
||||
1. Open DevTools > Memory tab
|
||||
2. Take heap snapshot
|
||||
3. Switch between components 10+ times
|
||||
4. Take another heap snapshot
|
||||
5. Compare: look for growing VueComponent count
|
||||
|
||||
## Workarounds
|
||||
|
||||
### Option 1: Remove Transition if Not Essential
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- No memory leak without Transition -->
|
||||
<KeepAlive :max="5">
|
||||
<component :is="currentView" />
|
||||
</KeepAlive>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Option 2: Use CSS Animations Instead
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<KeepAlive :max="5">
|
||||
<component
|
||||
:is="currentView"
|
||||
:class="{ 'fade-enter': isTransitioning }"
|
||||
/>
|
||||
</KeepAlive>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.fade-enter {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Option 3: Use Strict Cache Limits
|
||||
|
||||
If you must use both, minimize impact with strict limits:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<KeepAlive :max="3">
|
||||
<component :is="currentView" />
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Option 4: Key-Based Cache Invalidation
|
||||
|
||||
Force fresh instances when needed:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const currentView = ref('Dashboard')
|
||||
const cacheKey = ref(0)
|
||||
|
||||
function switchViewFresh(view) {
|
||||
currentView.value = view
|
||||
cacheKey.value++ // Force new instance
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<KeepAlive :max="3">
|
||||
<component :is="currentView" :key="cacheKey" />
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Keep Vue Updated
|
||||
|
||||
This is a known issue tracked in Vue's GitHub repository. Memory leak fixes are periodically released, so ensure you're on the latest Vue version:
|
||||
|
||||
```bash
|
||||
npm update vue
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
1. **Known issue** - Memory leaks with KeepAlive + Transition are documented
|
||||
2. **Test in DevTools** - Use Memory tab to verify your specific usage
|
||||
3. **Consider alternatives** - CSS animations may work without the leak
|
||||
4. **Set strict `max`** - Limit cache size to cap memory impact
|
||||
5. **Keep Vue updated** - Bug fixes are released periodically
|
||||
|
||||
## Reference
|
||||
- [GitHub Issue #9842: Memory leak with transition and keep-alive](https://github.com/vuejs/vue/issues/9842)
|
||||
- [GitHub Issue #9840: Memory leak with transition and keep-alive](https://github.com/vuejs/vue/issues/9840)
|
||||
- [Vue.js KeepAlive Documentation](https://vuejs.org/guide/built-ins/keep-alive.html)
|
||||
@@ -0,0 +1,156 @@
|
||||
---
|
||||
title: Register Lifecycle Hooks Synchronously During Setup
|
||||
impact: HIGH
|
||||
impactDescription: Asynchronously registered lifecycle hooks will never execute
|
||||
type: capability
|
||||
tags: [vue3, composition-api, lifecycle, onMounted, onUnmounted, async, setup]
|
||||
---
|
||||
|
||||
# Register Lifecycle Hooks Synchronously During Setup
|
||||
|
||||
**Impact: HIGH** - Lifecycle hooks registered asynchronously (e.g., inside setTimeout, after await) will never be called because Vue cannot associate them with the component instance. This leads to silent failures where expected initialization or cleanup code never runs.
|
||||
|
||||
In Vue 3's Composition API, lifecycle hooks like `onMounted`, `onUnmounted`, `onUpdated`, etc. must be registered synchronously during component setup. The hook registration doesn't need to be lexically inside `setup()` or `<script setup>`, but the call stack must be synchronous and originate from within setup.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Register all lifecycle hooks at the top level of setup() or `<script setup>`
|
||||
- [ ] Never register hooks inside setTimeout, setInterval, or Promise callbacks
|
||||
- [ ] When calling composables that use lifecycle hooks, call them synchronously
|
||||
- [ ] Hooks CAN be in external functions if called synchronously from setup
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
// WRONG: Hook registered asynchronously - will NEVER execute
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
export default {
|
||||
async setup() {
|
||||
// After await, we're in a different call stack
|
||||
const data = await fetchInitialData()
|
||||
|
||||
// This hook will NOT be registered!
|
||||
onMounted(() => {
|
||||
console.log('This will never run')
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// WRONG: Hook registered in setTimeout - will NEVER execute
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
setTimeout(() => {
|
||||
// This is asynchronous - hook won't be registered!
|
||||
onMounted(() => {
|
||||
initializeChart()
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// WRONG: Hook registered in Promise callback
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
fetchConfig().then(() => {
|
||||
// Asynchronous! This will silently fail
|
||||
onMounted(() => {
|
||||
applyConfig()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
// CORRECT: Hook registered synchronously at top level
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const data = ref(null)
|
||||
|
||||
// Register hook synchronously FIRST
|
||||
onMounted(async () => {
|
||||
// Async operations are fine INSIDE the hook
|
||||
data.value = await fetchInitialData()
|
||||
initializeChart()
|
||||
})
|
||||
|
||||
return { data }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- CORRECT: <script setup> - hooks at top level -->
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const isReady = ref(false)
|
||||
|
||||
// These are synchronous during script setup execution
|
||||
onMounted(() => {
|
||||
isReady.value = true
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
```javascript
|
||||
// CORRECT: Hook in external function called synchronously from setup
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
function useWindowResize(callback) {
|
||||
// This is fine - it's called synchronously from setup
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', callback)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', callback)
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
// Composable called synchronously - hooks will be registered
|
||||
useWindowResize(handleResize)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Multiple Hooks Are Allowed
|
||||
|
||||
```javascript
|
||||
// CORRECT: You can register the same hook multiple times
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
// Both will run, in order of registration
|
||||
onMounted(() => {
|
||||
initializeA()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initializeB()
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Lifecycle Hooks](https://vuejs.org/guide/essentials/lifecycle.html)
|
||||
- [Composition API Lifecycle Hooks](https://vuejs.org/api/composition-api-lifecycle.html)
|
||||
88
skills/vue-best-practices/reference/mount-return-value.md
Normal file
88
skills/vue-best-practices/reference/mount-return-value.md
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: mount() Returns Component Instance, Not App Instance
|
||||
impact: MEDIUM
|
||||
impactDescription: Using mount() return value for app configuration silently fails
|
||||
type: capability
|
||||
tags: [vue3, createApp, mount, api]
|
||||
---
|
||||
|
||||
# mount() Returns Component Instance, Not App Instance
|
||||
|
||||
**Impact: MEDIUM** - The `.mount()` method returns the root component instance, not the application instance. Attempting to chain app configuration methods after mount() will fail or produce unexpected behavior.
|
||||
|
||||
This is a subtle API detail that catches developers who assume mount() returns the app for continued chaining.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Never chain app configuration methods after mount()
|
||||
- [ ] If you need both instances, store them separately
|
||||
- [ ] Use the component instance for accessing root component state or methods
|
||||
- [ ] Use the app instance for configuration, plugins, and global registration
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
// WRONG: Assuming mount returns app instance
|
||||
const app = createApp(App).mount('#app')
|
||||
|
||||
// This fails! app is actually the root component instance
|
||||
app.use(router) // TypeError: app.use is not a function
|
||||
app.config.errorHandler = fn // app.config is undefined
|
||||
```
|
||||
|
||||
```javascript
|
||||
// WRONG: Trying to save both in one line
|
||||
const { app, component } = createApp(App).mount('#app') // Doesn't work this way
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
// Store app instance separately
|
||||
const app = createApp(App)
|
||||
|
||||
// Configure the app
|
||||
app.use(router)
|
||||
app.config.errorHandler = (err) => console.error(err)
|
||||
|
||||
// Store component instance if needed
|
||||
const rootComponent = app.mount('#app')
|
||||
|
||||
// Now you have access to both:
|
||||
// - app: the application instance (for config, plugins)
|
||||
// - rootComponent: the root component instance (for state, methods)
|
||||
```
|
||||
|
||||
```javascript
|
||||
// If you only need the app configured and mounted (most common case):
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.use(pinia)
|
||||
.mount('#app') // Return value (component instance) discarded - that's fine
|
||||
```
|
||||
|
||||
## When You Need the Root Component Instance
|
||||
|
||||
```javascript
|
||||
const app = createApp(App)
|
||||
const vm = app.mount('#app')
|
||||
|
||||
// Access root component's exposed state/methods
|
||||
console.log(vm.someExposedProperty)
|
||||
vm.someExposedMethod()
|
||||
|
||||
// In Vue 3 with <script setup>, use defineExpose to expose:
|
||||
// <script setup>
|
||||
// import { ref } from 'vue'
|
||||
// const count = ref(0)
|
||||
// defineExpose({ count })
|
||||
// </script>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js - Mounting the App](https://vuejs.org/guide/essentials/application.html#mounting-the-app)
|
||||
- [Vue.js Application API - mount()](https://vuejs.org/api/application.html#app-mount)
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
title: Mouse Button Modifiers Represent Intent, Not Physical Buttons
|
||||
impact: LOW
|
||||
impactDescription: Mouse modifiers .left/.right/.middle may not match physical buttons on left-handed mice or other input devices
|
||||
type: gotcha
|
||||
tags: [vue3, events, mouse, accessibility, modifiers]
|
||||
---
|
||||
|
||||
# Mouse Button Modifiers Represent Intent, Not Physical Buttons
|
||||
|
||||
**Impact: LOW** - Vue's mouse button modifiers (`.left`, `.right`, `.middle`) are named based on a typical right-handed mouse layout, but they actually represent "main", "secondary", and "auxiliary" pointing device triggers. This means they may not correspond to physical button positions on left-handed mice, trackpads, or other input devices.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Understand that `.left` means "primary/main" action, not physical left button
|
||||
- [ ] Understand that `.right` means "secondary" action (usually context menu)
|
||||
- [ ] Consider accessibility when relying on specific mouse buttons
|
||||
- [ ] Don't assume users have a traditional right-handed mouse
|
||||
|
||||
**Potentially Confusing:**
|
||||
```html
|
||||
<template>
|
||||
<!-- Documentation says "left click only" but... -->
|
||||
<div @click.left="handlePrimaryAction">
|
||||
<!-- On left-handed mouse: fires on physical RIGHT button -->
|
||||
<!-- On trackpad: fires on single-finger tap -->
|
||||
<!-- On touch screen: fires on tap -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Clear Understanding:**
|
||||
```html
|
||||
<template>
|
||||
<!-- Think of it as "primary action" -->
|
||||
<div @click.left="handlePrimaryAction">
|
||||
Primary action (main button)
|
||||
</div>
|
||||
|
||||
<!-- Think of it as "secondary/context action" -->
|
||||
<div @click.right="handleSecondaryAction">
|
||||
Secondary action (context menu button)
|
||||
</div>
|
||||
|
||||
<!-- Think of it as "auxiliary action" -->
|
||||
<div @click.middle="handleAuxiliaryAction">
|
||||
Auxiliary action (scroll wheel click)
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## What the Modifiers Actually Mean
|
||||
|
||||
```javascript
|
||||
// Vue modifier → MouseEvent.button value → Actual meaning
|
||||
|
||||
// .left → button === 0 → "Main button" (primary action)
|
||||
// .right → button === 2 → "Secondary button" (context menu)
|
||||
// .middle → button === 1 → "Auxiliary button" (middle click)
|
||||
|
||||
// The browser handles remapping for:
|
||||
// - Left-handed mouse settings
|
||||
// - Trackpad gestures
|
||||
// - Touch devices
|
||||
// - Stylus/pen input
|
||||
```
|
||||
|
||||
## Device Behaviors
|
||||
|
||||
```html
|
||||
<!-- How different devices trigger these modifiers -->
|
||||
|
||||
<!-- Traditional right-handed mouse -->
|
||||
<!-- .left = physical left button -->
|
||||
<!-- .right = physical right button -->
|
||||
<!-- .middle = scroll wheel press -->
|
||||
|
||||
<!-- Left-handed mouse (swapped in OS settings) -->
|
||||
<!-- .left = physical RIGHT button (remapped by OS) -->
|
||||
<!-- .right = physical LEFT button (remapped by OS) -->
|
||||
<!-- .middle = scroll wheel press -->
|
||||
|
||||
<!-- Trackpad -->
|
||||
<!-- .left = single-finger tap/click -->
|
||||
<!-- .right = two-finger tap/click (or corner click) -->
|
||||
<!-- .middle = three-finger tap (if configured) -->
|
||||
|
||||
<!-- Touch screen -->
|
||||
<!-- .left = tap -->
|
||||
<!-- .right = long press (context menu) -->
|
||||
<!-- .middle = typically not available -->
|
||||
```
|
||||
|
||||
## Best Practice: Semantic Naming in Comments
|
||||
|
||||
```html
|
||||
<template>
|
||||
<!-- Use comments to clarify intent -->
|
||||
|
||||
<!-- Primary action (select item) -->
|
||||
<div @click.left="selectItem">
|
||||
|
||||
<!-- Context menu action -->
|
||||
<div @click.right.prevent="showContextMenu">
|
||||
|
||||
<!-- Open in new tab (auxiliary/middle click convention) -->
|
||||
<a @click.middle="openInNewTab" href="...">
|
||||
</template>
|
||||
```
|
||||
|
||||
## Accessibility Considerations
|
||||
|
||||
```html
|
||||
<template>
|
||||
<!-- Don't require specific mouse buttons for essential actions -->
|
||||
|
||||
<!-- BETTER: Provide keyboard alternatives -->
|
||||
<div
|
||||
@click.left="select"
|
||||
@click.right.prevent="showMenu"
|
||||
@keydown.enter="select"
|
||||
@keydown.space="select"
|
||||
@contextmenu.prevent="showMenu"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>
|
||||
Accessible interactive element
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Event Handling - Mouse Button Modifiers](https://vuejs.org/guide/essentials/event-handling.html#mouse-button-modifiers)
|
||||
- [MDN - MouseEvent.button](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button)
|
||||
115
skills/vue-best-practices/reference/multiple-app-instances.md
Normal file
115
skills/vue-best-practices/reference/multiple-app-instances.md
Normal file
@@ -0,0 +1,115 @@
|
||||
---
|
||||
title: Use Multiple App Instances for Partial Page Control
|
||||
impact: MEDIUM
|
||||
impactDescription: Mounting single app to entire page when only controlling parts wastes resources and complicates SSR
|
||||
type: efficiency
|
||||
tags: [vue3, createApp, mount, ssr, progressive-enhancement, architecture]
|
||||
---
|
||||
|
||||
# Use Multiple App Instances for Partial Page Control
|
||||
|
||||
**Impact: MEDIUM** - When Vue only controls specific parts of a page (common with server-rendered HTML or progressive enhancement), mounting a single large app instance to the entire page is inefficient and can complicate server-side rendering integration.
|
||||
|
||||
Vue's `createApp` API explicitly supports multiple application instances on the same page. Each instance has its own isolated scope for configuration and global assets, making this pattern safe and recommended.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Assess whether Vue controls the entire page or just specific parts
|
||||
- [ ] For partial control, create separate app instances for each Vue-managed section
|
||||
- [ ] Each instance can have its own plugins, components, and configuration
|
||||
- [ ] Consider shared state via external stores if instances need to communicate
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
// Server-rendered page with Vue only needed for a few interactive widgets
|
||||
// WRONG: Mounting to entire page
|
||||
|
||||
// index.html (server-rendered)
|
||||
// <body id="app">
|
||||
// <header>...</header> <!-- static -->
|
||||
// <nav>...</nav> <!-- static -->
|
||||
// <div class="widget-1">...</div> <!-- needs Vue -->
|
||||
// <main>...</main> <!-- static -->
|
||||
// <div class="widget-2">...</div> <!-- needs Vue -->
|
||||
// <footer>...</footer> <!-- static -->
|
||||
// </body>
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import BigApp from './BigApp.vue'
|
||||
|
||||
// WRONG: Vue now controls entire page, including static content
|
||||
createApp(BigApp).mount('#app')
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
// CORRECT: Mount separate instances to specific elements
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import SearchWidget from './widgets/SearchWidget.vue'
|
||||
import CartWidget from './widgets/CartWidget.vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
// Shared store for cross-widget state
|
||||
const pinia = createPinia()
|
||||
|
||||
// Widget 1: Search functionality
|
||||
const searchApp = createApp(SearchWidget)
|
||||
searchApp.use(pinia)
|
||||
searchApp.mount('.widget-search')
|
||||
|
||||
// Widget 2: Shopping cart
|
||||
const cartApp = createApp(CartWidget)
|
||||
cartApp.use(pinia) // Same Pinia instance = shared state
|
||||
cartApp.mount('.widget-cart')
|
||||
|
||||
// Rest of page remains server-rendered static HTML
|
||||
```
|
||||
|
||||
## Benefits of Multiple Instances
|
||||
|
||||
```javascript
|
||||
// 1. Isolated configuration per section
|
||||
const adminApp = createApp(AdminPanel)
|
||||
adminApp.config.errorHandler = adminErrorHandler
|
||||
adminApp.use(adminOnlyPlugin)
|
||||
adminApp.mount('#admin-panel')
|
||||
|
||||
const publicApp = createApp(PublicWidget)
|
||||
publicApp.config.errorHandler = publicErrorHandler
|
||||
// Different plugins, components, configuration
|
||||
publicApp.mount('#public-widget')
|
||||
|
||||
// 2. Independent lifecycle
|
||||
// Can unmount/remount sections independently
|
||||
const app1 = createApp(Widget1).mount('#widget-1')
|
||||
const app2 = createApp(Widget2).mount('#widget-2')
|
||||
|
||||
// Later, unmount just one widget
|
||||
// app1.$destroy() in Vue 2, use app.unmount() for the app instance in Vue 3
|
||||
```
|
||||
|
||||
## Shared State Between Instances
|
||||
|
||||
```javascript
|
||||
// Option 1: Shared Pinia store
|
||||
const pinia = createPinia()
|
||||
|
||||
createApp(App1).use(pinia).mount('#app1')
|
||||
createApp(App2).use(pinia).mount('#app2')
|
||||
// Both apps share the same Pinia stores
|
||||
|
||||
// Option 2: Shared reactive state module
|
||||
// sharedState.js
|
||||
import { reactive } from 'vue'
|
||||
export const sharedState = reactive({
|
||||
user: null,
|
||||
cart: []
|
||||
})
|
||||
|
||||
// Both apps import and use sharedState directly
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js - Multiple Application Instances](https://vuejs.org/guide/essentials/application.html#multiple-application-instances)
|
||||
- [Vue.js Application API](https://vuejs.org/api/application.html)
|
||||
141
skills/vue-best-practices/reference/no-passive-with-prevent.md
Normal file
141
skills/vue-best-practices/reference/no-passive-with-prevent.md
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
title: Never Use .passive and .prevent Together
|
||||
impact: HIGH
|
||||
impactDescription: Conflicting modifiers cause .prevent to be ignored and trigger browser warnings
|
||||
type: gotcha
|
||||
tags: [vue3, events, modifiers, scroll, touch, performance]
|
||||
---
|
||||
|
||||
# Never Use .passive and .prevent Together
|
||||
|
||||
**Impact: HIGH** - The `.passive` modifier tells the browser you will NOT call `preventDefault()`, while `.prevent` does exactly that. Using them together causes `.prevent` to be ignored and triggers browser console warnings. This is a logical contradiction that leads to broken event handling.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Never combine `.passive` and `.prevent` on the same event
|
||||
- [ ] Use `.passive` for scroll/touch events where you want better performance
|
||||
- [ ] Use `.prevent` when you need to stop the default browser action
|
||||
- [ ] If you need conditional prevention, handle it in JavaScript without `.passive`
|
||||
|
||||
**Incorrect:**
|
||||
```html
|
||||
<!-- WRONG: Conflicting modifiers -->
|
||||
<template>
|
||||
<div @scroll.passive.prevent="handleScroll">
|
||||
<!-- .prevent will be IGNORED -->
|
||||
<!-- Browser shows warning -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- WRONG: On touch events -->
|
||||
<template>
|
||||
<div @touchstart.passive.prevent="handleTouch">
|
||||
<!-- Cannot prevent default - passive already promised not to -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- WRONG: On wheel events -->
|
||||
<template>
|
||||
<div @wheel.passive.prevent="handleWheel">
|
||||
<!-- Broken: will scroll anyway despite .prevent -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```html
|
||||
<!-- CORRECT: Use .passive for performance (no prevention needed) -->
|
||||
<template>
|
||||
<div @scroll.passive="handleScroll">
|
||||
<!-- Good for scroll tracking without blocking -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- CORRECT: Use .prevent when you need to prevent default -->
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<!-- Correctly prevents form submission -->
|
||||
</form>
|
||||
</template>
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- CORRECT: For touch events where you need to prevent -->
|
||||
<template>
|
||||
<div @touchmove="handleTouchMove">
|
||||
<!-- Handle prevention conditionally in JS -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
function handleTouchMove(event) {
|
||||
if (shouldPreventScroll.value) {
|
||||
event.preventDefault()
|
||||
}
|
||||
// ... handle touch
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Understanding .passive
|
||||
|
||||
```javascript
|
||||
// .passive tells the browser:
|
||||
// "I promise I won't call preventDefault()"
|
||||
|
||||
// This allows the browser to:
|
||||
// 1. Start scrolling immediately without waiting for JS
|
||||
// 2. Improve scroll performance, especially on mobile
|
||||
// 3. Reduce jank and stuttering
|
||||
|
||||
// Equivalent to:
|
||||
element.addEventListener('scroll', handler, { passive: true })
|
||||
```
|
||||
|
||||
## When to Use .passive
|
||||
|
||||
```html
|
||||
<!-- Good use cases for .passive -->
|
||||
|
||||
<!-- Scroll tracking analytics -->
|
||||
<div @scroll.passive="trackScrollPosition">
|
||||
|
||||
<!-- Touch gesture detection (no prevention needed) -->
|
||||
<div @touchmove.passive="detectGesture">
|
||||
|
||||
<!-- Wheel event monitoring -->
|
||||
<div @wheel.passive="monitorWheel">
|
||||
```
|
||||
|
||||
## When to Use .prevent (Without .passive)
|
||||
|
||||
```html
|
||||
<!-- Good use cases for .prevent -->
|
||||
|
||||
<!-- Form submission -->
|
||||
<form @submit.prevent="handleSubmit">
|
||||
|
||||
<!-- Link clicks with custom navigation -->
|
||||
<a @click.prevent="navigate">
|
||||
|
||||
<!-- Preventing context menu -->
|
||||
<div @contextmenu.prevent="showCustomMenu">
|
||||
```
|
||||
|
||||
## Browser Warning
|
||||
|
||||
When you combine `.passive` and `.prevent`, the browser console shows:
|
||||
```
|
||||
[Intervention] Unable to preventDefault inside passive event listener
|
||||
due to target being treated as passive.
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Event Handling - Event Modifiers](https://vuejs.org/guide/essentials/event-handling.html#event-modifiers)
|
||||
- [MDN - Improving scroll performance with passive listeners](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners)
|
||||
136
skills/vue-best-practices/reference/no-v-if-with-v-for.md
Normal file
136
skills/vue-best-practices/reference/no-v-if-with-v-for.md
Normal file
@@ -0,0 +1,136 @@
|
||||
---
|
||||
title: Never Use v-if and v-for on the Same Element
|
||||
impact: HIGH
|
||||
impactDescription: Causes confusing precedence issues and Vue 2 to 3 migration bugs
|
||||
type: capability
|
||||
tags: [vue3, v-if, v-for, conditional-rendering, list-rendering, eslint]
|
||||
---
|
||||
|
||||
# Never Use v-if and v-for on the Same Element
|
||||
|
||||
**Impact: HIGH** - Using `v-if` and `v-for` on the same element creates ambiguous precedence that differs between Vue 2 and Vue 3. In Vue 2, `v-for` had higher precedence; in Vue 3, `v-if` has higher precedence. This breaking change causes subtle bugs during migration and makes code intent unclear.
|
||||
|
||||
The ESLint rule `vue/no-use-v-if-with-v-for` enforces this best practice.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Never place v-if and v-for on the same element
|
||||
- [ ] For filtering list items: use a computed property that filters the array
|
||||
- [ ] For hiding entire list: wrap with `<template v-if>` around the v-for
|
||||
- [ ] Enable eslint-plugin-vue rule `vue/no-use-v-if-with-v-for`
|
||||
|
||||
**Incorrect:**
|
||||
```html
|
||||
<!-- WRONG: v-if and v-for on same element - ambiguous precedence -->
|
||||
<template>
|
||||
<!-- Intent: show only active users -->
|
||||
<li v-for="user in users" v-if="user.isActive" :key="user.id">
|
||||
{{ user.name }}
|
||||
</li>
|
||||
</template>
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- WRONG: Hiding entire list conditionally -->
|
||||
<template>
|
||||
<li v-for="user in users" v-if="shouldShowList" :key="user.id">
|
||||
{{ user.name }}
|
||||
</li>
|
||||
</template>
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- WRONG: Vue 3 precedence issue -->
|
||||
<template>
|
||||
<!-- In Vue 3, v-if runs FIRST, so 'user' is undefined! -->
|
||||
<li v-for="user in users" v-if="user.isActive" :key="user.id">
|
||||
{{ user.name }}
|
||||
</li>
|
||||
<!-- Error: Cannot read property 'isActive' of undefined -->
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```html
|
||||
<!-- CORRECT: Filter with computed property -->
|
||||
<template>
|
||||
<li v-for="user in activeUsers" :key="user.id">
|
||||
{{ user.name }}
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps(['users'])
|
||||
|
||||
const activeUsers = computed(() =>
|
||||
props.users.filter(user => user.isActive)
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- CORRECT: Wrap with <template v-if> for conditional list -->
|
||||
<template>
|
||||
<template v-if="shouldShowList">
|
||||
<li v-for="user in users" :key="user.id">
|
||||
{{ user.name }}
|
||||
</li>
|
||||
</template>
|
||||
</template>
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- CORRECT: v-if inside the loop (per-item condition) -->
|
||||
<template>
|
||||
<ul>
|
||||
<template v-for="user in users" :key="user.id">
|
||||
<li v-if="user.isActive">
|
||||
{{ user.name }}
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Vue 2 vs Vue 3 Precedence Change
|
||||
|
||||
```javascript
|
||||
// Vue 2: v-for evaluated first
|
||||
// <li v-for="user in users" v-if="user.isActive">
|
||||
// Equivalent to: users.forEach(user => { if (user.isActive) render(user) })
|
||||
|
||||
// Vue 3: v-if evaluated first
|
||||
// <li v-for="user in users" v-if="user.isActive">
|
||||
// Equivalent to: if (user.isActive) users.forEach(user => render(user))
|
||||
// Problem: 'user' doesn't exist yet when v-if runs!
|
||||
```
|
||||
|
||||
## Why Computed Properties Are Better
|
||||
|
||||
```javascript
|
||||
// Benefits of filtering via computed:
|
||||
// 1. Clear separation of concerns (logic vs template)
|
||||
// 2. Cached - only recalculates when dependencies change
|
||||
// 3. Reusable - can be used elsewhere in component
|
||||
// 4. Testable - can unit test the filtering logic
|
||||
// 5. No ambiguity about intent
|
||||
|
||||
const activeUsers = computed(() =>
|
||||
users.value.filter(u => u.isActive)
|
||||
)
|
||||
|
||||
// Can add more complex filtering
|
||||
const filteredUsers = computed(() =>
|
||||
users.value
|
||||
.filter(u => u.isActive)
|
||||
.filter(u => u.role === selectedRole.value)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
)
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Style Guide - Avoid v-if with v-for](https://vuejs.org/style-guide/rules-essential.html#avoid-v-if-with-v-for)
|
||||
- [Vue 3 Migration Guide - v-if vs v-for Precedence](https://v3-migration.vuejs.org/breaking-changes/v-if-v-for)
|
||||
- [ESLint Plugin Vue - no-use-v-if-with-v-for](https://eslint.vuejs.org/rules/no-use-v-if-with-v-for)
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
title: Avoid Excessive Component Abstraction in Large Lists
|
||||
impact: MEDIUM
|
||||
impactDescription: Each component instance has memory and render overhead - abstractions multiply this in lists
|
||||
type: efficiency
|
||||
tags: [vue3, performance, components, abstraction, lists, optimization]
|
||||
---
|
||||
|
||||
# Avoid Excessive Component Abstraction in Large Lists
|
||||
|
||||
**Impact: MEDIUM** - Component instances are more expensive than plain DOM nodes. While abstractions improve code organization, unnecessary nesting creates overhead. In large lists, this overhead multiplies - 100 items with 3 levels of abstraction means 300+ component instances instead of 100.
|
||||
|
||||
Don't avoid abstraction entirely, but be mindful of component depth in frequently-rendered elements like list items.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Review list item components for unnecessary wrapper components
|
||||
- [ ] Consider flattening component hierarchies in hot paths
|
||||
- [ ] Use native elements when a component adds no value
|
||||
- [ ] Profile component counts using Vue DevTools
|
||||
- [ ] Focus optimization efforts on the most-rendered components
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<!-- BAD: Deep abstraction in list items -->
|
||||
<template>
|
||||
<div class="user-list">
|
||||
<!-- For 100 users: Creates 400 component instances -->
|
||||
<UserCard v-for="user in users" :key="user.id" :user="user" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- UserCard.vue -->
|
||||
<template>
|
||||
<Card> <!-- Wrapper component #1 -->
|
||||
<CardHeader> <!-- Wrapper component #2 -->
|
||||
<UserAvatar :src="user.avatar" /> <!-- Wrapper component #3 -->
|
||||
</CardHeader>
|
||||
<CardBody> <!-- Wrapper component #4 -->
|
||||
<Text>{{ user.name }}</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<!-- Each UserCard creates: Card + CardHeader + CardBody + UserAvatar + Text
|
||||
100 users = 500+ component instances -->
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<!-- GOOD: Flattened structure in list items -->
|
||||
<template>
|
||||
<div class="user-list">
|
||||
<!-- For 100 users: Creates 100 component instances -->
|
||||
<UserCard v-for="user in users" :key="user.id" :user="user" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- UserCard.vue - Flattened, uses native elements -->
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<img :src="user.avatar" :alt="user.name" class="avatar" />
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<span class="user-name">{{ user.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
user: Object
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Styles that would have been in Card, CardHeader, etc. */
|
||||
.card { /* ... */ }
|
||||
.card-header { /* ... */ }
|
||||
.card-body { /* ... */ }
|
||||
.avatar { /* ... */ }
|
||||
</style>
|
||||
```
|
||||
|
||||
## When Abstraction Is Still Worth It
|
||||
|
||||
```vue
|
||||
<!-- Component abstraction is valuable when: -->
|
||||
|
||||
<!-- 1. Complex behavior is encapsulated -->
|
||||
<UserStatusIndicator :user="user" /> <!-- Has logic, tooltips, etc. -->
|
||||
|
||||
<!-- 2. Reused outside of the hot path -->
|
||||
<Card> <!-- OK to use in one-off places, not in 100-item lists -->
|
||||
|
||||
<!-- 3. The list itself is small -->
|
||||
<template v-if="items.length < 20">
|
||||
<FancyItem v-for="item in items" :key="item.id" />
|
||||
</template>
|
||||
|
||||
<!-- 4. Virtualization is used (only ~20 items rendered at once) -->
|
||||
<RecycleScroller :items="items">
|
||||
<template #default="{ item }">
|
||||
<ComplexItem :item="item" /> <!-- OK - only 20 instances exist -->
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
```
|
||||
|
||||
## Measuring Component Overhead
|
||||
|
||||
```javascript
|
||||
// In development, profile component counts
|
||||
import { onMounted, getCurrentInstance } from 'vue'
|
||||
|
||||
onMounted(() => {
|
||||
const instance = getCurrentInstance()
|
||||
let count = 0
|
||||
|
||||
function countComponents(vnode) {
|
||||
if (vnode.component) count++
|
||||
if (vnode.children) {
|
||||
vnode.children.forEach(child => {
|
||||
if (child.component || child.children) countComponents(child)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Use Vue DevTools instead for accurate counts
|
||||
console.log('Check Vue DevTools Components tab for instance counts')
|
||||
})
|
||||
```
|
||||
|
||||
## Alternatives to Wrapper Components
|
||||
|
||||
```vue
|
||||
<!-- Instead of a <Button> component for styling: -->
|
||||
<button class="btn btn-primary">Click</button>
|
||||
|
||||
<!-- Instead of a <Text> component: -->
|
||||
<span class="text-body">{{ content }}</span>
|
||||
|
||||
<!-- Instead of layout wrapper components in lists: -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- content -->
|
||||
</div>
|
||||
|
||||
<!-- Use CSS classes or Tailwind instead of component abstractions for styling -->
|
||||
```
|
||||
|
||||
## Impact Calculation
|
||||
|
||||
| List Size | Components per Item | Total Instances | Memory Impact |
|
||||
|-----------|---------------------|-----------------|---------------|
|
||||
| 100 items | 1 (flat) | 100 | Baseline |
|
||||
| 100 items | 3 (nested) | 300 | ~3x memory |
|
||||
| 100 items | 5 (deeply nested) | 500 | ~5x memory |
|
||||
| 1000 items | 1 (flat) | 1000 | High |
|
||||
| 1000 items | 5 (deeply nested) | 5000 | Very High |
|
||||
|
||||
## Reference
|
||||
- [Vue.js Performance - Avoid Unnecessary Component Abstractions](https://vuejs.org/guide/best-practices/performance.html#avoid-unnecessary-component-abstractions)
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
title: Return Stable Object References from Computed Properties
|
||||
impact: MEDIUM
|
||||
impactDescription: Computed properties returning new objects trigger effects even when values haven't meaningfully changed
|
||||
type: efficiency
|
||||
tags: [vue3, computed, performance, reactivity, vue3.4]
|
||||
---
|
||||
|
||||
# Return Stable Object References from Computed Properties
|
||||
|
||||
**Impact: MEDIUM** - In Vue 3.4+, computed properties only trigger effects when their value changes. However, if a computed returns a new object each time, Vue cannot detect that the values inside are the same. This causes unnecessary effect re-runs.
|
||||
|
||||
For primitive values, Vue 3.4+ handles this automatically. For objects, manually compare and return the previous value when nothing meaningful has changed.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] For computed properties returning primitives, Vue 3.4+ handles stability automatically
|
||||
- [ ] For computed properties returning objects, compare with previous value and return old reference if unchanged
|
||||
- [ ] Always perform the full computation before comparing (to track dependencies correctly)
|
||||
- [ ] Consider if you really need to return an object, or if primitives would suffice
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed, watchEffect } from 'vue'
|
||||
|
||||
const count = ref(0)
|
||||
|
||||
// BAD: Returns new object every time, always triggers effects
|
||||
const stats = computed(() => {
|
||||
return {
|
||||
isEven: count.value % 2 === 0,
|
||||
doubleValue: count.value * 2
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
console.log('Stats changed:', stats.value)
|
||||
// Logs on EVERY count change, even when isEven hasn't changed
|
||||
// count: 0 -> 2 -> 4: isEven is always true, but effect runs each time
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed, watchEffect } from 'vue'
|
||||
|
||||
const count = ref(0)
|
||||
|
||||
// GOOD (Vue 3.4+): Primitive computed - automatic stability
|
||||
const isEven = computed(() => count.value % 2 === 0)
|
||||
|
||||
watchEffect(() => {
|
||||
console.log('isEven:', isEven.value)
|
||||
// Only logs when isEven actually changes (0, 2, 4 won't re-trigger)
|
||||
})
|
||||
|
||||
// GOOD (Vue 3.4+): Manual comparison for object returns
|
||||
const stats = computed((oldValue) => {
|
||||
// Step 1: Always compute the new value first (to track dependencies)
|
||||
const newValue = {
|
||||
isEven: count.value % 2 === 0,
|
||||
category: count.value < 10 ? 'small' : 'large'
|
||||
}
|
||||
|
||||
// Step 2: Compare with previous value
|
||||
if (oldValue &&
|
||||
oldValue.isEven === newValue.isEven &&
|
||||
oldValue.category === newValue.category) {
|
||||
return oldValue // Return old reference - no effect triggers
|
||||
}
|
||||
|
||||
return newValue
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
console.log('Stats changed:', stats.value)
|
||||
// Now only logs when isEven or category actually changes
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Primitive vs Object Computed Behavior (Vue 3.4+)
|
||||
|
||||
```javascript
|
||||
import { ref, computed, watchEffect } from 'vue'
|
||||
|
||||
const count = ref(0)
|
||||
|
||||
// PRIMITIVE: Vue automatically detects value hasn't changed
|
||||
const isEven = computed(() => count.value % 2 === 0)
|
||||
|
||||
watchEffect(() => console.log(isEven.value)) // true
|
||||
|
||||
count.value = 2 // isEven still true - NO log
|
||||
count.value = 4 // isEven still true - NO log
|
||||
count.value = 3 // isEven now false - logs: false
|
||||
|
||||
// OBJECT: New reference every time (without manual comparison)
|
||||
const obj = computed(() => ({ isEven: count.value % 2 === 0 }))
|
||||
|
||||
watchEffect(() => console.log(obj.value)) // { isEven: true }
|
||||
|
||||
count.value = 2 // Logs again! New object reference
|
||||
count.value = 4 // Logs again! New object reference
|
||||
```
|
||||
|
||||
## Advanced: Deep Object Comparison
|
||||
|
||||
```javascript
|
||||
import { ref, computed } from 'vue'
|
||||
import { isEqual } from 'lodash-es' // For deep comparison
|
||||
|
||||
const filters = ref({ category: 'all', sortBy: 'date', page: 1 })
|
||||
|
||||
// For complex objects, use deep comparison
|
||||
const activeFilters = computed((oldValue) => {
|
||||
const newValue = {
|
||||
...filters.value,
|
||||
hasFilters: filters.value.category !== 'all' || filters.value.sortBy !== 'date'
|
||||
}
|
||||
|
||||
// Deep compare for complex objects
|
||||
if (oldValue && isEqual(oldValue, newValue)) {
|
||||
return oldValue
|
||||
}
|
||||
|
||||
return newValue
|
||||
})
|
||||
```
|
||||
|
||||
## Important: Always Compute Before Comparing
|
||||
|
||||
```javascript
|
||||
// BAD: Early return prevents dependency tracking
|
||||
const optimized = computed((oldValue) => {
|
||||
if (oldValue && someCondition) {
|
||||
return oldValue // Dependencies not tracked!
|
||||
}
|
||||
return computeExpensiveValue()
|
||||
})
|
||||
|
||||
// GOOD: Compute first, then compare
|
||||
const optimized = computed((oldValue) => {
|
||||
const newValue = computeExpensiveValue() // Always track dependencies
|
||||
if (oldValue && newValue === oldValue) {
|
||||
return oldValue
|
||||
}
|
||||
return newValue
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Performance - Computed Stability](https://vuejs.org/guide/best-practices/performance.html#computed-stability)
|
||||
- [Vue.js Computed Properties](https://vuejs.org/guide/essentials/computed.html)
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
title: Keep Props Stable to Minimize Child Re-renders
|
||||
impact: HIGH
|
||||
impactDescription: Passing changing props to list items causes ALL children to re-render unnecessarily
|
||||
type: efficiency
|
||||
tags: [vue3, performance, props, v-for, re-renders, optimization]
|
||||
---
|
||||
|
||||
# Keep Props Stable to Minimize Child Re-renders
|
||||
|
||||
**Impact: HIGH** - When props passed to child components change, Vue must re-render those components. Passing derived values like `activeId` to every list item causes all items to re-render when activeId changes, even if only one item's active state actually changed.
|
||||
|
||||
Move comparison logic to the parent and pass the boolean result instead. This is one of the most impactful update performance optimizations in Vue.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Avoid passing parent-level state that all children compare against (like `activeId`)
|
||||
- [ ] Pre-compute derived boolean props in the parent (like `:active="item.id === activeId"`)
|
||||
- [ ] Profile re-renders using Vue DevTools to identify prop stability issues
|
||||
- [ ] Consider this pattern especially critical for large lists
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- BAD: activeId changes -> ALL 100 ListItems re-render -->
|
||||
<ListItem
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
:id="item.id"
|
||||
:active-id="activeId"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const list = ref([/* 100 items */])
|
||||
const activeId = ref(null)
|
||||
|
||||
// When activeId changes from 1 to 2:
|
||||
// - ListItem 1 needs to re-render (was active, now not)
|
||||
// - ListItem 2 needs to re-render (was not active, now active)
|
||||
// - All other 98 ListItems ALSO re-render because activeId prop changed!
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- ListItem.vue - receives activeId and compares internally -->
|
||||
<template>
|
||||
<div :class="{ active: id === activeId }">
|
||||
{{ id }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
id: Number,
|
||||
activeId: Number // This prop changes for ALL items
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- GOOD: Only items whose :active actually changed will re-render -->
|
||||
<ListItem
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
:id="item.id"
|
||||
:active="item.id === activeId"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const list = ref([/* 100 items */])
|
||||
const activeId = ref(null)
|
||||
|
||||
// When activeId changes from 1 to 2:
|
||||
// - ListItem 1: :active changed from true to false -> re-renders
|
||||
// - ListItem 2: :active changed from false to true -> re-renders
|
||||
// - All other 98 ListItems: :active is still false -> NO re-render!
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- ListItem.vue - receives pre-computed boolean -->
|
||||
<template>
|
||||
<div :class="{ active }">
|
||||
{{ id }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
id: Number,
|
||||
active: Boolean // This only changes for items that truly changed
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Common Patterns That Cause Prop Instability
|
||||
|
||||
```vue
|
||||
<!-- BAD: Passing index that could shift -->
|
||||
<Item
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
:index="index"
|
||||
:total="items.length" <!-- Changes when list changes -->
|
||||
/>
|
||||
|
||||
<!-- BAD: Passing entire selection set -->
|
||||
<Item
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:selected-ids="selectedIds" <!-- All items re-render on any selection -->
|
||||
/>
|
||||
|
||||
<!-- GOOD: Pre-compute the boolean -->
|
||||
<Item
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:selected="selectedIds.includes(item.id)"
|
||||
/>
|
||||
```
|
||||
|
||||
## Performance Impact Example
|
||||
|
||||
| Scenario | Props Changed | Components Re-rendered |
|
||||
|----------|---------------|------------------------|
|
||||
| 100 items, pass `activeId` | 100 | 100 (all) |
|
||||
| 100 items, pass `:active` boolean | 2 | 2 (only changed) |
|
||||
| 1000 items, pass `activeId` | 1000 | 1000 (all) |
|
||||
| 1000 items, pass `:active` boolean | 2 | 2 (only changed) |
|
||||
|
||||
## Reference
|
||||
- [Vue.js Performance - Props Stability](https://vuejs.org/guide/best-practices/performance.html#props-stability)
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
title: Use SSR or SSG for Page Load Critical Applications
|
||||
impact: HIGH
|
||||
impactDescription: Client-side SPAs require JavaScript execution before content appears, severely impacting LCP and INP metrics
|
||||
type: capability
|
||||
tags: [vue3, performance, ssr, ssg, nuxt, page-load, architecture]
|
||||
---
|
||||
|
||||
# Use SSR or SSG for Page Load Critical Applications
|
||||
|
||||
**Impact: HIGH** - Pure client-side SPAs must download, parse, and execute JavaScript before users see content. This significantly delays Largest Contentful Paint (LCP) and impacts Core Web Vitals. For page load-critical apps (marketing sites, e-commerce, content sites), use Server-Side Rendering (SSR) or Static Site Generation (SSG).
|
||||
|
||||
Choose your architecture based on your content's nature and page load requirements.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Evaluate if page load performance is critical for your use case
|
||||
- [ ] Choose SSR for dynamic content that changes per request
|
||||
- [ ] Choose SSG for content that doesn't change frequently
|
||||
- [ ] Consider hybrid approaches (SSG for marketing, SPA for app)
|
||||
- [ ] Use Nuxt.js for streamlined SSR/SSG with Vue
|
||||
|
||||
## Architecture Decision Guide
|
||||
|
||||
| Content Type | Changes | Best Approach | Example |
|
||||
|--------------|---------|---------------|---------|
|
||||
| Marketing pages | Rarely | SSG | Landing pages, docs |
|
||||
| Blog/content | On publish | SSG with regeneration | Blog, documentation |
|
||||
| E-commerce catalog | Hourly/daily | SSG + ISR | Product listings |
|
||||
| User dashboard | Per request | SPA (ok) | Admin panels |
|
||||
| Social feed | Real-time | SSR or SPA | News feed |
|
||||
| Authenticated app | Per user | SPA (ok) | Internal tools |
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
// BAD: Pure client-side SPA for a marketing site
|
||||
// Users see blank page until JS loads and executes
|
||||
|
||||
// main.js
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app') // Nothing visible until this runs
|
||||
|
||||
// index.html - Users see empty #app until JS executes
|
||||
// <div id="app"></div>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
// GOOD: Use Nuxt.js for SSR/SSG
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
// SSG: Pre-render at build time (best for static content)
|
||||
ssr: true,
|
||||
nitro: {
|
||||
prerender: {
|
||||
routes: ['/', '/about', '/pricing', '/blog']
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Or full SSR for dynamic content
|
||||
export default defineNuxtConfig({
|
||||
ssr: true // Server renders on each request
|
||||
})
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- pages/index.vue - Works with both SSR and SSG -->
|
||||
<template>
|
||||
<div>
|
||||
<HeroSection />
|
||||
<FeatureList :features="features" />
|
||||
<PricingTable />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Data fetched at build time (SSG) or request time (SSR)
|
||||
const { data: features } = await useFetch('/api/features')
|
||||
</script>
|
||||
```
|
||||
|
||||
## Hybrid Approach: SSG Marketing + SPA App
|
||||
|
||||
```javascript
|
||||
// nuxt.config.ts - Hybrid rendering
|
||||
export default defineNuxtConfig({
|
||||
routeRules: {
|
||||
// Static pages - pre-rendered at build
|
||||
'/': { prerender: true },
|
||||
'/about': { prerender: true },
|
||||
'/pricing': { prerender: true },
|
||||
'/blog/**': { prerender: true },
|
||||
|
||||
// Dynamic app sections - client-side only
|
||||
'/dashboard/**': { ssr: false },
|
||||
'/app/**': { ssr: false },
|
||||
|
||||
// API routes - server-side
|
||||
'/api/**': { cors: true }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Manual SSR with Vue (without Nuxt)
|
||||
|
||||
```javascript
|
||||
// server.js - Express with Vue SSR
|
||||
import express from 'express'
|
||||
import { createSSRApp } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = express()
|
||||
|
||||
app.get('*', async (req, res) => {
|
||||
const vueApp = createSSRApp(App)
|
||||
const html = await renderToString(vueApp)
|
||||
|
||||
res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>My App</title></head>
|
||||
<body>
|
||||
<div id="app">${html}</div>
|
||||
<script type="module" src="/client.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
})
|
||||
```
|
||||
|
||||
## Performance Impact
|
||||
|
||||
| Approach | Time to First Byte | LCP | JavaScript Required |
|
||||
|----------|-------------------|-----|---------------------|
|
||||
| Client SPA | Fast | Slow (waits for JS) | Yes, before content |
|
||||
| SSR | Slower | Fast (HTML ready) | No, for initial view |
|
||||
| SSG | Fast (CDN) | Fast (HTML ready) | No, for initial view |
|
||||
|
||||
## When Client-Side SPA Is Acceptable
|
||||
|
||||
- Internal tools and dashboards (users expect loading)
|
||||
- Authenticated applications (initial load happens once)
|
||||
- Real-time collaborative apps (WebSocket-heavy)
|
||||
- PWAs where offline-first is the priority
|
||||
- Complex interactive applications (Figma-like)
|
||||
|
||||
## Static Site Generation with Vite
|
||||
|
||||
```javascript
|
||||
// vite.config.js - Using vite-ssg for SSG
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
ssgOptions: {
|
||||
script: 'async',
|
||||
formatting: 'minify'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Performance - Page Load Optimizations](https://vuejs.org/guide/best-practices/performance.html#page-load-optimizations)
|
||||
- [Vue.js SSR Guide](https://vuejs.org/guide/scaling-up/ssr.html)
|
||||
- [Nuxt.js Documentation](https://nuxt.com/)
|
||||
@@ -0,0 +1,187 @@
|
||||
---
|
||||
title: Use v-once and v-memo to Skip Unnecessary Updates
|
||||
impact: MEDIUM
|
||||
impactDescription: v-once skips all future updates for static content; v-memo conditionally memoizes subtrees
|
||||
type: efficiency
|
||||
tags: [vue3, performance, v-once, v-memo, optimization, directives]
|
||||
---
|
||||
|
||||
# Use v-once and v-memo to Skip Unnecessary Updates
|
||||
|
||||
**Impact: MEDIUM** - Vue re-evaluates templates on every reactive change. For content that never changes or changes infrequently, `v-once` and `v-memo` tell Vue to skip updates, reducing render work.
|
||||
|
||||
Use `v-once` for truly static content and `v-memo` for conditionally-static content in lists.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Apply `v-once` to elements that use runtime data but never need updating
|
||||
- [ ] Apply `v-memo` to list items that should only update on specific condition changes
|
||||
- [ ] Verify memoized content doesn't need to respond to other state changes
|
||||
- [ ] Profile with Vue DevTools to confirm update skipping
|
||||
|
||||
## v-once: Render Once, Never Update
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- BAD: Re-evaluated on every parent re-render -->
|
||||
<div class="terms-content">
|
||||
<h1>Terms of Service</h1>
|
||||
<p>Version: {{ termsVersion }}</p>
|
||||
<div v-html="termsContent"></div>
|
||||
</div>
|
||||
|
||||
<!-- This content NEVER changes, but Vue checks it every render -->
|
||||
<footer>
|
||||
<p>Copyright {{ copyrightYear }} {{ companyName }}</p>
|
||||
</footer>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- GOOD: Rendered once, skipped on all future updates -->
|
||||
<div class="terms-content" v-once>
|
||||
<h1>Terms of Service</h1>
|
||||
<p>Version: {{ termsVersion }}</p>
|
||||
<div v-html="termsContent"></div>
|
||||
</div>
|
||||
|
||||
<!-- v-once tells Vue this never needs to update -->
|
||||
<footer v-once>
|
||||
<p>Copyright {{ copyrightYear }} {{ companyName }}</p>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// These values are set once at component creation
|
||||
const termsVersion = '2.1'
|
||||
const termsContent = fetchedTermsHTML
|
||||
const copyrightYear = 2024
|
||||
const companyName = 'Acme Corp'
|
||||
</script>
|
||||
```
|
||||
|
||||
## v-memo: Conditional Memoization for Lists
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- BAD: All items re-render when selectedId changes -->
|
||||
<div v-for="item in list" :key="item.id">
|
||||
<div :class="{ selected: item.id === selectedId }">
|
||||
<ExpensiveComponent :data="item" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- GOOD: Items only re-render when their selection state changes -->
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
v-memo="[item.id === selectedId]"
|
||||
>
|
||||
<div :class="{ selected: item.id === selectedId }">
|
||||
<ExpensiveComponent :data="item" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const list = ref([/* many items */])
|
||||
const selectedId = ref(null)
|
||||
|
||||
// When selectedId changes:
|
||||
// - Only the previously-selected item re-renders (selected: true -> false)
|
||||
// - Only the newly-selected item re-renders (selected: false -> true)
|
||||
// - All other items are SKIPPED (v-memo values unchanged)
|
||||
</script>
|
||||
```
|
||||
|
||||
## v-memo with Multiple Dependencies
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Re-render only when item's selection OR editing state changes -->
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
v-memo="[item.id === selectedId, item.id === editingId]"
|
||||
>
|
||||
<ItemCard
|
||||
:item="item"
|
||||
:selected="item.id === selectedId"
|
||||
:editing="item.id === editingId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const selectedId = ref(null)
|
||||
const editingId = ref(null)
|
||||
const items = ref([/* ... */])
|
||||
</script>
|
||||
```
|
||||
|
||||
## v-memo with Empty Array = v-once
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- v-memo="[]" is equivalent to v-once -->
|
||||
<div v-for="item in staticList" :key="item.id" v-memo="[]">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## When NOT to Use These Directives
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- DON'T: Content that DOES need to update -->
|
||||
<div v-once>
|
||||
<span>Count: {{ count }}</span> <!-- count won't update! -->
|
||||
</div>
|
||||
|
||||
<!-- DON'T: When child components have their own reactive state -->
|
||||
<div v-memo="[selected]">
|
||||
<InputField v-model="item.name" /> <!-- v-model won't work properly -->
|
||||
</div>
|
||||
|
||||
<!-- DON'T: When the memoization benefit is minimal -->
|
||||
<span v-once>{{ simpleText }}</span> <!-- Overhead not worth it -->
|
||||
</template>
|
||||
```
|
||||
|
||||
## Performance Comparison
|
||||
|
||||
| Scenario | Without Directive | With v-once/v-memo |
|
||||
|----------|-------------------|-------------------|
|
||||
| Static header, parent re-renders 100x | Re-evaluated 100x | Evaluated 1x |
|
||||
| 1000 items, selection changes | 1000 items re-render | 2 items re-render |
|
||||
| Complex child component | Full re-render | Skipped if memoized |
|
||||
|
||||
## Debugging Memoized Components
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { onUpdated } from 'vue'
|
||||
|
||||
// This won't fire if v-memo prevents update
|
||||
onUpdated(() => {
|
||||
console.log('Component updated')
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js v-once Directive](https://vuejs.org/api/built-in-directives.html#v-once)
|
||||
- [Vue.js v-memo Directive](https://vuejs.org/api/built-in-directives.html#v-memo)
|
||||
- [Vue.js Performance - Update Optimizations](https://vuejs.org/guide/best-practices/performance.html#update-optimizations)
|
||||
@@ -0,0 +1,192 @@
|
||||
---
|
||||
title: Virtualize Large Lists to Avoid DOM Overload
|
||||
impact: HIGH
|
||||
impactDescription: Rendering thousands of list items creates excessive DOM nodes, causing slow renders and high memory usage
|
||||
type: efficiency
|
||||
tags: [vue3, performance, virtual-list, large-data, dom, optimization]
|
||||
---
|
||||
|
||||
# Virtualize Large Lists to Avoid DOM Overload
|
||||
|
||||
**Impact: HIGH** - Rendering all items in a large list (hundreds or thousands) creates massive amounts of DOM nodes. Each node consumes memory, slows down initial render, and makes updates expensive. List virtualization only renders visible items, dramatically improving performance.
|
||||
|
||||
Use a virtualization library when dealing with lists that could exceed 50-100 items, especially if items have complex content.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Identify lists that render more than 50-100 items
|
||||
- [ ] Install a virtualization library (vue-virtual-scroller, @tanstack/vue-virtual)
|
||||
- [ ] Replace standard `v-for` with virtualized component
|
||||
- [ ] Ensure list items have consistent or estimable heights
|
||||
- [ ] Test with realistic data volumes during development
|
||||
|
||||
## Recommended Libraries
|
||||
|
||||
| Library | Best For | Notes |
|
||||
|---------|----------|-------|
|
||||
| `vue-virtual-scroller` | General use, easy setup | Most popular, good defaults |
|
||||
| `@tanstack/vue-virtual` | Complex layouts, headless | Framework-agnostic, flexible |
|
||||
| `vue-virtual-scroll-grid` | Grid layouts | 2D virtualization |
|
||||
| `vueuc/VVirtualList` | Naive UI projects | Part of Naive UI ecosystem |
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- BAD: Renders ALL 10,000 items immediately -->
|
||||
<div class="user-list">
|
||||
<UserCard
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:user="user"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import UserCard from './UserCard.vue'
|
||||
|
||||
const users = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
// 10,000 DOM nodes created, browser struggles
|
||||
users.value = await fetchAllUsers()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<template>
|
||||
<!-- GOOD: Only renders ~20 visible items at a time -->
|
||||
<RecycleScroller
|
||||
class="user-list"
|
||||
:items="users"
|
||||
:item-size="80"
|
||||
key-field="id"
|
||||
v-slot="{ item }"
|
||||
>
|
||||
<UserCard :user="item" />
|
||||
</RecycleScroller>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { RecycleScroller } from 'vue-virtual-scroller'
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||||
import UserCard from './UserCard.vue'
|
||||
|
||||
const users = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
// 10,000 items in memory, but only ~20 DOM nodes
|
||||
users.value = await fetchAllUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-list {
|
||||
height: 600px; /* Container must have fixed height */
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Using @tanstack/vue-virtual
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div ref="parentRef" class="list-container">
|
||||
<div
|
||||
:style="{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
position: 'relative'
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="virtualRow in rowVirtualizer.getVirtualItems()"
|
||||
:key="virtualRow.key"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`
|
||||
}"
|
||||
>
|
||||
<UserCard :user="users[virtualRow.index]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useVirtualizer } from '@tanstack/vue-virtual'
|
||||
|
||||
const users = ref([/* 10,000 users */])
|
||||
const parentRef = ref(null)
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: users.value.length,
|
||||
getScrollElement: () => parentRef.value,
|
||||
estimateSize: () => 80, // Estimated row height
|
||||
overscan: 5 // Render 5 extra items above/below viewport
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list-container {
|
||||
height: 600px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Dynamic Heights with vue-virtual-scroller
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- For variable height items, use DynamicScroller -->
|
||||
<DynamicScroller
|
||||
:items="messages"
|
||||
:min-item-size="54"
|
||||
key-field="id"
|
||||
>
|
||||
<template #default="{ item, index, active }">
|
||||
<DynamicScrollerItem
|
||||
:item="item"
|
||||
:active="active"
|
||||
:data-index="index"
|
||||
>
|
||||
<ChatMessage :message="item" />
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
|
||||
</script>
|
||||
```
|
||||
|
||||
## Performance Comparison
|
||||
|
||||
| Approach | 100 Items | 1,000 Items | 10,000 Items |
|
||||
|----------|-----------|-------------|--------------|
|
||||
| Regular v-for | ~100 DOM nodes | ~1,000 DOM nodes | ~10,000 DOM nodes |
|
||||
| Virtualized | ~20 DOM nodes | ~20 DOM nodes | ~20 DOM nodes |
|
||||
| Initial render | Fast | Slow | Very slow / crashes |
|
||||
| Virtualized render | Fast | Fast | Fast |
|
||||
|
||||
## When NOT to Virtualize
|
||||
|
||||
- Lists under 50 items with simple content
|
||||
- Lists where all items must be accessible to screen readers simultaneously
|
||||
- Print layouts where all content must render
|
||||
- SEO-critical content that must be in initial HTML
|
||||
|
||||
## Reference
|
||||
- [Vue.js Performance - Virtualize Large Lists](https://vuejs.org/guide/best-practices/performance.html#virtualize-large-lists)
|
||||
- [vue-virtual-scroller Documentation](https://github.com/Akryum/vue-virtual-scroller)
|
||||
- [TanStack Virtual](https://tanstack.com/virtual/latest)
|
||||
@@ -0,0 +1,120 @@
|
||||
# Prefer provide/inject Over Global Properties in Plugins
|
||||
|
||||
## Rule
|
||||
|
||||
When creating Vue plugins, prefer using `app.provide()` to make plugin functionality available to components instead of attaching properties to `app.config.globalProperties`.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
1. **globalProperties don't work in setup()**: Properties attached to `globalProperties` are only accessible via `this` in Options API. They are NOT available in the Composition API's `setup()` function.
|
||||
|
||||
2. **Type safety**: `provide/inject` integrates better with TypeScript and requires less type augmentation boilerplate.
|
||||
|
||||
3. **Testability**: Injected dependencies are easier to mock in tests compared to global properties.
|
||||
|
||||
4. **Code clarity**: Explicit `inject()` calls make dependencies visible, while global properties can appear "magic".
|
||||
|
||||
5. **Scoping**: `provide/inject` follows Vue's component hierarchy, making it easier to provide different values to different parts of your app.
|
||||
|
||||
## Bad Practice
|
||||
|
||||
```typescript
|
||||
// plugins/i18n.ts
|
||||
export default {
|
||||
install(app, options) {
|
||||
// Attaching to globalProperties - only works with Options API
|
||||
app.config.globalProperties.$translate = (key: string) => {
|
||||
return key.split('.').reduce((o, i) => o?.[i], options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In component - requires type augmentation for TypeScript
|
||||
// Also DOES NOT work in <script setup>
|
||||
export default {
|
||||
mounted() {
|
||||
console.log(this.$translate('greeting.hello'))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Good Practice
|
||||
|
||||
```typescript
|
||||
// plugins/i18n.ts
|
||||
import type { InjectionKey, App } from 'vue'
|
||||
|
||||
export interface I18nOptions {
|
||||
[key: string]: string | I18nOptions
|
||||
}
|
||||
|
||||
export interface I18n {
|
||||
translate: (key: string) => string
|
||||
options: I18nOptions
|
||||
}
|
||||
|
||||
export const i18nKey: InjectionKey<I18n> = Symbol('i18n')
|
||||
|
||||
export default {
|
||||
install(app: App, options: I18nOptions) {
|
||||
const translate = (key: string): string => {
|
||||
return key.split('.').reduce((o, i) => o?.[i], options) as string ?? key
|
||||
}
|
||||
|
||||
// Use provide for Composition API compatibility
|
||||
app.provide(i18nKey, { translate, options })
|
||||
}
|
||||
}
|
||||
|
||||
// In component - works in setup() and has full type safety
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { i18nKey } from '@/plugins/i18n'
|
||||
|
||||
const i18n = inject(i18nKey)
|
||||
console.log(i18n?.translate('greeting.hello'))
|
||||
</script>
|
||||
```
|
||||
|
||||
## Hybrid Approach
|
||||
|
||||
If you must support both APIs (e.g., for backwards compatibility), provide both:
|
||||
|
||||
```typescript
|
||||
export default {
|
||||
install(app: App, options: I18nOptions) {
|
||||
const i18n = {
|
||||
translate: (key: string) => /* ... */
|
||||
}
|
||||
|
||||
// For Composition API
|
||||
app.provide(i18nKey, i18n)
|
||||
|
||||
// For Options API (use sparingly)
|
||||
app.config.globalProperties.$i18n = i18n
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## TypeScript Type Augmentation (if using globalProperties)
|
||||
|
||||
If you must use globalProperties, you need proper type augmentation:
|
||||
|
||||
```typescript
|
||||
// types/vue.d.ts
|
||||
export {}
|
||||
|
||||
declare module 'vue' {
|
||||
interface ComponentCustomProperties {
|
||||
$translate: (key: string) => string
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The file MUST contain `export {}` or another top-level export/import. Without it, the augmentation will OVERWRITE types instead of augmenting them.
|
||||
|
||||
## References
|
||||
|
||||
- [Vue.js Plugins Documentation](https://vuejs.org/guide/reusability/plugins.html)
|
||||
- [Vue.js Provide/Inject](https://vuejs.org/guide/components/provide-inject.html)
|
||||
- [TypeScript with Options API](https://vuejs.org/guide/typescript/options-api.html)
|
||||
@@ -0,0 +1,206 @@
|
||||
# Plugin Structure: Install Method Requirements
|
||||
|
||||
## Rule
|
||||
|
||||
A Vue plugin must be either an object with an `install()` method, or a function that serves as the install function. The install function receives the app instance and optional user-provided options.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
1. **API contract**: Vue's `app.use()` expects a specific interface. Incorrect structure causes silent failures or errors.
|
||||
|
||||
2. **Options passing**: The install method receives options that users pass to `app.use()`, enabling plugin configuration.
|
||||
|
||||
3. **App access**: The install method receives the app instance, providing access to `app.component()`, `app.directive()`, `app.provide()`, etc.
|
||||
|
||||
## Plugin Structures
|
||||
|
||||
### Object with install method (recommended)
|
||||
|
||||
```typescript
|
||||
// plugins/myPlugin.ts
|
||||
import type { App } from 'vue'
|
||||
|
||||
interface PluginOptions {
|
||||
prefix?: string
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
const myPlugin = {
|
||||
install(app: App, options: PluginOptions = {}) {
|
||||
const { prefix = 'my', debug = false } = options
|
||||
|
||||
if (debug) {
|
||||
console.log('Installing myPlugin with prefix:', prefix)
|
||||
}
|
||||
|
||||
app.provide('myPlugin', { prefix })
|
||||
}
|
||||
}
|
||||
|
||||
export default myPlugin
|
||||
|
||||
// Usage
|
||||
app.use(myPlugin, { prefix: 'custom', debug: true })
|
||||
```
|
||||
|
||||
### Function as install (alternative)
|
||||
|
||||
```typescript
|
||||
// plugins/simplePlugin.ts
|
||||
import type { App } from 'vue'
|
||||
|
||||
function simplePlugin(app: App, options?: { message: string }) {
|
||||
app.config.globalProperties.$greet = () => {
|
||||
return options?.message ?? 'Hello!'
|
||||
}
|
||||
}
|
||||
|
||||
export default simplePlugin
|
||||
|
||||
// Usage
|
||||
app.use(simplePlugin, { message: 'Welcome!' })
|
||||
```
|
||||
|
||||
### Factory function pattern (most flexible)
|
||||
|
||||
```typescript
|
||||
// plugins/configuredPlugin.ts
|
||||
import type { App, Plugin } from 'vue'
|
||||
|
||||
interface I18nOptions {
|
||||
locale: string
|
||||
messages: Record<string, Record<string, string>>
|
||||
fallbackLocale?: string
|
||||
}
|
||||
|
||||
export function createI18n(options: I18nOptions): Plugin {
|
||||
return {
|
||||
install(app: App) {
|
||||
// Options are captured in closure - no need to pass through app.use()
|
||||
const { locale, messages, fallbackLocale = 'en' } = options
|
||||
|
||||
const translate = (key: string): string => {
|
||||
return messages[locale]?.[key]
|
||||
?? messages[fallbackLocale]?.[key]
|
||||
?? key
|
||||
}
|
||||
|
||||
app.provide('i18n', { translate, locale })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage - options passed to factory, not app.use()
|
||||
const i18n = createI18n({
|
||||
locale: 'fr',
|
||||
messages: {
|
||||
en: { hello: 'Hello' },
|
||||
fr: { hello: 'Bonjour' }
|
||||
}
|
||||
})
|
||||
|
||||
app.use(i18n) // No second argument needed
|
||||
```
|
||||
|
||||
## Common Plugin Capabilities
|
||||
|
||||
```typescript
|
||||
const fullFeaturedPlugin = {
|
||||
install(app: App, options: PluginOptions) {
|
||||
// 1. Register global components
|
||||
app.component('MyButton', MyButtonComponent)
|
||||
app.component('MyInput', MyInputComponent)
|
||||
|
||||
// 2. Register global directives
|
||||
app.directive('focus', focusDirective)
|
||||
|
||||
// 3. Provide injectable values (recommended)
|
||||
app.provide('pluginConfig', options)
|
||||
|
||||
// 4. Add global properties (use sparingly)
|
||||
app.config.globalProperties.$myHelper = helperFunction
|
||||
|
||||
// 5. Add global mixins (avoid if possible)
|
||||
app.mixin({
|
||||
created() {
|
||||
// Runs for every component
|
||||
}
|
||||
})
|
||||
|
||||
// 6. Custom error handling
|
||||
app.config.errorHandler = (err, vm, info) => {
|
||||
// Handle errors
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## TypeScript: Plugin Type
|
||||
|
||||
Use the `Plugin` type for proper typing:
|
||||
|
||||
```typescript
|
||||
import type { App, Plugin } from 'vue'
|
||||
|
||||
// With options type parameter
|
||||
interface MyOptions {
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
const myPlugin: Plugin<[MyOptions]> = {
|
||||
install(app: App, options: MyOptions) {
|
||||
// options is typed as MyOptions
|
||||
}
|
||||
}
|
||||
|
||||
// Without options
|
||||
const simplePlugin: Plugin = {
|
||||
install(app: App) {
|
||||
// No options expected
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### Missing install method
|
||||
|
||||
```typescript
|
||||
// BAD - This is just an object, not a plugin
|
||||
const notAPlugin = {
|
||||
doSomething() { /* ... */ }
|
||||
}
|
||||
app.use(notAPlugin) // Error or silent failure
|
||||
|
||||
// GOOD
|
||||
const actualPlugin = {
|
||||
install(app) {
|
||||
app.provide('service', { doSomething() { /* ... */ } })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Forgetting to use the app parameter
|
||||
|
||||
```typescript
|
||||
// BAD - Does nothing
|
||||
const uselessPlugin = {
|
||||
install(app, options) {
|
||||
const service = createService(options)
|
||||
// Forgot to register anything with app!
|
||||
}
|
||||
}
|
||||
|
||||
// GOOD
|
||||
const usefulPlugin = {
|
||||
install(app, options) {
|
||||
const service = createService(options)
|
||||
app.provide('service', service) // Actually makes it available
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Vue.js Plugins Documentation](https://vuejs.org/guide/reusability/plugins.html)
|
||||
- [Vue.js Application API](https://vuejs.org/api/application.html)
|
||||
@@ -0,0 +1,165 @@
|
||||
# Use Symbol Injection Keys in Plugins
|
||||
|
||||
## Rule
|
||||
|
||||
When using `provide/inject` in Vue plugins, use Symbol keys (preferably with `InjectionKey<T>` for TypeScript) instead of string keys to prevent naming collisions and ensure type safety.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
1. **Collision prevention**: String keys like `'i18n'` or `'api'` can easily collide between multiple plugins or different parts of your application.
|
||||
|
||||
2. **Type safety**: TypeScript's `InjectionKey<T>` provides automatic type inference when using `inject()`.
|
||||
|
||||
3. **Refactoring safety**: Symbols are unique, so renaming is safe and explicit.
|
||||
|
||||
4. **Debugging**: Symbols can have descriptive names for debugging while remaining unique.
|
||||
|
||||
## Bad Practice
|
||||
|
||||
```typescript
|
||||
// plugin.ts
|
||||
export default {
|
||||
install(app) {
|
||||
// String key - can collide with other plugins!
|
||||
app.provide('http', axios)
|
||||
app.provide('config', appConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// component.vue
|
||||
const http = inject('http') // Type is unknown
|
||||
const config = inject('config') // Type is unknown
|
||||
|
||||
// Another plugin accidentally uses the same key
|
||||
otherPlugin.install = (app) => {
|
||||
app.provide('http', differentHttpClient) // COLLISION! Overwrites first
|
||||
}
|
||||
```
|
||||
|
||||
## Good Practice
|
||||
|
||||
```typescript
|
||||
// plugins/keys.ts
|
||||
import type { InjectionKey } from 'vue'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
|
||||
export interface AppConfig {
|
||||
apiUrl: string
|
||||
timeout: number
|
||||
}
|
||||
|
||||
// Typed injection keys - unique and type-safe
|
||||
export const httpKey: InjectionKey<AxiosInstance> = Symbol('http')
|
||||
export const configKey: InjectionKey<AppConfig> = Symbol('appConfig')
|
||||
|
||||
// plugin.ts
|
||||
import { httpKey, configKey } from './keys'
|
||||
|
||||
export default {
|
||||
install(app) {
|
||||
app.provide(httpKey, axios)
|
||||
app.provide(configKey, { apiUrl: '/api', timeout: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
// component.vue
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { httpKey, configKey } from '@/plugins/keys'
|
||||
|
||||
// Fully typed! No 'unknown' type
|
||||
const http = inject(httpKey) // Type: AxiosInstance | undefined
|
||||
const config = inject(configKey) // Type: AppConfig | undefined
|
||||
</script>
|
||||
```
|
||||
|
||||
## Providing Default Values with Type Safety
|
||||
|
||||
```typescript
|
||||
// With InjectionKey, default values are type-checked
|
||||
const config = inject(configKey, {
|
||||
apiUrl: '/default-api',
|
||||
timeout: 3000
|
||||
})
|
||||
// Type: AppConfig (not undefined because default provided)
|
||||
|
||||
// Type error if default doesn't match!
|
||||
const config = inject(configKey, {
|
||||
apiUrl: '/api'
|
||||
// Missing 'timeout' - TypeScript error!
|
||||
})
|
||||
```
|
||||
|
||||
## Organizing Injection Keys
|
||||
|
||||
For larger applications, organize keys by domain:
|
||||
|
||||
```typescript
|
||||
// injection-keys/index.ts
|
||||
export * from './auth'
|
||||
export * from './i18n'
|
||||
export * from './http'
|
||||
|
||||
// injection-keys/auth.ts
|
||||
import type { InjectionKey } from 'vue'
|
||||
|
||||
export interface AuthService {
|
||||
login: (credentials: Credentials) => Promise<User>
|
||||
logout: () => Promise<void>
|
||||
currentUser: Ref<User | null>
|
||||
}
|
||||
|
||||
export const authKey: InjectionKey<AuthService> = Symbol('auth')
|
||||
|
||||
// injection-keys/i18n.ts
|
||||
export interface I18n {
|
||||
t: (key: string, params?: Record<string, string>) => string
|
||||
locale: Ref<string>
|
||||
}
|
||||
|
||||
export const i18nKey: InjectionKey<I18n> = Symbol('i18n')
|
||||
```
|
||||
|
||||
## Creating a useInject Helper
|
||||
|
||||
For cleaner component code, create typed composables:
|
||||
|
||||
```typescript
|
||||
// composables/useAuth.ts
|
||||
import { inject } from 'vue'
|
||||
import { authKey, type AuthService } from '@/injection-keys'
|
||||
|
||||
export function useAuth(): AuthService {
|
||||
const auth = inject(authKey)
|
||||
if (!auth) {
|
||||
throw new Error('Auth plugin not installed. Did you forget app.use(authPlugin)?')
|
||||
}
|
||||
return auth
|
||||
}
|
||||
|
||||
// component.vue
|
||||
<script setup>
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
|
||||
const auth = useAuth() // Type: AuthService (not undefined)
|
||||
await auth.login(credentials)
|
||||
</script>
|
||||
```
|
||||
|
||||
## When String Keys Are Acceptable
|
||||
|
||||
1. **Internal plugin use**: If both provide and inject are in the same plugin file
|
||||
2. **Simple applications**: Very small apps with no collision risk
|
||||
3. **Dynamic keys**: When the key name must be computed at runtime
|
||||
|
||||
Even then, consider using a namespaced string:
|
||||
|
||||
```typescript
|
||||
// Better than plain 'config'
|
||||
app.provide('myPlugin:config', config)
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Vue.js Provide/Inject with Symbol Keys](https://vuejs.org/guide/components/provide-inject.html#working-with-symbol-keys)
|
||||
- [Vue.js TypeScript Support for Inject](https://vuejs.org/guide/typescript/composition-api.html#typing-provide-inject)
|
||||
@@ -0,0 +1,157 @@
|
||||
# Proper TypeScript Type Augmentation for Plugins
|
||||
|
||||
## Rule
|
||||
|
||||
When creating Vue plugins that add global properties, you MUST properly augment TypeScript types. The augmentation file MUST contain at least one top-level `import` or `export` statement to be treated as a module.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
1. **Without module syntax, types are overwritten**: If your augmentation file isn't a module, it will OVERWRITE Vue's types instead of augmenting them, breaking type checking for the entire application.
|
||||
|
||||
2. **Type safety**: Proper augmentation enables autocomplete and type checking for plugin-provided properties.
|
||||
|
||||
3. **IDE support**: Developers get proper IntelliSense for global properties like `this.$translate`.
|
||||
|
||||
4. **Error prevention**: Catch typos and incorrect usage at compile time rather than runtime.
|
||||
|
||||
## Critical Rule: Module Syntax Required
|
||||
|
||||
```typescript
|
||||
// BAD - This OVERWRITES Vue types instead of augmenting!
|
||||
// types/vue.d.ts
|
||||
declare module 'vue' {
|
||||
interface ComponentCustomProperties {
|
||||
$translate: (key: string) => string
|
||||
}
|
||||
}
|
||||
|
||||
// GOOD - The export {} makes this a module, so it AUGMENTS types
|
||||
// types/vue.d.ts
|
||||
export {} // This line is CRITICAL!
|
||||
|
||||
declare module 'vue' {
|
||||
interface ComponentCustomProperties {
|
||||
$translate: (key: string) => string
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Plugin Type Augmentation Example
|
||||
|
||||
```typescript
|
||||
// plugins/i18n.ts
|
||||
import type { App, InjectionKey } from 'vue'
|
||||
|
||||
export interface I18nOptions {
|
||||
locale: string
|
||||
messages: Record<string, Record<string, string>>
|
||||
}
|
||||
|
||||
export interface I18nInstance {
|
||||
translate: (key: string) => string
|
||||
locale: string
|
||||
}
|
||||
|
||||
export const i18nInjectionKey: InjectionKey<I18nInstance> = Symbol('i18n')
|
||||
|
||||
export function createI18n(options: I18nOptions) {
|
||||
const i18n: I18nInstance = {
|
||||
translate(key: string) {
|
||||
return options.messages[options.locale]?.[key] ?? key
|
||||
},
|
||||
locale: options.locale
|
||||
}
|
||||
|
||||
return {
|
||||
install(app: App) {
|
||||
// For Composition API
|
||||
app.provide(i18nInjectionKey, i18n)
|
||||
|
||||
// For Options API / templates
|
||||
app.config.globalProperties.$t = i18n.translate
|
||||
app.config.globalProperties.$i18n = i18n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// types/i18n.d.ts (or in the same file after export)
|
||||
export {}
|
||||
|
||||
declare module 'vue' {
|
||||
interface ComponentCustomProperties {
|
||||
$t: (key: string) => string
|
||||
$i18n: I18nInstance
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Alternative: Augment @vue/runtime-core
|
||||
|
||||
Some plugins augment `@vue/runtime-core` instead of `vue`:
|
||||
|
||||
```typescript
|
||||
// types/global.d.ts
|
||||
export {}
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
interface ComponentCustomProperties {
|
||||
$myPlugin: MyPluginInstance
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Both approaches work, but `'vue'` is more common in application code.
|
||||
|
||||
## Ensure tsconfig.json Includes the Declaration File
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
// ...
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.vue",
|
||||
"types/**/*.d.ts" // Include your declaration files
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## For Library Authors: package.json Types Field
|
||||
|
||||
If publishing a plugin as a package:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-vue-plugin",
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"import": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Errors and Solutions
|
||||
|
||||
### Error: Property '$xyz' does not exist on type
|
||||
|
||||
1. Check that your `.d.ts` file has `export {}` or an import statement
|
||||
2. Verify the file is included in `tsconfig.json`
|
||||
3. Restart your TypeScript language server (VS Code: Cmd+Shift+P > "Restart TS Server")
|
||||
|
||||
### Error: Types work in some components but not others
|
||||
|
||||
This often happens when using Vetur instead of Volar. If you're on Vue 3, switch to Volar (Vue - Official extension).
|
||||
|
||||
### Error in Options API but not Composition API
|
||||
|
||||
Global properties on `this` require proper augmentation of `ComponentCustomProperties`. The Composition API uses `inject()` which is typed separately.
|
||||
|
||||
## References
|
||||
|
||||
- [Vue.js TypeScript with Options API](https://vuejs.org/guide/typescript/options-api.html)
|
||||
- [TypeScript Module Augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation)
|
||||
- [Vue.js Plugins Documentation](https://vuejs.org/guide/reusability/plugins.html)
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: Prefer Local Component Registration Over Global
|
||||
impact: MEDIUM
|
||||
impactDescription: Global registration prevents tree-shaking and creates implicit dependencies
|
||||
type: best-practice
|
||||
tags: [vue3, component-registration, tree-shaking, performance, maintainability]
|
||||
---
|
||||
|
||||
# Prefer Local Component Registration Over Global
|
||||
|
||||
**Impact: MEDIUM** - Global registration prevents build tools from removing unused components (tree-shaking), increasing bundle size. It also creates implicit dependencies similar to global variables, making it difficult to trace where components are used and locate their implementations in large applications.
|
||||
|
||||
Use local registration for better maintainability, smaller bundles, and explicit dependency relationships.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use local registration (import in each component) by default
|
||||
- [ ] Reserve global registration only for truly ubiquitous components (icons, buttons, layouts)
|
||||
- [ ] When using `<script setup>`, simply import components - no explicit registration needed
|
||||
- [ ] Review existing global components and migrate to local registration where possible
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
// main.js - registering many components globally
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import Card from './components/Card.vue'
|
||||
import Modal from './components/Modal.vue'
|
||||
import Table from './components/Table.vue'
|
||||
import Pagination from './components/Pagination.vue'
|
||||
import UserAvatar from './components/UserAvatar.vue'
|
||||
import SearchBar from './components/SearchBar.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// WRONG: All these components are now in the bundle even if unused
|
||||
app.component('Card', Card)
|
||||
app.component('Modal', Modal)
|
||||
app.component('Table', Table)
|
||||
app.component('Pagination', Pagination)
|
||||
app.component('UserAvatar', UserAvatar)
|
||||
app.component('SearchBar', SearchBar)
|
||||
|
||||
app.mount('#app')
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- SomePage.vue - uses only Card, but all components are bundled -->
|
||||
<template>
|
||||
<Card>
|
||||
<p>Content</p>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// No imports - relying on global registration
|
||||
// This makes dependencies invisible and hurts tree-shaking
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
// main.js - only register truly universal components globally
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import BaseIcon from './components/BaseIcon.vue'
|
||||
import BaseButton from './components/BaseButton.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Only truly ubiquitous base components
|
||||
app.component('BaseIcon', BaseIcon)
|
||||
app.component('BaseButton', BaseButton)
|
||||
|
||||
app.mount('#app')
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- SomePage.vue - explicit local imports -->
|
||||
<script setup>
|
||||
// CORRECT: Explicit imports enable tree-shaking
|
||||
// Only Card is included in the bundle for this component
|
||||
import Card from '@/components/Card.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<UserAvatar :user="currentUser" />
|
||||
<p>Content</p>
|
||||
</Card>
|
||||
</template>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- Options API local registration -->
|
||||
<script>
|
||||
import Card from '@/components/Card.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Card,
|
||||
UserAvatar
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## When Global Registration IS Appropriate
|
||||
|
||||
```javascript
|
||||
// Truly universal base components used across the entire app
|
||||
import BaseIcon from './components/BaseIcon.vue'
|
||||
import BaseButton from './components/BaseButton.vue'
|
||||
import BaseInput from './components/BaseInput.vue'
|
||||
|
||||
// Third-party component libraries with controlled scope
|
||||
import { Button, Input } from 'some-ui-library'
|
||||
|
||||
app.component('BaseIcon', BaseIcon)
|
||||
app.component('BaseButton', BaseButton)
|
||||
app.component('BaseInput', BaseInput)
|
||||
```
|
||||
|
||||
## Benefits of Local Registration
|
||||
|
||||
| Aspect | Global | Local |
|
||||
|--------|--------|-------|
|
||||
| Tree-shaking | Not possible | Full support |
|
||||
| Dependency tracking | Implicit/hidden | Explicit imports |
|
||||
| Component location | Hard to find | Follow import path |
|
||||
| Bundle size | All registered components | Only used components |
|
||||
| Refactoring | Risk of breaking unknown usages | Clear dependency graph |
|
||||
|
||||
## Reference
|
||||
- [Vue.js Component Registration](https://vuejs.org/guide/components/registration.html)
|
||||
@@ -0,0 +1,216 @@
|
||||
---
|
||||
title: Prefer Props and Emit Over Component Refs
|
||||
impact: MEDIUM
|
||||
impactDescription: Component refs create tight coupling and break component abstraction
|
||||
type: best-practice
|
||||
tags: [vue3, component-refs, props, emit, component-design, architecture]
|
||||
---
|
||||
|
||||
# Prefer Props and Emit Over Component Refs
|
||||
|
||||
**Impact: MEDIUM** - Using template refs to access child component internals creates tight coupling between parent and child. This makes components harder to maintain, refactor, and reuse. Props and emit provide a cleaner contract-based API that preserves component encapsulation.
|
||||
|
||||
Component refs should be reserved for imperative actions (focus, scroll, animations) that can't be expressed declaratively.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use props for passing data down to children
|
||||
- [ ] Use emit for communicating events up to parents
|
||||
- [ ] Only use component refs for imperative DOM operations
|
||||
- [ ] If using refs, expose a minimal, documented API via defineExpose
|
||||
- [ ] Consider if the interaction can be expressed declaratively
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<!-- ParentComponent.vue -->
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import UserForm from './UserForm.vue'
|
||||
|
||||
const formRef = ref(null)
|
||||
|
||||
// WRONG: Reaching into child's internals
|
||||
function submitForm() {
|
||||
// Tight coupling - parent knows child's internal structure
|
||||
if (formRef.value.isValid) {
|
||||
const data = formRef.value.formData
|
||||
formRef.value.setSubmitting(true)
|
||||
api.submit(data).then(() => {
|
||||
formRef.value.setSubmitting(false)
|
||||
formRef.value.reset()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// WRONG: Parent managing child's state
|
||||
function prefillForm(userData) {
|
||||
formRef.value.formData.name = userData.name
|
||||
formRef.value.formData.email = userData.email
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserForm ref="formRef" />
|
||||
<button @click="submitForm">Submit</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- UserForm.vue - exposing too much -->
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
const formData = reactive({ name: '', email: '' })
|
||||
const isValid = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
function setSubmitting(value) {
|
||||
isSubmitting.value = value
|
||||
}
|
||||
|
||||
function reset() {
|
||||
formData.name = ''
|
||||
formData.email = ''
|
||||
}
|
||||
|
||||
// WRONG: Exposing internal state details
|
||||
defineExpose({
|
||||
formData,
|
||||
isValid,
|
||||
isSubmitting,
|
||||
setSubmitting,
|
||||
reset
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<!-- ParentComponent.vue -->
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import UserForm from './UserForm.vue'
|
||||
|
||||
const initialData = ref({ name: '', email: '' })
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
// CORRECT: Child communicates via events
|
||||
function handleSubmit(formData) {
|
||||
isSubmitting.value = true
|
||||
api.submit(formData).finally(() => {
|
||||
isSubmitting.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function handleValidChange(isValid) {
|
||||
console.log('Form validity:', isValid)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- CORRECT: Props down, events up -->
|
||||
<UserForm
|
||||
:initial-data="initialData"
|
||||
:submitting="isSubmitting"
|
||||
@submit="handleSubmit"
|
||||
@valid-change="handleValidChange"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- UserForm.vue - clean props/emit interface -->
|
||||
<script setup>
|
||||
import { reactive, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
initialData: { type: Object, default: () => ({}) },
|
||||
submitting: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submit', 'valid-change'])
|
||||
|
||||
const formData = reactive({ ...props.initialData })
|
||||
|
||||
const isValid = computed(() => {
|
||||
return formData.name.length > 0 && formData.email.includes('@')
|
||||
})
|
||||
|
||||
watch(isValid, (valid) => {
|
||||
emit('valid-change', valid)
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
if (isValid.value) {
|
||||
emit('submit', { ...formData })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<input v-model="formData.name" :disabled="submitting" />
|
||||
<input v-model="formData.email" :disabled="submitting" />
|
||||
<button type="submit" :disabled="!isValid || submitting">
|
||||
{{ submitting ? 'Submitting...' : 'Submit' }}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
```
|
||||
|
||||
## When Component Refs ARE Appropriate
|
||||
|
||||
```vue
|
||||
<!-- CORRECT: Refs for imperative DOM operations -->
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import CustomInput from './CustomInput.vue'
|
||||
|
||||
const inputRef = ref(null)
|
||||
|
||||
// Imperative focus action - good use of refs
|
||||
function focusInput() {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CustomInput ref="inputRef" v-model="text" />
|
||||
<button @click="focusInput">Focus Input</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- CustomInput.vue - minimal imperative API -->
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const inputEl = ref(null)
|
||||
|
||||
// Only expose imperative methods
|
||||
defineExpose({
|
||||
focus: () => inputEl.value?.focus(),
|
||||
blur: () => inputEl.value?.blur(),
|
||||
select: () => inputEl.value?.select()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input ref="inputEl" v-bind="$attrs" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
| Use Case | Approach |
|
||||
|----------|----------|
|
||||
| Pass data to child | Props |
|
||||
| Child notifies parent | Emit events |
|
||||
| Two-way binding | v-model (props + emit) |
|
||||
| Focus, scroll, animate | Component ref with minimal expose |
|
||||
| Access child internal state | Refactor to use props/emit |
|
||||
|
||||
## Reference
|
||||
- [Vue.js Component Basics - Props](https://vuejs.org/guide/components/props.html)
|
||||
- [Vue.js Component Events](https://vuejs.org/guide/components/events.html)
|
||||
- [Vue.js Template Refs - Ref on Component](https://vuejs.org/guide/essentials/template-refs.html#ref-on-component)
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
title: Prefer ref() Over reactive() for Consistency
|
||||
impact: MEDIUM
|
||||
impactDescription: Using ref() as default avoids reactive() gotchas and provides consistent patterns
|
||||
type: efficiency
|
||||
tags: [vue3, reactivity, ref, reactive, composition-api, best-practice]
|
||||
---
|
||||
|
||||
# Prefer ref() Over reactive() for Consistency
|
||||
|
||||
**Impact: MEDIUM** - The Vue documentation recommends using `ref()` as the primary API for declaring reactive state. This avoids several `reactive()` gotchas and provides a consistent pattern across your codebase.
|
||||
|
||||
While both `ref()` and `reactive()` create reactive state, `reactive()` has several limitations: it only works with objects (not primitives), cannot be reassigned, and loses reactivity when destructured. Using `ref()` consistently means one pattern to remember.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use `ref()` as the default for all reactive state
|
||||
- [ ] Only use `reactive()` when you have a specific reason (e.g., group of related state)
|
||||
- [ ] Be consistent within a codebase - pick one approach and stick with it
|
||||
- [ ] Remember: `.value` is the price for avoiding `reactive()` gotchas
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { reactive } from 'vue'
|
||||
|
||||
// reactive() has multiple gotchas:
|
||||
|
||||
// 1. Cannot use with primitives
|
||||
const count = reactive(0) // Won't work - not reactive
|
||||
|
||||
// 2. Cannot reassign the entire object
|
||||
let state = reactive({ items: [] })
|
||||
state = reactive({ items: [1, 2, 3] }) // Loses reactivity!
|
||||
|
||||
// 3. Destructuring breaks reactivity
|
||||
const { items } = state // items is not reactive
|
||||
|
||||
// 4. Passing to functions can lose reactivity
|
||||
someFunction(state.items) // May lose reactivity depending on usage
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { ref } from 'vue'
|
||||
|
||||
// ref() works universally:
|
||||
|
||||
// 1. Works with primitives
|
||||
const count = ref(0)
|
||||
count.value++ // Works!
|
||||
|
||||
// 2. Can reassign the entire object
|
||||
const state = ref({ items: [] })
|
||||
state.value = { items: [1, 2, 3] } // Reactivity preserved!
|
||||
|
||||
// 3. No destructuring issues (you work with .value)
|
||||
const items = state.value.items // If you need just the value
|
||||
|
||||
// 4. Passing refs is explicit
|
||||
someFunction(state) // Pass the ref
|
||||
someFunction(state.value) // Or pass the value explicitly
|
||||
```
|
||||
|
||||
```javascript
|
||||
// When reactive() makes sense: grouping related state
|
||||
import { reactive, toRefs } from 'vue'
|
||||
|
||||
// Acceptable use case: form state with many related fields
|
||||
const form = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
// But always use toRefs() if you need to destructure
|
||||
const { username, email } = toRefs(form)
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Reactivity Fundamentals](https://vuejs.org/guide/essentials/reactivity-fundamentals.html)
|
||||
- [Vue.js Composition API FAQ](https://vuejs.org/guide/extras/composition-api-faq.html)
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: Boolean Prop Type Order Affects Casting Behavior
|
||||
impact: MEDIUM
|
||||
impactDescription: Wrong type order causes boolean props to be parsed as empty strings instead of true
|
||||
type: gotcha
|
||||
tags: [vue3, props, boolean, type-casting, validation]
|
||||
---
|
||||
|
||||
# Boolean Prop Type Order Affects Casting Behavior
|
||||
|
||||
**Impact: MEDIUM** - When a prop accepts multiple types including both `Boolean` and `String`, the order in the type array determines how attribute-only syntax (e.g., `<Component disabled />`) is parsed. Incorrect ordering can result in `""` (empty string) instead of `true`.
|
||||
|
||||
Vue applies special boolean casting rules, but String appearing before Boolean disables this casting.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Place `Boolean` before `String` in type arrays when you want boolean casting
|
||||
- [ ] Test attribute-only syntax (`<Component disabled />`) to verify expected behavior
|
||||
- [ ] Consider using only `Boolean` type if the prop is truly boolean
|
||||
- [ ] Document the expected usage if both String and Boolean are intentional
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<script setup>
|
||||
// WRONG: String before Boolean disables boolean casting
|
||||
defineProps({
|
||||
disabled: [String, Boolean] // disabled="" is parsed as empty string ""
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- In parent template -->
|
||||
<MyComponent disabled /> <!-- props.disabled === "" (empty string, not true!) -->
|
||||
```
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
defineProps({
|
||||
// PROBLEMATIC: Order matters and may cause confusion
|
||||
loading: [String, Boolean], // <Component loading /> gives ""
|
||||
active: [Boolean, String] // <Component active /> gives true
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<script setup>
|
||||
// CORRECT: Boolean before String enables boolean casting
|
||||
defineProps({
|
||||
disabled: [Boolean, String] // <Component disabled /> parsed as true
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- All of these work as expected -->
|
||||
<MyComponent disabled /> <!-- props.disabled === true -->
|
||||
<MyComponent :disabled="true" /> <!-- props.disabled === true -->
|
||||
<MyComponent :disabled="false" /> <!-- props.disabled === false -->
|
||||
<MyComponent disabled="custom" /> <!-- props.disabled === "custom" -->
|
||||
```
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// BEST: Use only Boolean if you don't need String values
|
||||
defineProps({
|
||||
disabled: Boolean // Clear intent, no ambiguity
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Boolean Casting Rules
|
||||
|
||||
| Prop Declaration | Template Usage | Resulting Value |
|
||||
|-----------------|----------------|-----------------|
|
||||
| `Boolean` | `<C disabled />` | `true` |
|
||||
| `Boolean` | `<C />` (absent) | `false` |
|
||||
| `[Boolean, String]` | `<C disabled />` | `true` |
|
||||
| `[String, Boolean]` | `<C disabled />` | `""` (empty string) |
|
||||
| `[Boolean, Number]` | `<C disabled />` | `true` |
|
||||
| `[Number, Boolean]` | `<C disabled />` | `true` |
|
||||
|
||||
Note: The String type is special - it's the only type that overrides Boolean casting when placed first.
|
||||
|
||||
## When to Use Multiple Types
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// Use case: Prop can be a boolean toggle OR a string configuration
|
||||
defineProps({
|
||||
// Animation can be true/false OR a timing string like "fast", "slow"
|
||||
animate: {
|
||||
type: [Boolean, String], // Boolean first for casting
|
||||
default: false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- Usage examples -->
|
||||
<Dialog animate /> <!-- true - use default animation -->
|
||||
<Dialog :animate="false" /> <!-- false - no animation -->
|
||||
<Dialog animate="slow" /> <!-- "slow" - custom timing -->
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Props - Boolean Casting](https://vuejs.org/guide/components/props.html#boolean-casting)
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: Preserve Reactivity When Passing Props to Composables
|
||||
impact: HIGH
|
||||
impactDescription: Passing prop values directly to composables loses reactivity - composable won't update when props change
|
||||
type: gotcha
|
||||
tags: [vue3, props, composables, reactivity, composition-api]
|
||||
---
|
||||
|
||||
# Preserve Reactivity When Passing Props to Composables
|
||||
|
||||
**Impact: HIGH** - A common mistake is passing data received from a prop directly to a composable. This passes the current value, not a reactive source. When the prop updates, the composable won't receive the new value, leading to stale data.
|
||||
|
||||
This is one of the most frequent sources of "my composable doesn't update" bugs in Vue 3.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Pass props to composables via computed properties or getter functions
|
||||
- [ ] Use `toRefs()` when passing multiple props to maintain reactivity
|
||||
- [ ] In composables, use `toValue()` to normalize inputs that may be getters or refs
|
||||
- [ ] Test that composable output updates when props change
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { useFetch } from './composables/useFetch'
|
||||
import { useDebounce } from './composables/useDebounce'
|
||||
|
||||
const props = defineProps({
|
||||
userId: Number,
|
||||
searchQuery: String
|
||||
})
|
||||
|
||||
// WRONG: Passes initial value, not reactive source
|
||||
// useFetch will never refetch when userId changes!
|
||||
const { data } = useFetch(`/api/users/${props.userId}`)
|
||||
|
||||
// WRONG: Debounced value is frozen at initial searchQuery
|
||||
const debouncedQuery = useDebounce(props.searchQuery, 300)
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useFetch } from './composables/useFetch'
|
||||
import { useDebounce } from './composables/useDebounce'
|
||||
|
||||
const props = defineProps({
|
||||
userId: Number,
|
||||
searchQuery: String
|
||||
})
|
||||
|
||||
// CORRECT: Use computed to create reactive URL
|
||||
const userUrl = computed(() => `/api/users/${props.userId}`)
|
||||
const { data } = useFetch(userUrl)
|
||||
|
||||
// CORRECT: Pass getter function to preserve reactivity
|
||||
const debouncedQuery = useDebounce(() => props.searchQuery, 300)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Pattern: Using toRefs for Multiple Props
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { toRefs } from 'vue'
|
||||
import { useUserForm } from './composables/useUserForm'
|
||||
|
||||
const props = defineProps({
|
||||
initialName: String,
|
||||
initialEmail: String,
|
||||
initialAge: Number
|
||||
})
|
||||
|
||||
// Convert all props to refs, preserving reactivity
|
||||
const { initialName, initialEmail, initialAge } = toRefs(props)
|
||||
|
||||
// Now each is a ref that tracks prop changes
|
||||
const { form, isValid } = useUserForm({
|
||||
name: initialName,
|
||||
email: initialEmail,
|
||||
age: initialAge
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Writing Reactivity-Safe Composables
|
||||
|
||||
Composables should accept multiple input types using `toValue()`:
|
||||
|
||||
```javascript
|
||||
// composables/useDebounce.js
|
||||
import { ref, watch, toValue } from 'vue'
|
||||
|
||||
export function useDebounce(source, delay = 300) {
|
||||
// toValue() handles: ref, getter function, or plain value
|
||||
const debounced = ref(toValue(source))
|
||||
let timeout
|
||||
|
||||
watch(
|
||||
() => toValue(source), // Normalizes any input type
|
||||
(newValue) => {
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
debounced.value = newValue
|
||||
}, delay)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
return debounced
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// composables/useFetch.js
|
||||
import { ref, watchEffect, toValue } from 'vue'
|
||||
|
||||
export function useFetch(url) {
|
||||
const data = ref(null)
|
||||
const error = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
watchEffect(async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// toValue() makes this work with computed, getter, or string
|
||||
const response = await fetch(toValue(url))
|
||||
data.value = await response.json()
|
||||
} catch (e) {
|
||||
error.value = e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
return { data, error, loading }
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference: Input Types
|
||||
|
||||
| Input to Composable | Reactive? | Example |
|
||||
|---------------------|-----------|---------|
|
||||
| `props.value` | No | `useFetch(props.userId)` |
|
||||
| `computed(() => ...)` | Yes | `useFetch(computed(() => props.userId))` |
|
||||
| `() => props.value` | Yes* | `useFetch(() => props.userId)` |
|
||||
| `toRef(props, 'key')` | Yes | `useFetch(toRef(props, 'userId'))` |
|
||||
| `toRefs(props).key` | Yes | `const { userId } = toRefs(props); useFetch(userId)` |
|
||||
|
||||
*Requires composable to use `toValue()` internally
|
||||
|
||||
## Reference
|
||||
- [Vue.js Reactivity API - toValue](https://vuejs.org/api/reactivity-utilities.html#tovalue)
|
||||
- [Vue.js Composables - Conventions and Best Practices](https://vuejs.org/guide/reusability/composables.html#conventions-and-best-practices)
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
title: Wrap Destructured Props in Getters for watch and Composables
|
||||
impact: MEDIUM
|
||||
impactDescription: Passing destructured prop values directly to watch or composables loses reactivity
|
||||
type: gotcha
|
||||
tags: [vue3, props, reactivity, watch, composables, destructuring]
|
||||
---
|
||||
|
||||
# Wrap Destructured Props in Getters for watch and Composables
|
||||
|
||||
**Impact: MEDIUM** - When you destructure props with `defineProps`, the destructured variables are reactive in templates but passing them directly to `watch()` or external composables will pass the current value, not a reactive source. The watcher or composable won't track future changes.
|
||||
|
||||
Vue 3.5+ automatically transforms destructured props for template reactivity, but external functions still need getter wrappers.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Wrap destructured props in arrow functions when passing to `watch()`
|
||||
- [ ] Use getter functions when passing destructured props to composables
|
||||
- [ ] Verify composables use `toValue()` to normalize getter/ref inputs
|
||||
- [ ] Consider using `props.propertyName` directly if getter syntax feels awkward
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
import { useDebounce } from './composables'
|
||||
|
||||
const { searchQuery, userId } = defineProps(['searchQuery', 'userId'])
|
||||
|
||||
// WRONG: Passing value, not reactive source
|
||||
// This captures the initial value only - changes won't trigger the watcher
|
||||
watch(searchQuery, (newValue) => {
|
||||
console.log('Query changed:', newValue) // Never fires after initial!
|
||||
})
|
||||
|
||||
// WRONG: Composable receives static value
|
||||
const debouncedQuery = useDebounce(searchQuery, 300) // Won't update
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
import { useDebounce } from './composables'
|
||||
|
||||
const { searchQuery, userId } = defineProps(['searchQuery', 'userId'])
|
||||
|
||||
// CORRECT: Wrap in getter function to maintain reactivity
|
||||
watch(() => searchQuery, (newValue) => {
|
||||
console.log('Query changed:', newValue) // Fires on every change
|
||||
})
|
||||
|
||||
// CORRECT: Pass getter to composable
|
||||
const debouncedQuery = useDebounce(() => searchQuery, 300)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Alternative: Use props Object Directly
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
|
||||
const props = defineProps(['searchQuery', 'userId'])
|
||||
|
||||
// Also correct: Watch via props object (no getter needed)
|
||||
watch(() => props.searchQuery, (newValue) => {
|
||||
console.log('Query changed:', newValue)
|
||||
})
|
||||
|
||||
// Watch multiple props
|
||||
watch(
|
||||
() => [props.searchQuery, props.userId],
|
||||
([newQuery, newUserId]) => {
|
||||
console.log('Props changed:', newQuery, newUserId)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Writing Composables That Accept Props
|
||||
|
||||
When creating composables that should work with destructured props:
|
||||
|
||||
```javascript
|
||||
// composables/useDebounce.js
|
||||
import { ref, watch, toValue } from 'vue'
|
||||
|
||||
export function useDebounce(source, delay = 300) {
|
||||
const debounced = ref(toValue(source)) // toValue handles both getter and ref
|
||||
let timeout
|
||||
|
||||
watch(
|
||||
// toValue normalizes getter functions and refs
|
||||
() => toValue(source),
|
||||
(newValue) => {
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
debounced.value = newValue
|
||||
}, delay)
|
||||
}
|
||||
)
|
||||
|
||||
return debounced
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- Usage -->
|
||||
<script setup>
|
||||
const { query } = defineProps(['query'])
|
||||
|
||||
// Works with getter
|
||||
const debouncedQuery = useDebounce(() => query, 300)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Vue 3.5+ Reactive Destructuring
|
||||
|
||||
Vue 3.5+ added reactive props destructuring. The compiler transforms:
|
||||
|
||||
```javascript
|
||||
const { foo } = defineProps(['foo'])
|
||||
```
|
||||
|
||||
Into something like:
|
||||
|
||||
```javascript
|
||||
const __props = defineProps(['foo'])
|
||||
// foo accesses __props.foo reactively in templates
|
||||
```
|
||||
|
||||
However, external function calls still need getters because JavaScript itself passes values, not references.
|
||||
|
||||
## Reference
|
||||
- [Vue.js Props - Reactive Props Destructure](https://vuejs.org/guide/components/props.html#reactive-props-destructure)
|
||||
- [Vue.js Watchers - Watch Source Types](https://vuejs.org/guide/essentials/watchers.html#watch-source-types)
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
title: Props Are Validated Before Component Instance Creation
|
||||
impact: MEDIUM
|
||||
impactDescription: Instance properties like data and computed are unavailable in prop default/validator functions
|
||||
type: gotcha
|
||||
tags: [vue3, props, validation, lifecycle, default-values]
|
||||
---
|
||||
|
||||
# Props Are Validated Before Component Instance Creation
|
||||
|
||||
**Impact: MEDIUM** - Prop validation and default value functions execute before the component instance is created. This means `this`, `data`, `computed`, injections, and other instance properties are not available inside `default` or `validator` functions.
|
||||
|
||||
This timing catches developers who expect to use component state in prop validation logic.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Never reference `this` or instance properties in prop default/validator functions
|
||||
- [ ] Use factory functions for object/array defaults that only use the function parameters
|
||||
- [ ] For validation depending on other props, use the second `props` parameter in validators
|
||||
- [ ] Move complex validation logic to watchers or lifecycle hooks if instance access is needed
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
validOptions: ['a', 'b', 'c'],
|
||||
defaultMessage: 'Hello'
|
||||
}
|
||||
},
|
||||
props: {
|
||||
option: {
|
||||
type: String,
|
||||
// WRONG: 'this' is undefined during prop validation
|
||||
validator(value) {
|
||||
return this.validOptions.includes(value) // TypeError!
|
||||
}
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
// WRONG: Cannot access data properties
|
||||
default() {
|
||||
return this.defaultMessage // TypeError!
|
||||
}
|
||||
},
|
||||
config: {
|
||||
type: Object,
|
||||
// WRONG: Cannot access computed properties
|
||||
default() {
|
||||
return this.computedDefaults // TypeError!
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<script>
|
||||
// Define validation data outside the component
|
||||
const VALID_OPTIONS = ['a', 'b', 'c']
|
||||
const DEFAULT_MESSAGE = 'Hello'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
option: {
|
||||
type: String,
|
||||
// CORRECT: Use external constants
|
||||
validator(value) {
|
||||
return VALID_OPTIONS.includes(value)
|
||||
}
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
// CORRECT: Use external constant or inline value
|
||||
default: DEFAULT_MESSAGE
|
||||
},
|
||||
config: {
|
||||
type: Object,
|
||||
// CORRECT: Factory function with no instance dependencies
|
||||
default() {
|
||||
return { theme: 'light', size: 'medium' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Using the `props` Parameter in Validators
|
||||
|
||||
Validators receive the full props object as the second parameter for cross-prop validation:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
min: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 100
|
||||
},
|
||||
value: {
|
||||
type: Number,
|
||||
required: true,
|
||||
// CORRECT: Access other props via second parameter
|
||||
validator(value, props) {
|
||||
return value >= props.min && value <= props.max
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Using rawProps in Default Functions
|
||||
|
||||
Default factory functions receive `rawProps` for accessing other prop values:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
defineProps({
|
||||
size: {
|
||||
type: String,
|
||||
default: 'medium'
|
||||
},
|
||||
padding: {
|
||||
type: Number,
|
||||
// Access other prop values via rawProps parameter
|
||||
default(rawProps) {
|
||||
// Use size to determine default padding
|
||||
return rawProps.size === 'large' ? 20 : 10
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Post-Mount Validation Pattern
|
||||
|
||||
For validation that needs instance access, use lifecycle hooks:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { onMounted, inject, warn } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
theme: String
|
||||
})
|
||||
|
||||
const availableThemes = inject('availableThemes', [])
|
||||
|
||||
// Validation that needs injected values
|
||||
onMounted(() => {
|
||||
if (props.theme && !availableThemes.includes(props.theme)) {
|
||||
console.warn(`Invalid theme "${props.theme}". Available: ${availableThemes.join(', ')}`)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Props - Prop Validation](https://vuejs.org/guide/components/props.html#prop-validation)
|
||||
214
skills/vue-best-practices/reference/props-are-read-only.md
Normal file
214
skills/vue-best-practices/reference/props-are-read-only.md
Normal file
@@ -0,0 +1,214 @@
|
||||
---
|
||||
title: Props Are Read-Only - Never Mutate Props
|
||||
impact: HIGH
|
||||
impactDescription: Mutating props breaks one-way data flow and causes unpredictable parent-child state synchronization issues
|
||||
type: gotcha
|
||||
tags: [vue3, props, one-way-data-flow, mutation, component-design]
|
||||
---
|
||||
|
||||
# Props Are Read-Only - Never Mutate Props
|
||||
|
||||
**Impact: HIGH** - Props in Vue follow one-way data flow: parent to child only. Mutating a prop in a child component violates this contract, triggering Vue warnings and causing hard-to-debug state synchronization issues. The parent component loses control of the data it passed down.
|
||||
|
||||
Props are "snapshots" from the parent at render time. Vue's reactivity system tracks props at the parent level - mutating in the child doesn't notify the parent, leading to state inconsistencies.
|
||||
|
||||
This is especially dangerous with object/array props because JavaScript passes them by reference, allowing mutation without assignment.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Never assign new values to props
|
||||
- [ ] Never mutate object or array prop properties directly
|
||||
- [ ] Use emit to request parent to make changes
|
||||
- [ ] Create local copies if you need to modify prop-based data
|
||||
- [ ] Use computed properties for derived values
|
||||
|
||||
## The Problem
|
||||
|
||||
**Incorrect - Direct primitive prop mutation:**
|
||||
```vue
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
count: Number
|
||||
})
|
||||
|
||||
// WRONG: Vue will warn about mutating props
|
||||
function increment() {
|
||||
props.count++ // Mutation attempt - this WILL fail
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**Incorrect - Object/array prop mutation (silent but dangerous):**
|
||||
```vue
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
user: Object,
|
||||
items: Array
|
||||
})
|
||||
|
||||
// WRONG: No warning, but breaks data flow!
|
||||
function updateUser() {
|
||||
props.user.name = 'New Name' // Mutates parent's object
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
props.items.push({ id: 1 }) // Mutates parent's array
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
This pattern is dangerous because:
|
||||
1. Parent component doesn't know about the change
|
||||
2. Data can become out of sync
|
||||
3. Makes debugging difficult - where did the change come from?
|
||||
4. Breaks the component contract
|
||||
|
||||
## Pattern 1: Emit Events to Parent
|
||||
|
||||
Let the parent handle all data changes.
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<!-- ChildComponent.vue -->
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
count: Number,
|
||||
user: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:count', 'update-user'])
|
||||
|
||||
function increment() {
|
||||
emit('update:count', props.count + 1)
|
||||
}
|
||||
|
||||
function updateName(newName) {
|
||||
emit('update-user', { ...props.user, name: newName })
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- ParentComponent.vue -->
|
||||
<template>
|
||||
<ChildComponent
|
||||
:count="count"
|
||||
:user="user"
|
||||
@update:count="count = $event"
|
||||
@update-user="user = $event"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Pattern 2: Local Copy for Editable Data (Prop as Initial Value)
|
||||
|
||||
When the component needs to work with a modified version of prop data.
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
initialValue: String,
|
||||
user: Object
|
||||
})
|
||||
|
||||
// Local copy for editing
|
||||
const localValue = ref(props.initialValue)
|
||||
|
||||
// Deep copy for objects
|
||||
const localUser = ref({ ...props.user })
|
||||
|
||||
// Sync when parent changes the prop
|
||||
watch(() => props.initialValue, (newVal) => {
|
||||
localValue.value = newVal
|
||||
})
|
||||
|
||||
watch(() => props.user, (newUser) => {
|
||||
localUser.value = { ...newUser }
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input v-model="localValue" />
|
||||
<input v-model="localUser.name" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Pattern 3: Computed Properties for Transformations
|
||||
|
||||
When you need a derived/transformed version of the prop.
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
text: String,
|
||||
items: Array
|
||||
})
|
||||
|
||||
// Derived value - doesn't mutate prop
|
||||
const uppercaseText = computed(() => props.text.toUpperCase())
|
||||
|
||||
// Filtered view - doesn't mutate prop
|
||||
const activeItems = computed(() =>
|
||||
props.items.filter(item => item.active)
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Pattern 4: v-model for Two-Way Binding
|
||||
|
||||
For form-like components that need two-way binding.
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<!-- CustomInput.vue -->
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: String
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
:value="modelValue"
|
||||
@input="emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- ParentComponent.vue -->
|
||||
<template>
|
||||
<!-- v-model is shorthand for :modelValue + @update:modelValue -->
|
||||
<CustomInput v-model="text" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Common Mistake: Thinking Object Mutation Is Safe
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const props = defineProps({ config: Object })
|
||||
|
||||
// This "works" but is an anti-pattern!
|
||||
props.config.theme = 'dark' // No Vue warning, but still wrong
|
||||
</script>
|
||||
```
|
||||
|
||||
Vue doesn't warn because it can't efficiently detect deep mutations. But this still:
|
||||
- Breaks one-way data flow
|
||||
- Makes the component unpredictable
|
||||
- Causes maintenance nightmares
|
||||
|
||||
**Always treat props as if they were deeply frozen.**
|
||||
|
||||
## Reference
|
||||
- [Vue.js Props - One-Way Data Flow](https://vuejs.org/guide/components/props.html#one-way-data-flow)
|
||||
- [Vue.js Props - Mutating Object/Array Props](https://vuejs.org/guide/components/props.html#mutating-object-array-props)
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
title: Keep Provide/Inject Mutations in the Provider Component
|
||||
impact: MEDIUM
|
||||
impactDescription: Allowing injectors to mutate provided state leads to unpredictable data flow and difficult debugging
|
||||
type: best-practice
|
||||
tags: [vue3, provide-inject, state-management, architecture, debugging]
|
||||
---
|
||||
|
||||
# Keep Provide/Inject Mutations in the Provider Component
|
||||
|
||||
**Impact: MEDIUM** - When using reactive provide/inject values, mutations should be kept inside the provider component whenever possible. Allowing child components to mutate injected state directly leads to confusion about where changes originate, making debugging and maintenance difficult.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Keep all mutations to provided state in the provider component
|
||||
- [ ] Provide update functions alongside reactive data for child modifications
|
||||
- [ ] Use `readonly()` to prevent accidental mutations from injectors
|
||||
- [ ] Document provided values and their update patterns
|
||||
|
||||
## The Problem: Scattered Mutations
|
||||
|
||||
**Wrong - Injector mutates provided state directly:**
|
||||
```vue
|
||||
<!-- Provider.vue -->
|
||||
<script setup>
|
||||
import { ref, provide } from 'vue'
|
||||
|
||||
const user = ref({ name: 'John', preferences: { theme: 'dark' } })
|
||||
provide('user', user)
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- DeepChild.vue -->
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
|
||||
const user = inject('user')
|
||||
|
||||
// PROBLEMATIC: Mutating from anywhere in the tree
|
||||
function updateTheme(theme) {
|
||||
user.value.preferences.theme = theme // Where did this change come from?
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**Problems:**
|
||||
1. Hard to trace where state changes originate
|
||||
2. Multiple components might mutate the same data inconsistently
|
||||
3. No centralized validation or side effects
|
||||
4. Debugging becomes a nightmare in large apps
|
||||
|
||||
## Solution: Provide Update Functions
|
||||
|
||||
**Correct - Provider controls all mutations:**
|
||||
```vue
|
||||
<!-- Provider.vue -->
|
||||
<script setup>
|
||||
import { ref, provide, readonly } from 'vue'
|
||||
|
||||
const user = ref({ name: 'John', preferences: { theme: 'dark' } })
|
||||
|
||||
// Mutation function with validation
|
||||
function updateUserPreferences(preferences) {
|
||||
// Centralized validation
|
||||
if (preferences.theme && !['dark', 'light', 'system'].includes(preferences.theme)) {
|
||||
console.warn('Invalid theme')
|
||||
return false
|
||||
}
|
||||
|
||||
// Centralized side effects
|
||||
Object.assign(user.value.preferences, preferences)
|
||||
localStorage.setItem('userPrefs', JSON.stringify(user.value.preferences))
|
||||
return true
|
||||
}
|
||||
|
||||
function updateUserName(name) {
|
||||
if (!name || name.length < 2) {
|
||||
console.warn('Name must be at least 2 characters')
|
||||
return false
|
||||
}
|
||||
user.value.name = name
|
||||
return true
|
||||
}
|
||||
|
||||
// Provide readonly data + update functions
|
||||
provide('user', {
|
||||
data: readonly(user),
|
||||
updatePreferences: updateUserPreferences,
|
||||
updateName: updateUserName
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- DeepChild.vue -->
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
|
||||
const { data: user, updatePreferences } = inject('user')
|
||||
|
||||
function changeTheme(theme) {
|
||||
// Clear intent: calling provider's update function
|
||||
const success = updatePreferences({ theme })
|
||||
if (!success) {
|
||||
// Handle validation failure
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- data is readonly, prevents accidental mutation -->
|
||||
<div>Theme: {{ user.preferences.theme }}</div>
|
||||
<button @click="changeTheme('light')">Light Mode</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Pattern: Provide/Inject with Readonly Protection
|
||||
|
||||
Use `readonly()` to enforce the pattern at runtime:
|
||||
|
||||
```vue
|
||||
<!-- Provider.vue -->
|
||||
<script setup>
|
||||
import { ref, provide, readonly } from 'vue'
|
||||
|
||||
const cart = ref([])
|
||||
|
||||
function addItem(item) {
|
||||
cart.value.push(item)
|
||||
}
|
||||
|
||||
function removeItem(id) {
|
||||
cart.value = cart.value.filter(item => item.id !== id)
|
||||
}
|
||||
|
||||
function clearCart() {
|
||||
cart.value = []
|
||||
}
|
||||
|
||||
// Provide readonly cart + controlled mutations
|
||||
provide('cart', {
|
||||
items: readonly(cart),
|
||||
addItem,
|
||||
removeItem,
|
||||
clearCart
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- CartDisplay.vue -->
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
|
||||
const { items, removeItem } = inject('cart')
|
||||
|
||||
// items.push(newItem) would trigger a warning in dev mode
|
||||
// Must use provided removeItem function
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-for="item in items" :key="item.id">
|
||||
{{ item.name }}
|
||||
<button @click="removeItem(item.id)">Remove</button>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Benefits of This Pattern
|
||||
|
||||
1. **Traceability**: All mutations go through known functions
|
||||
2. **Validation**: Centralized validation in provider
|
||||
3. **Side Effects**: Consistent side effects (logging, storage, API calls)
|
||||
4. **Testing**: Easier to test mutation logic in isolation
|
||||
5. **Debugging**: Clear mutation source in Vue DevTools
|
||||
|
||||
## When Direct Mutation Might Be Acceptable
|
||||
|
||||
In rare cases, direct mutation may be acceptable:
|
||||
|
||||
- Very simple, local state within a small component tree
|
||||
- Form state that's isolated to a single form wizard
|
||||
- Temporary state that doesn't affect app logic
|
||||
|
||||
Even then, consider using `readonly()` with update functions for consistency.
|
||||
|
||||
## Reference
|
||||
- [Vue.js Provide/Inject - Working with Reactivity](https://vuejs.org/guide/components/provide-inject.html#working-with-reactivity)
|
||||
- [The Complete Guide to Provide/Inject API in Vue 3](https://www.codemag.com/Article/2101091/The-Complete-Guide-to-Provide-Inject-API-in-Vue-3-Part-1)
|
||||
@@ -0,0 +1,232 @@
|
||||
---
|
||||
title: Use Symbol Keys for Provide/Inject in Large Applications
|
||||
impact: MEDIUM
|
||||
impactDescription: String injection keys can collide in large applications or when using third-party components
|
||||
type: best-practice
|
||||
tags: [vue3, provide-inject, typescript, architecture, component-library]
|
||||
---
|
||||
|
||||
# Use Symbol Keys for Provide/Inject in Large Applications
|
||||
|
||||
**Impact: MEDIUM** - Using string keys for provide/inject works for small applications but can cause key collisions in large apps, component libraries, or when multiple teams work on the same codebase. Symbol keys guarantee uniqueness and provide better TypeScript integration.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use Symbol keys for provide/inject in large applications
|
||||
- [ ] Export symbols from a dedicated keys file
|
||||
- [ ] Use `InjectionKey<T>` for TypeScript type safety
|
||||
- [ ] Reserve string keys for simple, local use cases only
|
||||
|
||||
## The Problem: String Key Collisions
|
||||
|
||||
**Risky - String keys can collide:**
|
||||
```vue
|
||||
<!-- ThemeProvider.vue (your code) -->
|
||||
<script setup>
|
||||
import { provide, ref } from 'vue'
|
||||
provide('theme', ref('dark'))
|
||||
</script>
|
||||
|
||||
<!-- SomeThirdPartyComponent.vue (library) -->
|
||||
<script setup>
|
||||
import { provide, ref } from 'vue'
|
||||
// Oops! Same key, different value
|
||||
provide('theme', ref({ primary: 'blue', secondary: 'gray' }))
|
||||
</script>
|
||||
|
||||
<!-- DeepChild.vue -->
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
const theme = inject('theme') // Which one? Closest ancestor wins
|
||||
</script>
|
||||
```
|
||||
|
||||
## Solution: Symbol Keys
|
||||
|
||||
**Correct - Unique symbols prevent collisions:**
|
||||
|
||||
```js
|
||||
// injection-keys.js
|
||||
export const ThemeKey = Symbol('theme')
|
||||
export const UserKey = Symbol('user')
|
||||
export const ConfigKey = Symbol('config')
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- ThemeProvider.vue -->
|
||||
<script setup>
|
||||
import { provide, ref } from 'vue'
|
||||
import { ThemeKey } from '@/injection-keys'
|
||||
|
||||
const theme = ref('dark')
|
||||
provide(ThemeKey, theme)
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- ThemeConsumer.vue -->
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
import { ThemeKey } from '@/injection-keys'
|
||||
|
||||
const theme = inject(ThemeKey)
|
||||
</script>
|
||||
```
|
||||
|
||||
## TypeScript: InjectionKey for Type Safety
|
||||
|
||||
Vue provides `InjectionKey<T>` for strongly-typed injection:
|
||||
|
||||
```ts
|
||||
// injection-keys.ts
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
// Define the injected type
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
interface ThemeConfig {
|
||||
mode: 'light' | 'dark'
|
||||
primaryColor: string
|
||||
}
|
||||
|
||||
// Create typed injection keys
|
||||
export const UserKey: InjectionKey<Ref<User>> = Symbol('user')
|
||||
export const ThemeKey: InjectionKey<Ref<ThemeConfig>> = Symbol('theme')
|
||||
export const LoggerKey: InjectionKey<(msg: string) => void> = Symbol('logger')
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- Provider.vue -->
|
||||
<script setup lang="ts">
|
||||
import { provide, ref } from 'vue'
|
||||
import { UserKey, ThemeKey } from '@/injection-keys'
|
||||
|
||||
const user = ref({
|
||||
id: '123',
|
||||
name: 'John',
|
||||
email: 'john@example.com'
|
||||
})
|
||||
|
||||
const theme = ref({
|
||||
mode: 'dark' as const,
|
||||
primaryColor: '#007bff'
|
||||
})
|
||||
|
||||
// TypeScript validates the provided value matches the key's type
|
||||
provide(UserKey, user)
|
||||
provide(ThemeKey, theme)
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- Consumer.vue -->
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { UserKey, ThemeKey } from '@/injection-keys'
|
||||
|
||||
// TypeScript knows user is Ref<User> | undefined
|
||||
const user = inject(UserKey)
|
||||
|
||||
// With default value, TypeScript knows it's not undefined
|
||||
const theme = inject(ThemeKey, ref({ mode: 'light', primaryColor: '#000' }))
|
||||
|
||||
// Type-safe access
|
||||
console.log(user?.value.name) // TypeScript knows the shape
|
||||
console.log(theme.value.mode) // 'light' | 'dark'
|
||||
</script>
|
||||
```
|
||||
|
||||
## Pattern: Injection Keys File Organization
|
||||
|
||||
For larger applications, organize keys by feature:
|
||||
|
||||
```
|
||||
src/
|
||||
injection-keys/
|
||||
index.ts # Re-exports all keys
|
||||
auth.ts # Auth-related keys
|
||||
theme.ts # Theme-related keys
|
||||
feature-x.ts # Feature-specific keys
|
||||
```
|
||||
|
||||
```ts
|
||||
// injection-keys/auth.ts
|
||||
import type { InjectionKey, Ref, ComputedRef } from 'vue'
|
||||
|
||||
export interface AuthState {
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
export interface AuthActions {
|
||||
login: (credentials: Credentials) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
checkPermission: (permission: string) => boolean
|
||||
}
|
||||
|
||||
export const AuthStateKey: InjectionKey<Ref<AuthState>> = Symbol('auth-state')
|
||||
export const AuthActionsKey: InjectionKey<AuthActions> = Symbol('auth-actions')
|
||||
```
|
||||
|
||||
```ts
|
||||
// injection-keys/index.ts
|
||||
export * from './auth'
|
||||
export * from './theme'
|
||||
export * from './feature-x'
|
||||
```
|
||||
|
||||
## Handling Missing Injections with Types
|
||||
|
||||
TypeScript helps catch missing providers:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { UserKey } from '@/injection-keys'
|
||||
|
||||
// Option 1: Handle undefined explicitly
|
||||
const user = inject(UserKey)
|
||||
if (!user) {
|
||||
throw new Error('UserKey must be provided by an ancestor component')
|
||||
}
|
||||
|
||||
// Option 2: Provide a default
|
||||
const userWithDefault = inject(UserKey, ref({
|
||||
id: 'guest',
|
||||
name: 'Guest User',
|
||||
email: ''
|
||||
}))
|
||||
|
||||
// Option 3: Use non-null assertion (only if you're certain)
|
||||
const userRequired = inject(UserKey)!
|
||||
</script>
|
||||
```
|
||||
|
||||
## When String Keys Are Still OK
|
||||
|
||||
String keys are acceptable for:
|
||||
|
||||
- Small applications with few providers
|
||||
- Local component trees with clear boundaries
|
||||
- Quick prototypes
|
||||
- App-level provides with unique, namespaced strings
|
||||
|
||||
```vue
|
||||
<!-- App-level provides with namespaced strings -->
|
||||
<script setup>
|
||||
import { provide } from 'vue'
|
||||
|
||||
// Namespaced strings reduce collision risk
|
||||
provide('myapp:config', config)
|
||||
provide('myapp:analytics', analytics)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Provide/Inject - Working with Symbol Keys](https://vuejs.org/guide/components/provide-inject.html#working-with-symbol-keys)
|
||||
- [Vue.js TypeScript - Typing Provide/Inject](https://vuejs.org/guide/typescript/composition-api.html#typing-provide-inject)
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: Never Destructure reactive() Objects Directly
|
||||
impact: HIGH
|
||||
impactDescription: Destructuring reactive objects breaks reactivity - changes won't trigger updates
|
||||
type: capability
|
||||
tags: [vue3, reactivity, reactive, composition-api, destructuring]
|
||||
---
|
||||
|
||||
# Never Destructure reactive() Objects Directly
|
||||
|
||||
**Impact: HIGH** - Destructuring a `reactive()` object breaks the reactive connection. Updates to destructured variables won't trigger UI updates, leading to stale data display.
|
||||
|
||||
Vue's `reactive()` uses JavaScript Proxies to track property access. When you destructure, you extract primitive values from the proxy, losing the reactive connection. This is especially dangerous when destructuring from composables or imported state.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Never destructure reactive objects directly if you need reactivity
|
||||
- [ ] Use `toRefs()` to convert reactive object properties to refs before destructuring
|
||||
- [ ] Consider using `ref()` instead of `reactive()` to avoid this pitfall entirely
|
||||
- [ ] When importing state from composables, check if it's reactive before destructuring
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const state = reactive({
|
||||
count: 0,
|
||||
name: 'Vue'
|
||||
})
|
||||
|
||||
// WRONG: Destructuring breaks reactivity
|
||||
const { count, name } = state
|
||||
|
||||
// These updates work on the original state...
|
||||
state.count++ // state.count is now 1
|
||||
|
||||
// ...but the destructured variables are NOT updated
|
||||
console.log(count) // Still 0! Lost reactivity
|
||||
```
|
||||
|
||||
```javascript
|
||||
// WRONG: Destructuring from a composable
|
||||
function useCounter() {
|
||||
const state = reactive({ count: 0 })
|
||||
return state
|
||||
}
|
||||
|
||||
const { count } = useCounter() // count is now a non-reactive primitive
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { reactive, toRefs } from 'vue'
|
||||
|
||||
const state = reactive({
|
||||
count: 0,
|
||||
name: 'Vue'
|
||||
})
|
||||
|
||||
// CORRECT: Use toRefs() to maintain reactivity
|
||||
const { count, name } = toRefs(state)
|
||||
|
||||
state.count++
|
||||
console.log(count.value) // 1 - Reactivity preserved! (note: now needs .value)
|
||||
```
|
||||
|
||||
```javascript
|
||||
// CORRECT: Return toRefs from composables
|
||||
function useCounter() {
|
||||
const state = reactive({ count: 0 })
|
||||
return toRefs(state) // Now safe to destructure
|
||||
}
|
||||
|
||||
const { count } = useCounter() // count is now a ref, reactivity preserved
|
||||
```
|
||||
|
||||
```javascript
|
||||
// ALTERNATIVE: Just use ref() to avoid the issue entirely
|
||||
import { ref } from 'vue'
|
||||
|
||||
const count = ref(0)
|
||||
const name = ref('Vue')
|
||||
|
||||
// No destructuring needed, no gotchas
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Reactivity Fundamentals - reactive()](https://vuejs.org/guide/essentials/reactivity-fundamentals.html#reactive)
|
||||
- [Vue.js Reactivity API - toRefs()](https://vuejs.org/api/reactivity-utilities.html#torefs)
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: Use computed() Instead of watchEffect() for Derived State
|
||||
impact: MEDIUM
|
||||
impactDescription: Using watchEffect to mutate refs creates unnecessary indirection - computed() is declarative and cached
|
||||
type: efficiency
|
||||
tags: [vue3, reactivity, computed, watchEffect, best-practice, performance]
|
||||
---
|
||||
|
||||
# Use computed() Instead of watchEffect() for Derived State
|
||||
|
||||
**Impact: MEDIUM** - When you need state that derives from other reactive state, always prefer `computed()` over using `watchEffect()` to mutate a ref. Computed properties are declarative, automatically cached, and clearly express the dependency relationship.
|
||||
|
||||
Using `watchEffect()` to mutate a ref works but creates unnecessary indirection: you're imperatively updating state based on dependencies rather than declaring the relationship. This makes the code harder to understand and prevents Vue from optimizing.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use `computed()` when the result is a pure transformation of reactive state
|
||||
- [ ] Use `watchEffect()` only for side effects (DOM manipulation, logging, API calls)
|
||||
- [ ] Never use watchEffect to mutate a ref just to derive a value
|
||||
- [ ] Remember computed values are cached and only re-compute when dependencies change
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { ref, watchEffect } from 'vue'
|
||||
|
||||
const A0 = ref(1)
|
||||
const A1 = ref(2)
|
||||
const A2 = ref() // Unnecessary ref
|
||||
|
||||
// WRONG: Using watchEffect to derive state
|
||||
watchEffect(() => {
|
||||
A2.value = A0.value + A1.value
|
||||
})
|
||||
|
||||
// Problems:
|
||||
// 1. A2 is writable when it shouldn't be
|
||||
// 2. Imperative instead of declarative
|
||||
// 3. No caching optimization
|
||||
// 4. Harder to trace dependencies
|
||||
```
|
||||
|
||||
```javascript
|
||||
// WRONG: Complex derived state with watchEffect
|
||||
const items = ref([{ price: 10 }, { price: 20 }])
|
||||
const total = ref(0)
|
||||
|
||||
watchEffect(() => {
|
||||
total.value = items.value.reduce((sum, item) => sum + item.price, 0)
|
||||
})
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const A0 = ref(1)
|
||||
const A1 = ref(2)
|
||||
|
||||
// CORRECT: Declarative derived state
|
||||
const A2 = computed(() => A0.value + A1.value)
|
||||
|
||||
// Benefits:
|
||||
// 1. A2 is read-only by default
|
||||
// 2. Clearly declares the dependency relationship
|
||||
// 3. Cached - only recalculates when A0 or A1 changes
|
||||
// 4. Easy to understand data flow
|
||||
```
|
||||
|
||||
```javascript
|
||||
// CORRECT: Complex derived state with computed
|
||||
const items = ref([{ price: 10 }, { price: 20 }])
|
||||
|
||||
const total = computed(() => {
|
||||
return items.value.reduce((sum, item) => sum + item.price, 0)
|
||||
})
|
||||
|
||||
// Multiple derived values
|
||||
const itemCount = computed(() => items.value.length)
|
||||
const averagePrice = computed(() =>
|
||||
items.value.length ? total.value / itemCount.value : 0
|
||||
)
|
||||
```
|
||||
|
||||
**When watchEffect IS appropriate:**
|
||||
```javascript
|
||||
import { ref, watchEffect } from 'vue'
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
// CORRECT: watchEffect for side effects
|
||||
watchEffect(() => {
|
||||
// Logging
|
||||
console.log(`Search query changed: ${searchQuery.value}`)
|
||||
|
||||
// DOM manipulation
|
||||
document.title = `Search: ${searchQuery.value}`
|
||||
})
|
||||
|
||||
// CORRECT: watchEffect for async side effects
|
||||
watchEffect(async () => {
|
||||
if (searchQuery.value) {
|
||||
// API call (side effect, not derived state)
|
||||
await api.logSearch(searchQuery.value)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Summary of when to use each:**
|
||||
```javascript
|
||||
// Use computed() when:
|
||||
// - You're deriving a value from reactive state
|
||||
// - The result is pure (no side effects)
|
||||
// - You want caching
|
||||
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
|
||||
|
||||
// Use watchEffect() when:
|
||||
// - You need to perform side effects
|
||||
// - You're interacting with external systems
|
||||
// - You need to run async operations
|
||||
watchEffect(() => {
|
||||
document.title = fullName.value // Side effect
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Reactivity in Depth](https://vuejs.org/guide/extras/reactivity-in-depth.html)
|
||||
- [Vue.js Computed Properties](https://vuejs.org/guide/essentials/computed.html)
|
||||
- [Vue.js Watchers](https://vuejs.org/guide/essentials/watchers.html)
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
title: Use shallowRef Pattern for External State Libraries
|
||||
impact: MEDIUM
|
||||
impactDescription: External state systems (Immer, XState, Redux) should use shallowRef to avoid double-wrapping in proxies
|
||||
type: efficiency
|
||||
tags: [vue3, reactivity, shallowRef, external-state, immer, xstate, integration]
|
||||
---
|
||||
|
||||
# Use shallowRef Pattern for External State Libraries
|
||||
|
||||
**Impact: MEDIUM** - When integrating Vue with external state management libraries (Immer, XState, Redux, MobX), use `shallowRef()` to hold the external state. This prevents Vue from deep-wrapping the external state in proxies, which can cause conflicts and performance issues.
|
||||
|
||||
The pattern: hold external state in a `shallowRef`, and replace `.value` entirely when the external system updates. This gives Vue reactivity while letting the external library manage state internals.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use `shallowRef()` to hold external library state
|
||||
- [ ] Replace `.value` entirely when external state changes (don't mutate)
|
||||
- [ ] Integrate update functions that produce new state objects
|
||||
- [ ] Consider this pattern for immutable data structures
|
||||
|
||||
**Integrating with Immer:**
|
||||
```javascript
|
||||
import { produce } from 'immer'
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
export function useImmer(baseState) {
|
||||
const state = shallowRef(baseState)
|
||||
|
||||
function update(updater) {
|
||||
// Immer produces a new immutable state
|
||||
// Replace shallowRef value entirely to trigger reactivity
|
||||
state.value = produce(state.value, updater)
|
||||
}
|
||||
|
||||
return [state, update]
|
||||
}
|
||||
|
||||
// Usage
|
||||
const [todos, updateTodos] = useImmer([
|
||||
{ id: 1, text: 'Learn Vue', done: false }
|
||||
])
|
||||
|
||||
// Update with Immer's mutable API (produces immutable result)
|
||||
updateTodos(draft => {
|
||||
draft[0].done = true
|
||||
draft.push({ id: 2, text: 'Use Immer', done: false })
|
||||
})
|
||||
```
|
||||
|
||||
**Integrating with XState:**
|
||||
```javascript
|
||||
import { createMachine, interpret } from 'xstate'
|
||||
import { shallowRef, onUnmounted } from 'vue'
|
||||
|
||||
export function useMachine(options) {
|
||||
const machine = createMachine(options)
|
||||
const state = shallowRef(machine.initialState)
|
||||
|
||||
const service = interpret(machine)
|
||||
.onTransition((newState) => {
|
||||
// Replace state entirely on each transition
|
||||
state.value = newState
|
||||
})
|
||||
.start()
|
||||
|
||||
const send = (event) => service.send(event)
|
||||
|
||||
onUnmounted(() => service.stop())
|
||||
|
||||
return { state, send }
|
||||
}
|
||||
|
||||
// Usage
|
||||
const { state, send } = useMachine({
|
||||
id: 'toggle',
|
||||
initial: 'inactive',
|
||||
states: {
|
||||
inactive: { on: { TOGGLE: 'active' } },
|
||||
active: { on: { TOGGLE: 'inactive' } }
|
||||
}
|
||||
})
|
||||
|
||||
// In template: state.value.matches('active')
|
||||
send('TOGGLE')
|
||||
```
|
||||
|
||||
**Integrating with Redux-style stores:**
|
||||
```javascript
|
||||
import { shallowRef, readonly } from 'vue'
|
||||
|
||||
export function createStore(reducer, initialState) {
|
||||
const state = shallowRef(initialState)
|
||||
|
||||
function dispatch(action) {
|
||||
state.value = reducer(state.value, action)
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return state.value
|
||||
}
|
||||
|
||||
return {
|
||||
state: readonly(state), // Prevent direct mutations
|
||||
dispatch,
|
||||
getState
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const store = createStore(
|
||||
(state, action) => {
|
||||
switch (action.type) {
|
||||
case 'INCREMENT':
|
||||
return { ...state, count: state.count + 1 }
|
||||
default:
|
||||
return state
|
||||
}
|
||||
},
|
||||
{ count: 0 }
|
||||
)
|
||||
|
||||
store.dispatch({ type: 'INCREMENT' })
|
||||
console.log(store.state.value.count) // 1
|
||||
```
|
||||
|
||||
**Why NOT use ref() for external state:**
|
||||
```javascript
|
||||
import { ref } from 'vue'
|
||||
import { produce } from 'immer'
|
||||
|
||||
// WRONG: ref() deep-wraps the state
|
||||
const state = ref({ nested: { value: 1 } })
|
||||
|
||||
// This creates double-proxying:
|
||||
// 1. Vue wraps state in Proxy
|
||||
// 2. External library may also wrap/expect raw objects
|
||||
// 3. Causes identity issues and potential conflicts
|
||||
|
||||
// WRONG: Mutating ref with Immer
|
||||
state.value = produce(state.value, draft => {
|
||||
draft.nested.value = 2
|
||||
})
|
||||
// Vue's deep proxy on state.value may interfere with Immer's proxies
|
||||
```
|
||||
|
||||
**Correct pattern with shallowRef:**
|
||||
```javascript
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
// CORRECT: shallowRef only tracks .value replacement
|
||||
const state = shallowRef({ nested: { value: 1 } })
|
||||
|
||||
// External library works with raw objects inside
|
||||
state.value = produce(state.value, draft => {
|
||||
draft.nested.value = 2
|
||||
})
|
||||
// Clean separation: Vue tracks outer ref, library manages inner state
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Reactivity in Depth - Integration with External State](https://vuejs.org/guide/extras/reactivity-in-depth.html#integration-with-external-state-systems)
|
||||
- [Vue.js shallowRef API](https://vuejs.org/api/reactivity-advanced.html#shallowref)
|
||||
- [Immer Documentation](https://immerjs.github.io/immer/)
|
||||
- [XState Documentation](https://xstate.js.org/)
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
title: Use markRaw() for Objects That Should Never Be Reactive
|
||||
impact: MEDIUM
|
||||
impactDescription: Library instances, DOM nodes, and complex objects cause overhead and bugs when wrapped in Vue proxies
|
||||
type: efficiency
|
||||
tags: [vue3, reactivity, markRaw, performance, external-libraries, dom]
|
||||
---
|
||||
|
||||
# Use markRaw() for Objects That Should Never Be Reactive
|
||||
|
||||
**Impact: MEDIUM** - Vue's `markRaw()` tells the reactivity system to never wrap an object in a Proxy. Use it for library instances, DOM nodes, class instances with internal state, and complex objects that Vue shouldn't track. This prevents unnecessary proxy overhead and avoids subtle bugs from double-proxying.
|
||||
|
||||
Without `markRaw()`, placing these objects inside reactive state causes Vue to wrap them in Proxies, which can break library internals, cause identity issues, and waste memory on objects that don't need change tracking.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use `markRaw()` for third-party library instances (maps, charts, editors)
|
||||
- [ ] Use `markRaw()` for DOM elements stored in reactive state
|
||||
- [ ] Use `markRaw()` for class instances that manage their own state
|
||||
- [ ] Use `markRaw()` for large static data that will never change
|
||||
- [ ] Remember: markRaw only affects the root level - nested objects may still be proxied
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { reactive, ref } from 'vue'
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import * as monaco from 'monaco-editor'
|
||||
|
||||
// WRONG: Library instances wrapped in Proxy
|
||||
const state = reactive({
|
||||
map: new mapboxgl.Map({ container: 'map' }), // Proxied!
|
||||
editor: monaco.editor.create(element, {}), // Proxied!
|
||||
})
|
||||
|
||||
// Problems:
|
||||
// 1. Library's internal this references may break
|
||||
// 2. Unnecessary memory overhead
|
||||
// 3. Methods may not work correctly through proxy
|
||||
// 4. Performance degradation
|
||||
|
||||
// WRONG: DOM elements in reactive state
|
||||
const elements = reactive({
|
||||
container: document.getElementById('app'), // Proxied DOM node!
|
||||
})
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { reactive, markRaw, shallowRef } from 'vue'
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import * as monaco from 'monaco-editor'
|
||||
|
||||
// CORRECT: Mark library instances as raw
|
||||
const state = reactive({
|
||||
map: markRaw(new mapboxgl.Map({ container: 'map' })),
|
||||
editor: markRaw(monaco.editor.create(element, {})),
|
||||
})
|
||||
|
||||
// CORRECT: Or use shallowRef for mutable references
|
||||
const map = shallowRef(null)
|
||||
onMounted(() => {
|
||||
map.value = markRaw(new mapboxgl.Map({ container: 'map' }))
|
||||
})
|
||||
|
||||
// CORRECT: Large static data
|
||||
const geoJsonData = markRaw(await fetch('/huge-geojson.json').then(r => r.json()))
|
||||
const state = reactive({
|
||||
mapData: geoJsonData // Won't be proxied
|
||||
})
|
||||
```
|
||||
|
||||
**Class instances with internal state:**
|
||||
```javascript
|
||||
import { markRaw, reactive } from 'vue'
|
||||
|
||||
class WebSocketManager {
|
||||
constructor(url) {
|
||||
this.socket = new WebSocket(url)
|
||||
this.listeners = new Map()
|
||||
}
|
||||
|
||||
on(event, callback) {
|
||||
this.listeners.set(event, callback)
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT: Mark class instance
|
||||
const wsManager = markRaw(new WebSocketManager('ws://example.com'))
|
||||
|
||||
const state = reactive({
|
||||
connection: wsManager // Won't be proxied
|
||||
})
|
||||
|
||||
// Can still use the instance normally
|
||||
state.connection.on('message', handleMessage)
|
||||
```
|
||||
|
||||
**Gotcha: markRaw only affects root level:**
|
||||
```javascript
|
||||
import { markRaw, reactive } from 'vue'
|
||||
|
||||
const rawObject = markRaw({
|
||||
nested: { value: 1 } // This nested object is NOT marked raw
|
||||
})
|
||||
|
||||
const state = reactive({
|
||||
data: rawObject
|
||||
})
|
||||
|
||||
// rawObject itself won't be proxied
|
||||
// But if you access nested objects through a reactive parent:
|
||||
const container = reactive({ raw: rawObject })
|
||||
// container.raw.nested might still be proxied in some cases
|
||||
|
||||
// SAFER: Use shallowRef for the container
|
||||
import { shallowRef } from 'vue'
|
||||
const safeContainer = shallowRef(rawObject)
|
||||
```
|
||||
|
||||
**Combining with shallowRef for best results:**
|
||||
```javascript
|
||||
import { shallowRef, markRaw, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
// Pattern: shallowRef + markRaw for external library instances
|
||||
export function useMapbox(containerId) {
|
||||
const map = shallowRef(null)
|
||||
|
||||
onMounted(() => {
|
||||
const instance = new mapboxgl.Map({
|
||||
container: containerId,
|
||||
style: 'mapbox://styles/mapbox/streets-v11'
|
||||
})
|
||||
|
||||
// Mark raw to prevent any proxy wrapping
|
||||
map.value = markRaw(instance)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
map.value?.remove()
|
||||
})
|
||||
|
||||
return { map }
|
||||
}
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js markRaw() API](https://vuejs.org/api/reactivity-advanced.html#markraw)
|
||||
- [Vue.js Reducing Reactivity Overhead](https://vuejs.org/guide/best-practices/performance.html#reduce-reactivity-overhead-for-large-immutable-structures)
|
||||
- [Vue.js Reactivity in Depth](https://vuejs.org/guide/extras/reactivity-in-depth.html)
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
title: Avoid Comparing Reactive Objects with === Operator
|
||||
impact: HIGH
|
||||
impactDescription: Reactive proxies have different identity than original objects - comparison bugs are silent and hard to debug
|
||||
type: gotcha
|
||||
tags: [vue3, reactivity, proxy, comparison, debugging, identity]
|
||||
---
|
||||
|
||||
# Avoid Comparing Reactive Objects with === Operator
|
||||
|
||||
**Impact: HIGH** - Vue's `reactive()` returns a Proxy wrapper that has a different identity than the original object. Using `===` to compare reactive objects can lead to silent bugs where comparisons unexpectedly return `false`.
|
||||
|
||||
When you wrap an object with `reactive()`, the returned proxy is NOT equal to the original object. Additionally, accessing nested objects from a reactive object returns new proxy wrappers each time, which can cause identity comparison issues.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Never compare reactive object instances with `===` directly
|
||||
- [ ] Use unique identifiers (ID, UUID) for object comparison instead
|
||||
- [ ] Use `toRaw()` on both sides when identity comparison is absolutely necessary
|
||||
- [ ] Consider using primitive identifiers from database records for comparison
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const original = { id: 1, name: 'Item' }
|
||||
const state = reactive(original)
|
||||
|
||||
// BUG: Always returns false - proxy !== original
|
||||
if (state === original) {
|
||||
console.log('Same object') // Never executes
|
||||
}
|
||||
|
||||
// BUG: Nested object comparison fails
|
||||
const items = reactive([{ id: 1 }, { id: 2 }])
|
||||
const item = items[0]
|
||||
|
||||
// Later...
|
||||
if (items[0] === item) {
|
||||
// May or may not work depending on Vue's proxy caching
|
||||
}
|
||||
|
||||
// BUG: Comparing items from different reactive sources
|
||||
const listA = reactive([{ id: 1 }])
|
||||
const listB = reactive([{ id: 1 }])
|
||||
if (listA[0] === listB[0]) {
|
||||
// Never true, even though they represent the same data
|
||||
}
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { reactive, toRaw } from 'vue'
|
||||
|
||||
const original = { id: 1, name: 'Item' }
|
||||
const state = reactive(original)
|
||||
|
||||
// CORRECT: Use toRaw() for identity comparison
|
||||
if (toRaw(state) === original) {
|
||||
console.log('Same underlying object') // Works!
|
||||
}
|
||||
|
||||
// BEST: Use unique identifiers instead
|
||||
const items = reactive([
|
||||
{ id: 'uuid-1', name: 'Item 1' },
|
||||
{ id: 'uuid-2', name: 'Item 2' }
|
||||
])
|
||||
|
||||
function findItem(targetId) {
|
||||
return items.find(item => item.id === targetId)
|
||||
}
|
||||
|
||||
function isSelected(item) {
|
||||
return selectedId.value === item.id // Compare IDs, not objects
|
||||
}
|
||||
|
||||
// CORRECT: For Set/Map operations, use primitive keys
|
||||
const selectedIds = reactive(new Set())
|
||||
selectedIds.add(item.id) // Use ID, not object
|
||||
selectedIds.has(item.id) // Check by ID
|
||||
```
|
||||
|
||||
```javascript
|
||||
// When you must compare objects, use toRaw on both sides
|
||||
import { toRaw, isReactive } from 'vue'
|
||||
|
||||
function areEqual(a, b) {
|
||||
const rawA = isReactive(a) ? toRaw(a) : a
|
||||
const rawB = isReactive(b) ? toRaw(b) : b
|
||||
return rawA === rawB
|
||||
}
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Reactivity in Depth](https://vuejs.org/guide/extras/reactivity-in-depth.html)
|
||||
- [Vue.js toRaw() API](https://vuejs.org/api/reactivity-advanced.html#toraw)
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
title: Understand Reactive Updates are Batched Per Event Loop Tick
|
||||
impact: MEDIUM
|
||||
impactDescription: Multiple synchronous reactive changes are batched - watchers only see the final value, not intermediate states
|
||||
type: gotcha
|
||||
tags: [vue3, reactivity, batching, event-loop, watchers, nextTick]
|
||||
---
|
||||
|
||||
# Understand Reactive Updates are Batched Per Event Loop Tick
|
||||
|
||||
**Impact: MEDIUM** - Vue batches multiple reactive state changes that happen synchronously within the same event loop tick. Watchers and computed properties only see the final state, not intermediate values. This is an optimization, but it can be surprising if you expect watchers to fire for each individual change.
|
||||
|
||||
Understanding this behavior is essential for debugging scenarios where you expect to observe every state transition.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Understand watchers fire once per tick with final value, not for each mutation
|
||||
- [ ] Use `nextTick()` if you need to ensure DOM updates between state changes
|
||||
- [ ] Use `flush: 'sync'` on watchers only if you absolutely need immediate execution
|
||||
- [ ] For intermediate value tracking, consider logging or explicit state snapshots
|
||||
|
||||
**Example of batching behavior:**
|
||||
```javascript
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const count = ref(0)
|
||||
|
||||
watch(count, (newValue) => {
|
||||
console.log('Count changed to:', newValue)
|
||||
})
|
||||
|
||||
// Multiple synchronous changes in the same tick
|
||||
function multipleUpdates() {
|
||||
count.value = 1
|
||||
count.value = 2
|
||||
count.value = 3
|
||||
count.value = 4
|
||||
}
|
||||
|
||||
multipleUpdates()
|
||||
// Console output: "Count changed to: 4"
|
||||
// NOT: 1, 2, 3, 4 - only the final value is observed!
|
||||
```
|
||||
|
||||
**The console logs you WON'T see:**
|
||||
```javascript
|
||||
const items = reactive([])
|
||||
|
||||
watch(items, (newItems) => {
|
||||
console.log('Items count:', newItems.length)
|
||||
})
|
||||
|
||||
// Batch of changes
|
||||
items.push('a') // length: 1
|
||||
items.push('b') // length: 2
|
||||
items.push('c') // length: 3
|
||||
|
||||
// Output: "Items count: 3"
|
||||
// You won't see 1, 2, 3 logged separately
|
||||
```
|
||||
|
||||
**Using flush: 'sync' for immediate watching (use with caution):**
|
||||
```javascript
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const count = ref(0)
|
||||
|
||||
// Sync watcher fires immediately on each change
|
||||
watch(count, (newValue) => {
|
||||
console.log('Immediate:', newValue)
|
||||
}, { flush: 'sync' })
|
||||
|
||||
count.value = 1 // Logs: "Immediate: 1"
|
||||
count.value = 2 // Logs: "Immediate: 2"
|
||||
count.value = 3 // Logs: "Immediate: 3"
|
||||
|
||||
// WARNING: flush: 'sync' can cause performance issues
|
||||
// and creates less predictable behavior. Avoid if possible.
|
||||
```
|
||||
|
||||
**Using nextTick to separate batches:**
|
||||
```javascript
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
|
||||
const count = ref(0)
|
||||
|
||||
watch(count, (newValue) => {
|
||||
console.log('Count:', newValue)
|
||||
})
|
||||
|
||||
async function separatedUpdates() {
|
||||
count.value = 1
|
||||
await nextTick() // Force flush
|
||||
// Output: "Count: 1"
|
||||
|
||||
count.value = 2
|
||||
await nextTick()
|
||||
// Output: "Count: 2"
|
||||
|
||||
count.value = 3
|
||||
// Output: "Count: 3"
|
||||
}
|
||||
```
|
||||
|
||||
**Practical example - form validation:**
|
||||
```javascript
|
||||
const formData = reactive({
|
||||
email: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const validationErrors = ref([])
|
||||
|
||||
// This watcher only fires once, with final form state
|
||||
watch(formData, (data) => {
|
||||
// Runs once after all fields are updated
|
||||
validateForm(data)
|
||||
}, { deep: true })
|
||||
|
||||
// When user submits, you might update multiple fields
|
||||
function populateFromSavedData(saved) {
|
||||
formData.email = saved.email
|
||||
formData.password = saved.password
|
||||
// Validation runs once with both fields set
|
||||
}
|
||||
```
|
||||
|
||||
**When batching helps performance:**
|
||||
```javascript
|
||||
// Without batching, this would trigger 1000 watcher/render cycles
|
||||
const list = reactive([])
|
||||
|
||||
function addManyItems() {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
list.push(i)
|
||||
}
|
||||
}
|
||||
// With batching: renders once with all 1000 items
|
||||
// Without batching: would render 1000 times!
|
||||
```
|
||||
|
||||
**Debugging intermediate states:**
|
||||
```javascript
|
||||
// If you need to observe every change for debugging:
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const count = ref(0)
|
||||
|
||||
// Method 1: Sync watcher (not recommended for production)
|
||||
watch(count, (val) => console.log('DEBUG:', val), { flush: 'sync' })
|
||||
|
||||
// Method 2: Track history manually
|
||||
const history = []
|
||||
const originalSet = count.value
|
||||
Object.defineProperty(count, 'value', {
|
||||
set(val) {
|
||||
history.push(val)
|
||||
originalSet.call(this, val)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Reactivity in Depth](https://vuejs.org/guide/extras/reactivity-in-depth.html)
|
||||
- [Vue.js Watchers - Callback Flush Timing](https://vuejs.org/guide/essentials/watchers.html#callback-flush-timing)
|
||||
- [Vue.js nextTick()](https://vuejs.org/api/general.html#nexttick)
|
||||
61
skills/vue-best-practices/reference/ref-value-access.md
Normal file
61
skills/vue-best-practices/reference/ref-value-access.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: Always Use .value When Accessing ref() in JavaScript
|
||||
impact: HIGH
|
||||
impactDescription: Forgetting .value causes silent failures and bugs in reactive state updates
|
||||
type: capability
|
||||
tags: [vue3, reactivity, ref, composition-api]
|
||||
---
|
||||
|
||||
# Always Use .value When Accessing ref() in JavaScript
|
||||
|
||||
**Impact: HIGH** - Forgetting `.value` causes silent failures where state updates don't trigger reactivity, leading to hard-to-debug issues.
|
||||
|
||||
When using `ref()` in Vue 3's Composition API, the reactive value is wrapped in an object and must be accessed via `.value` in JavaScript code. However, in templates, Vue automatically unwraps refs so `.value` is not needed there. This inconsistency is a common source of bugs.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Always use `.value` when reading or writing ref values in `<script>` or `.js`/`.ts` files
|
||||
- [ ] Never use `.value` in `<template>` - Vue unwraps refs automatically there
|
||||
- [ ] When passing refs to functions, decide whether to pass the ref object or `.value`
|
||||
- [ ] Use IDE/TypeScript to catch missing `.value` errors early
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { ref } from 'vue'
|
||||
|
||||
const count = ref(0)
|
||||
|
||||
// These do NOT work as expected
|
||||
count++ // Tries to increment the ref object, not the value
|
||||
count = 5 // Reassigns the variable, loses reactivity
|
||||
console.log(count) // Logs "[object Object]", not the number
|
||||
|
||||
const items = ref([1, 2, 3])
|
||||
items.push(4) // Error: push is not a function
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { ref } from 'vue'
|
||||
|
||||
const count = ref(0)
|
||||
|
||||
// Always use .value in JavaScript
|
||||
count.value++ // Correctly increments to 1
|
||||
count.value = 5 // Correctly sets value to 5
|
||||
console.log(count.value) // Logs "5"
|
||||
|
||||
const items = ref([1, 2, 3])
|
||||
items.value.push(4) // Correctly adds 4 to the array
|
||||
```
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- In templates, NO .value needed - Vue unwraps automatically -->
|
||||
<p>{{ count }}</p>
|
||||
<button @click="count++">Increment</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Reactivity Fundamentals - ref()](https://vuejs.org/guide/essentials/reactivity-fundamentals.html#ref)
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: Refs in Arrays and Collections Require .value
|
||||
impact: MEDIUM
|
||||
impactDescription: Refs inside reactive arrays, Maps, or Sets are NOT auto-unwrapped like in reactive objects
|
||||
type: capability
|
||||
tags: [vue3, reactivity, ref, arrays, collections, unwrapping]
|
||||
---
|
||||
|
||||
# Refs in Arrays and Collections Require .value
|
||||
|
||||
**Impact: MEDIUM** - Unlike when a ref is a property of a reactive object, refs inside reactive arrays, Maps, and Sets are NOT automatically unwrapped. You must access them with `.value`, and forgetting this leads to silent bugs.
|
||||
|
||||
Vue only auto-unwraps refs when they are properties of reactive objects. When refs are elements in arrays or values in Maps/Sets, they remain as ref objects and require explicit `.value` access.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Always use `.value` when accessing refs stored in reactive arrays
|
||||
- [ ] Always use `.value` when accessing refs stored in reactive Maps or Sets
|
||||
- [ ] Consider storing plain values instead of refs in collections to avoid confusion
|
||||
- [ ] Be aware of this when iterating over arrays containing refs
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
const books = reactive([ref('Vue 3 Guide')])
|
||||
const counts = reactive(new Map([['clicks', ref(0)]]))
|
||||
|
||||
// WRONG: Refs in arrays are NOT unwrapped
|
||||
console.log(books[0]) // Ref object, not 'Vue 3 Guide'
|
||||
books[0] = 'New Title' // Replaces the ref, doesn't update it!
|
||||
|
||||
// WRONG: Refs in Maps are NOT unwrapped
|
||||
console.log(counts.get('clicks')) // Ref object, not 0
|
||||
counts.get('clicks')++ // Does nothing useful
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
const books = reactive([ref('Vue 3 Guide')])
|
||||
const counts = reactive(new Map([['clicks', ref(0)]]))
|
||||
|
||||
// CORRECT: Use .value for refs in arrays
|
||||
console.log(books[0].value) // 'Vue 3 Guide'
|
||||
books[0].value = 'New Title' // Updates the ref's value
|
||||
|
||||
// CORRECT: Use .value for refs in Maps
|
||||
console.log(counts.get('clicks').value) // 0
|
||||
counts.get('clicks').value++ // Increments to 1
|
||||
```
|
||||
|
||||
```javascript
|
||||
// ALTERNATIVE: Just store plain values in collections (simpler)
|
||||
const books = reactive(['Vue 3 Guide', 'Vuex Handbook'])
|
||||
const counts = reactive(new Map([['clicks', 0]]))
|
||||
|
||||
// No .value needed - but changes to individual items aren't independently reactive
|
||||
console.log(books[0]) // 'Vue 3 Guide'
|
||||
console.log(counts.get('clicks')) // 0
|
||||
|
||||
// Mutations still trigger reactivity through the reactive wrapper
|
||||
books[0] = 'New Title' // Works
|
||||
counts.set('clicks', counts.get('clicks') + 1) // Works
|
||||
```
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- In templates, refs in arrays also need special handling -->
|
||||
<div v-for="(book, index) in books" :key="index">
|
||||
<!-- If book is a ref, you'd need: -->
|
||||
{{ book.value }}
|
||||
|
||||
<!-- Or use computed to unwrap them first -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Reactivity Fundamentals - Caveat in Arrays and Collections](https://vuejs.org/guide/essentials/reactivity-fundamentals.html#caveat-in-arrays-and-collections)
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
title: Do Not Rely on Internal VNode Properties
|
||||
impact: MEDIUM
|
||||
impactDescription: Using undocumented vnode properties causes code to break on Vue updates
|
||||
type: gotcha
|
||||
tags: [vue3, render-function, vnode, internal-api]
|
||||
---
|
||||
|
||||
# Do Not Rely on Internal VNode Properties
|
||||
|
||||
**Impact: MEDIUM** - The `VNode` interface contains many internal properties used by Vue's rendering system. Relying on any properties other than the documented public ones will cause your code to break when Vue's internal implementation changes.
|
||||
|
||||
Only use the documented vnode properties: `type`, `props`, `children`, and `key`. All other properties are internal implementation details that may change without notice between Vue versions.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Only access documented vnode properties: `type`, `props`, `children`, `key`
|
||||
- [ ] Never access properties like `el`, `component`, `shapeFlag`, `patchFlag`, etc.
|
||||
- [ ] If you need DOM element access, use template refs instead
|
||||
- [ ] Treat vnodes as opaque data structures for rendering, not inspection
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
export default {
|
||||
setup(props, { slots }) {
|
||||
return () => {
|
||||
const slotContent = slots.default?.()
|
||||
|
||||
// WRONG: Accessing internal properties
|
||||
if (slotContent?.[0]?.el) {
|
||||
// el is an internal property
|
||||
console.log(slotContent[0].el.tagName)
|
||||
}
|
||||
|
||||
// WRONG: Using shapeFlag internal property
|
||||
if (slotContent?.[0]?.shapeFlag & 1) {
|
||||
// This is internal implementation
|
||||
}
|
||||
|
||||
return h('div', slotContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// WRONG: Inspecting component instance via vnode
|
||||
const vnode = h(MyComponent)
|
||||
console.log(vnode.component) // Internal property
|
||||
console.log(vnode.appContext) // Internal property
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
export default {
|
||||
setup(props, { slots }) {
|
||||
return () => {
|
||||
const slotContent = slots.default?.()
|
||||
|
||||
// CORRECT: Only use documented properties
|
||||
if (slotContent?.[0]) {
|
||||
const vnode = slotContent[0]
|
||||
console.log(vnode.type) // Safe: element type or component
|
||||
console.log(vnode.props) // Safe: props object
|
||||
console.log(vnode.children) // Safe: children
|
||||
console.log(vnode.key) // Safe: key prop
|
||||
}
|
||||
|
||||
return h('div', slotContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
import { h, ref, onMounted } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
// CORRECT: Use template refs for DOM access
|
||||
const divRef = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
// Safe way to access DOM element
|
||||
console.log(divRef.value.tagName)
|
||||
})
|
||||
|
||||
return () => h('div', { ref: divRef }, 'Content')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Documented VNode Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `type` | `string \| Component` | Element tag name or component definition |
|
||||
| `props` | `object \| null` | Props passed to the vnode |
|
||||
| `children` | `any` | Child vnodes, text, or slots |
|
||||
| `key` | `string \| number \| null` | Key for list rendering |
|
||||
|
||||
## Safe VNode Inspection Patterns
|
||||
|
||||
```javascript
|
||||
import { h, isVNode } from 'vue'
|
||||
|
||||
export default {
|
||||
setup(props, { slots }) {
|
||||
return () => {
|
||||
const children = slots.default?.() || []
|
||||
|
||||
// Safe: Check if something is a vnode
|
||||
children.forEach(child => {
|
||||
if (isVNode(child)) {
|
||||
// Safe: Check vnode type
|
||||
if (typeof child.type === 'string') {
|
||||
console.log('Element:', child.type)
|
||||
} else if (typeof child.type === 'object') {
|
||||
console.log('Component:', child.type.name)
|
||||
}
|
||||
|
||||
// Safe: Read props
|
||||
if (child.props?.class) {
|
||||
console.log('Has class:', child.props.class)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return h('div', children)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Vue's internal vnode structure may change for:
|
||||
- Performance optimizations
|
||||
- New feature implementations
|
||||
- Bug fixes
|
||||
- Tree-shaking improvements
|
||||
|
||||
Code relying on internal properties will break silently or throw errors when upgrading Vue versions. The documented properties are part of Vue's public API and are guaranteed to remain stable.
|
||||
|
||||
## Reference
|
||||
- [Vue.js Render Function APIs](https://vuejs.org/api/render-function.html)
|
||||
- [Vue.js Render Functions - The Virtual DOM](https://vuejs.org/guide/extras/render-function.html#the-virtual-dom)
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
title: Use withDirectives for Custom Directives in Render Functions
|
||||
impact: LOW
|
||||
impactDescription: Custom directives require special handling with withDirectives helper
|
||||
type: best-practice
|
||||
tags: [vue3, render-function, directives, custom-directive]
|
||||
---
|
||||
|
||||
# Use withDirectives for Custom Directives in Render Functions
|
||||
|
||||
**Impact: LOW** - To apply custom directives to vnodes in render functions, use the `withDirectives` helper. Attempting to apply directives through props or other means will not work.
|
||||
|
||||
The `withDirectives` function wraps a vnode and applies directives with their values, arguments, and modifiers.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Import `withDirectives` from 'vue' when using custom directives
|
||||
- [ ] Import or define the directive object
|
||||
- [ ] Pass directive as array: `[directive, value, argument, modifiers]`
|
||||
- [ ] Multiple directives can be applied at once
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
const vFocus = { mounted: (el) => el.focus() }
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
return () => {
|
||||
// WRONG: Directives don't work as props
|
||||
return h('input', {
|
||||
'v-focus': true // This doesn't work
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { h, withDirectives } from 'vue'
|
||||
|
||||
// Custom directive
|
||||
const vFocus = {
|
||||
mounted: (el) => el.focus()
|
||||
}
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
return () => {
|
||||
// CORRECT: Use withDirectives
|
||||
return withDirectives(
|
||||
h('input'),
|
||||
[[vFocus]] // Array of directive tuples
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Directive with Value, Argument, and Modifiers
|
||||
|
||||
The directive tuple format: `[directive, value, argument, modifiers]`
|
||||
|
||||
```javascript
|
||||
import { h, withDirectives, resolveDirective } from 'vue'
|
||||
|
||||
// Directive definition
|
||||
// Usage in template: <div v-pin:top.animate="200">
|
||||
const vPin = {
|
||||
mounted(el, binding) {
|
||||
console.log(binding.value) // 200
|
||||
console.log(binding.arg) // 'top'
|
||||
console.log(binding.modifiers) // { animate: true }
|
||||
|
||||
el.style.position = 'fixed'
|
||||
el.style[binding.arg] = binding.value + 'px'
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
return () => withDirectives(
|
||||
h('div', 'Pinned content'),
|
||||
[
|
||||
// [directive, value, argument, modifiers]
|
||||
[vPin, 200, 'top', { animate: true }]
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Multiple Directives
|
||||
|
||||
```javascript
|
||||
import { h, withDirectives } from 'vue'
|
||||
|
||||
const vFocus = { mounted: (el) => el.focus() }
|
||||
const vTooltip = {
|
||||
mounted(el, { value }) {
|
||||
el.title = value
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
return () => withDirectives(
|
||||
h('input', { placeholder: 'Enter text' }),
|
||||
[
|
||||
[vFocus],
|
||||
[vTooltip, 'This is a tooltip']
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Using Registered Directives
|
||||
|
||||
For globally or locally registered directives, use `resolveDirective`:
|
||||
|
||||
```javascript
|
||||
import { h, withDirectives, resolveDirective } from 'vue'
|
||||
|
||||
// Assuming v-focus and v-tooltip are registered globally
|
||||
export default {
|
||||
setup() {
|
||||
return () => {
|
||||
const focus = resolveDirective('focus')
|
||||
const tooltip = resolveDirective('tooltip')
|
||||
|
||||
return withDirectives(
|
||||
h('input'),
|
||||
[
|
||||
[focus],
|
||||
[tooltip, 'Helpful tip']
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Practical Example: Click Outside Directive
|
||||
|
||||
```javascript
|
||||
import { h, withDirectives, ref } from 'vue'
|
||||
|
||||
const vClickOutside = {
|
||||
mounted(el, binding) {
|
||||
el._clickOutside = (event) => {
|
||||
if (!el.contains(event.target)) {
|
||||
binding.value(event)
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', el._clickOutside)
|
||||
},
|
||||
unmounted(el) {
|
||||
document.removeEventListener('click', el._clickOutside)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const isOpen = ref(true)
|
||||
const closeDropdown = () => { isOpen.value = false }
|
||||
|
||||
return () => isOpen.value
|
||||
? withDirectives(
|
||||
h('div', { class: 'dropdown' }, 'Dropdown content'),
|
||||
[[vClickOutside, closeDropdown]]
|
||||
)
|
||||
: null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## JSX with Directives
|
||||
|
||||
Directives in JSX also require `withDirectives`:
|
||||
|
||||
```jsx
|
||||
import { withDirectives } from 'vue'
|
||||
|
||||
const vFocus = { mounted: (el) => el.focus() }
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
return () => withDirectives(
|
||||
<input placeholder="Search..." />,
|
||||
[[vFocus]]
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Render Functions - Custom Directives](https://vuejs.org/guide/extras/render-function.html#custom-directives)
|
||||
- [Vue.js Custom Directives](https://vuejs.org/guide/reusability/custom-directives.html)
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
title: Use withModifiers for Event Modifiers in Render Functions
|
||||
impact: MEDIUM
|
||||
impactDescription: Manual modifier implementation is error-prone; use withModifiers helper
|
||||
type: best-practice
|
||||
tags: [vue3, render-function, events, modifiers]
|
||||
---
|
||||
|
||||
# Use withModifiers for Event Modifiers in Render Functions
|
||||
|
||||
**Impact: MEDIUM** - When using render functions, event modifiers like `.stop`, `.prevent`, or `.self` require special handling. Use Vue's `withModifiers` helper function instead of manually implementing modifier logic, which is error-prone.
|
||||
|
||||
In templates, you can use modifiers like `@click.stop.prevent`. In render functions, you must either use camelCase concatenation for simple modifiers or the `withModifiers` helper for more complex ones.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use camelCase concatenation for capture, once, and passive modifiers
|
||||
- [ ] Use `withModifiers()` helper for stop, prevent, self, and key modifiers
|
||||
- [ ] Import `withModifiers` from 'vue' when needed
|
||||
- [ ] Combine multiple modifiers by passing them as an array
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const handleClick = (e) => {
|
||||
// WRONG: Manual modifier implementation is error-prone
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
// ... actual handler logic
|
||||
}
|
||||
|
||||
return () => h('button', { onClick: handleClick }, 'Click')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// WRONG: Forgetting to handle modifier in complex scenarios
|
||||
const handleDivClick = (e) => {
|
||||
// Intended: only trigger when clicking div itself, not children
|
||||
// This manual check is easy to get wrong
|
||||
if (e.target !== e.currentTarget) return
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { h, withModifiers } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const handleClick = () => {
|
||||
console.log('clicked!')
|
||||
}
|
||||
|
||||
return () => h('button',
|
||||
{
|
||||
// CORRECT: Use withModifiers for stop and prevent
|
||||
onClick: withModifiers(handleClick, ['stop', 'prevent'])
|
||||
},
|
||||
'Click'
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CamelCase Modifiers (No Helper Needed)
|
||||
|
||||
For `capture`, `once`, and `passive` modifiers, use camelCase concatenation:
|
||||
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const handler = () => console.log('event!')
|
||||
|
||||
return () => h('div', {
|
||||
// @click.capture -> onClickCapture
|
||||
onClickCapture: handler,
|
||||
|
||||
// @keyup.once -> onKeyupOnce
|
||||
onKeyupOnce: handler,
|
||||
|
||||
// @scroll.passive -> onScrollPassive
|
||||
onScrollPassive: handler,
|
||||
|
||||
// @mouseover.once.capture -> onMouseoverOnceCapture
|
||||
onMouseoverOnceCapture: handler
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## withModifiers Examples
|
||||
|
||||
```javascript
|
||||
import { h, withModifiers } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const handleClick = () => console.log('clicked')
|
||||
const handleSubmit = () => console.log('submitted')
|
||||
|
||||
return () => h('div', [
|
||||
// .stop modifier
|
||||
h('button', {
|
||||
onClick: withModifiers(handleClick, ['stop'])
|
||||
}, 'Stop Propagation'),
|
||||
|
||||
// .prevent modifier
|
||||
h('form', {
|
||||
onSubmit: withModifiers(handleSubmit, ['prevent'])
|
||||
}, [
|
||||
h('button', { type: 'submit' }, 'Submit')
|
||||
]),
|
||||
|
||||
// .self modifier - only trigger if event.target is the element itself
|
||||
h('div', {
|
||||
onClick: withModifiers(handleClick, ['self']),
|
||||
style: { padding: '20px', background: '#eee' }
|
||||
}, [
|
||||
h('button', 'Click me (won\'t trigger parent)')
|
||||
]),
|
||||
|
||||
// Multiple modifiers
|
||||
h('a', {
|
||||
href: '/path',
|
||||
onClick: withModifiers(handleClick, ['stop', 'prevent'])
|
||||
}, 'Link')
|
||||
])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Modifiers
|
||||
|
||||
For keyboard events, use `withKeys` for key modifiers:
|
||||
|
||||
```javascript
|
||||
import { h, withKeys } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const handleEnter = () => console.log('Enter pressed')
|
||||
const handleEscape = () => console.log('Escape pressed')
|
||||
|
||||
return () => h('input', {
|
||||
// @keyup.enter
|
||||
onKeyup: withKeys(handleEnter, ['enter']),
|
||||
|
||||
// Multiple keys
|
||||
onKeydown: withKeys(handleEscape, ['escape', 'esc'])
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## JSX Equivalent
|
||||
|
||||
```jsx
|
||||
import { withModifiers, withKeys } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const handleClick = () => console.log('clicked')
|
||||
|
||||
return () => (
|
||||
<div>
|
||||
<button onClick={withModifiers(handleClick, ['stop'])}>
|
||||
Stop
|
||||
</button>
|
||||
|
||||
<div onClick={withModifiers(handleClick, ['self'])}>
|
||||
<span>Child content</span>
|
||||
</div>
|
||||
|
||||
<input onKeyup={withKeys(() => {}, ['enter'])} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Render Functions - Event Modifiers](https://vuejs.org/guide/extras/render-function.html#event-modifiers)
|
||||
@@ -0,0 +1,171 @@
|
||||
---
|
||||
title: Use Functional Components for Stateless Presentational UI
|
||||
impact: LOW
|
||||
impactDescription: Functional components reduce overhead for simple stateless components
|
||||
type: best-practice
|
||||
tags: [vue3, render-function, functional-component, performance]
|
||||
---
|
||||
|
||||
# Use Functional Components for Stateless Presentational UI
|
||||
|
||||
**Impact: LOW** - Functional components are plain functions that return vnodes without component instance overhead. They're ideal for simple presentational components that don't need internal state, lifecycle hooks, or reactivity.
|
||||
|
||||
Functional components in Vue 3 are defined as plain functions that receive `props` and a `context` object (containing `slots`, `emit`, and `attrs`). They have slightly less overhead than stateful components.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Consider functional components for stateless UI like icons, badges, or simple layouts
|
||||
- [ ] Define `props` and `emits` as static properties on the function
|
||||
- [ ] Access slots, emit, and attrs from the context parameter
|
||||
- [ ] Use `inheritAttrs: false` when manually spreading attrs
|
||||
|
||||
**Basic Functional Component:**
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
// Simple functional component
|
||||
function MyButton(props, { slots }) {
|
||||
return h('button', { class: 'btn' }, slots.default?.())
|
||||
}
|
||||
|
||||
// With props definition
|
||||
MyButton.props = ['disabled', 'variant']
|
||||
|
||||
export default MyButton
|
||||
```
|
||||
|
||||
**With TypeScript:**
|
||||
```typescript
|
||||
import { h } from 'vue'
|
||||
import type { FunctionalComponent } from 'vue'
|
||||
|
||||
interface Props {
|
||||
message: string
|
||||
type?: 'info' | 'warning' | 'error'
|
||||
}
|
||||
|
||||
const Alert: FunctionalComponent<Props> = (props, { slots }) => {
|
||||
return h('div', {
|
||||
class: ['alert', `alert-${props.type || 'info'}`]
|
||||
}, [
|
||||
h('span', props.message),
|
||||
slots.default?.()
|
||||
])
|
||||
}
|
||||
|
||||
Alert.props = {
|
||||
message: { type: String, required: true },
|
||||
type: String
|
||||
}
|
||||
|
||||
export default Alert
|
||||
```
|
||||
|
||||
**With Emits:**
|
||||
```typescript
|
||||
import { h } from 'vue'
|
||||
import type { FunctionalComponent } from 'vue'
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update', value: string): void
|
||||
(e: 'clear'): void
|
||||
}
|
||||
|
||||
const SearchInput: FunctionalComponent<Props, Emits> = (props, { emit }) => {
|
||||
return h('div', { class: 'search-input' }, [
|
||||
h('input', {
|
||||
value: props.value,
|
||||
onInput: (e: Event) => emit('update', (e.target as HTMLInputElement).value)
|
||||
}),
|
||||
h('button', { onClick: () => emit('clear') }, 'Clear')
|
||||
])
|
||||
}
|
||||
|
||||
SearchInput.props = ['value']
|
||||
SearchInput.emits = ['update', 'clear']
|
||||
|
||||
export default SearchInput
|
||||
```
|
||||
|
||||
**Disabling Attribute Inheritance:**
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
function CustomInput(props, { attrs }) {
|
||||
return h('div', { class: 'input-wrapper' }, [
|
||||
h('input', { ...attrs, class: 'custom-input' })
|
||||
])
|
||||
}
|
||||
|
||||
CustomInput.inheritAttrs = false
|
||||
|
||||
export default CustomInput
|
||||
```
|
||||
|
||||
## When to Use Functional Components
|
||||
|
||||
**Good candidates:**
|
||||
- Icons and badges
|
||||
- Simple wrapper/layout components
|
||||
- Pure presentational components
|
||||
- Components that just format or display data
|
||||
|
||||
```javascript
|
||||
// Icon component - good use case
|
||||
function Icon(props) {
|
||||
return h('svg', {
|
||||
class: `icon icon-${props.name}`,
|
||||
width: props.size || 24,
|
||||
height: props.size || 24
|
||||
}, [
|
||||
h('use', { href: `#icon-${props.name}` })
|
||||
])
|
||||
}
|
||||
Icon.props = ['name', 'size']
|
||||
|
||||
// Badge component - good use case
|
||||
function Badge(props, { slots }) {
|
||||
return h('span', {
|
||||
class: ['badge', `badge-${props.variant || 'default'}`]
|
||||
}, slots.default?.())
|
||||
}
|
||||
Badge.props = ['variant']
|
||||
```
|
||||
|
||||
**Not recommended for:**
|
||||
- Components needing reactive state (use `ref`, `reactive`)
|
||||
- Components needing lifecycle hooks
|
||||
- Components with complex logic
|
||||
- Components that need to use composables
|
||||
|
||||
## Functional vs Stateful Comparison
|
||||
|
||||
```javascript
|
||||
// Functional - no reactivity, no lifecycle
|
||||
function StaticLabel(props) {
|
||||
return h('span', { class: 'label' }, props.text)
|
||||
}
|
||||
StaticLabel.props = ['text']
|
||||
|
||||
// Stateful - when you need state or lifecycle
|
||||
export default {
|
||||
setup(props) {
|
||||
const count = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
console.log('Mounted!')
|
||||
})
|
||||
|
||||
return () => h('button', {
|
||||
onClick: () => count.value++
|
||||
}, count.value)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Functional Components](https://vuejs.org/guide/extras/render-function.html#functional-components)
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
title: Always Include key Props When Rendering Lists in Render Functions
|
||||
impact: HIGH
|
||||
impactDescription: Missing keys cause inefficient re-renders and state bugs in dynamic lists
|
||||
type: best-practice
|
||||
tags: [vue3, render-function, v-for, performance, key]
|
||||
---
|
||||
|
||||
# Always Include key Props When Rendering Lists in Render Functions
|
||||
|
||||
**Impact: HIGH** - Omitting `key` props when rendering lists with `h()` or JSX causes Vue to use an inefficient "in-place patch" strategy, leading to performance issues and state bugs when list items have internal state or are reordered.
|
||||
|
||||
When rendering lists in render functions using `.map()`, always include a unique `key` prop on each item. This is the render function equivalent of using `:key` with `v-for` in templates.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Always provide a `key` prop when mapping over arrays in render functions
|
||||
- [ ] Use unique, stable identifiers (like `id`) for keys, not array indices
|
||||
- [ ] Ensure keys are primitive values (strings or numbers)
|
||||
- [ ] Never use the same key for different items in the same list
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const items = ref([
|
||||
{ id: 1, name: 'Apple' },
|
||||
{ id: 2, name: 'Banana' }
|
||||
])
|
||||
|
||||
return () => h('ul',
|
||||
// WRONG: No keys - causes inefficient patching
|
||||
items.value.map(item =>
|
||||
h('li', item.name)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```jsx
|
||||
// WRONG: Using array index as key when list can be reordered
|
||||
export default {
|
||||
setup() {
|
||||
const todos = ref([...])
|
||||
|
||||
return () => (
|
||||
<ul>
|
||||
{todos.value.map((todo, index) => (
|
||||
<TodoItem
|
||||
key={index} // Bad: index changes when list reorders
|
||||
todo={todo}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const items = ref([
|
||||
{ id: 1, name: 'Apple' },
|
||||
{ id: 2, name: 'Banana' }
|
||||
])
|
||||
|
||||
return () => h('ul',
|
||||
// CORRECT: Unique id as key
|
||||
items.value.map(item =>
|
||||
h('li', { key: item.id }, item.name)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```jsx
|
||||
// CORRECT: Using stable unique identifier as key
|
||||
export default {
|
||||
setup() {
|
||||
const todos = ref([
|
||||
{ id: 'a1', text: 'Learn Vue' },
|
||||
{ id: 'b2', text: 'Build app' }
|
||||
])
|
||||
|
||||
return () => (
|
||||
<ul>
|
||||
{todos.value.map(todo => (
|
||||
<TodoItem
|
||||
key={todo.id} // Good: stable unique identifier
|
||||
todo={todo}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const users = ref([])
|
||||
|
||||
return () => h('div', [
|
||||
h('h2', 'User List'),
|
||||
h('ul',
|
||||
users.value.map(user =>
|
||||
h('li', { key: user.email }, [ // email is unique
|
||||
h('span', user.name),
|
||||
h('span', ` (${user.email})`)
|
||||
])
|
||||
)
|
||||
)
|
||||
])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## When Index Keys Are Acceptable
|
||||
|
||||
Using array indices as keys is acceptable ONLY when:
|
||||
1. The list is static and will never be reordered
|
||||
2. Items will never be inserted or removed from the middle
|
||||
3. Items have no internal component state
|
||||
|
||||
```javascript
|
||||
// Index is OK here: static list that never changes
|
||||
const staticLabels = ['Name', 'Email', 'Phone']
|
||||
|
||||
return () => h('tr',
|
||||
staticLabels.map((label, index) =>
|
||||
h('th', { key: index }, label)
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
## Why Keys Matter
|
||||
|
||||
Without keys, Vue uses an "in-place patch" strategy:
|
||||
1. It reuses DOM elements in place
|
||||
2. Updates element content to match new data
|
||||
3. This breaks when components have internal state or transitions
|
||||
|
||||
With proper keys:
|
||||
1. Vue tracks each item's identity
|
||||
2. Elements are moved, created, or destroyed correctly
|
||||
3. Component state is preserved during reorders
|
||||
|
||||
## Reference
|
||||
- [Vue.js Render Functions - v-for](https://vuejs.org/guide/extras/render-function.html#v-for)
|
||||
@@ -0,0 +1,221 @@
|
||||
---
|
||||
title: Implement v-model Correctly in Render Functions
|
||||
impact: MEDIUM
|
||||
impactDescription: Incorrect v-model implementation breaks two-way binding with components
|
||||
type: best-practice
|
||||
tags: [vue3, render-function, v-model, two-way-binding]
|
||||
---
|
||||
|
||||
# Implement v-model Correctly in Render Functions
|
||||
|
||||
**Impact: MEDIUM** - When using `v-model` on components in render functions, you must manually handle both the `modelValue` prop and the `update:modelValue` event. Missing either part breaks two-way binding.
|
||||
|
||||
In templates, `v-model` is syntactic sugar that expands to a `modelValue` prop and an `update:modelValue` event listener. In render functions, you must implement this expansion manually.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Pass `modelValue` as a prop for the bound value
|
||||
- [ ] Pass `onUpdate:modelValue` handler to receive updates
|
||||
- [ ] For named v-models, use the corresponding prop and event names
|
||||
- [ ] Use emit in child components to trigger updates
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
import CustomInput from './CustomInput.vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const text = ref('')
|
||||
|
||||
return () => h(CustomInput, {
|
||||
// WRONG: Missing the update handler
|
||||
modelValue: text.value
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
import CustomInput from './CustomInput.vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const text = ref('')
|
||||
|
||||
return () => h(CustomInput, {
|
||||
// WRONG: Wrong event name format
|
||||
modelValue: text.value,
|
||||
onModelValueUpdate: (val) => text.value = val
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { h, ref } from 'vue'
|
||||
import CustomInput from './CustomInput.vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const text = ref('')
|
||||
|
||||
return () => h(CustomInput, {
|
||||
// CORRECT: modelValue prop + onUpdate:modelValue handler
|
||||
modelValue: text.value,
|
||||
'onUpdate:modelValue': (value) => text.value = value
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Native Input Elements
|
||||
|
||||
For native inputs, use `value` and `onInput`:
|
||||
|
||||
```javascript
|
||||
import { h, ref } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const text = ref('')
|
||||
|
||||
return () => h('input', {
|
||||
value: text.value,
|
||||
onInput: (e) => text.value = e.target.value
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Named v-models (Multiple v-models)
|
||||
|
||||
```javascript
|
||||
import { h, ref } from 'vue'
|
||||
import UserForm from './UserForm.vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const firstName = ref('')
|
||||
const lastName = ref('')
|
||||
|
||||
return () => h(UserForm, {
|
||||
// v-model:firstName
|
||||
firstName: firstName.value,
|
||||
'onUpdate:firstName': (val) => firstName.value = val,
|
||||
|
||||
// v-model:lastName
|
||||
lastName: lastName.value,
|
||||
'onUpdate:lastName': (val) => lastName.value = val
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## v-model with Modifiers
|
||||
|
||||
Handle modifiers in the child component:
|
||||
|
||||
```javascript
|
||||
import { h, ref } from 'vue'
|
||||
import CustomInput from './CustomInput.vue'
|
||||
|
||||
// Parent - passing modifier
|
||||
export default {
|
||||
setup() {
|
||||
const text = ref('')
|
||||
|
||||
return () => h(CustomInput, {
|
||||
modelValue: text.value,
|
||||
'onUpdate:modelValue': (val) => text.value = val,
|
||||
modelModifiers: { trim: true, capitalize: true }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Child - handling modifier
|
||||
export default {
|
||||
props: ['modelValue', 'modelModifiers'],
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const handleInput = (e) => {
|
||||
let value = e.target.value
|
||||
|
||||
if (props.modelModifiers?.trim) {
|
||||
value = value.trim()
|
||||
}
|
||||
if (props.modelModifiers?.capitalize) {
|
||||
value = value.charAt(0).toUpperCase() + value.slice(1)
|
||||
}
|
||||
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
return () => h('input', {
|
||||
value: props.modelValue,
|
||||
onInput: handleInput
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## JSX Syntax
|
||||
|
||||
```jsx
|
||||
import { ref } from 'vue'
|
||||
import CustomInput from './CustomInput.vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const text = ref('')
|
||||
const count = ref(0)
|
||||
|
||||
return () => (
|
||||
<div>
|
||||
{/* v-model on custom component */}
|
||||
<CustomInput
|
||||
modelValue={text.value}
|
||||
onUpdate:modelValue={(val) => text.value = val}
|
||||
/>
|
||||
|
||||
{/* v-model on native input */}
|
||||
<input
|
||||
value={text.value}
|
||||
onInput={(e) => text.value = e.target.value}
|
||||
/>
|
||||
|
||||
{/* Named v-model */}
|
||||
<Counter
|
||||
count={count.value}
|
||||
onUpdate:count={(val) => count.value = val}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Creating v-model-compatible Components
|
||||
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
modelValue: String
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('input', {
|
||||
value: props.modelValue,
|
||||
onInput: (e) => emit('update:modelValue', e.target.value)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Render Functions - v-model](https://vuejs.org/guide/extras/render-function.html#v-model)
|
||||
- [Vue.js Component v-model](https://vuejs.org/guide/components/v-model.html)
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
title: VNodes Must Be Unique in Render Functions
|
||||
impact: HIGH
|
||||
impactDescription: Reusing vnode references causes rendering bugs and unexpected behavior
|
||||
type: gotcha
|
||||
tags: [vue3, render-function, vnode, composition-api]
|
||||
---
|
||||
|
||||
# VNodes Must Be Unique in Render Functions
|
||||
|
||||
**Impact: HIGH** - Reusing the same vnode reference multiple times in a render function tree causes rendering bugs, where only one instance appears or updates behave unexpectedly.
|
||||
|
||||
Every vnode in a component's render tree must be unique. You cannot use the same vnode object multiple times. If you need to render the same element multiple times, create each vnode separately using a factory function or by calling `h()` in a loop.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Never store a vnode in a variable and use it multiple times in the same tree
|
||||
- [ ] Use a factory function or `.map()` to create multiple similar vnodes
|
||||
- [ ] Each `h()` call creates a new vnode, so call it for each instance needed
|
||||
- [ ] Be especially careful when extracting vnode creation into helper functions
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
return () => {
|
||||
// WRONG: Same vnode reference used twice
|
||||
const p = h('p', 'Hello')
|
||||
return h('div', [p, p]) // Bug! Duplicate vnode reference
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
return () => {
|
||||
// WRONG: Reusing vnode in different parts of tree
|
||||
const icon = h('span', { class: 'icon' }, '★')
|
||||
return h('div', [
|
||||
h('button', [icon, ' Save']), // Uses icon
|
||||
h('button', [icon, ' Delete']) // Reuses same icon - Bug!
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
return () => {
|
||||
// CORRECT: Create new vnode for each use
|
||||
return h('div', [
|
||||
h('p', 'Hello'),
|
||||
h('p', 'Hello')
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
return () => {
|
||||
// CORRECT: Factory function creates new vnode each time
|
||||
const createIcon = () => h('span', { class: 'icon' }, '★')
|
||||
return h('div', [
|
||||
h('button', [createIcon(), ' Save']),
|
||||
h('button', [createIcon(), ' Delete'])
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
return () => {
|
||||
// CORRECT: Using map to create multiple vnodes
|
||||
return h('div',
|
||||
Array.from({ length: 20 }).map(() => h('p', 'Hello'))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
import { h } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const items = ['Apple', 'Banana', 'Cherry']
|
||||
|
||||
return () => h('ul',
|
||||
// CORRECT: Each iteration creates a new vnode
|
||||
items.map((item, index) =>
|
||||
h('li', { key: index }, item)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Why VNodes Must Be Unique
|
||||
|
||||
VNodes are lightweight JavaScript objects that Vue's virtual DOM algorithm uses for diffing and patching. When the same vnode reference appears multiple times:
|
||||
- Vue cannot differentiate between the instances
|
||||
- The diffing algorithm produces incorrect results
|
||||
- Only one instance may render, or updates may corrupt the DOM
|
||||
|
||||
Each vnode maintains its own identity and position in the tree, which is essential for:
|
||||
- Correct DOM patching during updates
|
||||
- Proper lifecycle hook execution
|
||||
- Accurate key-based reconciliation in lists
|
||||
|
||||
## Reference
|
||||
- [Vue.js Render Functions - Vnodes Must Be Unique](https://vuejs.org/guide/extras/render-function.html#vnodes-must-be-unique)
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
title: Avoid Excessive Re-renders from Misused Watchers
|
||||
impact: HIGH
|
||||
impactDescription: Using watch instead of computed, or deep watchers unnecessarily, triggers excessive component re-renders
|
||||
type: gotcha
|
||||
tags: [vue3, rendering, performance, watch, computed, reactivity, re-renders]
|
||||
---
|
||||
|
||||
# Avoid Excessive Re-renders from Misused Watchers
|
||||
|
||||
**Impact: HIGH** - Improper use of watchers is a common cause of performance issues. Deep watchers track every nested property change, and using watch when computed would suffice creates unnecessary reactive updates that trigger re-renders.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use `computed` for derived values, not `watch` + manual state updates
|
||||
- [ ] Avoid `deep: true` on large objects unless absolutely necessary
|
||||
- [ ] When deep watching is needed, watch specific nested paths instead
|
||||
- [ ] Never trigger state changes inside watch that cause the watch to re-fire
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const user = ref({ name: 'John', settings: { theme: 'dark', notifications: true } })
|
||||
const displayName = ref('')
|
||||
|
||||
// BAD: Using watch to compute a derived value
|
||||
// This triggers an extra reactive update cycle
|
||||
watch(() => user.value.name, (name) => {
|
||||
displayName.value = `User: ${name}`
|
||||
}, { immediate: true })
|
||||
|
||||
// BAD: Deep watcher on a large object
|
||||
// Fires on ANY nested change, even unrelated ones
|
||||
const items = ref([/* 1000 items with nested properties */])
|
||||
watch(items, (newItems) => {
|
||||
console.log('Items changed') // Fires on every tiny change
|
||||
}, { deep: true })
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const user = ref({ name: 'John', settings: { theme: 'dark', notifications: true } })
|
||||
|
||||
// GOOD: Use computed for derived values
|
||||
// No extra reactive updates, automatically cached
|
||||
const displayName = computed(() => `User: ${user.value.name}`)
|
||||
|
||||
// GOOD: Watch specific paths, not the entire object
|
||||
const items = ref([/* 1000 items */])
|
||||
watch(
|
||||
() => items.value.length, // Only watch the length
|
||||
(newLength) => {
|
||||
console.log(`Items count: ${newLength}`)
|
||||
}
|
||||
)
|
||||
|
||||
// GOOD: Watch specific nested property
|
||||
watch(
|
||||
() => user.value.settings.theme,
|
||||
(newTheme) => {
|
||||
applyTheme(newTheme) // Side effect - appropriate for watch
|
||||
}
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
## When to Use Watch vs Computed
|
||||
|
||||
| Use Case | Use This |
|
||||
|----------|----------|
|
||||
| Derive a value from state | `computed` |
|
||||
| Format/transform data for display | `computed` |
|
||||
| Perform side effects (API calls, DOM updates) | `watch` |
|
||||
| React to route changes | `watch` |
|
||||
| Sync with external systems | `watch` |
|
||||
|
||||
## Infinite Loop from Watch
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const count = ref(0)
|
||||
|
||||
// DANGER: Infinite loop!
|
||||
watch(count, (newVal) => {
|
||||
count.value = newVal + 1 // Modifies watched source -> triggers watch again
|
||||
})
|
||||
|
||||
// CORRECT: Use computed or avoid self-modification
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Efficient Deep Watching
|
||||
|
||||
When you must watch complex objects:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, watch, toRaw } from 'vue'
|
||||
|
||||
const formData = ref({
|
||||
personal: { name: '', email: '' },
|
||||
address: { street: '', city: '' },
|
||||
preferences: { /* many properties */ }
|
||||
})
|
||||
|
||||
// BAD: Watches everything, including preferences changes
|
||||
watch(formData, () => {
|
||||
saveForm()
|
||||
}, { deep: true })
|
||||
|
||||
// GOOD: Watch only the sections you care about
|
||||
watch(
|
||||
() => formData.value.personal,
|
||||
() => savePersonalSection(),
|
||||
{ deep: true } // Deep only on this small subtree
|
||||
)
|
||||
|
||||
// GOOD: Watch serialized version for change detection
|
||||
watch(
|
||||
() => JSON.stringify(formData.value),
|
||||
() => {
|
||||
markFormDirty()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Array Mutation Gotcha
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const items = ref([1, 2, 3])
|
||||
|
||||
// This watch won't trigger on sort/reverse without deep!
|
||||
watch(items, () => {
|
||||
console.log('Items changed')
|
||||
})
|
||||
|
||||
items.value.sort() // Watch doesn't fire - array reference unchanged
|
||||
|
||||
// Solution 1: Use deep (performance cost)
|
||||
watch(items, callback, { deep: true })
|
||||
|
||||
// Solution 2: Replace array instead of mutating
|
||||
items.value = [...items.value].sort()
|
||||
</script>
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Watchers](https://vuejs.org/guide/essentials/watchers.html)
|
||||
- [Vue.js Computed Properties](https://vuejs.org/guide/essentials/computed.html)
|
||||
- [Vue.js Performance - Reactivity](https://vuejs.org/guide/best-practices/performance.html#reduce-reactivity-overhead-for-large-immutable-structures)
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: Prefer Templates Over Render Functions for Compiler Optimizations
|
||||
impact: MEDIUM
|
||||
impactDescription: Render functions bypass Vue's compile-time optimizations, resulting in more work during updates
|
||||
type: best-practice
|
||||
tags: [vue3, rendering, performance, templates, render-functions, optimization]
|
||||
---
|
||||
|
||||
# Prefer Templates Over Render Functions for Compiler Optimizations
|
||||
|
||||
**Impact: MEDIUM** - Vue's template compiler applies automatic optimizations (static hoisting, patch flags, tree flattening) that are impossible with hand-written render functions. Using render functions means every vnode must be traversed and diffed on every update, even if nothing changed.
|
||||
|
||||
Only use render functions when you genuinely need full JavaScript power for highly dynamic component logic.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Default to templates for all components
|
||||
- [ ] Only use render functions for highly dynamic rendering logic (e.g., dynamic tag selection)
|
||||
- [ ] If using render functions, understand you're opting out of compile-time optimizations
|
||||
- [ ] Consider JSX if you need render function power with better readability
|
||||
|
||||
## Why Templates Are Faster
|
||||
|
||||
Vue's compiler analyzes templates and generates optimized render functions with:
|
||||
|
||||
1. **Static Hoisting**: Static elements are created once and reused across re-renders
|
||||
2. **Patch Flags**: Dynamic bindings are marked so Vue only checks what can change
|
||||
3. **Tree Flattening**: Only dynamic nodes are traversed during reconciliation
|
||||
|
||||
**Incorrect:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { h, ref } from 'vue'
|
||||
|
||||
const count = ref(0)
|
||||
const items = ref(['a', 'b', 'c'])
|
||||
|
||||
// BAD: Hand-written render function - no compile-time optimizations
|
||||
// Every render: all vnodes created anew, all nodes diffed
|
||||
const render = () => h('div', [
|
||||
h('header', { class: 'static-header' }, 'My App'), // Static but recreated every render
|
||||
h('p', `Count: ${count.value}`),
|
||||
h('ul', items.value.map(item => h('li', { key: item }, item)))
|
||||
])
|
||||
</script>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<!-- GOOD: Static header hoisted, never recreated or diffed -->
|
||||
<header class="static-header">My App</header>
|
||||
|
||||
<!-- GOOD: Patch flag marks only text as dynamic -->
|
||||
<p>Count: {{ count }}</p>
|
||||
|
||||
<!-- GOOD: Only list items are tracked as dynamic -->
|
||||
<ul>
|
||||
<li v-for="item in items" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const count = ref(0)
|
||||
const items = ref(['a', 'b', 'c'])
|
||||
</script>
|
||||
```
|
||||
|
||||
## When Render Functions Are Appropriate
|
||||
|
||||
Use render functions when:
|
||||
- Rendering logic is highly dynamic (dynamic component types based on data)
|
||||
- Building reusable library components with complex slot forwarding
|
||||
- Need programmatic control that templates cannot express
|
||||
|
||||
```js
|
||||
// ACCEPTABLE: Dynamic component type based on data
|
||||
function render() {
|
||||
return h(props.level > 2 ? 'h3' : 'h' + props.level, slots.default?.())
|
||||
}
|
||||
|
||||
// ACCEPTABLE: Complex library component with dynamic children manipulation
|
||||
function render() {
|
||||
const children = slots.default?.() || []
|
||||
return h('div', children.filter(shouldRender).map(wrapChild))
|
||||
}
|
||||
```
|
||||
|
||||
## Compiler Optimizations You Lose
|
||||
|
||||
| Optimization | With Templates | With Render Functions |
|
||||
|--------------|---------------|----------------------|
|
||||
| Static Hoisting | Automatic | Manual (you must do it) |
|
||||
| Patch Flags | Automatic | None |
|
||||
| Tree Flattening | Automatic | None |
|
||||
| SSR Hydration Optimization | Full | Partial |
|
||||
| Block-level fast paths | Yes | No |
|
||||
|
||||
## If You Must Use Render Functions
|
||||
|
||||
```js
|
||||
import { h, ref } from 'vue'
|
||||
|
||||
// Manually hoist static vnodes outside component
|
||||
const staticHeader = h('header', { class: 'static' }, 'Title')
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const count = ref(0)
|
||||
|
||||
return () => h('div', [
|
||||
staticHeader, // Reused, not recreated
|
||||
h('p', `Count: ${count.value}`)
|
||||
])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Rendering Mechanism - Templates vs Render Functions](https://vuejs.org/guide/extras/rendering-mechanism.html#templates-vs-render-functions)
|
||||
- [Vue.js Render Functions & JSX](https://vuejs.org/guide/extras/render-function.html)
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: Import h Globally in Vue 3 Render Functions
|
||||
impact: HIGH
|
||||
impactDescription: Vue 3 requires explicit h import; using Vue 2 patterns causes runtime errors
|
||||
type: gotcha
|
||||
tags: [vue3, render-function, migration, h, vnode, breaking-change]
|
||||
---
|
||||
|
||||
# Import h Globally in Vue 3 Render Functions
|
||||
|
||||
**Impact: HIGH** - In Vue 2, the `h` function (createElement) was passed as an argument to render functions. In Vue 3, `h` must be explicitly imported from 'vue'. Using Vue 2 patterns causes runtime errors.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Import `h` from 'vue' at the top of files using render functions
|
||||
- [ ] Remove the `h` parameter from render function signatures
|
||||
- [ ] Update all render functions when migrating from Vue 2
|
||||
|
||||
**Incorrect (Vue 2 pattern - broken in Vue 3):**
|
||||
```js
|
||||
// WRONG: Vue 2 pattern - h is not passed as argument in Vue 3
|
||||
export default {
|
||||
render(h) { // h is undefined in Vue 3!
|
||||
return h('div', [
|
||||
h('span', 'Hello')
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// WRONG: Using createElement alias from Vue 2
|
||||
export default {
|
||||
render(createElement) { // Also undefined
|
||||
return createElement('div', 'Hello')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (Vue 3 pattern):**
|
||||
```js
|
||||
// CORRECT: Import h from vue
|
||||
import { h } from 'vue'
|
||||
|
||||
export default {
|
||||
render() {
|
||||
return h('div', [
|
||||
h('span', 'Hello')
|
||||
])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## With Composition API
|
||||
|
||||
```js
|
||||
import { h, ref } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const count = ref(0)
|
||||
|
||||
// Return a render function from setup
|
||||
return () => h('div', [
|
||||
h('button', { onClick: () => count.value++ }, `Count: ${count.value}`)
|
||||
])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## With script setup (Not Recommended)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { h, ref } from 'vue'
|
||||
|
||||
const count = ref(0)
|
||||
|
||||
// Cannot return render function from script setup
|
||||
// Must use a separate render option or template
|
||||
</script>
|
||||
|
||||
<!-- script setup typically uses templates, not render functions -->
|
||||
<template>
|
||||
<div>
|
||||
<button @click="count++">Count: {{ count }}</button>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
If you need render functions with `<script setup>`, use the `render` option:
|
||||
|
||||
```vue
|
||||
<script>
|
||||
import { h, ref } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const count = ref(0)
|
||||
return () => h('button', { onClick: () => count.value++ }, count.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Component Resolution Change
|
||||
|
||||
In Vue 3, you must also explicitly resolve components:
|
||||
|
||||
**Incorrect:**
|
||||
```js
|
||||
// Vue 2: Could use string names for registered components
|
||||
render(h) {
|
||||
return h('my-component', { props: { value: 1 } })
|
||||
}
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```js
|
||||
import { h, resolveComponent } from 'vue'
|
||||
|
||||
export default {
|
||||
render() {
|
||||
// Must resolve component by name
|
||||
const MyComponent = resolveComponent('my-component')
|
||||
return h(MyComponent, { value: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
// Or import the component directly (preferred)
|
||||
import { h } from 'vue'
|
||||
import MyComponent from './MyComponent.vue'
|
||||
|
||||
export default {
|
||||
render() {
|
||||
return h(MyComponent, { value: 1 })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Why This Changed
|
||||
|
||||
Vue 3's `h` is globally importable to:
|
||||
1. Enable tree-shaking (unused features can be removed)
|
||||
2. Support better TypeScript inference
|
||||
3. Allow use outside of component context
|
||||
|
||||
## Reference
|
||||
- [Vue 3 Migration Guide - Render Function API](https://v3-migration.vuejs.org/breaking-changes/render-function-api.html)
|
||||
- [Vue.js Render Functions & JSX](https://vuejs.org/guide/extras/render-function.html)
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: Return Render Function from setup(), Not Direct VNodes
|
||||
impact: HIGH
|
||||
impactDescription: Returning a vnode directly from setup makes it static; returning a function enables reactive updates
|
||||
type: gotcha
|
||||
tags: [vue3, render-function, composition-api, setup, reactivity]
|
||||
---
|
||||
|
||||
# Return Render Function from setup(), Not Direct VNodes
|
||||
|
||||
**Impact: HIGH** - When using render functions with the Composition API, you must return a function that returns vnodes, not the vnodes directly. Returning vnodes directly creates a static render that never updates when reactive state changes.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Always return an arrow function from setup() when using render functions
|
||||
- [ ] Never return h() calls directly from setup()
|
||||
- [ ] Ensure reactive values are accessed inside the returned function
|
||||
|
||||
**Incorrect:**
|
||||
```js
|
||||
import { h, ref } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const count = ref(0)
|
||||
const increment = () => count.value++
|
||||
|
||||
// WRONG: Returns a static vnode, created once
|
||||
// Clicking the button updates count.value, but the DOM never changes!
|
||||
return h('div', [
|
||||
h('p', `Count: ${count.value}`), // Captures count.value at setup time (0)
|
||||
h('button', { onClick: increment }, 'Increment')
|
||||
])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```js
|
||||
import { h, ref } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const count = ref(0)
|
||||
const increment = () => count.value++
|
||||
|
||||
// CORRECT: Returns a render function
|
||||
// Vue calls this function on every reactive update
|
||||
return () => h('div', [
|
||||
h('p', `Count: ${count.value}`), // Re-evaluated each render
|
||||
h('button', { onClick: increment }, 'Increment')
|
||||
])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Why This Happens
|
||||
|
||||
```js
|
||||
// What Vue does internally:
|
||||
|
||||
// WRONG approach - setup runs once:
|
||||
const result = setup()
|
||||
// result is a vnode { type: 'div', children: [...] }
|
||||
// Vue renders this once, then has no way to re-render
|
||||
|
||||
// CORRECT approach - setup returns a function:
|
||||
const renderFn = setup()
|
||||
// renderFn is () => h('div', ...)
|
||||
// Vue calls renderFn() on mount
|
||||
// Vue calls renderFn() again whenever dependencies change
|
||||
```
|
||||
|
||||
## Common Mistake: Mixing Template and Render Function
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { h, ref } from 'vue'
|
||||
|
||||
const count = ref(0)
|
||||
|
||||
// WRONG: Can't use render functions in script setup with templates
|
||||
// This h() call does nothing
|
||||
const node = h('div', count.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Template is used, render function is ignored -->
|
||||
<div>{{ count }}</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
If you need a render function with Composition API, don't use `<script setup>`:
|
||||
|
||||
```vue
|
||||
<script>
|
||||
import { h, ref } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const count = ref(0)
|
||||
return () => h('div', count.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<!-- No template - render function is used -->
|
||||
```
|
||||
|
||||
## Exposing Values While Using Render Functions
|
||||
|
||||
```js
|
||||
import { h, ref } from 'vue'
|
||||
|
||||
export default {
|
||||
setup(props, { expose }) {
|
||||
const count = ref(0)
|
||||
const reset = () => { count.value = 0 }
|
||||
|
||||
// Expose methods for parent refs
|
||||
expose({ reset })
|
||||
|
||||
// Still return the render function
|
||||
return () => h('div', count.value)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## With Slots
|
||||
|
||||
```js
|
||||
import { h, ref } from 'vue'
|
||||
|
||||
export default {
|
||||
setup(props, { slots }) {
|
||||
const count = ref(0)
|
||||
|
||||
return () => h('div', [
|
||||
h('p', `Count: ${count.value}`),
|
||||
// Slots must also be called inside the render function
|
||||
slots.default?.()
|
||||
])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Render Functions with Composition API](https://vuejs.org/guide/extras/render-function.html#render-functions-jsx)
|
||||
- [Vue.js Composition API setup()](https://vuejs.org/api/composition-api-setup.html)
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
title: Pass Slots as Functions in Render Functions, Not Direct Children
|
||||
impact: HIGH
|
||||
impactDescription: Passing slot content incorrectly causes slots to not render or be treated as props
|
||||
type: gotcha
|
||||
tags: [vue3, render-function, slots, children, vnode]
|
||||
---
|
||||
|
||||
# Pass Slots as Functions in Render Functions, Not Direct Children
|
||||
|
||||
**Impact: HIGH** - When creating component vnodes with `h()`, children must be passed as slot functions, not as direct children. Passing children directly may cause them to be interpreted as props or fail to render.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Pass slot content as functions: `{ default: () => [...] }`
|
||||
- [ ] Use `null` for props when only passing slots to avoid misinterpretation
|
||||
- [ ] For default slot only, a single function can be passed directly
|
||||
- [ ] For named slots, use an object with slot function properties
|
||||
|
||||
**Incorrect:**
|
||||
```js
|
||||
import { h } from 'vue'
|
||||
import MyComponent from './MyComponent.vue'
|
||||
|
||||
// WRONG: Children array may be misinterpreted
|
||||
h(MyComponent, [
|
||||
h('span', 'Slot content') // May not render as expected
|
||||
])
|
||||
|
||||
// WRONG: Named slots as direct properties
|
||||
h(MyComponent, {
|
||||
header: h('h1', 'Title'), // This is a prop, not a slot!
|
||||
default: h('p', 'Content') // This is also a prop
|
||||
})
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```js
|
||||
import { h } from 'vue'
|
||||
import MyComponent from './MyComponent.vue'
|
||||
|
||||
// CORRECT: Default slot as function
|
||||
h(MyComponent, null, {
|
||||
default: () => h('span', 'Slot content')
|
||||
})
|
||||
|
||||
// CORRECT: Single default slot shorthand
|
||||
h(MyComponent, null, () => h('span', 'Slot content'))
|
||||
|
||||
// CORRECT: Named slots as functions
|
||||
h(MyComponent, null, {
|
||||
header: () => h('h1', 'Title'),
|
||||
default: () => h('p', 'Content'),
|
||||
footer: () => [
|
||||
h('span', 'Footer item 1'),
|
||||
h('span', 'Footer item 2')
|
||||
]
|
||||
})
|
||||
|
||||
// CORRECT: With props AND slots
|
||||
h(MyComponent, { size: 'large' }, {
|
||||
default: () => 'Button Text'
|
||||
})
|
||||
```
|
||||
|
||||
## Why Functions?
|
||||
|
||||
Slots in Vue 3 are functions for lazy evaluation:
|
||||
|
||||
```js
|
||||
// Slots are called by the child component when needed
|
||||
// This enables:
|
||||
// 1. Scoped slots (passing data back)
|
||||
// 2. Conditional rendering (slot not called if not used)
|
||||
// 3. Proper reactivity tracking
|
||||
|
||||
h(MyList, { items }, {
|
||||
// Scoped slot - receives data from child
|
||||
item: ({ item, index }) => h('li', `${index}: ${item.name}`)
|
||||
})
|
||||
```
|
||||
|
||||
## The null Props Gotcha
|
||||
|
||||
When passing only slots, always use `null` for props:
|
||||
|
||||
```js
|
||||
// WRONG: Slots object interpreted as props!
|
||||
h(MyComponent, {
|
||||
default: () => 'Hello'
|
||||
})
|
||||
// MyComponent receives: props.default = () => 'Hello'
|
||||
|
||||
// CORRECT: null signals "no props, next arg is slots"
|
||||
h(MyComponent, null, {
|
||||
default: () => 'Hello'
|
||||
})
|
||||
// MyComponent receives slot correctly
|
||||
```
|
||||
|
||||
## Forwarding Slots from Parent
|
||||
|
||||
```js
|
||||
export default {
|
||||
setup(props, { slots }) {
|
||||
return () => h(ChildComponent, null, {
|
||||
// Forward all slots from parent
|
||||
...slots,
|
||||
|
||||
// Or forward specific slots
|
||||
default: slots.default,
|
||||
header: slots.header
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Scoped Slots in Render Functions
|
||||
|
||||
```js
|
||||
// Parent: Receives data from child via slot props
|
||||
h(DataTable, { data: items }, {
|
||||
row: (slotProps) => h('tr', [
|
||||
h('td', slotProps.item.name),
|
||||
h('td', slotProps.item.value)
|
||||
])
|
||||
})
|
||||
|
||||
// Child (DataTable): Calls slot with data
|
||||
export default {
|
||||
props: ['data'],
|
||||
setup(props, { slots }) {
|
||||
return () => h('table', [
|
||||
h('tbody',
|
||||
props.data.map(item =>
|
||||
// Pass data to slot function
|
||||
slots.row?.({ item })
|
||||
)
|
||||
)
|
||||
])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
```js
|
||||
// Wrapper component forwarding slots
|
||||
h('div', { class: 'wrapper' }, [
|
||||
h(InnerComponent, null, slots)
|
||||
])
|
||||
|
||||
// Conditional slot rendering
|
||||
h('div', [
|
||||
slots.header?.(), // Optional chaining - only render if slot provided
|
||||
h('main', slots.default?.()),
|
||||
slots.footer?.()
|
||||
])
|
||||
|
||||
// Slot with fallback content
|
||||
h('div', [
|
||||
slots.default?.() ?? h('p', 'Default content when slot not provided')
|
||||
])
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Render Functions - Passing Slots](https://vuejs.org/guide/extras/render-function.html#passing-slots)
|
||||
- [Vue.js Render Functions - Children](https://vuejs.org/guide/extras/render-function.html#children)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user