跳到主要内容

Vue 3 速查表,Composition API、响应式、组件、指令、Pinia,附 Options API 对照

Vue 3 速查表,Composition API、响应式、组件、指令、Pinia,带 Options API 对照。

  • 本地处理
  • 分类 开发运维
  • 适合 格式化、校验、压缩或检查和代码相关的文本。
132
响应式 (21)

ref:基本类型的响应式

响应式
import { ref } from 'vue';
const count = ref(0);
// inside <script setup> the template auto-unwraps:  {{ count }}
// in JS code you must use .value :
count.value++;
console.log(count.value);  // 1

说明:ref 把值装进一个响应式容器。在 JS 里通过 `.value` 读写;模板里 Vue 会自动解包顶层 ref,所以 `{{ count }}` 不用写 `.value`。基本类型(数字 / 字符串 / 布尔)和你可能整体替换的值用 ref。

Options API 对照写法
// Options API
export default {
  data() { return { count: 0 }; },
  methods: { inc() { this.count++; } }
};

reactive:深层响应式对象

响应式
import { reactive } from 'vue';
const state = reactive({ user: { name: 'Lei', age: 30 }, tags: ['vue'] });
state.user.age = 31;     // tracked
state.tags.push('web');  // tracked (arrays too)

说明:reactive 返回一个 Proxy,深层跟踪每个属性。适合需要"原地修改"的对象 state。只对 对象 / 数组 / Map / Set 有效,基本类型必须用 ref。直接解构 reactive 对象会失去响应性,要保留就用 toRefs。

Options API 对照写法
data() { return { user: { name: 'Lei', age: 30 }, tags: ['vue'] }; }

computed:派生响应式值

响应式
import { ref, computed } from 'vue';
const price = ref(100);
const qty = ref(3);
const total = computed(() => price.value * qty.value);
// writable computed
const fullName = computed({
  get: () => first.value + ' ' + last.value,
  set: (v: string) => { [first.value, last.value] = v.split(' '); }
});

说明:computed 会缓存派生值,只有依赖的响应式数据变了才重算。默认只读;传 `{ get, set }` 就能双向。模板里能用 computed 就别用 method,method 每次渲染都跑,computed 会缓存。

Options API 对照写法
computed: {
  total() { return this.price * this.qty; }
}

watch:显式 source + 回调

响应式
import { ref, watch } from 'vue';
const id = ref(1);

watch(id, (newId, oldId, onCleanup) => {
  const ctrl = new AbortController();
  fetch(`/api/user/${newId}`, { signal: ctrl.signal }).then(/*…*/);
  onCleanup(() => ctrl.abort());  // cancel stale request
});

// watch multiple sources
watch([first, last], ([f, l]) => console.log(f, l));

// deep watch on a reactive object
watch(() => state.user, (u) => console.log(u), { deep: true });

说明:watch 默认懒触发,回调只在 source 变化时跑,不会在创建时跑。source 可以是 ref、getter、reactive 对象,或它们组成的数组。回调第三个参数 `onCleanup` 用来取消过期的异步任务。传 `{ immediate: true }` 挂载时立即跑一次;`{ deep: true }` 跟踪嵌套修改。

Options API 对照写法
watch: {
  id(newId, oldId) { /* … */ }
}

watchEffect:自动追踪用到的依赖

响应式
import { ref, watchEffect } from 'vue';
const url = ref('/api/me');
watchEffect(() => {
  // any ref read inside is tracked automatically
  fetch(url.value).then(/*…*/);
});
// stop manually if you need to
const stop = watchEffect(() => console.log(count.value));
stop();

说明:watchEffect 同步跑一次,自动追踪里面读到的每一个响应式依赖,任何一个变了就重跑。不用列依赖,类比 React 就是"自动追踪版本的 useEffect"。返回 stop 函数,手动停。需要旧值或显式 source 时用 watch,纯同步副作用用它就够了。

shallowRef:只在顶层响应

响应式
import { shallowRef, triggerRef } from 'vue';
const big = shallowRef({ items: largeArray });

// replacing .value still triggers
big.value = { items: newArray };

// mutating nested fields does NOT trigger
big.value.items.push(1);  // no re-render
triggerRef(big);           // force update

说明:shallowRef 只跟踪 `.value` 整体赋值,不跟踪嵌套修改。适合大对象(1 万条的 Map、3D 场景、编辑器文档),深 proxy 太贵,你要么整体替换,要么调 triggerRef 手动通知更新。重型结构能省内存也能省 CPU。

shallowReactive:只第一层响应

响应式
import { shallowReactive } from 'vue';
const state = shallowReactive({
  user: { name: 'Lei' },   // user object itself is NOT reactive
  count: 0                 // count IS reactive (top-level primitive)
});
state.count++;             // tracked
state.user.name = 'Hong';  // NOT tracked
state.user = { name: 'Hong' };  // tracked — replacing the property is top-level

说明:shallowReactive 只代理第一层属性。嵌套对象原样返回,改它们不会触发更新,但替换顶层 key 会。适合 reactive state 里存着大的、已经定型的对象,你只关心顶层指针变化的场景。

readonly:响应式数据的只读视图

响应式
import { reactive, readonly } from 'vue';
const original = reactive({ count: 0 });
const copy = readonly(original);

original.count++;        // works, copy.count also updates
copy.count++;            // warns in dev, no-op
// pass readonly to children that should not mutate parent state
provide('config', readonly(original));

说明:readonly 返回一个不能改的 proxy,写会在开发模式下报警告。读还是能看到原对象的最新值。配 provide/inject 特别合适,子组件能读父组件 state 但改不了,强制单向数据流。

toRefs:解构而不失去响应性

响应式
import { reactive, toRefs } from 'vue';
const state = reactive({ x: 1, y: 2 });

// WRONG — destructuring breaks reactivity
const { x, y } = state;

// RIGHT — each becomes a ref pointing into the proxy
const { x: xRef, y: yRef } = toRefs(state);
xRef.value++;            // state.x is also 2
// useful when returning from a composable:
function useMouse() {
  const pos = reactive({ x: 0, y: 0 });
  return toRefs(pos);    // caller can destructure
}

说明:toRefs 把 reactive 对象每个属性变成一个指向 proxy 的 ref,双向同步。"我解构了 reactive,响应性没了"的标准解法。Composable 返回值就该 toRefs,调用方解构起来才舒服。

toRef:单个属性变 ref

响应式
import { reactive, toRef } from 'vue';
const state = reactive({ count: 0 });
const countRef = toRef(state, 'count');
countRef.value++;        // state.count is also 1

// 3.3+ getter-based form
const upper = toRef(() => state.name.toUpperCase());

说明:toRef 给 reactive 对象的某一个属性单独建一个 ref,和 toRefs 同理,但只挑一个。Vue 3.3+ 也支持 getter 形式,能把任意计算表达式包成 ref 传来传去。只需要一个 key 时省得整对象都转。

isRef / unref:类型守卫

响应式
import { isRef, unref, ref } from 'vue';
function useDouble(x: number | Ref<number>) {
  // unref returns x if it is a ref, else x itself
  return computed(() => unref(x) * 2);
}
const a = ref(10);
isRef(a);     // true
unref(a);     // 10
unref(99);    // 99

说明:isRef 判断是不是 ref。unref 是"管它是不是 ref,直接给我值"的简写,等价 `isRef(x) ? x.value : x`。写 composable 时支持"值或 ref"参数特别好用,调用方爱传哪种传哪种。

customRef:自定义追踪逻辑

响应式
import { customRef } from 'vue';
function useDebouncedRef<T>(value: T, delay = 300) {
  let timer: number;
  return customRef<T>((track, trigger) => ({
    get() { track(); return value; },
    set(newValue) {
      clearTimeout(timer);
      timer = setTimeout(() => { value = newValue; trigger(); }, delay);
    }
  }));
}
const text = useDebouncedRef('', 500);

说明:customRef 暴露底层的 track / trigger,让你自定义触发时机,防抖、节流、校验都行。读 `.value` 时调 `track()`,要通知读者重算时调 `trigger()`。业务代码很少用,写库时有用。

watch 的 getter 与 ref 两种 source

响应式
import { ref, reactive, watch } from 'vue';
const r = ref(0);
const state = reactive({ n: 0 });

// ref source — pass the ref itself
watch(r, (val) => console.log(val));

// getter source — pass () => expression
watch(() => state.n, (val) => console.log(val));

// getter returning multiple values needs an array source or object
watch(() => ({ ...state }), (snap) => console.log(snap));

说明:watch 的 source 可以是 ref(直接传)、getter `() => 表达式`(任意计算表达式或 reactive 的单个属性)、reactive 对象(自动深 watch),或它们的数组。要监听 reactive 对象的某一个字段就用 getter 形式;直接把属性值传进去拿到的是当下快照,不是被追踪的 source。

watch 的 flush 时机:pre / post / sync

响应式
import { ref, watch, nextTick } from 'vue';
const list = ref<number[]>([]);

// default flush: 'pre' — runs BEFORE the component re-renders
watch(list, () => { /* DOM still old here */ });

// flush: 'post' — runs AFTER the DOM is updated (read updated DOM)
watch(list, () => { measureRenderedList(); }, { flush: 'post' });

// flush: 'sync' — runs synchronously on every change (use sparingly)
watch(list, () => { /* fires immediately, no batching */ }, { flush: 'sync' });

说明:`flush` 选项控制 watch 回调相对渲染周期的触发时机。默认 `pre` 在组件重渲染前触发(此时 DOM 还是旧的)。`post` 在 Vue 给 DOM 打补丁后触发,能量到更新后的布局(等价于在回调里包一层 `nextTick`)。`sync` 每次变化同步触发、不做批处理,很贵,只在特殊场景用。

effectScope:成组创建与销毁副作用

响应式
import { effectScope, ref, watch, watchEffect } from 'vue';

const scope = effectScope();
scope.run(() => {
  const count = ref(0);
  watch(count, () => { /* … */ });
  watchEffect(() => { /* … */ });
});

// dispose every effect created inside the scope at once
scope.stop();

说明:effectScope 把在它的 `run` 回调里创建的所有响应式副作用(watch / watchEffect / computed)收集起来,一次 `scope.stop()` 全部销毁。适合在组件 setup 之外创建副作用的 composable,或自己管理响应式生命周期、不依赖组件实例的库。

markRaw:让对象不被响应式代理

响应式
import { markRaw, reactive } from 'vue';
import { Chart } from 'chart.js';

const state = reactive({
  // a third-party instance you never want proxied
  chart: markRaw(new Chart(/* … */)),
  config: { theme: 'dark' },
});
// state.chart stays the raw instance, methods like .update() keep working

说明:markRaw 给对象打标记,让 Vue 永远不把它包成响应式代理,哪怕嵌在 reactive state 里也不包。适合大的不可变数据、第三方库的类实例(图表对象、地图实例、编辑器文档),这些被代理会破坏内部 `this` 判断或白白浪费内存。一旦标记就无法取消。

toRaw:拿到代理背后的原始对象

响应式
import { reactive, toRaw } from 'vue';
const state = reactive({ list: [1, 2, 3] });

const raw = toRaw(state);
// raw is the plain non-reactive object — mutating it does NOT trigger updates
console.log(raw === state);  // false (state is a Proxy)

// common use: pass a clean object to a structuredClone / JSON / worker
const snapshot = structuredClone(toRaw(state));

说明:toRaw 返回 reactive / readonly / shallowReactive 代理所包裹的原始对象。通过原始对象读不会被追踪,写也不会触发更新。当你要把普通对象交给"遇到代理就报错"的 API 时用它(structuredClone、IndexedDB、postMessage 给 worker),但绝不要把原始对象再存回去当数据源。

computed 拿到上一次的值 (3.4+)

响应式
import { ref, computed } from 'vue';
const count = ref(2);

// the getter receives the previous computed result
const clamped = computed((prev) => {
  if (count.value <= 3) return count.value;
  // keep the last valid value when out of range
  return prev as number;
});

说明:Vue 3.4 起,computed 的 getter 第一个参数就是上一次算出的值。用它在新输入越界时保留上一个有效结果,或构建"部分依赖自身历史"的值(夹住的滚动位置、防抖后的显示值),不用再额外开一个 ref。

isReactive / isReadonly / isProxy

响应式
import { reactive, readonly, isReactive, isReadonly, isProxy } from 'vue';
const r = reactive({ x: 1 });
const ro = readonly(r);

