《JavaScript忍者秘籍》深度阅读记录(一)

1,483 阅读1小时+

从正式学习接触前端到目前从事前端工作已经一年多了,个人非常重视基础的学习,由于我不是计算机类专业科班出身,总感觉自己的基础不够牢固。于是,买了几本相对基础的书来做一个比较深度的阅读并且记录下来,就先从这本《JavaScript忍者秘籍》开始吧~

因为字数限制,又重新开了一篇文章: JavaScript忍者秘籍》深度阅读记录(二)

一、热身


1 理解JavaScript语言

与其他主流语言相比,JavaScript函数式语言的血统更多一些。JavaScript中的一些概念从根本上不同于其他语言。这些根本性的差异包括以下内容:

  • 函数是一等公民(一级对象),在JavaScript中,函数与其他对象共存,并且能够像其他对象一样地使用。函数可以通过字面量创建,可以赋值给变量,可以作为函数参数进行传递,甚至可以通过作为返回值从函数中返回。
  • 函数闭包
  • 作用域
  • 基于原型的面向对象

对象、原型、函数和闭包的紧密结合组成了JavaScript。理解这些概念的密切联系可以大大提高编程能力,也可以为我们开发各种类型的应用提供坚固的基础,无论是在网页上、桌面应用上、移动应用上还是服务器端。除了这些基本概念,JavaScript的一些其他功能也能帮助我们书写优雅高效的代码。有以下特性,之后的文章内容中会重点聚焦:

  • 生成器,一种基于一次请求生成多次值的函数,在不同请求之间也能挂起执行。
  • Promise,让我们更好地控制异步代码。
  • 代理,让我们控制对特定对象的访问。
  • Map,用于创建字典集合;Set,处理仅包含不重复项目的集合。
  • 正则表达式,简化用代码书写起来很复杂的逻辑。
  • 模块,把代码划分为较小的可以自包含的片段,使项目更易于管理。

2 理解浏览器

2.3 构造函数调用

    使用构造函数调用,需要在函数调用之前使用关键字new。使用关键字new调用函数会触发以下几个动作:

  1. 创建一个新的空对象。
  2. 该对象作为this参数传递给构造函数,从而成为构造函数的函数上下文。
  3. 新构造的对象作为new运算符的返回值

构造函数的目的是创建一个新对象,并进行初始化设置,然后将其作为构造函数的返回值。任何有悖于这两点情况都不适合作为构造函数。

特殊情况:构造函数返回值
    我们都知道,构造函数的目的是初始化新创建的对象,并且新构造的对象会作为构造函数的调用结果(通过new运算符)返回。但当构造函数自身有返回值时会是什么结果呢?我们通过下面几个例子来探讨这种情况。

    function Test() {
        this.say = function () {
            console.log('hello~')
        }
        
        return 1
    }
    
    const test = new Test()
    test.say() // 'htllo~'

    如果执行这段代码,会发现一切正常。事实上这个Test函数虽然返回简单的数字1,但对代码的行为没有显著的影响。如果将Test函数作为一个普通函数滴啊用,的确会返回1,但如果通过new关键字将其作为构造函数调用,会构造并返回一个新的test对象。目前为止,一切正常。
    但如果尝试做一些改变,一个构造函数返回另一个对象。

    const tempTest = {
        isFlag: false
    }
    
    fucntion Test() {
        this.isFlag = true
        
        return tempTest
    }
    
    const test = new Test()
    
    cosole.log(test.isFlag) // false
    

    测试结果表明tempTest对象最终作为构造函数调用的返回值,而且在构造函数中对函数上下文的操作都是无效的,最终返回的将是tempTest。 现在针对这些测试结论做一些总结。

  • 如果构造函数返回的一个对象,则该对象将作为整个表达式的值返回,而传入构造函数的this将被丢弃。
  • 但是,如果构造函数返回的是非对象类型,则忽略返回值,返回新创建的对象。

正是由于这些特性,构造函数的写法一般不用于其他函数。

2.4 使用apply和call 方法调用

不同类型函数调用之间的主要区别在于:最终作为函数上下文(可以通过this参数隐式引用到)传递给执行函数的对象不同。对于方法而言,即为方法所在的对象。对于顶级函数而言是window或者undefined(取决于是否处于严格模式下);对于构造函数而言是一个新创建的对象实例。

但是,如果想改变函数上下文怎么办?如果想要显示指定它怎么办?Js为我们提供了一种调用函数的方式,从而可以显示地指定任何对象作为函数的上下文。我们可以使用每个函数上都存在的这两种方法来完成:apply和call。

是的,我们所指的正式函数的方法。作为第一类对象(函数式由内置的Function构造函数所创建),函数可以像其他对象类型一下拥有属性,也包括方法。

apply和call之间唯一的不同之处在于如何传递参数。在使用apply的情况下我们使用参数数组,在使用call的情况下,我们则在函数上下文之后依次列出调用参数。

3.1 使用箭头函数绕过函数上下文

简体函数相比于传统的函数声明和表达式,可以更优雅的创建函数,箭头函数还有一个更优秀的特性:箭头函数没有单独的this值,箭头函数的this与声明所在的上下文相同, this的值是在创建时确定的。

调用箭头函数时,不会隐式传入this参数,而是从定义时的函数继承上下文。

    <button id="test">Click Me!</button>
    <script>
      const button = {
        clicked: false,
        click: () => {
            this.clicked = true
        }
      }
      
      const elem = document.getElementById("test")
      elem.addEvenListener("click", button.click)
      
      // 点击之后
      console.log(button.clicked) // false
      // (我们会发现 clicked并没有发生变化,因为此时箭头函数中的this指向window)
    </script>
  

在全局代码中定义对象字面量,在字面量中定义箭头函数,那么箭头函数内的this指向全局window对象

3.2 使用bind方法

除了call和apply可以控制调用函数的上下文及参数。除此之外,函数还可访问bind方法创建新函数。无论使用哪种方法调用,bind方法创建的新函数与原始函数的函数体相同,新函数被绑定到指定的对象上。

所有函数都可以访问bind方法,可以创建并返回一个新函数,并绑定在传入的对象上。不管如何调用该函数,this均被设置为对象本身。被绑定的函数与原始函数行为一致,函数体一致。

小结


  • 当调用函数时,除了传入在函数定义中显示声明的参数之外,同时还传入两个隐式参数: arguments与this。

    1. arguments参数是传入函数的所有参数的集合。具有length属性,表示传入参数的个数,通过arguments参数还可以获取那些与函数形参不匹配的参数。在非严格模式下。arguments对象是函数参数的别名,修改arguments对象会修改函数实参,可以通过严格模式避免修改函数实参。
    2. this表示函数上下文,即与函数调用相关联的对象。函数的定义方式和调用方式决定了this的取值。
  • 函数的调用方式有4种。

    1. 作为函数调用 test()
    2. 作为方法调用obj.test()
    3. 作为构造函数调用: new Ninja()
    4. 通过apply与call 方法调用: test.apply(obj), test.call(obj)
  • 箭头函数没有单独的this值,this在箭头函数创建时确定。

  • 所有函数均可使用Bind方法创建新函数,并绑定到Bind方法传入的参数上。被绑定的函数与原始函数具有一致的行为。

五、精通函数:闭包和作用域


5.1 理解闭包

  • 闭包允许函数访问并操作其他函数内部的变量。只要变量或函数存在于声明函数时的作用域内,闭包即可使函数能够访问这些变量或函数。
  • 当在外部函数中声明内部函数时,不仅定义了函数的声明,并且还创建了一个闭包。该闭包不仅包含了函数的声明,还包含了在函数声明时该作用域中所有变量。当最终执行内部函数时,尽管声明时的作用域已经消失了,但是通过闭包,仍然能够访问到原始作用域。
  • 每一个通过闭包访问变量的函数都具有一个作用域链,作用域链包含闭包的全部信息。
  • 虽然闭包是非常有用的,但不能过度使用。使用闭包时,所有的信息都会存储在内存中,直到Js引擎确保这些信息不再使用或页面卸载时,才会清理这些信息。

5.2 使用闭包

5.2.1 封装私有变量

许多编程语言使用私有变量,这些私有变量是对外部隐藏的对象属性。这是非常有用的一种特性。因为当通过其他代码访问这些变量时,我们不希望对象的实现细节对用户造成过度的负荷。遗憾的是,原生Js不支持私有变量。但是通过使用闭包,我们可以实现很接近的、可接受的私有变量,实例代码如下所示。

function Ninja() {
    var feints = 0
    this.getFeints = function() {
        return feints
    }
    
    this.feint = function() {
        feints++
    }
}

var ninja1 = new Ninja()
ninja1.feint()
  • 通过变量ninja1,对象实例是可见的。
  • 因为feint方法在闭包内部,因此可以访问变量feints。
  • 在闭包外部,我们无法访问变量feints。

通过使用闭包,可以通过方法对ninja的状态进行维护,而不允许用户直接访问—这是因为闭包内部的变量可以通过闭包内的方法访问,而构造器外部的代码则不能访问闭包内部的变量。

5.3 通过执行上下文来跟踪代码

在Js中,代码执行的基础单元是函数。我们时刻使用函数,使用函数进行计算,使用函数更新UI,使用函数达到复用代码的目的,使用函数让我们的代码更易于理解。为了达到这个目标,第一个函数可以调用第二个函数,第二个函数可以调用第三个函数,以此类推。当发生函数调用时,程序会回到函数调用的位置。那么Js引擎是如何跟踪函数的执行并回到函数的位置的呢?

之前有提到过,Js代码有两种类型:一种是全局代码,在所有函数外部定义,一种是函数代码,位于函数内部。Js引擎执行代码时,每一条语句都处于特定的执行上下文中。既然有两种类型的代码就有两种执行上下文:全局执行上下文和函数执行上下文。两者最主要的区别是:全局执行上下文只有一个,当Js程序开始执行时就创建了全局上下文,而函数执行上下文是在每次调用函数时,就会创建一个新的。

注意: 之前说过可通过关键字this访问函数上下文。函数执行上下文,虽然也称为上下文,但完全是不一样的概念。执行上下文是内部的Js概念,Js引擎使用执行上下文来跟踪函数的执行。

Js是基于单线程的执行模型:在某个特定的时刻只能执行特定的代码。一旦发生函数调用,当前的执行上下文必须停止执行,并创建新的函数执行上下文来执行函数。当函数执行完成后将函数执行上下文销毁,并重新回到发生调用时的执行上下文中。所以需要跟踪执行上下文—正在执行的上下文以及正在等待的上下文。最简单的跟踪方法是使用执行上下栈(或称为调用栈)。

栈是一种基本的数据结构,只能在栈的顶端对数据项进行插入和读取。
这种特性可类化于自助餐厅里的一叠托盘,你只能从托盘堆顶端拿到一个托盘,
服务员也只能将新的托盘放在这叠托盘的顶端。

下面我们来看一段代码:

function skulk(ninja) {
    report(ninja + ' skulking')
}

function report(message) {
    console.log(message)
}

skulk('Kuma')
skulk('Yoshi')

这段代码比较简单,首先定义了shulk函数,shulk函数调用report函数。然后在全局中调用skulk函数两次:skulk('Kuma')和skulk('Yoshi')。通过这段基础代码,我们可以探索执行上下文是如何创建的。如下图所示:

  1. 每个Js程序只创建一个全局执行上下文,并从全局执行上下文开始执行(在单页面应用中每个页面只有一个全局执行上下文)。当执行全局代码时,全局执行上下文处于活跃状态。
  2. 首先在全局代码中定义两个函数:shulk和report,然后调用skulk('Kuma')。由于在同一个特定时刻只能执行特定代码,所以Js引擎停止执行全局代码,开始执行带有Kuma参数的skulk函数。创建新的函数执行上下文,并置入执行上下文栈的顶部。
  3. skulk函数进而调用report函数。又一次因为在同一个特定时刻只能执行特定代码,所以暂停skulk执行上下文,创建新的Kuma作为参数的report函数的执行上下文,并置入执行上下文栈的顶部。
  4. report通过内置函数console.log打印出消息后,report函数执行完成,代码又回到skulk函数。report执行上下文从执行上下文栈顶部弹出,skulk函数执行上下文重新激活,skulk函数继续执行。
  5. skulk函数执行完成后也发生类似的事情:skulk函数执行上下文从栈顶端弹出,重新激活一直在等待的全局执行上下文并恢复执行。Js的全局代码恢复执行。

5.4 使用词法环境跟踪变量的作用域

词法环境是Js引擎内部用来跟踪标识符与特定变量之间的映射关系。词法环境是Js作用域内部实现机制,人们通常成为作用域。

