JS高级入门教程

momo707577045

JS高级入门教程

目录

  1. 本文章定位及介绍
  2. JavaScript与ECMAScript的关系
  3. DOM的本质及DOM级介绍
  4. JS代码特性
  5. 基本类型与引用类型
  6. JS的垃圾回收机制
  7. 作用域链介绍及其实现原理
  8. 闭包
  9. this指针
  10. 自执行函数的介绍及应用
  11. 声明提前
  12. JS线程问题

本培训的定位及相关介绍

内容特点:

目标对象定位:

  • 主要面向对象:对于有一年左右工作经验的前端工程师。提高JS的认识,突破JS学习瓶颈。
  • 对于没有经验的小伙伴,可以通过本文章对JS有初步认识,了解JS的相关特性。
  • 除了系统性的JS内容,还会穿插介绍一些笔者在学习过程中遇到的认识上的困惑与小问题。希望能减少小伙伴在学习过程中遇到的障碍。
  • 对了,里面有些内容是本人觉得有趣的,好玩的概念。可能除了能帮大家理清楚概念以外,并没什么卵用。希望大家不要见怪。

JavaScript与ECMAScript的关系

前言:

可能大家在阅读JS相关书籍或浏览网上资料的时候会多多少少看过ECMAScript这个词汇,它与JS有什么关系呢?还有为什么JS最新的语法不是叫JS6而是叫ES6呢?在这里给大家介绍一下JS与ES的关系。
ES6

解析:

  • 首先ECMAScript(简称ES)是由欧洲计算机制造商协会ECMA(European Computer Manufacturers Association)制定的标准化脚本程序设计语言。
  • 其次ES只是JS的其中一部分。一个完整的JS主要包括这几部分(ES,DOM,BOM)。他们的结构图是这样的。JS与ES的关系
  • 其中ES负责脚本的基本语法及逻辑的实现。
  • DOM负责HTML标签的解析及渲染实现。
  • BOM负责提供浏览器的API接口。

结论:

  • ES是一个与应用场景无关的纯语言,只提供基本逻辑操作的实现,原则上不实现视觉上的功能。
  • WEB只是ES实现的宿主环境之一。ES在WEB中结合DOM和BOM形成了JS。
  • 这也是为什么最新的JS语法称为ES6而不是JS6。(实际上,像前面所说的,ES只是一个纯语言,所以ES6上更新的只是JS语法上的内容。并不会提供其他新的WEB功能。新的WEB功能的内容属于HTML或CSS的内容,如HTML5和CSS3)
  • 小插曲,有的小伙伴可能看到过JScript这个词。JScript是在未制定出ES标准的混乱时代中,微软出的自家使用脚本语言,相当于IE版本的JS。但现在JS已经得到了统一。JScript已经不复存在了。

DOM的本质及DOM级介绍

前言:

在前端学习过程中,我们一听到DOM会自然地联想到HTML的标签。但是DOM真的只是和HTML有关系吗?到底什么才是DOM呢?另外时常遇到的DOM0123级又是指什么东西呢?

解析(DOM的本质)

  • 我们先来看看DOM的字面意思是什么:DOM(Document Object Model),文档对象模型。是将基于某文档结构(如XML结构)的字符串转化为一棵在常驻内容的树状数据结构的模型。对于XML来说,就是对XML标签进行解析后的数据结构体(我们称之为DOM树)。
  • 它的理念是:开发者通过该数据结构获得文档结构(即有特定字符串组成的文档)的控制权。通过DOM提供的接口,对该文档结构进行增,删,改等操作,即对数据结构进行操作。
  • DOM本身是独立于平台和语言的。是进行脚本解析的解决思路(将文档转化为对象,并以树状结构组织起来)。不同的语言可以针对自身的特点制作自己的DOM实现。(而我们理解中的DOM只是针对HTML语言的是其中一种实现而已)
  • 除了HTML DOM外,其他的语言也发布了只针对自己的DOM标准。如SVG(可伸缩矢量图),MathML(数学标记语言),SMIL(同步多媒体集成语言)

解析(DOM级别介绍)

