所有分类
  • 所有分类
  • 游戏源码
  • 网站源码
  • 单机游戏
  • 游戏素材
  • 搭建教程
  • 精品工具

别再写错单例模式!前端JavaScript实现方法与真实应用实例

别再写错单例模式!前端JavaScript实现方法与真实应用实例 一

文章目录CloseOpen

这篇文章就针对JavaScript,拆解闭包、ES6类、模块模式这3种常用单例写法,帮你揪出写法里的隐藏bug;还结合弹窗组件、Vuex/Pinia全局状态、工具类实例等真实项目场景,告诉你单例该“什么时候用”“怎么用才不会错”。不管你是刚学设计模式的新手,还是总写错单例的老司机,读完就能彻底搞懂前端单例的正确打开方式,再也不用怕踩坑!

你有没有过这种情况?写了个全局弹窗组件,用户点两次按钮弹出俩,覆盖在页面上丑得要命;或者用Pinia做状态管理,不小心创建了多个store实例,导致不同组件的状态串了,调试半天才发现问题?单例模式明明是设计模式里“看起来最简单”的一个,但我问过身边10个前端,有8个都写错过——要么实例判断逻辑有漏洞,要么懒加载没做好,要么搞出全局变量污染。其实不是你没学会,是没人把“前端场景下的正确写法”和“什么时候该用”讲透。今天我把自己踩过的坑、测过的正确实现方法,还有项目里的真实用法全掏出来,你跟着做,以后写单例再也不会错。

前端单例模式的3种正确实现方法,我踩过的坑你别再犯

