Vue 深入:依赖追踪、侦听清理与组件作用域
依赖、代理与外部状态
先完成Vue阅读清单,理解ref/computed、props/emits与SFC;沿用Vue3.5.44/Vite8.3.4。响应式对象通过属性读写追踪依赖;解构一个普通primitive属性得到的是当时值,不能假定仍连着原对象。ref把值放在.value,模板按规则解包;reactive代理身份与原对象不同,不能混用身份作缓存键。computed缓存基于所读取依赖,getter应无副作用。
本项目将框架外部模型的不可变快照放在shallowRef,只在模型发布时替换.value,避免再把冻结快照深度代理。shallowRef不会自动观察快照内部突变,这与模型禁止突变的契约一致;如果业务改成可变对象,就必须重新选择发布方式。输入草稿query、已提交submitted、触发序号request分开:相同查询也可重试,不能只watch字符串值而使相同值提交失效。
watch与组件作用域
watch显式指定依赖,默认只在依赖变化时运行;watchEffect自动追踪同步执行期间读取的依赖,await之后新读取的依赖不自动都被纳入。侦听器可能批处理,DOM更新也不是同步逐步发生;需要读更新后的DOM可用nextTick或合适flush时机,不能以任意setTimeout猜更新完成。
本例同步创建watch(request),回调立即注册onCleanup,再启动异步run。下次执行或停止侦听时取消当前任务;Vue3.5的onWatcherCleanup要求在同步执行期间调用,不能先await再注册。这里使用回调参数onCleanup,仍选择先注册再异步,方便推理资源归属。组件setup内同步创建的侦听器随作用域停止;延迟在setTimeout里创建的不自动拥有相同生命周期,需要保存stop函数自行管理。
AsyncPanel拥有模型,订阅返回off;onScopeDispose先退订再dispose模型,旧Promise失败也不再写状态。父组件v-if卸载子组件,重新挂载会创建全新模型和idle快照。这与React示例把模型放在父App的拥有关系不同,是有意展示两种资源边界,不强求两框架采用同样的代码。组件通信仍以props/emits为主;provide/inject适合跨层依赖,Pinia等状态库另解决应用级状态,不是响应式的必备前置。
完整项目
保留原examples/web-plus/vue阅读清单,添加四文件;共享外部模型和样式见JavaScript项目、Web完整源码,按仓库examples层级运行。所有新增文件与双入口配置如下。
advanced.html
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Vue 异步状态实验</title></head><body><div id="app"></div><script type="module" src="/src/advanced.mjs"></script></body></html>src/advanced.mjs
import {createApp} from 'vue';
import Advanced from './Advanced.vue';
import '../../../web/system-lab/style.css';
createApp(Advanced).mount('#app');src/Advanced.vue
<script setup>
import {ref} from 'vue';
import AsyncPanel from './AsyncPanel.vue';
const visible=ref(true);
</script>
<template><main><h1>Vue 异步状态实验</h1><p>slow延迟800ms;error模拟失败。侦听器在重跑与卸载时取消旧任务。</p><button @click="visible=!visible">{{visible?'卸载查询面板':'挂载查询面板'}}</button><AsyncPanel v-if="visible"/></main></template>src/AsyncPanel.vue
<script setup>
import {ref,shallowRef,watch,onScopeDispose} from 'vue';
import {createLatest} from '../../../web/system-lab/model.mjs';
const model=createLatest(),state=shallowRef(model.getSnapshot());
const query=ref('vue'),submitted=ref(''),request=ref(0),error=ref('');
const off=model.subscribe(()=>{state.value=model.getSnapshot();});
watch(request,(_value,_old,onCleanup)=>{
onCleanup(()=>model.cancel());
model.run(submitted.value).catch(e=>{error.value=e.message;});
});
onScopeDispose(()=>{off();model.dispose();});
function submit(){error.value='';submitted.value=query.value;request.value++;}
</script>
<template><form @submit.prevent="submit"><label for="query">课程查询</label><input id="query" v-model="query" required maxlength="80"><button>查询</button><button type="button" @click="model.cancel()">取消</button></form><p v-if="error" role="alert">{{error}}</p><p role="status">{{state.status}} · {{state.query}}{{state.message?' · '+state.message:''}}</p><ul><li v-for="item in state.items" :key="item">{{item}}</li></ul></template>vite.config.mjs
import {defineConfig} from 'vite';
import vue from '@vitejs/plugin-vue';
import {fileURLToPath} from 'node:url';
export default defineConfig({plugins:[vue()],base:'./',build:{rollupOptions:{input:{main:fileURLToPath(new URL('./index.html',import.meta.url)),advanced:fileURLToPath(new URL('./advanced.html',import.meta.url))}}}});package.json
{
"name": "m3-vue-reading",
"private": true,
"type": "module",
"scripts": {"dev": "vite --host 127.0.0.1", "test": "node --test model.test.mjs", "build": "vite build", "verify": "npm test && npm run build", "preview": "vite preview --host 127.0.0.1"},
"engines": {"node": ">=22.12.0"},
"dependencies": {"vue": "3.5.44"},
"devDependencies": {"vite": "8.3.4", "@vitejs/plugin-vue": "6.0.10"}
}进入examples/web-plus/vue执行npm ci、npm run verify、npm run preview -- --port 5191;访问http://127.0.0.1:5191/advanced.html。vue正常返回,slow后react不能被旧结果覆盖,error后可重试,取消显示idle;卸载后无查询面板,重新挂载回到新idle。原index.html行为保持。模型4组测试另从仓库根node --test examples/web/system-lab/model.test.mjs运行。
渲染、性能与诊断
批处理减少重复工作,不代表DOM立刻等于所有刚写入的状态。使用浏览器Performance和Vue DevTools确认更新来源,先查不必要的深侦听、大列表和频繁替换依赖,再决定是否缓存。深watch旧值与新值可能是同一引用,不是自动生成变更前快照;需要历史记录就显式复制。scoped样式通过选择器变换限定匹配,不是Shadow DOM或安全隔离。
SSR需要每请求状态隔离,异步数据与hydration一致;本客户端示例不具备服务器渲染入口。KeepAlive停用与真正卸载不同,若放入缓存组件,要定义onActivated/onDeactivated期间任务策略,不能默认onScopeDispose每次切换都触发。Teleport改变渲染DOM位置而不改变逻辑组件父子归属,不能靠DOM父节点猜inject作用域。
输入变了但请求未触发先查submitted/request分工;Vue warning先看prop是否被子组件写入;卸载后仍通知先查off和任务所有者;打包后404先查advanced入口和base。不要把watch改为deep:true当成所有更新问题的统一修复。
练习
- 去掉request只watch submitted,为何相同关键词不能重试?2. 把模型移动到父组件,卸载子组件时谁可dispose?3. 为什么shallowRef快照里的数组不能push?
参考解法
相同primitive赋值不构成依赖变化,独立序号描述一次用户提交。资源归父,子组件只退订并按协议通知取消,不能销毁其他消费者借用的模型。外部模型freeze数组且要求发布新快照,push既破坏只读契约也不会构成shallowRef的新.value;必须通过模型操作生成新快照。
继续阅读
- 排错
- 开发实践
- 开发实践