一位初级进阶中级 JavaScript 工作者的自我修养(一)

10,011 阅读21分钟

前言

最近的前端面试已经的飞起了😂,从计算机原理、编译原理、数据结构、算法、设计模式、编程范式编译工具、格式工具、Git、NPM、单元测试、Nginx、PM2、CI / CD 了解和使用

这随便挑选一个部分,知识点都可以深入挖掘,深不见底那种。

本文就重点介绍一下 JS 基础,希望可以给大家带来一点点🤏帮助。

温馨提示:本文适用于前端入门的同学和最近在准备想要系统化温习 JS 基础的朋友。已经工作多年的中高级前端大佬可以直接跳过本文哈~

第一章 「重学 JavaScript」变量和类型

一、数据类型

7 种基本数据类型:BigInt、Symbol、Undefined、Null、Boolean、Number 和 String

1 种复杂数据类型:Object

二、底层数据结构

通过 V8 的源码尝试分析 Object 的实现:V8 里面所有的数据类型的根父类都是 Object,Object 派生 HeapObject,提供存储基本功能,往下的 JSReceiver 用于原型查找,再往下的 JSObject 就是 JS 里面的 Object,Array/Function/Date 等继承于 JSObject。左边的 FixedArray 是实际存储数据的地方。 在创建一个 JSObject 之前,会先把读到的 Object 的文本属性序列化成 constant_properties ,如下的 data:

var data = {
  name: "yin",
  age: 18,
  "-school-": "high school",
};

会被序列成:

../../v8/src/runtime/runtime-literals.cc 72 constant_properties:

0xdf9ed2aed19: [FixedArray]

– length: 6

  [0]: 0x1b5ec69833d1 <String[4]: name>

  [1]: 0xdf9ed2aec51 <String[3]: yin>

  [2]: 0xdf9ed2aec71 <String[3]: age>

  [3]: 18

  [4]: 0xdf9ed2aec91 <String[8]: -school->

  [5]: 0xdf9ed2aecb1 <String[11]: high school>

它是一个 FixedArray,一共有 6 个元素,由于 data 总共是有 3 个属性,每个属性有一个 key 和一个 value,所以 Array 就有 6 个。第一个元素是第一个 key,第二个元素是第一个 value,第三个元素是第二个 key,第四个元素是第二个 key,依次类推。

Object 提供了一个 Print()的函数,把它用来打印对象的信息非常有帮助。上面的输出有两种类型的数据,一种是 String 类型,第二种是整型类型的。 FixedArray 是 V8 实现的一个类似于数组的类,它表示一段连续的内存。

参考自:www.rrfed.com/2017/04/04/…

三、Symbol

Symbol 类型在实际开发中的应用、可手动实现一个简单的 Symbol

3.1 使用 Symbol 来作为对象属性名(key)

在这之前,我们通常定义或访问对象的属性时都是使用字符串,比如下面的代码:

let obj = {
  abc: 123,
  hello: "world",
};

obj["abc"]; // 123
obj["hello"]; // 'world'

而现在,Symbol 可同样用于对象属性的定义和访问:

const PROP_NAME = Symbol();
const PROP_AGE = Symbol();

let obj = {
  [PROP_NAME]: "一斤代码",
};
obj[PROP_AGE] = 18;

obj[PROP_NAME]; // '一斤代码'
obj[PROP_AGE]; // 18

随之而来的是另一个非常值得注意的问题:就是当使用了 Symbol 作为对象的属性 key 后,在对该对象进行 key 的枚举时,会有什么不同?在实际应用中,我们经常会需要使用 Object.keys() 或者 for...in 来枚举对象的属性名,那在这方面,Symbol 类型的 key 表现的会有什么不同之处呢?来看以下示例代码:

let obj = {
  [Symbol("name")]: "一斤代码",
  age: 18,
  title: "Engineer",
};

Object.keys(obj); // ['age', 'title']

for (let p in obj) {
  console.log(p); // 分别会输出:'age' 和 'title'
}

Object.getOwnPropertyNames(obj); // ['age', 'title']

由上代码可知,Symbol 类型的 key 是不能通过 Object.keys()或者 for...in 来枚举的,它未被包含在对象自身的属性名集合 (property names) 之中。所以,利用该特性,我们可以把一些不需要对外操作和访问的属性使用 Symbol 来定义。

也正因为这样一个特性,当使用 JSON.stringify() 将对象转换成 JSON 字符串的时候,Symbol 属性也会被排除在输出内容之外:

JSON.stringify(obj); // {"age":18,"title":"Engineer"}

我们可以利用这一特点来更好的设计我们的数据对象,让“对内操作”和“对外选择性输出”变得更加优雅。

然而,这样的话,我们就没办法获取以 Symbol 方式定义的对象属性了么?非也。还是会有一些专门针对 Symbol 的 API,比如:

// 使用Object的API
Object.getOwnPropertySymbols(obj); // [Symbol(name)]

// 使用新增的反射API
Reflect.ownKeys(obj); // [Symbol(name), 'age', 'title']

3.2 使用 Symbol 来替代常量

先来看一下下面的代码,是不是在你的代码里经常会出现?

const TYPE_AUDIO = "AUDIO";
const TYPE_VIDEO = "VIDEO";
const TYPE_IMAGE = "IMAGE";

function handleFileResource(resource) {
  switch (resource.type) {
    case TYPE_AUDIO:
      playAudio(resource);
      break;
    case TYPE_VIDEO:
      playVideo(resource);
      break;
    case TYPE_IMAGE:
      previewImage(resource);
      break;
    default:
      throw new Error("Unknown type of resource");
  }
}

如上面的代码中那样,我们经常定义一组常量来代表一种业务逻辑下的几个不同类型,我们通常希望这几个常量之间是唯一的关系,为了保证这一点,我们需要为常量赋一个唯一的值(比如这里的'AUDIO'、'VIDEO'、 'IMAGE'),常量少的时候还算好,但是常量一多,你可能还得花点脑子好好为他们取个好点的名字。

现在有了 Symbol,我们大可不必这么麻烦了:

const TYPE_AUDIO = Symbol();
const TYPE_VIDEO = Symbol();
const TYPE_IMAGE = Symbol();

这样定义,直接就保证了三个常量的值是唯一的了!是不是挺方便的呢。

3.3 使用 Symbol 定义类的私有属性/方法

我们知道在 JavaScript 中,是没有如 Java 等面向对象语言的访问控制关键字 private 的,类上所有定义的属性或方法都是可公开访问的。因此这对我们进行 API 的设计时造成了一些困扰。

而有了 Symbol 以及模块化机制,类的私有属性和方法才变成可能。例如:

在文件 a.js 中

const PASSWORD = Symbol();

class Login {
  constructor(username, password) {
    this.username = username;
    this[PASSWORD] = password;
  }

  checkPassword(pwd) {
    return this[PASSWORD] === pwd;
  }
}

export default Login;

在文件 b.js 中

import Login from "./a";

const login = new Login("admin", "123456");

login.checkPassword("123456"); // true

login.PASSWORD; // oh!no!
login[PASSWORD]; // oh!no!
login["PASSWORD"]; // oh!no!

由于 Symbol 常量 PASSWORD 被定义在 a.js 所在的模块中,外面的模块获取不到这个 Symbol,也不可能再创建一个一模一样的 Symbol 出来(因为 Symbol 是唯一的),因此这个 PASSWORD 的 Symbol 只能被限制在 a.js 内部使用,所以使用它来定义的类属性是没有办法被模块外访问到的,达到了一个私有化的效果。

3.4 手动实现 Symbol

(function() {
  var root = this;

  var generateName = (function() {
    var postfix = 0;
    return function(descString) {
      postfix++;
      return "@@" + descString + "_" + postfix;
    };
  })();

  var SymbolPolyfill = function Symbol(description) {
    if (this instanceof SymbolPolyfill)
      throw new TypeError("Symbol is not a constructor");

    var descString =
      description === undefined ? undefined : String(description);

    var symbol = Object.create({
      toString: function() {
        return this.__Name__;
      },
      valueOf: function() {
        return this;
      },
    });

    Object.defineProperties(symbol, {
      __Description__: {
        value: descString,
        writable: false,
        enumerable: false,
        configurable: false,
      },
      __Name__: {
        value: generateName(descString),
        writable: false,
        enumerable: false,
        configurable: false,
      },
    });

    return symbol;
  };

  var forMap = {};

  Object.defineProperties(SymbolPolyfill, {
    for: {
      value: function(description) {
        var descString =
          description === undefined ? undefined : String(description);
        return forMap[descString]
          ? forMap[descString]
          : (forMap[descString] = SymbolPolyfill(descString));
      },
      writable: true,
      enumerable: false,
      configurable: true,
    },
    keyFor: {
      value: function(symbol) {
        for (var key in forMap) {
          if (forMap[key] === symbol) return key;
        }
      },
      writable: true,
      enumerable: false,
      configurable: true,
    },
  });

  root.SymbolPolyfill = SymbolPolyfill;
})();

四、变量在内存中的具体存储形式

4.1 栈内存和堆内存

JavaScript 中的变量分为基本类型和引用类型:

  • 基本类型是保存在栈内存中的简单数据段,它们的值都有固定的大小,保存在栈空间,通过按值访问
  • 引用类型是保存在堆内存中的对象,值大小不固定,栈内存中存放的该对象的访问地址指向堆内存中的对象,JavaScript 不允许直接访问堆内存中的位置,因此操作对象时,实际操作对象的引用

4.2 结合代码与图来理解

let a1 = 0; // 栈内存
let a2 = "this is string"; // 栈内存
let a3 = null; // 栈内存
let b = { x: 10 }; // 变量b存在于栈中,{ x: 10 }作为对象存在于堆中
let c = [1, 2, 3]; // 变量c存在于栈中,[1, 2, 3]作为对象存在于堆中

当我们要访问堆内存中的引用数据类型时:

  • 从栈中获取该对象的地址引用
  • 再从堆内存中取得我们需要的数据

4.3 基本类型发生复制行为

let a = 20;
let b = a;
b = 30;
console.log(a); // 20

结合下面图进行理解:

在栈内存中的数据发生复制行为时,系统会自动为新的变量分配一个新值,最后这些变量都是相互独立互不影响的。

4.4 引用类型发生复制行为

let a = { x: 10, y: 20 };
let b = a;
b.x = 5;
console.log(a.x); // 5
  • 引用类型的复制,同样为新的变量 b 分配一个新的值,保存在栈内存中,不同的是,这个值仅仅是引用类型的一个地址指针
  • 他们两个指向同一个值,也就是地址指针相同,在堆内存中访问到的具体对象实际上是同一个
  • 因此改变 b.x 时,a.x 也发生了变化,这就是引用类型的特性

结合下图理解:

总结:

五、内置对象和装箱拆箱操作

5.1 JS 中的内置函数(对象)