单例模式的核心是“保证一个类只有一个实例,并提供全局访问点”,但前端场景里的“类”不一定是传统的类——闭包、模块都能实现单例。我踩过的坑大多是“想当然”:比如觉得“只要判断instance有没有值就行”,结果没处理懒加载;或者用ES6类的时候,没挡住别人绕开constructor创建实例。下面这3种方法是我测过最稳的,每个都附我踩过的坑,你避开就行。

  • 闭包实现:最经典但容易漏懒加载的写法
  • 闭包是前端实现单例最经典的方式,靠函数作用域保存私有变量instance,外面拿不到,只能通过getInstance访问。我刚学的时候写过这么一段代码:

    function createUserStore() {
    

    let instance = { userInfo: null }; // 直接初始化实例

    return {

    getInstance: () => instance,

    setUser: (info) => instance.userInfo = info

    };

    }

    const userStore = createUserStore();

    当时我没意识到,直接初始化instance会导致页面加载时就创建实例——而userStore是用户登录后才会用到的,首屏加载时创建完全是浪费性能。后来我改成了“懒加载”:只有第一次调用getInstance时才创建实例:

    function createUserStore() {
    

    let instance; // 先不初始化

    function init() { // 初始化逻辑

    return { userInfo: null };

    }

    return {

    getInstance: () => {

    if (!instance) instance = init(); // 懒加载

    return instance;

    },

    setUser: (info) => {

    const store = createUserStore.getInstance();

    store.userInfo = info;

    }

    };

    }

    闭包的好处是私有性强:instance不会暴露在全局,避免被意外修改;但缺点是写法有点“绕”,新手容易漏写懒加载——我朋友去年做博客项目时,就因为没写懒加载,导致评论组件的实例提前创建,拖慢了首页加载速度,后来改成懒加载后,首屏时间缩短了150ms。

  • ES6类实现:面向对象风格,但要防“绕过constructor”
  • ES6出了class后,我以为写单例更简单了,结果又踩了坑。一开始我是这么写的:

    class Cart {
    

    constructor() {

    if (!Cart.instance) {

    this.items = []; // 购物车商品列表

    Cart.instance = this;

    }

    return Cart.instance;

    }

    }

    const cart1 = new Cart();

    const cart2 = new Cart();

    console.log(cart1 === cart2); // 看似true

    直到有天同事用Object.create(Cart.prototype)创建了实例:

    const cart3 = Object.create(Cart.prototype);
    

    console.log(cart3 === cart1); // false!

    我才发现问题:constructor里的判断挡不住用Object.create创建实例——因为Object.create直接继承原型,不会执行constructor。后来我改成了用static方法创建实例,彻底杜绝绕过:

    class Cart {
    

    constructor() {

    this.items = [];

    }

    // 用static方法保证唯一实例

    static getInstance() {

    if (!this.instance) {

    this.instance = new Cart();

    }

    return this.instance;

    }

    addItem(item) {

    this.items.push(item);

    }

    }

    // 使用时必须通过getInstance获取

    const cart = Cart.getInstance();

    这样不管别人用什么方式,只要调用getInstance,拿到的都是同一个实例。ES6类的写法更符合面向对象的习惯,如果你平时写class比较多,这个方法会更顺手——我现在做React项目的全局状态,基本都用这种方式。

  • 模块模式:ES6原生支持,最简单却容易被忽略
  • 现在很多前端项目都用ES6模块(import/export),而ES6模块本身就是单例的——每个模块只加载一次,导出的内容会被缓存。比如我写的工具类utils/date.js

    // 直接导出一个对象,模块加载时创建实例
    

    const dateTool = {

    format(date) {

    return new Intl.DateTimeFormat('zh-CN').format(date);

    },

    addDay(date, days) {

    const newDate = new Date(date);

    newDate.setDate(newDate.getDate() + days);

    return newDate;

    }

    };

    export default dateTool;

    你在任何组件里import dateTool from '@/utils/date',拿到的都是同一个对象——这就是天然的单例!我以前没意识到这一点,自己封装了一个createDateTool函数,结果每个组件导入后都要调用一次,创建多个实例,导致代码重复。后来改成直接导出对象,省了好多事。

    模块模式的优点是简洁到“不用写额外代码”,缺点是如果导出的是“函数”而不是“对象”,就会失去单例性——比如你导出function createDateTool() {...},每个组件调用时都会创建新实例。所以模块模式的关键是:导出对象或已经执行过的函数结果

    我把这3种方法的优缺点整理成了表格,你可以直接对照着选:

    实现方式 优点 缺点 适用场景
    闭包 私有性强,懒加载易实现 写法稍繁琐,新手易漏懒加载 需要私有状态的场景(如用户信息)
    ES6类(static方法) 面向对象风格,易理解 需防绕过constructor的情况 习惯用class的项目(如React组件)
    模块模式 ES6原生支持,写法最简 导出函数时需注意单例 现代前端项目(用ES6模块)

    单例模式在前端项目中的真实用法,看完直接套进你的代码

    以前我觉得单例模式是“花架子”,直到踩了几个坑才明白:前端里需要“全局唯一”的东西太多了——弹窗、状态管理、工具类,这些地方不用单例,早晚要出问题。下面这3个真实场景,你可以直接套进自己的代码里。

  • 全局弹窗组件:避免“多开”的核心解法
  • 我去年做电商项目时,写了个优惠券弹窗组件,没想着用单例,结果用户点了两次“领取优惠券”按钮,弹出两个一模一样的弹窗,叠在一起挡住了页面。测试提了bug,我才意识到:弹窗作为全局组件,应该只有一个实例——不管点多少次,都只显示一个。

    后来我用ES6类+static方法改成了单例:

    // components/CouponModal.js
    

    class CouponModal {

    constructor() {

    // 创建弹窗DOM

    this.el = document.createElement('div');

    this.el.className = 'coupon-modal';

    this.el.style.display = 'none';

    this.el.innerHTML =

    ;

    document.body.appendChild(this.el);

    // 绑定关闭事件

    this.el.querySelector('.close-btn').addEventListener('click', () => {

    this.hide();

    });

    }

    static getInstance() {

    if (!CouponModal.instance) {

    CouponModal.instance = new CouponModal();

    }

    return CouponModal.instance;

    }

    show() {

    this.el.style.display = 'block';

    }

    hide() {

    this.el.style.display = 'none';

    }

    }

    // 导出单例实例

    export default CouponModal.getInstance();

    现在不管我在代码里导入多少次CouponModal,都是同一个实例。用户点多少次按钮,都只会显示一个弹窗——bug解决了,测试也没再找我麻烦。你看,单例模式在这里的核心作用就是保证全局唯一,避免重复创建 DOM,既省内存又避免视觉混乱。

  • 全局状态管理:Pinia/Vuex的“隐形单例”
  • 现在很多项目用Pinia做状态管理,你可能没注意到:Pinia的store本身就是单例的。比如我定义的计数器store:

    // stores/counter.js
    

    import { defineStore } from 'pinia';

    export const useCounterStore = defineStore('counter', {

    state: () => ({ count: 0 }),

    actions: {

    increment() {

    this.count++;

    }

    }

    });

    你在组件里导入useCounterStore,调用时得到的是同一个实例:

    // ComponentA.vue
    

    const counter = useCounterStore();

    counter.increment(); // count变成1

    // ComponentB.vue

    const counter = useCounterStore();

    console.log(counter.count); // 还是1

    我以前用Vuex的时候,没注意到这一点,自己封装了一个store,结果创建了多个实例——ComponentA的count是1,ComponentB的count是0,调试了3小时才发现问题。后来看了Pinia的文档才知道,defineStore返回的函数,每次调用都会返回同一个store实例——这就是单例模式的“隐形应用”。

    如果你自己封装全局状态,一定要用单例——比如用模块模式导出一个对象:

    // stores/globalState.js
    

    const globalState = {

    user: null,

    theme: 'light'

    };

    export default globalState;

    这样不管哪个组件导入,拿到的都是同一个状态对象,不会出现状态不一致的问题。

  • 工具类封装:避免重复配置的关键
  • 我以前封装axios请求的时候,没做单例,结果每个组件里导入axios都会创建一个新实例:

    // 错误写法:每个组件导入都创建新实例
    

    import axios from 'axios';

    const request = axios.create({

    baseURL: 'https://api.example.com',

    timeout: 5000

    });

    导致的问题是:每个实例的拦截器、baseURL都要重新配置——我在ComponentA里加了token拦截器,ComponentB里的请求却没有token,因为是不同的实例。后来我改成了单例:

    // utils/request.js
    

    import axios from 'axios';

    class Request {

    constructor() {

    this.instance = axios.create({

    baseURL: 'https://api.example.com',

    timeout: 5000

    });

    // 添加全局拦截器

    this.instance.interceptors.request.use(config => {

    const token = localStorage.getItem('token');

    if (token) {

    config.headers.Authorization = Bearer ${token};

    }

    return config;

    });

    }

    static getInstance() {

    if (!Request.instance) {

    Request.instance = new Request();

    }

    return Request.instance;

    }

    get(url, params) {

    return this.instance.get(url, { params });

    }

    post(url, data) {

    return this.instance.post(url, data);

    }

    }

    // 导出单例实例

    export default Request.getInstance();

    现在不管哪个组件里用request.get,都是同一个axios实例——拦截器只需要加一次,baseURL只需要配置一次,省了好多重复代码。我同事看了我的代码,也把他们项目的请求封装改成了单例,说“以前要改baseURL得改5个地方,现在只需要改1处”。

    你以前写单例模式踩过什么坑?比如有没有过弹窗多开、状态串数据的情况?或者你有更好的单例实现方法,欢迎在评论区告诉我,我帮你看看怎么改。要是按我讲的方法试了,也可以回来分享效果——毕竟前端的坑,踩过一次就够了,能避则避!


    本文常见问题(FAQ)

    前端写单例模式时,最容易犯的“懒加载”错误是什么?

    我之前踩过的坑就是直接在闭包里初始化instance,比如写createUserStore时,一开始就把instance赋值为{ userInfo: null },结果页面加载时就创建了实例,但其实userStore是用户登录后才会用的,首屏加载时创建完全浪费性能。

    正确的做法是“懒加载”——先不初始化instance,只有第一次调用getInstance方法时,才执行init函数创建实例,这样能避免不必要的性能消耗。

    用ES6类实现单例时,怎么防止别人绕开constructor创建多个实例?

    我之前用ES6类写单例时,只在constructor里判断Cart.instance有没有值,结果同事用Object.create(Cart.prototype)创建了新实例,导致cart3和之前的cart1不是同一个。

    后来我改成用static方法来创建实例,比如给Cart类加一个static getInstance方法,里面判断this.instance有没有值,没有就new Cart(),这样不管别人用什么方式,只要调用getInstance拿到的都是同一个实例,彻底挡住了绕开constructor的情况。

    ES6模块本身是单例的,那导出函数时要注意什么?

    ES6模块的单例性是说每个模块只加载一次,但如果导出的是函数,比如export function createDateTool() {…},那每个组件调用这个函数时都会创建新实例,就失去单例性了。

    所以模块模式实现单例的关键是导出对象或已经执行过的函数结果,比如直接导出const dateTool = { format: () => {…} },这样不管导入多少次,拿到的都是同一个对象,保持了单例性。

    全局弹窗组件为什么必须用单例模式?

    我去年做电商项目时,没给优惠券弹窗用单例,结果用户点两次“领取优惠券”弹出两个叠在一起的弹窗,丑得要命还挡页面。

    弹窗作为全局组件,必须保证只有一个实例——用单例模式的话,不管点多少次按钮,都只会显示一个弹窗,既省内存(不用重复创建DOM),又不会造成视觉混乱,解决了多开的bug。

    Pinia的store为什么是单例的?用的时候要注意什么?

    Pinia的store是单例的,因为defineStore返回的函数每次调用都会返回同一个实例,比如你定义useCounterStore后,不管在ComponentA还是ComponentB里调用,拿到的都是同一个store。

    用的时候不用自己创建多个实例,要是像以前用Vuex那样封装多个store,反而会导致不同组件的状态串了——比如ComponentA的count是1,ComponentB的count还是0,调试起来很麻烦,所以直接用defineStore返回的函数就行。

    原文链接:https://www.mayiym.com/49607.html,转载请注明出处。
    0
    显示验证码
    没有账号?注册  忘记密码?

    社交账号快速登录

    微信扫一扫关注
    如已关注,请回复“登录”二字获取验证码