解释完DOM的本质以后,接下来的DOM全都特指HTML DOM

  • 一句话说完,DOM级别只是DOM的版本。不同的DOM级实现了不同的功能。
  • DOM0是指在W3C进行标准化之前,还处于未形成标准的初期阶段的版本。即还未形成标准的东西。严格意义上并不在DOM版本的范畴,只是为版本出来前的DOM起个名字,所以才说0版本。
  • DOM1级在1998年10月份成为W3C的提议,由DOM核心与DOM HTML两个模块组成。DOM核心能映射以XML为基础的文档结构,允许获取和操作文档的任意部分(即对某标签的get和set)。DOM HTML通过添加HTML专用的对象与函数对DOM核心进行了扩展(即封装了一些函数和对象,更方便地get和set)。
  • DOM2通过对象接口增加了对鼠标和用户界面事件、范围、遍历(重复执行DOM文档)和层叠样式表(CSS)的支持。
  • DOM3通过引入统一方式载入和保存文档和文档验证方法对DOM进行进一步扩展。
  • 并没什么卵用,只是学习几个名词的意思。

JS代码特性介绍

前言

这部分内容适合没有实际经验的同学,可以通过这一部分了解JS的特性。有经验的同学也可以了解一下,因为接下来的内容都是围绕这几大特性进行讲解的。

特性

  • 弱类型语言:在JS中,变量没有固定的数据类型。不同数据类型的变量是可以相互转换的,如:var a = 0; a = "a";(前面赋值为数值类型,后面变成了字符串类型) 而C++,PHP则是强类型语言,不能直接进行数据类型的转换。
  • 解析性语言:不同于java或C#等编译性语言,js是不需要进行编译的,而是由浏览器进行动态地解析与执行。可以理解为浏览器是一个大型的函数,而JS代码是函数的参数。由浏览器去解析JS代码。
  • 跨平台性:由于JS只依赖浏览器本身。底层实现无关,使得其余操作环境无关。实现了跨平台。
  • 一切皆对象:JS是一项面向对象的语言。所看到的一切都是一个对象。
  • 单线程:JS是单线程的。这牵扯到JS的代码执行顺序,下面章节会进行介绍。
  • 垃圾自动回收:JS不需要主动回收内存。JS引擎内部会周期性地检查内容,定时回收无用的内存。

基本类型与引用类型

前言

本小节将介绍JS的基本类型和引用类型。当然我们不会讲哪些无聊的基本语法,而是深入内部,介绍一些有趣的东西。

解析

  • ECMAScript中有5种基本数据类型:Undefined,Null,Boolean,Number和String。以及一种引用数据类型Object。
  • 其中Undefined和Null最为特殊,因为他们是只有一个值的数据类型(undefined和null)。而且他们几乎是同义的,他们之间有什么区别呢?(虽然并没什么卵用,但面试却最喜欢出这个题)。在这里做一下介绍。示例代码

    • 其实Underfined表示“缺失值”。表示有一个变量存在,即进行了定义,但该变量没有被赋值。
    • 而Null表示一个空对象指针,根本不存在这个变量。Null更多地是起到语义作用。强调不存在这个变量。而且在实际编程过程中,除非主动为变量赋值为null,否则很少出现变量为null的情况。另外null的作用是提前标示该变量已无用,让GC回收机制能早点回收该资源。
    • 还有一个常见的面试题:请写出typeof null的值。如示例代码所示typeof null的值为object。有没有小伙伴会好奇为什么其他4中基本数据类型的类型值都是其自身。而null的类型却为object
    • 其实这与JS的设计有关。JS类型值是存在32 BIT空间里面的,在这32位中有1-3位表示其类型值,其它位表示真实数值。其中表示object的标记位正好是低三位都是0。000: object. The data is a reference to an object.