通常来说,词法环境与特定的js结构关联,既可以是一个函数每一段代码片段,也可以是try-catch语句。这些代码结构可以具有独立的标识符映射表。

5.4.1 代码嵌套

词法环境主要基于代码嵌套,通过代码嵌套可以实现代码结构包含另一代码结构。在作用域范围内范围内,每次执行代码时,代码结构都获得与之关联的词法环境。例如,每次调用一个函数,都将创建新的函数词法环境。

此外需要着重强调的是,内部代码结构可以访问外部代码结构中定义的变量。Js引擎是如何跟踪这些变量的呢?如何判断可访问性呢?这就是词法环境的作用。

5.4.2 代码嵌套与词法环境

  • 除了跟踪局部变量、函数声明、函数的参数和词法环境外,还有必要跟踪外部(父级)词法环境。因为我们需要访问外部代码结构中的变量,如果在当前环境中无法找到某一标识符,就会对外部环境进行查找。一旦找到匹配的变量,或是在全局环境中仍然无法找到标识符而返回错误,就会停止查找。
  • 无论何时创建函数,都会创建一个与之关联的词法环境,并存储在名为[[Environment]]的内部属性上(也就是无法直接访问或者操作)。两个中括号用于标志内部属性。
  • 无论何时调用函数,都会创建一个新的执行环境,被推入执行上下栈。此外,还会创建一个与之相关联的词法环境。现在看来最重要的部分:外部环境与新建的词法环境,Js引擎将调用函数的内部[[Environment]]属性与创建函数时的环境进行关联。

5.5 理解JavaScript的变量类型

在Js中,我们可以通过3个关键字定义变量:var,let和const。这3个关键字有两点不同:可变性,与词法环境的关系(作用域)。

注意: var关键字是一开始就是Js的一部分,而let和const是在ES6时加起来的。

5.1 变量可变性

通过const声明的“变量”与普通变量类似,但在声明时需要写初始值,一旦声明完成之后,其值就无法更改。听起来它不可变,对吧? const变量常用于两种目的:

  • 不需要重新赋值的特殊变量。
  • 指向一个固定的值,例如球队人数的最大值,可通过const变量MAX_RONI_COUNT来表示,而不是仅仅通过数字234来表示。这使得代码更加易于理解和维护。
  • 在程序执行过程中不允许对const变量进行重新赋值,这可以避免代码发生不必要的变更,同时也为JavaScript引擎性能优化提供便利。

下图我们对const关键字声明的变量的可变性进行了测试:

  1. 首先定义了一个名为test的const变量,并赋值为1,接着执行test++, test++ 其实相当于 test = test + 1,由于test变量是静态变量,不允许重新赋值,所以Js引擎抛出异常。
  2. 接下来我们定义了一个testObj变量,并将其初始化一个空对象,然后给这个空对象增加新属性name,发现没有抛出异常。
  3. 然后定义了一个空数组testArr,并且向空数组push新的数值和操作数组长度,也都没有抛出异常
  4. 最后对这个数组进行重新赋值,赋值为一个空数组,Js引擎抛出异常。

总结: 这就是const变量的全部特性,const变量只能在声明时被初始化一次,之后再也不能将全新的值赋值给const变量。我们可以修改const变量已经存在的值,只是不能重写const变量。比如说:我们不能将全新的值赋值给const变量,但是我们可以修改const变量已有的对象,给已有对象添加属性。

5.5.2 定义变量的关键字与词法环境

  • var 是在距离最近的函数或全局词法环境中定义变量,与var不同的是,let和const更加直接。let和const直接在最近的词法环境中定义变量(可以是在块级作用域内,循环内,函数内或全局环境内)。我们可以使用let和const定义块级别、函数局别、全局级别的变量。 注意: 在使用const定义全局变量,全局静态变量通常使用大写表示。

5.5.3 在词法环境中注册标识符

Js作为一门编程语言,其设计的基本原则是易用性。这也是不需要指定函数返回值类型、函数参数类型、变量类型等的主要原因。我们已经了解到js是逐行执行的。查看如下代码:

firstRonin = 'Kiyokawa'
secondRoin = 'Kondo'

将Kiyokawa赋值给标识符firstRonin,将Kondo赋值给标识符secondRonin。看起来没什么特殊的地方。下面来看另一段代码:

const firstRonin = 'Kiyo'

check(firstRoin)

function check(roin) {
    console.log(roin === 'Kiyokawa')
}

以上代码中,我们将Kiyokawa赋值给firstRonin,然后调用check函数,传入参数firstRoin。先等一下,如果Js是逐行执行的,我们此时可以调用check函数吗,程序还没有执行到函数check的声明,所以Js引擎不应该认识check函数。

但是,程序运行得很顺利。Js对于在哪儿定义函数并不挑剔。在调用之前或者之后都可以,这是为什么呢?下面我们来看看注册标识符的过程,Js引擎是如何知道check函数的存在的呢? 这说明Js引擎耍了小把戏,Js代码的执行事实上是分两个阶段进行的。一旦创建了新的词法环境,就会执行第一阶段。在第一阶段,没有执行代码,但是JS引擎会访问并注册当前的词法环境中所声明的变量和函数。第一阶段完成后开始执行第二阶段,具体如何执行取决于变量的类型以及环境类型。 具体的处理过程如下:

  1. 如果是创建一个函数环境,那么创建形参及函数参数的默认值,如果是非函数环境将跳过步骤。
  2. 如果是创建全局或函数环境,就扫描当前代码进行函数声明(不会扫描其他函数的函数体), 函数表达式和箭头函数除外。对于所找到的函数声明,将创建函数,并绑定到当前环境与函数名相同的标识符上。若该标识符已经存在,那么该标识符的值将被重写。如果是块级作用域,将跳过此步骤。
  3. 扫描当前代码进行变量声明。在函数或者全局环境中,查找所有当前函数以及其他函数之外通过var声明的变量,并查找所有通过let或const定义的变量。在块级环境中,仅查找当前块中通过let或const定义的变量。对于在当前执行环境所查找到的变量,若该标识符不存在,进行注册并将其初始化为undefined.若该标识符已经存在,将保留其值。
  • 若函数是作为函数声明进行定义的,则可以在函数声明之前访问函数
  • 若函数是通过函数表达式或箭头函数进行定义的,则不可以在函数定义之前访问函数。

函数重载

有一个难题是处理重载函数标识符的问题。让我们先来看一段代码。

console.log(typeof fun === 'function');  // true
var fun = 3;
console.log(typeof fun === 'number');   // true
function fun(){}  // 函数声明
console.log(typeof fun === 'number')   // true   fun仍然指向数字
  • 在以上代码中,声明的变量与函数均使用相同的名字fun。如果你执行这段代码会发现,三个console.log打印的都为true。

  • Js的这种行为是由标识符注册的结果直接导致的。我们在之前提到过,在处理过程中的第2步中,通过函数声明进行定义的函数在执行之前对函数进行创建,并赋值给对应的标识符。在第3步中,处理变量的声明,那些在当前环境中未声明的变量,将被赋值为undefined。

  • 在上列代码中,由于函数声明在处理变量声明之前,标识符为fun的函数已经声明,所以在处理变量声明的时候并不会重新赋值为undefined,所以第一个console.log 打印为true。此时标识符fun是一个函数。

  • 之后执行赋值语句 var fun = 3, 将数字3赋值给标识符fun。执行完这个赋值语句之后,fun就不再指向函数了,而是指向数字3。

  • 在程序的实际执行过程中,跳过了函数声明的部分,所以函数的声明不会影响标识符fun的值。

变量提升

相信大家都已经遇到过这个词: 变量提升:变量的声明提升至函数顶部,函数的声明提升至全局代码顶部。 但是,正如我们在上述实例中看到的,并没有那么简单,变量和函数的声明并没有实际发生移动。只是在代码执行之前,先在词法环境中进行注册。我们通过对词法环境对整个处理过程进行更深入地理解,了解真正的原理。

5.7 小结

  • 通过闭包可以访问创建闭包时所处环境中的全部变量。闭包为函数创建时所处的作用域中的函数和变量,创建“安全气泡”。通过这种的方式,即使创建函数时所处的作用域已经消失,但是函数仍然能够获得执行时所需的全部内容

  • 我们可以使用闭包的这些高级功能:

    1. 通过构造函数内的变量以及构造方法来模拟对象的私有属性。
    2. 处理回调函数,简化代码。
  • Js 引擎通过执行上下栈(调用栈)跟踪函数的执行。每次调用函数时,都会创建新的函数上下文,并推入调用栈顶端。当函数执行完成之后,对应的执行上下文将从调用栈中推出。

  • Js 引擎通过词法环境跟踪标识符(俗称作用域)

  • 在Js中,我们可以定义全局级别,函数级别甚至块级别的变量。

  • 可以使用关键字var、let与const定义变量:

    1. 关键字var定义距离最近的函数级别变量或者全局变量。
    2. 关键字let与const定义距离最近级别的变量,包括块级变量。此外const定义的变量只能赋值一次。
  • 闭包是js作用域规则的副作用,当函数创建时所在的作用域消失后,仍然能够调用函数,访问创建时所在的作用域。

六、 未来的函数:生成器和promise

在前面的章节中,集中讨论了函数,尤其是如何定义函数及如何有效使用函数。我们已经介绍了ES6的一些特性,比如说箭头函数和块作用域。这一章将探索两个全新的ES6的前沿特性:成生器(generator)和 promise(promise)。

6.1 使用生成器函数

生成器函数几乎是一个完全崭新的函数类型,它和标准的普通函数完全不同。生成器函数能生成一组值的序列,但每个值的生成是基于每次请求,并不同于标准函数那样立即生成。我们必须显示地向生成器请求一个新的值,随后生成器要么响应一个新生成的值,要么告诉我们它之后都不会再生成新值。更让人好奇的是,每当生成器函数生成了一个值,它都不会像普通函数一样停止执行。相反,生成器几乎从不挂起。随后,当对另一个值的请求到来后,生成器就会从上次离开的位置恢复执行。

来看看下面的例子:

    function * WeaponGenerator() {  // 通过在关键字function后面添加星号 * 定义生成器函数
        yield 'test1';
        yield 'test2';             // 使用新关键字yield生成独立的值
        yield 'test3';
    }
    
    for( let weap of WeaponGenerator() ) {
        console.log(weap)
    }

例子首先定义了一个生成器,它能够生成一系列数据。创建一个生成器函数非常简单,仅仅需要在关键字function后面加上一个*星号。这样一来生成器函数体内就能够使用新关键字yield,从而生成独立的值。

我们会发现在WeaponGennerator函数的函数体中并没有return语句。如果想标准函数一样,那么返回的应该是undefined。但其实真相是生成器函数和标准函数非常不同。对初学者来说,调用生成器并不会执行生成器函数,相反,它会创建一个叫做迭代器的对象。我们来继续探索这个对象。

6.2.1 通过迭代器对象控制生成器

调用生成器函数不一定会执行生成器函数体。通过创建迭代器对象,可以与生成器通信。例如,可以通过迭代器对象请求满足条件的值。稍微修改一下之前的示例,看看迭代器对象是如何工作的:

// 定义一个生成器
function* WeaponGnnerator() {
    yield 'katana';
    yield 'wakizashi';
}

// 调用生成器得到一个迭代器,从而我们能够控制生成器的执行
const weaponsIterator = WeaponGnnerator();

// 调用迭代器next方法向生成器请求一个新值
const result1 = weaponsIterator.next(); // 结果为一个对象,其中包含着一个返回值,及一个指示器告诉我们生成器是否还会生成值

// 再次调用next方法从生成器中获取新值
const result2 = weaponsIterator.next();

const result3 = weaponsIterator.next(); // 当没有可执行的代码,生成器就会返回undefined值,表示它的状态已经完成。

result1 result2 result3 打印结果如下图所示

从上例代码和打印结果可见,调用生成器后,就会创建一个迭代器(iterator)。

  1. 迭代器用于控制生成器的执行。迭代器对象暴露最基本的接口方法是next方法。这个方法可以用来向生成器请求一个值,从而控制生成器。
  2. next函数调用后,生成器就开始执行代码,当代码执行到yield关键字时,就会生成一个中间结果(生成值序列中的一项),然后返回一个新对象。其中封装了返回结果和一个指示完成的指示器。
  3. 每当生成一个当前值后,生成器就会非阻塞的挂起执行,耐心等待下一次请求值的到达。这是普通函数完全不具有的强大特性。

对迭代器进行迭代

通过调用生成器得到的迭代器,暴露出一个next方法能让我们向迭代器请求一个新值。next方法返回一个携带着生成值的对象。而该对象中包含的另一个属性done也向我们指示了生成器是否还能追加生成值。

