JavaScript高级程序设计第四版总结(第1~4章)

950 阅读30分钟

前言

相信每一个前端开发者对这本书都不陌生,不管是用作入门、进阶还是全面深入,本书都可以作为首选教材。本书覆盖的知识点之全面,组织体系之完善,让不管是处于什么阶段的读者,都能收益良多。

JavaScript高级程序设计 从2006年出了第一版开始,就成为前端开发者最受欢迎的专业书籍之一,而作为最经典的第三版,也是于2012年3月份就出了。随着 ES2015(ES6) 的发布给 JavaScript 带来的变化,现如今如果我们再打开第三版就会发现,虽然很多原理都是共同的,但是所有新的特性并未覆盖到。2020年9月上市的第四版无疑弥补了该问题,这一版在上一版的框架和格局的基础上,删减了过时的内容,增补了 ES2015(ES6)ES2019(ES10) 的全新内容。

第四版内容提要

涵盖 ECMAScript2019(ES2019/ES10),全面、深入地介绍了 JavaScript 开 发者必须掌握的前端开发技术,涉及 JavaScript 的基础特性和高级特性。书中详尽讨论了 JavaScript 的各个方面,从 JavaScript 的起源开始,逐步讲解到新出现的技术,其中重点介绍 ECMAScriptDOM 标准。在此基础上,接下来的各章揭示了 JavaScript 的基本概念,包括类、Promise、迭代器、Proxy 等等。另外,书中深入探讨了客户端检测、事件、动画、表单、错误处理及 JSON。本书同时也介绍了近几年来涌现的重要新规范,包括 Fetch API、模块、工作者线程、服务线程以及大量新 API。 -- 引用自书中

目的

本人2016年年低实习的时候,就大致看过一遍第三版,不过当时的阅读效果可谓是“囫囵吞枣”(O(∩_∩)O哈哈~)。工作两年后,计划对现有知识做一次全面梳理,于是又翻出了第三版。那会儿就发现很多新的特性书中并未涉及,想要全面系统的梳理,总感觉差了点东西......

第四版的发布,无疑再次点燃了我想从头学习一遍的兴趣。这次打算以文字的形式,将书中提到的知识点,结合实际工作、面试中遇到的问题,做一个总结。不仅有助于日后方便查阅,也能帮助没时间看书的小伙伴快速了解新版所涉及的内容。因为本人平时也会参与一些面试的工作,所以也会把面试中的高频知识点总结出来。

所有的知识点都会以重要程度打上标记,具体规则如下:

✨: 了解

✨✨: 理解

✨✨✨: 熟练掌握

✨✨✨✨: 精通并能熟练运用

第一章 -- 什么是 JavaScript

本章内容

  • JavaScript 历史回顾
  • JavaScript 是什么
  • JavaScriptECMAScript 的关系
  • JavaScript 的不同版本 第一章简单地回顾了 JavaScript 的发展史、JavaScript 的组成及与 ECMAScript 的关系。基本都只是一些要了解的内容,太多的细节不管是平时的工作还是面试,都不会遇到。

知识点

✨ 1. JavaScriptECMAScript 关系:

JavaScript 由三个部分组成:

  • ECMAScript: 核心功能
  • DOM(Document Object Model): 文档对象模型,提供与网页交互的方法和接口
  • BOM(Browser Object Model): 浏览器对象模型,提供与浏览器交互的方法和接口

第二章 -- HTML 中的 JavaScript

本章内容

  • 使用 <script> 元素
  • 行内脚本与外部脚本的比较
  • 文档模式对 JavaScript 有什么影响
  • 确保 JavaScript 不可用时的用户体验 这一章主要讲了如何在 HTML 页面中插入 JavaScript 代码和引入外部 JavaScript ,以及在使用 <script> 标签的注意点。

知识点

✨✨ 1. 网页中如何优化对 JavaScript 脚本文件的加载

  • <script> 依照它们在网页中出现的次序被同步解释,并会阻塞页面的渲染(未设置 asyncdefer 属性),当加载的是外部文件时,阻塞时间还包含下载文件的时间。对于有很多 JavaScript 的页面,会导致页面渲染明显延迟,在此期间浏览器窗口完全空白。因此,通常将所有 JavaScript 引用放在 <body> 元素中的页面内容后面。

    浏览器中的事件循环机制,决定了 JavaScript 主引擎线程与 GUI 渲染线程互斥,两者同一时间只能有一个处于运行态。

  • 尽量多的将 JavaScript 代码以外部文件的方式导入

    • 可维护性: 将 JavaScript 文件与 HTML 文件分开,统一管理,更易维护(现代的编程方式,框架和打包程序接管了这部分,开发者可以更专注于实现逻辑上)
    • 缓存: 利用浏览器的缓存,不管是强缓存还是协商缓存,都有助于资源的加载
    • 适应未来
  • 对于外部资源的加载,在支持 HTTP2 的环境中,以轻量、独立的 JavaScript 组件形式向客户端发送脚本更具优势;否则用一个大文件更加合适。

    HTTP2 支持的多路复用、报头压缩等特性有关。

