建立知识架构。知识架构要具备逻辑性完备性
追本溯源,从一个小点,刨根究底,后面一般都有庞大的体系。关注知识点的整体性。当知识、概念没有标准时就需要关注技术出现的背景、原始的论文、文章、作者的观点,这个过程称为考古
通过考古这种学习方式,我们可以理解一些看上去不合理的东西。

树立目标。知识架构的搭建不是为了列标准和手册,目标之一是理解原理和背景,第二个目标是将不方便查阅和记忆的内容整理好。很重要,明确并时刻秉持目标,能够不做多余的工作,达到目标效果。

js 的知识架构可以划分为文法、语义、运行时。文法又划分为词法和语法。词法包含关键字、直接量、运算符。语法包含表达式、语句、函数、对象、模块。语法与语义具有对应关系。运行时又划分为数据结构和执行过程。

语句表示程序要执行的操作。语句和表达式的区别在于,语句不返回结果,执行语句只是为了产生副作用,而表达式总是返回结果,而通常没有副作用。表达式产生一个值,可以写在任何需要一个值的地方。而语句是一个行为,例如循环和条件语句,一个程序基本上就是一个语句序列。

知识架构的建立需要逻辑性和完备性。所有的计算机编程语言都是以特定的文法,表达特定的语义,操作运行时这样一个过程。js 的任何的知识都逃不过这个架构范围,这就是知识架构的完备性。

文法

文法可以划分为词法和语法,这是编译原理的划分,同样具有逻辑完备性。语法和语义具有对应关系。
文法是编译原理中对语言的写法的一种规定,一般来说,文法分成词法和语法两种。词法规定了语言的最小语义单元:token,可以翻译成“标记”或者“词”。

词法

词法分析技术上可以使用 状态机或者正则表达式 来进行。JavaScript 的词法定义。JavaScript 源代码中的输入可以这样分类:

  • WhiteSpace 空白字符
  • LineTerminator 换行符
  • Comment 注释
  • Token 词
    • IdentifierName 标识符名称,变量名、关键字。
    • Punctuator 符号,运算符、大括号等符号。
    • NumericLiteral 数字直接量。
    • StringLiteral 字符串直接量,就是我们用单引号或者双引号引起来的直接量。
    • Template 字符串模板,用反引号` 括起来的直接量。

这个词法分类在逻辑上也是非常完备的,写的代码都没有超过这个范围。这个设计符合比较通用的编程语言设计方式。

不过,JavaScript 中有两个特别之处,首先是除法和正则表达式冲突问题,对词法分析来说,其实是没有办法处理的,所以 JavaScript 的解决方案是定义两组词法,然后靠语法分析传一个标志给词法分析器,让它来决定使用哪一套词法。
另一个特别设计是字符串模板,“ ${ } ”内部可以放任何 JavaScript 表达式代码,而这些代码是以“ } ” 结尾的,也就是说,这部分词法不允许出现“ } ”运算符。todo no 懂。
是否允许“ } ”的两种情况,与除法和正则表达式的两种情况相乘就是四种词法定义,所以你在 JavaScript 标准中,可以看到四种定义:InputElementDiv;InputElementRegExp;InputElementRegExpOrTemplateTail;InputElementTemplateTail。
为了解决两个问题,标准中还不得不把除法、正则表达式直接量和“ } ”从 token 中单独抽出来,用词上,也把原本的 Token 改为 CommonToken。

对一般的语言的词法分析过程来说,都会丢弃除了 token 之外的输入,但是对 JavaScript 来说,不太一样,换行符和注释还会影响语法分析过程

空白符 Whitespace
常见有缩进、垂直、分页、普通空格、非断行空格

换行符 LineTerminator
JavaScript 中只提供了 4 种字符作为换行符。
其中,是 U+000A,就是最正常换行符,在字符串中的\n。是 U+000D,这个字符真正意义上的“回车”,在字符串中是\r,在一部分 Windows 风格文本编辑器中,换行是两个字符\r\n。是 U+2028,是 Unicode 中的行分隔符。是 U+2029,是 Unicode 中的段落分隔符。

注释 Comment
js 的注释分为单行注释和多行注释两种。多行注释不能出现正斜杠符/。除了四种 LineTerminator 之外,所有字符都可以作为单行注释。需要注意,多行注释中是否包含换行符号,会对 JavaScript 语法产生影响,对于“no line terminator”规则来说,带换行的多行注释与换行符是等效的。

标识符 IndentifierName
IdentifierName可以以美元符“$”、下划线“_”或者 Unicode 字母开始,除了开始字符以外,IdentifierName中还可以使用 Unicode 中的连接标记、数字、以及连接符号。IdentifierName的任意字符可以使用 JavaScript 的 Unicode 转义写法,使用 Unicode 转义写法时,没有任何字符限制。
关键字也属于这个部分,在 JavaScript 中,关键字有await break case catch class const continue debugger default delete do else export extends finally for function if import instance of new return super switch this throw try typeof var void while with yield

符号 Punctuator
{ ( ) [ ] . ... ; , < > <= >= == != === !== + - * % ** ++ -- << >> >>> & | ^ ! ~ && || ? : = += -= *= %= **= <<= >>= >>>= &= |= ^= => / /= },总共就这么多,因为除法与正则冲突,还有字符串模板问题,所以 /、/=、} 也被单独作为符号。

数字直接量 NumericLiteral
js 规范中规定的数字直接量可以支持四种写法:十进制数、二进制整数、八进制整数和十六进制整数。十进制的 Number 可以带小数,小数点前后部分都可以省略,但是不能同时省略。

  1. 12.toString();//error

所以这段代码报错的原因是 12. 会被当作省略了小数点后面部分的数字,而单独看成一个整体,所以我们要想让点单独成为一个 token,就要加入空格。
数字直接量还支持科学计数法,例如:10.24E+210.24e-210.24e2。这里 e 后面的部分,只允许使用整数。

字符串直接量 StringLiteral
单双引号的区别仅仅在于写法,在双引号字符串直接量中,双引号必须转义,在单引号字符串直接量中,单引号必须转义。字符串中其他必须转义的字符是 \ 和所有换行符。
第一种是单字符转义。 即一个反斜杠 \ 后面跟一个字符这种形式。

正则表达式直接量 RegularExpressionLiteral
正则表达式由 Body 和 Flags 两部分组成,例如:/RegularExpressionBody/g 其中 Body 部分至少有一个字符,第一个字符不能是 ,因为_ /_ 跟多行注释有词法冲突。

字符串模板 Template
从语法结构上,Template 是个整体,其中的${ }是并列关系。但是实际上,在 JavaScript 词法中,${} 是被拆开分析的。

  1. a${b}c${d}e 被拆分为 a${、b、}c${、d、}e

它被拆成了五个部分:a${ 这个被称为模板头、}c${ 被称为模板中段、}e 被称为模板尾、b 和 d 都是普通标识符。
实际上,这里的词法分析过程已经跟语法分析深度耦合了。不过我们学习的时候,大可不必按照标准和引擎工程师这样去理解,可以认为模板就是一个由反引号括起来的、可以在中间插入代码的字符串。
模板支持添加处理函数的写法,带函数的模板字符串,这时模板的各段会被拆开,传递给函数当参数

  1. function f(){ console.log(arguments);}
  2. var a = "world";
  3. f`Hello ${a}!`; // [["Hello", "!"], world]

模板字符串不需要关心大多数字符的转义,但是至少 ${ 和 还是需要处理的。

语法

语法和语义是对应关系。js 中语法主要包含模块、函数、对象、语句、表达式。模块与脚本、函数是全局的语法结构。模块、脚本、函数都是由语句组成的。还有两种全局机制,预处理、指令序言。声明语句和严格模式息息相关。语句分为声明语句与普通语句。表达式也有很多级。

行尾使用分号的风格来自于 Java,也来自于 C 语言和 C++,这一设计最初是为了降低编译器的工作负担。从今天的角度来看,行尾使用分号其实是一种语法噪音,恰好 JavaScript 语言又提供了相对可用的分号自动补全规则。
自动插入分号规则其实独立于所有的语法产生式定义,它的规则说起来非常简单,只有三条。

  • 要有换行符,且下一个符号是不符合语法的,那么就插入分号。
  • 有换行符,且语法中规定此处不能有换行符,那么就插入分号。
  • 源代码结束处,不能形成完整的脚本或者模块结构,那么就插入分号。
    1. let a = 1
    2. void function(a){
    3. console.log(a);
    4. }(a);
    5. // 第一行的结尾处有换行符,接下来 void 关键字接在 1 之后是不合法的。命中了我们的第一条规则
    不换行规则,自动插入分号规则的第二条跟它是强相关。 ```javascript // 意图上显然是形成两个 IIFE,要么在末尾加分号,妖媚在行首加分号。 (function(a){ console.log(a); })() (function(a){ console.log(a); })()

