前端应该掌握的设计模式—单例模式


highlight: a11y-dark
theme: github

前言

之前我们比较全面的阐述了策略模式,了解到策略模式在日常开发中的应用场景,解决了冗长的 if-else 或 switch 分支判断,同时让代码符合单一原则开放封闭原则,具有良好的扩展性,复用性和可维护性。

此篇的主题单例模式用于解决另外一系列问题,单例模式算是比较简单的设计模式,但使用场景很多,也经常出现在面试里,我们将从定义、使用场景,解决什么问题,怎么实现单例模式,在一些开源库里是怎么使用,以及我们日常开发里怎么使用等多个方面学习单例模式。

定义

保证一个类仅有一个实例,并提供一个访问它的全局访问点;

单例模式的定义相对于其他设计模式来讲,比较容易理解,就是纸面上的意思,不管我们尝试去创建多少次,它都只给你返回第一次所创建的那唯一的一个实例。

单例模式主要用来解决两个场景:

  • 一个全局使用的类频繁地被创建和销毁,占用内存,比如ModalLoading
  • 需要维持全局仅有一个实例,不然会导致出错的场景,比如Vuexredux

非单例模式

在实现单例模式前,先看一下正常对象实例化,是否符合单例模式,用构造函数或者Class(本质上还是构造函数)来实现都可以,我们这里就直接使用构造函数做示例,实现如下代码,可以看出相同的构造函数Singleton,经过两次创建的对象实例s1、s2并不相等,对象指向的内存地址不一致,不是同一个实例,不符合单例模式。

function Singleton() {}
const s1 = new Singleton();
const s2 = new Singleton();
console.log(s1);
console.log(s2);
console.log(s1 === s2);

{}

{}

false

单例模式实现

既然直接实例化不符合单例模式,那要怎么才能实现单例模式呢?要实现单例模式,需要构造函数具备判断自己是否已经创建过一个实例的能力,目前有三种方式,闭包、Class静态方法、模块化,接下来分别实现对应方式。

闭包

不管执行多少次,返回的都是同一个实例,聪明的你很快就能想到缓存,而闭包刚好就能满足我们的需求,创建一个IIFE(立即执行函数)返回一个函数,而这个函数返回函数的内部变量instance,也就是我们想要的唯一实例,我们实现了如下代码,进行了两次实例化,生成实例对象s1、s2,根据输出可以判定s1与s2相等,即s1,s2指向同一块内存地址,是同一个实例,符合单例模式。

const Singleton = (function () {
    // 实例变量
    let instance = null;
    // 实例的构造函数
    function getInstance() {}
    return function () {
      // 判断是否已经new过1个实例
      if (!instance) {
        // 如果实例不存在,则先new一个实例
        instance = new getInstance();
      }
      // 未来不管执行多少次,都返回这个唯一实例
      return instance;
    };
})();
const s1 = Singleton();
const s2 = Singleton();
console.log(s1);
console.log(s2);
console.log(s1 === s2);

image.png

静态方法

Es6 Class的静态方法也能实现单例模式,原理是借助于静态属性和静态方法,Class的本质是一个构造函数,存在static修饰符的属性称为静态属性,直接挂载在构造函数上,当前类未被销毁时,静态属性也不会被销毁,具有类似于闭包的缓存作用,可以用来存储实例,实现了如下代码,借助Singleton.getInstance()生成实例对象s1、s2,根据输出可以判定s1与s2相等,即s1,s2指向同一块内存地址,是同一个实例。

class Singleton {
    show() {
      console.log("我是单例");
    }
    // 实例变量
    static instance = null;
    // 返回唯一实例的静态方法
    static getInstance() {
      // 判断是否已经new过1个实例
      if (!Singleton.instance) {
        // 如果实例不存在,则先new一个实例
        Singleton.instance = new Singleton();
      }
      // 未来不管执行多少次,都返回这个唯一实例
      return Singleton.instance;
    }
}
const s1 = Singleton.getInstance();
const s2 = Singleton.getInstance();
console.log(s1);
console.log(s2);
console.log(s1 === s2);
console.dir(Singleton);

image.png

模块化

借助模块化实现单例模式的方式其实本质还是闭包,不过会比较简洁,没有模块化时,我们借助闭包实现单例模式时,就如上面实现的代码一样,需要自己在外层添加一个函数包裹,前端目前常用的模块化有ES6原生支持的ES Module和nodejs使用的commomjs,他们在加载模块时都会对加载的模块用函数包裹,这样我们只要实现其内部逻辑即可,这样子说可能不直观,我先用个代码解释,然后用nodejs使用commomjs加载文件的源码示例,大家就理解了模块化实现单例模式的原理了。