✨✨ 2. <script> 属性 asyncdefer 的作用及区别

都是用来 异步 加载 外部 脚本,浏览器在碰到有这两个属性的 <script> 时,都会立即启动单独的线程下载资源,但执行的时机不一致:

  • defer: 在 HTML 解析完毕后,会按照它们出现的顺序依次执行
  • async: 资源下载完毕后,会立刻执行,若此时 HTML 渲染未结束也会停止渲染,直到脚本执行完毕后继续渲染。多个脚本的执行也是无序的,哪个资源最先获取完,就先执行。

✨✨ 3. 关于 <script> src 属性

  • 该属性发起的请求不受浏览器同源策略限制,jsonp 就是利用了这个特性

    模拟实现一个简单的 JSONP 请求
    原理

    利用浏览器会自动解析 script 标签加载的远程资源:客户端动态创建一个 script 标签,将参数以及接收值的函数 callback 拼接后作为 urlquery ,并将该 script 插入 HTML 中,最后将接 callback 挂载到 window 对象上。服务端设置返回资源的格式为 callback(data),下面展示具体的实现代码:

    函数签名
    interface ICO {
      [props: string]: any
    }
    type JsonpFn = (props: { url: string; params?: ICO }) => Promise<ICO>
    

    接收一个对象参数,对象中有一个必填的 url 作为 API 地址,可选的 params 对象参数作为入参,并返回 Promise

    函数实现
    const Jsonp: JsonpFn = ({ url, params }) => {
      return new Promise((resolve, reject) => {
        // 模拟生成全局唯一的函数名
        const uuid = Math.random().toString().slice(2)
    
        // 在 query 中绑定 callback 函数
        // 服务端配合设置返回值:uuid(data)
        url += `?callback=${uuid}`
    
        if (typeof params === 'object') {
          for (const [key, value] of Object.entries(params)) {
            url += `&${key}=${value}`
          }
        }
        const script = document.createElement('script')
        script.src = url
    
        window[uuid] = function(data: ICO) {
          document.body.removeChild(script)
          delete window[uuid]
          return resolve(data)
        }
    
        document.body.appendChild(script)
      })
    }
    

    NoteJSONP 请求,只有在目标站点设置的 CookieSameSite 值为 None 时,才会自动携带。更多细节,可以参考 文章

  • 设置了 src 的标签会忽略内部的 JavaScript 代码

✨ 4. <script> 指定 type="module" 类型

type 指定为 module 后,代码会被当做 ES6 模块执行,此时能正确解析代码中出现的 importexport 关键字。

  • 前两年出的 Snowpack 和最近比较火的 Vite 都是利用了这一原理,跳过了繁杂的编译过程
  • 除了指定 module 外, 最佳做法是不指定 type 属性

✨ 5. 如何验证加载的第三方(如 CDN)资源的正确性和完整性,即子资源完成性(SRI)

可以设置需要验证资源的 <script><link> 标签的 integrity 属性值,之后在执行脚本或者应用样式表之前,会对比所加载文件的哈希值和设置的哈希值是否一致。可以有效的应对 数据劫持 这类攻击。

integrity 值由两部分组成,通过短横(-)分隔:如 sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC

  • 第一部分指定哈希值的生成算法,sha256/sha384/sha512
  • 第二部分是资源经过 base64 编码的实际哈希值

扩展

内容安全策略(CSP) 通过服务器配置返回头或者页面中增加指定 <meta> 标签开启

Content-Security-Policy: require-sri-for script; 规定了所有 <script> 标签都要有 integrity 属性

Content-Security-Policy: require-sri-for style; 规定了所有 <link> 标签都要有 integrity 属性

✨ 6. 如何获取跨域资源中的报错?

HTML 中有一些标签是默认支持获取跨域资源的,除了上面介绍的 <script> 外,还有 <audio><img><link><video>。而跨域资源中的报错,由于 HTTP安全协议 的规定,在被 window.onerror 捕捉错误时,只能得到 script error

