Files
pulse-memory/skills/vue-composition-api-best-practices/reference/script-setup-best-practices.md
T
Pulse ae39e45460 feat: biblioteca inteligente libs/ + 5 novas skills (20 skills total)
NOVAS SKILLS:
- next-best-practices      v0.1.0  (CLEAN) — Next.js App Router, RSC, caching, data
- nextjs-patterns          v1.0.0  (CLEAN) — Next.js 15: Server Actions, route handlers
- vite                     v1.0.0  (CLEAN) — env vars, aliases, proxy, CJS compat
- uncle-bob                v1.0.0  (CLEAN) — Clean Code, SOLID, Clean Architecture
- clean-code-review        v1.0.0  (CLEAN) — naming, guard clauses, anti-patterns, refactoring
- vue                      v1.0.0  (CLEAN) — Vue framework
- vue-composition-api-best-practices v1.0.0 (CLEAN) — composables, Pinia, reactivity

BIBLIOTECA INTELIGENTE libs/ (10 dominios, 11 arquivos):
- typescript/ — TS safe + generics gotchas
- react/ — Next.js App Router + Vite config
- vue/ — Composition API + Pinia
- linux/ — System diagnostic cheatsheet
- database/ — PostgreSQL + MySQL patterns
- browser/ — Chromium CLI + E2E testing
- security/ — SAST audit (OWASP Top 10)
- best-practices/ — Clean Code + SOLID + Clean Architecture
- deploy/ — Docker multi-stack + OpenClaw ops
- + INDEX.md como guia de navegacao

.learnings/ — LRN-20260519-003 criado (biblioteca compartilhada)
2026-05-19 21:03:25 -03:00

15 KiB
Raw Blame History

title, impact, impactDescription, type, tags
title impact impactDescription type tags
script setup Best Practices HIGH 滥用 script setup 特性会导致类型安全问题、运行时错误以及更难维护的代码 best-practice
vue3
composition-api
script-setup
typescript
best-practices

script setup 最佳实践

影响程度:高<script setup> 是编写 Vue 3 组件的推荐方式。理解其模式可确保类型安全和可维护性。

