js原型和原型链


2019-10-4 前端基础

预告

本文主要分两个部分,第一部分讲原理(原型和原型链),第二部分则是实践(封装 Canvas 验证码)

思维导图

原理

Prototype

众所周知,在 JavaScript 中,可以通过关键字 new 调用构造函数来创建一个实例对象。

function Person(name){
        this.name = name;
        this.say = function () {
            console.log(this.name);
        }
    }
    let lisi = new Person('lisi');
    let liwu = new Person('liwu');
    lisi.say() // lisi
    liwu.say() // liwu
    console.log(lisi.say === liwu.say); // false
1
2
3
4
5
6
7
8
9
10
11

可以看出, lisi 和 liwu 都有 say 这个方法,但是这两个方法并不是同一个。也就是说在创建对象的时候,每个实例对象都会有一套自己的属性和方法。很显然,这样造成了资源浪费。

这时候我们想,如果可以让实例对象引用同一个属性或方法就好了。所以 JavaScript 的作者引入了原型对象 [Prototype] 来解决这个问题。原型对象上有两个默认属性, constructorproto (下文会详细讲)。

function Person(name){
        this.name = name;
    }
    Person.prototype.say = function () {
        console.log(this.name);
    }
    let lisi = new Person('lisi');
    let liwu = new Person('liwu');
    console.log(lisi.say === liwu.say); // true
    console.log(lisi.hasOwnProperty('say'), liwu.hasOwnProperty('say')); // false false
1
2
3
4
5
6
7
8
9
10

这个时候可以看到,构造的新的实例对象都有 say 方法,但是hasOwnProperty('say')返回的结果却是 false 。这说明实例对象自身是没有 say 方法的,之所以可以使用 .say 的方式来调用,是因为在使用 . 语法调用对象方法的时候会触发对象自身的 [get] 操作。

[get] 操作会优先查找自身的属性,没有找到则会通过原型链来逐级查找上级的原型对象,直到 JavaScript 顶层的 Object 对象。所以此处可以说明实例对象会继承构造函数的原型对象上的属性和方法。

但是正因为如此,我们需要注意的是:因为原型对象的属性和方法是会被所有实例对象继承的,所以使用的时候要慎重考虑该属性或方法是否适合放在原型对象上。比如Person有一个 age 属性:

  Person.prototype.age = 18;
    console.log(lisi.age, liwu.age); // 18 18
    Person.prototype.age = 20;
    console.log(lisi.age, liwu.age); // 20 20
1
2
3
4

因为 age 属性是引用的Person的原型对象上的,所以原型对象上的属性值改了,所有的实例对象相应的属性值都会改动。这时候我们就不得不考虑,是否有必要将 age 属性放在原型对象了,毕竟鲁迅曾经说过:‘每个人都是都一无二的’。

强行插图,我们再来看下面这种情况:

 lisi.say = function() {
        console.log('oh nanana');
    };
    lisi.say(); // oh nanana
    liwu.say(); // liwu
    console.log(lisi.hasOwnProperty('say'), liwu.hasOwnProperty('say')); // true false
1
2
3
4
5
6

这是为什么呢,其实和之前类似,是因为.语法在赋值的时候触发了对象的 [set] 方法,所以会给 lisi 自身加上一个 say 方法。而在调用方法时,最先找到自身的 say 方法调用,输出oh nanana 。因为操作都是在 lisi 这个对象本身,所以对 liwu 没有影响。

constructor

constructor 即为 构造函数,构造函数其实和普通的函数没有什么区别,对构造函数使用new运算符,就能生成实例,并且 this 变量会绑定在实例对象上。

对于Person来讲,会有 prototype 属性指向它的原型对象,而在 Person.prototype 上又有 constructor 属性指向它对应的构造函数,所以这是一个循环的引用。大概是这样:Person -> Person.prototype —> Person.prototype.constructor -> Person。

    console.log(Person.constructor === Function) // true
    console.log(Person.prototype.constructor === Person) // true
    console.log(lisi.constructor === Person.prototype.constructor); // true
    console.log(lisi.hasOwnProperty('constructor')) // false