function f(){ return/ This is a return value. /1; } f();

  1. ![image.png](https://cdn.nlark.com/yuque/0/2022/png/26313388/1650172234489-dd93c659-1518-4361-ad59-b9ff1f7c39eb.png#clientId=u438cfeaa-b443-4&crop=0&crop=0.2096&crop=1&crop=0.8662&from=paste&height=403&id=ue01cd57f&margin=%5Bobject%20Object%5D&name=image.png&originHeight=598&originWidth=1008&originalType=url&ratio=1&rotation=0&showTitle=false&size=272296&status=done&style=none&taskId=ub30a9582-9bf5-416d-89c7-d1aeb298dc6&title=&width=680)
  2. ```javascript
  3. var a = 1, b = 1
  4. a
  5. ++
  6. b
  7. console.log(a, b);// 1 2
  8. // 为什么是b自增了?
  9. // todo 不是很理解winter的字面意思。我理解的是自增自减运算符不会添加换行符,
  10. // 自增自减运算符会作为下一行代码的前缀操作符,一起解析。

no LineTerminator here 规则的存在,多数情况是为了保证自动插入分号行为是符合预期的,但是令人遗憾的是,JavaScript 在设计的最初,遗漏了一些重要的情况,所以有一些不符合预期的情况出现,需要我们格外注意。
不写分号容易造成错误的4种情况,以括号开头的语句,以数组开头的语句,以正则表达式开头的语句,以 Template 开头的语句。
几年前,各种各样的书大致上都推荐你加分号。 几年前,曾经由于构建工具有一些问题,导致不加分号可能会出问题。 jquery依然留着分号,vue源码不用分号。 尤雨溪曾经在知乎说:真正会导致上下行解析出问题的 token 有 5 个:括号,方括号,正则开头的斜杠,加号,减号。我还从没见过实际代码中用正则、加号、减号作为行首的情况,所以总结下来就是一句话:一行开头是括号或者方括号的时候加上分号就可以了,其他时候全部不需要。 哦当然再加个反引号。

模块与脚本

JavaScript 有两种源文件,一种叫做脚本,一种叫做模块。这个区分是在 ES6 引入了模块机制开始的,在 ES5 和之前的版本中,就只有一种源文件类型(就只有脚本)。
脚本是可以由浏览器或者 node 环境引入执行的,而模块只能由 JavaScript 代码用 import 引入执行。
从概念上,我们可以认为脚本具有主动性的 JavaScript 代码段,是控制宿主完成一定任务的代码;而模块是被动性的 JavaScript 代码段,是等待被调用的库。
从语法产生式上做一些对比,实际上模块和脚本之间的区别仅仅在于是否包含 import 和 export。脚本是一种兼容之前的版本的定义,在这个模式下,没有 import 就不需要处理加载“.js”文件问题。todo 这句话不知如何解释,是要处理模块机制的意思吗?

脚本中只包含语句。模块中可以包含三种内容:import 声明,export 声明和语句。
import 声明有两种用法,一个是直接 import 一个模块,另一个是带 from 的 import,它能引入模块里的一些信息。

  1. // 第一种方式还可以跟后两种组合使用。
  2. import d, {a as x, modify} from "./a.js"
  3. import d, * as x from "./a.js"

语法要求不带 as 的默认值永远在最前。
export 声明导出变量的方式有两种,一种是独立使用 export 声明,另一种是直接在声明型语句前添加 export 关键字。
独立使用 export 声明就是一个 export 关键字加上变量名列表export {a, b, c};。也可以直接在声明型语句前添加 export 关键字,这里的 export 可以加在任何声明性质的语句之前,var / function (含 async 和 generator) / class / let / const。
export 关键字导出的是变量的值,而不是变量,所以模块内的变量改变了指向,也不会影响引用了变量值的那些模块。
export 导出不能写在 import 之前。但是 export … from … 可以写在最前面。相当于对外转发接口,当前模块不能使用。

函数体

函数体跟脚本和模块有一定的相似之处。通过 setTimeout / promise.then 注册了一个函数给宿主和引擎,定时时间到了或状态改变了,就会执行这个函数。宿主或引擎就会为这样的函数创建宏任务或微任务。
学习了脚本和模块的语法结构后,在微任务,宏任务中可能执行的代码就包括“脚本、模块、函数体”。
函数体就比脚本和模块多了return语句。函数的语法结构主要有funciton / () => / async function / function / async function ,对应的还有其他的关键字可以用,yield / await / return

js 语法有两个至关重要的全局机制,预处理、指令序言。预处理机制与 var 等声明类语句的行为息息相关。而指令序言与严格模式相关。

预处理机制

JavaScript 执行前,会对脚本、模块和函数体中的语句进行预处理。预处理过程将会提前处理 var、函数声明、class、const 和 let 这些语句,以确定其中变量的意义。预处理阶段只认脚本、模块和函数体三种语法结构。
var 声明预处理,会穿透控制语句、with 作用域。
function 声明,在全局(脚本、模块、函数体),会将声明和定义一起提前处理,如果在语句内,声明仍会穿透,但不会提前赋值。严格模式下,function 声明是块级函数声明,类似于let/const。es5 中声明块级函数会报错。
class 声明,和前两种情况都不一样。会进行预处理,但是不会穿透控制语句这些语法结构,全局下才会声明提升。

  1. var c = 1;
  2. function foo() {
  3. console.log(c);
  4. class c{} // 引用错误,不能在c初始化之前访问。注释这句,就可以访问到外层定义的c。
  5. // 可以证明 class 声明是进行了预处理的。
  6. }
  7. foo();

todo 屏蔽外部变量如何实现。

指令序言机制

脚本和模块都支持一种特别的语法,叫做指令序言(Directive Prologs)。
“use strict”是 JavaScript 标准中规定的唯一一种指令序言,但是设计指令序言的目的是,留给 JavaScript 的引擎和实现者一些统一的表达方式,在静态扫描时指定 JavaScript 代码的一些特性。js 的指令序言只能出现在脚本、模块、函数体 的最前面,否则就不叫指令序言。

语句

JavaScript 遵循了一般编程语言的“语句 - 表达式”结构,多数编程语言都是这样设计的。模块或者函数都是由语句列表构成的。在 JavaScript 标准中,把语句分成了两种:声明语句和普通语句。
普通语句包含语句块、空语句、表达式语句、条件控制语句(if、else if、else、switch)、循环控制语句(while、do-while、for、for…of、for…in、for await…of)、跳转控制语句(return、break、continue)、with、异常处理语句(throw、try)、debugger
声明型语句包含var、let、const、function、class
声明型语句跟普通语句最大区别就是声明型语句响应预处理过程,普通语句只有执行过程。

语句块的意义和好处在于:让我们可以把多行语句视为同一行语句。
for…of 背后的机制是 iterator 机制。
for await…of 可以用于异步生成器函数。

  1. function sleep(duration) {
  2. return new Promise(function(resolve, reject) {
  3. setTimeout(resolve,duration);
  4. })
  5. }
  6. async function* foo(){
  7. i = 0;
  8. while(true) {
  9. await sleep(1000);
  10. if (i < 4) yield i++;
  11. }
  12. }
  13. for await(let e of foo()) {
  14. console.log(e);
  15. }
  16. console.log('end'); // 为什么end不打印?todo

let、const 声明会预处理,但是屏蔽了外部同名变量。
class 声明特征与 let、const 类似。class 内定义的方法都是 strict 模式,对获取 this 值有影响。
todo 请你找出所有具有 Symbol.iterator 的原生对象,并且看看它们的 for of 遍历行为。

表达式

js 中很多语句类型,但是,其实最终产生执行效果的语句不多。事实上,真正能干活的就只有表达式语句,其它语句的作用都是产生各种结构,来控制表达式语句执行,或者改变表达式语句的意义。
表达式语句实际上就是一个表达式,它是由运算符连接变量或者直接量构成的。一般来说,我们的表达式语句要么是函数调用,要么是赋值,要么是自增、自减,否则表达式计算的结果没有任何意义。
从粒度最小到粒度最大了解一下都有哪些表达式。

表达式的原子项:Primary Expression。它是表达式的最小单位,它所涉及的语法结构也是优先级最高的。Primary Expression 包含了各种“直接量”,直接量就是直接用某种语法写出来的具有特定类型的值。

  1. "abc";
  2. 123;
  3. null;
  4. true;
  5. false;
  6. // 能够直接量的形式定义对象,针对函数、类、数组、正则表达式等特殊对象类型,
  7. // JavaScript 提供了语法层面的支持。
  8. ({});
  9. (function(){});
  10. (class{ });
  11. [];
  12. /abc/g;
  13. // Primary Expression 还可以是 this 或者变量,在语法上,把变量称作“标识符引用”。
  14. this;
  15. myVar;

任何表达式加上圆括号,都被认为是 Primary Expression,这个机制使得圆括号成为改变运算优先顺序的手段。

Member Expression 通常是用于访问对象成员的。访问对象成员有两种方法,. 运算符和方括号。另外 js 标准加入了两种语法结构,也算作成员表达式。一种是带函数的模板,另一个是带参数的 new 运算。不带参数的 new 运算优先级更低,不属于成员表达式。

new 表达式,new 加上成员表达式就是了。不加 new 也可以构成 New Expression,JavaScript 中默认独立的高优先级表达式都可以构成低优先级表达式。

  1. new new Cls(1);// 是 new (new Cls(1)) 还是 new (new Cls)(1) ?
  2. class Cls{
  3. constructor(n){
  4. console.log("cls", n);
  5. return class {
  6. constructor(n) {
  7. console.log("returned", n);
  8. }
  9. }
  10. }
  11. }
  12. new (new Cls(1));

Member Expression 还能构成 Call Expression 函数调用表达式。它的基本形式是 Member Expression 后加一个括号里的参数列表,或者我们可以用上 super 关键字代替 Member Expression。

New Expression 和 Call Expression 统称 LeftHandSideExpression,左值表达式。左值表达式最经典的用法是用于构成赋值表达式,凡是需要“可以被修改的变量”的位置,都能见到它的身影。

  1. a() = b; // 这些的语法是符合左值表达式的语义的。只不过得 js 原生函数返返回的值规定了不能赋值。
  2. a().c = b;

AssignmentExpression 赋值表达式,最基本的当然是使用等号赋值。除此之外,还有=、/=、%=、+=、-=、<<=、>>=、>>>=、&=、^=、|=、*=

  1. a = b = c = d // 这样的连续赋值是有结合的。
  2. a = (b = (c = d))

Expression 表达式,更高一级,赋值表达式可以构成 Expression 表达式的一部分。在 JavaScript 中,表达式就是用逗号运算符连接的赋值表达式。

  1. a = b, b = 1, null;

在很多场合,都不允许使用带逗号的表达式,比如我们在前面课程中提到,export 后只能跟赋值表达式,意思就是表达式中不能含有逗号。
todo 把今天讲到的所有运算符按优先级排列成一个表格。

RightHandSideExpression 右值表达式,出现在赋值表达式右边。在 JavaScript 标准中,规定了在等号右边表达式叫做条件表达式(ConditionalExpression)。JavaScript 标准也规定了左值表达式同时都是条件表达式(也就是右值表达式),此外,左值表达式也可以通过跟一定的运算符组合,逐级构成更复杂的结构,直到成为右值表达式。
右值表达式可以理解为以左值表达式为最小单位开始构成的。

更新表达式 UpdateExpression
左值表达式搭配 ++ — 运算符,可以形成更新表达式。分为前后自增,前后自减一共四种。--a; ++a; a--; a++

一元运算表达式 UnaryExpression
更新表达式搭配一元运算符,可以形成一元运算表达式。

  1. // 我以为必须有自增、自建运算符才叫更新表达式,看来是理解错了。todo
  2. // 那么更新表达式到底有哪些呢?todo
  3. delete a.b;
  4. void a;
  5. typeof a;
  6. - a;
  7. ~ a;
  8. ! a;
  9. await a;

乘方表达式 ExponentiationExpression 也是由更新表达式构成的。它使用号。 运算是右结合的。

  1. 4 ** 3 **2
  2. 4 ** (3 ** 2)

乘方表达式 MultiplicativeExpression 可以构成乘法表达式,用乘号或者除号、取余符号连接就可以了。
加法表达式是由乘法表达式用加号或者减号连接构成的。
移位表达式由加法表达式构成,移位是一种位运算,分成三种:<< 向左移位、>> 向右移位、>>> 无符号向右移位。js 的位运算只有低 32 位会参与,二进制操作整数并不能提高性能,移位运算这里也仅仅作为一种数学运算存在,这些运算存在的意义也仅仅是照顾 C 系语言用户的习惯了。
移位表达式可以构成关系表达式,这里的关系表达式就是大于、小于、大于等于、小于等于等运算符号连接,统称为关系运算。
在语法上,相等表达式是由关系表达式用相等比较运算符(如 ==)连接构成的,==、!=、===、!==。其中需要注意的是==,在类型不相同时,undefined与null相等,String、Boolean都会转换成Number,对象会转换成原始值。
位运算表达式含有三种:按位与表达式 BitwiseANDExpression按位异或表达式 BitwiseANDExpression按位或表达式 BitwiseORExpression。通过异或原地置换两个整数。

  1. var a = 1, b = 2;
  2. a = a ^ b;
  3. b = a ^ b;
  4. a = a ^ b;
  5. console.log(a, b);// 2 1

逻辑与表达式由按位或表达式经过逻辑与运算符连接构成,逻辑或表达式则由逻辑与表达式经逻辑或运算符连接构成。这里需要注意的是,这两种表达式都不会做类型转换。
条件表达式由逻辑或表达式和条件运算符构成,条件运算符又称三目运算符,它有三个部分,由两个运算符?和:配合使用。条件表达式实际上就是 JavaScript 中的右值表达式了 RightHandSideExpression,是可以放到赋值运算后面的表达式。
todo 总结下 JavaScript 中所有的运算符优先级和结合性。

编译器练习

写一个四则运算的解释器。根据编译原理,将问题拆解:

  • 定义四则运算:产出四则运算的词法定义和语法定义。
  • 词法分析:把输入的字符串流变成 token。
  • 语法分析:把 token 变成抽象语法树 AST。
  • 解释执行:后序遍历 AST,执行得出结果。

定义四则运算

四则运算就是加减乘除四种运算。首先定义词法,四则运算里面只有数字和运算符,所以定义很简单,但是还要注意空格和换行符,所以词法定义大概是下面这样的。

  • TokenNumber: 1 2 3 4 5 6 7 8 9 0 的组合。
  • Operator: + 、-、 *、 / 之一。
  • Whitespace:
  • LineTerminator:

这里我们对空白和换行符没有任何的处理,所以词法分析阶段会直接丢弃。

定义语法,语法定义多数采用 BNF,但是其实大家写起来都是乱写的,比如 JavaScript 标准里面就是一种跟 BNF 类似的自创语法。不过语法定义的核心思想不会变,都是几种结构的组合产生一个新的结构,所以语法定义也叫语法产生式。
叫语法产生式。因为加减乘除有优先级,所以我们可以认为加法是由若干个乘法再由加号或者减号连接成的:

  1. <Expression> ::=
  2. <AdditiveExpression><EOF>
  3. <AdditiveExpression> ::=
  4. <MultiplicativeExpression>
  5. |<AdditiveExpression><+><MultiplicativeExpression>
  6. |<AdditiveExpression><-><MultiplicativeExpression>

todo bnf https://www.zhihu.com/question/27051306

词法分析:状态机

字符流变成 token 流。词法分析有两种方案,一种是状态机,一种是正则表达式,它们是等效的。根据分析,我们可能产生四种输入元素,其中只有两种 token,我们状态机的第一个状态就是根据第一个输入字符来判断进入了哪种状态。具体代码看极客时间教程,现阶段没时间练习,就不关注代码实现。

语法分析:LL

LL 语法分析根据每一个产生式来写一个函数。
实际上一般编译代码都应该支持流式处理。

解释执行

得到了 AST 之后,最困难的一步我们已经解决了。这里不对这颗树做任何的优化和精简了,那么接下来,直接进入执行阶段。我们只需要对这个树做遍历操作执行即可。根据不同的节点类型和其它信息,写 if 分别处理即可。

语义

语义的大部分内容在运行时中讲解。记住语义和语法有对应关系。看完了课程,应该知道语义到底是什么。@todo

运行时

程序=算法+数据结构,将运行时划分为数据结构和执行过程。数据结构包含数据类型和实例。数据类型包括7种基本数据类型和7种语言类型,实例就是内置对象。@todo 还有哪7种语言类型?这里 winter 讲的7种语言类型是 number/ string/ boolean/ symbol/ null/ undefined/ object

执行过程的话,要从大结构到小结构的角度来剖析,从最顶层的程序与模块、事件循环、微任务的执行到函数的执行、语句级的执行。

运行时类型

语言类型

学习目标,理解运行时类型的含义,一些奇异现象的原因,类型转换,类型判断。

js的变量没有类型,值才拥有类型。js 有 7 种原始类型有 null、undefined、boolean、string、symbol、number、bigInt,symbol 是 es6 新增的,bigInt 原始类型是 ES10 新增的。
有 8 种语言类型,除了 7 种原始类型在内,还有 Object 类型。语言类型又叫运行时类型,在 js 实际执行过程中用到的数据类型。所有的数据类型都属于这 8 种。从变量、返回值、参数到表达式中间结果,任何 js 代码运行过程中产生的数据,都具有运行时类型。
@todo Object.create(null) 与 字面量创建对象、Object创建对象有什么区别?对象是如何创建的,从操作系统来看。

Null

typeof null 等于 object
null不是对象,但是 typeof null 等于 “object”。有两个角度可以解释这个奇异现象。
从实现角度来看,最初 js 是用32位比特来保存值,通过值的前3位来判断数据类型,1整数、000引用类型、010浮点数、100字符串、110布尔值,还有两个特殊值负2的30次方表示 undefined、null 用全0表示,低三位也是000,并且在 JS_TypeOfValue 里没有先过滤 null,所以判断为了object。
为什么要用32位比特存值,现在还是32位吗?@todo
todo v8引擎中好像不是这样。
从表示含义这个角度来看,null 表示空对象,所以也可以显示为object,所以没有在判断时过滤 null。
(1 封私信 / 49 条消息) 为什么JavaScript里面typeof(null)的值是”object”? - 知乎 (zhihu.com)

Undefined
用 void 0 替代 undefined
undefined 类型表示未定义,变量声明了,但是没有赋值,变量在赋值前都是 undefined 这个值。undefined 类型只有一个值 undefined。js 中实现了全局变量 undefined,这个变量的值是 undefined,可以用这个变量来表达这个值。还可以使用 void 关键字来把任何一个表达式变成这个值。

使用 void 0 代替 undefined 的原因就是 undefined 被设计成一个全量变量,而不是关键字,这个值可能被篡改。为了防止这种情况,才用 void 运算符来获取 undefined 值。
@todo void 是什么,有什么用? void 是一元运算符,对给定的表达是进行求值,然后返回 undefined。

与 undefined 不同,null 类型表示定义了,但是为空。null 类型只有一个值 null,通过关键字 null 获取,所以在任何代码中可以放心使用关键字 null 来获取 null 值。

undeclared 表示变量还没有被声明,在作用域中还没有这个变量。

为什么 typeof 未声明的变量也为 undefined
typeof在访问未声明变量的时候不会报引用错误,这是 typeof 的一个安全防范机制,一种变相的友好保护,避免了发生报错,可以用来检测这个变量是否声明。
至于为什么显示 undefined,那就是 js 的一种处理方式了,确实会让人将 undeclared 误认为是 undefiend。浏览器的报错也是,容易让人误导。而对象的属性没有声明定义,这时候访问是不会报错的。跨两级访问不存在的属性还是为报错,比如只声明了对象a,却访问a.b.b。但是报的错是类型错误

String

字符串最大长度是 2 的 53 次方 - 1
String 用来显示文本数据,有最大长度 2^53 - 1,字符串的长度受到下标限制,所以理论上的长度极限就是这个最大安全整数。
js 中字符串是采用 utf-16 编码方式。utf-16 编码方式采用现在国际标志字符集 unicode,一个 unicode 码点表示一个字符,码点通常用 u+??? 表示,??? 表示 16 进制的码点值,u+0000-u+ffff 的码点是基本字符区域。utf 决定了 unicode 码点在计算机中的表示方法。所以 js 中一个英文字符占据2个字节,中文字符也是。只有某些字符和汉字占据4个字节。

但是为什么是 2 的 53 次方 - 1 呢?@todo 2的53次方-1是最大安全整数,2的53次方开始就会损失精度。下标能表示的最大整数范围就是这个了。

超过BMP区域的字符,访问它的 length 属性,会打印2。因为超过dmp区域的字符使用 4 个字节来表示的。在es6之后,可以用Array.from().length来获取正确的字符长度,比如一个字符的 length 属性就应该是 1,管你是用几个节点来存储的。

  1. '𝌆';
  2. console.log('𝌆'.length);//2
  3. console.log('𝌆' === '\uD834\uDF06');//true '𝌆'的码点值是 '\u1D306'。
  4. // 这里还有个小插曲,用'\u1D306'是没法直接打印出这个字符的。'\uD834\uDF06'是经过计算的值。
  5. console.log(Array.from('𝌆').length);//1

https://www.yuque.com/heyao-uthnw/gr9otg/vppql3#Number

Number

0.1 + 0.2 不等于 0.3
计算机中能表示的数值顶多是数学中的有理数,而且在计算机中数字有精度限制。js 的 number 类型可以表示 18437736874454810627(2 的 64 次方 - 2 的 53 次方 + 3)个值。

js 中 number 类型符合ieee双精度浮点数规则,为了额外的使用场景(为了让除0不报错,引入了无穷大概念),表示几个额外的情况:
NaN,用 9007199254740990 来表示,实际包含了 2 的 53 次方 + 2个数。@todo这里有一个概念,NaN其实是表示一群数值,也就是js不能表示的数字。
Infinity,无穷大。-Infinity,负无穷大。
+0,-0,在除法中需要留意,除以 -0 得到负无穷大,除以 +0 得到无穷大,检测的方式就是 1/x 是 Infinity 还是 -Infinity。

根据双精度浮点数的定义,第 63 位表示正负,第 62-52 表示指数,第 51-0 位表示有效位。Number 类型中有效的整数范围是 0x1fffffffffffff 至 0x1fffffffffffff,也就是正负 2 的 53 次方 - 1,9007199254740991。

  1. // 双精度浮点数规则,保存小数,会无限循环下去,然而位数被截断后就存在精度限制,
  2. // 无法准备表示部分小数。0011一直循环。
  3. // 0.1 转换为二进制
  4. // 0.000110011001100110011001100110011001100110011001100110011001100110011001100110011...
  5. // 转换为科学计数法
  6. // 2^-4 * 1.100110011001100110011...
  7. // 推出双精度浮点数计算公式
  8. // (-1)^0 * 2^4 * 100110011001100110011...
  9. // 如果不够52位,用0补齐,超过52位,产生了截断。截断时第53位为1,会进位,所以最低两位为10。
  10. // 1001100110011001100...110011010。
  11. 0.1 = 2 ^ -4 * 1.10011(0011)
  12. console.log(0.1 + 0.2 === 0.3); // false

浮点数运算的精度问题导致了等式左右两边并不严格相等,而是相差微小的值。所以比较浮点数计算结果应该使用 js 提供的最小精度值。

  1. // 使用 Number.ESPLION
  2. console.log(Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON);// true
  3. // 可以用`toFixed`解决。
  4. (0.1+0.2).toFixed(10); // '0.3000000000'

@todo这一篇很细。javascript 双精度浮点数剖析 - 知乎 (zhihu.com)
当指数偏移值不为0时,《重学前端》 - 图1
当指数偏移值为0时,《重学前端》 - 图2
以 0.1 为例,转化为科学计数法之后,指数为 -4,在计算指数偏移值的时候会加上一个固定的偏移值,这个偏移值是 1023。采用第一个公式,计算出来的指数偏移值 e 为 1019,二进制表示为 111 1111 0111。


Symbol

es6 新类型,表示非字符串的对象属性的集合。在 es6 规范中,整个对象系统都用 symbol 重塑了。

可以使用全局函数 Symbol 创建 Symbol 类型。标准中提到的一些公开符号,挂在了 Symbol 全局函数的属性上。

公开符号
有两种写法,Symbol. 或者 @@。自定义操作的时候好像还是用Symbol.方式。Symbol.iterator、Symbol.toStringTag、Symbol.hasInstance、Symbol.toPrimitive。

Object

Object 是 js 的核心机制之一,定义为属性的集合。属性分为数据属性和访问器属性,都是键值对结构,键可以是字符串和Symbol类型。从两个角度学习 Object,类型的角度和对象机制。

类型的角度
c++、java中每个类就表示一种类型,而 js 中类是运行时对象的私有属性,js中无法自定义类型。
@todo留有疑问,class本质不是函数吗,[[class]]才是私有属性,js通过原型不是可以创建子类型吗。

装箱操作
js 中的4个基本类型有对应的对象类型,Number/ String/ Boolean/ Symbol,必须要分清原始值类型与对应的对象类型。
Number/ String/ Boolean构造函数有两个作用,第一,搭配new操作符用来创建实例,第二,直接调用,用来强制类型转换。
Symbol 函数比较特殊,不能用 new 调用,但是这个函数仍然是Symbol对象的构造函数。在Symbol原型上添加方法,Symbol类型的变量就可以直接调用。

  1. Symbol.prototype.test = function() {console.log('存在Symbol对象');}
  2. var s = Symbol(1);
  3. s.test();// 进行了装箱操作,将Symbol原始值类型转换为了对应的Symbol对象,可以调用原型方法。

运算符 . 提供了装箱操作,会根据原始值的类型来创建临时对象,这样就能在基本类型上调用对应对象的方法。js 重载了运算符 .,可以将基本类型转换为对应的对象,也可以显示调用Object完成装箱。利用装箱机制,可以创建Symbol对象。

  1. // 显示调用Object函数
  2. var so = Object(Symbol(1));
  3. console.log(typeof so); // object
  4. console.log(so instanceof Symbol);//true
  5. console.log(Object.prototype.toString.call(so));//'[object Symbol]'
  6. console.log(so.constructor === Symbol);//true
  7. console.log(so.__proto__ === Symbol.prototype);//true
  8. // 调用call方法,强迫装箱。这是因为call方法内部会对传入的执行上下文参数进行强制转换。
  9. // 同时将函数内部的this绑定为传入的执行上下文对象
  10. var so = (function(){return this;}).call(Symbol(1));

装箱操作会频繁创建临时对象,在性能要求高的场景下,应该尽量避免。

类型转换

类型转换在js中发生的很频繁,因为js是弱类型语言,大部分运算都会先进行类型转换,大部分的类型转换都比较正常,但是一些奇异的现象会导致判断失误。所以有必要理解类型转换的严格定义。

大部分的类型转换有Null/ Undefined/ Boolean/ Number/ String/ Symbol/ Objecct转换为Boolean/ Number/ String/ Object
转换成Boolean比较简单,js只有6个假值,null/undefined/“”/0/false/NaN,除了这几个值,其余的转换过来都是真值。也有一种例外,那就是假值对象,这种对象转换为布尔值是假值。
Null/Undefined/Boolean转Number,结果是0/ NaN/ 1或 0。转String,结果是“null”/ “undefined”/ “true”/ “false”

较为复杂的就是数字与字符串之间的转换,以及对象与基本类型之间的转换。要理清对象与基本类型之间的转换,很重要的一点是理清对象要转换的目标基本类型是什么。
js提供了4种类型转换的抽象操作 [[ToPrimitive]] / [[ToNumber]] / [[ToString]] / [[ToBoolean]]
[[ToNumber]] 操作负责将传入值转换为数值,如果传入的是对象,那么先对对象进行 ToPrimitive 操作。Number 函数参照这样的规则。
其中字符串转换到数字,存在一个语法结构,二进制、八进制、十进制、十六进制、科学计数法这类字符串。
但是parseInt / parseFloat不支持科学计数法字符串到数字的转换。而且再不传入第二个参数的情况下,parseInt只支持16进制前缀0x,会忽略非数字字符,不支持科学计数法,在一些老浏览器中,parseInt还支持0开头的八进制前缀,这很容易导致错误,所以在使用parseInt时最好传入第二个参数。parseFloat会将字符串作为十进制转换,不会引入其他进制。所以多数情况下 NumberparseInt / parseFloat 好用。
image.png
[[ToString]] 操作负责将传入值转换为字符串,如果传入的是对象,那么先对对象进行 ToPrimitive 操作。String 函数参照一样的规则。
其中数字转换为字符串,会转换为十进制表示的字符串。如果数值过大或国小,会用科学计数法表示,为了保证产生的字符串不会过长。

拆箱操作
[[ToPrimitive]] 操作的执行过程如下:

  • 调用ToPrimitive(inputValue[, inputType = Number),inputType可以是String/Number,如果是inputValue原始类型,则直接返回inputValue,否则根据inputType,调用valueOf或toString。
  • inputType是Number,先调用valueOf方法,如果得到原始类型值,则返回,得不到原始值再调用toString方法。否则先调用toString方法,再调用valueOf方法。注:inputType 默认是 number。但是日期对象的 inputType 是 String,就会先调用 toString 方法。
  • 如果都得不到基本类型的值,就抛出类型错误;
    1. var a = {
    2. valueOf() {
    3. return 'a';
    4. },
    5. toString() {
    6. return 'a';
    7. },
    8. [Symbol.toPrimitive]() {
    9. return 1;
    10. }
    11. }
    12. console.log(a + 1); // 2
    有一个格外需要注意的点假值对象,对假值对象进行转换布尔操作,会得到结果false。注意区分假值对象与封装了假值的对象,例如var s = new String(‘’)、document.all

运算符中的类型转换
  • 加法运算符中任一个操作数为字符串,则对另一个操作数进行 [[ToString]] 操作转换为字符串。否则就使用 [[ToNumber]] 操作转换为数字。如果操作数中有对象,先进行 ToPrimitive 操作。
    1. 'sdf' + 2; // 有字符串优先转换为字符串。
    2. 1 + 1 // 2
    3. true + true // 2 // 没有字符串,就转换为数字
    4. [] + {} // '[object Object]'

其余三个运算符 * / - 应该是将两者都转换为数字类型进行计算,ES规范看不明白,没找到与我想法相符的地方。

比较运算符 > >= < <=。如果都是字符串,那么比较unicode值;其余情况都转换为数字再比较。如果有NaNundefined参与比较时返回false

相等运算符== 、全等运算符===。全等不会转换类型,而且 null 和 undefined 不相等。其余没什么区别。相等运算符判断流程:如果类型相同,原始值就比较值,引用就比较指向的是否是同一个地址。都是数值类型的时候如果有NaN参与,返回false。如果类型不同,两者为undefined和null时相等,字符串和布尔值都转换为数值,对象转换为原始值再比较。

两个都是对象时不会调用自定义的Symbol.toPrimitive操作。

  • 小测试:console.log([] == ![]);?
    因为![]false,注意了javascript只有6个假值可以通过!转换为true

Object.is 也不会进行类型转换,但是两个NaN算相等,两个0符号相同才算相等。0的符号是正的。

@todo实践,如何实现一个字符串到数字的转换方法,不使用Number/parseInt/parseFloat。


类型判断

typeof可以正确判断数据类型吗
typeof 不能正确判断 Null 类型,可以正确判断除了null之外的原始数据类型。可以准确判断函数和普通对象。
函数在运行时类型表现为Object,为什么 typeof 结果为function。@todo

instanceof
instanceof 是通过对象原型构造函数的原型对象来判断的,一层一层的往上查找,直到原型为null 或找到匹配的构造函数。但是 instnceof 不能判断原始类型,可以自定义@@hasInstance进行改造。可以判断对象类型,以及自定义对象类型。

Object.prototype.toString.call
这个方法可以比 instanceof 更加准确判断对象类型,但是需要配合 typeof 区分基本类型和对象类型。

Object.prototype.toString 可以查看对象内部属性[[Class]]。一般来说对象内部的[[Class]]属性和这个对象的构造函数相对应。原始类型的话,null 和 undefined 没法调用,而其他原始值调用 toString 会返回自身值。当用call调用的时候,call 函数内部会包装传进来的上下文参数,原始类型的值也会转换为对象,比如 Object.prototype.toString.call(1) 会打印 [‘object Number’],而null和undefined调用的时候比较特殊,会打印 [‘object Null’] 和 [‘object Undefined’]。

可以判断内置引用数据类型,但是会进行装箱写操作,产生临时对象。并且没办法区分自定义对象,也没法区分原始数据类型还是原始数据类型的封装对象。所以一般用来判断内置的对象类型。

必须使用call调用Object原型对象的toString方法,不能用对象继承来的toString方法的原因是,函数和数组、日期这些对象的构造函数都自定义了toString。所以函数、数组、日期这些对象调用toString是不同的输出,还是需要调用Object原型对象上的toStirng,并且用call将自身作为上下文传递进去。

object 机制

js 是面向对象还是基于对象?
为什么js一直有对象的概念,却在es6才提出类?
为什么js对象可以自由添加属性,但是其他语言不行?
学习目标,理解面向对象,js中的面向对象,对象动态添加属性的原理。
@todo Grady Booch《面向对象分析与设计》
收益,第三点自由添加属性的原理仍不清楚。@todo
@todo实践,其他面向对象语言又是基于什么系统实现的呢?除了类和原型,还有什么系统?在实际应用中是应用function还是class呢?为什么?如何用functin实现class新语法?

对象是一种更接近人类思维模式的一种编程规范。在人类幼儿期就形成对象的概念,比值、过程等概念还要早形成,有了对象的自然定义,就可以描述编程语言中的对象。不同的编程语言中,利用不同的语言特性来描述对象。最成功的是用类来描述对象,例如c++、java,而js却选择了原型。
然而js在设计时,由于公司原因,js里又加入了new、this等类语言特性,看起来更像java。所以导致了这么怪异的感觉。

不过从运行时的角度来看对象,也就是从js实际执行过程中的对象模型来看,因为任何代码的执行都绕不过运行时的对象模型。从运行时角度来看,就可以不受类、原型这些的干扰,在运行时,任何语言类的概念都会被弱化。

js的对象特征
不论什么语言,都应该先去了解对象的本质特征。对象一般具有3个特点:唯一标识性,状态,行为。唯一标识性,即时两个完全相同的对象,也并非同一个对象。对象不同时刻拥有不同的状态。对象的状态可能因为行为而发生变化。

在编程语言中,唯一标识性通过内存地址来体现。而状态和行为,在不同的编程语言中,用不同的术语来抽象描述。例如在c++中,状态和行为通过成员变量和成员函数表示。java中,描述为属性和方法。js中,将状态和行为统一称为属性,js中函数被设计成特殊对象,所以行为和状态都可以用属性来抽象描述。

js中对象有一个特色,对象具有高度动态性,可以在运行时添加属性。为了实现这种能力,js的属性设计成了比别的语言更加复杂的形式,对象的属性划分为数据属性和访问器属性。

js 的属性并不是简单的键值对,而是用一组特征 attribute 来描述的属性。

数据属性具有4个特征,value/ enumberable/ configurable/ writable。访问器属性的4个特征是getter/ setter/ enumerable/ configurable。访问器属性的 getter/ setter 允许访问和写属性的时候,得到完全不同的值,是一种函数的语法糖。

代码中对对象添加属性会产生数据属性,除value以外的特征默认为true。如果想要改变特征或定义访问器属性,可以用Object.defineProperty。也可以在创建对象时创建访问器属性。

  1. var value;
  2. var o = {
  3. a: 1,
  4. get b() {
  5. return 'get b=' + value
  6. },
  7. set b(nv) {
  8. console.log('set b');
  9. return value = nv;
  10. }
  11. }

实际上,js的对象在实际执行过程中就是一个属性的集合,属性以字符串或Symbol为键,以数据属性特征值或访问器属性特征值为值。以Symbol为属性名,是js对象的一个特色。

正是这样的对象设计,使得js提供了完全运行时的对象系统,让它可以模仿多数面向对象编程范式,所以js也是面向对象的语言。js语言标准中也明确说明,js是一门面向对象的语言。完全运行时就是指的在js执行过程中,能够为对象动态添加删除属性。相对于其他语言,例如java,只有在定义类时规定好对象的属性和方法,在运行阶段,不允许修改对象的方法,只预先设定好或通过反射技术。js拥有更高的运行时动态性。
@todo java中有实例自身属性这一说法吗?
@todo 反射是啥?

基于类和基于原型的区别
基于类和基于原型都属于面向对象,是两种编程范式。js并不需要模拟面向对象,js中引入new、this等语言特性是因为公司原因,这模拟的是java等基于类的面向对象的语言特性。

js模拟类,又模拟了一半,没有模拟继承等关键特性,导致后来很多的继承方法,却没有一个统一的标准,es6 推出了class关键字,通过原型的方式完全模拟了类的实现,为的就是修正之前埋下的坑,统一标准的方案。

基于类的编程语言提倡使用一个关注分类与类之间关系的开发模型。在基于类的编程语言中,总是现有类,再用类去实例化对象。类与类之间构成继承、组合等关系。类与语言的类型系统相结合,形成一定编译时的能力。
@todo 类与类之间如何形成组合?
@todo 类与语言的类型系统如何结合,也就是说可以自定义类型吗?编译时的能力有哪些?什么是编译时能力?

基于原型的编程语言更提倡开发者关注一系列对象实例的行为,其次才去关心如何将这个对象划分到最近的使用方式相似的原型对象上,而不是分类。基于原型的对象系统通过 复制 的方式来创建新对象。

基于原型和基于类的编程语言都能满足基本的代码复用和抽象需求,但是使用场景不同。例如,专业人士在看老虎时,用猫科豹属豹亚科来描述老虎,而一些不正式的场合,可能称呼为大猫更直观。
@todo有哪些不同的使用场景呢?

js 并不是第一个使用原型的语言,在它之前,还有self、kevo等语言,js之父 Brendan 最初的构想是设计一个拥有基于原型的面向对象能力的 shceme 语言。@todo 什么是shceme语言,似乎与函数式编程有关。

原型系统更多的与高动态性的语言相互配合,基于原型的多数语言都提倡在运行时修改原型。原型系统的 复制 操作有两种实现思路,第一是创建一个新对象,新对象持有原型的引用。第二是复制一个完整的对象作为新对象,新对象与被拷贝的对象从此没有关联。js 采用的第一种复制操作。
@todo这和前面提到的对象自由添加属性一致吗?原型也是对象,应该是一致的说法。

js 的原型
抛开js种模拟类的语法设施(new、this、function object、函数的prototype属性等),原型系统只有两条线索。第一条线索是如果对象有私有字段[[prototype]],那么这个字段就是对象的原型。第二条线索是读取对象上的属性,如果对象没有就去对象的原型上去读取,直到原型为空或找到为止。

es6 提供了Object.create / Object.getPropertyOf / Object.setPropertyOf,可以更直观方便的操作原型。提供这些方法的目的是可以抛开早期模拟java类的语言特性,通过这3个api来利用原型。在早期的js版本种,只能通过java风格的类接口来操作原型的运行时。

早期js的类与原型
在早期的js中,类的定义是一个私有属性[[class]],js的内置类型都有这样的私有属性,用来表示它们的类。唯一可以访问[[class]]私有属性的方式是Object.prototype.toString。在es5开始,[[class]]私有属性被Symbol.toStringTag代替,Object.prototype.toString获取到的值不再和类有关系。甚至可以通过Symbol.toStringTag自定义Object.prototype.toString的行为。
@todo 现在还是可以用这个api判断类型?莫非是我又混淆了js中类和类型的概念?

  1. var a = {a: 1, [Symbol.toStringTag]: 'test'};
  2. console.log(Object.prototype.toString.call(a));//[object test]

早期的js通过new来模拟类,new的实现原理就是,根据传入构造器的原型创建一个新的对象,再传入新对象和接受的参数,以新对象为执行上下文调用构造器,如果调用返回值是对象就返回,否则就返回第一步创建的新对象。

这样的行为让函数这个对象,在语法上跟类变得相似。这样的行为有两种实现方式,第一种是在构造器中为实例添加属性,另一种是在构造器原型对象上添加属性。

  1. function c1() {
  2. this.p1 = 1;
  3. this.p2 = function() {console.log(this.p1)}
  4. }
  5. var o1 = new c1;
  6. o1.p2();
  7. function c2() {
  8. }
  9. c2.prototype.p1 = 1;
  10. c2.prototype.p2 = function() {console.log(this.p2)}
  11. var o2 = new c2;
  12. o2.p2();

所以在早期,只有两种方法可以指定对象的原型,第一种是通过new运算符进行原型的指定,第二种是使用不规范的proto属性。当然在Object.create没有出现之前,有利用new实现的polyfill版本。

  1. function object(prototype) {
  2. function cls() {}
  3. cls.prototype = prototype;
  4. return new cls;
  5. }

这个函数存在2个问题,第一个是不支持属性描述符对象,第二是不支持null为原型。

es6 class
es6 删除了与[[class]]私有属性相关的所有描述,引入了 class 关键字,类的概念正式从属性升级到了基础语法。这一刻开始,js 是一款基于原型和基于类的面向对象编程语言。getter / setter 和 函数 是兼容性最好的。

类的写法是由原型运行时来承载的,逻辑上js认为每个类是由共同原型的一组对象,类中定义的方法和属性都被写到了原型对象上。意思是类的语法在js实际运行过程中,最终转换成了对象。
@todo那怎么理解类是公共原型的一组对象呢?可以这样理解吗。类的本质就是函数,所有函数的原型都是Function.prototype,所以每个类拥有共同的原型,而js中函数又是特殊对象。
@todo getter、setter、函数兼容性好怎么理解?是因为class不能添加父类属性的原因吗?class只能添加实例属性、实例方法、私有属性、私有方法、静态属性、静态方法、原型方法,不能添加原型属性。

提供了extends 关键字进行类的继承,extends关键字自动设置了constructor,并且会自动调用父类的构造函数,减少了更多的坑。比早期的寄生式组合继承要nice的地方是,实现了两条链的继承,子了和父类的继承,另一条是子类原型对象和父类原型对象的继承。
所以使用class来代替function声明类是挺好的。

对象访问权限
不可扩展对象不能添加新属性。
密封对象不可扩展,并且所有属性的configurable特性会被设置为false(属性不能删除,除了value的特性不能修改)。
冻结对象肯定是密封的,其次,普通数据属性的writable特性为false,访问器属性仍可写。

对象类型和原始类型的区别
对象类型与原始类型存储的值不同,声明的变量保存在栈中,原始类型值也保存在栈中,而对象值保存在堆中,栈中变量的值保存的是对象在堆中的地址。

内存管理
js内存管理主要是针对堆内存进行内存的分配和回收。为新创建的对象数据寻找一块合适的空间,变量保存这个空间的地址,后续访问变量其实访问的是对象数据的地址。

@todo 从v8引擎来看,没有栈内存这一说法,字符串的赋值,其实也是将字符串分配在堆内存中,赋值的是字符串在堆中的地址,同理,赋值 undefined 的时候也是如此,是将 undefined 变量保存的值的地址拷贝给了变量。
(1 封私信 / 80 条消息) JavaScript中变量存储在堆中还是栈中? - 知乎 (zhihu.com)

堆内存与栈内存的区别
栈不仅用来存放变量和原始值,还要维护全局和函数的执行上下文,空间大小是固定的,由系统分配回收。而堆内存主要用于存放对象值,因为对象可大可小,堆内存的分配是动态的,也不会自动释放。

如果将对象值也存放在栈里,那么栈中占用内存就会更大,创建销毁执行上下文的时候,可能会进行大量内存的分配和回收,增大切换执行上下文的开销。

不要混淆堆栈数据结构与堆栈内存。这是两个不同的概念,二叉堆是一种完全二叉树结构,分为大顶堆、小顶堆,任何一个父节点的值都大于等于或小于等于孩子节点。栈是一种线性表,只允许一头插入和删除。堆栈内存与堆栈数据结构是不同的概念。堆栈内存是对内存的一种划分,主要是用来保存程序中用到的数据。
JavaScript函数的存储机制(堆)和执行原理(栈) - 掘金 (juejin.cn)

函数参数是对象会发生什么问题?进行引用的拷贝。

了解尾调用优化吗?
尾调用优化是针对函数调用时的栈内存优化。当函数的返回值仅仅是一个函数调用,没有其他操作,并且这个函数不是闭包。就会进行尾调用优化,比如函数A的返回值是函数B的调用,那么函数A执行到return时,立即出栈,函数B入栈,执行完再出栈,这样做有效减少了栈内存放的函数。

内存泄露
内存分配出去,但是又没有人使用,也没法回收就造成了内存泄露。全局变量,循环引用,没有清除定时器,闭包,没有清除对DOM的引用,没有清除事件处理程序都会导致内存泄露。

意外的全局变量可以通过严格模式来消除。

循环引用。在函数内有两个变量,两个变量的属性指向对方。如果采用计数引用,那么就会存在内存泄露问题。当声明两个变量时,对象的计数为1,属性指向对方时,计数为2,就算最后手动接触引用,对象的引用计数还是为1,得不到释放。为了避免循环引用造成的内存泄露,js采用了标记清除算法(v8是这样用的,IE还是有循环引用问题)。

闭包是如何造成内存泄露的呢?闭包会引用外部函数的变量。如果大量使用闭包,没有对闭包内访问的外部变量做引用解除,那么会造成,内存占用会增大,造成内存泄露。所以就好的实践就是在闭包内对不再使用的外部变量做解除引用。

定时器一般不会造成内存泄露,很多时候是和闭包一起使用造成的。除非大量创建定时器,忘记使用clearTimeout/clearInterval

DOM被移除后,最好移除他的事件处理程序。

V8 下的垃圾回收机制
V8 实现了准确式GC(Garbage Collection)。采用分代式垃圾回收机制,将堆内存分为新生代和老生代。新生代空间使用 scavenge 算法,将空间又划分为 from 空间和 to 空间,from 空间表示正在使用的内存,to 空间表示目前空闲的内存,新生代空间适合存放存活时间短的对象。老生代空间采用标记清除法,适合存放存活时间长的对象,对空间内的存活的对象进行标记,在垃圾回收的时候释放死亡的对象。

为对象分配内存的时候,在 from 空间中进行分配。垃圾回收时,检查 from 空间中的对象,未存活的对象直接释放,存活的对象赋值到 to 空间中。完成存活对象的赋值后,from 和 to 角色互换。from 空间始终处于使用中,to 空间处于闲置。scavenge 算法是一种空间换时间的算法。对对象进行内存分配的时候,可能会产生不连续的内存碎片,当一个大对象创建后,可能导致没有空闲的空间进行分配。使用这种拷贝的方法,将 from 空间的对象按顺序挨着存放在 to 空间中,可以解决内存碎片问题,最大化利用内存。

老生代空间采用标记清除法,并没有将空间一分为二。标记清除算法首先对老生代空间中的对象进行遍历标记,标记存活的对象,然后清除未存活的对象。执行过一次 scavenge 算法后或者 to 空间使用超过 25%,会将对象从新生代空间移动到老生代空间。当进行多次释放后,会产生不连续的内存碎片,需要用标记压缩算法对内存碎片进行压缩。老生代空间对内存碎片的整理方式是将存活的对象移动到一端。

两种内存碎片的处理方式
scavenge 的拷贝比老生代空间中移动要快,为什么?@todo
为什么标记压缩算法是老生代垃圾回收过程中最耗时的?

为了减少老生代标记对象所花费的时间,那么 v8 采用了增量标记的方式,将整个标记任务划分成多个小任务进行,每做完一个小部分就让业务逻辑执行一会,然后再执行后面的小任务。当标记任务完成之后,再进行内存碎片的整理。通过增量标记的方式,垃圾回收过程将应用程序的阻塞时间减少到了原来的 1/6,数据应该来自官方。
面试官问你有没有了解过 V8 的 javascript 垃圾回收机制算法 - 知乎 (zhihu.com)

在64位系统中,v8顶多分配1.4G内存,32位系统中,顶多分配0.7G。当读取2g大文件的时候,肯定无法将其全部装进内存,那么是如何进行操作的呢。而且nodejs也遵循这样的内存分配。这样做的原因有两个,js 是单线程的,js 垃圾回收机制。

js 单线程的执行机制,在进入垃圾回收的时候肯定会暂停应用代码的运行。js 的垃圾回收非常的耗时。v8 官方测例,对 1.5gb 堆内存进行垃圾回收需要 50ms 以上,做一次非增量式的垃圾回收需要 1s 以上。在这么长的时间消耗下,如果应用代码一直没有运行,那么肯定会造成页面卡顿。因此 v8 直接对堆内存空间大小做了限制。

在 64、32 位操作系统中,新生代空间只占 32mb、16mb。新生代中的from、to各占一半。
(2.4w字,建议收藏)😇原生JS灵魂之问(下), 冲刺🚀进阶最后一公里(附个人成长经验分享) - 掘金 (juejin.cn)
如何判断一个对象是存活?@todo

什么是标记清除和引用计数、标记压缩算法?
标记清除就是当变量进入执行环境时,标记这个变量为进入环境,当变量离开执行环境时,标记为离开环境。

引用计数就是记录每个值被引用的次数。赋值时就引用次数会加1,这个值的变量取另一个值,引用次数就减1。

标记压缩是为了清除回收内存后,内存空间出现不连续的状态。将内存碎片进行压缩,整理出连续地址的空闲空间。

深浅拷贝
注:拷贝是针对引用类型值而言,与赋值是不同的,原始类型是没有深浅拷贝的。
浅拷贝就是直接复制已存在对象的引用类型属性的地址,原始类型属性的值。

  1. // 常见的有4种方法:
  2. // Object.assign(target, source1, source2, ......),返回目标对象。
  3. // Array.prototype.slice.call(context, start, end),返回一个新数组,提取原数组中[start,end)的元素。
  4. // Array.prototype.concat(context, par1, par2, ......),返回一个新数组。
  5. // ...扩展运算符

深拷贝就是复制已存在对象的引用类型属性的值,堆内存中开辟新的空间来保存值,与原变量指向不同的空间。实现深拷贝的方法有:

  1. // 引用类型实现深拷贝
  2. // (1)JSON.stringify、JSON.parse实现多层深拷贝
  3. // 缺点:
  4. // 1.会忽略undefined、symbol、function;
  5. // 2.不能解决循环引用;
  6. // 3.不能拷贝不可枚举的属性,以及原型链;
  7. // 4.如果存在NaN、INfinty、-Infinty,序列化后会变成null;
  8. // (2)自定义递归函数实现(Loadsh.js实现的比较好,具有参考价值)
  9. function deepClone(value) {
  10. if (!(isObject(value))) return value;
  11. const ret = Array.isArray(value) ? [] : {};
  12. for (let key in value) {
  13. if (value.hasOwnProperty(key)) {
  14. ret[key] = isObject(value[key]) ? deepClone(value[key]) : value[key];
  15. }
  16. }
  17. return ret;
  18. }
  19. function isObject(val) {
  20. return typeof val === 'object' && val !== null;
  21. }

实例

学习目标,主要学习js的内置对象,从应用和机制的角度,挑选几个体系学习。
预期收益,了解js的对象体系,对象提供的api 的特性,更加深入理解js对象机制(对象机制不止是属性的集合+原型系统),用对象模拟函数与构造器的机制。
@todo 实践,不使用new运算符,尽可能找到获取对象的方法。获取js全部固有对象。

js的对象分类

  • 宿主对象,由js宿主环境提供,对象的行为由宿主环境决定。
  • 内置对象,js提供的对象。
    • 固有对象,js开始运行就自动创建的对象实例。
    • 原生对象,通过内置构造器或者特殊语法创建的对象。
    • 普通对象,由{}语法、Object构造器、class关键字定义的类创建的对象,能够被原型继承。

@todo 固有对象和宿主对象有啥区别,js运行时不也依托于宿主环境吗?特殊语法创建对象,有哪些特殊语法?只有普通对象能够被继承吗?

宿主对象

浏览器环境,全局对象是 window,window上有很多属性,一部分来自js,一部分来自浏览器环境。@todo那么具体有哪些典型的呢?

宿主对象也分为固有和用户可创建的两种,比如 document.createElement 创建的 dom 对象。

宿主也提供了一些构造器。比如 new Image 创建 img 标签元素。

内置对象

固有对象
固有对象在任何js代码执行前就被创建出来,扮演基础库的角色。类就是固有对象的一种。
原生对象
js中能够通过语言本身的构造器创建的对象就叫原生对象。js标准中,提供了30多个构造器。通过new调用构造器可以创建新的对象,将这些新创建的对象称为原生对象。

所有构造器的能力无法用纯js实现,也无法用class/extends语法来继承。这些构造器创建的对象多数使用了私有字段,例如[[ErrorData]]/ [[BooleanData]] / [[NumberData]] / [[DateValue]] / [[RegExpMatcher]] / [[SymbolData]] / [[MapData]]
@toto Object 可以被继承呀,至少不会报语法错误。还需要捋捋。

对象模拟函数与构造器
还可以从不同的视角看待对象,比如从对象来模拟函数和构造器。

js为这一类特殊对象预留了私有字段,并规定了抽象的函数对象和构造器对象。函数对象的定义是具有[[call]] 私有字段的对象。构造器对象的定义是具有[[construct]] 的对象。这是一种机制。

js 用对象模拟函数代替了其他编程语言中的函数,可以像其他语言中的函数一样被调用、传参、定义。只要提供了具有[[call]]私有字段的对象,就可以用js的函数语法来调用。更细节一点的话,[[call]]私有字段必须是一个引擎中定义的函数,需要接受this值和调用参数,并且会产生域的切换。
@todo这个在属性访问和执行过程中学习。

对于我们开发者来说,只要实现了[[call]]、[[construct]],就可以当作函数、构造器来用。

有的宿主对象和内置对象,作为函数被调用和作为构造器函数被调用,行为不总是一致的。例如内置对象Date,在直接调用时,返回字符串,在作为构造器调用时产生新对象。宿主提供的Image对象,就不能作为函数调用。而使用 function声明Function构造器 创建的函数对象,[[call]] 和 [[construct]] 的行为是一致的。

es6 引入的箭头函数仅仅是函数,不能作为构造器使用。@todo这其中证明了函数对象的prototype属性与new、[[construct]]有很大的内部关联。

[[construct]]的执行过程就是new运算符执行过程。首先以Object.prototype为原型创建一个对象,以新对象为this,执行函数的[[call]],如果返回值为对象则返回这个对象,否则返回第一步创建的对象。

  1. function mynew(constructor, ...args) {
  2. if (typeof constructor !== 'function') throw new TypeError('arguments[0] is not a functin');
  3. // var obj = Object.create(constructor.prototype);
  4. var obj = {
  5. __proto__: constructor.prototype,
  6. }
  7. var ret = constructor.apply(this, args);
  8. return ret instanceof Object ? ret : obj;
  9. }

通过 new 的方式创建对象和通过字面量创建有什么区别?
区别就是new创建对象会遍历原型链,寻找Object方法。使用字面量的话就没有这个消耗,并且简洁。todo 为什么用字面量就没有这个消耗呢?

这里与同桌讨论,引发一个疑问**js**中的对象是自动分配到堆内存中,那么为什么还需要**new**操作符?与**C/C++****new**有什么区别?js中的new实现了原型式继承,从代码实现上来看利用Object.create拷贝一份构造函数的原型对象,然后赋值给实例的原型。

Object es6 新增 api
Object.assign,浅拷贝,将源对象的属性拷贝到目标对象上,并返回一个新对象。

arguments为什么不是数组?
首先,arguments 是一种类数组类型的值,并不能调用数组的方法。arguments 对象自身属性从0开始,还有callee,length属性。callee 指向当前正在执行的函数。

还有一些常见的类数组,getElementByTagName 获取的 HTMLCollection / querySelectorAll 获取的 NodeList。

有5种方法可以将arguments转换为数组。

  1. function foo() {
  2. console.log(arguments);
  3. var a1 = [...arguments];
  4. var a2 = Array.prototype.slice.call(arguments);
  5. var a3 = Array.prototype.concat.apply([], arguments);
  6. // 用 [].concat(arguments)不行吗?
  7. var a4 = Array.from(arguments);
  8. // 第5种 遍历+拷贝。
  9. }

前4种方法的转换原理是什么?@todo(待学习…)

数组常用方法
reduce遍历数组,从左到右对数组元素做累计操作,将数组元素转换为一个值,作为累计结果返回。reduceRight 从右到左进行遍历累计。

  1. // reduce实现map
  2. Array.prototype.map = function(callback) {
  3. const array = this;
  4. return array.reduce((acc, cur, index) => {
  5. acc.push(callback(cur, index, array));
  6. return acc;
  7. }, []);
  8. }
  9. // 测试
  10. var a = [1,2,3];
  11. var r = a.map(num => num * 2);
  12. console.log(r); // (3) [2, 4, 6]
  13. var rr = a.reduce((acc, num) => {
  14. acc.push(num * 2);
  15. return acc;
  16. }, []);
  17. console.log(rr); // (3) [2, 4, 6]

Proxy
Proxy是ES6新增的数据结构,可以拦截外界对对象本身的访问和改写操作。Vue3Proxy替换了Object.defineProperty实现数据响应式。

函数
[[Call]]属性,使函数可以被调用,原理(待补充。。。)。typeof function === ‘function’也是根据这个属性来的。

JavaScript 的 typeof 原理小记 - 掘金 (juejin.cn)

哈希结构
弱引用与强引用 @todo
弱引用就是这个引用指向的对象在任何时候都可能被垃圾回收,强引用相反,强引用指向的对象不会被垃圾回收。

弱引用会存在指向的对象被回收之后不可访问的情况吗?出现了怎么办?

特殊行为的对象

Array 的 length 属性会根据最大的下标自动发生变化。

Object.prototype 作为所有正常对象的默认原型,不能给它设置原型。

String 为了支持下标运算,String 的正整数属性访问回去字符串里查找。

Arguments 对象的非负整数型下标属性跟对应的变量联动。

模块的 namespace 对象特殊的地方非常多,跟一般对象完全不一样,尽量只用于 import。

类型数组和数组缓冲区跟内存块相关,下标运算比较特殊。

bind 后的函数与原来的函数相关联。@todo 这个就是关联执行上下文吗。

执行过程

当浏览器或node环境拿到一段js代码的时候,首先要做的事情就是传递给 js 引擎,让js引擎去执行这段代码。首先我们要知道执行 js 并不是一个一次性的过程,当宿主环境遇到一些事情的时候,会继续将一段代码传递给 js 引擎执行。此外还有一些 api 可以向 js 引擎提交 js 代码,比如 setTimeout / promise.then / process.nextTick 等。

首先我们应该认识到,js 引擎会常驻于内存,等待宿主(我们)把 js 代码或者函数传递给它执行。在 es3 之前,js 本身没有异步执行代码的能力。这意味着 js 引擎无法发起任务,只能听从宿主环境的安排,宿主环境传给 js 引擎一段代码,引擎就把代码按顺序执行了。在 es5 之后,js 引入了 promise 对象,不需要浏览器安排,通过调用 promise api,js 引擎本身也能发起任务。根据 jsc 引擎的术语,把宿主发起的任务称为宏任务,js 引擎发起的任务称为微任务。

js 单线程
js 是用来实现用户交互和可以操作 DOM 和样式,如果是多线程,同时有多段 js 代码、多 DOM 和样式`进行操作,以哪段代码的处理结果为准很难判断,会造成渲染顺序的混乱。

使用单线程主要有3个原因,第一个就是避免渲染混乱,此外还可以节约创建线程的资源、节省切换上下文时间。为了满足异步的需求,搭配了事件循环机制。
web前端:关于浏览器内核的多线程机制 - 知乎 (zhihu.com)

事件循环

常驻内存的 js 引擎等待宿主环境分配宏任务,这样一个行为或者机制就叫做事件循环。事件循环的原理就是一个跑在独立线程中的循环。每一次执行过程就是一个宏任务,宏任务的任务队列就相当于事件循环。

事件循环是 js 事件驱动模型中管理和执行事件的一部分,其形式就是一个死循环,在这个循环里不断调取宏任务队列中的宏任务来执行。而每一次循环就是一个事件周期或者叫 tick。

  1. while(true) {
  2. r = wait();
  3. execute(r);
  4. }

宏任务与微任务

用户的操作:鼠标点击、键盘输入,网络请求,定时器,postMessage,同步代码,都是宏任务;Promise.then 注册的处理程序、async / await 后的代码、Object.observe、MutationObserver,都是微任务。微任务是 js 引擎发起的任务,宏任务是由宿主(浏览器)发起的任务。也可以说宏任务是由微任务组成的。

宏任务与微任务有几点不同,首先执行顺序不同,宏任务的执行是按注册顺序执行,而微任务是插队处理,所以可以说微任务比下一个宏任务优先级更高。
【study】宏任务和微任务的区别是什么 - 掘金 (juejin.cn)
【study】宏任务和微任务的区别是什么 - 掘金 (juejin.cn)面试率 90% 的JS事件循环Event Loop,看这篇就够了!! !_m0_46374969的博客-CSDN博客
当事件循环遇到更新渲染 - 知乎 (zhihu.com)

Node事件循环

NodeJS 中划分了不同的任务阶段是timers、pending callbacks、poll、check、close callbacks

  • timers 阶段:执行 setTimeout、setInterval 的回调。
  • callbacks 阶段:某些系统的回调。如 TCP 连接错误。
  • poll 阶段:轮询等待新的连接和请求等事件,执行I/O回调等。
  • check 阶段:执行setImmediate回调。
  • close callbacks 阶段:关闭回调执行,比如socket.on(‘close’, …)。

V8 引擎解析完代码后,首先进入 poll 阶段,此阶段任务队列执行完了或为空,就会检查 setImmediate 回调,有就进入 check 阶段。否则等待新的事件到来或者其他定时器计时到来,定时来了就进入 timers 阶段。此阶段可能阻塞等待。

NodeJS 在11版本之后向浏览器看齐,所有微任务会在执行完一个宏任务之后执行。nodejs 与浏览器不同的地方是在 node 11 之前,主要区别有两点,第一是微任务的执行时机,在11之前微任务要等到当前阶段的所有宏任务执行完再执行,第二 process.nextTick 的执行时机,11 之前要在每一个阶段的宏任务和微任务都执行完才会检查 nextTick 队列,而11之后,执行完每个宏任务就会去检查nextTick队列。
面试题:说说事件循环机制(满分答案来了) - 云+社区 - 腾讯云 (tencent.com)

process.nextTick的优先级

process.nextTick是独立于NodeJS事件循环之外的函数,并不属于微任务,比微任务的优先级高,拥有自己的回调函数队列。

@todo node的事件循环中,首次进入事件循环时,在poll阶段,有可能会跳到check阶段执行回调,但是check阶段在poll阶段之后,那么poll阶段是如何知道check阶段有没有回调需要执行的呢?

异步编程

回调函数

当函数被当作参数传递给其他程序进行调用,这个函数就是回调函数。在js中,通过事件的监听与绑定,给对象的某个事件绑定一个函数,当该事件发生时,调用绑定的函数,这个函数就是回调函数,还有传递给定时器的函数,传递给Axios请求的函数参数等都是回调函数。

回调函数有什么缺点?
回调函数会将控制权交给第三方,很有可能是第三方库或其他厂商人员。为了防止其他人员对回调函数不当的操作,保证回调函数代码的稳定性、健壮性,需要添加很多用于防范的逻辑代码。而且当多个异步操作需要串联起来处理时,回调函数嵌套层数过多时导致很差的代码阅读性,代码难以维护。更重要的是,多层嵌套的回调函数耦合度高,难以更新,而且代码出现错误,很难处理。不能用try catch捕获回调函数内发生的错误,不能直接return

如何解决回调地狱?
使用PromiseGenerator可以解决控制反转问题,以及回调地狱。

Generator

GeneratorES6新增的一种函数结构,可以暂停或开始函数体内代码的执行。生成器函数内部使用yield关键字暂停代码的执行。生成器函数会返回一个生成器对象,该对象具有next方法,调用该方法就继续执行生成器函数内的代码。还可以通过nextyield实现参数的传递。
当事件循环遇到更新渲染 - 知乎 (zhihu.com)

Promise

Promise构造函数会返回一个pending状态的Promise对象,等待状态可以转变为fullfiled状态和rejected状态。状态一经改变,就不能被更改。Promise构造函数内的代码会立即执行。
优点:通过链式调用解决了回调地狱问题。解决了控制反转问题。
缺点:调用链可能过长;处理错误还是得在回调函数内。

Promise实现了链式调用,Promise实现了thencatchfinally方法,都会返回一个期约对象。

  1. // Promise调度小技巧
  2. var p3 = new Promise( function(resolve,reject){
  3. resolve( "B" );
  4. } );
  5. var p1 = new Promise( function(resolve,reject){
  6. resolve( p3 );
  7. } );
  8. var p2 = new Promise( function(resolve,reject){
  9. resolve( "A" );
  10. } );
  11. p1.then( function(v){
  12. console.log( v );
  13. } );
  14. p2.then( function(v){
  15. console.log( v );
  16. } );
  17. // 'A'
  18. // 'B'
  19. // 为什么不先打印B,再打印A?

PromiseA+规范

主要内容:初始化状态量、结果值、处理程序队列、resolve函数、reject函数,执行执行器函数。主要内容:三种状态时对处理程序,以及返回对象结果值的不同处理。
重点,**then**的回调函数什么时候添加到微任务队列里?
promise then 的回调函数是在什么时候进入微任务队列的? - SegmentFault 思否

包装父期约then方法处理程序的返回值做父期约then方法返回的Promise对象的结果值,针对then方法处理程序的不同返回值做不同的处理,Promise对象、thenable对象则展开,普通对象和普通值则作为结果值。

Promise.all方法接收一组Promise实例,返回一个新实例。所有Promise实例状态都成功,就将这些成功Promise的结果值集合起来作为新Promise实例的结果值。如果有一个Promise实例失败了,就将失败值作为新实例的结果值。同时会将结果值传递给新实例的回调函数。所有成功,一个失败

Promise.racePromise.all类似,但是只要有一个状态发生变化就会将那个值作为新实例的结果。一变则变

**Promise.allSettled**方法,Promise.all不同,Promise.all只要有一个失败就会返回,不管其他未完成的Promise或已经执行成功的PromisePromise.allSettled会等待所有的Promise执行完,返回的新实例状态只会改变为fulfilled,结果值是一个数组,里面包含了所有Promise的结果值。全变则变

**Promise.any**方法,一组Promise实例,返回一个新promise实例。只要有一个Promise实例fulfilled了,就改变新实例的状态并将这个实例resolve的值作为新实例的结果值。当所有Promise实例都失败了,改变新实例的状态,并将这些失败Promise实例的结果值集合起来作为新Promise实例的结果值。所有失败,一个成功

使用**promsies-aplus-tests**测试
安装该插件

  1. npm i -g promises-aplus-tests
  2. promises-aplus-tests promise.js

可能出现的问题`adapter.deferred is not a function,解决办法:

  1. // 添加如下代码
  2. Promise.defer = Promise.deferred = function(){
  3. let dfd = {};
  4. dfd.promise = new Promise((resolve, reject)=>{
  5. dfd.resolve = resolve;
  6. dfd.reject = reject;
  7. });
  8. return dfd;
  9. }
  10. module.exports = Promise;


async / await

es7 引入的特性,被称为 js 的终极异步解决方案,可以用 for / if 等同步的方式来编写异步的任务,并且不需要借助第三方库,是 js 原生语法。

async 添加在函数前,这个 async 函数必定会返回一个Promise对象

await 与 async 配套使用,await 会解包右边表达式返回的值。并将 await 下面剩余的代码作为异步回调,注册到返回promise对象的微任务中。

js 引擎遇到 await 关键字,将其转换为一个 promise,然后 js 引擎暂停当前协程的运行,将线程的执行权交给父协程。回到父协程中,第一件事就是对 await 返回的 promise 调用 then,监听这个 promise 状态的改变。

  1. async function test() {
  2. console.log(100);
  3. let x = await 200;
  4. /*
  5. let promise = new Promise(resolve => {
  6. resolve(200);
  7. });
  8. */
  9. console.log(x);
  10. console.log(200);
  11. }
  12. console.log(0);
  13. test();
  14. console.log(300);

@todo 如何转换成promise的呢?

根据event loop机制,当前主线程的宏任务完成后,检查微任务队列,发现还有一个promise resolve注册进来的异步回调,那么现在父协程就在 then 中传入的回调里执行。

  1. promise.then(value => {
  2. // 1.将线程的执行权交给 test 协程
  3. // 2.将 value 值传递给 test 协程
  4. })

执行权交给了 test 协程,test 接受到父协程传来的200,然后赋值给 x,然后再依次执行后面的语句。

async / await 利用协程和 promise 实现了同步方式编写异步代码的效果,比起 promise 链式调用,async / await 写的代码语义化更明显,可以更加直观的表现逻辑,与 co + generator 相比,上手更简单,性能更高。

await 必须与 async 配套使用,如果没有依赖关系的异步代码被 await 串行化,反而会导致性能的下降。

原理
await 的原理是协程、promise,其中 generator 是对协程的一种实现。

定时器函数

setTimeout在设定时间到达后执行传入的函数参数,只执行一次。定时的时间不一定精准,如果当前事件循环 tick 后存在微任务需要执行,就会延迟执行,
setInterval设定间隔时间, 周期性执行传入的函数,类似于setTimeout,存在时间误差,并且随着执行的次数越多,误差累积的越大。

定义一个函数,函数内启动一个定时器,将这个函数传递给定时器,会发生什么?定时器属于宏任务,会在同步代码以及同步代码当前事件循环的微任务执行完毕后,再执行宏任务注册的回调函数,该函数会一直注册宏任务,占用js主线程。导致其他代码无法被执行。

setTimeout中this指向,浏览器环境,指向全局对象,严格非严格都指向全局对象。nodejs环境指向Timeout对象。

setImmediate,这东西没有在webkit中实现,也就是说谷歌、edge浏览器中都没有这个API。现在只有nodejs、internet explorer中有。重点。这个 api 只能传递 handler 函数参数。

nodejs的事件循环可以知道,这个API是先于setTimeout/setInterval执行的。为什么需要这样做呢?首先我们知道js是单线程的,并且和UI线程互斥,如果js执行长时间的同步代码,那么UI线程会被长时间阻塞。用户体验肯定是极差的,因为这个时候用户点击页面没有一点反应,或者说页面都渲染不出来。这时候提出了一种做法就是将js的同步任务拆分为更小份来异步执行。然而异步执行也会有个问题,那就是任务的整体执行时间会被拉长。

setTimeout/setInterval的定时又是不准的,如果在定时器宏任务之前还插入了很多微任务,那么延时会更久。**setTimeout/setInterval**为什么会有时间精度问题呢?无法解决吗?其实是可以解决的,但是时间精度提高了,机器的耗电也会提高,出于续航的考虑,没有强行将时间精度拔高。所以提出了这个API,每次事件循环的开始都会先检查setImmediate
JS魔法堂:初探传说中的setImmediate函数 - _肥仔John - 博客园 (cnblogs.com)
setTimeout和setImmediate到底谁先执行,本文让你彻底理解Event Loop - 掘金 (juejin.cn)

requestAnimationFrame,告诉浏览器在下一次重绘前执行回调。传给该函数的回调函数每秒执行60次,一般跟随系统屏幕刷新频率,能更有效的利用设备的性能。requestAnimationFrame不属于宏任务也不属于微任务,是由系统调用,在下一次渲染前调用。对比setTimeout/setInterval,还有两个优点是标签休眠时会暂停调用,减少耗电;可以用这个API优化节流。
requestAnimationFrame添加的回调会在下一次渲染前调用,如果需要一直执行回调,需要一致添加。
使用 requestAnimationFrame 替代 throttle 优化页面性能 - 云+社区 - 腾讯云 (tencent.com)

事件循环先执行宏任务、然后执行微任务,最后执行 ui 渲染。以此为一个周期。浏览器执行渲染和 gpu 绘制像素还不一样,浏览器执行渲染只是将图层帧喂给 gpu,真正的图像绘制是在 gpu 完成的吧。 @todo 待证实。

浏览器会尽量保持屏幕刷新率一样的频率来进行页面的渲染。但是浏览器的渲染频率会受到js代码的影响,更确切的说是受到事件循环的影响。有个问题,如果下一个宏任务中有DOM操作,但是当前同步代码创建了大量的微任务,微任务执行时间超过了屏幕刷新周期,那么浏览器就跟不上它的频率了,这时候就会出现页面卡顿。用户的DOM操作被大量的微任务阻塞。在屏幕进行刷新的时候浏览器没有新的渲染树数据喂给GPU进行图像的绘制。
这也是requestAnimationFrame出现的根本原因,就是为了避免这种情况。

@todo 还是有个问题,浏览器事件循环执行 ui 渲染是在微任务之后,如果还在执行微任务,那么 raf 由系统调用时,会强制刷新?还是说会给微任务固定的时间片?

具体代码执行过程

一段 JavaScript 代码可能会包含函数调用的相关内容。闭包、原型链、执行上下文、this 值都是函数执行过程相关的知识。先从一个经典的函数-闭包-来看。

闭包的执行

闭包就是一个函数,这个函数内部能访问到外层函数内的变量,并且运行在外层函数之外的地方。闭包其实只是绑定了执行环境,与普通函数的区别就是携带了执行环境。

闭包最开始是在一篇1964年的论文中提出来的。上世纪60年代,主流的编程语言还是基于 lambda 运算的函数式编程语言,最初的闭包定义就使用了大量的函数式术语。一个不太精确的描述是“带有一系列信息的 lambda 表达式”。

对于编程语言来说,信息就是执行环境,lambda 表达式就是函数体。执行环境主要包含函数的词法环境、标识符列表。标识符列表存放了函数中用到的未声明的变量。

闭包产生的原因就是当前作用域存在对父级作用域的引用。函数作为返回值,函数作为参数,定时器、事件监听、ajax请求这些异步任务的回调,都是闭包。

经典面试题,循环中使用闭包解决 var 定义函数的问题

  1. for(var i = 0; i < 5; i++) {
  2. setTimeout(function timer() {
  3. console.log(i);
  4. }, i * 1000);
  5. }
  6. // 三种解决方法
  7. for (var i = 0; i < 5; i++) {
  8. (function (i) { // 用IIFE包裹定时器回调
  9. setTimeout(function timer() {
  10. console.log(i);
  11. }, i * 1000);
  12. })(i);
  13. }
  14. for (let i = 0; i < 5; i++) {//用let声明
  15. setTimeout(function timer() {
  16. console.log(i);
  17. }, i * 1000);
  18. }
  19. for (var i = 0; i < 5; i++) {//用定时器第三个参数传参
  20. setTimeout(function timer(j) {
  21. console.log(j);
  22. }, i * 1000, i);
  23. }

执行上下文

js 函数主要的复杂性就是来自于他携带的环境部分。函数对应的环境不止包含词法环境,还要处理 this、变量声明、with 等等一系列的复杂语法。在 JavaScript 的设计中,词法环境只是 JavaScript 执行上下文的一部分。执行上下文的官方定义也在发生变化。
执行上下文在 ES3 中,包含三个部分。scope:作用域,也常常被叫做作用域链。variable object:变量对象,用于存储变量的对象。this value:this 值。
在 ES5 中,改进了命名方式,把执行上下文最初的三个部分改为下面这个样子。lexical environment:词法环境,当获取变量时使用。variable environment:变量环境,当声明变量时使用。this value:this 值。
在 ES2018 中,执行上下文又变成了这个样子,this 值被归入 lexical environment,但是增加了不少内容。
lexical environment:词法环境,当获取变量或者 this 值时使用。
variable environment:变量环境,当声明变量时使用。
code evaluation state:用于恢复代码执行位置。
Function:执行的任务是函数时使用,表示正在被执行的函数。
ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码。
Realm:使用的基础库和内置对象实例
Generator:仅生成器上下文有这个属性,表示当前生成器。
从标准的定义出发很难理解执行上下文。

从代码实例出发,推导函数执行过程中需要哪些信息,它们又对应着执行上下文中的哪些部分。以下面这段代码为例。

  1. var b = {}
  2. let c = 1;
  3. this.a = 2;

要想正确执行它,我们需要知道以下信息:b、c 在哪里声明?b 的原型是哪个对象?this 指向的哪个对象?

这些信息都是执行上下文给出的。这段代码出现的地方不同,在每次的执行中,会关联到不同的执行上下文,所以,同样的代码会产生不一样的行为。

var 声明与赋值

  1. var b = 1;
  2. // 它声明了 b,并且为它赋值为 1,var 声明作用域函数执行的作用域。
  3. // 也就是说,var 会穿透 for 、if 等语句。

在没有 let 的旧 JavaScript 时代,诞生了一个技巧,叫做:立即执行的函数表达式(IIFE),通过创建一个函数,并且立即执行,来构造一个新的域,从而控制 var 的范围

括号有个缺点,那就是如果上一行代码不写分号,括号会被解释为上一行代码最末的函数调用,产生完全不符合预期,并且难以调试的行为,加号等运算符也有类似的问题。所以一些推荐不加分号的代码风格规范,会要求在括号前面加上分号。或者使用 void 关键字,void 运算表示忽略后面表达式的值,变成 undefined。

  1. ;(function(){
  2. var a;
  3. //code
  4. }())
  5. ;(function(){
  6. var a;
  7. //code
  8. })()
  9. void function(){
  10. var a;
  11. //code
  12. }();

var 变量提升的特性在 with 语句内,会有所不同。with 语句中 var 声明的变量会添加到外一层的函数执行上下文中。with 语句中访问变量时会先去传入对象的属性上找,找不到时就会去函数作用域查找。所以下面这段代码这么怪异。var 这样的语句对两个域产生了作用,从语言的角度是个非常糟糕的设计。

  1. var b;
  2. void function(){
  3. var env = {b:1};
  4. b = 2;
  5. console.log("In function b:", b);
  6. with(env) {
  7. var b = 3;
  8. console.log("In with b:", b);
  9. }
  10. }();
  11. console.log("Global b:", b);
  12. // In function b: 2
  13. // In with b: 3
  14. // Global b: 2

let
为了实现 let,JavaScript在运行时引入了块级作用域。也就是说,在 let 出现之前,JavaScript 的 if for 等语句皆不产生作用域。以下语句会产生 let 使用的作用域:for;if;switch;try/catch/finally。

Realm
最新的标准(9.0)中,JavaScript 引入了一个新概念 Realm。

  1. var b = {};

在 ES2016 之前的版本中,标准中甚少提及{}的原型问题。但在实际的前端开发中,通过 iframe 等方式创建多 window 环境并非罕见的操作,所以,这才促成了新概念 Realm 的引入。

Realm 中包含一组完整的内置对象,而且是复制关系

对不同 Realm 中的对象操作,会有一些需要格外注意的问题,比如 instanceOf 几乎是失效的。

  1. var iframe = document.createElement('iframe')
  2. document.documentElement.appendChild(iframe)
  3. iframe.src="javascript:var b = {};"
  4. var b1 = iframe.contentWindow.b;
  5. var b2 = {};
  6. console.log(typeof b1, typeof b2); //object object
  7. console.log(b1 instanceof Object, b2 instanceof Object); //false true

b1、 b2 由同样的代码“ {} ”在不同的 Realm 中执行,所以表现出了不同的行为。
@todo instanceof 失效的原因。首先根据 instanceof 的原理去分析,是比较对象的原型和构造函数的原型对象,是内存地址的比较。像 iframe 这种情况,不同的 iframe,Object 的原型对象是拷贝的关系,那么肯定不是同一个地址。所以比较才会使 false。

执行上下文的切换

任何语句的执行都需要特定的上下文。js 中切换上下文最主要的场景是函数调用。首先来认识一下函数家族,一共有6种函数,普通函数、箭头函数、class、class中定义的函数、生成器函数、async 函数

ES6 以来,大量加入的新语法极大地方便了我们编程的同时,也增加了很多我们理解的心智负担。要想认识这些函数的执行上下文切换,我们必须要对它们行为上的区别有所了解。

对普通变量而言,这些函数并没有本质区别,都是遵循了“继承定义时环境”的规则,它们的一个行为差异在于 this 关键字
何来继承一说@todo

  1. function showThis(){
  2. console.log(this);
  3. }
  4. var o = {
  5. showThis: showThis
  6. }
  7. showThis(); // global
  8. o.showThis(); // o

普通函数的 this 值由“调用它所使用的引用”决定,其中奥秘就在于:我们获取函数的表达式,它实际上返回的并非函数本身,而是一个Reference 类型(记得运行时类型中的8种标准类型吗,正是其中之一)。
这个 referernce 对象由两部分组成:一个对象和一个属性。遇到 o.showThis(),获取的结果是一个 Reference 类型,即由对象 o 和属性“showThis”构成的对象。Reference 类型中的对象被当作 this 值,传入了执行函数时的上下文当中。
调用函数时使用的引用,决定了函数执行时刻的 this 值

实际上从运行时的角度来看,this 跟面向对象毫无关联,它是与函数调用时使用的表达式相关。通过这样的方式,巧妙地模仿了 Java 的语法,但是仍然保持了纯粹的“无类”运行时设施。

this 关键字的机制
函数能够引用定义时同域的变量,也能记住定义时的 this,因此,函数内部必定有一个机制来保存这些信息
在 JavaScript 标准中,为函数规定了用来保存定义时上下文的私有属性[[Environment]]。当一个函数执行时,会创建一条新的执行环境记录,记录的外层词法环境(outer lexical environment)会被设置成函数的[[Environment]]。这个动作就是切换上下文。切换上下文的机制是保存外层的词法环境。

js引擎用栈来管理执行上下文,栈中的每一项包含了一个链表。
e8d8e96c983a832eb646d6c17ff3df31.jpg
注意区分,链表中的词法环境是定义时的环境,所以不一定指向上一个栈帧。

  1. var a = 1;
  2. function global() {
  3. console.log(a);
  4. var b = 2;
  5. return function bibao() {
  6. console.log(c);
  7. }
  8. }
  9. var c = 3;
  10. var bb = global()();
  11. // 1
  12. // 3

当执行bibao函数的时候,执行栈中只有全局上下文、bibao 执行上下文。然而 bibao 上下文的词法环境链表依次指向自己的作用域、global 函数作用域、全局作用域。

this 的机制更加复杂,js 标准定义了 [[thisMode]] 私有属性。我的理解,调用函数时先获取函数,实际上获取一个引用,比如 bibao 这个函数获取到的引用就是全局执行上下文,将全局对象赋予了 this 关键字。
[[thisMode]] 私有属性有三个取值。

  • lexical:表示从上下文中找 this,这对应了箭头函数。
  • global:表示当 this 为 undefined 时,取全局对象,对应了普通函数。
  • strict:当严格模式时使用,this 严格按照调用时传入的值,可能为 null 或者 undefined。

函数创建新的执行上下文中的词法环境记录时,会根据[[thisMode]]来标记新纪录的[[ThisBindingStatus]]私有属性。代码执行遇到 this 时,会逐层检查当前词法环境记录中的[[ThisBindingStatus]],当找到有 this 的环境记录时获取 this 的值。

通过这样的规则,箭头函数的词法环境记录中的[[ThisBindingStatus]]私有属性就可以指向外层的[[ThisBindingStatus]]属性,这样就可以指向最外层普通函数的this。

call、bind、apply
call、bind 和 apply 用于不接受 this 的函数类型如箭头、class 都不会报错。这时候,它们无法实现改变 this 的能力,但是可以实现传参。class 中定义的方法默认按 strict 模式执行,所以会严格调用传进来的值,就算不是严格模式,this 强制转换仍会失败。

  1. class foo {
  2. a() {
  3. console.log(this);
  4. }
  5. }
  6. var a = new foo().a;
  7. a();//undefined
  8. a.call();//undefined;

this强制转换,在非严格模式下,如果传递进来的第一个参数是 null / undefined 或其他原始类型,那么函数会对其进行封装。红宝书p914。

如何简单正确判断this
普通函数内的this与函数调用的位置以及this的4种绑定规则:默认绑定、隐式绑定、显示绑定、new运算符绑定。当函数单独调用的时候指向全局对象,严格模式下等于undefined。当作为对象方法调用时,指向对象。使用call/apply调用时指向传入的执行上下文参数。普通函数作为构造函数使用时,this指向的是新创建的对象,也就是返回的对象。

箭头函数与普通函数的区别?
箭头函数中的this与普通函数不同,箭头函数中的this指向的是外层作用域的this。此外,箭头函数没有argumnets对象,可以使用剩余参数代替。箭头函数也没有prototype属性,不能用作构造函数。

构造函数需要prototype属性,因为在作为构造函数时,需要以构造函数的prototype属性作为新建实例的原型属性,函数没有prototype属性,实例就没法建立原型链。

闭包函数内的外部变量存放在栈还是堆?
闭包函数内的变量是存放在堆中的。函数执行完后,相应的执行上下文出栈被销毁,但是闭包函数还能引用父级函数作用域的变量是因为闭包函数的作用域链中保存了外层函数的变量对象。闭包函数就可以通过作用域链来获取外层函数作用域的变量的引用。

变量对象、作用域链、this,这些指向的都是对象值,存放在堆内存中。函数执行完后,销毁的是这些变量引用,并不是变量指向的对象值。原始值会存放在当前执行上下文的变量对象中,但是原始值也会存放在栈中,到底存放在哪儿呢?这不是一种浪费吗?@todo
todo那么堆中的 closure闭包对象有什么用呢?
JS中的闭包(closure) - jingwhale - 博客园 (cnblogs.com)

  1. var o = (function() {
  2. var person = {
  3. name: 'Vincent',
  4. age: 24,
  5. __proto__ : null // 是的你没有 看错 真的是 指向 null
  6. };
  7. return {
  8. run: function(k) {
  9. return person[k];
  10. }
  11. }
  12. }());
  13. // 在不修改代码的情况下,如何得到原有的person对象
  14. // https://www.zhihu.com/question/31840939
  15. // 我还是修改了代码
  16. var o = (function() {
  17. var person = {
  18. name: 'Vincent',
  19. age: 24,
  20. };
  21. person.get = function(){return this}.bind(person);
  22. return {
  23. run: function(k) {
  24. return person[k];
  25. }
  26. }
  27. }());
  28. o.run('get')(); //person

v8 的执行机制、执行过程是如何的,js 代码是如何执行的。
程序想要在机器上运行,首先要转换成机器码。js 解释器会先对代码进行词法和语法分析生成 ast 语法树,然后生成字节码,最后解释器根据字节码执行程序。

首先对全局代码进行解析,对代码进行词法分析,将一行行的代码分解为 token,然后进行语法分析,根据语法规则转化为 ast。解析过程中词法分析与语法分析交错进行。当生成 ast 之后,交由 Ignition 解释器转换成字节码进行执行。执行的时候再创建执行上下文。全局上下文中全局对象还要添加内置模块,将全局上下文的 this 指向全局对象。

生成 ast 语法树后,开始解释执行字节码。这解释执行的过程中会进行优化,对热点代码进行标记。如果某部分代码重复出现,那么会被标记为热点代码,会将代码编译成机器码保存下来,当下次遇到热点代码时,直接执行相应的机器码,不用再次转换成机器码。

todo如何标记热点代码的呢?字节码是什么,由谁生成?字节码如何转换成机器码的?机器码降低为字节码的原因是什么?如何比避免?
ignition 解释器和 truboFan 编译器的工作过程、执行原理?@todo

词法语法分析的产物是一颗 ast 语法树,包含了所有代码的声明语句、执行语句。

符号表的作用是什么?@todo
JS AST 原理揭秘 | 匠心博客 (zhaomenghuan.js.org)
其实更麻烦,nodejs环境实现了commonjs模块化。那么在nodejs环境中是如何处理commonjs模块化的呢?@todo

语句的执行

比较常见的语句包括变量声明、表达式、条件、循环等。

JavaScript 语句执行机制涉及的一种基础类型:Completion 类型。

  1. function foo(){
  2. try{
  3. return 0;
  4. } catch(err) {
  5. } finally {
  6. console.log("a")
  7. return 1;
  8. }
  9. }
  10. console.log(foo());

finally 确实执行了,而且 return 语句也生效了,foo() 返回了结果 0。虽然 return 执行了,但是函数并没有立即返回,又执行了 finally 里面的内容,这样的行为违背了很多人的直觉。

在 finally 中加入 return 语句,finally 中的 return “覆盖”了 try 中的 return。在一个函数中执行了两次 return,这已经超出了很多人的常识,也是其它语言中不会出现的一种行为。

如此怪异的行为,背后却是有一套机制在运作。这一机制的基础正是 JavaScript 语句执行的完成状态,我们用一个标准类型来表示:Completion Record(用于描述异常、跳出等语句执行过程)。

Completion Record 表示一个语句执行完之后的结果,它有三个字段:

  • [[type]] 表示完成的类型,有 break continue return throw 和 normal 几种类型。
  • [[value]] 表示语句的返回值,如果语句没有,则是 empty。
  • [[target]] 表示语句的目标,通常是一个 JavaScript 标签(标签在后文会有介绍)。

这个机制的控制过程也就是 js 使用 Completion Record 类型,控制语句执行的过程。
image.png

普通语句
普通语句不带控制能力。普通语句执行后,会得到 [[type]] 为 normal 的 Completion Record,JavaScript 引擎遇到这样的 Completion Record,会继续执行下一条语句。

这些语句中,只有表达式语句会产生 [[value]],当然,从引擎控制的角度,这个 value 并没有什么用处。
@todo为什么这么讲呢?还是有用的吧,要将这个值存放在内存里,返回地址。Chrome 控制台显示的正是语句的 Completion Record 的[[value]]。

语句块

  1. {
  2. var i = 1; // normal, empty, empty
  3. return i; // return, 1, empty
  4. i ++;
  5. console.log(i)
  6. } // return, 1, empty

但是假如我们在 block 中插入了一条 return 语句,产生了一个非 normal 记录,那么整个 block 会成为非 normal。这个结构就保证了非 normal 的完成类型可以穿透复杂的语句嵌套结构,产生控制效果。

控制型语句
控制类语句分成两部分,一类是对其内部造成影响,如 if、switch、while/for、try。另一类是对外部造成影响如 break、continue、return、throw,这两类语句的配合,会产生控制代码执行顺序和执行逻辑的效果,这也是我们编程的主要工作。

一般来说,for/while - break/continue 和 try - throw 这样比较符合逻辑的组合,是大家比较熟悉的,但是,实际上,我们需要 break 、continue 、return 、throw 四种类型与控制语句两两组合产生的效果。

穿透就是跳出当前结构,将当前控制语句产生的结果返回给外层代码处理。消费就是当前结构的代码可以正常处理控制语句产生的结果。每一种特殊处理情况都不一样嘛?@todo

因为 finally 中的内容必须保证执行,所以 try/catch 执行完毕,即使得到的结果是非 normal 型的完成记录,也必须要执行 finally。而当 finally 执行也得到了非 normal 记录,则会使 finally 中的记录作为整个 try 结构的结果。

带标签的语句
Completion 类型最后一个字段:target,这涉及了 JavaScript 中的一个语法,带标签的语句。实际上,任何 JavaScript 语句是可以加标签的,在语句前加冒号即可:

  1. firstStatement: var i = 1;

唯一有作用的时候是:与完成记录类型中的 target 相配合,用于跳出多层循环。
todo 后面的内容还要待整理。

作用域链

作用域就是代码中定义变量的区域(全局下,函数内),这片区域内规定了代码对变量的访问权限,以及如何查找变量。现在js有全局作用域、函数作用域、块级作用域。js采用的是静态作用域,也叫词法作用域。

静态作用域就是在函数在定义的时候就确定了函数的作用域,当函数执行的时候,先在函数内部寻找变量,如果没有就根据定义的位置,查找上一层作用域的变量。动态作用域是代码在执行的时候才能确定变量所在的作用域。

块级作用域和函数作用域的区别
变量是包含在作用域中的,js的作用域只有全局作用域、函数作用域、块级作用域。

js也不是用代码块作为块级作用域,而是通过let/const确定的。首先我们知道var声明会将声明提升到作用域的顶部。而使用let/const就不存在变量提升,当代码执行到let/const这行时才会进行声明,同时会检查当前作用域是否声明同名变量,防止声明过的变量被覆盖。块级作用域的目的时为了防止当前作用域的变量因为变量提升被覆盖,而函数作用域是规定代码对变量的访问权限,规定了如何查找变量。

var、let、const的区别
var会变量声明提升,let、const不存在声明提升;全局作用域下let、const声明的变量不会挂载到全局对象上;并且const在声明时必须初始化,后面的使用中都不能改变它的值;let、const不能重复声明同名变量,还存在暂时性死区。

const值不可变,其实需要分两种情况,如果是原始类型,const变量保存的就是原始值,确实不可变,而如果是对象类型的值,const变量保存的其实是对象的地址,对象的属性仍然可变。

暂时性死区,由let、const声明的变量在当前作用域顶部到let、const声明变量的那一行之间的区域都不能被访问,访问会报错未定义。这块区域就是暂时性死区。

const变量设置的意义,var为什么会被替换?const声明的变量具有块级作用域,不会污染全局,不能重复声明,没有变量声明提升,在全局作用域声明的变量不会挂载到全局对象上。

为什么node中重复声明同名函数会报语法错?

执行上下文是什么,包含了什么内容,有什么用?
js引擎不是一句一句解析执行代码,而是一段一段的解析执行。遇到可执行代码(全局代码、函数代码、eval代码)(这里的遇到就是说遇到函数调用。)就会进行准备工作(包含当不限于变量提升、函数提升,更确切的说是创建执行上下文)。说到底执行上下文就好比一个对象,保存了全局或函数内的变量、函数、参数、this、原型链。函数调用可能顺序执行,也可能是深层调用,为了更好的管理这些函数的作用域和调用顺序,用一个栈来管理执行上下文的调用顺序。
(2条消息) 写了这么久js,你知道js代码是在什么时候执行的么?_阿牛大牛中的博客-CSDN博客_javascript代码执行是在
JavaScript代码是怎么执行的? - 知乎 (zhihu.com)

这里有点疑惑,js引擎遇到函数调用才创建执行上下文?那遇到函数定义的时候干啥了,总不会啥事不做吧?

执行上下文中包含了三个重要属性,变量对象(variable object, VO)作用域链(scope chain)this
变量对象有哪些?包含什么内容?变量对象的创建过程?变量对象有两种,全局对象函数活动对象全局对象作用域链的顶点,在顶层作用域声明的变量都会称为全局对象的属性,在浏览器中全局变量对象就是window对象。执行上下文的代码分为两个阶段进行处理,分析执行。进入函数执行上下文的时候初始化活动对象,根据参数、函数内的变量函数声明对活动对象进行初始化。执行函数内代码的时候,根据代码修改活动对象内的属性值。

没有通过var声明的变量,不会被存放在活动对象中。

作用域链的创建过程
作用域在定义函数的时候就决定的,js引擎遇到函数声明,将上层执行上下文的作用域链赋值到函数的[[scope]]属性中。js引擎遇到函数调用,创建并进入执行上下文,先将该函数的[[scope]]属性复制到函数执行上下文的作用域链属性,然后初始化活动对象,将这个活动对象添加到函数执行上下文的作用域链属性的前面。最后确定this的指向。[[scope]]属性是函数内部属性,是个数组。

这里不是很矛盾吗?变量对象是遇到函数调用的时候创建好执行上下文,进入执行上下文的时候初始化的,js引擎遇到函数声明的时候就把外层执行上下文的变量对象保存了起来,外层上下文的变量对象不是还没定义吗?提问就是错的,js引擎遇到函数声明的时候,是将外层执行上下文的作用域链属性复制到函数的scope属性中,并不是保存外部执行上下文的变量对象。

所以说函数执行上下文到底是js引擎遇到函数声明的时候创建的还是函数调用的时候创建的?是在函数调用的时候创建的。

作用域链增强
其实js中还有第三种执行上下文,eval 内部用独立的上下文。但是除了这三种上下文,catch、with 这两种情况可以增强作用域链。增强作用域链就是在当前作用域的作用域链前添加一个执行上下文,代码执行完后将这个执行上下文删除。

with 会添加指定对象到作用域链前端,catch 会创建一个新的变量对象,这个对象里包含了错误对象。

  1. function buildUrl() {
  2. let qs = "?debug=true";
  3. with(location) {
  4. // 当访问href时会先去location里找,qs也会先去location里找,找不到再去buildurl作用域找
  5. var url = href + qs;
  6. }
  7. return url;
  8. }

原型与原型链

如何理解原型?如何理解原型链?

首先我们要明确一些概念。每个对象都有__proto_属性,普通函数有ptototype属性,函数同时也是对象,所以函数同时拥有__proto__和prototype属性。普通函数的prototype属性指向函数原型对象。

当我们用new调用函数创建实例的时候,实例对象的__proto__属性就指向了函数的原型对象,这个函数创建的实例就可以通过proto属性共享函数原型对象上的方法与属性。

js中每个对象都有[[Prototype]]内置属性,js 语言提供了Object.getPrototypeOf方法访问对象的这个内置属性,浏览器一般实现了__proto__属性来访问。

对象的__proto__属性函数的原型对象,函数的原型对象又通过__proto__属性指向上一级函数的原型对象,直到指向Object函数的原型对象为止。通过这种属性指向的方式形成了原型链。当实例自身找不到目标属性的时候就会到原型上去找,还找不到就到更上一级的原型上去找,直到找到或原型对象为null为止。

屏蔽属性

很重要的概念,和继承一块息息相关。屏蔽的意思是当对象和原型上都有同名属性时,访问对象属性时访问的是对象自身的属性,而不是原型上的同名属性。那么对象上的这个属性就是屏蔽属性。

给对象的属性赋值可能会添加屏蔽属性。对象和原型上都没有就添加在对象上。对象上没有,原型上有,那么就要看原型上属性的特性。普通数据属性可写,那就在对象上添加属性。普通数据属性只读,那就忽略该操作,严格模式下会报错。访问器属性,那就调用 setter。

可以使用 Object.definedProperty 向自身添加屏蔽属性,不受原型对象上只读数据属性和访问器属性的影响。
你不知道的js,上卷p144.

三大属性

proto 属性
__proto__访问器属性 定义在Object.prototype上,不可枚举。推荐用Object.getPrototypeOf/Reflect.getPrototypeOf替代。

constructor
数据属性,定义在构造函数的原型对象上,不可枚举。

prototype
普通函数特有的,所有函数和类的这个属性是一个对象,而且是冻结的。Function 的 prototype 和 proto 属性都指向Function.prototype。Function.prototype是一个函数。Object.prototype是所有对象的祖宗,所有对象都通过__proto__定位他。Function.prototype是所有函数的祖宗,所有函数都通过__proto__定位他。

实例构造函数之间没有直接联系,实例构造函数原型间才有直接联系。

寄生式组合和class继承

原型链继承
核心概念是Constructor.prototype = FatherInstance.__proto__。子类实例就能共享父类原型对象的属性,以及父类创建的实例属性。但是创建子类实例不能给父类函数传递参数。

  1. function Name() {
  2. this.name = 'name'
  3. }
  4. function Age() {
  5. this.age = 18
  6. }
  7. Age.prototype = new Name()
  8. let age = new Age()

组合继承

结合原型继承和构造函数继承,子类函数显示绑定调用父类函数,传入子类this,子类函数的原型对象设为父类函数的实例。子类实例不会共享父类的对象属性,创建子类实例可以传递参数。子类原型上父类属性多余,造成内存浪费;子类实例的构造函数指向的是父类函数。

  1. function Parent(value) {
  2. this.val = value;
  3. }
  4. Parent.prototype.getValue = function() {console.log(this.val);}
  5. function Child(value) {
  6. Parent.call(this, value); // 继承父类的属性
  7. }
  8. Child.prototype = new Parent(); // 继承父类函数原型上的属性
  9. var c = new Child(1);

原型式继承(**蚂蚁体验技术部小程序生态基础技术团队一面**)
分析window对象的原型关系发现,属于原型式继承。

window.__proto__.__proto__是一个对象WindowProperties,这个对象就是以EventTarget.prototype为原型创建的对象。这样做的好处是,继承了父函数原型对象的属性与方法,但是没有创建属于自己的父实例属性。而且构造函数还是指向的EventTarget

  1. var wp = Object.create(Object.create(EventTarget), {
  2. [Symbol.toStringTag]: {
  3. value: 'WindowProperties',
  4. configurable: true,
  5. writable: false,
  6. enumerable: false,
  7. }
  8. })
  9. // wp 的构造函数还是指向的EventTarget,而且通过修改Symbol.toStringTag,就算通过Object.prototype.toString.call(wp)还是检查出为EventTarget。
  10. //

Vue的数组变化检测。就是用的这样的方法,赋值一份Array.prototype,然后再。被蚂蚁面试官吊打了一波。

寄生组合式继承

解决组合继承两次调用父类函数的问题。

  1. function Parent(value) {
  2. this.val = value;
  3. }
  4. Parent.prototype.getValue = function() { console.log(this.val)}
  5. function Child(Value) {
  6. Parent.call(this, value);
  7. }
  8. Child.prototype = Object.create(Parent.prototype, { // 更精确,只获取原型函数的原型对象。子类实例的构造函数还是指向子类函数。
  9. constructor: {
  10. value: Child,
  11. enumerable: false,
  12. writable: true,
  13. configurable: true
  14. }
  15. })
  16. var c = new Child(111);
  17. c.getValue();

class继承的实现
  1. // 类必须用 new 调用,与普通函数不同
  2. class Point {
  3. static saticPro2 = 'ggg';
  4. static staticFn() {
  5. console.log('静态方法');
  6. console.log(this === Point);
  7. }
  8. #privatePro = 0;
  9. instancePro1 = '22';// 实例属性
  10. constructor() {
  11. console.log('构造函数');
  12. this.instancePro = '11';
  13. this.instanceFn = () => console.log('实例方法');
  14. }
  15. // node 环境会报错,不能识别。
  16. #privateFn() {
  17. console.log('私有方法');
  18. console.log(this); // this指向实例
  19. }
  20. // 原型方法不可枚举
  21. protoFn() {
  22. console.log('原型方法');
  23. this.#privateFn();
  24. }
  25. }

class通过super()、extends实现继承:

  1. class Father {
  2. constructor(value) {
  3. this.val = value;
  4. }
  5. getValue() {
  6. console.log(this.val);
  7. }
  8. }
  9. class Child extends Father {
  10. constructor(value) {
  11. super(value); // 类似于`Father.call(this, value)`,但是在继承原生对象构造函数的情况下,继承父类实例属性时是有区别的。es5中是先创建子类实例对象,再通过Father.call(this)获得父类实例属性,而es6是先创建父类实例对象,再用子类构造函数修饰this,这样来继承父类的所有行为。
  12. this.val = value;
  13. }
  14. }
  15. var c = new Child(11111);
  16. c.getValue();

class的本质就是函数。
重点在于super和extends的实现

super(待补充)

super 可以作为函数,也可以作为对象。super 作为函数调用时相当于调用父类构造函数,只能在子类构造函数中调用,super 函数中的this指向的是子类实例,即Father.prototype.constructor.call(this)
super 作为对象时,在普通方法中指向父类原型对象,在子类静态方法中指向父类。

super 在普通方法中作为对象调用父类原型方法时内部this指向子实例。如果super调用父类静态方法时内部this指向子类。

extends

首先我们知道class继承有两条继承链,从阮老师的es6教程里可知。一条链是子函数的原型指向父函数,另一条链是子函数原型对象的原型指向父函数原型对象。
而 extends 的实现就是用寄生式组合继承实现了第二条链,再设置子函数的原型指向父函数,就完成了第二条链。

通过将子类函数的原型设置为父类函数,子函数原型对象的原型设置为父类函数原型对象。然后在像组合继承那样在子类函数中call调用父类函数。使用Reflect.construct会将target函数内的new.target自动指向target。
深入理解 Class 和 extends 原理 - 掘金 (juejin.cn)

Reflect.consturct与Object.create 待补充

Reflect.construct(target, args[, newTarget]),类似于new操作符,new target(…args)。newTarget可以指定新创建对象原型对象的构造函数。

instanceof 的实现

基本原理就是通过原型链判断对象的原型链中是否有构造函数的原型对象prototype

  1. function myInstanceof(left, constructor) {
  2. if (typeof constructor !== 'function') throw new TypeError('right-hand is not a function');
  3. if ((typeof left !== 'object' && typeof left !== 'function') || left === null) return false;
  4. left = left.__proto__;
  5. while(true) {
  6. if (left == null) return false;
  7. if (left === constructor.prototype) return true;
  8. left = left.__proto__;
  9. }
  10. }

模块化

为什么要实现模块化?实现模块化有哪几种方式?各有什么特点?
模块化可以将相似功能的代码组织起来,形成相对独立的功能模块,想使用什么功能,就调用什么模块,方便了代码的使用。此外,模块化还可以:解决命名冲突(ES6前是使用对象和闭包来实现的命名空间,命名空间也是为了避免与使用了相同名字的变量和方法的js脚本造成冲突);提高代码可复用性;提高代码可维护性。

模块化有9种方式,单一全局变量、前缀命名空间、对象文字表示、立即执行函数、cjs规范、amd、umd、cmd、es6 modules。

单一全局变量
匿名函数返回一个对象,包含方法和属性。

  1. var mynamespace = (function() {
  2. var sayName = function() {}
  3. var age = 11;
  4. return {
  5. sayName: sayName,
  6. age: age,
  7. }
  8. })();
  9. // 缺点:其他js代码可能使用相同名的全局变量

前缀命名空间
在自己脚本内运行的属性和方法,名字前都加上前缀。

  1. var mynamespace_propertyA = {};
  2. var mynamespace_propertyA = {};
  3. var mynamespace_methods = function() {
  4. // ...
  5. }
  6. // 缺点:随着应用的增长,会产生大量的全局对象。

对象文字表示
定义一个对象,对象里保存属性和方法,通过点运算符来调用属性和方法。

  1. var mynamespace = {
  2. age: 1,
  3. models: {},
  4. getInfo: function() {},
  5. views: {},
  6. methods: {}
  7. }
  8. // 缺点:可能会导致较长的语法。
  • 检查变量是否存在(对象或插件命名空间),如果不存在就定义该变量
    1. var mynamespace = mynamespace || {};
    2. if (!mynamespace) mynamespace = {};
    3. window.mynamespace || (mynamespace = {});
    4. var mynamespace = $.fn.mynamespace = function() {};
    5. var mynamespace = mynamespace === undefined ? {} : mynamespace;
    立即执行函数(IIFE)
    早期比较常见的手段,可以传递参数来扩展命名空间对象。
    1. // 其实可以和前面的对象文字方法结合使用
    2. (function (o) {
    3. o.bar = "111";
    4. o.sayName = function() {
    5. console.log(o.bar);
    6. }
    7. }(mynamespace = mynamespace || {}))
    8. // 缺点:无法保证命名空间对象中已经存在相同名称的子属性。
    CommonJS规范
    随着nodejs项目的诞生,该规范应运而生。主要用于服务器端。
    1. // myApp.js
    2. var age = 11;
    3. var name = "hy";
    4. function sayName() {
    5. console.log(name);
    6. }
    7. module.exports = {
    8. age,
    9. name
    10. }
    11. export.sayName = sayName;
    12. // main.js
    13. var myApp = require('myApp');
    14. console.log(myApp.age);
    15. console.log(myApp.name);
    16. var { sayName } = require('myApp');
    17. sayName();
    实现原理
    当前模块遇到require,先找到模块的绝对路径,然后获取缓存,没有缓存的话就判断是否为内置模块,为该资源创建Module对象,然后执行模块的load方法,获取模块后缀名,根据后缀名调用不同的加载方法,在加载方法内获取到对应的模块文件,这里对文件内容做了改造,改造为compiledWrapper函数,执行这个函数。
    重点就在于这个**compiledWrapper**函数。
    (1 封私信 / 40 条消息) 为什么 Node.js 不给每一个.js文件以独立的上下文来避免作用域被污染? - 知乎 (zhihu.com)
    查找模块路径的逻辑
  1. 如果请求的内部模块,直接返回内部模块
  2. 如果以./或../开头,根据当前模块的父模块,确定请求模块的绝对路径,如果没带文件后缀名,那么就依次去查找.js/.json/.node,如果当作目录,就去查找package.json/index.js/index.json/index.node文件
  3. 如果请求资源不带路径,那么根据当前模块的父模块,确定请求模块可能在的目录,依次在每个目录中进行第2步查找。

遇到require语句的执行代码,重点理解Module对象compiledWrapper函数。
来来来,探究一下CommonJs的实现原理 - 掘金 (juejin.cn)

AMD规范
由于CommonJS规范的诞生,客户端也想要一个标准的模块化规范。 由于nodejsjs文件都是放在本地硬盘,客户端的js文件都放在服务器端,CommonJS提供的全局方法require是同步加载,客户端会同步等待js加载完毕,可能会造成长时间页面没有响应,因此需要一个异步加载方法,AMD规范出现了。

UMD规范
由社区自发组织,整合了CommonJSAMD,其目的是想同时适用于服务器端和客户端。

CMD规范
吸收了CommonJS的优点,改进了AMD,针对AMD依赖模块的执行采用预加载+主代码块调用模块时才调用模块内的代码

ES6 Modules规范

使用import/export进行导入导出。输出的是值得引用。编译时加载,两个非常重要的特点是import会优先于其他代码执行,export会变量提升。
深入理解 ES6 模块机制 - 知乎 (zhihu.com)

与 cjs 之间的3点区别:
CommonJS导出的是值的拷贝,外部改变原始值模块内部不会改变,如果是对象值就是浅拷贝ES6 Modules导出的是值的引用,只读。
CommonJS使用require/module.exports/exports导入导出,是同步加载,ES6 Modules使用import/export/default export导入导出,是异步加载,有独立的模块依赖的解析阶段。
CommonJS是运行时加载,ES6 Modules是编译时加载。import不能放在条件语句,或者使用拼接字符串import模块。ES6 Modules在代码静态解析阶段就确定了模块依赖关系,我理解的是去依赖模块里查找export导出的变量,创建变量的引用。后面在代码运行时才去执行模块中的代码。

如何解决依赖循环
CommonJS在加载时就会执行模块代码,一旦出现某个模块被循环加载,就只输出已经执行的部分,还没有执行的部分就不会输出。

  1. // a.js
  2. console.log('a.js start');
  3. exports.done = false;
  4. var b = require('./b.js');
  5. console.log('a.js 中,b.done = ' + b.done);
  6. console.log('a.js end');
  7. // b.js
  8. console.log('b.js start');
  9. exports.done = false;
  10. var a = require('./a.js'); // 发现循环加载,就去a.js模块的缓存里取出已经执行的值。
  11. console.log('b.js中,a.done = ' + a.done);
  12. console.log('b.js end');
  13. // main.js
  14. console.log('main.js start');
  15. var a = require('./a.js');
  16. console.log('main.js中,a.done = ', a.done);
  17. console.log('main.js end');

ES6模块化的import / export 具有变量提升,会提升到作用域的顶部,在编译解析时会根据 import 语句先去加载导入模块,得到模块内 export 暴露的值,暴露的是值的引用。当所有的依赖都加载完毕后才会指向当前模块的剩余代码。加载依赖的过程中如果遇到某个模块被循环加载,就不进行加载,执行当前脚本内剩下代码。

  1. // aa.js
  2. console.log('aa.js start');
  3. import { bbdone as done } from './bb.js';
  4. console.log('aa.js中,done = ' + done);
  5. export var aadone = 'done';
  6. console.log('aa.js end');
  7. // bb.js
  8. console.log('bb.js start');
  9. import { aadone as done } from './aa.js';
  10. console.log('bb.js中,done = ' + done);
  11. export var bbdone = 'done';
  12. console.log('bb.js end');
  13. // main.js
  14. console.log('main.js start');
  15. import { aadone as done } from './aa.js';
  16. console.log('main.js中,a.done = ', done);
  17. console.log('main.js end');
  18. /**
  19. * 运行 main.js,先去加载 aa.js,发现依赖了 bb.js,又去加载 bb.js,在bb.js里发现
  20. * 又加载了 aa.js,发现依赖循环加载了,就执行 bb.js 中剩余代码逻辑。执行完返回 aa.js 中
  21. * 执行aa.js剩余代码逻辑,依次。
  22. */

其他

解释型语言与编译型语言的区别?

js 并不是一门单纯的解释型语言,js 代码被解析成 ast 抽象语法树之后,会被解释器转换为字节码,也可能被编译器转换为机器码,所以 js 并不是单纯的解释型语言。这种字节码与解释器和编译器结合的技术叫做即时编译,jit。

解释器和编译器的区别在于解释器不会编译生成二进制文件。
(2.4w字,建议收藏)😇原生JS灵魂之问(下), 冲刺🚀进阶最后一公里(附个人成长经验分享) - 掘金 (juejin.cn)

面向对象编程、过程式编程、函数式编程的区别?

面向对象语言的4大特性,封装、抽象、继承、多态。封装主要是为了隐藏数据、保护数据,提高代码可维护性。抽象让调用者主要关注功能,隐藏实现细节,可扩展。继承为了解决代码复用问题,子类可以共享父类的方法属性。多态就是子类实例在运行时可以调用子类的方法。

面向对象的设计一定好吗
不一定,从继承的角度来讲,这样的设计有巨大的隐患。继承的最大问题在于无法决定继承哪些属性,所有属性都得继承。如果为了满足子类的特性再创建一个父类,铁定是有问题的,一方面父类无法描述所有子类的细节,为了满足子类特性而增加不同的父类,反而增加了重复代码。另一方面,一旦子类有所改动,父类也要进行相应的更新,代码耦合度太高,不好维护。

所以出现了函数组合。与函数式编程不同,函数式编程会出现洋葱代码。(待学习…)

高阶函数

高阶函数就是参数是函数或者返回值是一个函数。数组中有很多方法都是高阶函数。

节流、防抖
防抖用于减少用户的频繁操作,设定用户连续两次操作之间的最少时间间隔,在设定的时间间隔内连续操作只会响应第一次或重置定时器只响应最后一次的操作。用定时器来实现,可以在开启定时器之前执行或者定时器到时再执行。
比如用户觉得页面卡顿,频繁点击按钮发起网络请求,刷新页面,造成不必要的开销,可以设置一个防抖的点击函数给按钮,在接收到用户的连续点击时重置定时器,只响应用户的最后一次操作。

节流关注更连续的操作,比如浏览器的滚动条滚动、鼠标的移动,1 秒可能就会发生成百上千次变化。如果想针对鼠标移动事件进行处理的话,为了避免开销,只希望500ms或者1s执行一次处理程序里的逻辑,这时候就可以用节流。