如何编写可维护的面向对象JavaScript代码

本文探讨如何在JavaScript中编写高效、可维护的代码,包括面向对象编程、实例化、原型继承、公有和私有变量管理、参数列表设计、代码嵌入等方面,旨在提高代码质量,降低垃圾回收压力,提升游戏流畅性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

如何编写可维护的面向对象JavaScript代码

 英文原文:How to Write Maintainable OO JavaScript Code

  能够写出可维护的面向对象 JavaScript 代码不仅可以节约金钱,还能让你很受欢迎。不信?有可能你自己或者其他什么人有一天会回来重用你的代码。如果能尽量让这个经历不那么痛苦,就可以节省不少时间。地球人都知道,时间就是金钱。同样的,你也会因为帮某人省去了头疼的过程而获得他的偏爱。但是,在开始探索如何编写可维护的面向对象 JavaScript 代码之前,我们先来快速看看什么是面向对象。如果已经了解面向对象的概念了,就可以直接跳过下一节。

  什么是面向对象?

  面向对象编程主要通过代码代表现实世界中的实质对象。要创建对象,首先需要写一个“类”来定义。 类几乎可以代表所有的东西:账户,员工,导航菜单,汽车,植物,广告,饮料,等等。而每次要创建对象的时候,就从类实例化一个对象。换句话说,就是创建类的实例做为对象。事实上,通常处理一个以上的同类事物时就会使用到对象。另外,只需要简单的函数式程序就可以做的很好。对象实质上是数据的容器。因此在一个 employee 对象中,你可能要储存员工号,姓名,入职日期,职称,工资,资历,等等。对象也包括处理数据的函数(也叫做“方法”)。方法被用作媒介来确保数据的完整性,以及在储存之前对数据进行转换。例如,方法可以接收任意格式的日期然后在储存之前将其转化成标准化格式。最后,类还可以继承其他的类。继承可以让你在不同类中重复使用相同代码。例如,银行账户和音像店账户都可以继承一个基本的账户类,里面包括个人信息,开户日期,分部信息,等等。然后每个都可以定义自己的交易或者借款处理等数据结构和方法。

  警告:JavaScript 面向对象是不一样的

  在上一节中,概述了经典的面向对象编程的基本知识。说经典是因为 JavaScript 并不遵循这些规则。相反地,JavaScript 的类是写成函数的样子,而继承则是通过原型实现的。原型继承基本上意味着使用原型属性来实现对象的继承,而不是从类继承类。

  对象的实例化

  以下是 JavaScript 中对象实例化的例子:

// 定义 Employee 类
function Employee (num, fname, lname) {
this.getFullName = function
() {
return
fname + " " + lname;
}
};

// 实例化 Employee 对象

var john = new Employee ("4815162342", "John", "Doe");
alert ("The employee's full name is " + john.getFullName ());

  在这里,有三个重点需要注意:

  1. “class”函数名的第一个字母要大写。这表明该函数的目的是被实例化而不是像一般函数一样被调用。

  2. 在实例化的时候使用了 new 操作符。如果省略掉 new 而仅仅调用函数则会产生很多问题。

  3. 因为 getFullName 指定给 this 操作符了,所以是公共可用的,但是 fname 和 lname 则不是。由 Employee 函数产生的闭包给了 getFullName 到 fname 和 lname 的入口,但同时对于其他类仍然是私有的。

  原型继承

  下面是 JavaScript 中原型继承的例子:

// 定义 Human 类
function Human () {
this.setName = function
(fname, lname) {
this
.fname = fname;
this
.lname = lname;
}
this.getFullName = function
() {
return this.fname + " " + this
.lname;
}
}

// 定义 Employee 类

function Employee (num) {
this.getNum = function
() {
return
num;
}
};

//让 Employee 继承 Human 类

Employee.prototype = new Human ();

// 实例化 Employee 对象

