mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-16 08:26:52 +08:00
feat(rules): add Vue coding-style and composables/reactivity rules
Add rules/vue/coding-style.md: - <script setup> Composition API enforcement - Naming conventions (PascalCase components, useCamelCase composables) - SFC structure order, props/emits/slots patterns - Vue 3.5+ reactive props destructure with native default values - Template conventions, import ordering Add rules/vue/hooks.md: - ref() vs reactive() guidance and replacement pitfalls - Vue 3.5+ reactive props destructure (version-specific: Vue<3.5 loses reactivity, 3.5+ reactive by default with watch limitation) - computed() purity rules, watch vs watchEffect comparison - Watcher cleanup with onWatcherCleanup() (Vue 3.5+) and onCleanup callback - useTemplateRef() (Vue 3.5+) replacing name-matched plain refs - Composable conventions (use prefix, reactive returns, MaybeRef inputs) - shallowRef/shallowReactive for large data structures
This commit is contained in:
parent
6bde9be36c
commit
57386e156d
214
rules/vue/coding-style.md
Normal file
214
rules/vue/coding-style.md
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "**/*.vue"
|
||||||
|
- "**/components/**/*.ts"
|
||||||
|
- "**/components/**/*.js"
|
||||||
|
- "**/composables/**/*.ts"
|
||||||
|
- "**/composables/**/*.js"
|
||||||
|
---
|
||||||
|
# Vue Coding Style
|
||||||
|
|
||||||
|
> This file extends [typescript/coding-style.md](../typescript/coding-style.md) and [common/coding-style.md](../common/coding-style.md) with Vue-specific conventions. For composable rules see [hooks.md](./hooks.md).
|
||||||
|
|
||||||
|
## API Style
|
||||||
|
|
||||||
|
- Use `<script setup>` Composition API for all new Vue 3 components.
|
||||||
|
- Options API is acceptable only when maintaining a legacy Vue 2 / early Vue 3 codebase.
|
||||||
|
- Mixins are forbidden in new code — replace with composables.
|
||||||
|
- `<script setup lang="ts">` for all TypeScript projects.
|
||||||
|
|
||||||
|
## File Extensions
|
||||||
|
|
||||||
|
- `.vue` for Single-File Components
|
||||||
|
- `.ts` for composables, stores, utilities, router config, type definitions
|
||||||
|
- `.test.ts` mirroring the source file
|
||||||
|
- `.cy.ts` for Cypress component tests
|
||||||
|
|
||||||
|
## Naming
|
||||||
|
|
||||||
|
| Entity | Convention | Example |
|
||||||
|
|--------|-----------|---------|
|
||||||
|
| Component files | PascalCase or kebab-case (team convention) | `UserCard.vue` or `user-card.vue` |
|
||||||
|
| Component name | `PascalCase` (multi-word, enforced by `vue/multi-word-component-names`) | `UserCard`, `BaseButton` |
|
||||||
|
| Composables | `useCamelCase` | `useUser`, `useDebounce` |
|
||||||
|
| Pinia stores | `useCamelCaseStore` | `useUserStore`, `useCartStore` |
|
||||||
|
| Props | camelCase in `<script>`, kebab-case in templates | `userName` / `user-name` |
|
||||||
|
| Events | kebab-case in templates | `@update:model-value`, `@item-selected` |
|
||||||
|
| Boolean props | `isXxx`, `hasXxx`, `canXxx`, `shouldXxx` | `isLoading`, `hasError`, `canSubmit` |
|
||||||
|
|
||||||
|
## Component Shape
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 1. Imports
|
||||||
|
import { ref, computed, onMounted } from "vue";
|
||||||
|
import { useUser } from "@/composables/useUser";
|
||||||
|
import UserAvatar from "./UserAvatar.vue";
|
||||||
|
|
||||||
|
// 2. Props & Emits
|
||||||
|
const props = defineProps<{
|
||||||
|
userId: string;
|
||||||
|
showAvatar?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [id: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 3. Composables
|
||||||
|
const { user, isLoading } = useUser(() => props.userId);
|
||||||
|
|
||||||
|
// 4. Local state
|
||||||
|
const isExpanded = ref(false);
|
||||||
|
|
||||||
|
// 5. Computed
|
||||||
|
const displayName = computed(() =>
|
||||||
|
user.value ? `${user.value.firstName} ${user.value.lastName}` : "Unknown"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. Methods
|
||||||
|
function handleSelect() {
|
||||||
|
emit("select", props.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Lifecycle hooks
|
||||||
|
onMounted(() => {
|
||||||
|
console.log("UserCard mounted");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="isLoading">Loading...</div>
|
||||||
|
<div v-else>
|
||||||
|
<UserAvatar :src="user?.avatar" />
|
||||||
|
<span>{{ displayName }}</span>
|
||||||
|
<button @click="handleSelect">Select</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Single-File Component Structure
|
||||||
|
|
||||||
|
Enforce this order inside `.vue` files:
|
||||||
|
|
||||||
|
1. `<script setup>` (or `<script>`)
|
||||||
|
2. `<template>`
|
||||||
|
3. `<style scoped>` (or `<style module>`)
|
||||||
|
|
||||||
|
Use block comments (`/* */`) inside `<script>`, HTML comments (`<!-- -->`) inside `<template>`.
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
- Prefer `defineProps<>()` type-based declaration with TypeScript.
|
||||||
|
- **Vue 3.5+**: Reactive Props Destructure is stabilized — you can destructure `defineProps()` and the variables are automatically reactive. Use JavaScript native default values syntax:
|
||||||
|
```ts
|
||||||
|
const { count = 0, msg = "hello" } = defineProps<{ count?: number; msg?: string }>();
|
||||||
|
```
|
||||||
|
- **Vue < 3.5**: Use `withDefaults()` for typing props with default values, or access via `props.xxx`. Never destructure (captures snapshot).
|
||||||
|
- Never mutate props — use `defineEmits` for upward communication.
|
||||||
|
- Group related props into a single object type when they represent a logical entity.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Vue 3.5+: native defaults with destructuring -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { user, variant = "primary", disabled = false } = defineProps<{
|
||||||
|
user: User;
|
||||||
|
variant?: "primary" | "secondary";
|
||||||
|
disabled?: boolean;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Vue < 3.5: withDefaults -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
variant?: "primary" | "secondary";
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
variant: "primary",
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Emits
|
||||||
|
|
||||||
|
- Use type-based `defineEmits<>()` with TypeScript payload signatures.
|
||||||
|
- Keep event names in kebab-case in templates, camelCase in script.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: string];
|
||||||
|
submit: [];
|
||||||
|
cancel: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Slots
|
||||||
|
|
||||||
|
- Type slots explicitly with `defineSlots<>()` for TypeScript projects.
|
||||||
|
- Document slot purpose and expected props in a comment above template usage.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineSlots<{
|
||||||
|
default: (props: { item: Item }) => any;
|
||||||
|
header: () => any;
|
||||||
|
footer: () => any;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Template Conventions
|
||||||
|
|
||||||
|
- Self-close tags with no children: `<UserAvatar :src="url" />`
|
||||||
|
- Use `<template>` for conditional groups, not wrapper `<div>`.
|
||||||
|
- `v-if` / `v-else-if` / `v-else` must be on consecutive sibling elements.
|
||||||
|
- Never put multi-line logic inline in templates — extract to computed or method.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Prefer -->
|
||||||
|
<h1>{{ greeting }}</h1>
|
||||||
|
|
||||||
|
<!-- Over -->
|
||||||
|
<h1>{{ user.isAdmin ? "Welcome, admin" : `Hello ${user.name}` }}</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Imports
|
||||||
|
|
||||||
|
- Vue imports first: `import { ref, computed } from "vue"`
|
||||||
|
- Then ecosystem packages (vue-router, pinia), then absolute project imports, then relative
|
||||||
|
- Type-only imports: `import type { User } from "@/types"`
|
||||||
|
- Auto-imported functions (Nuxt, unplugin-auto-import) must still be explicitly imported when the project does not use auto-import.
|
||||||
|
|
||||||
|
## Script vs Template
|
||||||
|
|
||||||
|
- Keep `<script setup>` as the logic owner — templates should contain only rendering directives.
|
||||||
|
- Composable returns keep naming consistent: `const { user, isLoading } = useUser(id)` — destructured for readability.
|
||||||
|
- No side effects in `computed()` getters — they must be pure.
|
||||||
|
|
||||||
|
## Class Components
|
||||||
|
|
||||||
|
Forbidden in new code. The `vue-class-component` and `vue-property-decorator` libraries are deprecated. Migrate to Composition API.
|
||||||
|
|
||||||
|
## File Layout per Component
|
||||||
|
|
||||||
|
```
|
||||||
|
components/UserCard/
|
||||||
|
UserCard.vue
|
||||||
|
UserCard.test.ts
|
||||||
|
index.ts # re-export for barrel pattern
|
||||||
|
```
|
||||||
|
|
||||||
|
Or co-located:
|
||||||
|
|
||||||
|
```
|
||||||
|
components/UserCard.vue
|
||||||
|
components/__tests__/UserCard.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Follow the project's existing convention. Inline single-file components are fine for trivial presentational pieces.
|
||||||
362
rules/vue/hooks.md
Normal file
362
rules/vue/hooks.md
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "**/*.vue"
|
||||||
|
- "**/composables/**/*.ts"
|
||||||
|
- "**/composables/**/*.js"
|
||||||
|
- "**/use-*.ts"
|
||||||
|
- "**/use-*.js"
|
||||||
|
---
|
||||||
|
# Vue Composables and Reactivity
|
||||||
|
|
||||||
|
> This file covers **Vue composables** (`use*()`, `ref()`, `reactive()`, `computed()`, `watch()`, `watchEffect()`). Named to match the per-language convention `rules/<lang>/hooks.md`.
|
||||||
|
>
|
||||||
|
> Extends [typescript/patterns.md](../typescript/patterns.md) and [common/patterns.md](../common/patterns.md).
|
||||||
|
|
||||||
|
## Reactivity Fundamentals
|
||||||
|
|
||||||
|
### `ref()` vs `reactive()`
|
||||||
|
|
||||||
|
- Use `ref()` for primitives and for values that will be replaced wholesale.
|
||||||
|
- Use `reactive()` for object structures whose properties are mutated individually.
|
||||||
|
- In practice, `ref()` is preferred as the default — it's explicit, works everywhere, and avoids the pitfalls of `reactive()` (no replacement, no destructuring).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ref — universal, explicit .value
|
||||||
|
const count = ref(0);
|
||||||
|
const user = ref<User | null>(null);
|
||||||
|
|
||||||
|
// reactive — only for objects, no .value
|
||||||
|
const form = reactive({ email: "", password: "" });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props Destructuring (Version-Specific)
|
||||||
|
|
||||||
|
**Vue 3.5+**: Reactive Props Destructure is stabilized and enabled by default. Destructured variables from `defineProps()` are automatically reactive — the compiler transforms `count` to `props.count` at compile time.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Vue 3.5+: CORRECT — destructured props are reactive
|
||||||
|
const { userId, userName } = defineProps<{ userId: string; userName: string }>();
|
||||||
|
// userId and userName track the parent's prop updates
|
||||||
|
|
||||||
|
// Native default values (Vue 3.5+)
|
||||||
|
const { count = 0, msg = "hello" } = defineProps<{
|
||||||
|
count?: number;
|
||||||
|
msg?: string;
|
||||||
|
}>();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vue < 3.5**: Destructuring captures snapshot values at setup time — they won't update.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Vue < 3.5: WRONG: destructured props lose reactivity
|
||||||
|
const { userId, userName } = defineProps<{ userId: string; userName: string }>();
|
||||||
|
|
||||||
|
// Vue < 3.5: CORRECT: access via props.xxx
|
||||||
|
const props = defineProps<{ userId: string; userName: string }>();
|
||||||
|
// In methods/computed: props.userId
|
||||||
|
|
||||||
|
// ALSO CORRECT: toRefs for individual refs
|
||||||
|
const { userId, userName } = toRefs(props);
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important limitation (all Vue 3.5+ versions)**: You cannot `watch()` a destructured prop variable directly — must wrap in a getter:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// WRONG: direct watch on destructured prop (compile-time error in Vue 3.5+)
|
||||||
|
watch(count, (newVal) => { ... });
|
||||||
|
|
||||||
|
// CORRECT: getter wrapper
|
||||||
|
watch(() => count, (newVal) => { ... });
|
||||||
|
```
|
||||||
|
|
||||||
|
When passing a destructured prop to a composable that needs reactivity, wrap in a getter and use `toValue()` inside the composable:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
useDynamicCount(() => count); // ✅ preserves reactivity
|
||||||
|
```
|
||||||
|
|
||||||
|
### Replacing reactive() Objects
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// WRONG: breaks reactivity
|
||||||
|
let state = reactive({ a: 1, b: 2 });
|
||||||
|
state = reactive({ a: 3, b: 4 }); // new object, old watchers lost
|
||||||
|
|
||||||
|
// CORRECT: mutate in place
|
||||||
|
Object.assign(state, { a: 3, b: 4 });
|
||||||
|
|
||||||
|
// BETTER: use ref for values that get replaced
|
||||||
|
const state = ref({ a: 1, b: 2 });
|
||||||
|
state.value = { a: 3, b: 4 }; // reactivity preserved
|
||||||
|
```
|
||||||
|
|
||||||
|
### `.value` in Script vs Template
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup>
|
||||||
|
const count = ref(0);
|
||||||
|
// Inside script: MUST use .value
|
||||||
|
console.log(count.value);
|
||||||
|
function increment() { count.value++; }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Inside template: NO .value (auto-unwrapped) -->
|
||||||
|
<span>{{ count }}</span>
|
||||||
|
<button @click="count++">Increment</button>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## `computed()` Rules
|
||||||
|
|
||||||
|
- Computed getters must be pure — no side effects (no state mutation, API calls, DOM writes).
|
||||||
|
- Never mutate other state inside a computed getter.
|
||||||
|
- Computed setter must be paired with a getter — don't create write-only computeds.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// CORRECT: pure getter
|
||||||
|
const fullName = computed(() => `${firstName.value} ${lastName.value}`);
|
||||||
|
|
||||||
|
// CORRECT: with setter
|
||||||
|
const fullName = computed({
|
||||||
|
get: () => `${firstName.value} ${lastName.value}`,
|
||||||
|
set: (val: string) => {
|
||||||
|
const [first, last] = val.split(" ");
|
||||||
|
firstName.value = first;
|
||||||
|
lastName.value = last;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// WRONG: side effect in computed
|
||||||
|
const displayName = computed(() => {
|
||||||
|
analytics.track("name-computed"); // ❌ side effect
|
||||||
|
return user.value.name;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## `watch()` vs `watchEffect()`
|
||||||
|
|
||||||
|
| Feature | `watch()` | `watchEffect()` |
|
||||||
|
|---------|-----------|-----------------|
|
||||||
|
| Explicit source | Yes — declare what to track | No — auto-tracks dependencies |
|
||||||
|
| Access to old/new values | Yes | No |
|
||||||
|
| Initial run | Optional (`immediate: true`) | Always runs immediately |
|
||||||
|
| Use case | Side effect on specific data change | Sync reactive state to external system |
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// watch: explicit, has old/new
|
||||||
|
watch(
|
||||||
|
() => props.userId,
|
||||||
|
(newId, oldId) => {
|
||||||
|
fetchUser(newId);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// watchEffect: auto-tracking, immediate
|
||||||
|
watchEffect(() => {
|
||||||
|
console.log(`User ${userId.value} is ${status.value}`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Watcher Source Pitfalls
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// WRONG: watching a ref object (never changes)
|
||||||
|
const u = ref({ name: "Alice" });
|
||||||
|
watch(u, (val) => {}); // ❌ watches the ref wrapper, not the value
|
||||||
|
|
||||||
|
// CORRECT: getter returning .value
|
||||||
|
watch(() => u.value, (val) => {});
|
||||||
|
|
||||||
|
// ALSO WRONG: reactive getter that doesn't track
|
||||||
|
watch(() => state.name, (val) => {}); // ❌ val is snapshot at setup
|
||||||
|
|
||||||
|
// CORRECT: getter that accesses property on reactive object
|
||||||
|
watch(() => state.name, (val) => {}); // ✅ .name access inside getter is tracked
|
||||||
|
// Wait — careful: `() => state.name` DOES track correctly because the getter
|
||||||
|
// accesses `.name` on the reactive proxy. The getter is re-evaluated by Vue.
|
||||||
|
|
||||||
|
// ACTUALLY WRONG case: direct reactive property
|
||||||
|
watch(state.name, ...); // ❌ state.name evaluates to a primitive, not trackable
|
||||||
|
|
||||||
|
// CORRECT: getter returning reactive property
|
||||||
|
watch(() => state.name, (newName) => { ... });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
|
||||||
|
Every watcher that creates subscriptions, intervals, or fetch requests must clean up.
|
||||||
|
|
||||||
|
**Vue 3.5+**: Use `onWatcherCleanup()` (globally importable from `vue`) for watcher-side-effect cleanup:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { watch, onWatcherCleanup } from "vue";
|
||||||
|
|
||||||
|
watch(userId, async (newId) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
onWatcherCleanup(() => controller.abort());
|
||||||
|
const data = await fetch(`/api/users/${newId}`, { signal: controller.signal });
|
||||||
|
user.value = await data.json();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**All Vue 3 versions**: The watcher callback also receives an `onCleanup` parameter:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// watch callback receives an onCleanup function
|
||||||
|
watch(userId, async (newId, _oldId, onCleanup) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
onCleanup(() => controller.abort());
|
||||||
|
const data = await fetch(`/api/users/${newId}`, { signal: controller.signal });
|
||||||
|
user.value = await data.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
// watchEffect also receives onCleanup
|
||||||
|
watchEffect((onCleanup) => {
|
||||||
|
const id = setInterval(tick, 1000);
|
||||||
|
onCleanup(() => clearInterval(id));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## `useTemplateRef()` (Vue 3.5+)
|
||||||
|
|
||||||
|
Use `useTemplateRef()` instead of matching a plain `ref` variable name to the template `ref` attribute. It supports dynamic ref IDs and provides better type safety.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useTemplateRef } from "vue";
|
||||||
|
|
||||||
|
// Static ref
|
||||||
|
const inputEl = useTemplateRef<HTMLInputElement>("input");
|
||||||
|
|
||||||
|
// Dynamic ref
|
||||||
|
const refId = ref("input");
|
||||||
|
const dynamicEl = useTemplateRef<HTMLInputElement>(refId);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input ref="input" type="text" />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
- The string passed to `useTemplateRef()` must match the `ref` attribute value in the template, **not** the variable name.
|
||||||
|
- `@vue/language-tools` 2.1+ provides auto-completion and warnings for `useTemplateRef`.
|
||||||
|
|
||||||
|
## Composable Conventions
|
||||||
|
|
||||||
|
### Must start with `use`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// CORRECT
|
||||||
|
export function useDebounce<T>(value: Ref<T>, delay: number): Ref<T> { ... }
|
||||||
|
|
||||||
|
// WRONG
|
||||||
|
export function debounce<T>(value: Ref<T>, delay: number): Ref<T> { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Return reactive values
|
||||||
|
|
||||||
|
Composables must return `ref()` / `computed()` / `reactive()` so the consumer stays reactive. Never return a raw primitive or plain object snapshot.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// CORRECT
|
||||||
|
export function useCounter() {
|
||||||
|
const count = ref(0);
|
||||||
|
const doubled = computed(() => count.value * 2);
|
||||||
|
function increment() { count.value++; }
|
||||||
|
return { count, doubled, increment };
|
||||||
|
}
|
||||||
|
|
||||||
|
// WRONG: returns snapshot
|
||||||
|
export function useCounter() {
|
||||||
|
let count = 0;
|
||||||
|
function increment() { count++; }
|
||||||
|
return { count, increment }; // count is a plain number — not reactive
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accept reactive inputs gracefully
|
||||||
|
|
||||||
|
When a composable accepts reactive data, use `toRef()` / `toValue()` (Vue 3.3+) so callers can pass either a ref or a plain value.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useTitle(newTitle: MaybeRef<string>) {
|
||||||
|
const title = toRef(newTitle);
|
||||||
|
watchEffect(() => {
|
||||||
|
document.title = title.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller can pass either:
|
||||||
|
useTitle("Home"); // plain value
|
||||||
|
useTitle(ref("Home")); // ref
|
||||||
|
useTitle(computed(...)); // computed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Side effects must be scoped
|
||||||
|
|
||||||
|
Composables that create side effects (event listeners, timers, subscriptions) must:
|
||||||
|
|
||||||
|
1. Only run when the component using them is mounted — use `onMounted` / `watch`.
|
||||||
|
2. Clean up automatically — use `onUnmounted` or watcher `onCleanup`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useEventListener<K extends keyof WindowEventMap>(
|
||||||
|
event: K,
|
||||||
|
handler: (e: WindowEventMap[K]) => void,
|
||||||
|
) {
|
||||||
|
onMounted(() => window.addEventListener(event, handler));
|
||||||
|
onUnmounted(() => window.removeEventListener(event, handler));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### No module-scope side effects
|
||||||
|
|
||||||
|
Never initialize state, start timers, or subscribe to external systems in the module scope of a composable file — it runs once regardless of component instance count.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// WRONG: module scope side effect
|
||||||
|
const globalCount = ref(0); // ❌ shared across all components
|
||||||
|
setInterval(() => globalCount.value++, 1000);
|
||||||
|
|
||||||
|
export function useGlobalCount() {
|
||||||
|
return globalCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT: scoped to each invocation
|
||||||
|
export function useInterval(fn: () => void, ms: number) {
|
||||||
|
onMounted(() => {
|
||||||
|
const id = setInterval(fn, ms);
|
||||||
|
onUnmounted(() => clearInterval(id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## `shallowRef()` and `shallowReactive()`
|
||||||
|
|
||||||
|
Use `shallowRef()` for large immutable data structures that are replaced as a whole — avoids the deep reactivity overhead.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const items = shallowRef<Item[]>([]);
|
||||||
|
// items.value = await fetchItems(); // replacement works
|
||||||
|
// items.value[0].name = "new"; // ❌ inner mutations are NOT reactive
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `shallowReactive()` when only top-level properties should be reactive.
|
||||||
|
|
||||||
|
## Lint Configuration
|
||||||
|
|
||||||
|
Required rules:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rules": {
|
||||||
|
"vue/no-ref-as-operand": "error",
|
||||||
|
"vue/no-mutating-props": "error",
|
||||||
|
"vue/return-in-computed-property": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
Loading…
x
Reference in New Issue
Block a user