比如现在有一个index.js文件,实现了如下代码:

// 实例变量
let instance = null;
// 实例的构造函数
function getInstance() {}
export default () => {
  // 判断是否已经new过1个实例
  if (!instance) {
    // 如果实例不存在,则先new一个实例
    instance = new getInstance();
  }
  // 未来不管执行多少次,都返回这个唯一实例
  return instance;
};

经过commonjs或者ES module模块化包裹后,就会是类似于如下的代码,在文件内容外层包裹一个函数,这样子我们就借助于模块化实现闭包了,从而实现了单例模式

function () {
    // 实例变量
    let instance = null;
    // 实例的构造函数
    function getInstance() {}
    export default () => {
      // 判断是否已经new过1个实例
      if (!instance) {
        // 如果实例不存在,则先new一个实例
        instance = new getInstance();
      }
      // 未来不管执行多少次,都返回这个唯一实例
      return instance;
    };
}

commomJs

下面我们通过nodejs的commomjs源码证实我们上面的说的问题,模块化会对文件内容包裹一个函数,为了更契合我们单例模式的主题,只分析commonJs是怎么编译js文件的,了解其原理,借助它实现我们需要的单例。

  • 可以看到node处理js文件需要进行编译
/**
* module 模块实例
* filename js 文件路径
*/
Module._extensions['.js'] = function(module, filename) {
    // 通过 readFileSync 读取当前js文件的字符串
    var content = fs.readFileSync(filename, 'utf8');
    // 执行 module._compile 将字符串转为可以运行的代码
    module._compile(content, filename);
};
  • module._compile执行js文件编译
Module.prototype._compile = function(content, filename) {
  // ...
  // 将模块代码
  var wrapper = Module.wrap(content);
  // 将字符串转换成可执行的js代码 
  var compiledWrapper = vm.runInThisContext(wrapper, {
    filename: filename,
    lineOffset: 0,
    displayErrors: true
  });
  // ...
  // 执行函数
  result = compiledWrapper.call(this.exports, this.exports, require, this,
                                  filename, dirname);
  return result;
}
  • Module.wrap通过字符串拼接,在代码外包含一个函数
Module.wrap = function(script) {
    return Module.wrapper[0] + script + Module.wrapper[1];
};
Module.wrapper = [
    '(function (exports, require, module, __filename, __dirname) { ',
    '\n});'
];

通过了解commomjs执行js的流程不难发现,其在内部运行时会在js文件外层包裹一个自执行函数,并且传递5个对象参数,看到自执行函数,我们想起了通过闭包实现单例模式,假如我在一个a.js实现如下代码:

/ 实例变量
let instance = null;
// 实例的构造函数
function getInstance() {}
const singleton = function () {
  // 判断是否已经new过1个实例
  if (!instance) {
    // 如果实例不存在,则先new一个实例
    instance = new getInstance();
  }
  // 未来不管执行多少次,都返回这个唯一实例
  return instance;
};
module.exports = {
    singleton
}

而当文件执行时,会为我们的js文件内容包裹一个自执行函数,

(function (exports, require, module, __filename, __dirname) {
    
});

所以最终我们的结果就是如下,一样实现了单例模式,

(function (exports, require, module, __filename, __dirname) {
    / 实例变量
    let instance = null;
    // 实例的构造函数
    function getInstance() {}
    const singleton = function () {
      // 判断是否已经new过1个实例
      if (!instance) {
        // 如果实例不存在,则先new一个实例
        instance = new getInstance();
      }
      // 未来不管执行多少次,都返回这个唯一实例
      return instance;
    };
    module.exports = {
        singleton
    }
})();

时至今日,对于前端也不是说完全100%都符合原始单例模式的定义,更多的借助于单例模式的思想,来实现我们保持唯一实例或者唯一值。

从这里也可以看出,曾经你可能不怎么使用到的闭包还是很重要的,如果你不了解,那你很多的代码都没办法看出其深意和内部原理,如果有对于闭包不懂的,赶紧多去网上找点资料看,然后赶紧掌握吧,如果实现不行,评论区留言,我在写一篇关于闭包的文章

实战

Vuex

在之前,你可能没注意Vuex内部实现了单例模式,这算是一个单例模式使用的经典场景,我们先不谈它是怎么实现的,思考一下,不使用单例会导致什么问题,我们在Vue项目入口实例化了一次Vuex,然后在运行过程中有一些数据存储到了Store中,但是有过小伙伴搞事情,在其某个页面又实现了一次Vuex的实例化,这就相当于初始化,那之前Store里面的存储内容将都丢失了,导致了一系列问题,为了杜绝这种问题,所以Vuex必须实现单例模式。