var john = new Employee ("4815162342");
john.setName ("John", "Doe");
alert (john.getFullName () + "'s employee number is " + john.getNum ());

  这一次,创建的 Human 类包含人类的一切共有属性——我也将 fname 和 lname 放进去了,因为不仅仅是员工才有名字,所有人都有名字。然后将 Human 对象赋值给它的 prototype 属性。

  通过继承实现代码重用

  在前面的例子中,原来的 Employee 类被分解成两个部分。所有的人类通用属性被移到了 Human 类中,然后让 Employee 继承 Human。这样的话,Human 里面的属性就可以被其他的对象使用,例如 Student(学生),Client(顾客),Citizen(公民),Visitor(游客),等等。现在你可能注意到了,这是分割和重用代码很好的方式。处理 Human 对象时,只需要继承 Human 来使用已存在的属性,而不需要对每种不同的对象都重新一一创建。除此以外,如果要添加一个“中间名字”的属性,只需要加一次,那些继承了 Human 类的就可以立马使用了。反而言之,如果我们只是想要给一个对象加“中间名字”的属性,我们就直接加在那个对象里面,而不需要在 Human 类里面加。

  Public(公有的)和 Private(私有的)

  接下来的主题,我想谈谈类中的公有和私有变量。根据对象中处理数据的方式不同,数据会被处理为私有的或者公有的。私有属性并不一定意味着其他人无法访问。可能只是某个方法需要用到。

  只读

  有时,你只是想要在创建对象的时候能有一个值。一旦创建,就不想要其他人再改变这个值。为了做到这点,可以创建一个私有变量,在实例化的时候给它赋值。

function Animal (type) {
var
data = [];
data['type'] = type;
this.getType = function
() {
return
data['type'];
}
}

var fluffy = new
Animal ('dog');
fluffy.getType ();
//
返回 'dog'

  在这个例子中,Animal 类中创建了一个本地数组 data。当 Animal 对象被实例化时,传递了一个 type 的值并将该值放置在 data 数组中。因为它是私有的,所以该值无法被覆盖(Animal 函数定义了它的范围)。一旦对象被实例化了,读取 type 值的唯一方式是调用 getType 方法。因为 getType 是在 Animal 中定义的,因此凭借 Animal 产生的闭包,getType 可以进到 data 中。这样的话,虽可以读到对象的类型却无法改变。

  有一点非常重要,就是当对象被继承时,“只读”技术就无法运用。在执行继承后,每个实例化的对象都会共享那些只读变量并覆盖其值。最简单的解决办法是将类中的只读变量转换成公共变量。但是你必须保持它们是私有的,你可以使用 Philippe 在评论中提到的技术。

  Public(公有)

  当然也有些时候你想要任意读写某个属性的值。要实现这一点,需要使用 this 操作符。

function Animal () {
this
.mood = '';
}

var fluffy = new
Animal ();
fluffy.mood = 'happy';
fluffy.mood;
//
返回 'happy'

  这次 Animal 类公开了一个叫 mood 的属性,可以被随意读写。同样地,你还可以将函数指定给公有的属性,例如之前例子中的 getType 函数。只是要注意不要给 getType 赋值,不然的话你会毁了它的。

  完全私有

  最后,可能你发现你需要一个完全私有化的本地变量。这样的话,你可以使用与第一个例子中一样的模式而不需要创建公有方法。

function Animal () {
var
secret = "You'll never know!"
}

var fluffy = new Animal ();

  写灵活的 API

  既然我们已经谈到类的创建,为了保持与产品需求变化同步,我们需要保持代码不过时。如果你已经做过某些项目或者是长期维护过某个产品,那么你就应该知道需求是变化的。这是一个不争的事实。如果你不是这么想的话,那么你的代码在还没有写之前就将注定荒废。可能你突然就需要将选项卡中的内容弄成动画形式,或是需要通过 Ajax 调用来获取数据。尽管准确预测未来是不大可能,但是却完全可以将代码写灵活以备将来不时之需。

  Saner 参数列表

  在设计参数列表的时候可以让代码有前瞻性。参数列表是让别人实现你代码的主要接触点,如果没有设计好的话,是会很有问题的。你应该避免下面这样的参数列表:

function Person (employeeId, fname, lname, tel, fax, email, email2, dob) {
};

  这个类十分脆弱。如果在你发布代码后想要添加一个中间名参数,因为顺序问题,你不得不在列表的最后往上加。这让工作变得尴尬。如果你没有为每个参数赋值的话,将会十分困难。例如:

var ara = new Person (1234, "Ara", "Pehlivanian", "514-555-1234", null, null, null, 
"1976-05-17");

  操作参数列表更整洁也更灵活的方式是使用这个模式:

function Person (employeeId, data) {
};

  有第一个参数因为这是必需的。剩下的就混在对象的里面,这样才可以灵活运用。

var ara = new Person (1234, {
fname: "Ara",
lname: "Pehlivanian",
tel: "514-555-1234",
dob: "1976-05-17"
});

  这个模式的漂亮之处在于它即方便阅读又高度灵活。注意到 fax, email 和 email2 完全被忽略了。不仅如此,对象是没有特定顺序的,因此哪里方便就在哪里添加一个中间名参数是非常容易的:

var ara = new Person (1234, {
fname: "Ara",
mname: "Chris",
lname: "Pehlivanian",
tel: "514-555-1234",
dob: "1976-05-17"
});

  类里面的代码不重要,因为里面的值可以通过索引来访问:

function Person (employeeId, data) {
this.fname = data['fname'];
};

  如果 data['fname'] 返回一个值,那么他就被设定好了。否则的话,没被设定好,也没有什么损失。

  让代码可嵌入

  随着时间流逝,产品需求可能对你类的行为有更多的要求。而该行为却与你类的核心功能没有半毛钱关系。也有可能是类的唯一一种实现,好比在一个选项卡的面板获取另一个选项卡的外部数据时,将这个选项卡面板中的内容变灰。你可能想把这些功能放在类的里面,但是它们不属于那里。选项卡条的责任在于管理选项卡。动画和获取数据是完全不同的两码事,也必须与选项卡条的代码分开。唯一一个让你的选项卡条不过时而又将那些额外的功能排除在外的方法是,允许将行为嵌入到代码当中。换句话说,通过创建事件,让它们在你的代码中与关键时刻挂钩,例如 onTabChange, afterTabChange, onShowPanel, afterShowPanel 等等。那样的话,他们可以轻易地与你的 onShowPanel 事件挂钩,写一个将面板内容变灰的处理器,这样就皆大欢喜了。JavaScript 库让你可以足够容易地做到这一点,但是你自己写也不那么难。下面是使用 YUI 3的一个例子。

<script type="text/javascript" src="http://yui.yahooapis.com/combo?3.2.0/build/
yui/yui-min.js"></script>
<script type="text/javascript">
YUI () ..use ('event',
function
(Y) {
function
TabStrip () {
this.showPanel = function
() {
this
.fire ('onShowPanel');
// 展现面板的代码

this
.fire ('afterShowPanel');
};
};

// 让 TabStrip 有能力激发常用事件

Y.augment (TabStrip, Y.EventTarget);
var ts = new
TabStrip ();
// 给 TabStrip 的这个实例创建常用时间处理器

ts.on ('onShowPanel', function () {
//在展示面板之前要做的事

});
ts.on ('onShowPanel',
function
() {
//在展示面板之前要做的其他事

});
ts.on ('afterShowPanel',
function
() {
//在展示面板之后要做的事

});
ts.showPanel ();
});
</script>

  这个例子有一个简单的 TabStrip 类,其中有个 showPanel 方法。这个方法激发两个事件,onShowPanel 和 afterShowPanel。这个能力是通过用Y.EventTarget 扩大类来实现的。一旦做成,我们就实例化了一个 TabStrip 对象,并将一堆处理器都分配给它。这是用来处理实例的唯一行为而又能避免混乱当前类的常用代码。

  总结

  如果你打算重用代码,无论是在同一网页,同一网站还是跨项目操作,考虑一下在类里面将其打包和组织起来。面向对象 JavaScript 很自然地帮助实现更好的代码组织以及代码重用。除此以外,有点远见的你可以确保代码具有足够的灵活性,可以在你写完代码后持续使用很长时间。编写可重用的不过时 JavaScript 代码可以节省你,你的团队还有你公司的时间和金钱。这绝对能让你大受欢迎。

 

