javascript:面向对象的程序设计

JavaScript读书笔记

备注:为了防止标签错乱,现在规定,标题用 ## ,一级标题是### , 二级是#### , 三级是 #####

面向对象的程序设计

内容(3点):

理解对象属性

理解并创建对象

理解继承

面向对象(Object-Oriented,OO)的语言有一个标志,那就是他们都有类的概念,而通过类可以创建任意多个具有相同属性和方法的对象。前面提过,ECMAScript中没有类的概念,因此它的对象也与基于类的语言的对象有所不同。
ECMA-262把对象定义为:“无序属性的集合,其属性可以包含基本值、对象或者函数。”严格来讲,这就相当于说对象是一组没有特点顺序的值。对象的每个属性或者方法都有一个名字,而每个名字都映射到一个值。正因为这样,我们可以把ECMAScript的对象想象成散列表:无非就是一组名值对,其中值可以是数据或函数。

每个对象都是基于引用类型创建的,这个引用类型可以是原生类型,也可以是自定义的类型。

1 理解对象

创建一个自定义对象的简单方式:

var person = new Object();
person.name = 'Luna';
person.age = 29;
person.job = "software engineer";

person.sayName = function(){
    console.log(this.name);
    // 这里的 this 就是 person 对象
}

使用字面量表示法:

var person = {
    name : 'Luna',
    age : 29,
    job : 'software engineer',
    sayName : function() {
        console.log(this.name);
    }
};

两种方式创建的person对象是一样的,都有相同的属性和方法。这些属性在创建时都带有一些特征值(characteristic),Javascript通过这些特征值来定义它们的行为。

1.1 属性类型

ECMAScript中有两种属性:数据属性和访问器属性。

01 数据属性

数据属性包含一个数据值的位置。在这个位置可以读取和写入值。数据属性有4个描述其行为的特性。

  • [[Configrable]]: 表示能否通过delete删除属性从而重写定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。像前面例子中那样直接在对象上定义的属性,它们的这个特性默认值为true
  • [[Enumerable]]: 表示能否通过for-in循环返回属性。像前面例子中那样直接在对象上定义的属性,它们的这个特性默认值为true
  • [[Writable]]: 表示能否修改属性的值。像前面例子中那样直接在对象上定义的属性,它们的这个特性默认值为true
  • [[Value]]: 包含这个属性的数据值。读取属性值的时候,从这个位置读;写入属性值的时候,把新值保存在这个位置。这个特性的默认值为undefined

要修改属性默认的特性,必须使用ECMAScript5Object.defineProperty()方法。这个方法接收三个参数:属性所在的对象,属性的名字和一个描述符对象。其中描述符(descriptor)对象的属性必须是:configurableenumerablewritablevalue。设置其中一个或者多个值,可以修改对象的特征值。如下:

修改对象的 valuewriteable属性:

var person = {
    name : 'Luna'
};

var desc = {
    value : 18,
    writeable : false
};

Object.defineProperty(person,"age",desc);
person.age = 30;

console.log(person.age);

Object.defineProperty()方法,在多数情况下都不需要使用。

02 访问器属性

访问器属性不包含数据值;它们包含一对儿gettersetter函数。(不过,这两个函数都不是必须的)。在读取访问器属性时,会调用getter函数,这个函数负责返回有效的值;在写入访问器属性时,会调用setter函数并传入新值,这个函数负责决定如何处理数据。访问器属性有如下4个特性。

  • [[Configurable]]: 表示能否通过delete删除属性从而重写定义属性,这个特性的默认值为true
  • [[Enumrable]]: 表示能否通过for-in循环返回属性。对于直接在对象上定义的属性,这个特性的默认值为true
  • [[Get]]: 在读取属性时调用的函数,默认值为undefined
  • [[Set]]: 在写入属性时调用的函数。默认值为undefined

访问器属性不能直接定义,必须使用Object.defineProperty()来定义。如下示例:

var book = {
    _year: 2004,
    edition: 1
};
book.price = 1024.4;

// 定义访问器属性 year
Object.defineProperty(book, "year", {
    set: function (val) {
        this._year = val;
        if (val > 2004) {
            this.edition += val - 2004;
        }
    },
    get: function () {
        return this._year;
    }
    // ,enumerable: true  // 加上这句就可以输出 year 属性
});

book.year = 2018; // 定义完成就可以调用 该属性了
console.log(book.year + " , " + book.edition);

book._year = 2028;
console.log(book._year + " , " + book.edition);


for (var prop in book) {
    console.log("prop: " + prop); 
    // 并不会输出 year 这个属性
}

以上代码创建了一个book对象,并给它定义了两个默认属性:_year,edition_year前面的下划线是一种常用的记号,用来表示只能通过对象方法访问的属性。而访问器属性year则包含一个getter函数和一个setter函数。getter函数返回_year的值,setter函数通过计算来确定正确的版本。因此把year属性修改为2018会导致_year变成2018,而edtion变成15。这是使用访问器属性的常见方式,即设置一个属性的值会导致其他属性发生变化。

不一定要同时指定gettersetter。严格模式下,需要同时指定。

1.2 定义多个属性

由于为对象定义多个属性的可能性很大,ECMAScript5又定义了一个Object.defineProperties()方法。利用这个方法可以通过描述符一次性定义多个属性。这个方法接收两个对象参数:第一个对象是要添加和修改其属性的对象,第二个对象的属性与第一个对象中要添加或修改的属性一一对应。如下:

/**
 * Created by cat on 2018/4/21.
 */

var book = {};

var props = {
    _year: {
        value: 2004
        , enumerable: true
    },
    edition: {
        value: 1
        // , enumerable: true
    },
    year: {
        set: function (val) {
            this._year = val;
            if (val > 2004) {
                this.edition += val - 2004;
            }
        },
        get: function () {
            return this._year
        }
        , enumerable: true
    }
};

console.log(props + " ### " + typeof props + " , " + Boolean(props));
Object.defineProperties(book, props);


for (var prop in book) {
    console.log(prop, book[prop]);
}

上述代码给book对象添加了_year,year,edition,这三个属性。并设置_yearyear属性是可枚举的(可以通过对对象执行for-in语句返回对应的属性)。