1
2
3
4

从中可以看出,Person的 constructor 是 Function,lisi 的 constructor 是 Person。这是因为,他们自身是都没有 constructor 属性的,而是从他们所继承的原型对象上继承得来的 constructor 属性。即 lisi.constructor === Person.prototype.constructor 和 Person.constructor === Function.prototype.constructor

延用上面的栗子,我们在加点东西:

function Chinese() {
        this.country = '中国';
    }
    Person.prototype = new Chinese();
    let lisisi = new Person('lisisi');
    console.log(lisi.country, lisisi.country); // undefined  中国
1
2
3
4
5
6

在这个栗子中,我们将Person.prototype整体赋值成了Chinese的实例对象。注意,是赋值的实例对象,不是构造函数。上面打印结果是 lisisi 有 country 属性,这个我们好理解,因为 lisisi 继承了Person.prototype ,而Person.prototype被我们赋值成了Chinese的实例对象,自然会继承Chinese实例对象的 country 属性。

但是 lisi 为什么没有 country 属性呢,之前改得 say 方法明明受影响啊。我们打印出 lisi 和 lisisi 的完整结构来看一下:

可以看到,其实是因为我们将Person.prototype整体替换成了 Chinese 实例对象,相当于改变了Person.prototype的地址,但是 lisi 在实例化的时候,引用的是之前的Person.prototype地址,这两者之间没有联系,自然不会有影响。而之前的 say 方法是用Person.prototype.say的形式改的,lisi 继承的依旧是同一地址上的 say 方法,所以会受影响。

这个例子之所以放在这里讲,而不是 prototype 那里,是因为这个方法会有一点副作用,将Person.prototype整体赋值成了Chinese的实例对象,会导致原来的 constructor 属性也被覆盖掉。

    console.log(lisisi instanceof Person); // true
    console.log(Person.prototype.isPrototypeOf(lisisi)); // true
    console.log(Object.getPrototypeOf(lisisi)); // Chinese {country: "中国"}
    
    // instanceof做的事是判断在`lisisi`的整条[Prototype]链中是否有指向 Person.prototype 的对象。
    // isPrototypeOf做的事是判断在`lisisi`的整条[Prototype]链中是否出现过 Person.prototype。
    // 它们的区别在于前者要访问构造函数,后者直接访问原型对象。
    
    console.log(lisisi.__proto__ === Person.prototype); // true   
    // __proto__指向实例对象对应的原型对象,但不一定是其构造函数的原型对象,因为prototype可以修改
    
    console.log(lisisi.constructor === Chinese); // true
1
2
3
4
5
6
7
8
9
10
11
12

从上可以看出,虽然 lisisi 继承的依然是的Person.prototype,但是由于Person.prototype指向了Chinese的实例对象。所以,这个时候 lisisi 的 constructor 已经不是Person了,而是继承了Chinese实例对象的 constructor,也就是构造函数Chinese。为了解决这个问题,我们需要手动修正 constructor 的指向。

Person.prototype = new Chinese();
    Person.prototype.constructor = Person;
    let lisisi = new Person('lisisi');
    console.log(lisisi.constructor === Person); // true
1
2
3
4

从这个栗子也可以说明,使用引用类型的 constructor 是并不安全的,因为他们可以修改。不过基础类型的 constructor 都是只读的,都指向对应基础类型构造函数。

 let a = 'oh nanana', b = 0, c = true;
    console.log(a.constructor, b.constructor, c.constructor);
    // ƒ String() { [native code] } ƒ Number() { [native code] } ƒ Boolean() { [native code] }
    
    a.constructor = {};
    b.constructor = {}; 
    c.constructor = {};
    console.log(a.constructor, b.constructor, c.constructor);
    // ƒ String() { [native code] } ƒ Number() { [native code] } ƒ Boolean() { [native code] }
1
2
3
4
5
6
7
8
9

proto

实例对象有__proto__属性,指向实例对象对应的原型对象,即lisi.proto === Person.prototype。但是直接用.__proto__的写法来设置原型对象的写法是不被赞同的,因为这样还会有除了性能消耗以外的问题。MDN 中这样说到:

由于现代 JavaScript 引擎优化属性访问所带来的特性的关系,更改对象的 [[Prototype]]在各个浏览器和 JavaScript 引擎上都是一个很慢的操作。其在更改继承的性能上的影响是微妙而又广泛的,这不仅仅限于 obj.proto = ... 语句上的时间花费,而且可能会延伸到任何代码,那些可以访问任何[[Prototype]]已被更改的对象的代码。如果你关心性能,你应该避免设置一个对象的 [[Prototype]]。相反,你应该使用 Object.create()来创建带有你想要的[[Prototype]]的新对象。

在《你不知道的JavaScript》中说到,__proto__的本质其实更像是getter/setter,大致实现为:

Object.defineProperty( Object.prototype, "__proto__", {
        get: function() {
            return Object.getPrototypeOf( this );
        },
        set: function(o) {
            // ES6 中的 setPrototypeOf(obj, prototype) 设置原型对象
            Object.setPrototypeOf(this, o );
            return o;
        }
    } );
1
2
3
4
5
6
7
8
9
10

何为原型链

现在我们知道,实例对象的__proto__属性指向其对应的原型对象。而在原型对象 prototype 上又有 constructor 和__proto__属性,此时的__proto__又指向上级对应的原型对象,最终指向Object.prototype, 而Object.prototype.proto === null。这就构成了原型链,而原型链最终都是指向 null。

还是来看个栗子:

 function Person(name){
        this.name = name;
    }
    let lisi = new Person('lisi');
1
2
3
4

在这个栗子中可以找到两条原型链,我们逐一来看。

  • 第一条:首先,lisi.proto === Person.prototype,而原型对象也是对象,所以 Person.prototype.proto === Object.prototype,最后,Object.prototype.proto === null。即:
 lisi.__proto__.__proto__.__proto__ === null;
1
  • 第二条:Person这个函数对象的__proto__指向的应该是它的构造函数对应的原型对象,Person.proto === Funcion.prototype,然后Funcion.prototype.proto === Object.prototype,最后一样都回到 null。即:
 Person.__proto__.__proto__.__proto__ === null;
1

到这里,相信你已经可以理解文章开头的那张图了。

new方法做了什么

文章中创建实例对象是通过new运算符。new命令的作用,就是执行构造函数,返回一个实例对象。

那么在执行new操作的过程中到底做了哪些事呢?我们可以看到,new 操作返回的实例对象具有两个特征:

  1. 具有构造函数中定义的 this 指针的属性和方法
  2. 具有构造函数原型上的属性和方法

于是我们大概可以知道,使用new命令时它所执行的几个步骤:

  1. 创建一个空对象,并将这个空对象的__proto__,指向构造函数的原型对象 [prototype] ,使其继承构造函数原型上的属性。
  2. 改变构造函数内部 this 指针为这个空对象(如果有传参,需要将参数也导入构造函数)
  3. 执行构造函数中的代码,使其具有构造函数 this 指针的属性。 所以我们可以简单模拟实现一个具有new命令功能的函数。
 function newObj() {
        let o, f = [].shift.call(arguments); // 取出参数的第一个成员,即构造函数,shift 会改变原有数组
        o = Object.create(f.prototype); // 创建一个继承了构造函数原型的新对象
        f.call(o, ...arguments); // 执行构造函数使得新对象获取相应属性
        return o;
    }
    let zs = newObj(Person, 'zs');
    console.log(zs instanceof Person); // true
1
2
3
4
5
6
7
8

我们打印一下 zs 实例对象:

可以看出 zs 是继承了Person的原型的,但是还有一个需要注意的点:假如构造函数 return 了一个对象的话,new命令会优先返回构造函数 return 的对象。如果是其他类型的数据,则会忽略,和没有返回值(函数默认返回 undefined )是一样的。这里就不再举例,感兴趣的伙伴可以自己实践一下,也有助于理解。

实践

Canvas验证码