现在我们利用这一原理,试着用普通的while循环来迭代生成器生成的值序列,如下列代码所示:

    function* TestGenerator() {
        yield 'HELLO1'
        yield 'HELLO2'
    }
    
    const testIterator = TestGenerator()
    
    // 创建一个变量,用这个变量来保存生成器产生的值
    let item
    
    while(!((item = testIterator.next()).done)) {
        console.log(item)
    }

在每次迭代中我们通过迭代器testIterator的next方法从生成器中取一个值,然后把值存放在item变量中。和所有的next返回的对象一样,item变量引用的对象包含一个属性value为生成器的值,一个属性done指示生成器是否已经完成了值的生成。如果生成器没有生成完毕,我们就会进入下一次迭代,反之停止循环。

这就是第一个生成器示例中for-of循环的原理。for-of循环不过是对迭代器进行迭代的语法糖。

6.2.4 探索生成器内部构成

我们已经知道了调用一个生成器不会实际的执行它。相反,它创建了一个新的迭代器,通过该迭代器我们才能从生成器中请求值。在生成器生成了一个值后,生成器会挂起执行并等待下一个请求的到来。在某种方面来说,生成器的工作更像是一个小程序,一个在状态中运动的状态机。让我们更进一步补充一些知识,看看生成器是如何跟随执行上下文的。

function* TestGenerator() {
	yield 'hello1'
    yield 'hello2'
}
const testIterator = TestGenerator()

创建生成器函数从挂起状态开始

const result = testIterator.next()

激活生成器,从挂起状态转为执行状态,执行到yield'hello1'语句中止,进而转为挂起让渡状态,返回新对象{value: 'hello1', done: false }

const result2 = const result = testIterator.next()

重新激活生成器,从挂起让渡状态转为执行状态,直到执行yield 'hello2'语句中止进而转为挂起让渡状态,返回新对象{value: 'hello1', done: false}

const result3 = const result = testIterator.next()

重新激活生成器,从挂起让渡状态转为执行状态,没有代码可以执行,转为完成状态,返回新对象{value: undefined, done: true}

通过执行上下文跟踪生成器函数

在前面的例子中,我们介绍了执行环境上下文。它是一个用于跟踪函数的执行的js内部机制。尽管有些特别,生成器依然是一种函数,所以让我们仔细看看它们和执行环境上下文之间的关系吧。

6.3 使用promise

使用js编写代码会大量的依赖异步计算,计算那些我们现在不需要但将来某时候可能需要的值。所以ES6引入了一个新的概念,用于更简单地处理异步任务:promise。

promise对象是对我们现在尚未得到但将来会得到值的占位符,它是对我们最终能够得知异步计算结果的一种保证。如果我们兑现了我们的承诺,那结果会得到一个值。如果发生了问题,结果则是一个错误,一个为什么不能交付的借口。使用promise的一个最佳例子是从服务器获取数据:我们要承诺最终会拿到数据,但其实总有可能发生错误。

// 通过内置Promise构造函数可以创建一个promise对象,需要向构造函数中传入两个函数参数resolve,reject
const testPromise = new Promise((resolve, reject)=>{
	resolve('success')
    // reject('fail')
})

// 在一个promise对象上使用then方法后可以传入两个回调函数,promise成功兑现后会调用第一个回调函数,出现错误则调用第二个
testPromise.then(res => console.log(res), err => console.log(err))

6.3.1 简单回调函数带来的问题

  1. 错误难以处理,try catch 无法处理异步错误
  2. 执行连续步骤棘手,存在一堆嵌套的回调函数

6.3.2 深入研究promise

promise对象用于作为异步任务结果的占位符。它代表了一个我们暂时还没获得但在未来有希望获得的值,基于这点原因,在一个promise对象从等待(pending)状态开始,此时我们对承诺的值一无所知。因此一个等待状态对的promise对象也称为未实现的promise。在程序执行的过程中,如果promise的resolve函数被调用,promise就会进入完成状态,在该状态下我们能够成功获取到承诺的值。

另一方面,如果promise的reject函数被调用,或者如果一个未处理的异常在promise调用过程中发生了,promise就会进入到拒绝状态,尽管在该状态下我们无法获取承诺的值,但我们至少知道了原因。一旦某个promise进入到完成态或者拒绝态,它的状态都不能在切换了。

让我们仔细看看在使用promise的时候到底发生了什么:

console.log('code start')

const delayPromise = new Promise((resolve, reject) => {
  console.log('delayPromise execute')

  setTimeout(() => {
    console.log('resolve delayPromise')

    resolve('hello1')
  }, 500)
})

console.log('created delayPromise')

delayPromise.then(res => {
  console.log(res)
})

const immediatePromise = new Promise((resolve, reject) => {
  console.log('immediatePromise execute')

  resolve('hello2')
})

immediatePromise.then(res => {
  console.log(res)
})

console.log('code end')

依次输出:

code start

delayPromise execute

created delayPromise

immediatePromise execute

code end

hello2

resolve delayPromise

hello1

在delayPromise 被创建后,依然无法得知最终会得到什么值,或者无法保证promise会成功进入完成状态。

所以在构造函数调用后,delayPromise 就进入了等待状态。然后调用delayPromise的then方法,用于建立在一个预计在promise被成功实现后执行的回调函数。我们继续创建另一个promise—immediatePromise,它会在对象构造阶段立刻调用promise的resolve函数,立即完成承诺。不同于delayPromise对象在构造后进入等待状态,immediatePromise对象在解决状态下完成了对象的构造,所以该promise对象已经获得了值hello2。

然后,通过屌用immediatePromise的then方法,我们为其注册了一个回调函数,用于在Promise成功被解决后调用。然而此时promise已经被解决了,这个成功回调函数会被立即调用吗?答案是不会。

Promise是设计用来处理异步任务的,所以Js引擎经常会凭借异步处理使promise的行为得以预见。Js通过在本次时间循环中的所有代码都执行完毕后,调用then回调函数来处理promise。因此我们首先会看到打印 'code end' ,然后是immediatePromise 结果返回,最后经过了500ms,delayPromise的结果也返回。

6.3.3 拒绝promise

拒绝一个promise有两种方式: 显式拒绝,即在一个promise的执行函数中调用传入的reject方法,隐式拒绝,正处理一个promise的过程中抛出了一个异常。让我们一起来探索这个过程。

显式拒绝

// 可以通过调用传入的reject函数显式拒绝该promise
const promise = new Promise((resolve, reject) => {
	reject('reject a promise')
})

// 如果promise被拒绝,则第二个回调函数err将会被调用
promise.then(()=>{}, err => console.log(err))

通过调用传入的reject函数可以显式拒绝promise。如果promise被拒绝,则第二个回调函数err总会被调用。

链式调用catch方法

promise.then(()=>{})
	   .catch(err => console.log(err))

隐式拒绝

如果在执行过程中遇到了一个异常,除了显式拒绝(通过调用reject),promise还可以被隐式拒绝。

// 在处理promise时出现未处理的异常 则会被隐式地拒绝

const promise = new Promise((resolve, reject) => {
	count++
})

在promise函数体内,我们试着对变量count进行自增,该变量并未在程序中定义。不出所料,程序产生了异常,由于在执行函数中没有try-catch语句,所以当前的promise被隐式的拒绝了。这样的拒绝也会像显式拒绝一样,catch回调函数会被调用。如果把错误回调函数作为then函数的第二个参数,结果也是相同的。

以这种方式处理promise中发生的错误可以说是相当简便。无论promise是被如何拒绝的,显式调用reject方法还是隐式调用,只要是发生了异常,所有的错误和拒绝原因都会在拒绝回调函数中被定为。这个特性大大减轻了我们的工作。

6.3.4 等待多个promise

除了处理相互依赖的异步任务序列以外,对于等待多个独立的异步任务,promise也能够显著地减少代码量。

使用Promise.all等待多个promise

console.time()
function getData1() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('hello1')
    }, 100)
  })
}

function getData2() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('hello2')
    }, 500)
  })
}

function getData3() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('hello3')
    }, 800)
  })
}

// Promise.all方法接收一个promise数组,并创建一个新的promise对象,当所有的promises均成功时,该promise为成功状态,反之若其中任一promise失败,则该promise为失败状态
Promise.all([getData1(), getData2(), getData3()]).then(results => {
// 结果是将是所有的promise成功值组成的数组,数组中的每一项都对应promise数组中的对应项
  console.log(results)

  console.timeEnd()
})

打印结果为: [ 'hello1', 'hello2', 'hello3' ] default: 815.734ms

Promise.all 方法等待列表中所有的promise。但如果我们只关心第一个成功(或失败的promise),可以认识一下Promise.race方法。

6.3.5 promise 竞赛

Promise.race([getData1(), getData2(), getData3()]).then(result => {
  console.log(result)

  console.timeEnd()
})

打印结果: hello1 default: 111.448ms

使用Promise.race方法并传入一个promise数组会返回一个全新的promise对象,一旦数组中某一个promise被处理或被拒绝,这个返回的promise就同样会被处理或被拒绝。

async函数

通过在关键字function之前使用关键字async,可以表明当前的函数依赖一个异步返回的值。在每个调用异步任务的位置上,都要放置一个await关键字,用来告诉JS引擎,请在不阻塞应用执行的情况下在这个位置上等待执行结果。

6.5 小结

  • 生成器是一种不会在同时输出所有值序列的函数,而是基于每次的请求生成值。

  • 不同于标准函数,生成器可以挂起和回复它们的执行状态。当生成器生成了一个值后,它会在不阻塞主线程的基础上挂起执行,随后静静地等待下次请求。

  • 生成器通过在function后面在一个星号(*)来定义。在生成器函数体内,我们可以使用新的关键字yield来生成一个值并挂起生成器的执行。如果我们想让渡到另一个生成器中,可以使用yield操作符。

  • 在我们控制生成器执行的过程中,通过使用迭代器的next方法调用一个生成器,它能够创建一个迭代器对象。除此之外,我们还能通过next函数向生成器中传值。

  • promise是计算结果值的一个占位符,它是对我们最终会得到异步计算结果的一个保证。promise既可以成功也可以失败,一旦设定好了,就不能够有更多改变。

  • promise 显著简化了我们处理异步代码的过程。通过使用then方法来生成promise链,我们就能轻易地处理异步时序依赖。并行执行多个异步任务也同样简单: 仅使用Promise.all方法即可。

七、 面向对象与原型

7.1 理解原型

在软件开发的过程中,为了避免重复造轮子,我们希望可以尽可能地复用代码,继承是代码复用的一种方式,继承有助于合理地组织程序代码,将一个对象的属性扩展到另一个对象上。在Js中,可通过原型来实现继承。

原型的概念很简单。每个对象都含有原型的引用,当查找属性时,若对象本身不具有该属性,则会查找原型上是否有该属性。

const obj1 = {
  isFlag1: true
}
const obj2 = {
  isFlag2: true
}
const obj3 = {
  isFlag3: true
}

// 操作符 in 可以测试对象是否具有某一个特定的属性
console.log('isFlag1' in obj1) // true
console.log('isFlag2' in obj1) // false 访问不到对象obj2的属性

// Object.setPrototypeOf()方法,将对象obj2设置为obj1对象的原型

Object.setPrototypeOf(obj1, obj2)

console.log('isFlag2' in obj1) // 现在可以访问属性 isFlag2

当访问对象上不存在的属性时,将查询对象的原型。在这里,我们可以通过对象obj1访问Obj2的属性,因为obj2是obj1的原型。 每个对象都可以有一个原型,每个对象的原型也可以拥有一个原型,以此类推,形成一个原型链。查找特定属性会被委托在整个原型链上,只有当没有更多的原型可以进行查找时,才会停止查找。

注意: 没有正式的方法用于直接访问一个对象的原型对象——原型链中的“连接”被定义在一个内部属性中,在 JavaScript 语言标准中用 [[prototype]] 表示。 然而,大多数现代浏览器还是提供了一个名为 proto (前后各有2个下划线)的属性,其包含了对象的原型。 obj.proto obj.constructor.prototype Object.getPrototypeOf(obj) 上面三种方法之中,前两种都不是很可靠。 最新的ES6标准规定,__proto__属性只有浏览器才需要部署,其他环境可以不部署。而obj.constructor.prototype在手动改变原型对象时,可能会失效。

7.2 对象构造器与原型

Js通过new操作符,通过构造函数初始化新对象,但是没有真正的类定义。通过操作符new,应用于构造函数之前,触发创建一个新的对象分配。

// 定义一个空的函数
function Test() {}

