jQuery 插件開發(Plugin)

jQuery 插件(Plugin)是擴充 jQuery 功能的方式,讓你能夠將常用的功能封裝起來,方便重複使用。本篇將介紹如何開發自己的 jQuery 插件,以及最佳實踐。

插件基本概念

jQuery 插件有兩種類型:

  1. 實例方法插件:擴充 $.fn,可在 jQuery 物件上呼叫
  2. 工具函式插件:擴充 $,直接在 jQuery 上呼叫
// 實例方法插件
$('div').myPlugin();

// 工具函式插件
$.myUtility();

基本插件架構

最簡單的插件

// 定義插件
$.fn.highlight = function () {
  this.css('background-color', 'yellow');
  return this; // 返回 this 以支援鏈式呼叫
};

// 使用
$('p').highlight();

使用 IIFE 包裹

為了避免污染全域命名空間,建議使用立即執行函式表達式(IIFE):

(function ($) {
  $.fn.highlight = function () {
    this.css('background-color', 'yellow');
    return this;
  };
})(jQuery);

這種寫法的好處:

  • 確保 $ 指向 jQuery(即使使用了 noConflict
  • 建立私有範圍,避免變數洩漏

支援選項

大多數插件都需要支援可自訂的選項:

(function ($) {
  $.fn.highlight = function (options) {
    // 合併預設值與使用者選項
    var settings = $.extend(
      {
        color: 'yellow',
        duration: 1000,
      },
      options
    );

    return this.each(function () {
      $(this).css('background-color', settings.color);
    });
  };
})(jQuery);

// 使用
$('p').highlight(); // 使用預設值
$('p').highlight({ color: 'red' }); // 自訂顏色
$('p').highlight({ color: 'blue', duration: 500 });

暴露預設值

讓使用者可以全域修改預設值:

(function ($) {
  $.fn.highlight = function (options) {
    var settings = $.extend({}, $.fn.highlight.defaults, options);

    return this.each(function () {
      $(this).css('background-color', settings.color);
    });
  };

  // 暴露預設值
  $.fn.highlight.defaults = {
    color: 'yellow',
    duration: 1000,
  };
})(jQuery);

// 全域修改預設值
$.fn.highlight.defaults.color = 'lightblue';

// 現在所有呼叫都會使用新的預設值
$('p').highlight();

完整插件模板

基本模板

(function ($) {
  'use strict';

  var pluginName = 'myPlugin';

  // 預設選項
  var defaults = {
    option1: 'value1',
    option2: 100,
    onComplete: null,
  };

  // 插件建構函式
  function Plugin(element, options) {
    this.element = element;
    this.$element = $(element);
    this.settings = $.extend({}, defaults, options);
    this.init();
  }

  // 插件方法
  Plugin.prototype = {
    init: function () {
      // 初始化邏輯
      this.bindEvents();
    },

    bindEvents: function () {
      var self = this;

      this.$element.on('click.' + pluginName, function () {
        self.doSomething();
      });
    },

    doSomething: function () {
      // 執行某些操作
      this.$element.addClass('active');

      // 觸發回呼
      if (typeof this.settings.onComplete === 'function') {
        this.settings.onComplete.call(this.element);
      }
    },

    destroy: function () {
      // 清理:移除事件和資料
      this.$element.off('.' + pluginName);
      this.$element.removeData('plugin_' + pluginName);
    },
  };

  // 避免多重實例化
  $.fn[pluginName] = function (options) {
    return this.each(function () {
      if (!$.data(this, 'plugin_' + pluginName)) {
        $.data(this, 'plugin_' + pluginName, new Plugin(this, options));
      }
    });
  };

  // 暴露預設值和建構函式
  $.fn[pluginName].defaults = defaults;
  $.fn[pluginName].Plugin = Plugin;
})(jQuery);

支援方法呼叫的模板

允許使用者呼叫插件的方法:

(function ($) {
  'use strict';

  var pluginName = 'accordion';

  var defaults = {
    speed: 300,
    oneOpen: true,
  };

  function Accordion(element, options) {
    this.element = element;
    this.$element = $(element);
    this.settings = $.extend({}, defaults, options);
    this.init();
  }

  Accordion.prototype = {
    init: function () {
      this.$headers = this.$element.find('.accordion-header');
      this.$panels = this.$element.find('.accordion-panel');
      this.bindEvents();
    },

    bindEvents: function () {
      var self = this;

      this.$headers.on('click.' + pluginName, function () {
        self.toggle($(this).index());
      });
    },

    toggle: function (index) {
      var $panel = this.$panels.eq(index);

      if (this.settings.oneOpen) {
        this.$panels.not($panel).slideUp(this.settings.speed);
      }

      $panel.slideToggle(this.settings.speed);
    },

    open: function (index) {
      this.$panels.eq(index).slideDown(this.settings.speed);
    },

    close: function (index) {
      this.$panels.eq(index).slideUp(this.settings.speed);
    },

    openAll: function () {
      this.$panels.slideDown(this.settings.speed);
    },

    closeAll: function () {
      this.$panels.slideUp(this.settings.speed);
    },

    destroy: function () {
      this.$headers.off('.' + pluginName);
      this.$element.removeData('plugin_' + pluginName);
    },
  };

  $.fn[pluginName] = function (options) {
    var args = Array.prototype.slice.call(arguments, 1);

    return this.each(function () {
      var instance = $.data(this, 'plugin_' + pluginName);

      if (!instance) {
        // 初始化
        $.data(this, 'plugin_' + pluginName, new Accordion(this, options));
      } else if (typeof options === 'string' && typeof instance[options] === 'function') {
        // 呼叫方法
        instance[options].apply(instance, args);
      }
    });
  };

  $.fn[pluginName].defaults = defaults;
})(jQuery);

// 使用
$('.accordion').accordion({ speed: 500 });

// 呼叫方法
$('.accordion').accordion('open', 0);
$('.accordion').accordion('closeAll');
$('.accordion').accordion('destroy');

最佳實踐

1. 保持鏈式呼叫

永遠返回 this,讓使用者可以繼續鏈式操作:

$.fn.myPlugin = function () {
  // ... 做一些事情
  return this; // 重要!
};

// 使用者可以這樣
$('div').myPlugin().addClass('active').fadeIn();

2. 使用 .each() 處理多元素

插件可能被應用在多個元素上,使用 .each() 確保每個元素都被處理:

$.fn.myPlugin = function () {
  return this.each(function () {
    var $this = $(this);
    // 對每個元素進行操作
  });
};

3. 使用命名空間

為事件使用命名空間,方便之後移除:

// 綁定事件
this.$element.on('click.myPlugin', handler);

// 移除事件
this.$element.off('.myPlugin');

4. 避免重複實例化

使用 $.data() 檢查是否已經初始化:

$.fn.myPlugin = function () {
  return this.each(function () {
    if (!$.data(this, 'myPlugin')) {
      $.data(this, 'myPlugin', new Plugin(this));
    }
  });
};

5. 提供銷毀方法

讓使用者能夠清理插件:

destroy: function() {
  // 移除事件監聽
  this.$element.off('.' + pluginName);

  // 移除新增的元素或類別
  this.$element.find('.plugin-added').remove();
  this.$element.removeClass('plugin-active');

  // 移除資料
  this.$element.removeData('plugin_' + pluginName);
}

6. 使用私有方法

在插件內部使用私有函式,不暴露給外部:

(function ($) {
  // 私有函式
  function privateHelper(value) {
    return value * 2;
  }

  $.fn.myPlugin = function () {
    var result = privateHelper(10); // 內部使用
    // ...
  };
})(jQuery);

實作範例

簡單 Tooltip 插件

(function ($) {
  'use strict';

  var defaults = {
    position: 'top',
    delay: 200,
    class: 'tooltip',
  };

  $.fn.tooltip = function (options) {
    var settings = $.extend({}, defaults, options);

    return this.each(function () {
      var $this = $(this);
      var $tooltip = null;
      var showTimeout;

      $this.on('mouseenter.tooltip', function () {
        var title = $this.attr('title') || $this.data('tooltip');

        if (!title) return;

        // 移除原生 title 提示
        $this.data('tooltip', title).removeAttr('title');

        showTimeout = setTimeout(function () {
          $tooltip = $('<div>').addClass(settings.class).text(title).appendTo('body');

          positionTooltip($this, $tooltip, settings.position);
          $tooltip.fadeIn(150);
        }, settings.delay);
      });

      $this.on('mouseleave.tooltip', function () {
        clearTimeout(showTimeout);

        if ($tooltip) {
          $tooltip.fadeOut(150, function () {
            $(this).remove();
          });
          $tooltip = null;
        }
      });
    });
  };

  function positionTooltip($element, $tooltip, position) {
    var offset = $element.offset();
    var width = $element.outerWidth();
    var height = $element.outerHeight();
    var tipWidth = $tooltip.outerWidth();
    var tipHeight = $tooltip.outerHeight();

    var positions = {
      top: {
        top: offset.top - tipHeight - 10,
        left: offset.left + (width - tipWidth) / 2,
      },
      bottom: {
        top: offset.top + height + 10,
        left: offset.left + (width - tipWidth) / 2,
      },
      left: {
        top: offset.top + (height - tipHeight) / 2,
        left: offset.left - tipWidth - 10,
      },
      right: {
        top: offset.top + (height - tipHeight) / 2,
        left: offset.left + width + 10,
      },
    };

    $tooltip.css(positions[position]);
  }

  $.fn.tooltip.defaults = defaults;
})(jQuery);

// 使用
$('[data-tooltip]').tooltip({ position: 'bottom' });

計數器插件

(function ($) {
  'use strict';

  var defaults = {
    start: 0,
    min: 0,
    max: Infinity,
    step: 1,
    onChange: null,
  };

  function Counter(element, options) {
    this.$element = $(element);
    this.settings = $.extend({}, defaults, options);
    this.value = this.settings.start;
    this.init();
  }

  Counter.prototype = {
    init: function () {
      this.render();
      this.bindEvents();
      this.updateDisplay();
    },

    render: function () {
      this.$element.addClass('counter');

      this.$minus = $('<button>', { class: 'counter-minus', text: '-' });
      this.$plus = $('<button>', { class: 'counter-plus', text: '+' });
      this.$display = $('<span>', { class: 'counter-display' });

      this.$element.append(this.$minus, this.$display, this.$plus);
    },

    bindEvents: function () {
      var self = this;

      this.$minus.on('click.counter', function () {
        self.decrement();
      });

      this.$plus.on('click.counter', function () {
        self.increment();
      });
    },

    increment: function () {
      if (this.value < this.settings.max) {
        this.value += this.settings.step;
        this.updateDisplay();
        this.triggerChange();
      }
    },

    decrement: function () {
      if (this.value > this.settings.min) {
        this.value -= this.settings.step;
        this.updateDisplay();
        this.triggerChange();
      }
    },

    setValue: function (value) {
      value = Math.max(this.settings.min, Math.min(this.settings.max, value));
      this.value = value;
      this.updateDisplay();
      this.triggerChange();
    },

    getValue: function () {
      return this.value;
    },

    updateDisplay: function () {
      this.$display.text(this.value);

      this.$minus.prop('disabled', this.value <= this.settings.min);
      this.$plus.prop('disabled', this.value >= this.settings.max);
    },

    triggerChange: function () {
      if (typeof this.settings.onChange === 'function') {
        this.settings.onChange.call(this.$element[0], this.value);
      }

      this.$element.trigger('counter:change', [this.value]);
    },

    destroy: function () {
      this.$element.off('.counter').removeClass('counter').empty().removeData('counter');
    },
  };

  $.fn.counter = function (options) {
    var args = Array.prototype.slice.call(arguments, 1);
    var returnValue = this;

    this.each(function () {
      var instance = $.data(this, 'counter');

      if (!instance) {
        $.data(this, 'counter', new Counter(this, options));
      } else if (typeof options === 'string') {
        var result = instance[options].apply(instance, args);

        if (options === 'getValue') {
          returnValue = result;
          return false; // 中斷迭代
        }
      }
    });

    return returnValue;
  };

  $.fn.counter.defaults = defaults;
})(jQuery);

// 使用
$('#counter').counter({
  start: 5,
  min: 0,
  max: 10,
  onChange: function (value) {
    console.log('新值:', value);
  },
});

// 呼叫方法
$('#counter').counter('setValue', 7);
var value = $('#counter').counter('getValue');

推薦的第三方插件

UI 相關

  • Select2 - 增強的下拉選單
  • DataTables - 表格增強
  • Slick - 輪播/滑動元件
  • Magnific Popup - 燈箱效果
  • jQuery UI - 官方 UI 元件庫

表單相關

  • jQuery Validation - 表單驗證
  • jQuery Mask - 輸入遮罩
  • Dropzone.js - 拖放上傳

其他

  • Waypoints - 捲動觸發
  • Lazy Load - 延遲載入圖片
  • Chart.js - 圖表(搭配 jQuery 使用)