你不知道的JavaScript之路

1,202 阅读19分钟

前言

想要成为一个专业的前端er,学习JavaScript是一条必经之路。曾经的我是一个前端新手时,只会写点html+css,但是不敢写JavaScript,觉得这个太难了,看到它就害怕,但是后来还是硬着写,写了一段时间以后感觉JavaScript写着还行,逐渐地也就喜欢上这门语言了。接下来我根据我自己学过的JavaScript技术写点学习总结,大体内容是函数作用域,闭包,this指向,原型链,ES6常用问题等等。

1.函数作用域

1.1 作用域链查找机制

<script>
//全局作用域

function add(a){
    //add函数作用域
    console.log(a + b);
}

var b = 2;
add(1);
</script>

上述代码执行 add()后会再执行console.log()这句代码,但括号里有a和b两个变量相加,js引擎就会先在add()函数作用域内寻找a和b两个变量,此时a作为函数的实参被找到并被赋值到了括号里的a变量,但此时还未找到b变量的值,这个时候引擎就会往外层嵌套的作用域里去寻找b这个变量,直到引擎在最外层作用域(全局作用域)还未找到b变量时就会抛出Uncaught ReferenceError: b is not defined(引用错误,b变量未定义)

1.2 变量提升

//一般我们定义变量是先声明再赋值使用的
    var a;
    a = 1;
    console.log(a);  //此时会打印出a的值为1
//但还有另一种写法,得到的结果也相同
    a = 1;
    console.log(a);  //这时也会打印出1
    var a;

另一种写法就是变量提升的一个案例体现,由于JavaScript是没有编译阶段的,它是边解释边执行的,所以它会有一个预解释的过程,函数声明和变量声明每次会被解释器提到方法体的最顶层,这也是声明提升的概念。接下来再看另一个案例:

//初始化a和b
var a = 1;
var b = 2;
console.log(a,b); //这里会打印出a和b的值也就是1,2

//再看看另一个写法
var a = 1;
console.log(a,b); //这里会打印出1,undefined
var b = 2;

产生上面代码两种结果的原因其实是因为 var b被提升了,但是初始化的var b=2并没有被提升,这说明在js里只有声明的变量才会被提升,初始化的不会。变量提升后的代码如下:

//由于b的值初始化时undefined, js也是按照上下文执行的,所以此时打印b结果才是undefined
var a = 1;
var b;
console.log(a,b);
b = 2;

1.3 函数提升

add();

function add(){
    console.log(1);
}

函数提升与变量提升是一样的,定义完add函数以后,它会被提升到最顶层,然后add就可以调用到了, 但有的写法不行。

add();

var add = function(){
    console.log(1);
}

此时控制台会打印出Uncaught TypeError: add is not a function(类型错误:add不是一个函数),因为这个时候触发的是变量提升,var add被提升到了最顶层,它的初始化值也就是undefined,所以才会报错。

1.4 声明提升综合应用总结

var a;
function a(){}
console.log(a); //打印出function a()

声明提升的顺序是变量声明优先于函数声明,但是函数的声明会覆盖未定义的同名变量,再看另一个例子:

//例子
var a = 1;
function a(){}
console.log(a); //打印出1

//例子等价于下面代码
var a;
function a(){}
a = 1;
console.log(a);
  • 重复的变量声明是无效的,因为它会被提多个var a上去,但是无论前面是什么,后面的函数声明都能将其覆盖。
  • 由于声明提升的顺序问题,同名的函数声明会优先于变量声明。
  • 后面的函数声明会覆盖掉前面的函数声明。


再看看一个笔试题

console.log(a);
var a = 1;
function foo(){
    console.log(a);
    var a = 2;
    console.log(a);
}
foo();
console.log(a);
//打印顺序结果是 undefined undefined 2 1 

把上面的代码通过变量提升以后:

var a;  
console.log(a);  //第一处打印
a = 1;
function foo(){
    var a;
    console.log(a);  //第二处打印
    a = 2;
    console.log(a);  //第三处打印
}
foo();
console.log(a);   //第四处打印

  • 第一个打印a的值是全局作用域的初始化值a也就是undefined,。
  •  第二个打印a的值是函数作用域的初始化a也是undefined。
  • 第三个打印a的值是已经在函数作用域被赋值2的值也就是2。
  • 第四个打印a的值是在全局作用域被赋值1的值也就是1。

