49

1. 什么是单例模式?

单例模式是一种十分常用但却相对而言比较简单的单例模式。它是指在一个类只能有一个实例,即使多次实例化该类,也只返回第一次实例化后的实例对象。单例模式不仅能减少不必要的内存开销, 并且在减少全局的函数和变量冲突也具有重要的意义。

1.1 最简单的单例模式

就算你对于单例模式的概念还比较模糊,但是我相信你肯定已经使用过单例模式了。我们来看一下下面的一段代码:

let timeTool = {
  name: '处理时间工具库',
  getISODate: function() {},
  getUTCDate: function() {}
}

以对象字面量创建对象的方式在JS开发中很常见。上面的对象是一个处理时间的工具库, 以对象字面量的方式来封装了一些方法处理时间格式。全局只暴露了一个timeTool对象, 在需要使用时, 只需要采用timeTool.getISODate()调用即可。timeTool对象就是单例模式的体现。在JavaScript创建对象的方式十分灵活, 可以直接通过对象字面量的方式实例化一个对象, 而其他面向对象的语言必须使用类进行实例化。所以,这里的timeTool就已经是一个实例, 且ES6中letconst不允许重复声明的特性,确保了timeTool不能被重新覆盖。

1.2 惰性单例

采用对象字面量创建单例只能适用于简单的应用场景,一旦该对象十分复杂,那么创建对象本身就需要一定的耗时,且该对象可能需要有一些私有变量和私有方法。此时使用对象字面创建单例就不再行得通了,我们还是需要采用构造函数的方式实例化对象。下面就是使用立即执行函数和构造函数的方式改造上面的timeTool工具库。

let timeTool = (function() {
  let _instance = null;
  
  function init() {
    //私有变量
    let now = new Date();
    //公用属性和方法
    this.name = '处理时间工具库',
    this.getISODate = function() {
      return now.toISOString();
    }
    this.getUTCDate = function() {
      return now.toUTCString();
    }
  }
  
  return function() {
    if(!_instance) {
      _instance = new init();
    }
    return _instance;
  }
})()

上面的timeTool实际上是一个函数,_instance作为实例对象最开始赋值为nullinit函数是其构造函数,用于实例化对象,立即执行函数返回的是匿名函数用于判断实例是否创建,只有当调用timeTool()时进行实例的实例化,这就是惰性单例的应用,不在js加载时就进行实例化创建, 而是在需要的时候再进行单例的创建。 如果再次调用, 那么返回的永远是第一次实例化后的实例对象。

let instance1 = timeTool();
let instance2 = timeTool();
console.log(instance1 === instance2); //true

2. 单例模式的应用场景

2.1 命名空间

一个项目常常不只一个程序员进行开发和维护, 然后一个程序员很难去弄清楚另一个程序员暴露在的项目中的全局变量和方法。如果将变量和方法都暴露在全局中, 变量冲突是在所难免的。就想下面的故事一样:

//开发者A写了一大段js代码
function addNumber () {}


//开发者B开始写js代码
var addNumber = '';

//A重新维护该js代码
addNumber(); //Uncaught TypeError: addNumber is not a function

命名空间就是用来解决全局变量冲突的问题,我们完全可以只暴露一个对象名,将变量作为该对象的属性,将方法作为该对象的方法,这样就能大大减少全局变量的个数。

//开发者A写了一大段js代码
let devA = {
  addNumber() { }
}

//开发者B开始写js代码
let devB = {
  add: ''
}

//A重新维护该js代码
devA.addNumber();

上面代码中,devAdevB就是两个命名空间,采用命名空间可以有效减少全局变量的数量,以此解决变量冲突的发生。

2.2 管理模块

上面说到的timeTool对象是一个只用来处理时间的工具库,但是实际开发过程中的库可能会有多种多样的功能,例如处理ajax请求,操作dom或者处理事件。这个时候单例模式还可以用来管理代码库中的各个模块,例如下面的代码所示。