// 每个函数都具有可置的原型对象,我们可以对其自由更改
Test.prototype.say = function () {
  console.log('hello')
}

// 作为构造函数调用Test,验证生成新的实例具有原型上的方法
const test = new Test()

test.say() // hello

当函数创建完成之后,立即就获得了一个原型对象,我们可以对该原型对象进行扩展。在本例中我们在原型对象上添加了say方法。 然后我们通过new操作符调用该函数,此次是作为构造器进行调用,创建了一个新的对象,并且将其设置为函数的上下文(可通过this关键字访问)。操作符new返回的结果是这个新对象的引用。然后我们发现生成的test(新创建对象的引用)具有say方法。

  • 每一个函数都具有一个原型对象。
  • 每一个函数的原型都具有一个constructor属性,该属性指向函数本身

从上图可以看出,我们创建的每一个函数都具有一个新的原型对象。最初的原型对象只有一个属性就是constructor属性。该属性指向函数本身。

当我们将函数作为构造函数调用时,新构造出来的对象的原型被设置为构造函数的原型的引用。在上列例子中我们在Test.prototype上增加了say方法,对象test创建完成时,对象test的原型被设置为Test的原型。因此,通过test调用方法say,将查找该方法委托到Test的原型对象上。注意,所有通过Test创建出来的对象都可以访问到say方法。现在我们看到了一种代码复用的方式。

注意: say方法是Test的原型属性,而不是test实例的属性

7.2.1 实例属性

当把函数作为构造函数,通过操作符new进行调用时,它的上下文被定义为新的对象实例, 通过构造函数的参数进行初始化。在下面的例子中,我们来检查使用这种方法创建的实例的属性。

function Test() {
  this.flag = false

  // 创建实例方法
  this.say = function () {
    console.log(!this.flag)
  }
}

// 在函数原型上添加一个与实例方法同名的方法
Test.prototype.say = function () {
  console.log(this.flag)
}

// 测试会优先使用哪一个
const test = new Test()
test.say() // true  使用的是实例上的方法

测试发现调用的是实例上的方法。当通过test访问say属性时,在实例中就可以查找到的属性,将不会查找原型。

function Test() {

}

Test.prototype.flag = false

const test = new Test()
const test1 = new Test()

Test.prototype.flag = true

console.log(test.flag, test1.flag) // true true

每一个实例分别获得了在构造器内创建的属性版本,但是它们都可以访问同一个原型属性

7.2.3 通过构造函数实现对象类型

每个函数的原型对象都有一个constructor属性,该属性指向函数本身。通过使用contructor属性,我们可以访问创建该对象时所用的函数。这个特性可以用于类型校验。

检查实例的类型与它的constructor

function Test() {}

const test = new Test()

console.log(typeof test) // 'object'

console.log(test instanceof Test) // true

通过typeof 仅仅能够知道test是一个对象而已,通过Instanceof 检测test的类型,其结果提供更多信息—test是由Test构造而来的。

此外,我们可以使用constructor属性,所有的实例对象都可以访问constructor属性,contructor属性是创建实例对象的函数的引用,我们可以使用constructor属性验证实例的原始类型。

7.3 实现继承

继承是一种新对象复用现有对象的属性的形式。这有助于避免重复代码和重复数据。在Js中,继承原理与其他流行的面向对象语言略有不同。

使用原型实现继承

function Person(){}

Person.prototype.dance = function () {}

function Test(){}

// 通过将Test的原型赋值为Person的实例,Test继承Person
Test.prototype = new Person()

const test = new Test()

test.dance()

console.log(test instanceof Person) // true

当我们在定义一个Person函数时,同时也创建了Person的原型,该原型通过其constructor属性引用函数本身。正常来说,我们可以使用附加属性拓展Person原型,在本例中我们在Person的原型上扩展了dance方法,因此每个Person的实例对象也都具有dance方法。我们也定义了一个Test函数。该函数的原型也具有一个constructor属性指向函数本身。接下来为了实现继承,将Test的原型赋值为Person 的实例。现在,每当创建一个新的Test实例对象时,新创建的Test实例对象的原型将设置为Test的原型属性所指向的所指向的对象。

当我们尝试通过Test的实例对象test访问dance方法,Js运行时将会首先查找test对象本身。由于test本身不具有dance方法,接下来搜索test对象的原型(proto)即为Person的实例对象。Person的实例对象也不具有dance方法,所以再接着查找Person实例对象的原型,最终找到了dance方法。这就是Js中实现继承的原理。

7.3.1 重写constructor属性的问题

我们会发现使用上面的例子使用原型实现继承,通过摄制Person实例对象作为Test构造器的原型时,我们已经丢失了Test和与Test初始原型之间的关联。

console.log(test.constructor === Test) // false

我们可以来修复这种问题,但是在修复之前,我们不得不先看看Js提供的配置属性的功能 配置对象的属性 在Js中,对象是通过属性描述进行描述的,我们可以配置一下关键字。

  • configurable 如果设置为true,则可以修改或删除属性。如果设为false,则不允许修改。
  • enumerable 如果设为true,则可在for-in 循环对象属性时出现
  • value 默认为undefined
  • writable 如果设为true, 则可通过赋值语句修改属性值
  • get 定义getter函数,当访问属性时发生调用,不能与valuew,writable 同时使用
  • set 定义setter函数,当对属性赋值时发生调用,也不能与valuew,writable 同时使用

通过简单的赋值语句创建对象属性,例如:

test.name = 'wave'

该赋值语句创建的属性可被修改或删除、可遍历、可写,test的name属性被设置为wave, get和set函数均为undefined。

如果想调整属性的配置信息,我们可以使用内置的Object.defineProperty方法,传入三个参数:属性所在的对象,属性名和属性描述对象。

配置属性

let test = {}
test.name = 'wave'
test.age = 18

// 使用内置的Obejct.defineProperty方法设置对象属性的配置信息

Object.defineProperty(test, 'isFlag', {
	configurable: false,
    enumerable: false,
    value: true,
    writable: true
})

for(let prop in test) {
	console.log(prop)   // name // age 
}

console.log(test.isFlag) // true

虽然我们可以正常访问isFlag 属性,但是在for-in循环中无法遍历到。因为enumerable为false,在for-in循环中无法遍历该属性。为了理解为什么要这样做,让我们回到最初的问题。

最后解决constructor属性被覆盖的问题

为了实现Test继承Person,当把Test的原型设置为Person的实例对象后,我们丢失了原来在constructor中的Test原型。我们不希望丢失construcotor属性,constructor属性可用于确定用于创建对象实例的构造函数。

通过使用我们刚刚获得的知识可以解决这个问题。使用Object.defineProperty方法在Test.prototype对象上增加新的constructor属性。

解决constructor属性问题

function Person(){}
Person.prototype.say = function () {
  console.log('hello')
}

function Test(){}
Test.prototype = new Person()

Object.defineProperty(Test.prototype, 'constructor', {
  enumerable: false,
  value: Test
})

const test = new Test()
console.log(test instanceof Test) // true

for(let prop in Test.prototype) {
  console.log(prop)               // 只打印了say
}

这样就重新建立了 test 与 构造函数Test 之间的联系。所以可以确定test实例是通过Test构造器创建的。此外,如果遍历Test.prototype对象,可确保不会访问到constructor属性。

7.3.2 instanceof 操作符

在大部分编程语言中,检测对象是否是类的最直接方法是使用操作符instanceof。例如,在Java中,使用instanceof检测左边的类与右边的类是否是同一个子类。虽然在Js中操作符instanceof与Java中类似,但是仍然有些不同,在Js中,操作符instanceof使用在原型链中、例如,查看下列表达式

test instanceof Test

操作符instanceof用于检测Test函数的原型是否存在于test实例的原型链中。让我们回到person与test,查看一个更加具体的例子。

探讨instanceof操作符

function Person(){}
function Test(){}

Test.prototype = new Person

const test = new Test()

console.log(test instanceof Person) // true
console.log(test instanceof Test) // true

test实例的原型链是由new Person() 对象 和 Person的原型组成的,所以(test instanceof Person)为true是可以确定的。当执行(test instanceof Test)表达式时,Js引擎检查Test函数的原型—new Person()对象,是否存在于test实例的原型链上。new Person()对象是test实例的原型,因此,表达式执行结果也为true。

注意: instanceof 它是会检查操作符右边的函数的原型是否存在于操作符左边的对象的原型链上。

instanceof操作符的警告

构造函数原型的改变同样会改变instanceof的返回结果

function Test(){}
const test = new Test()

console.log(test instanceof Test) // false

Test.prototype = {}

console.log(test instanceof Test) // true

以上的代码中,我们对Test的原型进行了重新赋值, 再对之前生成了test实例执行(test instanceof Test) 我们会发现结果发生了变化。由此证明,instance操作符并不是检测对象是否由某一个函数构造器创建的,真正的含义是:检测右边的函数原型是否存在于操作符左边对象的原型链上。

7.4在ES6使用Js的class

7.4.1 使用关键字class

ES6 引入新的关键字class,它提供了一种更为优雅的创建对象和实现继承的方式,底层仍然是基于原型的实现。

在ES6中创建类

// 使用ES6指定的关键字class创建类
class Test {
  // 定义一个构造函数,当使用关键字new调用类时,会调用这个构造函数
  constructor(name) {
    this.name = name
  }
  // 定义一个所有Test实例都可访问的方法
  say() {
    console.log('hello')
  }
}

const test = new Test('wave')

console.log(test.name) // wave
test.say() // hello

上列代码中显示了,我们可以通过使用ES6的关键字class创建Test类,在类中创建构造函数,使用类创建实例对象时,调用该构造函数。在构造函数体内,可以通过this访问新创建的实例,添加属性很简单,例如添加name属性。在类中,还可以定义所有实例对象均可访问的方法。

class是语法糖

虽然ES6引入关键字class,但是底层仍然是基于原型的实现。class只是语法糖,使得在js模仿类的代码更为简洁。

上列代码可以转化为如下ES5代码:

function Test(name) {
	this.name = name
}

Test.prototype.say = function () {
	console.log('hello')
}

可以看出Es6的类没有任何特殊之处,虽然看起来优雅,但使用的是相同的概念。

静态方法

之前的例子中展示了如何定义所有实例对象可访问的对象方法(原型方法)。除了对象方法之外,经典面向对象语言Java中一般是用类级别的静态方法。

在ES6中的静态方法

class Test {
  constructor(name, level) {
    this.name = name
    this.level = level
  }

  say() {
    console.log('hello')
  }

  // 使用关键字 static 创建静态方法
  static compara(pram1, pram2) {
    return pram1.level - pram2.level
  }
}

const test1 = new Test('wave1', 6)
const test2 = new Test('wave2', 1)

// 实例不可访问compara方法
console.log(test1.compara) // undefined

// Test类可以访问compara方法
console.log(Test.compara(test1, test2)) // 5

让我们来看看 ES6之前版本中如何实现静态方法的,我们只需要记住通过函数实现类。由于静态方法是类级别的方法,所以可以利用第一类型对象,在构造函数上添加方法

function Test() {}
Test.compara = function() {}

7.4.2 实现继承

老实说,在ES6之前的版本中实现继承是一件痛苦的事情,让我们看看之前的示例

function Person(){}
Person.prototype.say = function () {
  console.log('hello')
}

function Test(){}
Test.prototype = new Person()

Object.defineProperty(Test.prototype, 'constructor', {
  enumerable: false,
  value: Test
})

注意: 对所有的实例均可访问的方法必须直接添加在构造函数原型上,如Person构造函数上的dance方法。为了实现继承,我们必须将实例对象衍生的原型设置成基类。在本例中,我们将一个新的Person实例对象赋值给Test的原型。糟糕的是,这会弄乱constructor属性,所以需要通过Object.defineProperty方法进行手动设置。为了实现一个相对简单和通用的继承特性,我们需要记住这一系列细节。但是在ES6中,整个过程大大简化了。

在ES6中实现继承

class Person {
  constructor(name) {
    this.name = name
  }

  say() {
    console.log('hello')
  }
}

// 使用extends 关键字实现继承
class Test extends Person {
  constructor(name, level) {
  // 使用关键字 super 调用基类构造函数
    super(name)

    this.level = level
  }
}

const test = new Test ('wave', 999)

console.log(test.constructor === Test) // true
console.log(test instanceof Test) // true
console.log(test.name, test.level) // wave 999
test.say()                         // 'hello'

