likes
comments
collection
share

前端设计模式系列——单例模式

作者站长头像
站长
· 阅读数 21

持续整理一些前端用到的设计模式,欢迎大家关注专栏:

  • 工厂模式
  • 单例模式
  • 代理模式
  • 策略模式
  • 建造者模式
  • 观察者模式
  • 适配器模式
  • 装饰器模式
  • 迭代器模式
  • 中间件模式
  • ……

单例模式

基本概念

单例模式是所有程序设计语言中都会用到的一种模式,单例模式的目标就是确保某个类只能出现一个实例,对该类所作的任何访问,都必须通过这个实例执行。例如考虑下面的情况:

  • 共享状态信息,不想让同一个实体由好几个对象来表示,否则就会出现状态不一致的情况
  • 优化资源占用,不想让用户创建多个相同的资源

一个典型的场景就是通过 Database 类设计一个数据库连接池,这样就可以复用这些连接,而不是每遇到一个请求,就去开一个新的连接。

class Database {
  constructor(name, options = {}) {
    
  }
}

于是我们希望全局只能出现一个 Database 类的实例,可以通过下面的静态方法来实现:

class Database {
  constructor(name) {
    this.name = name
  }
  static getInstance(name) {
    if (!this.instance) {
      this.instance = new Database(name)
    }
    return this.instance
  }
}

var w1 = Database.getInstance('mysql')
var w2 = Database.getInstance('mysql')
console.log(w1 === w2) // true

但是这种方式是有缺点的,用户必须调用 getInstance 方法来创建,如果不想抛弃 new 的方式,可以再定义一个 SingleDatabase 对原类进行二次封装:

const SingletonDatabase = (() => {
  let db
  return function (name) {
    if (db) return db
    return (db = new Database(name))
  }
})()
const db1 = new SingletonDatabase('mysql')
const db2 = new SingletonDatabase('mysql')
console.log(db1 === db2) // true

这样的话,用户通过 new 操作符创建的所有实例,其实都是同一份全局唯一的实例。我们不建议直接修改原来的类,让其默认成为单例,这样会对用户造成困惑。由于 SingletonDatabase 这个类名在语义上已经很清晰了,所以这么做是没问题的。

不过需要注意的是,单例不一定和 new 操作符有关系,即使是一个普通的 JavaScript 对象,如果能够保证其全局唯一,那么就是单例。

记住:单例模式的核心是确保只有一个实例,并提供全局访问。

典型示例

angular

在下面的代码中,通过 @Injectable() 装饰器在全局注册了单例的 user service:

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class UserService {
}

例如当前的登录用户只可能会有一个,用此方法可以确保全局唯一的登录用户实例。

gulp

在 gulp 中,也是通过 new Gulp()创建了 Gulp 类的一个实例,然后导出去:

var util = require('util');
var Undertaker = require('undertaker');
var vfs = require('vinyl-fs');
var watch = require('glob-watcher');

function Gulp() {
  Undertaker.call(this);
  this.watch = this.watch.bind(this);
  this.task = this.task.bind(this);
  this.series = this.series.bind(this);
  this.parallel = this.parallel.bind(this);
  this.registry = this.registry.bind(this);
  this.tree = this.tree.bind(this);
  this.lastRun = this.lastRun.bind(this);
  this.src = this.src.bind(this);
  this.dest = this.dest.bind(this);
  this.symlink = this.symlink.bind(this);
}
util.inherits(Gulp, Undertaker);

// 省略一些代码...

// Let people use this class from our instance
Gulp.prototype.Gulp = Gulp;

var inst = new Gulp();
module.exports = inst;

这样用户无论在哪个文件中引入 gulp 的时候,就会拿到这个唯一的实例:

var gulp = require('gulp'); // 得益于 CommonJS 的模块缓存机制,拿到的是同一个 Gulp 实例

当然,你肯定注意到了这句话:

// Let people use this class from our instance
Gulp.prototype.Gulp = Gulp;

注释说的很明白了,用户可以直接使用 gulp.Gulp 类自己创建一个实例,也就是说如果用户不想要单例,而是想创建多实例,这就是逃生通道。

redux

在 redux 项目中,我们会通过 createStore 函数创建一个全局的 store:

import { createStore } from 'redux'
function reducer(state, action) {
	return state
}
const store = createStore(reducer)
export default store

这个 store 就是全局单例的,可以放在 Provider 组件的 store 属性里面供所有组件使用:

ReactDOM.render(
  <Provider store={store}>
  	{//省略无关代码}
  </Provider>,
  document.getElementById('app')
)

想想看,如果调用两次 createStore 创建 store 对象,那它们之间的状态是完全隔离的,那么组件之间就无法通过 store 来进行通信,因为状态无法共享。本质上是因为 createStore 返回的 store 对象中引用到的状态是保存在这个函数内部的,是闭包变量:

export default function createStore(reducer, preloadedState?, enhancer?) {
  let currentReducer = reducer
  let currentState = preloadedState as S
  let currentListeners: (() => void)[] | null = []
  let nextListeners = currentListeners
  let isDispatching = false
  // 省略很多行代码...
  const store = {
    dispatch,
    subscribe,
    getState,
    replaceReducer,
  } 
  return store
}

history

同样在 React 项目中,如果使用了 react-router 路由模块,往往会通过 history 这个包创建全局唯一的 hashHistory 或者 browserHistory:

import { createHashHistory, createBrowserHistory } from "history";
const hashHistory = createHashHistory();
window.hashHistory = hashHistory; // 挂载 window 上面方便调试
export default hashHistory;

然后放到 Router 组件的 history 属性里面:

ReactDOM.render(
  <Router history={hashHistory}>
    <Switch>
      <Route path="/" component={App} />
    </Switch>
  </Router>,
  document.getElementById('app')
)

createHashHistory 的逻辑跟 redux 中的 createStore 几乎如出一辙,都是在函数内部保存一些状态变量,然后返回一个可以操纵这些闭包变量的对象:

export function createHashHistory(options: HashHistoryOptions = {}): HashHistory {
  let { window = document.defaultView! } = options;
  let globalHistory = window.history;
  let action = Action.Pop;
  let [index, location] = getIndexAndLocation();
  let listeners = createEvents<Listener>();
  let blockers = createEvents<Blocker>();
  // 省略代码...
	let history: HashHistory = {
    get action() { return action; },
    get location() { return location; },
    createHref,
    push,
    replace,
    go,
    back() { go(-1); },
    forward() { go(1); },
    listen(listener) { return listeners.push(listener); },
  };

  return history;
}