【前端发动机】JavaScript 原型和原型链及 canvas 验证码实践

8,807 阅读14分钟

前言

最近在复习 JavaScript 基础,因为工作以后基本上没用过,天天都是拿起框架加油干,确实大部分都忘了。到了原型和原型链这一部分,觉得自己理解的比较模糊。又翻阅了《你不知道的 JavaScript》、阮一峰老师的JavaScript继承机制的设计思想还有网络上的各种文章,收获满满(感谢各位作者大佬)。所以整理成这篇文章,加深自己的印象,也希望对大家有所帮助。

文章收录在作者代码库 fe-code,主要是个人学习的代码以及文章,觉得有帮助可以点个小星星,会持续更新。

另外也希望大家可以支持一下我的开源作品 Vchat — 从头到脚,撸一个社交聊天系统(vue + node + mongodb),这是 源码仓库。感谢!

预告

本文主要分两个部分,第一部分讲原理(原型和原型链),第二部分则是实践(封装 Canvas 验证码、手写 Promise),实际应用原型、构造函数,做到学以致用。 如果你对原型和原型链已经很熟悉了,也可以直接跳过原理部分,直接看实践

思维导图

不太了解原型链的同学可能会觉得有点乱,没关系,看完文章再回过头来看,就很清晰了。

原理

打好基础,才能建设万丈高楼。

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

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

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

    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

这个时候可以看到,构造的新的实例对象都有 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

因为 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

这是为什么呢,其实和之前类似,是因为.语法在赋值的时候触发了对象的 [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

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

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

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

在这个栗子中,我们将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
    

从上可以看出,虽然 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

从这个栗子也可以说明,使用引用类型的 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] }

__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;
        }
    } );

何为原型链

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

还是来看个栗子:

    function Person(name){
        this.name = name;
    }
    let lisi = new Person('lisi');

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

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

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

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); // 取出参数的第一个成员,即构造函数
        o = Object.create(f.prototype); // 创建一个继承了构造函数原型的新对象
        f.call(o, ...arguments); // 执行构造函数使得新对象获取相应属性
        return o;
    }
    let zs = newObj(Person, 'zs');
    console.log(zs instanceof Person); // true

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

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

实践 Canvas验证码

光说不练假把式,实践过程也能帮助我们更好地理解。

以下内容需要一些基础的 Canvas 知识,不太了解的同学建议结合 Canvas 参考手册 一起看,本文重点讲实现流程。源码依然在 fe-code

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

需求

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

简单分析一下几点需求:

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

实现

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

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

    function Regcode() {}

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

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

draw 方法

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

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

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

在 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();
    };

我们需要简单判断一下是否有 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;
    };

因为 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]);
    };

绘制线条

有了随机颜色,绘制线条就方便多了。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(); // 进行绘制
        }
    };

绘制圆点

绘制圆点要注意的是需要随机获取圆心的位置,即分别随机获取在宽高范围内的 (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();
        }
    };

绘制文字

绘制文字稍微麻烦一些,需要先从定义好的验证码因子(允许通过 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. 需要获得文字的实际宽度,用来确定单个文字的活动范围。
    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();
        }
    };

自定义参数

到这里,单次绘制基本完成,我们再回头来看看有哪些允许自定义的参数。

    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
    }

点击更新画布

最开始分析需求的时候说过,需要点击可以更新验证码的功能,所以,现在还得加点东西。我们要更新画布,首先要清空之前的画布:

    Regcode.prototype.clear = function() { // 清空画布
        this.paint.clearRect(0, 0, this.canvas.width, this.canvas.height);
    };

清空之后,可以再次绘制以及 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();
            }
        }
        ...
        ...
    }

测试以及小结

现在,整个验证码就写完了,当然需要测试一下:

    let reg = new Regcode(); // 不传值,统一走默认值
    reg.draw(document.querySelector('#regcode'), r => {
        console.log(r); // WwB5
    });
    console.log(reg);

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

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

当然现在这种方式也有好处,就是足够灵活。其实这些都只是使用上的小差别,大体思路是一致的。作者之前用 class 方式也写过一版,在作者的开源作品 vchat 中的验证码,就是用的那一版。不过现在原型这一版,相对来说做了许多优化,有兴趣的同学可以对比看一下。

手写Promise

对于 Promise 的实现,网上有很多,作者之前也写过一篇 站住,你这个Promise!。里面有详细的介绍和手写流程,所以就不再赘叙了,有需要的同学可以前去看看。

相关文章

由衷感谢这些文章的作者。

交流群

qq前端交流群:960807765,欢迎各种技术交流,期待你的加入

公众号

欢迎关注公众号 前端发动机,江三疯的前端二三事,专注技术,也会时常迷糊。希望在未来的前端路上,与你一同成长。

后记

如果你看到了这里,且本文对你有一点帮助的话,希望你可以动动小手支持一下作者,感谢🍻。文中如有不对之处,也欢迎大家指出,共勉。