资讯专栏INFORMATION COLUMN

编写高质量JavaScript代码之对象和原型

sourcenode / 568人阅读

摘要:类是由一个构造函数和一个关联的原型组成的一种设计模式。该模式的一个缺点是,为了让构造函数中的变量在使用它们的方法的作用域内,这些方法必须放置于实例对象中,这会导致方法副本的扩散。

参考书籍:《Effective JavaScript》

对象和原型 理解prototype、getPrototypeOf和__proto__之间的不同

原型包括三个独立但相关的访问器。

C.prototype用于建立由new C()创建的对象的原型。

Object.getPrototypeOf(obj)是ES5中用来获取obj对象的原型对象的标准方法。

obj.__proto__是获取obj对象的原型对象的非标准方法。

function User(name, passwordHash) {
    this.name = name;
    this.passwordHash = passwordHash;
}

User.prototype.toString = function () {
    return "[User " + this.name + "]";
};

User.prototype.checkPassword = function (password) {
    return hash(password) === this.passwordHash;
}

var u = new User("sfalken", "0ef33ae791068ec64b502d6cb0191387");

User函数带有一个默认的prototype属性,其包含一个开始几乎为空的对象。当我们使用new操作符创建User的实例时,产生的对象u得到了自动分配的原型对象,该原型对象被存储在User.prototype中。

Object.getPrototypeOf(u) === User.prototype; // true
u.__proto__ === User.prototype; // true

提示:

C.prototype属性是new C()创建的对象的原型。

Object.getPrototypeOf(obj)是ES5中检索对象原型的标准函数。

Obj.__proto__是检索对象原型的非标准函数。

类是由一个构造函数和一个关联的原型组成的一种设计模式。

使用Object.getPrototypeOf函数而不要使用__proto__属性

__proto__属性提供了Object.getPrototypeOf方法所不具备的额外能力,即修改对象原型链接的能力。这种能力会造成严重的影响,应当避免使用,原因如下:

可移植性:并不是所有的平台都支持改变对象原型的特性,所以无法编写可移植的代码。

性能问题:现代的JavaScript引擎痘深度优化了获取和设置对象属性的行为,如更改了对象的内部结构(如添加或删除该对象或其原型链中的对象的属性)会使一些优化失效。

可预测性:修改对象的原型链会影响对象的整个继承层次结构,在某些情况下这样的操作可能有用,但是保持继承层次结构的相对稳定是一个基本的准则。

可以使用ES5中的Object.create函数来创建一个具有自定义原型链的新对象。

提示:

始终不要修改对象的__proto__属性。

使用Object.create函数给新对象设置自定义的原型。

使构造函数与new操作符无关
function User(name, passwordHash) {
    this.name = name;
    this.passwordHash = passwordHash;
}

var u = User("baravelli", "d8b74df393528d51cd19980ae0aa028e");
u; // undefined
this.name; // baravelli
this.passwordHash; // d8b74df393528d51cd19980ae0aa028e

如果调用者忘记使用new关键字,该函数不但会返回无意义的undefined,而且会创建(如果这些全局变量已经存在则会修改)全局变量name和passwordHash。

如果将User函数定义为ES5的严格代码,那么它的接收者默认为undefined。

function User(name, passwordHash) {
    "use strict";

    this.name = name;
    this.passwordHash = passwordHash;
}

var u = User("baravelli", "d8b74df393528d51cd19980ae0aa028e"); // Uncaught TypeError: Cannot set property "name" of undefined

一个更为健壮的方式是提供一个不管怎么调用都工作如构造函数的函数。

function User(name, passwordHash) {
    if (!this instanceof User) {
        return new User(name, passwordHash);
    }

    this.name = name;
    this.passwordHash = passwordHash;
}

var x = User("baravelli", "d8b74df393528d51cd19980ae0aa028e"); 
var y = new User("baravelli", "d8b74df393528d51cd19980ae0aa028e"); 
x instanceof User; // true
y instanceof User; // true

上述模式的一个缺点是它需要额外的函数调用,且难适用于可变参数函数,因为没有一种模拟apply方法将可变参数函数作为构造函数调用的方式。

一种更为奇异的方式是利用ES5的Object.create函数。

function User(name, passwordHash) {
    var self = this instanceof User ? this : Object.create(User.prototype);

    self.name = name;
    self.passwordHash = passwordHash;

    return self;
}