var devA = (function(){
  //ajax模块
  var ajax = {
    get: function(api, obj) {console.log('ajax get调用')},
    post: function(api, obj) {}
  }

  //dom模块
  var dom = {
    get: function() {},
    create: function() {}
  }
  
  //event模块
  var event = {
    add: function() {},
    remove: function() {}
  }

  return {
    ajax: ajax,
    dom: dom,
    event: event
  }
})()

上面的代码库中有ajax,domevent三个模块,用同一个命名空间devA来管理。在进行相应操作的时候,只需要devA.ajax.get()进行调用即可。这样可以让库的功能更加清晰。

3. ES6中的单例模式

3.1 ES6创建对象

ES6中创建对象时引入了classconstructor用来创建对象。下面我们来使用ES6的语法实例化苹果公司

class Apple {
  constructor(name, creator, products) {
    this.name = name;
    this.creator = creator;
    this.products = products;
  }
}

let appleCompany = new Apple('苹果公司', '乔布斯', ['iPhone', 'iMac', 'iPad', 'iPod']);
let copyApple = new Apple('苹果公司', '阿辉', ['iPhone', 'iMac', 'iPad', 'iPod']);

3.2 ES6中创建单例模式

苹果这么伟大的公司明显有且只有一个, 就是乔爷爷创建的那个, 哪能容别人进行复制?所以appleCompany应该是一个单例, 现在我们使用ES6的语法将constructor改写为单例模式的构造器。

class SingletonApple {
  constructor(name, creator, products) {
    //首次使用构造器实例
    if (!SingletonApple.instance) {
      this.name = name;
      this.creator = creator;
      this.products = products;
      //将this挂载到SingletonApple这个类的instance属性上
      SingletonApple.instance = this;
    }
    return SingletonApple.instance;
  }
}

let appleCompany = new SingletonApple('苹果公司', '乔布斯', ['iPhone', 'iMac', 'iPad', 'iPod']);
let copyApple = new SingletonApple('苹果公司', '阿辉', ['iPhone', 'iMac', 'iPad', 'iPod']);

console.log(appleCompany === copyApple);  //true

3.3 ES6的静态方法优化代码

ES6中提供了为class提供了static关键字定义静态方法, 我们可以将constructor中判断是否实例化的逻辑放入一个静态方法getInstance中,调用该静态方法获取实例, constructor中只包需含实例化所需的代码,这样能增强代码的可读性、结构更加优化。

class SingletonApple {
  constructor(name, creator, products) {
      this.name = name;
      this.creator = creator;
      this.products = products;
  }
  //静态方法
  static getInstance(name, creator, products) {
    if(!this.instance) {
      this.instance = new SingletonApple(name, creator, products);
    }
    return this.instance;
  }
}

let appleCompany = SingletonApple.getInstance('苹果公司', '乔布斯', ['iPhone', 'iMac', 'iPad', 'iPod']);
let copyApple = SingletonApple.getInstance('苹果公司', '阿辉', ['iPhone', 'iMac', 'iPad', 'iPod'])

console.log(appleCompany === copyApple); //true

4. 单例模式的项目实战应用

4.1 实现登陆弹框

登陆弹框在项目中是一个比较经典的单例模式,因为对于大部分网站不需要用户必须登陆才能浏览,所以登陆操作的弹框可以在用户点击登陆按钮后再进行创建。而且登陆框永远只有一个,不会出现多个登陆弹框的情况,也就意味着再次点击登陆按钮后返回的永远是一个登录框的实例。

现在来梳理一下我登陆弹框的流程,在来进行代码的实现:

  1. 给顶部导航模块的登陆按钮注册点击事件
  2. 登陆按钮点击后JS动态创建遮罩层和登陆弹框
  3. 遮罩层和登陆弹框插入到页面中
  4. 给登陆框中的关闭按钮注册事件, 用于关闭遮罩层和弹框
  5. 给登陆框中的输入框添加校验(此步骤略)
  6. 给登陆框中的确定按钮添加事件,用于Ajax请求(此步骤略)
  7. 给登陆框中的清空按钮添加事件,用于清空输入框(此步骤略)