String()、Number()、Boolean()、RegExp()、Date()、Error()、Array()、Function()、Object()、symbol();类似于对象的构造函数

  • 这些内置函数构造的变量都是封装了基本类型值的对象如:
var a = new String("abb"); // typeof(a)=object

除了利用 Function() 构造的变量通过 typeof 输出为 function 外其他均为 object

  • 为了知道构造的变量的真实类型可以利用:
Object.prototype.toString.call([1, 2, 3]); // "[object,array]"

后面的一个值即为传入参数的类型

  • 如果有常量形式(即利用基本数据类型)赋值给变量就不要用该方式来定义变量

5.2 装箱

就是把基本类型转变为对应的对象。装箱分为隐式和显示

  • 隐式装箱: 每当读取一个基本类型的值时,后台会创建一个该基本类型所对应的对象。在这个基本类型上调用方法,其实是在这个基本类型对象上调用方法。这个基本类型的对象是临时的,它只存在于方法调用那一行代码执行的瞬间,执行方法后立刻被销毁。
let num = 123;
num.toFixed(2); // '123.00'//上方代码在后台的真正步骤为
var c = new Number(123);
c.toFixed(2);
c = null;
  1. 创建一个 Number 类型的实例。

  2. 在实例上调用方法。

  3. 销毁实例。

  • 显式装箱: 通过内置对象 Boolean、Object、String 等可以对基本类型进行显示装箱。
var obj = new String("123");

5.3 拆箱

拆箱与装箱相反,把对象转变为基本类型的值。拆箱过程内部调用了抽象操作 ToPrimitive 。该操作接受两个参数,第一个参数是要转变的对象,第二个参数 PreferredType 是对象被期待转成的类型。第二个参数不是必须的,默认该参数为 number,即对象被期待转为数字类型

  • Number 转化为对象

    1. 先调用对象自身的 valueOf 方法。如果返回原始类型的值,则直接对该值使用 Number 函数,返回结果。

    2. 如果 valueOf 返回的还是对象,继续调用对象自身的 toString 方法。如果 toString 返回原始类型的值,则对该值使用 Number 函数,返回结果。

    3. 如果 toString 返回的还是对象,报错。

Number([1]); //1
转换演变:
[1].valueOf(); // [1];
[1].toString(); // '1';Number('1'); //1
  • String 转化为对象

    1. 先调用对象自身的 toString 方法。如果返回原始类型的值,则对该值使用 String 函数,返回结果。

    2. 如果 toString 返回的是对象,继续调用 valueOf 方法。如果 valueOf 返回原始类型的值,则对该值使用 String 函数,返回结果。

    3. 如果 valueOf 返回的还是对象,报错。

String([1,2]) //"1,2"
转化演变:
[1,2].toString();  //"1,2"
String("1,2");  //"1,2"
  • Boolean 转化对象

    Boolean 转换对象很特别,除了以下六个值转换为 false,其他都为 true

undefined  null  false  0(包括+0和-0)  NaN  空字符串('')
Boolean(undefined) //false
Boolean(null)        //false
Boolean(false)       //false
Boolean(0)           //false
Boolean(NaN)         //false
Boolean('')          //false
 Boolean([]) //true
Boolean({})          //true
Boolean(new Date())  //true

六、值类型和引用类型

6.1 声明变量时不同的内存分配

  • 原始值:存储在栈(stack)中的简单数据段,也就是说,它们的值直接存储在变量访问的位置。

这是因为这些原始类型占据的空间是固定的,所以可将他们存储在较小的内存区域 – 栈中。这样存储便于迅速查寻变量的值。

  • 引用值:存储在堆(heap)中的对象,也就是说,存储在变量处的值是一个指针(point),指向存储对象的内存地址。

这是因为:引用值的大小会改变,所以不能把它放在栈中,否则会降低变量查寻的速度。相反,放在变量的栈空间中的值是该对象存储在堆中的地址。 地址的大小是固定的,所以把它存储在栈中对变量性能无任何负面影响。

6.2 不同的内存分配机制也带来了不同的访问机制

  • 在 JS 中是不允许直接访问保存在堆内存中的对象的,所以在访问一个对象时,首先得到的是这个对象在堆内存中的地址,然后再按照这个地址去获得这个对象中的值,这就是传说中的按引用访问。

  • 而原始类型的值则是可以直接访问到的。

6.3 复制变量时的不同

  • 原始值:在将一个保存着原始值的变量复制给另一个变量时,会将原始值的副本赋值给新变量,此后这两个变量是完全独立的,他们只是拥有相同的值而已,彼此都是独立的。

  • 引用值:在将一个保存着对象内存地址的变量复制给另一个变量时,会把这个内存地址赋值给新变量,也就是说这两个变量都指向了堆内存中的同一个对象,他们中任何一个作出的改变都会反映在另一个身上。(复制对象时并不会在堆内存中新生成一个一模一样的对象,只是多了一个保存指向这个对象指针的变量罢了)

6.4 参数传递的不同(把实参复制给形参的过程)

首先我们应该明确一点:ECMAScript 中所有函数的参数都是按值来传递的。 但是为什么涉及到原始类型与引用类型的值时仍然有区别呢?还不就是因为内存分配时的差别。

  • 原始值:只是把变量里的值传递给参数,之后参数和这个变量互不影响。

  • 引用值:对象变量它里面的值是这个对象在堆内存中的内存地址,这一点你要时刻铭记在心!

因此它传递的值也就是这个内存地址,这也就是为什么函数内部对这个参数的修改会体现在外部的原因了,因为它们都指向同一个对象。

七、null 和 undefined 的区别

7.1 定义

Null 类型:Null 类型也只有一个特殊的值——null。从逻辑角度来看,null 值表示一个空对象指针。

Undefined 类型:Undefined 类型只有一个值,即特殊的 undefined。在使用 var 声明变量但未对其加以初始化时,这个变量的值就是 undefined。

7.2 null 和 undefined 的应用场景

null 表示"没有对象",即该处不应该有值。典型用法是:

  • 作为函数的参数,表示该函数的参数不是对象。
  • 作为对象原型链的终点。
console.log(null instanceof Object); // false

undefined 表示"缺少值",就是此处应该有一个值,但是还没有定义。典型用法是:

  • 变量被声明了,但没有赋值时,就等于 undefined。
  • 调用函数时,应该提供的参数没有提供,该参数等于 undefined。
  • 对象没有赋值的属性,该属性的值为 undefined。
  • 函数没有返回值时,默认返回 undefined。

7.3 Number 转换的值

Number(null) 输出为 0, Number(undefined) 输出为 NaN

八、判断数据类型

至少可以说出三种判断 JavaScript 数据类型的方式,以及他们的优缺点,如何准确的判断数组类型

8.1 typeof

  • 适用场景

typeof 操作符可以准确判断一个变量是否为下面几个原始类型:

typeof "ConardLi"; // string
typeof 123; // number
typeof true; // boolean
typeof Symbol(); // symbol
typeof undefined; // undefined

你还可以用它来判断函数类型:

typeof function() {}; // function
  • 不适用场景

    当你用 typeof 来判断引用类型时似乎显得有些乏力了:

typeof []; // object
typeof {}; // object
typeof new Date(); // object
typeof /^\d*$/; // object

除函数外所有的引用类型都会被判定为 object

另外 typeof null === 'object' 也会让人感到头痛,这是在 JavaScript 初版就流传下来的 bug ,后面由于修改会造成大量的兼容问题就一直没有被修复...

8.2 instanceof

instanceof 操作符可以帮助我们判断引用类型具体是什么类型的对象:

[] instanceof Array; // true
new Date() instanceof Date; // true
new RegExp() instanceof RegExp; // true

我们先来回顾下原型链的几条规则:

  1. 所有引用类型都具有对象特性,即可以自由扩展属性
  2. 所有引用类型都具有一个 __proto__ (隐式原型)属性,是一个普通对象
  3. 所有的函数都具有 prototype (显式原型)属性,也是一个普通对象
  4. 所有引用类型 __proto__ 值指向它构造函数的 prototype
  5. 当试图得到一个对象的属性时,如果变量本身没有这个属性,则会去他的 __proto__ 中去找

[] instanceof Array 实际上是判断 Array.prototype 是否在 [] 的原型链上。 所以,使用 instanceof 来检测数据类型,不会很准确,这不是它设计的初衷:

[] instanceof Object // true
function(){}  instanceof Object // true

另外,使用 instanceof 也不能检测基本数据类型,所以 instanceof 并不是一个很好的选择。

8.3 toString

上面我们在拆箱操作中提到了 toString 函数,我们可以调用它实现从引用类型的转换。

每一个引用类型都有 toString 方法,默认情况下, toString() 方法被每个 Object 对象继承。如果此方法在自定义对象中未被覆盖, toString() 返回 "[object type]" ,其中 type 是对象的类型。

const obj = {};
obj.toString(); // [object Object]

注意,上面提到了 如果此方法在自定义对象中未被覆盖toString 才会达到预想的效果,事实上,大部分引用类型比如 Array、Date、RegExp 等都重写了 toString 方法。

我们可以直接调用 Object 原型上未被覆盖的 toString() 方法,使用 call 来改变 this 指向来达到我们想要的效果。

8.4 jquery

我们来看看 jquery 源码中如何进行类型判断:

var class2type = {};
jQuery.each(
  "Boolean Number String Function Array Date RegExp Object Error Symbol".split(
    " "
  ),
  function(i, name) {
    class2type["[object " + name + "]"] = name.toLowerCase();
  }
);

type: (obj) => {
  if (obj == null) {
    return obj + "";
  }
  return typeof obj === "object" || typeof obj === "function"
    ? class2type[Object.prototype.toString.call(obj)] || "object"
    : typeof obj;
};

isFunction: (obj) => {
  return jQuery.type(obj) === "function";
};

原始类型直接使用 typeof ,引用类型使用 Object.prototype.toString.call 取得类型。 判断数组类型可以用 Array.isArray(value) 或者 Object.prototype.toString.call(value)

九、隐式类型转换

可能发生隐式类型转换的场景以及转换原则,应如何避免或巧妙应用

因为 JavaScript 是弱类型的语言,所以类型转换发生非常频繁,上面我们说的装箱和拆箱其实就是一种类型转换。

类型转换分为两种,隐式转换即程序自动进行的类型转换,强制转换即我们手动进行的类型转换。

强制转换这里就不再多提及了,下面我们来看看让人头疼的可能发生隐式类型转换的几个场景,以及如何转换:

9.1 类型转换规则

如果发生了隐式转换,那么各种类型互转符合下面的规则:

9.2 if 语句和逻辑语句

if 语句和逻辑语句中,如果只有单个变量,会先将变量转换为 Boolean 值,只有下面几种情况会转换成 false ,其余被转换成 true

null;
undefined;
("");
NaN;
0;
false;

9.3 各种运数学算符

我们在对各种非 Number 类型运用数学运算符(- * /)时,会先将非 Number 类型转换为 Number 类型;