1.5 执行流

js由全局环境开始执行,如下图当全局环境执行到fn1(50)时,此时就会去执行fn1的环境,然后fn1执行到fn2(20)和fn(30)时就会再去执行fn2的环境,直到fn2()被执行完成。



1.6 函数上下文环境

var a = 1;
var b = 2;

function fn1(c){
    var a = 10;
    function fn2(c){
        var a = 100;
        b = a + c;
        console.log(b);
    }
    fn2(20);
    fn2(30);
}
fn1(50);

每个执行环境中都有一个对应的变量对象,它把环境中定义的变量和函数都保存在这个对象里。




2.闭包


2.1 闭包的概念

红宝书 上对于闭包的定义:闭包是指有权访问另外一个函数作用域中的变量的函数
MDN 对闭包的定义为:闭包是指那些能够访问自由变量的函数。 (其中自由变量,指在函数中使用的,但既不是函数参数arguments也不是函数的局部变量的变量,其实就是另外一个函数作用域中的变量。)

var a = 1;
function fn1(){
    var b = 2;
    function fn2(){
        console.log(b);
    }
    return fn2;
}
var fn = fn1();
fn();
// console.log(b)  这里会打印出 b is not defined

先来理一下上面代码的作用域,上面函数分为全局作用域、fn1作用域、fn2作用域。在全局作用域下访问b变量,因b变量是在f1作用域内部定义的,js的查找机制也是从里到外的,故b变量无法找到,所以才会打印出b变量未定义。但是在fn2作用域下访问b变量,fn2就会先在fn2作用域下查找b变量,如果未找到就会一直往外层作用域查找有没有b变量也就是会在最近的fn1作用域下找到b变量然后直接赋给fn2的b。

这样看,闭包就是fn2能访问到其他外层作用域的变量,但是外层作用域不能直接访问到内部作用域的变量,也可以理解为定义在一个函数内部的函数,闭包的本质是函数内部和函数外部之间连接的一条桥梁。

2.2 闭包的应用

//闭包用作计数器
//被用作读取函数内部的变量,这些变量始终被存在内存里
function sum(){
    var n = 0;
    function inc(){
        return n++;
    }
    return inc;
}
var inc2 = sum();
console.log(inc2()); //打印出 0
console.log(inc2()); //打印出 1
console.log(inc2()); //打印出 2
inc2 = null; //释放该内存

计数器的闭包函数被创建以后,将sum返回的inc函数给了inc2,然后返回的n变量存在inc2的内存块内,三次打印inc2()的值就是调用了三次 n++ ,故三次打印依次打印出了0、1、2,最后再将inc2的内存块释放掉,因为闭包使得函数里的变量始终存在内存里,内存会占很多消耗,最终会造成浏览器性能问题也就是内存溢出问题。

//创建私有变量和私有函数
function student(name){
    var age;
    function setAge(a){
        age = a;
    }
    function getAge(){
        return age;    
    }
    return{
        name:name,
        setAge: setAge,
        getAge: getAge
    }
}

var s = student('cc'); // name:'cc'
s.setAge(20);  // age: 20
console.log(s.name,s.getAge()); //打印出 cc, 20
s = null;

在student函数里创建了一个私有变量age,使用私有函数setAge去间接给age赋值,使用私有函数getAge返回age的值。

2.3 闭包的注意事项

function sum(){
    var arr = [];
    for(var i = 0; i< 10; i++){
        arr[i] = function(){
                return i;
        }
    }
    return arr;
}
var sum2 = sum();
console.log(sum2[0]()); //打印出 10

这个时候就会疑惑为什么打印不是0,其实是因为sum[0]返回的是一个函数,根据查找机制函数返回的i返回的是循环结束以后i++的值也就是10,那么把代码修改一下改成想要的结果:

//es6  将var改成let
function sum(){
    var arr = [];
    for(let i = 0; i< 10; i++){
        arr[i] = function(){
                return i;
        }
    }
    return arr;
}
var sum2 = sum();
console.log(sum2[0]()); //打印出 0

//自执行函数
function sum(){
    var arr = [];
    for(let i = 0; i< 10; i++){
        arr[i] = (function(n){
                return function(){
                       return n;
                }
        })(i)
    }
    return arr;
}
var sum2 = sum();
console.log(sum2[0]()); //打印出 0

第一个例子里ES6的let与var区别后面再说,再看第二个例子自执行函数将每次循环的i值当做实参赋给了形参n,然后再将n返回出去,最后就得到了每次循环的i值。

function sum(){
    var n = 0;
    function inc(){
        return n++;
    }
    return inc;
}
//这里我将sum()实例化后的inc2注释掉,用用自执行函数
//var inc2 = sum();
console.log(sum()()); //打印0
console.log(sum()()); //打印0
console.log(sum()()); //打印0

这里使用了三次自执行函数,打印还是0的原因其实是每次调用sum()()都是独自生成了一个内存块,调用了三次也就是生成了三个不同的内存块存储n值。

2.4 模拟缓存机制

//函数求和,但是每次执行完以后保存每次放进入的数字
//相当于于Object的key和value
//例如:var abc = { "1,2,3" : 6 }

var save = function(){
    var obj={}
    function fn(){
        var sum = 0;
        for(var i =0;i<arguments.length;i++){
             sum = sum + arguments[i];
        }
        return sum; 
    }
    return function(){
        //将arguments强转换成数组然后执行Array.join()方法
        var arg = Array.prototype.join.call(arguments, ',');
        obj[arg] = fn.apply(null, arguments);
        console.log(obj);
        for(var i = 0;i<Object.keys(obj).length;i++){
            console.log(Object.keys(obj)[i].split(',').map(Number));
        }
    }
}();

save(1,2,3,4,5);
save(1,2,3,4,5,6,7);
save();

运行结果


可以看到每次调用save()函数以后,每次存进去的参数都会被保存在obj集合里,obj集合的key就是每次保存进去的所有参数,obj集合的value值就是每次传入参数后计算后的总和值。

3.this指向

3.1 非严格模式和严格模式下的this指向

this === window;  //true

'use strict';
this === window; //true
this.n = 10;
console.log(this.n); //打印出10

非严格模式和严格模式下this都指向的是最顶层作用域(浏览器是window)

//非严格模式下
var n = 10;
function fn(){
    console.log(this);  //打印出window
    console.log(this.n); //打印出10
}
fn();

//严格模式下
'use strict';
var n = 10;
function fn(){
    console.log(this); //打印出undefined
    console.log(this.n); //报错TypeError
}

非严格模式下函数里的this指向window ,this.n则可以打印出10。但严格模式下this则指向undefined,故打印this.n时浏览器会打印出TypeError:Cannot read property 'n' of undefined(无法读取到未定义的属性n)

3.2 隐式绑定

var n = 1;
function fn(){
    console.log(this.n);
}
var obj = {
    n:5,
    fn:fn,
    obj2:{
        n:10,
        fn:fn
    }
}
obj.fn();  //打印5
obj.obj2.fn(); //打印10

  • 第一次调用fn()函数的直接对象是obj,此时this指向了obj
  • 第二次调用fn()函数的直接对象是obj2,此时this指向了obj2

3.3 隐式绑定丢失this指向

var n = 1;
function fn(){
    console.log(this.n);
}
var obj = {
    n:5,
    fn:fn
}

var fn2 = obj.fn;
fn2();  //此时打印的是1 而不是5

将obj.fn赋值给了fn2,由于fn2是在window指向下的,故fn2()去调用fn()函数时this指向从obj内部指向了window。

var n = 1;
function fn(){
    console.log(this.n);
}
var obj = {
    n:5,
    fn:fn
}

setTimeout(obj.fn,1000) //一秒后打印出1

内置函数setTimeout和setInterval这种,第一个参数回调函数里的this默认指向的是window。

var n = 1;