于是 HTML5 引入了一个 crossorigin 属性。上面的几个标签在设置了该属性后,会跳过默认的支持获取跨域资源的方式,提供对 CORS 的支持,此时跨域脚本的服务器必须通过 Access-Control-Allow-Origin 头信息允许当前请求域名获取资源。

crossorigin 有以下可能的值:

描述
anonymous请求不设置凭证标志
use-credentials请求提供凭证
''空值,如 crossorigin 或 crossorigin='',或任何其他的值crossorigin='xxx',效果同 anonymous

✨ 7. 对 JavaScript 不可用时的降级处理

以下两种情况,会导致浏览器中的 JavaScript 不可用:

  • 浏览器不支持脚本(现代浏览器无需考虑这一点)
  • 对脚本的支持被关闭 通过使用 <noscript> ,指定在浏览器不支持脚本时显示的内容。<noscript>...</noscript> 中可以包含除 <script> 外的 任何 可以出现在 <body> 中的 HTML 元素。通过 Vue-Cli 创建的项目会包含如图所示的主页面文件。

第三章 -- 语言基础

本章内容

  • 语法、关键字与保留字
  • 变量声明、数据类型
  • 操作符与语句
  • 函数简介 本章节从最基础的词法、语法、句法等讲起,全面详细的介绍了如何开发 JavaScript 代码,可以说是全书最基础的部分,也是每一个开发者都必须要掌握的技能。

由于本章节涉及到的知识点众多,很多都是日常开发中必备的技能。因此,我将本章节的内容做了总结,梳理出如下的知识点。

本章节中在讲解 Symbol 时,将其翻译成 符号...... 个人认为这种专有名词完全可以不用翻译成中文。

知识点

✨✨✨✨ varletconst 声明变量的区别

由于 const 的行为与 let 基本相同,唯一的区别是它声明的变量必须初始化,且无法修改。下面重点比较 varlet 的区别。

  • 作用域

    var 声明的变量根据执行上下文的不同,只存在两种作用域,全局作用域函数作用域。当变量声明在全局执行上下文中时,该变量会被挂载到全局对象上(window对象)

    let 声明的变量为块级作用域,块是以 {} 为边界的代码块,块存在于花括号中。在全局上下文中用 let 声明的变量不会成为全局对象的属性。

    在全局作用域中不同声明方式区别:

    var date = 2021
    let url = 'juejin.cn'
    function fn () {
        console.log(date, url)
    }
    console.dir(fn)
    

    可以看出,用 let 声明的变量位于 fn 作用域链的 Script 对象上,而用 var 声明的变量保存在 Global 对象上(太大就不展开了,可以在浏览器中尝试一下)

    一个很经典的问题,先看代码:

    // 代码1
    for (var i = 0; i < 5; i++) {
      setTimeout(() => console.log(i))
    }
    // 代码2
    for (let i = 0; i < 5; i++) {
      setTimeout(() => console.log(i))
    }
    

    代码 1 通过 var 声明了一个迭代的计数器,迭代体内通过 setTimeout 输出计数器的值。因为 var 声明的变量存储在最接近的上下文中,所以 setTimeout 的回调在下一轮宏任务执行时,获取到的已经是迭代结束后,值为 6 的计数器。可以做如下修改:

    // IIFE 
    for (var i = 0; i < 5; i++) {
      (function(v) {
        setTimeout(() => console.log(v))
      })(i)
    }
    

    代码 2 通过 let 声明计数器,作为块作用域的变量声明关键字,每次迭代都会为位于 {} 中的代码赋值在上一轮基础上加 1 的新的计数器(JavaScript 引擎会记住上一轮的值),此时的 i 存在于如下图所示的作用域中:

  • 提升(Hoisting)

    var 声明存在变量提升,即预编译阶段会将当前执行上下文中所有用 var 声明的变量,提到当前上下文的最顶端并赋值 undefined,执行阶段才会按照书写的顺序依次赋值。并且在实际赋值之前可以使用提升的变量。

    函数声明也存在变量提升,优先级会高于变量声明。这里所说的优先级并非指提升的顺序,如下例子:

    console.log(fn)  // f fn() {}
    var fn = 1
    function fn () {}
    console.log(fn)   // 1
    
    // 提升的实际效果如下:
    var fn = undefined
    function fn () {}
    console.log(fn)
    fn = 1
    console.log(fn)
    

    由此可以看出,变量提升会先于函数提升,而前文所说的优先级我想指的应该是重要度。

    检测一下是否掌握提升机制:

    console.log(a)
    fn()
    a = 2
    console.log(a)
    var a = 3
    console.log(a)
    console.log(fn)
    var fn = 'fn'
    console.log(fn)
    function fn () {
      console.log(a)
      var a = 1
      console.log(a)
    }
    

    let 声明同样也存在变量提升,不同的是在实际赋值之前会存在 暂时性死区(TDZ) ,处于该阶段的变量在被引用时会报错 ReferenceError

  • 重复声明

    var 支持在同一个上下文中,对变量重复声明,并且后者会覆盖前者

    var url = 'juejin.cn'
    var url = 'baidu.com'
    console.log(url)
    
    // 以上声明等同
    var url = undefined
    url = 'juejin.cn'
    url = 'baidu.com'
    console.log(url)
    

    let 不允许在同一个上下文中对同一个变量重复声明,与 varconst 混合的重复声明也不允许。