isReactive(r);     // true
isReadonly(ro);    // true
isReactive(ro);    // true (readonly of a reactive is still reactive)
isProxy(r);        // true
isProxy({});       // false

说明:这几个守卫判断一个值是不是代理、是哪种代理。isReactive 对 reactive / shallowReactive 代理为真,isReadonly 对 readonly / shallowReadonly 为真,isProxy 对它们任意一种都为真。主要用在库代码或通用工具里,需要根据"拿到的是原始对象还是 Vue 代理"分支处理时。

triggerRef:手动通知 shallowRef 更新

响应式
import { shallowRef, triggerRef } from 'vue';
const state = shallowRef({ count: 0 });

// nested mutation does NOT auto-trigger on a shallowRef
state.value.count++;
triggerRef(state);   // force dependents to re-run now

说明:triggerRef 强制依赖某个 shallowRef(或 customRef)的副作用立刻重跑,哪怕 `.value` 没有被重新赋值。当你出于性能考虑故意修改 shallowRef 内部时用它,你跳过了深响应,就得自己负责告诉 Vue 何时更新。对普通 `ref` 没意义,它本就追踪深层变化。

watchPostEffect / watchSyncEffect

响应式
import { watchPostEffect, watchSyncEffect } from 'vue';

// shorthand for watchEffect(fn, { flush: 'post' })
watchPostEffect(() => {
  // runs AFTER DOM update — read measured layout safely
});

// shorthand for watchEffect(fn, { flush: 'sync' })
watchSyncEffect(() => {
  // runs synchronously on each dependency change
});

说明:这两个是 watchEffect 的 flush 时机简写。`watchPostEffect` 等于 `watchEffect(fn, { flush: "post" })`,副作用在 DOM 打补丁后运行,正适合读取更新后的元素尺寸或位置。`watchSyncEffect` 是 `sync` 变体,每次变化同步触发、不批处理;只在确实无法容忍微任务延迟的场景少量使用。

生命周期 (11)

onMounted:首次挂载后

生命周期
import { onMounted, ref } from 'vue';
const elRef = ref<HTMLDivElement | null>(null);

onMounted(() => {
  // DOM is ready, refs are attached
  console.log(elRef.value?.offsetHeight);
  const obs = new ResizeObserver(/*…*/);
  obs.observe(elRef.value!);
});

说明:组件挂载、DOM 进入文档之后调用一次。这时候 template ref 不为 null,量尺寸、挂 observer、初始化第三方 DOM 库都在这里。 不要 用它做"不依赖 DOM"的一次性请求(那种直接在 setup 里调就行,SSR 时还能流式输出)。

Options API 对照写法
mounted() { /* … */ }

onUpdated:每次重渲染后

生命周期
import { onUpdated, ref } from 'vue';
const listRef = ref<HTMLUListElement | null>(null);

onUpdated(() => {
  // runs after EVERY update — keep it cheap
  listRef.value?.scrollTo({ top: listRef.value.scrollHeight });
});

说明:组件因响应式数据变化重渲染后触发。同步"依赖最新 DOM"的事很有用(聊天滚到底、聚焦刚插入的输入框)。每次更新都跑,里面别做重活,能用 watch 监听具体值就用 watch。

Options API 对照写法
updated() { /* … */ }

onUnmounted:从 DOM 移除前

生命周期
import { onMounted, onUnmounted } from 'vue';
onMounted(() => {
  const id = setInterval(tick, 1000);
  onUnmounted(() => clearInterval(id));   // colocated cleanup
});

// or pair with the listener
const onResize = () => { /* … */ };
onMounted(() => window.addEventListener('resize', onResize));
onUnmounted(() => window.removeEventListener('resize', onResize));

说明:组件即将卸载时调用。把 onMounted 里建的所有东西清掉:定时器、订阅、全局监听、ResizeObserver。漏写这个是"SPA 用着用着越来越卡"的第一名 bug 源头。

Options API 对照写法
beforeUnmount() { /* … */ }

onBeforeMount:首次渲染前

生命周期
import { onBeforeMount } from 'vue';
onBeforeMount(() => {
  // reactive state is ready, but DOM is NOT yet rendered
  // refs are still null — DO NOT read them here
  console.log('about to mount');
});

说明:第一次渲染前调用,响应式 state 准备好了但 DOM 还不存在,template ref 还是 null。业务代码几乎用不上;"挂载前"的需求直接写在 `<script setup>` 里就行,setup 本身就是"挂载前"。

Options API 对照写法
beforeMount() { /* … */ }

onBeforeUpdate:重渲染前

生命周期
import { onBeforeUpdate, ref } from 'vue';
const listRef = ref<HTMLUListElement | null>(null);
let prevScroll = 0;

onBeforeUpdate(() => {
  // DOM is still pre-update — measure the old state
  prevScroll = listRef.value?.scrollTop ?? 0;
});

说明:Vue 给 DOM 打补丁前触发。这时候 DOM 还是 上一次 渲染的样子。适合捕获更新前的测量值,等 patch 后再恢复(头部插入条目时保留滚动位置、列表洗牌前记下焦点)。

Options API 对照写法
beforeUpdate() { /* … */ }

onBeforeUnmount:卸载前最后机会

生命周期
import { onBeforeUnmount } from 'vue';
onBeforeUnmount(() => {
  // DOM and reactive state are still alive
  // send the draft, save scroll position, etc.
  saveDraftToServer();
});

说明:卸载前一刻调用,DOM 和响应式 state 都还在,ref 还指着活元素。给 analytics、保存未提交输入、发个 fire-and-forget 清理的最后机会。onUnmounted 是 之后 才跑。

Options API 对照写法
beforeUnmount() { /* … */ }

onErrorCaptured:子组件错误边界

生命周期
import { onErrorCaptured, ref } from 'vue';
const errorMsg = ref<string | null>(null);

onErrorCaptured((err, instance, info) => {
  errorMsg.value = err.message;
  console.error('caught from child:', info);
  return false;   // stop propagation; return true / undef to bubble
});

说明:捕获后代组件抛出的错误(渲染、生命周期、watch 回调、setup)。返回 `false` 阻止冒泡到全局 app.config.errorHandler。Vue 里最接近 React error boundary 的写法,把可能出错的子树包到一个注册了这个 hook 的组件里。

Options API 对照写法
errorCaptured(err, vm, info) { /* … */ }

onActivated / onDeactivated:配 <KeepAlive>

生命周期
import { onActivated, onDeactivated } from 'vue';

onActivated(() => {
  // entered from a kept-alive cache — refresh data, resume timers
  refreshList();
});
onDeactivated(() => {
  // cached, not destroyed — pause work, save scroll
  pausePolling();
});

说明:组件被 <KeepAlive> 包住时不会真销毁,只是被"停放"。每次回到屏幕上 onActivated 触发,每次被收起来 onDeactivated 触发。回来时刷新过期数据、恢复轮询;离开时暂停工作。

Options API 对照写法
activated() { /* … */ }, deactivated() { /* … */ }

nextTick:等 DOM 更新完成

生命周期
import { ref, nextTick } from 'vue';
const show = ref(false);
const inputRef = ref<HTMLInputElement | null>(null);

async function reveal() {
  show.value = true;
  // DOM has NOT updated yet on this line
  await nextTick();
  // now the v-if element exists — safe to focus
  inputRef.value?.focus();
}

说明:Vue 把响应式变更批量收集,放到下一个微任务统一刷新,所以你改完 state 那一行 DOM 还没更新。nextTick 返回一个在"待处理的 DOM 更新已应用"后 resolve 的 promise。要测量或聚焦一个刚因 state 变化而渲染出来的元素,先 await 它。

onServerPrefetch:SSR 阶段预取数据

生命周期
import { ref, onServerPrefetch } from 'vue';
const data = ref<User | null>(null);

onServerPrefetch(async () => {
  // runs on the server before HTML is serialized
  data.value = await fetchUser();
});

// on the client, fetch in onMounted instead if data is empty

说明:onServerPrefetch 注册一个在 SSR 服务端、组件序列化成 HTML 之前运行的异步钩子,让你 await 数据,使其随首屏 HTML 一起发出。它不在客户端运行。`<script setup>` 里配 <Suspense> 的顶层 await 能更简洁地满足同样需求;无法让 setup 异步时再用这个钩子。

onRenderTracked / onRenderTriggered(调试)

生命周期
import { onRenderTracked, onRenderTriggered } from 'vue';

// which deps the render tracked (fires on first render + updates)
onRenderTracked((e) => {
  console.log('tracked', e.type, e.key);
});

// which dep change caused THIS re-render
onRenderTriggered((e) => {
  console.log('triggered by', e.type, e.key, e.target);
});

说明:两个仅开发环境的调试钩子。onRenderTracked 对渲染读到的每个响应式依赖各触发一次(帮你发现意外依赖)。onRenderTriggered 在被追踪的依赖变化导致重渲染时触发,精确报出是哪个 key 变了,是回答"这组件为啥又重渲染了"最快的办法。生产构建里两者都是空操作。

组件 (20)

defineComponent:带类型的组件工厂

组件
import { defineComponent } from 'vue';

export default defineComponent({
  name: 'UserCard',
  props: { id: { type: Number, required: true } },
  setup(props) {
    // props.id is typed as number
    return { /* … */ };
  }
});

说明:把选项对象包一层,让 TypeScript 能推出 props / emits 的类型。只在 `.ts` 文件或用选项对象写组件时才需要。 `<script setup>` 里不用 defineComponent,它是隐式的。

defineProps:在 <script setup> 里声明 props

组件
<script setup lang="ts">
// runtime declaration
const props = defineProps({
  id: { type: Number, required: true },
  tags: { type: Array as PropType<string[]>, default: () => [] }
});

// TS type-only declaration (3.3+: supports external types)
const props = defineProps<{
  id: number;
  tags?: string[];
}>();

// 3.3+: with defaults via destructure
const { id, tags = [] } = defineProps<{ id: number; tags?: string[] }>();
</script>

说明:`<script setup>` 里声明 props 的编译时宏。两种写法:运行时(`defineProps({...})`)给你 Vue 运行时校验;纯类型(`defineProps<{...}>()`)只给 TS 类型,没运行时检查。Vue 3.3+ 支持引入外部类型,也支持解构 props + 默认值且保持响应。

defineEmits:声明事件

组件
<script setup lang="ts">
// type-only — recommended
const emit = defineEmits<{
  (e: 'select', id: number): void;
  (e: 'close'): void;
}>();

// 3.3+: object syntax (shorter)
const emit = defineEmits<{
  select: [id: number];
  close: [];
}>();

emit('select', 42);
</script>

说明:编译时宏,声明组件会触发哪些事件、负载是什么类型(TS 写法)。返回的 `emit` 全类型,事件名拼错或负载类型不对都是 TS 报错。3.3+ 的 tuple 形式(`select: [id: number]`)比 call-signature 写法短。

defineExpose:暴露给父组件 ref

组件
<!-- Child.vue -->
<script setup lang="ts">
const open = () => { /* … */ };
defineExpose({ open });   // parent can call childRef.value?.open()
</script>

<!-- Parent.vue -->
<script setup lang="ts">
import Child from './Child.vue';
const childRef = ref<InstanceType<typeof Child> | null>(null);
const handleClick = () => childRef.value?.open();
</script>
<template><Child ref="childRef" /></template>

说明:`<script setup>` 默认全闭包,父组件 template ref 啥都拿不到。defineExpose 主动把指定的函数或值暴露出去。少用,优先 props 向下 / 事件向上。命令式 API(打开 / 聚焦 / 滚动到视野)没法声明式表达时再用。

defineModel:双向绑定 (3.4+)

组件
<!-- Child.vue (3.4+) -->
<script setup lang="ts">
const model = defineModel<string>();           // v-model default
const title = defineModel<string>('title');    // v-model:title
const search = defineModel<string>('search', { required: true });
</script>
<template>
  <input v-model="model" />
</template>

<!-- Parent.vue -->
<Child v-model="text" v-model:title="t" />

说明:3.4+ 的双向绑定简写。defineModel 返回 ref,写它父组件 v-model 跟着变,读它拿父组件当前值。替代了过去的 prop + emit 模板(`modelValue` + `update:modelValue`)。多个 v-model 用具名参数(`defineModel("title")`)。

defineOptions:setup 里设组件选项

组件
<script setup lang="ts">
defineOptions({
  name: 'UserCard',          // for DevTools + recursion
  inheritAttrs: false,       // don't auto-bind $attrs to root
});
</script>

