交叉观察器(intersectionObserver) Vue实战

2,476 阅读7分钟
原文链接: zhuanlan.zhihu.com

参考文章:IntersectionObserver API 使用教程

Intersection Observer

intersectionObserver解决什么问题?

intersectionObserver解决目标元素与视口产生一个交叉区,所以这个 API 叫做"交叉观察器",

比如在web网页开发中,常常需要了解某个元素是否进入了"视口"(viewport),即用户能不能看到它。

传统的实现方法是,监听到scroll事件后,调用目标元素(绿色方块)的getBoundingClientRect()方法,得到它对应于视口左上角的坐标,再判断是否在视口之内。这种方法的缺点是,由于scroll事件密集发生,计算量很大,容易造成性能问题

目前有一个新的 IntersectionObserver API,可以自动"观察"元素是否可见,Chrome 51+ 已经支持。由于可见(visible)的本质是,目标元素与视口产生一个交叉区,所以这个 API 叫做"交叉观察器"。

IntersectionObserver API 是异步的,不随着目标元素的滚动同步触发。 规格写明,IntersectionObserver的实现,应该采用requestIdleCallback(),即只有线程空闲下来,才会执行观察器。这意味着,这个观察器的优先级非常低,只在其他任务执行完,浏览器有了空闲才会执行。

IntersectionObserverEntry 对象

IntersectionObserverEntry对象提供目标元素的信息,一共有六个属性。

{
  time: 3893.92,
  rootBounds: ClientRect {
    bottom: 920,
    height: 1024,
    left: 0,
    right: 1024,
    top: 0,
    width: 920 },
  boundingClientRect: ClientRect {  // ...
 },
  intersectionRect: ClientRect {  // ...
 },
  intersectionRatio: 0.54,
  target: element
} 

每个属性的含义如下。

  • time:可见性发生变化的时间,是一个高精度时间戳,单位为毫秒
  • target:被观察的目标元素,是一个 DOM 节点对象
  • rootBounds:根元素的矩形区域的信息,getBoundingClientRect()方法的返回值,如果没有根元素(即直接相对于视口滚动),则返回null
  • boundingClientRect:目标元素的矩形区域的信息
  • intersectionRect:目标元素与视口(或根元素)的交叉区域的信息
  • intersectionRatio:目标元素的可见比例,即intersectionRectboundingClientRect的比例,完全可见时为1,完全不可见时小于等于0
通常都是通过判断intersectionRatio来做某些事,比如当intersectionRatio打于多少的时候做什么事,加载图片等

灰色的水平方框代表视口,深红色的区域代表四个被观察的目标元素。它们各自的intersectionRatio图中都已经注明。

DEMO:

<!DOCTYPE html>
<html>

	<head>
		<meta charset="utf-8">
		<meta name="viewport" content="width=device-width">
		<title> </title>
		<style type="text/css">
			* {
				padding: 0px;
				margin: 0px;
				outline: none;
			}
			
			body {}
			
			#main {
				margin: 0 auto;
				width: 600px;
				height: 300px;
				overflow: scroll;
			}
			
			#con {
				width: 1200px;
				height: 300px;
			}
			
			.cc {
				width: 300px;
				height: 300px;
				float: left;
				background-size: cover;
			}
			
			#a {
				background-color: red;
			}
			
			#b {
				background-color: black;
			}
			
			#c {
				background-color: blue;
			}
			
			#d {
				background-color: green;
			}
		</style>
	</head>

	<body>
		<div id="main">
			<div id="con">
				<div id="a" class="cc"></div>
				<div id="b" class="cc"></div>
				<div id="c" class="cc"></div>
				<div id="d" class="cc"></div>
			</div>
		</div>
	</body>
	<script type="text/javascript">
		var io = new IntersectionObserver(
			entries => {
				console.log('********************************');
				console.log(entries);
				console.log('********************************');
				entries.forEach(i => {
					console.log('================================');
					console.log('Time: ' + i.time);
					console.log('Target: ' + i.target.nodeName);
					console.log('IntersectionRatio: ' + i.intersectionRatio);
					console.log('rootBounds: ' + i.rootBounds);
					console.log(i.boundingClientRect);
					console.log(i.intersectionRect);
					console.log('================================');

					if(i.intersectionRatio >= 0.25) { //可见元素占视窗的25%触发
						console.log('^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^');
						i.target.style.backgroundImage = "url(" + 'http://pic35.photophoto.cn/20150528/0020032932102307_b.jpg' + ")"
					}

				});
			}, {
				/* Using default options. Details below */
				threshold: [0, 0.25, 0.5, 0.75, 1],   //会执行5次
			}
		);
		// Start observing an element
		io.observe(document.querySelector('#d'));
		io.observe(document.querySelector('#c'));
	</script>