使用优先级const(在明确知道是常量的前提下) > let > var(应该杜绝使用)

优先使用 const 有助于静态代码分析工具提前发现不合法的赋值操作,以及让开发者更有信心地推断哪些变量属于常量。

✨✨✨✨ JavaScript 中的数据类型

  • 基本数据类型(7种): UndefinedNullBooleanNumberStringSymbol(ES6)、BigInt(ES11)
  • 复杂数据类型(1种): Object

✨✨✨✨ 类型判断方法,及注意点

  • typeof: 用于区分基本类型和复杂类型,有两种特殊情况:
    • typeof null 的返回值为 'object',因为 null 被认为是空指针
    • typeof function fn () {} 的返回值为 function
  • instanceof: 用于判断一个对象实例的原型链上是否有指定原型。
    • 该属性定义在 Function 的原型上,因此所有正常继承的对象都可以调用,可以通过 Symbol.hasInstance 修改
  • Array.isArray(): 用于判断是否为数组
  • Object.prototype.toString.call(): 最强大的类型判断方式
    • 许多内置的对象类型,如: NumberBooleanUndefined 等支持自动识别
    • 另一些类型,如: MapPromiseGenerator 等,引擎内置了 Symbol.toStringTag 标签
    • 自定义类,需要手动加上类型标签,示例:
    class ValidatorClass {
      get [Symbol.toStringTag]() {
        return "Validator";
      }
    }
    
    类型判断函数
    function toRawType (target) {
      return Object.prototype.toString.call(target).slice(8, -1)
    }
    
  • target.constructor: 用于获取当前对象的构造函数。nullundefined 会报错,其它基本类型会自动执行装箱操作。
    const str = 'juejin'
    console.log(str.constructor === String)   // true
    const sy = Symbol()
    console.log(sy.constructor === Symbol)   // true
    

✨✨✨✨ JavaScript 中哪些值为假值

undefinednull±0±0nNaN'' | "" | ``(三种不同格式的空字符串)、falsedocument.all

以上值在通过 Boolean(xx) 转换、逻辑非取反 !! 转换、 if (xx) 判断时,结果都为 false。(后面两种操作本质上也是使用了 Boolean(xx) 转换)

带有 [[IsHTMLDDA]] 内部槽位的对象,在 ToBoolean 和抽象相等操作时会被转义成 false ,而在 typeof 修饰操作时返回 undefined。目前只有 document.all 满足这种情况

✨✨✨ 常见的数值转换方法

Number()parseInt()parseFloat()一元加(+)/一元减(-) 可以将非数值转换为数值

  • Number() 转换规则

    • 布尔值:true 转换为 1, false 转换为 0
    • 数值:直接返回
    • null:返回 0
    • undefined:返回 NaN
    • 字符串:规则如下
      • 如果字符串包含数值字符,包括数值字符前面带加、减号的情况,则转换为一个十进制数值。(忽略前面的 0)
      • 如果字符串包含有效的浮点值格式如 "1.1",则会转换为响应的浮点值(忽略前面的 0)
      • 如果字符串包含有效的十六进制格式如 "0xf",则会转换为该十六进制值对应的十进制整数值
      • 如果是空字符串,则返回 0
      • 如果字符串包含除上述情况之外的其他字符,则返回 NaN
    • 对象:调用 valueOf() 方法,并按照上述规则转换返回的值。如果转换结果是 NaN ,则调用 toString() 方法,再按照转换字符串的规则转换。

    一元加(+)/一元减(-),规则同 Number()一元减(-) 在此基础上取反。

    Number() 函数在转换字符串时相对复杂且反常规,而且实际需求中一般都是将字符串转换成整数,所以在转换时可以优先考虑使用 parseInt() 函数

  • parseInt() 转换规则

    忽略字符串最前面的空格会,从第一个非空格字符开始转换,如果第一个字符是数值字符、加号或减号,则继续依次检测每个字符,直到字符串末尾或碰到非数值字符。

    • 如果第一个字符非数值字符、加号或减号,会直接返回 NaN,空字符串也返回 NaN
    • parseInt() 会自动识别不同的整数格式(十进制/二进制/八进制等),避免解析出错,不管什么情况下都要指定第二个入参 底数
    • parseInt() 更专注于字符串是否包含数值模式,从 TypeScript 的定义可以看出 declare function parseInt(s: string, radix?: number): number
  • parseFloat() 转换规则

    parseInt() 类似,从位置 0 开始检测,直到字符串末尾或解析到无效的浮点值字符为止

    • 第一个小数点 有效,第二次出现的小数点无效,此时字符串的剩余字符都会被忽略。"22.34.5" => 22.34
    • 只能解析十进制值,不能指定底数,并且会忽略字符串开头的 0。如果字符串表示整数( 没有小数点或小数点后面只有一个 0),则返回整数
    • 同样也只接受字符串作为入参,Typescript 定义的函数签名 declare function parseFloat(string: string): number