1 - true; // 0
1 - null; //  1
1 * undefined; //  NaN
2 * ["5"]; //  10

注意 + 是个例外,执行 + 操作符时:

  • 当一侧为 String 类型,被识别为字符串拼接,并会优先将另一侧转换为字符串类型。
  • 当一侧为 Number 类型,另一侧为原始类型,则将原始类型转换为 Number 类型。
  • 当一侧为 Number 类型,另一侧为引用类型,将引用类型和 Number 类型转换成字符串后拼接。
123 + "123"; // 123123   (规则1)
123 + null; // 123    (规则2)
123 + true; // 124    (规则2)
123 + {}; // 123[object Object]    (规则3)

9.4 ==

使用 == 时,若两侧类型相同,则比较结果和 === 相同,否则会发生隐式转换,使用 == 时发生的转换可以分为几种不同的情况(只考虑两侧类型不同):

  • NaN

    NaN 和其他任何类型比较永远返回 false (包括和他自己)。

NaN == NaN; // false
  • Boolean

    Boolean 和其他任何类型比较, Boolean 首先被转换为 Number 类型。

true == 1; // true
true == "2"; // false
true == ["1"]; // true
true == ["2"]; // false

这里注意一个可能会弄混的点:undefined、nullBoolean 比较,虽然 undefined、nullfalse 都很容易被想象成假值,但是他们比较结果是 false ,原因是 false 首先被转换成 0

undefined == false; // false
null == false; // false
  • String 和 Number

    StringNumber 比较,先将 String 转换为 Number 类型。

123 == "123"; // true
"" == 0; // true
  • null 和 undefined

    null == undefined 比较结果是 true ,除此之外, null、undefined 和其他任何结果的比较值都为 false

null == undefined; // true
null == ""; // false
null == 0; // false
null == false; // false
undefined == ""; // false
undefined == 0; // false
undefined == false; // false
  • 原始类型和引用类型

    当原始类型和引用类型做比较时,对象类型会依照 ToPrimitive 规则转换为原始类型:

"[object Object]" == {}; // true
"1,2,3" == [1, 2, 3]; // true

来看看下面这个比较:

[] == ![]; // true

! 的优先级高于 ==![] 首先会被转换为 false ,然后根据上面第三点, false 转换成 Number 类型 0,左侧 [] 转换为 0 ,两侧比较相等。

([null] == false[undefined]) == // true
  false; // true

根据数组的 ToPrimitive 规则,数组元素为 nullundefined 时,该元素被当做空字符串处理,所以 [null]、[undefined] 都会被转换为 0 。 所以,说了这么多,推荐使用 === 来判断两个值是否相等...

9.5 一道有意思的面试题

一道经典的面试题,如何让: a == 1 && a == 2 && a == 3 。 根据上面的拆箱转换,以及 == 的隐式转换,我们可以轻松写出答案:

const a = {
  value: [3, 2, 1],
  valueOf: function() {
    return this.value.pop();
  },
};

十、小数精度

出现小数精度丢失的原因,JavaScript 可以存储的最大数字、最大安全数字,JavaScript 处理大数字的方法、避免精度丢失的方法

10.1 出现小数精度丢失的原因

计算机的二进制实现和位数限制有些数无法有限表示。就像一些无理数不能有限表示,如 圆周率 3.1415926...,1.3333... 等。JS 遵循  IEEE 754  规范,采用双精度存储(double precision),占用 64 bit。如图

1 位用来表示符号位

11 位用来表示指数

52 位表示尾数

浮点数,比如

1

2

0.1 >> 0.0001 1001 1001 1001…(1001 无限循环)

0.2 >> 0.0011 0011 0011 0011…(0011 无限循环)

此时只能模仿十进制进行四舍五入了,但是二进制只有 0 和 1 两个,于是变为 0 舍 1 入。这即是计算机中部分浮点数运算时出现误差,丢失精度的根本原因。 JS 的最大和最小安全值可以这样获得:

console.log(Number.MAX_SAFE_INTEGER); //9007199254740991
console.log(Number.MIN_SAFE_INTEGER); //-9007199254740991

对于整数,前端出现问题的几率可能比较低,毕竟很少有业务需要需要用到超大整数,只要运算结果不超过 Math.pow(2, 53) 就不会丢失精度。如果实在是超过最大安全数字了,那就用 BigInt(Number)计算。

对于小数,前端出现问题的几率还是很多的,尤其在一些电商网站涉及到金额等数据。解决方式:把小数放到位整数(乘倍数),再缩小回原来倍数(除倍数),也就是说,尽量在业务中避免处理小数。

第二章 「重学 JavaScript」原型和原型链

一、原型设计模式和原型规则

理解原型设计模式以及 JavaScript 中的原型规则

1.1 设计模式

1.1.1 工厂模式

在函数内创建一个对象,给对象赋予属性及方法再将对象返回

function Person() {
  var People = new Object();
  People.name = "CrazyLee";
  People.age = "25";
  People.sex = function() {
    return "boy";
  };
  return People;
}

var a = Person();
console.log(a.name); // CrazyLee
console.log(a.sex()); // boy

1.1.2 构造函数模式

无需在函数内部重新创建对象,而是用 this 指代

function Person() {
  this.name = "CrazyLee";
  this.age = "25";
  this.sex = function() {
    return "boy";
  };
}

var a = new Person();
console.log(a.name); // CrazyLee
console.log(a.sex()); // boy

1.1.3 原型模式

函数中不对属性进行定义,利用 prototype 属性对属性进行定义,可以让所有对象实例共享它所包含的属性及方法。

function Parent() {
  Parent.prototype.name = "carzy";
  Parent.prototype.age = "24";
  Parent.prototype.sex = function() {
    var s = "女";
    console.log(s);
  };
}

var x = new Parent();
console.log(x.name); // crazy
console.log(x.sex()); // 女

1.1.4 混合模式

原型模式+构造函数模式。这种模式中,构造函数模式用于定义实例属性,而原型模式用于定义方法和共享属性

function Parent() {
  this.name = "CrazyLee";
  this.age = 24;
}
Parent.prototype.sayname = function() {
  return this.name;
};

var x = new Parent();
console.log(x.sayname()); // Crazy&emsp;&emsp;

1.1.5 动态原型模式

将所有信息封装在了构造函数中,而通过构造函数中初始化原型,这个可以通过判断该方法是否有效而选择是否需要初始化原型。

function Parent() {
  this.name = "CrazyLee";
  this.age = 24;
  if (typeof Parent._sayname == "undefined") {
    Parent.prototype.sayname = function() {
      return this.name;
    };
    Parent._sayname = true;
  }
}

var x = new Parent();
console.log(x.sayname());

1.2 原型规则

1.2.1 原型规则

  1. 所有的引用类型(数组、对象、函数),都具有对象特征,即可自由扩展属性;
var arr = [];
arr.a = 1;
  1. 所有的引用类型,都有一个__proto__ 属性(隐式原型),属性值是一个普通对象;
  2. 所有函数,都具有一个 prototype(显式原型),属性值也是一个普通原型;
  3. 所有的引用类型(数组、对象、函数),其隐式原型指向其构造函数的显式原型;(obj.__proto__ === Object.prototype)
  4. 当试图得到一个对象的某个属性时,如果这个对象本身没有这个属性,那么会去它的__proto__(即它的构造函数的 prototype)中去寻找;

1.2.2 原型对象

prototype 在 js 中,函数对象其中一个属性:原型对象 prototype。普通对象没有 prototype 属性,但有__proto__属性。 原型的作用就是给这个类的每一个对象都添加一个统一的方法,在原型中定义的方法和属性都是被所有实例对象所共享的。

var person = function(name){
    this.name = name
};
person.prototype.getName=function(){ // 通过person.prototype设置函数对象属性
    return this.name;
}
var crazy= new person(‘crazyLee’);
crazy.getName(); // crazyLee//crazy继承上属性

1.2.3 原型链

当试图得到一个对象 f 的某个属性时,如果这个对象本身没有这个属性,那么会去它的__proto__(即它的构造函数的 prototype)obj.__proto__中去寻找;当 obj.__proto__ 也没有时,便会在 obj.__proto__.__proto__(即 obj 的构造函数的 prototype 的构造函数的 prototype)中寻找;

二、instanceof

instanceof 的底层实现原理,手动实现一个 instanceof

function instance_of(L, R) {
  //L 表示左表达式,R 表示右表达式
  var O = R.prototype; // 取 R 的显示原型
  L = L.__proto__; // 取 L 的隐式原型
  while (true) {
    if (L === null) return false;
    if (O === L)
      // 当 O 显式原型 严格等于  L隐式原型 时,返回true
      return true;
    L = L.__proto__;
  }
}

三、继承

实现继承的几种方式以及他们的优缺点

3.1 原型链继承

原型链继承的基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法

function SuperType() {
  this.name = "yanxugong";
  this.colors = ["pink", "blue", "green"];
}

SuperType.prototype.getName = function() {
  return this.name;
};

function SubType() {
  this.age = 22;
}

SubType.prototype = new SuperType();
SubType.prototype.getAge = function() {
  return this.age;
};
SubType.prototype.constructor = SubType;

let instance1 = new SubType();
instance1.colors.push("yellow");
console.log(instance1.getName()); // 'yanxugong'
console.log(instance1.colors); // ["pink", "blue", "green", "yellow"]

let instance2 = new SubType();
console.log(instance2.colors); // ["pink", "blue", "green", "yellow"]

缺点:

  • 通过原型来实现继承时,原型会变成另一个类型的实例,原先的实例属性变成了现在的原型属性,该原型的引用类型属性会被所有的实例共享
  • 在创建子类型的实例时,没有办法在不影响所有对象实例的情况下给超类型的构造函数中传递参数

3.2 借用构造函数

借用构造函数的技术,其基本思想为:在子类型的构造函数中调用超类型构造函数。

function SuperType(name) {
  this.name = name;
  this.colors = ["pink", "blue", "green"];
  this.getColors = function() {
    return this.colors;
  };
}

SuperType.prototype.getName = function() {
  return this.name;
};

function SubType(name) {
  SuperType.call(this, name);
  this.age = 22;
}

let instance1 = new SubType("yanxugong");
instance1.colors.push("yellow");
console.log(instancel.colors); // ['pink','blue','green','yellow']
console.log(instancel.getColors()); // ["pink", "blue", "green", "yellow"]
console.log(instancel.getName); // undefined

let instance2 = new SubType("Jack");
console.log(instance2.colors); // ['pink','blue','green']
console.log(instance2.getColors()); // ["pink", "blue", "green"]
console.log(instance2.getName); // undefined

优点:

  • 可以向超类传递参数
  • 解决了原型中包含引用类型值被所有实例共享的问题

缺点:

  • 方法都在构造函数中定义,函数复用无从谈起,另外超类型原型中定义的方法对于子类型而言都是不可见的。