7.5 小结

  • Js对象是属性名与属性值的集合
  • Js使用原型
  • 每个对象上都有原型的引用,搜索指定的属性时,如果对象本身不存在该属性,则可以代理到原型上进行搜索。对象的原型也可以具有原型,以此类推,形成原型链
  • 可以通过Object.setPrototypeOf方法定义对象的原型
  • 原型与构造函数密切相关。每个函数都具有原型属性,该函数创建的对象的原型,就是指向函数的原型
  • 函数原型对象初始只有一个属性就是constructor,该属性指向函数本身。该函数创建的全部对象均访问该属性,constructor属性还可以用于判断是否由指定的函数创建。
  • 在Js中,几乎所有的内容在运行时都会发生变化,包括对象的原型和函数的原型。
  • ES6中引入关键字 class,使得我们可以更方便地实现模拟类。在底层仍然是使用原型实现的。
  • 使用extends 可以更优雅地实现继承

八、控制对象的访问

本章包括以下内容:

  • 使用getter和setter控制访问对象的属性
  • 通过代理控制对象的访问
  • 使用代理解决交叉访问的问题

8.1 使用getter与setter控制属性访问

8.1.1 定义getter和setter

在Js(ES5)中,可以通过两种方式定义getter和setter

  • 通过对象字面量定义,或在ES的class中定义
  • 通过使用内置的Object.defineProperty方法

在对象字面量中定义getter和setter

const test = {
  arr: [1, 2, 3, 4],
  
  // 定义firstArr的getter方法,返回arr列表的第一个值,并打印访问记录
  get firstArr() {
    console.log('getting firstArr')
    return this.arr[0]
  },
  // 定义firstArr 的 setter方法,设置arr列表中的第一个值,并记录
  set firstArr(value) {
    console.log('setting firstArr', value)
    this.arr[0] = value
  }
}

console.log(test.firstArr)

test.firstArr = 5

console.log(test.firstArr)

全部打印结果为下图所示:

  • 定义getter和setter的语法,在属性名之前添加关键字set或者get
  • 如果一个属性具有getter和setter方法,访问该属性时将隐式调用getter方法,为该属性赋值时将隐式调用setter方法

在ES6中的class中使用getter和setter

class Test {
  constructor() {
    this.arr = [1, 2, 3, 4]
  }

  get firstArr() {
    console.log('getting firstArr')
    return this.arr[0]
  }

  set firstArr(value) {
    console.log('setting firstArr', value)
    this.arr[0] = value
  }
}

const test = new Test()
console.log(test.firstArr)
test.firstArr = 5
console.log(test.firstArr)

输出结果同上图。

注意: 针对指定的属性不一定需要同时定义getter和setter,例如我们通常仅仅提供getter,如果在某些情况下需要写入属性值,具体的行为取决于代码是在严格模式还是非严格模式。如果在非严格模式下,对仅有getter的属性赋值不起作用,Js引擎会默默忽略我们的请求。另外一方面,如果在严格模式下,Js引擎将会抛出异常,表明我们给一个仅有getter没有setter的属性赋值。

尽管通过ES6和对象字面量指定getter和setter是很容易的,但你可能已经注意到一些问题。传统上,getter和setter方法用于控制访问私有对象属性,但遗憾的是Js没有私有对象属性。我们可以通过闭包模拟私有对象属性,通过定义变量和指定对象包含这些变量。但是由于对象字面量和类、getter和setter方法不是在同一个作用域中定义的,因此那些希望作为私有对象属性的变量是无法实现的。幸运的是,我们可以通过Object.defineProperty方法实现。

通过Object.defineProperty定义getter和setter

function Test() {
  // 定义私有变量,将通过闭包访问该变量
  let _skillLevel = 0

  Object.defineProperty(this, 'skillLevel', {
    get: () => {
      console.log('getter')
      return _skillLevel
    },

    set: (val) => {
      console.log('setter')
      _skillLevel = val
    }
  })
}

const test = new Test()

console.log(typeof test._skillLevel) // 'undefined'
console.log(test.skillLevel)
test.skillLevel = 1
console.log(test.skillLevel)

  • 不管定义方式如何,getter和setter允许我们定义对象属性与标准对象属性一样,但是当访问属性或对属性赋值时,将会立即调用getter和setter方法。这是一个非常有用的功能,使我们能够执行日志记录,验证属性值,甚至在发生变化时可以通知其他部分代码。(可以通过使用getter与setter校验属性值、定义计算属性)

8.2 使用代理控制访问

代理(proxy)使我们通过代理控制对另一个对象的访问。通过代理可以定义当对象发生交互时可执行的自定义行为:如读取或设置属性值、或调用方法。可以将代理理解为通用化的setter和getter,区别是每个setter与getter仅能控制单个对象属性,而代理可用于对象交互的通用处理,包括调用对象的方法。 过去使用setter与getter处理日志记录、数据校验、计算属性等操作,均可使用代理对它们进行处理。代理更加强大。使用代理,我们可以很容易地在代码中添加分析和新能度量。自动填充对象属性以避免讨厌的null异常。包装宿主对象,例如DOM用于减少跨浏览器的不兼容性。

通过Proxy构造器创建代理

// test 是目标对象
const test = { name: 'wave' }
// 通过Proxy构造器创建代理,传入对象test,以及包含get,set方法的对象,用于处理对象属性的读写操作

// 代理对象
const proxyTest = new Proxy(test, {
  get: (target, key) => {
    console.log('getting', key, target)

    return key in target ? target[key] : 'none'
  },

  set: (target, key, value) => {
    console.log('setting', key)

    target[key] = value
  }
})

// 分别通过目标对象和代理对象访问name属性
console.log(test.name) 
console.log(proxyTest.name)

// 直接访问目标对象上不存在的属性nickname 将返回undefined
console.log(test.nickname)
// 通过代理对象访问时,将会检测到nickname属性不存在,并返回在代理对象中的预设返回结果
console.log(proxyTest.nickname)


// 通过代理对象添加nickname属性后,分别通过目标对象和代理对象均可访问nickname属性
proxyTest.nickname = 'wang'

console.log(proxyTest.nickname)
console.log(test.nickname)

以上代码的打印结果:

使用代理对象的要点:通过Proxy构造器创建代理对象,代理对象访问目标对象时执行指定的操作。 在上列例子中,我们使用get和set,还有许多其他的内置方法用于定义各种对象的行为,详情见 mng.bz/ba55 例如:

  • 调用函数时激活apply,使用new操作符时激活constructor
  • 读取/写入属性时激活get与set
  • 执行for-in语句时激活enumerate
  • 获取和设置属性值时激活getPrototypeOf与setPrototypeOf

8.2.2 使用代理检测性能

除了用于记录属性访问访问日志以外,代理还可以在不需要修改函数代码的情况下,评估函数调用的性能。例如我们想要评估计算一个数值是否是素数的函数的性能。

使用代理评估性能

// 使用代理包装isPrime方法
isPrime = new Proxy(isPrime, {
  // 定义apply方法,当代理对象作为函数被调用时,将会触发该apply方法的执行
  apply: (target, thisArg, args) => {
    // 启动一个计时器
    console.time()

    // 调用目标函数
    console.log(target, thisArg, args)
    const result = target.apply(thisArg, args)

    console.timeEnd()

    return result
  }
})

console.log(isPrime)

isPrime(11111139038030)

使用isPrime函数作为代理的目标对象。同时,添加apply方法,当调用isPrime函数时,就会调用apply方法。我们将新创建的代理对象赋值给isPrime标识符。这样,我们无需修改isPrime函数内部代码,就可以调用apply方法实现isPrime函数的性能评估。

8.2.3 使用代理自动填充属性

除了简化日志,代理还可以用于自动填充属性。

例如,假设需要抽象计算机的文件夹结构模型,一个文件夹对象,既可以有属性,也可以是文件夹。现在假设你需要长路径的文件模型,如: rooFolder.firstDir.secondDir.file = 'test.txt'

为了创建这个长路径文件模型,你可能会按照以下思路设计代码:

const rootFoller = new Folder()
rootFolder.firstDir = new Folder()
rootFolder.firstDir.secondDir = new Folder()
rootFolder.firstDir.secondDir.file = 'test.txt'

这样的话似乎有点必要的繁琐,这时候我们可能就需要自动填充属性登场。

使用代理自动填充属性

  return new Proxy({}, {
    get(target, prop) {
      console.log(prop, target)
      if(!(prop in target)) {
        target[prop] = new Folder()
      }

      return target[prop]
    }
  })
}

const rootFolder = new Folder()

rootFolder.firstDir.secondDir.file = 'test.txt'

正常情况下,上面这段代码肯定会抛出异常,但我们使用了代理,所以每次访问属性时,代理方法都会被激活。如果访问的属性不存在,将会创建新的文件夹并赋值给该属性。

8.3 小结

  • 我们可以使用getter、setter和代理监控对象

  • 通过使用访问器方法(getter和setter),我们可以对对象属性的访问进行控制。可以通过内置的Object.defineProperty 方法定义访问属性,或在对象字面量中使用get和set语法或ES6的class。当读取对象属性时会隐式调用get方法,当写入对象属性时隐式调用set方法。 使用getter方法可以定义计算属性,在每次读取对象属性时计算属性值;同理,setter方法可用于实现数据验证与日志记录。

  • 代理是JSes6引入的,可用于控制对象。

  • 代理可以定制对象交互时行为(例如,当读取属性或调用方法时)。所有的交互行为都必须通过代理,指定的行为发生交互时会调用代理方法。

  • 使用代理可以优雅的实现以下内容: 日志记录、性能测试、数据校验、自动填充对象属性、数组负索引

九、处理集合

9.1 数组

数组是最常见的数据类型之一。使用数组,可以处理数据集合。如果你的编程背景是强类型语言如C语言,你可能会认为数组是连续的内存块存储相同的数据类型,每个内存块大小固定,都有一个关联的索引,可以通过索引轻松访问每个数据项。下面让我们来看看Js中的数组

9.1.1 创建数组

创建数组的两种基本方式:

  • 使用内置的Array构造函数
  • 使用数组字面量[]

注意: 使用数组字面量创建数组优于构造函数。主要原因很简单: []与new Array()。此外,由于JavaScript的高度动态特性,无法阻止修改内置的Array构造函数,也就意味着new Array()创建的不一定是数组。因此,推荐坚持使用数组字面量。

无论通过哪种方式创建的数组,每个数组都具有length属性,表示数组的长度。通过使用索引访问数组元素,第一个元素的索引是0,如果试图访问数组长度范围之外的索引,不会像其他语言那样抛出异常,而是返回undefined。**这个结果表明:**Js数组是对象。假如访问不存在的的对象,会返回undefined。访问不存在的数组索引,也会返回Undefined。

另外一方面,若在数组边界之外写入元素,数组将会扩大以适应新的形势,如下图所示:

如上图所示,实际上我们在数组中创建了一个空元素,索引为5以前的的元素为undefined。同时改变了length属性,现在的长度变为6. 与其他大多数语言不同,Js在length属性上,也表现出一个特殊的功能:可以手动修改length属性的值。将length值改为比原有值大的数,数组会被扩展,新扩展的元素均为undefined,将length改变比原有值小的数,数据将会被裁减。

下面让我们来看看一些最常见的数组操作


这些常见的方法,我们基本都使用和学习过,所以就不详细介绍,只整理出一些需要注意的细节。

9.1.2 在数组两端添加、删除元素

性能考虑: pop和push与shift和unshift pop和push方法只影响数组最后一个元素: pop移除最后一个元素,push在数组末尾增加元素。shift和unshift方法修改第一个元素,之后的每一个元素的索引都需要调整。因此pop和push方法要比shift和unshift要快很多,非特殊情况,不建议使用shift和unshift

返回值: 他们的返回值皆为被添加或被被删除的元素

9.1.3 在数组任意位置添加、删除元素

delete 操作符可以删除元素,但是这种是删除元素的方法只是在数组中 创建了一个空元素, 应该使用splice方法进行删除和替换

区分以下方法: find: 使用find函数在数组中查找元素: 返回第一个回调函数返回true的元素

indexOf: 查找特定元素的索引,传入目标元素作为参数 (lastIndexOf查找最后一次目标元素出现的索引)

findexIndex 返回第一个回调函数返回true的元素。与find类似,唯一的区别是find方法返回元素本身,而findIndex方法返回元素的索引。

sort排序

Js具有sort方法排序, 使用方法如下

array.sort((a, b) => a - b)

我们需要提供回调函数,告诉排序算法相邻的两个数组元素的关系。可能打的结果有如下几种:

  • 如果回调函数的返回值小于0 , 元素a应该出现在元素b之前
  • 如果回调函数的返回值等于0 , 元素a和元素b应该出现在相同位置
  • 如果回调函数的返回值小于0 , 元素a应该出现在元素b之后

使用reduce合计数组元素:

const arr = [1, 2, 3, 4]