✨✨✨ 常见的字符串转换方法

toString()String()'' + target

  • toString() 无法作用于 nullundefined,而 String() 可以将他们转成字符串的 "null""undefined"

    原因: 基本类型除 nullundefined 外,在调用方法时都会隐式的执行 装箱 操作转换为对象,而他们的构造函数原型上都定义了 toString() 方法。关于 装箱 操作即 原始包装类型转换,将在下一篇文章中详细介绍。

  • '' + target 转换 nullundefined 时,使用 String() 函数;其它值会调用它们的 toString() 方法获取字符串,
  • toString() 在转换数值的时候,可以接收底数,即以什么底数来输出数值的字符串表示:
    const num = 10
    console.log(num.toString())   // '10'
    console.log(num.toString(2))   // '1010'
    console.log(num.toString(8))   // '12'
    console.log(num.toString(10))   // '10'
    console.log(num.toString(16))   // 'a'
    

✨✨ Symbol 的性质及列举 内置Symbol(well-known symbol)

ES6 新增数据类型,用于创建唯一标记,可以用在非字符串形式的对象属性。Symbol 不能使用 new 关键字,并且尽管传入相同的参数,每次构建的实例值也都不相等。可以使用 Symbol.for(xx) 幂等式 (若已注册过,直接返回实例)的注册全局 Symbol,并使用 Symbol.keyFor() 查询全局注册表。

遍历方式:

Object.getOwnPropertyNames() 获取对象实例的常规属性数组 Object.getOwnPropertySymbols() 获取对象实例的 Symbol 属性数组 Object.getOwnPropertyDescriptors() 同时获取常规和 Symbol 属性描述符的对象 Reflect.ownKeys() 返回两种类型的键

接口签名

通过 TypeScript 定义的接口,可以清楚的看出入参的类型

interface SymbolConstructor {
  readonly prototype: Symbol
	
  (description?: string | number): symbol
  
  for(key: string): symbol
  
  keyFor(sym: symbol): string | undefined
}

内置(well-known) Symbol