而JS里的Null是机器码NULL空指针(空指针以全0标示)故其标示为也是0,最终体现的类型还是object。曾经有提案 typeof null === 'null'。但提案被拒绝了。

  • 基本数据类型和引用数据类型的区别:基本数据类型是按值访问的。即该变量就存在了实际值。而引用数据类型保存的是则是对实际值的引用(即指向实际值的指针)。而我们知道这个有什么用呢?当然有用,这涉及到对象复制(浅复制与深复制)的问题。

    • 我们来看一个例子示例代码。可以看到,到直接使用b=a进行赋值时。这时b获取到是a变量的指针值,即此时b和a指向的是同一个地址值。所以当修改b对象中的值时,a对象中的值也会发生改变。这种只复制指针地址值的行为称为浅复制。相应的,如果能返回独立对象的值,我们称为深复制。这也是为什么array的复制需要用到concat函数而不是直接用“=”进行复制。
    • 另外,插播一个知识点。在进行函数参数传递时。是通过按值传递的。即传递的是变量本身的值,而不是变量的引用。同样我们看一下示例代码。即在函数在进行传递参数时,会新建一个形参变量elem,并为其赋予实参变量的值。也许有同学会认为,在修改objA的时候明明会影响函数外部值,为什么还能叫做按值传递呢?其实这恰恰是按值专递的结果。因为这里传递的是objA这个指针对象的值,即对象的地址。那么elem指向的就行该对象。即objA和elem指向的是同一个值(浅复制)。所以外部会发生变化
    • 引用类型和按值传递的概念很重要,后面讲函数特性及this指针的时候会用到。
  • 另外,还有一个有趣的知识点。var a = "aaaa";这里定义的a明明只是一个基本数据类型。而不是object,为什么会有a.substr这样的函数呢?示例代码。其实JS引擎在读取基本数据类型时,会在后台创建一个对应的类对象(称为基本包装类型)。从而使我们更加方便地对该变量进行操作。但这个基本操作类型的生存时间非常短,在相应的函数调用完成后就会自动销毁,变回基本数据类型。所以对其添加变量的操作是无效的。

JS的垃圾回收机制

  • 首先科普一下GC是什么意思。GC是垃圾回收的英文缩写GC(Gabage collection)。(额,本人之前一直以为什么是高大上的东西....)
  • JavaScript有自动垃圾回收机制,也就是说执行环境会负责管理代码执行过程中使用的内存,在开发过程中就无需考虑内存回收问题。
  • JavaScript垃圾回收的机制很简单:找出不再使用的变量,然后释放掉其占用的内存,但是这个过程不是实时的,因为其开销比较大,所以垃圾回收器会按照固定的时间间隔周期性的执行。
  • JS的内存回收机制一般有两种实现方式,分别是"标记清除""和"引用计数""方式。
  • 其中"标记清除"是最常见的回收方式,当某变量进入到执行上下文(作用域)时。JS引擎会将该变量标记为“进入环境”状态,当该环境所在的执行上下文(作用域)执行完毕时。里面的变量将被标记为“离开环境”状态。然后当JS引擎周期性地检测内存时。会将标记为“离开环境”的变量所占内存清空。以起到回收内存的目的。
  • 另外一种垃圾回收实现方式是"引用计数"。即JS引擎会跟踪每一个变量的被引用次数。当被其他变量引用时,计数就加一。但引用结束后,就将计数减一。当引用次数为0时,进行内存回收。但这种方法有可能会导致内存的泄露。示例代码
  • 在日常开发中,我们可以通过将变量的值设置为null的方法,将该变量的引用计数清为0。主动回收内存资源

作用域链介绍及其实现原理

前言的前言

作用域是JS中相当重要的一部分内容。是理解JS其他高级内容(如:闭包,this指针,自执行函数)的基础。如果能把JS作用域的原理及其应用理解透,那相当于已经拿下半个JS了。

前言

首先,我们先来看这么一段代码。示例代码。这段代码很简单,相信大家都能猜到预期的结果,外部函数不能访问内部函数定义的变量,所以最后一个输出elem2 is not defined。但是,有同学好奇过为什么外部函数就不能访问内部函数的变量吗?其底层的实现原理是什么?

作用域及作用域链介绍

在介绍上面的问题之前,我们先来介绍作用域这个概念。

  • 作用域的定义

    • 定义什么的我们就直接略过了,我们用人话来说:是指函数中定义过的内容的集合,即定义了的全部内容加在一起就是作用域(除了程序员主动定义的,还有程序内部帮我们定义的变量,例如函数中arguments变量)。
  • 作用域有这么几个特点

    • 每定义一个函数,都会产生一个对应的作用域
    • 作用域的层级关系与函数定义时所处的层级关系相同
    • 各层作用域间通过单向链式结构组织起来,形成作用域链,链接方向为由内向外。
    • 变量的访问是从当前所在作用域起,沿着不断向外访问,直到访问到为止,所以作用域间的访问范围是单向的,是内部作用域可以访问外部作用域。
  • 结论:所以在上面的例子中,外部函数不能访问内部函数的变量
  • 彩蛋

    • 相信大家在看书的时候,经常会看到“作用域链”,“执行环境”,“执行上下文”这样的字眼。其实这三个词汇的意思是一样的。都是指在执行这段代码(通常以函数的形式存在)的时候,所能访问的资源集合。
  • 很简单,对吧。但这个概念后面会反复用到。