Object.create需要一个原型对象作为参数,并返回一个继承自原型对象的新对象。

多亏了构造函数覆盖模式,使用new操作符调用上述User函数的行为与以函数调用它的行为是一样的,这能工作完全得益于JavaScript允许new表达式的结果可以被构造函数的显示return语句所覆盖

提示:

通过使用new操作符或Object.create方法在构造函数定义中调用自身使得该构造函数与调用语法无关。

当一个函数期望使用new操作符调用时,清晰地文档化该函数。

在原型中存储方法

JavaScript完全有可能不借助原型进行编程。

function User(name, passwordHash) {
    this.name = name;
    this.passwordHash = passwordHash;
    this.toString = function () {
        return "User " + this.name + "]";
    };
    this.checkPassword = function (password) {
        return hash(password) === this.passwordHash;
    }
} 

var u1 = new User(/* ... */);
var u2 = new User(/* ... */);
var u3 = new User(/* ... */);

上述代码中的每个实例都包含toString和checkPassword方法的副本,而不是通过原型共享这些方法。

将方法存储在原型,使其可以被所有的实例使用,而不需要存储方法实现的多个副本,也不需要给每个实例对象增加额外的属性。

同时,现代的JavaScript引擎深度优化了原型查找,所以将方法复制到实例对象并不一定保证查找的速度有明显的提升,而且实例方法比起原型方法肯定会占用更多的内存。

提示:

将方法存储在实例对象中会创建该函数的多个副本,因为每个实例对象都有一份副本。

将方法存储于原型中优于存储在实例对象中。

使用闭包存储私有数据

任意一段程序都可以简单地通过访问JavaScript对象的属性名来获取相应地对象属性,例如for in循环、ES5的Object.keys函数和Object.getOwnPropertyNames函数。

一些程序员使用命名规范给私有属性前置或后置一个下划线字符_。

然而实际上,一些程序需要更高程度的信息隐藏。

对于这种情形,JavaScript为信息隐藏提供了闭包。闭包将数据存储到封闭的变量中而不提供对这些变量的直接访问,获取闭包内部结构的唯一方式是该函数显式地提供获取它的途径。

利用这一特性在对象中存储真正的私有数据。不是将数据作为对象的属性来存储,而是在构造函数中以变量的方式存储它。

function User(name, passwordHash) {
    this.toString = function () {
        return "[User " + name + "]";
    };
    this.checkPassword = function (password) {
        return hash(password) === passwordHash;
    } 
}

上述代码的toString和checkPassword方法是以变量的方式来引用name和passwordHash变量的,而不是以this属性的方式来引用,User的实例不包含任何实例属性,因此外部的代码不能直接访问User实例的name和passwordHash变量。

该模式的一个缺点是,为了让构造函数中的变量在使用它们的方法的作用域内,这些方法必须放置于实例对象中,这会导致方法副本的扩散。

提示:

闭包变量是私有的,只能通过局部的引用获取。

将局部变量作为私有数据从而通过方法实现信息隐藏。

只将实例状态存储在实例对象中

一种错误的做法是不小心将每个实例的数据存储到了其原型中。

function Tree(x) {
    this.value = x;
}

Tree.prototype = {
    children: [], // should be instance state!
    addChild: function(x) {
        this.children.push(x);
    }
};

var left = new Tree(2);
left.addChild(1);
left.addChild(3);

var right = new Tree(6);
right.addChild(5);
right.addChild(7);

var top = new Tree(4);
top.addChild(left);
top.addChild(right);

top.children; // [1, 3, 5, 7, left, right]

每次调用addChild方法,都会将值添加到Tree.prototype.children数组中。

实现Tree类的正确方式是为每个实例对象创建一个多带带的children数组。

function Tree(x) {
    this.value = x;
    this.children = []; // instance state
}

Tree.prototype = {
    addChild: function(x) {
        this.children.push(x);
    }
};

一般情况下,任何不可变的数据可以被存储在原型中从而被安全地共享。有状态的数据原则上也可以存储在原型中,只要你真正想共享它。然而迄今为止,在原型对象中最常见的数据是方法,而每个实例的状态都存储在实例对象中。

提示:

共享可变数据可能会出问题,因为原型是被其所有的实例共享的。

将可变的实例状态存储在实例对象中。

认识到this变量的隐式绑定问题

编写一个简单的、可定制的读取CSV(逗号分隔型取值)数据的类。