用于暴露语言内部行为,开发者可以直接访问、重写或模拟这些行为。这些内置 Symbol 最重要的用途之一就是重新定义它们,从而改变原生结构的行为。

  • Symbol.toStringTag:

    一个字符串,用于创建对象的默认字符串描述,由 Object.prototype.toString() 使用。参考 类型判断

  • Symbol.species

    一个函数值,作为创建派生对象的构造函数,用于内置类型实例方法的返回值暴露实例化派生对象的方法,很拗口也很难理解,让我们用一个示例演示一下:

    class Baz extends Array {
      static get [Symbol.species] () {
      	return Array
      }
    }
    const baz = new Baz()
    console.log(baz instanceof Array)   // true
    console.log(baz instanceof Baz)     // true
    
    // 这一步操作,会在 Symbol.species 指定的构造函数上执行,并返回该构造函数的实例
    const bazChild = baz.concat('baz')
    console.log(bazChild instanceof Array)    // true
    console.log(bazChild instanceof Baz)
    

    使用场景:有些类库是在基类的基础上修改的,那么子类使用继承的方法时,作者可能希望返回基类的实例,而不是子类的实例 -- 引用自文章

  • Symbol.toPrimitive

    • 一个方法,用于将对象转换为相应的原始值,由 ToPrimitive 抽象操作使用,定义该属性可以修改默认的类型转换
    const o = {}
    console.log('' + o)    // '[object Object]'
    o[Symbol.toPrimitive] = () => 'cover'
    console.log('' + o)    // 'cover'
    
  • Symbol.hasInstance

    一个方法,决定一个构造器对象是否认可一个对象是它的实例,由 instanceof 操作符使用。

    // 使用方法
    class Bar {}
    const bar = new Bar()
    console.log(bar instanceof Bar)               // true
    console.log(Bar[Symbol.hasInstance](bar))     // true
    
  • Symbol.iterator

    一个方法,返回对象默认的迭代器,表示实现迭代器 API 的函数,由 for-of 语句使用。由 Symbol.iterator 函数生成的对象通过其 next() 方法陆续返回值,本质上就是一个 单链表

  • Symbol.asyncIterator

    一个方法,返回对象默认的 AsyncIterator,表示实现异步迭代器 APIAsyncGenerator 函数,由 for-await-of 语句使用

  • Symbol.isConcatSpreadable

    一个布尔值,如果是 true,则意味着对象应该用 Array.prototype.concat 打平其数组元素。

    • 数组对象默认会被打平到已有的数组,false 或假值会导致整个对象被追加到数组末尾
    • 类数组对象默认情况下会被追加到数组末尾,true 或真值会导致这个类数组对象被打平到数组实例
    • 其他非类数组对象的对象在 Symbol.isConcatSpreadable 被设置为 true 的情况下将被忽略
  • Symbol.match

    • 一个正则表达式方法,用于匹配字符串,由 String.prototype.match() 方法使用
  • Symbol.replace

    • 一个正则表达式方法,替换一个字符串匹配的子串,由 String.prototype.replace() 方法使用
  • Symbol.search

    • 一个正则表达式方法,返回字符串中匹配正则表达式的索引,由 String.prototype.search() 方法使用
  • Symbol.split

    • 一个正则表达式方法,匹配正则表达式的索引位置拆分字符串,由 String.prototype.split() 方法使用
  • Symbol.unscopables

    • 一个对象,该对象所有的以及继承的属性,都会从关联对象的 with 环境绑定中排除。因为 with 语法不推荐使用,因此该属性一般不会被用到。
    const o = { foo: 'bar' }
    with (o) {
      console.log(foo)    // bar
    }
    // 绑定 Symbol.unscopables
    o[Symbol.unscopables] = { foo: true }
    with (o) {
      console.log(foo)    // ReferenceError
    }
    

Symbol 作为 ES6 新属性,实际使用场景不多,而 内置 的 12 个 Symbol 理解前几个,了解一下后几个应该就够了。更详细的介绍可以阅读 ECMAScript 6入门

✨✨ 位运算的原理及有哪些

位运算会将 ECMAScript 存储数值的 IEEE 754 64 位格式,转换为 32 位整数。前 31 位表示整数值,第 32 位是符号位,0 表示正,1 表示负。

正值: 以真正的二进制格式存储,前 31 位中的每一位都代表 2 的幂,空位用 0 填充。

负值: 以 补码二补数) 的方式存储,计算规则如下

  1. 取绝对值的二进制表示
  2. 找到数值的 反码 。即每一位都取反
  3. 给结果加 1

    -18(先确定18的二进制表示)

    0000 0000 0000 0000 0000 0000 0001 0010

    计算 一补数,反转每一位的二进制值

    1111 1111 1111 1111 1111 1111 1110 1101

    一补数 加 1(结果就是 -18 的二进制表示)

    1111 1111 1111 1111 1111 1111 1110 1110

位操作有以下几种

  • 按位非(~): 取反,即 0 -> 1,1 -> 0
  • 按位与(&): 都为 1 结果才为 1
  • 按位或(|): 都为 0 结果才为 0
  • 按位异或(^): 相同为 0,不同为 1
  • 左移(<<): 按照指定的位数将数值的所有位(除符号位)向左移动,用 0 填充右侧多出的空位,并保留符号
  • 有符号右移(>>): 左移的逆运算,用 0 填充左侧多出的空位
  • 无符号右移(>>>): 将数值的所有 32 位右移,这种移动方式,正数时同 >> ,负数时差异会很大,因为负数是用绝对值的二补数表示,而无符号右移会将其当做正数的二进制来处理,即第 32 位不再当做是符号位

Tips:

  1. 位操作符应用到非数值时,首先会自动使用 Number() 函数转换为数值
  2. NaNInfinity 在位操作中都会被当成 0 处理
  3. 再调用 .toString(2) 求得二进制表示的时候,转换过程会求得二补数,最终会以更符合逻辑的形式表示出来。-18 => -10010, 即 18 的二进制 10010 前面加上负号。