TIPS: 给属性前面加_成为_year只是不希望外部直接访问,并不是说外部不能直接访问!

1.3 读取属性的特性

使用ECMAScript5Object.getOwnPropertyDescriptor()方法,可以取得给定属性的描述符。这个方法接收两个参数:属性所在的对象和要去读其描述符的属性的名称。返回值是一个对象。如果是数据属性,这个对象的属性有configurable,enumerable,writeable,value如果是访问器属性,这个对象的属性有configurable,enumerable,get,set


var descriptor = Object.getOwnPropertyDescriptor(book,"_year");
// 遍历描述符对象,读取其属性
for(prop in descriptor){
    console.log(prop, descriptor[prop]);
}

数据属性返回(这里是_year属性):

value 2004
writable false
enumerable true
configurable false

访问器属性的描述符中有(这里是year属性):

get function () {
            return this._year
        }
set function (val) {
            this._year = val;
            if (val > 2004) {
                this.edition += val - 2004;
            }
        }
enumerable true
configurable false
2 创建对象

虽然Object构造函数或者对象字面量都可以创建单个对象,但是这个方式有个明显的缺点:使用同一个接口创建很多对象,会产生大量的重复代码。为了解决这个问题,人们开始使用工厂模式的的一种变体。

2.1 工厂模式
function createPerson(name,age,job){
    var person = {};
    person.name = name;
    person.age = age;
    person.job = job;
    person.sayName = function(){
        console.log(person.name);
    }

    return person;
}

console.log(a.valueOf());
console.log(b.valueOf());

console.log(a instanceof Object);

但是这个方法也要一个缺点,就是不知道创建的这个对象的具体类型,只知道是一个Obejct类型。(没有解决对象识别问题)

2.2 构造函数模式

ECMAScript中构造函数可以用来创建特定类型的对象。像ObjectArray这样的原生构造函数,在运行时会自动出现在执行环境中。此外,也可以创建自定义的构造函数,从而定义自定义对象类型的属性和方法。例如,可以使用构造函数模式将前面的例子重写。如下:

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = function () {
        return this.name;
    };
}

var p1 = new Person("Ann" , 23 , "actress");
var p2 = new Person("Rose" , 23 , "writer");

这种方式和上一种(createPerson()),有一些不同之处:

  • 没有显式地创建对象;
  • 直接将属性值赋给了this对象; 提问:这里的this是什么,是window 吗?
  • 没有return语句。

此外,函数名Person首字母大写,这是构造函数的惯例。(非必须,是推荐方式)主要目的是区分构造函数和ECMAScript中的其他函数;因为构造函数本身也是函数,只不过可以用来创建对象而已。

要创建Person的新实例,必须使用new操作符。以这种方式调用构造函数,实际上会经历以下4个步骤:

  1. 创建一个新的对象
  2. 将构造函数的作用域赋给新对象(因此 this 就指向了这个新对象)
  3. 执行构造函数中的代码(为这个新对象添加属性)
  4. 返回新对象

质疑:构造函数与普通函数有什么区别,调用构造函数创建对象必须使用new操作符吗?能不能省略new

— 通过比较this对象来查看区别。

1 使用new调用构造函数的代码:

console.log("<>>>before this是 Window 类型吗? " + (this instanceof Window));
function Person(name, age, job) {
    console.log("inner this是 Window 类型吗? " + (this instanceof Window));
    console.log("inner this是 Person 类型吗? " + (this instanceof Person));
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = function () {
        return this.name;
    };
}
var p1 = new Person("Ann", 23, "actress"); 
// todo: 使用了 new 操作符
console.log("outer this是 Window 类型吗? " + (this instanceof Window));
console.log("outer this是 Person 类型吗? " + (this instanceof Person));

// 在浏览器运行的输出结果如下

/**
 <pre>
 // todo: 在浏览器运行的输出结果如下 :
 <>>>before this是 Window 类型吗? true
 inner this是 Window 类型吗? false
 inner this是 Person 类型吗? true
 outer this是 Window 类型吗? true
 outer this是 Person 类型吗? false
 </pre>
 */

2 不使用new,直接调用构造函数的代码:

console.log("<>>>before this是 Window 类型吗? " + (this instanceof Window));
function Person(name, age, job) {
    console.log("inner this是 Window 类型吗? " + (this instanceof Window));
    console.log("inner this是 Person 类型吗? " + (this instanceof Person));
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = function () {
        return this.name;
    };
}
var p1 = Person("Ann", 23, "actress"); // 注意:这里没有 new
console.log("outer this是 Window 类型吗? " + (this instanceof Window));
console.log("outer this是 Person 类型吗? " + (this instanceof Person));

/*
 <pre>
 // todo: 在浏览器运行的输出结果如下 :
 <>>>before this是 Window 类型吗? true
 inner this是 Window 类型吗? true
 inner this是 Person 类型吗? false
 outer this是 Window 类型吗? true
 outer this是 Person 类型吗? false
 </pre>
 */

同样的代码,只是在调用的时候,一次使用了new操作符来调用Person()函数,一次没有。然后输出的效果并不相同。
log可以看到:

  • 在函数外部,this一直是 Window类型的对象。(在全局环境中调用的)[即使函数内部的this不是Window类型。]
  • 在函数内部:
    • 如果当前函数被new funcName(args);的方式调用,this为当funcName类型的对象;
    • 如果当前函数没有使用new操作符调用,只是普通调用(var result = funcName(args);),则thisWindow类型的对象。

所以,通过构造函数模式确实创建了一个新的对象,而且,这个对象有自己的类型,可以被类型识别到了。

创建自定义的构造函数意味着将来可以将它的实例标识为一种特定的类型;而这正是构造函数模式胜过工厂模式的地方。

TIPS: 以这种方式定义的构造函数是定义在Global对象(在浏览器中是window对象)中的。

1.将构造函数当作普通函数

构造函数与其他函数的唯一区别,就在于调用它们的方式不同。任何函数,只要通过new操作符来调用,那它就可以作为构造函数;而任何函数,如果不通过new操作符来调用,那它跟普通函数也不会有什么两样。

