代码改变世界

读书笔记之 - javascript 设计模式 - 享元模式

2014-08-26 16:36  sai.zhao  阅读(937)  评论(0编辑  收藏  举报

本章探讨另一种优化模式-享元模式,它最适合于解决因创建大量类似对象而累及性能的问题。这种模式在javascript中尤其有用,因为复杂的javascript代码很快就会用光浏览器的所有可用内存,通过把大量独立对象转化为少量共享对象,可以降低运行web应用程序所需的资源数量。

享元模式用于减少应用程序所需对象的数量。这是通过对对象的内部状态划分为内在数据和外在数据俩类实现的。内在数据是指类的内部方法所需要的信息,没有这种数据的话类就不能正常运转。外在数据则是可以从类身上剥离并存储在其外部的信息。我们可以将内在状态相同的所有对象替换为同一个共享对象,用这种方法可以把对象数量减少到不同内在状态的数量。

创建这种共享对象需要使用工厂,而不是构造函数,这样就可以跟踪到已经实例化的各个对象,从而仅当所需对象的内在状态不同于已有对象时才创建一个新对象,对象的外在状态被保存在一个管理器中,在调用对象的方法时,管理器会把这些外在状态作为参数传入。

汽车登记示例:

假设你要开发一个系统,用以代表一个城市的所有汽车,你需要保存每一辆汽车的详细情况(品牌、型号和出厂日期)及其所有权的详细情况(车主,车牌、登记日期)。当然你决定把每辆汽车表示为一个对象。

var Car = function (make,model,year,owner,tag,renewDate) {
    this.make = make;
    this.model = model;
    this.yuea = year;
    this.owner = owner;
    this.tag = tag;
    this.renewDate = renewDate;
};
Car.prototype = {
    getMake: function () {
        return this.make;
    },
    getModel: function () {
        return this.model;
    },
    getYear: function () {
        return this.year;
    },
    transferOwnership: function (newOwner, newTag, newRenewDate) {
        this.owner = newOwner;
        this.tag = newTag;
        this.renewDate = newRenewDate;
    },
    renewRegistration: function (newRenewDate) {
        this.renewDate = newRenewDate;
    },
    isRegistrationCurrent: function () {
        var today = new Date();
        return today.getTime() < Date.parse(this.renewDate);
    }
};

这个系统开始表现不错,但是随着城市人口的增长,数以十万计的汽车对象耗尽了可用的计算资源,想要优化这个系统,可以采用享元模式减少所需对象的数目。而优化工作的第一步,就是把内在状态和外在状态分开。

将对象划分为内在和外在的过程有一定的随意性。既要维持每个对象的模块性,又要尽可能多的数据做外在数据处理。在本例中,车的自然属性(品牌,型号,出厂如期)属于内在数据,而所有权数据(车主、车牌、登记日)属于外在数据,这意味着对于品牌、型号、和出厂日期的每一种组合,只需要一个汽车对象就成。每个品牌、型号、出厂日期组合对应的那个实例将被所有该类汽车的车主共享。下面是新版代码.

var Car = function (make,model,year) {
    this.make = make;
    this.model = model;
    this.year = year;
};

Car.prototype = {
    getMake: function () {
        return this.make;
    },
    getModel: function () {
        return this.model;
    },
    getYear: function () {
        return this.year;
    }
};

上面的代码删除了所有外在数据。所有的处理登记事宜方法都被转移到一个管理器对象中(不过也可以将这些方法留在原地,并为其增加对应于各种外在数据的参数)。因为现在对象的数据已经被分为俩大部分,所以必须用工厂来实例化它。

用工厂进行实例化

这个工厂很简单,它会检查之前是否已经创建过对应于指定品牌、型号、出厂日期组合的汽车,如果存在这样的汽车那就返回,否则就创建一辆新车,并把它保存起来以便以后使用。这就确保了对应于每个唯一的内在状态,只会创建一个实例:

var CarFactory = (function(){
    var createdCars = {};
    return {
        createCar:function(make,model,year) {
            if(createdCars[make+'-'+model+'-'+year]) {
                return createdCars[make+'-'+model+'-'+year];
            }else{
                var car = new Car(make, model, year);
                createdCars[make+'-'+model+'-'+year] = car;
                return car;
            }
        }
    }
})();

封装在管理器中的外在状态:

要完成这种优化还需要一个对象。所有那些从Car对象中删除的数据必须有个保存的地点,我们用一个单体来做封装这些数据的管理器。原先的每一个Car对象现在都被分割为外在数据及其所属的共享汽车对象的引用这样俩部分。Car对象与车主数据的组合称为汽车记录,管理器存储着这俩方面的信息,它还包含着从原先Car类删除的方法:

var CarRecordManager = (function(){
    var carRecordDatabase = {};
    return {
        addCarRecord:function(make,model,year,owner,tag,renewDate) {
            var car = CarFactory.createCar(make, model, year);
            carRecordDatabase[tag] = {
                owner:owner,
                renewDate:renewDate,
                car:car
            };
        },
        transferOwnership: function (tag,newOwner, newTag, newRenewDate) {
            var record = carRecordDatabase[tag];
            record.owner = newOwner;
            record.tag = newTag;
            record.renewDate = newRenewDate;
        },
        renewRegistration: function (newRenewDate) {
            carRecordDatabase[tag].renewDate = newRenewDate;
        },
        isRegistrationCurrent: function () {
            var today = new Date();
            return today.getTime() < Date.parse(carRecordDatabase[tag].renewDate);
        }
    }
})();

从Car类剥离的所有数据都保存在 CarRecordManager这个单体的私有属性 carRecordDatabase中,这个carRecordDatabase对象要比以前使用的一大批对象高效得多。那些处理所有权的事宜的方法现在也被封装在这个单体中,因为他们处理的都是外在数据。

可见,这种优化是以复杂性为代价的,原先只有一个类,现在变成了一个类和俩个单体对象,但与所解决的性能问题相比,这都是小问题。

管理外在状态

管理享元对象的外在数据有许多不同的方法,使用管理器对象是一种常见的做法。这种对象有一个集中管理的数据库,用于存放外在状态及其所属的享元对象。汽车登记那个示例就采用了这种方案。

另一种管理外在状态的办法是使用组合模式,借助前面的组合模式,你可以用对象自身的层次体系来保存信息,而不需要另外使用一个集中管理的数据库。组合对象的叶节点全都可以是享元对象,这样一来这些享元对象就可以在组合对象层次体系中的多个地方被共享。对于大型的对象层次体系这非常有用,因为同样的数据用这种方案来表示时所需对象的数量要少的多。

WEB 日历示例:

为了演示组合对象来保存外在状态的具体做法,我们创建一个web日历,首先实现的是一个未经优化,未使用享元的版本。这是一个大型组合对象,位于最顶层的代表年份的组合对象,它封装着代表月份的组合对象,而后者又封装着代表日期的叶对象。这是一个简单的例子,他会按顺序显示每月的各天,还会按顺序显示一年中个各个月:

var CalendarItem = new Interface('CalendarItem', ['display']);

var CalendarYear = function(year,parent) {
    this.year = year;
    this.element = document.createElement('div');
    this.element.style.display = 'none';
    parent.appendChild(this.element);
    function isLeapYear(y) {
        return (y > 0) && !(y % 4) && ((y % 100) || !(y % 400));
    }

    this.months = [];
    this.numDays = [31, isLeapYear(this.year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
    for (var i= 0,len=12;i<len;i++) {
        this.months[i] = new CalendarMonth(i,this.numDays[i],this.element);
    }
};

CalendarYear.prototype = {
    display:function(){
        for (var i= 0,len=this.months.length;i<len;i++) {
            this.months[i].display();
        }
        this.element.style.display = 'block';
    }
}

var CalendarMonth = function (monthNum,numDays,parent) {
    this.monthNum = monthNum;
    this.element = document.createElement('div');
    this.element.style.display = 'none';
    parent.appendChild(this.element);

    this.days = [];
    for (var i= 0,len=numDays;i<len;i++) {
        this.days[i] = new CalendarDay(i,this.element);
    }
};

CalendarMonth.prototype = {
    display:function(){
        for (var i= 0,len=this.days.length;i<len;i++) {
            this.days[i].display();
        }
        this.element.style.display = 'block';
    }
}


var CalendarDay = function (date,parent) {
    this.date = monthNum;
    this.element = document.createElement('div');
    this.element.style.display = 'none';
    parent.appendChild(this.element);
};

CalendarDay.prototype = {
    display:function(){
        this.element.style.display = 'block';
        this.element.innerHTML = this.date;
    }
}

这段代码的问题在于你不得不为每一年创建365个 CalendarDay,如果创建一个十年日历,会给浏览器带来资源压力,更有效的做法是无论日历要显示多少年,都只用一个CalendarDay对象来代表所有日期。

把日期对象转化为享元模式实现:

把CalendarDay对象转化为享元对象的过程很简单。首先,修改CalendarDay类本身,除去其中保存的所有数据。让这些数据(日期和父元素)成为外在数据:

var CalendarDay = function(){};
CalendarDay.prototype = {
    display:function(date,parent){
        var element = document.createElement('div');
        parent.appendChild(element);
        element.innerHTML = date;
    }
};

接下来,创建日期对象的单个实例。所有CalendarMonth对象都要使用这个实例。这里本来也可以像上面的例子那样使用工厂来创建该类的实例,不过由于这个类只需要创建一个实例,所有直接实例化它就行。

var calendarDay = new CalendarDay();

现在外在数据成了display方法的参数,而不是类的构造函数的参数。这是享元模式的典型工作方式。因为在此情况下有些数据被保存在对象之外,要想实现与之前同样的功能就必须把他们提供给各个方法。

最后CalendarMonth类也要略作修改。原来用CalendarDay类构造函数创建该类实例的那个表达式被替换为CalendarDay对象,而那些原本提供给CalendarDay类构造函数的参数现在被转而提供给display方法:

var CalendarMonth = function (monthNum,numDays,parent) {
    this.monthNum = monthNum;
    this.element = document.createElement('div');
    this.element.style.display = 'none';
    parent.appendChild(this.element);

    this.days = [];
    for (var i= 0,len=numDays;i<len;i++) {
        this.days[i] = CalendarDay;
    }
};
CalendarMonth.prototype = {
    display:function(){
        for (var i= 0,len=this.days.length;i<len;i++) {
            this.days[i].display(i,this.element);
        }
        this.element.style.display = 'block';
    }
}

本例没有像前面那样使用一个中心数据库来保存所有从享元对象剥离的数据。实际上,其他类基本没什么修改。CalendarYear根本没有改变,CalendarMonth只修改了俩行,这都是因为组合对象的结构本身就已经包含了所有的外在数据。由于月份对象中是所有日期对象依次存放在一个数组中,所以他们知道每一个日期对象的状态、从CalendarDay构造函数中提出的俩种数据都已经存在于CalendarMonth对象中。

这就是组合模式与享元模式配合的如此完美的原因,组合对象通常拥有大量的叶对象,它还保存着许多可作为外在数据处理的数据。叶对象通常只包含极少的内在数据,所以很容易被转化为共享资源。

工具提示对象示例:

在javascript对象需要创建html内容这种情况下,享元模式特别有用。那种会生成DOM元素的对象如果数目众多的话,会占用过多内存,采用享元模式后,只需要创建少许这种对象即可,所有需要这种对象的地方都可以共享他们。工具提示就是一个简单例子。

先看看未使用享元模式的Tooltip类。

var Tooltip = function (targetElement, text) {
    this.target = targetElement;
    this.text = text;
    this.delayTimeout = null;
    this.delay = 1500;

    //Create the HTML
    this.element = document.createElement('div');
    this.element.style.display = 'none';
    this.element.style.position = 'absolute';
    this.element.className = 'tooltip';
    document.getElementsByTagName('body')[0].appendChild(this.element);
    this.element.innerHTML = this.text;

    //Attach the events
    var _this = this;
    addEvent(this.target,'mouseover', function (e) {
        _this.startDelay(e);
    });
    addEvent(this.target,'mouseout', function (e) {
        _this.hide();
    });
}

Tooltip.prototype = {
    startDelay: function (e) {
        if(this.delayTimeout == null) {
            var _this = this;
            var x = e.clientX;
            var y = e.clientY;
            this.delayTimeout = setTimeout(function () {
                _this.show(x,y);
            }, this.delay);
        }
    },
    show: function (x,y) {
        clearTimeout(this.delayTimeout);
        this.delayTimeout = null;
        this.element.style.left = x + 'px';
        this.element.style.top = (y + 20) + 'px';
        this.element.style.display = 'block';
    },
    hide: function () {
        clearTimeout(this.delayTimeout);
        this.delayTimeout = null;
        this.element.style.display = 'none';
    }
}

Tooltip 类的构造函数为mouseover和mouseout事件注册了事件监听器。这里有个问题,这些事件监听器通常是作为触发事件的HTML元素的方法执行的,这意味着其中this的指向的是该元素而不是那个Tooltip,所以我们使用了_this来保存this变量,这是一个普通变量,它不会因事件监听器是否作为html元素的方法调用而改变,所以我们可以通过它调用Tooltip的方法。

这个类的用法很简单。只管创建实例,并把网页上的某个元素的引用和想要显示的文字传递给它的构造函数就行了。

var linkEl = $('link-id');
var tt = new Tooltip('linkElement','Lorem ipsum...');

但是,如果网页上有几百个甚至几千个元素需要用到工具提示,那么会出现成百上千个Tooltip实例,它们每个都有自己的属性、DOM元素和样式,这非常低效。

既然工具提示只能显示一个,那么为每个工具提示对象都创建一份HTML内容实在没有意义,如果把Tooltip对象实现为享元,那么它只要一个实例就行。可以让管理器对象把要显示的文字作为外在数据提供给它的方法。

把Tooltip类转化为享元要做三件事。

  1. 把外在数据从Tooltip对象中删除
  2. 创建一个用来实例化Tooltip的工厂
  3. 创建一个用来保存外在数据的管理器

在这个例子中我们还可以发挥一点创造性,用一个单体同时扮演工厂和管理者的角色,此外,由于外在数据可以作为事件监听器的一部分保存,一次没有必要使用一个中心数据库。

首先,把外在数据从Tooltip中删除

var Tooltip = function () {
    this.delayTimeout = null;
    this.delay = 1500;

    //Create the HTML
    this.element = document.createElement('div');
    this.element.style.display = 'none';
    this.element.style.position = 'absolute';
    this.element.className = 'tooltip';
    document.getElementsByTagName('body')[0].appendChild(this.element);
}

Tooltip.prototype = {
    startDelay: function (e,text) {
        if(this.delayTimeout == null) {
            var _this = this;
            var x = e.clientX;
            var y = e.clientY;
            this.delayTimeout = setTimeout(function () {
                _this.show(x,y,text);
            }, this.delay);
        }
    },
    show: function (x,y,text) {
        clearTimeout(this.delayTimeout);
        this.delayTimeout = null;
        this.element.innerHTML = text;
        this.element.style.left = x + 'px';
        this.element.style.top = (y + 20) + 'px';
        this.element.style.display = 'block';
    },
    hide: function () {
        clearTimeout(this.delayTimeout);
        this.delayTimeout = null;
        this.element.style.display = 'none';
    }
}

上面的Tooltip类删除了原来构造函数的参数以及事件处理的代码和注册事件处理器代码。而startDelay和show方法则各增加了一个新参数,这样要显示的数据就可以作为外在数据传递给它们。

下一步是创建要做工厂和管理器的那个单体,我们把Tooltip类的声明放在 TooltipManager 这个单体中,这样它就不能再别的地方被实例化。

var TooltipManager = (function(){
    var storedInstance = null;
    var Tooltip = function(){
        //......
    }
    Tooltip.prototype = {
        //......
    }
    return {
        addTooltip: function (targetElement,text) {
            var tt = this.getTooltip();
            addEvent(targetElement,'mouseover', function (e) {
                tt.startDelay(e,text);
            });
            addEvent(targetElement,'mouseout', function (e) {
                tt.hide();
            });
        },
        getTooltip: function () {
            if(storedInstance == null) {
                storedInstance = new Tooltip();
            }
            return storedInstance;
        }
    }
})()

这个单体有俩个方法,分别体现了它的俩种角色,getTooltip是工厂方法,addTooltip是管理器方法,它先获取一个Tooltip对象,然后分别把俩个匿名函数注册为目标元素的监听器。这个例子用不着创建中心数据库,因为那俩个匿名函数中生成的闭包已经保存了外在数据。

现在在这里应该是调用addTooltip方法而不是实例化Tooltip:

TooltipManager.addTooltip($('link-id'),'Lorem ipsum..');

把 Tooltip 转化为享元的收获是现在需要生成的DOM元素已经减少为一个。这很重要,假如你想为工具提示添加阴影或者iframe垫片等特性,那么每个Tooltip对象需要生产5-10个Dom元素。如果事先未享元的话,网页将被成百上千个工具提示压垮。此外,享元模式的应用减少了对象内部保存的数据。现在你不用担心为此会创建几千个挤来挤去的Tooltip了实例。

保存实例供以后重用

模式对话框是享元模式的另一个适用场合。与工具提示一样,对话框对象也封装着数据和HTML内容。不过,后者包含的DOM元素要多得多。因此尽量减少实例个数更显重要。

因为运行期间需要用到的实例确切数目无法在开发期确定,所以不能对实例的个数进行限制。而只能要多少就创建多少,然后把它们保存起来以供以后使用。这样就不用再次承受其创建过程的开销,而且所创建的数目也刚好能满足。

在这个示例中 ,DialogBox对象的实现细节不重要,你只需要知道,它是资源密集型对象,应该尽量少实例化,该类的基本框架以及实现的接口如下所示:

var DisplayModule = new Interface('DisplayModule',['show','hide','state']);
var DialogBox = function () {};
DialogBox.prototype = {
    show:function(header,body,footer){},
    hide:function(){},
    tsate:function(){}
}

该类实现了DisplayModule接口中定义的3个方法。这里不关心其具体的实现,重点在于那个控制享元数量的管理器。该管理器需要3个部件:一个用来显示对话框的方法,一个检查当前网页上正在使用的对话框数目,以及一个用来保存所生产的对话框的结构数据。我们用一个单体来保证这些部件,以确保管理器的唯一性。

//这段代码在原文注释中有误
    var DialogBoxManager = (function(){
        var created = [];
    
        return {
            displayDialogBox: function (header,body,footer) {
                var inUse = this.numberInUse();
                if (inUse>created.length) {
                    created.push(this.createDialogBox());
                }
                created[inUse].show(header,body,footer);
            },
            creteDialogBox : function () {
                var db = new DiologBox();
                return db;
            },
            numberInUse: function () {
                var inUse = 0;
                for (var i= 0,len=created.length;i<len;i++) {
                    if(created[i].state()==='visible'){
                        inUse++;
                    }
                }
                return inUse;
            }
        }
    })();

这个管理器把已经创建出来的对话框对象保存在数组created中,以便于重用。numberInUse方法用于获取现有DialogBox对象中当前正在被使用的对象的个数,它通过检查DialogBox对象的状态判断其是否正被使用。displayDialogBox方法先会检查这个数字是否不小于数组的长度,并且只有在不能重用现有实例的情况下才创建新实例。

这个示例比前面那个工具提示要复杂一点,但是他们的工作原理是相同的,总结起来就是:

  1. 通过把外在数据从资源密集型对象剥离以实现对这种对象的重用;
  2. 用一个管理器控制对象的个数并保存外在数据;
  3. 所生成的实例个数应该刚好够用,并且在实例化开销较大的情况下,这些实例应被保存起来供以后重用。

这种技术类似于服务器端语言中的SQL连接池。在后一种技术中,仅当现有连接都在使用当中是才会创建新连接。

要想把对象转化为享元模式需要满足一些前提条件。最重要的一个条件就是网页中必须使用了大量资源密集型对象。

第二个条件就是这些对象所保存的数据至少有一部分能被转化为外在数据。这就是说必须能够把存储在对象内部的部分数据分离出来,然后将其作为参数提供给各个方法。那些包含大量基础性代码和HTML内容的对象可能比较合适这种优化,如果一个对象只不过是一些数据和访问这些数据方法的容器,那么结论就不是那么回事了。

最后一个条件是,将外在数据分离出去以后,独一无二的对象数目相对较少。最理想的情况是只存在一个独一无二的对象,比如日历和工具提示那俩个实例。尽管并非任何时候都能把对象实例数目减少到一个,但是应该尽量减少。

能实现享元模式的步骤:

将所有外在的数据从目标剥离。具体做法就是尽可能多的删除该类的属性,所删除的应该是哪种因实例而异的属性。构造函数的参数也要这样处理。这些参数应该被添加到该类的各个方法。这些外在数据现在不再保持在类的内部,而是由管理器提供给类的方法,经过这样的处理后,目标类应该具有与之前一样的功能,唯一的区别就是数据的来源发生了变化。

创建一个用来控制该类的实例化工厂。这个工厂应该掌握该类所有已创建出来的独一无二的实例。具体做法之一是用一个对象字面量来保存每一个这类对象的引用,并以用来生成这些对象的参数的唯一性组合作为他们的索引。这样一来,每次要求工厂提供一个对象时,它会检查那个对象字面量,看看以前是否请求过这个对象,如果是,那么只要返回那个现有对象就行。否则他会创建一个新对象并将其保存在那个对象字面量中,然后返回这个对象。另一种做法称为对象池,这种技术用数组来保存所创建的对象引用。它适合于注重可用对象的数量而不是那些单独配置的实例的场合。这种技术用来将所实例化的对象数目维持在最低值,工厂会处理根据内在数据创建对象的所有事宜。

创建一个用来保存外在数据的管理器。该管理器负责控制处理外在数据的各种事宜。在实施优化之前,你需要一个目标类的实例,你会把所有数据传给构造函数以创建其新实例。而现在要是需要一个实例,你会调用管理器的某个方法,把所有数据都提供给它。这个方法会分辨内在数据和外在数据,它把内在数据提供给工厂对象以创建一个对象,或者已经存在这样一个对象的话,则重用该对象。外在数据则被保存在管理器内的一个数据结构中,管理器随后会根据需要将这些数据提供给共享对象的方法,其效果就如同该类有许多实例一样。

享元模式的利弊:

享元模式可以把网页资源负荷降低一个数量级。同时这种节省模式不需要大量修改代码。在创建了管理器、工厂和享元之后,你需要对代码进行的修改只不过是从直接实例化目标元素改为调用这个管理器对象的某个方法。

享元模式只不过是一种优化模式。其作用是在满足一系列严格条件的前提下提高代码的运行效率。这种模式在优化代码的同时,也提高了其复杂程度,这给调试和维护带来困难。

享元模式纯粹是一种优化模式,适合的是系统资源已经用的差不多而且明显需要进行某种优化的这样一类场合。这正是其利大于弊的时候。