brunoyang / blog

134 stars 13 forks source link

使用 IntersectionObserver 和 registerElement 打造 Lazyload #19

Open brunoyang opened 8 years ago

brunoyang commented 8 years ago

Lazyload 是应用非常广泛的网页效果,可以有效加快用户访问速度并减少流量消耗,实现思路就是判断图片元素是否可见来决定是否加载图片。所以把大象装进冰箱只用两步:

  1. 判断是否可见
  2. 可见时加载图片

老浏览器要是想获取一个元素位于 viewport 中的位置,那可是相当费劲,滚动过的距离,窗口大小,元素大小,元素位置等等,监听滚动事件,然后把几个数字加加减减,还要处理兼容性问题,反正我写过一遍之后就再也不想写了。

现代一些的方法是使用getBoundingClientRect

const img = document.querySelector('img');
const rect = img.getBoundingClientRect();

// 第一步
if (
  rect.top >= 0 &&
  rect.left >= 0 &&
  rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && 
  rect.right <= (window.innerWidth || document.documentElement.clientWidth)
) {
  // 第二步
  img.src = 'https://t.alipayobjects.com/images/T11rdgXbFkXXXXXXXX.png';
}

获取该 img 相对于浏览器 viewport 的位置,若用户可见,就更改该图片元素的 src 属性,让它自动开始加载图片。

看着貌似不错,但有些问题:调用getBoundingClientRect方法会触发页面重排,使页面响应变慢,并且上面这一大串判断看着都累,更不用说写了,此外,通过getBoundingClientRect获取 iframe 里的元素,它是将 iframe 当做 viewport,而不是父窗口,这显然不是我们想要的。有没有更好的,更直观的方法呢?从 Chrome 51 开始,IntersectionObserver拯救你。

IntersectionObserver

第一个参数,callback

直接上代码:

// 1. 获取 img
const img = document.querySelector('img');
// 2. 实例化 IntersectionObserver,添加 img 出现在 viewport 瞬间的回调
const observer =  new IntersectionObserver((changes) => { 
  console.log('我出现了!') 
});
// 3. 开始监听 img
observer.observe(img);

done! 就是这么简单,几行代码就可以监听某个元素是否出现在 viewport 内,这大大减少了代码量和思考成本。

回调里的change参数,接收一个数组,数组元素是 IntersectionObserverEntry 对象。IntersectionObserverEntry 对象上有5个属性,

IntersectionObserverEntry: {
  boundingClientRect, // 对 observe 的元素执行 getBoundingClientRect 的结果
  rootBounds, 
  // 对根视图(默认是viewport,可以指定根元素,后面会讲到)
  // 执行 getBoundingClientRect 的结果
  intersectionRect,
  target: // observe 的对象,如上述代码就是 img
  time: 过了多久才出现在 viewport 内
}

intersectionRect 是个很有意思的属性,我们单拎出来说。intersection 是交汇的意思,intersectionRect 就是 observe 元素和 viewport 相交汇的那个矩形,这个矩形会随着元素出现在 viewport 内的大小而改变。

盗用谷歌一张图

完全进入 viewport 的元素 intersectionRect.height 就是元素的高度,部分进入的元素 intersectionRect.height 就是留在 viewport 内的高度。

默认情况下,查看这个属性,会发现 top 是1,bottom 是1,也就是当某个被监听元素随着页面滚动刚冒出来 1px 时,浏览器就会调用 IntersectionObserver 的回调,表示该对象已经出现在 viewport 内了。

在该元素离开页面时,浏览器也会调用一次 IntersectionObserver 的回调,这次 intersectionRect 的所有属性都变成0了。所以我们可以这么干:

const observer =  new IntersectionObserver((changes) => { 
  for (const change of changes) {
    const intersectionRect = change.intersectionRect;
    if (intersectionRect.height * intersectionRect.width > 0) {
      console.log('本宝宝来了!');
    } else {
      console.log('本宝宝又走了!');
    }
  } 
});

我们也可以一次性监听多个元素:

const observer =  new IntersectionObserver((changes) => { 
  console.log(changes.length); // 2
});
observer.observe(img);
observer.observe(div);

有 observe 自然也有unobserve。调用observer.unobserve(img);就可以停止监听。

第二个参数,option

在默认的情况下,回调只会在元素出现于 viewport 内和消失在 viewport 时被触发,但有时候我们会想要在元素进入一半时触发回调(如图片进入一半时才进行加载),这时候该怎么办呢?

其实 IntersectionObserver 除了回调,还可以传入第二个参数 option 对象,option 对象有个很有意思的属性:threshold,也就是所谓的阈值。threshold 是一个范围为0到1数组,默认值是[0],也就是在元素可见高度变为0时就会触发。如果赋值为 [0, 0.5, 1],那回调就会在元素可见高度是0%,50%,100%时,各触发一次回调。