// 当作构造函数使用
var person = new Person("Tom",29,"coder");
person.sayName(); // Tom

// 当成普通函数使用
Person("Ann",28, "actress");
window.sayName(); // Ann

当成普通函数后,就把name,age这些属性的值添加到当前执行环境变量中去了,所以window.name === "Ann"了。

ps: 普通函数的this对象不一定总是window,取决于当前执行环境。

比如下面这种操作,将Person()函数的this对象指定为obj,则后续的添加属性的操作,也就是针对obj了。

var obj = {};

Person.apply(obj,['Rose' , 26 , 'writer']);

console.log(obj.sayName() +" ### "+obj.age);
// 输出: Rose ### 26
2.构造函数的问题

构造函数模式虽然解决了类型识别的问题,但是构造函数存在一个问题,就是每个方法都要在每个实例上重新创建一遍。在如下代码(通过构造函数模式创建对象)中:

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = function () {
        console.log(this.name);
    };
}

var p1 = new Person("Ann" , 23 , "actress");
var p2 = new Person("Rose" , 23 , "writer");

给每个实例对象(p1,p2)都创建了一个名为sayName()的方法,但是这两个方法不是同一个Function实例。ECMAScript中的函数是对象,因此每定义一个函数,也就是实例化了一个对象(函数对象)。从逻辑上讲,此时的构造函数也可以这样定义。

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = new Function("console.log(this.name)");
}

var p1 = new Person("Ann" , 23 , "actress");
var p2 = new Person("Rose" , 23 , "writer");

通过这种方式更容易看出,每创建一个实例对象,都会同时创建一个函数对象。说明白些,以这种方式创建函数,会导致不同的作用域链和标示符解析,但是创建Function新实例的机制仍然是相同的。因此,不同实例上面的同名函数是不相等的,如下代码可以证明这点:

console.log(p1.sayName == p2.sayName); // false

改进方案:

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = inner;
}

function inner(){
    console.log(this.name);
}

var p1 = new Person("Ann" , 23 , "actress");
var p2 = new Person("Rose" , 23 , "writer");

通过把sayName()函数定义转移到构造函数外部。而构造函数内部,将sayName属性设置为全局的inner函数。这样的确解决了多个对象实例共享同一个函数的问题。但是,很明显,这样的代码过于松散,封装性不足。而且inner()函数虽然在全局定义,但是实际上只是被某个对象调用。而且一旦对象中包含多个函数,这种写法就丝毫没有封装性可言了。

2.3 原型模式

每个被创建的函数都有一个prototype(原型)属性,这个属性是一个指针,指向一个对象,而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。如果按照字面意思来理解,那么prototype就是通过调用构造函数而创建的那个对象实例的原型对象。使用原型对象的好处就是让所有对象实例共享它所包含的属性和方法。换句话说,不必再构造函数中定义对象实例的信息,而是可以将这些信息直接添加到原型对象中,如下示例:

function Person() {

}

Person.prototype.name = "张汤姆";
Person.prototype.age = 29;
Person.prototype.job = 'actress';

Person.prototype.sayName = function () {
    console.log(Person.prototype.name);
};


var p1 = new Person();
p1.sayName(); // 张汤姆
p1.name = "李吉米";
p1.sayName(); // 张汤姆
var p2 = new Person();
p2.sayName(); // 张汤姆

在此,我们将sayName()方法和所有属性直接添加到了Personprototype属性中,构造函数变成了空函数。即使如此,也仍然可以通过调用构造函数来创建新对象,而且新对象还会具有相同的属性和方法。但与构造函数模式不同的是,新对象的这些属性和方法是由所有实例共享的。换句话说,person1person2访问的都是同一组属性和同一个sayName()函数。要理解原型模式的工作原理,必须先理解ECMAScript中原型对象的性质。

1 理解原型对象

无论什么时候,只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个prototype属性,这个属性指向函数的原型对象。默认情况下,所有原型对象都会自动获得一个constructor(构造函数)属性,这个属性包含一个指向prototype属性所在函数的指针。而通过这个构造函数,我们还可以继续为原型对象添加其他的属性和方法。

原型对象有一个constructor属性,指向该原型对象对应的构造函数

function Foo(){};
console.log(Foo.prototype.constructor === Foo);//true

由于实例对象可以继承原型对象的属性,所以实例对象也拥有constructor属性,同样指向原型对象对应的构造函数

function Foo(){};
var f1 = new Foo;
console.log(f1.constructor === Foo);//true

实例对象有一个proto属性,指向该实例对象对应的原型对象

function Foo(){};
var f1 = new Foo;
console.log(f1.__proto__ === Foo.prototype);//true

创建了自定义的的构造函数之后,其原型对象默认只会取得constructor属性;至于其他方法,则都是从Object继承过来的。当调用构造函数创建一个新实例后,该实例的内部将包含一个指针(内部属性),指向构造函数的原型对象。ECMA-262第5版中管这个指针叫[[Prototype]]。虽然在脚本中没有标准的方式访问[[Prototype]],但FirefoxSafariChrome在每个对象上都支持一个属性__proto__;而在其他实现中,这属性对脚本是完全不可见的。不过,要明确的真正重要的一点就是,这个连接存在于实例与构造函数的原型对象之间,而不是存在于实例与构造函数之间

虽然在所有的实现中都无法访问到[[Prototype]],但可以通过isPrototyeOf()方法来确定对象之间是否存在这种关系。从本质上讲,如果[[Prototype]]指向调用isPrototypeOf()方法的对象(Person.prototype),那么这个方法就返回true,如下所示:

function Duck(){}
var du = new Duck();

console.info(Duck.isPrototypeOf(du)); // false 
console.info(Duck.prototype.isPrototypeOf(du)); // true

// -------------------------

console.info(du.__proto__ == Duck.prototype); // true
console.info(du.constructor == Duck); // true
console.info(du.__proto__ 
== Duck.prototype.constructor.prototype); // true

ECMAScript5增加了一个新方法,叫Object.getPrototypeOf(),在所有支持的实现中,这个方法返回[[Prototype]]的值。例如:

console.info(du.__proto__ == Duck.prototype 
&& Object.getPrototypeOf(du) == du.__proto__); // true

