Vue 3.6 换掉的是响应式的追踪算法、Vapor 换掉的是渲染路径,这两件事都不要求你重写状态层,但会改变「什么该进 store、什么该留在组件里」的成本判断。

alien-signals 进的是内核,不是新 API

3.6 的 @vue/reactivity 换成了 alien-signals 那套算法实现,代码搬进 Vue 自己的包里,不是多引一个运行时依赖。对外签名没动,refreactivecomputedwatcheffectScope 的用法跟 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 组件里 refcomputedwatch 的来源跟 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。放 MapSet、class 实例、函数进去,这三件事都会出问题。

扁平和可序列化是同一个约束的两面。嵌套深不会直接拖慢渲染,但会让「改一个字段」更容易变成「替换一整块对象」,快照和 diff 也难看。

另一个高频误用是把能算出来的值存进 state(totalcountisAllChecked)。这类字段一旦落进 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,跑一遍测试。