锅巴的JavaScript进阶修炼日记2:面向对象编程/原型及原型链

手写call和apply

在开始手写之前,我们先来回顾一下什么是call和apply,它们是干嘛的,有什么区别:

先从call开始入手,call是一个方法,是函数的方法

首先call可以调用函数:

复制代码
function fun () {
    console.log("hello world")
}
fun.call()

控制台上输出结果为hello world

其次call可以改变函数中this的指向:

复制代码
function fun() {
    console.log(this.name)
}

let cat = {
    name:'喵喵'
}

fun.call(cat)

控制台上输出结果为喵喵

call还能传入参数:

复制代码
let dog = {
    name:'旺财',
    eat(food1,food2){
        console.log('我喜欢吃' + food1 + food2)
    }
}

let cat = {
    name:'喵喵'
}

dog.eat.call(cat,'鱼','肉')

控制台输出结果为我喜欢吃鱼肉

而apply和call唯一的区别就在于,call传入参数时是一个一个写,call是以数组的形式传:

call:(cat, 'food1', 'food2')

apply:(cat, ['food1', 'food2'])

就这么一个区别没了

那么根据这几个功能,我们就可以尝试一步一步手写我们自己的call和apply了,先从call入手。

我们模拟的步骤分为三步:1.将函数设为该对象的属性 2.执行该函数 3.删除该函数

参照上述思路,提供一版:

复制代码
// 第一版
Function.prototype.call2 = function(context) {
    // 首先要获取调用call的函数,用this可以获取
    context.fn = this;
    context.fn();
    delete context.fn;
}

// 测试一下
 let foo = {
    value: 1
};

function bar() {
    console.log(this.value);
}

bar.call2(foo); // 1

先在所有函数对象原型上添加call2方法,让任何函数都能调用bar.call2(foo)

当调用bar.call2(foo)时,this指向bar函数,把bar函数赋值给了context.fn,也就是foo.fn = bar

context.fn()相当于foo.fn(),由于fn(现在是bar)是foo的方法,所以函数内部的this指向foo,执行console.log(this.value)时,this是foo,所以输出1

最后删除临时添加的fn属性,避免污染原对象。

接下来继续实现call指定参数的功能,我们可以从Arguments对象中取值,取出第二个到最后一个参数,然后放到一个数组中,由此可以得出第二版:

复制代码
// 第二版
Function.prototype.call2 = function(context) {
    context.fn = this;
    let arg = [...arguments].slice(1)
    context.fn(...arg)
    delete context.fn;
}

// 测试一下
var foo = {
    value: 1
};

function bar(name, age) {
    console.log(name)
    console.log(age)
    console.log(this.value);
}

bar.call2(foo, 'kevin', 18); 
// kevin
// 18
// 1

解释一下新增的代码:首先用一个数组arg记录传入的参数,传递参数调用时,先将arg数组展开为参数列表,相当于context.fn('kevin', 18)

最后一步,this参数可以传null,当为null的时候,视为指向window:

复制代码
var value = 1;

function bar() {
    console.log(this.value);
}

bar.call(null); // 1

针对函数,可以实现返回值,这里给出最终的第三版:

复制代码
// 第三版
Function.prototype.call2 = function (context) {
        var context = context || window;
    context.fn = this;

    let arg = [...arguments].slice(1)
    let result = context.fn(...arg)

    delete context.fn
    return result
}

// 测试一下
var value = 2;

var obj = {
    value: 1
}

function bar(name, age) {
    console.log(this.value);
    return {
        value: this.value,
        name: name,
        age: age
    }
}

bar.call2(null); // 2

console.log(bar.call2(obj, 'kevin', 18));
// 1
// Object {
//    value: 1,
//    name: 'kevin',
//    age: 18
// }

手写apply的实现和call类似,只是入参不一样:

复制代码
Function.prototype.apply = function (context, arr) {
    var context = Object(context) || window;
    context.fn = this;

    var result;
    if (!arr) {
        result = context.fn();
    }
    else {
                result = context.fn(...arr)
    }

    delete context.fn
    return result;
}

手写bind