Object.getPrototypeOf(du)实际上就是du.__proto__

只不过,在部分浏览器中不支持__proto__属性。

每当代码读取某个对象的某个属性时(比如:per.name这种),都会执行一次搜索,目标是具有给定名字的属性。搜索首先从对象实例本身开始。如果在实例中找到了具有给定名字的属性,则返回该属性的值;如果没有找到,则继续搜索指针指向的原型对象,在原型对象中查找具有给定名字的属性。如果在原型对象中找到了这个属性,则返回该属性的值。todo:::

虽然可以通过对象实例访问保存在原型中的值,但却不能通过对象实例重写原型中的值。如果我们在实例中添加了一个属性,而该属性与实例原型中的一个属性同名,那实例中的属性将会屏蔽原型中的那个属性。

function Lemon(){}

Lemon.prototype.username = 'Ann';

var m1 = new Lemon();
m1.username = 'Stone';
var m2 = new Lemon();
console.log(m1.username +" ## "+m2.username); // Stone ## Ann

当为对象实例添加一个属性时,这个属性会屏蔽原型对象中保存的同名属性;或者说,添加这个属性只会阻止我们访问原型中的那个属性,但不会修改那个属性。即使将这个属性设置为null,也只会在实例中设置了这个属性,而不会恢复其指向原型的连接。(访问 m1.username返回了null,但是m1.username依然存在,依然起到了阻止访问原型对象的同名属性的效果)。不过,如果使用delete操作符,删除了这个实例属性(delete m1.username),则能够再次访问原型中的属性了。如下:

function Lemon(){}

Lemon.prototype.username = 'Ann';

var m1 = new Lemon();
m1.username = 'Stone';
console.log(m1.username); // Stone
m1.username = null;
console.log(m1.username); // null

delete m1.username
console.log(m1.username); // Ann

使用hasOwnPrototype()方法可以检测一个属性是存在于实例中,还是存在于原型中。这个方法(继承自Object)只在给定属性存在的对象实例中时,才会返回true。如下:

function Panda() {
    this.age = 3;
}

Panda.prototype.usename = "Ann";

var p1 = new Panda();

p1.gender = 'female';
console.log(p1.hasOwnProperty('gender')); // true
console.log(p1.hasOwnProperty('name')); // false
console.log(p1.hasOwnProperty('age')); // true

p1.usename = "Rose";
console.log(p1.hasOwnProperty('name')); // true
2 原型与in操作符

有两种方式使用in操作符:单独使用和在for-in循环中使用。在单独使用时,in操作符会在通过对象能够返回给定属性时返回true,无论该属性存在于实例中还是原型中。如下:

判断一个属性是存在于实例中还是实例的原型中


// 判断一个属性是否是只存在于原型中
function onlyInPrototype(obj, propName) {
    return (propName in obj)
        && !obj.hasOwnProperty(propName);
}

// 判断一个属性是否是只存在于实例中(todo: 待完善)
function onlyInInstance(obj, propName) {
    return obj.hasOwnProperty(propName)
        && !( propName in obj.constructor.prototype);
} // 这个方法有问题,因为原型依然会有原型,除非一直追溯到 null

要取得对象上所有可枚举的实例属性,可以使用ECMAScript5Object.keys()方法。这个方法接收一个对象作为参数,返回一个包含所有可枚举属性的字符串数组。(亲测效果不好,对于定义在prototype中的属性,不会包含在其中)如下:

function Panda() {
    this.age = 3;
}
Panda.prototype.username = "Ann";

var p1 = new Panda();
p1.gender = 'female';

console.log(Object.keys(p1));

输出如下:

["age", "gender"]

如果你想要得到实例的所有属性,无论它是否可以枚举,都可以通过Object.getOwnPropertyNames()方法得到。亲测效果不好,对于当前实例的原型中定义的属性,并不能得到。

还是for-in比较好

3 更简单的原型语法

function Panda() {
}
Panda.prototype = {
    name: "Ann",
    'age': 5,
    job: 'actress',
    sayName: function () {
        console.info(this.name + " this ? " + (this instanceof Panda));

    }
};

但是,这样写会有一个问题,这里将Panda.proptotype设置为一个字面量形式的新对象,本质上完全重写了默认的prototype对象。而每个创建的函数,都会一个同时被创建的prototype对象,而这个prototype对象也会自动获取constructor属性。换句话说,这种字面量形式创建的prototype属性,会丢失其默认的constructor属性。

看一下这种方式带来的后果:

function Panda() {
}
Panda.prototype = {
    name: "Ann",
    'age': 5,
    job: 'actress'
};
var p1 = new Panda();
console.log(p1.constructor === Panda); // false

function Lemon() {

}

Lemon.prototype.name = 'rose';
Lemon.prototype.job = 'writer';

var m = new Lemon();

console.log(m.constructor === Lemon); // true

console.log(p1.constructor === Panda); // false可以看到,的确丢失constructor属性。这个constructor是继承自Object的,所以指向的也就成了Object了。

改进方案:

function Panda() {
}
Panda.prototype = {
    constructor: Panda, 
    // 手动添加 constructor 属性 , 会导致 enumerable 为 true
    name: "Ann",
    'age': 5,
    job: 'actress'
};
var p1 = new Panda();
console.log(p1.constructor === Panda); // true

这种方式重设constructor属性会导致它的[[Enumerable]]属性被设置为true。默认情况下,原生的constructor属性是不可枚举的。

改进方案2:(无副作用了…)

function Panda() {
}
Panda.prototype = {
    // constructor: Panda,
    name: "Ann",
    'age': 5,
    job: 'actress'
};

Object.defineProperty(Panda.prototype, "constructor", {
    enumerable: false,
    value: Panda
});
var p1 = new Panda();
console.log(p1.constructor === Panda); // true
4 原型的动态性

由于在原型中查找值的过程是一次搜索,因此我们对原型对象所做的任何修改都能够立即从实例上反映出来–即使是先创建了实例后修改原型也是如此。如下:

function Person() {

}

var p1 = new Person();

Person.prototype.sayHi = function () {
    console.log("Hi");
};

p1.sayHi(); // 正常输出