为什么选择验证码来做这个实践呢,因为这在我们平时的项目非常常见。也许由于需求等各种原因我们平时用的是插件或者是后端返回的验证码,但是没关系,我们可以借此作为练习,加深对构造函数和原型的理解。

  • 需求

首先,我们要实现这样一个图片验证码。

简单分析一下几点需求:

  1. 随机四个(或n个)数字字母(或汉字或其他),随机颜色,随机排列。
  2. 数个点随机颜色,随机排列;数条线随机颜色,随机长度,随机排列。
  3. 随机背景色。
  4. 点击更新视图。
  5. 最重要的一点是需要可以拿到每次图片上的文字,进而与用户输入验证码比对。
  • 实现

了解了上面的几点需求,回想一下之前学习的内容,再来思考一下如何实现。

现在我们需要一个对象,然后调用对象的某个方法可以将验证码画出来。所以我们需要一个构造函数,用来实例化对象。

 function Regcode() {}
1

构造函数接受一些参数,用来定制验证码的点、线、字的各种属性(颜色、长短、大小等)。

function Regcode(params = {}) {
        let p = Object.assign({...}, params); // 这里有定义好的属性和默认值
        Object.keys(p).forEach(k => { // 将所有属性组合后添加到this上
            this[k] = p[k];
        });
    }
1
2
3
4
5
6
  • draw 方法

可是我们现在并不知道需要哪些参数,但是根据需求我们可以先定下大概的框架。首先我们需要一个 draw 方法,作为验证码的绘制方法。draw 方法接收两个参数,Canvas 的 dom 对象,用来创建绘图的2d对象。还需要一个回调函数 callback,用来接收每次绘制的文字。