位运算是在数值的底层表示上完成的,因此会比其他的操作符快得多。在大型的库中应用的较为广泛,Vue3 在做静态标记的时候,就用到了位运算

如何通过位运算判断是否为 2 的幂?

我们知道如果一个数为 2 的幂,那这个数的二进制表示必然只有最高位是有效位 1,其余位皆为 0。将此数减去 1,即可去掉最高位的 1,并将剩余位全部填上 1。

例如:16 的二进制为 10000,而 15 的二进制为 1111,使用 & 位运算符并判断是否等于 0,即可判断出是否为 2 的幂。

function isPowerOf2 (i: number) {
  return (i & (i - 1)) === 0
}

✨✨✨✨ JavaScript 的短路操作符

逻辑与(&&): 如果第一个操作数决定了结果,那么永远不会对第二个操作数求值。即第一个操作数只有在 非假值(可以看上面列出的假值) 时,才求值第二个操作数。

逻辑或(||): 第一个操作数为 假值 时,才求值第二个操作数。

以上两种操作符都具有短路的特性,即第一个操作满足条件后,就不会求值第二个操作数。

✨✨✨✨ 等于全等于 的区别

等于(==)/不等于(!=): 比较时,会发生 强制类型转换,规则如下:

  • 如果任一操作数是布尔值,将其转换为数值再比较,false -> 0,true -> 1
  • 如果一个是字符串,另一个是数值,则将字符串用 Number() 转换后再比较
  • 如果一个是对象,另一个不是,则调用对象的 valueOf() 方法取得原始值,再根据前面的规则进行比较
  • nullundefined 相等,并且与任何其他数值都不相等
  • NaN 不等于任何其他数值,包括自身
  • 如果两个都是对象,比较它们是否为同一个对象

全等于(===)/不全等(!==): 纯粹的比较,不发生任何类型转换

强烈建议,任何时候,都使用 全等于(===)

第四章 -- 变量、作用域与内存

本章内容

  • 通过变量使用原始值与引用值
  • 理解执行上下文
  • 理解垃圾回收

本章节从变量定义、存储、查找,执行上下文及作用域链,内存管理等角度,深度剖析了这门语言的原理

知识点

✨✨✨✨ JavaScript 中值存储的方式

JavaScript 中变量可以包含两种不同类型的数据:原始值引用值

原始值按值访问,操作的是存储在 中该变量的实际值。通过变量把一个原始值赋值给另一个变量时,原始值会被复制到新变量的位置。7 种基本数据类型都属于按值访问。

引用值按引用访问,操作的是存储在 中该对象的引用(指针),而对象的实际值存储在 中。把引用值从一个变量复制给另一个变量时,复制的是存储在 中的指针。JavaScript 中的对象都属于按引用访问。

函数的参数,不管是 基本类型 还是 引用类型 ,都属于按值传递。

更多关于 原始值引用值 的特性会在第五章详细介绍。

✨✨✨✨ 理解执行上下文、作用域、作用域链

JavaScript 中相当重要的一环,只言片语难以讲明白,后续将以单独的文章呈现。

✨ 垃圾回收机制

JavaScript 是使用垃圾回收的语言,代码执行时的内存管理由执行环境负责。内存分配和闲置资源回收,由垃圾回收程序定时处理,垃圾回收程序通过标记的方式跟踪记录哪些变量还会使用。有两种主要的标记策略: 标记清理引用计数

标记清理: 目前主流的回收策略

  • 原理: 垃圾回收程序运行时,会标记内存中存储的所有变量。然后,它会将所有在上下文中的变量,以及被上下文中的变量引用的变量的标记去掉,此时仍然带标记的变量就是待回收的了。随后垃圾回收程序做一次内存清理,销毁待标记的所有值并收回它们的内存。

  • 策略: 实现标记的策略有很多种:

    • 当一个变量进入上下文时,反转某一位;
    • 维护 “在上下文中” 和 “不在上下文中” 两个变量列表,把变量从一个列表转移到另一个列表;
    • 从根对象(全局对象)递归,找到从根开始所有被引用的对象以及被引用对象再引用的对象......如此可以找到未被引用的对象,并回收

