开发一个懒加载图片 npm 插件

597 阅读4分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第12天,点击查看活动详情

懒加载图片

懒加载图片是 web 中常见的性能优化手段,可以有效的减少非首屏的图片请求,带宽消耗

实现方案

  1. img 元素定义一个 data-src 属性存放图片地址或者普通元素定义一个 data-background-src 属性
  2. 获取屏幕可视区域的尺寸
  3. 获取元素到窗口边缘的距离
  4. 判断元素是否在可视边缘内,如果是则把 data-src 赋值给 src 或者 data-background-src 赋值给 background-image: url()

使用的 API

IntersectionObserver:提供了一种异步观察目标元素与其祖先元素或顶级文档视窗(viewport)交叉状态的方法,具体使用参考 mdn

scroll:监听滚动事件,判断目标元素是否在视窗内

编码

目录结构

image.png

  1. 先写一个页面
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>lazy</title>
  <style>
    img{
      width: 300px;
      min-height: 300px;
      border-radius: 5px;
      background-color: #ddd;
      margin: 10px;
    }
    .bg{
      width: 300px;
      height: 300px;
      border-radius: 5px;
      background-color: #eee;
      margin: 10px;
    }
    .data{
      width: 100%;
      overflow: scroll;
    }
    #data{
      display: flex;
      width: 10000px;
    }
  </style>
</head>
<body>
  <div id="app"></div>
  <div class="data">
    <div id="data"></div>
  </div>
  <script>
    window.onload = function() {
      const app = document.getElementById("app");
      const data = document.getElementById("data");
      const src = 'https://p9-passport.byteacctimg.com/img/user-avatar/fbf0dad2f64546b5718cb7e502e92c7b~1000x1000.image'
      const lazyHtml = new Array(20).fill(0).reduce((prev, cur, index) => {
        return prev + `<img class='img' index=${index} data-src='${src}?index=${+new Date() + index}'>`
      }, '')
      const lazyBackgroundHtml = new Array(20).fill(0).reduce((prev, cur, index) => {
        return prev + `<div class='bg' index=${index} style='background-position: center' data-background-image='${src}?index=${+new Date() + index}'></div>`
      }, '')
      app.innerHTML = lazyHtml + lazyBackgroundHtml
      data.innerHTML = lazyHtml + lazyBackgroundHtml
    }
  </script>
  <script src="./index.js"></script>
</body>
</html>

image.png

  1. 先写如何调用
setTimeout(() => {
  // 确保节点插入后使用懒加载工具
  window.lazyImage = LazyImage({
    el: '.data'
  })
  window.lazyImage = LazyImage({
    el: '#app',
  })
})
  1. 开始来写 LazyImage 的实现,先把代码骨架写好
// index.js
class Lazy {
  
}

function LazyImage(...arg) {
  return new Lazy(...arg)
}

  1. 构造函数中需要去对参数进行提取合并,初始化操作
  constructor(options) {
    options = options || {}
    // 从哪个元素下获取节点
    const el = options.el || ''
    this.el = util.querySelector(el)
    // 懒加载 N 秒后执行
    this.wait = options.wait || 500
    // 偏移量
    this.diffTop = 0
    this.diffLeft = 0
    // 容器,是否父容器为滚动元素,相对父容器还是window
    this.isScrollContainer =
      options.isScrollContainer ||
      !(this.el.scrollWidth <= this.el.clientWidth) ||
      !(this.el.scrollHeight <= this.el.clientHeight)
    this.container = this.isScrollContainer ? this.el : window
    // 合并 observerOption IntersectionObserver 使用的配置
    this.observerOption = options.observerOption || {
      thresholds: [1],
      root: this.isScrollContainer ? this.el : null,
    }
    // 待观察图片数组
    this.images = []
    // 初始化监听方式,使用单例模式
    this.initEvents = getEventFunc().bind(this)
    // 初始化销毁函数,避免报错,先使用空函数
    this.destroyEvent = () => { }
    // 更新插件
    this.update()
  }
  1. getEventFunc 的实现
let func = null
function getEventFunc() {
  // 如果已经判断过,直接返回
  if (func) return func
  // 优先使用 IntersectionObserver
  if (window.IntersectionObserver) {
    func = function () {
      // 这里使用缓存数组,缓存激活元素
      let activeImages = []
      // 使用 setTimeout 做一个防抖
      let timeout
      // 监听
      const observer = new IntersectionObserver((images) => {
        activeImages.push(...images)
        if (timeout) clearTimeout(timeout)
        timeout = setTimeout(() => {
          activeImages
            .filter((image) => image.isIntersecting)
            .map((image) => image.target)
            .forEach((image) => {
              if (this.initImages(image)) {
                // 完成图片加载后移除监听
                observer.unobserve(image)
              }
            })
          // 清空本次激活数组
          activeImages = []
        }, this.wait)
      }, this.observerOption)
      // 对每一项进行监听
      this.images.forEach((image) => observer.observe(image))
      // 返回一个销毁事件监听方法
      return () => {
        this.images.forEach((image) => observer.unobserve(image))
      }
    }
  } else {
    // 否则降级使用 scroll
    func = function () {
      // 这里也做一个防抖
      let timeout = null
      const load = () => {
        if (timeout) clearTimeout(timeout)
        timeout = setTimeout(() => {
          this.images.forEach((image) => this.initImages(image))
        }, this.wait)
      }
      if (this.container !== window) {
        // fix 横向滚动时上下滚动无法触发更新问题
        window.addEventListener('scroll', load)
      }
      this.container.addEventListener('scroll', load)
      // 同样返回一个销毁函数
      return () => {
        this.container.removeEventListener('scroll', load)
        window.removeEventListener('scroll', load)
      }
    }
  }
  return func
}
  1. update_init 方法实现
  _init() {
    // 先销毁
    this.destroyEvent()
    // 获取元素
    this.queryImage()
    // 监听,并到下一次销毁的方法
    this.destroyEvent = this.initEvents()
  }
  update() {
    return this._init()
  }
  1. 编写工具方法 util