</html>

一、API

它的用法非常简单。

var io = new IntersectionObserver(callback, option); 

上面代码中,IntersectionObserver是浏览器原生提供的构造函数,接受两个参数:callback是可见性变化时的回调函数,option是配置对象(该参数可选)。

构造函数的返回值是一个观察器实例。实例的observe方法可以指定观察哪个 DOM 节点。

// 开始观察
io.observe(document.getElementById('example')); 
// 停止观察
io.unobserve(element); 
// 关闭观察器
io.disconnect(); 

上面代码中,observe的参数是一个 DOM 节点对象。如果要观察多个节点,就要多次调用这个方法。

io.observe(elementA);
io.observe(elementB); 

二、callback 参数

目标元素的可见性变化时,就会调用观察器的回调函数callback

callback一般会触发两次。一次是目标元素刚刚进入视口(开始可见),另一次是完全离开视口(开始不可见)。

var io = new IntersectionObserver(
  entries => {
    console.log(entries); } 
); 

上面代码中,回调函数采用的是箭头函数的写法。callback函数的参数(entries)是一个数组,每个成员都是一个IntersectionObserverEntry对象。举例来说,如果同时有两个被观察的对象的可见性发生变化,entries数组就会有两个成员。

三、Option 对象

IntersectionObserver构造函数的第二个参数是一个配置对象。它可以设置以下属性。

IntersectionObserver.root 只读所监听对象的具体祖先元素(element)。如果未传入任何值或值为null,则默认使用viewport。

IntersectionObserver.rootMargin 只读计算交叉时添加到根(root)边界盒bounding box的矩形偏移量, 可以有效的缩小或扩大根的判定范围从而满足计算需要。此属性返回的值可能与调用构造函数时指定的值不同,因此可能需要更改该值,以匹配内部要求。所有的偏移量均可用像素(pixel)(px)或百分比(percentage)(%)来表达, 默认值为"0px 0px 0px 0px"。

IntersectionObserver.thresholds 只读一个包含阈值的list, 升序排列, list中的每个阈值都是监听对象的交叉区域与边界区域的比率。当监听对象的任何阈值被越过时,都会生成一个通知(Notification)。如果构造器未传入值, 则默认值为0.

threshold 属性

threshold属性决定了什么时候触发回调函数。它是一个数组,每个成员都是一个门槛值,默认为[0],即交叉比例(intersectionRatio)达到0时触发回调函数。

new IntersectionObserver(
  entries => {/* ... */}, {
    threshold: [0, 0.25, 0.5, 0.75, 1] } ); 

用户可以自定义这个数组。比如,[0, 0.25, 0.5, 0.75, 1]就表示当目标元素 0%、25%、50%、75%、100% 可见时,会触发回调函数。

root 属性,rootMargin 属性

很多时候,目标元素不仅会随着窗口滚动,还会在容器里面滚动(比如在iframe窗口里滚动)。容器内滚动也会影响目标元素的可见性,参见本文开始时的那张示意图。

IntersectionObserver API 支持容器内滚动。root属性指定目标元素所在的容器节点(即根元素)。注意,容器元素必须是目标元素的祖先节点。

var opts = { 
  root: document.querySelector('.container'),
  rootMargin: "500px 0px" 
}; 

var observer = new IntersectionObserver(
  callback,
  opts
); 

上面代码中,除了root属性,还有rootMargin属性。后者定义根元素的margin,用来扩展或缩小rootBounds这个矩形的大小,从而影响intersectionRect交叉区域的大小。它使用CSS的定义方法,比如10px 20px 30px 40px,表示 top、right、bottom 和 left 四个方向的值。

