winter重学前端——训练营预习课

tech2025-07-28  2

重学前端读书笔记

javascriptjavascript 类型javascript对象面向对象与基于对象模拟类JavaScript 中的对象分类 javascript 执行Promise里的代码为什么比setTimeout先执行?闭包和执行上下文你知道现在有多少种函数吗try里面放return,finally还会执行吗

javascript

javascript 类型

javascript类型有以下几种: Undefined;Null;Boolean;String;Number;Symbol;Object。

为什么有的编程规范要求用 void 0 代替 undefined? Undefined 类型表示未定义,它的类型只有一个值,就是 undefined。任何变量在赋值前是 Undefined 类型、值为 undefined,一般我们可以用全局变量 undefined(就是名为 undefined 的这个变量)来表达这个值,或者 void 运算来把任意一个表达式变成 undefined 值。但是呢,因为 JavaScript 的代码 undefined 是一个变量,而并非是一个关键字,这是 JavaScript 语言公认的设计失误之一,所以,我们为了避免无意中被篡改,我建议使用 void 0 来获取 undefined 值。 void的使用规则可以参考:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/void

字符串有最大长度吗? String有最大长度是 2^53 - 1,这在一般开发中都是够用的,但是有趣的是,这个所谓最大长度,并不完全是你理解中的字符数。因为 String 的意义并非“字符串”,而是字符串的 UTF16 编码,我们字符串的操作 charAt、charCodeAt、length 等方法针对的都是 UTF16 编码。所以,字符串的最大长度,实际上是受字符串的编码长度影响的。

0.1 + 0.2 不是等于 0.3 么?为什么 JavaScript 里不是这样的? Number 类型表示我们通常意义上的“数字”。这个数字大致对应数学中的有理数,当然,在计算机中,我们有一定的精度限制。JavaScript 中的 Number 类型有 18437736874454810627(即 264-253+3) 个值。

console.log( Math.abs(0.1 + 0.2 - 0.3) <= Number.EPSILON);

ES6 新加入的 Symbol 是个什么东西? Symbol 是 ES6 中引入的新类型,它是一切非字符串的对象 key 的集合,在 ES6 规范中,整个对象系统被用 Symbol 重塑。 Symbol 可以具有字符串类型的描述,但是即使描述相同,Symbol 也不相等。我们创建 Symbol 的方式是使用全局的 Symbol 函数。例如:

var mySymbol = Symbol("my symbol");

一些标准中提到的 Symbol,可以在全局的 Symbol 函数的属性中找到。例如,我们可以使用 Symbol.iterator 来自定义 for…of 在对象上的行为:

var o = new Object o[Symbol.iterator] = function() { var v = 0 return { next: function() { return { value: v++, done: v > 10 } } } }; for(var v of o) console.log(v); // 0 1 2 3 ... 9

代码中我们定义了 iterator 之后,用 for(var v of o) 就可以调用这个函数,然后我们可以根据函数的行为,产生一个 for…of 的行为。

为什么给对象添加的方法能用在基本类型上? JavaScript 语言设计上试图模糊对象和基本类型之间的关系,我们日常代码可以把对象的方法在基本类型上使用,比如:

console.log("abc".charAt(0)); //a

甚至我们在原型上添加方法,都可以应用于基本类型,所以答案就是. 运算符提供了装箱操作,它会根据基础类型构造一个临时对象,使得我们能在基础类型上调用对应对象的方法。

javascript对象

面向对象与基于对象

对象定义

一个可以触摸或者可以看见的东西;人的智力可以理解的东西;可以指导思考或行动(进行想象或施加动作)的东西。

对象特点

对象具有唯一标识性:即使完全相同的两个对象,也并非同一个对象。对象有状态:对象具有状态,同一对象可能处于不同状态之下。对象具有行为:即对象的状态,可能因为它的行为产生变迁。

JS对象的特点 在实现了对象基本特征的基础上, JavaScript 中对象独有的特色是:对象具有高度的动态性,这是因为 JavaScript 赋予了使用者在运行时为对象添改状态和行为的能力。