说明:编译时宏(3.3+),用来设过去得另开 `<script>` 块才能设的选项,`name`、`inheritAttrs`、`customOptions`。所有声明都能留在 `<script setup>` 里,不用再 setup 和 options 混写。

defineSlots:带类型的插槽签名

组件
<script setup lang="ts">
defineSlots<{
  default(props: { item: User }): any;
  header?(props: { count: number }): any;
}>();
</script>
<template>
  <header><slot name="header" :count="items.length" /></header>
  <slot v-for="i in items" :item="i" :key="i.id" />
</template>

说明:编译时宏(3.3+),声明组件每个插槽的 prop 签名,让父组件用 `<template v-slot:default="{ item }">` 时能拿到完整的 TS 推断。3.3 之前插槽 prop 实际上是 `any`,defineSlots 把这块补上了。

withDefaults:给纯类型 props 加默认值

组件
<script setup lang="ts">
interface Props { size?: 'sm' | 'md' | 'lg'; tags?: string[]; }
const props = withDefaults(defineProps<Props>(), {
  size: 'md',
  tags: () => []     // object/array defaults MUST be functions
});
</script>

说明:用纯类型 `defineProps<{...}>()` 时会丢掉 Vue 的默认值能力,withDefaults 把它补回来。 对象 / 数组默认值必须用函数返回,否则所有实例共用同一个引用。(Vue 3.5+ 解构 + 默认值的写法大部分场景能替代 withDefaults。)

defineAsyncComponent:懒加载

组件
import { defineAsyncComponent } from 'vue';

const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'));

// with loading / error UI
const Editor = defineAsyncComponent({
  loader: () => import('./Editor.vue'),
  loadingComponent: Spinner,
  errorComponent: ErrorBox,
  delay: 200,
  timeout: 10_000
});

说明:把 dynamic import 包成一个占位组件,在 chunk 到达前挂起。配路由懒路由做代码分割;或用对象形式 `delay` 毫秒后显示 spinner、`timeout` 后显示错误 UI。完整形式还支持 `onError` 重试。

<Suspense>:异步 setup 边界

组件
<template>
  <Suspense>
    <template #default>
      <!-- component whose setup() is async -->
      <UserProfile :id="userId" />
    </template>
    <template #fallback>
      <div>Loading…</div>
    </template>
  </Suspense>
</template>

<!-- UserProfile.vue -->
<script setup lang="ts">
const user = await fetch('/api/me').then(r => r.json());
</script>

说明:边界组件,等待后代组件的 `async setup()`(或 `<script setup>` 顶层 await)解析完再显示默认插槽,期间显示 fallback。还在 experimental 状态,但实际生产可用。配 defineAsyncComponent 可以在一个边界里同时处理"懒 chunk + 异步数据"。

<KeepAlive>:缓存切换的组件

组件
<template>
  <KeepAlive :include="['UserList', 'UserDetail']" :max="10">
    <component :is="currentTab" />
  </KeepAlive>
</template>

说明:`<component :is="…">` 切走时缓存实例而不卸载,切回来 state 和 DOM 都还在。`include` / `exclude` 按组件名过滤;`max` 限制 LRU 上限。配 onActivated / onDeactivated 在回来时刷新过期数据。

<Teleport>:把内容渲染到 DOM 别处

组件
<template>
  <button @click="open = true">Open</button>
  <Teleport to="body">
    <div v-if="open" class="modal-overlay">
      <div class="modal">Dialog content</div>
    </div>
  </Teleport>
</template>

<!-- conditionally disable to render in place -->
<Teleport to="#drawer" :disabled="isMobile">…</Teleport>

说明:Teleport 把插槽内容搬到真实 DOM 的另一处(CSS 选择器或元素),但在逻辑上它仍是当前组件的子节点,props、事件、provide/inject 照常流动。最经典的用法是模态框和 toast,需要挂到 `body` 下以逃出某个 `overflow:hidden` 或 `z-index` 祖先。`:disabled` 为真时就地渲染。

<Transition>:单元素进入/离开过渡

组件
<template>
  <Transition name="fade">
    <p v-if="show">hello</p>
  </Transition>
</template>