3.3 组合继承

组合继承指的是将原型链和借用构造函数技术组合到一块,从而发挥二者之长的一种继承模式。

基本思路:

使用原型链实现对原型属性和方法的继承,通过借用构造函数来实现对实例属性的继承,既通过在原型上定义方法来实现了函数复用,又保证了每个实例都有自己的属性。

function SuperType(name) {
  this.name = name;
  this.colors = ["pink", "blue", "green"];
}

SuperType.prototype.getName = function() {
  return this.name;
};

function SubType(name, age) {
  SuperType.call(this, name);
  this.age = age;
}

SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function() {
  return this.age;
};

let instancel = new SubType("yanxugong", 20);
instancel.colors.push("yellow");
console.log(instancel.colors); // ['pink','blue','green','yellow']
console.log(instancel.sayAge()); // 20
console.log(instancel.getName()); // yanxugong

let instance2 = new SubType("Jack", 18);
console.log(instance2.colors); // ['pink','blue','green']
console.log(instance2.sayAge()); // 18
console.log(instance2.getName()); // Jack

console.log(new SuperType("po"));

缺点:

  • 无论什么情况下,都会调用两次超类型构造函数:一次是在创建子类型原型的时候,另一次是在子类型构造函数内部。

优点:

  • 可以向超类传递参数
  • 每个实例都有自己的属性
  • 实现了函数复用

3.4 原型式继承

原型继承的基本思想:

借助原型可以基于已有的对象创建新对象,同时还不必因此创建自定义类型。