bind和call与apply相比,又有细微的不同了,bind和call传参的方式是完全一模一样的,区别在于call是直接调用函数,bind是作为一个返回值返回一个函数,然后你才能去调用这个新函数

所以bind函数的两个特点就是:1.返回一个函数 2.可以传入参数

返回函数模拟实现中,关于指定this的指向,我们可以使用call或者apply实现:

复制代码
//第一版
Function.prototype.bind2 = function (context){
    var self = this
    return function () {
        return self.apply(context)
    }
}

接下来,关于参数的传递:

复制代码
var foo = {
    value: 1
};

function bar(name, age) {
    console.log(this.value);
    console.log(name);
    console.log(age);

}

var bindFoo = bar.bind(foo, 'daisy');
bindFoo('18');
// 1
// daisy
// 18

当需要传name和age两个参数时,可以在bind的时候,只传一个name,在执行返回的函数时,再传领一个参数age。

这里如果不使用rest,可以用arguments进行处理:

复制代码
//第二版
Function.prototype.bind2 = function (context) {
    var self = this
    //获取bind2函数从第二个参数到最后一个参数
    var args = Array.prototype.slice.call(arguments, 1)

    return function (){
    var bindArgs = Array.prototype.slice.call(arguments)
    return self.apply(context, args.concat(bindArgs))
    }
}

bind还有一个特点,就是当 bind 返回的函数作为构造函数的时候,bind 时指定的 this 值会失效,但传入的参数依然生效。举个例子:

复制代码
var value = 2;

var foo = {
    value: 1
};

function bar(name, age) {
    this.habit = 'shopping';
    console.log(this.value);
    console.log(name);
    console.log(age);
}

bar.prototype.friend = 'kevin';

var bindFoo = bar.bind(foo, 'daisy');

var obj = new bindFoo('18');
// undefined
// daisy
// 18
console.log(obj.habit);
console.log(obj.friend);
// shopping
// kevin

尽管在全局和 foo 中都声明了 value 值,最后依然返回了 undefind,说明绑定的 this 失效了

但是在这个写法中,我们直接将 fBound.prototype = this.prototype,我们直接修改 fBound.prototype 的时候,也会直接修改绑定函数的 prototype。这个时候,我们可以通过一个空函数来进行中转:

复制代码
// 第四版
Function.prototype.bind2 = function (context) {

    var self = this;
    var args = Array.prototype.slice.call(arguments, 1);

    var fNOP = function () {};

    var fBound = function () {
        var bindArgs = Array.prototype.slice.call(arguments);
        return self.apply(this instanceof fNOP ? this : context, args.concat(bindArgs));
    }

    fNOP.prototype = this.prototype;
    fBound.prototype = new fNOP();
    return fBound;
}

手写模拟new

new 运算符创建一个用户定义的对象类型的实例或具有构造函数的内置对象类型之一

先看看 new 实现了哪些功能。

复制代码
function Person (name, age) {
    this.name = name;
    this.age = age;

    this.habit = 'Games';
}

Person.prototype.strength = 80;

Person.prototype.sayYourName = function () {
    console.log('I am ' + this.name);
}

var person = new Person('Kevin', '18');

console.log(person.name) // Kevin
console.log(person.habit) // Games
console.log(person.strength) // 60

person.sayYourName(); // I am Kevin

我们可以看到,实例 person 可以:1.访问到 Otaku 构造函数里的属性 2.访问到 Otaku.prototype 中的属性;

接下来,我们可以尝试着模拟一下了。

因为 new 是关键字,所以无法像 bind 函数一样直接覆盖,所以我们写一个函数,命名为 objectFactory,来模拟 new 的效果。用的时候是这样的:

因为 new 的结果是一个新对象,所以在模拟实现的时候,我们也要建立一个新对象,假设这个对象叫 obj,因为 obj 会具有 Person 构造函数里的属性,我们可以使用 Person.apply(obj, arguments)来给 obj 添加新的属性。

然后,实例的 proto 属性会指向构造函数的 prototype,也正是因为建立起这样的关系,实例可以访问原型上的属性

复制代码
// 第一版代码
function objectFactory() {
    var obj = new Object(),
    Constructor = [].shift.call(arguments);
    obj.__proto__ = Constructor.prototype;
    Constructor.apply(obj, arguments);

    return obj;

};