以上代码先创建了Person实例(p1),并将其保存在p1中。然后,下一条语句在Person.prototype中添加了一个方法sayHi()。即使p1实例是在添加新方法之前创建的,但它仍然可以访问这个新的方法。其原因可以归结为实例与原型之间的松散连接关系。当我们调用person.sayHi()时,首先会在实例中搜索名为sayHi()的属性,在没有找到的情况下,会继续搜索原型。因为实例与原型之间的连接只不过是一个指针,而非副本,因此就可以在原型中找到新的sayHi()属性并返回保存在那里的函数。

尽管可以随时为原型添加属性和方法,并且修改能够立即在所有对象实例中反映出来,但如果是重写整个原型对象,那么情况就不一样了。我们知道,调用构造函数时会为实例添加一个指向最初原型的[[Prototype]]指针,而把原型修改为另一个对象就等于切断了构造函数与最初原型之间的联系。请记住:实例中的指针仅指向原型,而不指向构造函数。如下:


function Person(){}
var p1 = new Person();
Person.prototype = {
    sayHi: function(){
        console.log('Hi');
    }
};
p1.sayHi(); // TypeError: p1.sayHi is not a function

在这段代码中,我们先创建了Person的一个实例,然后又重写了其原型对象。然后再调用p1.sayHi()时发生了错误,因为p1指向的原型中不包含以该名字命名的属性。重写原型对象切断了现有原型与任何之前已经存在的对象实例之间的联系,它们引用的仍然是最初的原型。

5 原生对象的原型

所有原生的引用类型,都是采用原型模式创建的。所有的原生引用类型(ObjectArrayString,等等)都在其构造函数的原型上定义了方法。例如,在Array.prototype中可以找到sort()方法,而在String.prototype中可以找到substring()方法。如下:

console.log(typeof Array.prototype.sort);
console.log(typeof String.prototype.substring);

由于原型的动态性,所有可以像修改自定义对象的原型一样修改原生对象的原型,比如,给原生对象的原型添加一个方法。如下:


String.prototype.startWith = function (key) {
    return this.indexOf(key) === 0
};

var sw = "abcde".startWith('abc');
console.log(sw); // true

var sq = "zabcde".startWith('abc');
console.log(sq); // false

虽然可以动态修改原生对象的原型,但是不建议这样做。

6 原型对象的问题

原型模式首先忽略了为构造函数传递初始化参数的这一环节,结果所有的实例在默认情况下都讲取得相同的属性值。虽然这会在某种程度上带来一些不方便,但是这还不是原型的最大问题。原型模式的最大问题是由其共享的本性所导致的。

原型中所有属性是被很多实例共享的,这种共享对于函数非常合适。对于那些包含基本值的属性倒也说得过去,因为这些值是立即修改的(值传递)。然而,对于包含引用类型的值的属性来说,问题就比较突出。如下:

function Person(){}

Person.prototype.colors = ['red'];

var p1 = new Person();
p1.colors.push('blue');
console.log(p1.colors); // red,blue

var p2 = new Person();
console.log(p2.colors); // red,blue

console.log(p1.colors === p2.colors); // true

可以看到,p1修改了colors属性,导致p2colors也被修改了。(因为实际上两个实例对象使用的是同一个colors属性)

每个实例对象一般都是要由属于自己的全部的属性的(不能被其他实例修改的属性)。而这个问题正是我们很少看到有人单独使用原型模式的原因所在。

2.4 组合继承

组合使用构造函数模式原型模式

创建自定义类型的最常见的方式,就是组合使用构造函数模式与原型模式构造函数模式用于定义实例属性,而原型模式用于定义方法和共享的属性。结果,每个实例都会有自己的一份实例属性的分布,但同时又共享着对方法的引用,最大限度地节省了内存。另外,这种混合模式还支持向构造函数传递参数;可谓是集两种模式之长。如下:

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
}

Person.prototype.sayName = function () {
    console.log(this.name);
};

Person.prototype.friends = ['Dogs', 'Cats'];

var p1 = new Person('Rose', 29, 'writer');
var p2 = new Person('Ann', 29, 'actress');

p1.sayName();
p2.sayName();
console.log(p1);
console.log(p2);
console.log(p1.friends);

输出如下:

Rose
Ann
Person { name: 'Rose', age: 29, job: 'writer' }
Person { name: 'Ann', age: 29, job: 'actress' }
[ 'Dogs', 'Cats' ]

上面的代码也可以这样写:

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
}

Person.prototype = {
    constructor: Person, 
    // 这一句不能少,不然`constructor`就成了`Object`
    sayName: function () {
        console.log(this.name);
    },
    friends: ['Dogs', 'Cats']
};

var p1 = new Person('Rose', 29, 'writer');
var p2 = new Person('Ann', 29, 'actress');

p1.sayName();
p2.sayName();
console.log(p1);
console.log(p2);
console.log(p1.friends);

在这个例子里,实例属性都是在构造函数中定义的,而由所有实例共享的属性(constructor,friedns)和方法(sayName())则是在原型中定义的。

这种构造函数与原型混成的模式,是目前在ECMAScript中使用最广泛、认同度最高的一种创建自定义类型的方法。可以说,这是用来定义引用类型的一种默认模式。

2.5 动态原型模式

有其他OOP语言经验的开发人员看到独立的构造函数和原型时,很可能会感到困惑。动态原型模式正是致力于解决这个问题的一个方案,它把所有的信息都封装在构造函数中,而通过构造函数中初始化原型(仅在第一次调用的时候),又保持了同时使用构造函数和原型的优点。


// todo: 错误示例!,原型动态模式中如果重写原型,会切断构造函数与原型直接的连接。
// ---> 构造函数连接的原型,依然是最初自动创建的原型
function Person(name, age) {
    this.name = name;
    this.age = age;
    if (typeof this.sayName !== 'function') {
        Person.prototype = {
            constructor: Person,
            sayName: function () {
                console.log(this.name);
            },
            sayHi: function () {
                console.log('Hi')
            },
            friends: ['Dogs', 'Cats']
        }
    }
}

var p1 = new Person('Tom', 23);
var p2 = new Person();