接下来我们看一下Vuex是怎么实现单例模式,我在Vuex的源码里看到了如下这段代码,当然我删除了一些多余的代码,可以看到在store.js这个文件里面声明Vue全局变量,然后export导出一个函数,这个函数使用到了Vue这个外部变量,是不是跟我们上面所说的借助于了模块化和闭包实现了单例模式如出一辙。

import applyMixin from './mixin'

let Vue // bind on install

export function install (_Vue) {
  if (Vue && _Vue === Vue) {
    if (__DEV__) {
      console.error(
        '[vuex] already installed. Vue.use(Vuex) should be called only once.'
      )
    }
    return
  }
  Vue = _Vue
  applyMixin(Vue)
}

ElementUI Loading

下面这张图截取至ElementUI文档,从中可以了解到以服务的方式调用全屏Loading是单例的,那我们在从源码的角度分析其单例模式的实现方式是否和我们上面学习到的方式吻合。

image.png

这边是截取ElementUI Loading组件的一段代码,当然也是忽略掉其他跟实现单例模式无关的代码,从代码中可以看出一样是借助于模块化和闭包实现单例模式,看优秀开源代码的实现思路也是我们学习的过程,以后我们需要实现单例时,也可以使用一样的方式。

// 引入 loadingVue 组件
import loadingVue from './loading.vue';
// 通过Vue.extend实现一个继承于Vue的组件构造器
const LoadingConstructor = Vue.extend(loadingVue);
// 函数外实现一个变量,用户保存LoadingConstructor的实例
let fullscreenLoading;
// loading关闭时,清空fullscreenLoading
LoadingConstructor.prototype.close = function() {
  if (this.fullscreen) {
    fullscreenLoading = undefined;
  }
};
const Loading = (options = {}) => {
  // 如果是全屏loading,并且fullscreenLoading存在,直接返回fullscreenLoading实例
  if (options.fullscreen && fullscreenLoading) {
    return fullscreenLoading;
  }
  // 实例化组件实例
  let instance = new LoadingConstructor({
    el: document.createElement('div'),
    data: options
  });
  // 如果是全屏Loading,将实例赋值给fullscreenLoading
  if (options.fullscreen) {
    fullscreenLoading = instance;
  }
  return instance;
};
// 导出Loading
export default Loading;

登录页

上面举的都是开源代码的实现方式,接下来我们自己实现一个具有单例模式的登录页。

目录

image.png

Login/login.vue

<template>
  <el-dialog title="登录" :visible.sync="visible" width="30%" @close="visible = false">
    <el-form ref="form">
      <el-form-item>
        <el-input v-model="formData.account" placeholder="请输入账号"></el-input>
      </el-form-item>
      <el-form-item>
        <el-input v-model="formData.password" type="password" placeholder="请输入密码"></el-input>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" style="width: 100%">登录</el-button>
      </el-form-item>
    </el-form>
  </el-dialog>
</template>
<script>
export default {
  name: "Login",
  data() {
    return {
      visible: false,
      formData: {
        account: "",
        password: "",
      },
    };
  },
  methods: {
    show() {
      this.visible = true;
    },
  },
};
</script>

Login/index.js

import vue from "vue";
// 登录代码实现
import loginVue from "./login.vue";
// 通过Vue.extend 实现登录组件构造器
const LoginConstructor = vue.extend(loginVue);
// 声明存储实例变量
let instance;

function Login() {
  // 如果登录实例不存在,则创建实例
  if (!instance) {
    instance = new LoginConstructor({
      el: document.createElement("div"),
    });
    document.body.appendChild(instance.$el);
  }
  // 返回实例
  return instance;
}
export default Login;

src/main.js

import Login from "./components/Login/index";
// 挂载到Vue全局
Vue.prototype.$login = Login();

使用

进行多次调用,只显示一个登录实例

this.$login.show();
this.$login.show();
this.$login.show();
this.$login.show();
this.$login.show();

image.png

小结

通过这篇文章的学习,是不是突然发现有个问题也许找到了答案,那就是闭包在日常开发中,并不经常使用,也不是很重要,但为什么面试总是问,那是因为它在你看不见或者没思考过的地方有许多有用的场景,当然文章举例的只是闭包的用法之一,同样的,单例模式在许多的开源库里都使用,也会影响着我们的开发,如果你不懂闭包,不懂设计模式,那会错过实现更好功能,错过很多的了解别人代码原理的机会,错过很多学习和变强的机会,当然也是应对面试的内容。

建了个设计模式专栏,后续写的前端常用设计模式的文章都会放在里面,欢迎学习,点赞,收藏。

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容