var o = { a: 1 }; o.b = 2; console.log(o.a, o.b); //1 2

上面代码展现了运行时如何向一个对象添加属性,一开始定义了一个对象 o,定义完成之后,再添加它的属性 b,这样操作是完全没问题的。 为了提高抽象能力,JavaScript 的属性被设计成比别的语言更加复杂的形式,它提供了数据属性和访问器属性(getter/setter)两类。

JavaScript 对象的两类属性

对 JavaScript 来说,属性并非只是简单的名称和值,JavaScript 用一组特征(attribute)来描述属性(property)。 先来说第一类属性,数据属性。它比较接近于其它语言的属性概念。数据属性具有四个特征。

value:就是属性的值。writable:决定属性能否被赋值。enumerable:决定 for in 能否枚举该属性。configurable:决定该属性能否被删除或者改变特征值。

在大多数情况下,我们只关心数据属性的值即可。

第二类属性是访问器(getter/setter)属性,它也有四个特征。

getter:函数或 undefined,在取属性值时被调用。setter:函数或 undefined,在设置属性值时被调用。enumerable:决定 for in 能否枚举该属性。configurable:决定该属性能否被删除或者改变特征值。

我们通常用于定义属性的代码会产生数据属性,其中的 writable、enumerable、configurable 都默认为 true。我们可以使用内置函数 getOwnPropertyDescripter 来查看,如以下代码所示:

var o = { a: 1 }; o.b = 2; //a和b皆为数据属性 Object.getOwnPropertyDescriptor(o,"a") // {value: 1, writable: true, enumerable: true, configurable: true} Object.getOwnPropertyDescriptor(o,"b") // {value: 2, writable: true, enumerable: true, configurable: true}

如果我们要想改变属性的特征,或者定义访问器属性,我们可以使用 Object.defineProperty,示例如下:

var o = { a: 1 }; Object.defineProperty(o, "b", {value: 2, writable: false, enumerable: false, configurable: true}); //a和b都是数据属性,但特征值变化了 Object.getOwnPropertyDescriptor(o,"a"); // {value: 1, writable: true, enumerable: true, configurable: true} Object.getOwnPropertyDescriptor(o,"b"); // {value: 2, writable: false, enumerable: false, configurable: true} o.b = 3; //因为 writable 特征为 false,所以我们重新对 b 赋值,b 的值不会发生变化。 console.log(o.b); // 2

在创建对象时,也可以使用 get 和 set 关键字来创建访问器属性,代码如下所示:

var o = { get a() { return 1 } }; console.log(o.a); // 1

这样,我们就理解了,实际上 JavaScript 对象的运行时是一个“属性的集合”,属性以字符串或者 Symbol 为 key,以数据属性特征值或者访问器属性特征值为 value。

模拟类

我们已经可以理解为什么会有“JavaScript 不是面向对象”这样的说法了。这是由于 JavaScript 的对象设计跟目前主流基于类的面向对象差异非常大。这样的对象系统设计虽然特别,但是 JavaScript 提供了完全运行时的对象系统,这使得它可以模仿多数面向对象编程范式。

JavaScript 本身就是面向对象的,它并不需要模拟,只是它实现面向对象的方式和主流的流派不太一样,所以才让很多人产生了误会。那么,随着我们理解的思路继续深入,这些“模拟面向对象”,实际上做的事情就是“模拟基于类的面向对象”。

原型 原型系统的“复制操作”有两种实现思路:

一个是并不真的去复制一个原型对象,而是使得新对象持有一个原型的引用;另一个是切实地复制对象,从此两个对象再无关联。

历史上的基于原型语言因此产生了两个流派,显然,JavaScript 显然选择了前一种方式。

JavaScript 的原型

如果我们抛开 JavaScript 用于模拟 Java 类的复杂语法设施(如 new、Function Object、函数的 prototype 属性等),原型系统可以说相当简单,我可以用两条概括:

如果所有对象都有私有字段[[prototype]],就是对象的原型;读一个属性,如果对象本身没有,则会继续访问对象的原型,直到原型为空或者找到为止。

但从 ES6 以来,JavaScript 提供了一系列内置函数,以便更为直接地访问操纵原型。三个方法分别为:Object.create 根据指定的原型创建新对象,原型可以是 null;Object.getPrototypeOf 获得一个对象的原型;Object.setPrototypeOf 设置一个对象的原型。利用这三个方法,我们可以完全抛开类的思维,利用原型来实现抽象和复用。我用下面的代码展示了用原型来抽象猫和虎的例子。

var cat = { say(){ console.log("meow~"); }, jump(){ console.log("jump"); } } var tiger = Object.create(cat, { say:{ writable:true, configurable:true, enumerable:true, value:function(){ console.log("roar!"); } } }) var anotherCat = Object.create(cat); anotherCat.say(); var anotherTiger = Object.create(tiger); anotherTiger.say();

早期版本中的类与原型 prototype 在早期版本的 JavaScript 中,“类”的定义是一个私有属性 [[class]],语言标准为内置类型诸如 Number、String、Date 等指定了[[class]]属性,以表示它们的类。语言使用者唯一可以访问[[class]]属性的方式是 Object.prototype.toString。 因此,在 ES3 和之前的版本,JS 中类的概念是相当弱的,它仅仅是运行时的一个字符串属性。在 ES5 开始,[[class]] 私有属性被 Symbol.toStringTag 代替,Object.prototype.toString 的意义从命名上不再跟 class 相关。我们甚至可以自定义 Object.prototype.toString 的行为,以下代码展示了使用 Symbol.toStringTag 来自定义 Object.prototype.toString 的行为:

var o = { [Symbol.toStringTag]: "MyObject" } console.log(o + "");

new 我们要把 new 理解成 JavaScript 面向对象的一部分. new 运算接受一个构造器和一组调用参数,实际上做了几件事:

以构造器的 prototype 属性(注意与私有字段[[prototype]]的区分)为原型,创建新对象;将 this 和调用参数传给构造器,执行;如果构造器返回的是对象,则返回,否则返回第一步创建的对象。 以下展示构造器模拟类的两种方法: function c1(){ this.p1 = 1; this.p2 = function(){ console.log(this.p1); } } var o1 = new c1; o1.p2(); function c2(){ } c2.prototype.p1 = 1; c2.prototype.p2 = function(){ console.log(this.p1); } var o2 = new c2; o2.p2();

第一种方法是直接在构造器中修改 this,给 this 添加属性。第二种方法是修改构造器的 prototype 属性指向的对象,它是从这个构造器构造出来的所有对象的原型。

ES6 中的类 在新的 ES 版本中,我们不再需要模拟类了:我们有了光明正大的新语法。而原型体系同时作为一种编程范式和运行时机制存在。我们可以自由选择原型或者类作为代码的抽象风格,但是无论我们选择哪种,理解运行时的原型系统都是很有必要的一件事。 比起早期的原型模拟方式,使用 extends 关键字自动设置了 constructor,并且会自动调用父类的构造函数,这是一种更少坑的设计。所以当我们使用类的思想来设计代码时,应该尽量使用 class 来声明类,而不是用旧语法,拿函数来模拟对象。一些激进的观点认为,class 关键字和箭头运算符可以完全替代旧的 function 关键字,它更明确地区分了定义函数和定义类两种意图,我认为这是有一定道理的。

JavaScript 中的对象分类

宿主对象(host Objects):由 JavaScript 宿主环境提供的对象,它们的行为完全由宿主环境决定。内置对象(Built-in Objects):由 JavaScript 语言提供的对象。 固有对象(Intrinsic Objects ):由标准规定,随着 JavaScript 运行时创建而自动创建的对象实例。 原生对象(Native Objects):可以由用户通过 Array、RegExp 等内置构造器或者特殊语法创建的对象。 普通对象(Ordinary Objects):由{}语法、Object 构造器或者 class 关键字定义类创建的对象,它能够被原型继承。