<style>
.fade-enter-active, .fade-leave-active { transition: opacity .3s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

说明:Transition 为通过 v-if、v-show、动态组件或路由切换而插入/移除的单个元素或组件加进出动画。你给它一个 `name`,Vue 在恰当时机切换六个 CSS 类(`-enter-from`、`-enter-active`、`-enter-to` 以及离开三件套),动画就用纯 CSS 写。配 `mode="out-in"` 可以让一个元素完全离开后下一个再进来。

<TransitionGroup>:列表增删与重排动画

组件
<template>
  <TransitionGroup name="list" tag="ul">
    <li v-for="item in items" :key="item.id">{{ item.text }}</li>
  </TransitionGroup>
</template>

<style>
.list-enter-from, .list-leave-to { opacity: 0; transform: translateY(8px); }
.list-enter-active, .list-leave-active { transition: all .3s ease; }
.list-move { transition: transform .3s ease; }  /* smooth reorder */
</style>

说明:TransitionGroup 给 v-for 渲染的列表加动画,条目增删时进出动画,重排时特殊的 `-move` 类用 FLIP 技术让兄弟元素平滑滑到新位置。通过 `tag` 渲染一个真实包裹元素。每个子节点都必须有唯一 `:key`,move 动画才能正确追踪条目。

动态组件:<component :is>

组件
<script setup lang="ts">
import Foo from './Foo.vue';
import Bar from './Bar.vue';
import { shallowRef } from 'vue';
const current = shallowRef(Foo);   // shallowRef, not ref
</script>
<template>
  <component :is="current" :some-prop="x" @evt="onEvt" />
  <!-- :is also accepts a registered name string or an HTML tag -->
  <component :is="ok ? 'a' : 'span'">link</component>
</template>

说明:内置的 `<component>` 渲染 `:is` 解析出的任意东西,组件定义、全局注册的名字字符串,甚至是普通 HTML 标签名。props 和事件照常透传。把组件引用放在 `shallowRef`(不是 `ref`)里,免得 Vue 白白深度代理组件对象。套 <KeepAlive> 可在切换间保留状态。

递归组件:引用自身

组件
<!-- TreeNode.vue -->
<script setup lang="ts">
defineProps<{ node: { label: string; children?: any[] } }>();
defineOptions({ name: 'TreeNode' });   // name enables self-reference
</script>
<template>
  <li>
    {{ node.label }}
    <ul v-if="node.children?.length">
      <TreeNode v-for="c in node.children" :key="c.id" :node="c" />
    </ul>
  </li>
</template>

说明:组件可以渲染自身,用来展示树、评论楼层这类递归数据。在 SFC 里,文件名(或用 defineOptions 显式指定的 `name`)让模板能按名引用自己。递归一定要加终止条件(`v-if="node.children"`),否则无界自调用会爆栈。

全局注册与局部注册组件

组件
// global — available in every template, but always in the bundle
import { createApp } from 'vue';
import BaseButton from './BaseButton.vue';
const app = createApp(App);
app.component('BaseButton', BaseButton);

// local — import where used, tree-shakeable (preferred)
<script setup>
import BaseButton from './BaseButton.vue';  // just use it in template
</script>

说明:全局注册(`app.component`)让组件在任意模板里免导入使用,但无论用没用都留在包里、伤 tree-shaking,只留给少数真正全应用通用的基础组件。局部注册(在 `<script setup>` 里 import)是默认选择:显式、可 tree-shake,对工具链和 IDE 跳转也更友好。

透传属性:$attrs

组件
<!-- Child with a single root: attrs auto-apply to it -->
<template>
  <button class="btn"><slot /></button>
  <!-- parent's class / @click / id land on this button automatically -->
</template>

<!-- multi-root or custom target: bind $attrs yourself -->
<script setup>
defineOptions({ inheritAttrs: false });
</script>
<template>
  <label>{{ label }}</label>
  <input v-bind="$attrs" />
</template>

说明:父组件加在组件上、又没声明成 prop 的属性(class、style、id、事件监听)会自动"透传"到唯一的根元素上。当有多个根节点,或包裹元素不是真正目标时,设 `inheritAttrs: false` 并把 `v-bind="$attrs"` 绑到你真正想要的元素上(比如带 label 字段里内层的 `<input>`)。

app.config.globalProperties

组件
import { createApp } from 'vue';
const app = createApp(App);

// add a property available on every component instance
app.config.globalProperties.$formatDate = (d: Date) =>
  d.toLocaleDateString();

// TS augmentation so $formatDate is typed in templates
declare module 'vue' {
  interface ComponentCustomProperties {
    $formatDate: (d: Date) => string;
  }
}

说明:globalProperties 给每个组件挂一个值或工具,在 Options API 里通过 `this.$x`、在模板里通过 `$x` 访问,是 Vue 2 `Vue.prototype.$x` 在 Vue 3 的替代。它只是模板语法糖(`<script setup>` 脚本里用不了,那里直接 import 工具就好)。扩展 `ComponentCustomProperties` 获得类型安全。少用,显式 import 或 provide/inject 通常更干净。

app.provide:应用级注入

组件
import { createApp } from 'vue';
const app = createApp(App);

// available to EVERY component, no provider component needed
app.provide('apiBase', 'https://api.example.com');

// any component, any depth
<script setup>
import { inject } from 'vue';
const apiBase = inject<string>('apiBase');
</script>

说明:app.provide 在应用根注册一个注入,任何组件都能 `inject` 它,不需要包裹型 provider 组件,适合在启动时一次性给出全应用配置、HTTP 客户端或功能开关。和组件级 `provide` 不同,它不局限于某个子树,而是整个应用的全局默认。库代码用 Symbol key 避免冲突。

指令 (17)

v-if / v-else-if / v-else

指令
<div v-if="status === 'loading'">Loading…</div>
<div v-else-if="error">{{ error.message }}</div>
<div v-else>Hello {{ user.name }}</div>

<!-- group multiple elements with <template> -->
<template v-if="ready">
  <h1>Title</h1>
  <p>Body</p>
</template>

说明:条件渲染,为假时元素及子树根本不在 DOM 里。切换开销比 v-show 大(挂载 / 卸载),但隐藏时无 DOM,空闲开销更低。`<template v-if>` 用来包多个兄弟元素而不引入额外 DOM 节点。

Options API 对照写法
// Options API uses the same template directives

v-show:用 display:none 切换

指令
<div v-show="open">always rendered, just hidden when false</div>

说明:元素永远渲染,只是切换 `style.display`。切换更便宜,空闲时 DOM 更多。频繁切换的小元素用 v-show;大子树或"隐藏时就不该存在"的(自带数据加载的模态框)用 v-if。

v-for:列表渲染配 :key

指令
<li v-for="(item, idx) in items" :key="item.id">
  {{ idx + 1 }}. {{ item.name }}
</li>

<!-- iterate object -->
<li v-for="(val, key) in obj" :key="key">{{ key }}: {{ val }}</li>

<!-- range -->
<span v-for="n in 5" :key="n">{{ n }}</span>

说明:对数组 / 对象 / Map / Set / 整数范围每个元素渲染一次。 一定 要给稳定的 `:key`(通常 `item.id`), 不给,Vue 用 index,插入 / 删除 / 排序时会复用错 DOM。可变顺序的列表绝不能用 index 当 key。

Options API 对照写法
<li v-for="item in items" :key="item.id">{{ item.name }}</li>

v-model:输入元素双向绑定

指令
<input v-model="text" />
<input type="checkbox" v-model="agreed" />
<input type="checkbox" v-model="selected" value="a" />
<select v-model="picked"><option>x</option><option>y</option></select>

<!-- modifiers -->
<input v-model.lazy="text" />     <!-- sync on change instead of input -->
<input v-model.number="age" />    <!-- cast to number -->
<input v-model.trim="title" />    <!-- trim whitespace -->

说明:表单输入的双向绑定。底层展开成 `:value` + `@input`(或 `.lazy` 时的 `@change`)。修饰符 `.lazy`(失焦 / change 才同步)、`.number`(转数字)、`.trim`(去空格)省样板。自定义组件看 defineModel。

v-bind:绑定任意属性 / prop

指令
<img v-bind:src="url" :alt="alt" />     <!-- : is shorthand -->

<!-- bind an object — spreads keys -->
<div v-bind="{ id, class: cls, 'data-x': 1 }"></div>

<!-- bind class / style -->
<div :class="['btn', { active: isActive }, sizeCls]"></div>
<div :style="{ color, fontSize: size + 'px' }"></div>

说明:把 JS 表达式绑到 HTML 属性或组件 prop 上。冒号(`:src`)是简写。两种特殊形式:`v-bind="obj"` 把对象所有 key 展开(传 `$attrs` 透传特别有用),`:class` / `:style` 接受数组 / 对象做条件组合。

v-on:事件监听

指令
<button v-on:click="handle">go</button>
<button @click="handle">go</button>    <!-- @ shorthand -->

<button @click="count++">inline</button>
<button @click="handle($event, item)">with extra arg</button>

<!-- modifiers -->
<form @submit.prevent="save"></form>          <!-- preventDefault -->
<button @click.stop="x"></button>             <!-- stopPropagation -->
<input @keyup.enter="submit" />               <!-- keycode shortcut -->
<input @keyup.ctrl.s.prevent="save" />        <!-- chord + prevent -->
<div @click.self="closeModal"></div>          <!-- only when target === self -->
<div @click.once="trackOnce"></div>           <!-- fire once -->

说明:监听 DOM 或自定义事件。`@` 是简写。修饰符覆盖了 90% 的"事件样板":`.prevent`(preventDefault)、`.stop`(stopPropagation)、`.self`(只在 target 是自身时触发)、`.once`(只触发一次)、`.capture`(捕获阶段)、按键和弦(`.ctrl.s`)。

v-html:渲染原始 HTML

指令
<div v-html="trustedHtml"></div>

<!-- NEVER do this with user input — XSS -->
<div v-html="userBio"></div>   <!-- DANGEROUS -->

说明:把元素的 innerHTML 设成绑定的字符串。 只 能用在你自己生成或清洗过的 HTML,把不受信任的用户内容直接喂进去等于开 XSS 大门,和原生 innerHTML 一样危险。先过 DOMPurify 或服务端清洗。

v-text:渲染为文本

指令
<span v-text="msg"></span>
<!-- equivalent to -->
<span>{{ msg }}</span>

说明:设 `textContent`。等价 `{{ msg }}`,但是写成属性形式,会覆盖元素已有的子内容。常见用法是 `<noscript>` 里写硬编码骨架时保持模板整洁。日常更推荐 mustache 插值。

v-cloak:编译完成前隐藏

指令
<style>[v-cloak] { display: none; }</style>

<div v-cloak>
  {{ message }}
</div>

说明:渐进式接管服务端渲染页面时,挡住"还没编译完的 mustache 标记"那一闪(FOUC)。要配 CSS `[v-cloak] { display: none; }`。纯 SPA / Vite 项目几乎用不上。

v-pre:跳过编译

指令
<span v-pre>{{ this will appear literally }}</span>

说明:让 Vue 跳过这个元素及其子树的编译,mustache 大括号会原样显示。文档 / 速查表里要展示未渲染的 Vue 语法,或加速一大块纯静态内容,用它。

v-once:只渲染一次

指令
<h1 v-once>{{ title }}</h1>
<!-- title can change later — this element will not update -->

说明:元素只在 首次 挂载渲染一次,之后响应式更新全部跳过。首屏后绝不会变的内容用(法律条款、服务端注入的初始值)。长列表里某一格是静态的,套 v-once 能跳过 diff 提速。

自定义指令:vClickOutside

指令
// directives/clickOutside.ts
import type { Directive } from 'vue';
export const vClickOutside: Directive<HTMLElement, () => void> = {
  mounted(el, binding) {
    el._click = (e: Event) => {
      if (!el.contains(e.target as Node)) binding.value();
    };
    document.addEventListener('click', el._click);
  },
  unmounted(el) { document.removeEventListener('click', el._click); }
};

// in component
<script setup>
import { vClickOutside } from './directives/clickOutside';
</script>
<template>
  <div v-click-outside="close">…</div>
</template>

说明:自定义指令暴露 mounted / updated / unmounted / beforeMount 等钩子,直接操作 DOM。模板里的 kebab-case 名(`v-click-outside`)对应变量 `vClickOutside`。处理底层 DOM 需求用指令;封装可复用 逻辑 更应该用 composable。

组件上的 v-model:参数与修饰符

指令
<!-- multiple bindings + custom modifier -->
<UserForm
  v-model:first-name="first"
  v-model:last-name.capitalize="last"
/>

<!-- Child reads the modifier (3.4+ defineModel) -->
<script setup lang="ts">
const [last, lastMods] = defineModel<string>('lastName');
function onInput(v: string) {
  last.value = lastMods.capitalize ? v[0].toUpperCase() + v.slice(1) : v;
}
</script>

说明:在组件上,`v-model:name` 绑定具名 model,`.修饰符` 加上子组件可读取的自定义修饰符。用 defineModel(3.4+)解构 `[model, modifiers]` 即可读出父组件加了哪些修饰符(比如 `.capitalize`)并据此转换值。这样就能做出和原生 v-model 体验一致、还带 `.trim` 之类选项的输入组件。

v-memo:跳过子树的重渲染

指令
<!-- only re-renders this row when item.id or item.active changes -->
<div
  v-for="item in list"
  :key="item.id"
  v-memo="[item.id, item.active]"
>
  <!-- expensive cell content -->
  <HeavyCell :item="item" />
</div>

说明:v-memo 接收一个依赖数组,只有数组里的某个值变化时才重渲染该元素及其子树,相当于模板层的 React.memo。这是针对超大列表(上千行、每次更新大部分行不变)的小众微优化。用错(依赖写漏)会导致 UI 不更新,所以只在 profiling 证明确实需要后再用。

带参数和修饰符的自定义指令

指令
// v-tooltip:top.delay="text"
import type { Directive } from 'vue';
export const vTooltip: Directive<HTMLElement, string> = {
  mounted(el, binding) {
    const placement = binding.arg ?? 'bottom';   // 'top'
    const delayed = binding.modifiers.delay;      // true
    el.dataset.tip = binding.value;               // the text
    el.dataset.place = placement;
    if (delayed) el.dataset.delay = '500';
  },
  updated(el, binding) { el.dataset.tip = binding.value; },
};

说明:自定义指令的 binding 携带的信息不止 value:`binding.arg` 是冒号后面那段(`v-tooltip:top` 得到 `"top"`),`binding.modifiers` 是点号标志组成的对象(`.delay` 得到 `{ delay: true }`),`binding.oldValue` 让 `updated` 钩子能和上一个值做对比。用它们让一个指令可配置(位置、延迟、变体),不必拆成多个指令。

<style> 里的 v-bind:响应式 CSS

指令
<script setup>
import { ref } from 'vue';
const theme = ref('#22d3ee');
const size = ref(16);
</script>
<template><p class="label">colored</p></template>
<style scoped>
.label {
  color: v-bind(theme);
  /* wrap expressions in quotes */
  font-size: v-bind('size + "px"');
}
</style>

说明:SFC 的 `<style>` 块里可以用 `v-bind(表达式)` 引用响应式 state,Vue 会把它编译成一个随 state 实时更新的 CSS 自定义属性,不用再手动拼 inline style。任何表达式(不是单纯标识符)都要用引号包起来。很适合主题色、动态间距,或由组件 state 驱动的进度宽度。

v-for 遍历 Map / Set

指令
<!-- Map: value, key, index -->
<li v-for="(val, key, i) in myMap" :key="key">
  {{ i }}. {{ key }} = {{ val }}
</li>

<!-- Set: value only -->
<li v-for="tag in mySet" :key="tag">{{ tag }}</li>

<script setup>
const myMap = reactive(new Map([['a', 1], ['b', 2]]));
const mySet = reactive(new Set(['vue', 'web']));
</script>

说明:v-for 不止能遍历数组和对象,也能遍历 Map 和 Set。Map 的别名顺序是 `(value, key, index)`;Set 只给到 value。两者用 reactive() 包裹后都保持响应式,增删条目会更新列表。Map 保留插入顺序,所以"渲染顺序要跟插入顺序一致"时用它很方便。

插槽 (6)

默认插槽:来自父组件的内容

插槽
<!-- Card.vue -->
<template>
  <div class="card">
    <slot>fallback when parent provides nothing</slot>
  </div>
</template>

<!-- Parent.vue -->
<Card>
  <p>arbitrary parent content lands here</p>
</Card>

说明:`<slot>` 元素是占位,由父组件在使用处填充。`<slot>` 开闭标签之间的内容是 默认值,父组件什么都不传时显示。默认插槽是最朴素的组合形式,任何"布局组件"都该开放一个。

具名插槽:多个插入点

插槽
<!-- Layout.vue -->
<template>
  <header><slot name="header" /></header>
  <main><slot /></main>           <!-- default slot -->
  <footer><slot name="footer" /></footer>
</template>

<!-- Parent.vue -->
<Layout>
  <template #header><h1>Title</h1></template>
  <p>main body — goes to the default slot</p>
  <template #footer><small>© 2026</small></template>
</Layout>

说明:多个 `<slot name="x">` 占位,父组件用 `<template v-slot:x>`(简写 `#x`)填。一个组件能开放多个可定制区域,卡片的 header / footer、模态框的 actions。没用 `<template>` 包的内容自动进默认插槽。

作用域插槽:子组件向上传数据

插槽
<!-- List.vue -->
<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <slot :item="item" :index="$index">{{ item.name }}</slot>
    </li>
  </ul>
</template>

<!-- Parent.vue -->
<List :items="users">
  <template #default="{ item, index }">
    <strong>{{ index + 1 }}.</strong> {{ item.name }} ({{ item.email }})
  </template>
</List>

说明:把子组件的数据反向暴露给父组件模板用。子写 `<slot :item="item">`,父解构 `#default="{ item }"`。这是 headless / renderless 组件的基础,子管逻辑,父管样式。配 defineSlots 拿全 TS 类型。

动态插槽名

插槽
<Layout>
  <template v-slot:[dynamicName]="slotProps">
    content for {{ dynamicName }}
  </template>
</Layout>

<!-- shorthand -->
<Layout>
  <template #[dynamicName]>…</template>
</Layout>

说明:插槽名本身是个 JS 表达式(放方括号里)。按 schema 循环把内容塞进对应插槽时有用,或包装子组件、动态把它所有具名插槽透传出去,看 `Object.keys($slots)` 那个套路。

条件插槽:用 $slots 判断

插槽
<script setup lang="ts">
import { useSlots } from 'vue';
const slots = useSlots();
</script>
<template>
  <div class="card">
    <header v-if="slots.header" class="card-head">
      <slot name="header" />
    </header>
    <slot />
    <footer v-if="$slots.footer"><slot name="footer" /></footer>
  </div>
</template>

说明:用 `$slots.name`(模板里)或 `useSlots()`(script 里)判断父组件是否给某个插槽传了内容,从而在内容为空时连同外层包裹元素一起省掉。最典型的场景是卡片:没传 header 插槽时,标题栏(连边框、内边距)都不应该出现。

无渲染组件模式

插槽
<!-- MouseTracker.vue — owns logic, renders nothing of its own -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
const x = ref(0), y = ref(0);
const move = (e: MouseEvent) => { x.value = e.clientX; y.value = e.clientY; };
onMounted(() => window.addEventListener('mousemove', move));
onUnmounted(() => window.removeEventListener('mousemove', move));
</script>
<template><slot :x="x" :y="y" /></template>

<!-- Parent owns ALL the markup -->
<MouseTracker v-slot="{ x, y }">
  <p>cursor at {{ x }}, {{ y }}</p>
</MouseTracker>

说明:无渲染组件把有状态逻辑打包,只通过作用域插槽暴露出来,自己不渲染任何标记,视觉部分全由父组件决定。它是 composable 出现之前、基于插槽的前身。现代 Vue 里 composable 通常更省事,但当逻辑还需要控制子内容挂在哪里时,无渲染模式依然好用。

路由 (13)

vue-router 基础配置

路由
// router.ts
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: () => import('./pages/Home.vue') },
    { path: '/about', component: () => import('./pages/About.vue') },
    { path: '/:pathMatch(.*)*', component: NotFound }   // 404
  ]
});

// main.ts
createApp(App).use(router).mount('#app');

// App.vue
<template>
  <RouterLink to="/">Home</RouterLink>
  <RouterView />
</template>

说明:createWebHistory 走 HTML5 History API(干净 URL,但服务端要配 fallback)。createWebHashHistory 走 `#/path`(免服务端配置)。route 可以 dynamic import 懒加载。`:pathMatch(.*)*` 是标准 404 通配。RouterView 必须挂某处,通常 App.vue。

