万字长文,深入JS核心语法(上篇)(es6类&继承的实现机制)

吐槽君 分类:javascript

目录

前言
  一、编译原理
    - JavaScript 如何运行
    - JIT
  二、执行上下文与作用域
    - 执行上下文
    - 作用域
    - 修改作用域
    - 暂时性死区
  三、原型与继承
    - 原型与原型链
    - JS中的继承
    - 性能
后记
 

前言

本文为原创文章,万字长文,建议先收藏后阅读。为什么那么多 js 文章,越看越晕?概念越来越模糊?不妨试试这篇。本篇是深入学习,不是入门文章;如果对你有所帮助,欢迎收藏、点赞、评论,转载请注明出处。

文中无特殊标明的都是指在浏览器环境下的语法特性

一、编译原理

众所周知,我们写的语言,机器是“听不懂”的,需要借助翻译把我们的代码转化成机器能理解的语言(二进制文件)。

我们写的语言统称为 编程语言。
(不乏有个别大佬直接撸机器语言)

根据“翻译”时间先后的不同还可细分为 编译型语言、解释型语言。

  1. 编译型语言
    在代码运行之前,需要提前 “翻译”(编译) 好,并且编译之后会直接保留机器能读懂的二进制文件,以便之后每次运行时,都可以直接运行二进制文件,而不需要再次重新编译。常见的编译型语言有 C、C++、C#、Java 等。
  2. 解释型语言
    也被称为 脚本语言。在每次运行时才去做“翻译”,有点 “同声传译” 内味了。常见的解释型语言有 Python、VBScript、ActionScript 等。

在 编译型语言 与 机器码 间充当 “翻译” 角色的就是 编译器。
编译 是一个复杂的过程,大致包括 词法分析、语法分析、语义分析、性能优化、生成可执行文件 五个步骤。这里不深究,《编译原理》学的不够扎实,不敢随意探讨。

作者:@月舟
作者:@月舟

解释型语言 与 机器码 的 "翻译" 通常被称作 解释器。
代码在执行前,需确保环境中已经安装了 解释器。
大致需要 词法分析、语法分析、语义分析、 解释执行 四个步骤。

<img alt="作者:@月舟" title="作者:@月舟" class="lazyload" src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2aa4ed938c6942a3be3279ae2eef3015~tplv-k3u1fbpfcp-watermark.image" data-width="800" data-height="600">
作者:@月舟

JavaScript 如何运行

本小节只探讨语言层面的运行,不深究底层运行机制,对底层机制感兴趣的,请参考上一篇《从浏览器原理谈 EventLoop 与微任务宏任务》

首先,JavaScript 常被归类为 解释型语言

但是,实际上现代的浏览器中运行 JS 是有 编译器 参与的,不过它不生成可以到处执行的 二进制文件,并且 编译 通常发生在代码运行前的几微妙,或是代码运行中。
需要注意的是,这个并不是 JavaScript 或 TC39 要求的,而是 Mozilla 和 Google 的开发人员为了提升浏览器性能而引入的。

接下来看一下 js 中的 解释器 编译器 具体是如何工作的

我们都知道 js 代码运行在 v8 引擎

v8jieshiqi.jpg

  1. 生成抽象语法树(AST)
    • 分词(tokenize),也称为 词法分析,将一行行的源码拆解成一个个 token(语法上不可能再分的、最小的单个字符或字符串)
    • 解析(parse),又称为语法分析,将上一步生成的 token 数据,根据语法规则转为 AST。如果源码存在语法错误,这一步就会终止,并抛出 语法错误
  2. 以 AST 为蓝本生成 字节码
    Ignition 根据 AST 生成字节码。
  3. 执行
    Ignition 除了生成字节码,还负责解释执行字节码。一段字节码第一次执行时,Ignition 会逐条解释执行。

看到这里有的同学就会问了,“都执行完了,你说的编译器呢?”

mamalielie.gif

各位看官少安毋躁,听我解释

JIT

容我扔张图先

v8jit.png

TurboFan 就是你们要的 编译器 惹。

Ignition 在解释执行字节码时。

  • 如果发现一段代码被重复执行多次,这段代码就是所谓的 热点代码(HotSpot)
  • 这时 TurboFan 就会介入,把这段字节码直接编译机器码,机器码是啥,就是一份可以直接执行的二进制文件呀
  • 于是当这段 热点代码 再次被执行时,就会直接执行编译后的机器码,不需要再通过 字节码 “翻译” 为 机器码,大大提升了代码的执行效率。

这个技术呢,就叫做 即时编译(JIT)
正是因为这个技术的存在,所以才有人说 V8 代码执行时间越久,执行效率越高

题外话:如果一段代码执行超过一次 被称为 warm,被执行多次以后会更加 warm,当达到 hot 或者 hotter 就被称作 热点代码(HotSpot)。

