☰
jQuery插件开发实战:从零封装可复用的Tooltip组件
2026/10/22 6:16:34 网站建设 项目流程

如果你写过三个以上的页面,迟早会撞见这个场景:某个下拉菜单、一个提示框、一组轮播图,已经在好几个项目里被抄来抄去,而它们最初的载体都是 jQuery。刚开始还能忍,直到某天要改交互逻辑,改一处、崩两处,你才开始认真考虑:是不是该把这些通用交互整理成独立的 jQuery 插件。

这篇文章就是带你把这件事完整做一遍。从 jQuery 插件的最小骨架开始,到实现一个带定位、延迟、回调、销毁接口的完整 tooltip 插件,再把写插件过程中最容易踩的九个坑挨个排一遍。适合已经会用选择器和事件、但还没怎么封装过代码的前端初学者,也适合想把手头重复代码重构成插件的老手。所有代码我都贴全了,复制到本地存成一个 JS 文件就能直接用。

1. 为什么要写插件:从三份重复代码说起

1.1 插件解决的三个核心问题

我最早被逼着写 jQuery 插件,状态很狼狈。第一次写提示层,直接在页面里内联了一段脚本;第二个项目要用,复制过来改了三个 class 名;第三次要换成从右往左弹出的方向,结果发现得在三个页面的三份代码里同步改,改完还漏了一处。

这个场景暴露出来的问题,正好就是插件解决的三个核心诉求:

  • 复用:一份逻辑可以在不同项目、不同页面里直接挂载使用,不用每次复制再改类名。
  • 统一维护:逻辑只存一处,改行为只需要动一个文件。
  • 实例隔离:每一个触发元素都有一份独立的配置和状态,一个页面上有十个提示层,A 的样式调整不会影响 B。

很多人把"插件"当成高深技术,其实它就是一个组织代码的方式:把散落的函数、变量、事件绑定收拢成一个对象,再挂到 jQuery 实例上。核心原理翻来覆去就是 IIFE、闭包、原型扩展、选项合并这几样,并没有黑魔法。

1.2 现在学 jQuery 插件还有没有用

每次聊这个话题都会有人问:都什么年代了还学 jQuery?我的看法分两点。

第一,存量项目远比想象中多。企业内部系统、老平台、各类历史页面,哪个里面没有几段$(),这些项目日常还需要人维护。你手里有写 jQuery 插件的能力,碰到这些代码时就不会只敢做局部修补,而是能看出它结构上的问题并动手重构。

第二,插件背后那套思维完全不贬值。IIFE 的变量隔离、$.extend的配置合并、事件命名空间的精准解绑、data 存储实例、暴露销毁接口——这些东西放到组件化前端框架里,不过是换了个马甲:默认配置变成 props 默认值,事件解绑变成组件卸载时的清理函数,数据存储变成组件状态。你学的是招式,内功是"如何设计一个可复用、可配置、可销毁的组件"。这层内功,在任何时代都不会过时。

2. 最小插件骨架拆解:$.fn、IIFE、链式调用

2.1 标准外壳长什么样

先看一个最精简的插件外壳,这是几乎所有 jQuery 插件的通用模板:

;(function ($) { 'use strict'; $.fn.samplePlugin = function (options) { var settings = $.extend({}, $.fn.samplePlugin.defaults, options); // this 是 jQuery 对象,each 遍历后返回 this,保持链式调用 return this.each(function () { // 这里的 this 变成了原生 DOM 元素 var $this = $(this); // 在这里写具体的插件逻辑 }); }; // 默认配置挂在插件命名空间下,外部可以直接改 $.fn.samplePlugin.defaults = { color: '#333' }; })(jQuery);

拆开讲几个关键点。

为什么开头要写一个分号?因为这个文件将来可能被压缩合并到别的 JS 后面,如果上一个文件最后一行没有分号,合并后语法就炸了。分号写在左括号外面是个防御性习惯,我写插件、写公共脚本都这么开头。

(function($){...})(jQuery)是一个立即执行函数,作用是把插件的所有内部变量关在独立作用域里,不污染全局;同时把 jQuery 作为参数传进来,保证内部拿到的$一定是 jQuery,避免和其他使用$的库冲突。写在'use strict'还能顺手揪出"忘了写 var 的隐式全局变量"这类手滑问题。

$.fn是什么?它在 jQuery 内部就是实例对象的原型,往$.fn上添加方法,所有通过$()创建出来的 jQuery 对象都能直接调用。这也是为什么插件方法都是$.fn.xxx = function(){...}的形式。

2.2 this 到底是谁

初学者最容易懵的就是这个this。

插件函数内部的this是 jQuery 对象,比如你调用$('#btn').samplePlugin(),方法体里的this就是$('#btn')这个对象。但是一旦进入this.each(function(){...}),回调里的this就被 jQuery 换成了原生 DOM 元素。想在这个回调里用 jQuery 方法,就得先做一次$(this)包装,这也正是代码里var $this = $(this)这一步的由来。

记住一句话:插件的 this 是 jQuery,each 回调里的 this 是 DOM,each 回调外层的 this 才是插件要控制的对象。搞不清这一点,后面所有报错都从这儿来。

2.3 return this 与链式调用

插件函数一定要记得return this。jQuery 的链式调用依赖上一个方法返回 jQuery 对象,如果你写插件时只做遍历不返回,像$('#btn').samplePlugin().css('color', 'red')这种用法就直接报错。

标准写法是return this.each(function(){...}),因为each本身也会返回被遍历的 jQuery 对象,这一行代码同时完成了"遍历初始化"和"保持链式"两件事。如果你做的是轮播图这类只需要作用于单个容器的插件,也同样建议return this,大不了内部取this.first()或this.eq(0)来做。

3. 完整实例:手写一个带定位的 tooltip 插件

3.1 需求清单

很多 UI 库里都有 tooltip,我们这次动手实现一个类似的东西,但完全不依赖任何现成组件。选它当例子,是因为体积小、逻辑清晰,而且"计算悬浮层位置"这件事,是轮播箭头、二级菜单、下拉面板等一大批插件的地基。

开工前先列需求:

  • 鼠标移入显示、移出隐藏
  • 位置可选:上 / 下 / 左 / 右
  • 内容来源优先级:配置项 >>;(function ($) { 'use strict'; var defaults = { position: 'top', // top / bottom / left / right distance: 8, // 提示层与触发元素之间的距离(px) delay: 0, // 延迟显示(毫秒) content: '', // 手动指定的内容 className: '', // 附加到提示层上的样式类 onShow: null, onHide: null }; function Tooltip(el, options) { this.el = el; this.$el = $(el); this.options = $.extend({}, defaults, options); this.namespace = 'myTooltip' + Math.random().toString(36).slice(2); this.tip = null; this.timer = null; this.init(); } Tooltip.prototype.init = function () { var self = this; // 提示层插入 body 而不是触发元素内部,避免被父容器的 overflow 裁掉 this.tip = $('<div class="my-tooltip"></div>') .html(this.getContent()) .appendTo('body'); if (this.options.className) { this.tip.addClass(this.options.className); } this.$el.on('mouseenter.' + this.namespace, function () { clearTimeout(self.timer); self.timer = setTimeout(function () { self.show(); }, self.options.delay); }); this.$el.on('mouseleave.' + this.namespace, function () { clearTimeout(self.timer); self.hide(); }); }; Tooltip.prototype.getContent = function () { if (this.options.content) return this.options.content; if (this.$el.data('content')) return this.$el.data('content'); var title = this.el.getAttribute('title'); if (title) { // 取完立刻删掉 title,避免浏览器原生提示双重弹出 this.el.removeAttribute('title'); return title; } return ''; }; Tooltip.prototype.show = function () { this.$el.addClass('my-tooltip-open'); // 先显示再测量,否则 offsetWidth / offsetHeight 都是 0 this.tip.addClass('show'); var rect = getTipRect(this.el, this.tip[0], this.options.position, this.options.distance); this.tip.css({ left: rect.left + 'px', top: rect.top + 'px' }); if ($.isFunction(this.options.onShow)) { this.options.onShow.call(this.el, this.tip); } }; Tooltip.prototype.hide = function () { this.$el.removeClass('my-tooltip-open'); this.tip.removeClass('show'); if ($.isFunction(this.options.onHide)) { this.options.onHide.call(this.el, this.tip); } }; Tooltip.prototype.destroy = function () { clearTimeout(this.timer); this.$el.off('.' + this.namespace); this.$el.removeData('myTooltip'); if (this.tip) { this.tip.remove(); this.tip = null; } this.$el.removeClass('my-tooltip-open'); }; // 核心定位计算:getBoundingClientRect 返回的是视口坐标, // 所以加回滚动偏移量,转成文档坐标,配合 absolute 定位 function getTipRect(el, tipEl, position, distance) { var elRect = el.getBoundingClientRect(); var scrollX = window.pageXOffset || document.documentElement.scrollLeft; var scrollY = window.pageYOffset || document.documentElement.scrollTop; var tipW = tipEl.offsetWidth; var tipH = tipEl.offsetHeight; var left = elRect.left + scrollX; var top = elRect.top + scrollY; var rect = { left: 0, top: 0 }; switch (position) { case 'bottom': rect.left = left + (elRect.width - tipW) / 2; rect.top = top + elRect.height + distance; break; case 'left': rect.left = left - tipW - distance; rect.top = top + (elRect.height - tipH) / 2; break; case 'right': rect.left = left + elRect.width + distance; rect.top = top + (elRect.height - tipH) / 2; break; default: // top rect.left = left + (elRect.width - tipW) / 2; rect.top = top - tipH - distance; break; } return rect; } // 插件入口:传对象是初始化,传字符串是调用实例方法 $.fn.myTooltip = function (option) { var args = Array.prototype.slice.call(arguments, 1); return this.each(function () { var $el = $(this); var instance = $el.data('myTooltip'); if (typeof option === 'string') { if (instance && $.isFunction(instance[option])) { instance[option].apply(instance, args); } return; } // 重复调用时只更新配置,不重复创建提示层 if (instance) { $.extend(instance.options, option || {}); return; } $el.data('myTooltip', new Tooltip(this, option)); }); }; $.fn.myTooltip.defaults = defaults; })(jQuery);

    配套的样式需要一份最小 CSS:

    .my-tooltip { position: absolute; z-index: 9999; display: none; max-width: 260px; padding: 6px 10px; border-radius: 4px; background: rgba(33, 33, 33, 0.9); color: #fff; font-size: 12px; line-height: 1.4; pointer-events: none; } .my-tooltip.show { display: block; }

    用法很简单,三种初始化方式都支持:

    <button id="btn1">上侧提示</button> <button id="btn2">下侧提示</button> <button>$('#btn1').myTooltip({ content: '默认在顶部显示', position: 'top' }); $('#btn2').myTooltip({ position: 'bottom', delay: 200 }); $('[data-content]').myTooltip(); $('[title]').myTooltip(); // 方法调用 $('#btn1').myTooltip('destroy');

    3.3 代码里几个容易被忽略的设计点

    提示层为什么要放到body下而不是触发元素里面?因为页面里常见的overflow: hidden、position: relative容器都会把悬浮层裁掉或者改变它的定位参照。挂在body下,再用绝对定位直接指定坐标,才能最大程度规避父容器样式带来的干扰。

    定位计算这里,getBoundingClientRect()返回的是相对视口的坐标,如果页面发生了滚动,直接拿这个值去设left/top,提示层会跟着视口"漂"。所以必须把scrollX、scrollY加回去,转成文档坐标。很多人第一次写悬浮层都栽在这个滚动偏移上,页面在顶部时一切正常,滚到中间就错位。

    测量宽高的时机也有讲究。offsetWidth在元素处于display: none时返回 0,所以我先addClass('show')让它显示出来,再去测量和设置坐标。如果调换顺序,定位会全部变成 0 或 NaN。

    title属性的处理是体验细节。页面里原有的title是由浏览器原生托管,鼠标移上去会弹系统提示,跟插件提示叠在一起非常难看。getContent 里取完标题后立刻removeAttribute('title'),一次性消费掉。

    最后是数据存储和防重复初始化。实例对象存在$(el).data('myTooltip')里,第二次调用插件时发现已有实例,只合并新配置,不新建提示层、不重复绑事件。这种"单例 + 配置更新"的模式,是绝大多数 UI 插件的标准做法。

    4. 从"能用"到"好用":选项合并、回调、销毁接口

    上一版的 tooltip 已经能跑,但一个插件要称得上"可用",还得补上几个关键能力:配置合并的边界、回调机制、公开方法和销毁清理。这里逐个说。

    4.1 $.extend 的深浅坑

    插件内部最常见的代码是$.extend({}, defaults, options),这行是浅合并:把 defaults 和 options 的属性平铺复制到新对象里。

    假如 defaults 里出现了嵌套结构,例如:

    var defaults = { animation: { duration: 300, easing: 'ease-out' } }; $.extend({}, defaults, { animation: { duration: 600 } });

    浅合并的结果是把整个animation对象替换成了{ duration: 600 },默认的easing丢了。

    更危险的是,如果不小心写成了$.extend(defaults, options),直接改变了全局 defaults 对象,那后续所有新实例都会带着被污染过的默认配置。

    需要处理嵌套时,用深合并:

    $.extend(true, {}, defaults, options);

    代价是深合并性能比浅合并差一些,这也是为什么插件的默认配置我习惯尽量平铺,能少一层嵌套就少一层。

    4.2 回调与事件命名空间

    回调是这个插件里最轻但没有它反而难受的能力。用户在初始化时传进来一个函数,插件在特定时机调用它,使用者在不动插件内部源码的前提下挂接自己的业务逻辑。调用时用call(this.el, this.tip),把回调的this指向触发元素,回调里就能通过this拿到当前上下文,这比只传参数要顺手得多。

    事件命名空间是另一个"看起来不起眼、崩溃时救命"的设计。绑定事件时我写的是'mouseenter.' + this.namespace,这个后缀就是命名空间。解绑时用off('.' + this.namespace)只移除自己绑的那批处理函数,不会误伤页面上其他代码为同一个元素绑定的鼠标事件。如果没有命名空间,直接off('mouseenter')会把同类事件全部解掉,排查起来非常痛。

    4.3 公开方法、单实例与销毁

    插件入口的第二个参数判断了传进来的是配置对象还是方法名字符串。传字符串时从data('myTooltip')里取出实例,调用对应原型方法。这样使用者不需要理解内部实现,$el.myTooltip('destroy')就能完成清理。

    销毁是插件最容易偷懒但绝不能省的部分。一个合格的 destroy 至少要干四件事:清掉定时器、解绑事件、移除生成的 DOM、清除 data 里的实例引用。只有 data 也被清掉,再次调用插件时才不会出现"旧实例还在,新实例建不起来"的诡异状态。我见过不少"只能初始化、无法销毁"的插件,动态交互的页面里想重挂组件就卡住了。

    4.4 全局默认值覆盖

    代码末尾的$.fn.myTooltip.defaults = defaults把默认配置暴露到了插件命名空间下。这么做的价值是:整个项目想统一把提示方向从 top 改成 bottom,不需要挨个改调用点,在项目初始化早期执行一行:

    $.fn.myTooltip.defaults.position = 'bottom';

    之后所有实例的默认方向都是 bottom。需要注意的是这种覆盖是全局性的,执行时机越早越好,别在运行时根据用户交互反复改默认值,那会让各实例行为不一致,比写死配置还难维护。

    5. 写插件常踩的九个坑:现场排错方法

    这部分我按排查难度分成了三组,每个坑都是我在实际项目里踩过的,附上现象和修法。

    5.1 作用域和 this 相关的三个坑

    坑 1:回调里的 this 悄悄变成了其他东西。插件方法里的this是 jQuery 对象,进入each回调变 DOM,进入普通事件回调又指触发元素。新手在第一层回调里习惯了this是 DOM,到$(this).addClass()没问题;但进了第二层嵌套回调,比如鼠标事件里再包一个setTimeout,this直接指向 window。修法很朴素:外层先用var self = this锁定上下文,内层一律用self。这也是我在插件代码里到处写var self = this的原因。

    坑 2:循环里绑定事件,变量全是最后一个。经典闭包问题:

    for (var i = 0; i < items.length; i++) { $el.on('click', function () { console.log(i); // i 始终是 items.length }); }

    修法要么用自执行函数包一层,要么用 jQuery 的data把索引存到元素上,点击时再取:

    $el.data('index', i).on('click', function () { console.log($(this).data('index')); });

    坑 3:忘记 return this 导致链式调用中断。报错一般表现为$(...).myPlugin(...).css is not a function。插件方法执行完没有返回 jQuery 对象,后面的.css、.on全部无从调用。修法就是在插件方法里明确return this.each(...)。

    5.2 绑定和数据存储的三个坑

    坑 4:重复初始化导致事件叠加。同一个元素上调用两次插件,如果没有做单例检查,事件会绑两次,状态越跑越乱。遇到"鼠标移入提示层弹两次""点击一次触发两遍逻辑",先怀疑这里。修法就是实例化前查 data,已有实例就只合并配置。

    坑 5:解绑事件误伤其他代码。off('click')会把元素上所有 click 处理函数全部移除,包括别人绑的。写插件时务必用命名空间:绑定用on('click.myPlugin' + 随机后缀, fn),解绑用off('.myPlugin' + 后缀)。事件名带随机后缀还能让同一元素上的多个实例互不干扰。

    坑 6:data 里存的是字符串而不是实例。有人把$(el).data('tooltip', 'active')当状态标记,初始化时发现"已存在"就直接 return,导致后续想调用$el.myTooltip('destroy')根本取不到实例。data 的正确存法是把实例对象整体存进去,任何需要方法调用的场景都能直接从 data 取出来干活。

    5.3 配置和兼容性的三个坑

    坑 7:嵌套配置浅合并丢失子项。前面已经演示过,defaults 有嵌套对象时,浅合并会把整个子对象覆盖掉。排查时可以打印合并后的 settings,如果发现某个子配置"凭空消失"或"变成 undefined",多半是浅合并的锅,换成$.extend(true, {}, defaults, options)。

    坑 8:默认值污染造成全局串改。在某个业务页面里写了$.fn.myTooltip.defaults.position = 'right',结果其他所有用到这个插件的页面提示层全部跑到右面去了。全局默认值适合在项目级别统一设置,不适合在运行时零散修改。想改单个实例,直接传 options 就好。

    坑 9:压缩合并后报错。插件文件被合并时,如果上一个文件没写结尾分号,下一个文件开头也不写,压缩器把它们黏成一行后语法直接崩。修法就是插件顶部那个"裸分号 + IIFE",配合内部所有变量用 var / let 声明,杜绝隐式全局变量被压缩后改名引发的冲突。

    6. 这段经验能带到哪里去:给前端新人的迁移思路

    写完 jQuery 插件再看现在的组件化开发,会发现大量思路是相通的。

    选项合并这个模式,到了组件里就是 props 和默认配置的合并逻辑。组件设计时同样要考虑:什么样的配置允许外部覆盖?嵌套对象怎么处理默认值?要不要暴露全局配置入口?这些问题的思考路径,和$.extend的深浅合并完全同源。

    事件命名空间对应的,是组件卸载时的清理逻辑。任何框架里,组件销毁阶段要做的事都和 destroy 方法高度重合:清定时器、移除事件监听、释放内部创建的 DOM。把"挂载做什么、销毁做什么"这件事想清楚,组件才不会有内存泄漏和状态残留。

    data 存储实例和单例检查,对应到组件里就是"这个实例是否存在、是否需要复用、重复挂载怎么处理"。理解了这个模式,再看一遍 useEffect 里的依赖清理和组件复用的边界,会顺畅很多。

    最后分享一个我个人坚持了很久的习惯:任何插件写完,我都会拿到三种页面环境里各跑一遍——带长滚动条的页面、父容器设置过overflow: hidden的页面、以及一个页面同时挂多个实例的场景。大部分悬浮层的定位 bug、事件冲突、实例串扰,都只在这三种环境里冒头。你可以在上面这份 tooltip 插件代码基础上继续加东西:给提示层加一个小三角箭头、把"移入显示"改成"移出后延迟再消失"的经典交互、或者在边界处加一套自动翻转方向的逻辑。等这些都跑顺了,再回头看你最初那几份重复粘贴的代码,会很清楚地知道自己当初是怎么从"能跑"走到"能用"的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询