非科班Java出身GISer头像
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 传统方式:一个属性一个句柄

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 一个句柄盯多个属性

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 最省事:

// 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不同缩放级别显示不同详细程度的图层
首次加载完成回调updatingupdating 由 true 变 false 即本轮渲染结束
相机参数记录与回放camera记录关键帧,实现三维漫游录制
图层可见性联动layer.visible图层显隐变化时刷新图例或统计面板

四、核心代码

📦 完整代码 已保存至 sample/lesson48_sceneview_watch.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(...) 并显式打印失败原因。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/linzi19900517/article/details/168528452

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--