function object(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

在 object()函数内部,新建一个临时性的构造函数,然后将传入的对象作为这个构造函数的原型,最后返回了这个临时类型的一个新实例,从本质上讲,object()对传入的对象执行了一次浅拷贝。

ECMAScript5 通过新增 Object.create()方法规范了原型式继承。这个方法接收两个参数:一个用作新对象原型的对象和(可选的)一个为新对象定义额外属性的对象(可以覆盖原型对象上的同名属性),在传入一个参数的情况下,Object.create()和 object()方法的行为相同。

var person = {
  name: "yanxugong",
  hobbies: ["reading", "photography"],
};

var personl = Object.create(person);
personl.name = "jack";
personl.hobbies.push("coding");

var person2 = Object.create(person);
person2.name = "Echo";
person2.hobbies.push("running");

console.log(person.hobbies); // ["reading", "photography", "coding", "running"]
console.log(person.name); // yanxugong

console.log(personl.hobbies); // ["reading", "photography", "coding", "running"]
console.log(personl.name); // jack

console.log(person2.hobbies); // ["reading", "photography", "coding", "running"]
console.log(person2.name); // Echo

在没有必要创建构造函数,仅让一个对象与另一个对象保持相似的情况下,原型式继承是可以胜任的。

缺点:

  • 同原型链实现继承一样,包含引用类型值的属性会被所有实例共享。

3.5 寄生式继承

寄生式继承是与原型式继承紧密相关的一种思路。寄生式继承的思路与寄生构造函数和工厂模式类似,即创建一个仅用于封装继承过程的函数,该函数在内部已某种方式来增强对象,最后再像真地是它做了所有工作一样返回对象。

function object(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

function createAnother(original) {
  var clone = object(original); // 通过调用函数创建一个新对象
  clone.sayHi = function() {
    // 以某种方式增强这个对象
    console.log("hi");
  };
  return clone; // 返回这个对象
}

var person = {
  name: "yanxugong",
  hobbies: ["reading", "photography"],
};

var personl = createAnother(person);
personl.sayHi(); // hi
personl.hobbies.push("coding");
console.log(personl.hobbies); // ["reading", "photography", "coding"]
console.log(person); // {hobbies:["reading", "photography", "coding"],name: "yanxugong"}

基于 person 返回了一个新对象 personl,新对象不仅具有 person 的所有属性和方法,而且还有自己的 sayHi()方法。在考虑对象而不是自定义类型和构造函数的情况下,寄生式继承也是一种有用的模式。

缺点:

  • 使用寄生式继承来为对象添加函数,会由于不能做到函数复用而效率低下。
  • 同原型链实现继承一样,包含引用类型值的属性会被所有实例共享。

3.6 寄生组合式继承

所谓寄生组合式继承,即通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。

基本思路:

不必为了指定子类型的原型而调用超类型的构造函数,我们需要的仅是超类型原型的一个副本,本质上就是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。寄生组合式继承的基本模式如下所示:

function object(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

function inheritPrototype(subType, superType) {
  var prototype = object(superType.prototype); // 创建对象
  prototype.constructor = subType; // 增强对象
  subType.prototype = prototype; // 指定对象
}
  • 创建超类型原型的一个副本
  • 为创建的副本添加 constructor 属性
  • 将新创建的对象赋值给子类型的原型

至此,我们就可以通过调用 inheritPrototype 来替换为子类型原型赋值的语句:

function object(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

function inheritPrototype(subType, superType) {
  var prototype = object(superType.prototype); // 创建对象
  prototype.constructor = subType; // 增强对象
  subType.prototype = prototype; // 指定对象
}

function SuperType(name) {
  this.name = name;
  this.colors = ["pink", "blue", "green"];
}

SuperType.prototype.getName = function() {
  return this.name;
};

function SubType(name, age) {
  SuperType.call(this, name);
  this.age = age;
}

inheritPrototype(SubType, SuperType);

SubType.prototype.sayAge = function() {
  return this.age;
};

let instancel = new SubType("yanxugong", 20);
instancel.colors.push("yellow");
console.log(instancel.colors); // ['pink','blue','green','yellow']
console.log(instancel.sayAge()); // 20
console.log(instancel.getName()); // yanxugong

let instance2 = new SubType("Jack", 18);
console.log(instance2.colors); // ['pink','blue','green']
console.log(instance2.sayAge()); // 18
console.log(instance2.getName()); // Jack

console.log(new SuperType("po"));

优点:

  • 只调用了一次超类构造函数,效率更高。避免在 SuberType.prototype 上面创建不必要的、多余的属性,与其同时,原型链还能保持不变。
  • 因此寄生组合继承是引用类型最理性的继承范式。

四、原型继承的案例

至少说出一种开源项目(如 Node)中应用原型继承的案例

4.1 Vue.extend( options )

  • 参数:

    • {Object} options
  • 用法:

使用基础 Vue 构造器,创建一个“子类”。参数是一个包含组件选项的对象。

data  选项是特例,需要注意 - 在  Vue.extend()  中它必须是函数

<div id="mount-point"></div>
// 创建构造器
var Profile = Vue.extend({
  template: "<p>{{firstName}} {{lastName}} aka {{alias}}</p>",
  data: function() {
    return {
      firstName: "Walter",
      lastName: "White",
      alias: "Heisenberg",
    };
  },
});
// 创建 Profile 实例,并挂载到一个元素上。
new Profile().$mount("#mount-point");

结果如下:

<p>Walter White aka Heisenberg</p>

4.2 为什么使用 extend

在 vue 项目中,我们有了初始化的根实例后,所有页面基本上都是通过 router 来管理,组件也是通过 import 来进行局部注册,所以组件的创建我们不需要去关注,相比 extend 要更省心一点点。但是这样做会有几个缺点:

  • 组件模板都是事先定义好的,如果我要从接口动态渲染组件怎么办?
  • 所有内容都是在 #app 下渲染,注册组件都是在当前位置渲染。如果我要实现一个类似于 window.alert() 提示组件要求像调用 JS 函数一样调用它,该怎么办?这时候,Vue.extend + vm.$mount 组合就派上用场了。

五、new 操作符

可以描述 new 一个对象的详细过程,手动实现一个 new 操作符

先看看 new 操作符都干了什么事情,有哪些操作?通过下面的代码来进行思考:

// 新建一个类(构造函数)
function Otaku(name, age) {
  this.name = name;
  this.age = age;
  // 自身的属性
  this.habit = "pk";
}
// 给类的原型上添加属性和方法
Otaku.prototype.strength = 60;
Otaku.prototype.sayYourName = function() {
  console.log("I am " + this.name);
};
// 实例化一个person对象
const person = new Otaku("乔峰", 5000);
person.sayYourName(); // I am 乔峰
console.log(person); // 打印出构造出来的实例

image

5.1 解析

从控制台打印出来的结果我们可以看出 new 操作符大概做了一下几件事情:

  • 返回(产生)了一个新的对象
  • 访问到了类 Otaku 构造函数里的属性
  • 访问到 Otaku 原型上的属性和方法 并且设置了 this 的指向(指向新生成的实例对象)

通过上面的分析展示,可以知道 new 团伙里面一定有 Object 的参与,不然对象的产生就有点说不清了。 先来边写写:

// 需要返回一个对象 借助函数来实现new操作
// 传入需要的参数: 类 + 属性
const person = new Otaku("乔峰", 5000);
const person1 = objectFactory(Otaku, "鸠摩智", 5000);

// 开始来实现objectFactory 方法
function objectFactory(obj, name, age) {}
// 这种方法将自身写死了 如此他只能构造以obj为原型,并且只有name 和 age 属性的 obj
// 在js中 函数因为arguments 使得函数参数的写法异常灵活,在函数内部可以通过arguments来获得函数的参数
function objectFactory() {
  console.log(arguements); //{ '0': [Function: Otaku], '1': '鸠摩智', '2': 5000 }
  // 通过arguments类数组打印出的结果,我们可以看到其中包含了构造函数以及我们调用objectfactory时传入的其他参数
  // 接下来就是要想如何得到其中这个构造函数和其他的参数
  // 由于arguments是类数组,没有直接的方法可以供其使用,我们可以有以下两种方法:
  // 1. Array.from(arguments).shift(); //转换成数组 使用数组的方法shift将第一项弹出
  // 2. [].shift().call(arguments); // 通过call() 让arguments能够借用shift方法
  const Constructor = [].shift.call(arguments);
  const args = arguments;
  // 新建一个空对象 纯洁无邪
  let obj = new Object();
  // 接下来的想法 给obj这个新生对象的原型指向它的构造函数的原型
  // 给构造函数传入属性,注意:构造函数的this属性
  // 参数传进Constructor对obj的属性赋值,this要指向obj对象
  // 在Coustructor内部手动指定函数执行时的this 使用call、apply实现
  let result = Constructor.apply(obj, arguments);
  //确保new出来的是一个对象
  return typeof result === "object" ? result : obj;
}

上面的代码注释太多,剔除注释以后的代码:

function objectFactory() {
  let Constructor = [].shift.call(arguments);
  const obj = new Object();
  obj.__proto__ = Conctructor.prototype;
  let result = Constructor.apply(obj, arguments);
  return typeof result === "object" ? result : obj;
}

还有另外一种操作:

function myNew(Obj, ...args) {
  var obj = Object.create(Obj.prototype); // 使用指定的原型对象及其属性去创建一个新的对象
  Obj.apply(obj, args); // 绑定 this 到obj, 设置 obj 的属性
  return obj; // 返回实例
}

六、class 构造以及继承

理解 ES6 class 构造以及继承的底层实现原理

6.1 ES6 class 使用

javascript 使用的是原型式继承,我们可以通过原型的特性实现类的继承, ES6 为我们提供了像面向对象继承一样的语法糖。

class Parent {
  constructor(a) {
    this.filed1 = a;
  }
  filed2 = 2;
  func1 = function() {};
}

class Child extends Parent {
  constructor(a, b) {
    super(a);
    this.filed3 = b;
  }

  filed4 = 1;
  func2 = function() {};
}

下面我们借助 babel 来探究 ES6 类和继承的实现原理。

6.2 class 的实现

转换前:

class Parent {
  constructor(a) {
    this.filed1 = a;
  }
  filed2 = 2;
  func1 = function() {};
}

转换后:

function _classCallCheck(instance, Constructor) {
  if (!(instance instanceof Constructor)) {
    throw new TypeError("Cannot call a class as a function");
  }
}

var Parent = function Parent(a) {
  _classCallCheck(this, Parent);

  this.filed2 = 2;

  this.func1 = function() {};

  this.filed1 = a;
};

可见 class 的底层依然是构造函数:

  • 调用_classCallCheck 方法判断当前函数调用前是否有 new 关键字。

构造函数执行前有 new 关键字,会在构造函数内部创建一个空对象,将构造函数的 proptype 指向这个空对象的__proto__,并将 this 指向这个空对象。如上,_classCallCheck 中:this instanceof Parent 返回 true。

若构造函数前面没有 new 则构造函数的 proptype 不会不出现在 this 的原型链上,返回 false。

  • 将 class 内部的变量和函数赋给 this。

  • 执行 constuctor 内部的逻辑。

  • return this (构造函数默认在最后我们做了)。

6.3 继承实现

转换前:

class Child extends Parent {
  constructor(a, b) {
    super(a);
    this.filed3 = b;
  }

  filed4 = 1;
  func2 = function() {};
}

转换后:

我们先看 Child 内部的实现,再看内部调用的函数是怎么实现的:

var Child = (function(_Parent) {
  _inherits(Child, _Parent);

  function Child(a, b) {
    _classCallCheck(this, Child);

    var _this = _possibleConstructorReturn(
      this,
      (Child.__proto__ || Object.getPrototypeOf(Child)).call(this, a)
    );

    _this.filed4 = 1;

    _this.func2 = function() {};

    _this.filed3 = b;
    return _this;
  }

  return Child;
})(Parent);
  • 调用_inherits 函数继承父类的 proptype。

_inherits 内部实现:

function _inherits(subClass, superClass) {
  if (typeof superClass !== "function" && superClass !== null) {
    throw new TypeError(
      "Super expression must either be null or a function, not " +
        typeof superClass
    );
  }
  subClass.prototype = Object.create(superClass && superClass.prototype, {
    constructor: {
      value: subClass,
      enumerable: false,
      writable: true,
      configurable: true,
    },
  });
  if (superClass)
    Object.setPrototypeOf
      ? Object.setPrototypeOf(subClass, superClass)
      : (subClass.__proto__ = superClass);
}
  1. 校验父构造函数。

  2. 典型的寄生继承:用父类构造函数的 proptype 创建一个空对象,并将这个对象指向子类构造函数的 proptype。

  3. 将父构造函数指向子构造函数的__proto__(这步是做什么的不太明确,感觉没什么意义。)

  • 用一个闭包保存父类引用,在闭包内部做子类构造逻辑。

  • new 检查。

  • 用当前 this 调用父类构造函数。

var _this = _possibleConstructorReturn(
  this,
  (Child.__proto__ || Object.getPrototypeOf(Child)).call(this, a)
);

这里的 Child.proto || Object.getPrototypeOf(Child)实际上是父构造函数(_inherits 最后的操作),然后通过 call 将其调用方改为当前 this,并传递参数。(这里感觉可以直接用参数传过来的 Parent)

function _possibleConstructorReturn(self, call) {
  if (!self) {
    throw new ReferenceError(
      "this hasn't been initialised - super() hasn't been called"
    );
  }
  return call && (typeof call === "object" || typeof call === "function")
    ? call
    : self;
}

校验 this 是否被初始化,super 是否调用,并返回父类已经赋值完的 this。

  • 将行子类 class 内部的变量和函数赋给 this。

  • 执行子类 constuctor 内部的逻辑。

可见,ES6 实际上是为我们提供了一个“组合寄生继承”的简单写法。

6.4 super

super 代表父类构造函数。

super.fun1() 等同于 Parent.fun1()Parent.prototype.fun1()

super() 等同于 Parent.prototype.construtor()

当我们没有写子类构造函数时:

var Child = (function(_Parent) {
  _inherits(Child, _Parent);

  function Child() {
    _classCallCheck(this, Child);

    return _possibleConstructorReturn(
      this,
      (Child.__proto__ || Object.getPrototypeOf(Child)).apply(this, arguments)
    );
  }

  return Child;
})(Parent);

可见默认的构造函数中会主动调用父类构造函数,并默认把当前 constructor 传递的参数传给了父类。

所以当我们声明了 constructor 后必须主动调用 super(),否则无法调用父构造函数,无法完成继承。

典型的例子就是 React 的 Component 中,我们声明 constructor 后必须调用 super(props),因为父类要在构造函数中对 props 做一些初始化操作。

第三章 「重学 JavaScript」作用域和闭包

一、作用域

理解 JavaScript 的作用域、作用域链和内部原理

1.1 作用域

javascript 拥有一套设计良好的规则来存储变量,并且之后可以方便地找到这些变量,这套规则被称为作用域

作用域就是代码的执行环境,全局执行环境就是全局作用域,函数的执行环境就是私有作用域,它们都是栈内存。

1.2 作用域链

当代码在一个环境中执行时,会创建变量对象的一个作用域链(作用域形成的链条),由于变量的查找是沿着作用域链来实现的,所以也称作用域链为变量查找的机制。

  • 作用域链的前端,始终都是当前执行的代码所在环境的变量对象
  • 作用域链中的下一个对象来自于外部环境,而在下一个变量对象则来自下一个外部环境,一直到全局执行环境
  • 全局执行环境的变量对象始终都是作用域链上的最后一个对象

内部环境可以通过作用域链访问所有外部环境,但外部环境不能访问内部环境的任何变量和函数。

1.3 内部原理

  • 编译

    以 var a = 2;为例,说明 javascript 的内部编译过程,主要包括以下三步:

    • 分词(tokenizing)

      把由字符组成的字符串分解成有意义的代码块,这些代码块被称为词法单元(token)

      var a = 2;被分解成为下面这些词法单元:var、a、=、2、;。这些词法单元组成了一个词法单元流数组

      [
        "var": "keyword",
        "a": "identifier",
        "=": "assignment",
        "2": "integer",
        ";": "eos" (end of statement)
      ]
      
    • 解析(parsing)

      把词法单元流数组转换成一个由元素逐级嵌套所组成的代表程序语法结构的树,这个树被称为“抽象语法树” (Abstract Syntax Tree, AST)

      var a = 2;的抽象语法树中有一个叫 VariableDeclaration 的顶级节点,接下来是一个叫 Identifier(它的值是 a)的子节点,以及一个叫 AssignmentExpression 的子节点,且该节点有一个叫 Numericliteral(它的值是 2)的子节点

      {
        operation: "=",
        left: {
          keyword: "var",
          right: "a"
        }
        right: "2"
      }
      
    • 代码生成

      将 AST 转换为可执行代码的过程被称为代码生成

      var a=2;的抽象语法树转为一组机器指令,用来创建一个叫作 a 的变量(包括分配内存等),并将值 2 储存在 a 中

      实际上,javascript 引擎的编译过程要复杂得多,包括大量优化操作,上面的三个步骤是编译过程的基本概述

      任何代码片段在执行前都要进行编译,大部分情况下编译发生在代码执行前的几微秒。javascript 编译器首先会对 var a=2;这段程序进行编译,然后做好执行它的准备,并且通常马上就会执行它

  • 执行

    简而言之,编译过程就是编译器把程序分解成词法单元(token),然后把词法单元解析成语法树(AST),再把语法树变成机器指令等待执行的过程

    实际上,代码进行编译,还要执行。下面仍然以 var a = 2;为例,深入说明编译和执行过程

    • 编译

      • 编译器查找作用域是否已经有一个名称为 a 的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为 a

      • 编译器将 var a = 2;这个代码片段编译成用于执行的机器指令

      依据编译器的编译原理,javascript 中的重复声明是合法的

      // test在作用域中首次出现,所以声明新变量,并将20赋值给test
      var test = 20;
      // test在作用域中已经存在,直接使用,将20的赋值替换成30
      var test = 30;
      
    • 执行

      • 引擎运行时会首先查询作用域,在当前的作用域集合中是否存在一个叫作 a 的变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量

      • 如果引擎最终找到了变量 a,就会将 2 赋值给它。否则引擎会抛出一个异常

  • 查询

    在引擎执行的第一步操作中,对变量 a 进行了查询,这种查询叫做 LHS 查询。实际上,引擎查询共分为两种:LHS 查询和 RHS 查询

    从字面意思去理解,当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧时进行 RHS 查询

    更准确地讲,RHS 查询与简单地查找某个变量的值没什么区别,而 LHS 查询则是试图找到变量的容器本身,从而可以对其赋值

    function foo(a) {
      console.log(a); // 2
    }
    foo(2);
    

    这段代码中,总共包括 4 个查询,分别是:

    1、foo(...)对 foo 进行了 RHS 引用

    2、函数传参 a = 2 对 a 进行了 LHS 引用

    3、console.log(...)对 console 对象进行了 RHS 引用,并检查其是否有一个 log 的方法

    4、console.log(a)对 a 进行了 RHS 引用,并把得到的值传给了 console.log(...)

  • 嵌套

    在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止

    function foo(a) {
      console.log(a + b);
    }
    var b = 2;
    foo(2); // 4
    

    行 RHS 引用,没有找到;接着,引擎在全局作用域中查找 b,成功找到后,对其进行 RHS 引用,将 2 赋值给 b

  • 异常

    为什么区分 LHS 和 RHS 是一件重要的事情?因为在变量还没有声明(在任何作用域中都无法找到变量)的情况下,这两种查询的行为不一样

    • RHS

      • 如果 RHS 查询失败,引擎会抛出 ReferenceError(引用错误)异常
      // 对b进行RHS查询时,无法找到该变量。也就是说,这是一个“未声明”的变量
      function foo(a) {
        a = b;
      }
      foo(); // ReferenceError: b is not defined
      
      • 如果 RHS 查询找到了一个变量,但尝试对变量的值进行不合理操作,比如对一个非函数类型值进行函数调用,或者引用 null 或 undefined 中的属性,引擎会抛出另外一种类型异常:TypeError(类型错误)异常
      function foo() {
        var b = 0;
        b();
      }
      foo(); // TypeError: b is not a function
      
    • LHS

      • 当引擎执行 LHS 查询时,如果无法找到变量,全局作用域会创建一个具有该名称的变量,并将其返还给引擎
      function foo() {
        a = 1;
      }
      foo();
      console.log(a); // 1
      
      • 如果在严格模式中 LHS 查询失败时,并不会创建并返回一个全局变量,引擎会抛出同 RHS 查询失败时类似的 ReferenceError 异常
      function foo() {
        "use strict";
        a = 1;
      }
      foo();
      console.log(a); // ReferenceError: a is not defined
      
  • 原理

    function foo(a) {
      console.log(a);
    }
    foo(2);
    

    以上面这个代码片段来说明作用域的内部原理,分为以下几步:

    【1】引擎需要为 foo(...)函数进行 RHS 引用,在全局作用域中查找 foo。成功找到并执行

    【2】引擎需要进行 foo 函数的传参 a=2,为 a 进行 LHS 引用,在 foo 函数作用域中查找 a。成功找到,并把 2 赋值给 a

    【3】引擎需要执行 console.log(...),为 console 对象进行 RHS 引用,在 foo 函数作用域中查找 console 对象。由于 console 是个内置对象,被成功找到

    【4】引擎在 console 对象中查找 log(...)方法,成功找到

    【5】引擎需要执行 console.log(a),对 a 进行 RHS 引用,在 foo 函数作用域中查找 a,成功找到并执行

    【6】于是,引擎把 a 的值,也就是 2 传到 console.log(...)中

    【7】最终,控制台输出 2

二、词法作用域和动态作用域

2.1 词法作用域

编译器的第一个工作阶段叫作分词,就是把由字符组成的字符串分解成词法单元。这个概念是理解词法作用域的基础

简单地说,词法作用域就是定义在词法阶段的作用域,是由写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变

  • 关系

无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定

function foo(a) {
  var b = a * 2;
  function bar(c) {
    console.log(a, b, c);
  }
  bar(b * 3);
}
foo(2); // 2 4 12

在这个例子中有三个逐级嵌套的作用域。为了帮助理解,可以将它们想象成几个逐级包含的气泡

image

作用域气泡由其对应的作用域块代码写在哪里决定,它们是逐级包含的

气泡 1 包含着整个全局作用域,其中只有一个标识符:foo

气泡 2 包含着 foo 所创建的作用域,其中有三个标识符:a、bar 和 b

气泡 3 包含着 bar 所创建的作用域,其中只有一个标识符:c

  • 查找

作用域气泡的结构和互相之间的位置关系给引擎提供了足够的位置信息,引擎用这些信息来查找标识符的位置

在代码片段中,引擎执行 console.log(...)声明,并查找 a、b 和 c 三个变量的引用。它首先从最内部的作用域,也就是 bar(...)函数的作用域开始查找。引擎无法在这里找到 a,因此会去上一级到所嵌套的 foo(...)的作用域中继续查找。在这里找到了 a,因此引擎使用了这个引用。对 b 来讲也一样。而对 c 来说,引擎在 bar(...)中找到了它

[注意]词法作用域查找只会查找一级标识符,如果代码引用了 foo.bar.baz,词法作用域查找只会试图查找 foo 标识符,找到这个变量后,对象属性访问规则分别接管对 bar 和 baz 属性的访问

foo = {
  bar: {
    baz: 1,
  },
};
console.log(foo.bar.baz); // 1
  • 遮蔽

作用域查找从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止

在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”,内部的标识符“遮蔽”了外部的标识符

var a = 0;
function test() {
  var a = 1;
  console.log(a); // 1
}
test();

全局变量会自动为全局对象的属性,因此可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引用来对其进行访问

var a = 0;
function test() {
  var a = 1;
  console.log(window.a); //0
}
test();

通过这种技术可以访问那些被同名变量所遮蔽的全局变量。但非全局的变量如果被遮蔽了,无论如何都无法被访问到

2.2 动态作用域

javascript 使用的是词法作用域,它最重要的特征是它的定义过程发生在代码的书写阶段

那为什么要介绍动态作用域呢?实际上动态作用域是 javascript 另一个重要机制 this 的表亲。作用域混乱多数是因为词法作用域和 this 机制相混淆,傻傻分不清楚

动态作用域并不关心函数和作用域是如何声明以及在任何处声明的,只关心它们从何处调用。换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套

var a = 2;
function foo() {
  console.log(a);
}
function bar() {
  var a = 3;
  foo();
}
bar();

【1】如果处于词法作用域,也就是现在的 javascript 环境。变量 a 首先在 foo()函数中查找,没有找到。于是顺着作用域链到全局作用域中查找,找到并赋值为 2。所以控制台输出 2

【2】如果处于动态作用域,同样地,变量 a 首先在 foo()中查找,没有找到。这里会顺着调用栈在调用 foo()函数的地方,也就是 bar()函数中查找,找到并赋值为 3。所以控制台输出 3

两种作用域的区别,简而言之,词法作用域是在定义时确定的,而动态作用域是在运行时确定的

三、执行上下文

理解 JavaScript 的执行上下文栈,可以应用堆栈信息快速定位问题

3.1 执行上下文

  • 全局执行上下文: 这是默认的、最基础的执行上下文。不在任何函数中的代码都位于全局执行上下文中。它做了两件事:1. 创建一个全局对象,在浏览器中这个全局对象就是 window 对象。2. 将 this 指针指向这个全局对象。一个程序中只能存在一个全局执行上下文。
  • 函数执行上下文: 每次调用函数时,都会为该函数创建一个新的执行上下文。每个函数都拥有自己的执行上下文,但是只有在函数被调用的时候才会被创建。一个程序中可以存在任意数量的函数执行上下文。每当一个新的执行上下文被创建,它都会按照特定的顺序执行一系列步骤,具体过程将在本文后面讨论。
  • Eval 函数执行上下文: 运行在 eval 函数中的代码也获得了自己的执行上下文,但由于 Javascript 开发人员不常用 eval 函数,所以在这里不再讨论。

3.2 执行栈

执行栈,在其他编程语言中也被叫做调用栈,具有 LIFO(后进先出)结构,用于存储在代码执行期间创建的所有执行上下文。

当 JavaScript 引擎首次读取你的脚本时,它会创建一个全局执行上下文并将其推入当前的执行栈。每当发生一个函数调用,引擎都会为该函数创建一个新的执行上下文并将其推到当前执行栈的顶端。

引擎会运行执行上下文在执行栈顶端的函数,当此函数运行完成后,其对应的执行上下文将会从执行栈中弹出,上下文控制权将移到当前执行栈的下一个执行上下文。

让我们通过下面的代码示例来理解这一点:

let a = "Hello World!";

function first() {
  console.log("Inside first function");
  second();
  console.log("Again inside first function");
}

function second() {
  console.log("Inside second function");
}

first();
console.log("Inside Global Execution Context");

当上述代码在浏览器中加载时,JavaScript 引擎会创建一个全局执行上下文并且将它推入当前的执行栈。当调用  first()  函数时,JavaScript 引擎为该函数创建了一个新的执行上下文并将其推到当前执行栈的顶端。

当在  first()  函数中调用  second()  函数时,Javascript 引擎为该函数创建了一个新的执行上下文并将其推到当前执行栈的顶端。当  second()  函数执行完成后,它的执行上下文从当前执行栈中弹出,上下文控制权将移到当前执行栈的下一个执行上下文,即  first()  函数的执行上下文。

当  first()  函数执行完成后,它的执行上下文从当前执行栈中弹出,上下文控制权将移到全局执行上下文。一旦所有代码执行完毕,Javascript 引擎把全局执行上下文从执行栈中移除。

3.3 执行上下文是如何被创建的

到目前为止,我们已经看到了 JavaScript 引擎如何管理执行上下文,现在就让我们来理解 JavaScript 引擎是如何创建执行上下文的。

执行上下文分两个阶段创建: 1)创建阶段; 2)执行阶段

3.4 创建阶段

在任意的 JavaScript 代码被执行前,执行上下文处于创建阶段。在创建阶段中总共发生了三件事情:

  • 确定  this  的值,也被称为  This Binding 。
  • LexicalEnvironment(词法环境)  组件被创建。
  • VariableEnvironment(变量环境)  组件被创建。

因此,执行上下文可以在概念上表示如下:

ExecutionContext = {
  ThisBinding = <this value>,
  LexicalEnvironment = { ... },
  VariableEnvironment = { ... },
}

This Binding:

在全局执行上下文中, this  的值指向全局对象,在浏览器中, this  的值指向 window 对象。

在函数执行上下文中, this  的值取决于函数的调用方式。如果它被一个对象引用调用,那么  this  的值被设置为该对象,否则  this  的值被设置为全局对象或  undefined (严格模式下)。例如:

let person = {
  name: "peter",
  birthYear: 1994,
  calcAge: function() {
    console.log(2018 - this.birthYear);
  },
};

person.calcAge();
// 'this' 指向 'person', 因为 'calcAge' 是被 'person' 对象引用调用的。

let calculateAge = person.calcAge;
calculateAge();
// 'this' 指向全局 window 对象,因为没有给出任何对象引用

3.4.1 词法环境(Lexical Environment)

官方 ES6 文档将词法环境定义为:

词法环境是一种规范类型,基于 ECMAScript 代码的词法嵌套结构来定义标识符与特定变量和函数的关联关系。词法环境由环境记录(environment record)和可能为空引用(null)的外部词法环境组成。

简而言之,词法环境是一个包含  标识符变量映射  的结构。(这里的  标识符  表示变量/函数的名称, 变量  是对实际对象【包括函数类型对象】或原始值的引用)

在词法环境中,有两个组成部分:(1) 环境记录(environment record) (2) 对外部环境的引用

  1. 环境记录  是存储变量和函数声明的实际位置。
  2. 对外部环境的引用  意味着它可以访问其外部词法环境。

词法环境有两种类型:

  • 全局环境(在全局执行上下文中)是一个没有外部环境的词法环境。全局环境的外部环境引用为  null 。它拥有一个全局对象(window 对象)及其关联的方法和属性(例如数组方法)以及任何用户自定义的全局变量, this  的值指向这个全局对象。
  • 函数环境,用户在函数中定义的变量被存储在  环境记录  中。对外部环境的引用可以是全局环境,也可以是包含内部函数的外部函数环境。

注意:对于  函数环境  而言, 环境记录  还包含了一个  arguments  对象,该对象包含了索引和传递给函数的参数之间的映射以及传递给函数的参数的  长度(数量) 。例如,下面函数的  arguments  对象如下所示:

function foo(a, b) {
var c = a + b;
}
foo(2, 3);

// arguments 对象
Arguments: {0: 2, 1: 3, length: 2},

环境记录同样有两种类型(如下所示):

  • 声明性环境记录  存储变量、函数和参数。一个函数环境包含声明性环境记录。
  • 对象环境记录  用于定义在全局执行上下文中出现的变量和函数的关联。全局环境包含对象环境记录。

抽象地说,词法环境在伪代码中看起来像这样:

GlobalExectionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // 标识符绑定在这里
      outer: <null>
    }
  }
}

FunctionExectionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // 标识符绑定在这里
      outer: <Global or outer function environment reference>
    }
  }
}

3.4.2 变量环境:

它也是一个词法环境,其  EnvironmentRecord  包含了由  VariableStatements  在此执行上下文创建的绑定。

如上所述,变量环境也是一个词法环境,因此它具有上面定义的词法环境的所有属性。

在 ES6 中, LexicalEnvironment  组件和  VariableEnvironment  组件的区别在于前者用于存储函数声明和变量( let  和  const )绑定,而后者仅用于存储变量( var )绑定。

让我们结合一些代码示例来理解上述概念:

let a = 20;
const b = 30;
var c;

function multiply(e, f) {
  var g = 20;
  return e * f * g;
}

c = multiply(20, 30);

执行上下文如下所示:

GlobalExectionContext = {
  ThisBinding: <Global Object>,
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // 标识符绑定在这里
      a: < uninitialized >,
      b: < uninitialized >,
      multiply: < func >
    }
    outer: <null>
  },
  VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // 标识符绑定在这里
      c: undefined,
    }
    outer: <null>
  }
}

FunctionExectionContext = {
  ThisBinding: <Global Object>,
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // 标识符绑定在这里
      Arguments: {0: 20, 1: 30, length: 2},
    },
    outer: <GlobalLexicalEnvironment>
  },
  VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // 标识符绑定在这里
      g: undefined
    },
    outer: <GlobalLexicalEnvironment>
  }
}