动态路由 + 命名路由

路由
const routes = [
  { path: '/user/:id', name: 'user', component: User, props: true },
  { path: '/post/:id(\\d+)', component: Post }   // id must be digits
];

<!-- template -->
<RouterLink :to="{ name: 'user', params: { id: 42 }, query: { tab: 'bio' } }">
  Lei
</RouterLink>

<!-- inside User.vue -->
<script setup lang="ts">
import { useRoute } from 'vue-router';
const route = useRoute();
// route.params.id is reactive
</script>

说明:动态段(`:id`)对应 `route.params` 上的字段。可选正则(`(\d+)`)限制匹配范围。`props: true` 把 params 注入成组件 props,代码更干净。命名路由(`name: "user"`)按"名 + params"生成 URL,而不是硬编码路径,重构安全。

嵌套路由

路由
const routes = [
  {
    path: '/user/:id',
    component: User,
    children: [
      { path: '', component: Overview },          // /user/42
      { path: 'posts', component: Posts },        // /user/42/posts
      { path: 'settings', component: Settings }   // /user/42/settings
    ]
  }
];

// User.vue
<template>
  <h1>{{ user.name }}</h1>
  <RouterView />     <!-- child route renders here -->
</template>

说明:子路由在父组件的 `<RouterView>` 里渲染。空路径子(`path: ""`)是父路径单独匹配时的默认。tabs / 共享布局子页面的标准套路(用户主页带 posts / settings tabs)。URL 自然组合:`/user/42/posts`。

路由守卫:beforeEach

路由
router.beforeEach(async (to, from) => {
  const auth = useAuthStore();
  if (to.meta.requiresAuth && !auth.user) {
    return { name: 'login', query: { redirect: to.fullPath } };
  }
  // return true / undefined to allow
});

// per-route
const routes = [
  { path: '/admin', component: Admin, meta: { requiresAuth: true },
    beforeEnter: (to) => { /* … */ } }
];

// in-component
<script setup>
import { onBeforeRouteLeave } from 'vue-router';
onBeforeRouteLeave((to, from) => {
  if (dirty.value && !confirm('Discard changes?')) return false;
});
</script>

说明:拦截导航的钩子。返回 `true` / undefined 放行,`false` 取消,返回 route 对象就是重定向。三层:全局(`router.beforeEach`)、单路由(`beforeEnter`)、组件内(`onBeforeRouteLeave` / `onBeforeRouteUpdate`)。全局守卫每次跳转都跑,别做重活。

编程式导航

路由
<script setup>
import { useRouter } from 'vue-router';
const router = useRouter();

router.push('/about');
router.push({ name: 'user', params: { id: 42 } });
router.replace('/login');         // no history entry
router.go(-1);                    // browser back
router.back();                    // alias for go(-1)
router.forward();
</script>

说明:`<script setup>` 里用 useRouter 拿到 router 实例做编程式跳转。push 加历史记录;replace 不加(适合不想留在后退栈里的重定向);go(n) 在前进 / 后退两个方向跨 n 步。push / replace 都返回 promise,等导航完成可以 await。

useRoute:响应式读当前路由

路由
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router';
import { watch } from 'vue';
const route = useRoute();

// reactive — re-runs whenever params change
watch(() => route.params.id, (id) => loadUser(id), { immediate: true });

// access query / hash / meta
console.log(route.query.tab, route.hash, route.meta.requiresAuth);
</script>

说明:useRoute 返回当前路由的响应式对象,字段有 `params`、`query`、`hash`、`path`、`fullPath`、`meta`、`name`。watch 某个字段实现 URL 变化重新拉数据(比如同一组件下 `/user/1` → `/user/2`)。 不能 解构,会失去响应性。

懒加载路由组件

路由
const routes = [
  // each route becomes its own chunk
  { path: '/', component: () => import('./pages/Home.vue') },
  { path: '/dashboard', component: () =>
      import(/* webpackChunkName: "admin" */ './pages/Dashboard.vue') }
];

// with Suspense for async setup
<template>
  <Suspense>
    <RouterView />
    <template #fallback><Spinner /></template>
  </Suspense>
</template>

说明:`component` 传 `() => import(...)` 而不是直接 import,触发代码分割,每个路由各自一个 chunk,首次访问才下载。可选 `webpackChunkName` 魔法注释把多个路由打到同一个 chunk。配 <Suspense> 显示加载态。

RouterLink 的激活类名

路由
<!-- exact match gets router-link-exact-active too -->
<RouterLink to="/users">Users</RouterLink>

<!-- custom active classes -->
<RouterLink to="/users" active-class="on" exact-active-class="on-exact">
  Users
</RouterLink>

<!-- full control with v-slot -->
<RouterLink to="/users" custom v-slot="{ href, navigate, isActive }">
  <a :href="href" :class="{ active: isActive }" @click="navigate">Users</a>
</RouterLink>

说明:RouterLink 给"目标是当前路由前缀"的链接加 `router-link-active`,给精确匹配的加 `router-link-exact-active`,用 `active-class` / `exact-active-class` 改名。要完全掌控就传 `custom` 加 `v-slot`,它把 `href`、`navigate`、`isActive`、`isExactActive` 交给你,既能渲染自己的元素(带样式的按钮、列表项),又保留正确的 SPA 导航。

路由 meta 与类型扩展

路由
// augment the type so route.meta is typed everywhere
import 'vue-router';
declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean;
    title?: string;
    roles?: string[];
  }
}

const routes = [
  { path: '/admin', component: Admin,
    meta: { requiresAuth: true, roles: ['admin'], title: 'Admin' } },
];

// in a guard — fully typed
router.beforeEach((to) => {
  document.title = to.meta.title ?? 'App';
});

说明:每条路由都带一个任意的 `meta` 对象,是挂鉴权标志、页面标题、布局名、面包屑数据的标准位置,供守卫和布局读取。通过 `declare module "vue-router"` 扩展 `RouteMeta` 接口,让 `to.meta.xxx` 在守卫和组件里都有完整类型而非 `any`。嵌套路由会继承并合并父级的 meta。

scrollBehavior:导航时控制滚动

路由
const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior(to, from, savedPosition) {
    // back/forward → restore previous position
    if (savedPosition) return savedPosition;
    // anchor link → scroll to the element
    if (to.hash) return { el: to.hash, behavior: 'smooth' };
    // otherwise → top of page
    return { top: 0 };
  },
});

说明:scrollBehavior 在每次导航时运行,返回页面应滚到哪里。`savedPosition` 只在浏览器前进/后退时非空,返回它就得到原生的"回到我刚才的位置"。`to.hash` 让你滚到锚点;默认 `{ top: 0 }` 在全新导航时回到顶部。返回 promise 可把滚动推迟到异步内容加载完之后。

onBeforeRouteUpdate:同组件换参数

路由
<script setup lang="ts">
import { onBeforeRouteUpdate } from 'vue-router';
import { ref } from 'vue';
const user = ref<User | null>(null);

// fires when navigating /user/1 → /user/2 (component is REUSED)
onBeforeRouteUpdate(async (to, from) => {
  if (to.params.id !== from.params.id) {
    user.value = await fetchUser(to.params.id as string);
  }
});
</script>

说明:在两条解析到同一个组件、只是参数不同的路由间导航时(比如 `/user/1` → `/user/2`),Vue 复用实例,onMounted 不会再跑。onBeforeRouteUpdate 就是这次切换时触发的钩子,用它(或 `watch(() => route.params.id)`)重新拉数据。漏掉它就是"点下一个条目页面不变"的经典 bug。

路由懒加载配合加载失败处理

路由
import { defineAsyncComponent } from 'vue';

const routes = [
  {
    path: '/reports',
    component: defineAsyncComponent({
      loader: () => import('./pages/Reports.vue'),
      loadingComponent: PageSpinner,
      errorComponent: ChunkLoadError,   // shown if the chunk fails
      timeout: 8000,
    }),
  },
];

// global recovery: reload once when a stale chunk 404s after deploy
router.onError((err) => {
  if (err.message.includes('Failed to fetch dynamically imported module')) {
    window.location.reload();
  }
});

说明:懒加载的路由 chunk 可能加载失败,通常是新部署让旧 chunk 的 hash 失效、而用户还开着上一版页面。把 loader 包进 `defineAsyncComponent` 并配 `errorComponent` 做优雅的页面内兜底,再加一个全局 `router.onError`,检测到 "Failed to fetch dynamically imported module" 就做一次性 reload 拉取新 chunk。

isNavigationFailure:识别被取消的导航

路由
import { isNavigationFailure, NavigationFailureType } from 'vue-router';

const failure = await router.push('/checkout');
if (isNavigationFailure(failure, NavigationFailureType.aborted)) {
  // a guard returned false — navigation was cancelled
  toast('Please complete the form first');
}
// other types: duplicated (same route), cancelled (newer nav started)

说明:router.push / replace 在守卫取消或重定向导航时,会 resolve 出一个 Navigation Failure 对象(而不是抛错)。`isNavigationFailure(result, type)` 让你区分被中止的导航(守卫返回 false)、重复导航(又 push 了当前路由)、被取消的导航(更新的导航把它顶替了),从而正确响应,而不是想当然地认为每次 push 都成功了。

Pinia / 状态 (10)

Pinia:安装与使用

Pinia / 状态
// main.ts
import { createPinia } from 'pinia';
createApp(App).use(createPinia()).mount('#app');

// stores/counter.ts
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: { double: (s) => s.count * 2 },
  actions: { inc() { this.count++; } }
});

// in component
<script setup>
import { useCounterStore } from '@/stores/counter';
const counter = useCounterStore();
counter.inc();
</script>

说明:Pinia 是 Vue 3 官方 store,Vuex 的替代品。每个 store 是一个 hook(`useXxxStore`),`<script setup>` 里调。第一个参数是唯一 id(也是 DevTools 名称)。store 首次使用时懒初始化,全应用共享,零样板、TS 类型推得完整、HMR 友好。

defineStore:选项式写法

Pinia / 状态
import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({ name: '', email: '', loading: false }),
  getters: {
    initials: (s) => s.name.split(' ').map(w => w[0]).join(''),
  },
  actions: {
    async load(id: number) {
      this.loading = true;
      const r = await fetch(`/api/user/${id}`).then(r => r.json());
      this.name = r.name; this.email = r.email;
      this.loading = false;
    }
  }
});

说明:选项式写法和 Vuex 类似:state(工厂函数)、getters(等价 Vue computed)、actions(等价 Vue methods)。actions 里的 `this` 就是 store 实例,TS 能正确推断。state 工厂 必须 每次返回新对象(否则 SSR 下请求间会共享 state)。

defineStore:setup 写法(推荐)

Pinia / 状态
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useCartStore = defineStore('cart', () => {
  // state
  const items = ref<Item[]>([]);
  // getters
  const total = computed(() => items.value.reduce((s, i) => s + i.price, 0));
  // actions
  function add(i: Item) { items.value.push(i); }
  function clear() { items.value = []; }

  return { items, total, add, clear };
});

说明:setup store 写法:传一个 setup 函数,返回想暴露的东西。里面就是普通的 ref / computed / 函数,和 `<script setup>` 完全一样。组合性更好(把逻辑抽 composable 然后在 store 里调),TS 推断更准,而且 store 内部要用 `watch` / 生命周期就只能用这种。

Pinia actions:异步 + this

Pinia / 状态
// options form
actions: {
  async fetchPosts() {
    this.loading = true;
    try {
      this.posts = await api.posts();
    } finally {
      this.loading = false;
    }
  }
}

// setup form — call from component
const cart = useCartStore();
await cart.fetchPosts();

// $patch — batch state changes
cart.$patch({ items: [], total: 0 });
cart.$patch((s) => { s.items.push(x); s.discount = 10; });

说明:actions 可以是异步的,没有 commit / dispatch 那一层。选项写法里 `this` 类型完整。`$patch` 把多次修改合并成 一 次响应式更新(几个字段一起改时,订阅者只看到最终态)。`store.$subscribe` 订阅 state 变更。

Pinia getters:像 computed

Pinia / 状态
getters: {
  // simple
  double: (s) => s.count * 2,
  // access this for other getters
  doublePlusOne(): number { return this.double + 1; },
  // parameterized getter — returns a function
  getById: (s) => (id: number) => s.users.find(u => u.id === id),
  // use another store
  totalWithTax(): number {
    const tax = useTaxStore();
    return this.total * (1 + tax.rate);
  }
}