宿主对象 JavaScript 宿主对象千奇百怪,但是前端最熟悉的无疑是浏览器环境中的宿主了。 宿主对象也分为固有的和用户可创建的两种,比如 document.createElement 就可以创建一些 DOM 对象。宿主也会提供一些构造器,比如我们可以使用 new Image 来创建 img 元素等。

内置对象·原生对象 用对象来模拟函数与构造器:函数对象与构造器对象

任何对象只需要实现[[call]],它就是一个函数对象,可以去作为函数被调用。而如果它能实现[[construct]],它就是一个构造器对象,可以作为构造器被调用。 当然了,用户用 function 关键字创建的函数必定同时是函数和构造器。不过,它们表现出来的行为效果却并不相同。对于宿主和内置对象来说,它们实现[[call]](作为函数被调用)和[[construct]](作为构造器被调用)不总是一致的。比如内置对象 Date 在作为构造器调用时产生新的对象,作为函数时,则产生字符串。

console.log(typeof new Date()) // object console.log(typeof Date()) // string

而浏览器宿主环境中,提供的 Image 构造器,则根本不允许被作为函数调用。

console.log(new Image); console.log(Image());//抛出错误

再比如基本类型(String、Number、Boolean),它们的构造器被当作函数调用,则产生类型转换的效果。值得一提的是,在 ES6 之后 => 语法创建的函数仅仅是函数,它们无法被当作构造器使用。 特殊行为的对象 除了上面介绍的对象之外,在固有对象和原生对象中,有一些对象的行为跟正常对象有很大区别。它们常见的下标运算(就是使用中括号或者点来做属性访问)或者设置原型跟普通对象不同,这里我简单总结一下。

Array:Array 的 length 属性根据最大的下标自动发生变化。Object.prototype:作为所有正常对象的默认原型,不能再给它设置原型了。String:为了支持下标运算,String 的正整数属性访问会去字符串里查找。Arguments:arguments 的非负整数型下标属性跟对应的变量联动。模块的 namespace 对象:特殊的地方非常多,跟一般对象完全不一样,尽量只用于 import 吧。类型数组和数组缓冲区:跟内存块相关联,下标运算比较特殊。bind 后的 function:跟原来的函数相关联。

javascript 执行

Promise里的代码为什么比setTimeout先执行?

当拿到一段 JavaScript 代码时,浏览器或者 Node 环境首先要做的就是;传递给 JavaScript 引擎,并且要求它去执行。然而,执行 JavaScript 并非一锤子买卖,宿主环境当遇到一些事件时,会继续把一段代码传递给 JavaScript 引擎去执行,此外,我们可能还会提供 API 给 JavaScript 引擎,比如 setTimeout 这样的 API,它会允许 JavaScript 在特定的时机执行。 所以,我们首先应该形成一个感性的认知:一个 JavaScript 引擎会常驻于内存中,它等待着我们(宿主)把 JavaScript 代码或者函数传递给它执行。在 ES3 和更早的版本中,JavaScript 本身还没有异步执行代码的能力,这也就意味着,宿主环境传递给 JavaScript 引擎一段代码,引擎就把代码直接顺次执行了,这个任务也就是宿主发起的任务。但是,在 ES5 之后,JavaScript 引入了 Promise,这样,不需要浏览器的安排,JavaScript 引擎本身也可以发起任务了。由于我们这里主要讲 JavaScript 语言,那么采纳 JSC 引擎的术语,我们把宿主发起的任务称为宏观任务,把 JavaScript 引擎发起的任务称为微观任务。

宏观和微观任务 在底层的 C/C++ 代码中,这个事件循环是一个跑在独立线程中的循环,我们用伪代码来表示,大概是这样的:

while(TRUE) { r = wait(); execute(r); }

