0-introduction
-
引言与感谢
- 演讲者 Kyle Simpson 感谢 Mark (Frontend Masters 创始人) 给了他七年前开始教学生涯的机会。
- 正是在第一次工作坊的教学过程中,他意识到教学是自己的使命。
-
课程核心:深入理解 JavaScript
- 这门课程将深入探讨 JavaScript,超越大多数人习惯的层面。
- 背景: 演讲者以 GETIFY 的网名在线上活跃,并著有《你不知道的 JS》系列书籍。
- 本课程内容与该系列的前三本书(《作用域与闭包》、《this 与对象原型》、《类型与语法》)密切相关。
- 这些书籍是课程结束后深入学习的绝佳资源。
-
为什么要深入学习 JavaScript?
-
当前趋势: 很多人不再直接编写 JavaScript,而是使用 TypeScript、Go、Closure 等语言,或者依赖 Babel 等工具进行多层转换。
-
核心问题: 既然如此,为什么还要深入学习和理解原生 JavaScript?
-
示例:
++操作符的误解-
普遍认知:
let x = 40; console.log(x++); // 输出: 40 (先返回值,再递增) console.log(x); // 输出: 41 (x已经被递增) let y = 40; console.log(++y); // 输出: 41 (先递增,再返回值) console.log(y); // 输出: 41 (y已经被递增) -
错误的心理模型: 大多数人(包括演讲者自己)认为
x++本质上就是x = x + 1,并假设在后缀递增中,值会完全“原封不动”地返回,然后再进行增加操作。 -
深入探究与发现:
- 问题: 当
++应用于一个非数字类型(如字符串"5")时会发生什么? - 错误的预期: 以为
y++(当y为"5") 会先返回字符串"5",然后再将"5"强制转换为数字5并增加到6。let y = "5"; console.log(y++); // 错误预期: 输出字符串 "5" console.log(y); // 然后 y 变成数字 6 - 实际行为: 实际返回的是数字
5。这说明,在返回值之前,JavaScript 已经进行了类型强制转换(将字符串"5"转换为数字5)。 - 结论: 这个递增操作并非简单的“之后”,而是“中间”发生了一些事情,这揭示了我们心理模型的偏差。
- 问题: 当
-
-
-
核心论点:开发者的责任
- 问题的根源: 开发者倾向于基于假设构建心理模型,当代码行为与模型不符时(即出现 bug),我们往往归咎于语言本身“设计糟糕”。
- 社区对比: C++ 或 Java 开发者大多读过语言规范,而 JavaScript 社区中,读过规范的开发者寥寥无几。
- 错误的心态: 许多 JS 开发者认为这门语言入门门槛低,就应该“直观易懂”,一旦出现不符合直觉的地方,就认为是语言的错。
- 正确的态度: 我们作为开发者,有责任去学习和理解我们使用的工具。当遇到意外行为时,不应归咎于语言,而应反思自己理解的不足。
- 如何解决: 当遇到不理解的行为时,唯一可靠的答案来源是 语言规范 (The Specification),而不是 JavaScript 引擎本身。
- 首先问:“规范怎么说?”
- 然后问:“我观察到的行为和规范匹配吗?”
- 如果不匹配,那是引擎的 bug。
- 如果匹配,那 bug 就出在你的大脑里,你的理解是错的。
1-understanding-your-code
- 深入探究
++操作符的规范- 通过查看语言规范(spec)可以了解
++后缀操作符的真实工作流程。 - 规范通常以算法步骤的形式呈现,对于开发者来说,这是一种熟悉且直接的格式。
- 虽然阅读规范需要努力,但它是理解 JavaScript 行为的唯一权威来源。
- 规范算法揭示:
++操作符首先会获取旧值。- 然后对旧值执行
ToNumber这个抽象操作,即将其转换为数字。 - 接着,将原始数字(转换后的)返回。
- 最后,对变量进行加一更新。
- 修正心理模型:
- 演讲者通过阅读规范,修正了自己对
x++的理解。它不只是x = x + 1的简单封装。 - 更准确的模型是:它像一个函数,接收原始值 x,先将其强制转换为数字,然后返回这个转换后的原始数字,最后再将变量 x 的值加一。
- 演讲者通过阅读规范,修正了自己对
- 通过查看语言规范(spec)可以了解
- 开发者最大的障碍:凭猜测编程
- 许多 JavaScript 开发者实际上是在“猜测”代码如何工作。代码能跑通,似乎符合自己构建的心理模型,就认为没问题了。
- 这种“希望测试能通过”的心态,就像建筑师说“我希望墙不会塌”一样不专业。
- 建立对代码的信心,唯一的方法是真正理解代码在做什么。
- 像 MDN 这样的文档虽然很有用,但并非总是 100%正确,最终的权威来源只能是规范。
- Getify 定律 :Bug 的来源
- 核心观点: 当你大脑中对代码的预期(心理模型)与计算机的实际执行之间存在分歧时,Bug 就产生了。
- 虽然存在简单的拼写错误,但绝大多数 Bug 源于开发者错误的思考和不正确的心理模型。
- 本课程的目的就是帮助你理解 JavaScript 的算法和 DNA,从而让你的心理模型与计算机的实际行为保持一致,减少 Bug。
2-course-overview
- JavaScript 的三大支柱
- JavaScript 的所有编程知识,无论使用何种框架(React, Vue, Angular, jQuery),都建立在三个核心支柱之上。
- 开发者在框架中遇到的很多问题,根源往往在于对这三大支柱之一的不理解。
- 如果你选择成为一名 JavaScript 开发者并以此为生,那么你就应该理解你的工具。
- 支柱一:类型与强制转换 (Types and Coercion)
- 这是最不受欢迎但又至关重要的话题。
- 普遍误解: 很多人(如受 Doug Crockford 影响)主张避免使用类型转换,只用
===(严格相等)。 - 核心观点:
- 不理解 JavaScript 的类型系统就去写代码是不负责任的。
- 简单地回避这个话题是导致 Bug 产生的重要原因之一。
- 只用
===并不能完全避免类型问题,这并非一种务实的编程方式。
- 演讲者的立场转变: 过去他认为开发者可以自行决定,但现在他坚信,对于类型和转换,存在一种正确的思考方式,而社区尚未达到这一共识。本课程将阐明其重要性。
- 支柱二:作用域与闭包 (Scope and Closure)
- 这是语言的另一个关键基础,几乎所有其他概念都建立于此。
- 核心内容:
- 词法作用域 (Lexical Scopes)
- 闭包 (Closure)
- 模块模式 (Module Pattern)
- 支柱三:对象与
this(Objects Oriented)- 核心内容:
- 理解 JavaScript 的对象系统 (Objects Oriented),区别于传统的面向对象 (Object-Oriented)。
this关键字:一旦你抛开它在其他语言中的含义,并学习它在 JavaScript 中的实际工作方式,它其实非常直接和简单。- 原型 (Prototypes):理解原型是理解
class语法糖的基础。
- 关于
class关键字:- 演讲者坚信
class关键字不应在 JavaScript 中使用,因为它掩盖了原型机制的本质。 - 但是,不该用不代表不该学。你必须学习这个系统,才能理解为什么这种编程风格不适合 JS,并且在被迫使用时(如框架要求)能够写出更高质量的代码。
- 演讲者坚信
- 核心内容:
- 课程目标
- 这三大支柱是 JS 的基石,大部分在 1995 年 Brendan Eich 设计语言的头十天里就已经奠定了。
- 这门课程内容非常密集,它不是学习的终点,而是你深入理解 JavaScript 旅程的起点。
- 演讲者将分享他二十多年来探索 JavaScript 的“藏宝图”,书籍是这个旅程的补充材料。
3-primitive-types
- 核心单元一:类型系统 (Types System)
- 本单元将深入探讨类型和强制转换 (coercion)——这个你被告知要避免,但实际上是代码中缺失的关键部分。
- 我们将从最基础的原始类型 (Primitive Types) 开始。
- 破除误解:“在 JavaScript 中,万物皆对象”
- 这个说法是错误的。
- 例如,
false本身就不是一个对象,它是一个布尔类型的原始值。 - 之所以有这种说法,是因为大多数值可以表现得像对象(这被称为“boxing”),但这并不意味着它们本身就是对象。
- 语言规范明确定义了原始类型的存在。
- JavaScript 的原始类型
- 根据规范,JavaScript 有以下几种类型:
undefined:undefined类型,只有一个值undefined。string: 原始字符串类型(由" "或' '创建),不是String对象。number: 数字类型,涵盖所有 JS 数字。boolean: 布尔类型,只有true和false两个值。object: 对象类型,它本身是一种类型,并有许多子类型。symbol(ES6 新增): 原始符号类型,常用于创建对象的“私有”键。
- 根据规范,JavaScript 有以下几种类型:
- 其他需要讨论的“类似类型”
- 未声明的变量(Undeclared variables): 它不是一个正式的类型,但具有独特的行为。
null: 规范称其为一种类型,但它有点古怪。typeof null返回"object"是一个历史遗留的 bug。function: 函数在 JS 中被视为object的一个“子类型”,有时被称为“可调用对象”(callable objects)。它不是一个顶级的原始类型。array: 数组也可以看作是object的一个子类型,是一种特殊的对象,具有数字索引和自动更新的length属性。
- 即将到来的新原始类型
bigint: 大整数,用于支持超出常规number范围的整数。它已经被 V8 引擎等实现,很可能成为下一个官方的原始类型。
- 关键区别:类型属于值,而非变量
- 与 C++ 或 Java 等静态语言不同,在 JavaScript 中,类型是附加在值上的,而不是变量上。
- 变量只是一个容器,可以持有任何类型的值。
let x = 42;和let x = "42";,x这个变量没有类型,但它所持有的值42和"42"分别是number和string类型。- 值类型 (Value Types) 是一个更准确的描述。
4-typeof-operator
-
typeof操作符的作用typeof用于探查一个值的类型。- 它查询的不是变量的类型,而是变量当前所持有值的类型。
-
typeof的返回值-
typeof操作符总是返回一个字符串。 -
返回值是一个有限的、可预测的列表。
-
示例:
typeof undefined; // "undefined" typeof "hello"; // "string" typeof 42; // "number" typeof true; // "boolean" typeof {}; // "object" typeof []; // "object" (数组是对象的子类型) typeof function () {}; // "function" (虽然函数是对象的子类型,但 typeof 为其提供了特殊的返回值)- 关于
undefined:- 当一个变量被声明但未被赋值时,它的默认值是
undefined。 undefined意味着"当前没有值",而不是"还没有值"。一个变量可以被赋值后再次变回undefined。
- 当一个变量被声明但未被赋值时,它的默认值是
- 关于
-
-
typeof的怪异之处typeof null返回"object"- 这是一个历史遗留的 bug。
- 从逻辑上讲,它应该返回
"null"。 - 这个 bug 无法修复,因为修复它会破坏大量现有网站。
- 因此,在使用
typeof检查一个值是否是对象时,需要额外检查它是否不为null(e.g.,val !== null && typeof val === "object")。
typeof无法区分对象的子类型,比如数组和普通对象都返回"object"。- 对于数组,应使用
Array.isArray()来进行更精确的判断。
- 对于数组,应使用
-
总结
typeof是一个基础的类型检查工具,可以区分大多数顶层原始类型。- 需要注意它对
null的错误行为,以及对对象子类型(如数组)的区分能力有限。 - 当需要更细致的区分时,应使用其他专门的工具(如
Array.isArray())。
5-bigint
BigInt简介BigInt是一个新的原始类型,用于表示可以无限增长的整数(受限于系统内存)。- 它与标准的
number类型是分开的,后者遵循 IEEE 754 标准,有精度限制。
- 语法
- 通过在数字字面量后面添加
n来创建BigInt。 - 示例:
let myBigInt = 42n;
- 通过在数字字面量后面添加
- 与
number的关系BigInt和number是完全分离的类型系统。- 它们不能直接混合进行数学运算,需要进行显式转换。
- 因此,区分一个值是
number还是BigInt变得非常重要。
typeof对BigInt的支持typeof操作符被设计为可以识别BigInt。typeof 42n将会返回字符串"bigint"。这有助于在代码中明确地处理这两种不同的数字类型。
6-kinds-of-emptiness
-
三种“空”状态的概念
- 在 JavaScript 中,表示“空”或“不存在”的状态有三种,它们经常被混淆,但含义完全不同。
-
1. Undeclared (未声明)
- 定义: 一个变量从未在任何可访问的作用域中通过
var,let,const等方式创建过。 - 特点:
- 直接访问一个未声明的变量会导致
ReferenceError。 - 唯一例外:
typeof操作符是唯一可以安全地引用一个未声明变量而不会抛出错误的操作符。 typeof someUndeclaredVar会返回"undefined"。
- 直接访问一个未声明的变量会导致
- 历史问题: 这是 JS 的一个历史遗留问题。
typeof返回"undefined"而不是"undeclared"使得区分undefined和undeclared变得不直观。
- 定义: 一个变量从未在任何可访问的作用域中通过
-
2. Undefined (未定义)
- 定义: 变量已经被声明,但当前没有被赋予任何值。
- 特点:
- 变量确实存在于作用域中。
- 它的当前值为原始值
undefined。 var a;声明的变量会被自动初始化为undefined。
-
3. Uninitialized (未初始化) / TDZ
- 定义: 这是 ES6 引入的一个与"空"相关的概念。它描述的是变量已经声明但还不能被访问的一种状态。这种情况也被称为 TDZ (Temporal Dead Zone),即"时间死区"。
- 适用场景: 这个状态主要出现在某些特定类型的变量上,比如块级作用域变量(用
let或const声明的)。与var不同,这些变量在进入作用域时不会被自动初始化为undefined。 - 严格限制: 当一个变量处于未初始化状态时,它是"禁区" (off-limits)。任何形式的访问都会导致一个 TDZ 错误。
-
总结
- Undeclared: 变量根本不存在。
- Undefined: 变量存在,值为
undefined。 - Uninitialized (TDZ): 变量存在,但暂时无法访问。
7-nan-isnan
- 理解特殊值
NaNNaN字面上是 "Not a Number"(不是一个数字)的缩写,但这是一种误导。- 更好的心理模型:
NaN代表一个无效的数字 (invalid number)。它是一个特殊的标记值(sentinel value)。 NaN来自 IEEE 754 数字规范。
- 如何产生
NaN- 将无法解析为数字的字符串转换为数字时:
Number("n/a")->NaN。 - 进行无意义的数学运算时:
"my son's age" - 10->NaN。这是因为 操作符会尝试将操作数强制转换为数字,"my son's age"转换后就是NaN。 - 任何涉及
NaN的数学运算,结果总是NaN。
- 将无法解析为数字的字符串转换为数字时:
NaN的奇特性质NaN是 JavaScript 中唯一一个不等于自身的值。NaN === NaN的结果是false。===在这里撒了谎,因为它遵循 IEEE 规范,即NaN不与任何东西(包括它自己)相等。
- 如何检测
NaN- 错误的方式:
isNaN()(全局函数)- 这个函数存在严重缺陷:它会先对传入的值进行强制类型转换,再判断是否为
NaN。 - 例如,
isNaN("my son's age")返回true,因为字符串"my son's age"被强制转换为数字时得到NaN。这不符合我们的本意,我们想知道值本身是不是NaN。
- 这个函数存在严重缺陷:它会先对传入的值进行强制类型转换,再判断是否为
- 正确的方式:
Number.isNaN()(ES6 新增)- 这个函数不会进行类型转换。
- 它只会在传入的值确实是
NaN这个特定值时才返回true。 Number.isNaN("my son's age")返回false。Number.isNaN(NaN)返回true。
- 错误的方式:
NaN的类型typeof NaN返回"number"。- 这再次印证了
NaN是一个“无效数字”而不是“不是数字”。它仍然属于数字类型系统的一部分。
NaN的应用- 当你设计一个 API,预期返回一个数字,但没有有效的数字可返回时,
NaN是最合适的返回值。 - 使用
undefined,null,false, 甚至是-1(如indexOf的历史遗留做法) 都是不理想的,因为它们会改变返回值的类型或引入歧义。 - 尤其要避免使用
0来表示“无效”或“不存在”,因为0是一个完全有效的、重要的数字。
- 当你设计一个 API,预期返回一个数字,但没有有效的数字可返回时,
8-negative-zero
- 特殊值:
0(负零)- 在数学上,负零不存在;但在编程和 IEEE 754 数字规范中,它确实存在。
- 它本质上是
0这个值,但其符号位(sign bit)被设置了。
0在 JavaScript 中的怪异行为- 早期的 JavaScript 设计者试图向开发者“隐藏”
0的存在,导致了一系列不一致的行为。 - 字符串转换:
(-0).toString()返回"0",负号丢失了。 - 相等性比较:
===操作符认为-0和0是相等的。-0 === 0返回true。(-0 > 0)和(-0 < 0)都返回false。
- 这导致在很长一段时间里,我们虽然可以得到
0这个值,却很难检测到它。
- 早期的 JavaScript 设计者试图向开发者“隐藏”
- 如何正确检测
0Object.is()(ES6 新增) 是检测0的标准方法。Object.is被称为“第四个等于号”,因为它不撒谎。Object.is(-0, 0)返回false。Object.is(-0, -0)返回true。Object.is也可以用来检测NaN(Object.is(NaN, NaN)返回true),但Number.isNaN()在语义上更清晰。
0的实际用途0看起来很偏门,但它有实际应用场景,特别是在需要同时表示量值 (magnitude) 和 方向 (direction) 的时候。- 示例:
- 物理模拟: 一个物体在移动,速度(量值)可能降为
0,但我们仍想保留它停止前的移动方向(向左或向右)。这时可以用0和0来区分。 - 数据可视化: 追踪一个股票价格的趋势。当量值变化为
0时,我们仍想知道它是从上涨趋势停滞的 (0) 还是从下跌趋势停滞的 (0),从而在图表上显示不同的指示箭头。
- 物理模拟: 一个物体在移动,速度(量值)可能降为
- 使用
Math.sign()本应可以检测符号,但Math.sign(-0)返回0而不是1,使其在此场景下作用有限。通过自定义函数和Object.is可以修复这个问题。
- 结论
- 尽管
0看起来很奇怪,但了解它的存在和检测方法,可以帮助我们编写更精确的代码,甚至在特定场景下利用它来更优雅地解决问题。
- 尽管
9-type-check-exercise
- 练习目标
- 为 ES6 的
Object.is工具方法编写一个 polyfill(兼容性补丁)。 - 通过这个练习,加深对原始值、类型和特殊值(如
NaN和0)处理的理解。
- 为 ES6 的
- 任务要求
- 定义一个名为
Object.is的函数,它接受两个参数(例如v1和v2)。 - 函数应返回
true当且仅当两个值完全相同。 - 你需要自己处理
===(严格相等) 操作符失效的两个主要边界情况:NaN的处理:NaN === NaN是false,但Object.is(NaN, NaN)应该是true。你需要找到一种方法来识别NaN。0的处理:0 === 0是true,但Object.is(-0, 0)应该是false。你需要找到一种方法来区分0和0。
- 对于所有其他情况,
===的行为是正确的,可以直接使用。
- 定义一个名为
- Polyfill 模式
- 一个标准的 polyfill 代码结构如下,它只在当前环境不存在该功能时才定义它:
if (!Object.is) { Object.is = function (v1, v2) { // Your implementation here }; } - 练习提示: 由于现代浏览器和 Node.js 环境几乎都内置了
Object.is,为了测试你自己的实现,你需要暂时绕过if条件检查。可以注释掉if语句,或者临时修改条件为if (true)。
- 一个标准的 polyfill 代码结构如下,它只在当前环境不存在该功能时才定义它:
- 如何进行练习
- 在提供的
ex.js文件中编写你的Object.is实现。 - 该文件包含一系列
console.log测试用例。 - 你的目标是让所有的
console.log语句都输出true。 - 你可以通过
node ex.js在命令行运行测试,或者将代码复制到浏览器控制台执行。 - 如果遇到困难,可以参考
ex.fixed.js文件中的解决方案。
- 在提供的
10-type-check-exercise-solution
-
练习回顾:实现
Object.is的 Polyfill- 首先,设置 Polyfill 的标准结构,即仅在
Object.is未定义时才创建它。if (!Object.is) { // 实现代码 } - 为了在练习中强制运行我们自己的实现,可以临时修改条件,例如
if (!Object.is || true)。
- 首先,设置 Polyfill 的标准结构,即仅在
-
核心逻辑拆解
Object.is的工作方式基本等同于===(严格相等),但需要修正===在两个特殊情况下的“谎言”:0和NaN。- 处理特殊情况 1: 负零 (
0)- 挑战: 如何在不使用内置
Object.is的情况下检测0? - 思路: 利用数学运算。加法和减法无法区分
0和0。 - 关键技巧: 使用除法。
1 / 0结果是Infinity。- 因此,
1 / -0的结果是-Infinity。
- 实现辅助函数
isNegZero(v):- 首先判断
v === 0,确保我们只处理零值。 - 然后判断
1 / v === -Infinity。如果成立,那么v一定是-0。
- 首先判断
- 挑战: 如何在不使用内置
- 处理特殊情况 2:
NaN- 挑战: 如何不依赖内置工具检测
NaN? - 关键技巧:
NaN是 JavaScript 中唯一一个不等于其自身的值。 - 实现辅助函数
isItNaN(v):- 逻辑非常简单:
return v !== v;。如果一个值不等于它自己,那它一定是NaN。
- 逻辑非常简单:
- 挑战: 如何不依赖内置工具检测
-
整合最终实现
-
将上述逻辑组合成
Object.is函数。 -
步骤 1: 首先处理
0的情况。- 检查
x或y是否为-0。 - 如果其中任意一个是
-0,那么只有当两者都是-0时,才应返回true。
- 检查
-
步骤 2: 接着处理
NaN的情况。- 如果
x和y都是NaN,则返回true。
- 如果
-
步骤 3: 其他所有情况。
- 如果以上两种特殊情况都不满足,那么
===的行为是可靠的,直接返回x === y的结果。
if (!Object.is /*|| true*/) { Object.is = function ObjectIs(x, y) { var xNegZero = isItNegZero(x); var yNegZero = isItNegZero(y); if (xNegZero || yNegZero) { return xNegZero && yNegZero; } else if (isItNaN(x) && isItNaN(y)) { return true; } else if (x === y) { return true; } return false; // ********** function isItNegZero(x) { return x === 0 && 1 / x === -Infinity; } function isItNaN(x) { return x !== x; } }; } - 如果以上两种特殊情况都不满足,那么
-
-
代码验证
- 作者将最终实现的代码放入一个名为 "Run JS" 的环境中执行。
- 所有测试用例都成功通过,控制台输出了预期的
true值。
11-fundamental-objects
- 基础对象 (Fundamental Objects)
- 除了原始值,JavaScript 还提供了一些基础对象。
- 这个术语在规范中是比较新的,旧称可能是“内置对象 (built-in objects)”或“原生函数 (native functions)”。
- 它们是 JavaScript 中类似 Java 的面向对象部分,为原始值提供了对应的对象表示形式。
- 使用建议分类
- 第一类:应该使用
new关键字创建的对象- 这些用于构造复杂的对象实例。
- 包括:
Object,Array,Function,Date,RegExp,Error。 - 一个关键例子是
new Date()。因为 JavaScript 没有日期字面量语法,所以创建日期对象必须使用new Date()。这甚至是保留new关键字的一个重要理由。
- 第二类:绝对不应该使用
new的“对象”- 这些对应于原始类型。
- 包括:
String,Number,Boolean。 - 错误用法: 使用
new String("abc")会创建一个“奇怪的”字符串对象包装器,而不是原始字符串。永远不要这样做。 - 正确用法: 将它们作为普通函数来调用,不带
new。- 当作为函数使用时,它们的功能是进行显式的类型强制转换 (explicit coercion)。
String(123)→"123"Number("123")→123Boolean(0)→false- 这是它们远比构造函数形式更有用的功能。
- 第一类:应该使用
- 示例
- 创建日期:
new Date() - 类型转换:
String(3.8)可以将一个数字(比如 GPA 成绩)转换为字符串,以便于显示。
- 创建日期:
12-abstract-operations
-
抽象操作 (Abstract Operations)
- 抽象操作是 JavaScript 规范中定义的、用于执行类型转换等核心任务的概念性步骤。
- 它们不是可以直接在代码中调用的函数,而是描述引擎内部行为的算法。
- 类型转换在(conversion) JavaScript 中通常被称为强制转换 (coercion),这两个词可以互换使用。
-
核心抽象操作:
ToPrimitive- 目的: 将一个非原始类型的值(如
object,array,function)转换为一个原始类型的值。 - 工作方式:
- 该操作会接收一个可选的类型提示 (type hint),通常是
"number"或"string"。- 当进行数学运算时,提示为
"number"。 - 当进行字符串拼接时,提示为
"string"。
- 当进行数学运算时,提示为
- 根据提示调用方法:
- 如果提示是
"number",它会首先尝试调用该对象的.valueOf()方法。如果.valueOf()返回的是一个原始值,就使用它;否则,再尝试调用.toString()方法。 - 如果提示是
"string",顺序则相反:先尝试.toString(),再尝试.valueOf()。
- 如果提示是
- 递归性: 规范中的很多算法是递归的。如果
ToPrimitive的一次调用结果仍然是一个非原始值,它会再次被调用,直到获得一个原始值或抛出错误。
- 该操作会接收一个可选的类型提示 (type hint),通常是
- 总结: 当你对一个对象进行数学或字符串操作时,引擎内部会通过调用它的
valueOf()或toString()方法来获取一个原始值。
// 创建一个简单的对象 let myObj = { // 当需要数字时,返回这个 valueOf() { console.log("valueOf被调用了!"); return 42; }, // 当需要字符串时,返回这个 toString() { console.log("toString被调用了!"); return "我是对象"; }, }; console.log("=== 测试数字场景 ==="); console.log("结果:", +myObj); // 数字场景,应该先调用valueOf console.log("\n=== 测试字符串场景 ==="); console.log("结果:", String(myObj)); // 字符串场景,应该先调用toString console.log("\n=== 测试加法 ==="); console.log("结果:", myObj + 0); // 数学运算,应该调用valueOf - 目的: 将一个非原始类型的值(如
13-tostring
- 抽象操作:
toString- 目的: 接受任何值,并返回其字符串表示形式。
- 几乎所有值都有其对应的字符串形式。
toString的转换规则示例- 原始值:
null→"null"undefined→"undefined"true→"true"- 数字(如
3.14)→"3.14" -0→"0"(这是一个特例,负号会丢失,toString在这里会“说谎”)
- 对象 (非原始值):
- 当对一个对象执行
toString时,会先调用ToPrimitive并带有"string"提示。 - 这意味着它会首先尝试调用对象的
.toString()方法,其次是.valueOf()。 - 数组 (Array):
- 默认的
.toString()会将数组元素用逗号连接起来,但会省略外层的方括号[]。 - 空数组
[]→""(空字符串) [1, 2, 3]→"1,2,3"- 数组中的
null和undefined会被转换为空字符串,只留下逗号。例如[1, null, 3]→"1,,3"。 - 这种行为很奇怪,不建议在生产代码中依赖数组的默认字符串化。
- 默认的
- 普通对象 (Object):
- 默认的
.toString()返回"[object Object]"。 [object和]是固定的。- 中间的
Object被称为字符串标签 (string tag),可以通过Symbol.toStringTag来修改。
- 默认的
- 自定义
.toString():- 你可以重写任何对象的
.toString()方法来完全控制其字符串表示。 - 例如,重写
.toString()来返回JSON.stringify(this),这样在控制台调试时会更有用。
- 你可以重写任何对象的
- 当对一个对象执行
- 原始值:
14-tonumber
- 抽象操作:
ToNumber- 目的: 当在一个需要数字的上下文中使用了非数字值时,该操作会被调用,将其转换为数字。
- 这个操作涉及的边界情况较多。
ToNumber的转换规则示例- 字符串 (String):
""(空字符串) →0。这是演讲者认为的“所有强制转换罪恶的根源”,因为它将“无值”的表示转换成了一个具体的数值0,而不是代表无效数字的NaN。"0"→0"-0"→-0(正确保留了符号)" 9 "→9(会去除前后空格和前导零)"3.14"→3.14"0xFF"→255(支持十六进制等)
- 其他原始值:
false→0true→1(演讲者认为这也不是好设计,应为NaN)null→0undefined→NaN(与null的行为不一致,很奇怪)
- 对象 (非原始值):
- 当对一个对象执行
ToNumber时,会先调用ToPrimitive并带有"number"提示。 - 这意味着它会首先尝试调用对象的
.valueOf()方法,其次是.toString()。 - 默认行为: 对于普通对象和数组,默认的
.valueOf()方法基本就是返回对象自身(失败),所以实际会退回到调用.toString(),然后调用ToNumber。 - 数组 (Array):
[""]→ToString得到""→ToNumber得到0。[null]或[undefined]→ToString得到""→ToNumber得到0。
- 当对一个对象执行
- 自定义
.valueOf():- 你可以重写对象的
.valueOf()方法来返回一个数字,从而控制其数字表示。
- 你可以重写对象的
- 字符串 (String):
15-toboolean
- 抽象操作:
ToBoolean- 目的: 在需要布尔值的上下文中(如
if语句),将任何非布尔值转换为布尔值。 - 工作方式: 与其他转换不同,它不是一个复杂的算法,而是一个简单的查找表。它只检查一个值是否在“假值 (falsy)列表”中。
- 目的: 在需要布尔值的上下文中(如
- Falsy (假值) 列表
- 这是一个有限的、需要记住的列表。所有在这个列表中的值都会被转换为
false。 ""(空字符串)0,-0(所有零值)nullNaNfalseundefined
- 这是一个有限的、需要记住的列表。所有在这个列表中的值都会被转换为
- Truthy (真值)
- 任何不在 Falsy 列表中的值都是 Truthy(真值),它们会被转换为
true。 - 示例:
"hello"(非空字符串)42(非零数字)[](空数组是真值!){}(空对象是真值!)function(){}(函数)
- 任何不在 Falsy 列表中的值都是 Truthy(真值),它们会被转换为
- 重要提醒
ToBoolean操作不会触发ToPrimitive,ToString或ToNumber。它只是简单地查表。- 因此,
[]虽然ToString后是""(假值),但ToBoolean直接判断[]本身不在假值列表中,所以结果是true。
16-cases-of-coercion
- 强制转换无处不在
- 即使你声称“只用
===,从不搞强制转换”,实际上你的代码中已经充满了隐式转换。 - 示例 1: 模板字符串 (Template Literals)
let msg = `There are ${num} students`;- 如果
num是一个数字,它会被隐式地强制转换为字符串。这背后是+操作符的重载行为。
- 如果
- 示例 2:
+操作符+操作符被重载了。根据规范,如果其任意一个操作数是字符串,它就会优先执行字符串拼接。"" + 16→"16"。这会导致另一个操作数被ToString转换。
- 示例 3: 字符串转数字
- 从表单获取的用户输入都是字符串。当你对它们进行数学运算时,就需要转换。
"16" + 1→"161"(字符串拼接)- 使用一元加号
+可以强制转换为数字:+ "16" + 1→17。一元加号会调用ToNumber抽象操作。 - 使用 操作符时,因为它只为数字定义,所以它会自动对非数字操作数调用
ToNumber。
- 示例 4: 布尔值转换
- 在
if或while语句的条件中放入非布尔值是一种非常常见的做法。 if (someString)就是在利用隐式布尔转换,检查字符串是否非空。- 这同样会遇到边界情况,如一个只包含空格的字符串
" "是真值,但可能并非你想要的。
- 在
- 即使你声称“只用
- 显式 vs. 隐式转换
- 隐式 (Implicit): 依赖语言特性的自动转换,如
+拼接、if条件。 - 显式 (Explicit): 代码意图明确的转换。
- 字符串转换: 推荐使用
String()函数,例如String(num)。 - 数字转换: 推荐使用
Number()函数,例如Number(str)。 - 布尔转换: 推荐使用
Boolean()函数,或双重否定!!。
- 字符串转换: 推荐使用
- 观点:
- 隐式转换并不总是坏事,有时它可以让代码更简洁。关键在于有意图地、有意识地使用它,而不是在不了解其工作原理的情况下滥用。
- 在某些情况下,更明确的比较(如
arr.length > 0)比依赖真假值(while(arr.length))更具可读性和健壮性。
- 隐式 (Implicit): 依赖语言特性的自动转换,如
17-boxing
- 装箱 (Boxing)
- 现象: 我们可以在原始类型的值上调用方法或访问属性,例如
"hello".length或(42).toString()。但是,原始值本身是没有方法和属性的。 - 原理: 这是一种特殊的隐式强制转换,称为“装箱”。
- 当你试图在一个原始值(如字符串、数字)上访问属性或方法时,JavaScript 引擎会:
- 临时地、在后台为这个原始值创建一个对应的对象包装器(例如,为
"hello"创建一个new String("hello")对象)。 - 在这个临时对象上执行属性访问或方法调用。
- 操作完成后,这个临时对象被丢弃。
- 临时地、在后台为这个原始值创建一个对应的对象包装器(例如,为
- 意义:
- 这是 JavaScript 的一个非常实用的特性,它让我们能够方便地操作原始值,而无需手动创建对象包装器。
- 这是导致“JavaScript 中万物皆对象”这一误解的主要原因。事实是,原始值可以表现得像对象,但这并不意味着它们本身就是对象。
- 这是一个非常受欢迎且有用的隐式转换,它让代码更简洁、更符合直觉。
- 现象: 我们可以在原始类型的值上调用方法或访问属性,例如
- 总结:转换是必要的
- 任何编程语言都必须处理类型转换问题。
- 声称可以在不处理类型转换的情况下编写有意义的 JavaScript 程序是不现实的。
- 你总会遇到需要将字符串当作数字,或将数字当作布尔值处理的场景。既然无法避免,就应该去学习和理解它。
18-corner-cases-of-coercion
- 边界情况 (Corner Cases) 是普遍现象
- 所有语言的类型转换系统都有边界情况,JavaScript 也不例外。
- 不应该因为存在边界情况就否定整个机制,而应该学习并有效管理它们。
- JavaScript 的一些边界情况示例
Number("")→0: 这是“万恶之源”。不仅空字符串,只包含空格的字符串(如" ")也会转换为0。如果当初设计成转换为NaN,很多问题都可以避免。new Boolean(false)是真值: 创建一个布尔对象包装器,即使它的内部值是false,这个对象本身在布尔上下文中也是真值(因为它是一个对象,不在假值列表中)。这再次说明了不要使用new Boolean()。- 链式比较的陷阱:
1 < 2 < 3结果为true。但这并非因为 JS 理解链式比较,而是一个巧合。1 < 2首先计算为true。- 然后表达式变为
true < 3。 true被强制转换为数字1。- 表达式变为
1 < 3,结果为true。 - 反例:
3 > 2 > 1结果为false。 3 > 2首先计算为true。- 表达式变为
true > 1。 true被强制转换为数字1。- 表达式变为
1 > 1,结果为false。
- 结论: 依赖布尔值到数字的隐式转换进行数学运算是危险且不可靠的。
19-intentional-coercion
- 应对边界情况的正确策略
- 不是回避: 简单的“避免”整个强制转换机制是不可行的,因为你无法真正避开它。
- 而是管理: 采用一种编码风格,让你的代码中值的类型变得清晰和明显。
- 如何编写高质量的、拥抱转换的代码
- 明确函数签名: 不要设计过于多态(polymorphic)的函数,即一个函数接受各种类型的参数并根据类型做完全不同的事。这会自找麻烦。
- 替代方案: 设计只接受特定类型的函数(例如,一个只处理数字,另一个只处理字符串)。或者明确声明函数只接受有限的几种类型,并处理好它们之间的转换边界。
- 有意图地选择: 你可以主动选择让代码的类型管理更简单或更复杂。通过更明确的函数设计,可以主动规避很多问题。
- 明确函数签名: 不要设计过于多态(polymorphic)的函数,即一个函数接受各种类型的参数并根据类型做完全不同的事。这会自找麻烦。
- 重新审视 JavaScript 的类型系统
- 主流观点: 很多人认为 JS 的弱类型和强制转换是其最大的弱点。
- 演讲者立场 (Kyle Simpson): 他坚信,这实际上是 JavaScript 最强大的品质之一,是其未被颂扬的英雄。
- 正是这种灵活的类型系统,使得 JavaScript 能够成为第一个真正意义上的多范式语言。
- 它让 JS 能够适应各种不同的用例,从而发展成今天无处不在的语言。
- 如果你不学习和使用它,你的程序就错失了这门语言的一个强大特性。
20-culture-of-learning
- 反驳一个常见的论点
- 论点: “就算我学会了这些复杂的 JavaScript 知识(比如强制转换),我的团队里的初级开发者也理解不了。为了他们,我们应该保持代码简单。”
- 演讲者的回应: 这种心态——认为“我聪明到可以理解,但初级开发者太笨学不会”——是完全错误的,而且是一种精英主义。
- 核心观点:建立学习型文化
- 不要“向下兼容”: 不应该为了迎合团队中经验最少的成员而降低代码库的质量和复杂性。代码库应该使用最有效的工具和方法。
- 创造学习机会:
- 当团队成员遇到不理解的代码时,这不应该是一个障碍,而应该是一个学习的机会。
- 应该通过代码审查 (Code Reviews)、结对编程 (Pairing) 和点对点学习 (Peer-to-peer Learning) 的文化来帮助每个人成长。
- 正确进行代码审查:
- 当一个初级开发者在代码审查中犯了错(例如,没有正确处理强制转换的边界情况),不应该直接拒绝并说“你太蠢了”。
- 正确的做法是:“嘿,过来坐一下,我给你讲讲你没注意到的这个边界情况,如果我们换一种方式写,就可以完全避免这个问题。”
- 这样,代码得到了改进,新人也学到了知识。
- 关注成长方向: 核心不在于你当前的技术水平在哪里,而在于你是否在持续学习、持续进步。团队应该鼓励每个人都向上成长。
- 区分“有效利用工具”与“炫技”
- 演讲者提倡的是有效、清晰地使用语言特性,而不是为了炫耀而编写晦涩难懂的代码(例如,使用复杂的位运算技巧或把所有逻辑写在一行里)。
- 代码即沟通: 你的代码是一种沟通形式。当你要求代码的阅读者去学习某个语言特性才能理解某行代码时,这是一种投资。
- 确保投资有回报:
- 好的投资: 如果他们学习了这个特性后,能在代码库的其他地方也看到并应用它,那么这个学习就是有价值的。
- 坏的投资: 如果他们只是为了理解一个你写的、一次性的、晦涩的技巧,而这个知识再也用不上,那就是在浪费他们的时间。
- 建立健康的开发文化
- 健康的开发文化应该致力于让所有人都能理解代码库,无论是刚入门三周的新手,还是有二十年经验的老手。
- 类比建筑行业:建筑师不会因为团队里有实习生就降低建筑的设计标准;他们会教实习生如何把建筑造好。
- 软件开发也应如此,特别是对于那些经常被告知要“忽略”的部分,比如类型和强制转换。
21-code-communication-q-a
- 问题:JSDoc 等辅助性工具在代码沟通中扮演什么角色?
- 除了通过代码本身进行沟通,是否推荐使用像 JSDoc 或代码注释这样的辅助策略?
- 回答 (Kyle Simpson):
- 代码是沟通的核心: 将写代码视为一种沟通思想的方式,那么所有能帮助沟通的工具都是有价值的。
- 代码注释的正确用法:
- 常见的错误: 注释写的是“怎么做 (how)”或“做什么 (what)”。
- 例如,在
i++旁边写注释// increment i。这是多余的,因为代码本身已经说明了这一点。
- 例如,在
- 正确的用法: 注释应该解释“为什么 (why)”。
- 为什么需要在这里增加
i?为什么是加1而不是2或12?注释应该提供代码本身无法传达的上下文和意图。
- 为什么需要在这里增加
- 常见的错误: 注释写的是“怎么做 (how)”或“做什么 (what)”。
- JSDoc 的价值:
- JSDoc 非常有用。例如,在一个函数的 JSDoc 中明确指出:“这个参数只接受字符串或数字类型”。
- 这就向所有阅读者发出了一个清晰的信号:请注意,这里可能会发生强制转换,但范围仅限于字符串和数字之间。
- 这样做可以有效地缩小需要关注的边界情况范围,让代码的维护者知道他们需要处理哪些具体问题。
- 总结:
- 代码注释和 JSDoc 是非常有用的沟通工具,但不应过度依赖它们来弥补写得不清不楚的代码。
- 它们应该作为代码的补充,提供更高层次的解释和上下文。
22-implicit-coercion
- 普遍误解:隐式 = 魔法 = 坏
- 社区中普遍存在一种看法,认为隐式(implicit)机制是“魔法”,是不可预测和坏的。
- 这是反强制转换观点的主要来源,人们常常拿它与 Java 或 C++ 中显式的类型转换作对比,认为 JavaScript 的自动转换是其弱点。
- 重新定义“隐式”:隐式 = 抽象
- 核心观点: 不应该将“隐式”等同于“魔法”,而应该将其视为一种抽象。
- 抽象本身有好有坏,但它是编程中必不可少的工具。
- 抽象的目的: 隐藏不必要的细节,从而让读者专注于更重要的事情,提高代码的清晰度。
- JavaScript 的 DNA 与隐式机制
- JavaScript 之所以入门门槛低,一个重要原因就是它不强迫开发者处理大量不必要的细节。
- 隐式机制是 JavaScript 设计哲学的一部分。完全排斥所有隐式行为,实际上是违背了 JavaScript 的核心 DNA。
- 有用的隐式转换:
- Boxing: 在原始值上调用方法(如
"str".length)就是一种非常有用的隐式转换。它隐藏了创建临时包装对象的细节,让代码更简洁,避免了不必要的干扰。
- Boxing: 在原始值上调用方法(如
- 如何明智地使用隐式转换
- 上下文决定一切: 是否使用隐式转换,取决于它是否能让代码更清晰。
- 示例 1: 模板字符串
如果已经确保`There are ${numStudents} students`;numStudents是一个不会触发边界情况的有效数字,那么直接使用它(让其隐式转换为字符串)比显式写String(numStudents)更清晰,因为它减少了不必要的噪音。 - 示例 2:
<运算符<运算符会尝试将操作数转换为数字进行比较。- 需要显式转换的情况: 如果两个操作数都可能是字符串,那么它们会进行字母顺序比较,这可能不是你想要的。此时,应该显式地将它们都转换为数字。
- 可以隐式转换的情况: 如果你能确定其中一个操作数已经是数字,那么让
<运算符自动转换另一个操作数是完全可以接受的,因为它隐藏了不必要的转换细节。
- 最终目标:成为工程师,而非代码猴子
- 关键在于进行批判性、分析性思考。
- 核心问题是:“在这个特定场景下,展示这些额外的转换细节对代码的读者有帮助吗?”
- 答案有时是肯定的,有时是否定的。你需要作为一名工程师做出判断。
23-understanding-features
- 反驳 Doug Crockford 的“好部分”哲学
- Crockford 的原则: “如果一个特性有时有用,有时危险,并且存在一个更好的选项,那么就应该总是使用那个更好的选项。”
- Kyle Simpson 的批判:
- 定义模糊: 这个原则本身太抽象了。谁来定义什么是“有用”、“危险”和“更好”?这往往变成了个人主观意见。
- 导致知识盲区: Crockford 的结论是,因为类型转换有危险之处,所以“更好”的选择就是完全不学习和理解它。
- Kyle 的观点: 这种做法并非“更好”,因为它系统性地导致了开发者不理解他们自己的代码,从而减少了真正的理解。
- 重新定义“有用”、“危险”和“更好”
- Useful (有用): 当代码让读者能够专注于重要的事情时。
- Dangerous (危险): 当读者无法判断代码会发生什么时。
- Better (更好): 当读者能够理解代码时。
- 核心论点:不学习是一种不负责任的行为
- 不负责任的定义: 故意避免使用一个能够并且确实可以提高代码可读性的语言特性,是一种不负责任的行为。
- 这样做不仅没有利用好工具,实际上是让代码变得更糟,对未来的自己和所有需要维护代码的同事都是一种伤害。
- 既然这个工具(强制转换)存在,不去学习和使用它就是不负责任的。
24-coercion-exercise
- 练习目标
- 通过编写两个验证函数,来实践和深化对强制转换(coercion)及其边界情况处理的理解。
- 任务一:定义
isValidName(name)函数- 验证规则:
- 输入必须是一个字符串。
- 字符串必须非空,且不能只包含空格。
- 去除空格后,有效字符的长度必须至少为 3。
- 返回值: 如果满足所有条件,返回
true;否则返回false。
- 验证规则:
- 任务二:定义
hoursAttended(attended, length)函数- 验证规则:
- 两个输入 (
attended和length) 都可以是字符串或数字类型。 - 无论输入是什么类型,都应将它们作为数字来处理。
- 转换后的数字必须是大于等于 0的整数。
attended的值必须小于或等于length的值。
- 两个输入 (
- 返回值: 如果满足所有条件,返回
true;否则返回false。
- 验证规则:
- 练习说明
- 在提供的练习文件中,已经包含了用于测试的
console.log语句。 - 目标是让所有的
console.log都输出true。 - 练习预计耗时约 10 分钟。
- 在提供的练习文件中,已经包含了用于测试的
25-coercion-exercise-solution
- 函数一:
isValidName(name)实现- 核心逻辑:
- 类型检查:
typeof name === "string"确保输入是字符串。 - 内容和长度检查:
- 使用
name.trim()方法去除字符串两端的空格。 - 然后检查处理后字符串的
.length是否>= 3。
- 使用
- 类型检查:
- 组合: 将这两个条件用
&&连接起来,可以直接作为函数的返回值,非常简洁。function isValidName(name) { return typeof name == "string" && name.trim().length >= 3; }
- 核心逻辑:
- 函数二:
hoursAttended(attended, length)实现- 核心逻辑:
- 预处理输入: 分别检查
attended和length是否为需要处理的字符串。- 如果是字符串,并且
trim()后不为空,则使用Number()将其转换为数字,并重新赋值给原变量。 - 这一步是为了将合法的字符串输入统一为数字类型,同时过滤掉空字符串或纯空格字符串。
- 如果是字符串,并且
- 核心验证: 在一个
if语句中组合所有验证条件。- 类型检查:
typeof attended === "number" && typeof length === "number"确保两个值都是(或已成功转换为)数字。 - 非负检查:
attended >= 0 && length >= 0。 - 整数检查: 使用
Number.isInteger(attended) && Number.isInteger(length)确保它们是整数。 - 大小关系检查:
attended <= length。
- 类型检查:
- 返回结果: 如果所有条件都满足,则返回
true;否则,在函数末尾返回false。
- 预处理输入: 分别检查
- 要点: 这种逐步筛选和转换的方式,能有效地将各种可能的输入(字符串、数字、
null、undefined等)收窄到我们期望处理的范围内,从而安全地进行后续的数值比较,避免了意外的边界情况。
- 核心逻辑:
- 练习总结
- 这个练习旨在让你熟悉如何处理原始类型的值,以及如何利用强制转换的知识来编写健壮的代码,同时主动防御那些已知的疯狂边界情况。
26-double-triple-equals
- 破除常见误解
- 误解:
==(双等号) 只检查值(所谓“松散相等”),而===(三等号) 检查值和类型(所谓“严格相等”)。 - 事实: 这个说法不准确。它掩盖了两者真正的区别,影响了我们对它们用途的理解。
- 误解:
- 深入规范:
==的真相- 根据 ECMAScript 规范,
==(抽象相等比较) 的算法第一步就是检查类型。 ==和===都检查类型。- 真正的区别:
- 当类型相同时,
==和===的行为完全一样。 - 当类型不同时,
===直接返回false,而==会尝试进行类型强制转换 (coercion),然后再进行比较。
- 当类型相同时,
- 结论: 更准确的描述是,
==允许在比较前进行强制转换,而===不允许。
- 根据 ECMAScript 规范,
===(严格相等) 的行为- 如果类型不同,直接返回
false。 - 如果类型相同,进行值比较。但它在两个地方会“说谎”:
NaN === NaN→false(不等于自身)0 === -0→true(认为两者相等)
- 如果类型不同,直接返回
- 对于非原始值(对象、数组)
==和===在比较对象时,都执行的是引用(或身份)比较。- 它们不进行结构性比较(即不检查对象内部的属性是否相同)。
- 只有当两个变量指向内存中同一个对象实例时,比较才会返回
true。 - 因此,对于两个结构相同但独立创建的对象,
==和===都会返回false。
27-coercive-equality
- 改变思维:从“好坏”到“是否适用”
- 不应该简单地认为
==是坏的、不可预测的。 - 而应该进行批判性思考:在当前上下文中,如果我知道值的类型,允许强制转换是有帮助的,还是有害的?
- 你选择使用
===,往往是一个 “滞后指标” ,它表明你其实不确定比较中涉及的值的类型,所以需要用===来“保护”自己。 - 更好的做法: 应该从根源上解决问题,即通过代码设计,让值的类型变得清晰、可预测。
- 不应该简单地认为
==算法中的特例:null和undefined- 根据规范,
null == undefined会返回true。 - 并且,
null和undefined只与它们彼此相等,不与任何其他值(如0,"",false)相等。 - 这是一个非常有用的特性。
- 它允许我们将
null和undefined这两个表示“空”或“无”的值视为等同的,从而简化代码。 - 例如,检查一个变量或属性是否“已设置”时,
if (value == null)可以同时捕捉到value是null或undefined的情况。 - 这比写
if (value === null || value === undefined)更简洁、可读性更高。
- 它允许我们将
- 即使是最坚定的强制转换批评者,也常常会在代码中使用
== null这种检查。
- 根据规范,
- 关于 Linter (代码检查工具)
- Linter 提供了关于代码风格和潜在问题的意见 (opinions),但它们不等于“正确”。
- 一个好的 Linter (如 ESLint) 应该是高度可配置的,允许团队根据自己的需求和判断来定制规则。
- 如果一个工具强迫你改变代码以适应它的规则,而不是帮助你更有效地工作,那么这个工具就在妨碍你。
- 不要盲从 Linter 的默认规则,要理解规则背后的原因,并为你的团队做出明智的选择。
28-double-equals-algorithm
==算法的核心偏好- 当比较字符串、数字和布尔值时,
==算法有一个明显的偏好:它倾向于将所有东西都转换为数字来进行比较。 - 规范中多个条款都明确指出,如果一个操作数是数字,另一个是字符串或布尔值,那么后者将被
ToNumber转换。 - 记住这个核心事实——“双等号偏好数字比较”——能帮你理解和预测
==的绝大多数行为。
- 当比较字符串、数字和布尔值时,
- 应用场景:字符串与数字的比较
- 如果你能通过代码设计,将比较的范围限定在字符串和数字之间,那么使用
==就是安全且有益的。 - 例如,
workshopCount == "42"vsNumber(workshopCount) === 42。 - 如果
workshopCount确定只可能是数字或代表数字的字符串,那么前者 (==) 是一种有用的抽象,它隐藏了不必要的显式转换,让代码更简洁。 - 关键在于: 你通过设计缩小了可能出现问题的范围。你不是在一个可能包含任意类型(数组、对象、布尔等)的混乱环境中随意使用
==。
- 如果你能通过代码设计,将比较的范围限定在字符串和数字之间,那么使用
==与非原始类型(对象)- 如果
==的操作数中有一个是非原始类型(如对象、数组),算法会首先调用ToPrimitive将其转换为一个原始值。 - 核心思想:
==只在原始值之间进行真正的比较。 - 这个转换过程是递归的。
==会持续应用转换规则,直到它得到两个可以进行比较的原始值(通常是两个相同类型的原始值)或者确定无法转换。
- 如果
29-double-equals-walkthrough
- 案例分析:
42 == [42]- 这是一个不应该在实际代码中出现的糟糕比较,但通过分析它,可以深入理解
==的工作流程。 - 步骤 1:
ToPrimitive- 比较
number和array,类型不同,且array是非原始类型。 - 对
[42]调用ToPrimitive。对于数组,这通常会退回到ToString。 [42].toString()的结果是字符串"42"。- 比较变成了
42 == "42"。
- 比较
- 步骤 2:
ToNumber- 现在比较
number和string,类型依然不同。 - 根据
==偏好数字比较的规则,字符串"42"被ToNumber转换为数字42。 - 比较变成了
42 == 42。
- 现在比较
- 步骤 3: 严格相等比较
- 现在类型相同,执行
===比较。 42 === 42的结果是true。
- 现在类型相同,执行
- 这是一个不应该在实际代码中出现的糟糕比较,但通过分析它,可以深入理解
- 从中得到的教训
- 问题根源不是
==: 这个例子会得到true,看起来很奇怪。但问题的根源不是==操作符本身,而是你正在进行一个毫无意义的比较(数字 vs 数组)。 ===掩盖了问题: 如果你把==换成===,结果会是false。但这并没有解决根本问题,它只是掩盖了你的代码正在进行一种不合逻辑的比较。- 真正的解决方案: 应该修复代码,确保你总是在进行有意义的比较。例如,如果你期望的是数字,就应该确保你得到的是数字,而不是一个包含数字的数组。
- 历史原因:
ToPrimitive规则的存在,部分原因是为了兼容早期new String("...")这样的对象包装器用法,让它们可以和原始值进行比较。虽然现在不推荐这样写,但算法的历史渊源于此。
- 问题根源不是
30-double-equals-summary
==(双等号) 算法简明摘要- 这是一个帮助记忆
==行为模式的总结,而非完整算法。
- 这是一个帮助记忆
- 核心规则:
- 类型相同时: 如果比较的两个值类型已经相同,
==的行为就等同于===。 null与undefined: 如果比较的一方是null,另一方是undefined(或反之),它们总是相等的 (true)。- 非原始值处理: 如果比较中涉及非原始值(如对象、数组),它会首先被转换为一个原始值(通常是通过
toString()或valueOf())。 - 原始值偏好: 当比较的是不同的原始值时(如字符串 vs 数字),
==算法优先将它们转换为数字再进行比较。
- 类型相同时: 如果比较的两个值类型已经相同,
- 信念与可学性
- 演讲者坚信,这个规则系统是足够直接和简单的。
- 任何开发者,无论经验深浅,只要愿意学习,就能够理解这个系统,并从而有能力避免那些可能产生问题的场景。
31-double-equals-corner-cases
- 解构著名的“WAT”视频案例:
[] == ![]- 现象: 在著名的 “WAT” 视频中,
[] == ![]的结果是true,这被用来嘲笑 JavaScript 的荒谬。 - 反驳: 这是一个人为构造的、脱离实际的场景。
- 在真实的程序中,你永远不会去比较一个值和它自身的布尔否定。
- 有意义的比较是检查两个值是否不相等,即
[] != [],而不是[] == ![]。 - 用一个永远不会在实际代码中出现的极端例子来否定整个机制,是不合理的。
- 现象: 在著名的 “WAT” 视频中,
[] == ![]为true的原因分析 (算法分解)![]: 数组[]是一个真值 (truthy)。所以![]计算结果为false。- 表达式变为:
[] == false。 - 此时比较的是一个非原始值 (数组
[]) 和一个原始值 (false)。数组需要被转换为原始值。 [].toString()结果是空字符串""。- 表达式变为:
"" == false。 - 此时比较的是字符串和布尔值,类型不同。根据
==偏好数字比较的规则,两者都会被转换为数字。 Number("")结果是0。Number(false)结果是0。- 表达式变为:
0 == 0。 - 类型相同,执行
===,0 === 0结果为true。
- 结论: 算法本身是按照规则一致地执行的,只是应用在了一个本身就无意义的场景上。
- 对比有意义的比较:
[] != []- 这等价于
! ( [] == [] )。 [] == []: 比较两个数组。由于它们是两个不同的对象实例,引用不同,所以[] == []是false。! (false)的结果是true。- 这是一个完全合理且符合预期的行为。
- 这等价于
32-corner-cases-booleans
- 另一个危险的边界情况:
==与布尔值true/false的比较- 这是一个非常常见但绝对应该避免的做法。
- 场景分析:检查一个值是否为“真”
- 正确的方式:
- 使用隐式布尔转换,如
if (myValue) { ... }。 - 这会调用
ToBoolean抽象操作,它只是一个简单的查表(检查myValue是否在假值列表中)。 - 对于数组
[],if ([])会进入if代码块,因为[]是一个真值。
- 使用隐式布尔转换,如
- 错误的方式:
- 显式地与
true或false进行比较,如if (myValue == true)。 - 这不会调用
ToBoolean,而是触发了==那一套复杂的、偏好数字的转换规则。
- 显式地与
- 正确的方式:
- 为什么
[] == true是false而[] == false是true?[] == true:[]转换为""。""转换为0,true转换为1。0 == 1是false。
[] == false:[]转换为""。""转换为0,false转换为0。0 == 0是true。
- 结论: 结果与直觉完全相反,这是一个巨大的陷阱。
- 核心建议
- 永远不要写
myValue == true或myValue == false。 - 如果你想检查一个值的“真假性”,就让语言的隐式布尔转换 (
if (myValue)) 来做,这是最安全、最直接的方式。 - 在这个场景下,隐式转换比显式转换更好、更安全。
- 永远不要写
33-corner-cases-summary
- 使用
==(双等号) 的安全指南- 这是一套可以帮助你安全使用
==而避免踩坑的指导原则。
- 这是一套可以帮助你安全使用
- 应避免使用
==的场景:- 当比较的任意一方可能是
0、""(空字符串) 或只包含空格的字符串时。- 这是由“空字符串转换为 0”这个核心问题衍生出的大量边界情况的重灾区。
- 当涉及非原始值 (non-primitives) 时。
- 尽管
==在比较两个对象引用时行为与===一致,但为了安全起见,最好不要在对象、数组等非原始值上使用==。这离危险的边界太近了。 ==的强制转换应该只用于原始值之间。
- 尽管
- 当与布尔字面量
true或false比较时。- 正如之前讨论的,这会触发意想不到的数字转换。应该使用隐式的布尔转换。
- 当比较的任意一方可能是
- 结论
- 这个需要避开的“黑名单”其实相当短。
- 任何愿意学习的开发者都可以记住并遵守这个列表,从而在他们的代码中有效且安全地利用
==。
34-the-case-for-double-equals
- 提出一个更强的论点
- 演讲者之前的观点是
==和===可以共存。 - 现在,他提出了一个更激进的论点:在所有可能的情况下,你应该优先选择
==。 - 这个论点的前提是:高质量的代码是建立在你了解并能明确表达值的类型的基础之上的。
- 演讲者之前的观点是
- 核心论证:分两种情况讨论
- 情况一:当你知道(或能让代码明确表达出)比较值的类型时
- 如果类型相同:
==和===完全等价。在这种情况下,使用===是不必要的,应该选择更短的==。这不仅仅是为了少打一个字符,而是语义上的清晰。- 类比 TypeScript:TypeScript 会在你用
===比较两个永远不可能相等的类型时报错,因为它认为这是一种无意义的操作。反之,当类型确定相同时,使用更严格的===也是不必要的。
- 类比 TypeScript:TypeScript 会在你用
- 如果类型不同:
===将永远返回false,这是一个无用的比较。- 此时,你只有两个选择:要么不做比较,要么使用
==来允许有意义的转换。 - 使用
==往往比写多个===语句(如val === "42" || val === 42)性能更高(虽然是微秒级)、代码更简洁,并且更少干扰(避免了在null和undefined这种情况下引入不必要的细节)。
- 如果类型相同:
- 情况二:当你不知道值的类型时
- 首先,不知道类型本身就是一个问题。这表明你对这部分代码的理解不够充分,最好的解决办法是重构代码,让类型变得清晰。
- 如果实在无法避免不确定性,那么这种不确定性应该被明确地传达给代码的阅读者。
- 在这种情况下,
===成为了一个非常有用的信号。 - 新的语义:
==意味着:“我知道这里的类型,并且我允许或需要进行强制转换”。===意味着:“我不确定这里的类型,所以我需要保护自己,防止意外的转换发生”。
===应该被保留给那些真正存在类型不确定性的稀有情况,作为一种“警告”标志。
- 情况一:当你知道(或能让代码明确表达出)比较值的类型时
- 总结论点
- 目标: 编写类型清晰、可知的代码。
- 当类型已知时,
==在所有方面都优于或等同于===。 - 当类型未知时,应使用
===作为一种保护措施和明确的信号。
- 盲目地在所有地方使用
===,实际上是在发送错误的语义信号,即“我的代码所有地方的类型都是不确定的”。这会阻碍代码的真正理解,并可能促使未来的开发者进行不必要的重构。
35-equality-exercise
- 练习目标
- 通过实现一个自定义的
findAll工具函数,深入实践对等性比较的 nuanced (细微差别) 控制。 - 这个练习比较复杂,需要处理多种边界情况。
- 通过实现一个自定义的
- 任务:实现
findAll(matchValue, array)- 功能: 在一个数组 (
array) 中查找所有与matchValue“强制相等”的值,并返回一个包含这些匹配值的新数组。 - 自定义的“强制相等”规则 (比原生
==更严格):- 精确匹配: 任何通过
Object.is()判断为完全相等的值都应该被包含。 - 字符串与数字:
- 字符串可以与数字强制匹配,但要排除空字符串和纯空格字符串。
- 数字可以与字符串强制匹配,但要排除
NaN和Infinity/Infinity。 - 提示: 要特别小心
0这个捣蛋鬼。
null与undefined:null和undefined应该可以互相匹配。- 布尔值 (Booleans): 布尔值只能与其他布尔值精确匹配 (
true只匹配true,false只匹配false),不允许任何强制转换。 - 非原始值 (Objects/Arrays): 只进行严格的身份(引用)匹配,不进行结构性比较。
- 精确匹配: 任何通过
- 功能: 在一个数组 (
- 练习说明
- 在
ex.js文件中,已经提供了大量的测试用例来验证你的实现。 - 建议先仔细阅读测试用例,以便更好地理解需求中的各种细节。
- 这是一个很好的机会去思考如何构建一个安全的、可控的强制比较系统。
- 在
36-equality-exercise-solution
- 实现思路拆解
- 创建一个空的
ret数组用于存放结果,遍历输入数组arr中的每一个值v,然后根据一系列规则判断v是否与match相等。
- 创建一个空的
- 判断逻辑的层次与顺序
- 第一道防线:
Object.is()if (Object.is(match, v))- 这是最严格的匹配,能处理所有精确相等的情况,包括
NaN和0的精确匹配。如果满足,直接将v推入结果数组。
- 处理
null和undefinedelse if (match == null && v == null)- 利用
== null的安全特性,可以同时匹配null和undefined。
- 处理布尔值 (Booleans)
else if (typeof match == "boolean" && typeof v == "boolean")- 如果
match和v都是布尔值,那么再进行一次match == v的比较(此时==和===等价)来确保值相同。
- 处理字符串与数字的相互匹配
- 场景 A:
match是字符串,v是数字else if (typeof match == "string" && match.trim() != "" && typeof v == "number" && !Object.is(v, -0))- 检查
match是非空字符串,v是数字,并且要排除v是-0的情况(因为-0转为字符串会丢失符号)。 - 在这些条件下,可以安全地使用
match == v进行强制比较。
- 场景 B:
match是数字,v是字符串else if (typeof match == "number" && !Object.is(match, -0) && !Object.is(match, NaN) && ... && typeof v == "string" && v.trim() != "")- 检查
match是数字,并排除-0,NaN,-Infinity,Infinity。 - 检查
v是非空字符串。 - 在这些条件下,可以安全地使用
match == v进行比较。
- 场景 A:
- 处理非原始值 (Objects) - 已被
Object.is覆盖- 规则要求非原始值只能进行身份匹配,这个需求已经被第一步的
Object.is()完美处理了。
- 规则要求非原始值只能进行身份匹配,这个需求已经被第一步的
- 第一道防线:
- 练习的核心要点
- 这个练习的重点不在于实现一个你会在生产中使用的函数,而在于展示一种思维模式。
- 核心思想: 强制转换(coercion)可以是安全的,前提是你通过外部的
if语句主动地、明确地排除了所有已知的危险边界情况。 - 在排除了危险之后,内部的
==就可以被信任,因为它只会在你允许的安全范围内工作。这体现了如何有意识地、可控地利用语言特性。
37-typescript-flow
- 背景:类型感知 Linting (Type-aware Linting)
- 之前讨论的核心是要“了解我们的类型”。TypeScript 和 Flow 等工具就是为了解决这个问题而生的。
- 可以把它们看作是一种更高级的、具备类型感知能力的 Linting 工具。
- 作者本人支持 Linting 的理念,但前提是工具必须是可配置的,因为不存在一刀切的解决方案。团队应该有权民主决定适合自己的风格指南。
- 对 TypeScript 和 Flow 的看法
- 作者的态度演变:
- 过去: “我不使用它们,因为它们解决的是我没有的问题。”
- 现在: “我不用它们,不是因为问题不存在,而是因为它们解决问题的方式,在我看来,会让我的代码变得更糟。”
- 共识: “在编码时不知道类型”是一个确实存在且需要解决的问题。分歧在于如何解决。
- 作者的态度演变:
- TypeScript/Flow 的优点
- 捕捉类型错误: 能在编译时发现与类型相关的错误,这是它们的核心价值。
- 沟通类型意图: 将类型注解直接写在代码中(如
let name: string),使得代码的类型意图更加明确。 - 强大的工具链支持: 提供顶级的 IDE 集成,如自动补全、实时类型分析等,极大地提升了开发体验。
- TypeScript/Flow 的注意事项/缺点
- 类型推断是“最佳猜测”: 在没有显式注解的地方,工具会进行类型推断,但这只是基于编译时信息的猜测,无法保证运行时的情况。
- 注解是可选的 (Opt-in): 如果开发者忘记或选择不添加注解,变量类型可能会默认为
any,从而失去了类型检查的意义。这可能导致一种“虚假的安全感”。 - 无法覆盖所有边界: 类型系统只能保证在你自己的、已类型化的代码内部的正确性。对于任何外部输入(如 API 响应、用户输入)或未类型化的第三方库,仍然存在类型不确定性。
38-inferencing
- TypeScript/Flow 的类型推断 (Inferencing)
- 即使不添加任何类型注解,这些工具也会默认进行类型推断。
- 静态类型推断 (Static Types Inference):
- 示例:
let teacher = "Kyle"; teacher = 42; - 当
teacher被初始化为字符串"Kyle"时,类型系统会推断出teacher这个变量的“类型”应该是string。 - 当后续代码尝试将一个数字
42赋给它时,系统会报错,认为这是一个类型不匹配的赋值。
- 示例:
- 这种特性的价值:
- 对于那些经常因为“意外地给变量赋予了错误类型的值”而产生 Bug 的开发者来说,这个功能非常有帮助。
- 作者的个人经验: 在他二十多年的编程生涯中,从未因为这种“意外赋值”导致过 Bug。他经常有意地改变变量持有的值的类型。因此,对他个人而言,这个特性解决的是一个不存在的问题。
- 显式类型注解 (Explicit Type Annotation)
- 示例:
let teacher: string = "Kyle"; teacher = 42; - 这里,我们不再依赖推断,而是明确地声明
teacher变量只能持有字符串类型的值。 - 当尝试赋值
42时,系统会基于这个明确的注解报错。 - 这种方式更清晰地表达了开发者的意图。
- 示例:
39-custom-types
-
TypeScript/Flow 的高级功能:自定义类型
-
这些工具的强大之处在于,它们允许你定义自己的、更复杂的类型结构。
-
示例:定义一个
Student类型type Student = { name: string }; function getStudentName(studentRec: Student): string { return studentRec.name; } let student = { name: "Suzy" }; let studentName: string = getStudentName(student); -
类型检查流程:
- 我们定义了一个
Student类型,它必须是一个拥有name属性(且该属性为字符串)的对象。 getStudentName函数被注解为:接收一个Student类型的参数,并返回一个string。- 当调用
getStudentName时,类型系统会检查传入的student对象是否符合Student类型的结构。 - 它还会检查函数的返回值 (
studentRec.name) 是否确实是字符串。 - 最后,检查
studentName变量的赋值是否类型匹配。
- 我们定义了一个
-
核心关注点: 这种检查主要还是围绕着赋值的正确性展开的——包括函数参数的传递(也是一种赋值)和返回值的接收。
-
-
在实践中的应用
- 如果作者自己使用 TypeScript,他可能会更多地使用联合类型 (Union Types),例如
string | number | null。 - 因为现实世界的函数往往需要处理多种类型输入,而不是像示例中那样严格限定为单一、精确的结构。
- 尽管如此,对于那些主要问题是类型误赋值的团队来说,这种类型系统能够提供非常有用的保障。
- 如果作者自己使用 TypeScript,他可能会更多地使用联合类型 (Union Types),例如
40-validating-operand-types.txt
- TypeScript 的一个被低估的价值在于,除了能提供变量赋值的静态类型信息外,它还能指出某些操作是无效的。
- 例如,TypeScript 可以提示你不能用一个数字减去一个字符串。
- 这个功能非常有用,因为它能捕捉到许多常见的 bug。
- 讲师的个人偏好:
- 希望有一个工具(linter)只检查这类无效的操作,而不过多地干涉静态类型赋值。
- 他希望在某些情况下能够允许类型转换(coercion),而在另一些情况下避免它。
- 对 TypeScript 的看法:
- TypeScript 在处理类型转换方面似乎是“全有或全无”的。一旦选择使用,就要接受其全部规则。
- 讲师希望能有更多的细微差别和配置选项,以更灵活地控制类型转换的行为。
41-typescript-flow-summary.txt
- 推荐文章:
- 如果对 TypeScript 和 Flow 的异同点感兴趣,有一篇文章并排比较了这两个项目,详细列出了它们的相同点和不同之处,非常值得一读。
- TypeScript 和 Flow 的价值:
- 这两个工具非常有用,因为它们能帮助开发者解决类型问题,并使代码中的类型更加明确。
- 讲师的观点和困惑:
- 他感到沮丧的是,生态系统似乎立即将这些工具视为解决类型问题的唯一选项。
- 这造成了一种极端选择:要么完全忽略类型(使用
===),要么在 JavaScript 之上叠加一个新层次,而这个层次与 JavaScript 的“基因”并不完全相符。 - 他希望能在两者之间找到一个“中间地带”的解决方案。
42-static-typing-pros.txt
- 以下是关于 TypeScript 和 Flow 这类静态类型系统优点的个人看法:
- 1. 让类型更明显
- 这是对代码的一个巨大改进,消除了操作中的不确定性。
- 2. 语法熟悉度
- 它们的语法设计与许多其他静态类型语言(如 Java, C++)相似。
- 这种熟悉感降低了有其他语言背景的开发者的学习门槛。
- 3. 极高的普及度与强大的生态系统
- 流行度:TypeScript 的受欢迎程度毋庸置疑,许多大型开源项目都在转向它。
- 大公司支持:TypeScript 来自微软,Flow 来自 Facebook,有强大的企业背书。
- 社区与资源:拥有庞大的社区、丰富的文档和强大的发展势头,学习它的投资是长期的。
- 适用场景:在某些工作环境中(如微软.NET 技术栈),使用 TypeScript 可以减少在后端(如 C#)和前端 JavaScript 之间切换时的心智负担。
- 4. 成熟与精密
- 这些工具非常擅长类型推断,即使面对有意混淆的代码,也能准确分析出类型。
- 1. 让类型更明显
43-static-typing-cons.txt
- 以下是关于 TypeScript 和 Flow 这类静态类型系统缺点的个人看法:
- 1. 非标准语法与生态系统锁定
- 它们使用了一套需要在 JavaScript 之上额外添加的语法,并非 JS 官方标准。
- 无法保证这套语法未来会成为 JavaScript 的一部分。
- 这导致了生态系统锁定:代码不具备可移植性,必须依赖特定的工具链才能运行。
- 尽管可以使用代码注释的方式来添加类型注解(从而避免语法锁定),但几乎没有人这样做。
- 2. 强制要求构建过程
- 这提高了新手开发者的入门门槛。
- 相比于直接编写和加载一个 JS 文件,构建过程增加了额外的复杂性。
- 这迫使新学习 JavaScript 的人,在写第一行代码前就必须学习 DevOps 相关的知识。
- 3. 快速增加的复杂性
- 当开始使用泛型(generics)、接口(interfaces)等高级功能时,代码的复杂性会呈指数级增长。
- 代码会变得越来越不像 JavaScript,而更像 Haskell 等函数式语言。
- 这对于没有深厚 TypeScript 经验的开发者来说非常 intimidating(令人生畏),大大提高了招聘和团队协作的门槛。
- 4. 与 JavaScript 的“基因”不符
- JavaScript 的核心是动态类型,其理念是值有类型,而不是变量有类型。
- 将静态类型强加于变量之上,感觉像是对 JavaScript 本质的一种“背叛”。
- 这种做法源于一种观点,即认为原生 JavaScript “有缺陷”或“不够好”,而讲师并不同意这一点,他认为 JavaScript 本身就很出色。
- 1. 非标准语法与生态系统锁定
- 结论:
- 这些工具的优点和缺点之间存在巨大鸿沟。
- 生态系统应该提供更多介于“完全不用类型”和“完全静态类型”之间的中间选项。
- 因此,讨论应该从 TypeScript 和 Flow 这两个具体工具,扩展到更广泛的 类型感知代码检查(type aware linting) 概念上。
44-understanding-your-types.txt
- 总结关于 JavaScript 类型的核心观点:
- 1. JavaScript 拥有类型系统
- 它是一个动态类型系统,而不是没有类型。
- 类型转换(coercion)是用于值的类型转换,而非变量的类型绑定。
- 2. “回避策略”的问题
- 目前主流的做法是尽可能回避 JS 的类型系统,只使用
===来“保护”自己。 - 讲师认为这是一种无效策略,因为它系统性地滋生了 bug,原因是开发者不去理解语言的一个核心部分。
- 目前主流的做法是尽可能回避 JS 的类型系统,只使用
- 3. 理解类型是高质量代码的关键
- 如果不理解类型,就无法编写出高质量的 JavaScript 程序。代码的读者也需要能理解类型。
- 采用 TypeScript/Flow 这样的静态类型系统,在某种程度上也是一种回避,因为它传达了“JavaScript 原生系统是无望的”这一信息。
- 4. 更好的方法:拥抱并利用 JavaScript 的类型
- 讲师认为,正确地学习和掌握 JavaScript 的类型系统,比学习一个复杂的静态类型系统要容易。
- 最佳实践:
- 拥抱 JavaScript 的类型,并采用能让类型意图更明显的编码风格。
- 深入思考类型问题,会促使你设计出结构更好、更健壮、bug 更少的代码。
- 即使不使用
==,仅仅是思考类型本身就能带来巨大的好处。
- 1. JavaScript 拥有类型系统
- 最后的呼吁:
- 希望听众能重新思考这些观点,并亲自验证。
- 坚持“我不需要懂类型,只需要用
===”的态度,最终会损害代码质量。
45-scope.txt
- 核心概念介绍
- JavaScript 的三大核心支柱之一是作用域(Scope),具体来说是词法作用域(Lexical Scope)。
- 学习路径:理解词法作用域 -> 理解闭包 -> 理解模块模式,最终目标是改善代码组织。
- 作用域是什么?
- 定义:作用域是寻找标识符(identifier)的地方。
- 标识符的两种角色:
- 目标(Target):接收赋值,如
x = 10。 - 源头(Source):被读取值,如
console.log(y)。
- 目标(Target):接收赋值,如
- JavaScript 是一门编译型语言
- 这是一个常见的误解,很多人认为 JS 是自上而下逐行解释执行的。
- 证据:如果第 10 行有语法错误,程序会立即报错,而不会执行第 1-9 行。这证明 JS 在执行前经过了一个处理/编译阶段。
- 编译步骤:词法分析 -> 语法分析(生成 AST) -> 代码生成。
- 水桶与弹珠的比喻 (Marble and Bucket Analogy)
- 这个比喻用来解释编译阶段如何处理作用域和变量:
- 水桶 (Buckets):作用域。在 JS 中主要是函数和 块级(Block) 作用域。
- 弹珠 (Marbles):标识符(即变量名、函数名)。
- 编译过程:就像把不同颜色的弹珠(标识符)放入对应颜色的水桶(作用域)里。
- 这个比喻用来解释编译阶段如何处理作用域和变量:
- 编译与执行的两阶段过程
- 第一阶段:编译
- 引擎通读代码,找出所有的作用域(水桶)和标识符(弹珠),并确定每个弹珠属于哪个水桶。
- 这个过程会生成一个“执行计划”,这个计划明确了所有词法环境的结构。
- 第二阶段:执行
- JS 引擎拿着这份“计划”来执行代码。
- 第一阶段:编译
46-compilation-scope.txt
- 通过对话隐喻来理解编译过程
- 整个过程可以想象成编译器 (Compiler) 和作用域管理器 (Scope Manager) 之间的一场对话。
- 第一阶段:编译(寻找正式声明)
- 全局作用域(红色水桶)
var teacher;(line 1): 编译器对作用域管理器说:“红色水桶,我有个叫teacher的声明,你听过吗?” 管理器回答:“没有,现在我为你创建一个‘红色弹珠’。”function otherClass...(line 3): 编译器再次询问:“红色水桶,我有个叫otherClass的声明。” 管理器再次创建一个“红色弹珠”。
otherClass函数作用域(蓝色水桶)- 编译器发现
otherClass是一个函数,于是告诉作用域管理器:“我们需要一个新的水桶,蓝色的。” var teacher;(line 4): 编译器进入函数内部,对作用域管理器说:“蓝色水桶,我有个叫teacher的声明。” 管理器为这个新作用域创建了一个“蓝色弹珠”。- 这种情况被称为 遮蔽 (Shadowing):内部作用域的
teacher(蓝色弹珠)遮蔽了外部作用域的teacher(红色弹珠)。
- 编译器发现
ask函数作用域(绿色水桶)function ask...(line 8): 编译器在全局作用域(红色水桶)中为ask创建一个“红色弹珠”,并告诉作用域管理器:“为ask函数准备一个‘绿色水桶’。”var question;(line 9): 编译器进入ask函数内部,为question在绿色水桶中创建一个“绿色弹珠”。
- 全局作用域(红色水桶)
- 词法作用域的核心要点
- 在编译时确定:所有的作用域和标识符的归属关系在代码编写阶段(编译时)就已经完全确定,而不是在运行时动态改变。这就是词法作用域的本质。
- 引擎优化:因为作用域结构是固定的,JavaScript 引擎可以据此进行高效的优化。
47-executing-code.txt
- 执行阶段的对话隐喻
- 现在对话的角色变成了 JavaScript 引擎 (JS Engine) 和作用域管理器 (Scope Manager)。
- 此时,
var等声明已经不存在了,因为它们在编译阶段已被处理。
- LHS 与 RHS 查询 (目标与源)
- 目标引用 (Target / LHS - Left-Hand Side):对变量进行赋值操作。例如:
teacher = "Kyle"。 - 源引用 (Source / RHS - Right-Hand Side):获取变量的值。例如:
console.log(teacher)。
- 目标引用 (Target / LHS - Left-Hand Side):对变量进行赋值操作。例如:
- 第二阶段:代码执行过程
teacher = "Kyle";(line 1):- JS 引擎向作用域管理器请求一个对
teacher的目标引用(在全局红色水桶中)。 - 管理器确认存在,并返回这个引用(红色弹珠)。
- JS 引擎将字符串 "Kyle" 赋给它。
- JS 引擎向作用域管理器请求一个对
otherClass();(line 13):- JS 引擎请求一个对
otherClass的源引用。 - 管理器返回
otherClass的引用,它指向一个函数。 ()操作符执行这个函数。如果otherClass不是函数,会抛出TypeError。
- JS 引擎请求一个对
- 进入
otherClass函数 (蓝色作用域):teacher = "Suzy";(line 4):- 引擎请求对
teacher的目标引用,这次是在当前(蓝色)作用域中查找。 - 找到了蓝色弹珠,赋值 "Suzy"。
- 引擎请求对
console.log(...)(line 5):- 引擎请求对
console的源引用。 - 在当前(蓝色)作用域中查找,未找到。
- 作用域链查找:引擎向上一级作用域(全局红色水桶)继续查找。
- 在全局作用域中找到了
console(一个内置的全局变量),返回引用。
- 引擎请求对
- 关键要点
- 每次在运行时引用一个变量,都会发生一次查询。
- 如果在当前作用域找不到,引擎会沿着作用域链向外层作用域逐级查找,直到找到或者到达最外层的全局作用域。
48-compilation-and-scope-q-a.txt
- 问题 1:赋值(Targeting)发生在何时?
- 回答:变量的角色在编译时就被识别出来。而实际的赋值或取值动作则发生在运行时。
- 问题 2:编译器创建的是占位符吗?
- 回答:是的。可以把它看作是编译器为每个作用域输出了一份“计划”。这份计划描述了作用域内将会有哪些标识符。真正的内存分配和变量创建是在运行时进入该作用域时才发生的。
- 问题 3:如果一个函数被多次调用会发生什么?
- 回答:每次执行函数时,它的词法环境(作用域“水桶”和“弹珠”)都会根据编译器的计划被从头全新创建一次。当函数执行完毕后,这个环境通常会被销毁。下一次调用时,又会重新创建。
49-code-execution-finishing-up.txt
- 继续执行代码
ask();(line 14):- JS 引擎向全局作用域请求对
ask的源引用 (source reference)。 - 作用域管理器找到了
ask(红色弹珠)并返回它所代表的函数。 ()操作符执行该函数。
- JS 引擎向全局作用域请求对
- 进入
ask函数 (绿色作用域):question = "Why";(line 9):- 引擎在当前 (绿色) 作用域中查找对
question的目标引用 (target reference)。 - 找到了
question(绿色弹珠),并将字符串 "Why" 赋给它。
- 引擎在当前 (绿色) 作用域中查找对
console.log(question);(line 10):- 引擎查找
console(最终在全局作用域找到)。 - 为了将
question作为参数传递,引擎需要在当前 (绿色) 作用域中查找对question的源引用 (source reference)。 - 找到了
question,取出它的值 ("Why"),然后传递给console.log函数。
- 引擎查找
- 参数 (Parameters) 与实参 (Arguments) 的关系
- 实参 (Argument):在函数调用时传递的变量(如
log(question)中的question),这是一个源引用 (source) 查询,因为需要读取它的值。 - 形参 (Parameter):在函数定义时声明的变量(如
function ask(myParam){...}中的myParam),这是一个目标引用 (target),因为它会接收传入的实参值。
- 实参 (Argument):在函数调用时传递的变量(如
- 结论
- 至此,我们完整地走了一遍 JavaScript 的两阶段处理流程:编译时确定作用域和标识符的“地图”,执行时根据这张地图进行查找和操作。这个过程就是词法作用域的核心工作原理。
50-lexical-scope-review.txt
- 回顾核心概念:词法作用域的两阶段处理
- JavaScript 不是逐行解释执行的,而是经过一个两阶段的处理过程。
- 第一阶段:编译/解析 (Compilation/Parsing)
- 目标:通读所有代码,建立作用域的“计划”。
- 水桶与弹珠:这个阶段会确定所有的作用域(水桶)和标识符(弹珠),并根据代码的词法结构(即代码写在哪里)将弹珠“放入”对应的水桶。
- 过程:
- 全局作用域(红色水桶):
var teacher-> 创建一个teacher红色弹珠。function otherClass-> 创建一个otherClass红色弹珠,并为其准备一个蓝色水桶。function ask-> 创建一个ask红色弹珠,并为其准备一个绿色水桶。
otherClass函数作用域(蓝色水桶):var teacher-> 创建一个teacher蓝色弹珠(遮蔽了全局的teacher)。
ask函数作用域(绿色水桶):var question-> 创建一个question绿色弹珠。
- 全局作用域(红色水桶):
- 第二阶段:执行 (Execution)
- 目标:根据第一阶段生成的“计划”来执行代码。
- 过程:
teacher = "Kyle"-> 对全局的teacher(红色弹珠)进行目标引用赋值。otherClass()-> 对otherClass进行源引用查找,找到函数并执行。- 进入
otherClass函数:teacher = "Suzy"-> 对内部的teacher(蓝色弹珠)进行目标引用赋值。console.log()-> 在蓝色水桶找不到console,于是向上到红色水桶(全局作用域)查找。
ask()-> 对ask(红色弹珠)进行源引用查找,找到函数并执行。- 进入
ask函数:question = "Why"-> 对内部的question(绿色弹珠)进行目标引用赋值。console.log(question)-> 对question进行源引用查找,在当前绿色水桶中找到并获取其值。
51-compilation-review.txt
- 场景:测试对词法作用域的理解
- 本示例修改了之前的代码,以测试一种特殊情况:在函数内部对一个未声明的变量进行赋值。
- 第一阶段:编译
- 全局作用域(红色水桶):
var teacher:创建一个teacher红色弹珠。function otherClass:创建一个otherClass红色弹珠,并为其准备一个蓝色水桶。
otherClass函数作用域(蓝色水桶):- 关键点:
otherClass函数内部没有任何var或function声明。因此,在编译阶段,蓝色水桶是空的,没有创建任何蓝色弹珠。
- 关键点:
- 全局作用域(红色水桶):
- 第二阶段:执行
teacher = "Kyle":对全局的teacher(红色弹珠)进行目标引用 (target reference) 赋值。otherClass():执行otherClass函数。- 进入
otherClass函数,执行teacher = "Suzy":- 查找过程:
- 引擎在当前作用域(蓝色水桶)中查找
teacher的目标引用。 - 未找到,因为蓝色水桶是空的。
- 引擎向上一级作用域(全局红色水桶)继续查找。
- 在全局作用域中找到了
teacher(红色弹珠)。
- 引擎在当前作用域(蓝色水桶)中查找
- 结果:
"Suzy"这个值被赋给了全局变量teacher,覆盖了原来的值"Kyle"。
- 查找过程:
- 结论:因为
otherClass函数内部没有通过var声明自己的teacher变量(没有形成遮蔽),所以对teacher的赋值操作影响了外层(全局)作用域中的同名变量。
52-dynamic-global-variables.txt
- 场景:JavaScript 的一个历史“坏”部分——动态全局变量
- 在非严格模式下,对一个从未声明过的变量进行赋值操作会发生什么?
- 执行
topic = "React"(line 5):- 查找过程 (目标引用):
- 引擎在当前
otherClass作用域(蓝色水桶)中查找topic。 未找到。 - 引擎向上一级作用域(全局红色水桶)继续查找。 也未找到。
- 引擎在当前
- 非严格模式下的特殊行为:
- 当查找链一直到达全局作用域仍然找不到该变量时,全局作用域会“热心”地自动创建一个同名的全局变量 (
topic)。 - 这个新创建的变量是一个红色弹珠,因为它是在全局作用域中被创建的。
- 然后,引擎将值
"React"赋给这个新创建的全局变量topic。
- 当查找链一直到达全局作用域仍然找不到该变量时,全局作用域会“热心”地自动创建一个同名的全局变量 (
- 查找过程 (目标引用):
- 输出结果分析:
console.log(teacher)(line 11) 输出Suzy,因为全局的teacher变量在otherClass函数中被修改了。console.log(topic)(line 12) 输出React,因为在otherClass函数中意外地创建并赋值了一个全局变量topic。
- 核心要点/警告:
- 这种自动创建全局变量的行为被称为自动全局变量 (auto globals)。
- 这是一个非常糟糕的特性,因为它会无声地污染全局命名空间,并导致难以追踪的 bug。
- 永远不要故意依赖或使用这种行为。始终明确声明你需要使用的所有变量。
53-strict-mode.txt
- 场景:使用严格模式 (Strict Mode) 来修正问题
- 严格模式是 JavaScript 的一种更安全、更受限制的变体,可以修复一些语言的历史遗留问题。
- 通过在文件或函数顶部添加
"use strict";来启用。
- 严格模式下的行为差异:
- 当执行
topic = "React"时,查找过程和之前一样:- 在当前作用域查找
topic,未找到。 - 到全局作用域查找
topic,也未找到。
- 在当前作用域查找
- 关键区别:在严格模式下,当全局作用域也找不到这个目标引用时,它不会再自动创建全局变量。
- 结果:程序会立即抛出一个
ReferenceError,明确地告诉你这个变量从未被声明过。
- 当执行
TypeErrorvs.ReferenceErrorTypeError:当你找到了变量,但对它所持有的值的操作是非法的(比如,试图执行一个不存在的函数)。ReferenceError:当你根本找不到所引用的变量时抛出。
- 关于严格模式的普及:
- 为了向后兼容,JavaScript 默认不是严格模式。
- 工具如 Babel 等转译器通常会自动为代码添加
"use strict";。 - 在 ES6 的类 (classes) 和模块 (modules) 中,严格模式是默认开启的,无需手动声明。
- 结论:严格模式代表了语言的未来方向,应当始终使用它来避免像自动全局变量这样的陷阱。
54-nested-scope.txt
- 核心概念:嵌套作用域 (Nested Scope)
- 作用域可以像套娃一样层层嵌套,形成作用域链。
- 本示例作用域结构:
全局作用域(红色桶子) ├── teacher (红色弹珠) ├── otherClass (红色弹珠) └── otherClass函数作用域(蓝色桶子) ├── teacher (蓝色弹珠,参数) ├── topic (蓝色弹珠,参数) ├── ask (蓝色弹珠) └── ask函数作用域(绿色桶子) └── question (绿色弹珠,参数)- 全局作用域 (红色水桶)
var teacher-> 红色弹珠function otherClass-> 红色弹珠
otherClass函数作用域 (蓝色水桶)var teacher-> 蓝色弹珠 (遮蔽了全局teacher)function ask-> 蓝色弹珠 (因为ask定义在otherClass内部)
ask函数作用域 (绿色水桶)question(参数) -> 绿色弹珠
- 全局作用域 (红色水桶)
- 执行分析:
otherClass("Kyle", "Why?")(line 13):调用otherClass函数。ask(question)(line 10):在otherClass内部调用ask。- 字符串
"Why?"被作为实参传递,并赋值给了ask函数的形参question(绿色弹珠)。
- 字符串
console.log(teacher, question)(line 7):- 查找
teacher:- 在
ask作用域 (绿色) 找,没找到。 - 向上一层到
otherClass作用域 (蓝色) 找,找到了。所以这里的teacher是蓝色弹珠。
- 在
- 查找
question:- 在
ask作用域 (绿色) 找,找到了。所以这里的question是绿色弹珠。
- 在
- 查找
ask("???")(line 14) 的问题:- 查找过程 (源引用):
- 引擎在全局作用域中查找
ask。 - 未找到。因为
ask是一个蓝色弹珠,它只存在于otherClass函数的作用域内,在全局作用域中是不可见的。
- 引擎在全局作用域中查找
- 结果:抛出
ReferenceError。 - 结论:即使一个变量在程序中存在,但如果它不在当前作用域或其外层作用域链上,就无法访问它。
- 查找过程 (源引用):
55-undefined-vs-undeclared.txt
- 核心概念辨析:
undefinedvs.undeclared- 这两个词听起来相似,但在 JavaScript 中有本质区别。
undefined(未定义)- 定义:一个变量已经被正式声明(即存在这个“弹珠”),但在当前时刻,它没有被赋予任何值。
- 本质:
undefined是 JavaScript 中的一个实际值,表示“值的空缺”。 - 举例:
var a;此时变量a存在,但它的值是undefined。
undeclared(未声明)- 定义:一个变量从未在任何可访问的作用域中被正式声明过(即不存在这个“弹珠”)。
- 本质:它不是一个值,而是一种状态——变量不存在。
- 结果:在严格模式下,试图访问一个未声明的变量会直接导致
ReferenceError。
- 总结
undefined:变量存在,但没有值。undeclared:变量根本不存在。- 历史上,JavaScript 在错误信息中有时会混淆这两个概念,这是一个不良设计,开发者应在头脑中清晰地将它们区分开。
56-lexical-scope-elevator.txt
- 词法作用域的电梯比喻
- 这是一个帮助理解嵌套作用域和作用域链查找过程的比喻。
- 比喻的构成:
- 一栋楼:代表整个作用域环境。
- 楼层:每一层代表一个嵌套的作用域。
- 一楼:当前正在执行的作用域。
- 顶楼:全局作用域。
- 电梯:代表作用域链的查找机制。
- 查找过程:
- 当你要查找一个变量时,你首先在**当前楼层(当前作用域)**寻找。
- 如果找不到,你就乘坐电梯向上一层,在上一层楼继续寻找。
- 重复这个过程,一层一层地向上找。
- 最终,你会到达顶楼(全局作用域)。如果在顶楼找到了,就使用它;如果连顶楼都找不到,查找就失败了。
- 要点:查找过程是逐层向上的,不会跳层,也不会直接跳到顶楼。
57-function-expressions.txt
- 核心概念:函数声明 vs. 函数表达式
- 函数声明 (Function Declaration)
- 语法:
function关键字是语句的第一个词。例如:function teacher() { ... } - 作用域行为:函数的标识符(如
teacher)被作为一个“弹珠”添加到其所在的 外部作用域(enclosing scope) 中。
- 语法:
- 函数声明 (Function Declaration)
- 函数表达式 (Function Expression)
- 语法:函数是另一个表达式的一部分,通常是赋值表达式。例如:
var myTeacher = function anotherTeacher() { ... } - 关键区别(对于命名函数表达式):
- 赋值目标
myTeacher作为标识符被添加到外部作用域(红色弹珠)。 - 函数自身的名称
anotherTeacher作为一个标识符被添加到函数自己的作用域内部(蓝色弹珠)。
- 赋值目标
- 语法:函数是另一个表达式的一部分,通常是赋值表达式。例如:
- 命名函数表达式的特点
- 函数名(如
anotherTeacher)只能在函数内部被访问,用于递归或自我引用。 - 从函数外部访问该名称会导致
ReferenceError。 - 这个内部的函数名引用还是只读 (read-only) 的,不能在函数内部被重新赋值。
- 函数名(如
- 匿名函数表达式 vs. 命名函数表达式
- 匿名:
var student = function() { ... } - 命名:
var student = function newStudent() { ... } - 讲师强烈主张:应该永远优先使用命名函数表达式,而不是匿名函数表达式。
- 匿名:
58-naming-function-expressions.txt
- 主张:永远(100%)使用命名函数表达式
- 尽管匿名函数表达式更常见,但命名函数表达式有三个关键优势,这些优势使得为函数命名的额外努力是值得的。
- 三大理由:
- 可靠的自我引用
- 函数名在其自身作用域内提供了一个可靠且只读的引用。
- 这对于实现递归、事件处理器解绑自身等场景至关重要。
- 相比引用外部可能被修改的变量,引用内部的只读名称更安全、更语义化。
- 更易于调试的堆栈跟踪
- 当程序出错时,堆栈跟踪中会显示函数调用栈。
- 匿名函数在堆栈中显示为
(anonymous function),这对于定位问题几乎没有帮助。 - 命名函数会显示其名称(如
handleUserClick),使调试者能迅速了解错误发生的上下文。
- 代码自文档化
- 一个好的函数名能清晰地传达该函数的目的和作用。
- 读者无需阅读函数体和上下文代码来推断其功能。
- 如果你无法为一个函数想出一个好名字,这往往意味着这个函数可能功能过于复杂,需要被拆分成更小的、目的更明确的单元。
- 可靠的自我引用
- 回应常见实践:
- 很多人将匿名函数作为回调函数(如传给
.map,.then),在这种情况下,引擎通常无法推断出函数名,导致失去了调试优势。 - 结论:命名函数是提升代码质量、可读性和可维护性的关键实践,其目的是为了更清晰地沟通,而不仅仅是为了方便打字。
- 很多人将匿名函数作为回调函数(如传给
59-arrow-functions.txt
- 一个不受欢迎的观点:关于箭头函数
- 箭头函数是 ES6 中备受欢迎的特性,但讲师对其普遍使用持批评态度。
- 核心论点:箭头函数是匿名的
- 从本质上讲,箭头函数是匿名函数的一种语法糖。
- 因此,它们继承了匿名函数的所有缺点:
- 堆栈跟踪不友好:在调试时难以识别。
- 缺乏自我文档性:需要通过阅读函数体来理解其目的,而不是通过一个明确的名称。
- 没有可靠的自我引用。
- 不应仅为简洁而使用
- 简洁的语法 (
=>) 并不等同于更具可读性的代码。一个清晰的函数名getPersonID比p => p.id更能传达意图。 - 不应将箭头函数作为所有常规函数的通用替代品,尤其是在牺牲了代码清晰度的情况下。
- 简洁的语法 (
- 唯一推荐的使用场景
- 讲师认为,箭头函数唯一合理的、不可替代的用途是其词法
this绑定行为(lexical this),这将在课程的后续部分讨论。 - 除此以外,应优先使用带有名称的函数声明或函数表达式。
- 讲师认为,箭头函数唯一合理的、不可替代的用途是其词法
- 示例:Promise 链
- 在 Promise 链中,常见的做法是使用内联的箭头函数。
- 讲师认为这种风格可读性差,类似于过去被诟病的 jQuery “回调地狱”。
- 他推荐将这些回调函数提取为独立的命名函数声明,然后在
.then中通过名称引用它们,这样可以使代码更清晰、更易于维护。
60-function-types-hierarchy.txt
- 总结:各类函数的优先级
- 讲师分享了他对不同函数类型的个人偏好,形成了一个层级结构。
- 函数声明 (Function Declaration)
- 最优选。讲师认为它相比其他类型有一些优势(例如,之后会讲到的变量提升
hoisting)。
- 最优选。讲师认为它相比其他类型有一些优势(例如,之后会讲到的变量提升
- 命名函数表达式 (Named Function Expression)
- 次优选。它比函数声明稍逊一筹,但远远优于匿名函数表达式。
- 匿名函数表达式 (Anonymous Function Expression)
- 应避免。
- 箭头函数 (Arrow Function)
- 最后考虑。讲师只会在一种特定情况下使用它,即为了获得其词法
this的行为,否则会完全避免。
- 最后考虑。讲师只会在一种特定情况下使用它,即为了获得其词法
- 核心原则:代码的沟通性高于一切
- 即使是一个简单的操作,如
x => x * 2,一个明确的名称如doubleIt也能更直接地传达意图,让读者无需在头脑中“执行”代码来理解其功能。 - 代码的首要目的是清晰地沟通,让读者能一目了然地理解其目的。
- 因此,应优先选择能最大化代码沟通性的方式,即使这需要多打几个字。
- 即使是一个简单的操作,如
61-function-expression-exercise.txt
- 练习任务:函数表达式
- 目标:管理研讨会的学生注册记录。
- 精神:尽可能多地使用函数,特别是命名函数,以及
map,filter,find等函数式方法,而不是传统的for循环。
- 练习分为两部分:
- 第一部分: 使用函数声明和命名函数表达式来完成任务。
- 第二部分: 将第一部分的所有函数重写为箭头函数。
- 目的:通过并排比较,感受这两种风格在代码可读性和功能上的差异。
- 需要实现的核心功能:
printRecords(recordIds)- 输入:学生 ID 数组。
- 过程:
- 根据 ID 从
studentRecords中检索出每个学生的完整记录。 - 按照学生姓名升序排序。
- 将每条记录(姓名、ID、是否付费)打印到控制台。
- 根据 ID 从
paidStudentsToEnroll()- 过程:
- 遍历所有学生记录,找出那些已付费但尚未注册(即其 ID 不在
currentEnrollment数组中)的学生。 - 收集这些学生的 ID。
- 返回一个新数组,包含原有的注册 ID 加上这些新找到的 ID。
- 遍历所有学生记录,找出那些已付费但尚未注册(即其 ID 不在
- 过程:
remindUnpaid(recordIds)- 输入:学生 ID 数组。
- 过程:
- 筛选出这个数组中所有未付费的学生。
- 将筛选后的 ID 列表传递给
printRecords函数进行打印。
- 时间安排:
- 第一部分:约 15 分钟。
- 第二部分:约 5 分钟。
- 总计:约 20 分钟。
62-function-expression-solution-functions.txt
- 第一部分练习解答:使用函数声明和命名函数表达式
- 1. 创建一个辅助函数
getStudentById- 动机:通过分析需求,发现“根据 ID 获取学生记录”这个操作会在多个地方被用到,因此将其提取为一个可复用的函数。
- 实现:
function getStudentById(studentId) { return studentRecords.find(function matchId(record) { return record.id == studentId; }); }- 使用了
Array.prototype.find方法。 find的回调函数被命名为matchId,遵循“命名所有函数”的原则。
- 使用了
- 2. 实现
printRecords- 步骤:
- ID 列表 -> 记录列表:使用
recordIds.map(getStudentById)将 ID 数组转换为学生记录数组。 - 排序:使用
records.sort()进行原地排序。- 必须提供一个自定义的比较函数
sortByNameAsc来处理对象数组的排序。 sortByNameAsc接收两个记录对象,比较它们的name属性,并返回-1,1, 或0。
- 必须提供一个自定义的比较函数
- 打印:使用
records.forEach()遍历排序后的记录,并用console.log格式化输出。
- ID 列表 -> 记录列表:使用
- 步骤:
- 3. 实现
paidStudentsToEnroll- 步骤:
- 筛选:使用
studentRecords.filter(needsToEnroll)筛选出所有“已付费但未注册”的学生记录。needsToEnroll回调函数检查record.paid为true且currentEnrollment.includes(record.id)为false。
- 提取 ID:使用
.map(getStudentId)将筛选出的学生记录数组转换为 ID 数组。 - 合并与返回:使用扩展语法
[...currentEnrollment, ...idsToEnroll]创建并返回一个包含新旧所有注册 ID 的新数组。
- 筛选:使用
- 步骤:
- 4. 实现
remindUnpaid- 步骤:
- 筛选:使用
recordIds.filter()筛选出所有未付费学生的 ID。- 其回调函数内部需要调用
getStudentById来获取每个 ID 对应的学生记录,然后检查其paid状态。
- 其回调函数内部需要调用
- 打印:将筛选出的
unpaidIds数组传递给printRecords()函数。
- 筛选:使用
- 步骤:
- 总体风格:大量使用独立的、有明确名称的函数,作为
map,filter,find等高阶函数的回调,提高了代码的模块化和可读性。
63-function-expression-solution-arrow-functions.txt
- 第二部分练习解答:将函数重写为箭头函数
- 目标是探索一种更“简洁”的代码风格。
- 1. 重写
getStudentFromIdconst getStudentFromId = (studentId) => studentRecords.find((record) => record.id == studentId);- 利用了箭头函数的隐式返回特性,代码变得非常紧凑。
- 2. 重写
printRecordsconst printRecords = (recordIds) => recordIds .map(getStudentFromId) .sort((record1, record2) => record1.name < record2.name ? -1 : record1.name > record2.name ? 1 : 0 ) .forEach(/*...console.log logic...*/);- 链式调用:利用了
.map和.sort的链式调用能力。 - 三元运算符:将
sort的比较逻辑用一个嵌套的三元运算符压缩到一行,这是一个常见的箭头函数风格“技巧”。 - 隐式返回:所有回调都利用了隐式返回。
- 链式调用:利用了
- 3. 重写
paidStudentsToEnrollconst paidStudentsToEnroll = () => [ ...currentEnrollment, ...studentRecords .filter((record) => record.paid && !currentEnrollment.includes(record.id)) .map((record) => record.id), ];- 整个函数体变成一个返回数组的表达式,其中数组的第二个元素是一个完整的
filter().map()链式调用。
- 整个函数体变成一个返回数组的表达式,其中数组的第二个元素是一个完整的
- 4. 重写
remindUnpaidconst remindUnpaid = (recordIds) => printRecords( recordIds.filter((studentId) => !getStudentFromId(studentId).paid) );filter的回调直接调用辅助函数并访问其属性,非常简洁。
- 总结与反思
- 将所有函数都转换为箭头函数和链式表达式,确实让代码看起来更紧凑。
- 然而,这也带来了一些可读性上的挑战,比如将复杂逻辑压缩到单行三元运算符中。
- 讲师通过这种对比,旨在让学习者自己判断哪种风格在特定场景下更具可读性和可维护性,而不是盲目追求简洁。
64-lexical-dynamic-scope.txt
- 词法作用域 (Lexical Scope) 的正式定义
- 核心思想:作用域的结构是在代码编写时 (author time) 由代码的词法结构(即函数和块在代码中的位置)决定的。
- 与编译器的关系:这个决定是在编译/解析阶段完成的,与程序的运行时状态无关。
- 特点:固定、可预测。
- 绝大多数编程语言,包括 JavaScript,都采用词法作用域。
- 动态作用域 (Dynamic Scope)
- 核心思想:作用域的解析取决于函数被调用时 (run time) 的调用栈 (call stack)。
- 查找规则:当一个函数引用一个变量时,它会沿着调用它的函数链向上查找,而不是沿着其词法上的嵌套链查找。
- 举例:Bash 脚本是动态作用域的一个典型例子。
- 特点:灵活、可重用性强,但难以预测和推理。
- 两种模型的对比
- 词法作用域:
- 查找依据:代码写在哪里。
- 决定时机:编译时。
- 结果:固定、可预测 (predictable)。
- 动态作用域:
- 查找依据:函数从哪里被调用。
- 决定时机:运行时。
- 结果:动态、灵活 (flexible)。
- 词法作用域:
- JavaScript 的情况
- JavaScript 是词法作用域语言。
- 尽管 JavaScript 没有动态作用域,但它有其他机制(如
this关键字)提供了类似的灵活性,这将在课程后面讨论。
65-lexical-scope.txt
- 深入理解词法作用域
- 编译时决定:当你在一个函数内部嵌套另一个函数时,内部函数对外部变量的引用关系在编译时就已经不可撤销。
- 优化潜力:正因为这种关系是固定的,JavaScript 引擎可以进行大量优化。它不必在每次运行时都去动态查找变量,可以在编译时就确定变量的来源(“弹珠的颜色”)。
- 可视化词法作用域
- 气泡模型:
- 可以将每个作用域想象成一个“气泡”。
- 这些气泡根据代码的嵌套结构严格地包含在一起,不会有交叉或重叠。
- 这有助于在头脑中构建清晰的作用域边界。
- 编辑器插件 (ES Levels):
- 存在一些工具(如
ES levels),可以在编辑器中通过不同的颜色来可视化代码中不同部分所属的作用域。 - 这可以直观地展示出每个变量(“弹珠”)属于哪个作用域(“水桶”)。
- 尽管这些工具可能不完美(比如对命名函数表达式的处理有瑕疵),但它们对于理解复杂的作用域嵌套非常有帮助。
- 存在一些工具(如
- 气泡模型:
- 结论:无论是通过心智模型还是工具,清晰地可视化和理解代码的作用域边界,是掌握词法作用域的关键。
66-dynamic-scope.txt
- 动态作用域的理论工作方式
- 场景设想:如果 JavaScript 是动态作用域语言,代码的行为将会如何?
- 查找规则:
- 函数
ask内部引用了变量teacher,但ask自身作用域内没有定义它。 - 在动态作用域下,引擎不会去查找
ask的词法外层作用域。 - 相反,它会问:“
ask是从哪里被调用的?” - 因为
ask是从otherClass函数内部调用的,所以引擎会在otherClass的作用域中查找teacher。
- 函数
- 关键影响:同一个函数,如果从 100 个不同的地方调用,它内部的变量引用可能会解析到 100 个不同的变量。
- 动态作用域的优缺点
- 优点:极大的灵活性 和可重用性。同一个函数可以适应多种不同的上下文。
- 缺点:对于习惯词法作用域的开发者来说,这看起来像是混乱,因为代码的行为变得难以预测。
- 总结
- 词法作用域:在编写时确定,是固定 和可预测 的。
- 动态作用域:在运行时确定,是动态 和灵活 的。
- 尽管 JavaScript 没有动态作用域,但理解这个概念有助于我们更好地欣赏词法作用域的特点,并为后续学习 JavaScript 中提供类似灵活性的机制(如
this)做好铺垫。
67-function-scoping.txt
- 利用函数作用域解决实际问题
- 问题场景:命名冲突。
- 一段代码中,两个部分无意中使用了相同的变量名,导致后者的赋值覆盖了前者的值,引发难以发现的 bug。
- 问题场景:命名冲突。
- 解决方案的演进
- 初步尝试:使用函数包装
- 将冲突的代码用一个函数包裹起来,利用函数作用域创建一个新的“水桶”。
- 新问题:虽然解决了内部变量的冲突,但这个包装函数本身又在外部作用域中引入了一个新的名称(如
anotherTeacher),只是将命名冲突的问题转移了,并未根除。
- 初步尝试:使用函数包装
- 核心原则:最小暴露原则 (Principle of Least Exposure / Privilege)
- 定义:默认将所有东西保持为私有 (private),只暴露最小化的、必要的部分。
- 优势:
- 减少命名冲突:隐藏内部实现细节,减小了与其他代码发生名称碰撞的“表面积”。
- 防止误用:他人无法意外或故意地依赖和使用你的内部实现。
- 保护未来重构:这是最重要的原因。如果实现细节被隐藏,你就可以在未来自由地重构它,而不用担心破坏依赖于它的外部代码。
- 引出 IIFE 模式
- 我们需要一种方法来创建一个作用域,但又不污染外部作用域。
- 思路推导:
- 函数调用可以被看作两步:a) 获取函数引用;b) 执行它。
- 我们可以用括号包裹函数引用来强调第一步:
(anotherTeacher)()。 - 既然括号里可以是一个表达式(变量),那为什么不能直接放一个函数表达式呢?
- 这就引出了下一节要讲的 IIFE 模式。
68-iife-pattern.txt
- IIFE (Immediately Invoked Function Expression) 模式
- 全称:立即调用的函数表达式。
- 目的:创建一个临时的、一次性的作用域,以封装变量和逻辑,避免污染外部作用域。
- 语法结构:
(function anotherTeacher() { // ... a new scope ... })(); - 关键点分析:
(...)():外层的括号(function...{...})将函数声明强制转换为函数表达式。因为function不再是语句的第一个词。():紧随其后的括号立即调用这个函数表达式。
- 为什么要用函数表达式?
- 函数声明会将其名称绑定到所在作用域。
- 函数表达式(特别是其名称)则不会污染外部作用域,从而完美地解决了之前的问题。
- IIFE 的命名
- 尽管常见的 IIFE 是匿名的,但根据之前“命名所有函数”的原则,IIFE 也应该被命名。
- 名称(如
anotherTeacher)有助于提高堆栈跟踪的可读性。 - 如果实在想不出好名字,可以用
IIFE作为函数名,这比(anonymous function)强。
- IIFE 的其他用途
- 传递参数:IIFE 也是函数,可以接收参数,这是一种明确依赖注入的方式。
- 将语句转换为表达式:
- 某些有用的语法结构,如
try...catch,是语句 (statement),不能用在需要表达式 (expression) 的位置(如变量赋值的右侧)。 - 通过将
try...catch语句包裹在一个 IIFE 中,并从内部返回值,可以将整个结构“转换”为一个表达式,从而可以用在赋值等场景中,使代码意图更清晰。
- 某些有用的语法结构,如
69-block-scoping.txt
- 块级作用域 (Block Scoping)
- 定义:使用花括号
{}创建的作用域,而不是函数。 - 目的:与函数作用域相同——隐藏变量,避免命名冲突,遵循最小暴露原则。
- 优势:比 IIFE 更轻量,语法更简洁,副作用更少(如不影响
this、return等)。
- 定义:使用花括号
- 如何创建块级作用域
let和const:这两个关键字是开启块级作用域的关键。- 当你在一个块
{...}内部使用let或const声明变量时,这个块就变成了一个作用域。 - 这些变量只在该块内部有效。
- 当你在一个块
- 为什么不是
var:var声明的变量会“穿透”块级结构,将自身附加到其外层的函数作用域或全局作用域,因此它不能用于创建块级作用域。
let的正确使用时机- 理念:
let应该被用来强化你已经通过代码风格暗示的意图。 - 示例:
- 过去,开发者可能会在
if语句或for循环内部使用var来声明一个临时变量(如tmp),虽然var会将变量提升到函数作用域,但这在风格上已经向读者传达了“这个变量只属于这个块”的意图。 - 现在,你可以用
let替换这些var,从而将这种风格上的暗示强制执行为真正的块级作用域。
- 过去,开发者可能会在
- 反模式:不应该为了使用
let这个“新潮”特性而刻意地在代码中增加不必要的块级作用域,这只会增加代码的复杂性。
- 理念:
- “
let是新的var” 这种说法的批判- 这是一个非常糟糕的建议。简单地将项目中的所有
var替换为let可能会引入 bug,并且是对let特性的误用。 let和const是工具箱中的新工具,而不是var的完全替代品。var仍然有其存在的意义和适用场景(例如,当你想声明一个在整个函数范围内都可用的变量时)。
- 这是一个非常糟糕的建议。简单地将项目中的所有
70-choosing-let-or-var.txt
- 核心论点:
var和let应该共存,而不是互相取代- 针对“
let是新的var”的流行观点,讲师提出了反对意见,认为这是一种对工具的误用。
- 针对“
var的适用场景:函数作用域内的变量- 语义清晰度:当一个变量的意图是在整个函数范围内都可用时,使用
var是一种明确的语义信号。这利用了var20 多年来一贯的行为,为代码读者提供了清晰的意图。 let的局限性:如果在函数顶层使用let,读者无法判断你的意图是让变量在整个函数可用,还是只在开头几行使用。let的核心语义是局部化,适用于小范围(几行代码)的变量。- 工具的正确使用:即使一个工具(
let)能够工作,也不意味着它被用在了正确的地方或以正确的方式使用。var在表示“函数范围”这个意图上是更合适的工具。
- 语义清晰度:当一个变量的意图是在整个函数范围内都可用时,使用
var的行为优势:在特定场景下更可取- “逃离”非预期的块:
- 像
try...catch这样的语法结构会创建块,但开发者通常不把它看作是用来隐藏变量的作用域。 - 如果在
try块内用let声明变量,这个变量将无法在块外部被访问,这可能会违背开发者的意图。 - 使用
var可以让变量“提升”到函数作用域,从而在try...catch外部也能被访问,避免了需要将声明提前到块外部的冗长写法。
- 像
- 在同一作用域内重复声明:
var允许在同一作用域内被声明多次,这可以作为一种语义信号,用来消除歧义。- 示例 1(条件分支):在
if...else的两个分支中都使用var id = ...,可以清晰地告诉读者,无论哪个分支被执行,函数作用域内都会有一个名为id的变量。 - 示例 2(长函数):在一个很长的函数中,如果一个变量在开头声明,然后在几百行之后再次使用,可以在使用前再次用
var“重新声明”它。这可以提醒读者这个变量所属的作用域,避免他们向上滚动查找。let则不允许这样做。
- “逃离”非预期的块:
- 结论:
letislet+var- “
x是新的y”在计算机科学史上几乎从未成立过。新工具通常是增强 (augment) 而非完全取代 (obviate) 旧工具。 let并非var的替代品,而是var的补充。开发者应该根据具体场景和想要传达的语义,明智地选择使用var还是let。
- “
71-explicit-let-block.txt
- 使用块级作用域的最佳实践:显式
let块- 问题:即使在函数顶层使用
let,也常常会有一些变量实际上只在函数的某个小片段(几行代码)内被使用。将这些变量的声明放在函数顶部,会扩大它们不必要的“存活”范围。
- 问题:即使在函数顶层使用
- 解决方案:创建显式作用域块
- 方法:当一组变量只用于一小段逻辑时,应该主动用一对花括号
{}将这段逻辑和这些变量的let声明包裹起来。 - 语法风格建议:将
let声明放在与起始花括号{同一行,这样可以非常清晰地向读者传达:“这个块的存在就是为了这些变量,并且它们只属于这个块。”function processData(data) { // ... { let prefix, rest; // 只在这里使用 prefix 和 rest } // ... }
- 方法:当一组变量只用于一小段逻辑时,应该主动用一对花括号
- 好处
- 更清晰地沟通意图:明确地告诉读者这些变量的生命周期和作用范围非常有限。
- 遵循最小暴露原则:将变量的作用域限制在最小的必要范围内。
- 与性能的关系
- 这种做法主要是为了语义清晰性和代码可读性,而不是为了性能。
- 理论上可能有微小的性能优势(如垃圾回收可能更早介入),但在实践中几乎无法观测到差异。
- 核心思想总结:作为开发者,应该养成主动使用显式块来最小化变量作用域的习惯,这是一种在其他拥有块级作用域的语言中长期存在的优秀实践。
72-const.txt
- 对
const的批判性观点- 核心论点:
const关键字在 JavaScript 中“名不副实”。它带来的好处微乎其微,而其潜在的混淆成本却很高。 - 讲师非常节制 地使用它,而不是将其作为默认声明方式。
- 核心论点:
const的主要问题:混淆性- 普遍的误解:人们直觉上认为
const(constant/常量) 意味着“一个值不会改变”。 - 实际的含义:
const实际上只保证“一个变量不能被重新赋值”。 - 问题实例:
const myTeacher = ...,之后myTeacher = ...会报错,这符合预期。const teachers = [...],之后teachers.push(...)不会报错。因为这只是改变 (mutate) 了数组这个值的内容,而没有重新赋值 (reassign)teachers这个变量。
- 历史包袱:这种混淆性在多种编程语言中都存在,导致了大量的困惑。
- 普遍的误解:人们直觉上认为
const带来的实际好处有限- 语义:
const实际上是在说:“在这个小块的剩余部分,我保证这个变量不会被重新赋值。” - 作用范围小:由于
const通常被推荐用在很小的块级作用域内(3-5 行),这个“保证”的范围也非常有限,其作用就像“儿童房里的夜灯”——让人感觉安心,但实际上并没解决什么大问题。 - 讲师认为,变量的意外重新赋值并不是 bug 的主要来源。
- 语义:
- 讲师推荐的使用策略
- 何时使用
const:只在为一个原始类型 (primitive) 且因此是不可变 (immutable) 的值(如字符串、数字、布尔值)赋予一个语义化的名称时使用。 - 目的:作为“魔法数字”或“魔法字符串”的替代品,提高代码的可读性,而不是为了防止重新赋值。
// Good use of const const API_URL = "<https://api.example.com>"; const TIMEOUT_MS = 5000;
- 何时使用
- 总结:与主流观点的对比
- 主流观点:优先使用
const,其次let,绝不使用var。 - 讲师的观点(反向):默认使用
var,在需要块级作用域时使用let,只在少数特定情况下(为不可变原始值提供语义名称时)才使用const。
- 主流观点:优先使用
73-const-q-a.txt
- 问题 1:
const和字符串- 回答:是的,讲师只对原始、不可变的值(字符串、数字、布尔值)使用
const。并且,使用const的目的是为了给一个字面量赋予一个语义化的名称,而不是为了防止所有字符串变量被修改。一个很好的例子是 API 的 URL。
- 回答:是的,讲师只对原始、不可变的值(字符串、数字、布尔值)使用
- 问题 2:
const和Object.freeze结合使用- 回答:
- 讲师喜欢使用
Object.freeze()来实现对象或数组的浅层不可变。 - 但是,他不会这样写:
const x = Object.freeze({...})。 - 原因:这样做会给读者一种错误的暗示,让他们以为
const关键字与值的“冻结”(不可变性)有直接关系,而实际上它们是两个独立的概念。const只保证引用不被重分配,而freeze保证对象属性不被修改。 - 因此,他仍然坚持只对原始类型的值使用
const。
- 讲师喜欢使用
- 回答:
74-hoisting.txt
- 揭示“提升 (Hoisting)”的真相
- Hoisting 不是真实存在的过程:JavaScript 引擎实际上并不会在物理上“移动”代码。这个词甚至在很长一段时间里都未出现在 JS 规范中。
- Hoisting 是一个比喻 (Metaphor):它是一个我们为了方便解释词法作用域的结果而创造出来的心智模型,特别是为了那些坚持用“单遍执行”的思维模式来理解代码的人。
- 为什么 Hoisting 不可能真实存在:要实现所谓的“预先查找并移动声明”,引擎必须先对代码块进行复杂的处理来识别出所有声明——这个处理过程,本质上就是我们之前讨论的解析 (Parsing) 或编译。
- 结论:与其说“变量被提升了”,不如说“变量声明在编译阶段被处理了”。
- Hoisting 的不准确性可能导致误解
- 仅凭 Hoisting 这个简化的比喻来解释复杂的代码行为,很容易出错,并导致在 Stack Overflow 等社区中传播不准确的信息。
- 正确的理解方式:先理解真正的两阶段处理模型(编译 + 执行),然后再将 Hoisting 作为一种讨论时的“简写”。
- 函数声明与函数表达式的 Hoisting 差异
- 函数声明
function foo() {}:整个函数(包括函数体)都会“提升”,因此可以在声明之前被调用。 - 函数表达式
var foo = function() {}:- 只有变量声明
var foo会“提升”,并在作用域顶部被初始化为undefined。 - 赋值操作
... = function() {}仍然保留在原来的位置,在运行时才执行。 - 因此,在赋值之前调用
foo()会导致TypeError(因为foo的值是undefined,而不是一个函数)。
- 只有变量声明
- 函数声明
- 利用 Hoisting 改善代码结构
- 传统风格:将所有函数定义放在文件顶部,可执行代码放在底部。这会导致打开文件后需要滚动很长才能看到核心逻辑。
- 利用 Hoisting 的新风格:将可执行代码放在文件顶部,而将函数声明放在底部。
- 这样一打开文件就能看到程序的入口和主要逻辑,更加直观。
- 将函数声明视为“实现细节”,放在后面。
- 这体现了如何利用 hoisting 这个特性来提高代码的可读性。
75-hoisting-example.txt
-
变量提升 vs. 函数提升
-
场景分析:
function someFunc() { console.log(teacher); // 输出什么? undefined otherTeacher(); var teacher = "Kyle"; function otherTeacher() { ... } }console.log(teacher)会输出undefined。因为var teacher的声明被“提升”了,所以在执行到console.log时,变量teacher已经存在于作用域中,但尚未被赋值。- 这是变量提升可能导致困惑的一个典型例子。
-
-
对不同类型 Hoisting 的态度
- 变量赋值提升:即在
var声明之前就对变量进行赋值或读取。- 普遍共识:这是一种糟糕的编码风格,应该避免。几乎在所有情况下,都应该先声明再使用。
- 函数声明提升:
- 观点分歧:有些人认为这也是不好的,但更多人能接受,甚至认为它非常有用。
- 讲师的观点:函数提升非常有用,因为它允许开发者将可执行的核心逻辑放在文件顶部,而将实现细节(函数声明)放在底部,这极大地改善了代码的可读性。
- 变量赋值提升:即在
76-let-doesn-t-hoist.txt
- 一个常见的误解:“
let不会提升”- 这种说法是错误的。
let和const同样会提升,但它们的行为与var不同。
- 这种说法是错误的。
- 证据:
let确实会提升- 考虑以下代码:
var teacher = "Kyle"; { console.log(teacher); // Throws TDZ error let teacher = "Suzy"; } - 如果
let teacher没有提升,那么console.log(teacher)应该会沿着作用域链找到外层的var teacher并打印 "Kyle"。 - 但实际上它抛出了一个 TDZ (Temporal Dead Zone) 错误。这证明在执行到
console.log时,块级作用域内已经知道存在一个teacher变量,只是它处于一个“暂时性死区”中。
- 考虑以下代码:
let/const与var提升的关键区别- 提升范围:
var提升到函数作用域,而let/const提升到块级作用域。 - 初始化行为(更重要的区别):
var:在作用域开始时,提升的变量被初始化为undefined。let/const:在作用域开始时,提升的变量处于未初始化 状态。在代码执行到其声明语句之前,任何对它的访问都会触发 TDZ 错误。
- 提升范围:
- TDZ 存在的原因(学术性的解释)
- TDZ 的主要动机并非为了阻止“先使用后声明”,而是源于
const的设计需求。 - 设想一下,如果
const像var一样被初始化为undefined,那么在一个const变量的生命周期中,它将可能存在两个值:undefined和它最终被赋予的常量值。 - 这在学术上违反了“常量”的定义。
- 因此,TDZ 被设计出来,以确保在
const变量被赋予其唯一的值之前,绝对无法被访问。 let只是顺带也应用了 TDZ 规则。
- TDZ 的主要动机并非为了阻止“先使用后声明”,而是源于
- 如何避免 TDZ
- 非常简单:将所有的
let和const声明放在其所在块的最顶部。这样,你永远不会在声明之前访问它们。
- 非常简单:将所有的
77-hoisting-exercise.txt
- 练习任务:提升 (Hoisting)
- 目标:通过重构现有代码,实践和利用函数提升的知识。
- 核心任务:改变代码风格,但不改变其行为。
- 重构要点
- 提取内联函数表达式:
- 查找代码中可以被提取出来的内联函数表达式。
- 判断标准:如果一个内联函数没有依赖其所在位置的词法作用域变量(闭包),它就可以被安全地提取到更外层的作用域,甚至全局作用域。
- 好处:
- 使作用域结构更扁平 (flatter),从而更易于理解和分析。
- 使使用这些函数的地方(如
.map(doSomething))读起来更清晰。
- 利用函数提升调整代码布局:
- 当前代码中,可执行的逻辑位于文件的底部。
- 任务:利用函数声明会提升的特性,将可执行的核心代码移动到文件顶部,而将所有的函数声明(实现细节)移动到底部。
- 好处:打开文件时,最重要的执行逻辑一目了然。
- 提取内联函数表达式:
- 总结
- 这个练习旨在让你体验如何通过理解 hoisting 这一机制,来主动地组织代码,以达到更清晰、更易读的结构。
78-hoisting-exercise-solution.txt
- 练习解答:重构代码以利用 Hoisting
- 第一步:提取内联函数为独立的函数声明
getStudentById内部的回调:studentRecords.find(function matchId(record){...})->matchId被提取出来,但因为它闭包了外部的studentId,所以它必须保留在getStudentById函数作用域内部。
printRecords内部的回调:sort的比较函数sortByNameAsc被提取为独立的函数声明。forEach的回调printRecord也被提取为独立的函数声明。- 好处:
records.sort(sortByNameAsc)读起来比内嵌一个复杂的函数表达式更清晰。
- 其他函数中的回调:
needsToEnroll,getStudentId,isUnpaid等回调函数都因不依赖局部词法环境而被提取到了顶层(全局作用域),实现了作用域的扁平化。
- 第二步:调整代码整体布局
- 将所有提炼出来的、以及原有的函数声明全部移动到文件的底部。
- 将可执行的代码(即对这些函数的调用)移动到文件的顶部。
- 在可执行代码和函数声明之间,可以添加一个注释行(如
// *********************)作为视觉分隔符,清晰地标示出“执行逻辑区”和“实现细节区”。
- 最终效果与总结
- 可读性提升:
- 打开文件即可看到程序的入口和核心流程。
map,filter等高阶函数的调用点变得非常简洁明了,因为回调逻辑被封装在有意义的函数名后面。
- hoisting 的正面应用:
- 这个练习展示了 hoisting 并非总是“坏”的。当被有意识地、有策略地使用时,它可以成为一种强大的工具,用于组织和改善代码结构。
- 通过这种方式,开发者能够将“做什么”(what)和“怎么做”(how)在代码中进行物理分离,提高了代码的抽象层次和可维护性。
- 可读性提升:
79-origin-of-closure.txt
- 闭包 (Closure) 的重要性与普遍性
- 闭包是计算机科学史上最重要、最普遍的概念之一。
- 讲师引用 Douglas Crockford 的一个玩笑话来强调其伟大之处:伟大的思想需要一代人才能普及,而闭包需要了两代人才被广泛接受。
- 普遍存在:几乎所有现代编程语言都支持闭包。任何写过几小时 JavaScript 的开发者,都在不经意间使用过它(例如,异步回调、事件处理器等)。
- 闭包的起源与在 JavaScript 中的引入
- 学术渊源:闭包的概念早于计算机科学,源于λ 演算 (lambda calculus)。
- JavaScript 的“意外天才”:
- 1995 年,Brendan Eich 被 Netscape 雇佣,他本想将 Scheme(一种具有强大闭包特性的函数式语言)引入浏览器。
- 但管理层要求他做一门“看起来像 Java”的语言。
- 于是,他创造了 JavaScript——一门有着 Java 般语法的“伪装的 Scheme”。
- JavaScript 将闭包这个原本属于学术圈的强大特性,带入了主流的、面向消费者的编程领域,这在当时是革命性的。
- 这种结合了平易近人的语法和强大函数式特性的设计,是 JavaScript 后来取得巨大成功并无处不在的关键原因之一。
- 对闭包定义的挑战
- 普遍的困惑:尽管闭包无处不在,但很多人(甚至是资深开发者)都很难给出一个精确的定义。
- 学术定义的局限性:无论是 λ 演算的数学定义,还是教科书式的计算机科学定义,对于实践中的开发者来说,通常都过于抽象,难以理解和应用。
- 本课程的目标:一个可观察的定义
- 讲师的目标是提供一个非学术性的、可观察 (observable) 的定义。
- 这个定义将侧重于:当一门语言拥有闭包特性时,我们能在程序中观察到哪些与众不同的行为。
- 先决条件:要理解闭包,必须首先牢固地掌握词法作用域 (lexical scope)。这就是为什么课程先讲词法作用域,再引向闭包,最终目标是理解模块模式 (module pattern)。
80-what-is-closure
- 闭包的定义
- 从观察角度看,闭包的定义是:当一个函数能够记住并访问其词法作用域(即函数外部的变量),即使该函数在不同的作用域中执行时,这种现象就称为闭包。
- 这个定义包含两个关键部分:
- 能够访问词法作用域:这本身就是词法作用域的特性,函数可以引用其外部的变量。
- 即使函数在不同的作用域中执行:这是闭包的核心和关键。没有这一部分,就仅仅是词法作用域。
- 闭包如何工作
- 当你将一个函数作为回调函数传递,或从另一个函数中返回它时,它最初被定义的作用域通常我们认为会“消失”或被垃圾回收。
- 然而,如果在这个作用域中定义的函数“存活”了下来(被传递或返回),那么该作用域实际上并不会消失。
- 这个存活下来的函数会持有对其原始词法作用域的引用。无论你将这个函数传递到哪里,它都持续保有对那个作用域的访问权。
- 这种对原始作用域的保持和链接,就是闭包。
- 常见示例
- 定时器 (
setTimeout)- 当我们将一个函数(如
waitASec)传递给setTimeout时,这个函数引用了其外部作用域(ask函数)中的变量question。 - 在
waitASec函数执行时,ask函数早已执行完毕。 - 但变量
question并没有消失,因为它被闭包“保存”了下来。我们称waitASec函数“闭包”了变量question。
- 当我们将一个函数(如
- 返回函数
- 当一个函数(如
ask)返回另一个函数时,被返回的函数闭包了ask函数作用域中的变量question。 - 即使
ask函数已经执行完毕,我们仍然可以通过调用被返回的函数来访问变量question。 - 这种访问不是对变量值的快照,而是对变量本身的实时访问。
- 当一个函数(如
- 定时器 (
- 闭包的作用域范围
- 学术观点:闭包是基于单个变量的。只有被引用的变量才会被保留。
- 实际引擎实现:通常,JavaScript 引擎将闭包实现为对整个作用域的链接,而不是基于单个变量。
- 注意事项:最好假设闭包是基于作用域的。如果你在一个作用域中有一个持有大量数据的变量,然后创建了一个闭包(即使该闭包没有引用这个大变量),这个大变量也可能不会被垃圾回收,从而导致内存问题。
- 闭包的必要性
- 在一个拥有词法作用域和头等函数(first-class functions)的语言中,闭包是必然的产物。
- 如果没有闭包,你传递一个函数后,它会丢失所有对其外部变量的记忆,这将使头等函数变得毫无用处。
81-closing-over-variables
- 闭包不是值的快照
- 一个常见的误解是,闭包会“快照”或捕获变量在某一时刻的值。
- 这是完全错误的。闭包根本不关心值。
- 正确理解:你不是闭包一个值,而是闭包一个变量。
- 闭包是对变量的实时链接
- 闭包创建的是一个指向变量的实时链接。
- 当你通过闭包访问变量时,你看到的是该变量在访问时刻的当前值,而不是创建闭包时的值。
- 示例:
- 一个函数
myTeacher闭包了变量teacher,初始值为 "Kyle"。 - 之后,
teacher的值被修改为 "Suzy"。 - 当执行
myTeacher函数时,它会打印 "Suzy",因为它访问的是teacher变量的最新值。
- 一个函数
- 经典问题:循环中的闭包
- 问题描述: 在一个使用
var的for循环中创建多个闭包(例如,在setTimeout中打印循环变量i)。 - 预期结果: 打印出 1, 2, 3...
- 实际结果: 打印出 4, 4, 4... (或者循环结束时的最终值)。
- 原因分析:
- 整个循环中只有一个
i变量。 - 所有被创建的函数都闭包了这同一个
i变量。 - 当
setTimeout的回调函数最终执行时,循环早已结束,此时i的值是它的最终值(4)。 - 如果你需要三个不同的值,你就需要三个不同的变量。
- 整个循环中只有一个
- 问题描述: 在一个使用
- 解决方案:为每次迭代创建新的变量
- 在循环内部使用
let(ES6 之前常用 IIFE)- 在
for循环内部,使用let j = i;。 let会为每次循环迭代创建一个新的块级作用域变量j。- 这样,每个闭包都会捕获一个不同的
j变量实例。
- 在
- 在
for循环声明中使用let(现代最佳实践)- 直接写
for (let i = 1; i <= 3; i++)。 - ES6 特别规定,当
let用在for循环的头部时,它会为每次循环迭代创建一个新的i变量。 - JavaScript 引擎会自动处理,将上一次迭代的
i值赋给下一次迭代的新i。 - 这使得闭包可以“神奇地”按预期工作。
- 这个特性适用于所有形式的
for循环 (for,for...of,for...in)。 - 注意: 在
for (let i...; i++..)结构中,必须使用let而不是const,因为i++会修改变量。
- 直接写
- 在循环内部使用
82-module-pattern
-
模块模式的基础
- 模块模式建立在词法作用域和闭包的理解之上。
-
什么不是模块模式:命名空间模式 (Namespace Pattern)
var workshop = { teacher: "Kyle", ask: function() { ... } };- 这不是一个模块,因为它缺乏封装 (Encapsulation)。
-
封装:模块的核心
- 封装指的是隐藏数据和行为的思想。
- 一个真正的模块必须有公共 (public) 和 私有 (private) 部分之分。
-
经典模块模式
function createWorkshop(name) { var teacher = name; // 私有变量 function getTeacher() { // 私有函数 return teacher; } var publicAPI = { ask: function (question) { // 公共函数 console.log(getTeacher() + " asks: " + question); }, }; return publicAPI; } var workshop = createWorkshop("Kyle"); workshop.ask("What is a module?"); // Kyle asks: What is a module?- 它利用闭包来实现封装。
- 两个关键组成部分:
- 一个外部包裹函数,它创建了一个私有作用域。
- 一个或多个内部函数,它们闭包了外部函数的私有变量。
-
模块模式的变体和目的
-
单例模块 (Singleton)
var workshop = (function () { var teacher = "Kyle"; // 私有变量 return { ask: function (question) { // 公共函数 console.log(teacher + " asks: " + question); }, }; })(); // IIFE 立即执行 -
模块工厂 (Factory)
function createWorkshop(name) { var teacher = name; return { ask: function (question) { console.log(teacher + " asks: " + question); }, }; } var workshop1 = createWorkshop("Kyle"); var workshop2 = createWorkshop("Suzy");
- 模块的真正目的:
- 跟踪和管理状态。
- 通过暴露一个最小化的公共 API 来控制对状态的访问。
-
83-es6-modules-node-js
- ES6 模块的诞生
- 由于模块模式在 JavaScript 中极为重要,社区长期以来一直呼吁语言层面提供原生语法支持,最终 ES6 模块应运而生。
- 历史问题:与 Node.js 的不兼容
- 在制定规范时,TC39(JavaScript 标准委员会)与 Node.js 团队之间缺乏沟通。
- 导致 ES6 模块的规范与 Node.js 现有的 CommonJS 模块系统存在根本性的不兼容。
- 为了解决这个问题,社区经历了长达数年的讨论和艰难的妥协。
- 主要不兼容点:最大的挑战在于实现两种模块系统的互操作性(例如,
require一个 ES6 模块,或import一个 CommonJS 模块),尤其是在涉及循环依赖时情况变得异常复杂。
- 妥协方案与现状
- 一个主要的妥协是在 Node.js 环境中,需要使用不同的文件扩展名(
.mjs)来明确标识 ES6 模块文件。 - Node.js 对 ES6 模块的支持经历了漫长的过程(规范发布后约 5 年才趋于稳定),并且是分阶段实现的。
- 一个主要的妥协是在 Node.js 环境中,需要使用不同的文件扩展名(
- ES6 模块的工作方式
- 基于文件:每个文件就是一个独立的模块。一个文件不能包含多个 ES6 模块。
- 默认私有:模块文件中的所有变量和函数默认都是私有的,仅在模块内部可见。你不再需要使用函数包裹来创建私有作用域。
export关键字:使用export关键字来将模块内的成员(变量、函数、类等)暴露出去,使其成为公共 API 的一部分。- 默认单例:一个模块在整个应用程序中只会被执行一次(在它首次被
import时)。后续所有对该模块的import都会获取对这同一个实例的引用。 - 模块工厂:如果你需要创建多个模块实例,你必须从你的单例模块中导出一个工厂函数,由调用方来执行以创建新实例。
84-es6-module-syntax
- 导入模块的两种主要风格
- 命名导入 (Named Import)
- 语法:
import { ask } from "./workshop.mjs"; - 这里
ask是从workshop.mjs模块中导出的一个具名成员。你也可以导入默认导出:import myDefaultExport from "./module.js";并给它命名。 - 概念: 这种风格将标识符(变量、函数等)直接“导入”到当前文件的作用域中。 speaker 称之为“Java 风格”。
- 这代表了一种新的思维方式,即按需引入特定功能。
- 语法:
- 命名空间导入 (Namespace Import)
- 语法:
import * as workshop from "./workshop.mjs"; - 概念: 这种风格将模块所有导出的公共成员收集到一个单一的对象上(此例中为
workshop)。然后通过该对象的属性来访问具体成员,例如workshop.ask()。 - 这种方式更符合 JavaScript 传统的模块使用习惯(类似 CommonJS 的
require)。 - speaker 个人更偏好这种风格,因为它能将来自不同模块的功能清晰地组织在各自的命名空间下,避免了命名冲突。
- 语法:
- 命名导入 (Named Import)
- 关于模块模式的总结
- 无论是使用新的 ES6 语法,还是经典的“揭示模块模式”,其核心设计理念是相同的:
- 将行为组织成一个内聚的单元。
- 隐藏内部实现细节和状态。
- 只暴露一个最小且必要的公共 API。
- 模块模式是组织 JavaScript 代码最重要的设计模式之一,无论你在模块内部使用何种编码风格(如面向对象、函数式),模块都是组织代码的顶层结构。
- 无论是使用新的 ES6 语法,还是经典的“揭示模块模式”,其核心设计理念是相同的:
- 课程回顾
- 至此,关于 JavaScript 三大支柱中最重要的一个——词法作用域——的深入探讨已经完成。
85-module-exercise
- 练习目标
- 将一段现有代码重构为经典的揭示模块模式 (Revealing Module Pattern)。
- 重构过程中不改变代码原有的功能。
- 附加挑战: 在完成经典模式后,尝试使用 ES6 模块语法再次实现。
- 具体步骤
- 定义一个名为
defineWorkshop的模块工厂函数。 - 这个工厂函数将创建并返回一个代表公共 API 的对象实例。
- 公共 API 需要暴露以下五个方法:
addStudentenrollStudentprintCurrentEnrollmentenrollPaidStudentsremindUnpaidStudents
- 将现有的
currentEnrollment和studentRecords数组移动到模块工厂函数内部,使它们成为私有状态。在模块内部将它们初始化为空数组。 - 修改外部代码,不再直接操作数组,而是通过调用模块实例的公共 API 方法(如
addStudent,enrollStudent)来填充数据。 - 使用工厂函数创建一个模块实例:
var deepJS = defineWorkshop();。 - 通过
deepJS实例调用相应的方法来添加学生、注册课程并执行其他操作。
- 定义一个名为
- 练习的核心思想
- 隐藏实现细节:外部代码不需要知道内部是使用数组来跟踪学生记录和注册情况的。这只是一个实现细节。
- 封装的好处:
- 便于未来重构:将来可以随意更改内部数据结构(例如从数组换成 Map),而不会破坏外部依赖此模块的代码。
- 防止滥用:避免外部代码意外或恶意地修改内部状态。
86-module-exercise-solution
-
揭示模块模式 (Revealing Module Pattern) 实践解析
-
核心思想: 把散乱的全局变量和函数,包装成一个有边界的"盒子"
-
基本实现结构:
// 1. 工厂函数(造模块的函数) function defineWorkshop() { // 2. 私有数据(外面看不见) var currentEnrollment = []; var studentRecords = []; // 3. 私有函数(外面看不见) function getStudentFromId() { /* ... */ } function printRecords() { /* ... */ } // 4. 公共接口(外面能用的) var publicAPI = { addStudent: function () { /* ... */ }, enrollStudent: function () { /* ... */ }, printCurrentEnrollment: function () { /* ... */ }, }; // 5. 返回接口 return publicAPI; } // 6. 使用 var deepJS = defineWorkshop(); deepJS.addStudent(123, "张三", true);
-
-
改造前后对比
-
改造前:
// 全局变量 - 谁都能改 var currentEnrollment = [410, 105]; var studentRecords = [{...}]; // 直接操作 currentEnrollment.push(123); printRecords(currentEnrollment); -
改造后:
// 创建模块 var deepJS = defineWorkshop(); // 通过接口操作 deepJS.enrollStudent(123); deepJS.printCurrentEnrollment();
-
-
三个关键设计原则
- 闭包 = 数据保险箱
- 私有数据被"锁"在函数内部
- 只有公共方法能访问
- 工厂模式 = 模块制造机
defineWorkshop()每次调用创建新模块- 多个模块互不干扰
- API 设计 = 只露必要的
- 暴露:做什么 (
addStudent) - 隐藏:怎么做 (用数组还是对象存储)
- 暴露:做什么 (
- 闭包 = 数据保险箱
-
模块化的核心价值
问题 解决方案 数据被意外修改 私有变量 + 受控访问 全局命名冲突 模块命名空间 代码难维护 相关功能打包 重构影响大 接口不变,内部随便改 -
实践建议: 看到全局变量 → 考虑模块化包装
87-objects-overview
- JavaScript 的三大支柱
- 本部分将介绍课程中的第二个核心支柱:“面向对象” (Objects Oriented) 系统。
- 对象系统的组成部分
- 这个系统主要由以下三个部分构成:
- 对象 (Objects)
this关键字- 原型 (Prototypes)
- 这个系统主要由以下三个部分构成:
- “Objects Oriented” vs. “Object Oriented”
- 讲师特意使用 "Objects Oriented" (多个对象) 而非 "Object Oriented" (单个对象),是为了强调 JavaScript 的对象系统并非严格意义上的基于类的系统,尽管 ES6 引入了
class语法。其本质是围绕对象本身构建的。
- 讲师特意使用 "Objects Oriented" (多个对象) 而非 "Object Oriented" (单个对象),是为了强调 JavaScript 的对象系统并非严格意义上的基于类的系统,尽管 ES6 引入了
- 本单元学习路线图
this关键字: 从理解这个基础且关键的概念开始。class关键字: 接着分析 ES6 中添加在 JavaScript 之上的类系统。- 原型系统: 回过头来深入理解
class语法糖背后真正的实现机制——原型。 - 继承 vs. 委托:
- 探讨由类带来的继承 (Inheritance) 模式。
- 将其与 JavaScript 原型系统天然支持的委托 (Delegation) 模式进行对比。
- 讲师将论证委托是一种更强大、但在 JavaScript 中未被充分利用的模式。
- 编码风格对比:OO vs. OLOO:
- OO (Object Oriented): 指代在 JavaScript 中以类为中心的编码风格。
- OLOO (Objects Linked to Other Objects): 讲师提出的另一种编码风格,意为“对象连接到其他对象”,它充分利用了委托模式。
88-the-this-keyword
this的普遍困惑this是 JavaScript 中最容易被混淆的特性之一。- 主要原因是开发者常常试图用其他语言中
this的工作方式来理解它。 - 关键在于忘记其他语言的规则,专注于 JavaScript 中
this的实际工作方式。
this的核心定义- 一个函数内部的
this关键字,引用的是该函数被调用时的执行上下文 (execution context)。
- 一个函数内部的
- 黄金法则:由调用方式决定
this的值完全取决于函数是如何被调用的,而与函数在哪里定义无关。- 你无法通过查看函数定义来确定
this的指向,必须找到该函数被调用的地方。
- 与动态作用域的类比
this的灵活性与动态作用域 (dynamic scope) 的概念相似,它允许一段行为根据调用环境的不同而改变。这是 JavaScript 实现灵活、可复用行为的方式。- 但它与真正的动态作用域不同,
this不关心函数是从哪里被调用的,而是关心函数是如何被调用的。
- 四个绑定规则
- 在 JavaScript 中,有四种不同的函数调用方式。
- 每一种调用方式都对应一套决定
this指向的规则。 - 掌握这四个规则是精通
this的关键。
- “哪栋楼”的比喻
- 词法作用域像是在一栋固定的建筑里,从当前楼层向上查找。
this则像是有人告诉你去“317 办公室”,你的第一个问题必然是“在哪栋楼?”。this的四个绑定规则就是用来告诉你当前代码在“哪一栋楼”(即哪个上下文对象)里。
89-implicit-explicit-binding
1. 隐式绑定(Implicit Binding)
核心概念
隐式绑定是最常见和最直观的this绑定方式。当通过对象调用方法时,this会自动指向调用该方法的对象。
工作原理
const workshop = {
topic: "JavaScript",
ask: function () {
console.log(this.topic);
},
};
workshop.ask(); // this指向workshop对象
关键要点
- 调用点决定绑定:
this的值由函数被调用的方式决定,而不是函数定义的位置 - 命名空间模式:这种方式实际上就是我们常说的"命名空间模式"
- 最直观的规则:类似于其他编程语言中的行为,因此最容易理解
2. 显式绑定(Explicit Binding)
使用.call()和.apply()
function ask() {
console.log(this.topic);
}
const workshop1 = { topic: "React" };
const workshop2 = { topic: "Vue" };
ask.call(workshop1); // 显式指定this为workshop1
ask.apply(workshop2); // 显式指定this为workshop2
优势
- 明确控制:可以明确指定函数执行时的
this上下文 - 代码复用:一个函数可以在不同的上下文中被复用
- 灵活性:不依赖于对象的方法调用形式
3. "丢失 this 绑定"问题
问题场景
const workshop = {
topic: "JavaScript",
ask: function () {
console.log(this.topic);
},
};
// 问题:传递方法作为回调函数
setTimeout(workshop.ask, 10); // this指向全局对象,输出undefined
问题原因
- 调用点变化:回调函数的实际调用点不再是
workshop.ask() - 实际调用:内部调用类似
cb()或cb.call(window) - this 重新绑定:this 被重新绑定到全局对象
4. 硬绑定(Hard Binding)
解决方案
const workshop = {
topic: "JavaScript",
ask: function () {
console.log(this.topic);
},
};
// 使用.bind()创建硬绑定函数
const boundAsk = workshop.ask.bind(workshop);
setTimeout(boundAsk, 10); // this始终指向workshop
.bind()方法特点
- 创建新函数:不会立即执行,而是返回一个新的绑定函数
- 固定 this:无论如何调用,this 都指向 bind 时指定的对象
- 牺牲灵活性:换取了可预测性
5. 灵活性 vs 可预测性的权衡
核心矛盾
- 灵活性:this 的动态绑定允许函数在不同上下文中复用
- 可预测性:有时我们希望 this 的行为是固定和可预测的
设计哲学对比
| 特性 | this 绑定 | 词法作用域(闭包) |
|---|---|---|
| 确定时间 | 运行时 | 编写时 |
| 可预测性 | 动态的 | 固定的 |
| 灵活性 | 高 | 低 |
| 适用场景 | 需要上下文共享 | 需要可预测行为 |
6. 最佳实践指导
使用原则
- 充分利用动态性:如果大部分调用都能利用 this 的灵活性,继续使用 this 系统
- 避免过度硬绑定:如果大部分调用都需要.bind(),考虑改用闭包
- 工具选择:
- 需要灵活的上下文共享 → 使用 this
- 需要可预测的行为 → 使用闭包和词法作用域
代码示例
// 好的this使用场景 - 充分利用动态性
const askFunction = function () {
console.log(`${this.speaker}: ${this.topic}`);
};
const workshop1 = { speaker: "Kyle", topic: "JavaScript", ask: askFunction };
const workshop2 = { speaker: "Sarah", topic: "React", ask: askFunction };
const workshop3 = { speaker: "John", topic: "Vue", ask: askFunction };
workshop1.ask(); // Kyle: JavaScript
workshop2.ask(); // Sarah: React
workshop3.ask(); // John: Vue
// 不好的使用场景 - 过度使用bind
const boundAsk1 = askFunction.bind(workshop1);
const boundAsk2 = askFunction.bind(workshop2);
const boundAsk3 = askFunction.bind(workshop3);
// 这种情况下,闭包可能是更好的选择
7. 关键洞察
this 绑定的本质
- 不是反模式:
.bind()本身不是坏的模式 - 使用频率指标:如果频繁使用
.bind(),可能在"用复杂的方式做简单的事" - 工具选择:不同的工具适用于不同的场景,关键是选择正确的工具
设计决策
JavaScript 的 this 绑定设计是有意为之的权衡:
- 提供了灵活性,允许函数在不同上下文中复用
- 同时保留了通过显式绑定获得可预测性的能力
- 开发者可以根据具体需求选择合适的方式
这种设计哲学体现了 JavaScript 语言"给开发者选择权"的特点,但也要求开发者理解每种方式的适用场景。
90-the-new-keyword
new 关键字
基本概念
new 关键字作为函数调用方式
new关键字是第三种调用函数并决定其this上下文的方式- 一个常见的误解是
new专门用于类的构造函数。实际上,它是一种通用的函数调用机制,其主要目的是以一个全新的空对象作为this来调用一个函数
new 关键字的四个步骤
当使用 new 关键字调用一个函数时(通常称为"构造函数调用"),会按顺序发生以下四件事:
- 创建新对象:一个全新的空对象被凭空创建出来
- 链接对象:这个新对象被链接到另一个对象(这个链接就是原型链,稍后会详细解释)
- 绑定
this并执行函数:这个新创建的对象被作为this上下文来调用函数 - 返回对象:如果该函数没有自己返回一个对象,那么
new表达式会自动返回在第一步中创建的新对象
谁在做主要工作?
- 从这四个步骤可以看出,真正完成核心工作的是
new关键字本身,而不是被调用的函数 - 即使你对一个空函数使用
new,这四个步骤也同样会发生。new关键字只是"劫持"了该函数来完成它的操作
91-default-binding
- 默认绑定:最后的备用规则
- 这是决定
this指向的第四种,也是最后一种规则。 - 当一个函数调用不满足前三种规则(
new、call/apply/bind、对象方法调用)时,就会触发默认绑定。 - 这通常发生在一次普通的、独立的函数调用中,例如
ask();。
- 这是决定
- 两种模式下的不同行为
- 非严格模式 (Sloppy Mode)
this会默认指向全局对象(在浏览器中是window对象)。- 这通常是错误的根源,因为它可能导致意外地修改全局变量,类似于自动创建全局变量的坏习惯。
- 严格模式 (Strict Mode)
this的值会是undefined。- 此时,如果函数内部尝试访问
this上的属性(如this.teacher),会立即抛出一个TypeError。 - 设计意图:这是一种保护机制。在一个需要
this的函数中,却在没有提供this上下文的情况下调用它,这几乎可以肯定是一个错误。严格模式通过抛出错误,帮助开发者立即发现并修复这个问题。
- 非严格模式 (Sloppy Mode)
- 核心回顾
- 再一次强调,
this的指向与函数如何定义无关,完全取决于函数在调用点的调用方式。
- 再一次强调,
92-binding-precedence
- 绑定规则的优先级
- 当一个函数调用点同时满足多个绑定规则时(例如
new workshop.ask.bind(anotherObj)()),JavaScript 需要一个清晰的优先级顺序来决定this的指向。
- 当一个函数调用点同时满足多个绑定规则时(例如
- 判断
this指向的优先级顺序- 从今以后,要确定一个函数的
this指向,请按以下顺序提问:
new绑定:函数是否通过new关键字调用?如果是,this就是那个新创建的对象。- 显式绑定:函数是否通过
call、apply调用,或者已经被bind硬绑定?如果是,this就是那个被明确指定的对象。 - 隐式绑定:函数是否作为对象的一个方法被调用(例如
workshop.ask())?如果是,this就是那个对象(workshop)。 - 默认绑定:如果以上规则都不适用,则使用默认绑定。在非严格模式下
this是全局对象,在严格模式下是undefined。
- 从今以后,要确定一个函数的
- 总结
- 这四个规则,按照这个优先级顺序,可以完美且完整地解答所有关于
this指向的问题。
- 这四个规则,按照这个优先级顺序,可以完美且完整地解答所有关于
93-arrow-functions-lexical-this
- 箭头函数与
this:词法this- 箭头函数引入了一种被称为“词法
this”的行为。
- 箭头函数引入了一种被称为“词法
- 正确的理解模型
- 一个普遍的误解是“箭头函数会将其
this绑定到父作用域的this”。这是不准确的。 - 正确的模型是:箭头函数自身根本不定义
this关键字。 - 这意味着,如果你在箭头函数内部使用
this,它会像其他任何普通变量一样,遵循词法作用域规则,向上层作用域查找,直到找到一个定义了this的非箭头函数为止。
- 一个普遍的误解是“箭头函数会将其
- 工作原理示例
var workshop = { ask: function () { // 第1层:有 this (来自 workshop.ask() 调用) setTimeout(() => { // 第2层:没有 this,向上查找 console.log(() => { // 第3层:嵌套箭头函数,也没有 this return this.topic; // 一直向上找到 ask 函数的 this }); }, 100); }, topic: "JS", }; - 箭头函数不是硬绑定函数
- ECMAScript 规范明确指出,箭头函数没有自己的
arguments,super,this,new.target绑定。 - 一个有力的证据是:你不能对一个箭头函数使用
new关键字,它会抛出异常。而一个通过.bind()创建的硬绑定函数是可以用new调用的(尽管行为特殊)。 - 用正确的模型思考问题至关重要,即使错误的模型在某些情况下看似有效,但它最终会导致系统性的 bug。
- ECMAScript 规范明确指出,箭头函数没有自己的
94-resolving-this-in-arrow-functions
常见的作用域混淆陷阱
问题代码
var workshop = {
teacher: "Kyle",
ask: () => {
console.log(this.teacher); // 这里的 this 指向什么?
},
};
错误理解
很多人认为:this会指向workshop对象,因为箭头函数在workshop的"作用域"内。
正确答案
this指向全局对象! 为什么?
关键概念:对象不是作用域
var workshop = {
// ← 这些花括号不是作用域!
teacher: "Kyle",
ask: () => {
// 箭头函数向上查找作用域时,跳过了 workshop
// 直接找到全局作用域
console.log(this.teacher); // undefined (或全局的 teacher)
},
};
对象字面量的花括号不创建词法作用域!
作用域层次分析
// 全局作用域 (第1层)
var workshop = {
teacher: "Kyle",
ask: () => {
// 箭头函数在全局作用域中定义
// this 从全局作用域解析
console.log(this.teacher);
},
};
// 这个程序只有1个作用域:全局作用域
对比有真正作用域的情况:
// 全局作用域 (第1层)
function outer() {
// outer函数作用域 (第2层)
var workshop = {
teacher: "Kyle",
ask: () => {
// 箭头函数在 outer 作用域中定义
// this 从 outer 函数的 this 解析
console.log(this.teacher);
},
};
return workshop;
}
Kyle 对箭头函数的使用建议
唯一推荐使用场景
只有当你需要词法this行为时才使用箭头函数
var workshop = {
teacher: "Kyle",
ask: function () {
// 普通函数,有自己的 this
setTimeout(() => {
// 箭头函数,借用外层的 this
console.log(this.teacher); // "Kyle"
}, 100);
},
};
workshop.ask(); // this 指向 workshop
重要洞察:不是新规则
Kyle 强调的关键点:
"箭头函数不是
this关键字的第五条规则。它根本不参与this绑定规则,因为它没有this!"
这意味着:
- 你仍然只需要掌握那四条
this绑定规则 - 遇到箭头函数时,忽略它,继续向上查找有
this的函数 - 箭头函数让
this的规则更简单,而不是更复杂
总结
箭头函数的正确使用哲学:
- 明确目的:只为词法
this而使用 - 理解作用域:对象不是作用域
- 解决匿名问题:通过命名和良好的代码组织
- 保持简单:不要为了
this规则增加复杂性
这样既能享受箭头函数的便利,又能避免常见的陷阱。
95-this-exercise
练习目标
这个练习要求将使用模块模式(Module Pattern)的代码重构为使用命名空间模式(Namespace Pattern),主要变化是:
原来的模块模式
- 使用
defineWorkshop()工厂函数 - 内部数据(如
currentEnrollment、studentRecords)是私有的 - 返回一个
publicAPI对象,只暴露需要的方法
要改成的命名空间模式
- 直接定义为对象字面量
- 数据属性直接作为对象的属性
- 所有方法都是公开的,通过
this.属性名来访问数据
需要注意的关键问题
讲师特别强调了一个重要的陷阱:this 绑定问题
// 例如在代码中有这样的回调函数
someArray.map(callback);
- 在模块模式中,普通的词法函数作为回调传递没有问题
- 但改为对象方法后,当方法作为回调传递时,
this的绑定会丢失 - 需要在适当的地方使用
.bind()来硬绑定this
具体要求
- 移除
defineWorkshop()工厂函数 - 替换为对象字面量定义
- 修改所有内部引用,使用
this.来访问属性 - 谨慎使用
.bind():- 在需要的地方使用(如回调函数)
- 不要在所有地方都使用(不是所有函数调用都需要)
这个练习的核心是让学习者理解this关键字在不同上下文中的行为,特别是在回调函数中容易出现的this绑定丢失问题。
96-this-exercise-solution
核心内容解析
1. 重构目标
将工厂函数创建的对象重构为直接的对象字面量,使用this来访问对象的属性和方法。
2. 重构步骤
第一步:创建对象字面量
const deepJS = {
// 将所有函数作为方法放入对象中
currentEnrollment: [],
studentRecords: [],
// 使用简洁方法语法
addStudent() {
/* ... */
},
printRecords() {
/* ... */
},
// ... 其他方法
};
第二步:添加this引用
// 之前: currentEnrollment (直接变量访问)
// 之后: this.currentEnrollment (通过this访问)
printRecords(students) {
this.printRecord(this.currentEnrollment); // 需要this.前缀
}
3. 关键难点:方法绑定问题
当方法作为回调函数传递时,会丢失this绑定:
// 问题代码
students.map(this.getStudentFromId); // this会丢失
// 解决方案
students.map(this.getStudentFromId.bind(this)); // 绑定this
4. 绑定规则的重要问题
学员提问:什么时候需要绑定this?
讲师回答:
- 需要绑定:方法内部使用了
this关键字的函数 - 不需要绑定:纯函数,不依赖
this的方法
// 需要绑定 - 使用了this.studentRecords
getStudentFromId(studentId) {
return this.studentRecords.find(matchId);
}
// 不需要绑定 - 纯函数
sortByName(record1, record2) {
return record1.name < record2.name ? -1 : 1;
}
教学要点
优缺点分析
缺点:
- 代码变得冗长(大量
this.前缀) - 需要频繁使用
.bind(this) - 容易出错(忘记绑定导致运行时错误)
适用场景:
- 需要通过原型链连接多个相关对象
- 对象间需要通过调用上下文进行虚拟组合
实际建议
讲师坦诚地指出:对于这种特定场景,模块模式可能是更好的选择,因为:
this绑定的复杂性超过了收益- 代码可读性下降
- 维护成本增加
97-es6-class-keyword
class关键字简介- ES6 引入的
class关键字,表面上是 JavaScript 原型系统的一层语法糖。 - 它提供了更传统、更熟悉的语法来定义构造函数和方法。
- ES6 引入的
- 基本语法
- 使用
class关键字定义一个类。 constructor方法是类的构造函数。- 类的方法定义之间不需要逗号。
- 使用
new关键字来实例化类。 - 类可以是表达式,也可以是匿名的(但不推荐)。
- 使用
- 继承与
super- 使用
extends关键字来实现类之间的继承。 - 子类可以继承父类的方法。
super关键字用于在子类中调用父类的方法(例如super.someMethod()),这实现了相对多态。- 注意:
super的功能在 ES6 之前几乎无法可靠地实现,因此class提供的不仅仅是纯粹的语法糖。
- 使用
class未改变this的本质class语法糖没有改变this的工作方式。- 类的方法在作为回调函数传递时,其
this绑定仍然会丢失。它们不会被自动绑定。
- 对“自动绑定”模式的批判
- 常见模式: 为了解决
this丢失问题,开发者常在constructor中使用箭头函数或.bind()来定义方法,例如this.ask = () => { ... }。 - 讲师的批判: 这种做法完全背离了类的原型系统。
- 原型系统的优势在于方法定义在共享的
prototype对象上,所有实例共享同一份函数定义,节省内存。 - 而这种"自动绑定"模式,会在每个实例上创建一份方法的副本。
- 这本质上是创造了一个非常蹩脚、丑陋且脆弱的模块模式。如果你需要的是固定的、可预测的词法作用域行为,那么从一开始就应该使用真正的模块模式,而不是滥用
class语法来实现它。
- 原型系统的优势在于方法定义在共享的
- 常见模式: 为了解决
98-fixing-this-in-classes
- 一个思想实验:为何自动绑定
this是个坏主意- 为了说明强制自动绑定方法与 JavaScript 的设计哲学有多么不符,讲师进行了一个思想实验:如何实现一个既在原型上定义方法,又能自动绑定
this到实例的方案?
- 为了说明强制自动绑定方法与 JavaScript 的设计哲学有多么不符,讲师进行了一个思想实验:如何实现一个既在原型上定义方法,又能自动绑定
- 一个丑陋的 Hacky 解决方案
- 这个方案的思路是:
- 用一个工具函数遍历类的
prototype上的所有方法。 - 将每个真实的方法替换成一个
getter。 - 当通过实例访问该方法时(例如
instance.myMethod),会触发这个getter。 getter会动态地创建一个该方法的硬绑定版本(method.bind(this))。getter会将这个新创建的绑定函数缓存到一个WeakMap中(以实例为键),然后返回它。- 这样,第一次访问会创建绑定函数,后续访问则会从缓存中读取。
- 用一个工具函数遍历类的
- 这个方案的思路是:
- 实验的结论
- 这个方案虽然能实现目标,但它极其复杂和丑陋。
- 核心观点:需要如此复杂的 hack 才能实现的功能,说明这个功能本身不符合 JavaScript 函数的“DNA”。
this感知函数的全部意义在于它们的动态性和灵活性。试图强行将它们变成像其他语言中那样静态、自动绑定的模式,是对语言核心特性的扭曲。
- 一个警示故事
- 讲师将这个“坏主意”的实现发到 Twitter 上以作警示,结果却收到了很多“我已经在我的项目里用上了”的回复。
- 这引出了一句名言:“没有什么比一个临时的 hack 更持久”。
99-class-exercise
- 练习目标
- 将之前练习中使用的命名空间模式的代码,重构为使用 ES6
class语法。
- 将之前练习中使用的命名空间模式的代码,重构为使用 ES6
- 重构策略
- 利用
class extends实现继承,以更好地组织代码。 - 分离关注点:
- 创建一个父类
Helpers,将那些不感知this的纯辅助函数(例如sortByName)放在这个类中。 - 创建一个子类
Workshop,让它extends Helpers。 - 将所有感知
this的方法(那些使用this.的方法)放在Workshop类中。
- 创建一个父类
- 通过继承,
Workshop的实例将能够访问到Helpers类中定义的方法。
- 利用
- 主要任务
- 将原来的
deepJS对象字面量,改写成Helpers和Workshop两个类。 - 将对象上的方法,分别移动到对应的类中。
- 确保在实例化
Workshop类后,代码的行为与之前保持一致,特别是那些需要this绑定的回调函数依然能正常工作。
- 将原来的
- 关于代码组织的讨论
- 提问:将一个大对象拆分成多个
this感知对象(或类)的依据是什么?是代码大小和可读性,还是别的? - 回答:主要依据是关注点分离 (Separation of Concerns)。应该将逻辑上内聚的功能组织在一起,形成最小化的、功能单一的单元,这比单纯看代码行数更重要。这个原则适用于所有代码组织模式(模块、类等),不仅仅是
this风格的代码。
- 提问:将一个大对象拆分成多个
100-class-exercise-solution
- 目标
- 将一个命名空间对象重构为一个 ES6
class。 - 核心代码逻辑基本不变,主要是改变其外部的组织结构。
- 将一个命名空间对象重构为一个 ES6
- 重构步骤
- 创建主类
Workshop- 将原来的对象字面量改为
class Workshop { ... }。 - 添加一个
constructor方法来初始化实例属性:this.currentEnrollment = [];this.studentRecords = [];
- 将原来的对象字面量改为
- 迁移方法
- 将原来对象上的所有方法直接移动到
class定义内部。 - 移除方法之间的逗号。
- 将原来对象上的所有方法直接移动到
- 创建主类
- 分离关注点与继承
- 根据
Readme的指示,将那些不感知this的纯辅助函数(sortByName和printRecord)提取出来。 - 创建一个父类
class Helpers并将这两个方法放入其中。Helpers类不需要constructor。
- 根据
- 建立继承关系
- 让
Workshop类继承Helpers类:class Workshop extends Helpers。 - 重要细节:
- 当子类(
Workshop)定义了自己的constructor时,必须在constructor内部首先调用super()。 - 调用
super()是为了执行父类的构造函数,并正确地建立this对象的上下文。
- 当子类(
- 让
- 实例化
- 最后的改动是实例化对象的方式:
- 之前 (模块工厂):
deepJS = defineWorkshop();(普通函数调用) - 现在 (类):
deepJS = new Workshop();(必须使用new关键字调用构造函数)
- 之前 (模块工厂):
- 最后的改动是实例化对象的方式:
101-prototypes
- 引言:揭示语法糖之下的原型系统
- 理解
class语法背后的原型系统至关重要,避免错误的心理模型。 - JavaScript 中的对象是通过所谓的“构造函数调用”(即
new关键字)创建的。
- 理解
- 传统类理论 vs. JavaScript 现实
- 类理论
- 蓝图隐喻: 类是蓝图,实例是根据蓝图建造的建筑。
- 核心操作是“复制”: 实例化时,蓝图的特性被复制到建筑中。之后,修改蓝图不会影响已建好的建筑,反之亦然。
- 继承隐喻: 遗传学隐喻,子类从父类那里复制 DNA。子代是独立的个体。
- 这种“复制”的心理模型,深受 Java、C++ 等传统面向对象语言的影响。
- JavaScript 的现实
- 当构造函数创建对象时,它并不是“基于”其原型进行复制。
- 实际上,它是将新对象“链接到”其原型。
- 类理论
- “复制” vs. “链接”:根本性的区别
- 复制和链接不是同一事物的两面,它们是截然相反的概念。
- 复制模型: 创建后关系即断开,实例是独立的副本。
- 链接模型: 创建后关系持续存在,实例通过一个实时的链接访问原型上的属性和方法。
- 如果你的心理模型是“复制”,而系统的实际行为是“链接”,那么当代码行为不符合预期时,你将很难定位问题,因为你的根本假设就是错误的。
- 结论
- 将一个基于“复制”的设计模式(传统类)强加在一个不执行复制操作的语言(JavaScript)之上,本身就存在根本性的不匹配。
102-prototypal-class
- 原型式类的“旧式”写法
- 这是在 ES6
class语法出现之前,手动实现类似类行为的方式。 - 构成:
- 一个普通函数(如
Workshop)充当构造函数的角色。 - 通过向该函数的
.prototype属性(它是一个对象)上添加方法(如Workshop.prototype.ask = function() {...})。 - 使用
new关键字调用该函数来创建实例。
- 一个普通函数(如
- 虽然现在我们更倾向于使用
class语法,但理解这套底层的“管道系统”如何工作对于深入理解 JavaScript 至关重要。
- 这是在 ES6
- 目标:建立正确的心理模型
- 由于“复制”是错误的模型,我们需要一个新的模型来理解原型系统是如何真正工作的。
- 这个模型将帮助我们理解对象之间的链接关系,并解释为什么代码会那样运行。
103-the-prototype-chain
- 图解原型链关系
- (本节内容是基于讲师的现场绘图讲解,以下为文字化描述)
- 符号: 圆圈代表函数,方块代表对象。
- “第 0 行”环境 (运行时内置)
- 存在一个内置的
Object函数。 Object函数有一个.prototype属性,指向一个非常重要的Object.prototype对象。Object.prototype对象上包含了如toString,valueOf等基础方法。Object.prototype对象有一个.constructor属性,指回Object函数。形成一个循环引用,但这主要是为了模拟类的概念。
- 存在一个内置的
- 代码执行分析
function Workshop(...):- 创建一个名为
Workshop的函数 (圆圈 W)。 - 同时,自动创建一个与之关联的
Workshop.prototype对象 (方块)。 Workshop函数的.prototype属性指向这个新对象。Workshop.prototype对象有一个.constructor属性指回Workshop函数。Workshop.prototype对象有一个隐藏的内部链接[[Prototype]],指向Object.prototype。
- 创建一个名为
Workshop.prototype.ask = ...:- 在
Workshop.prototype这个方块对象上添加一个名为ask的属性。
- 在
var deepJS = new Workshop(...)(关键步骤)new关键字四步走:- 创建一个全新的空对象。
- 将这个空对象的内部链接
[[Prototype]]指向Workshop.prototype对象。 - 以这个新对象为
this上下文,执行Workshop函数。函数内的this.teacher = ...会在新对象上添加teacher属性。 - 返回这个新对象,并赋值给
deepJS。
reactJS的创建过程与deepJS完全相同。
deepJS.ask()- 属性查找:
- JavaScript 在
deepJS对象上查找ask属性,没有找到。 - 通过内部的
[[Prototype]]链接,向上移动到Workshop.prototype对象。 - 在
Workshop.prototype上找到了ask方法。
- JavaScript 在
this绑定:- 尽管
ask方法是在原型上找到的,但它的调用点是deepJS.ask()。 - 根据隐式绑定规则,
this仍然指向deepJS对象。
- 尽管
- 属性查找:
- 结论
- 原型链通过链接而非复制,实现了方法在多个实例间的共享。
this绑定机制确保了共享的方法在执行时能够正确地操作调用它的那个实例的数据。
104-dunder-prototypes
.constructor属性的假象- 当访问
deepJS.constructor时:deepJS对象本身没有constructor属性。- 通过原型链向上查找,在
Workshop.prototype上找到了.constructor属性。 - 该属性指向
Workshop函数。
- 这造成了
deepJS是由Workshop“构造”出来的假象,但这只是为了模拟类系统而设置的一系列属性关系。
- 当访问
__proto__(Dunder Proto)dunder proto是一个访问器属性(getter/setter),用于暴露对象的内部[[Prototype]]链接。- 工作原理:
- 当访问
deepJS.__proto__时,deepJS对象本身没有这个属性。 - 原型链向上查找到
Workshop.prototype,它也没有。 - 继续向上查找到
Object.prototype,它有一个名为__proto__的 getter 函数。 - 这个 getter 函数被调用,其调用点是
deepJS.__proto__,所以this绑定到deepJS。 - getter 函数内部逻辑会读取并返回
deepJS对象的内部[[Prototype]]链接,这个链接指向Workshop.prototype。
- 当访问
- 因此,
deepJS.__proto__ === Workshop.prototype的结果为true。 这是原型链机制的一个具体体现。
105-this-prototypes-q-a
- 问:一个已经用
.bind()绑定的函数,还能被重新绑定到其他对象吗?- 答:不能通过再次调用
.bind()来重新绑定。一个硬绑定函数是固定的。唯一的例外是,当对这个硬绑定函数使用new关键字时,this会被覆盖为新创建的对象。
- 答:不能通过再次调用
- 问:在箭头函数内定义的变量,作用域是箭头函数还是父作用域?
- 答:箭头函数有自己的词法作用域。在内部用
var,let,const定义的变量,其作用域就是这个箭头函数本身,与普通函数完全一样。箭头函数只是没有自己的this绑定。
- 答:箭头函数有自己的词法作用域。在内部用
- 问:对于一个
this感知的回调函数,this的值是否取决于高阶函数如何调用它?- 答:是的,完全取决于调用点。像
Array.prototype.map这样的高阶函数如何在其内部调用你传入的回调,决定了回调的this指向(除非你传入的是一个已经硬绑定的函数)。
- 答:是的,完全取决于调用点。像
- 问:设置
__proto__会发生什么?- 答:
__proto__同时也是一个 setter,可以用来动态地修改一个对象的原型链。这是一个不常见的操作,通常被认为是一种反模式,但在某些高级场景下可能有用。ES6 提供了更规范的 APIObject.setPrototypeOf()来实现同样的功能。
- 答:
- 问:原型对象(图中的方块)是每个函数都自带的吗?
- 答:普通函数 (
function声明、表达式) 都有.prototype属性,指向一个关联的原型对象。但箭头函数没有.prototype属性,因此它们不能用作构造函数(不能对它们使用new)。
- 答:普通函数 (
- 问:
super关键字也能在原型对象中使用吗?- 答:可以。在非
class的对象字面量中,如果你使用了简洁方法语法,并且这个对象通过原型链接到另一个对象,那么你可以在方法内部使用super来引用原型链上一层的同名方法。但super是静态绑定的,它在对象创建时就确定了指向,之后再修改原型链不会影响super的行为。
- 答:可以。在非
106-shadowing-prototypes
- 属性遮蔽 (Shadowing)
- 当你在实例对象(如
deepJS)上创建一个与原型链上层同名的属性(如ask)时,这个实例属性会“遮蔽”原型属性。 - 之后对该属性的访问会直接找到实例上的版本,而不会再向上查找原型链。
- 当你在实例对象(如
- 在原型风格中尝试多态的问题
- 场景: 我们在
deepJS上定义了一个新的ask方法,并想在其中调用原型上的ask方法,以此来扩展功能(这是多态的典型用法)。 - 错误的尝试: 在新的
ask方法中调用this.ask()。this指向deepJS。this.ask会再次找到deepJS上的这个新方法,导致无限递归。
- 丑陋的变通方法:
this.__proto__.ask.call(this)this.__proto__:手动向上走一层原型链,找到原型对象。.ask:获取原型上的ask方法。.call(this):必须用call来确保即便是在原型上找到的函数,执行时的this仍然是当前的实例 (deepJS)。
- 问题所在:
- 这种方法被称为“显式伪多态”,非常笨拙且脆弱。
- 如果原型链加深,你需要写
this.__proto__.__proto__.ask...,代码与原型链的深度紧密耦合。
- 场景: 我们在
- 结论
- 在 ES6
class和super出现之前,JavaScript 的原型系统虽然强大,但在实现真正的相对多态(即子类安全地调用父类同名方法)方面存在天然的缺陷。这是class语法带来的一个重要改进。
- 在 ES6
107-prototypal-inheritance
- 在原型风格中实现“继承”
Object.create(): 这是实现原型继承的核心方法。Object.create(proto)会做两件事:- 创建一个全新的空对象。
- 将这个新对象的内部
[[Prototype]]链接指向proto对象。
- 这与
new关键字操作的前两个步骤完全相同。
- 实现继承的步骤:
- 定义 "子类" 构造函数,如
AnotherWorkshop。 - 关键的一步:
AnotherWorkshop.prototype = Object.create(Workshop.prototype);。这行代码将 "子类" 的原型对象链接到了 "父类" 的原型对象,从而建立了原型链。
- 定义 "子类" 构造函数,如
- 原型链的威力
- 场景:
var jsRecentParts = new AnotherWorkshop()jsRecentParts链接到AnotherWorkshop.prototype。AnotherWorkshop.prototype链接到Workshop.prototype。Workshop.prototype链接到Object.prototype。
- 当调用
jsRecentParts.ask()时:- 在
jsRecentParts上找不到ask。 - 向上到
AnotherWorkshop.prototype,也找不到ask。 - 再向上到
Workshop.prototype,找到了ask方法。 this绑定: 尽管方法是在两层原型链之上找到的,this仍然由调用点jsRecentParts.ask()决定,始终指向jsRecentParts实例。
- 在
- 场景:
- “超级独角兽魔法” (Super Unicorn Magic)
- 这是讲师对原型链强大之处的赞美。无论方法在原型链的哪个层级被找到,
this都能被正确地、动态地绑定到最初发起调用的那个实例对象上。这套机制是 JavaScript 对象系统强大灵活性的核心。 - 所有的复杂性(如
.prototype、new、constructor)都只是为了构建和管理这个底层的、强大的对象间链接。
- 这是讲师对原型链强大之处的赞美。无论方法在原型链的哪个层级被找到,
108-classical-vs-prototypal-inheritance
- 两种继承模型的视觉对比
- 经典继承 (Classical Inheritance - 如 Java, C++)
- 这是一个复制 (Copy) 操作。
- 行为从类复制到实例中,从父类复制到子类中。
- 关系箭头是从上到下,从左到右的单向流动。
- 原型继承 (Prototypal Inheritance - JavaScript)
- 这是一个链接 (Linkage) 操作。
- 实例对象链接到原型对象。子类的原型链接到父类的原型。
- 关系箭头是从下到上,从右到左的反向链接。
- 经典继承 (Classical Inheritance - 如 Java, C++)
- 对“原型继承”术语的批判
- 讲师认为“原型继承 (prototypal inheritance)”这个术语本身是造成混乱的根源。
- 问题所在:
- “Prototypal”这个词对大多数人来说含义模糊。
- 而“Inheritance”(继承)这个词却带有强烈的心理暗示,即它是一个复制关系。
- “红色的橙子”比喻:
- 将 JavaScript 的链接系统称为“原型继承”,就像指着一个苹果说它是一个“红色的橙子”。
- 你给它取的名字并不能改变它的本质,只会让事情变得更加混乱。
- 结论
- JavaScript 开发者之所以长期以来对所谓的“类”感到困惑,是因为其底层系统(链接)与传统类语言(复制)是根本上完全不同的。
- 我们一直在用各种语法糖(duct tape)试图让 JavaScript 看起来像一个类系统,而不是去拥抱和理解它本身(一个基于链接的原型系统)的特性。
109-inheritance-is-delegation
- 为原型系统正名:委托模式 (Delegation Pattern)
- JavaScript 的原型系统并非设计拙劣,它实际上实现了一个非常强大且不同的设计模式,这个模式的正确名称是委托 (Delegation)。
- 讲师的观点是“为苹果正名为苹果”,即指出 JavaScript 原型链的本质就是委托。
- 类与委托:不匹配的设计
- 类模式本身没有错,但它不适合一个被设计为委托系统的语言。
- 我们一直在试图将类(一个基于复制的模型)强行塞进 JavaScript(一个基于链接的模型)中。
- 更有效的方式应该是去理解 JavaScript 系统本身的设计,并发挥其固有的优势。
- 委托模式更强大
- 超集理论:原型/委托系统实际上是类系统的一个超集 (superset)。
- 你可以在一个原型语言中实现一个类系统。
- 但你无法在一个纯粹的类语言中实现一个原型系统。
- 这意味着我们拥有一个功能更强大的系统,却一直在用一种非常受限且不匹配的方式(模拟类)来使用它。
- 超集理论:原型/委托系统实际上是类系统的一个超集 (superset)。
- 行动的呼吁
- 尽管在某些框架中,使用类可能是必须的,但这不应该是我们唯一的选择。
- 我们应该去探索,当我们抛开“类是唯一模式”的成见时,委托模式能为我们带来什么新的可能性。
110-oloo-pattern
- OLOO:连接到其他对象的对象 (Objects Linked to Other Objects)
- 这是讲师提出的一种编码风格,旨在更直接、更简单地使用 JavaScript 的原型/委托系统。
- 命名缘由:传统的“面向对象 (Object Oriented)”术语已被类语言占据。但 JavaScript 和 OLOO 这种可以不依赖类而创建对象的语言,才是真正“面向对象”的。OLOO 作为对 OO 的一个对比。
- OLOO 风格的特点
- 只有对象:不再有类或构造函数,只有普通的对象字面量。
Object.create()是核心:使用Object.create()来创建新对象并将其链接到另一个对象。- 没有多余的“包袱”:
- 没有
.prototype属性。 - 没有
constructor函数。 - 没有
new关键字。
- 没有
- 结果:代码实现了与类系统相同的行为(方法委托、
this动态绑定),但语法更简洁,并且直接揭示了“对象链接到其他对象”的本质。
Object.create()的魔力Object.create()本身是 ES5 的一个内置方法。- 它的作用就是将过去实现原型继承所需的复杂步骤(创建一个空构造函数、设置其 prototype、然后 new 一个实例)封装并隐藏起来。
- 它为我们提供了一个干净利落的 API,只做一件事:创建并链接对象。
- 结论
- OLOO 是一种更简单、更直接地利用 JavaScript 原生委托机制的编码风格。它移除了模拟类所带来的复杂性和伪装,让开发者直接与对象及其链接关系打交道。
111-delegation-oriented-design
核心思想转变
传统的面向对象编程思维是垂直的父子关系(继承链),而委托模式提倡水平的对等关系(peer-to-peer)。
问题场景
假设你需要构建一个登录页面,需要两个控制器:
AuthController- 负责认证逻辑(与服务器通信)LoginFormController- 负责 UI 逻辑(表单、按钮、错误信息等)
传统解决方案的演进
1. 继承组合(1980-90 年代)
// 通过继承链实现,一个作为父类,一个作为子类
class AuthController { ... }
class LoginFormController extends AuthController { ... }
2. 属性组合(90 年代中后期)
// 避免深层继承链,通过属性包含
const pageInstance = new LoginFormController();
pageInstance.auth = new AuthController();
3. 混入组合(Mixin)
// 将一个对象的方法复制到另一个对象
Object.assign(loginController, authController);
委托模式的革命性方案
核心机制
通过原型链链接两个独立的具体对象,而不是类:
// 两个独立的对象
const AuthController = {
authenticate() {
// this.userName 实际指向LoginFormController的属性
console.log("认证用户:", this.userName);
},
handleResponse() {
if (error) {
this.displayError(error); // 委托给LoginFormController
}
},
};
const LoginFormController = {
onSubmit() {
this.authenticate(); // 委托给AuthController
},
displayError(msg) {
console.log("显示错误:", msg);
},
};
// 建立委托链接
Object.setPrototypeOf(LoginFormController, AuthController);
神奇之处
- 虚拟组合:两个对象在函数调用时通过
this关键字虚拟地组合在一起 - 共享上下文:
this始终指向调用者,让两个对象能够互相访问彼此的属性和方法 - 动态协作:不是静态的包含关系,而是运行时的动态协作
委托模式的优势
1. 更好的可测试性
// 测试LoginFormController时,只需mock AuthController
const MockAuth = {
authenticate() {
/* mock实现 */
},
};
Object.setPrototypeOf(LoginFormController, MockAuth);
// 测试AuthController时,只需mock LoginFormController
const MockLoginForm = {
displayError() {
/* mock实现 */
},
};
Object.setPrototypeOf(MockLoginForm, AuthController);
2. 真正的关注点分离
- 两个对象保持独立,各自负责自己的职责
- 需要协作时通过委托实现,而不是强耦合
3. 符合 JavaScript 本质
JavaScript 的 DNA 就是原型委托,而不是类继承。委托模式充分发挥了 JavaScript 原型系统的优势。
深层哲学
Kyle Simpson 认为,我们应该拥抱 JavaScript 的本质特性,而不是试图用复杂的语法糖来模拟其他语言的类系统。委托不仅仅是一种代码风格,更是一种根本性的问题解决思路,它让我们以更自然、更灵活的方式构建 JavaScript 应用。
这种设计模式特别适合 JavaScript 的动态特性,让代码更加灵活、可测试,同时保持了清晰的职责分离。
112-wrapping-up
- 核心总结
- 如果你以 JavaScript 为职业,那么深入了解这门语言是理所当然的。
- 保持好奇心,多问“为什么”,理解代码工作的原理,这样在代码出问题时你才有能力去修复它。
- 认真对待 JavaScript,偶尔阅读规范,建立正确的心理模型。
- 最终目标是通过深刻理解工具来更有效地沟通你的思想和意图。
- 继续学习的资源
- “你不知道的 JavaScript” (You Don't Know JS) 系列丛书 是深入学习的绝佳资源。
- 所有六本书都可以在 GitHub 上免费阅读。
- 与本课程内容直接相关的三本书是:
- 《作用域与闭包》 (Scope and Closures)
- 《this 与对象原型》 (This and Object Prototypes)
- 《类型与语法》 (Types and Grammar)
113-bonus-typl
- 背景:现有类型检查工具 (TypeScript/Flow) 的局限
- 静态限制: 它们在编译时工作,无法提供运行时类型保证。
- 风格侵入: 为了让编译器理解类型,开发者常常需要改变自己的编码风格,使其变得不那么“JavaScript”。
- 生态锁定: 引入了非标准的语法。
- TypL:一个不同的类型感知 Linter 方案
- 这是讲师正在开发的一个 alpha 阶段项目,旨在提供一种更符合 JavaScript 风格的类型检查方案。
- TypL 的设计理念
- 只使用标准 JavaScript 语法: 避免生态锁定。
- 同时包含编译器和运行时组件:
- 编译器在构建时进行检查。
- 运行时断言可以在生产代码中捕捉动态错误。
- 用户可以选择只用其一,或两者都用。
- 高度可配置 (类似 ESLint): 默认报告所有可能的类型问题,由用户决定忽略哪些。
- 关注值的类型,而非变量的类型: 这是核心区别。类型注解直接附加在值和表达式上。
- 顺应 JavaScript 的“纹理”: 目标是增强 JavaScript,而不是改变它。
- TypL 的实现方式
- 值类型注解: 使用模板标签 (template tag) 这种标准 JS 语法来注解值的类型。
- 例如:"string
Kyle" 明确表示这是一个字符串值。 const student = { name: stringSuzy};直接在对象属性值上注解。
- 例如:"string
- 函数签名: 利用默认参数表达式这种巧妙的方式来定义函数参数的类型签名。
- 多遍推断 (Multi-pass Inferencing): 能够处理函数提升等复杂场景,通过多次分析代码来推断出更准确的类型,在某些方面比 TS/Flow 更智能。
- 编译器与运行时的协同:
- 编译器会尽可能在构建时验证类型。对于可以静态确定的部分,它会移除类型注解,减小运行时代码体积。
- 对于无法静态确定的部分,它会将类型注解转换为运行时的断言函数,保留在最终代码中,从而在生产环境中提供类型保障。
- 值类型注解: 使用模板标签 (template tag) 这种标准 JS 语法来注解值的类型。