减少javascript垃圾回收[译]

  英文链接: How to write low garbage real-time Javascript

  对于用javascript开发的HTML5游戏来说,垃圾回收暂停会严重阻碍游戏的流畅体验。Javascript并没有提供显式的内存管理机制,这就意味着你能创建对象但是并不能释放他们。浏览器迟早需要清理这些对象,一旦开始清理,就意味着当前执行的任务必须暂停,浏览器必须计算出哪一部分内存正在使用中,从而释放其他没有使用的内容所占用的内存空间。

  这篇博客将会深入研究避免过度垃圾回收的技术细节,而这也正是用Construct 2提供的Javascript SDK开发插件或特性的开发人员正需要了解的。

  浏览器开发者在实现浏览器的过程中,就使用了许多技术来减少垃圾回收暂停,但是如果你的代码创建了非常多的内存垃圾,浏览器仍然不得不暂停当前执行的任务,并且陷入内存清理的工作中。

  随着内存对象的不断创建,浏览器将会间歇性的执行内存清理,清理过程如下“锯齿形内存使用统计图”所示。下图就是在游戏Space Blaster运行过程中Chrome的内存使用情况图。

图1 进行javascript游戏时的锯齿形内存使用统计图,这真实的反映了大多数情况下javascript的内存使用情况(除了内存泄露的情况)

  另外,一个以60帧每秒的速度运行的游戏,每一帧的渲染之间只有16毫秒,但是一次垃圾回收过程则经常需要占用100毫秒或者更长的时间。这就导致了一个非常容易察觉的暂停,或者更坏的情况,那就是非常卡的游戏体验。

  因此,对于游戏引擎这种实时性要求很高的javascript代码,在每一帧中尽量减少创建的对象是减少垃圾回收的一个重要解决方案。

  由于大多数看上去没有问题的javascript代码,很可能会产生内存垃圾,而这些代码必须从每一帧都需要运行的代码中移除掉。由于问题代码的隐蔽性,导致通过改善代码质量来减少垃圾回收变得异常困难。

  在Construct2引擎的per-tick引擎中,为避免过多的垃圾回收,我们进行了大量的工作,并且取得了可喜的进展。虽然就像图1所示的那样,仍然有一小部分对象创建,迫使Chrome必须每隔几秒钟就进行一次垃圾清理。

  虽然每次垃圾清理过程中,都只清理了少数内存,比起大量内存清理引起的大锯齿,这或许并不能引起足够的重视。但是这也是可以接受的,因为小规模的垃圾回收速度更快,并且收集的时间是随机的,用户也不容易觉察到。再说了,避免新的内存分配,确实是一件异常困难的事情哪!

  话虽这样说,但是对于第三方插件和特性开发者来说,遵循下面介绍的这些规则和技术,也是非常必要的。因为一个创建了大量垃圾的写的很烂的插件,会使得游戏变得很卡,即使Construct 2主引擎产生的垃圾很少,也经不起这么折腾啊!

  简单实用的技术

  首先,最明显的,new关键字就意味着一次内存分配,例如 new Foo()。最好的处理方法是:在初始化的时候新建对象,然后在后续过程中尽量多的重用这些创建好的对象。

  另外还有以下三种内存分配表达式(可能不像new关键字那么明显了):

  – {} (创建一个新对象)

  – [] (创建一个新数组)

  – function() {…} (创建一个新的方法,注意:新建方法也会导致垃圾收集!!)

  1、对象object优化

  为了最大限度的实现对象的重用,应该像避使用new语句一样避免使用{}来新建对象。

  {“foo”:”bar”}这种方式新建的带属性的对象,常常作为方法的返回值来使用,可是这将会导致过多的内存创建,因此最好的解决办法是:每一次函数调用完成之后,将需要返回的数据放入一个全局的对象中,并返回此全局对象。如果使用这种方式,就意味着每一次方法调用都会导致全局对象内容的修改,这有可能会导致错误的发生。因此,一定要对此全局对象的使用进行详细的注释和说明。

  有一种方式能够保证对象(确保对象prototype上没有属性)的重复利用,那就是遍历此对象的所有属性,并逐个删除,最终将对象清理为一个空对象。

  cr.wipe(obj)方法就是为此功能而生,代码如下:

// 删除obj对象的所有属性,高效的将obj转化为一个崭新的对象! 
cr.wipe =
function
(obj) {
for (var p in
obj) {
if
(obj.hasOwnProperty(p))
delete
obj[p];
}
};

  有些时候,你可以使用cr.wipe(obj)方法清理对象,再为obj添加新的属性,就可以达到重复利用对象的目的。虽然通过清空一个对象来获取“新对象”的做法,比简单的通过{}来创建对象要耗时一些,但是在实时性要求很高的代码中,这一点短暂的时间消耗,将会有效的减少垃圾堆积,并且最终避免垃圾回收暂停,这是非常值得的!

  2、数组array优化

  将[]赋值给一个数组对象,是清空数组的捷径(例如: arr = [];),但是需要注意的是,这种方式又创建了一个新的空对象,并且将原来的数组对象变成了一小片内存垃圾!实际上,将数组长度赋值为0(arr..length = 0)也能达到清空数组的目的,并且同时能实现数组重用,减少内存垃圾的产生。

  3、方法function优化

  方法一般都是在初始化的时候创建,并且此后很少在运行时进行动态内存分配,这就使得导致内存垃圾产生的方法,找起来就不是那么容易了。但是从另一角度来说,这更便于我们寻找了,因为只要是动态创建方法的地方,就有可能产生内存垃圾。例如:将方法作为返回值,就是一个动态创建方法的实例。

  在游戏的主循环中,setTimeout或requestAnimationFrame来调用一个成员方法是很常见的,例如:

setTimeout(
(
function
(self) {
return function
() {
self.tick();
};
})(
this), 16)

  每过16毫秒调用一次this.tick(),嗯,乍一看似乎没什么问题,但是仔细一琢磨,每一次调用都返回了一个新的方法对象,这就导致了大量的方法对象垃圾!

  为了解决这个问题,可以将作为返回值的方法保存起来,例如:

// at startup
this.tickFunc = (
function
(self) {
return function
() {
self.tick();
};
}
)(
this);
// in the tick() function

setTimeout(this.tickFunc, 16);

  相比于每次都新建一个方法对象,这种方式在每一帧当中重用了相同的方法对象。这种方式的优势是显而易见的,而这种思想也可以应用在任何以方法为返回值或者在运行时创建方法的情况当中。

  高级技术

  从根本上来说,javascript本身就是围绕着垃圾收集来设计的。随着我们工作的进行,避免内存垃圾变得越来越困难。因为很多方便实用的Javascript库方法也会产生一些新的对象。对于这些库方法产生的垃圾,我们束手无策,只能重新翻看文档,并且检查方法的返回值。例如,数组的slice方法返回一个新的数组(在不修改原数组的基础上,截取出一部分作为新数组),字符串的substr方法返回一个新的字符串(在不修改原字符串的基础上,截取出一部分字符串作为返回值)等等。

  调用这些库方法,将会创建内存垃圾,而你能做的,只有避免调用这些方法,或者用不创建系统垃圾的方式重写这些方法(有点极端啦~)。

  例如,在Construct 2引擎中,从数组中利用下标来删除一个元素,是经常进行的操作。最初我们是用下面这种方式来实现的:

var sliced = arr.slice(index + 1); 
arr.length = index;
arr.push.apply(arr, sliced);

  然而,slice方法会返回一个新的数组对象(数组中的元素是原数组中删掉的部分),并且会通过arr.push.apply方法将元素重新复制回原数组,但是在此操作之后,该数组就成为了一片内存垃圾。由于这是我们引擎中的垃圾产生的热点代码(使用频率非常很高),因此我们利用了迭代的方式重写了上述代码:

for (var i = index, len = arr.length – 1; i < len; i++) 
  arr[i] = arr[i + 1];
  arr.length = len;

  显然,重写大量的库函数是非常痛苦的,因此你必须仔细权衡方法的易用性和内存垃圾产生情况。如果产生大量内存垃圾的方法在动画的每一帧中被多次调用,你可能就会兴高采烈的重写库函数啦。

  在递归函数中,通过{}构造空对象,并在递归过程中传递数据,虽然是很方便的。但是更好的方式是:利用一个单独的数组对象作为堆栈,在递归过程中对数组进行push和pop操作。更进一步,不要调用array的pop方法(pop将会使得array的最后一个元素将会变成内存垃圾),而应该使用一个索引来记录数组的最后一个元素的位置,在pop时简单的将索引减一即可;类似的,将索引加1来代替array的push操作,只有当索引对应的元素不存在时,才执行真正的push为数组加入一个新元素。

  另外,在任何时候,都应该避免使用向量对象(例如:包含x和y属性的vector2对象)。有些方法将向量对象作为方法返回值,既可以支持返回值的再次修改,又能够将需要的属性一次性返回,使用起来非常方便。但是有时候在一帧动画中,创建了成百上千个这样的向量对象,从而导致严重的垃圾回收性能问题,也是非常常见的。因此最好将这些方法分离成具有独立职责的功能个体,例如:利用getX()和getY()方法(返回具体数据)代替getPosition()方法(返回一个vector2对象)

  有时候,有的库是一个生产垃圾的噩梦,人人趋而避之,可是你或许就偏偏强烈依赖于这样的库。Box2Dweb就是一个典型的例子:这个库在每一帧都会产生成百上千个b2Vec2对象,持续向浏览器中注入内存垃圾,最终导致严重的垃圾收集暂停。针对这种情况,最好的解决办法就是创建一个重复利用的对象缓存,我们正在对一个修改后版本的Box2D进行测试,这个版本已经创建了对象缓存,并且它看上去能够有助于缓解(虽然并没有完全解决)垃圾收集暂停。Get和Free的源码请参见b2Vec2.js。

  新版的Box2D中存在一个被称为“自由缓存”(free cache)的数组,在任何涉及b2Vec2对象操作的地方都包含了对自由缓存的考虑。如果b2Vec2对象不再使用,则将此对象放置在自由缓存中;当需要新建b2Vec2对象是,如果自由缓存中存在对象,则重用这些对象,如果不存在,才会创建一个新的对象。这种方案并不完美,因为在我进行的一些测试中,只有一半的b2Vec2对象能被重复利用,但是这种方案,的的确确减少了垃圾回收的压力,并且有效的减少了垃圾回收暂停的频率。

  结论

  在Javascript中,彻底避免垃圾回收是非常困难的。垃圾回收机制与实时软件(例如:游戏)的实时性要求,从根本上就是对立的。

  但是,为了减少内存垃圾,我们还是可以对javascript代码进行彻底检查,有些代码中存在明显的产生过多内存垃圾的问题代码,这些正是我们需要检查并且完善的。

  我认为,只要我们投入更多的精力和关注,实现实时的、低垃圾收集的javascript应用还是很有可能的。毕竟,对于可交互性要求较高的游戏或应用来说,实时性和低垃圾收集,两者都是至关重要。

 
 
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值