说明:getters 就是 Vue computed,按响应式依赖缓存,state 变了才重算。需要参数的查询用"返回函数的 getter"(`getById`)(注意:这种不缓存,多次调用 = 多次工作)。跨 store 读:getter 里调另一个 `useXxxStore()`。

storeToRefs:解构不丢响应性

Pinia / 状态
<script setup>
import { storeToRefs } from 'pinia';
import { useUserStore } from '@/stores/user';

const userStore = useUserStore();
// WRONG — name, email become plain values, lose reactivity
const { name, email } = userStore;

// RIGHT — refs that stay reactive
const { name, email } = storeToRefs(userStore);
// actions are still destructured directly (they don't need to be refs)
const { login, logout } = userStore;
</script>

说明:Pinia store 是响应式 Proxy,直接解构值会失去响应性(和普通 `reactive` 同一个坑)。storeToRefs 返回指向 store 的 ref,state 和 getter 解构这个。actions 是普通函数,直接解构没问题。

Pinia 在 setup 外用:传 pinia

Pinia / 状态
// utils/api.ts — NOT a component
import { useUserStore } from '@/stores/user';
import { pinia } from '@/main';   // exported from main.ts

export async function getCurrentUser() {
  // Pass pinia explicitly outside setup
  const store = useUserStore(pinia);
  return store.user;
}

// router guard
router.beforeEach((to) => {
  const auth = useAuthStore();    // works — app is mounted before guards run
  if (!auth.user) return '/login';
});

说明:`<script setup>`、路由守卫(挂载后)等地方,激活的 Pinia 实例自动可用,`useXxxStore()` 直接用。 独立工具文件 被测试 / SSR 配置 / 挂载前代码引用时,要显式传入 pinia 实例:`useXxxStore(pinia)`。忘了传就报 "getActivePinia was called with no active Pinia"。

Pinia $reset:恢复初始 state

Pinia / 状态
// options store — $reset works out of the box
const store = useCounterStore();
store.$reset();   // state back to the factory's initial values

// setup store — $reset is NOT auto-available, define it yourself
export const useCart = defineStore('cart', () => {
  const items = ref<Item[]>([]);
  function $reset() { items.value = []; }
  return { items, $reset };
});

说明:在选项式 store 里,`store.$reset()` 会重跑 state 工厂、把 state 换回初始值,登出或取消表单时很方便。setup 式 store 不会自动拥有 `$reset`(Pinia 无从得知初始结构),所以要自己定义一个清空各 ref 的 reset 函数。把选项式 store 迁到 setup 式时留意这个差异。

Pinia $subscribe 与 $onAction

Pinia / 状态
const store = useCartStore();

// react to ANY state change
store.$subscribe((mutation, state) => {
  localStorage.setItem('cart', JSON.stringify(state));
}, { detached: true });   // survive component unmount

// observe action calls (logging, analytics, error capture)
store.$onAction(({ name, args, after, onError }) => {
  console.log('calling', name, args);
  after((result) => console.log('done', name, result));
  onError((err) => console.error(name, 'failed', err));
});

说明:`$subscribe` 在每次 state 变更后跑一个回调,是把 store 持久化到 localStorage 的标准位置。传 `{ detached: true }`,让订阅不随注册它的组件卸载而被清掉。`$onAction` 钩入 action 调用,暴露 `after` 和 `onError` 回调;用它做横切的日志、analytics 或集中式错误捕获,而不必动每个 action。

跨 store 组合

Pinia / 状态
export const useCheckoutStore = defineStore('checkout', () => {
  // call another store INSIDE the setup function
  const cart = useCartStore();
  const user = useUserStore();

  const canCheckout = computed(
    () => cart.items.length > 0 && !!user.address
  );
  async function submit() {
    await api.order(cart.items, user.id);
    cart.clear();
  }
  return { canCheckout, submit };
});

说明:store 可以使用其他 store:在 setup 函数里(或选项式 store 的 action/getter 里)调 `useOtherStore()`,读它的 state 或调它的 action。Pinia 在首次使用时惰性解析依赖,所以两个 store 互相引用也没问题,只要别在模块顶层就互读。这样每个 store 保持单一职责,而在更高层把它们组合起来。

组合式函数 (15)

自定义组合式函数:useToggle

组合式函数
// composables/useToggle.ts
import { ref } from 'vue';

export function useToggle(initial = false) {
  const value = ref(initial);
  const toggle = () => { value.value = !value.value; };
  return { value, toggle };
}

// in component
<script setup>
import { useToggle } from '@/composables/useToggle';
const { value: open, toggle: toggleOpen } = useToggle();
</script>

说明:"composable" 就是名字以 `use` 开头、把有状态逻辑封装成可复用单元的函数。Vue 版的 React custom hook。composable 内部可以调其他 composable、用生命周期钩子(必须在 setup 调用链里),返回 ref / 函数。`use` 前缀是约定,不是强制。

useMouse:跟踪鼠标

组合式函数
// composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue';

export function useMouse() {
  const x = ref(0), y = ref(0);
  const update = (e: MouseEvent) => { x.value = e.pageX; y.value = e.pageY; };
  onMounted(() => window.addEventListener('mousemove', update));
  onUnmounted(() => window.removeEventListener('mousemove', update));
  return { x, y };
}

// in component
const { x, y } = useMouse();

说明:展示生命周期模板的标杆 composable:挂载订阅、卸载取消、暴露响应式 state。resize、scroll、online/offline、IntersectionObserver 都是这个套路。注意取消逻辑紧挨订阅写,composable 最常见的 bug 就是漏写 unsubscribe。

useLocalStorage:同步 localStorage

组合式函数
import { ref, watch } from 'vue';

export function useLocalStorage<T>(key: string, initial: T) {
  const stored = typeof window !== 'undefined'
    ? window.localStorage.getItem(key) : null;
  const value = ref<T>(stored ? JSON.parse(stored) : initial);

  watch(value, (v) => {
    if (typeof window !== 'undefined') {
      window.localStorage.setItem(key, JSON.stringify(v));
    }
  }, { deep: true });

  return value;
}

// component
const theme = useLocalStorage<'light' | 'dark'>('theme', 'light');

说明:返回一个映射 localStorage 某 key 的 ref,当普通 ref 读,写它自动持久化。SSR(Nuxt / Astro)必须加 `typeof window` 判断,否则服务端构建直接挂。存对象 state 要配 watch + `{ deep: true }`。

useFetch:最小数据请求

组合式函数
import { ref, watchEffect } from 'vue';

export function useFetch<T>(url: () => string) {
  const data = ref<T | null>(null);
  const error = ref<Error | null>(null);
  const loading = ref(false);

  watchEffect((onCleanup) => {
    const ctrl = new AbortController();
    onCleanup(() => ctrl.abort());
    loading.value = true;
    fetch(url(), { signal: ctrl.signal })
      .then(r => r.json()).then(d => { data.value = d; loading.value = false; })
      .catch(e => { if (e.name !== 'AbortError') { error.value = e; loading.value = false; } });
  });
  return { data, error, loading };
}

// caller passes a GETTER so watchEffect tracks it
const id = ref(1);
const { data } = useFetch<User>(() => `/api/user/${id.value}`);

说明:原型级的 fetch composable。关键是接收 getter `() => string`(不是字符串),让 watchEffect 能追踪依赖,变化时自动重发。onCleanup 取消上一次请求,保证最新 URL 总赢。生产代码应该用 VueUse 的 `useFetch` 或正经数据库(TanStack Query Vue)。

VueUse:useEventListener

组合式函数
import { useEventListener } from '@vueuse/core';

// auto-cleans on unmount, no boilerplate
useEventListener(window, 'resize', () => { /* … */ });
useEventListener(document, 'keydown', onKey);

// target can also be a ref
const elRef = ref<HTMLElement | null>(null);
useEventListener(elRef, 'click', onClick);

说明:@vueuse/core 是事实上的"composable 版 lodash",200+ 个开箱即用 composable。useEventListener 省掉 addEventListener / removeEventListener 样板,target 还能传 ref(ref 变了自动重挂)。安装:`pnpm add @vueuse/core`。

VueUse:useDebouncedRef / useThrottleFn

组合式函数
import { refDebounced, useThrottleFn } from '@vueuse/core';
import { ref } from 'vue';

const search = ref('');
const debounced = refDebounced(search, 300);  // updates 300ms after stable

watch(debounced, (q) => fetchResults(q));

// throttled function
const onScroll = useThrottleFn(() => updateScrollPos(), 100);
addEventListener('scroll', onScroll);

说明:refDebounced 返回一个"延迟跟随"的 ref,源每次变都重置计时器。useThrottleFn 把函数包装成"每间隔最多触发一次"(首次立即)。搜索 debounce 300 ms、scroll throttle 100 ms 是经验甜区。

provide / inject:依赖注入

组合式函数
// parent
<script setup lang="ts">
import { provide, ref, readonly } from 'vue';
const theme = ref<'light' | 'dark'>('light');
provide('theme', readonly(theme));   // child can read but not write
provide('toggleTheme', () => { theme.value = theme.value === 'light' ? 'dark' : 'light'; });
</script>

// child (any descendant depth)
<script setup lang="ts">
import { inject } from 'vue';
const theme = inject<Ref<'light' | 'dark'>>('theme');
const toggleTheme = inject<() => void>('toggleTheme');
</script>

说明:provide 向任意深度后代传值,inject 取值,绕过 prop drilling。可变 ref 包 readonly 防止子写;允许子改的部分单独暴露 setter。库代码用 Symbol 当 key 避免命名冲突。全应用全局 state 通常 Pinia 更干净。

useTemplateRef:带类型的 template ref (3.5+)

组合式函数
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue';

const inputRef = useTemplateRef<HTMLInputElement>('myInput');

onMounted(() => inputRef.value?.focus());
</script>
<template>
  <input ref="myInput" />
</template>

说明:Vue 3.5+ 用来替代 `const x = ref<HTMLInputElement | null>(null)` + `<input ref="x">` 这一套。传给 useTemplateRef 的字符串对应 `ref="…"` 属性,返回的 ref 自带类型。旧 `ref(null)` 写法仍然能用,useTemplateRef 只是把意图写得更明白。

VueUse:useStorage

组合式函数
import { useStorage } from '@vueuse/core';

// reactive ref synced to localStorage (type inferred from default)
const settings = useStorage('app-settings', { theme: 'dark', fontSize: 16 });
settings.value.theme = 'light';   // persisted automatically

// sessionStorage instead
const draft = useStorage('draft', '', sessionStorage);

说明:VueUse 的 `useStorage` 返回一个与 localStorage(或 sessionStorage)双向同步的 ref。它从默认值推断序列化方式,对象自动走 JSON,还能通过 `storage` 事件跨标签页同步。比自己手写的 composable 多了 SSR 安全、跨标签同步和可配置序列化器。用 `pnpm add @vueuse/core` 安装。

VueUse:useIntersectionObserver

组合式函数
import { ref } from 'vue';
import { useIntersectionObserver } from '@vueuse/core';

const target = ref<HTMLElement | null>(null);
const isVisible = ref(false);

useIntersectionObserver(target, ([entry]) => {
  isVisible.value = entry?.isIntersecting ?? false;
  // e.g. trigger lazy load / infinite scroll when entry is visible
}, { threshold: 0.1 });

说明:把 IntersectionObserver API 封成 composable,观察一个 template ref 并在卸载时自动断开。它是图片懒加载、无限滚动(在列表末尾观察一个哨兵元素)、滚动进入视口动画、视口埋点的标准工具,不必手动创建和销毁 observer。`threshold` 控制元素露出多少比例才触发。

VueUse:useClipboard

组合式函数
import { useClipboard } from '@vueuse/core';

const { copy, copied, isSupported } = useClipboard();

// copied is a ref that flips true for ~1.5s after a successful copy
async function onCopy() {
  await copy('text to copy');
}
// in template:  <button @click="onCopy">{{ copied ? 'Copied!' : 'Copy' }}</button>

说明:useClipboard 封装异步 Clipboard API,带一个会在超时后自动复位的响应式 `copied` 标志,做"复制 / 已复制!"按钮反馈正好,不用手动管 setTimeout。`isSupported` 让你在没有剪贴板权限的浏览器上隐藏按钮,还能传一个 `source` ref 实现响应式复制。

inject 带默认值与 Symbol key