function CSVReader(separators) {
    this.separators = separators || [","];
    this.regexp = new RegExp(this.separators.map(function (sep) {
        return "" + sep[0];
    }).join("|"));
}

实现一个简单的read方法可以分为两步来处理。第一步,将输入的字符串分为按行划分的数组。第二步,将数组的每一行再分为按单元格划分的数组。结果获得一个二维的字符串数组。

CSVReader.prototype.read = function (str) {
    var lines = str.trim().split(/
/);
    return lines.map(function (line) {
        return line.split(this.regexp);
    });
};

var reader = new CSVReader();
reader.read("a, b, c
d, e, f
"); // [["a, b, c"], ["d, e, f"]]

上述代码的bug是,传递给line.map的回调函数引用的this指向的是window,因此,this.regexp产生undefined值。

备注:"a, b, c".split(undefined)返回["a, b, c"]

幸运的是,数组的map方法可以传入一个可选的参数作为其回调函数的this绑定。

CSVReader.prototype.read = function (str) {
    var lines = str.trim().split(/
/);
    return lines.map(function (line) {
        return line.split(this.regexp);
    }, this);
};

var reader = new CSVReader();
reader.read("a, b, c
d, e, f
"); // [["a", "b", "c"], ["d", "e", "f"]]

但是,不是所有基于回调函数的API都考虑周全。另一种解决方案是使用词法作用域的变量来存储这个额外的外部this绑定的引用。

CSVReader.prototype.read = function (str) {
    var lines = str.trim().split(/
/);
    var self = this; // save a reference to outer this-binding
    return lines.map(function (line) {
        return line.split(this.regexp);
    });
};

var reader = new CSVReader();
reader.read("a, b, c
d, e, f
"); // [["a", "b", "c"], ["d", "e", "f"]]

在ES5的环境中,另一种有效的方法是使用回调函数的bind方法。

CSVReader.prototype.read = function (str) {
    var lines = str.trim().split(/
/);
    return lines.map(function (line) {
        return line.split(this.regexp);
    }.bind(this)); // bind to outer this-binding
};

var reader = new CSVReader();
reader.read("a, b, c
d, e, f
"); // [["a", "b", "c"], ["d", "e", "f"]]

提示:

this变量的作用域总是由其最近的封闭函数所确定。

使用一个局部变量(通常命名为self、me或that)使得this绑定对于内部函数是可用的。

在子类的构造函数中调用父类的构造函数

场景图(scene graph)是在可视化的程序中(如游戏或图形仿真场景)描述一个场景的对象集合。一个简单的场景包含了在该场景中的所有对象(称为角色),以及所有角色的预加载图像数据集,还包含一个底层图形显示的引用(通常被称为context)。

function Scene(context, width, height, images) {
    this.context = context;
    this.width = width;
    this.height = height;
    this.images = images;
    this.actors = [];
}

Scene.prototype.register = function (actor) {
    this.actors.push(actor);
};

Scene.prototype.unregister = function (actor) {
    var i = this.actors.indexOf(actor);
    
    if (i >= 0) {
        this.actors.splice(i, 1);
    }
};

Scene.prototype.draw = function () {
    this.context.clearRect(0, 0, this.width, this.height);
    
    for (var a = this.actors, i = 0, n = a.length; i < n; i++) {
        a[i].draw();
    }
};

场景中的所有角色都继承自基类Actor。

function Actor(scene, x, y) {
    this.scene = scene;
    this.x = x;
    this.y = y;
    scene.register(this);
}

Actor.prototype.moveTo = function (x, y) {
    this.x = x;
    this.y = y;
    this.scene.draw();
};

Actor.prototype.exit = function() {
    this.scene.unregister(this);
    this.scene.draw();
};

Actor.prototype.draw = function () {
    var image = this.scene.images[this.type];
    this.scene.context.drawImage(image, this.x, this.y);
};

Actor.prototype.width = function () {
    return this.scene.images[this.type].width;
};

Actor.prototype.height = function () {
    return this.scene.images[this.type].height;
};

我们将角色的特定类型实现为Actor的子类。例如,在街机游戏中太空飞船就会有一个拓展自Actor的SpaceShip类。

为了确保SpaceShip的实例能作为角色被正确地初始化,其构造函数必须显式地调用Actor的构造函数。通过将接收者绑定到该新对象来调用Actor可以达到此目的。

function SpaceShip(scene, x, y) {
    Actor.call(this, scene, x, y);
    this.points = 0;
}

调用Actor的构造函数能确保Actor创建的所有实例属性都被添加到了新对象(SpaceShip实例对象)中。为了使SpaceShip成为Actor的一个正确地子类,其原型必须继承自Actor.prototype。做这种拓展的最好的方式是使用ES5提供的Object.create方法。

SpaceShip.prototype = Object.create(Actor.prototype);

一旦创建了SpaceShip的原型对象,我们就可以向其添加所有的可被实例共享的属性。

SpaceShip.prototype.type = "spaceShip";

SpaceShip.prototype.scorePoint = function () {
    this.points++;
};

SpaceShip.prototype.left = function () {
    this.moveTo(Math.max(this.x - 10, 0), this.y);
};

SpaceShip.prototype.right = function () {
    var maxWidth = this.scene.width - this.width();
    this.moveTo(Math.min(this.x + 10, maxWidth), this.y);
};

提示:

在子类构造函数中显示地传入this作为显式地接收者调用父类构造函数。

使用Object.create函数来构造子类的原型对象以避免调用父类的构造函数。

不要重用父类的属性名
function Actor(scene, x, y) {
    this.scene = scene;
    this.x = x;
    this.y = y;
    this.id = ++Actor.nextID;
    scene.register(this);
}

Actor.nextID = 0;
function Alien(scene, x, y, direction, speed, strength) {
    Actor.call(this, scene, x, y);
    this.direction = direction;
    this.speed = speed;
    this.strength = strength;
    this.damage = 0;
    this.id = ++Alien.nextID; // conflicts with actor id!
}

Alien.nextID = 0;

Alien类与其父类Actor类都视图给实例属性id写数据。如果在继承体系中的两个类指向相同的属性名,那么它们指向的是同一个属性。

该例子显而易见的解决方法是对Actor标识数和Alien标识数使用不同的属性名。

function Actor(scene, x, y) {
    this.scene = scene;
    this.x = x;
    this.y = y;
    this.actorID = ++Actor.nextID; // distinct from alienID
    scene.register(this);
}

Actor.nextID = 0;

function Alien(scene, x, y, direction, speed, strength) {
    Actor.call(this, scene, x, y);
    this.direction = direction;
    this.speed = speed;
    this.strength = strength;
    this.damage = 0;
    this.alienID = ++Alien.nextID; // distinct from actorID
}

Alien.nextID = 0;

提示:

留意父类使用的所有属性名。

不要在子类中重用父类的属性名。

避免继承标准类

一个操作文件系统的库可能希望创建一个抽象的目录,该目录继承了数组的所有行为。

function Dir(path, entries) {
    this.path = path;
    for (var i = 0, n = entries.length; i < n; i++) {
        this[i] = entries[i];
    }
}

Dir.prototype = Object.create(Array.prototype); // extends Array

遗憾的是,这种方式破坏了数组的length属性的预期行为。

var dir = new Dir("/tmp/mysite", ["index.html", "script.js", "style.css"]);

dir.length; // 0

失败的原因是length属性只对在内部标记为“真正的”数组的特殊对象起作用。ECMAScript标准规定它是一个不可见的内部属性,称为[[Class]]。

数组对象(通过Array构造函数或[]语法创建)被加上了值为“Array”的[[Class]]属性,函数被加上了值为“Function”的[[Class]]属性。

事实证明,length的行为只被定义在内部属性[[Class]]的值为“Array”的特殊对象中。对于这些对象,JavaScript保持length属性与该对象的索引属性的数量同步。

但当我们拓展Array类时,子类的实例并不是通过new Array()或字面量[]语法创建的。所以,Dir的实例[[Class]]属性值为“Object”。

更好的实现是定义一个entries数组的实例属性。

function Dir(path, entries) {
    this.path = path;
    this.entries = entries; // array property
}

Dir.prototype.forEach = function (f, thisArg) {
    if (typeof thisArg === "undefined") {
        thisArg = this;
    }

    this.entries.forEach(f, thisArg);
};

提示:

继承标准类往往由于一些特殊的内部属性(如[[Class]])而被破坏。

使用属性委托优于继承标准类。

将原型视为实现细节

原型是一种对象行为的实现细节。

JavaScript提供了便利的内省机制(introspection mechanisms)来检查对象的细节。Object.prototype.hasOwnProperty方法确定一个属性是否为对象“自己的”属性(即一个实例属性),而完全忽略原型继承机构。Object.getPrototypeOf__proto__特性允许程序员遍历对象的原型链并多带带查询其原型对象。

检查实现细节(即使没有修改它们)也会在程序的组件之间创建依赖。如果对象的生产者修改了实现细节,那么依赖于这些对象的使用者就会被破坏。

提示:

对象是接口,原型是实现。

避免检查你无法控制的对象的原型结构。

避免检查实现在你无法控制的对象内部的属性。

避免使用轻率的猴子补丁

由于对象共享原型,因此每一个对象都可以增加、删除或修改原型的属性,这个有争议的实践通常被称为猴子补丁(monkey-patching)。

猴子补丁的吸引力在于它的强大,数组缺少一个有用的方法,你自己就可以增加它。

Array.prototype.split = function (i) { // alternative #1
    return [this.slice(0, 1), this.slice(i)];
};

但是当多个库以不兼容的方式给同一个原型打猴子补丁时,问题就出现了。

Array.prototype.split = function (i) { // alternative #2
    var i = Math.floor(this.length / 2);
    return [this.slice(0, 1), this.slice(i)];
};

现在,任一对数组split方法的使用都大约有50%的机会被破坏。

一个方法可以将这些修改置于一个函数中,用户可以选择调用或忽略。

function addArrayMethods() {
    Array.prototype.split = function (i) {
        return [this.slice(0, 1), this.slice(i)];
    }
}

尽管猴子补丁很危险,但是有一种特别可靠而且有价值的使用场景:polyfill。

if (typeof Array.prototype.map !== "function") {
    Array.prototype.map = function (f, thisArg) {
        var result = [];
        
        for (var i = 0, n = this.length; i < n; i++) {
            result[i] = f.call(thisArg, this[i], i);
        }

        return result;
    };
}

提示:

避免使用轻率的猴子补丁。

记录程序库所执行的所有猴子补丁。

考虑通过将修改置于一个到处函数中,使猴子补丁称为可选的。

使用猴子补丁为缺失的标准API提供polyfills。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/107678.html

相关文章

  • 编写质量JavaScript代码数组字典

    摘要:但实例化该构造函数仍然得到的是的实例。或者,为了避免在所有查找属性的地方都插入这段样本代码,我们可以将该模式抽象到的构造函数中。该构造函数封装了所有在单一数据类型定义中编写健壮字典的技术细节。 参考书籍:《Effective JavaScript》 数组和字典 对象是JavaScript中最万能的数据结构。取决于不同的环境,对象可以表示一个灵活的键值关联记录,一个继承了方法的面向对象数...

    izhuhaodev 评论0 收藏0
  • 编写质量JavaScript代码的基本技巧

    摘要:五不要增加内置的原型增加内置构造函数如,和等的原型属性是一个增强功能性的强大的方法,但这可能会严重影响可维护性,因为这种做法将使代码变得更加不可预测。推荐使用后者,这样根据名字就能明显地区分出变量函数和基本常量等。 一、尽量少用全局变量 减少全局名字空间污染,最理想的情况是一个应用程序仅有一个全局变量。 二、单一var模式 var a = 1, b = 2, sum =...

    Alex 评论0 收藏0
  • 深入理解JavaScript系列1:编写质量JavaScript代码的基本要点

    摘要:访问全局对象在浏览器中,全局对象可以通过属性在代码的任何位置访问除非你做了些比较出格的事情,像是声明了一个名为的局部变量。 前言 才华横溢的Stoyan Stefanov,在他写的由O’Reilly初版的新书《JavaScript Patterns》(JavaScript模式)中,我想要是为我们的读者贡献其摘要,那会是件很美妙的事情。具体一点就是编写高质量JavaScript的一些要素...

    Enlightenment 评论0 收藏0
  • 个人分享--web前端学习资源分享

    摘要:前言月份开始出没社区,现在差不多月了,按照工作的说法,就是差不多过了三个月的试用期,准备转正了一般来说,差不多到了转正的时候,会进行总结或者分享会议那么今天我就把看过的一些学习资源主要是博客,博文推荐分享给大家。 1.前言 6月份开始出没社区,现在差不多9月了,按照工作的说法,就是差不多过了三个月的试用期,准备转正了!一般来说,差不多到了转正的时候,会进行总结或者分享会议!那么今天我就...

    sherlock221 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<