这里每次的执行过程,其实都是一个宏观任务。我们可以大概理解:宏观任务的队列就相当于事件循环。在宏观任务中,JavaScript 的 Promise 还会产生异步代码,JavaScript 必须保证这些异步代码在一个宏观任务中完成,因此,每个宏观任务中又包含了一个微观任务队列: 有了宏观任务和微观任务机制,我们就可以实现 JavaScript 引擎级和宿主级的任务了,例如:Promise 永远在队列尾部添加微观任务。setTimeout 等宿主 API,则会添加宏观任务。

Promise

var r = new Promise(function(resolve, reject){ console.log("a"); resolve() }); setTimeout(()=>console.log("d"), 0) r.then(() => console.log("c")); console.log("b")

我们发现,不论代码顺序如何,d 必定发生在 c 之后,因为 Promise 产生的是 JavaScript 引擎内部的微任务,而 setTimeout 是浏览器 API,它产生宏任务。

setTimeout(()=>console.log("d"), 0) var r = new Promise(function(resolve, reject){ resolve() }); r.then(() => { var begin = Date.now(); while(Date.now() - begin < 1000); console.log("c1") new Promise(function(resolve, reject){ resolve() }).then(() => console.log("c2")) });

这里我们强制了 1 秒的执行耗时,这样,我们可以确保任务 c2 是在 d 之后被添加到任务队列。我们可以看到,即使耗时一秒的 c1 执行完毕,再 enque 的 c2,仍然先于 d 执行了,这很好地解释了微任务优先的原理。 通过一系列的实验,我们可以总结一下如何分析异步执行的顺序:

首先我们分析有多少个宏任务;在每个宏任务中,分析有多少个微任务;根据调用次序,确定宏任务中的微任务执行次序;根据宏任务的触发规则和调用次序,确定宏任务的执行次序;确定整个顺序。 稍微复杂的例子: function sleep(duration) { return new Promise(function(resolve, reject) { console.log("b"); setTimeout(resolve,duration); }) } console.log("a"); sleep(5000).then(()=>console.log("c")); // a b c

新特性:async/await async/await 是 ES2016 新加入的特性,它提供了用 for、if 等代码结构来编写异步的方式。 async 函数必定返回 Promise,我们把所有返回 Promise 的函数都可以认为是异步函数。async 函数是一种特殊语法,特征是在 function 关键字之前加上 async 关键字,这样,就定义了一个 async 函数,我们可以在其中使用 await 来等待一个 Promise。 async 函数强大之处在于,它是可以嵌套的。我们在定义了一批原子操作的情况下,可以利用 async 函数组合出新的 async 函数。

function sleep(duration) { return new Promise(function(resolve, reject) { setTimeout(resolve,duration); }) } async function foo(name){ await sleep(2000) console.log(name) } async function foo2(){ await foo("a"); await foo("b"); }

闭包和执行上下文

闭包 我们可以这样简单理解一下,闭包其实只是一个绑定了执行环境的函数,这个函数并不是印在书本里的一条简单的表达式,闭包与普通函数的区别是,它携带了执行的环境,就像人在外星中需要自带吸氧的装备一样,这个函数也带有在程序中生存的环境。 这个古典的闭包定义中,闭包包含两个部分。

环境部分 环境 标识符列表 表达式部分 执行上下文:执行的基础设施 执行上下文在 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:仅生成器上下文有这个属性,表示当前生成器。

var 声明与赋值 var 声明作用域函数执行的作用域。也就是说,var 会穿透 for 、if 等语句。在只有 var,没有 let 的旧 JavaScript 时代,诞生了一个技巧,叫做:立即执行的函数表达式(IIFE),通过创建一个函数,并且立即执行,来构造一个新的域,从而控制 var 的范围。 由于语法规定了 function 关键字开头是函数声明,所以要想让函数变成函数表达式,我们必须得加点东西,最常见的做法是加括号。 但是,括号有个缺点,那就是如果上一行代码不写分号,括号会被解释为上一行代码最末的函数调用,产生完全不符合预期,并且难以调试的行为,加号等运算符也有类似的问题。所以一些推荐不加分号的代码风格规范,会要求在括号前面加上分号。 比较推荐的写法是使用 void 关键字。也就是下面的这种形式。

