ArcGIS JS 基础教程(48):SceneView watch 状态监听

ArcGIS JS 基础教程(48):SceneView watch 状态监听

零、写在前面

📌 本系列教程完整目录 :ArcGIS JS 系列基础教程(100个项目常用热门功能)

💡 在线示例 :完整可运行的 HTML 示例,无需任何环境配置,可直接在浏览器中打开体验

🗂️ 专栏导航 :收藏 + 关注,专栏文章第一时间送达

❤️ 一键三连:点赞 + 评论 + 收藏


一、功能介绍

在三维应用中,「知道视图什么时候变了、变成了什么样」是几乎所有交互功能的基础:拖动地图后要重新统计可见要素、旋转视角后要同步更新指北针、镜头停下后要加载当前范围的数据......这些需求本质上都是监听视图状态的变化。

ArcGIS JS 提供了两套监听机制:

① 传统 view.watch()(Accessor 监听器)

来自 Accessor 基类,view.watch("属性名", 回调) 每次只能盯住一个属性 ,回调收到 (newValue, oldValue, propertyName, target)。它返回一个 WatchHandle,调用 handle.remove() 即可取消监听。

② reactiveUtils(5.0 推荐)

reactiveUtils 提供 watch() / when() / on() / once() / whenOnce() 五个函数。它的核心是自动依赖收集 :你写一个 getValue 表达式,里面访问到的所有属性都会被自动跟踪,任意一个变化就触发回调。因此一个句柄可以同时盯住多个属性。

二者的关键差异:

对比项 view.watch() reactiveUtils.watch()
跟踪属性数 一次一个 一次多个(表达式里访问到的都算)
表达式灵活性 只能写属性名字符串 任意 JS 表达式(可计算、可条件判断)
初始化回调 不支持 支持 initial: true
只触发一次 需手动 remove 支持 once: true 自动移除
一次性等待 不支持 once() / whenOnce() 返回 Promise,可 await
取消方式 handle.remove() handle.remove()

💡 三维要点 :SceneView 最常监听的属性是 camera(位置/朝向)、scale、zoom、center、extent、stationary(是否静止)、updating(是否渲染中)。三维场景中 camera 还包含 heading(方位角)、tilt(倾斜角),这是二维视图没有的。

参考:reactiveUtils 官方 API | 监听变化指南


二、功能实现

2.1 传统方式:一个属性一个句柄

javascript 复制代码
const handles = [];
handles.push(view.watch("camera", updatePanel));
handles.push(view.watch("scale", updatePanel));
handles.push(view.watch("stationary", updatePanel));

// 取消监听:逐个 remove,避免内存泄漏
handles.forEach(h => h.remove());
handles.length = 0;

2.2 推荐方式:reactiveUtils 一个句柄盯多个属性

javascript 复制代码
const reactiveUtils = await $arcgis.import("@arcgis/core/core/reactiveUtils.js");

const handle = reactiveUtils.watch(
    // getValue:表达式里访问到的属性都会被自动跟踪
    () => [view.camera?.heading, view.camera?.tilt, view.scale, view.stationary],
    // callback:值变化时执行
    () => updatePanel(),
    { initial: true }   // 初始化时立即回调一次
);

handle.remove();   // 取消监听

options 常用参数:initial(初始化立即回调,默认 false)、sync(同步回调,默认 false 即微任务异步)、once(触发一次后自动移除句柄)、equals(自定义相等判断,避免无谓回调)。

2.3 一次性等待:when / whenOnce

「等视图停下来再做一次」是三维里极常见的需求,用 when + once:true 最省事:

javascript 复制代码
// when:条件变为真时触发,once:true 触发后自动释放句柄
reactiveUtils.when(
    () => view.stationary === true,
    () => console.log("视图静止,开始加载数据"),
    { once: true }
);

// whenOnce:返回 Promise,可直接 await
await reactiveUtils.whenOnce(() => view.stationary === true);

⚠️ 注意布尔属性要用显式比较 (=== true / === false)。when() 判断的是「真值」,如果只写 () => view.stationary,那么 false → false 不会触发;而 stationary 恰好默认就是 true,直接写会导致首次拖动后行为不符合预期。


三、功能应用

场景 监听属性 说明
视口内要素统计 stationary 镜头停下后再查询,避免拖动过程中频繁请求
指北针 / 俯仰角同步 camera.heading / camera.tilt 实时把相机朝向同步到自定义 UI
缩放级别联动 zoom / scale 不同缩放级别显示不同详细程度的图层
首次加载完成回调 updating updating 由 true 变 false 即本轮渲染结束
相机参数记录与回放 camera 记录关键帧,实现三维漫游录制
图层可见性联动 layer.visible 图层显隐变化时刷新图例或统计面板

四、核心代码