const util = {
  // 获取容器宽高
  getVwVh(container) {
    return {
      vw:
        container.innerWidth ||
        window.innerWidth ||
        document.documentElement.clientWidth,
      vh:
        container.innerHeight ||
        window.innerHeight ||
        document.documentElement.clientHeight,
    }
  },
  // 获取元素
  querySelector(el = '') {
    return el ? document.querySelector(el) : document.body
  },
  // 获取元素属性值
  getAttribute(el, name) {
    return el.getAttribute(name)
  },
  // 获取全部元素
  querySelectorAll(el, search) {
    return el && search ? el.querySelectorAll(search) : []
  },
}
// 初始化工具函数
;[
  ['src', 'getSrc', util.getAttribute],
  ['data-src', 'getDataSrc', util.getAttribute],
  ['data-background-image', 'getBackgroundSrc', util.getAttribute],
  ['style', 'getStyle', util.getAttribute],
  ['[data-src]', 'queryAllSrc', util.querySelectorAll],
  ['[data-background-image]', 'queryAllImage', util.querySelectorAll],
].forEach(([key, val, fn]) => {
  util[val] = (el) => fn(el, key)
})
  1. queryImage 方法
  queryImage() {
    if (!this.el) return
    // 获取所有符合标准的元素
    this.images = [
      ...util.queryAllSrc(this.el),
      ...util.queryAllImage(this.el),
    ].filter((el) => {
      // 过滤漏网之鱼
      return !!(
        !util.getSrc(el) &&
        (util.getDataSrc(el) || util.getBackgroundSrc(el))
      )
    })
  }
  1. inViewport 判断是否处于视窗内
  inViewport(el) {
    // 获取容器宽高
    const { vw, vh } = util.getVwVh(this.container)
    // 获取元素的位置
    const { top, right, bottom, left } = el.getBoundingClientRect()
    // 计算判断是否满足条件
    return (
      top - vh < this.diffTop &&
      bottom > this.diffTop &&
      left - vw < this.diffLeft &&
      right > this.diffLeft
    )
  }
  1. initImages 加载图片方法
  initImages(image) {
    // 如果不在视窗内,直接返回
    if (!this.inViewport(image)) return null
    // 获取内容
    const src = util.getSrc(image)
    const dataSrc = util.getDataSrc(image)
    const dataBackground = util.getBackgroundSrc(image)
    // 判断是否加载过
    if (src || (!dataSrc && !dataBackground)) return image
    // 存在src
    if (dataSrc) {
      // 修改src
      image.setAttribute('src', dataSrc)
      const load = () => {
        // 初始化结束后从数组中清除该节点
        this.images = this.images.filter((img) => img !== image)
        image.removeAttribute('data-src')
        // 删除事件监听
        image.removeEventListener('load', load)
      }
      image.addEventListener('load', load)
    }
    if (dataBackground) {
      // 获取原有的 style 进行拼接
      image.style = `${util.getStyle(
        image
      )}; background-image:url(${dataBackground});`
      this.images = this.images.filter((img) => img !== image)
      image.removeAttribute('data-background-image')
    }
    return image
  }
  1. 看看效果

GIF.gif

打包

  1. 使用 rollup 打包
// rollup.config.js
import babel from "rollup-plugin-babel";
import resolve from 'rollup-plugin-node-resolve';
import commonjs from 'rollup-plugin-commonjs';
import { terser } from 'rollup-plugin-terser';

export default {
  input: 'src/index.js',
  output: {
    name: 'LazyImage',
    file: 'xy-lazyimage.min.js',
    format: 'umd'
  },
  plugins: [
    resolve(),
    commonjs(),
    babel({
      exclude: 'node_modules/**', // 防止打包node_modules下的文件
      runtimeHelpers: true,       // 使plugin-transform-runtime生效
    }),
    terser()
  ]
}
  1. package.json
{
  "name": "xy-lazyimage",
  "version": "1.0.6",
  "description": "懒加载图片插件",
  "main": "src/index.js",
  "module": "src/index.js",
  "browser": "xy-lazyimage.min.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "rollup -c"
  },
  "homepage": "https://github.com/Luoyuda/xy-lazyimage",
  "author": "xiayuchen",
  "license": "ISC",
  "devDependencies": {
    "rollup-plugin-babel": "^4.4.0",
    "@babel/core": "^7.17.8",
    "@babel/plugin-transform-runtime": "^7.17.0",
    "@babel/polyfill": "^7.12.1",
    "@babel/preset-env": "^7.16.11",
    "core-js": "^3.21.1",
    "rollup-plugin-commonjs": "^10.1.0",
    "rollup-plugin-node-resolve": "^5.2.0",
    "rollup-plugin-terser": "^7.0.2"
  },
  "keywords": ["lazy", "lazy image", "lazyload"],
  "dependencies": {
  },
  "files": [
    "xy-lazyimage.min.js"
  ]
}

image.png

image.png

源码地址

npm包地址