const sum = arr.reduce((aggregated, number) => {
  return aggregated + number
}, 0)

console.log(sum) // 10

reduce函数向每个回调函数传入合计值和当前元素,最终返回一个值。第二个参数为传入初始值。

9.1.5 复用内置的数组函数

    const elems = {
      // 用于模拟数组长度,存储集体中元素的数量
      length: 0,
      
      // 实现向集合添加元素的add方法。直接利用数组的原型方法
      add(elem) {
        Array.prototype.push.call(this, elem)
      },
      // 实现通过ID查找元素并且添加到集合中的方法
      gather(id) {
        this.add(document.getElementById(id))
      },

      // 复用数组find方法,实现集合中查找元素的方法
      find(callback) {
        return Array.prototype.find.call(this, callback)
      }
    }

    elems.gather('first')

    console.log(elems.length)

    // nodeType : 1 为元素节点, 2为属性节点
    console.log(elems[0].nodeType)

    elems.gather('second')

    console.log(elems.find(item => item.id === 'second'))

输出结果: 在上列例子中,我们创建对象,并且模拟一些数组的行为。首先定义了length属性用于存储元素的数量,与数组类似。然后定义在末尾添加元素的add方法。利用内置的数组方法find实现自定义对象的find方法,用于查找自定义对象中的任意元素。


下面让我们继续研究ES6引入的两个新的集合类型:Map与Set

9.2 Map

假设我们构建一个网站需要满足全球用户的需求。需要创建不同国家相对应的语言,例如韩语、英语等。这种集合,将key映射到指定的值上,在不同的编程语言中具有不同的名称,通常称为字典或者Map。

但是在Js中如何有效地管理这种定位呢?一种传统的方法是利用对象是属性名与属性值的特性,创建如下字典:

const dictionary = {
	'zh': {
    	'ninja': '忍者'
    },
    
    'ko': {
    	'ninja': '닌 자'
    }
}

粗略的看,这似乎完美的解决了,对于这个示例来说确实不错,但是通常来说这种方法并不可靠。

9.2.1 别把对象当做Map

假设在网站上需要访问单词的constructor属性。

const dictionary = {
	'zh': {
    	'ninja': '忍者'
    },
    
    'ko': {
    	'ninja': '닌 자'
    }
}

console.log(dictionary.zh['constructor'])

试图访问constructor属性,这是在字典中未定义的单词。本应该返回undefined。但是结果并非如此。返回结果如下图所示:

由于可以通过原型访问未显示定义的对象属性,因此对象并非最佳map 每个对象都有原型,尽管定义新的空对象作为map,仍然可以访问原型对象的属性。原型对象的属性之一是constructor,这会导致混乱。

同时,对象的key必须是字符串。如果想映射为其他类型,它会默默转化为字符串,没有任何提示。看看下面的例子,假设需要跟踪HTML节点信息。

  const firstElement = document.getElementById('first')
  const secondElement = document.getElementById('second')

  // 定义空对象,使用映射存储HTML节点的额外信息
  const map = {}

  // 存储第一个元素信息,并且验证是否正确
  map[firstElement] = { data: 'firstElement' }
  console.log(map[firstElement].data === 'firstElement' )

  // 存储第二个元素信息,并且验证是否正确
  map[secondElement] = { data: 'secondElement' }
  console.log(map[secondElement].data === 'secondElement' )
  
  // 再次验证第一个元素信息,失效
  console.log(map[firstElement].data === 'firstElement')
  // 打印map
  console.log(map)

输出结果:

这导致的原因是因为对象的key必须是字符串,这意味着当试图使用非字符串类型如HTML元素作为key时,其值被toString方法静默转换为字符串类型。HTML元素(input)转换为字符串的值为[object HTMLInputElement],第一个元素的数据信息被存储在了[object HTMLInputElement]属性中,当存储第二个元素时,则会覆盖掉第一个元素的值。

**由于这两个原因:**原型继承属性以及key仅支持字符串,所以通常不能使用对象作为map。由于这种限制,ECMAScript委员会决定定义了一个全新类型:Map

9.2.2 创建map

使用新的内置构造函数Map

  // 使用Map构造函数创建map
  const testMap = new Map()

  // 定义3个对象
  const test1 = { name: 'wave1' }
  const test2 = { name: 'wave2' }
  const test3 = { name: 'wave3' }

  // 使用Map的set方法,建立两个对象的映射关系
  testMap.set(test1, { age: 18 })
  testMap.set(test2, { age: 19 })

  // 使用Map的get方法获取对象
  console.log(testMap.get(test1).age === 18)
  console.log(testMap.get(test2).age === 19)

  // 验证第三个对象不存在映射关系
  console.log(testMap.get(test3) === undefined)

  console.log(testMap.size === 2)

  // 使用has方法验证map中是否存在指定key
  console.log(testMap.has(test1))

  // 使用delete方法删除指定key
  testMap.delete(test1)
  console.log(testMap.size)

  // 使用clear方法完全清空map
  testMap.clear()
  console.log(testMap.size)

以上代码的输出结果为:

我们通过set方法创建映射,get方法获取映射,除了这两个方法,map还具有size属性、has、delete、clear方法,size属性告诉我们已经创建了多少个映射。map是键值对的集合,key可以是任意类型的值,甚至可以是对象。

9.2.2 遍历map

目前为止,我们已看出了map的一些优点:可以确定的是map中只存在你放入的内容,可以任意使用任意类型的数据作为key等。

因为map是集合,可以使用forof循环遍历map。也可以确保遍历的顺序与插入的顺序一致。(在对象上使用for-of或者for-in循环则无法保证)。这是不同浏览器的Js引擎遵循不同的规范导致的,它们会先提取所有 key 的 parseFloat 值为非负整数的属性,然后根据数字顺序对属性排序首先遍历出来,然后按照对象定义的顺序遍历余下的所有属性。下面我们来看看示例:

var obj = { name: 'abc', '3': 'ccc', age: 23, school: 'sdfds', class: 'dfd', hobby: 'dsfd' }

// console.log(Object.keys(obj))

for(let key of Object.keys(obj)) {
  console.log(key)
}

输出结果为: key 为 '3'的键变成了第一位。

接下来我们遍历map

  const directory = new Map()

  directory.set('name', 'wave')
  directory.set('3', 'age')
  directory.set('address', 'shenzhen')

  // 使用for of 遍历
  // 每个元素有两个值,key与value
  for(let item of directory) {
    console.log('key:' + item[0], 'value:' + item[1])
  }
    console.log('分割— — — — — — — — — — — —')

  // 可以通过内置的keys方法遍历所有keys
  for(let key of directory.keys()){
    console.log(key)
  }

      console.log('分割— — — — — — — — — — — —')

  // 可以使用values方法遍历所有value
  for(let value of directory.values()) {
    console.log(value)
  }

下图为输出结果:

了解了Map之后,让我们来看看另一个新的集合类型:Set,Set集合中的元素是唯一的。

9.3 Set

在许多实际问题中,我们必须处理一种集合,集合中的每个元素都是唯一的,每个元素只能出现一次,这种集合称为Set。在ES6之前,这种集合只能通过模拟实现。

通过对象模拟Set

  function Set() {
    // 使用对象存储数据
    this.data = {}

    this.length = 0
  }

  // 用于判断set中是否有该元素
  Set.prototype.has = function(item) {
    return typeof this.data[item] !== 'undefined'
  }

  // 添加元素
  Set.prototype.add = function(item, data) {
    console.log(!this.has(item))
    // if(!this.has(item)) {
      this.data[item] = data
      this.length++
    // }
  }

  // 移除元素
  Set.prototype.remove = function(item) {
    if(this.has(item)) {
      delete this.data[item]

      this.length--
    }
  }

  // 试图添加两次name
  const test = new Set()

  test.add('name', 'wave')
  test.add('name', 'wave')
  console.log(test)

输出结果为: 这仅仅是模拟,也会存在同样的问题,不能真正的存储对象,只能存储字符串或者数字,仍然存在访问原型对象的风险。由于这些问题,ECMAScript委员会决定引入了一个全新的集合类型:Set。

9.3.1 创建Set

创建Set的方法是使用构造函数:Set

创建Set

  const test = new Set(['name', 'age', 'weight', 'name'])

  console.log(test.has('name'))
  // 丢弃重复项
  console.log(test.size)

  console.log(test.has('sex'))
  test.add('sex')
  console.log(test.has('sex'))
  console.log(test.size)
  // 向集合添加已经存在的元素不起任何作用
  test.add('sex')
  console.log(test.size)

使用内置构造函数创建Set。如果不传入任何参数,将创建一个空Set

Set的成员都是唯一的,最重要的作用是避免存储多个相同对象。在本例中试图添加两次'sex'但是只成功添加一次。has可以验证Set中是否存在该元素。如果想知道Set中具有几个元素,可以使用size属性。与Map和数组类似,Set也是集合,因此可以使用for-of循环进行遍历。遍历顺序与插入顺序也一致。

接下来,让我们看看Set的一些常见操作:并集、交集和差集。

9.3.2 并集

const arr1 = [1, 2, 3]
const arr2 = [2, 3, 4]

// 创建两个数组的并集
const newSet = new Set([...arr1, ...arr2])
console.log(newSet.size) // 3

9.3.3 交集

const set1 = new Set([1, 2, 3])
const set2 = new Set([2, 3, 4])

// 只包含集合set1和set2中同时出现的成员
const newSet = new Set(
	[...set1].filter(item => set2.has(item))
)

9.3.4 差集

const set1 = new Set([1, 2, 3])
const set2 = new Set([2, 3, 4])

// 只包含集合set1和set2中同时出现的成员
const newSet = new Set(
	[...set1].filter(item => !set2.has(item))
)

9.4小结

  • 数组是特殊的对象,具有length属性,原型是Array.prototype
  • 可以使用数组字面量([])或Array构造函数创建数组。
  • 可以在自定义对象上,显示定义对象方法,使用call或apply方法对数组的方法进行复用。
  • Map和字典是包含key与value映射关系的对象。
  • javaScript 中的对象是糟糕的map,只能使用字符串类型作为key,并且存在访问原型属性的风险。因此,使用内置的Map集合。
  • 可以使用for...of循环遍历Map集合
  • Set成员的值都是唯一的

十、正则表达式

正则表达式是现代开发中的必需品。虽然许多开发者不用正则表达式也可以顺利完成工作,但是如果不使用正则表达式,就无法优雅的解决许多问题。 在这篇文章中,只会做简单的基础介绍。

正则表达式是处理拆分字符串并进行信息查找的过程。在主流Js库中,可以看到开发者大量的使用正则表达式解决各种任务:

  • 操作HTML节点中的字符串
  • 使用CSS选择器表达式定位部分选择器
  • 判断一个元素是否具有指定的类名
  • 输入校验
  • 其他人物

10.1 为什么需要正则表达式

如果用户在网页的表单上输入的字符串需要遵循一种邮政编码格式, 99999-9999。我们需要对输入的内容进行校验。

我们可以创建一个函数,对指定的字符串验证是否符合美国总局要求的格式。但是这样十分繁琐。 如果我们用正则:

   function isRight(str) {
    return /^\d{5}-\d{4}$/.test(str)
  }

10.2.1 正则表达式说明

我们简单地将正则表达式理解为使用模式匹配文字字符串的表单式。表达式本身具有用于定义模式的术语和操作符。

在Js中,与其他对象类型类似,创建正则表达式有两种方式。

  • 使用正则表达式字面量
const pattern = /test/
  • 通过创建RegExp对象的实例
const pattern = new RegExp('test')

**注意:**当正则表达式在开发环境中是明确的,推荐优先使用字面量语法,当需要运行时动态创建的,则使用构造函数表达式

优先使用字面量语法,原因之一是反斜线在正则表达式中发挥了重要的作用。但是反斜线也用于转义字符。因此对于反斜线本身则需要使用双反斜线来标识\。

除了表达式本身,还可以使用5个修饰符:

  • i 对大小写不敏感,例如 /test/i 不仅可以匹配test还可以匹配Test
  • g 查找所有匹配项,会查找到第一个匹配时不会停止,会继续查找下一个匹配项
  • m 允许多行匹配,对获取的testarea元素的值很有用。
  • y 开始粘连匹配。正则表达式执行粘连
  • u -允许使用Unicod点转义符

使用: 在字面量末尾添加修饰符(如/test/ig),或者作为第二个参数传给RegExp构造函数(new RegExp('test', 'ig'))

10.2.2 术语和操作符

精确匹配 除了非特殊字符和操作符之外,字符必须准确出现在表达式中。例如,正则/test/中的4个字符必须完全出现在所匹配的字符串中,一个接一个字符直接连在一起。

