Files
zhuangang/topics/frontend/vue/01-reactivity-composition.md

5.7 KiB
Raw Permalink Blame History

id, title, type, domain, stack, tags, level, prerequisites, est_hours, related, week, order
id title type domain stack tags level prerequisites est_hours related week order
frontend.vue.reactivity-composition Vue 3 响应式原理、组合式函数、Pinia 和性能优化 lesson frontend
vue
Vue 3
响应式
Proxy
ref
reactive
computed
watch
组合式函数
Pinia
性能
intermediate
frontend.typescript.type-system
3
frontend.vue.scenario.ai-chat-stream
frontend.react.index
frontend.vue.interview
7
1

Vue 3 响应式原理、组合式函数、Pinia 和性能优化

基于 Vue 3.5 编写,以官方文档为准:https://cn.vuejs.org/

解释说明

响应式原理(面试必问)

  • reactive() 用 Proxy 拦截对象的读写:读取时收集依赖(当前正在运行的副作用:组件渲染、computed、watch),写入时触发依赖重新执行。
  • ref() 用对象的 .value getter / setter 实现,可以包装基本类型;模板中自动解包。
  • computed 是带缓存的副作用:依赖不变就不重新计算。
  • 更新是异步批量的:同一轮中多次修改只触发一次渲染,await nextTick() 之后才能拿到更新后的 DOM。
  • Vue 2 用 Object.defineProperty,无法检测新增属性和数组索引赋值;Vue 3 的 Proxy 解决了这个问题。
API 用途 注意
ref / reactive 响应式状态 reactive 解构会丢失响应性,用 toRefs;整体替换 reactive 对象会断开引用
shallowRef 只追踪 .value 的替换 大对象、第三方实例、只整体替换的数据,性能更好
computed 派生状态 不要在里面做副作用
watch / watchEffect 副作用(请求、存储) 异步副作用要处理竞态(onWatcherCleanup / onCleanup)
effectScope / onScopeDispose 管理一组副作用的生命周期 组合式函数里清理定时器、请求、订阅

组合式函数(Composables)

把有状态的逻辑封装成 useXxx():内部用 ref、watch、生命周期钩子,返回响应式状态和方法。和 React Hooks 的区别:setup 只执行一次,没有“依赖数组”和闭包陈旧值的问题,但要注意返回值的响应性和副作用清理。

Pinia

官方状态管理:defineStore,支持组合式写法;store 解构要用 storeToRefs;服务端渲染时每个请求要有独立的 Pinia 实例,避免跨请求状态污染。

性能

v-memo、v-once;长列表虚拟滚动;大对象用 shallowRef / markRaw;组件懒加载 defineAsyncComponent;computed 代替模板中的复杂表达式;避免在模板中创建新对象 / 函数导致子组件重复渲染。

案例

AI 对话页面:流式输出时每秒几十次追加文本,最初用 reactive 存整个消息数组并且每次替换,导致长对话卡顿。改为:已完成的消息用 shallowRef 数组(完成后整体替换一次),正在生成的消息单独用一个 ref 追加文本;Markdown 渲染做节流。把请求、解析、状态机封装成 useChatStream 组合式函数,组件卸载时自动停止生成。

代码示例

组合式函数的核心(完整版本 examples/ai-chat-stream/useChatStream.ts,带 vitest 测试):

export function useChatStream(opts: ChatOptions) {
  const status = ref<ChatStatus>("idle");
  const text = ref("");
  const final = shallowRef<FinalPayload | null>(null);   // 整体替换的对象用 shallowRef
  let controller: AbortController | null = null;

  async function send(question: string) {
    controller?.abort();                                  // 新问题:取消上一次
    controller = new AbortController();
    status.value = "connecting";
    const resp = await fetch(opts.url, { method: "POST", body: JSON.stringify({ question }), signal: controller.signal });
    status.value = "streaming";
    const feed = createSSEParser((e) => {
      if (e.event === "token") text.value += e.data;
      else if (e.event === "final") final.value = parseFinal(e.data);
    });
    const reader = resp.body!.pipeThrough(new TextDecoderStream()).getReader();
    for (;;) {
      const { value, done } = await reader.read();
      if (done) break;
      feed(value);
    }
    status.value = "done";
  }

  onScopeDispose(() => controller?.abort());              // 组件卸载时停止,避免泄漏
  return { status: readonly(status), text: readonly(text), final, send, stop: () => controller?.abort() };
}

在组件中使用:

<script setup lang="ts">
import { useChatStream } from "./useChatStream";
const { status, text, final, send, stop } = useChatStream({ url: "/api/chat" });
</script>

<template>
  <p v-if="status === 'connecting'">连接中…</p>
  <article>{{ text }}</article>
  <ul v-if="final"><li v-for="c in final.citations" :key="c.id">{{ c.title }}</li></ul>
  <button v-if="status === 'streaming'" @click="stop">停止生成</button>
</template>

易错点

  • 解构 reactive 对象或 Pinia store 后丢失响应性。
  • watch 一个 reactive 对象的属性时写成 watch(obj.count, ...):应该传 getter () => obj.count。
  • 异步 watch 没有处理竞态:快速切换时旧请求的结果覆盖新结果。
  • 在组合式函数里注册了定时器 / 订阅,没有在作用域销毁时清理。
  • 用 v-html 渲染模型输出的 Markdown,没有做 XSS 清洗(见 Web 安全专题)。
  • 把大量第三方对象(图表实例、编辑器)放进 reactive,造成深度代理的性能开销。

面试常问

  • Vue 3 的响应式原理?和 Vue 2 有什么区别?
  • ref 和 reactive 的区别?
  • computed 和 watch 的区别?
  • 组合式函数和 React Hooks 有什么区别?