一言蔽之就是:
v8 引擎在 Ignition(点火)启动以后,代码段(机器部件)开始变热(warm),运行的久了就开始发烫(HotSpot),同时配合 TurboFan (涡轮增压)极大地提升了引擎效率。

二、执行上下文与作用域

执行上下文

执行上下文(execution context),顾名思义,就是代码的执行环境。
主要作用是 跟踪代码的执行情况。

执行上下文大致可分为 3 类:

  1. 全局上下文:为运行代码主体而创建的执行上下文,为那些存在于 JavaScript 函数之外的任何代码而创建的。页面关闭后销毁。
  2. 函数上下文:每个函数会在执行的时候创建自己的执行上下文。这个上下文就是通常说的 本地上下文(local context)。函数执行完毕后销毁。
  3. eval 上下文:使用 eval() 函数创建的一个执行上下文。

每个上下文创建的时候会被推入 执行上下文栈。当退出的时候,它会从上下文栈中移除。

  • 代码开始运行时,全局上下文 被创建。
  • 当需要执行函数时,在执行开始前 函数的执行上下文 被创建,并被推入 执行上下文栈 中。
  • 函数中调用另一个函数或代码块(es6)时,当前 可执行上下文 被挂起,一个新的执行上下文被创建,并压入栈中。
  • 当前代码块执行完毕后,弹出上下文栈,上一个被挂起的上下文继续执行;执行完毕后出栈。
  • 代码执行完毕,主程序退出,全局执行上下文从执行栈中弹出。此时栈中所有的上下文都已经弹出,程序执行完毕。

(注:执行上下文栈 最顶部的可执行上下文被称为 running execution context)

ES3、ES5、ES9 三个阶段中 执行上下文 所包含的内容是不同的

ES3
  • variable object:变量对象,用于存储变量的对象。
  • scope:作用域,也常常被叫做作用域链。
  • this
ES5
  • variable environment:变量环境, 当声明变量时使用。(此环境还包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为 outer(外部环境))
  • lexical environment:词法环境, 当获取变量时使用。
  • this
ES9
  • variable environment:变量环境,当声明变量时使用。
  • lexical environment:词法环境,当获取变量或者 this 值时使用。
  • code evaluation state:用于恢复代码执行位置。
  • Realm:使用的基础库和内置对象实例
ES9 额外内容
  • Function:执行的任务是函数时使用,表示正在被执行的函数。
  • ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码。
  • Generator:仅生成器上下文有这个属性,表示当前生成器

作用域

作用域 是个抽象的概念,指在执行上下文中变量与函数的可访问范围;起到隔离变量、函数的作用,使不同作用域的变量、函数相互独立。

具体实现机制是 词法环境(lexical encironment),ES3 中使用 scope 实现,主要作用就是跟踪标识符和特定变量之间的映射关系。

从上一小节中知道,词法环境(lexical encironment)是存储在 执行上下文中的,因此,作用域 也可以看作是 执行上下文 的组成部分。

js 代码的执行需要经过 语法/词法分析、Ignition 解释执行/TurboFan 编译执行。在分析阶段 作用域 被确定,执行之前 执行上下文 被创建,并保存 作用域(词法环境) 信息。

换言之,在代码编写时,作用域就已经确定;这种作用域也被叫做 词法作用域。
(注:与之对立的是 动态作用域,在运行时才确定其作用域)

作用域分类:

  • 全局作用域
  1. 最外层函数 和在最外层函数外面定义的变量拥有全局作用域
  2. 所有末定义直接赋值的变量 自动声明为拥有全局作用域
  3. 所有 window 对象的属性拥有全局作用域
  • 块级作用域(ES6 引入)
  1. 在一个函数内部
  2. 在一个代码块(由一对花括号包裹)内部
  • 函数作用域(ES6 前)
    在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。

作用域链 即作用域的嵌套,当前 作用域 访问不到一个变量时,会沿着 作用域链 一层层向上查找。
看个小栗子

var name = "Bob";
function foo() {
  console.log(name);
}
function bar() {
  var name = "Ben";
  foo();
}
bar(); // Bob
 

简单分析一下这个例子

  • 调用 bar,bar 函数中声明了一个 name 变量,值为 “Ben”,同时调用 foo
  • foo 函数中输出 name 变量的值,foo 函数中找不到 name 变量,于是沿着 作用域链 向上查找
  • 由于 js 是 词法作用域,因此 foo 的上一层作用域是 全局作用域,而不是 bar 函数的作用域
  • 在全局作用域中找到 值为 “Bob” 的 name 变量,打印输出

zuoyongyushili.png

修改作用域

想要修改作用域是不太容易的,毕竟 js 是 词法作用域,书写时就已经确定了。
但是,我们仍可以通过两个 evel 函数 和 with 来达到修改作用域的目的。