任务清单

  • 使用 <script setup lang="ts"> 以支持 TypeScript
  • 使用 defineOptions 设置组件名称(Vue 3.3+)—— 务必始终使用
  • 使用类型声明式的 definePropsdefineEmits
  • 使用 defineModel 实现双向绑定(Vue 3.4+
  • 使用 useTemplateRef() 获取类型安全的模板引用(Vue 3.5+)
  • 使用 useId() 生成无障碍友好的唯一 ID(Vue 3.5+)
  • 优先使用 toValue() 而非 unref() 来解包 MaybeRefVue 3.3+
  • 使用 InjectionKeyprovide/inject 添加类型
  • 避免将 <script setup> 与 Options API 混用
  • 在组件内和组件外使用正确的 Store 访问模式

问题所在

使用 <script setup> 时的常见错误可能导致类型问题、响应性丢失或令人困惑的代码模式。

defineOptions — 务必始终使用

defineOptions 对于 DevTools 识别、keep-alive 的 include/exclude 以及递归组件至关重要:

<script setup lang="ts">
// ✅ Good - 始终设置组件名称
defineOptions({
  name: 'MiSearch'
})
</script>

在真实项目中的重要性:

  • Vue DevTools 显示组件名称而非 <Anonymous>
  • <keep-alive :include="['MiSearch']"> 可以正常工作
  • 递归组件可以引用自身
  • 调试堆栈信息具有可读性
  • 更易于在代码库中按组件名称搜索

TypeScript 集成

带类型的 Props

BAD — 运行时声明:

<script setup lang="ts">
// props 使用时没有类型推导
const props = defineProps({
  title: String,
  count: {
    type: Number,
    default: 0
  }
})
// props.title 的类型是 string | undefined,而不是 string
</script>

GOOD — 基于类型的声明:

<script setup lang="ts">
interface Props {
  title: string
  count?: number
}

const props = withDefaults(defineProps<Props>(), {
  count: 0
})

// props.title 的类型是 string(必填、已定义)
// props.count 的类型是 number(可选、有默认值)
</script>

带类型的 Emits

BAD — 无类型安全:

<script setup lang="ts">
const emit = defineEmits(['update', 'delete'])
// 没有参数类型检查
emit('update', { any: 'thing' })
</script>

GOOD — 类型安全的 emits

<script setup lang="ts">
interface Emits {
  update: [value: { id: string; name: string }]
  delete: [id: string]
}

const emit = defineEmits<Emits>()

// 类型检查!
emit('update', { id: '1', name: 'Test' }) // ✅
emit('update', { wrong: 'type' }) // ❌ Error
emit('delete', '123') // ✅
</script>

defineModel 实现双向绑定

Vue 3.4+ 特性:

<script setup lang="ts">
// 简单 model
const model = defineModel<string>()
model.value = 'new value' // 更新父组件

// 带选项
const count = defineModel<number>({
  default: 0,
  required: true
})

// 命名 model
const title = defineModel<string>('title')
const description = defineModel<string>('description')

// 带校验器
const email = defineModel<string>({
  default: '',
  validator: (value) => value.includes('@')
})
</script>

<template>
  <input v-model="model" />
  <input v-model:title="title" v-model:description="description" />
</template>

Vue 3.4 之前(手动实现):

<script setup lang="ts">
const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

const inputValue = computed({
  get: () => props.modelValue,
  set: (value) => emit('update:modelValue', value)
})
</script>

Store 访问模式

在 Vue 组件内 — 使用 useXxxStore()

<script setup lang="ts">
// ✅ Good - 在组件内,使用标准的 store 访问方式
const appStore = useAppStore()
const businessStore = useBusinessStore()

// 在组件中直接访问
const isDark = computed(() => appStore.getIsDark)
</script>

在 Vue 组件外 — 使用 useXxxStoreWithOut()

// ✅ Good - 在 utils、hooks、plugins 中使用 WithOut 版本
import { useAppStoreWithOut } from '@/store/modules/app'

export const useEngine = () => {
  const appStore = useAppStoreWithOut() // 传入全局 pinia 实例
  // ...
}
// ✅ Good - 在 utils/migration.ts 中
import { useAppStoreWithOut } from '@/store/modules/app'

export async function migrateOnlineIcons() {
  const appStore = useAppStoreWithOut()
  // 可以在组件上下文之外访问 store
}
// ✅ Good - 在 plugins/vue-i18n/index.ts 中
import { useLocaleStoreWithOut } from '@/store/modules/locale'

export async function setupI18n(app: App) {
  const localeStore = useLocaleStoreWithOut()
  // ...
}

区分两者的原因:

  • useXxxStore() 依赖 Vue 的 inject/provide,仅在组件上下文中可用
  • useXxxStoreWithOut(store) 显式传入 pinia 实例,可在任意位置使用
  • 使用错误会导致运行时错误:getActivePinia was called with no active Pinia

详见 store-without-pattern

常见模式

带默认值的响应式 Props

<script setup lang="ts">
interface Props {
  items: string[]
  pageSize?: number
}

const props = withDefaults(defineProps<Props>(), {
  pageSize: 10
})

// 使用 toRefs 获取响应式 props
const { pageSize } = toRefs(props)

// 或使用 computed 获取派生值
const totalPages = computed(() =>
  Math.ceil(props.items.length / pageSize.value)
)
</script>

暴露组件方法

<script setup lang="ts">
const inputRef = ref<HTMLInputElement | null>(null)

const focus = () => {
  inputRef.value?.focus()
}

const clear = () => {
  inputRef.value!.value = ''
}

// 暴露方法供父组件调用
defineExpose({
  focus,
  clear
})
</script>

模板引用

<script setup lang="ts">
// 元素引用
const inputRef = ref<HTMLInputElement | null>(null)

// 组件引用
const componentRef = ref<InstanceType<typeof MyComponent> | null>(null)

// v-for 中的引用数组
const itemRefs = ref<HTMLDivElement[]>([])

onMounted(() => {
  inputRef.value?.focus()
})
</script>

<template>
  <input ref="inputRef" />
  <MyComponent ref="componentRef" />
  <div v-for="item in items" :key="item.id" ref="itemRefs">
    {{ item.name }}
  </div>
</template>

useTemplateRefVue 3.5+

Vue 3.5 引入了 useTemplateRef() — 一种类型安全的替代方案,用于替代普通的 ref() 来获取模板引用。它解决了尴尬的 null 初始化问题,并提供更好的类型推导:

<script setup lang="ts">
// ✅ Vue 3.5+:无需 null 初始值,类型从模板中推导
const inputRef = useTemplateRef<HTMLInputElement>('inputRef')
const componentRef = useTemplateRef<InstanceType<typeof MyComponent>>('componentRef')

onMounted(() => {
  inputRef.value?.focus() // HTMLInputElement | undefined
})
</script>

<template>
  <input ref="inputRef" />
  <MyComponent ref="componentRef" />
</template>

ref<T | null> 的关键区别:

特性 ref<T | null> useTemplateRef<T>()
初始值 必须指定 null 自动推导,无需手动设置 null
挂载后的类型 T | null T | undefined
需要 ref 名称匹配 手动(靠约定) 通过字符串参数强制匹配
支持 v-for ref<T[]>([]) useTemplateRef<T[]>('list')

useIdVue 3.5+

Vue 3.5 引入了 useId(),用于生成唯一的、SSR 安全的 ID。对于无障碍访问(aria-labelledbyfor/id 关联)和避免 ID 冲突至关重要:

<script setup lang="ts">
const inputId = useId()
const labelId = useId()
</script>

<template>
  <label :for="inputId" :id="labelId">Email</label>
  <input :id="inputId" :aria-labelledby="labelId" />
</template>

为什么不使用 Math.random() 或计数器?

  • useId() 是 SSR 安全的 — 服务端和客户端生成匹配的 ID
  • 不会在组件实例之间产生冲突
  • 在客户端导航(SPA 路由切换)之间会清除

Provide/Inject 与 TypeScript

provide/inject 是避免 props 逐层传递的强大工具,但类型安全需要明确的模式:

步骤 1:定义 InjectionKey

// types/injection-keys.ts
import type { InjectionKey, Ref } from 'vue'

// 类型化的 injection key
export const THEME_KEY: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')
export const CONFIG_KEY: InjectionKey<AppConfig> = Symbol('config')

步骤 2:带类型安全地 provide

<script setup lang="ts">
import { provide } from 'vue'
import { THEME_KEY, CONFIG_KEY } from '@/types/injection-keys'

const theme = ref<'light' | 'dark'>('light')
const appConfig = { apiUrl: 'https://api.example.com', timeout: 5000 }

provide(THEME_KEY, theme)  // ✅ 类型检查通过
provide(CONFIG_KEY, appConfig)  // ✅ 类型检查通过

// ❌ Error:类型错误
provide(THEME_KEY, 'blue')
</script>

步骤 3:带类型安全地 inject

<script setup lang="ts">
import { inject } from 'vue'
import { THEME_KEY, CONFIG_KEY } from '@/types/injection-keys'

// ✅ 使用 InjectionKey — 完全类型化,无需默认值
const theme = inject(THEME_KEY) // Ref<'light' | 'dark'>

// ✅ 带默认值 — 类型推导,不会是 undefined
const config = inject(CONFIG_KEY, { apiUrl: '/fallback', timeout: 3000 })

// ⚠️ 不使用 key 或默认值 — 返回 T | undefined
const maybeConfig = inject<AppConfig>('config') // AppConfig | undefined
</script>
模式 返回类型 使用场景
inject(key) 配合 InjectionKey T(不可为 null 祖先组件中存在 provider
inject(key, default) T(不可为 null provider 可能不存在
inject<string>('key') T | undefined 旧的字符串 key 模式

⚠️ 在 TypeScript 中不要使用纯字符串进行 provide/inject — 你会失去所有类型安全和 IDE 自动补全。

toValue() vs unref()

Vue 3.3 引入了 toValue(),作为解包 "MaybeRef" 值的首选方式:

import { toValue, unref } from 'vue'
import type { MaybeRef } from 'vue'

// 两者都可以标准化 ref 和普通值
const a = ref(42)
const b = 100

toValue(a) // 42 — 解包 ref,透传普通值
toValue(b) // 100

unref(a)  // 42 — 行为相同
unref(b)  // 100

// 但 toValue() 有一个关键区别:
// toValue() 还可以解包 getter(返回值的函数)
const getter = () => 42
toValue(getter) // 42 ✅
unref(getter)   // () => 42 ❌(不会调用 getter

经验法则:

  • 如果你的组合式函数接受 MaybeRef<T> — 使用 toValue() 来标准化
  • 如果你只处理 Ref 对象 — unref() 也可以
  • 新代码应优先使用 toValue() 以保持向前兼容

应避免的反模式

不要与 Options API 混用

<!-- BAD - 混合风格 -->
<script>
export default {
  data() {
    return {
      count: 0
    }
  }
}
</script>

<script setup lang="ts">
// 无法在这里访问 Options API 的 data
const double = computed(() => this.count * 2) // Error!
</script>

不要解构 Props

<script setup lang="ts">
const props = defineProps<{ count: number }>()

// BAD - 丢失响应性
const { count } = props

// GOOD - 使用 toRefs
const { count } = toRefs(props)

// 或者直接使用 props
const double = computed(() => props.count * 2)
</script>

不要在 script setup 中使用 this

<script setup lang="ts">
// ❌ Error'this' 是 undefined
console.log(this.$router)

// ✅ 改用组合式函数
import { useRouter } from 'vue-router'
const router = useRouter()
console.log(router)
</script>

不要忘记 ref 的 .value

<script setup lang="ts">
const count = ref(0)

// ❌ 不会触发响应性
count = 1

// ✅ 正确方式
count.value = 1

// 模板中不需要 .value
</script>

<template>
  <!-- 模板中自动解包 -->
  <div>{{ count }}</div>
</template>

不要在组件外使用 useXxxStore()

// ❌ Bad - 在 utils/hooks/plugins 中会抛出错误
import { useAppStore } from '@/store/modules/app'
export function someUtil() {
  const store = useAppStore() // Error: pinia is not defined
}

// ✅ Good - 使用 WithOut 版本
import { useAppStoreWithOut } from '@/store/modules/app'
export function someUtil() {
  const store = useAppStoreWithOut() // 正常工作!
}

性能提示

对大型对象和动态组件使用 shallowRef

<script setup lang="ts">
// 动态组件切换 — 使用 shallowRef 避免深层响应性
const activeCom = shallowRef()
watchEffect(() => {
  activeCom.value = isPure.value ? PureMode : HomeMode
})

// 大型数据对象 — 替换整个值时使用 shallowRef
const largeData = shallowRef<BigObject>({})
largeData.value = { /* 新对象 */ } // 仅在引用变化时触发更新
</script>

对派生状态使用 computed

<script setup lang="ts">
const items = ref<string[]>([])

// ✅ 高效 - 缓存直到 items 变化
const sortedItems = computed(() =>
  [...items.value].sort()
)

// ❌ 低效 - 每次渲染都重新创建
const getSortedItems = () => [...items.value].sort()
</script>

参考资料