前言
相信每一个前端开发者对这本书都不陌生,不管是用作入门、进阶还是全面深入,本书都可以作为首选教材。本书覆盖的知识点之全面,组织体系之完善,让不管是处于什么阶段的读者,都能收益良多。
JavaScript高级程序设计 从2006年出了第一版开始,就成为前端开发者最受欢迎的专业书籍之一,而作为最经典的第三版,也是于2012年3月份就出了。随着 ES2015(ES6) 的发布给 JavaScript 带来的变化,现如今如果我们再打开第三版就会发现,虽然很多原理都是共同的,但是所有新的特性并未覆盖到。2020年9月上市的第四版无疑弥补了该问题,这一版在上一版的框架和格局的基础上,删减了过时的内容,增补了 ES2015(ES6) 到 ES2019(ES10) 的全新内容。
第四版内容提要
涵盖 ECMAScript2019(ES2019/ES10),全面、深入地介绍了 JavaScript 开 发者必须掌握的前端开发技术,涉及 JavaScript 的基础特性和高级特性。书中详尽讨论了 JavaScript 的各个方面,从 JavaScript 的起源开始,逐步讲解到新出现的技术,其中重点介绍 ECMAScript 和 DOM 标准。在此基础上,接下来的各章揭示了 JavaScript 的基本概念,包括类、Promise、迭代器、Proxy 等等。另外,书中深入探讨了客户端检测、事件、动画、表单、错误处理及 JSON。本书同时也介绍了近几年来涌现的重要新规范,包括 Fetch API、模块、工作者线程、服务线程以及大量新 API。 -- 引用自书中
目的
本人2016年年低实习的时候,就大致看过一遍第三版,不过当时的阅读效果可谓是“囫囵吞枣”(O(∩_∩)O哈哈~)。工作两年后,计划对现有知识做一次全面梳理,于是又翻出了第三版。那会儿就发现很多新的特性书中并未涉及,想要全面系统的梳理,总感觉差了点东西......
第四版的发布,无疑再次点燃了我想从头学习一遍的兴趣。这次打算以文字的形式,将书中提到的知识点,结合实际工作、面试中遇到的问题,做一个总结。不仅有助于日后方便查阅,也能帮助没时间看书的小伙伴快速了解新版所涉及的内容。因为本人平时也会参与一些面试的工作,所以也会把面试中的高频知识点总结出来。
所有的知识点都会以重要程度打上标记,具体规则如下:
✨: 了解
✨✨: 理解
✨✨✨: 熟练掌握
✨✨✨✨: 精通并能熟练运用
第一章 -- 什么是 JavaScript
本章内容
- JavaScript 历史回顾
- JavaScript 是什么
- JavaScript 与 ECMAScript 的关系
- JavaScript 的不同版本 第一章简单地回顾了 JavaScript 的发展史、JavaScript 的组成及与 ECMAScript 的关系。基本都只是一些要了解的内容,太多的细节不管是平时的工作还是面试,都不会遇到。
知识点
✨ 1. JavaScript 与 ECMAScript 关系:
JavaScript 由三个部分组成:
- ECMAScript: 核心功能
- DOM(Document Object Model): 文档对象模型,提供与网页交互的方法和接口
- BOM(Browser Object Model): 浏览器对象模型,提供与浏览器交互的方法和接口
第二章 -- HTML 中的 JavaScript
本章内容
- 使用
<script>
元素 - 行内脚本与外部脚本的比较
- 文档模式对 JavaScript 有什么影响
- 确保 JavaScript 不可用时的用户体验
这一章主要讲了如何在 HTML 页面中插入 JavaScript 代码和引入外部 JavaScript ,以及在使用
<script>
标签的注意点。
知识点
✨✨ 1. 网页中如何优化对 JavaScript 脚本文件的加载
-
<script>
依照它们在网页中出现的次序被同步解释,并会阻塞页面的渲染(未设置async
或defer
属性),当加载的是外部文件时,阻塞时间还包含下载文件的时间。对于有很多 JavaScript 的页面,会导致页面渲染明显延迟,在此期间浏览器窗口完全空白。因此,通常将所有 JavaScript 引用放在<body>
元素中的页面内容后面。浏览器中的事件循环机制,决定了 JavaScript 主引擎线程与 GUI 渲染线程互斥,两者同一时间只能有一个处于运行态。
-
尽量多的将 JavaScript 代码以外部文件的方式导入
- 可维护性: 将 JavaScript 文件与 HTML 文件分开,统一管理,更易维护(现代的编程方式,框架和打包程序接管了这部分,开发者可以更专注于实现逻辑上)
- 缓存: 利用浏览器的缓存,不管是强缓存还是协商缓存,都有助于资源的加载
- 适应未来
-
对于外部资源的加载,在支持 HTTP2 的环境中,以轻量、独立的 JavaScript 组件形式向客户端发送脚本更具优势;否则用一个大文件更加合适。
与 HTTP2 支持的多路复用、报头压缩等特性有关。
✨✨ 2. <script>
属性 async
与 defer
的作用及区别
都是用来 异步 加载 外部 脚本,浏览器在碰到有这两个属性的 <script>
时,都会立即启动单独的线程下载资源,但执行的时机不一致:
defer
: 在 HTML 解析完毕后,会按照它们出现的顺序依次执行async
: 资源下载完毕后,会立刻执行,若此时 HTML 渲染未结束也会停止渲染,直到脚本执行完毕后继续渲染。多个脚本的执行也是无序的,哪个资源最先获取完,就先执行。
✨✨ 3. 关于 <script>
src
属性
-
该属性发起的请求不受浏览器同源策略限制,jsonp 就是利用了这个特性
模拟实现一个简单的 JSONP 请求
原理
利用浏览器会自动解析 script 标签加载的远程资源:客户端动态创建一个 script 标签,将参数以及接收值的函数 callback 拼接后作为 url 的 query ,并将该 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) }) }
Note:JSONP 请求,只有在目标站点设置的 Cookie 的 SameSite 值为 None 时,才会自动携带。更多细节,可以参考 文章
-
设置了
src
的标签会忽略内部的 JavaScript 代码
✨ 4. <script>
指定 type="module"
类型
当 type
指定为 module 后,代码会被当做 ES6 模块执行,此时能正确解析代码中出现的 import
和 export
关键字。
- 前两年出的 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
时,将其翻译成 符号...... 个人认为这种专有名词完全可以不用翻译成中文。
知识点
✨✨✨✨ var
、let
、const
声明变量的区别
由于
const
的行为与let
基本相同,唯一的区别是它声明的变量必须初始化,且无法修改。下面重点比较var
与let
的区别。
-
作用域
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
不允许在同一个上下文中对同一个变量重复声明,与var
、const
混合的重复声明也不允许。
使用优先级:const
(在明确知道是常量的前提下) > let
> var
(应该杜绝使用)
优先使用
const
有助于静态代码分析工具提前发现不合法的赋值操作,以及让开发者更有信心地推断哪些变量属于常量。
✨✨✨✨ JavaScript 中的数据类型
- 基本数据类型(7种):
Undefined
、Null
、Boolean
、Number
、String
、Symbol
(ES6)、BigInt
(ES11) - 复杂数据类型(1种):
Object
✨✨✨✨ 类型判断方法,及注意点
typeof
: 用于区分基本类型和复杂类型,有两种特殊情况:typeof null
的返回值为 'object',因为null
被认为是空指针typeof function fn () {}
的返回值为 function
instanceof
: 用于判断一个对象实例的原型链上是否有指定原型。- 该属性定义在
Function
的原型上,因此所有正常继承的对象都可以调用,可以通过Symbol.hasInstance
修改
- 该属性定义在
Array.isArray()
: 用于判断是否为数组Object.prototype.toString.call()
: 最强大的类型判断方式- 许多内置的对象类型,如:
Number
、Boolean
、Undefined
等支持自动识别 - 另一些类型,如:
Map
、Promise
、Generator
等,引擎内置了Symbol.toStringTag
标签 - 自定义类,需要手动加上类型标签,示例:
类型判断函数class ValidatorClass { get [Symbol.toStringTag]() { return "Validator"; } }
function toRawType (target) { return Object.prototype.toString.call(target).slice(8, -1) }
- 许多内置的对象类型,如:
target.constructor
: 用于获取当前对象的构造函数。null
和undefined
会报错,其它基本类型会自动执行装箱操作。const str = 'juejin' console.log(str.constructor === String) // true const sy = Symbol() console.log(sy.constructor === Symbol) // true
✨✨✨✨ JavaScript 中哪些值为假值
undefined
、null
、±0
、±0n
、NaN
、'' | "" | ``
(三种不同格式的空字符串)、false
、document.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()
无法作用于null
和undefined
,而String()
可以将他们转成字符串的 "null" 和 "undefined"原因: 基本类型除
null
和undefined
外,在调用方法时都会隐式的执行 装箱 操作转换为对象,而他们的构造函数原型上都定义了toString()
方法。关于 装箱 操作即 原始包装类型转换,将在下一篇文章中详细介绍。'' + target
转换null
和undefined
时,使用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,表示实现异步迭代器 API 的 AsyncGenerator 函数,由
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
-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:
- 位操作符应用到非数值时,首先会自动使用
Number()
函数转换为数值- NaN 和 Infinity 在位操作中都会被当成 0 处理
- 再调用
.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()
方法取得原始值,再根据前面的规则进行比较 null
和undefined
相等,并且与任何其他数值都不相等NaN
不等于任何其他数值,包括自身- 如果两个都是对象,比较它们是否为同一个对象
全等于(===)/不全等(!==): 纯粹的比较,不发生任何类型转换
强烈建议,任何时候,都使用 全等于(===)
第四章 -- 变量、作用域与内存
本章内容
- 通过变量使用原始值与引用值
- 理解执行上下文
- 理解垃圾回收
本章节从变量定义、存储、查找,执行上下文及作用域链,内存管理等角度,深度剖析了这门语言的原理
知识点
✨✨✨✨ JavaScript 中值存储的方式
JavaScript 中变量可以包含两种不同类型的数据:原始值 和 引用值
原始值 即按值访问,操作的是存储在 栈 中该变量的实际值。通过变量把一个原始值赋值给另一个变量时,原始值会被复制到新变量的位置。7 种基本数据类型都属于按值访问。
引用值 即按引用访问,操作的是存储在 栈 中该对象的引用(指针),而对象的实际值存储在 堆 中。把引用值从一个变量复制给另一个变量时,复制的是存储在 栈 中的指针。JavaScript 中的对象都属于按引用访问。
函数的参数,不管是 基本类型 还是 引用类型 ,都属于按值传递。
更多关于 原始值 和 引用值 的特性会在第五章详细介绍。
✨✨✨✨ 理解执行上下文、作用域、作用域链
JavaScript 中相当重要的一环,只言片语难以讲明白,后续将以单独的文章呈现。
✨ 垃圾回收机制
JavaScript 是使用垃圾回收的语言,代码执行时的内存管理由执行环境负责。内存分配和闲置资源回收,由垃圾回收程序定时处理,垃圾回收程序通过标记的方式跟踪记录哪些变量还会使用。有两种主要的标记策略: 标记清理 和 引用计数
标记清理: 目前主流的回收策略
-
原理: 垃圾回收程序运行时,会标记内存中存储的所有变量。然后,它会将所有在上下文中的变量,以及被上下文中的变量引用的变量的标记去掉,此时仍然带标记的变量就是待回收的了。随后垃圾回收程序做一次内存清理,销毁待标记的所有值并收回它们的内存。
-
策略: 实现标记的策略有很多种:
- 当一个变量进入上下文时,反转某一位;
- 维护 “在上下文中” 和 “不在上下文中” 两个变量列表,把变量从一个列表转移到另一个列表;
- 从根对象(全局对象)递归,找到从根开始所有被引用的对象以及被引用对象再引用的对象......如此可以找到未被引用的对象,并回收
引用计数: 被淘汰的回收策略。
-
原理: 对每个值都记录它被引用的次数。存储在内存中的值,每次被一个新的变量引用,引用数加 1,保存对该值引用的变量被其它值覆盖时,引用数减 1,当引用数为 0 的值,就可以被安全的回收内存。垃圾回收程序下次运行时,就会释放引用数为 0 的值的内存。
-
缺陷: 循环引用,比如下面的示例:
function fn () { const a = {} const b = {} a.someObject = b b.anotherObject = a }
以上函数在采用 引用计数 的宿主环境中执行结束后,
a
和b
的引用计数永远不会变成 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引擎优化后效果,只保留被闭包引用的指定变量)。因此滥用闭包,会导致这部分内存不能被回收。建议仅在十分必要时使用
-
通过
const
和let
声明变量这两个关键字都以块级作用域声明变量,在块级作用域比函数作用域更早终止的情况下,可以尽早的让垃圾回收程序介入
-
隐藏类和删除操作 (弱)
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哈哈~),感兴趣的小伙伴记得点赞、关注哦!
新的一年里,祝大家升职加薪,早日脱单! ✿✿ヽ(°▽°)ノ✿