Vue 3.6 换掉的是响应式的追踪算法、Vapor 换掉的是渲染路径,这两件事都不要求你重写状态层,但会改变「什么该进 store、什么该留在组件里」的成本判断。
alien-signals 进的是内核,不是新 API
3.6 的 @vue/reactivity 换成了 alien-signals 那套算法实现,代码搬进 Vue 自己的包里,不是多引一个运行时依赖。对外签名没动,ref、reactive、computed、watch、effectScope 的用法跟 3.5 一致,升级不用改业务代码。这点和 Vapor 不一样:新响应式在 3.6 RC 里是默认生效的,没有开关。
内部有两处值得知道。依赖关系从每个 effect 挂一堆 Set/Map,改成双向链表串联,订阅多、销毁频繁的场景少掉大量小对象分配。computed 的脏值做了分级(NotDirty / MaybeDirty / Dirty),依赖变了但重算出来的值没变,下游不白跑。
收益就落在这些地方:依赖图大、computed 链深、组件频繁挂载卸载的页面,量级上的差别能测出来;一个表单页改两个字段,感知不到。别只看官方 benchmark,拿自己项目里最重的那个列表页跑一遍更靠谱。
边界也说清楚:换的是追踪和调度这一层,reactive() 返回的还是 Proxy,深层代理的开销没变。几万行的数据该用 shallowRef 还是用 shallowRef。
Vapor 是 opt-in 的渲染层
Vapor 组件编译出来直接操作 DOM,不生成 vnode,也没有 diff 这一步。开关写在 SFC 上:
<script setup vapor>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<button @click="count++">{{ count }}</button>
</template>
编译结果大致是:建一次 button、绑一次 click,点击时直接改 textContent。没有 patchFlag,没有 block tree。
响应式内核是同一套。vapor 组件里 ref、computed、watch 的来源跟 vDOM 组件一样,Pinia 的 store 在两边行为一致,不需要为 vapor 单独准备一套状态层。省体积这件事有前提:只有全量 vapor 的应用才去得掉 vDOM 运行时,混用的话两个运行时都在包里。
限制得说在前面。当时只有 <script setup> 写法,Options API 和手写 render 函数用不了;SSR 和 hydration 没跟上;<Transition>、<KeepAlive>、<Suspense> 在 vapor 下还没实现。实验期这份清单变得快,以官方文档为准。
混用是支持的,vapor 组件能被 vDOM 父组件引用,反过来也行,但跨边界的插槽和 attrs 透传有约束。要试就按整块页面迁,别一个组件一个组件地切。
vDOM 与 Vapor 的对照
| 维度 | vDOM | Vapor |
|---|---|---|
| 更新方式 | 生成 vnode,diff 后 patch | 编译期生成 DOM 操作 |
| 运行时 | 需要 vDOM 运行时 | 单用可去掉 vDOM 运行时 |
| 组件写法 | script setup / Options API / render 函数 | 仅 script setup |
| 响应式内核 | @vue/reactivity |
同一套 |
| SSR / hydration | 支持 | 当时未支持 |
| 内置组件 | 全量 | Transition、KeepAlive、Suspense 等未实现 |
| 第三方组件 | 全部 | 可用,跨边界有互操作开销 |
| 迁移方式 | — | 逐组件加 vapor 标记,可混用可回退 |
状态层不用选边,要选的是渲染路径。SSR 站点、路由缓存依赖 KeepAlive 的后台、深度绑定组件库的项目,现在不适合动。移动端首屏对体积敏感、有长列表高频更新、又不上 SSR 的,可以挑一两个叶子组件先试。
Pinia 的 state:扁平、可序列化
// 别这样:深层嵌套 + 不可序列化 + 冗余派生值
state: () => ({
profile: { base: { name: '', avatar: '' }, settings: new Map() },
list: [] as Item[],
total: 0,
})
// 这样:扁平、全是可序列化的原始值,派生值交给 getter
state: () => ({
name: '',
avatar: '',
theme: 'light' as 'light' | 'dark',
list: [] as Item[],
})
三条理由:SSR 要把 pinia.state.value 序列化进 HTML;devtools 的时间旅行是整份 state 的快照;持久化插件基本直接 JSON.stringify。放 Map、Set、class 实例、函数进去,这三件事都会出问题。
扁平和可序列化是同一个约束的两面。嵌套深不会直接拖慢渲染,但会让「改一个字段」更容易变成「替换一整块对象」,快照和 diff 也难看。
另一个高频误用是把能算出来的值存进 state(total、count、isAllChecked)。这类字段一旦落进 state,就得在每个写入点手动同步,早晚不一致。
大数组单独说:store 的 state 是深响应式的,几万行表格数据塞进去,代理开销和依赖追踪都不划算,用组件内的 shallowRef。
const rows = shallowRef<Row[]>([])
const loading = ref(false)
async function load() {
loading.value = true
try {
rows.value = await fetchRows() // 整体替换,一次触发
} finally {
loading.value = false
}
}
getters 只做派生
getters 就是 computed:有缓存,依赖没变就不重算。发请求、写 state、读 Date.now() 都不该出现在里面。
export const useCartStore = defineStore('cart', {
state: () => ({ items: [] as CartItem[], coupon: '' }),
getters: {
count: (s) => s.items.reduce((n, i) => n + i.qty, 0),
subtotal: (s) => s.items.reduce((n, i) => n + i.qty * i.price, 0),
discount(): number { // 用 this 访问别的 getter,TS 下必须写返回类型
return this.coupon ? 10 : 0
},
total(): number {
return this.subtotal - this.discount
},
},
})
跨 store 引用要放在 getter 函数体里调 useXxxStore(),写在模块顶层会踩初始化顺序的坑,两个 store 互相引用时直接拿到 undefined。
getter 返回函数((id) => items.find(...))会丢掉缓存,每次调用都重新遍历。数据量小无所谓,上千条就该换个写法:让 getter 返回一个按 id 索引的普通对象,缓存住索引,组件里直接取。
一个 store 挂几十个 getter 的项目,3.6 下这些 computed 的追踪开销比 3.5 小,但规则没变。
actions 收敛写入
所有对 state 的写入都从 action 走,收益是具体的:devtools 里能按 action 名看到变更来源和参数;$onAction 能订阅调用前后与错误;测试直接调 action,不用挂载组件。
state: () => ({ items: [] as CartItem[], loading: false, error: '' }),
actions: {
async add(sku: string, qty = 1) {
this.loading = true
this.error = ''
try {
const item = await api.addToCart(sku, qty)
const hit = this.items.find((i) => i.id === item.id)
if (hit) hit.qty += qty
else this.items.push(item)
} catch (e) {
this.error = (e as Error).message
throw e
} finally {
this.loading = false
}
},
},
异步的 loading 和错误信息一起收在 action 里,别散到各个组件。收敛也别过头:hover、展开、输入框的临时值这些纯 UI 状态留在组件里,进 store 只会让它变成杂物间。
setup store 的映射关系
选项式和组合式两种写法在 Pinia 里等价,对应关系很直接:
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const count = computed(() => items.value.reduce((n, i) => n + i.qty, 0))
async function add(sku: string, qty = 1) {
items.value.push(await api.addToCart(sku, qty))
}
return { items, count, add }
})
ref 是 state,computed 是 getter,函数是 action。在 setup store 里写的 watch 生命周期跟着 store 走,不会因为某个组件卸载就断——store 本身挂在 app 上,Pinia 内部用 effectScope 管理它的响应式副作用,这也是它在 vDOM 和 Vapor 组件里表现一致的原因。
注意 ref 在 store 实例上会自动解包,store.items 直接是数组;组件里解构 state 和 getter 要 storeToRefs,actions 可以直接解构。
状态按共享范围切
判定只看一个问题:这份状态被谁读、活多久。跨路由、跨组件树,或者要跟着登录态活很久的,进 Pinia;只在一个页面子树里共享的,提到最近的公共父组件,或者 provide/inject 传一个 composable;只在一个组件里用的,ref,别为了「统一」塞进 store。
拆 store 按业务域,不按页面。useAppStore 里同时装用户、主题、权限、通知,最后会变成谁都依赖它,改一个字段要翻半天引用。
| 状态 | 放哪 |
|---|---|
| 登录用户、权限 | Pinia,跨路由共享 |
| 页面表单草稿 | 组件内 ref,离开页面就丢 |
| 列表筛选条件 | 路由 query,要能分享链接 |
| 上万行的表格数据 | 组件内 shallowRef,整体替换 |
| 服务端数据缓存 | 查询库,缓存失效和重试它更擅长 |
真要动手,先做一件事:把当前 store 里所有能由别的字段算出来的值列出来,改成 getter,跑一遍测试。