clipboard.js 与 Vue

相关参考资料:

  1. zenorocha/clipboard.js: Modern copy to clipboard. No Flash. Just 3kb gzipped (github.com)

Clipboard.js

一个专职于“复制文本”操作的 js 库,以其短小精悍、简单实用的功能而被广泛应用

写这边文章的目的是为了说明:Clipboard.js的事件绑定存在直接绑定和事件委托两种方式

其实官网有说明使用方法和注意事项,不过鉴于以前没用过这个库,我并没有第一时间去看文档,反而是看过源码之后才发现文档里面居然已经写明了:

所以,看文档的好习惯,还是不能丢了···,那下面纯粹记录一下bug解决过程

一:Bug 描述

维护一个Vue2+Element的项目,调用Clipboard.js初始化的复制按钮;页面第一次进入后点击正常,后续退出、再进入,每次点击会累计多触发一次

看看初始化是怎么写的:

if (!this.jsonClipboard) {
    this.jsonClipboard = new Clipboard('.json-btn');
    this.jsonClipboard.on('success', (e) => {
        this.$message.success('复制成功');
    })
}

这么写好像没问题;保证每次进入页面只初始化一次
而且每次页面进入,dom都是重新生成,不存在重复绑定同一个dom的情况

难道,他绑定的事件不是绑定在按钮dom

二:源码查阅

GitHub:zenorocha/clipboard.js: Modern copy to clipboard. No Flash. Just 3kb gzipped (github.com)
文件:/src/clipboard.js

// https://github.com/zenorocha/clipboard.js/blob/master/src/clipboard.js#L22
/**
 * Base class which takes one or more elements, adds event listeners to them,
 * and instantiates a new `ClipboardAction` on each click.
 */
class Clipboard extends Emitter {
    /**
     * @param {String|HTMLElement|HTMLCollection|NodeList} trigger
     * @param {Object} options
     */
    constructor(trigger, options) {
        super();

        this.resolveOptions(options);
        this.listenClick(trigger); // 事件监听
    }

    // ...
    // 此处省略 1w 字
    // ...
    
    // https://github.com/zenorocha/clipboard.js/blob/master/src/clipboard.js#L58
    /**
     * Adds a click event listener to the passed trigger.
     * @param {String|HTMLElement|HTMLCollection|NodeList} trigger
     */
    listenClick(trigger) {
        this.listener = listen(trigger, 'click', (e) => this.onClick(e));
    }

    // ...
    // 此处省略 1w 字
    // ...
} 

listen方法来自于组件good-listener,出自他自己之手
GitHub:zenorocha/good-listener: A more versatile way of adding & removing event listeners (github.com)
文件:/src/listen.js

// https://github.com/zenorocha/good-listener/blob/master/src/listen.js#L4
/**
 * Validates all params and calls the right
 * listener function based on its target type.
 *
 * @param {String|HTMLElement|HTMLCollection|NodeList} target
 * @param {String} type
 * @param {Function} callback
 * @return {Object}
 */
function listen(target, type, callback) {
    if (!target && !type && !callback) {
        throw new Error("Missing required arguments");
    }

    if (!is.string(type)) {
        throw new TypeError("Second argument must be a String");
    }

    if (!is.fn(callback)) {
        throw new TypeError("Third argument must be a Function");
    }

    if (is.node(target)) {
        return listenNode(target, type, callback);
    } else if (is.nodeList(target)) {
        return listenNodeList(target, type, callback);
    } else if (is.string(target)) {
        return listenSelector(target, type, callback);
    } else {
        throw new TypeError(
            "First argument must be a String, HTMLElement, HTMLCollection, or NodeList"
        );
    }
}

/**
 * Adds an event listener to a HTML element
 * and returns a remove listener function.
 *
 * @param {HTMLElement} node
 * @param {String} type
 * @param {Function} callback
 * @return {Object}
 */
function listenNode(node, type, callback) {
    node.addEventListener(type, callback);

    return {
        destroy: function() {
            node.removeEventListener(type, callback);
        }
    }
}

/**
 * Add an event listener to a list of HTML elements
 * and returns a remove listener function.
 *
 * @param {NodeList|HTMLCollection} nodeList
 * @param {String} type
 * @param {Function} callback
 * @return {Object}
 */
function listenNodeList(nodeList, type, callback) {
    Array.prototype.forEach.call(nodeList, function(node) {
        node.addEventListener(type, callback);
    });

    return {
        destroy: function() {
            Array.prototype.forEach.call(nodeList, function(node) {
                node.removeEventListener(type, callback);
            });
        }
    }
}

/**
 * Add an event listener to a selector
 * and returns a remove listener function.
 *
 * @param {String} selector
 * @param {String} type
 * @param {Function} callback
 * @return {Object}
 */
function listenSelector(selector, type, callback) {
    return delegate(document.body, selector, type, callback);
}

这里这哥们又调用了一个自己的库:delegate
Github:zenorocha/delegate: Lightweight event delegation (github.com)

三:解决方案

从上面的源码已经很清楚:
参数为nodenodeList时,直接使用addEventListener进行事件绑定;
参数为字符串时,会用事件委托实现选择器可能的所有dom的操作事件

而出现bug的原因自然是:

// 此处使用类选择器 .json-btn
this.jsonClipboard = new Clipboard('.json-btn');

所以在使用字符串/选择器做参数时,一定要在页面销毁/退出时,主动销毁绑定的委托事件:

beforeDestroy() {
    if (this.jsonClipboard) this.jsonClipboard.destroy();
},

四:总结

总的来说,依托外部组件进行的事件绑定,一定要清楚它是如何绑定、如何解绑
Vue中使用Clipboard.js,不管用何种方式进行初始化,它绑定的事件会一直存在

所以任何时候都应在组件销毁(前)进行手动销毁,即使指定Dom的事件绑定不会出现类似bug,但绑定的事件会一直存在于内存中,对性能也是一种负担