我们先用new Object()的方法新建了一个对象obj,接着取出第一个参数,就是我们要传入的构造函数。此外因为shift会修改原数组,所以arguments会被去除第一个参数。然后将obj的原型指向构造函数,这样obj就可以访问到构造函数原型中的属性。最后使用apply,改变构造函数this的指向到新的对象,这样obj就可以访问到构造函数中的属性,返回obj即可

测试下:

复制代码
function Person (name, age) {
    this.name = name;
    this.age = age;

    this.habit = 'Games';
}

Person.prototype.strength = 60;

Person.prototype.sayYourName = function () {
    console.log('I am ' + this.name);
}

function objectFactory() {
    var obj = new Object(),
    Constructor = [].shift.call(arguments);
    obj.__proto__ = Constructor.prototype;
    Constructor.apply(obj, arguments);
    return obj;
};

var person = objectFactory(Person, 'Kevin', '18')

console.log(person.name) // Kevin
console.log(person.habit) // Games
console.log(person.strength) // 60

person.sayYourName(); // I am Kevin

假如构造函数有返回值

复制代码
function Person (name, age) {
    this.strength = 60;
    this.age = age;

    return {
        name: name,
        habit: 'Games'
    }
}

var person = new Person('Kevin', '18');

console.log(person.name) // Kevin
console.log(person.habit) // Games
console.log(person.strength) // undefined
console.log(person.age) // undefined

在这个例子中,构造函数返回了一个对象,在实例 person 中只能访问返回的对象中的属性。

而且还要注意一点,在这里我们是返回了一个对象,假如我们只是返回一个基本类型的值呢?

再举个例子:

复制代码
function Person (name, age) {
    this.strength = 60;
    this.age = age;

    return 'handsome boy';
}

var person = new Otaku('Kevin', '18');

console.log(person.name) // undefined
console.log(person.habit) // undefined
console.log(person.strength) // 60
console.log(person.age) // 18

这次尽管有返回值,但是相当于没有返回值进行处理。

所以我们还需要判断返回的值是不是一个对象,如果是一个对象,我们就返回这个对象,如果没有,我们该返回什么就返回什么

复制代码
// 最终版的代码
function objectFactory() {
    var obj = new Object(),
    Constructor = [].shift.call(arguments);
    obj.__proto__ = Constructor.prototype;
    var ret = Constructor.apply(obj, arguments);
    return typeof ret === 'object' ? ret : obj;

};

类数组对象

所谓的类数组对象,就是拥有一个length属性和若干索引属性的对象,举个例子:

复制代码
var array = ['name', 'age', 'sex'];

var arrayLike = {
    0: 'name',
    1: 'age',
    2: 'sex',
    length: 3
}

它只能通过Function.call间接调用

复制代码
var arrayLike = {0: 'name', 1: 'age', 2: 'sex', length: 3 }

Array.prototype.join.call(arrayLike, '&'); // name&age&sex

Array.prototype.slice.call(arrayLike, 0); // ["name", "age", "sex"] 
// slice可以做到类数组转数组

Array.prototype.map.call(arrayLike, function(item){
    return item.toUpperCase();
}); 
// ["NAME", "AGE", "SEX"]

类数组转数组:

复制代码
var arrayLike = {0: 'name', 1: 'age', 2: 'sex', length: 3 }
// 1. slice
Array.prototype.slice.call(arrayLike); // ["name", "age", "sex"] 
// 2. splice
Array.prototype.splice.call(arrayLike, 0); // ["name", "age", "sex"] 
// 3. ES6 Array.from
Array.from(arrayLike); // ["name", "age", "sex"] 
// 4. apply
Array.prototype.concat.apply([], arrayLike)

Arguments对象

Arguments 对象只定义在函数体中,包括了函数的参数和其他属性。在函数体中,arguments 指代该函数的 Arguments 对象。

举个例子:

复制代码
function foo(name, age, sex) {
    console.log(arguments);
}

foo('name', 'age', 'sex')

打印结果:

可以看到除了类数组的索引属性和length属性之外,还有一个callee属性

Arguments对象的length属性,表示实参的长度,举个例子:

复制代码
function foo(b, c, d){
    console.log("实参的长度为:" + arguments.length)
}

console.log("形参的长度为:" + foo.length)

foo(1)

// 形参的长度为:3
// 实参的长度为:1

Arguments对象的callee属性,通过它可以调用函数自身,讲个闭包经典面试题使用callee的解决方法:

复制代码
var data = []
for(var i = 0 ; i < 3; i++){
    data[i] = function() {
        console.log(i)//总是输出3
    }
}

因为var没有块级作用域,所有的函数都共享同一个i

复制代码
var data = [];

for (var i = 0; i < 3; i++) {
    (data[i] = function () {
       console.log(arguments.callee.i) 
    }).i = i;
}

data[0]();
data[1]();
data[2]();

// 0
// 1
// 2

创建对象的多种方式&优缺点

工厂模式:

复制代码
function createPerson(name) {
    var o = new Object();
    o.name = name;
    o.getName = function () {
        console.log(this.name);
    };

    return o;
}

var person1 = createPerson('kevin');

优点:简单;

缺点:对象无法识别,因为所有的实例都指向一个原型;

构造函数模式:

复制代码
function Person(name) {
    this.name = name;
    this.getName = function () {
        console.log(this.name);
    };
}

var person1 = new Person('kevin');

优点:实例可以识别为一个特定的类型;

缺点:每次创建实例时,每个方法都要被创建一次;

原型模式:

复制代码
function Person(name) {

}

Person.prototype.name = 'xianzao';
Person.prototype.getName = function () {
    console.log(this.name);
};

var person1 = new Person();

优点:方法不会重新创建;

缺点:

所有的属性和方法都共享;

不能初始化参数;

继承的多种方式&优缺点

原型链继承:

复制代码
function Parent () {
    this.name = 'xianzao';
}

Parent.prototype.getName = function () {
    console.log(this.name);
}

function Child () {

}

Child.prototype = new Parent();

var child1 = new Child();

console.log(child1.getName()) // xianzao

问题:引用类型的属性被所有实例共享,举个例子:

复制代码
function Parent () {
    this.names = ['xianzao', 'zaoxian'];
}

function Child () {

}

Child.prototype = new Parent();

var child1 = new Child();

child1.names.push('test');

console.log(child1.names); // ["xianzao", "zaoxian", "test"]

var child2 = new Child();

console.log(child2.names); // ["xianzao", "zaoxian", "test"]

组合继承:

复制代码
function Parent (name) {
    this.name = name;
    this.colors = ['red', 'blue', 'green'];
}

Parent.prototype.getName = function () {
    console.log(this.name)
}

function Child (name, age) {

    Parent.call(this, name);
    
    this.age = age;

}

Child.prototype = new Parent();
Child.prototype.constructor = Child;

var child1 = new Child('kevin', '18');

child1.colors.push('black');

console.log(child1.name); // kevin
console.log(child1.age); // 18
console.log(child1.colors); // ["red", "blue", "green", "black"]

var child2 = new Child('daisy', '20');

console.log(child2.name); // daisy
console.log(child2.age); // 20
console.log(child2.colors); // ["red", "blue", "green"]

优点:融合原型链继承和构造函数的优点,是 JavaScript 中最常用的继承模式。

相关推荐
热爱编程的小刘2 小时前
Lesson02---类与对象(上篇)
开发语言·c++
!停2 小时前
数据结构时间复杂度
c语言·开发语言·算法
mseaspring2 小时前
一款高颜值SSH终端工具!基于Electron+Vue3开发,开源免费还好用
运维·前端·javascript·electron·ssh
一叶星殇2 小时前
.NET 6 NLog 实现多日志文件按业务模块拆分的实践
开发语言·.net
lead520lyq2 小时前
Golang GPRC流式传输案例
服务器·开发语言·golang
xyq20242 小时前
《C 经典100例》
开发语言
不染尘.2 小时前
二分算法(优化)
开发语言·c++·算法
只是懒得想了2 小时前
Go语言ORM深度解析:GORM、XORM与entgo实战对比及最佳实践
开发语言·数据库·后端·golang
西门吹-禅2 小时前
react native --Expo---Android 开发
javascript·react native·react.js