Apr 17, 2024
# 章节1. 什么是JS
JavaScript 语言本质的三大支柱 scope/closures, prototypes/objects, and types/coercion,一些围绕着这些特性展开。JS 是一种多范式语言,可以编写过程式、面向类或函数式编程,并且可以逐行做出这些决定,而不是被迫做出全有或全无的选择。
a. 面向过程(Procedural style):通过已定义好的函数从上到下、线性过程执行。
b. 面向对象(OO style):逻辑和数据封装在被调用类的单元中。
c. 函数式(FP style ):代码组织成函数(纯粹的计算,而不是过程),并将这些函数的调整作为值。
技术指导委员会 TC39
他们的主要任务是管理语言的官方规范,其中JS 的语法和行为在 ES 规范中进行了定义。会定期召开会议,就任何商定的修改进行投票,然后提交给标准组织 ECMA。
向后兼容&Babel&Polyfill
从设计之初,JS 就是向后兼容的,但一味的保持向后兼容性给JavaScript带来了许多代价。(向前兼容= 向未来兼容. 向后兼容= 向以前兼容.)旧引擎无法兼容新的 JS 特性,试想一个情景,如果在 2016 年的引擎中运行使用 ES2019 功能的程序,很可能会出现程序崩溃的情况,那该怎么做呢?
转译工具Babel就诞生了。比如对于关键字 let。
// 转译前 if (something) { let x = 3; console.log(x); } else { let x = 4; console.log(x); } // Babel转译后 // 只有当应用程序需要在支持 ES6 之前的 JS 环境中运行时,才需要转译 var x$0, x$1; if (something) { x$0 = 3; console.log(x$0); } else { x$1 = 4; console.log(x$1); }
如果旧版本的 JS 方法缺失,不是 API 更新/兼容性的问题。那么我们需要 Polyfill,即为缺失的 API 方法提供一个定义。
// 不在原型链上,新增到原型链上 if (!Promise.prototype.finally) { Promise.prototype.finally = function f(fn){ return this.then( function t(v){ return Promise.resolve( fn() ) .then(function t(){ return v; }); }, function c(e){ return Promise.resolve( fn() ) .then(function t(){ throw e; }); } ); }; }
关于解析、编译。
- 解析(Parsing): JavaScript代码首先被解析器(Parser)解析成抽象语法树(AST),解析器会将代码分析成语法单元,并构建出相应的AST。JavaScript在运行时会逐行解释和执行代码,这意味着JavaScript引擎会一边解析代码一边执行,而不是提前将代码转换为机器码。
- 编译(Compilation): 接下来,AST会被编译器(Compiler)编译成中间表示形式(IR)。这个IR通常是字节码或其他中间表示形式,它类似于机器码,但不是直接可执行的。这个过程可能会包括一些优化步骤,例如内联函数、常量折叠等。
- 即时编译(Just-In-Time Compilation): 在某些JavaScript引擎中,IR会被进一步编译成机器码,这个过程称为即时编译(JIT Compilation)。即时编译器(JIT Compiler)将IR转换为与特定硬件平台兼容的机器码,以便在运行时直接执行。这个过程通常是延迟的,只有在代码需要执行时才会进行。
- 执行(Execution): 最后,生成的机器码会被JavaScript引擎执行。执行过程中,引擎会根据需要调用相应的机器码,以执行相应的操作。这个阶段也可能包括一些优化和调整,以适应实际的执行情况。
关于Web Assembly
Web Assembly(WASM)是一种旨在提高网络性能和扩展Web平台功能的技术。
它类似于ASM.js,但通过一种更接近汇编语言的表示形式,跳过了JavaScript引擎通常进行的解析和编译过程,从而实现了更高效的执行。
WASM的初衷是为了让非JavaScript程序(如C等)能够在Web浏览器中运行,从而为Web平台带来更多非JavaScript语言的支持和平等性。
尽管WASM在JS引擎中执行,但它并不是JS,而是一种更接近汇编语言的表现形式,因此不会直接取代JS,而是为Web平台带来了更丰富的功能和更高的性能。
严格模式
严格模式(strict mode)在 JavaScript 中是至关重要的,尽管它不是默认选项。严格模式提供了指导编写高质量、高性能代码的最佳实践,应该被视为开发过程中的重要工具。
尽管一些开发者可能试图绕过严格模式,但这种做法通常会导致代码质量和性能的下降。在 JavaScript 文件中,可以通过特殊的 pragma 启用严格模式,或者在函数级别启用。尽管严格模式不太可能成为 JavaScript 的默认模式,但随着 ES6 模块的广泛应用,严格模式已经成为了事实上的默认模式,提高了代码质量和可靠性。
# 章节2. Surveying JS
每个文件都是一个独立的程序
在 JavaScript 中,每个独立的
.js
文件都被视为一个单独的程序。这意味着,即使一个文件失败,也不会阻止其他文件的解析、编译或执行。因此,每个文件都应该被独立地编写和测试,以确保整个应用程序的正常运行。由于 JavaScript 将文件视为独立的程序,如果一个文件发生错误,整个应用程序可能只能部分运行,或者根本无法运行。
许多项目使用构建工具将项目中的多个文件合并为一个单独的文件,以便交付给网页。在这种情况下,JavaScript 将这个单一的合并文件视为整个程序。
自 ES6 开始,JavaScript 还支持模块化格式,模块也是基于文件的。无论是独立文件还是模块,每个文件都应该被视为一个独立的程序。
在 JavaScript 中,多个独立文件通过全局作用域(或模块作用域)共享状态和公共功能。全局作用域允许独立文件在运行时混合在一起,而将模块导入到另一个模块中则允许在运行时进行模块之间的交互操作。
Value
值有两种形式:原始值和对象。
三种定义的类型:单引号‘’,双引号“”。另一种给字符串文字定界的方法是使用回车 ` 字符。然而,这种选择不仅仅是风格上的,还有行为上的区别。
假设该程序已经定义了一个变量 firstName,其字符串值为 "Kyle",那么`分隔字符串就会将变量表达式(用 ${ ... } 表示)解析为其当前值。这就是所谓的插值interpolation。该方法有插值需求才会用。
3.141592 是数学 PI 前六位的近似值。不过,JS 通常不会嵌入这样一个值,而是使用预定义的 Math.PI 值。数字的另一种变体是 bigint 原始类型,用于存储任意大的数字。
除了字符串、数字和布尔值外,JS 程序中还有两个原始值,即 null 和 undefined。虽然它们之间存在差异,但在大多数情况下,这两个值的作用都是表示值的空性(或不存在)。
许多开发人员倾向于以这种方式一致地处理这两种值,也就是说,假定这两种值是无法区分的。不过,最安全也是最好的做法是:只使用 undefined 作为单一的空值。
Arrays And Objects
JS 数组可以保存任何值类型,包括原始值或对象(包括其他数组),即使函数也是可以保存在数组或对象中的值。
函数和数组一样,是对象的一种特殊类型(又称子类型)。
对象则更为普遍:它是由各种值组成的无序、带键的集合。换句话说,访问元素时使用的是字符串位置名称(又称 "键 "或 "属性"),而不是数字位置(如数组)。例如
var me = { first: "Kyle"、 last: "Simpson"、 age:39, hobby: [ "JS", "Table Tennis" ]. };
这里,me 代表一个对象,first 代表该对象中信息位置的名称(值集合)。另一种通过属性/键访问对象中信息的语法选项使用方括号 [ ],如 me["first"]。
值类型
为了区分值,typeof 操作符会告诉你它的内置类型(如果是原始类型),否则就是 "对象"。
typeof 42; // "number" typeof "abc";// "string" typeof true; // "boolean" typeof undefined; // "undefined" typeof null; // "object" typeof { "a": 1 }; // "object" typeof [1,2,3]; // "object" typeof function hello(){}; // "function"
typeof null 不幸返回 "对象",而不是预期的 "null"。此外,对于函数,typeof 返回的是特定的 "function",而对于数组,typeof 返回的不是预期的 "array"。
从一种值类型转换为另一种值类型(如从字符串转换为数字)在 JS 中被称为 "强制转换"。
值类型的定义
与常规或函数作用域相比,let 允许对变量进行更有限的访问,这被称为 “块级作用域”。
var adult = true; if (adult) { var myName = "Kyle"; let age = 39; console.log("Shhh, this is a secret!"); } console.log(myName); // Kyle console.log(age); // Error!
“块级作用域” 对于限制变量声明在程序中的广泛性非常有用,有助于防止变量名意外重叠。但 var 仍然很有用,因为它表达了"整个函数的更大范围将看到这个变量 "的意思。
试图在 if 语句之外访问 age 会导致错误,因为 age 在 if 语句中是块作用域,而 myName 不是。
const 声明的变量并非 "不可更改",只是不能重新 assign 分配。在对象值中使用 const 是不明智的,因为即使变量不能重新赋值,这些值仍然可以更改,这会导致潜在的混淆。
函数
function awesomeFunction(coolThings) { // .. return amazingStuff; } // let awesomeFunction = .. // const awesomeFunction = .. var awesomeFunction = function(coolThings) { // .. return amazingStuff; };
函数声明 vs 函数声明形式。与函数声明形式不同,函数表达式在运行时才与标识符相关联。
需要注意的是,在 JS 中,函数是可以赋值(如本代码段所示)和传递的值。事实上,JS 函数是对象值类型的一种特殊类型。并非所有语言都将函数视为值,但对于支持函数式编程模式的语言来说,这一点至关重要,而 JS 正是如此。
由于函数是值,因此可以作为属性分配给对象,
/** * whatToSay 持有的对象中包含对三个函数(greeting()、question() 和 answer())的引用 */ var whatToSay = { greeting() { console.log("Hello!"); }, question() { console.log("What's your name?"); }, answer() { console.log("My name is Kyle."); } }; whatToSay.greeting(); // Hello!
How We Organize in JS
在 JS 中,有两种组织代码“数据和行为”的主要模式被广泛使用:类和模块。
类。"面向对象"、"面向类 "和 "类 "这些术语都有很多细节和细微差别;它们的定义并不通用。程序中的类是一种自定义数据结构 "类型"的定义,包括数据和对数据进行操作的行为。类定义了这种数据结构的工作方式,但类本身并不是具体的值。要获得可在程序中使用的具体值,必须对类进行一次或多次实例化(使用 new 关键字)。
class Page { constructor(text) { this.text = text; } print() { console.log(this.text); } } class Notebook { constructor() { this.pages = []; } addPage(text) { var page = new Page(text); this.pages.push(page); } print() { for (let page of this.pages) { page.print(); } } } // instances of the Page class are created. var mathNotes = new Notebook(); // Behavior (methods) can only be called on instances mathNotes.addPage("Arithmetic: + - * / ..."); mathNotes.addPage("Trigonometry: sin cos tan ..."); mathNotes.print();
类机制,允许将打包数据(pages和text)与其行为(如 addPage(..) 和 print())组织在一起。同样的程序可以在没有任何类定义的情况下构建,但其组织性可能会大打折扣,更难阅读和推理,也更容易出现错误和不合格的维护。
所以有了类继承。传统的 "面向类 "设计的另一个固有方面是 "继承inheritance" 和 "多态性polymorphism",尽管在 JS 中使用得较少。
var YDKJS = new Book({ title: "You Don't Know JS", author: "Kyle Simpson", publishedOn: "June 2014", publisher: "O'Reilly", ISBN: "123456-789" }); YDKJS.print(); // Title: You Don't Know JS // By: Kyle Simpson // June 2014 // Publisher: O'Reilly // ISBN: 123456-789 var forAgainstLet = new BlogPost( "For and against let", "Kyle Simpson", "October 27, 2014", "https://davidwalsh.name/for-and-against-let" ); forAgainstLet.print(); // Title: For and against let // By: Kyle Simpson // October 27, 2014 // https://davidwalsh.name/for-and-against-let
每个构造函数中的 super(..) 调用都将初始化工作,委托给父 类的构造函数,然后根据"子类"执行更具体的操作。
事实上,继承方法和重载方法可以使用相同的名称并存,这就是所谓的多态性。继承是一种强大的工具,它可以将数据/行为组织到独立的逻辑单元(类)中,但允许子类通过访问/使用父类的行为和数据与父类合作。
模块
模块模式与类模式的目标基本相同,都是将数据和行为组合成逻辑单元。与类一样,模块也可以 "包含 "或 "访问 "其他模块的数据和行为,以达到合作的目的。
ES6 在本地 JS 语法中添加了模块语法形式,经典模块的主要标志是一个外部函数(至少运行一次),该函数返回模块的一个 "实例",该实例暴露了一个或多个函数,可以对模块实例的内部(隐藏)数据进行操作。
因为这种形式的模块只是一个函数,调用它就会产生一个模块 "实例",所以这些函数的另一种描述是 "模块工厂"。
类在对象实例上存储方法和数据,必须使用 this. 前缀才能访问。而在模块中,方法和数据是作为作用域中的标识符变量来访问的,不需要任何 this. 前缀。
对于类,实例的 "API "隐含在类的定义中,所有数据和方法都是公开的。使用模块工厂函数,可以显式地创建并返回一个对象,其中包含所有公开的方法,而任何数据或其他未引用的方法在工厂函数中都是私有的。
这种工厂函数形式还有其他变体,在 JS 中非常常见,你可能会在不同的 JS 程序中遇到这些形式:
AMD(异步模块定义)、UMD(通用模块定义)和 CommonJS(经典的 Node.js 风格模块)。它们之间的差异很小(不完全兼容),不过,所有这些形式都依赖于相同的基本原则。
ES 模块
ES 模块(ESM)是在 ES6 中引入 JS 语言的,其精神和目的与上述现有的经典模块基本相同,特别是考虑到了 AMD、UMD 和 CommonJS 的重要变化和用例。
不过,其实现方法有很大不同。
首先,没有定义模块的封装函数。封装上下文是一个文件。ESM 总是基于文件的;一个文件,一个模块。
其次,你不会明确地与模块的 "API "交互,而是使用导出关键字将变量或方法添加到模块的公共 API 定义中。如果模块中定义了某些内容,但没有导出,那么这些内容就会被隐藏起来(就像经典模块一样)。
第三,也许是与之前讨论过的模式最明显的不同之处,你不会 "实例化 "一个 ES 模块,你只是导入它来使用它的单个实例。实际上,ESM 程序中首次导入时只创建一个实例,其他所有导入都只是接收对该单个实例的引用。如果您的模块需要支持多个实例,您就必须在您的 ESM 定义中提供一个经典的模块式工厂函数来实现这一目的。
在我们正在运行的示例中,我们确实假设了多重实例化,因此下面这些代码段将同时混合使用 ESM 和经典模块。
如果 ES 模块需要支持多重实例化,它们可以在内部使用经典模块。或者,我们也可以在模块中公开一个类,而不是 create(..) 工厂函数,结果大致相同。不过,由于您已经在使用 ESM,我建议您坚持使用经典模块而不是类。如果您的模块只需要一个实例,您可以跳过额外的复杂层:直接导出其公共方法。
// 1. 类的定义与使用 // 定义类 class Person { constructor(name) { this.name = name; } greet() { console.log(`Hello, my name is ${this.name}`); } } // 实例化类 const person = new Person('Alice'); person.greet(); // 输出: Hello, my name is Alice // 2.经典模块工厂函数 function createPerson(name) { const privateName = name; return { greet() { console.log(`Hello, my name is ${privateName}`); } }; } // 3. 创建模块实例 const person = createPerson('Alice'); person.greet(); // 输出: Hello, my name is Alice // 定义模块 (person.js) export function createPerson(name) { return { greet() { console.log(`Hello, my name is ${name}`); } }; } // 使用模块 import { createPerson } from './person.js'; const person = createPerson('Alice'); person.greet(); // 输出: Hello, my name is Alice
章节三:挖掘 JS 的根源
迭代器
迭代器模式定义了一种称为 "迭代器 "的数据结构,它具有对底层数据源(比如查询结果行)的引用,并暴露了类似 next() 这样的方法。调用 next() 会返回下一条数据(即数据库查询中的 "记录 "或 "行")。
该协议定义了一个 next() 方法,其返回值是一个称为迭代器结果的对象;该对象具有 value 和 done 属性,其中 done 是一个布尔值,在底层数据源的迭代完成之前为 false。
消费迭代器
有了 ES6 的迭代协议,每次消耗一个数据源值,在每次 next() 调用后检查 done 是否为真以停止迭代是可行的。但这种方法需要大量手工操作,因此 ES6 还为这些迭代器的标准化消耗提供了多种机制(语法和 API)。
其中一种机制就是 for...of 循环:
// given an iterator of some data source: var it = /* .. */; // loop over its results one at a time for (let val of it) { console.log(`Iterator value: ${ val }`); } // Iterator value: .. // Iterator value: .. // ..
另一种常用于消费迭代器的机制是...操作符。这个操作符实际上有两种对称的形式:散布(spread)和休止(rest)(或者收集(gather))。展开形式是一个迭代器消费者。
// spread an iterator into an array, // with each iterated value occupying // an array element position. var vals = [ ...it ]; // spread an iterator into a function, // call with each iterated value // occupying an argument position. doSomethingUseful( ...it );
在这两种情况下,迭代器展开形式的 ...都遵循迭代器消耗协议(与 for...of 循环相同),从迭代器中获取所有可用值,并将它们放入(又称展开)接收上下文(数组、参数列表)中。
可迭代程序
从技术上讲,迭代器消耗协议是为消耗迭代器而定义的;迭代器是一个可以被迭代的值。
该协议自动从可迭代值创建一个迭代器实例,并消耗该迭代器实例直至其完成。这意味着一个可迭代值可以被消耗多次;每次都会创建并使用一个新的迭代器实例。
那么,我们在哪里可以找到可迭代程序呢?
ES6 将 JS 中的基本数据结构/集合类型定义为可迭代。其中包括字符串、数组、映射、集合等。
由于数组是可迭代的,因此我们可以通过 ...传播操作符使用迭代器消耗来浅层复制数组:
var arrCopy = [ ...arr ]; var greeting = "Hello world!"; var chars = [ ...greeting ]; chars; // [ "H", "e", "l", "l", "o", " ", // "w", "o", "r", "l", "d", "!" ] var buttonNames = new Map(); buttonNames.set(btn1,"Button 1"); buttonNames.set(btn2,"Button 2"); for (let [btn,btnName] of buttonNames) { btn.addEventListener("click",function onClick(){ console.log(`Clicked ${ btnName }`); }); }
在默认映射迭代的 for...of 循环中,我们使用 [btn,btnName] 语法(称为 "数组重组")将每个消耗的元组分解为相应的键/值对(btn1 /"Button 1"和 btn2 /"Button 2")。
JS 中的每个内置迭代表都有一个默认迭代,如果需要,您也可以选择更具体的迭代。比如
buttonNames.values()
arr.entries()
等。Closure
什么是闭包?闭包是指函数记住并继续访问其作用域之外的变量,即使函数在不同的作用域中执行也是如此。闭包在处理异步代码(如回调)时最为常见。
闭包是指函数记住并继续访问其作用域之外的变量,即使函数在不同的作用域中执行也是如此。