你不知道的JavaScript之路

前言

想要成为一个专业的前端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变量未定义)javascript

1.2 变量提高

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

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

//初始化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里只有声明的变量才会被提高,初始化的不会。变量提高后的代码以下:html

//因为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,因此才会报错。java

1.4 声明提高综合应用总结

var a;
function a(){}
console.log(a); //打印出function a()复制代码

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

//例子
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);复制代码

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




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、一、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);复制代码


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

相关文章
相关标签/搜索