闭包

前言

讲闭包之前,我们先来看一段示例代码。大家觉得这段代码的运行结果是什么呢?

解析

  • 如控制台所示,log出来的结果是“elem a”。funA函数已经执行完了。elemA这个变量应该会被回收释放啊?那么为什么还会输出a呢?我们使用JS的其中一种回收机制“引用计数”发来分析一下。
  • 我们把变量elemA的内存记为memoryA。memoryA首先会被elemA引用,引用计数为1。
  • 在funB函数中使用了elemA,memoryA引用计数加一,为2
  • funA执行完毕。回收funA中的elemA。memoryA引用计数减一。还剩1,不为零。所以JS回收机制不会回收memoryA的内存空间。所以在执行c()实际是执行funB的时候。还能访问到memoryA的内存。所以输出为"elem a"。
  • 像这种函数本身已执行完,但由于其内部变量仍被使用。而得不到释放的现象。我们就称作为“闭包”。那么怎么释放该内存呢?继续减少其引用计数即可。就如代码中所示。执行funB函数。memoryA的引用计数再减一。为0。回收机制即可回收该内存。
  • 我们可以通过作用域链的角度,使用“标记清除”法再分析一遍。在定义elemA的时候,elemA标记为“进入环境”(即进入其本身的作用域)。在funA执行完成后,原本其作用域需要回收的,但是由于funB使用到elemA变量。所以funA的作用域没能回收,所以elemA仍在“作用域链中”,没能被标记为“离开环境”。所以没能被释放。
  • 接下来,我们通过一些小练习强化一下对闭包的理解。示例代码
  • 为什么输出的两遍都是2?我们来分析一下。
  • setTimeout函数里面的i是在那个作用域里面?是在setTimeout函数外部的test函数里面。所以这里形成了闭包。即使test执行完毕,i变量也不会被释放。
  • setTimeout函数和test函数那个函数先被执行?很明显setTimeout中的函数是后于test函数执行的。所以当setTimeout函数执行的时候。i以及被自增为2了。所以两边的输出为2。
  • 再来一个更难的。我们结合作用域和闭包来看一道题示例代码
  • 这不是形成了闭包吗?为什么输出的是2而不是3呢?
  • 我们回过头看一下作用域链的定义。函数的作用域层级是在定义函数的时候就被固定下来的,与函数定义时的层级关系相同。所以funA和funB的作用域是处于同一级的。不存在闭包关系。所以funA中的输出的A的值是外部定义的A的值。所以是2而不是3。

this指针

this指针的介绍

  • 什么是this指针。

    • this指针是指向某个对象空间的指针,一般情况下是指向(和强类型语言一样)定义当前作用域所在的对象示例代码。如示例代码所示,logName函数中的this指针指向了objA对象(logName的作用域是在objA对象中定义的)。所以输出了aaa。
    • 接着看上面的示例代码,我们在后面定义了一个没有logName函数的对象objB,但没有定义logName函数。那需要输出objB中的name要这么办呢?我们能不能向objA对象借用一下logName函数?让objA里面的this指针指向objB对象呢?
  • JS中this指针的特点

    • 不同于强语言的是,JS中的this指针是可以通过修改,动态变化的。我们可以通过修改this指针来实现上面的需求。如示例代码
    • 比较两个例子,可以发现,第二个代码多加了bind(objB)。是这里改变了this指针的指向吗?是的。其实在JS中。有三个函数可以改变this指针的指向。分别是bind,call,和apply函数。
  • bind,call,apply的介绍和比较

    • 先来看看这三个函数的使用示例示例代码
    • 首先,三个里面,最突出的函数就是bind。为什么唯独bind函数不是直接使用。还需要在外面添加一个setTimeout呢?如果不加setTimeout会怎么样呢?示例代码。在不加setTimeout的时候没有输出任何东西。而加了setTimeout才会输出。其实那是因为使用bind的时候,是仅仅修改this指针,并不会执行函数。在setTimeout中,计时后,才由setTimeout去调用执行这个函数。
    • 接着,call和apply直接执行了函数。他们的区别是什么呢?他们唯一的区别是传参方式的不同,call函数以枚举(即直接列出来)的方式传参。而apply是以数组的方式传参的。我们试一下调转过来传参示例代码
    • 彩蛋,除了使用bind来修改this指针以外。我们还可以使用它来返回一个函数,往后再去执行。示例代码。其实这个实例中并没什么卵用,只是这个实例说明两个问题。

      • 当bind函数的第一个参数传null时,表示不改变函数中this的指向。
      • 当函数前面的参数已经被赋值时,再使用bind时,是从剩余没有赋值的函数参数开始赋值的。

this指针的应用

  • 将this的指针指向正确的值。这个内容后面再讲。
  • 利用this指针借用某对象的函数,前面的几个例子就是利用了this指针借用函数。在介绍更多例子之前,我们先插入一个伪数组的概念

    • 伪数组是指哪些只有length和中括索引功能,没有数组相应功能函数的数据结构。例如示例代码。如这个例子中pList1就没有slice的函数。
    • 在前端中最经常接触到的伪数组有函数中的隐藏变量 arguments 和用 getElementsByTagName获得到的元素集合。
    • 这时我们就可以利用到this指针去借用数组中的函数,实现我们想要的目的。示例代码
    • 另外怎么把伪数组转化为真数组呢?其实只要在上一个例子上再修改一下就可以了。示例代码
  • 在此基础上,我们还可以利用this指针实现一部分类功能。示例代码

this指针与作用域的关系(主要是window)

其实如果没有该死的window对象的话,原本this指针和作用域是没有太大关系的

  • 先看代码,示例代码。!!!?居然输出的是HanMeiMei而不是LiLei?为什么会输出HanMeiMei?这是不是意味着this指针发生变化了?我们log一下this指针的值.
  • 示例代码。果然,this指针真的是指向了window??WTF?我们明明没有修改过this指针的值啊?为什么this的指向改变了?
  • 在这里就要补充一下的this指针的定义了。上面讲到(一般情况下是指向定义当前作用域时所在的对象,即在那个对象内定义就是指向谁。)。但实际上,this是指向通过点操作符(如objA.funA())调用本函数的那个对象。即谁调用我,我就指向谁。示例代码如在这里,objB并没有定义logName函数。只是定义了一个变量并赋值为函数的引用。这时使用objB.logName实际上调用的是objA对象里面的函数。而log出来的对象就是objB。
  • 再回到setTimeout函数。我们前面log过this,发现this是指向window的。这证明在setTimeout中的是window对象去调用logName的。即相当于window.logName();发现问题了吗?HanMeiMei既是作用域中的变量,也是window对象中的变量。
  • 实际上,所有在全局作用域(注意仅仅是全局作用域)中定义的变量都是window对象下的变量。都可以通过window对象进行访问。所以一旦没有通过对象去调用某函数,而是直接运行的话(如不是objA.fun()而是直接fun()),等价于在window下调用(fun()等价于window.fun())。this指针的值都指向window。
  • 再由于所有在全局作用域(注意仅仅是全局作用域)中定义的变量都是window对象下的变量所以logName中的值指指向了window.name。也就是作用域中的name值。作用域就是通过window与this指针挂上了关系。

this指针和闭包的比较

  • 这是一个性能优化的探讨问题。
  • 有时候我们会遇到这样的情况。使用闭包和this指针都可以实现同样的功能。例如示例代码。使用这两种方式均可以成功log出name的值。那这时候我们使用哪个好呢?
  • 由于log函数会输出到控制台,执行速度慢,我们通过修改name的值来模拟内部操作示例代码可以看到。直接使用闭包的性能更佳。性能是使用bind的5倍以上。由于时间原因。原理我就不再这里介绍了。有兴趣的同学可以通过这个链接看看.为什么闭包比this更快
  • 至此,本教程中,最困难的两个点(this指针与JS线程)中的其中一个已经介绍完了。接下来休息一下,穿插几个比较简单的概念。

自执行函数

什么是自执行函数

  • 这个术语看起来很高大上。其实说到底就是一个定义完了马上就执行的函数。其实这不是JS的新特性。而是利用JS特性做出来的效果。即JS特性的巧妙应用。它的表现形式是这样的。示例代码.其中第一个()是用于隔离第二个括号,使其function(){}函数定义完,避免语法错误。而第二个()是为了执行刚刚定义好的函数。

自执行函数的作用

  • 第一个,也是百度答案最多的一个避免污染全局作用域

    • 到底是怎么污染全局作用域呢?示例代码。这里假设调用了两个JS的情况,原本LaoWang要向HanMeiMei表白的,但是由于第二个文件中,name的值被修改成了LiLei,导致LaoWang表错白。恩,小明是爽了。隔壁老王就糟了。
    • 那怎么用自执行函数来解决呢?很简单,用自执行函数把每个人自己写的JS代码包裹起来就可以了;示例代码.
    • 原理:利用到了作用域的概念。由于每自执行函数本身形成了一个作用域。而这两个作用域是处于同一级的。互不影响,从而避免了污染全局作用域。这个技巧在多人协作的项目里面很有用。
  • 第二个,也是用得比较少的一个构建私有属性

    • 使用强类型语言的同学应该都知道私有的概念。而在JS中,没有类的概念(在ES5之前是没有的,但ES6新增了类的概念),从而也没有了私有属性。但我们可以利用自执行函数构建一个。示例代码。这样就避免了使用直接调用name变量。实现了私有属性的功能。
    • 原理:前面对闭包及作用域理解了的同学,相信你们已经可以猜测背后的原理了。这里使用到了闭包(使得name不被回收),作用域(使得返回的对象中的函数能访问name变量),已经自执行函数(没有留下函数的引用,使得外界不能访问)的特点。

声明提前

前言

人们常说JS是解释性语言,语句都是执行到哪里才进行解析的。但实际上真的是这样吗?

什么是声明提前

  • JS声明提前是指,在作用域中(即在函数中啦),变量的声明总是优先于其他语句被执行的。(即总是先执行声明语句,再执行其他语句)。示例代码
  • 但如果把var name;改成var name = "LiLei"又会怎么样呢?示例代码。说好的提前呢?其实JS引擎在解析这段代码的时候会把var name = "LiLei"分成两条语句来执行。一个是变量定义,一个是变量赋值。所以实际上登记于这样示例代码
  • 声明对于函数同样适用示例代码。而且注意点也是一样的。示例代码

什么时候容易出现错误

讲这个点之前,先插入两个待会要用到的概念

  • 没有块级作用域

    • 在JS中是没有块级作用域的概念的。for,while等控制语句不构成块级作用域。示例代码
  • 没有通过var定义的变量均是定义全局变量

    • 如标题所说。 没有通过var定义的变量都是全局变量,都在全局作用域中找得到。示例代码。当这两个概念在加载声明提前上就很恶心了。
  • 例子示例代码。这个例子比较特殊,在chrome中可能回报错。建议大家用其他浏览器打开。我这里使用safari打开。执行结果是HanMeiMei。给大家一点时间整理一下思路。其实它等价于
  • 当然这些例子都比较极端。而且看起来很傻。其实只是想告诉大家。当大家在看法中遇到很奇怪的bug的时候。可以考虑是不是由于声明提前引起的了。

JS线程问题

前言

这是前面提到过在本教程中,最复杂的两个知识点(this指针与JS线程)中的JS线程问题。虽然我们日常开发中可能不会主动提到这个概念,但其实我们经常会用到。譬如ajax异步请求,事件处理,计时器延迟执行等都涉及到JS线程的概念。学习好本概念虽然不会像this指针那样解决很多问题,但有助于加深我们对JS底层的理解。有助于理清逻辑关系。

什么是线程

  • 首先,我们先来了解一下什么是线程。百度一下。恩,第一句进程什么的我们就略过,先不管啦。重点是后面那句。线程是程序执行流的最小单元。用人话翻译就是说,一次只能执行一段代码的执行器。同一时间内只能完成一项任务。后一项任务必须等待前面的任务执行完成才能被执行。
  • 单线程语言,顾名思义,是指一次只能执行一项任务的语言。
  • 多线程语言,是指一次能执行多项任务的语言。典型的多线程语言有C,C++等强类型语言。
  • JS是单线语言。介绍到这里的时候,不知道小伙伴们会不会有这样的疑惑。不对啊,JS能执行异步操作(例如AJAX操作)的啊,异步操作不就是允许后面的代码先执行吗?这和单线程的概念相冲突啊。JS怎么会是单线程的呢?恩,我们带着这个问题往下看。

页面加载流程解析