匹配字符集 [abc]表示匹配a、b、c中的任意一个字符。如果我们希望匹配除a、b、c以外的任意字符,我们可以在左括号后面添加一个尖角号^: [^abc]

字符集还有一个更重要的操作:限定范围。例如,匹配a和m之间的小写字母,虽然可以直接用[abcdefghijklm]表示,但是这样写更简洁: [a-m]

转义注意,并不是所有的字符和字符字面量都是等价的。我们可以看到字符[、]、-、^表示它们本身以外的内容。那么,我们如何匹配字符[ 本身呢,在正则表达式中,反斜线对其后面的字符进行转义,使其匹配字符本身的含义。所以,[匹配[字符,而不在表示字符分组的括号。双反斜线\匹配一个反斜线。

起止符号 我们经常需要确保匹配字符串的开始,或是字符串的结束。尖角号用于匹配字符串的开始如/^test/匹配的是test出现在字符串的开头。类似地,美元符号表示字符串的结束:/test表示字符串的结束:/test/,同时使用^与$表示匹配整个字符串。

重复出现

  • 指定可选字符(可以出现0次或者1次),在字符后加?,例如,/t?est/可以同时匹配test和est
  • 指定字符串必须出现一次或者多次,使用+
  • 0次或者一次或者多次,使用*
  • 指定次数使用{},例如/a{4}/匹配连续4个字符a
  • 指定循环次数的范围,使用逗号分隔,例如/a{4, 10}/匹配4-10个连续的字符a
  • 指定开放区间,省略第二个值,保留逗号。例如/a{4,}/匹配4个或更多个连续的字符a

预定义字符集

有很多,这里只整理一些常用的!

预定义元字符匹配的字符集
\d匹配任意十进制数字,等价于[0-9]
\D匹配除了十进制数字外的任意字符,等价于[^0-9]
\w匹配任何字母、数字和下划线、等价于[A-Za-z0-9_]
\W匹配除了字母、数字和下划线之外的字符,等价于[^A-Za-z0-9_]
\s匹配任意空白字符
\S匹配除空白字符外的任意字符

分组 使用(), 如果对一组术语使用操作符,可以使用圆括号进行分组,例如,/(ab)+/匹配一个或多个连续的ab。

或操作符(OR) 使用竖线(|)表示或。例如,/a|b/可以匹配a或者b。

反向引用 反向引用可引用正则中定义的捕获。我们可以暂时将捕获看作匹配的字符串,也就是前面匹配的字符串。反向引用分组中捕获的内容,使用反斜线加上数字表示引用,该数字从1开始,第一个分组捕获的为\1,第二个为\2,以此类推。

例如 ^([dtn])a\1 ,匹配任意从d或t或n开始的,连续有a的字符串,连续匹配第一个分组中捕获的内容。后面这一点很重要,这与 /[dtn] a[dtn]/ 不同。a字符之后必须是之前匹配时的字母。因此,字符\1表示知道相等才匹配。

在匹配XML类型的标记元素时,反向引用很有用,看看以下正则: /<\w+>(.+)</\1>/ 这可以匹配简单的元素如whatever。如果没有反向引用,也许无法做到,因为无法预先知道与起始标记相匹配的结束标记是什么。

此外,我们可以调用字符串replace方法,对替代字符串内获取捕获。不使用反向引用,我们可以使用1,1,2,$3等标记捕获序号

十一、 代码的模块化

模块是比对象和函数更大的代码单元,使用模块可以将程序进行归类。创建模块时,我们应该努力形成一致的抽象和封装。这样有益于思考应用程序,当使用模块功能时,可以避免被琐碎细节干扰。此外,使用模块意味着可以在应用程序的不同部分更容易复用模块功能,甚至可以跨应用来复用模块,极大地提高了应用程序的开发效率。

当我们在主线程代码中定义变量,该变量自动被识别为全局变量,并且可以被其他部分的代码范围。对于一些小的程序来说,这也许还不是问题,但是当应用程序开始扩展,引用第三方代码后,命名冲突的可能性会大大提高。在ES6之前,Js仍未提供高级的内置特性。该内置空间用于在模块、命名空间或包中封装变量。因此,为了解决这个问题,Js程序员们利用现有的特性,例如对象,立即执行函数和闭包等,开发出了高级模块化技术。

11.1 在ES6之前的版本中模块化代码

在JsES6之前,只有两种作用域: 全局作用域和函数作用域。没有介于两者之间的作用域,没有命名空间或模块可以将功能进行分组。为了编写模块化代码,Js开发者们不得不创造性的使用Js的现有的语法特性。

  • 定义模块接口,通过接口可以调用模块的功能。
  • 隐藏模块的内部实现,避免有可能产生的副作用和对bug的不必要修改

我们先使用对象、闭包和立即执行函数,创建模块。然后我们继续研究最流行的模块化标准AMD和CommonJs,二者的基础原理稍微有些不同,并讲解如何使用这两种标准,以及两者的优劣。

模块模式:使用函数扩展模块,使用对象实现接口 模块接口通常包含一组变量和函数。创建接口最简单的方式是使用Js对象。例如,未统计页面单击模块创建接口。

  const ClickConutModule = function() {
    let numClicks = 0

    const  handleClick = () => {
      console.log(++numClicks)
    }

    return {
      conutClick: () => {
        document.addEventListener('click', handleClick)
      }
    }
  }()

  ClickConutModule.conutClick()

  console.log(ClickConutModule.handleClick) // undefined
  console.log(ClickConutModule.numClicks) // undefined

本例子中使用立即执行函数来实现模块。在立即执行函数内部定义模块内部的实现细节,一个局部变脸numClicks,一个局部函数handleClick,都只能在模块内部访问。通过返回的对象暴露模块的公共接口。模块内部的实现(私有变量和函数)通过公共接口创建的闭包保持活跃。

这种在Js中通过使用立即执行函数、对象和闭包来创建模块的方式成为模块模式。一旦我们有能力定义模块,就能够将不同的模块拆分为多个文件,或在已有模块上不修改原有代码就可以定义更多功能。

拓展模块

ClickConutModule.newMethod = () => {
	...
	return ...
}()

通过独立的立即执行函数拓展模块无法共享模块私有变量,因为每个函数都分别从创建了新的作用域。模块模式,还有其他问题,当我们开始创建模块化应用的时候,模块本身常常依赖其他模块的功能。然而,模块模式无法实现这些依赖关系。

为了解决这个问题,出现了两个标准:AMD和CommonJS

11.1.2 使用AMD和CommonJs模块化Js应用

AMD和CommonJS是两个相互竞争的标准,这两者都可以定义模块。除了语法和原理不同之外,主要的区别是AMD的设计理念是明确基于浏览器,CommonJs的设计是面向通用的Js环境。而不局限于浏览器。

AMD

AMD(Asynchronous Module Definition)翻译为异步模块定义。异步强调的是,在加载模块以及模块所依赖的其它模块时,都采用异步加载的方式,避免模块加载阻塞了网页的渲染进度。AMD源于Dojo toolkit,它是构建客户端Web应用程序的JavaScript流行工具之一。AMD可以很容易指定模块及依赖关系。同时,它支持浏览器。AMD最流行的实现是RequireJS。

AMD作为一个规范,只需定义其语法API,而不关心其实现。AMD规范简单到只有一个API,即define函数。

使用AMD定义模块依赖于JQuery

// 使用define函数指定模块及其依赖,模块工厂函数会创建对应的模块
define('MouseCounterMOdule', ['Jquery'], $ => {
	let num = 0
    
    const handleClick = () => {
    	console.log(++num)
    }
    
    return {
    	countClick: () => {
        	$(document).on("click", handleClick)
        }
    }
})

AMD提供给名为define的函数,它接收以下参数

  • 新创建模块的ID。使用该ID,可以在系统的其他部分引用该模块。
  • 当前模块依赖的模块ID列表
  • 初始化模块的工厂函数,该工厂函数接收依赖的模块列表作为参数

在上个例子中,我们使用AMD的define函数定义ID为MouseCounterMOdule的模块。该模块依赖于JQuery。因为依赖于JQeury,因此AMD首先请求JQuery模块,如果需要从服务器请求,那么这个过程将会需要花费一些时间。这个过程是异步执行的,以避免阻塞。所有依赖的模块下载并且解析完成之后,调用模块的工厂函数。并传入所依赖的模块。在本例中只依赖一个模块因此只传入一个参数JQuery。在工厂函数内部,是与标准模块模式类似的创建模块的过程:创建暴露模块公共接口的对象。

可以看出AMD有以下几项优点:

  • 自动处理依赖,我们无须考虑模块引入的顺序。
  • 异步加载模块,避免阻塞
  • 在同一个文件中可以定义多个模块

CommonJS

ConmonJS使用基于文件的模块、所以每个文件只能定义一个模块。ConmonJS提供变量module,该变量具有属性exports,通过exports可以很容易地扩展额外属性。最后,module.exports作为该模块的公共接口。如果希望在应用的其他部分使用模块,那么可以引用模块。文件同步加载,可以访问模块公共接口。这是CommonJS在服务器更流行的原因,模块加载速度相对更快,只需要读取文件系统,而在客户端则必须从远程服务器下载文件,同步加载通常意味着阻塞。

使用CommonJS 定义模块

const $ = require('JQuery')

let numClicks = 0
const handleClick = () => {
	console.log(++numClicks)
}

module.exports = {
	countClicks: () => {
    	$(document).on('click', handleClick)
    }
}

在另一个文件中引用该模块可以这样写:

const MouseCounterModule = require('MouseCounterModule.js')
MouseCounterModule.countClicks()

由于ConmonJS要求一个文件是一个模块,文件中的代码就是模块的一部分。因此,不需要使用立即执行函数来包装变量在模块中定义的变量都是安全地包含在当前模块中,不会泄露到全局作用域。只有通过module.exports对象暴露的对象或函数才可以在模块外部访问。

ComonJS具有两个优势:

  • 语法简单。只需要定义module.exports属性,剩下的模块代码与标准JS无差异。引用模块的方法也很简单,只需要使用require函数。
  • CommonJS是Node.js默认的模块格式,所以我们可以使用npm上成千上万的包。

ConmonJS最大的缺点是不显示地支持浏览器。浏览器端的JS不支持module变量及export属性,我们不得不采用浏览器支持的格式打包代码,可以通过Browerify,或RequireJS来实现。

ECMAScript委员会,已经意识到需要一个支持所有JS环境的模块语法,因此,ES6定义了一个新的模块标准,它将最终解决这些问题。

11.2 ES6模块

ES6模块结合了CommonJS与AMD的优点,具体如下:

  • 与CommonJS类似,ES6模块语法相对简单,并且基于文件(每个文件就是一个模块)
  • 与AMD类似,ES6模块支持异步模块加载。

ES6模块的主要思想是必须显示地使用标识符导出模块,才能从外部访问模块。为了提供这个功能,ES6引入了两个关键字:

  • export - 从模块外部指定标识符
  • import - 导入模块标识符

导入/导出模块的语法比较简单,但是有很多微妙的差别,我们一步一步地慢慢研究。

导出和导入功能

我们从简单的示例开始

从ninja.js模块中导出

const ninja = 'wave'

export const message = 'hello'

export function sayHi() {
	return message + ' ' + ninja
}

在模块最后一行导出

const ninja = 'wave'

const message = 'hello'

function sayHi() {
	return message + ' ' + ninja
}

export { message, sayHi }

这种导出模块标识符的方式与模块模式一些相似之处,直接函数的返回对象代表模块的公共接口,尤其与CommonJS相似,我们通过公共模块接口扩展了module.exports对象。无论我们如何导出模块,如果我们需要在另一个模块中导入,就必须得使用关键字import。

使用关键字import从模块ninja.js中导入变量message和函数sayHi

import { message, sayHi } from 'ninja.js'

通过模块,可以避免滥用全局变量而让代码更安全。没有显示导出的内容仍然可以通过模块进行隔离。

导入在ninja.js 中的全部标识符

import * as ninjaModule from 'Ninja.js'

我们使用符号*导入全部标识符,并使用as关键字指明模块别名, *之后必须使用as定义名称

默认导出

// 使用export default 关键字定义模块的默认导出
export default class Ninja {
	constructor(name) {
    	this.name = name
    }
}

export function comparaNinjas(ninja1, ninja2) {
	return ninja1.name === ninja2.name
}

在关键字export后面增加关键字default,指定模块的默认导出。在本例中,模块默认导出类Ninja。虽然指定了模块的默认导出,但是仍然可以导出其他标识符。

导入模块默认导出的内容

// 导入模块默认导出的内容,不需要使用花括号{},可以任意指定名称
import ImportNinja from 'Ninja.js'

// 导入指定内容
import { comparaNinjas } from 'Ninja.js'

默认导出可以不需要使用花括号,同时我们可以为默认导出自定义名称,不一定需要使用导出时的命名。也可以这样导入:

import ImportNinja, { comparaNinjas } from 'Ninja.js'

在一条语句中使用逗号操作符,分隔从Ninja.js文件导入的默认和命名的导出

我们也可以使用as关键字对导出和导入的标识符设置别名,避免命名冲突。

11.3 小结

  • 小的、组织良好的代码远比庞大的代码更容易理解和维护。优化程序结构和组织方式的一种方式是将代码拆分成小的、耦合相对松散的片段或模块。

  • 模块是比对象或函数稍大的、用于组织代码的单元,通过模块可以将程序进行分类。

  • 通常来说,模块可以降低理解成本,模块易于维护,并可以提高代码的可重用性。

  • 在JavaScript ES6之前,没有内置的模块,开发者不得不创造性地发挥Js语言现有的特性实现模块化。最流行的方式之一是通过立即执行函数的闭包实现模块:

  1. 使用立即执行函数创建定义模块变量的闭包,从外部作用域无法访问这些变量。
  2. 使用闭包可以使模块变量保持活跃。
  3. 最流行的是模块模式,通常采用立即执行函数,并返回一个新对象作为模块的公共接口
  • 除了模块模式,还有两个流行的模块标准: AMD,可以在浏览器端使用。CommonJS,在JavaScript服务器更流行。AMD可以自动解决依赖,异步加载模块,避免阻塞。CommonJS语法简单,可以同步加载模块(因此在服务器端更流行),通过npm可以获取大量模块。

  • ES6结合了AMD和CommonJS的特点。ES6模块受CommonJS影响,语法简单,并提供了与AMD类似的异步模块加载机制。

  1. ES6 模块基于文件,一个文件是一个模块
  2. 通过关键字export 导出标识符,import导入标识符 3.模块可以使用默认导出,通过一个export导出整个模块。
  3. export 与 import 都可以通过关键字as使用别名。

12、 DOM操作

现在有很多JsDOM操作库的源码了,我们来了解库中DOM操作的实现原理,既可以配合类库写出更高效的代码,也可以将这些技术灵活运用在自己代码中。因此,我们将从本章开始,看看如何根据需求,以注入HTML的方式来扩充现有页面。

12.1 向DOM中注入HTML

在以下场景中,我们需要高效地在文档中任意位置插入一段HTML字符串:

  • 在网页中插入任意HTML是以及操作并插入客户端模板时
  • 拉取并注入从服务器返回的HTML时

我们要从头实现一套简洁的DOM操作方式。具体步骤如下:

  • 将任意有效的HTML字符串转换为DOM结构
  • 尽可能高效地将DOM结构注入到任意位置

12.1.1 将HTML字符串转换成DOM

HTML字符串转DOM结构不是特别。事实上,它主要用到了一个大家都很熟悉的工具:innerHTML属性。

转换的步骤如下所示:

  1. 确保HTML字符串是合法有效的
  2. 将它包裹在任意符合浏览器规则要求的闭合标签内。
  3. 使用innerHTML将这串HTML插入到一个需求DOM中
  4. 提取该DOM节点

预处理HTML源字符串

如下代码是一个骨架HTML

<option>Wave</option>
<option>Kuma</option>
<table/>

这段代码有两个问题:

  1. 选项元素不能孤立存在。如果遵循良好的HTML语义,它们应该被包含在select元素内。
  2. 虽然标记语言通常会允许自闭合无子元素的标签,类似于<br/>,但HTML里只有一小部分元素支持

我们可以对HTML字符串进行快速的预处理,例如将<table/>元素转换为<table></table>

包装HTML p307 这里就不做详细记录,个人认为这不是目前需要重点学习的内容。

12.1.2 将DOM元素插入到文档中

我们有一个需要插入的元素数组,可能是文档中的任意地方,我们尝试将插入操作步骤减少到最少。为此我们可以使用DOM片段(DOM fragments)进行插入。DOM片段是W3C DOM规范的一部分,它为我们提供了一个存储临时DOM节点的容器。

首先先了解一下 createDocumentFragment()方法

  1. createDocumentFragment()方法,是用来创建一个虚拟的节点对象,或者说,是用来创建文档碎片节点。它可以包含各种类型的节点,在创建之初是空的。

  2. DocumentFragment节点不属于文档树,继承的parentNode属性总是null。它有一个很实用的特点,当请求把一个DocumentFragment节点插入文档树时,插入的不是DocumentFragment自身,而是它的所有子孙节点,即插入的是括号里的节点。这个特性使得DocumentFragment成了占位符,暂时存放那些一次插入文档的节点。它还有利于实现文档的剪切、复制和粘贴操作。 另外,当需要添加多个dom元素时,如果先将这些元素添加到DocumentFragment中,再统一将DocumentFragment添加到页面,会减少页面渲染dom的次数,效率会明显提升。

  3. 如果使用appendChid方法将原dom树中的节点添加到DocumentFragment中时,会删除原来的节点。

如果想详细了解该方法的用法可以进下面的链接看看: blog.csdn.net/qiao1363342…

12.2 DOM的特性和属性

当访问元素对的特性值时,我们有两种选择: 选择传统的DOM方法getAttribute和setAttribute,或使用DOM对象上与之相对应的属性。

举例来说,一个元素保存在变量e中,要获取其id的话,我们可以使用如以下方式:

e.getAttribute('id')
e.id

注意: 特性(e.getAttribute('id'))和对应的属性值(e.id),并不是共享一个相同的值,虽然有联系,但并不总是相同。

我们自定义的特性,不能自动被元素属性表示,访问这些自定义特性,我们需要使用DOM方法getAttribute()

    const myDiv = document.querySelector('div')

    myDiv.id = 1

    console.log(myDiv.getAttribute('id'))

    myDiv.setAttribute('myId', 666)

    console.log(myDiv.myId)
    console.log(myDiv.getAttribute('myId'))

    myDiv.testId = 888
    console.log(myDiv.getAttribute('testId'))

打印结果:

由以上打印结果可以看出,自定义属性或者特性,不可以被非本身的创建方式获取

注意: 在HTML5中,为遵循规范,建议使用data-作为自定义属性的前缀。这是一个很好的约定,方便清除区分自定义特性和原生特性。

12.3 令人头疼的样式特性

与一般特性的获取和设置相比,样式特性的获取和设置可谓是让人相当头疼。就像我们在上一节研究的特性和属性一样,本节我们也是用两种方式来处理style值:特性值,以及从特性值中创建的元素属性。

最常用的是style元素属性,它不是字符串,而是一个对象,该对象的属性与元素标签内指定的样式相对应。此外,我们将介绍一下可以访问元素所有计算后的样式信息的API。计算样式是对所有集成样式的应用样式和应用样式求值以后,在该元素上应用的实际样式。

12.3.1 样式在何处

元素的样式信息位于DOM元素中的style属性上,初始值是在元素的style特性上设置的。例如,style="color:red",将会把该样式信息保存在样式对象中。在页面执行期间,脚本可以设置或修改样式对象中的值,并且这些修改会直接作用于元素的展示上。

  <style>
    div {
      font-size: 24px;
      border: 0 solid gold;
    }
  </style>

  <div style="color: #000000">ninja</div>

  <script>
    document.addEventListener('DOMContentLoaded', () => {
      const div = document.querySelector('div')

      console.log(div.style.color)

      console.log(div.style.fontSize)

      console.log(div.style.borderWidth)
      
      div.style.borderWidth = '4px'
      
      console.log(div.style.borderWidth)
    })
  </script>

打印结果:

测试表明,内联样式和新赋值的样式被记录,但是样式表继承的样式没有。

注意: 元素的style属性中的任何值,都优先于样式表继承的值(即使样式表规则使用!important的注释)。 (虽然呈现效果!important优先级最高,这跟渲染有关)

12.3.2 获取计算后样式

所有现代浏览器实现的标准方法,是getComputedStyle方法。该方法接收要计算其样式的元素,并返回一个接口,通过该接口可进行属性查询。返回的接口提供了一个名为getPropertyValue的方法,用于检索特定样式属性的计算。

<style>
    div {
      background-color: #ffc;
      display: inline;
      font-size: 24px !important;
      border: 1px solid crimson;
      color: green;
    }
  </style>

  <div style="font-size: 28px;" id="testSubject" title="Ninja power!"></div>

  <script>
    const div = document.querySelector('div')

    const computedStyles =  getComputedStyle(div)

    console.log(computedStyles.getPropertyValue('font-size'))

    console.log(computedStyles.getPropertyValue('display'))
    console.log(computedStyles.getPropertyValue('border'))
    console.log(computedStyles.getPropertyValue('border-top-color'))

  </script>

12.3.3 测量元素的高度和宽度

height 和 width 这样的style属性造成了另外一个特殊问题,在不指定值的情况下,它们的默认值是auto,以便让元素的大小根据其内容进行决定。因此,除非显示提供特性字符串,我们是不能使用height和width来获取准确的值的。

offsetHeight 和 offsetWidth 都提供了这样的功能: 可以相当可靠地访问实际元素的高度和宽度。

一个隐藏元素的offsetHeight 和 offsetWidth皆为0,如果我们想获取它非隐藏情况下的宽高,可以暂时取消元素的隐藏,然后获取其值,然后再将其隐藏。

具体方法如下:

  1. 将display 属性设置为block
  2. 将visibility 设置为hidden
  3. 将position 设置为absolute
  4. 获取元素尺寸
  5. 恢复先前更改的属性

12.4 避免布局抖动

我们已经学会了如何修改DOM,创建和插入新元素,删除现有元素以及修改其属性。修改DOM是实现高度动态Web应用程序的基础工具之一。 但是这个工具也有一定的副作用,最重要的是一个可能造成布局抖动。当我们对DOM进行一系列连贯的读写操作时,会发生布局抖动,而此过程中浏览器是无法执行布局优化的。

在深入研究之前,需要意识到,改变一个元素的特性时,不一定只影响该元素,相反,它可能会导致级联的变化。例如,设置一个元素的宽度,可能导致元素的子节点、兄弟节点和父节点改变。所以每次进行更改时,浏览器都必须计算这些改变的影响。在某些情况下,我们无法做到这一点,我们需要进行这些更改。与此同时,我们不应该继续加重浏览器的不良影响力,从而导致Web应用程序的性能下降。

因为重新计算布局十分昂贵,浏览器尽量可能的少、尽可能延缓布局的工作。他们尝试在队列中批处理DOM上尽可能多的写入操作,并执行所有批量操作,最后更新布局。但有时候,我们编写代码的方式并不能让浏览器有足够的空间来执行这些优化,我们强制执行大量的重新计算。这就是造成布局抖动的元凶。当我们的代码对DOM进行一系列连续的读取和写入时,浏览器就无法优化布局操作。核心问题在于,每当我们修改DOM时,浏览器必须在读取任何布局信息之前先重新计算布局。这对性能损耗十分巨大。

在进行读取布局属性和写入布局属性的操作时,尽量批量操作。

引起布局抖动的API和属性:

React的虚拟DOM React使用虚拟DOM和一组JavaScript对象,通过模拟实际DOM来实现极佳的性能。当我们在React中开发应用程序时,我们可以对虚拟DOM执行所有修改,而不考虑布局抖动。然后,再恰当的事后,React会使用虚拟DOM来判断对实际DOM需要做什么改变,以保证UI同步。这种创新的批量处理方式,进一步提高了应用程序的性能。

12.5 小结

  • 为了快速插入DOM节点,请使用DOM片段,因为可以在单个操作中注入片段,从而大大减少了操作次数。

  • DOM元素属性和特性,尽管挂钩,但并不总是相同。我们可以通过使用getAttribute和setAttribute方法读取和写入DOM属性,同时也可以使用对象属性符号方式写入DOM属性。

  • 使用属性和特性时,也有必要了解自定义属性。我们在DOM元素上自定义的特性,仅用于自定义信息,不能与元素属性等同看待或使用。

  • 元素style是一个对象,它含有与元素标记中指定的样式值相对应的属性。要获得计算后样式,需要同时考虑样式表中设置的样式,请使用内置的getComputedStyle方法。

  • 要获取HTML元素的尺寸请使用,offsetWidth 和 offsetHeight

  • 当代码对DOM进行一系列连续的读取和写入操作时,浏览器每次都会强制重新计算布局信息,这会引起布局抖动。进而导致Web应用程序运行和响应速度变慢。

因为字数限制,又重新开了一篇文章: JavaScript忍者秘籍》深度阅读记录(二)