组合式函数
// keys.ts — typed injection key
import type { InjectionKey, Ref } from 'vue';
export const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> =
  Symbol('theme');

// provider
provide(ThemeKey, theme);   // type-checked value

// consumer — default value if no provider above
const theme = inject(ThemeKey, ref('light'));
// factory default for expensive values
const cfg = inject(ConfigKey, () => makeConfig(), true);

说明:用 `InjectionKey<T>` 的 Symbol 取代字符串 key,能让 `provide` 和 `inject` 两端都有完整类型推断,也避免大型应用或库里的命名冲突。`inject(key, default)` 在没有祖先提供该 key 时给一个兜底值;默认值构建很贵时,传工厂函数加第三个参数 `true`,这样只在需要时才创建。

返回响应式 state 的 composable

组合式函数
import { reactive, toRefs } from 'vue';

export function useCounter(start = 0) {
  const state = reactive({ count: start, doubled: 0 });
  const inc = () => { state.count++; state.doubled = state.count * 2; };
  // return toRefs so the caller can destructure AND stay reactive
  return { ...toRefs(state), inc };
}

// caller
const { count, doubled, inc } = useCounter(10);

说明:把 state 存在 `reactive` 对象里的 composable,应该返回 `toRefs(state)`(和它的方法一起展开),这样调用方解构每个字段还能保持响应。直接返回 reactive 对象,调用方一解构就失效。这是惯用形态:state 以 ref 返回,action 以普通函数返回。

VueUse:useMediaQuery / 断点

组合式函数
import { useMediaQuery, useBreakpoints, breakpointsTailwind } from '@vueuse/core';

// reactive boolean for any media query
const isDark = useMediaQuery('(prefers-color-scheme: dark)');
const isWide = useMediaQuery('(min-width: 1024px)');

// named breakpoints
const bp = useBreakpoints(breakpointsTailwind);
const isDesktop = bp.greaterOrEqual('lg');

说明:useMediaQuery 返回一个追踪 CSS media query 的响应式布尔值,适合 JS 里的响应式逻辑(移动端渲染不同组件、响应 `prefers-reduced-motion` 或 `prefers-color-scheme`)。useBreakpoints 在它之上提供具名断点集(Tailwind、Bootstrap 或自定义)和 `greaterOrEqual("lg")` 之类的辅助方法,让 JS 断点和 CSS 保持一致,且不必自己写 resize 监听。

VueUse:useAsyncState

组合式函数
import { useAsyncState } from '@vueuse/core';

const { state, isLoading, isReady, error, execute } = useAsyncState(
  (id: number) => fetch(`/api/user/${id}`).then(r => r.json()),
  null,                 // initial state while loading
  { immediate: true },  // run on mount
);

// re-run with new args
function reload(id: number) { execute(0, id); }

说明:useAsyncState 把一个异步函数包成响应式的 `state`、`isLoading`、`isReady`、`error` 几个 ref,外加一个可重跑的 `execute`,是驱动"加载中 / 已加载 / 出错" UI 的快捷办法,不必每个页面手写三遍样板。它支持初始值、立即或惰性执行,以及 `resetOnExecute` 选项。需要跨组件缓存、去重、重新验证时,改用 TanStack Query Vue。

常见坑 (19)

坑:ref 还是 reactive?

常见坑
// RULE OF THUMB
const count = ref(0);                    // primitives → ref
const user = ref({ name: 'Lei' });       // also fine for objects you may replace whole

const cart = reactive({ items: [], total: 0 });   // object you mutate in place

// REPLACEMENT pattern — ref wins
const items = ref<Item[]>([]);
items.value = await fetch(/*…*/);   // wholesale replace, easy

// MUTATION pattern — reactive wins (no .value noise)
const form = reactive({ name: '', age: 0 });
form.name = 'Lei';                  // looks like a plain object

说明:实用规则:基本类型、可能整体替换的对象(API 响应、路由数据)用 `ref`;原地修改的对象(表单 state、设置面板)用 `reactive`。另一条:`ref` 永远 能用,`reactive` 只对对象有效。拿不准就默认 ref。

坑:解构 reactive 失去响应性

常见坑
import { reactive, toRefs } from 'vue';
const state = reactive({ x: 1, y: 2 });

// WRONG — x, y become plain numbers
const { x, y } = state;
// x is 1 FOREVER no matter what state.x becomes

// RIGHT — toRefs hands out refs that POINT into state
const { x, y } = toRefs(state);
// x.value tracks state.x both ways

// also: spreading loses reactivity
const copy = { ...state };  // plain object

说明:Vue 3 头号大坑。响应性挂在 Proxy 上,解构 / 展开会把值 拷 出 proxy,从此不跟踪。用 toRefs 解决(解出来的是指回 proxy 的 ref)。Pinia store 同一个坑,要用 storeToRefs。

坑:v-for 和 v-if 写在同一元素

常见坑
<!-- WRONG — v-if has HIGHER priority than v-for in Vue 3 -->
<!-- (the reverse of Vue 2!) — `item` is not in scope yet -->
<li v-for="item in items" v-if="item.show">{{ item.name }}</li>

<!-- RIGHT — filter in the source, OR wrap v-for in <template> -->
<li v-for="item in visibleItems" :key="item.id">{{ item.name }}</li>

<!-- or -->
<template v-for="item in items" :key="item.id">
  <li v-if="item.show">{{ item.name }}</li>
</template>

<script setup>
const visibleItems = computed(() => items.value.filter(i => i.show));
</script>

说明:Vue 3 把这俩优先级 翻转 了,现在是 `v-if` 赢。意味着 `v-if` 求值时 `item` 还没进作用域,这个组合是硬错(Vue 会警告)。要么 computed 里先过滤(推荐,还更快),要么拆成 `<template v-for>` + `<li v-if>`。

坑:v-for 忘了写 :key

常见坑
<!-- WRONG — Vue falls back to in-place patch (sometimes called "in-place reuse") -->
<li v-for="item in items">{{ item.name }}</li>
<!-- insert / remove / reorder → DOM gets reused for the WRONG item -->

<!-- RIGHT — stable id -->
<li v-for="item in items" :key="item.id">{{ item.name }}</li>

<!-- AVOID — index as key when list can reorder -->
<li v-for="(item, idx) in items" :key="idx">{{ item.name }}</li>

说明:不给 `:key`,Vue 走"原地复用",复用现有 DOM,只改文本内容。可见后果:焦点跑到错的输入框、错的复选框保持选中、过渡动画在错的行上播。 一定 要给稳定的唯一 key(通常 `item.id`)。index 只在"永不重排、永不插入"的列表里能用。

坑:直接修改 props

常见坑
// WRONG — Vue warns "Avoid mutating a prop directly"
const props = defineProps<{ user: User }>();
props.user.name = 'new';   // also mutates parent state via the same reference

// RIGHT for primitives — use v-model / emit
const props = defineProps<{ modelValue: string }>();
const emit = defineEmits<{ 'update:modelValue': [string] }>();
emit('update:modelValue', 'new');

// or with defineModel (3.4+)
const model = defineModel<string>();
model.value = 'new';

// RIGHT for derived state — make a local copy
const localUser = ref({ ...props.user });

说明:props 是单向契约:父拥有,子只读。写 props 时 Vue 会警告。需要双向就用 v-model + defineModel(3.4+)或手写的 prop + emit 对。子组件派生 state 要拷一份到本地 ref。绝不能直接 `props.user.name = …`,会通过共享引用改到父组件 state。

坑:watch reactive 和 ref 不一样

常见坑
import { ref, reactive, watch } from 'vue';

const r = ref(0);
const obj = reactive({ x: 0 });

// REF — pass it directly
watch(r, (n) => console.log(n));

// REACTIVE OBJECT — pass it directly to watch the WHOLE object (deep auto)
watch(obj, (n) => console.log(n));

// REACTIVE PROPERTY — must use a GETTER (passing obj.x passes the number 0!)
watch(() => obj.x, (n) => console.log(n));

// MULTIPLE sources
watch([r, () => obj.x], ([rv, xv]) => console.log(rv, xv));

说明:watch ref 直接传 ref。watch reactive 整对象 直接传对象(自动 deep)。但 watch reactive 对象的 单个属性 必须用 getter,`watch(obj.x, …)` 传的是当时解包后的 值,不是被追踪的 source。"watcher 怎么不触发"的头号 bug。

坑:deep: true 很贵

常见坑
// EXPENSIVE — walks every property every change
watch(bigObj, (v) => save(v), { deep: true });

// CHEAPER — watch only the field you care about
watch(() => bigObj.draft.title, (t) => saveTitle(t));

// or memoize a shape via computed
const summary = computed(() => ({ id: bigObj.id, name: bigObj.name }));
watch(summary, (s) => save(s));

说明:`{ deep: true }` 每次变化都要走整个对象树找 diff。1 万条数组或深 schema state 时,这一步能吃掉一整帧。优先 watch 具体 getter,或先 computed 提取关心的字段再 watch。Pinia store 默认就是深响应,watch 具体 getter,别 watch 整个 store。

坑:async setup 需要 <Suspense>

常见坑
<!-- Child.vue -->
<script setup lang="ts">
// top-level await — makes setup async
const user = await fetch('/api/me').then(r => r.json());
</script>

<!-- Parent — REQUIRED, otherwise child stays unmounted forever -->
<template>
  <Suspense>
    <Child />
    <template #fallback>Loading…</template>
  </Suspense>
</template>

说明:`<script setup>` 里的顶层 await 把 setup 变成异步。没在外层套 `<Suspense>` 的话,这个组件永远不渲染,页面就是一片白,不报错,啥都没有。任何 async setup 组件,父级要么套 <Suspense>,要么用 vue-router 自带支持。

坑:`<script setup>` 里没有 this

常见坑
<script setup>
// WRONG — this is undefined in setup
function handleClick() {
  this.count++;          // TypeError
}

// RIGHT — refer to refs directly
const count = ref(0);
function handleClick() {
  count.value++;
}
</script>

说明:`<script setup>`(以及选项式 `setup()` 函数内部)里 没有 `this`。state 就是普通变量。从 Options API 抄代码时,每个 `this.xxx` 要变成 `xxx.value`(ref)或直接 `xxx`(reactive)。心智模型是"模块作用域变量",不是"组件实例"。

坑:defineProps 的数组 / 对象默认值

常见坑
// WRONG — every instance shares the SAME array
withDefaults(defineProps<{ tags?: string[] }>(), {
  tags: []           // ERROR (runtime form would warn)
});

// RIGHT — default must be a FUNCTION returning a fresh value
withDefaults(defineProps<{ tags?: string[]; user?: User }>(), {
  tags: () => [],
  user: () => ({ name: '' })
});

// 3.5+: destructuring with default avoids the function-factory dance
const { tags = [], user = { name: '' } } = defineProps<{ tags?: string[]; user?: User }>();

说明:数组 / 对象做默认值 必须 用工厂函数返回,否则所有组件实例共用同一个引用,改一个连带改全部。和 React 里 `useState(initialObject)` 用非稳定默认值是同一个坑。Vue 3.5+ 的解构 + 默认值写法可以绕开这个套路。

坑:在 setup 外用 composable

常见坑
// WRONG — onMounted has no active component instance to attach to
function init() {
  onMounted(() => console.log('mounted'));   // warning + no-op
}
init();   // called too early, outside setup

// RIGHT — call composables INSIDE <script setup> body
<script setup>
import { useMouse } from '@/composables/useMouse';
const { x, y } = useMouse();    // OK — synchronously inside setup
</script>

// Also wrong — calling a composable inside a click handler
function onClick() {
  const { x } = useMouse();    // warning, no lifecycle hook can attach
}

说明:用了生命周期(onMounted 等)或 inject 的 composable 必须在 `<script setup>` 或 `setup()` 里 同步 调用,和 React hooks 同一条规则。不能在异步回调、事件处理函数、非 setup 文件顶层调。违反时 Vue 会警告。

坑:SSR 注水不匹配

常见坑
<!-- WRONG — server renders 0, client immediately renders 1234 → mismatch -->
<script setup>
const time = ref(Date.now());
</script>
<template>{{ time }}</template>

<!-- RIGHT — render placeholder on server, real value AFTER mount -->
<script setup>
import { ref, onMounted } from 'vue';
const time = ref<number | null>(null);
onMounted(() => { time.value = Date.now(); });
</script>
<template>{{ time ?? '—' }}</template>

<!-- or use <ClientOnly> in Nuxt -->
<ClientOnly><InteractiveWidget /></ClientOnly>