你要是想提前预加载呢,threshold 属性就做不到了。不要方,还有另外一个属性可以使用:rootMargin。rootMargin 是一个字符串,和 css 的 margin 写法一样,如rootMargin: '1px 2px 3px 3px'rootMargin: '5px'。表现出的效果就是给被监听的元素加上 margin,如rootMargin: '20px'时,回调会在元素出现前 20px 提前调用,消失后延迟 20px 调用回调。也可以设置成负值,rootMargin: '-20px',效果就是元素出现后/消失前 20px 调用回调。

还有另外一个配置项是 root,默认为 null,取 rootBounds 时会按照 viewport 的尺寸来计算。也可以自行定义为某个 dom。

const observer =  new IntersectionObserver((changes) => { 
  console.log(changes.length); 
}, {
  root: null, 
  rootMargin: '20px', 
  threshold: [0, 0.25, 0.5, 0.75, 1]
});

在 iframe 中大显神威

在上面说到,getBoundingClientRect方法不能拿到位于 iframe 中元素相对于父窗口的位置。但 IntersectionObserver 可以。一个很常见的例子,个人博客下面的评论框,很多时候是一个嵌在 iframe 中的第三方评论组件。评论组件若是使用了 IntersectionObserver,就能很好地检测自己在父窗口中的位置,然后做相应的动作。

polyfill

虽然 IntersectionObserver 目前只有 Chrome 51+ 上有,但马上就可以通过 polyfill 实现了,但性能上不如原生(因为是通过 getBoundingClientRect 实现)。

registerElement

registerElement 是一个在 Chrome 27 上出现的技术,用于自定义元素。使用 registerElement 注册的元素拥有生命周期,还可以继承并增强现有元素,十分强大,polymer 框架就是建立在此 api 之上。但因为兼容性原因,没有推广开来。当然该 api 也有polyfill。在 html5rocks 上有篇非常棒的文章,感兴趣的同学可以前去观看。

实现个 Lazyload

setp 1:注册 Lazyload 组件

首先,我们先来利用 registerElement 实现个自定义 img:

const FALLBACK_IMAGE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAEElEQVR42gEFAPr/AP///wAI/AL+Sr4t6gAAAABJRU5ErkJggg==';

class LazyloadImage extends HTMLImageElement {
  createdCallback() {}

  attachedCallback() {}

  detachedCallback() {}
}

然后,注册 lazyload-image:

document.registerElement('lazyload-image', {
  prototype: LazyloadImage.prototype,
  extends: 'img'
})

step 2:实现 Lazyload 组件

接着,实现各个生命周期:

createdCallback() {
  this.original = this.currentSrc || this.src;
  this.src = FALLBACK_IMAGE;
  this.observer = new IntersectionObserver(this.visibleChanged.bind(this));
}

attachedCallback() {
  this.observer.observe(this);
}

detachedCallback() {
  this.observer.unobserve(this);
}

和最重要的 visibleChanged 方法:

visibleChanged(changes) {
  for (const change of changes) {
    const intersectionRect = change.intersectionRect;
    if (intersectionRect.height * intersectionRect.width > 0) {
      this.addEventListener('load', () => {
        this.observer.unobserve(this);
      });

      this.addEventListener('error', () => {
        this.removeAttribute('srcset');
        this.src = FALLBACK_IMAGE;
        this.observer.unobserve(this);
      });

      this.src = this.original;
    }
  }
}

step 3:添加至页面

在 html 中插入<img is="lazyload-image" src="https://t.alipayobjects.com/images/T11rdgXbFkXXXXXXXX.png" /> ,is 属性表示该元素是自定义的加强版 img,同时,在不支持 registerElement 的浏览器上也能自然回退成普通的 img。

更为具体的实现可以围观 lazyload-image

mlyknown commented 8 years ago

大范围的使用registerElement,会不会有更大的内存开销之类的?

brunoyang commented 8 years ago

@mlyknown 如果你是指单纯地使用 registerElement 会不会造成资源大量占用,答案是不会。因为registerElement 只是给 dom 元素的原型上加了几个方法而已。如果配合 IntersectionObserver 一起使用的话,_可能_会造成卡顿内存暴涨之类的问题。我在 mac 上测试,监听了 500 个 dom,没有什么问题,fps 稳稳的 60。当然,也要注意及时unobserve

yisibl commented 8 years ago

Nice! Firefox 52 也已经支持了 https://bugzilla.mozilla.org/show_bug.cgi?id=1243846

zmmbreeze commented 8 years ago

:+1: