The Future Depends on You
首页/Vue/Vue 自定义指令之手写 vue-lazyload/
Vue 自定义指令之手写 vue-lazyload
上次更新时间:2021-1-17 文章分类:Vue 阅读人数:29

前言

除了核心功能默认内置的指令 ,Vue 也允许注册自定义指令。在 Vue2.0 中,代码复用和抽象的主要形式是组件。然而,有的情况下,你仍然需要对普通 DOM 元素进行底层操作,这时候就会用到自定义指令。

1、自定义简介

Vue 中通过 directives 属性来自定义指令。可以定义全局指令,也可以定义局部指令。

1.1、钩子函数

一个指令定义对象可以提供如下几个钩子函数:

  1. bind:只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。
  2. inserted:被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。
  3. update:所在组件的 VNode 更新时调用,但是可能发生在其子 VNode 更新之前。指令的值可能发生了改变,也可能没有。但是你可以通过比较更新前后的值来忽略不必要的模板更新 。
  4. componentUpdated:指令所在组件的 VNode 及其子 VNode 全部更新后调用。
  5. unbind:只调用一次,指令与元素解绑时调用。

每个指令钩子函数会被传入以下参数:

  1. el:指令所绑定的元素,可以用来直接操作 DOM。
  2. binding:一个对象,包含以下 property:
    1. name:指令名,不包括 v- 前缀。
    2. value:指令的绑定值。
    3. oldValue:指令绑定的前一个值,仅在 update 和 componentUpdated 钩子中可用。无论值是否 改变都可用。
    4. expression:字符串形式的指令表达式。
    5. arg:传给指令的参数,可选。
    6. modifiers:一个包含修饰符的对象。
  3. vnode:Vue 编译生成的虚拟节点。
  4. oldVnode:上一个虚拟节点,仅在 update 和 componentUpdated 钩子中可用。

1.2、案例

yctang

如图,我们希望在点击输入框时,输入框下方的文本和按钮能显示出来并且按钮和文本可以点击。如果,我们给输入框添加一个 focus 和 blur 事件的话,按钮和文本就无法点击。

这时,我们就可以用自定义指令来完成这个功能。

<div id="app">
  <div v-click-outside="blur">
    <input type="text" @focus="focus">
    <div v-if="visible">yctang <button>按钮</button></div>
  </div>
</div>
<script src="node_modules/vue/dist/vue.js"></script>
<script>
  let vm = new Vue({
    el: '#app',
    directives: {
      clickOutside: {
        bind(el, bindings, vnode, oldVnode) {
          el.handler = (e) => {
            if (!el.contains(e.target)) {
              let method = bindings.expression;
              vnode.context[method]();
            }
          }
          document.addEventListener('click', el.handler);
        },
        unbind(el, bindings, vnode, oldVnode) {
          document.removeEventListener('click', el.handler);
        }
      }
    },
    data() {
      return {
        visible: false
      }
    },
    methods: {
      focus() {
        this.visible = true
      },
      blur() {
        this.visible = false
      }
    }
  })
</script>

2、手写 vue-lazyload

vue-lazyload 这个插件通过将图片的 src 属性替换成 v-lazy 指令实现的。接下来就让我们来实现这个自定义指令吧。

通过 Vue.use 引用插件的方式,我们可以通过 install 接收 Vue 及引用插件时的传递的参数。这里我们将所有的逻辑封装成一个函数,函数中可以创建一个类。便于扩展。

const VueLazyload = {
  install(Vue, options) {
    // 把所有逻辑进行封装:类,把类封装到函数中
    const LazyClass = Lazy(Vue);
    const lazy = new LazyClass(options);
    Vue.directive('lazy', {
      bind: lazy.add.bind(lazy)
    })
  }
}

LazyClass 类中添加 add 方法。由于在 bind 的时候还取不到 el 的父级元素,所以我们只能在 Vue.nextTick 中获取el的父级元素。通过 getScrollParent 方法获取到具有可滚动的父级元素。然后给该父级元素添加 scroll 方法。

const getScrollParent = (el) => {
  let parent = el.parentNode;
  while (parent) {
    if (/(scroll)|(auto)/.test(getComputedStyle(parent)['overflow'])) {
      return parent;
    }
    parent = parent.parentNode;
  }
  return parent;
}

const Lazy = (Vue) => {
  // ...
  return class LazyClass {
    constructor(options) {
      // 保存用户传入的属性
      this.options = options;
      this.bindings = false;
    }
    handleLazyLoad() {
      // 这里应该看一下,是否应该显示这个图片
      // 计算当前图片的位置
    }
    add(el, bindings, vnode, oldVnode) {
      // 找到父级元素
      Vue.nextTick(() => {
        // 带有滚动的盒子 infiniteScroll
        let scrollParent = getScrollParent(el);
        if (scrollParent && !this.bindings) {
          this.bindings = true;
          scrollParent.addEventListener('scroll', this.handleLazyLoad.bind(this))
        }
      })
    }
  }
}

然后,我们需要判断,当前图片是否在可视区域内。给每个图片元素添加一个类 ReactiveListener 。其中的 checkInView 检测图片是否在可是区域内; load 加载图片。 getBoundingClientRect 用于用于判断当前元素的top值,是否在可视区域内。详见另一篇文章 js-API之getBoundingClientRect详解与应用

const loadImageAsync = (src, resolve, reject) => {
  let image = new Image();
  image.src = src;
  image.onload = resolve;
  image.onerror = reject;
}