console.log(Object.getPrototypeOf(p1)); // {constructor: ƒ}
p1.sayName();  // TypeError: p1.sayName is not a function
// p2.sayName();

通过console.log(Object.getPrototypeOf(p1)); // {constructor: ƒ}可以明显看到,构造函数连接的原型对象还是默认的原型对象,而不是自己重写的原型对象。

<>

正确的动态原型模式的打开方式:

function Person(name, age) {
    this.name = name;
    this.age = age;

// todo: 注意这里的判断,只要对任意一个共享的方法做这个判断即可
    if (typeof this.sayName !== 'function') {
        Person.prototype.sayName = function () {
            console.log(this.name);
        };
        Person.prototype.sayHi = function () {
            console.log('Hi');
        };
        Person.prototype.friends = ['Dogs', 'Cats'];
    }
}

var p1 = new Person('Tom', 23);
var p2 = new Person();

console.log(Object.getPrototypeOf(p1)); // {constructor: ƒ}
p1.sayName();  // TypeError: p1.sayName is not a function
p2.sayName();

注意构造函数中的if判断的部分,这里只在sayName()方法不存在时,才会执行里面的代码。此后,原型已经完成初始化。由于原型的动态性,对原型的修改,会立即反映到所有的实例上。因此这种方式没有任何的副作用。对于采用这种方式创建的对象,还可以使用instanceof操作符确定它的类型。(不清楚这句话有什么意义,前面的几种方式不都可以嘛!)

2.6 寄生构造函数模式

通常,在前述几种模式都不适用的情况下,可以使用寄生(parasitic)构造函数模式。这种模式的基本思想是创建一个函数,该函数的作用仅仅是封装创建对象的代码,然后再返回新创建的对象;但从表面上看,这个函数又很像是典型的构造函数。如下:

function Person(name,age,job){
    var o = new Object();
    o.name = name;
    o.age = age;
    o.sayName = function() {
        // console.log("this is ? "+(this instanceof Person)); // false
        console.log(this.name);
    }
    // console.log("this is -- ? "+(this instanceof Person)); // true
    return o;
}

var friend = new Person('Stone', 29 , 'software engineer');
friend.sayName();

从输出也可以看出,在o.sayName()中的thisObject类型的,在下一个this就是Person类型的。

在这个例子中,Person函数创建了一个对象,并以相应的属性和方法初始化该对象,然后返回了这个对象。除了使用new操作符并把使用的包装函数叫做构造函数之外,这个模式跟工厂模式其实是一模一样的。构造函数在不返回值的情况下,默认返回新对象实例。而通过在构造函数的末尾添加一个return语句,可以重新调用构造函数时返回的值.

此种模式(寄生构造函数模式)往往是用来创建包装类型的对象,和Java中是包装模式很类似

function SpecialArray() {

    var values = [];
    values.push.apply(values, arguments);

    values.toPipString = function () {
        // console.log("--"+this instanceof SpecialArray); // false
        // --> 这个 this 的类型是 Array 并不是 SpecialArray
        return this.join('|', values);
    };
    return values;

}

var sa = new SpecialArray('red', 'green', 'blue');
console.log(sa.toPipString());

console.log(sa instanceof SpecialArray); // false

比如上面的代码,通过包装Array创建了一个新的类型SpecialArray。不过,通过 instanceof操作符,并不能识别这个定制的类型。(应该是重新调用构造函数时返回的值导致的。)

2.7 稳妥构造函数模式

略。感觉没什么实际意义。


创建对象的方法总结:

创建对象哪家强?

  • 当然是: 2.4组合继承 。

    2.5 也可用,但是要注意。


3 继承

继承是OO语言中的一个最为津津乐道的概念。许多OO语言都支持两种继承方式:接口继承和实现继承。接口继承只继承方法签名,而实现继承则继承实际的方法。如前所述,由于函数没有签名,在ECMAScript中无法实现接口继承。ECMAScript只支持实现继承,而其实现继承主要是依靠原型链来实现的。

3.1 原型链

ECMAScript中描述了原型链的概念,并将原型链作为实现继承的主要方法。其基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针。而实例都包含一个指向原型对象的内部指针。那么,假如我们让原型对象等于另一个类型的实例,结果会怎么样呢?显然,此时的原型对象将包含一个指向另一个原型的指针,相应的,另一个原型中也包含着一个指向另一个构造函数的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立,如此层层递进,就构成了实例与原型的链条。这就是所谓的原型链的基本概念。

function Apple(name) {
    this.name = name;
}

Apple.prototype.sayName = function () {
    console.log(this.name);
};

function Banana(name) {
    this.name = name;
}

Banana.prototype = new Apple('苹果');

Banana.prototype.constructor = Banana;

function Cherry(name) {
    this.name = name;
}

Cherry.prototype = new Banana('香蕉');
Cherry.prototype.constructor = Cherry;

var ch = new Cherry('樱桃');

console.log(ch);

这个通过chrome浏览器的控制台输出可以很明显看出继承关系,如我们预期。Cherry <-- Banana <-- Apple

console.log((ch instanceof Cherry) 
&& (ch instanceof Banana)
&& (ch instanceof Apple)); // true
原型链的问题

原型链虽然很强大,可以用它来实现继承,但是它也存在一些问题。其中,最主要的问题来着包含引用类型值的原型。前面介绍过包含引用类型值的原型属性会被所有实例共享;而这也正是为什么要再构造函数中,而不是原型对象中定义属性的原因。在通过原型来实现继承时,原型实际上会变成另一个类型的实例。于是,原先的实例属性也就顺理成章地变成了现在的(子类型对象的)原型属性了。

通过在上面的原型继承的代码中添加几行代码即可看出问题:

function Apple(name) {
    this.name = name;
    this.friends = ['cats'];
}

Apple.prototype.sayName = function () {
    console.log(this.name);
};

function Banana(name) {
    this.name = name;
}

Banana.prototype = new Apple('苹果');

Banana.prototype.constructor = Banana;

function Cherry(name) {
    this.name = name;
}

Cherry.prototype = new Banana('香蕉');
Cherry.prototype.constructor = Cherry;

var ch = new Cherry('樱桃');
console.log(ch);

var c2 = new Cherry('桃子��');
ch.friends.push('dogs');