eval

function show(execute) {
  eval(execute);
  console.log(str);
}

var str = "hello world";
var execute = 'var str = "hello javaScript"';

show(execute); // hello javaScript
 

在上面的例子中,如果不执行 eval 函数 的话,毫无疑问最后输出的会是 hello world,执行了 eval 函数后,实际的 show 函数变成了这样

function show() {
  var str = "hello javaScript";
  console.log(str);
}
 

show 函数中多了 str 变量,所以最后打印输出的是 show 函数内部的 str 而不是全局的 str 变量。

with

with 语法可以帮我们更便利的读取对象中的属性(es6 的解构出现前),同时它也会创建一个作用域。

function change(animal) {
  with (animal) {
    say = "moo";
  }
}

var dog = {
  say: "bark",
  size: "small",
};
var bull = {
  size: "big",
};

change(dog);
change(bull);
console.log(dog.say); // moo
console.log(say); // moo
 

这个例子在 严格模式 下运行会报错,简单分析一下这个例子

  • 执行 change(dog) 时,change 函数内创建了一个 with 作用域,其中的变量包括 say 和 size,with 中对 say 重新赋值,这里的传参属于 引用传递。因此 dog.say 从 “bark” 变成了 “moo”。
  • 执行 change(bull) 时,change 函数内同样创建了一个 with 作用域,其中的变量只有 size,但是,在 with 中,对一个不存的变量 say 赋值,在非严格模式下,没有声明的变量都会变成全局变量,因此,在全局作用域中多了一个 say 变量,且值为 “moo”。

实际开发中,还是慎用 eval 和 with。

暂时性死区

提一嘴 暂时性死区,这一概念随着 es6 中的 let const 声明语句引入。
看下面这个例子

function do_something() {
  console.log(bar); // undefined
  console.log(foo); // ReferenceError
  var bar = 1;
  let foo = 2;
}
 

我们都知道,var 声明的变量存在 变量提升,bar 的声明会被提升,等价于下面的代码

function do_something() {
  var bar;
  console.log(bar); // undefined
  console.log(foo); // ReferenceError
  bar = 1;
  let foo = 2;
}
 

bar 变量已经声明,但未被赋值,所以输出 undefined。

访问 foo 时直接报 引用错误,说明 let 不存在变量的提升,也可以说 foo 处在一个自块顶部到初始化处理的 暂时性死区 中。const 同理。

三、原型与继承

原型与原型链

在 JS 中只有一种结构,那就是 对象,包括 function 也只是一个 Function 对象而已,同时,Object 对象是所有对象的“祖宗”。

每个实例对象( object )又都有一个私有属性(称之为 __proto__ )指向它的构造函数的原型对象(prototype )。
实例的构造函数的原型对象又会指向它的 构造函数 的原型对象,一层层往上,最后指向 Object 的构造函数的 prototype,而它的 __proto__ 最终会指向 null。
一条完整的“链路”形成,即所谓的 原型链。

以下是 MDN 关于 原型链 的定义:

每个对象拥有一个原型对象,对象以其原型为模板、从原型继承方法和属性。原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为原型链 (prototype chain)

有点绕,通过一个例子理解一下

const a = {
  name: "张三",
  gender: "男",
  say: function () {
    console.log(`I am ${this.name}`);
  },
};
const b = {
  name: "李四",
  gender: "女",
  say: function () {
    console.log(`I am ${this.name}`);
  },
};
 

? 这个例子中,我们声明了两个对象,a、b 分别存了张三与李四的个人信息。
a、b 对象它们的 __proto__ 会指向它们的 构造函数 的原型对象,此时指向的就是 Object 构造函数的 prototype

回到例子本身,这种方式看着不太优雅,如果我们要再加一个 王五,又要再写一遍 name、gender,有点繁琐。
在其它编程语言中(如:C++、Java),通常做法会通过声明一个 类 来解决,但是,我们的 js 没有 类。
(es6 的 class 只是语法糖,本质上还是构造函数)

在 js 中通常使用 构造函数 来模拟类,并通过 new 运算符实例化。

new 的作用:

  • 将实例的 __proto__ 属性指向 构造函数 的 prototype 属性
  • 将内部的 this 绑定到实例对象上。

ok,现在再来改造一下上面的例子

function Person(name, gender) {
  this.name = name;
  this.gender = gender;
}

// 不放入 Person 防止每次新建都被赋值一次
Person.toString = function () {
  console.log("I am a person");
};
Person.prototype.say = function () {
  console.log(`Hi! I am ${this.name}`);
};

/* 也可使用 es6 的 class
  class Person {
    constructor(name, gender) {
      this.name = name;
      this.gender = gender;
    }
    static toString() {
      console.log('I am a person');
    }
    say() {
      console.log(`Hi! I am ${this.name}`);
    }
  }
*/
const a = new Person("李四", "女");
const b = new Person("张三", "男");