var obj = {
    n:5,
    fn:()=>{
        console.log(this.n);
    }
}
obj.fn(); //打印出1

ES6的箭头函数与普通函数不同,箭头函数中没有this指向,它必须通过查找作用域链来决定this的值,如果箭头函数包含在一个普通函数里,则它的this值会是最近的一个普通函数的this值,否则this的值会被设置成全局变量也就是window。

3.4 显式绑定

var n = 1;
function fn(){
    console.log(this.n);
}
var obj = {
    n:10
}

fn(); //打印出1
fn.call(obj); //打印出10
fn.apply(obj); //10
fn.bind(obj)(); //10

call、apply、bind方法的第一个参数是this指向的目标,它会强制改变this的指向,并且使得this不能再被改变。

3.5 new绑定

var n = 1;

function Fn(n){
    this.n = n;
    console.log(this);  //打印出 Fn: { n:10 }
    //return {}
    //return function f(){}
}

var fn = new Fn(10);

new操作符调用时this会指向生成的新对象,但是new调用的返回值没有显示返回对象或者函数,才是返回生成的新对象。

4.原型链

4.1 构造函数、实例对象、原型对象的关系

function Foo(name){
    this.name = name;
}
var foo = new Foo('CC');

console.log(foo.constructor === Foo); //true
console.log(foo.__proto__ === Foo.prototype); //true
console.log(Foo.prototype.constructor === Foo); //true


Foo()函数被new实例后成为实例对象foo以后,foo实例对象里产生了构造函数constructor和__proto__原型属性,foo的constructor指向Foo本身,控制台打印foo.constructor会把Foo这个函数自身显示出来。而foo的原型属性_proto__则指向了Foo的原型对象属性prototype,当然Foo的原型对象属性prototype的构造函数constructor是指向了Foo自身。

__proto__和prototype看起来很相似,但是两者还是有点区别的,__proto__存在于所有的对象上,prototype存在于所有的函数上,从上面例子可以看到foo是一个实例对象所以它只拥有__proto__属性,但没有prototype属性,尝试去打印foo.prototype可以看到结果是undefined,但是在js里函数也是对象的一种,所以在Foo里__proto__属性和prototype属性都会同时拥有。

4.2 对象的原型链


JavaScript通过 __proto__属性指向父类对象,直到指向Object对象为止,这样形成了一个原型的链条就叫做原型链,原型链的尽头也就是Object.prototype,因为再往下指就是null了。

4.3 模拟实现ES6的class

//ES6 class语法
class Square{
    constructor(edge){
        this.edge = edge;
    }
    
    getEdge(){
        console.log(`正方形的边长是${this.edge}`);
    }
}
new Square(5).getEdge();  //打印出 正方形的边长是5

//用原型链模拟实现class语法
var Square = (function (){
    function Square(edge){
        this.edge = edge;
    }
    
    Square.prototype.getEdge = function(){
            console.log(`正方向的边长是${this.edge}`);
        }
    return Square;
})();
new Square(3).getEdge(); //打印出 正方形的边长是3

由上面可以看出ES6的class其实就是构造器的语法糖在class里定义的函数其实就是放在了构造器的prototype里。

4.4 模拟实现ES6的extends

//ES6的class继承
class Person{
    constructor(name){
        this.name = name;
    }
}

class Student extends Person{
    constructor(name, number){
        super(name);
        this.number = number;
    }
    getView(){
        console.log(`学生姓名是${this.name},学号是${this.number}`);
    }
}
new Student('小米',20200101).getView();  //打印出  学生姓名是小米,学号是20200101

//使用原型继承和组合继承模拟ES6的继承
//原型继承
function inheritsLoose(child,parent){
    child.prototype = Object.create(parent.prototype);
    child.prototype.constructor = child;
    child.__proto__ = parent;
}

var Person = function(name){
    this.name = name;
}

var Student = (function(_Person){
    inheritsLoose(Student,_Person);
    
    function Student(name,number){
        var _this;
        //组合继承
        _this = _Person.call(this,name) || this;
        _this.number = number;
        return _this;
    }
    
    Student.prototype.getView = function(){
        console.log(`学生姓名是${this.name}, 学号是${this.number}`);
    }
    return Student;
})(Person);

