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
}
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
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));
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;
});
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
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();
});
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);
});
<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>
// 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>
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>
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)
<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 式时留意这个差异。
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 保持单一职责,而在更高层把它们组合起来。
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>
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
<!-- 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>
<!-- 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>
// 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 });
// 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));
<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>
// 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 }>();
// 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
}
<!-- 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>
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));
// 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 -->
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()`。这和闭包那个坑同理:传响应式容器,别传解包后的值。