在鸿蒙应用开发中,经常需要处理这类需求:用户滚动到某个位置时触发动画、图片滑入可见区域再加载、导航栏吸顶后切换样式。很多开发者第一反应是监听 scroll 事件并手动计算位置,代码冗长且容易出错。实际上,ASCF 框架已经提供了两个专门的能力:has.createSelectorQuery 用于节点查询,has.createIntersectionObserver 用于交叉观察。合理使用它们,可以大幅简化逻辑。
一、SelectorQuery:解决 ASCF 中的节点查询问题
传统 Web 开发中,document.querySelector 配合 getBoundingClientRect 可以轻松拿到元素位置。但 ASCF 没有直接操作 DOM 的接口,所以需要用 has.createSelectorQuery 来查询节点的位置、尺寸、滚动偏移、自定义属性等信息。
1. 基础用法
调用 has.createSelectorQuery() 会返回一个 SelectorQuery 对象,之后可以链式调用 select、boundingClientRect 等方法,最后必须调用 exec() 才会真正执行查询。很多初次使用的开发者容易忘记 exec(),导致回调一直不触发。
获取单个元素的位置:
- const query = has.createSelectorQuery();
- query
- .select('#myElement')
- .boundingClientRect()
- .exec((res) => {
- if (res && res[0]) {
- console.info('left:', res[0].left);
- console.info('top:', res[0].top);
- console.info('width:', res[0].width);
- console.info('height:', res[0].height);
- }
- });
复制代码
res 是一个数组,每个元素对应一次查询结果。只查一个节点时,用 res[0] 读取即可。
2. 批量查询节点位置
使用 selectAll 可以一次获取多个匹配节点的信息,返回结果是数组的数组,res[0] 即为所有匹配节点的信息数组。
- const query = has.createSelectorQuery();
- query
- .selectAll('.card-item')
- .boundingClientRect()
- .exec((res) => {
- if (res && res[0]) {
- res[0].forEach((item, index) => {
- console.info('第' + index + '个卡片:', item.top, item.height);
- });
- }
- });
复制代码
3. 查询视口与滚动偏移
selectViewport 可以拿到视口宽高,适合做响应式布局判断。
- const query = has.createSelectorQuery();
- query
- .selectViewport()
- .boundingClientRect()
- .exec((res) => {
- if (res && res[0]) {
- console.info('视口宽度:', res[0].width);
- console.info('视口高度:', res[0].height);
- }
- });
复制代码
查询滚动容器的偏移量时,用 scrollOffset。注意只有可滚动元素(scroll-view、页面本身)才能返回有效值,普通元素的 scrollLeft 和 scrollTop 恒为 0。
- const query = has.createSelectorQuery();
- query
- .select('#scrollContainer')
- .scrollOffset()
- .exec((res) => {
- if (res && res[0]) {
- console.info('scrollTop:', res[0].scrollTop);
- }
- });
复制代码
4. fields 通用查询
fields 是最灵活的查询方式,一次可获取多种信息,包括 id、dataset、rect、size、scrollOffset、计算样式等。注意 computedStyle 需要传入需要获取的样式属性数组,不要传太多,否则影响性能。
- const query = has.createSelectorQuery();
- query
- .select('#myElement')
- .fields({
- id: true,
- dataset: true,
- rect: true,
- size: true,
- scrollOffset: true,
- computedStyle: ['backgroundColor', 'fontSize'],
- }, (res) => {
- console.info('id:', res.id);
- console.info('rect:', res.rect);
- console.info('样式:', res.backgroundColor, res.fontSize);
- })
- .exec();
复制代码
5. 自定义组件内的查询范围
在自定义组件中使用 createSelectorQuery 时,默认查询的是整个页面节点。如果只想查询组件内部,必须用 .in(this) 限定范围,否则可能查错节点。
- const query = has.createSelectorQuery().in(this);
- query
- .select('#innerElement')
- .boundingClientRect()
- .exec((res) => {
- console.info(res[0]);
- });
复制代码
二、IntersectionObserver:监听元素可见性
has.createIntersectionObserver 用来监听一个元素与视口(或另一个元素)的交叉状态。它非常适合图片懒加载、吸顶检测、曝光统计、无限滚动等场景。
1. 图片懒加载
监听所有 .lazy-image 元素,当它们进入视口区域(可设置提前量)时,再替换为真正的图片地址。使用 relativeToViewport({ bottom: 100 }) 可以让图片在距离底部还有 100px 时就开始加载,避免用户滑到时白屏。
- Page({
- data: {
- imageList: [
- { id: 1, src: '', dataSrc: 'https://example.com/img1.jpg', loaded: false },
- { id: 2, src: '', dataSrc: 'https://example.com/img2.jpg', loaded: false },
- ],
- },
- onReady() {
- this.setupLazyLoad();
- },
- setupLazyLoad() {
- let that = this;
- let observer = has.createIntersectionObserver(this, {
- thresholds: [0],
- observeAll: true,
- });
- observer.relativeToViewport({ bottom: 100 });
- observer.observe('.lazy-image', (res) => {
- if (res.intersectionRatio > 0) {
- let index = res.dataset && res.dataset.index;
- if (index !== undefined) {
- that.loadImage(index);
- }
- }
- });
- that._observer = observer;
- },
- loadImage(index) {
- let imageList = this.data.imageList;
- if (imageList[index] && !imageList[index].loaded) {
- imageList[index].src = imageList[index].dataSrc;
- imageList[index].loaded = true;
- this.setData({ imageList });
- }
- },
- onUnload() {
- if (this._observer) {
- this._observer.disconnect();
- }
- },
- });
复制代码
模板中需要给每个 item 设置 data-index,以便在回调中识别具体元素。
- <view class="image-list">
- <view class="image-item" for="{{imageList}}" data-index="{{$idx}}">
- <image class="lazy-image" src="{{$item.src || 'https://example.com/placeholder.png'}}" mode="aspectFill"></image>
- </view>
- </view>
复制代码
2. 吸顶检测
在导航栏上方放置一个哨兵元素,监听它是否离开视口顶部。哨兵离开时,说明导航栏应该吸顶。
- Page({
- data: { isSticky: false },
- onReady() {
- let observer = has.createIntersectionObserver(this, {
- thresholds: [0, 1],
- });
- observer.relativeToViewport();
- observer.observe('#sticky-sentinel', (res) => {
- this.setData({
- isSticky: res.intersectionRatio <= 0,
- });
- });
- this._stickyObserver = observer;
- },
- onUnload() {
- if (this._stickyObserver) {
- this._stickyObserver.disconnect();
- }
- },
- });
复制代码
模板中哨兵元素放在导航栏上方,导航栏根据 isSticky 切换 fixed 定位。
- <view id="sticky-sentinel" class="sentinel"></view>
- <view class="nav-bar {{isSticky ? 'nav-sticky' : ''}}">
- <text>导航栏内容</text>
- </view>
复制代码
3. 以其他元素为参照:relativeTo
除了以视口为参照,还可以指定页面上的某个元素作为参照区域。这在 scroll-view 内部做可见性检测时特别有用。
- const observer = has.createIntersectionObserver(this);
- observer
- .relativeTo('#scrollContainer', { top: 50, bottom: 50 })
- .observe('#innerItem', (res) => {
- if (res.intersectionRatio > 0) {
- console.info('innerItem 在 scrollContainer 内可见');
- }
- });
复制代码
4. 曝光统计
设置 thresholds: [0.5] 可以控制元素面积超过一半进入视口时才触发回调,从而精确统计用户真正看到的卡片。
- let observer = has.createIntersectionObserver(this, {
- thresholds: [0.5],
- observeAll: true,
- });
- observer.relativeToViewport();
- observer.observe('.exposure-card', (res) => {
- if (res.intersectionRatio >= 0.5) {
- let index = res.dataset && res.dataset.index;
- if (index !== undefined) {
- // 上报曝光,并标记已曝光
- }
- }
- });
复制代码
三、常见坑与注意点
1. 忘记调用 exec()。SelectorQuery 的链式调用最后必须执行 exec(),否则所有查询都不会触发回调。
2. 查询结果为 null。可能是节点未渲染、id 拼写错误,或者自定义组件内未使用 .in(this)。建议在回调中加空值判断。
3. observeAll 与 observe 的区别。observeAll 可以同时监听多个元素,但回调中必须依赖 dataset 来区分具体元素,所以模板里一定要设置 data-index 之类的标识。
4. disconnect() 必须在 onUnload 中调用。否则交叉观察器会持续监听,造成内存泄漏。
5. thresholds 数组的含义。它接受 0~1 之间的数组,例如 [0, 0.5, 1] 表示交叉比例分别达到 0%、50%、100% 时触发回调。阈值设得越多,回调触发越频繁,性能开销越大,建议按需设置。
四、小结
SelectorQuery 和 IntersectionObserver 是 ASCF 框架中处理节点信息和可见性检测的官方方案。相比手动监听 scroll 计算位置,它们更简洁、更稳定。在实际开发中,这两个 API 可以覆盖大多数滚动联动、懒加载和曝光统计需求。建议在代码中统一封装工具方法,并在页面销毁时做好观察器的清理工作,避免内存问题。 |