本文深入解析JavaScript模块化开发中的核心概念。通过计数器对象示例,对比JSON风格与闭包封装的差异,阐述如何利用闭包保护内部状态。同时详解原型链机制,展示如何将简单操作置于构造函数内,复杂逻辑移至原型,实现高效的数据封装与继承。
JavaScript模块化概述
尽管 JavaScript 早期设计较为随意,但随着浏览器功能日益强大,这门语言在应对复杂逻辑时,必须转向模块化开发。模块化的核心在于封装,即隐藏内部实现细节,仅暴露必要的接口供外部调用。闭包作为 JavaScript 实现模块封装的关键机制,往往是初学者的难点。作者通过封装一个简单对象的过程,分享了对这一概念的深入理解。
我们的目标是构建一个名为 ticker 的计数器对象,该对象内部维护一个数值 n。用户操作允许增加计数(n 加 1),但严禁直接修改或减少 n 的值。同时,系统需要提供查询当前 n 值的功能。
JSON风格模块化的缺陷
JSON 风格的模块化虽然直观,但在封装性上存在明显缺陷。
一种常见的实现方式如下所示:
代码如下:
var ticker = {
n:0,
tick:function(){
this.n++;
},
};
这种写法直观且有效:调用 ticker.tick() 增加计数,访问 ticker.n 查询数值。然而,其致命缺陷在于缺乏封装性。使用者可以随意修改 n 的值,例如执行 ticker.n-- 或 ticker.n=-1。此时,n 和 tick() 虽看似 ticker 的成员,但实际访问权限与全局变量 ticker 相同,未实现真正的数据隐藏。这种方式的封装性甚至不如更拙劣的实现,尽管对于简单应用而言,这种程度的暴露或许尚可接受。
代码如下:
var ticker = {};
var tickerN = 0;
var tickerTick = function(){
tickerN++;
}
tickerTick();
值得注意的是,在 tick() 函数中访问 this.n,并非因为 n 是 ticker 对象的成员属性,而是因为在调用 tick() 时,this 指向了 ticker 对象。实际上,此处直接使用 ticker.n 更为恰当。如果 tick() 被其他对象调用,例如:
代码如下:
var func = ticker.tick;
func();
此时,函数执行的上下文变为 window 对象,尝试访问 window.n 将导致错误。
实际上,这种“门户大开”型的模块化方式,通常用于组织 JSON 风格的数据,而非程序。例如,可将下述 JSON 对象传入 ticker 的某函数,以设定 ticker 从 100 开始计数,每次递增 2 。
代码如下:
var config = {
nStart:100,
step:2
}
闭包与数据封装
作用域链与闭包机制 上述代码已支持通过 config 参数自定义 ticker 的行为。
代码如下:
function ticker(config){
var n = config.nStart;
function tick(){
n += config.step;
}
}
console.log(ticker.n); // ->undefined
读者可能会疑惑,为何 ticker 从对象变成了函数?这是因为 JavaScript 中只有函数拥有独立的作用域,外部无法直接访问函数内部的变量。在 ticker() 外部访问 ticker.n 会返回 undefined,而在 tick() 内部访问 n 则正常。从 tick() 到 ticker() 再到全局作用域,构成了 JavaScript 的“作用域链”。
然而,新的问题出现了:如何调用内部的 tick() 函数?ticker() 的作用域将 tick() 隐藏了起来。解决此问题有两种方案:
•1)将需要调用的方法作为返回值,例如将递增 n 的方法作为 ticker() 的返回值; •2)设定外层作用域的变量,例如在 ticker() 中定义 getN 函数。
代码如下:
var getN;
function ticker(config){
var n = config.nStart;
getN = function(){
return n;
};
return function(){
n += config.step;
};
}
var tick = ticker({nStart:100,step:2});
tick();
console.log(getN()); // ->102
此时,变量 n 被包裹在“闭包”中。虽然无法从 ticker() 外部直接访问 n,但可以通过返回的两个方法(tick 和 getN)来观察或修改它。
对比前文代码,ticker() 执行后,n 和 tick() 随即被销毁,直到下次调用才重新创建;而在本例中,ticker() 执行后,n 并未销毁,因为 tick() 和 getN() 仍可能访问或改变它,浏览器会维持 n 的状态。我对“闭包”的理解是:一种机制,用于保证处于函数作用域内、函数执行结束后仍需维持且可能被其他方式访问的变量不被销毁。
但仍有疑虑:如果需要创建多个功能相同的 ticker 对象(如 ticker1 和 ticker2),该怎么办?难道要重复编写 ticker() 函数吗?
构造函数与实例化
new 运算符与构造函数 当使用 new 运算符调用函数时,会创建一个新的对象,并绑定该对象来调用此函数。在作者的理解中,以下代码中 t1 和 t2 的构造过程本质相同。
代码如下:
function myClass(){}
var t1 = new myClass();
var t2 = {};
t2.func = myClass;
t2.func();
t2.func = undefined;
t1 和 t2 均为新构造的对象,myClass() 即为此处的构造函数。类似地,我们可以重构 ticker()。
代码如下:
function TICKER(config){
var n = config.nStart;
this.getN = function(){
return n;
};
this.tick = function(){
n += config.step;
}
}
var ticker1 = new TICKER({nStart:100,step:2});
ticker1.tick();
console.log(ticker1.getN()); // ->102
var ticker2 = new TICKER({nStart:20,step:3});
ticker2.tick();
ticker2.tick();
console.log(ticker2.getN()); // ->26
按照惯例,构造函数名称首字母大写。需注意,TICKER() 本质上仍是函数(尽管函数也是对象),闭包机制依然有效,我们无法直接访问 ticker1.n。
原型链与继承机制
原型 prototype 与继承 上述 TICKER() 实现仍存在缺陷:ticker1.tick() 和 ticker2.tick() 相互独立。每次使用 new 调用 TICKER(),都会生成新对象及新的函数绑定,导致内存空间重复开辟以存储 tick() 及其变量,这并非理想状态。我们期望 ticker1.tick 和 ticker2.tick 指向同一个函数对象。
为此,需要引入原型机制。
在 JavaScript 中,除 Object 外,其他对象均具有 prototype 属性,指向另一个对象。该“另一个对象”也有其原型,形成原型链,最终指向 Object 对象。当调用某对象的方法时,若该对象未定义该方法,则沿原型链向上查找,直至 Object 对象。
函数也是对象,因此函数拥有原型对象。当函数声明时(即定义函数对象时),会生成一个新对象,其 prototype 属性指向 Object 对象,且该对象的 constructor 属性指向函数本身。
通过构造函数创建的新对象,其原型指向构造函数的原型对象。因此,我们可以在构造函数的原型上添加函数,这些函数不再依赖于具体实例(如 ticker1 或 ticker2),而是依赖于 TICKER 构造函数本身。
初学者可能会尝试以下写法:
代码如下:
function TICKER(config){
var n = config.nStart;
}
TICKER.prototype.getN = function{
// attention : invalid implementation
return n;
};
TICKER.prototype.tick = function{
// attention : invalid implementation
n += config.step;
};
请注意,这是无效的实现。原型对象的方法无法访问闭包中的内容(即变量 n)。TICKER() 运行结束后,n 会被浏览器销毁。为了访问闭包内容,对象必须包含依赖于实例的简洁方法,用于访问闭包数据,然后在 prototype 上定义复杂的公有方法来实现具体逻辑。实际上,示例中的 tick() 方法已足够简洁,应保留在 TICKER 构造函数中。下面实现一个更复杂的方法 tickTimes(),允许调用者指定调用 tick() 的次数。
代码如下:
function TICKER(config){
var n = config.nStart;
this.getN = function(){
return n;
};
this.tick = function(){
n += config.step;
};
}
TICKER.prototype.tickTimes = function(n){
while(n>0){
this.tick();
n--;
}
};
var ticker1 = new TICKER({nStart:100,step:2});
ticker1.tick();
console.log(ticker1.getN()); // ->102
var ticker2 = new TICKER({nStart:20,step:3});
ticker2.tickTimes(2);
console.log(ticker2.getN()); // ->26
此时的 TICKER 设计较为完善。它封装了变量 n,外部无法直接修改;复杂函数 tickTimes() 定义在原型上,通过调用实例中的小函数来操作对象数据。
因此,为维持对象封装性,建议将对数据的操作解耦为尽可能小的单元函数,在构造函数中定义为依赖于实例的(常称“私有”方法),而将复杂逻辑实现在原型上(即“公有”方法)。
最后补充关于继承的内容。实际上,在原型上定义函数时,已在使用继承机制。JavaScript 的继承比 C++ 更简单(或称简陋)。在 C++ 中,我们可能定义 animal 类,再定义 bird 类继承 animal 类;但在 JavaScript 中,我们讨论的是另一种继承:定义一个函数 TICKER(),然后实例化 ticker1 和 ticker2 对象。在 C++ 中这仅是实例化,但在 JavaScript 中视为继承。
JavaScript 不支持传统的类概念,浏览器仅关注当前存在的对象,而不关心这些对象属于哪个 class 或应具备何种结构。在示例中,TICKER() 是函数对象,可赋值(TICKER=1)或删除(TICKER=undefined),但正因为存在通过 new 调用它而来的 ticker1 和 ticker2,TICKER() 便充当了构造函数的角色,而 TICKER.prototype 对象则充当了类的角色。
以上即为 JavaScript 模块化的主要方法。希望这些内容对初学者有所帮助。如有不当之处,欢迎指正。
作者:一叶斋主人 出处:www.cnblogs.com/yiyezhai