class ReactiveListener { // 每一个图片元素,都构造成一个类的实例
  constructor({ el, src, options, elRender }) {
    this.el = el;
    this.src = src;
    this.options = options;
    this.state = { loading: false } // 默认没有加载
    this.elRender = elRender
  }
  // 检测图片是否在可是区域内
  checkInView() {
    let { top } = this.el.getBoundingClientRect();
    return top < window.innerHeight * (this.options.preLoad || 1.3)
  }
  // 加载图片
  load() {
    // 先加载loading
    // 如果加载,需要正常显示图片
    this.elRender(this, 'loading');
    // 懒加载的核心就是 new Image
    loadImageAsync(this.src, () => {
      this.state.loading = true;
      this.elRender(this, 'finish');
    }, () => {
      this.state.error = true;
      this.elRender(this, 'error');
    })
  }
}

在 add 方法中添加 ReactiveListener

add(el, bindings, vnode, oldVnode) {
  Vue.nextTick(() => {
    let scrollParent = getScrollParent(el);
    if (scrollParent && !this.bindings) {
      this.bindings = true;
      scrollParent.addEventListener('scroll', this.handleLazyLoad.bind(this))
    }
    // 判断当前这个元素是否在容器可视区域中,如果不是就不渲染
    const litener = new ReactiveListener({
      el,
      src: bindings.value,
      options: this.options,
      elRender: this.elRender.bind(this)
    })
    // 把所有的图片都创建一个实例,放到数组中
    this.listenerQueue.push(litener);
    // 加载时就需要显示在可视区域的图片
    this.handleLazyLoad();
  })
}

调用 elRender 方法渲染图片

elRender(litener, state) { // 渲染方法
  let el = litener.el;
  let src = '';
  switch (state) {
    case "loading":
      src = litener.options.loading || "";
      break;
    case "error":
      src = litener.options.error || "";
      break;
    default:
      src = litener.src
      break;
  }
  el.setAttribute('src', src);
}

测试:

动态加载图片
<div id="app">
  <div class="box">
    <li v-for="img in imgs" :key="img">
      <img v-lazy="img" alt="">
    </li>
  </div>
</div>
<script src="node_modules/vue/dist/vue.js"></script>
<script src="./vue-lazyload.js"></script>
<script src="node_modules/axios/dist/axios.js"></script>
<script>
  const loading = 'http://localhost:3000/images/21.gif';    
  Vue.use(VueLazyload, {
    preLoad: 1.3,
    loading
  });
  let vm = new Vue({
    el: '#app',
    data() {
      return {
        imgs: []
      }
    },
    created() {
      axios.get('http://localhost:3000/api/img').then((res => {
        this.imgs = res.data
      }))
    }
  })
</script>

这里,使用了 express 搭建的图片服务器返回了20张图片。

yctang

附源码

const getScrollParent = (el) => {
  let parent = el.parentNode;
  while (parent) {
    if (/(scroll)|(auto)/.test(getComputedStyle(parent)['overflow'])) {
      return parent;
    }
    parent = parent.parentNode;
  }
  return parent;
}

const loadImageAsync = (src, resolve, reject) => {
  let image = new Image();
  image.src = src;
  image.onload = resolve;
  image.onerror = reject;
}

const Lazy = (Vue) => {
  // ...
  class ReactiveListener { // 每一个图片元素,都构造成一个类的实例
    constructor({ el, src, options, elRender }) {
      this.el = el;
      this.src = src;
      this.options = options;
      this.state = { loading: false } // 默认没有加载
      this.elRender = elRender
    }
    // 检测图片是否在可是区域内
    checkInView() {
      let { top } = this.el.getBoundingClientRect();
      return top < window.innerHeight * (this.options.preLoad || 1.3)
    }
    // 加载图片
    load() {
      // 先加载loading
      // 如果加载,需要正常显示图片
      this.elRender(this, 'loading');
      // 懒加载的核心就是 new Image
      loadImageAsync(this.src, () => {
        this.state.loading = true;
        this.elRender(this, 'finish');
      }, () => {
        this.state.error = true;
        this.elRender(this, 'error');
      })
    }
  }
  return class LazyClass {
    constructor(options) {
      // 保存用户传入的属性
      this.options = options;
      this.bindings = false;
      this.listenerQueue = [];
    }
    handleLazyLoad() {
      // 这里应该看一下,是否应该显示这个图片
      // 计算当前图片的位置
      this.listenerQueue.forEach(litener => {
        if (!litener.state.loading) {
          let catIn = litener.checkInView();
          catIn && litener.load();
        }
      })
    }
    add(el, bindings, vnode, oldVnode) {
      // 找到父级元素
      Vue.nextTick(() => {
        // 带有滚动的盒子 infiniteScroll
        let scrollParent = getScrollParent(el);
        if (scrollParent && !this.bindings) {
          this.bindings = true;
          scrollParent.addEventListener('scroll', this.handleLazyLoad.bind(this))
        }
        // 判断当前这个元素是否在容器可视区域中,如果不是就不渲染
        const litener = new ReactiveListener({
          el,
          src: bindings.value,
          options: this.options,
          elRender: this.elRender.bind(this)
        })
        // 把所有的图片都创建一个实例,放到数组中
        this.listenerQueue.push(litener);
        this.handleLazyLoad();
      })
    }
    elRender(litener, state) { // 渲染方法
      let el = litener.el;
      let src = '';
      switch (state) {
        case "loading":
          src = litener.options.loading || "";
          break;
        case "error":
          src = litener.options.error || "";
          break;
        default:
          src = litener.src
          break;
      }
      el.setAttribute('src', src);
    }
  }
}

const VueLazyload = {
  install(Vue, options) {
    // 把所有逻辑进行封装:类,把类封装到函数中
    const LazyClass = Lazy(Vue);
    const lazy = new LazyClass(options);
    Vue.directive('lazy', {
      bind: lazy.add.bind(lazy)
    })
  }
}

  • 完结.
The Future Depends on You
tangyincheng
博客分类