为什么js文件要放在body标签的底部,而不建议放在头部。因为当JS文件加载过程太慢的时候,会阻碍后面标签的执行。恩,这个知识点相信大家都知道。但为什么JS文件的加载会影响HTML标签的解析呢?它底层的原理是什么呢?

先问大家一个小问题,你知道在HTML页面里面,怎么样做到不引人script标签去执行JS代码?

  • 先抛出两个概念,浏览器的核心是两部分:渲染引擎和JavaScript解释器(又称JavaScript引擎)。

    • 其中渲染引擎负责解析HTML标签,将标签转化为HTML视图。渲染引擎处理页面,通常分成四个阶段

      • 解析代码:HTML代码解析为DOM,CSS代码解析为CSSOM(CSS Object Model)
      • 对象合成:将DOM和CSSOM合成一棵渲染树(render tree)
      • 布局:计算出渲染树的布局(layout)
      • 绘制:将渲染树绘制到屏幕
    • JavaScript引擎的主要作用是,读取网页中的JavaScript代码,对其处理后运行。
    • 渲染引擎和JS引擎分别使用不同的线程
  • 其实整个HTML页面的加载过程是这样的。

    • 浏览器一边下载HTML网页,一边开始解析。
    • 解析过程中,发现script标签。
    • 暂停解析,网页渲染的控制权转交给JavaScript引擎。
    • 如果script标签引用了外部脚本,就下载外部脚本,否则就直接执行脚本。
    • 执行完毕,控制权交还渲染引擎,恢复往下解析HTML网页
  • 也就是说,加载外部脚本时,浏览器会暂停页面渲染,等待脚本下载并执行完成后,再继续渲染。既然渲染引擎和JS引擎是用不同的线程去执行的。那应该可以并行执行啊。例如渲染线程继续解析标签,JS引擎去执行JS语句。为什么不这样做呢?
  • 原因是JavaScript可以修改DOM(比如使用document.write方法),所以必须把控制权让给它,否则会导致复杂的线程竞赛的问题。(例如同时对同一个DIV进行修改,那以那个为准?)
  • 彩蛋,恩,回答前面问的那个问题。其实在html页面中,在标签里面设置的每一个事件。都是一个JS执行段。都会以事件回调的方式执行里面的代码。示例代码

JS事件循环(Event Loop)

注意,这里讲的是JS的事件循环机制,不是JS的事件传递机制,通过本节的学习,你将明白JS异步是怎么实现的.

  • 首先,需要明确一个概念:JS是单线程的,但并不意味着浏览器也是单线程的。实际上浏览器是多线程的(例如前面讲到的渲染引擎就是其中一个线程),JS通过和浏览器后台线程的配合,实现了JS的事件机制。
  • 我们以示例代码为例。JS的执行流程是这样的。

    • 首先JS在底层维护了一个是消息队列的队列。
    • JS执行到addEventListener时,将回调函数的地址交给监听页面操作的其中一个浏览器后台线程(假设叫做监听线程)。
    • 当监听的事件被触发的时候,监听线程将之前JS交代的回调函数地址放入到消息队列当中。
    • 重点来了,JS引擎是怎么读取消息列表的呢?JS引擎是先把JS同步操作全部执行完毕(即JS文件中的代码全部执行完毕,我们先用“主逻辑操作”),才会去按顺序调用消息队列的回调函数地址。而且这个从消息队列中调用回调函数的过程是循环执行的。
  • 从上面的分析中,我们可以得到以下结论:

    • 全部回调操作都在主逻辑操作完成后才被执行的.
    • 由于消息队列是以队列的形式保存起来的。而队列本身是一个先进先出的数据结构,所以会优先调用队列排在前面的回调函数,(由于JS的执行是单线程的,一次只能执行一个代码段)所以只有前一个回调函数被执行完了,第二个回调函数才能被执行。所以回调函数的执行顺序是在被加入到消息队列的那一刻决定的。
    • 另外,除了前面提到的监听线程,浏览器还有处理定时器的进程(如setTimeout)、处理用户输入的进程(input)、处理网络通信的进程(AJAX)等等