注意:只有在遇到函数  multiply  的调用时才会创建函数执行上下文。

你可能已经注意到了  let  和  const  定义的变量没有任何与之关联的值,但  var  定义的变量设置为  undefined 。

这是因为在创建阶段,代码会被扫描并解析变量和函数声明,其中函数声明存储在环境中,而变量会被设置为  undefined (在  var  的情况下)或保持未初始化(在  let  和  const  的情况下)。

这就是为什么你可以在声明之前访问  var  定义的变量(尽管是  undefined ),但如果在声明之前访问  let  和  const  定义的变量就会提示引用错误的原因。

这就是我们所谓的变量提升。

3.5 执行阶段

这是整篇文章中最简单的部分。在此阶段,完成对所有变量的分配,最后执行代码。

注:在执行阶段,如果 Javascript 引擎在源代码中声明的实际位置找不到  let  变量的值,那么将为其分配  undefined  值。

3.6 错误堆栈的裁剪

Node.js 才支持这个特性,通过 Error.captureStackTrace 来实现,Error.captureStackTrace 接收一个 object 作为第 1 个参数,以及可选的 function 作为第 2 个参数。其作用是捕获当前的调用栈并对其进行裁剪,捕获到的调用栈会记录在第 1 个参数的 stack 属性上,裁剪的参照点是第 2 个参数,也就是说,此函数之前的调用会被记录到调用栈上面,而之后的不会。

让我们用代码来说明,首先,把当前的调用栈捕获并放到 myObj 上:

const myObj = {};
function c() {}
function b() {
  // 把当前调用栈写到 myObj 上
  Error.captureStackTrace(myObj);
  c();
}
function a() {
  b();
}

// 调用函数 a
a();

// 打印 myObj.stack
console.log(myObj.stack);

// 输出会是这样
//    at b (repl:3:7) <-- Since it was called inside B, the B call is the last entry in the stack
//    at a (repl:2:1)
//    at repl:1:1 <-- Node internals below this line
//    at realRunInThisContextScript (vm.js:22:35)
//    at sigintHandlersWrap (vm.js:98:12)
//    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
//    at REPLServer.defaultEval (repl.js:313:29)
//    at bound (domain.js:280:14)
//    at REPLServer.runBound [as eval] (domain.js:293:12)
//    at REPLServer.onLine (repl.js:513:10)

上面的调用栈中只有 a -> b,因为我们在 b 调用 c 之前就捕获了调用栈。现在对上面的代码稍作修改,然后看看会发生什么:

const myObj = {};
function d() {
  // 我们把当前调用栈存储到 myObj 上,但是会去掉 b 和 b 之后的部分
  Error.captureStackTrace(myObj, b);
}
function c() {
  d();
}
function b() {
  c();
}
function a() {
  b();
}

// 执行代码
a();

// 打印 myObj.stack
console.log(myObj.stack);

// 输出如下
//    at a (repl:2:1) <-- As you can see here we only get frames before b was called
//    at repl:1:1 <-- Node internals below this line
//    at realRunInThisContextScript (vm.js:22:35)
//    at sigintHandlersWrap (vm.js:98:12)
//    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
//    at REPLServer.defaultEval (repl.js:313:29)
//    at bound (domain.js:280:14)
//    at REPLServer.runBound [as eval] (domain.js:293:12)
//    at REPLServer.onLine (repl.js:513:10)
//    at emitOne (events.js:101:20)

在这段代码里面,因为我们在调用 Error.captureStackTrace 的时候传入了 b,这样 b 之后的调用栈都会被隐藏。

现在你可能会问,知道这些到底有啥用?如果你想对用户隐藏跟他业务无关的错误堆栈(比如某个库的内部实现)就可以试用这个技巧。

3.7 错误调试

3.7.1 Error 对象和错误处理

当程序运行出现错误时, 通常会抛出一个 Error 对象. Error 对象可以作为用户自定义错误对象继承的原型.

Error.prototype 对象包含如下属性:

constructor–指向实例的构造函数

message–错误信息

name–错误的名字(类型)

上述是 Error.prototype 的标准属性, 此外, 不同的运行环境都有其特定的属性. 在例如 Node, Firefox, Chrome, Edge, IE 10+, Opera 以及 Safari 6+

这样的环境中, Error 对象具备 stack 属性, 该属性包含了错误的堆栈轨迹. 一个错误实例的堆栈轨迹包含了自构造函数之后的所有堆栈结构.

3.7.2 如何查看调用栈

只查看调用栈:console.trace

a();
function a() {
  b();
}
function b() {
  c();
}
function c() {
  let aa = 1;
}
console.trace();

3.7.3 debugger 打断点形式

四、this

this 的原理以及几种不同使用场景的取值

4.1 作为对象方法调用

在 JavaScript 中,函数也是对象,因此函数可以作为一个对象的属性,此时该函数被称为该对象的方法,在使用这种调用方式时,this 被自然绑定到该对象

var test = {
  a: 0,
  b: 0,
  get: function() {
    return this.a;
  },
};

4.2 作为函数调用

函数也可以直接被调用,此时 this 绑定到全局对象。在浏览器中,window 就是该全局对象。比如下面的例子:函数被调用时,this 被绑定到全局对象,

接下来执行赋值语句,相当于隐式的声明了一个全局变量,这显然不是调用者希望的。

function makeNoSense(x) {
  this.x = x;
}

4.3 作为构造函数调用

javaScript 支持面向对象式编程,与主流的面向对象式编程语言不同,JavaScript 并没有类(class)的概念,而是使用基于原型(prototype)的继承方式。

相应的,JavaScript 中的构造函数也很特殊,如果不使用 new 调用,则和普通函数一样。作为又一项约定俗成的准则,构造函数以大写字母开头,

提醒调用者使用正确的方式调用。如果调用正确,this 绑定到新创建的对象上。

function Point(x, y) {
  this.x = x;
  this.y = y;
}

4.4 在 call 或者 apply,bind 中调用

让我们再一次重申,在 JavaScript 中函数也是对象,对象则有方法,apply 和 call 就是函数对象的方法。

这两个方法异常强大,他们允许切换函数执行的上下文环境(context),即 this 绑定的对象。

很多 JavaScript 中的技巧以及类库都用到了该方法。让我们看一个具体的例子:

function Point(x, y) {
  this.x = x;
  this.y = y;
  this.moveTo = function(x, y) {
    this.x = x;
    this.y = y;
  };
}

var p1 = new Point(0, 0);
var p2 = { x: 0, y: 0 };
p1.moveTo(1, 1);
p1.moveTo.apply(p2, [10, 10]);

五、闭包

闭包的实现原理和作用,可以列举几个开发中闭包的实际应用

5.1 闭包的概念

  • 指有权访问另一个函数作用域中的变量的函数,一般情况就是在一个函数中包含另一个函数。

5.2 闭包的作用

  • 访问函数内部变量、保持函数在环境中一直存在,不会被垃圾回收机制处理

因为函数内部声明 的变量是局部的,只能在函数内部访问到,但是函数外部的变量是对函数内部可见的,这就是作用域链的特点了。

子级可以向父级查找变量,逐级查找,找到为止

因此我们可以在函数内部再创建一个函数,这样对内部的函数来说,外层函数的变量都是可见的,然后我们就可以访问到他的变量了。

function bar() {
  //外层函数声明的变量
  var value = 1;

  function foo() {
    console.log(value);
  }
  return foo();
}
var bar2 = bar;
//实际上bar()函数并没有因为执行完就被垃圾回收机制处理掉
//这就是闭包的作用,调用bar()函数,就会执行里面的foo函数,foo这时就会访问到外层的变量
bar2();

foo()包含 bar()内部作用域的闭包,使得该作用域能够一直存活,不会被垃圾回收机制处理掉,这就是闭包的作用,以供 foo()在任何时间进行引用。

5.3 闭包的优点

  • 方便调用上下文中声明的局部变量
  • 逻辑紧密,可以在一个函数中再创建个函数,避免了传参的问题

5.4 闭包的缺点

  • 因为使用闭包,可以使函数在执行完后不被销毁,保留在内存中,如果大量使用闭包就会造成内存泄露,内存消耗很大

5.5 闭包在实际中的应用

function addFn(a, b) {
  return function() {
    console.log(a + "+" + b);
  };
}
var test = addFn(a, b);
setTimeout(test, 3000);

一般 setTimeout 的第一个参数是个函数,但是不能传值。如果想传值进去,可以调用一个函数返回一个内部函数的调用,将内部函数的调用传给 setTimeout。内部函数执行所需的参数,外部函数传给他,在 setTimeout 函数中也可以访问到外部函数。

六、堆栈溢出和内存泄漏

理解堆栈溢出和内存泄漏的原理,如何防止

6.1 内存泄露

  • 申请的内存执行完后没有及时的清理或者销毁,占用空闲内存,内存泄露过多的话,就会导致后面的程序申请不到内存。因此内存泄露会导致内部内存溢出

6.2 堆栈溢出

  • 内存空间已经被申请完,没有足够的内存提供了

6.3 标记清除法