我们把 draw 方法放在Regcode的原型上,这样所有的实例对象都可以继承这些方法,而不是自己独立有一套。

 Regcode.prototype.draw = function(dom, callback = function () {}) { // 绘图 };
1

在 draw 方法中,可以想到的是,我们需要创建 canvas 的 2d对象,创建画布,然后开始依次绘制点、线、文字。

 Regcode.prototype.draw = function(dom, callback = function () {}) { // 绘图
        // 获取canvas dom
        if (!this.paint) { // 如果没有2d对象,再进行赋值操作
            this.canvas = dom; // 保存到this指针,方便使用
            if (!this.canvas) return;
            this.paint = this.canvas.getContext('2d'); // 保存到this指针,方便使用
            if (!this.paint) return;
            
            // 回调函数赋值给this,方便使用
            this.callback = callback;
        }
        // 随机画布颜色,使用背景色
        let colors = this.getColor(this.backgroundColor);
        this.paint.fillStyle = `rgba(${colors[0]}, ${colors[1]}, ${colors[2]}, 0.8)`;
        // 绘制画布
        this.paint.fillRect(0, 0, this.canvas.width, this.canvas.height);
        // 绘图
        this.arc();
        this.line();
        this.font();
    };
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

我们需要简单判断一下是否有 dom 对象和2d对象,其实应该判断参数是否为 dom 对象,可以通过判断节点类型或者通过 dom instanceof HTMLElement(谷歌和火狐支持)来判断。但是这里因为要求不高,所以只是简单判断。回调函数只是简单的赋值给了实例对象,具体的使用稍后再看。

  • 随机颜色 从中我们可以看到整体的思路,还需要哪些方法。需要注意的是,在创建画布的时候,我们使用了获取背景色的一个方法。在之前的需求中我们可以看到,最高频的两个词是随机和颜色,所以肯定是需要将这两个方法单独封装的。

随机颜色这里采用的是 rgb 的强度值(0 ~ 255, 由暗 -> 亮),需要指定两个颜色区间:前景色(文字、线条)和背景色(画布背景)。因为需要将文字和背景颜色区分,避免色值太接近无法识别,所以默认前景色区间 [10, 80],背景色区间 [150, 250]。

Regcode.prototype.getColor = function(arr) { // 随机获取颜色
        let colors = new Array(3).fill(''); // 创建一个长度为3的数组,值都填充为 ''
        colors = colors.map(v => this.getRand(...arr)); // 每个成员随机获取一个强度值重组为新数组
        return colors;
    };
1
2
3
4
5

因为 rgb 颜色通常表示为 rgba(0,0,0,0.8),最后一位是透明度,这里没有参加随机。所以只考虑前3个数,在指定的强度区间内,只需要依次随机出3个数就好。所以在上面的方法中,还需要做的就是随机在一个数值区间中取值。

  Regcode.prototype.getRand = function(...arr) { // 获取某个区间的随机数
        arr.sort((a, b) => a - b); // 将传入的参数从小到大排序
        return Math.floor(Math.random() * (arr[1] - arr[0]) + arr[0]);
    };
1
2
3
4
  • 绘制线条

有了随机颜色,绘制线条就方便多了。lineNum 用于指定绘制几条线,默认为2条。之前说过前景色(foregroundColor) 和 背景色 (backgroundColor)也是可以传参的,文字、线条、点都使用前景色。在绘制线条的时候,还需要计算出线条的随机起止坐标,在这里 canvas 的宽高范围内都允许,这样就可以做到随机长度。

Regcode.prototype.line = function() { // 绘制线条
        for (let i = 0; i < this.lineNum; i++) {
            // 随机获取线条的起止坐标
            let x = this.getRand(0, this.canvas.width), y = this.getRand(0, this.canvas.height),
                endx = this.getRand(0, this.canvas.width), endy = this.getRand(0, this.canvas.width);
            this.paint.beginPath(); // 开始绘制
            this.paint.lineWidth = this.lineWidth;
            // 随机获取路径颜色
            let colors = this.getColor(this.foregroundColor); // 使用前景色
            this.paint.strokeStyle = `rgba(${colors[0]}, ${colors[1]}, ${colors[2]}, 0.8)`;
            // 指定绘制路径
            this.paint.moveTo(x, y);
            this.paint.lineTo(endx, endy);
            this.paint.closePath();
            this.paint.stroke(); // 进行绘制
        }
    };
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  • 绘制圆点

绘制圆点要注意的是需要随机获取圆心的位置,即分别随机获取在宽高范围内的 (x, y) 坐标。dotNum 是允许传入的需要绘制圆点的个数,默认为10,dotR 是半径,默认为 1。

Regcode.prototype.arc = function() { // 绘制圆点
        for (let i = 0; i < this.dotNum; i++) {
            // 随机获取圆心
            let x = this.getRand(0, this.canvas.width), y = this.getRand(0, this.canvas.height);
            this.paint.beginPath();
    
            // 指定圆周路径
            this.paint.arc(x, y, this.dotR, 0, Math.PI * 2, false);
            this.paint.closePath();
    
            // 随机获取路径颜色
            let colors = this.getColor(this.foregroundColor);
            this.paint.fillStyle = `rgba(${colors[0]}, ${colors[1]}, ${colors[2]}, 0.8)`;
    
            // 绘制
            this.paint.fill();
        }
    };
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  • 绘制文字 绘制文字稍微麻烦一些,需要先从定义好的验证码因子(允许通过 content 参数自定义,默认为 acdefhijkmnpwxyABCDEFGHJKMNPQWXY12345789,这里去掉了类似于字母 b 和 数字 6 这样的容易混淆的字符。)中,随机获取指定长度(允许通过参数自定义)的验证码。
Regcode.prototype.getText = function() { // 随机获取验证码
        let len = this.content.length, str = '';
        for (let i = 0; i < this.len; i++) { // 随机获取每个因子,组成验证码
            str += this.content[this.getRand(0, len)];
        }
        return str;
    };
1
2
3
4
5
6
7

绘制文字的时候需要注意以下几点:

  1. 需要通过回调函数将当前绘制的文字输出。
  2. 需要指定文字的旋转角度、字体类型、文字颜色、绘制风格(填充或者不填充)。
  3. 需要获得文字的实际宽度,用来确定单个文字的活动范围。
Regcode.prototype.font = function() { // 绘制文字
        let str = this.getText(); // 获取验证码
        this.callback(str); // 利用回调函数输出文字,用于与用户输入验证码进行比对
        // 指定文字风格
        this.paint.font = `${this.fontSize}px ${this.fontFamily}`;
        this.paint.textBaseline = 'middle'; // 设置文本基线,middle是整个文字所占方框的高度的正中。
        // 指定文字绘制风格
        let fontStyle = `${this.fontStyle}Text`;
        let colorStyle = `${this.fontStyle}Style`;
        for (let i = 0; i < this.len; i++) { // 循环绘制每个字
            let fw = this.paint.measureText(str[i]).width; // 获取文字绘制的实际宽度
            // 获取每个字的允许范围,用来确定绘制单个文字的横坐标
            let x = this.getRand(this.canvas.width / this.len * i, (this.canvas.width / this.len) * i + fw/2);
            // 随机获取字体的旋转角度
            let deg = this.getRand(-6, 6);
            // 随机获取文字颜色
            let colors = this.getColor(this.foregroundColor);
            this.paint[colorStyle] = `rgba(${colors[0]}, ${colors[1]}, ${colors[2]}, 0.8)`;
            // 开始绘制
            this.paint.save();
            this.paint.rotate(deg * Math.PI / 180);
            this.paint[fontStyle](str[i], x, this.canvas.height / 2);
            this.paint.restore();
        }
    };
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
  • 自定义参数 到这里,单次绘制基本完成,我们再回头来看看有哪些允许自定义的参数。
function Regcode(params = {}) {
        let p = Object.assign({
            lineWidth: 0.5,  // 线条宽度
            lineNum: 2,  // 线条数量
            dotNum: 10, // 点的数量
            dotR: 1, // 点的半径
            foregroundColor: [10, 80], // 前景色区间
            backgroundColor: [150, 250], // 背景色区间
            fontSize: 20, // 字体大小
            fontFamily: 'Georgia', // 字体类型
            fontStyle: 'fill', // 字体绘制方法,fill/stroke
            content: 'acdefhijkmnpwxyABCDEFGHJKMNPQWXY12345789', // 验证码因子
            len: 4 // 验证码长度
        }, params);
        Object.keys(p).forEach(k => { // 将所有属性组合后添加到this上
            this[k] = p[k];
        });
        this.canvas = null; // canvas dom
        this.paint = null; // canvas 2d
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  • 点击更新画布 最开始分析需求的时候说过,需要点击可以更新验证码的功能,所以,现在还得加点东西。我们要更新画布,首先要清空之前的画布:
 Regcode.prototype.clear = function() { // 清空画布
        this.paint.clearRect(0, 0, this.canvas.width, this.canvas.height);
    };
1
2
3

清空之后,可以再次绘制以及 dom 点击事件的监听。

// 更新画布
    Regcode.prototype.drawAgain = function() {
        this.clear();
        this.draw(this.callback);
    };
    
    // 监听点击事件
    Regcode.prototype.draw = function(dom, callback = function () {}) { // 绘图
        // 获取canvas dom
        if (!this.paint) {
            ...
            ...
            // 回调函数赋值给this,方便使用
            this.callback = callback;
            this.canvas.onclick = () => {
                this.drawAgain();
            }
        }
        ...
        ...
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  • 测试以及小结 现在,整个验证码就写完了,当然需要测试一下:
let reg = new Regcode(); // 不传值,统一走默认值
    reg.draw(document.querySelector('#regcode'), r => {
        console.log(r); // WwB5
    });
    console.log(reg);
1
2
3
4
5

看看打印出来的实例对象:

显而易见的是实例对象拥有构造函数中定义好的属性以及默认值,而且继承了原型上的所有方法。

其实这种验证码的实现形式有很多,比如其实可以在实例化的时候就将所有的参数传入。我们之前了解new命令的原理,所以知道其实在实例化对象的时候,会执行一遍构造函数。这样,我们可以将 draw 方法和点击事件监听一并放在构造函数中,也就不需要在外部再调用一次 draw 方法。

当然现在这种方式也有好处,就是足够灵活。其实这些都只是使用上的小差别,大体思路是一致的。

Thomas: 10/11/2019, 3:14:55 PM