console.log(c2);
console.log("ch:" + ch.friends + " #### c2:" + c2.friends);

最后一行的输出为:ch:cats,dogs #### c2:cats,dogs

也就是说,改变了Cherry的某一个实例的属性,会导致该类型的全部实例的这个属性都会被改变。这并不是我们想要的效果,但是符合原型的逻辑。

原型链的第二个问题是:在创建子类型的实例时,不能向超类型的构造函数中传递参数。实际上,应该说是没有办法在不影响所有对象实例的情况下,给超类型的构造函数传递参数。有鉴于此,再加上原型中属性共享问题,实践中很少单独使用原型链

3.2 借用构造函数

在解决原型中包含引用类型值所带来问题的过程中,开发人员开始使用一种叫做借用构造函数(constructor stealing)的技术(有时候也叫做伪造对象或经典继承)。这种技术的基本思想相当简单,即在子类型构造函数的内部调用超类型构造函数。别忘了,函数只不过是在特定环境中执行代码的对象,因此通过使用apply()call()方法也可以在(将来)新创建的对象上执行构造函数,如下所示:

function SuperType() {
    this.colors = ['red', 'blue', 'green'];
}

function SubType() {

    SuperType.call(this); // 这里的 this 是什么? 当然是 SubType 类型的对象
    // todo:注意,这里并没有把 SuperType()当成构造函数调用,而是当成普通函数调用了。
}

var instance = new SubType();
instance.colors.push('black');
console.log(instance.colors); // [ 'red', 'blue', 'green', 'black' ]

var instance2 = new SubType();
console.log(instance2.colors); // [ 'red', 'blue', 'green' ]

上述代码其实等效于下面的写法:

function SuperType() {
    this.colors = ['red', 'blue', 'green'];
}

function SubType() {

    // SuperType.call(this); // 这里的 this 是什么? 当然是 SubType 类型的对象
    // 另外这句代码到底执行了什么呢? --> 相当于如下的代码:

}
var instance = new SubType();
instance.SuperType = SuperType;
instance.SuperType();

instance.colors.push('black');
console.log(instance.colors); // [ 'red', 'blue', 'green', 'black' ]

var instance2 = new SubType();
instance2.SuperType = SuperType;
instance2.SuperType();
console.log(instance2.colors); // [ 'red', 'blue', 'green' ]

通过输出可以看出,以上两种写法的效果是相同的。

而且,这实际上并不是什么继承。从console.log(instance);Chrome控制台输出可以明显看出这一点:

SubType {colors: Array(4)}
colors
:
(4) ["red", "blue", "green", "black"]
__proto__
:
Object

通过 chrome查看会更直接(建议把以上任意一种代码方到chrome下运行。)。

console.log(instance instanceof SubType); // true
console.log(instance instanceof SuperType); // false

以上两句代码也可以明确这一点!

个人以为,借用构造函数模式,仅仅是给每个实例对象创建了不共享了实例属性。并且这种方式并没有实现原型继承。

3.3 组合继承

组合继承(combination inheritance),有时候也叫做伪经典继承,指的是将原型链和借用构造函数的技术组合到一块,从而发挥二者纸厂的一种继承模式。其背后的思路是使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承。这样,既通过在原型上定义方法实现了函数复用,又能保证每个实例都有它自己的属性,如下:

// 第一段
function SuperType(name) {
    this.name = name;
    this.colors = ['red', 'blue', 'green'];
}
// 第二段
SuperType.prototype.sayName = function () {
    console.log(this.name);
};

// 第三段
function SubType(name, age) {
    SuperType.call(this, name); // --> this.colors = ['red','blue','green']
    this.age = age;
}
// 第四端
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType; 
// 第五段
SubType.prototype.sayAge = function () {
    console.log(this.age);
    // 这里的 this 是谁?
    // 现在还看不出来,要看调用者,但是可以猜测,这个方法的调用者一定是一个 SubType 类型的对象,
    // 所以,这里的 this 就是一个 一个 SubType 类型的对象
};
// 第六段
var s1 = new SubType('Tom', 29);
s1.colors.push('black');
console.log(s1.colors);
s1.sayName();
s1.sayAge();

var s2 = new SubType('Ann', 33);
console.log(s2.colors);
s2.sayName();
s2.sayAge();

上述代码就解决了原型对象上面定义的属性会被实例共享的尴尬(此尴尬参见:3.1 原型链)。

-<>- 先来分析一下上面的代码,为什么这样就 既实现了函数复用,又能保证每个实例都有它自己的属性

前面的两段代码不用分析,就是典型的组合使用构造函数和原型模式的代码。然后是第三段代码:

// 3
function SubType(name, age) {
    SuperType.call(this, name);
    this.age = age;
}

注意这里的SuperType.call(this, name);,千万不要被SuperType是构造函数的概念给唬住了。(每个构造函数都可以是普通函数。只要不是通过new操作符去调用的函数,都是普通函数。)在这句代码里,使用了call()语法。call(thisValue,args),因为是call(),也就是相当于SubType的实例有一个普通函数叫做SuperType,然后在此时调用了。而这一调用,就是给自己添加了两个属性namecolors

然后后面一句this.age = age;就不用说了。

然后是第4段代码:

// 4
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType; 

这段代码,在原型链中已经见识过,就是将当前构造函数的原型重写为父类型的实例。(并且重写自己构造函数。)通过修改原型,实现了继承。

然后是第5段代码:

// 5
SubType.prototype.sayAge = function () {
    console.log(this.age);
};

这段代码也很简单,就是通过自己的原型属性,添加一个共享方法。

然后第6段代码是验证性代码,也不必解释了。

通过对上述代码的分析可知:实现函数复用的代码段是[第4段代码],保证每个实例都有它自己的属性的代码是[第3段代码]

在这个例子中,SuperType构造函数定义了两个属性:namecolorsSubperType的原型定义了一个方法sayName()SubType构造函数在调用SubperType构造函数时传入了name参数。(实际上,这里是把SuperType()当成普通函数去使用的。)紧接着,又定义了它自己的属性age。然后,将SuperType的实例赋值给SubType的原型,然后又再该新原型上定义了方法sayAge()。这样一来就可以让两个不同的SubType实例既分别拥有自己的属性(包括colors属性),又可以使用相同的方法了。

