Day 103 · Three × Vue 边界 — 3D 版三问法则(本周灵魂)
前四天你有了能跑能转能发光的场景。今天不写新功能,做本周最重要的事:把第 3 个月沉淀的边界方法论(Day 85 三问法则)搬到 3D——场景图归 Three 管、业务状态归 Pinia 管、跨边界的通信走桥。今天决定"谁管什么",Day 105 才能交出边界清晰的车间。这是数字孪生的地基:孪生系统 80% 的坑,都埋在今天没想清楚的边界里。
目录
一、为什么 3D 更需要边界(对比 2D)
Day 85 解决的是"Canvas 引擎 × Vue"的边界;今天解决"Three.js 引擎 × Vue"。两者是同一门课,但 3D 把矛盾放大了:
结论:2D 里"把图元放进 ref"只是不优雅;3D 里"把场景树放进 reactive"是直接掉帧。边界从"该不该做"变成"必须做"。
记忆钩子:Three.js 的场景树是它自己的响应式系统(属性改动 → 下一帧自动重算矩阵)。两个响应式系统(Three 内部 + Vue)叠加 = 双重开销 + 语义混乱。一个对象只属于一个系统。
二、3D 场景的数据全盘点
照 Day 85 流程:先穷举一个 3D 车间会涉及的全部数据(不管归属):
A. 静态结构类
1. 车间布局配置(设备清单/位置/朝向声明) —— 类似 Day 68 的 topo-config
2. 设备几何与材质(形状、颜色、金属度) —— 场景树的"零件图纸"
3. 光照配置(灯的种类/位置/强度)
B. 实时数据类(第 6 个月接 WS,本周先静态)
4. 设备状态(ok / warn / alarm)
5. 设备指标(温度、转速——未来驱动动画/颜色)
C. 交互状态类
6. 相机位置/朝向(OrbitControls 内部)
7. 悬停/点击的设备(详情面板数据源)
8. 设备选中态(高亮效果)
D. 动画状态类
9. 设备自转/风扇旋转相位(0~2π 循环)
10. 帧时间戳(rAF 的 now)
三、3D 版三问法则逐项过堂
三问(Day 85 原版,3D 直接复用):
需要被 Vue 组件读吗?
每帧都变吗?
变化触发整场景重绘还是局部 UI?
规律与 Day 85 完全一致:Vue 拿低频、事件级、跨组件消费的;Three 拿每帧、高频、纯渲染的。这张表是 Day 105 验收的核心交付物。
3.1 两个"看起来争议"的裁决
光照配置(#3)归 Three 还是 Vue?
取决于是否会被 UI 配置。本周做成 Three 内部私有(最简单);未来做"光照设置面板"时,把"配置数据"归 Vue、"渲染结果"归 Three——数据进 Vue,渲染结果不出引擎。
车间布局配置(#1)为什么归 Vue 又要 markRaw?
归 Vue 是"它是配置数据的来源";markRaw 是"配置只在加载时消费一次,不需要响应式跟踪"(Day 85 坑 3 同款)。裁决"归 Vue"不等于"必须响应式"。
四、markRaw:为什么 Three 对象不能被响应式包裹
4.1 问题:ref(threeObject) 会发生什么
import { ref } from "vue";
import * as THREE from "three";
// ❌ 反例:把 Three 场景树放进 ref
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), mat);
const meshRef = ref(mesh);
// → Vue 用 Proxy 深度代理 mesh:
// 每次访问 meshRef.value.rotation 都走 Proxy getter
// 每次改 rotation.y 都触发 Proxy setter + 依赖追踪
// rAF 里每帧改几十个属性 = 每帧几十次 Proxy + 可能的 watcher 调度
结果:掉帧 + 语义混乱(场景树的"属性改动 → 下一帧重算"被 Proxy 拦截打断)。
4.2 修法:markRaw 或普通变量
import { markRaw } from "vue";
import * as THREE from "three";
// ✅ 方式 A:markRaw——告诉 Vue"别代理我"
// 适合必须放进响应式容器(ref/reactive)但不需要追踪的场景
const deviceGroup = markRaw(new THREE.Group());
// ✅ 方式 B:普通变量/模块级持有(Day 87 同款纪律,更推荐)
// 场景树根本不需要"进 Vue 的视野"——普通变量即可
let sceneGraph: THREE.Group | null = null; // 引擎内部持有
记忆钩子:Three 对象只进引擎,不进 Vue。需要用到的"数据形态"(设备 id、状态、指标)才走桥进 Vue——传数据,不传对象(Day 85 桥的窄口原则 3D 版)。
五、桥契约:入向桥与出向桥
边界定了,桥就是两份显式契约——Day 85 第三节的 3D 版:
5.1 入向桥:Vue → Three(数据驱动场景)
/**
* 入向桥契约:Vue 侧配置/数据 → Three 引擎
* 原则:桥口要"窄"——只传引擎需要的最小形态,不传整个 store
*/
export interface SceneBridge {
/** 初始化车间:布局配置(设备清单/位置)→ 场景树(只调一次) */
initDevices(config: DeviceLayout[]): void;
/** 数据快照到达:状态 → 驱动设备颜色/动画(第 6 个月接 WS 时用) */
updateStates(states: Map<string, DeviceState>): void;
/** resize 通知(DPR/尺寸适配引擎内部处理) */
resize(): void;
/** 动画开关:组件失活暂停 / 激活恢复(Day 65 激活喂策略 3D 版) */
setAnimation(on: boolean): void;
}
5.2 出向桥:Three → Vue(事件上报)
/**
* 出向桥契约:引擎"报告事实",不携带渲染对象
* 原则:传 id 传数据,绝不把 Group/Mesh 引用递给 Vue
*/
export type SceneEvents =
| { type: "hover"; deviceId: string | null } // 悬停变化(离开为 null)
| { type: "select"; deviceId: string }; // 设备点击(Day 104 实现)
5.3 为什么"窄口"这么重要
两份契约就是 Vue × Three 的全部耦合面:
未来换引擎(Three → 自研 WebGL/Babylon):只动引擎侧,Vue 侧零改动
升级 Vue/组件结构:不碰引擎
面试价值:“我用两个接口隔离了 3D 引擎与 Vue”——这是架构能力的话术
边界的本质是可替换性的承诺(Day 85 原句),3D 版照搬。
六、组件结构:ThreeViewport + 引擎 + 状态
边界落地成代码,三层结构:
ThreeViewport.vue(Vue 层:挂载点 + 生命周期)
│ 使用 markRaw/普通变量持有引擎
▼
SceneEngine(Three 层:场景树、循环、Raycaster——day99-102 的汇总)
│ 通过 SceneBridge 入向 / SceneEvents 出向
▼
Pinia store(数据层:设备状态/指标——第 6 个月才接 WS,本周先静态)
<!-- src/components/ThreeViewport.vue(Day 100 升级版:边界化的完整形态) -->
<script setup lang="ts">
/**
* 3D 视口组件:Vue 层唯一与 Three 接触的"门户"
* 边界纪律:
* 1. 引擎实例普通变量持有(不放进 ref/reactive)
* 2. 只通过 SceneBridge/SceneEvents 与引擎通信
* 3. 卸载时停止循环 + dispose(成对纪律)
*/
import { onMounted, onUnmounted, ref } from "vue";
import { createSceneEngine } from "../core/three/scene-engine";
import { deviceLayout } from "../config/device-layout"; // 静态布局配置(Vue 侧)
const canvas = ref<HTMLCanvasElement>();
let dispose: (() => void) | null = null;
let engine: ReturnType<typeof createSceneEngine> | null = null;
onMounted(() => {
// 1. 创建引擎(内部建场景/光照/循环/桥)
engine = createSceneEngine(canvas.value!);
// 2. 入向桥:喂静态布局(本周无 WS,用静态配置)
engine.bridge.initDevices(deviceLayout);
// 3. 出向桥:引擎事件 → Vue(Day 104 接交互)
engine.events.on((e) => {
if (e.type === "select") {
// 未来:写 Pinia 选中态 / 打开详情面板
}
});
// 4. 启动循环 + 清理成对
const stop = engine.start();
dispose = () => {
stop();
engine!.dispose();
};
});
onUnmounted(() => dispose?.());
</script>
注意第 2 步:本周引擎的入向数据是静态配置(红线第 3 条:数据不接 WS)。桥的接口从今天定死,第 6 个月只把"静态配置"换成"store 快照"——边界提前定,接入零返工。
七、边界决策文档(Day 105 定稿)
今天的核心作业——把第三至五节写成 docs/boundary-three-vue.md 存进工程(Day 105 定稿后作为交付物):
# 3D 场景 × Vue 边界决策文档(Day 103 初稿 / Day 105 定稿)
## 数据裁决表
(第三节完整表格,含三问依据与理由)
## 桥契约
(第五节的 SceneBridge / SceneEvents + 设计原则注释)
## 响应式纪律
- Three 对象只用 markRaw 或普通变量,绝不进 reactive/ref
- 传数据不传对象:Vue 侧只持有 id / 状态,不持有 Group/Mesh
## 反例推演记录
(第四节"场景树进 ref"的掉帧推演,供 review 对照)
## 复审触发条件
- 第 6 个月接 WS 时:确认 updateStates 的分频与脏检查(Day 46 能力回流)
- 设备数 > 500 时:重评 draw call 与可见性剔除(Day 106+ 主线)
- 新增"设备配置面板"时:重审光照/布局配置是否要从 Three 拆回 Vue
边界决策要有失效条件——Day 85 原话:环境和需求变了边界就要复审,没有一劳永逸的分界线。
八、常见坑点
坑 1:把场景树放进了 ref/reactive
症状:FPS 骤降、动画卡顿、甚至 Proxy 报错。原因:Vue 深度代理整个 Three 场景树。修法:markRaw 或普通变量(第四节)。
坑 2:桥传了对象引用
症状:Vue 侧持有 Group,逻辑开始"从 Vue 改引擎内部状态"。原因:出向桥返回了渲染对象。修法:只传 id/数据(第三节规律)。
坑 3:静态配置做成了 computed
症状:布局配置只消费一次,却每帧被依赖追踪。原因:静态数据用了响应式 API。修法:普通常量 + markRaw(Day 85 坑 3 同款)。
坑 4:为省事直接从引擎 import store
症状:桥走不通就开侧门,边界上凿洞。原因:偷懒。修法:任何数据流动必须走桥契约,桥不够就扩桥(显式决策),不开侧门(Day 85 坑 1)。
坑 5:卸载只 stop 不 dispose
症状:显存泄漏。原因:只停了循环,没释放 GPU 上下文。修法:stop + renderer.dispose 成对(Day 100)。
九、自测挑战
T1 · 数据盘点 + 三问过堂(60 分钟)
不看本文,从零完成第二、三节:穷举 3D 车间全部数据(≥ 9 项),逐项三问裁决。与本文对照,差异处重点分析——分歧比一致更有价值(Day 85 原话)。
T2 · 反例推演(30 分钟)
选一个数据(如"设备状态"),假设放错边:把它做成 ref 放 Vue 且每帧更新,推演性能与语义代价链。写进边界文档的"反例推演"节。
T3 · 桥契约评审(30 分钟)
给 SceneBridge / SceneEvents 的每个方法补注释:触发时机、频率、payload 形态。写不清楚的地方就是设计漏洞(Day 85 原话)。
T4 · markRaw 实验(20 分钟)
建两个场景:A 把 Group 放进 ref,B 用 markRaw。跑动画对比 FPS——亲手测出差距,纪律才长在身上。
十、总结
边界图纸画完了。明天给场景加上"手"——Raycaster 点击拾取,让 3D 世界能被操作。
明日预告:Day 104 交互——Raycaster 射线拾取、OrbitControls 相机控制、以及出向桥的落地:引擎报"我点了哪个设备",Vue 收到 id 打开面板。