说明:SSR HTML 和客户端首次渲染不一致就是 hydration mismatch,Date.now()、Math.random()、localStorage、window.matchMedia 都会不一致。Vue 会报 "Hydration node mismatch" 并重渲染,SSR 优势就丢了。解法:服务端先渲染稳定占位,onMounted 里填动态值,或用 `<ClientOnly>`(Nuxt)/ 等价守卫包起来。

坑:给 ref 数组按下标赋值

常见坑
import { ref } from 'vue';
const list = ref([1, 2, 3]);

// FINE in Vue 3 — index assignment IS tracked (unlike Vue 2)
list.value[0] = 99;          // triggers update
list.value.length = 1;       // also tracked

// but forgetting .value silently does nothing
list[0] = 99;                // WRONG — mutates a plain array property, no update

说明:Vue 2 的条件反射是用 `Vue.set` / `this.$set` 让下标或 length 赋值变响应式。Vue 3 基于 Proxy,`arr[i] = x` 和 `arr.length = n` 原生就被追踪,所以 `$set` 没了也用不上。Vue 3 真正的坑是 ref 漏写 `.value`:`list[0] = 99` 写到的是 ref 对象本身而不是数组,什么都不会更新。

坑:闭包里读到过期的值

常见坑
import { ref, watch } from 'vue';
const count = ref(0);

// WRONG — captures the value 0 once, never sees updates
const snapshot = count.value;
watch(() => snapshot, () => {});   // never fires

// RIGHT — pass the ref or a getter so the dependency is tracked
watch(count, (n) => console.log(n));
watch(() => count.value, (n) => console.log(n));

说明:把 `ref.value`(或 reactive 属性)读进一个普通局部变量,拿到的是一次性快照,该变量不会追踪后续变化。需要响应的地方必须接收 ref 本身或一个 getter(`() => ref.value`),而不是解包后的值。这和解构那个坑同根同源,只是藏在函数体里。

坑:emit 事件名的大小写

常见坑
// Child — emit a camelCase event
const emit = defineEmits<{ updateValue: [string] }>();
emit('updateValue', 'x');

<!-- Parent — listen with kebab-case in the template -->
<Child @update-value="onUpdate" />
<!-- @updateValue also works in <script>, but kebab is the template norm -->

说明:Vue 会在 `defineEmits` 声明的 camelCase 事件名与 HTML 模板里用的 kebab-case 之间自动转换:emit `updateValue`,用 `@update-value` 监听。HTML 属性大小写不敏感,模板里写 camelCase 监听器可能被浏览器悄悄转小写、从此匹配不上。事件名声明用 camelCase,模板监听用 kebab-case,映射就顺了。

坑:await 之后丢失组件上下文

常见坑
<script setup>
import { onMounted } from 'vue';

async function load() {
  const data = await fetch('/api').then(r => r.json());
  // WRONG — registering a lifecycle hook AFTER await
  onMounted(() => {});   // warning: no active instance
}
load();

// RIGHT — register hooks synchronously, do async work inside them
onMounted(async () => {
  const data = await fetch('/api').then(r => r.json());
});
</script>

说明:生命周期钩子(onMounted、onUnmounted)和 inject 必须在 setup 期间同步调用,Vue 只在第一个 await 之前追踪"当前实例"。`await` 之后的代码跑在更晚的微任务上、没有活动实例,在那里注册钩子会警告且空操作。先注册钩子,异步活儿放进钩子回调里做。

坑:v-html 内容套不上 scoped 样式

常见坑
<template>
  <!-- scoped CSS does NOT reach v-html'd content -->
  <div v-html="article" class="prose"></div>
</template>
<style scoped>
/* .prose h2 { … }  ← will NOT apply to injected markup */
</style>
<style>
/* use a non-scoped block, or :deep() */
.prose :deep(h2) { color: #22d3ee; }
</style>

说明:scoped 样式靠给模板编译器认识的元素打 data 属性来生效。v-html 插入的标记是运行时创建的,拿不到这个属性,scoped 选择器永远匹配不上它。给 v-html 内容写样式要用非 scoped 的 `<style>` 块,或用 `:deep()` 组合器,它能为后代选择器穿透 scope 边界。

坑:computed 必须是纯函数

常见坑
import { ref, computed } from 'vue';
const ids = ref([3, 1, 2]);

// WRONG — getter mutates its dependency
const sorted = computed(() => ids.value.sort());   // sort() mutates in place!

// RIGHT — copy first, never mutate inside a getter
const sorted2 = computed(() => [...ids.value].sort((a, b) => a - b));

// also wrong: async / side effects inside a getter
const bad = computed(() => { fetch('/x'); return 1; });

说明:computed 的 getter 必须是其响应式依赖的纯函数:不修改、不异步、无副作用。隐蔽的情况是 `Array.prototype.sort()` / `reverse()` / `splice()`,它们原地修改,在 getter 里对依赖调用会污染源数据、还可能导致无限更新循环。排序前一定先复制(`[...arr]`),副作用放进 `watch` 而不是 `computed`。

坑:provide 要传 ref 而非它的值

常见坑
import { provide, ref } from 'vue';
const count = ref(0);

// WRONG — provides the number 0; children never see updates
provide('count', count.value);

// RIGHT — provide the ref so descendants stay reactive
provide('count', count);

// later in the parent
count.value++;   // every injecting child re-renders

说明:`provide(key, ref.value)` 给后代的是一份冻结快照,它们拿到的是 provide 时刻的值,之后的变化全看不到。要 provide ref 本身(或 reactive 对象),注入的值才保持鲜活。若子组件只读不写,包一层 `readonly()`。这和闭包那个坑同理:传响应式容器,别传解包后的值。

这个工具能做什么

可搜索的 Vue 3 速查表,80+ 条真实代码片段,覆盖九大类: 响 应式(ref / reactive / computed / watch / watchEffect / shallowRef / readonly / toRefs / customRef)、生命周期(完 整的 onMounted / onUpdated / onUnmounted,加上错误边界用的 onErrorCaptured 和配 KeepAlive 的 onActivated / onDeactivated)、 组件宏(defineProps 的运行时 / 纯类型 / 3.3+ 解构带默认值 三种写法;defineEmits、defineExpose、defineModel 3.4+、 defineOptions 和 defineSlots 3.3+、defineAsyncComponent、 Suspense、KeepAlive)、指令(v-if / v-show / v-for 配 :key 规则、v-model 配 .lazy / .number / .trim、v-bind 对象展 开、v-on 全套修饰符矩阵、v-html 把 XSS 警告写在例子旁 边、v-text / v-cloak / v-pre / v-once,以及一个能用的 v-click-outside 自定义指令)、插槽(默认、具名、作用域, headless 组件的基础,以及动态名)、vue-router(基础配 置、动态 + 命名、嵌套、三层导航守卫、编程式导航、useRoute、 路由懒加载)、Pinia(两种 defineStore 写法,推荐 setup store、异步 actions 和 $patch、带参数 / 跨 store 的 getters、storeToRefs,"setup 外用 Pinia"的套路)、组合 式函数(怎么写自己的、标杆 useMouse / useLocalStorage / useFetch 带 AbortController、@vueuse/core 生态常用、 provide / inject 作 DI、useTemplateRef 3.5+),以及每个 Vue 3 团队都会踩的那十几个坑: 解构 reactive 失去响应 性、v-for + v-if 优先级在 Vue 3 翻转了(和 Vue 2 反过 来)、`watch obj.x` 实际传了一个数字而不是被追踪的 source、async setup 不套 Suspense 就一片白、`<script setup>` 里没有 `this`、defineProps 数组对象默认值的工厂 函数规则、composable 必须在 setup 同步调用、SSR 注水不 匹配。每条都有中英文 独立 撰写(非机翻)的说明、可拷贝 的代码,有 Options API 对照的都并排展示,Vue 2 过来的 人能直接看到两种写法。完全在浏览器里跑,不上传。

工具细节

输入
文本
页面会根据工具类型展示文本框、数值控件、文件选择或结构化输入。
输出
即时结果 + 复制 + 预览
结果区优先给出可操作结果,支持项会显示复制、下载或可视化预览。
隐私
可能使用网络查询
组件源码里检测到网络调用,页面会按工具逻辑处理;敏感内容建议先脱敏。
保存 / 分享
本地保存偏好
偏好、历史或草稿保存在本机浏览器,不需要账号。
性能预算
首屏 JS ≤ 30 KB
没有声明 WASM 依赖,适合快速打开和移动端使用。
适用场景
开发运维 · 程序员
分类和职业标签用于推荐相关工具、组织内链,并帮助用户快速判断是否适合当前任务。

怎么用

  1. 1. 输入

    把内容粘贴或拖入工具面板。

  2. 2. 处理

    点击按钮,在浏览器内本地处理,文件不上传。

  3. 3. 复制 / 下载

    一键复制结果或下载到本地。

Vue 3 速查表 适合怎么用

适合穿插在写代码、查问题、做 Review、上线前的小任务里。

适合开发场景

  • 格式化、校验、压缩或检查和代码相关的文本。
  • 把片段整理好再放进文档、工单、提交或交接材料。
  • 不切换工具,快速检查一个小 payload。

开发检查项

  • 压缩、混淆这类不可逆处理,先对副本操作。
  • 除非确认工具本地处理,不要粘贴密钥和敏感片段。
  • 转换后的代码上线前,仍要跑自己的测试或 lint。

下一步可以接着做

这些入口会把当前任务接到更完整的工具链里。

  1. 1 JSON 格式化与校验 浏览器内即时格式化、校验、压缩 JSON,数据不离开本地。 打开
  2. 2 React Hooks 速查表 React Hooks 速查表,17 个内置 hook (useState / useEffect / useMemo / useTransition / useFormStatus...) 含真实例子和常见坑。 打开
  3. 3 TypeScript 速查表 TypeScript 速查表,100+ 段代码涵盖类型/泛型/工具类型/类型收窄/异步模式。 打开

真实使用场景

  • Vue 2 老手把 Options API 的肌肉记忆带进新 Vue 3 项目

    写了三年 Vue,突然 `mounted()` 和 `data()` 不存在了。 搜「生命周期」,每个 Composition 钩子都挨着它的 Options 双胞胎摆:`mounted()` 旁边是 `onMounted(() => {})`, `data() { count: 0 }` 旁边是 `ref(0)`。到第二天你脑子里 就不再两边来回翻译了。

  • code review 里查一个死活不触发的 watch

    同事写了 `watch(obj.x, cb)`,回调永远不跑,表单一直不 校验。搜「watch」,getter 和值的坑就摆在那:得写 `watch(() => obj.x, cb)`。一次 30 秒的查询,省掉横跨四 个组件、一小时的 console.log 考古。

  • 搭一个结账表单时纠结 ref 还是 reactive

    你起一个 12 个字段的结账表单,卡在 ref 还是 reactive。 响应式那栏把规则写清楚了:按字段改的 state 用 reactive, 整体替换的 API 响应用 ref,还有 `const { x } = state` 会 丢响应性的解构坑。20 秒就定了,不是 20 分钟。

  • 接 Pinia store 时忘了它在 setup 外也能用

    你在路由守卫里调 `useCartStore()`,晚上 11 点撞上「no active pinia」报错。Pinia 那栏有「setup 外用 Pinia」的 套路,还有解构丢响应性时用的 storeToRefs,都能直接拷。 一个吃掉你一晚上的报错,变成两行粘贴就过的代码。

常见踩坑

  • 按值 watch reactive 属性:`watch(obj.x, cb)` 只传一次那个数字。包成 getter,写 `watch(() => obj.x, cb)`,Vue 才会追踪 source。

  • 解构 reactive 对象,`const { count } = state` 会丢响应性。用 `toRefs(state)`(Pinia 用 `storeToRefs(store)`)保住那条连接。

  • 在 `setup()` 里跑 async 又不套 `<Suspense>`,页面一片白。要么把 await 放进被 Suspense 包住的子组件,要么 setup 保持同步、放 onMounted 里加载。

隐私说明

这份速查是一个纯静态页。你的搜索词只在浏览器里过滤一个内存 中的代码片段数组,什么都不发给服务器,什么都不进 URL,也不 执行任何代码。打开 DevTools 的 Network 边输入边看,零请求。 公司代理后面或气隙机器上,行为完全一样。

常见问题

类似工具组合

做你这行的人, 还会一起用这些。

Made by Toolora · 100% client-side · Updated 2026-06-13