setTimeout问题

  • 同样的setTimeout函数也可以使用上面的过程进行分析。只不过把上面的监听线程换成处理定时器的浏览器线程(假设叫做定时器线程)。即

    • JS执行到setTimeout时,将回调函数的地址交给定时器线程。
    • 定时间线程进行计时,当计时结束后。定时器线程将所携带的回调函数地址放入消息队列中。
    • JS引擎把主逻辑执行完成后,调用消息队列中的回调函数。
  • 前面两步都没有问题,但到最后一步就可能会出现问题了。

    • 由于定时器线程和JS引擎是使用不同线程,同时进行的。而JS引擎去读取消息队列之前需要先将主操作执行完。那么一旦主操作的执行时间大于定时器的计时时间。那么回调函数的时间等待时间将大于程序所设置的计时时间。示例代码
    • 另外,除了主操作会延迟计时器的回调函数执行。由于JS引擎在读取消息队列的时候是按顺序读取的。这意味着排在前面的回调函数也可能会推迟排在后面的计时器回调函数的执行示例代码
    • 插播一个知识HTML5标准规定了setTimeout()的第二个参数的最小值(最短间隔),不得低于4毫秒,如果低于这个值,就会自动增加。在此之前,老版本的浏览器都将最短间隔设为10毫秒。示例代码。当然了,实际运行时间还需要结合电脑本身的运行情况。再加上console.log本身需要消耗一定的时间。所以每次的运行时间可能都会有变化。在这是也只是告诉大家会有这个规定。
  • setTimeout的妙用。

    • 除了平常的计时作用,我们还可以利用消息队列必须在主逻辑之后被执行的特点,结合setTimeout把某段代码放在最后执行(即主逻辑之后)。示例代码setTimeout(fn,0)的含义是,指定某个任务在主线程最早可得的空闲时间执行。

HTML5新内容:worker介绍

web Worker这是H5新出的一个内容,使用这个API,我们可以简单地创建后台运行的线程。但JS本质上还是单线程的。

  • 我们先来看看他的基本使用方法示例代码。注意,worker的调用是异步的。所以after post优先执行。
  • 需要注意的是worker只能进行数据操作,不能调用DOM,和BOM。它里面连window对象都没有。这个我就不进行深入讲解的。这些都可以在网上找得到。我想把哪些不怎么容易找得到的内容给大家讲解一下。
  • worker实现的不是真正意义上的线程,它完全受主线程所控制的。示例代码注意,这里执行将执行死循环,小伙伴们根据自己的情况考虑要不要运行】。可以看到,一开始worker可以被正常执行,但当JS主线程被的死循环执行的时候,worker马上停止了工作,貌似JS和worker同一时间只能执行一个。而真正的多线程运行结果应该是script和worker随机交替出现的。(其实这里的分析是不够全面的)
  • 前面只给到JS主线程貌似阻塞了worker的线程。那么反过来。worker会不会阻塞JS主线程的操作呢?我们来看示例代码。【注意,这里也是死循环的】(其实大家应该已经猜到了运行的结果,如果worker会阻塞JS主逻辑的操作,那要worker还有什么用?)可以看到,worker线程并不会阻塞JS主线程的执行。这意味着worker很有用。我们可以将一些很耗时的操作放到worker中执行。保证主页面的流畅运行。譬如对大型的图片或附件进行压缩,加密操作等。参考网站,这是一个分解质因数的网站,之前高等数学的老师告诉我们。分解质因数的难度复杂度是O(n1/4)。这意味着整个分解过程是很耗时间的。而这个页面使用了worker在后台进行分解。所以在等待的过程中,页面是不卡的。

worker的本质分析

  • 我们来分析一下worker的本质是什么,其实worker本质上和浏览器的计时器线程等是没有区别的。是浏览器新建立的一个线程,用于执行特定的任务。
  • 而worker实际上和JS主线程是可以同步执行的,是可以组成多线段的。之前貌似JS阻塞了worker的原因,只是因为console对象不能同时被多个线程所拥有。而JS主线程的优先级比worker高,所以从worker中抢走了console对象的控制器。造成了这个现象发生。
  • 而第二个例子则证明了worker和JS主线程的并行性。因为在做修改数字的操作时。console的输出没有被打断。
  • 所以证明worker是可以实现在数据计算上的多线程。但由于它本身不完全具备JS的全部功能,如不能操作DOM,BOM。所以普遍被认为worker实现的是ECMAScript的多线程,JS从本质上是单线程的
阅读 3.7k

原创小文章
专注中小团队开发。前端技术,效率工具分享与探讨。
0 条评论
宣传栏