JavaScript由文档对象模型DOM、浏览器对象模型BOM以及它的核心ECMAScript这三部分组成,本篇文章带来了JavaScript中的底层原理知识,希望对大家有帮助。
JavaScript是一门直译式的解释型脚本语言,它具有动态性、弱类型、基于原型等特点。JavaScript植根于我们使用的Web浏览器中,它的解释器为浏览器中的JavaScript引擎。这一门广泛用于客户端的脚本语言,最早是为了处理以前由服务器端语言负责的一些输入验证操作,随着Web时代的发展,JavaScript不断发展壮大,成为一门功能全面的编程语言。它的用途早已不再局限于当初简单的数据验证,而是具备了与浏览器窗口及其内容等几乎所有方面交互的能力。它既是一门非常简单的语言,又是一门及其复杂的语言,要想真正精通JavaScript,我们就必须深入的去了解它的一些底层设计原理。本文将参考《JavaScript高级程序设计》和《你不知道的JS》系列丛书,为大家讲解一些关于JavaScript的底层知识。
数据类型
按照存储方式,JavaScript的数据类型可以分为两种,原始数据类型(原始值)和引用数据类型(引用值)。
原始数据类型目前有六种,包括Number、String、Boolean、Null、Undefined、Symbol(ES6),这些类型是可以直接操作的保存在变量中的实际值。原始数据类型存放在栈中,数据大小确定,它们是直接按值存放的,所以可以直接按值访问。
引用数据类型则为Object,在JavaScript中除了原始数据类型以外的都是Object类型,包括数组、函数、正则表达式等都是对象。引用类型是存放在堆内存中的对象,变量是保存在栈内存中的一个指向堆内存中对象的引用地址。当定义了一个变量并初始化为引用值,若将它赋给另一个变量,则这两个变量保存的是同一个地址,指向堆内存中的同一个内存空间。如果通过其中一个变量去修改引用数据类型的值,另一个变量也会跟着改变。
对于原始数据类型,除了null比较特殊(null会被认为是一个空的对象引用),其它的我们可以用typeof进行准确判断:
表达式 |
返回值 |
typeof 123 |
'number' |
typeof "abc" |
'string' |
typeof true |
'boolean' |
typeof null |
'object' |
typeof undefined |
'undefined' |
typeof unknownVariable(未定义的变量) |
'undefined' |
typeof Symbol() |
‘symbol’ |
typeof function() {} |
'function' |
typeof {} |
'object' |
typeof [] |
'object' |
typeof(/[0-9,a-z]/) |
‘object’ |
对于null类型,可以使用全等操作符进行判断。一个已经声明但未初始化的变量值会默认赋予undefined (也可以手动赋予undefined),在JavaScript中,使用相等操作符==无法区分null和undefined,ECMA-262规定对它们的相等性测试要返回true。要准确区分两个值,需要使用全等操作符===。
对于引用数据类型,除了function在方法设计上比较特殊,可以用typeof进行准确判断,其它的都返回object类型。我们可以用instanceof 对引用类型值进行判断。instanceof 会检测一个对象A是不是另一个对象B的实例,它在底层会查看对象B是否在对象A的原型链上存在着(实例和原型链文章后面会讲)。如果存在,则返回true,如果不在则返回false。
表达式 |
返回值 |
[1,2,3] instanceof Array |
‘true’ |
function foo(){ } instanceof Function |
‘true’ |
/[0-9,a-z]/ instanceof RegExp |
‘true’ |
new Date() instanceof Date |
‘true’ |
{name:”Alan”,age:”22”} instanceof Object |
‘true’ |
由于所有引用类型值都是Object的实例,所以用instance操作符对它们进行Object的判断,结果也会返回true。
表达式 |
返回值 |
[1,2,3] instanceof Object |
‘true’ |
function foo(){ } instanceof Object |
‘true’ |
/[0-9,a-z]/ instanceof Object |
‘true’ |
new Date() instanceof Object |
‘true’ |
当然,还有一种更为强大的方法,可以精准的判断任何JavaScript中的任何数据类型,那就是Object.prototype.toString.call() 方法。在ES5中,所有对象(原生对象和宿主对象)都有一个内部属性[[Class]],它的值是一个字符串,记录了该对象的类型。目前包括"Array", "Boolean", "Date", "Error", "Function", "Math", "Number", "Object", "RegExp", "String",“Arguments”, "JSON","Symbol”。通过Object.prototype.toString() 方法可以用来查看该内部属性,除此自外没有其它方法。
在Object.prototype.toString()方法被调用时,会执行以下步骤:1.获取this对象的[[Class]]属性值(关于this对象文章后面会讲)。2.将该值放在两个字符串”[object ” 与 “]” 中间并拼接起来。3.返回拼接完的字符串。
当遇到this的值为null时,Object.prototype.toString()方法直接返回”[object Null]”。当遇到this的值为undefined时,直接返回”[object Undefined]”。
表达式 |
返回值 |
Object.prototype.toString.call(123) |
[object Number] |
Object.prototype.toString.call(“abc”) |
[object String] |
Object.prototype.toString.call(true) |
[object Boolean] |
Object.prototype.toString.call(null) |
[object Null] |
Object.prototype.toString.call(undefined) |
[object Undefined] |
Object.prototype.toString.call(Symbol()) |
[object Symbol] |
Object.prototype.toString.call(function foo(){}) |
[object Function] |
Object.prototype.toString.call([1,2,3]) |
[object Array] |
Object.prototype.toString.call({name:”Alan” }) |
[object Object] |
Object.prototype.toString.call(new Date()) |
[object Date] |
Object.prototype.toString.call(RegExp()) |
[object RegExp] |
Object.prototype.toString.call(window.JSON) |
[object JSON] |
Object.prototype.toString.call(Math) |
[object Math] |
call()方法可以改变调用Object.prototype.toString()方法时this的指向,使它指向我们传入的对象,因此能获取到我们传入对象的[[Class]]属性(使用Object.prototype.toString.apply()也能达到同样的效果)。
JavaScript的数据类型也是可以转换的,数据类型转换分为两种方式:显示类型转换和隐式类型转换。
显示类型转换可以调用方法有Boolean()、String()、Number()、parseInt()、parseFloat()和toString() (null和undefined值没有这个方法),它们各自的用途一目了然,这里就不一一介绍了。
由于JavaScript是弱类型语言,在使用算术运算符时,运算符两边的数据类型可以是任意的,不用像Java或C语言那样指定相同的类型,引擎会自动为它们进行隐式类型转换。隐式类型转换不像显示类型转换那么直观,主要是三种转换方式:
1. 将值转换为原始值:toPrimitive()
2. 将值转换为数字:toNumber()
3. 将值转换为字符串:toString()
一般来说,当对数字和字符串进行相加操作时,数字会被转换成字符串;当进行真值判断时(如if、||、&&),参数会被转换成布尔值;当进行比较运算、算术运算或自增减运算时,参数会被转换成Number值;当对象需要进行隐式类型转换时,会取得对象的toString()方法或valueOf()方法的返回值。
关于NaN:
NaN是一个特殊的数值,表示非数值。首先,任何涉及NaN的运算操作都会返回NaN。其次,NaN与任何值都不相等,包括NaN本身。ECMAScript定义了一个isNaN()函数,可以用来测试某个参数是否为“非数值”。它首先会尝试将参数隐式转换为数值,如果无法转换为数值则返回true。
我们可以先通过typeof判断是否为Number类型,再通过isNaN来判断当前数据是否为NaN。
关于字符串:
JavaScript中的字符串是不可变的,字符串一旦被创建,它们的值就不能改变。要改变某个变量保存的字符串,首先要销毁原来的字符串,然后再用另一个包含新值的字符串填充该变量。这个过程在后台发生,而这也是某些老版本浏览器在拼接字符串时速度很慢的原因所在。
其实为了便于操作基本类型值,ECMAScript还提供了3个特殊的引用类型:Boolean、Number和String。原始数据类型是没有属性和方法的,当我们在原始类型值上调用方法读取它们时,访问过程会处于一种读取模式,后台会创建一个相应的原始包装类型的对象,从而让我们能够调用一些方法来操作这些数据。这个过程分为三个步骤:1.创建原始包装类型的实例 2.在实例上调用指定的方法 3.销毁这个实例。
引用类型与原始包装类型的主要区别是对象的生存周期,自动创建的原始包装类型对象,只存在于一行代码的执行瞬间,然后立即被销毁,因此我们不能在运行时为原始类型值添加属性和方法。
在《你不知道的JavaScript》一书中作者表示过,尽管将JavaScript归类为“动态语言”或“解释执行语言”,但事实上它是一门编译语言。JavaScript运行分为三个步骤:1.语法分析 2.预编译 3.解释执行。语法分析和解释执行都不难理解,一个是检查代码是否有语法错误,一个则负责将程序一行一行的执行,但JavaScript中的预编译阶段却稍微比较复杂。
任何JavaScript代码在执行前都要进行编译,编译过程大部分情况下发生在代码执行前的几微秒内。编译阶段JavaScript引擎会从当前代码执行作用域开始,对代码进行RHS查询,以获取变量的值。接着在执行阶段引擎会执行LHS查询,对变量进行赋值。
在编译阶段,JavaScript引擎的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来。在预编译过程,如果是在全局作用域下,JavaScript引擎首先会在全局作用域上创建一个全局对象(GO对象,Global Object),并将变量声明和函数声明进行提升。提升后的变量先默认初始化为undefined,而函数则将整个函数体进行提升(如果是以函数表达式的形式定义函数,则应用变量提升的规则),然后将它们存放到全局变量中。函数声明的提升会优先于变量声明的提升,对于变量声明来说,重复出现的var声明会被引擎忽略,而后面出现的函数声明可以覆盖前面的函数声明(ES6新的变量声明语法let情况稍稍有点不一样,这里暂时先不讨论)。
函数体内部是一块独立的作用域,在函数体内部也会进行预编译阶段。在函数体内部,首先会创建一个活动对象(AO对象,Active Object),并将形参和变量声明以及函数体内部的函数声明进行提升,形参和变量初始化为undefined,内部函数依然为内部函数体本身,然后将它们存放到活动对象中。
编译阶段结束后,就会执行JavaScript代码。执行过程根据先后顺序依次对变量或形参进行赋值操作。引擎会在作用域上查找是否有对应的变量声明或形参声明,如果找到了就会对它们进行赋值操作。对于非严格模式来说,若变量未经声明就进行赋值,引擎会在全局环境自动隐式地为该变量创建一个声明,但对于严格模式来说对未经声明的变量进行赋值操作则会报错。因为JavaScript执行是单线程的,所以如果在赋值操作(LHS查询)执行前就先对变量进行获取(RHS查询)并输出,会得到undefined的结果,因为此时变量还未赋值。
每个函数都是Function对象的一个实例,在JavaScript中,每个对象都有一个仅供JavaScript引擎存取的内部属性—— [[Scope]]。对于函数来说,[[Scope]]属性包含了函数被创建的作用域中对象的集合——作用域链。当在全局环境中创建一个函数时,该函数的作用域链便会插入一个全局对象,包含所有在全局范围内定义的变量。
内部作用域可以访问外部作用域,但外部作用域无法访问内部作用域。由于JavaScript没有块级作用域,因此在if语句或者for循环语句中定义的变量是可以在语句外部访问到的。在ES6之前,javascript只有全局作用域和函数作用域,ES6新增了块级作用域的机制。
而当该函数被执行时,会为执行函数创建一个称为执行环境(execution context,也称为执行上下文)的内部对象。每个执行环境都有自己的作用域链,当执行环境被创建时,它的作用域链顶端先初始化为当前运行函数的[[Scope]]属性中的对象。紧接着,函数运行时的活动对象(包括所有局部变量、命名参数、arguments参数集合和this)也会被创建并推入作用域链的最顶端。
函数每次执行时对应的执行环境都是独一无二的,多次调用同一个函数就会导致创建多个执行环境。当函数执行完毕,执行环境就会被销毁。当执行环境被销毁,活动对象也随之销毁(全局执行环境会等到应用程序退出时才会被销毁,如关闭网页或浏览器)。
函数执行过程中,每遇到一个变量,都会经历一次标识符解析过程,以决定从哪里获取或存储数据。标识符解析是沿着作用域链一级一级地搜索标识符的过程,全局变量始终都是作用域链的最后一个对象(即window对象)。
在JavaScript中,有两个语句可以在执行时临时改变作用域链。第一个是with语句。with语句会创建一个可变对象,包含参数指定对象的所有属性,并将该对象推入作用域链的首位,这意味着函数的活动对象被挤到作用域链的第二位。这样虽然使得访问可变对象的属性非常快,但访问局部变量等的速度就变慢了。第二条能改变执行环境作用域链的语句是try-catch语句中的catch子句。当try代码块中发生错误,执行过程会自动跳转到catch子句,然后把异常对象推入一个变量对象并置于作用域的首位。在catch代码块内部,函数所有局部变量将会放在第二个作用域链对象中。一旦catch子句执行完毕,作用域链就会返回到之前的状态。
JavaScript中的构造函数可以用来创建特定类型的对象。为了区别于其它函数,构造函数一般使用大写字母开头。不过在JavaScript中这并不是必须的,因为JavaScript不存在定义构造函数的特殊语法。在JavaScript中,构造函数与其它函数的唯一区别,就在于调用它们的方式不同。任何函数,只要通过new操作符来调用,就可以作为构造函数。
JavaScript函数有四种调用模式:1.独立函数调用模式,如foo(arg)。2.方法调用模式,如obj.foo(arg)。3.构造器调用模式,如new foo(arg)。4.call/apply调用模式,如foo.call(this,arg1,arg2)或foo.apply(this,args) (此处的args是一个数组)。
要创建构造函数的实例,发挥构造函数的作用,就必须使用new操作符。当我们使用new操作符实例化构造函数时,构造函数内部会执行以下步骤:
1.隐式创建一个this空对象
2.执行构造函数中的代码(为当前this对象添加属性)
3.隐式返回当前this对象
如果构造函数显示的返回一个对象,那么实例为这个返回的对象,否则则为隐式返回的this对象。
当我们调用构造函数创建实例后,实例便具备构造函数所有的实例属性和方法。对于通过构造函数创建的不同实例,它们之间的实例属性和方法都是各自独立的。那怕是同名的引用类型值,不同实例之间也不会相互影响。
原型和原型链既是JavaScript这门语言的精髓之一,也是这门语言的难点之一。原型prototype(显式原型)是函数特有的属性,任何时候,只要创建了一个函数,这个函数就会自动创建一个prototype属性,并指向该函数的原型对象。所有原型对象都会自动获得一个constructor(构造者,也可翻译为构造函数)属性,这个属性包含一个指向prototype属性所在函数(即构造函数本身)的指针。而当我们通过构造函数创建一个实例后,该实例内部将包含一个[[Prototype]]的内部属性(隐式原型),同样也指向构造函数的原型对象。在Firefox、Safari和Chrome浏览器中,每个对象都可以通过__proto__属性访问它们的[[Prototype]]属性。而对其它浏览器而言,这个属性对脚本则是完全不可见的。
构造函数的prototype属性和实例的[[Prototype]]都是指向构造函数的原型对象,实例的 [[Prototype]] 属性与构造函数之间并没有直接的关系。要想知道实例的 [[Prototype]] 属性是否指向某个构造函数的原型对象,我们可以使用isPrototypeOf()或者Object.getPrototypeOf() 方法。
每当读取某个对象实例的某个属性时,都会执行一次搜索,目标是具有给定名字的属性。搜索首先从对象实例本身开始,如果在实例中找到了具有给定名称的属性,就返回该属性的值;如果没有找到,则继续搜索该对象[[Prototype]]属性指向的原型对象,在原型对象中查找给定名称的属性,如果找到再返回该属性的值。
判断对象是哪个构造函数的直接实例,可以直接在实例上访问constructor属性,实例会通过[[Prototype]]读取原型对象上的constructor属性返回构造函数本身。
原型对象中的值可以通过对象实例访问,但却不能通过对象实例修改。如果在实例中添加一个与实例原型对象同名的属性,那我们就在实例中创建该属性,这个实例属性会阻止我们访问原型对象中的那个属性,但不会修改那个属性。简单将该实例属性设为null并不能恢复访问原型对象中该属性的连接,若要恢复访问原型对象中的该属性,可以用delete操作符完全删除对象实例的该属性。
使用hasOwnProperty()方法可以检测一个属性是存在于实例中,还是存在于原型中。这个方法只有在给定属性存在于对象实例中时,才会返回true。若要取得对象自身所有可枚举的实例属性,可以使用ES5的Object.keys() 方法。若要获取所有实例属性,无论是否可枚举,可以使用Object.getOwnPropertyNames() 方法。
原型具有动态性,对原型对象所做的任何修改都能立即从实例上反应出来,但如果是重写整个原型对象,情况就不一样了。调用构造函数会为对象实例添加一个指向最初原型对象的 [[Prototype]] 指针,而重写整个原型对象后,构造函数指向新的原型对象,所有的原型对象属性和方法都存在与新的原型对象上;而对象实例还指向最初的原型对象,这样一来构造函数与最初原型对象之间指向同一个原型对象产生的联系就被切断了,因为它们分别指向了不同的原型对象。
若要恢复这种联系,可以在构造函数prototype重写后再实例化对象实例,或者修改对象实例的__proto__属性重新指向构造函数新的原型对象。
JavaScript将原型链作为实现继承的主要方式,它利用原型让一个引用类型继承另一个引用类型的属性和方法。构造函数的实例有一个指向原型对象的 [[Prototype]] 属性,当我们让构造函数的原型对象等于另一个类型的实例,原型对象也将包含一个指向另一个原型的 [[Prototype]] 指针,假如另一个原型又是另一个类型的实例…如此层层递进,就构成了实例与原型的链条。这就是所谓原型链的基本概念。
原型链扩展了原型搜索机制,当读取一个实例属性时,首先会在实例中搜索该属性。如果没有找到该属性,则会继续搜索实例[[Prototype]] 指向的原型对象,原型对象此时也变成了另一个构造函数的实例,如果该原型对象上也找不到,就会继续搜索该原型对象[[Prototype]] 指向的另一个原型对象…搜索过程沿着原型链不断向上搜索,在找不到指定属性或者方法的情况下,搜索过程就会一环一环地执行到原型链末端才会停下来。
如果不对函数的原型对象进行修改,所有引用类型都有一个[[Prototype]] 属性默认指向Object的原型对象。因此,所有函数的默认原型都是Object的实例,这也正是所有自定义类型都会继承toString()、valueOf() 等默认方法的根本原因。可以使用instanceof操作符或isPrototypeOf() 方法来判断实例的原型链中是否存在某个构造函数的原型。
原型链虽然很强大,但是它也存在一些问题。第一个问题是原型对象上的引用类型值是所有实例共享的,这意味着不同实例的引用类型属性或方法都指向同一个堆内存,一个实例在原型上修改引用值会同时影响到所有其它实例在原型对象上的该引用值,这便是为何要在构造函数中定义私有属性或方法,而不是在原型上定义的原因。原型链的第二个问题,在于当我们将一个构造函数的原型prototype等于另一个构造函数的实例时,如果我们在这时候给另一个构造函数传递参数设置属性值,那么基于原来的构造函数所有实例的该属性都会因为原型链的关系跟着被赋予相同的值,而这有时候并不是我们想要的结果。
闭包是JavaScript最强大的特性之一,在JavaScript中,闭包,是指有权访问另一个函数作用域中的变量的函数,它意味着函数可以访问局部作用域之外的数据。创建闭包的常见方式,就是在一个函数内部创建另一个函数并返回这个函数。
一般来讲,当函数执行完毕后,局部活动对象就会被销毁,内存中仅保存全局作用域。但是,闭包的情况有所不同。
闭包函数的[[Scope]]属性会初始化为包裹它的函数的作用域链,所以闭包包含了与执行环境作用域链相同的对象的引用。一般来讲,函数的活动对象会随着执行环境一同销毁。但引入闭包时,由于引用仍然存在于闭包的[[Scope]]属性中,因此原函数的活动对象无法被销毁。这意味着闭包函数与非闭包函数相比,需要更多的内存开销,导致更多的内存泄漏。此外,闭包访问原包裹函数的活动对象时,在作用域链上需要先跨过对自身活动对象的标识符解析,找到更上面的一层,因此闭包使用原包裹函数的变量对性能也是有很大的影响。
在定时器、事件监听器、Ajax请求、跨窗口通信、Web Workers或者任何其他的异步或同步任务中,只要使用了回调函数,实际上就是在使用闭包。
典型的闭包问题就是在for循环中使用定时器输出循环变量:
这段代码,对于不熟悉JavaScript闭包的朋友来说,可能会想当然的认为结果会依次输出0、1、2、3,然而,实际的情况是,这段代码输出的四个数字都是4。
这是因为,由于定时器是异步加载机制,会等for循环遍历完毕才会执行。每次执行定时器,定时器都会在它外部作用域中查找i变量。由于循环已经结束,外部作用域的i变量早已被更新为4,所以4个定时器取得的i变量都为4,而不是我们理想中输出0,1,2,3。
解决这个问题,我们可以创建一个包裹立即执行函数的新的作用域,将每次循环中外部作用域的i变量保存到新创建的作用域中,让定时器每次都先从新作用域中取值,我们可以用立即执行函数来创建这个新的作用域:
这样循环执行的结果就会依次输出0,1,2,3了,我们还可以把这个立即执行函数再简化一些,直接将i作用实参传给立即执行函数,就不用在里面给j赋值了:
当然,采用立即执行函数不是必须的,你也可以创建一个非匿名的函数并在每次循环的时候执行它,只不过这样就会多占用一些内存来保存函数声明了。
因为在ES6之前还没有块级作用域的设定,所以我们只能采取手动创建一个新的作用域的方法来解决这个问题。ES6开始设定了块级作用域,我们可以使用let定义块级作用域的方法:
let操作符会创建块级作用域,通过let声明的变量保存在当前块级作用域中,所以每个立即执行函数每次都会从它当前的块级作用域中查找变量。
let还有一个特殊的定义,它使变量在循环过程中不止被声明一次,每次循环都会重新声明,并用上一个循环结束时的值来初始化新声明的变量,所以,我们也可以直接在for循环头部使用let:
this关键字是JavaScript中最复杂的机制之一,它被自动定义在所有函数的作用域中。人们很容易把this理解成指向函数自身,然而,在 ES5 中,this并不是在函数声明时绑定的,它是在函数运行时进行绑定的,它的指向只取决于函数的调用方式,与函数声明的位置没有关系。(ES6新增的箭头函数里的 this有所不同,它的指向取决于函数声明的位置。)
还记得我前面提到的函数四种调用模式吗: 1.独立函数调用模式,如foo(arg)。2.对象方法调用模式,如obj.foo(arg)。3.构造器调用模式,如new foo(arg)。4.call/apply调用模式,如foo.call(this)或foo.apply(this) 。
对于独立函数调用模式来说,在非严格模式下,它里面的this会默认指向全局对象。而在严格模式中,this不允许默认绑定到全局对象,因此会绑定为undefined。
对于对象方法调用模式来说,函数中的this会指向调用它的对象本身:
对于构造器调用模式,前面有介绍过构造函数内部的执行步骤:
1.隐式创建一个this空对象
2.执行构造函数中的代码(为当前this对象添加属性)
3.隐式返回当前this对象
所以,用new方式调用一个函数时,它的this是指向构造函数内部隐式独立创建的this对象,所有通过this添加的属性或方法最终都会添加到这个空对象中并返回给构造函数的实例。
对于call/apply调用模式,函数里的this会绑定到你传入的第一个参数,如图所示:
foo.apply()和foo.call()在改变this指向的功能是一样的,区别只在于第二个参数开始传的是数组格式的参数还是分散开来的参数格式。
关于JavaScript的底层原理的东西,今天就暂时写到这里,之后还会陆续更新关于JavaScript的内容,欢迎继续关注。
【相关推荐:javascript学习教程】
以上是深入理解JS数据类型、预编译、执行上下文等JS底层机制的详细内容。更多信息请关注PHP中文网其他相关文章!