在一些编程软件中,比如 c 语言中,需要使用 malloc 来申请内存空间,再使用 free 释放掉,需要手动清除。而 js 中是有自己的垃圾回收机制的,一般常用的垃圾收集方法就是标记清除。

标记清除法:在一个变量进入执行环境后就给它添加一个标记:进入环境,进入环境的变量不会被释放,因为只要执行流进入响应的环境,就可能用到他们。当变量离开环境后,则将其标记为“离开环境”。

6.4 常见的内存泄露的原因

  • 全局变量引起的内存泄露
  • 闭包
  • 没有被清除的计时器

6.5 解决方法

  • 减少不必要的全局变量
  • 减少闭包的使用(因为闭包会导致内存泄露)
  • 避免死循环的发生

七、如何处理循环的异步操作

7.1 使用自执行函数

1、当自执行函数在循环当中使用时,自执行函数会在循环结束之后才会运行。比如你在自执行函数外面定义一个数组,在自执行函数当中给这个数组追加内容,你在自执行函数之外输出时,会发现这个数组当中什么都没有,这就是因为自执行函数会在循环运行完后才会执行。

2、当自执行函数在循环当中使用时,要是自执行函数当中嵌套 ajax,那么循环当中的下标 i 就不会传进 ajax 当中,需要在 ajax 外面把下标 i 赋值给一个变量,在 ajax 中直接调用这个变量就可以了。

例子:

$.ajax({
  type: "GET",
  dataType: "json",
  url: "***",
  success: function(data) {
    //console.log(data);
    for (var i = 0; i < data.length; i++) {
      (function(i, abbreviation) {
        $.ajax({
          type: "GET",
          url: "/api/faults?abbreviation=" + encodeURI(abbreviation),
          dataType: "json",
          success: function(result) {
            //获取数据后做的事情
          },
        });
      })(i, data[i].abbreviation);
    }
  },
});

7.2 使用递归函数

所谓的递归函数就是在函数体内调用本函数。使用递归函数一定要注意,处理不当就会进入死循环。

const asyncDeal = (i) = > {
    if (i < 3) {
        $.get('/api/changeParts/change_part_standard?part=' + data[i].change_part_name, function(res) {
            //获取数据后做的事情
            i++;
            asyncDeal(i);
        })
    } else {
        //异步完成后做的事情
    }
};
asyncDeal(0);

7.3 使用 async/await

  • async/await 特点

async/await 更加语义化,async 是“异步”的简写,async function 用于申明一个 function 是异步的; await,可以认为是 async wait 的简写, 用于等待一个异步方法执行完成;

async/await 是一个用同步思维解决异步问题的方案(等结果出来之后,代码才会继续往下执行)

可以通过多层 async function 的同步写法代替传统的 callback 嵌套

  • async function 语法

自动将常规函数转换成 Promise,返回值也是一个 Promise 对象

只有 async 函数内部的异步操作执行完,才会执行 then 方法指定的回调函数

异步函数内部可以使用 await

  • await 语法

await 放置在 Promise 调用之前,await 强制后面点代码等待,直到 Promise 对象 resolve,得到 resolve 的值作为 await 表达式的运算结果

await 只能在 async 函数内部使用,用在普通函数里就会报错

const asyncFunc = function(i) {
  return new Promise(function(resolve) {
    $.get(url, function(res) {
      resolve(res);
    });
  });
};
const asyncDeal = async function() {
  for (let i = 0; i < data.length; i++) {
    let res = await asyncFunc(i);
    //获取数据后做的事情
  }
};
asyncDeal();

八、模块化

理解模块化解决的实际问题,可列举几个模块化方案并理解其中原理

8.1 CommonJS 规范(同步加载模块)

允许模块通过 require 方法来同步加载所要依赖的其他模块,然后通过 exports 或 module.exports 来导出需要暴露的接口。

使用方式:

// 导入
require("module");
require("../app.js");
// 导出
exports.getStoreInfo = function() {};
module.exports = someValue;

优点:

  • 简单容易使用
  • 服务器端模块便于复用

缺点:

  • 同步加载方式不适合在浏览器环境中使用,同步意味着阻塞加载,浏览器资源是异步加载的
  • 不能非阻塞的并行加载多个模块

为什么浏览器不能使用同步加载,服务端可以?

  • 因为模块都放在服务器端,对于服务端来说模块加载时
  • 而对于浏览器端,因为模块都放在服务器端,加载的时间还取决于网速的快慢等因素,如果需要等很长时间,整个应用就会被阻塞。
  • 因此,浏览器端的模块,不能采用"同步加载"(CommonJs),只能采用"异步加载"(AMD)。

参照 CommonJs 模块代表 node.js 的模块系统

8.2 AMD(异步加载模块)

采用异步方式加载模块,模块的加载不影响后面语句的运行。所有依赖模块的语句,都定义在一个回调函数中,等到加载完成之后,回调函数才执行。

使用实例:

// 定义
define("module", ["dep1", "dep2"], function(d1, d2) {...});
// 加载模块
require(["module", "../app"], function(module, app) {...});

加载模块 require([module], callback);第一个参数[module],是一个数组,里面的成员就是要加载的模块;第二个参数 callback 是加载成功之后的回调函数。

优点:

  • 适合在浏览器环境中异步加载模块
  • 可以并行加载多个模块

缺点:

  • 提高了开发成本,代码的阅读和书写比较困难,模块定义方式的语义不顺畅
  • 不符合通用的模块化思维方式,是一种妥协的实现

实现 AMD 规范代表 require.js

RequireJS 对模块的态度是预执行。由于 RequireJS 是执行的 AMD 规范, 因此所有的依赖模块都是先执行;即 RequireJS 是预先把依赖的模块执行,相当于把 require 提前了

RequireJS 执行流程:

  • require 函数检查依赖的模块,根据配置文件,获取 js 文件的实际路径
  • 根据 js 文件实际路径,在 dom 中插入 script 节点,并绑定 onload 事件来获取该模块加载完成的通知。
  • 依赖 script 全部加载完成后,调用回调函数

8.3 CMD 规范(异步加载模块)

CMD 规范和 AMD 很相似,简单,并与 CommonJS 和 Node.js 的 Modules 规范保持了很大的兼容性;在 CMD 规范中,一个模块就是一个文件。

定义模块使用全局函数 define,其接收 factory 参数,factory 可以是一个函数,也可以是一个对象或字符串;

factory 是一个函数,有三个参数,function(require, exports, module):

  • require 是一个方法,接受模块标识作为唯一参数,用来获取其他模块提供的接口:require(id)
  • exports 是一个对象,用来向外提供模块接口
  • module 是一个对象,上面存储了与当前模块相关联的一些属性和方法

实例:

define(function(require, exports, module) {
  var a = require("./a");
  a.doSomething();
  // 依赖就近书写,什么时候用到什么时候引入
  var b = require("./b");
  b.doSomething();
});

优点:

  • 依赖就近,延迟执行
  • 可以很容易在 Node.js 中运行

缺点:

  • 依赖 SPM 打包,模块的加载逻辑偏重
  • 实现代表库 sea.js:SeaJS 对模块的态度是懒执行, SeaJS 只会在真正需要使用(依赖)模块时才执行该模块

8.4 AMD 与 CMD 的区别

  • 对于依赖的模块,AMD 是提前执行,CMD 是延迟执行。不过 RequireJS 从 2.0 开始,也改成了可以延迟执行(根据写法不同,处理方式不同)。CMD 推崇 as lazy as possible.
  • AMD 推崇依赖前置;CMD 推崇依赖就近,只有在用到某个模块的时候再去 require。
// AMD
define(['./a', './b'], function(a, b) {  // 依赖必须一开始就写好
   a.doSomething()
   // 此处略去 100 行
   b.doSomething()
   ...
});
// CMD
define(function(require, exports, module) {
   var a = require('./a')
   a.doSomething()
   // 此处略去 100 行
   var b = require('./b')
   // 依赖可以就近书写
   b.doSomething()
   // ...
});

8.5 UMD

  • UMD 是 AMD 和 CommonJS 的糅合
  • AMD 以浏览器第一原则发展异步加载模块。
  • CommonJS 模块以服务器第一原则发展,选择同步加载,它的模块无需包装。
  • UMD 先判断是否支持 Node.js 的模块(exports)是否存在,存在则使用 Node.js 模块模式;在判断是否支持 AMD(define 是否存在),存在则使用 AMD 方式加载模块。
(function(window, factory) {
  if (typeof exports === "object") {
    module.exports = factory();
  } else if (typeof define === "function" && define.amd) {
    define(factory);
  } else {
    window.eventUtil = factory();
  }
})(this, function() {
  //module ...
});

8.6 ES6 模块化

  • ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。
  • ES6 模块设计思想:尽量的静态化、使得编译时就能确定模块的依赖关系,以及输入和输出的变量(CommonJS 和 AMD 模块,都只能在运行时确定这些东西)。

使用方式:

// 导入
import "/app";
import React from “react”;
import { Component } from “react”;
// 导出
export function multiply() {...};
export var year = 2018;
export default ...
...

优点:

  • 容易进行静态分析
  • 面向未来的 EcmaScript 标准 缺点:
  • 原生浏览器端还没有实现该标准
  • 全新的命令字,新版的 Node.js 才支持。

8.7 回到问题“require 与 import 的区别”

require 使用与 CommonJs 规范,import 使用于 Es6 模块规范;所以两者的区别实质是两种规范的区别;

CommonJS:

  • 对于基本数据类型,属于复制。即会被模块缓存;同时,在另一个模块可以对该模块输出的变量重新赋值。
  • 对于复杂数据类型,属于浅拷贝。由于两个模块引用的对象指向同一个内存空间,因此对该模块的值做修改时会影响另一个模块。
  • 当使用 require 命令加载某个模块时,就会运行整个模块的代码。
  • 当使用 require 命令加载同一个模块时,不会再执行该模块,而是取到缓存之中的值。也就是说,CommonJS 模块无论加载多少次,都只会在第一次加载时运行一次,以后再加载,就返回第一次运行的结果,除非手动清除系统缓存。
  • 循环加载时,属于加载时执行。即脚本代码在 require 的时候,就会全部执行。一旦出现某个模块被"循环加载",就只输出已经执行的部分,还未执行的部分不会输出。

ES6 模块

  • ES6 模块中的值属于【动态只读引用】。
  • 对于只读来说,即不允许修改引入变量的值,import 的变量是只读的,不论是基本数据类型还是复杂数据类型。当模块遇到 import 命令时,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。
  • 对于动态来说,原始值发生变化,import 加载的值也会发生变化。不论是基本数据类型还是复杂数据类型。
  • 循环加载时,ES6 模块是动态引用。只要两个模块之间存在某个引用,代码就能够执行。

最后:require/exports 是必要通用且必须的;因为事实上,目前你编写的 import/export 最终都是编译为 require/exports 来执行的。