void function(){ var a; //code }();

这有效避免了语法问题,同时,语义上 void 运算表示忽略后面表达式的值,变成 undefined,我们确实不关心 IIFE 的返回值,所以语义也更为合理。

let let 是 ES6 开始引入的新的变量声明模式,比起 var 的诸多弊病,let 做了非常明确的梳理和规定。为了实现 let,JavaScript 在运行时引入了块级作用域。也就是说,在 let 出现之前,JavaScript 的 if for 等语句皆不产生作用域。简单统计了下,以下语句会产生 let 使用的作用域:

for;if;switch;try/catch/finally

Realm Realm 中包含一组完整的内置对象,而且是复制关系。对不同 Realm 中的对象操作,会有一些需要格外注意的问题,比如 instanceOf 几乎是失效的。以下代码展示了在浏览器环境中获取来自两个 Realm 的对象,它们跟本土的 Object 做 instanceOf 时会产生差异:

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

你知道现在有多少种函数吗

函数 第一种,普通函数:用 function 关键字定义的函数。 第二种,箭头函数:用 => 运算符定义的函数。 第三种,方法:在 class 中定义的函数。 第四种,生成器函数:用 function * 定义的函数。 第五种,类:用 class 定义的类,实际上也是函数。 第六 / 七 / 八种,异步函数:普通函数、箭头函数和生成器函数加上 async 关键字。 this 关键字的行为 this 是执行上下文中很重要的一个组成部分。同一个函数调用方式不同,得到的 this 值也不同。 我们对 this 的解释已经非常清晰了:调用函数时使用的引用,决定了函数执行时刻的 this 值。实际上从运行时的角度来看,this 跟面向对象毫无关联,它是与函数调用时使用的表达式相关。

生成器函数、异步生成器函数和异步普通函数跟普通函数行为是一致的,异步箭头函数与箭头函数行为是一致的。

this 关键字的机制 JavaScript 用一个栈来管理执行上下文,这个栈中的每一项又包含一个链表。如下图所示: 当函数调用时,会入栈一个新的执行上下文,函数调用结束时,执行上下文被出栈。而 this 则是一个更为复杂的机制,JavaScript 标准定义了 [[thisMode]] 私有属性。 [[thisMode]] 私有属性有三个取值。

lexical:表示从上下文中找 this,这对应了箭头函数。global:表示当 this 为 undefined 时,取全局对象,对应了普通函数。strict:当严格模式时使用,this 严格按照调用时传入的值,可能为 null 或者 undefined。 操作 this 的内置函数 Function.prototype.call 和 Function.prototype.apply 可以指定函数调用时传入的 this 值。 有趣的是,call、bind 和 apply 用于不接受 this 的函数类型如箭头、class 都不会报错。这时候,它们无法实现改变 this 的能力,但是可以实现传参。

try里面放return,finally还会执行吗

Completion类型 先来看第一段代码:

function foo(){ try{ return 0; } catch(err) { } finally { console.log("a") } } console.log(foo());

通过实际试验,我们可以看到,finally 确实执行了,而且 return 语句也生效了,foo() 返回了结果 0。 如果在这个例子中,我们在 finally 中加入 return 语句,会发生什么呢?

function foo(){ try{ return 0; } catch(err) { } finally { return 1; } } console.log(foo());

通过实际执行,我们看到,finally 中的 return “覆盖”了 try 中的 return。在一个函数中执行了两次 return。 面对如此怪异的行为,我们当然可以把它作为一个孤立的知识去记忆,但是实际上,这背后有一套机制在运作。这一机制的基础正是 JavaScript 语句执行的完成状态,我们用一个标准类型来表示:Completion Record。

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

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

js中的语句分类:

最新回复(0)