📦 完整代码 已保存至 sample/lesson48_sceneview_watch.html,可直接在浏览器打开。

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>第48课:SceneView watch 状态监听</title>
    <link rel="stylesheet" href="https://js.arcgis.com/5.0/esri/themes/light/main.css">
    <script type="module" src="https://js.arcgis.com/5.0/"></script>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: "Microsoft YaHei", sans-serif; }
        #mapContainer { width: 100vw; height: 100vh; }
        .page-title {
            position: absolute; top: 20px; left: 50%; transform: translateX(-50%);
            background: rgba(255,255,255,0.95); padding: 10px 24px; border-radius: 6px;
            font-size: 18px; font-weight: bold; z-index: 100;
            box-shadow: 0 2px 8px rgba(0,0,0,0.15);
        }
        .panel {
            position: absolute; top: 80px; right: 20px; width: 300px;
            background: rgba(255,255,255,0.95); padding: 16px; border-radius: 8px;
            box-shadow: 0 2px 12px rgba(0,0,0,0.15); z-index: 100;
        }
        .panel h3 { margin: 0 0 8px 0; font-size: 14px; }
        .info-row {
            display: flex; justify-content: space-between;
            font-size: 12px; padding: 4px 0; border-bottom: 1px dashed #eee;
        }
        .info-row span:last-child { color: #1890ff; font-weight: bold; }
        .btn-row { display: flex; gap: 8px; margin-top: 8px; }
        .btn-row button {
            flex: 1; padding: 6px 0; font-size: 12px;
            border: 1px solid #d9d9d9; border-radius: 4px;
            background: white; cursor: pointer;
        }
        .btn-row button:hover { border-color: #1890ff; color: #1890ff; }
        .btn-row button.active { background: #1890ff; color: white; border-color: #1890ff; }
        .status {
            position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
            background: rgba(0,0,0,0.7); color: white; padding: 8px 20px;
            border-radius: 20px; font-size: 13px; z-index: 100; pointer-events: none;
        }
    </style>
</head>
<body>
<h1 class="page-title">第48课:SceneView watch 状态监听</h1>

<div class="panel">
    <h3>📡 实时状态</h3>
    <div class="info-row"><span>经度</span><span id="vLng">--</span></div>
    <div class="info-row"><span>纬度</span><span id="vLat">--</span></div>
    <div class="info-row"><span>相机高度</span><span id="vZ">--</span></div>
    <div class="info-row"><span>方位角 heading</span><span id="vHeading">--</span></div>
    <div class="info-row"><span>倾斜角 tilt</span><span id="vTilt">--</span></div>
    <div class="info-row"><span>比例尺 1:</span><span id="vScale">--</span></div>
    <div class="info-row"><span>静止 stationary</span><span id="vStationary">--</span></div>
    <div class="info-row"><span>回调次数</span><span id="vCount">0</span></div>

    <h3 style="margin-top: 12px;">① 传统 view.watch()</h3>
    <div class="btn-row">
        <button id="btnLegacyStart">启动监听</button>
        <button id="btnLegacyStop">停止监听</button>
    </div>

    <h3 style="margin-top: 12px;">② reactiveUtils.watch()</h3>
    <div class="btn-row">
        <button id="btnRuStart">启动监听</button>
        <button id="btnRuStop">停止监听</button>
    </div>

    <h3 style="margin-top: 12px;">③ when / whenOnce</h3>
    <div class="btn-row">
        <button id="btnWhen">when 静止后提示</button>
        <button id="btnWhenOnce">whenOnce 等待静止</button>
    </div>

    <h3 style="margin-top: 12px;">④ 触发视图变化</h3>
    <div class="btn-row">
        <button id="btnShanghai">飞到上海</button>
        <button id="btnBeijing">飞回北京</button>
    </div>
</div>

<div class="status" id="st">SceneView watch | 先启动监听,再拖动/旋转地图观察状态变化</div>
<div id="mapContainer"></div>

<script type="module">
    const Map = await $arcgis.import("@arcgis/core/Map.js");
    const SceneView = await $arcgis.import("@arcgis/core/views/SceneView.js");
    const GraphicsLayer = await $arcgis.import("@arcgis/core/layers/GraphicsLayer.js");
    const Graphic = await $arcgis.import("@arcgis/core/Graphic.js");
    const Point = await $arcgis.import("@arcgis/core/geometry/Point.js");
    const ElevationLayer = await $arcgis.import("@arcgis/core/layers/ElevationLayer.js");
    const reactiveUtils = await $arcgis.import("@arcgis/core/core/reactiveUtils.js");
    const getTianditu = await $arcgis.import("https://openlayers.vip/examples/resources/tianditu.js");

    const tl = getTianditu.default({ type: "vec_w" });
    const map = new Map({
        basemap: { baseLayers: [tl.base, tl.anno] },
        ground: {
            surface: {
                elevationLayers: [new ElevationLayer({
                    url: "https://www.geosceneonline.cn/image/rest/services/OpenData/ChinaTerrain3D/ImageServer/"
                })]
            }
        }
    });

    const view = new SceneView({
        container: "mapContainer",
        map: map,
        camera: { position: { longitude: 116.397, latitude: 39.908, z: 1500 }, heading: 0, tilt: 60 }
    });

    // 少量参照点,便于观察视图变化时坐标的联动
    const gl = new GraphicsLayer({ title: "参照点", elevationInfo: { mode: "relative-to-ground" } });
    [[116.397, 39.908, "北京"], [121.473, 31.230, "上海"]].forEach(([lng, lat, name]) => {
        gl.add(new Graphic({
            geometry: new Point({ longitude: lng, latitude: lat, z: 200 }),
            symbol: {
                type: "point-3d",
                symbolLayers: [{ type: "object", resource: { primitive: "sphere" }, width: 120, height: 120, depth: 120, material: { color: [255, 80, 80] } }]
            },
            attributes: { NAME: name }
        }));
    });
    map.add(gl);

    await view.when();

    // ===== 面板刷新 =====
    let callbackCount = 0;
    function setStatus(msg) { document.getElementById("st").textContent = msg; }

    function updatePanel() {
        const cam = view.camera;
        if (!cam) return;
        callbackCount++;
        document.getElementById("vLng").textContent = cam.position.longitude.toFixed(5);
        document.getElementById("vLat").textContent = cam.position.latitude.toFixed(5);
        document.getElementById("vZ").textContent = cam.position.z.toFixed(0) + " m";
        document.getElementById("vHeading").textContent = cam.heading.toFixed(1) + "°";
        document.getElementById("vTilt").textContent = cam.tilt.toFixed(1) + "°";
        document.getElementById("vScale").textContent = Math.round(view.scale).toLocaleString();
        document.getElementById("vStationary").textContent = view.stationary ? "是" : "运动中";
        document.getElementById("vCount").textContent = callbackCount;
    }

    // ===== ① 传统 watch:每个属性一个句柄 =====
    const legacyHandles = [];
    function startLegacy() {
        if (legacyHandles.length) { setStatus("传统监听已在运行"); return; }
        legacyHandles.push(view.watch("camera", updatePanel));
        legacyHandles.push(view.watch("scale", updatePanel));
        legacyHandles.push(view.watch("stationary", updatePanel));
        document.getElementById("btnLegacyStart").classList.add("active");
        setStatus("传统 watch 已启动:camera / scale / stationary 共 3 个句柄");
    }
    function stopLegacy() {
        legacyHandles.forEach(h => h.remove());
        legacyHandles.length = 0;
        document.getElementById("btnLegacyStart").classList.remove("active");
        setStatus("传统 watch 已停止,句柄已释放");
    }

    // ===== ② reactiveUtils:一个句柄跟踪多个属性 =====
    let ruHandle = null;
    function startReactive() {
        if (ruHandle) { setStatus("reactiveUtils 监听已在运行"); return; }
        ruHandle = reactiveUtils.watch(
            () => [view.camera?.heading, view.camera?.tilt, view.scale, view.stationary],
            () => updatePanel(),
            { initial: true }
        );
        document.getElementById("btnRuStart").classList.add("active");
        setStatus("reactiveUtils.watch 已启动:1 个句柄跟踪 4 个属性(initial:true 立即回调一次)");
    }
    function stopReactive() {
        if (!ruHandle) { setStatus("reactiveUtils 监听未启动"); return; }
        ruHandle.remove();
        ruHandle = null;
        document.getElementById("btnRuStart").classList.remove("active");
        setStatus("reactiveUtils 句柄已 remove(),监听解除");
    }

    // ===== ③ when / whenOnce =====
    function runWhen() {
        // once:true → 触发一次后自动移除句柄,适合「停下来再做一次」的场景
        reactiveUtils.when(
            () => view.stationary === true,
            () => setStatus("when:视图已静止,执行一次(句柄自动释放)"),
            { once: true }
        );
        setStatus("when 已注册:请在地图中拖动/旋转,松手后触发一次");
    }
    async function runWhenOnce() {
        setStatus("whenOnce:等待视图静止(Promise)...");
        await reactiveUtils.whenOnce(() => view.stationary === true);
        setStatus("whenOnce:Promise 已 resolve,视图静止");
    }

    // ===== ④ 触发视图变化 =====
    function flyTo(lng, lat, name) {
        view.goTo({
            position: new Point({ longitude: lng, latitude: lat, z: 1500 }),
            heading: 0,
            tilt: 60
        }, { duration: 2000 })
            .catch((e) => console.warn("goTo 失败:", e?.message));
        setStatus("正在飞往" + name + ",观察面板数值联动更新");
    }

    document.getElementById("btnLegacyStart").onclick = startLegacy;
    document.getElementById("btnLegacyStop").onclick = stopLegacy;
    document.getElementById("btnRuStart").onclick = startReactive;
    document.getElementById("btnRuStop").onclick = stopReactive;
    document.getElementById("btnWhen").onclick = runWhen;
    document.getElementById("btnWhenOnce").onclick = runWhenOnce;
    document.getElementById("btnShanghai").onclick = () => flyTo(121.473, 31.230, "上海");
    document.getElementById("btnBeijing").onclick = () => flyTo(116.397, 39.908, "北京");

    updatePanel();
    setStatus("场景就绪 | 启动监听后拖动或旋转地图,观察状态实时联动");
</script>
</body>
</html>

五、在线示例

🔗 在线体验 :https://southjor.github.io/arcgis-examples/lessons/lesson48.html

操作说明:

  1. 点击「① 启动监听」,然后拖动或旋转地图,观察面板中经纬度、heading、tilt、比例尺实时联动。
  2. 点击「① 停止监听」后再拖地图,面板不再更新,直观理解 handle.remove() 的作用。
  3. 切换到「② reactiveUtils」,注意 initial: true 会在启动时立刻回调一次(顶栏提示与回调次数变化)。
  4. 点击「③ when 静止后提示」,再拖动地图,松手后触发一次且句柄自动释放。
  5. 用「④ 飞到上海 / 飞回北京」触发 goTo 动画,观察整个飞行过程中监听回调被连续触发。

六、关键 API 说明

API 说明
view.watch(prop, cb) 传统监听,一次一个属性,返回 WatchHandle
WatchHandle.remove() 取消监听,释放引用
reactiveUtils.watch(getValue, cb, options?) 自动依赖收集,一个句柄盯多个属性,返回 ResourceHandle
reactiveUtils.when(getValue, cb, options?) 表达式变为真值时触发,常用于「等条件满足」
reactiveUtils.once(getValue, signal?) 返回 Promise,值变化时 resolve 一次
reactiveUtils.whenOnce(getValue, signal?) 返回 Promise,条件变为真值时 resolve
ResourceHandle.remove() 取消监听;remove() 幂等,可重复调用
options.initial 初始化时立即回调一次(默认 false)
options.once 触发一次后自动移除句柄(默认 false)
options.sync 同步回调(默认 false,即微任务异步批处理)
view.camera 相机,含 position / heading / tilt
view.stationary 视图是否静止(交互结束为 true)
view.updating 是否正在渲染(由 true→false 表示本轮渲染完成)

参考链接:


七、系列导航

⬅️ 上一篇 :ArcGIS JS 基础教程(47):内置组件集成

➡️ 下一篇 :ArcGIS JS 基础教程(49):场景组件1


💡 小贴士 :监听最容易踩的坑有两个,忘记 remove() 导致内存泄漏 (尤其是单页应用反复创建监听时),以及对布尔属性用真值判断 (when(() => view.stationary) 在默认 true 时不会按预期触发,务必写 === true)。另外,when() / watch() 默认在微任务中异步回调,会把一帧内的多次变化合并成一次执行,这是它性能更好的原因;只有在必须同步读取新值的极少数场景才需要 sync: true。还有一个与监听无关的坑:三维 view.goTo 的 position 必须传 Point 实例或 [x,y,z] 坐标数组 ,不能传 { longitude, latitude, z } 这种平面对象------后者不会被解析成目标点,动画会静默失败(.catch(() => {}) 还会把错误吞掉,难以排查),本示例的 flyTo 已改用 new Point(...) 并显式打印失败原因。

相关推荐
JudithHuang2 天前
React 嵌套路由与布局
前端·react.js·arcgis·react·react router
Dxy12393102163 天前
JS DES 加密详解
开发语言·javascript·arcgis
非科班Java出身GISer6 天前
ArcGIS JS 基础教程(46):编辑要素属性 FeatureForm
arcgis·arcgis js编辑要素·arcgis js 要素·arcgis js要素属性·arcgis js 编辑属性·arcgis js编辑要素属性
梅如你7 天前
ISPRS | 1990-2020年间中国人工林与天然林的空间分布数据
arcgis·gis·遥感
limit007510 天前
公开 WMTS / XYZ 瓦片接入 Cesium:6 类常见问题与排查顺序
chrome·低代码·arcgis·web3·旅游
limit007511 天前
三维模型悬空与切片偏移的排查顺序
低代码·3d·arcgis·web3·安卓·webgl
最强小杰11 天前
Continue + minimax-m3 接入教程:协议、模型 ID、404 与工具调用排错
arcgis·ai
2501_9343044015 天前
新增2026年! 2012-2026年全国逐月夜间灯光数据
arcgis·gis数据
梅如你16 天前
中国2026年路网数据
arcgis·gis·遥感