这样设置以后,不管是窗口滚动或者容器内滚动,只要目标元素可见性变化,都会触发观察器。

四、方法

IntersectionObserver.disconnect使IntersectionObserver对象停止监听工作。

IntersectionObserver.observe使IntersectionObserver开始监听一个目标元素。

IntersectionObserver.takeRecords为所有监听目标返回一个IntersectionObserverEntry对象数组并且停止监听这些目标。

IntersectionObserver.unobserve使IntersectionObserver停止监听特定目标元素。

五、Vue(xunleif2e/vue-lazy-component

核心 VueLazyComponent.vue

<template>
  <transition-group :tag="tagName" name="lazy-component" style="position: relative;"
    @before-enter="(el) => $emit('before-enter', el)"
    @before-leave="(el) => $emit('before-leave', el)"
    @after-enter="(el) => $emit('after-enter', el)"
    @after-leave="(el) => $emit('after-leave', el)"
  >
    <div v-if="isInit" key="component">
      <slot :loading="loading"></slot>
    </div>
    <div v-else-if="$slots.skeleton" key="skeleton">
      <slot name="skeleton"></slot>
    </div>
    <div v-else key="loading">
    </div>
  </transition-group>
</template>

<script>
  export default {
    name: 'VueLazyComponent',

    props: {
      timeout: {
        type: Number
      },
      tagName: {
        type: String,
        default: 'div'
      },
      viewport: {
        type: typeof window !== 'undefined' ? window.HTMLElement : Object,
        default: () => null
      },
      threshold: {
        type: String,
        default: '0px'
      },
      direction: {
        type: String,
        default: 'vertical'
      },
      maxWaitingTime: {
        type: Number,
        default: 50
      }
    },

    data () {
      return {
        isInit: false,
        timer: null,
        io: null,
        loading: false
      }
    },

    created () {
      // 如果指定timeout则无论可见与否都是在timeout之后初始化
      if (this.timeout) {
        this.timer = setTimeout(() => {
          this.init()
        }, this.timeout)
      }
    },

    mounted () {
      if (!this.timeout) {
        // 根据滚动方向来构造视口外边距,用于提前加载
        let rootMargin
        switch (this.direction) {
          case 'vertical':
            rootMargin = `${this.threshold} 0px`
            break
          case 'horizontal':
            rootMargin = `0px ${this.threshold}`
            break
        }

        // 观察视口与组件容器的交叉情况
        this.io = new window.IntersectionObserver(this.intersectionHandler, {
          rootMargin,
          root: this.viewport,
          threshold: [ 0, Number.MIN_VALUE, 0.01]
        })
        this.io.observe(this.$el)
      }
    },

    beforeDestroy () {
      // 在组件销毁前取消观察
      if (this.io) {
        this.io.unobserve(this.$el)
      }
    },

    methods: {
      // 交叉情况变化处理函数
      intersectionHandler (entries) {
        if (
          // 正在交叉
          entries[0].isIntersecting ||
          // 交叉率大于0
          entries[0].intersectionRatio
        ) {
          this.init()
          this.io.unobserve(this.$el)
        }
      },

      // 处理组件和骨架组件的切换
      init () {
        // 此时说明骨架组件即将被切换
        this.$emit('beforeInit')
        this.$emit('before-init')

        // 此时可以准备加载懒加载组件的资源
        this.loading = true

        // 由于函数会在主线程中执行,加载懒加载组件非常耗时,容易卡顿
        // 所以在requestAnimationFrame回调中延后执行
        this.requestAnimationFrame(() => {
          this.isInit = true
          this.$emit('init')
        })
      },

      requestAnimationFrame (callback) {
        // 防止等待太久没有执行回调
        // 设置最大等待时间
        setTimeout(() => {
          if (this.isInit) return
          callback()
        }, this.maxWaitingTime)

        // 兼容不支持requestAnimationFrame 的浏览器
        return (window.requestAnimationFrame || ((callback) => setTimeout(callback, 1000 / 60)))(callback)
      }
    }
  }
</script>