a.say(); // Hi! I am 李四
b.say(); // Hi! I am 张三
 

张三与李四有个共同点,都是人嘛 ~
于是我们搞了一个 Person 函数,通过 new 实例化 Person。

  • new 运算符后跟的就是 构造函数,也就是这里的 Person
  • a、b 都是 Person 的实例
  • 此时 a、b 中的私有属性 __proto__ 都指向了 Person 的 prototype 属性
  • 因为 Person 的 prototype 属性是个对象,所以它的 __proto__ 又指向 Object 构造函数的 prototype 属性

当我们访问 say 方法时,会先在实例中找,显然实例对象中只有 name、gender。
于是,会再去 实例的原型对象 寻找,找到了 say 方法,直接调用。
当然,也可能在原型对象中也找不到,这时就会去找原型对象的原型对象。

关系图如下:

yuanxinglian.png

小结

再来总结一下

  • 层级:

    • 对象都有私有属性 __proto__(非标准,由浏览器实现)
    • 构造函数(constructor)包含两个与原型有关的私有属性 prototype 和 __proto__
    • __proto__(也可以说是 constructor 的 prototype) 属性下又包含两个私有属性 constructor 和 __proto__(Object 的 __proto__ 为 null)
  • 关系:

    • 对象的 __proto__ 指向 构造函数(constructor)的 prototype
    • prototype 下的 __proto__ 又会指向上一级的 构造函数 的 prototype,形成原型链
    • 顶层是 Object 构造函数的 prototype,它的 __proto__ 最终指向 null

JS 中的继承

现在 张三、李四 长大了,要开始工作了,李四成了一名教师,张三成了法外狂徒。

? 这里我们新建了 Teacher 和 OutLaw 两个类,并使用 寄生组合继承 的方式实现对 Person 的继承。
(es6 extends 的简化版)

// 继承
function extend(child, parent) {
  // 子类构造函数的 prototype 的 proto 指向父类构造器的 prototype,继承父类的方法
  child.prototype = Object.create(parent.prototype);
  child.prototype.constructor = child;
  // 子类构造函数的 proto 指向父类构造器,继承父类的静态方法
  child.__proto__ = parent;
}

// 教师
function Teacher(name, gender, lesson) {
  // 子类构造器里调用父类构造器,继承父类的属性
  Person.call(this, name, gender);
  this.lesson = lesson;
}

extend(Teacher, Person);

// 重写 say 方法,属性遮蔽
Teacher.prototype.say = function () {
  console.log(`Hi! I am a ${this.lesson} teacher`);
};

// 法外狂徒
function OutLaw(name, gender) {
  Person.call(this, name, gender);
}
extend(OutLaw, Person);
OutLaw.prototype.say = function () {
  console.log("阿巴阿巴阿巴...");
};

const a = new Teacher("李四", "女", "English");
const b = new OutLaw("张三", "男");

a.say(); // Hi! I am a English teacher
b.say(); // 阿巴阿巴阿巴...
 

结合下图与注释理解

jicheng.png

? es6 完整写法:

class Person {
  constructor(name, gender) {
    this.name = name;
    this.gender = gender;
  }
  static toString() {
    console.log("I am a person");
  }
  say() {
    console.log(`Hi! I am ${this.name}`);
  }
}

class Teacher extends Person {
  constructor(name, gender, lesson) {
    super(name, gender);
    this.lesson = lesson;
  }
  say() {
    console.log(`Hi! I am a ${this.lesson} teacher`);
  }
}

class OutLaw extends Person {
  constructor(name, gender) {
    super(name, gender);
  }
  say() {
    console.log("阿巴阿巴阿巴...");
  }
}

const a = new Teacher("李四", "女", "English");
const b = new OutLaw("张三", "男");

a.say(); // Hi! I am a English teacher
b.say(); // 阿巴阿巴阿巴...
 

性能

在原型链上查找属性比较耗时,对性能有副作用,这在性能要求苛刻的情况下很重要。另外,试图访问不存在的属性时会遍历整个原型链。

在遍历对象的属性时,原型链上的每个可枚举属性都会被枚举出来。如果只是要检查对象是否具有自己定义的属性,而不是其原型链上的某个属性,可以使用从 Object.prototype 继承的 hasOwnProperty 方法,避免找不到属性时,查找整个原型链。

function Person(name, gender) {
  this.name = name;
  this.gender = gender;
}
const a = new Person('张三', '男');

for(key in a) {
  if(a.hasOwnProperty(key)) {
    const ele = a[key];
    // do something
  }
}
 

后记

如有其它意见,欢迎评论区讨论。

回复

我来回复
  • 暂无回复内容