new Student('小米',20200101).getView(); //打印出  学生姓名是小米,学号是20200101

ES6的继承机制其实就是实现了原型继承和组合继承,子类构造器调用父类构造器并将this指向了子类构造器。

5.ES6常用问题

5.1 var与let、const的区别

ES6里引入了一个块级作用域,它存在于函数内部或者{ }中,接着就有了块级声明,块级声明用来声明在指定块的作用域外无法访问的变量。

ES6里用let和const来当做块级声明去声明变量,为的就是控制变量的生命周期,用var声明时会出现很多问题,比如变量提升,能重复声明变量等,但是用let和const时就不会再产生该问题了。

//不存在变量提升
let a;
console.log(a);  //打印出 undefined
a = 1;

//不能重复声明
let b;
let b = 2;
console.log(b); //打印出SyntaxError错误,b变量已经被声明了

//不存在污染变量
for(let i = 0;i<3;i++){
   //dosth
}
console.log(i); //打印出ReferenceError错误, i变量未定义

//不绑定全局作用域
let c = 1;
function fn(){
    console.log(this.c); //打印出 undefined
}
fn()

接下来再来看看let和const的区别,const用于定义常量,定义结束以后不允许被修改,否则会报TypeError的错误,虽然const定义后不能被修改其值,但允许被修改内部的值,例如当用const定义一个object类型时:

const obj = { a:1 };
obj.a = 2;
obj.b = 3;
console.log(obj); //打印出 { a:2, c:3 }

5.2 call、apply、bind的区别

var m = 1, n = 2;
function fn(){
    console.log(this.m, this.n);
}
var obj = {
    m : 5,
    n : 10
}
fn(); //打印 1,2
fn.call(obj, m, n); //打印 5,10
fn.apply(obj,[m,n]); //打印 5,10
fn.bind(obj,m,n)(); //打印 5,10

从上面例子里很容易就可以看到三者的共同点都能改变this指向,接下来再说三者的区别:

  • call第一个参数是用来this指向,后面可以传入多个参数
  • apply第一个参数是用来this指向,后面只能把多个参数作为一个数组传进去
  • bind第一个参数是用来this指向,后面跟call用法一样可以传入多个参数,但是它最后返回的是一个函数,要使用它的话需要间接调用。

5.3 扁平化数组排序筛选

//将数组扁平化后去重并按数字大小从大到小排序最后再留下小于50的数字
var a = [49,[12,14,25,7],[23,53,25,[98,9,[65,25,20]]],65,20,9];

  • 数组扁平化 ( Array.flat( )或者其他方案 )
  • 数组去重     ( new Set( ) )
  • 数组排序     (Array.sort( ))
  • 数组筛选     ( Array.filter( ) )

//用flat()实现扁平化  上面最深的嵌套有3层

let b = Array.from(new Set(a.flat(3))).sort((a,b)=> b - a).filter(i => i < 50);

//用ES6的generator函数实现扁平化
function* flatUp(array){
    for(let item of array){
        if( Array.isArray(item) ){
            yield* flatUp(item);
        }else{
            yield item;
        }
    }
}

let b = Array.from(new Set([...flatUp(a)])).sort((a,b)=> b - a).filter(i => i < 50);

5.4 深浅拷贝

首先得理解堆内存和栈内存的区别:

基本数据类型(如number,String类型)都会直接存储在栈内存里,但引用数据类型(如Object,Array类型)在栈内存中存储的是指针位置,实际真实数据存储在堆内存里,该指针指向堆存储的该实体的起始地址。


5.4.1 深浅拷贝与直接赋值的区别

深拷贝和浅拷贝都是针对引用数据类型(Object,Array)的方案


深浅拷贝的区别:浅拷贝是复制指向对象的指针,而不复制整个对象的本身,新旧对象使用的是同一个内存块。但是深拷贝会创建一个与原来一模一样的对象,并且不共用同一个内存块,修改新对象时不会改到原对象。

先来看看浅拷贝和普通直接赋值的区别:

//直接赋值
var obj1 = {
    n: 2,
    arr:[1,[2,3]]
}

var obj2 = obj1;
obj2.n = 1;
obj2.arr[1] = [5,6,7];
console.log(obj1); //obj1: { n:1, arr:[1,[5,6,7]] }
console.log(obj2); //obj2: { n:1, arr:[1,[5,6,7]] }

//浅拷贝
function shallowCopy(obj){
    var data = {}
    for(let item in obj){
        if(obj.hasOwnProperty(item)){
            data[item] = obj[item];
        }
    }
    return data;
}

var obj1 = {
    n: 2,
    arr:[1,[2,3]]
}

var obj3 = shallowCopy(obj1);
obj3.n = 1;
obj3.arr[1] = [5,6,7];
console.log(obj1); //obj1: { n:2, arr:[1,[5,6,7]] }
console.log(obj3); //obj3: { n:1, arr:[1,[5,6,7]] }
  • 直接赋值是将obj1直接赋值给obj2过程中是相当于将obj1的内存地址赋给了obj2,而不是堆中的数据,所以obj2和obj1两者是联动的,当obj2改变属性以后obj1也会被改变。
  • 浅拷贝是将obj1的属性依次拷贝后并创建一个新对象也就是obj3,如果拷贝的属性是基本数据类型,拷贝的就是基本数据类型的数值,但如果拷贝的属性是引用数据类型,拷贝的就是引用数据类型的地址,所以当obj3改变的属性是引用数据类型时也会影响到obj1的引用数据类型的属性值,但是不会影响基本数据类型的属性值。
  • 深拷贝顾名思义就是会创建一个完全的新的对象,无论更改的是基本数据类型的属性还是引用数据类型的属性都完全不会影响到原来的对象。

5.4.2 浅拷贝的三种实现方式(Object.assign( )、Array.slice( )、Array.concat( ))

1. Object.assign()

//多重嵌套时Object.assign()是浅拷贝
var obj = { a:{a:1,b:2}};
var obj2 = Object.assign({},obj);
obj2.a.a = 10;
obj2.a.c = 5;
console.log(obj); //{a:{a:10,b:2,c:10}}
console.log(obj2); //{a:{a:10,b:2,c:10}}


//当Object只有一层时Object.assign()是深拷贝
var obj = { a:1 };
var obj2 = Object.assign({},obj);
obj2.a = 10;
console.log(obj); //{a:1}
console.log(obj2); //{a:10}

2. Array.slice()

var arr = [1,2,{a:1}];
var arr2 = arr.slice();
arr2[1] = 5;
arr2[2].a = 5;
console.log(arr); // [1,2,{a:5}]
console.log(arr2); // [1,5,{a:5}]

3. Array.concat()

var arr = [1,2,{a:1}];
var arr2 = arr.concat();
arr2[1] = 10;
arr2[2].a = 10;
console.log(arr); // [1,2,{a:10}]
console.log(arr2); // [1,10,{a:10}]

5.4.3 深拷贝的三种实现方式(JSON.parse(JSON.stringify( ))、递归、lodash库)

1. JSON.parse(JSON.stringify( ))

//这种方法只能用来深拷贝数组或者对象,不能用于拷贝函数
var arr = [1,2,{a:1}];
var arr2 = JSON.parse(JSON.stringify(arr));
arr2[1]=10;
arr2[2].a=10;
console.log(arr); // [1,2,{a:1}]
console.log(arr2); // [1,10,{a:10}]

2. 递归

function deepClone(obj){
    let result = typeof obj === 'function' ? [] : {};
    if(obj && typeof obj === 'object'){
        for(let i in obj){
            if(obj[i] && typeof obj[i] === 'object'){
                result[i] = deepClone(obj[i]);
            }else{
                result[i] = obj[i];
            }
        }
        return result;
    }
    return obj;
}

3. lodash库

//lodash函数库使用 _.cloneDeep()
const _ = require('lodash');
var obj = {
    a:1,
    b:{c:2}
}
var obj2 = _.cloneDeep(obj);


如有错误或者缺漏,欢迎指点。