引用计数: 被淘汰的回收策略。

  • 原理: 对每个值都记录它被引用的次数。存储在内存中的值,每次被一个新的变量引用,引用数加 1,保存对该值引用的变量被其它值覆盖时,引用数减 1,当引用数为 0 的值,就可以被安全的回收内存。垃圾回收程序下次运行时,就会释放引用数为 0 的值的内存。

  • 缺陷: 循环引用,比如下面的示例:

    function fn () {
      const a = {}
      const b = {}
      a.someObject = b
      b.anotherObject = a
    }
    

    以上函数在采用 引用计数 的宿主环境中执行结束后,ab 的引用计数永远不会变成 0,则垃圾回收程序不会回收他们占据的内存空间。若有大量的循环引用,则会导致大量的内存永远不会被释放。

✨ 内存管理的注意点

正如前文介绍的,JavaScript 是采用垃圾回收的语言,合理的开发中,内存管理一般不是开发者需要关心的点。但是,将内存占用量保持在一个较小的值可以让页面性能更好,优化内存占用的最佳手段就是保证在执行代码时只保留必要的数据。

  • 解除引用

    不必要的全局变量和全局对象,可以把它设置为 null,从而释放引用。

    解除对一个值的引用并不会导致相关内存被立刻回收,解除引用的关键在于将当前值标记为可回收状态,待下次垃圾回收时会被回收。

  • 闭包

    局部作用域中变量占据的内存,会在执行完毕后被自动解除引用,但是有一个特殊之处就是 闭包。被闭包引用的变量,即使在创建它的上下文已经被销毁后,在闭包函数的作用域链中,以旧保持对该变量的引用。

    function fn () {
      const url = 'juejin.cn'
      const full = 'https.juejin.cn'
      return function () {
        const year = 2021
        return function (day) {
          const month = 2
          return full + year + month + day
        }
      }
    }
    const f = fn()()
    console.dir(f)   // 输出如下
    

    从图中我们可以清晰的看出,闭包函数的作用域链中依旧保持着对外部作用域变量的引用(V8引擎优化后效果,只保留被闭包引用的指定变量)。因此滥用闭包,会导致这部分内存不能被回收。建议仅在十分必要时使用

  • 通过 constlet 声明变量

    这两个关键字都以块级作用域声明变量,在块级作用域比函数作用域更早终止的情况下,可以尽早的让垃圾回收程序介入

  • 隐藏类和删除操作 (弱)

    V8 引擎会利用 “隐藏类” 来优化性能,这一条只有在使用了 V8 引擎并且非常注重性能时,才需要关注。

    解决方案就是避免 JavaScript“先创建再补充” 式的动态属性赋值,在构造函数中一次性的声明所有属性,如下所示:

    function Article (author) {
      this.title = 'JavaScript'
      this.author = author
    }
    // 此时 a1 与 a2 共享一个 *“隐藏类”*
    const a1 = new Article()
    const a2 = new Article('kang')
    
    // 以下操作会导致 a1 与 a2 不再共享一个 “隐藏类”
    delete a1.title          // 删除
    a1.date = '2021/2/15'    // 新增属性
    
    a1.title = null          // 删除的优化方式。而新增的属性应该在构造函数中就声明
    
  • 避免内存泄漏

    合理的使用全局变量,避免错误的声明全局变量

    function set () {
      name = 'kang'   // 此时变量会被声明在全局作用域
    }
    
  • 静态分配和对象池

    此方案的目的就是压榨浏览器,减少垃圾回收程序的执行次数,因为垃圾回收程序的执行也是会消耗性能的。

    • 对于会非常频繁的创建和销毁的对象,可以创建 对象池,需要时申请一个对象,完事后再还给 对象池。这种机制可以避免重复新增和销毁对象,而频繁的唤起垃圾回收程序执行。

    • 更简单的示例

      const arr = new Array(100)
      arr.push(1)
      

      由于 JavaScript 数组的大小是动态可变的,以上操作,有些引擎可能会先删除大小为 100 的数组,新创建一个更大的数组用于容纳新加入的元素。因此,可以初始化就创建一个大小够用的数组,从而避免上述先删除再创建的操作。

    这是优化的极端形式,只有在应用程序被垃圾回收严重拖了后腿,才可以利用它提升性能,属于过早优化。

结语

前四章从历史背景、执行环境、语言基础等多方面,全面系统的介绍了 JavaScript 这门语言。而后面的章节更侧重于从单个重要的点切入,深度剖析其中的原理。

春节假期在家断断续续的看了前四章,并结合自己的理解写了这篇文章,若有理解错误或不到位的地方,还望指出!

本系列后续将会不定期更新(主要看阅读速度 O(∩_∩)O哈哈~),感兴趣的小伙伴记得点赞、关注哦!

新的一年里,祝大家升职加薪,早日脱单! ✿✿ヽ(°▽°)ノ✿