因为5,6是登陆框的实际项目逻辑, 和单例模式关系不大。下面的项目实战代码只实现1 - 4步,其余步骤读者可自行进行扩展练习。完整的代码可在 CodePen中进行查看。

4.1.1 给页面添加顶部导航栏的HTML代码

  <nav class="top-bar">
    <div class="top-bar_left">
      LTH BLOG
    </div>
    <div class="top-bar_right">
      <div class="login-btn">登陆</div>
      <div class="signin-btn">注册</div>
    </div>
  </nav>

4.1.2 使用ES6的语法创建Login类

class Login {

  //构造器
  constructor() {
    this.init();
  }

  //初始化方法
  init() {
    //新建div
    let mask = document.createElement('div');
    //添加样式
    mask.classList.add('mask-layer');
    //添加模板字符串
    mask.innerHTML = 
    `
    <div class="login-wrapper">
      <div class="login-title">
        <div class="title-text">登录框</div>
        <div class="close-btn">×</div>
      </div>
      <div class="username-input user-input">
        <span class="login-text">用户名:</span>
        <input type="text">
      </div>
      <div class="pwd-input user-input">
        <span class="login-text">密码:</span>
        <input type="password">
      </div>
      <div class="btn-wrapper">
        <button class="confrim-btn">确定</button>
        <button class="clear-btn">清空</button>
      </div>
    </div>
    `;
    //插入元素
    document.body.insertBefore(mask, document.body.childNodes[0]);

    //注册关闭登录框事件
    Login.addCloseLoginEvent();
  }

  //静态方法: 获取元素
  static getLoginDom(cls) {
    return  document.querySelector(cls);
  }

  //静态方法: 注册关闭登录框事件
  static addCloseLoginEvent() {
    this.getLoginDom('.close-btn').addEventListener('click', () => {
      //给遮罩层添加style, 用于隐藏遮罩层
      this.getLoginDom('.mask-layer').style = "display: none";
    })
  }

  //静态方法: 获取实例(单例)
  static getInstance() {
    if(!this.instance) {
      this.instance = new Login();
    } else {
      //移除遮罩层style, 用于显示遮罩层
      this.getLoginDom('.mask-layer').removeAttribute('style');
    }
    return this.instance;
  }
}

4.1.3 给登陆按钮添加注册点击事件

//注册点击事件
Login.getLoginDom('.login-btn').addEventListener('click', () => {
  Login.getInstance();
})

4.1.4 效果演示

单例效果展示.gif-36.8kB

完整的项目代码见: CodePen(单例模式案例——登录框)

上面的登陆框的实现中,我们只创建了一个Login的类, 但是却实现了一个并不简单的登陆功能。在第一次点击登陆按钮的时候,我们调用Login.getInstance()实例化了一个登陆框,且在之后的点击中,并没有重新创建新的登陆框,只是移除掉了"display: none"这个样式来显示登陆框,节省了内存开销。

总结

单例模式虽然简单,但是在项目中的应用场景却是相当多的,单例模式的核心是确保只有一个实例, 并提供全局访问。就像我们只需要一个浏览器的window对象, jQuery的$对象而不再需要第二个。 由于JavaScript代码书写方式十分灵活, 这也导致了如果没有严格的规范的情况下,大型的项目中JavaScript不利于多人协同开发, 使用单例模式进行命名空间,管理模块是一个很好的开发习惯,能够有效的解决协同开发变量冲突的问题。灵活使用单例模式,也能够减少不必要的内存开销,提高用于体验。


yihui_indie
2.5k 声望938 粉丝

前端拼装工程师