目录
前言
一、编译原理
- JavaScript 如何运行
- JIT
二、执行上下文与作用域
- 执行上下文
- 作用域
- 修改作用域
- 暂时性死区
三、原型与继承
- 原型与原型链
- JS中的继承
- 性能
后记
前言
文中无特殊标明的都是指在浏览器环境下的语法特性
一、编译原理
众所周知,我们写的语言,机器是“听不懂”的,需要借助翻译把我们的代码转化成机器能理解的语言(二进制文件)。
我们写的语言统称为 编程语言。
(不乏有个别大佬直接撸机器语言)
根据“翻译”时间先后的不同还可细分为 编译型语言、解释型语言。
- 编译型语言 在代码运行之前,需要提前 “翻译”(编译) 好,并且编译之后会直接保留机器能读懂的二进制文件,以便之后每次运行时,都可以直接运行二进制文件,而不需要再次重新编译。常见的编译型语言有 C、C++、C#、Java 等。
- 解释型语言 也被称为 脚本语言。在每次运行时才去做“翻译”,有点 “同声传译” 内味了。常见的解释型语言有 Python、VBScript、ActionScript 等。
在 编译型语言 与 机器码 间充当 “翻译” 角色的就是 编译器。
编译 是一个复杂的过程,大致包括 词法分析、语法分析、语义分析、性能优化、生成可执行文件 五个步骤。这里不深究,《编译原理》学的不够扎实,不敢随意探讨。
作者:@月舟
解释型语言 与 机器码 的 "翻译" 通常被称作 解释器。
代码在执行前,需确保环境中已经安装了 解释器。
大致需要 词法分析、语法分析、语义分析、 解释执行 四个步骤。
作者:@月舟
JavaScript 如何运行
首先,JavaScript 常被归类为 解释型语言。
但是,实际上现代的浏览器中运行 JS 是有 编译器 参与的,不过它不生成可以到处执行的 二进制文件,并且 编译 通常发生在代码运行前的几微妙,或是代码运行中。
需要注意的是,这个并不是 JavaScript 或 TC39 要求的,而是 Mozilla 和 Google 的开发人员为了提升浏览器性能而引入的。
接下来看一下 js 中的 解释器 编译器 具体是如何工作的
我们都知道 js 代码运行在 v8 引擎
- 生成抽象语法树(AST)
- 分词(tokenize),也称为 词法分析,将一行行的源码拆解成一个个 token(语法上不可能再分的、最小的单个字符或字符串)
- 解析(parse),又称为语法分析,将上一步生成的 token 数据,根据语法规则转为 AST。如果源码存在语法错误,这一步就会终止,并抛出 语法错误
- 以 AST 为蓝本生成 字节码 Ignition 根据 AST 生成字节码。
- 执行 Ignition 除了生成字节码,还负责解释执行字节码。一段字节码第一次执行时,Ignition 会逐条解释执行。
看到这里有的同学就会问了,“都执行完了,你说的编译器呢?”
各位看官少安毋躁,听我解释
JIT
容我扔张图先
TurboFan 就是你们要的 编译器 惹。
Ignition 在解释执行字节码时。
- 如果发现一段代码被重复执行多次,这段代码就是所谓的 热点代码(HotSpot)
- 这时 TurboFan 就会介入,把这段字节码直接编译机器码,机器码是啥,就是一份可以直接执行的二进制文件呀
- 于是当这段 热点代码 再次被执行时,就会直接执行编译后的机器码,不需要再通过 字节码 “翻译” 为 机器码,大大提升了代码的执行效率。
这个技术呢,就叫做 即时编译(JIT)
正是因为这个技术的存在,所以才有人说 V8 代码执行时间越久,执行效率越高
一言蔽之就是:
v8 引擎在 Ignition(点火)启动以后,代码段(机器部件)开始变热(warm),运行的久了就开始发烫(HotSpot),同时配合 TurboFan (涡轮增压)极大地提升了引擎效率。
二、执行上下文与作用域
执行上下文
执行上下文(execution context),顾名思义,就是代码的执行环境。
主要作用是 跟踪代码的执行情况。
执行上下文大致可分为 3 类:
- 全局上下文:为运行代码主体而创建的执行上下文,为那些存在于 JavaScript 函数之外的任何代码而创建的。页面关闭后销毁。
- 函数上下文:每个函数会在执行的时候创建自己的执行上下文。这个上下文就是通常说的 本地上下文(local context)。函数执行完毕后销毁。
- 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 编译执行。在分析阶段 作用域 被确定,执行之前 执行上下文 被创建,并保存 作用域(词法环境) 信息。
换言之,在代码编写时,作用域就已经确定;这种作用域也被叫做 词法作用域。
(注:与之对立的是 动态作用域,在运行时才确定其作用域)
作用域分类:
- 全局作用域
- 最外层函数 和在最外层函数外面定义的变量拥有全局作用域
- 所有末定义直接赋值的变量 自动声明为拥有全局作用域
- 所有 window 对象的属性拥有全局作用域
- 块级作用域(ES6 引入)
- 在一个函数内部
- 在一个代码块(由一对花括号包裹)内部
- 函数作用域(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 变量,打印输出
修改作用域
想要修改作用域是不太容易的,毕竟 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 关于 原型链 的定义:
有点绕,通过一个例子理解一下
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 方法,直接调用。
当然,也可能在原型对象中也找不到,这时就会去找原型对象的原型对象。
关系图如下:
小结
再来总结一下
-
层级:
- 对象都有私有属性 __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(); // 阿巴阿巴阿巴...
结合下图与注释理解
? 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
}
}
后记
如有其它意见,欢迎评论区讨论。文章同时发在个人公众号,欢迎关注 MelonField 深入JS核心语法
参考:
- www.yuque.com/suihangadam…
- www.ruanyifeng.com/blog/2011/0…
- www.jianshu.com/p/6dd0e22ff…
- developer.mozilla.org/zh-CN/docs/…
- developer.mozilla.org/zh-CN/docs/…
- developer.mozilla.org/zh-CN/docs/…
- developer.mozilla.org/zh-CN/docs/…
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!