组合继承避免了原型链和借用构造函数的缺陷,融合了它们的优点,成为Javascript中最常用的继承模式。而且instanceofisPrototypeOf()也能够用于识别基于组合继承创建的对象。

该方式可用,但是不及寄生组合式继承

3.4 原型式继承
function object(o){
    function F(){}
    F.prototype = o;
    return new F();
}

ECMAScript5通过新增Object.create()方法规范化了原型式继承。这个刚发接收两个参数:一个是用作新对象原型的对象和(可选的)一个为新对象定义额外属性的对象。在传入一个参数的情况下,Object.create()object()方法的行为相同。

该方式只做了一半的工作。

3.5 寄生式继承

寄生式(parasitic)继承与寄生构造函数和工厂模式类似,即创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后再像真的是它做了所有工作一样返回对象。如下:

function createAnother(original){
    var clone = object(original);
    clone.sayHi = function(){
        console.log('hi');
    }
    return clone;
}

该方式(单独使用的)意义不大。

3.6 寄生组合式继承

前面说过,组合继承是Javascript最常用的继承模式;不过,它也有不足之处。组合继承最大的问题就是无论什么情况下,都会调用两次超类型构造函数:一次是创建子类型原型的时候,另一个是在子类型构造函数内部(这一次实际上是把超类型的构造函数当成普通函数调用的)。子类型最终会包含超类型对象的全部实例属性,但我们不得不在调用子类型构造函数的时候重写这些属性。再看一看组合继承的代码:

function SuperType(name){
    this.name = name;
    this.colors = ['red','green','blue'];
}
SuperType.prototype.sayName = function(){
    console.log(this.name);
}
function SubType(name,age){
    SuperType.call(this,name); // 第二次调用 SuperType() [作为普通函数调用]
    this.age = age;
}

SubType.prototype = new SuperType(); // 第一次调用 SuperType() [作为构造函数调用]
SubType.prototype.constructor = SubType;

SubType.prototype.sayAge = function(){
    console.log(this.age);
}

以上就是SuperType()被两次调用的地方。在第一次调用SuperType()构造函数时,SubType.prototype会得到两个属性:namecolors;它们都是SuperType的实例属性,只不过现在位于SubType的原型中。当调用SubType构造函数时,又会调用一次SuperType()普通函数,这一次又再SubType的新对象上创建了实例属性namecolors。于是,这两个属性就屏蔽了原型中的两个同名属性。

有两组namecolors属性:一组在实例上,一组在SubType原型中。这就是调用两次SuperType()的结果。好在我们已经找到了解决方法–寄生组合式继承。

所谓寄生组合式继承,即通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。其背后的基本思路是:不必为了指定子类型的原型而调用超类型的构造函数。我们所需要的无非就是超类型原型的一个副本而已。本质上,就是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。如下:

// todo: javascript 继承的最佳实践!
function extend(Child, Parent) {

    var F = function () {
    };

    F.prototype = Parent.prototype;

    Child.prototype = new F();

    Child.prototype.constructor = Child;

    Child.uber = Parent.prototype;

}
function SuperType(name){
    this.name = name;
    this.colors = ['red','blue','green'];
}
SuperType.prototype.sayName = function(){
    console.log();
}
function SubType(name,age){
    SuperType.call(this,name);
    this.age = age;
}

extend(SubType,SuperType);
SubType.prototype.sayAge = function(){
    console.log(this.age);
}

这个例子的高效率体现在它只调用了一次SuperType()函数[普通方式调用],并且避免了在SubType.prototype上面创建不必要的属性。与此同时,原型链保存不变;因此,还能够正常使用instanceofisPrototyOf()开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式

YUIYAHOO.lang.extend()方法采用了寄生组合式继承,从而让这种模式首次出现在了一个应用非常广泛的Javascript库中。

4 小结

ECMAScript支持面向对象(OO)编程,但不使用类或者接口。对象可以在代码执行过程中创建和增强,因此具有动态性而非严格定义的实体。在没有类的情况下,可以采用下列模式创建对象。

  • 工厂模式,使用简单的函数创建对象,为对象添加属性和方法,然后返回对象。这个模式后来被构造函数模式所取代
  • 构造函数模式,可以创建自定义引用类型,可以像创建内置对象实例一样使用new操作符,不过,构造函数也有缺点,即它的每个成员都无法复原,包括函数。由于函数可以不局限于任何对象(即与对象具有松耦合的特点),因此没有理由不在多个对象间共享函数。
  • 原型模式,使用构造函数的prototype属性来制定那些应该共享的属性和方法。组合使用构造函数和原型模式时,使用构造函数定义实例属性,而使用原型定义共享的属性和方法。

Javascript主要通过原型链实现继承。原型链的构建是通过将一个类型的实例赋值给另一个构造函数的原型实现的。这样,子类型就能够访问超类型的所有属性和方法,这一点与基于类的继承很相似。原型链的问题是对象实例共享所有继承的属性和方法。因此不宜单独使用,解决这个问题的技术是借用构造函数。即子类型构造函数的内部调用超类型构造函数(实际上是当初普通函数去调用)。这样就可以做到每个实例具有自己的属性,同时还能保证只使用构造函数模式来定义类型。使用最多的继承模式是组合继承,这种模式使用原型链继承共享的属性和方法,而通过借用构造函数继承实例属性

此外,还有下列可供选择的继承模式:

  • 原型式继承。可以在不必预先定义构造函数的情况下实现继承,其本质是执行对给定对象的浅复制。而复制得到的副本还可以得到进一步改造。==> 与调用Object.create(original);行为相同。
  • 寄生式继承,与原型式继承非常相似,也是基于某个对象或某些信息创建一个对象,然后增强对象,最后返回对象。为了解决组合继承模式由于多次(其实是两次)调用超类型构造函数(其实第二次是作为普通函数来调用的)而导致的低效率的问题,可以将这个模式与组合继承一起使用。
  • 寄生组合式继承,集寄生式继承和组合继承的优点于一身,是实现基于类型继承的最有效方式。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值