普通视图

发现新文章,点击刷新页面。
昨天 — 2026年2月21日首页

你不知道的JS(下):深入编程

作者 牛奶
2026年2月21日 21:53

你不知道的JS(下):深入编程

本文是《你不知道的JavaScript(下卷)》的阅读笔记,第一部分:深入编程。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

原文地址

墨渊书肆/你不知道的JS(下):深入编程

代码与语句

程序是一组特定的计算机指令。指令的格式和组合规则被称为计算机语言(语法)。

语句

执行特定任务的一组单词、数字和运算符被称为语句。

a = b * 2;
  • ab:变量
  • 2:字面值
  • =*:运算符
  • JS 语句通常以分号 ; 结尾。

表达式

语句由一个或多个表达式组成。表达式是对变量、值的引用,或者是其与运算符的组合。

执行程序

程序需要通过解释器或编译器翻译成计算机可理解的命令后执行。 JS 引擎实际上会即时编译(JIT)程序,然后立即执行编译后的代码。虽然 JS 常被称为解释型语言,但现代引擎的 JIT 过程使得其运行速度非常快。

实践环境

最简单的方法是使用浏览器(Chrome、Firefox 等)的开发者工具。

  • 输出console.log()(控制台输出)或 alert()(弹窗输出)。
  • 输入prompt()(获取用户输入)。

运算符

JavaScript 常用运算符包括:

  • 赋值=(将值保存在变量中)。
  • 算术+-*/%(取模)。
  • 复合赋值+=-=*=/=(如 a += 2 等同于 a = a + 2)。
  • 递增/递减++(递增)、--(递减)。
  • 对象属性访问.(如 obj.a)或 [](如 obj["a"])。
  • 相等==(宽松相等)、===(严格相等)。
  • 逻辑&&(与)、||(或)、!(非),用于表示复合条件。

值与类型

在编程术语中,对值的不同表示方法称为类型。JavaScript 提供了以下内置基本类型:

  • 数字 (number):用于数学计算。
  • 字符串 (string):一个或多个字符组成的文本。
  • 布尔值 (boolean)truefalse,用于决策判断。
  • 除此之外,还提供 数组对象函数 等复合类型。

类型转换

JavaScript 提供显式和隐式两种类型转换机制。

var a = "42"; 
var b = Number(a); // 显式类型转换
console.log( a ); // "42" 
console.log( b ); // 42
console.log( a == b ); // true,隐式类型转换(宽松相等)

代码注释

编写代码不仅是给计算机看,也是给开发者阅读。良好的注释能显著提高代码的可读性,解释器会忽略这些内容。

变量

变量是用于跟踪值变化的符号容器。JavaScript 采用动态(弱)类型机制,变量可以持有任意类型的值。

ES6 块作用域声明

除了传统的 var,ES6 引入了更强大的变量声明方式:

  • let 声明:创建块级作用域变量。相比 var,它解决了提升导致的逻辑混乱,并引入了“暂时性死区”(TDZ)。

  • const 声明:用于创建只读常量。注意,const 锁定的是变量的赋值,而不是值本身。

    const a = [1, 2, 3]; 
    a.push( 4 ); // 成功!内容可以修改
    console.log( a ); // [1, 2, 3, 4] 
    a = 42; // TypeError! 赋值被锁定
    

模板字面量

ES6 引入了反引号 ( ` ) 界定的模板字面量,支持变量插值和多行字符串。

var name = "Kyle"; 
var greeting = `Hello ${name}!`; // 插值解析
var text = `
Now is the time 
for all good men
`; // 支持多行

解构

解构是一种“结构化赋值”方法,可以从数组或对象中快速提取值。

var [ a, b, c ] = [1, 2, 3]; 
var { x, y } = { x: 10, y: 20 };

块与条件判断

:使用 { .. } 将一系列语句组织在一起。

条件判断:最常用的是 if 语句,根据条件的真假决定是否执行后续代码块。

var bank_balance = 302.13; 
var amount = 99.99; 
if (amount < bank_balance) { 
    console.log( "I want to buy this phone!" ); 
}

循环

循环用于重复执行任务,每次执行被称为一次“迭代”。

  • while / do..while:根据条件循环。
  • for:更紧凑的循环形式,包含初始化、测试条件和更新。
var i = 0;
while (true) { 
    if ((i <= 9) === false) { 
        break; // 停止循环
    } 
    console.log(i); 
    i = i + 1; 
} 

for (var i = 0; i <= 9; i++) { 
    console.log( i ); 
}

函数

函数是可复用的代码片段,可以接受参数并返回值。

function printAmount(amt) { 
    console.log( amt.toFixed( 2 ) ); 
} 
function formatAmount() { 
    return "$" + amount.toFixed( 2 ); 
} 
var amount = 99.99; 
printAmount( amount * 2 ); // "199.98" 
amount = formatAmount(); 
console.log( amount ); // "$99.99"

作用域

在 JS 中,每个函数都有自己的作用域(词法作用域)。作用域是变量的集合及访问规则。

  • 只有函数内部的代码才能访问该作用域中的变量。
  • 作用域可以彼此嵌套:内层作用域可以访问外层作用域的变量,反之则不行。

小结

学习编程并不必然是复杂、费力的过程。我们需要熟悉几个基本的概念:

  • 运算符:在值上执行动作。
  • 值与类型:执行各种类型的动作需要值和类型,比如对数字进行数学运算,用字符串输出。
  • 变量:在程序的执行过程中需要变量来保存数据(也就是状态)。
  • 条件判断:需要 if 这样的条件判断来作出决策。
  • 循环:需要循环来重复任务,直到不满足某个条件。
  • 函数:需要函数将代码组织为逻辑上可复用的块。

在编程学习中,实践是绝对无法替代的。理论无法让你成为一个程序员,唯有动手尝试。

你不知道的JS(下):总结与未来

作者 牛奶
2026年2月21日 21:45

你不知道的JS(下):总结与未来

本文是《你不知道的JavaScript(下卷)》的阅读笔记,第四部分:总结与未来。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

原文地址

墨渊书肆/你不知道的JS(下):总结与未来

深入“你不知道的JS”系列回顾

1. 作用域和闭包

深入理解编译器对代码的处理方式(如“提升”),掌握词法作用域。这是研究闭包的基础,让我们明白变量是如何在不同层级的作用域中被查找和管理的。

2. this 和对象原型

this 是根据函数执行方式动态绑定的,而非定义位置。原型机制是一个属性查找链(委托),模拟类继承通常是对该机制的误用。

3. 类型和语法

类型转换(强制转换)是被严重低估的工具。正确使用它能显著提升代码质量,而不是回避它。

4. 异步和性能

异步编程不仅关乎应用响应速度,更是现代 JS 开发中代码易读性和可维护性的关键。

5. ES6 及更新版本

ES6 是 JavaScript 的一个巨大飞跃。令人兴奋的新特性包括:

  • 语法糖:解构赋值、默认参数值、简洁方法、计算属性、箭头函数。
  • 作用域:块作用域(let/const)。
  • 处理能力:Promise、生成器(Generators)、迭代器(Iterators)。
  • 元编程:代理(Proxy)、反射(Reflect)。
  • 新结构与 API:Map、Set、Symbol、模块(Modules)。
  • 集合扩展:TypedArray。

6. 集合与数据结构

ES6 极大地丰富了处理数据的手段:

  • Map/WeakMap:真正的键值对映射,键可以是任意类型(包括对象)。WeakMap 允许键被垃圾回收,适合存储元数据。
  • Set/WeakSet:唯一值的集合。WeakSet 同样支持弱引用,成员必须是对象。
  • TypedArray:如 Uint8ArrayFloat64Array,提供了对二进制数据的结构化访问,是处理音频、视频及 Canvas 数据的利器。

7. 元编程 (Meta Programming)

元编程关注程序自身的结构和运行时行为:

  • Proxy (代理):通过自定义处理函数(traps)拦截并重新定义对象的底层操作(如 get、set、has 等)。

    var pobj = new Proxy( obj, {
        get(target, key) {
            console.log( "accessing: ", key );
            return target[key];
        }
    } );
    
  • Reflect (反射):提供了一套与 Proxy 拦截器一一对应的静态方法,用于执行对象的默认行为。

  • 尾调用优化 (TCE):ES6 规范要求在严格模式下支持尾调用优化,能够有效避免递归时的栈溢出问题。

8. 新增 API 亮点

  • ArrayArray.of(..) 解决了 Array(..) 构造器的单数字陷阱;Array.from(..) 将类数组轻松转换为真数组。
  • ObjectObject.assign(..) 用于对象混入/克隆。
  • String:新增 includes(..)startsWith(..)repeat(..) 等实用方法。

9. ES6 之后与未来展望

JavaScript 的进化从未停歇:

  • 异步增强async/await(ES2017)让异步代码看起来像同步一样自然。
  • Object.observe:虽然最终被 Proxy 取代,但它代表了数据绑定机制的早期探索。
  • SIMD:单指令多数据流,旨在利用 CPU 并行指令加速数值计算。
  • WebAssembly (WASM):为 JS 引擎引入二进制指令格式,让 C/C++ 等高性能语言能以接近原生的速度在浏览器运行。
  • 正则表达式:新增 u (Unicode) 和 y (Sticky) 标识符。
  • 数字扩展:新的二进制 (0b) 和八进制 (0o) 字面量形式。

10. 代码组织与封装

  • Iterators (迭代器):提供了一套标准化的数据遍历协议。
  • Generators (生成器):通过 yield 实现可暂停/恢复的函数执行。
  • Modules (模块):原生支持基于文件的模块系统,通过 exportimport 实现静态依赖分析。
  • Classes (类):虽然只是原型委托的语法糖,但极大地简化了“面向对象”风格代码的编写。

ES 的现在与未来

版本演进

JavaScript 标准的官方名称是 ECMAScript (ES)

  • ES3:早期的流行标准(IE6-8 时代)。
  • ES5:2009 年发布,现代浏览器的稳固基石。
  • ES6 (ES2015):具有里程碑意义,引入了模块化和类等大型特性。
  • 后续版本:采用基于年份的命名方式(如 ES2016, ES2017...),每年发布一次,使语言特性能够更快速地迭代。

持续进化与工具化

JavaScript 的发展速度已显著加快。为了解决开发者想用新特性与旧环境支持落后之间的矛盾,工具化变得至关重要。

Transpiling 的重要性

Transpiling(转换+编译)技术(如使用 Babel)允许开发者编写最前沿的 ES 代码,并将其自动转换为兼容旧环境(如 ES5)的代码。这让我们既能享受语言进化的红利,又能兼顾用户覆盖面。配合 Polyfilling(填补 API 缺失),构成了现代 JS 开发的基础设施。

小结

JavaScript 的旅程从未停止:

  • 核心积淀:通过对作用域、this、类型和异步的深入探讨,我们夯实了 JS 的底层知识架构。
  • ES6 飞跃:作为里程碑式的版本,ES6 彻底改变了我们编写 JavaScript 的方式,使其具备了开发大型复杂应用的能力。
  • 面向未来:随着年度版本的发布和 WebAssembly 等新技术的出现,JS 正在变得更强、更快、更无处不在。
  • 工具赋能:Transpiler 和 Polyfill 是我们保持技术领先、跨越版本鸿沟的得力助手。

学习这门语言的秘诀在于:不满足于“它能运行”,而要追求“它是如何运行的”。唯有如此,方能在这门不断进化的语言中游刃有余。

你不知道的JS(下):深入JS(下)

作者 牛奶
2026年2月21日 21:43

你不知道的JS(下):深入JS(下)

本文是《你不知道的JavaScript(下卷)》的阅读笔记,第三部分:深入JS(下)。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

原文地址

墨渊书肆/你不知道的JS(下):深入JS(下)

严格模式 (Strict Mode)

ES5 引入了严格模式,通过 "use strict"; 开启。它可以使代码更安全、更易于引擎优化。

  • 不允许省略 var 的隐式自动全局变量声明。
  • 限制了某些不安全或不合理的语法行为。

函数进阶

作为值的函数

函数在 JavaScript 中是第一类对象,可以作为值赋给变量,也可以作为参数传递或从其他函数返回。

var foo = function() { /* .. */ };
var x = function bar(){ /* .. */ };

立即调用函数表达式 (IIFE)

IIFE 用于创建一个临时作用域并立即执行代码。它也可以有返回值:

var x = (function IIFE(){ 
    return 42; 
})(); 
x; // 42

闭包 (Closure)

闭包允许函数在其定义的词法作用域之外执行时,仍能“记忆”并访问该作用域。

模块模式

这是闭包最常见的应用。模块允许定义外部不可见的私有实现,同时提供公开 API。

function User(){ 
    var username, password; 
    function doLogin(user,pw) { 
        username = user; 
        password = pw; 
    } 
    var publicAPI = { 
        login: doLogin 
    }; 
    return publicAPI;
} 
var fred = User(); 
fred.login( "fred", "12Battery34!" );

this 标识符

this 指向哪个对象取决于函数是如何被调用的。遵循以下四条规则:

  1. 默认绑定:非严格模式下指向全局对象,严格模式下为 undefined
  2. 隐式绑定:由上下文对象调用(如 obj1.foo()),指向该对象。
  3. 显式绑定:通过 callapplybind 指定指向。
  4. new 绑定:指向新创建的空对象。
function foo() { console.log( this.bar ); } 
var bar = "global"; 
var obj1 = { bar: "obj1", foo: foo }; 
var obj2 = { bar: "obj2" }; 

foo();            // "global" (默认绑定)
obj1.foo();       // "obj1"   (隐式绑定)
foo.call( obj2 ); // "obj2"   (显式绑定)
new foo();        // undefined (new 绑定)

原型 (Prototype)

当访问对象不存在的属性时,JavaScript 会自动在内部原型链上查找。这是一种属性查找的备用机制(也称为委托)。

var foo = { a: 42 }; 
var bar = Object.create( foo ); 
bar.a; // 42 (委托给 foo 查找)

ES6 核心特性

符号 (Symbol)

Symbol 是 ES6 引入的新原生类型,没有字面量形式,主要用于创建唯一的、不会冲突的键值。

  • 单例模式:非常适合实现模块单例。
  • 符号注册:通过 Symbol.for(..) 在全局注册表中查找或创建符号。
  • 隐藏属性:符号属性不会出现在一般的属性枚举中(如 Object.keys),需使用 Object.getOwnPropertySymbols(..) 获取。

迭代器 (Iterator)

迭代器是一个结构化模式,用于从数据源一次提取一个值。

  • 接口:必须包含 next() 方法,返回 { value, done }

  • 自定义迭代器:可以手动实现 [Symbol.iterator] 接口。

    var Fib = { 
        [Symbol.iterator]() { 
            var n1 = 1, n2 = 1; 
            return { 
                next() { 
                    var current = n2; 
                    n2 = n1; n1 = n1 + current; 
                    return { value: current, done: false }; 
                } 
            }; 
        } 
    };
    

生成器 (Generator)

生成器是一种特殊的函数,可以在执行中暂停(yield)并恢复。

  • 语法function *foo() { .. }
  • 迭代器控制:生成器返回一个迭代器,通过调用 next() 来控制生成器的执行流。
  • 双向通信yield 不仅可以返回值,还可以接收 next(val) 传入的值。

模块 (Modules)

ES6 模块是基于文件的单例,具有静态 API。

  • 导出与导入:使用 exportimport
  • 静态加载:编译时确定依赖关系,支持模块间循环依赖。
  • 对比旧方法:不再需要依赖闭包和封装函数来实现模块化。

填补与转换 (Polyfilling & Transpiling)

Polyfilling

根据新特性定义,在旧环境中手动实现等价行为的代码。适用于新 API。

if (!Number.isNaN) { 
    Number.isNaN = function isNaN(x) { 
        return x !== x; // NaN 是唯一不等于自身的值
    }; 
}

Transpiling

通过工具(如 Babel)将新语法转换为等价的旧版代码。适用于新语法特性(如箭头函数、解构等),因为这些无法通过 Polyfill 实现。

小结

JavaScript 的进阶特性赋予了这门语言强大的表达能力:

  • 闭包与模块:通过词法作用域记忆功能实现私有化封装,是构建大型应用的基础。
  • this 与原型:理解动态绑定规则与原型委托机制,能够更高效地进行对象间的功能复用。
  • ES6 新范式:迭代器、生成器和原生模块系统标志着 JS 向更成熟、更工程化的方向迈进。
  • 兼容性保障:通过 Polyfill 和 Transpiling,我们可以在拥抱未来的同时,确保代码在旧环境中的稳健运行。

掌握这些核心机制,不仅能帮助我们写出更好的代码,更能让我们深入理解 JavaScript 的运行本质。

你不知道的JS(下):深入JS(上)

作者 牛奶
2026年2月21日 21:42

你不知道的JS(下):深入JS(上)

本文是《你不知道的JavaScript(下卷)》的阅读笔记,第二部分:深入JS(上)。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

原文地址

墨渊书肆/你不知道的JS(下):深入JS(上)

值和类型

JavaScript 的值有类型,但变量无类型。内置类型包括:

  • 字符串 (string)
  • 数字 (number)
  • 布尔型 (boolean)
  • nullundefined
  • 对象 (object)
  • 符号 (symbol,ES6 新增)

使用 typeof 运算符可以查看值的类型。注意:typeof null 返回 "object",这是一个历史遗留问题。

对象

对象是 JavaScript 中最有用的值类型,可以设置属性。

var obj = { 
    a: "hello world", 
    b: 42, 
    c: true
}; 
obj.a; // "hello world" 
obj["b"]; // 42

数组与函数

数组和函数是对象的特殊子类型:

  • 数组:持有值的对象,通过数字索引位置管理。

    var arr = ["hello world", 42, true]; 
    arr[0]; // "hello world" 
    arr.length; // 3 
    typeof arr; // "object"
    
  • 函数:也是对象的一个子类型,可以拥有属性。

    function foo() { return 42; } 
    foo.bar = "hello world"; 
    typeof foo; // "function"
    

内置类型方法

内置类型及其子类型拥有作为属性和方法暴露出来的行为:

var a = "hello world"; 
a.length; // 11 
a.toUpperCase(); // "HELLO WORLD" 

值的比较

JavaScript 中任何比较的结果都是布尔值(truefalse)。

真与假 (Truthy & Falsy)

JavaScript 中的“假”值列表:

  • ""(空字符串)
  • 0-0NaN(无效数字)
  • nullundefined
  • false 除以上值外,所有其他值均为“真”值。

相等性

相等运算符有四种:=====!=!==

  • ==:允许类型转换情况下的相等性检查。
  • ===:不允许类型转换(严格相等)。
var a = "42"; 
var b = 42;
a == b;  // true (隐式转换)
a === b; // false (严格相等)

关系比较

<><=>= 用于比较有序值(如数字或字母序字符串 "bar" < "foo")。

变量与作用域

变量标识符必须由 a-zA-Z$_ 开始,可以包含数字。

ES6 语法扩展

  • spread/rest 运算符 (...):取决于使用位置,用于展开数组或收集参数。

    // 展开
    function foo(x,y,z) { console.log( x, y, z ); } 
    foo( ...[1,2,3] ); // 1 2 3
    // 收集
    var a = [2,3,4]; 
    var b = [ 1, ...a, 5 ]; // [1,2,3,4,5]
    
  • 默认参数值:为缺失参数提供默认值。

    function foo(x = 11, y = 31) { console.log( x + y ); } 
    foo(5); // 36 (y 使用默认值)
    foo(5, undefined); // 36 (undefined 触发默认值)
    foo(5, null); // 5 (null 被强制转换为 0)
    

提升 (Hoisting)

使用 var 声明的变量和函数声明会被“提升”到其所在作用域的最顶端。

var a = 2;
foo(); 
function foo() { 
    a = 3; 
    console.log( a ); // 3 
    var a; // 声明被提升到了 foo() 的顶端
} 
console.log( a ); // 2

作用域嵌套

声明后的变量在当前作用域及其所有内层作用域中随处可见。

function foo() { 
    var a = 1; 
    function bar() { 
        var b = 2; 
        function baz() { 
            var c = 3; 
            console.log( a, b, c ); // 1 2 3 (向上查找作用域链)
        } 
        baz(); 
    } 
    bar(); 
}

条件判断与循环

除了 if..else,JavaScript 还提供了多种控制流机制。

条件判断

  • switch:适用于多分支判断。
  • 三元运算符 ? ::简洁的条件表达式。

循环

  • for..of 循环:ES6 新增,直接在迭代器产生的上循环。

    var a = ["a","b","c"]; 
    for (var val of a) { 
        console.log( val ); // "a" "b" "c"
    }
    

箭头函数 (=>)

箭头函数不仅是更短的语法,它还解决了 this 绑定的常见痛点(采用词法 this)。

var controller = { 
    makeRequest: function(){ 
        btn.addEventListener( "click", () => { 
            this.makeRequest(); // this 继承自父层,即 controller
        }, false ); 
    } 
};

箭头函数是匿名函数表达式,没有自己的 argumentssupernew.target

小结

深入理解 JavaScript 的第一步是掌握其核心机制:

  • 值与类型:JS 的变量无类型但值有类型。
  • 强制类型转换:理解 ===== 的区别,以及真假值的判断规则。
  • 作用域与提升:掌握 var 的声明提升行为以及嵌套作用域的查找规则。
  • 现代语法:熟悉 ES6 带来的 spread 运算符、默认参数、for..of 循环以及箭头函数等新特性。

通过掌握这些基础,我们可以更从容地应对更高级的 JS 特性。

昨天以前首页

你不知道的JS(中):程序性能与测试

作者 牛奶
2026年2月15日 10:43

你不知道的JS(中):程序性能与测试

本文是《你不知道的JavaScript(中卷)》的阅读笔记,第四部分:程序性能与测试。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

程序性能

异步对 JavaScript 来说真的很重要,最显而易见的原因就是性能。如果要发出两个 Ajax 请求,并且它们之间是彼此独立的,但是需要等待两个请求都完成才能执行下一步的任务,那么为这个交互建模有两种选择:顺序与并发。 通常后一种模式会比前一种更高效。而更高的性能通常也会带来更好的用户体验。

Web Worker

我们已经详细介绍了 JavaScript 是如何单线程运作的。但是,单线程并不是组织程序执行的唯一方式。 设想一下,把你的程序分为两个部分:一部分运行在主 UI 线程下,另外一部分运行在另一个完全独立的线程中。

你的浏览器这样的环境,很容易提供多个 JavaScript 引擎实例,各自运行在自己的线程上,这样你可以在每个线程上运行不同的程序。程序中每一个这样的独立的多线程部分被称为一个(Web)Worker。这种类型的并行化被称为任务并行,因为其重点在于把程序划分为多个块来并发运行。

从 JavaScript 主程序(或另一个 Worker)中,可以这样实例化一个 Worker:

// 主程序
var w1 = new Worker( "http://some.url.1/mycoolworker.js" );
// 监听事件
w1.addEventListener( "message", function(evt){ 
    // evt.data 
} );
// 发送事件
w1.postMessage( "something cool to say" );

worker内部,收发消息是完全对称的:

// "mycoolworker.js" 
addEventListener( "message", function(evt){ 
    // evt.data 
} ); 
postMessage( "a really cool reply" );

1. Worker环境 在 Worker 内部是无法访问主程序的任何资源的。这意味着你不能访问它的任何全局变量,也不能访问页面的 DOM 或者其他资源。记住,这是一个完全独立的线程。

但你可以执行网络操作Ajax、WebSockets以及设定定时器。还有Worker可以访问几个重要的全局变量和功能的本地复本,包括 navigator、location、JSON 和 applicationCache。

你还可以通过 importScripts(..) 向 Worker 加载额外的 JavaScript 脚本:

// 在Worker内部
importScripts( "foo.js", "bar.js" );

这些脚本加载是同步的。也就是说,importScripts(..) 调用会阻塞余下 Worker 的执行,直到文件加载和执行完成。

Web Worker 通常应用于哪些方面呢?

  • 处理密集型数学计算
  • 大数据集排序
  • 数据处理(压缩、音频分析、图像处理等)
  • 高流量网络通信

2. 数据传递 在线程之间通过事件机制传递大量的信息,可能是双向的。 特别是对于大数据集而言,就是使用 Transferable 对象。这时发生的是对象所有权的转移,数据本身并没有移动。一旦你把对象传递到一个 Worker 中,在原来的位置上,它就变为空的或者是不可访问的,这样就消除了多线程编程作用域共享带来的混乱。当然,所有权传递是可以双向进行的。

// 比如foo是一个Uint8Array 
postMessage( foo.buffer, [ foo.buffer ] );

3. 共享Worker 创建一个整个站点或 app 的所有页面实例都可以共享的中心 Worker 就非常有用了。这称为 SharedWorker,可通过下面的方式创建(只有 Firefox 和 Chrome 支持这一功能):

var w1 = new SharedWorker( "http://some.url.1/mycoolworker.js" );

在共享 Worker 内部,必须要处理额外的一个事件:"connect"。这个事件为这个特定的连接提供了端口对象。保持多个连接独立的最简单办法就是使用 port 上的闭包:

// 在共享Worker内部
addEventListener( "connect", function(evt){ 
    // 这个连接分配的端口
    var port = evt.ports[0]; 
    port.addEventListener( "message", function(evt){ 
        // .. 
        port.postMessage( .. ); 
        // .. 
    } ); 
    // 初始化端口连接
    port.start(); 
} );

SIMD

单指令多数据(SIMD)是一种数据并行(data parallelism)方式,与 Web Worker 的任务并行(task parallelism)相对,因为这里的重点实际上不再是把程序逻辑分成并行的块,而是并行处理数据的多个位。

asm.js

asm.js这个标签是指 JavaScript 语言中可以高度优化的一个子集。通过小心避免某些难以优化的机制和模式(垃圾收集、类型强制转换,等等),asm.js 风格的代码可以被 JavaScript 引擎识别并进行特别激进的底层优化。

1. 如何使用

var a = 42;
var b = a | 0;

此处我们使用了与 0 的 |(二进制或)运算,除了确保这个值是 32 位整型之外,对于值没有任何效果。这样的代码在一般的 JavaScript 引擎上都可以正常工作。 而对支持 asm.js 的JavaScript 引擎来说,这段代码就发出这样的信号,b 应该总是被当作 32位整型来处理,这样就可以省略强制类型转换追踪。

2. asm.js 模块 对一个 asm.js 模块来说,你需要明确地导入一个严格规范的命名空间——规范将之称为stdlib,因为它应该代表所需的标准库。 你还需要声明一个堆(heap)并将其传入。这个术语用于表示内存中一块保留的位置,变量可以直接使用而不需要额外的内存请求或释放之前使用的内存。这样,asm.js 模块就不需要任何可能导致内存扰动的动作了,只需使用预先保留的空间即可。

var heap = new ArrayBuffer( 0x10000 ); // 64k堆

var arr = new Float64Array( heap );

asm.js 代码如此高度可优化的那些限制的特性显著降低了这类代码的使用范围。asm.js 并不是对任意程序都适用的通用优化手段。它的目标是对特定的任务处理提供一种优化方法,比如数学运算(如游戏中的图形处理)。

程序性能小结

异步编码模式使我们能够编写更高效的代码,通常能够带来非常大的改进。但是,异步特性只能让你走这么远,因为它本质上还是绑定在一个单事件循环线程上。 因此,在这一章里,我们介绍了几种能够进一步提高性能的程序级别的机制。

性能测试与调优

性能测试

如果被问到如何测试某个运算的速度(执行时间),绝大多数 JavaScript 开发者都会从类似下面的代码开始:

var start = (new Date()).getTime(); // 或者Date.now() 
// 进行一些操作
var end = (new Date()).getTime(); 
console.log( "Duration:", (end - start) );

这样低可信度的测试几乎无力支持你的任何决策。这个性能测试基本上是无用的。更坏的是它是危险的,因为它可能提供了错误的可信度。

1. 重复 你可以不以固定次数执行运算,转而循环运行测试,直到达到某个固定的时间。这可能会更可靠一些。

2. Benchmark.js 一个统计学上有效的性能测试工具,名为 Benchmark.js,我们使用这个工具就好了。

环境为王

对特定的性能测试来说,不要忘了检查测试环境,特别是比较任务 X 和 Y 这样的比对测试。仅仅因为你的测试显示 X 比 Y 快,并不能说明结论 X 比 Y 快就有实际的意义。

引擎优化 现代引擎要比我们凭直觉进行的推导复杂得多。它们会实现各种技巧,比如跟踪记录代码在一小段时期内或针对特别有限的输入集的行为。

jsPerf.com

如果想要在不止一个环境下得出像“X 比 Y 快”这样的有意义的结论成立,那你需要在尽可能多的真实环境下进行实际测试。仅仅因为在 Chrome 上某个 X 运算比 Y 快并不意味着这在所有的浏览器中都成立。当然你可能还想要交叉引用多个浏览器上的测试运行结果,并有用户的图形展示。 有一个很棒的网站正是因这样的需求而诞生的,名为 jsPerf (jsperf.com)。它使用我们前面介绍的 Benchmark.js 库来运行统计上精确可靠的测试,并把测试结果放在一个公开可得的 URL 上,你可以把这个 URL 转发给别人。

写好测试

编写更好更清晰的测试。

微性能

var x = [ .. ]; 
// 选择1 
for (var i=0; i < x.length; i++) { 
    // .. 
} 
// 选择2 
for (var i=0, len = x.length; i < len; i++) { 
    // .. 
}

理论上说,这里应该在变量 len 中缓存 x 数组的长度,因为表面上它不会改变,来避免在每个循环迭代中计算 x.length 的代价。

如下是 v8 的一些经常提到的例子:

  • 不要从一个函数到另外一个函数传递 arguments 变量,因为这样的泄漏会降低函数实现速度.
  • 把 try..catch 分离到单独的函数里。浏览器对任何有 try..catch 的函数实行优化都有一些困难,所以把这部分移到独立的函数中意味着你控制了反优化的害处,并让其包含的代码可以优化。

尾调用优化

ES6 包含了一个性能领域的特殊要求。这与一个涉及函数调用的特定优化形式相关:尾调用优化(Tail Call Optimization,TCO)。

function foo(x) { 
    return x; 
} 
function bar(y) { 
    return foo( y + 1 ); // 尾调用
} 
function bar(y) { 
    return foo( y + 1 ); // 尾调用
} 
function baz() { 
    return 1 + bar( 40 ); // 非尾调用
} 
baz(); // 42

调用一个新的函数需要额外的一块预留内存来管理调用栈,称为栈帧。所以前面的代码一般会同时需要为每个 baz()、bar(..) 和 foo(..) 保留一个栈帧。 然而,如果支持 TCO 的引擎能够意识到 foo(y+1) 调用位于尾部,这意味着 bar(..) 基本上已经完成了,那么在调用 foo(..) 时,它就不需要创建一个新的栈帧,而是可以重用已有的 bar(..) 的栈帧。这样不仅速度更快,也更节省内存。

性能测试与调优小结

尾调用优化是 ES6 要求的一种优化方法。它使 JavaScript 中原本不可能的一些递归模式变得实际。TCO 允许一个函数在结尾处调用另外一个函数来执行,不需要任何额外资源。这意味着,对递归算法来说,引擎不再需要限制栈深度。

原文地址

墨渊书肆/你不知道的JS(中):程序性能与测试

你不知道的JS(中):Promise与生成器

作者 牛奶
2026年2月15日 10:41

你不知道的JS(中):Promise与生成器

本文是《你不知道的JavaScript(中卷)》的阅读笔记,第三部分:Promise与生成器。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

Promise

什么是Promise

未来值 在具体解释 Promise 的 工作方式之前,先来推导通过我们已经理解的方式——回调——如何处理未来值。为了统一处理现在和将来,我们把它们都变成了将来,即所有的操作都成了异步的。

Promise值

function add(xPromise,yPromise) { 
    // Promise.all([ .. ])接受一个promise数组并返回一个新的promise,
    // 这个新promise等待数组中的所有promise完成
    return Promise.all( [xPromise, yPromise] ) 
    // 这个promise决议之后,我们取得收到的X和Y值并加在一起
    .then( function(values){ 
        // values是来自于之前决议的promisei的消息数组
        return values[0] + values[1]; 
    } ); 
} 
// fetchX()和fetchY()返回相应值的promise,可能已经就绪,
// 也可能以后就绪 
add( fetchX(), fetchY() ) 
// 我们得到一个这两个数组的和的promise
// 现在链式调用 then(..)来等待返回promise的决议
.then( function(sum){ 
    console.log( sum ); // 这更简单!
} );

完成事件 在典型的 JavaScript 风格中,如果需要侦听某个通知,你可能就会想到事件。因此,可以把对通知的需求重新组织为对 foo 发出的一个完成事件(completion event,或continuation 事件)的侦听。

function foo(x) { 
    // 开始做点可能耗时的工作
    // 构造一个listener事件通知处理对象来返回
    return listener; 
} 
var evt = foo( 42 ); 
evt.on( "completion", function(){ 
    // 可以进行下一步了!
} ); 
evt.on( "failure", function(err){ 
    // 啊,foo(..)中出错了
} );

promise中监听回调事件:

function foo(x) { 
    // 可是做一些可能耗时的工作
    // 构造并返回一个promise
    return new Promise( function(resolve,reject){ 
        // 最终调用resolve(..)或者reject(..)
        // 这是这个promise的决议回调
    } ); 
} 
var p = foo( 42 ); 
bar( p ); 
baz( p );

具有then方法的鸭子类型

识别 Promise(或者行为类似于 Promise 的东西)就是定义某种称为 thenable 的东西,将其定义为任何具有 then 方法的对象 and 函数。我们认为,任何这样的值就是Promise 一致的 thenable。thenable值的鸭子类型检测就大致类似于:

if ( 
 p !== null && 
 ( 
 typeof p === "object" || 
 typeof p === "function" 
 ) && 
 typeof p.then === "function" 
) { 
 // 假定这是一个thenable! 
} 
else { 
 // 不是thenable 
}

Promise信任问题

先回顾一下只用回调编码的信任问题。把一个回调传入工具 foo(..) 时可能出现如下问题:

  • 调用回调过早;
  • 调用回调过晚(或不被调用);
  • 调用回调次数过少或过多;
  • 未能传递所需的环境和参数;
  • 吞掉可能出现的错误和异常;

1. 调用过早 Promise 就不必担心这种问题,因为即使是立即完成的 Promise(类似于 new Promise(function(resolve){ resolve(42); }))也无法被同步观察到。

2. 调用过晚 Promise 创建对象调用 resolve 或 reject 时,这个 Promise 的then 注册的观察回调就会被自动调度。可以确信,这些被调度的回调在下一个异步事件点上一定会被触发。

3. 回调未调用 如果你对一个 Promise 注册了一个完成回调和一个拒绝回调,那么 Promise在决议时总是会调用其中的一个。 但是,如果 Promise 本身永远不被决议呢?即使这样,Promise 也提供了解决方案,其使用了一种称为竞态的高级抽象机制:

// 用于超时一个Promise的工具
function timeoutPromise(delay) { 
    return new Promise( function(resolve,reject){ 
        setTimeout( function(){ 
            reject( "Timeout!" ); 
        }, delay ); 
    } ); 
} 
// 设置foo()超时
Promise.race( [ 
    foo(), // 试着开始foo() 
    timeoutPromise( 3000 ) // 给它3秒钟
] ) 
.then( 
     function(){ 
         // foo(..)及时完成!
     },
    function(err){ 
        // 或者foo()被拒绝,或者只是没能按时完成
        // 查看err来了解是哪种情况
    } 
);

4. 调用次数过少或过多 如果你把同一个回调注册了不止一次(比如 p.then(f); p.then(f);),那它被调用的次数就会和注册次数相同。响应函数只会被调用一次。

5. 未能传递参数/环境值 Promise 至多只能有一个决议值(完成或拒绝)。 如果你没有用任何值显式决议,那么这个值就是 undefined,这是 JavaScript 常见的处理方式。但不管这个值是什么,无论当前或未来,它都会被传给所有注册的(且适当的完成或拒绝)回调。

6. 吞掉错误或异常 如果在 Promise 的创建过程中或在查看其决议结果过程中的任何时间点上出现了一个 JavaScript 异常错误,比如一个 TypeError 或ReferenceError,那这个异常就会被捕捉,并且会使这个 Promise 被拒绝。

var p = new Promise( function(resolve,reject){ 
    foo.bar(); // foo未定义,所以会出错!
    resolve( 42 ); // 永远不会到达这里
} ); 
p.then( 
    function fulfilled(){ 
        // 永远不会到达这里 :( 
    }, 
    function rejected(err){ 
        // err将会是一个TypeError异常对象来自foo.bar()这一行
    } 
);

链式流

这种方式可以实现的关键在于以下两个 Promise 固有行为特性:

  • 每次你对 Promise 调用 then,它都会创建并返回一个新的 Promise,我们可以将其链接起来;
  • 不管从 then 调用的完成回调(第一个参数)返回的值是什么,它都会被自动设置为被链接 Promise的完成。
var p = Promise.resolve( 21 ); 
var p2 = p.then( function(v){ 
    console.log( v ); // 21 
    // 用值42填充p2
    return v * 2; 
} ); 
// 连接p2 
p2.then( function(v){ 
    console.log( v ); // 42 
} );

术语:决议、完成以及拒绝 对于术语决议(resolve)、完成(fulfill)和拒绝(reject),在更深入学习 Promise 之前,我们还有一些模糊之处需要澄清。先来研究一下构造器 Promise(..):

var p = new Promise( function(X,Y){ 
    // X()用于完成
    // Y()用于拒绝
} );

错误处理

错误处理最自然的形式就是同步的 try..catch 结构。遗憾的是,它只能是同步的,无法用于异步代码模式:

function foo() { 
    setTimeout( function(){ 
        baz.bar(); 
    }, 100 ); 
} 
try {
    foo(); 
    // 后面从 `baz.bar()` 抛出全局错误
} catch (err) { 
    // 永远不会到达这里
}

Promise 使用了分离回调风格。一个回调用于完成情况,一个回调用于拒绝情况:

var p = Promise.reject( "Oops" ); 
p.then( 
    function fulfilled(){ 
        // 永远不会到达这里
    }, 
    function rejected(err){ 
        console.log( err ); // "Oops" 
    } 
);

处理未捕获的情况 浏览器有一个特有的功能是我们的代码所没有的:它们可以跟踪并了解所有对象被丢弃以及被垃圾回收的时机。所以,浏览器可以追踪 Promise 对象。如果在它被垃圾回收的时候其中有拒绝,浏览器就能够确保这是一个真正的未捕获错误,进而可以确定应该将其报告到开发者终端。

Promise模式

1. Promise.all Promise.all 需要一个参数,是一个数组,通常由 Promise 实例组成。从 Promise.all([ .. ]) 调用返回的 promise 会收到一个完成消息。这是一个由所有传入 promise 的完成消息组成的数组,与指定的顺序一致(与完成顺序无关)。

// request(..)是一个Promise-aware Ajax工具
// 就像我们在本章前面定义的一样
var p1 = request( "http://some.url.1/" ); 
var p2 = request( "http://some.url.2/" ); 
Promise.all( [p1,p2] ) 
.then( function(msgs){ 
    // 这里,p1和p2完成并把它们的消息传入
    return request("http://some.url.3/?v=" + msgs.join(",")); 
}) 
.then( function(msg){ 
    console.log( msg ); 
});

2. Promise.race Promise.race也接受单个数组参数。这个数组由一个或多个 Promise、thenable 或立即值组成。一旦有任何一个 Promise 决议为完成,Promise.race就会完成;一旦有任何一个 Promise 决议为拒绝,它就会拒绝。

// request(..)是一个Promise-aware Ajax工具
// 就像我们在本章前面定义的一样
var p1 = request( "http://some.url.1/" ); 
var p2 = request( "http://some.url.2/" ); 
Promise.race( [p1,p2] ) 
.then( function(msg){ 
    // p1或者p2将赢得这场竞赛
    return request("http://some.url.3/?v=" + msg); 
}) 
.then( function(msg){ 
    console.log( msg ); 
});

all和race的变体

  • none([ .. ]) 这个模式类似于 all([ .. ]),不过完成和拒绝的情况互换了。所有的 Promise 都要被 拒绝,即拒绝转化为完成值,反之亦然。
  • any([ .. ]) 这个模式与 all([ .. ]) 类似,但是会忽略拒绝,所以只需要完成一个而不是全部。
  • first([ .. ]) 这个模式类似于与 any([ .. ]) 的竞争,即只要第一个 Promise 完成,它就会忽略后续的任何拒绝和完成。
  • last([ .. ]) 这个模式类似于 first([ .. ]),但却是只有最后一个完成胜出。

Promise API概述

new Promise构造器 有启示性的构造器 Promise(..) 必须和 new 一起使用,并且必须提供一个函数回调。这个回调是同步的或立即调用的。这个函数接受两个函数回调,用以支持 promise 的决议。通常我们把这两个函数称为 resolve(..) 和 reject(..):

var p = new Promise( function(resolve,reject){ 
    // resolve(..)用于决议/完成这个promise
    // reject(..)用于拒绝这个promise
} );

Promise.resolve和 Promise.reject 创建一个已被拒绝的 Promise 的快捷方式是使用 Promise.reject(..),所以以下两个promise 是等价的:

var p1 = new Promise( function(resolve,reject){ 
    reject( "Oops" ); 
} ); 
var p2 = Promise.reject( "Oops" );

then和catch then接受一个或两个参数:第一个用于完成回调,第二个用于拒绝回调。如果两者中的任何一个被省略或者作为非函数值传入的话,就会替换为相应的默认回调。默认完成回调只是把消息传递下去,而默认拒绝回调则只是重新抛出其接收到的出错原因。 catch只接受一个拒绝回调作为参数,并自动替换默认完成回调。 then 和 catch 也会创建并返回一个新的 promise,这个 promise 可以用于实现Promise 链式流程控制。

Promise局限性

顺序错误处理 很多时候并没有为 Promise 链序列的中间步骤保留的引用。因此,没有这样的引用,你就无法关联错误处理函数来可靠地检查错误。

单一值 根据定义,Promise 只能有一个完成值或一个拒绝理由。在简单的例子中,这不是什么问题,但是在更复杂的场景中,你可能就会发现这是一种局限了。

  1. 分裂值: 这种方法更符合 Promise 的设计理念。如果以后需要重构代码把对 x 和 y 的计算分开,这种方法就简单得多。由调用代码来决定如何安排这两个 promise,而不是把这种细节放在 foo(..) 内部抽象,这样更整洁也更灵活。
function foo(bar,baz) { 
    var x = bar * baz; 
    // 返回两个promise
    return [ 
        Promise.resolve( x ), 
        getY( x ) 
    ]; 
} 
Promise.all( foo( 10, 20 ) ) 
.then( function(msgs){ 
    var x = msgs[0]; 
    var y = msgs[1]; 
    console.log( x, y ); 
} );
  1. 展开/传递参数:

ES6 提供了数组参数解构形式

Promise.all( foo( 10, 20 ) ) 
.then( function([x,y]){ 
    console.log( x, y ); // 200 599 
} );

单决议 Promise 最本质的一个特征是:Promise 只能被决议一次(完成或拒绝)。在许多异步情况中,你只会获取一个值一次,所以这可以工作良好。

无法取消的Promise 一旦创建了一个 Promise 并为其注册了完成或拒绝处理函数,如果出现某种情况使得这个任务悬而未决的话,你也没有办法从外部停止它的进程。

Promise的性能 Promise 使所有一切都成为异步的了,即有一些立即(同步)完成的步骤仍然会延迟到任务的下一步。这意味着一个 Promise 任务序列可能比完全通过回调连接的同样的任务序列运行得稍慢一点。

Promise小结

Promise 非常好,请使用。它们解决了我们因只用回调的代码而备受困扰的控制反转问题。 Promise 链也开始 provide 以顺序的方式表达异步流的一个更好的方法,这有助于我们的大脑更好地计划和维护异步 JavaScript 代码。

生成器

JS 开发者在代码中几乎普遍依赖的一个假定:一个函数一旦开始执行,就会运行到结束,期间不会有其他代码能够打断它并插入其间。不过 ES6 引入了一个新的函数类型,它并不符合这种运行到结束的特性。这类新的函数被称为生成器。

打破完整运行

如果foo自身可以通过某种形式在代码的这个位置指示暂停的话,那就仍然可以以一种合作式的方式实现这样的中断(并发)。

var x = 1; 
function *foo() { 
    x++; 
    yield; // 暂停!
    console.log( "x:", x ); 
} 
function bar() { 
    x++; 
} 

// 构造一个迭代器it来控制这个生成器
var it = foo(); 

// 这里启动foo()!
it.next(); 
x; // 2 
bar(); 
x; // 3 
it.next(); // x: 3

解释 ES6 生成器的不同机制和语法之前,我们先来看看运行过程。

  1. it = foo() 运算并没有执行生成器 *foo(),而只是构造了一个迭代器(iterator),这个迭代器会控制它的执行。后面会介绍迭代器。
  2. 第一个 it.next() 启动了生成器 *foo(),并运行了 *foo() 第一行的 x++。
  3. *foo() 在 yield 语句处暂停,在这一点上第一个 it.next() 调用结束。此时 *foo() 仍在运行并且是活跃的,但处于暂停状态。
  4. 我们查看 x 的值,此时为 2。
  5. 我们调用 bar(),它通过 x++ 再次递增 x。
  6. 我们再次查看 x 的值,此时为 3。
  7. 最后的 it.next() 调用从暂停处恢复了生成器 *foo() 的执行,并运行 console.log(..)语句,这条语句使用当前 x 的值 3。

显然,foo() 启动了,但是没有完整运行,它在 yield 处暂停了。后面恢复了 foo() 并让它运行到结束,但这不是必需的。

输入和输出 生成器函数是一个特殊的函数,具有前面我们展示的新的执行模式。但是,它仍然是一个函数,这意味着它仍然有一些基本的特性没有改变。比如,它仍然可以接受参数(即输入),也能够返回值(即输出)。

function *foo(x,y) { 
    return x * y; 
} 
var it = foo( 6, 7 );

var res = it.next();
res.value; // 42

多个迭代器 同一个生成器的多个实例可以同时运行,它们甚至可以彼此交互:

function *foo() { 
    var x = yield 2; 
    z++; 
    var y = yield (x * z); 
    console.log( x, y, z ); 
} 
var z = 1; 
var it1 = foo(); 
var it2 = foo(); 
var val1 = it1.next().value; // 2 <-- yield 2 
var val2 = it2.next().value; // 2 <-- yield 2 
val1 = it1.next( val2 * 10 ).value; // 40 <-- x:20, z:2 
val2 = it2.next( val1 * 5 ).value; // 600 <-- x:200, z:3 
it1.next( val2 / 2 ); // y:300 
 // 20 300 3 
it2.next( val1 / 4 ); // y:10 
 // 200 10 3

我们简单梳理一下执行流程。

  1. *foo() 的两个实例同时启动,两个 next() 分别从 yield 2 语句得到值 2。
  2. val2 * 10 也就是 2 * 10,发送到第一个生成器实例 it1,因此 x 得到值 20. z 从 1 增加到 2,然后 20 * 2 通过 yield 发出,将 val1 设置为 40。
  3. val1 * 5 也就是 40 * 5,发送到第二个生成器实例 it2,因此 x 得到值 200. z 再次从 2递增到 3,然后 200 * 3 通过 yield 发出,将 val2 设置为 600。
  4. val2 / 2 也就是 600 / 2,发送到第一个生成器实例 it1,因此 y 得到值 300,然后打印出 x y z 的值分别是 20 300 3。
  5. val1 / 4 也就是 40 / 4,发送到第二个生成器实例 it2,因此 y 得到值 10,然后打印出x y z 的值分别为 200 10 3。

生成器产生值

我们提到生成器的一种有趣用法是作为一种产生值的方式。

生产者与迭代器 假定你要产生一系列值,其中每个值都与前面一个有特定的关系。要实现这一点,需要一个有状态的生产者能够记住其生成的最后一个值。

var gimmeSomething = (function(){ 
    var nextVal; 
    return function(){ 
        if (nextVal === undefined) { 
            nextVal = 1; 
        } 
        else { 
            nextVal = (3 * nextVal) +6; 
        } 
        return nextVal; 
    }; 
})(); 
gimmeSomething(); // 1 
gimmeSomething(); // 9 
gimmeSomething(); // 33 
gimmeSomething(); // 105

实际上,这个任务是一个非常通用的设计模式,通常通过迭代器来解决。迭代器是一个定义良好的接口,用于从一个生产者一步步得到一系列值。JavaScript 迭代器的接口,与多数语言类似,就是每次想要从生产者得到下一个值的时候调用 next()。

var something = (function(){ 
    var nextVal; 
    return { 
        // for..of循环需要
        [Symbol.iterator]: function(){ return this; }, 
        // 标准迭代器接口方法
        next: function(){ 
            if (nextVal === undefined) { 
                nextVal = 1; 
            } 
            else { 
                nextVal = (3 * nextVal) + 6; 
            } 
            return { done:false, value:nextVal }; 
        } 
    }; 
})(); 
something.next().value; // 1 
something.next().value; // 9 
something.next().value; // 33
something.next().value; // 105

ES6 还新增了一个 for..of 循环,这意味着可以通过原生循环语法自动迭代标准迭代器:

for (var v of something) { 
    console.log( v ); 
    // 不要死循环!
    if (v > 500) { 
        break; 
    } 
} 
// 1 9 33 105 321 969

iterable 可迭代 下面代码片段中的 a 就是一个 iterable。for..of 循环自动调用它的 Symbol.iterator 函数来构建一个迭代器。我们当然也可以手工调用这个函数,然后使用它返回的迭代器:

var a = [1,3,5,7,9]; 
var it = a[Symbol.iterator](); 
it.next().value; // 1 
it.next().value; // 3 
it.next().value; // 5

生成器迭代器 严格说来,生成器本身并不是 iterable,尽管非常类似——当你执行一个生成器,就得到了一个迭代器:

function *something() { 
    var nextVal; 
    while (true) { 
        if (nextVal === undefined) { 
            nextVal = 1; 
        } 
        else { 
            nextVal = (3 * nextVal) + 6; 
        } 
        yield nextVal; 
    } 
}

停止生成器 for..of 循环的“异常结束”(也就是“提前终止”),通常由 break、return 或者未捕获异常引起,会向生成器的迭代器发送一个信号使其终止。

var it = something(); 
for (var v of it) { 
    console.log( v ); 
    // 不要死循环!
    if (v > 500) { 
        console.log( 
            // 完成生成器的迭代器
            it.return( "Hello World" ).value 
        ); 
        // 这里不需要break 
    } 
} 
// 1 9 33 105 321 969 
// 清理!
// Hello World

异步迭代生成器

同步错误处理 我们可以把错误抛入生成器中:

function *main() { 
    var x = yield "Hello World"; 
    yield x.toLowerCase(); // 引发一个异常!
} 
var it = main(); 
it.next().value; // Hello World 
try { 
    it.next( 42 ); 
} 
catch (err) { 
    console.error( err ); // TypeError 
}

生成器 + Promise

首先,把支持 Promise 的 foo(..) 和生成器 *main() 放在一起:

function foo(x,y) { 
    return request( 
        "http://some.url.1/?x=" + x + "&y=" + y 
    ); 
} 
function *main() { 
    try { 
        var text = yield foo( 11, 31 ); 
        console.log( text ); 
    } catch (err) { 
        console.error( err ); 
    } 
}

var it = main(); 
var p = it.next().value; 
// 等待promise p决议
p.then( 
    function(text){ 
        it.next( text ); 
    }, 
    function(err){ 
        it.throw( err ); 
    } 
);

ES7: async与await

function foo(x,y) { 
    return request( 
        "http://some.url.1/?x=" + x + "&y=" + y 
    ); 
} 
async function main() { 
    try { 
        var text = await foo( 11, 31 ); 
        console.log( text ); 
    } catch (err) { 
        console.error( err ); 
    } 
} 
main();

生成器委托

yield * 暂停了迭代控制,而不是生成器控制。当你调用 *foo() 生成器时,现在 yield 委托到了它的迭代器。但实际上,你可以 yield 委托到任意iterable,yield *[1,2,3] 会消耗数组值 [1,2,3] 的默认迭代器。

function *foo() { 
    var r2 = yield request( "http://some.url.2" ); 
    var r3 = yield request( "http://some.url.3/?v=" + r2 ); 
    return r3; 
} 
function *bar() { 
    var r1 = yield request( "http://some.url.1" );
    // 通过 yeild* "委托"给*foo()
    var r3 = yield *foo(); 
    console.log( r3 ); 
} 
run( bar );

为什么用委托 yield 委托的主要目的是代码组织,以达到与普通函数调用的对称。

生成器并发

两个同时运行的进程可以合作式地交替运作,而很多时候这可以产生非常强大的异步表示。 回想一下之前给出的一个场景:其中两个不同并发 Ajax 响应处理函数需要彼此协调,以确保数据交流不会出现竞态条件。我们把响应插入到 res 数组中,就像这样:

function response(data) { 
    if (data.url == "http://some.url.1") { 
        res[0] = data; 
    } 
    else if (data.url == "http://some.url.2") { 
        res[1] = data; 
    } 
}

但是这种场景下如何使用多个并发生成器呢?

// request(..)是一个支持Promise of Ajax工具
var res = []; 
function *reqData(url) { 
    res.push( 
        yield request( url ) 
    ); 
}

形实转换程序

你用一个函数定义封装函数调用,包括需要的任何参数,来定义这个调用的执行,那么这个封装函数就是一个形实转换程序。之后在执行这个 thunk 时,最终就是调用了原始的函数。

function foo(x,y,cb) { 
    setTimeout( function(){ 
        cb( x + y ); 
    }, 1000 ); 
} 
function fooThunk(cb) { 
    foo( 3, 4, cb ); 
} 
// 将来
fooThunk( function(sum){ 
    console.log( sum ); // 7 
} );

ES6之前的生成器

function foo(url) { 
    // .. 
    // 构造并返回一个迭代器
    return { 
        next: function(v) { 
        // .. 
        }, 
        throw: function(e) { 
            // .. 
        } 
    }; 
}

var it = foo( "http://some.url.1" );

生成器小结

生成器为异步代码保持了顺序、同步、阻塞的代码模式,这使得大脑可以更自然地追踪代码,解决了基于回调的异步的两个关键缺陷之一。

原文地址

墨渊书肆/你不知道的JS(中):Promise与生成器

你不知道的JS(中):强制类型转换与异步基础

作者 牛奶
2026年2月15日 10:39

你不知道的JS(中):强制类型转换与异步基础

本文是《你不知道的JavaScript(中卷)》的阅读笔记,第二部分:强制类型转换与异步基础。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

强制类型转换

值类型转换

将值从一种类型转换为另一种类型通常称为类型转换(type casting),这是显式的情况;隐式的情况称为强制类型转换(coercion)。

var a = 42;
var b = a + ""; // 隐式强制类型转换
var c = String( a ); // 显式强制类型转换

抽象值操作

ToString 基本类型值的字符串化规则为:null转换为"null",undefined转换为"undefined",true转换为 "true"。数字的字符串化则遵循通用规则,不过那些极小和极大的数字使用指数形式:

// 1.07 连续乘以七个 1000
var a = 1.07 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000;
// 七个1000一共21位数字
a.toString(); // "1.07e21"

JSON字符串化 工具函数 JSON.stringify 在将 JSON 对象序列化为字符串时也用到了 ToString。但JSON.stringify(..) 在对象中遇到 undefined、function 和 symbol 时会自动将其忽略,在数组中则会返回 null(以保证单元位置不变)。

JSON.stringify(undefined); // undefined
JSON.stringify(function(){}); // undefined
JSON.stringify([1,undefined,function(){},4]); // "[1,null,null,4]"
JSON.stringify({ a:2, b:function(){} }); // "{"a":2}"

ToNumber 其中 true 转换为 1,false 转换为 0。undefined 转换为 NaN,null 转换为 0。

ToBoolean JS中的值可以分为俩类:

  1. 可以被强制类型转换为false的值
  2. 其他

以下是假值,假值的布尔强制类型转换结果为false:

  • undefined
  • null
  • false
  • +0、-0和NaN
  • ""

假值对象是真值

var a = new Boolean( false );
var b = new Number( 0 );
var c = new String( "" );

Boolean( a && b && c ); // true

真值:假值列表之外的就是真值

var a = "false";
var b = "0";
var c = "''";
Boolean( a && b && c ); // true

var a = []; // 空数组——是真值还是假值?
var b = {}; // 空对象——是真值还是假值?
var c = function(){}; // 空函数——是真值还是假值?
Boolean( a && b && c ); // true

显式强制类型转换

日期显式转换为数字

var d = new Date( "Mon, 18 Aug 2014 08:53:06 CDT" );
+d; // 1408369986000

奇特的~运算符 ~,它首先将值强制类型转换为 32 位数字,然后执行字位操作“非”(对每一个字位进行反转)。 ~ 返回 2 的补码

~42; // -(42+1) ==> -43

~ 的神奇之处在于进行检查字符串中是否有包含指定的字符串:

var a = "Hello World";
~a.indexOf( "lo" ); // -4 <-- 真值!
if (~a.indexOf( "lo" )) { // true
 // 找到匹配!
}
~a.indexOf( "ol" ); // 0 <-- 假值!
!~a.indexOf( "ol" ); // true
if (!~a.indexOf( "ol" )) { // true
 // 没有找到匹配!
}

显式解析数字字符串 解析字符串中的数字和将字符串强制类型转换为数字的返回结果都是数字。但解析和转换两者之间还是有明显的差别。

var a = "42";
var b = "42px";
Number( a ); // 42
parseInt( a ); // 42
Number( b ); // NaN
parseInt( b ); // 42

解析允许字符串中含有非数字字符,解析按从左到右的顺序,如果遇到非数字字符就停止。而转换不允许出现非数字字符,否则会失败bing返回 NaN。

解析非字符串

parseInt( 1/0, 19 ); // 18

很多人想当然地以为“如果第一个参数值为 Infinity,解析结果也应该是 Infinity”,返回 18 也太无厘头了。实际的 JavaScript 代码中不会用到基数 19,它的有效数字字符范围是 0-9 和 a-i(区分大小写)。parseInt(1/0, 19) 实际上是 parseInt("Infinity", 19)。第一个字符是 "I",以 19 为基数时值为 18。第二个字符 "n" 不是一个有效的数字字符,解析到此为止。 此外还有一些看起来奇怪但实际上解释得通的例子:

parseInt( 0.000008 ); // 0 ("0" 来自于 "0.000008")
parseInt( 0.0000008 ); // 8 ("8" 来自于 "8e-7")
parseInt( false, 16 ); // 250 ("fa" 来自于 "false")
parseInt( parseInt, 16 ); // 15 ("f" 来自于 "function..")
parseInt( "0x10" ); // 16
parseInt( "103", 2 ); // 2

显式转换为布尔值 显式强制类型转换为布尔值最常用的方法是!!。

隐式强制类型转换

字符串和数字之间的隐式强制类型转换 通过+运算符进行字符串拼接

var a = "42";
var b = "0";
var c = 42;
var d = 0;
a + b; // "420"
c + d; // 42

因为数组的valueOf() 操作无法得到简单基本类型值,于是它转而调用 toString()。因此下面例子中的两个数组变成了 "1,2" 和 "3,4"。+ 将它们拼接后返回 "1,23,4"。

var a = [1,2];
var b = [3,4];
a + b; // "1,23,4"

a + ""(隐式)和 String(a)(显式)之间有一个细微的差别需要注意。根据ToPrimitive 抽象操作规则,a + "" 会对 a 调用 valueOf() 方法,然后通过 ToString 抽象操作将返回值转换为字符串。而 String(a) 则是直接调用 ToString()。

var a = {
    valueOf: function() { return 42; },
    toString: function() { return 4; }
};
a + ""; // "42"
String( a ); // "4"

再来看看从字符串强制类型转换为数字的情况。- 是数字减法运算符,因此 a - 0 会将 a 强制类型转换为数字。

var a = "3.14";
var b = a - 0;
b; // 3.14

隐式强制类型转换为布尔值 相对布尔值,数字和字符串操作中的隐式强制类型转换还算比较明显。下面的情况会发生布尔值隐式强制类型转换。 (1) if (..) 语句中的条件判断表达式。 (2) for ( .. ; .. ; .. ) 语句中的条件判断表达式(第二个)。 (3) while (..) 和 do..while(..) 循环中的条件判断表达式。 (4) ? : 中的条件判断表达式。 (5) 逻辑运算符 ||(逻辑或)和 &&(逻辑与)左边的操作数(作为条件判断表达式)。

|| 和 && && 和 || 运算符的返回值并不一定是布尔类型,而是两个操作数其中一个的值。

var a = 42;
var b = "abc";
var c = null;

a || b; // 42 
a && b; // "abc"

c || b; // "abc" 
c && b; // null

|| 和 && 首先会对第一个操作数(a 和 c)执行条件判断,如果其不是布尔值(如上例)就先进行 ToBoolean 强制类型转换,然后再执行条件判断。 对于 || 来说,如果条件判断结果为 true 就返回第一个操作数(a 和 c)的值,如果为false 就返回第二个操作数(b)的值。 && 则相反,如果条件判断结果为 true 就返回第二个操作数(b)的值,如果为 false 就返回第一个操作数(a 和 c)的值。

符号的强制类型转换 ES6 中引入了符号类型,它的强制类型转换有一个坑,在这里有必要提一下。ES6 允许从符号到字符串的显式强制类型转换,然而隐式强制类型转换会产生错误:

var s1 = Symbol( "cool" );
String( s1 ); // "Symbol(cool)"
var s2 = Symbol( "not cool" );
s2 + ""; // TypeError

宽松相等和严格相等

常见的误区是“== 检查值是否相等,=== 检查值和类型是否相等”。听起来蛮有道理,然而 还不够准确。很多 JavaScript 的书籍和博客也是这样来解释的,但是很遗憾他们都错了。

正确的解释是:“== 允许在相等比较中进行强制类型转换,而 === 不允许。”

抽象相等 == 在比较两个不同类型的值时会发生隐式强制类型转换,会将其中之一或两者都转换为相同的类型后再进行比较。

  • 字符串和数字之间的相等比较: (1) 如果 Type(x) 是数字,Type(y) 是字符串,则返回 x == ToNumber(y) 的结果。 (2) 如果 Type(x) 是字符串,Type(y) 是数字,则返回 ToNumber(x) == y 的结果
var a = 42;
var b = "42";
a === b; // false
a == b; // true
  • 其他类型和布尔类型之间的相等比较: (1) 如果 Type(x) 是布尔类型,则返回 ToNumber(x) == y 的结果; (2) 如果 Type(y) 是布尔类型,则返回 x == ToNumber(y) 的结果。
var a = "42";
var b = true;
a == b; // false
  • null 和 undefined 之间的相等比较 (1) 如果 x 为 null,y 为 undefined,则结果为 true。 (2) 如果 x 为 undefined,y 为 null,则结果为 true。
var a = null;
var b;
a == b; // true
a == null; // true
b == null; // true

a == false; // false
b == false; // false
a == ""; // false
b == ""; // false
a == 0; // false
b == 0; // false
  • 对象 and 非对象之间的相等比较 (1) 如果 Type(x) 是字符串或数字,Type(y) 是对象,则返回 x == ToPrimitive(y) 的结果; (2) 如果 Type(x) 是对象,Type(y) 是字符串或数字,则返回 ToPromitive(x) == y 的结果。
var a = 42;
var b = [ 42 ];
a == b; // true

比较少见的情况

  1. 返回其他数字:
Number.prototype.valueOf = function() {
 return 3;
};
new Number( 2 ) == 3; // true
  1. 假值的相等比较:
"0" == null; // false
"0" == undefined; // false
"0" == false; // true
"0" == NaN; // false
"0" == 0; // true
"0" == ""; // false

false == null; // false
false == undefined; // false
false == NaN; // false
false == 0; // true
false == ""; // true
false == []; // true
false == {}; // false

"" == null; // false
"" == undefined; // false
"" == NaN; // false
"" == 0; // true
"" == []; // true
"" == {}; // false

0 == null; // false
0 == undefined; // false
0 == NaN; // false
0 == []; // true
0 == {}; // false
  1. 极端情况

根据 ToBoolean 规则,它会进行布尔值的显式强制类型转换。所以 [] == ![] 变成了 [] == false。前面介绍 of false == [],最后的结果就顺理成章了

[] == ![] // true

安全运用隐式强制类型转换

  • 如果两边的值中有 true 或者 false,千万不要使用 ==。
  • 如果两边的值中有 []、"" 或者 0,尽量不要使用 ==。 这时最好用 === 来避免不经意的强制类型转换。这两个原则可以让我们避开几乎所有强制类型转换的坑

抽象关系比较

a < b 中涉及的隐式强制类型转换: 比较双方首先调用 ToPrimitive,如果结果出现非字符串,就根据 ToNumber 规则将双方强制类型转换为数字来进行比较。

var a = [ 42 ];
var b = [ "43" ];
a < b; // true
b < a; // false

如果比较双方都是字符串,则按字母顺序来进行比较:

var a = [ "42" ];
var b = [ "043" ];
a < b; // false

var a = [ 4, 2 ];
var b = [ 0, 4, 3 ];
a < b; // false

还有个特殊情况:

var a = { b: 42 };
var b = { b: 43 };
a < b; // false
a == b; // false
a > b; // false
a <= b; // true
a >= b; // true

因为 a 是 [object Object],b 也是 [object Object],所以按照字母顺序a < b 并不成立。

为什么 a == b 的结果不是 true ?它们的字符串值相同(同为 "[object Object]"),按道理应该相等才对?实际上不是这样,你可以回忆一下前面讲过的对象的相等比较。

但是 if a < b 和 a == b 结果为 false,为什么 a <= b 和 a >= b 的结果会是 true 呢?因为根据规范 a <= b 被处理为 b < a,然后将结果反转。因为 b < a 的结果是 false,所以 a <= b 的结果是 true。

这可能与我们设想的大相径庭,即 <= 应该是“小于或者等于”。实际上 JavaScript 中 <= 是“不大于”的意思(即 !(a > b),处理为 !(b < a))。同理 a >= b 处理为 b <= a。

强制类型转换小结

JS 的数据类型之间的转换,即强制类型转换:包括显式和隐式。

显式强制类型转换明确告诉我们哪里发生了类型转换,有助于提高代码可读性和可维护性。

隐式强制类型转换则没有那么明显,是其他操作的副作用。实际上隐式强制类型转换也有助于提高代码的可读性。在处理强制类型转换的时候要十分小心,尤其是隐式强制类型转换。

语法

语句和表达式

JS中语句相当于句子,表达式相当于短语,运算符则相当于标点符号和连接词。

语句的结果值 代码块的结果值就如同一个隐式的返回,即返回最后一个语句的结果值。

var b;
if (true) {
    b = 4 + 38;
}

表达式的副作用 函数调用的副作用:

function foo() {
 a = a + 1;
}
var a = 1;
foo(); // 结果值:undefined。副作用:a的值被改变

= 赋值运算符:

var a;
a = 42; // 42
a; // 42

运算符优先级

&& 先执行,然后是 ||:

(false && true) || true; // true
false && (true || true); // false

false && true || true; // true

那执行顺序是否就一定是从左到右呢?不妨将运算符颠倒一下看看:

true || false && false; // true
(true || false) && false; // false
true || (false && false); // true

这说明 && 运算符先于 || 执行,而且执行顺序并非我们所设想的从左到右。原因就在于运算符优先级。

短路 对于 && 和 || 来说,如果从左边的操作数能够得出结果,就可以忽略右边的操作数。我们将这种现象称为“短路”(即执行最短路径)。

更强的绑定 因为 && 运算符的优先级高于 ||,而 || 的优先级又高于 ? :。

a && b || c ? c || b ? a : c && b : a
// 等同于
(a && b || c) ? (c || b) ? a : (c && b) : a

关联 一般多个&&和||执行顺序是从左到右,也被称为左关联,但? : 是右关联

a ? b : c ? d : e;
// 等同于
a ? b : (c ? d : e)

另一个右关联组合的例子是 = 运算符:

var a, b, c;
a = b = c = 42;
// 等同于
a = (b = (c = 42))

自动分号

JS会自动为代码行补上缺失的分号,即自动分号插入(Automatic Semicolon Insertion,ASI)。

错误

JS不仅有各种类型的运行时错误(TypeError、ReferenceError、SyntaxError 等),它的语法中也定义了一些编译时错误。

提前使用变量 ES6 规范定义了一个新概念,叫作 TDZ(Temporal Dead Zone,暂时性死区)。TDZ 指的是由于代码中的变量还没有初始化而不能被引用的情况。

{
    a = 2; // ReferenceError!
    let a; 
}

函数参数

在 ES6 中,如果参数被省略或者值为 undefined,则取该参数的默认值:

function foo( a = 42, b = a + 1 ) {
    console.log( a, b );
}
foo(); // 42 43
foo( undefined ); // 42 43
foo( 5 ); // 5 6
foo( void 0, 7 ); // 42 7
foo( null ); // null 1

try finally

finally 中的代码总是会在 try 之后执行,如果有 catch 的话则在 catch 之后执行。也可以将 finally 中的代码看作一个回调函数,即无论出现什么情况最后一定会被调用。

function foo() {
    try {
        return 42;
    } 
    finally {
        console.log( "Hello" );
    }
    console.log( "never runs" );
}
console.log( foo() );
// Hello
// 42

这里 return 42 先执行,并将 foo() 函数的返回值设置为 42。然后 try 执行完毕,接着执行 finally。最后 foo() 函数执行完毕,console.log(..) 显示返回值。 try 中的 throw 也是如此:

function foo() {
    try {
        throw 42; 
    }
    finally {
        console.log( "Hello" );
    }
    console.log( "never runs" );
}
console.log( foo() );
// Hello
// Uncaught Exception: 42

switch

switch,可以把它看作 if..else if..else.. 的简化版本:

switch (a) {
    case 2:
    // 执行一些代码
    break;
    case 42:
    // 执行另外一些代码
    break;
    default:
    // 执行缺省代码
}

a 和 case 表达式的匹配算法与 === 相同。通常case语句中switch都是简单值,但有时可能会需要通过强制类型转换来进行相等比较,这时就需要做一些特殊处理:

var a = "42";
switch (true) {
    case a == 10:
        console.log( "10 or '10'" );
        break;
    case a == 42;
        console.log( "42 or '42'" );
        break;
    default:
        // 永远执行不到这里
}
// 42 or '42'

尽管可以使用 ==,但 switch 中 true and true 之间仍然是严格相等比较。即 if case 表达式的结果为真值,但不是严格意义上的 true,则条件不成立。

var a = "hello world";
var b = 10;
switch (true) {
    case (a || b == 10):
        // 永远执行不到这里
        break;
    default:
        console.log( "Oops" );
}
// Oops

最后,default 是可选的,并非必不可少。break 相关规则对 default 仍然适用:

var a = 10;
switch (a) {
    case 1:
    case 2:
        // 永远执行不到这里
    default:
        console.log( "default" );
    case 3:
        console.log( "3" );
        break;
    case 4:
        console.log( "4" );
}
// default
// 3

上例中的代码是这样执行的,首先遍历并找到所有匹配的 case,如果没有匹配则执行default 中的代码。因为其中没有 break,所以继续执行已经遍历过的 case 3 代码块,直到 break 为止。

语法小结

JS的语法规则之上是语义规则,也称上下文。 JS还详细定义了运算符的优先级和关联。

异步:现在与将来

程序中现在运行的部分和将来运行的部分之间的关系就是异步编程的核心。

分块的程序

可以把 JavaScript 程序写在单个 .js 文件中,但是这个程序几乎一定是由多个块构成的。这些块中只有一个是现在执行,其余的则会在将来执行。最常见的块单位是函数。 大多数 JS 新手程序员都会遇到的问题是:程序中将来执行的部分并不一定在现在运行的部分执行完之后就立即执行。 从现在到将来的“等待”,最简单的方法是使用一个通常称为回调函数的函数:

// ajax(..)是某个库中提供的某个Ajax函数
ajax( "http://some.url.1", function myCallbackFunction(data){
    console.log( data ); // 耶!这里得到了一些数据!
});

异步控制台 在某些条件下,某些浏览器的 console.log 并不会把传入的内容立即输出。出现这种情况的主要原因是,在许多程序(不只是 JS)中,I/O 是非常低速的阻塞部分。所以浏览器在后台异步处理控制台 I/O 能够提高性能,这时用户甚至可能根本意识不到其发生。

事件循环

所有这些环境都有一个共同“点”(thread,也指线程。),即它们都提供了一种机制来处理程序中多个块的执行,且执行每块时调用 JS 引擎,这种机制被称为事件循环。 先通过一段伪代码了解一下这个概念 :

// eventLoop是一个用作队列的数组
// (先进,先出)
var eventLoop = [ ];
var event;
// “永远”执行
while (true) {
    // 一次tick
    if (eventLoop.length > 0) {
        // 拿到队列中的下一个事件
        event = eventLoop.shift();
        // 现在,执行下一个事件
        try {
            event();
        } catch (err) {
            reportError(err);
        }
    }
}

可以看到,有一个用 while 循环实现的持续运行的循环,循环的每一轮称为一个 tick。对每个 tick 而言,如果在队列中有等待事件,那么就会从队列中摘下一个事件并执行。这些事件就是你的回调函数。

并行线程

术语“异步”和“并行”常常被混为一谈,但实际上它们的意义完全不同。记住,异步是关于现在和将来的时间间隙,而并行是关于能够同时发生的事情。 并行计算最常见的工具就是进程和线程. 进程和线程独立运行,并可能同时运行:在不同的处理器,甚至不同的计算机上,但多个线程能够共享单个进程的内存。

并发

两个或多个“进程”同时执行就出现了并发,不管组成它们的单个运算是否并行执行(在独立的处理器或处理器核心上同时运行)。可以把并发看作“进程”级(或者任务级)的并行,与运算级的并行(不同处理器上的线程)相对。

非交互 如果进程间没有相互影响的话,不确定性是完全可以接受的。

交互 并发的“进程”需要相互交流,通过作用域或 DOM 间接交互。正如前面介绍的,如果出现这样的交互,就需要对它们的交互进行协调以避免竞态的出现。

协作 还有一种并发合作方式,称为并发协作(cooperative concurrency)。这里的重点不再是通过共享作用域中的值进行交互。这里的目标是取到一个长期运行的“进程”,并将其分割成多个步骤或多批任务,使得其他并发“进程”有机会将自己的运算插入到事件循环队列中交替运行。

任务

在 ES6 中,有一个新的概念建立在事件循环队列之上,叫作任务队列(job queue)。对于任务队列最好的理解方式就是,它是挂在事件循环队列的每个 tick 之后的一个队列。在事件循环的每个 tick 中,可能出现的异步动作不会导致一个完整的新事件添加到事件循环队列中,而会在当前 tick 的任务队列末尾添加一个项目(一个任务)。

语句顺序

代码中语句的顺序和js引擎执行语句的顺序并不一定要一致。

异步小结

JS 程序总是至少分为两个块:第一块现在运行;下一块将来运行,以响应某个事件。尽管程序是一块一块执行的,但是所有这些块共享对程序作用域和状态的访问,所以对状态的修改都是在之前累积的修改之上进行的。 一旦有事件需要运行,事件循环就会运行,直到队列清空。事件循环的每一轮称为一个tick。用户交互、IO 和定时器会向事件队列中加入事件。

回调

到目前为止,回调是编写和处理 JavaScript 程序异步逻辑的最常用方式。确实,回调是这门语言中最基础的异步模式。

延续(continuation)

回调函数包裹或者说封装了程序的延续(continuation)。

// A 
setTimeout( function(){ 
    // C 
}, 1000 ); 
// B

执行 A,设定延时 1000 毫秒,然后执行 B,然后定时到时后执行C

顺序的大脑

执行与计划 我们的大脑可以看作类似于单线程运行的事件循环队列,就像 JavaScript 引擎那样。这个比喻看起来很贴切。但是,我们的分析还需要比这更加深入细致一些。显而易见的是,在我们如何计划各种任务和我们的大脑如何实际执行这些计划之间,还存在着很大的差别。

嵌套回调和链式回调

listen( "click", function handler(evt){ 
    setTimeout( function request(){ 
        ajax( "http://some.url.1", function response(text){ 
            if (text == "hello") { 
                handler(); 
            } 
            else if (text == "world") { 
                request(); 
            } 
        } ); 
    }, 500) ; 
} );

这种代码常常被称为回调地狱(callback hell),有时也被称为毁灭金字塔。 让我们不用嵌套再把前面的嵌套事件 / 超时 /Ajax 的例子重写一遍吧:

listen( "click", handler ); 
function handler() { 
    setTimeout( request, 500 ); 
} 
function request(){ 
    ajax( "http://some.url.1", response ); 
} 
function response(text){ 
    if (text == "hello") { 
        handler(); 
    } 
    else if (text == "world") { 
        request(); 
    } 
}

信任问题

// A 
ajax( "..", function(..){ 
    // C 
} ); 
// B

在 JS 主程序的直接控制之下。而 // C 会延迟到将来发生,并且是在第三方的控制下——在本例中就是函数 ajax。从根本上来说,这种控制的转移通常不会给程序带来很多问题。 但是,请不要被这个小概率迷惑而认为这种控制切换不是什么大问题。实际上,这是回调驱动设计最严重(也是最微妙)的问题。它以这样一个思路为中心:有时候 ajax 不是你编写的代码,也不在你的直接控制下。多数情况下,它是某个第三方提供的工具。 我们把这称为控制反转,也就是把自己程序一部分的执行控制交给某个第三方。在你的代码和第三方工具之间有一份并没有明确表达的契约。

省点回调

为了更优雅地处理错误,有些 API 设计提供了分离回调(一个用于成功通知,一个用于出错通知):

function success(data) { 
    console.log( data ); 
} 
function failure(err) { 
    console.error( err ); 
} 
ajax( "http://some.url.1", success, failure );

在这种设计下,API 的出错处理函数 failure() 常常是可选的,如果没有提供的话,就是假定这个错误可以吞掉。

回调小结

回调函数是 JavaScript 异步的基本单元。但是随着 JavaScript 越来越成熟,对于异步编程领域的发展,回调已经不够用了。

原文地址

墨渊书肆/你不知道的JS(中):强制类型转换与异步基础

你不知道的JS(中):类型与值

作者 牛奶
2026年2月15日 10:38

你不知道的JS(中):类型与值

本文是《你不知道的JavaScript(中卷)》的阅读笔记,第一部分:类型与值。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

类型

对语言引擎和开发人员来说,类型是值的内部特征,它定义了值的行为,以使其区别于其他值。

内置类型

JavaScript 有七种内置类型:

  • 空值(null)
  • 未定义(undefined)
  • 布尔值( boolean)
  • 数字(number)
  • 字符串(string)
  • 对象(object)
  • 符号(symbol,ES6 中新增)

对于类型, we 一般使用typeof来判断,但有一些特殊情况无法准确判断,如下:

// null的类型不是null
typeof null === "object"; // true
(!a && typeof a === "object"); // null需要复合条件来判断

// function的类型不是object
typeof function a(){} === "function"; // true

// 数组也是object
typeof [1,2,3] === "object"; // true

值和类型

JS中的变量是没有类型的,只有值才有。JS不做“类型强制”;

undefined 和 undeclared 已在作用域中声明但还没有赋值的变量,是 undefined 的。相反,还没有在作用域中声明过的变量,是 undeclared 的。

内置类型小结

JS中有其中内置类型:null、undefined、boolean、number、string、object和symbol,可以使用typeof运算符来查看。但对于null、function和数组要特殊处理。

变量没有类型,但它们持有的值 have 类型。类型定义了值的行为特征。 在 JS 中它们是两码事,undefined 是值的一种,undeclared 则表示变量还没有被声明过。

数组(array)、字符串(string)和数字(number)是一个程序最基本的组成部分。

数组

与其他强类型语言不同,在JS中数组可以容纳任何类型的值,可以是字符串、数字、对象,甚至是其他数组(多维数组就是这么实现的):

var a = [1, '2', [3]];

a.length; // 3
a[0] === 1; // true
a[2][0] === 3; // true

数组声明可以不预先设定大小,但使用delete删除时要注意length不会被改变。还有在创建稀疏数组时,长度会变化,没有设置的位置的值为undefined。

var a = [];
a[0] = 1;
a[2] = 3;

a[1]; // undefined
a.length; // 3

同时数组也是对象,可以使用字符串的key去获取属性

var a = [0, 1, 2];
a['2']; // 2

但也需要注意如果把字符串的数字作为索引赋值处理,会被强制转换为十进制的数字,且长度也会改变:

var a = [];
a['13'] = 22;
a.length; // 14

类数组 有时需要将类数组转换为真正的数组,一般通过数组工具函数(如indexOf、concat、forEach等)来实现; 还有函数的参数arguments也可以进行数组转化:

function foo() {
 var arr = Array.prototype.slice.call( arguments );
 arr.push( "bam" );
 console.log( arr );
}
foo( "bar", "baz" ); // ["bar","baz","bam"]

ES6也可以使用Array.from去处理:

var arr = Array.from( arguments );

字符串

字符串和数组的确很相似,它们都是类数组,都有length属性以及indexOf和concat方法。 但字符串是不可变的,数组是可变的。

数字

JS只有一种数值类型:number,包括“整数”和带小数的十进制数。JS没有真正意义上的整数。JS中的数字类型是基于IEEE754标准来实现的,该标准通常也被称为“浮点数”。JS使用的是“双精度”格式。 特别大或者特别小的数字默认使用指数格式显示:

var a = 5E10;
a; // 50000000000
a.toExponential(); // "5e+10"
var b = a * a;
b; // 2.5e+21
var c = 1 / a;
c; // 2e-11

较小的数值 二进制浮点数最大的问题就是较小的数值运算不精确:

0.1+0.2 === 0.3; // false
// 因为相加等于0.30000000000000004

如何来判断相等呢?最常见的方法是设置一个误差范围,通常为称为“机器精度”。JS是2^-52 (2.220446049250313e-16)。 在ES6中使用Number.EPSILON,ES6之前使用polyfill:

if (!Number.EPSILON) {
 Number.EPSILON = Math.pow(2,-52);
}

使用 Number.EPSILON 来比较两个数字是否相等

function numbersCloseEnoughToEqual(n1,n2) {
 return Math.abs( n1 - n2 ) < Number.EPSILON;
}
var a = 0.1 + 0.2;
var b = 0.3;
numbersCloseEnoughToEqual( a, b ); // true
numbersCloseEnoughToEqual( 0.0000001, 0.0000002 ); // false

整数的安全范围:

数字的呈现方式决定了“整数”的安全值范围远远小于 Number.MAX_VALUE。 能够被“安全”呈现的最大整数是 2^53 - 1,即 9007199254740991,在 ES6 中 被定义为Number.MAX_SAFE_INTEGER。最小整数是 -9007199254740991,在 ES6 中被定义为Number.MIN_SAFE_INTEGER。

特殊数值

不是值的值:

  • null指空值
  • undefined指没有值

undefined 在非严格模式可以给undefined赋值:

function foo() {
    undefined = 2; // very bad
}

在非严格和严格模式可以把undefined命名变量:

function foo() {
    "use strict";
    var undefined = 2; // very bad
    console.log(undefined); // 2
}
foo();

void运算符 表达式void xxx没有返回值,因此返回的结果是undefined。

特殊的数字 NaN:不是一个数字。

var a = 2 / 'foo'; // NaN
typeof a === 'number'; // true

NaN 是一个“警戒值”,用于指出数字类型中的错误情况,即“执行数学运算没有成功,这是失败后返回的结果”。 NaN ≠ NaN为true,它和自己不相等,是唯一一个非自反。

var a = 2 / "foo";
a == NaN; // false
a === NaN; // false

如果要判断是否是NaN,需要使用全局工具函数isNaN来判断:

var a = 2 / "foo";
isNaN(a); // true

但isNaN有个缺陷,就是检查参数是否不是NaN,也不是数字:

isNaN('foo'); // true

很明显‘foo’不是数字也不是NaN,这是一个很久的bug。 ES6中我们可以使用Number.isNaN,ES6之前可以使用polyfill:

// 方法1:
if (!Number.isNaN) {
    Number.isNaN = function (n) {
        return typeof n === 'number' && window.isNaN(n)
    }
}

// 方法2:
if (!Number.isNaN) {
    Number.isNaN = function (n) {
        return n !== n
    }
}

无穷数 正无穷: Infinity 负无穷:-Infinity

零值 JS有0 and -0,-0也是有意义的,对负数的乘法和除法可以出现-0,加减法不行;-0的判断:

function isNegZero(n) {
    n = Number(n);
    return (n === 0) && (1/n === -Infinity)
}

isNegZero( -0 ); // true
isNegZero( 0 / -3 ); // true
isNegZero( 0 ); // false

特殊等式 ES6中新加入一个工具方法Object.is来判断俩个值是否绝对相等。

Object.is(2 / 'foo', NaN); // true
Object.is(-3*0, -0); // true
Object.is(-3*0, 00); // false

polyfill:

if (!Object.is) {
    Object.is = function(v1, v2) {
        // 判断是否是-0
        if (v1 === 0 && v2 === 0) {
            return 1 / v1 === 1 / v2;
        }
        // 判断是否是NaN
        if (v1 !== v1) {
            return v2 !== v2;
        }
        // 其他情况
        return v1 === v2;
    };
}

值和引用

JS引用指向的是值,根据值得类型来决定。基本类型是通过值复制的方式来赋值/传递,包括null、undefined、字符串、数字、布尔和ES6中的symbol。复合值(对象:数组和封装对象、函数)则是通过引用复制的方式来赋值/传递。

由于引用指向的是值本身而非变量,所以一个引用无法更改另一个引用的指向.

var a = [1,2,3];
var b = a;
a; // [1,2,3]
b; // [1,2,3]
// 然后
b = [4,5,6];
a; // [1,2,3]
b; // [4,5,6]

函数参数就经常让人产生这样的困惑:

function foo(x) {
    x.push( 4 );
    x; // [1,2,3,4]
    // 然后
    x = [4,5,6];
    x.push( 7 );
    x; // [4,5,6,7]
}
var a = [1,2,3];
foo( a );
a; // 是[1,2,3,4],不是[4,5,6,7]

我们向函数传递 a 的时候,实际是将引用 a 的一个复本赋值给 x,而 a 仍然指向 [1,2,3]。在函数中我们可以通过引用 x 来更改数组的值(push(4) 之后变为 [1,2,3,4])。但 x = [4,5,6] 并不影响 a 的指向,所以 a 仍然指向 [1,2,3,4]。 我们不能通过引用 x 来更改引用 a 的指向,只能更改 a 和 x 共同指向的值。 如果要将 a 的值变为 [4,5,6,7],必须更改 x 指向的数组,而不是为 x 赋值一个新的数组。

function foo(x) {
    x.push( 4 );
    x; // [1,2,3,4]
    // 然后
    x.length = 0; // 清空数组
    x.push( 4, 5, 6, 7 );
    x; // [4,5,6,7]
}
var a = [1,2,3];
foo( a );
a; // 是[4,5,6,7],不是[1,2,3,4]

如果通过值复制的方式来传递复合值(如数组),就需要为其创建一个复本,这样传递的就不再是原始值。例如:

foo( a.slice() )

值小结

JavaScript 中的数字包括“整数”和“浮点型”。 null 类型只有一个值 null,undefined 类型也只有一个值 undefined。所有变量在赋值之前默认值都是 undefined。void 运算符返回 undefined。 数字类型有几个特殊值,包括NaN(意指“not a number”,更确切地说是“invalid number”)、+Infinity、-Infinity 和 -0。

原生函数

JS的内建函数,也叫原生函数。常用的原生函数有:

  • String()
  • Number()
  • Boolean()
  • Array()
  • Object()
  • Function()
  • RegExp()
  • Date()
  • Error()
  • Symbol()——ES6 中新加入的!

原生函数可以被当作构造函数来使用,但其构造出来的对象可能会和我们设想的有所出入:

var a = new String( "abc" );
typeof a; // 是"object",不是"String"
a instanceof String; // true
Object.prototype.toString.call( a ); // "[object String]"

内部属性[[Class]]

所有 typeof 返回值为 "object" 的对象(如数组)都包含一个内部属性 [[Class]]。这个属性无法直接访问,一般通过 Object.prototype.toString(..) 来查看。例如:

Object.prototype.toString.call( [1,2,3] );
// "[object Array]"
Object.prototype.toString.call( /regex-literal/i );
// "[object RegExp]"

封装对象包装

使用封装对象时有些地方需要特别注意。比如 Boolean:

var a = new Boolean( false );
if (!a) {
    console.log( "Oops" ); // 执行不到这里
}

拆分

如果想要得到封装对象中的基本类型值,可以使用 valueOf() 函数:

var a = new String( "abc" );
var b = new Number( 42 );
var c = new Boolean( true );
a.valueOf(); // "abc"
b.valueOf(); // 42
c.valueOf(); // true

原生函数作为构造函

Array

var a = new Array( 1, 2, 3 );
a; // [1, 2, 3]
var b = [1, 2, 3];
b; // [1, 2, 3]

Array 构造函数只带一个数字参数的时候,该参数会被作为数组的预设长度(length),而非只充当数组中的一个元素。这实非明智之举:一是容易忘记,二是容易出错。

var a = new Array( 3 );
a.length; // 3
a;

Object/Function/RegExp 万不得已,不要使用这些构造函数。在实际情况中没有必要使用 new Object() 来创建对象,因为这样就无法像常量形式那样一次设定多个属性,而必须逐一设定。

Date和Error 相较于其他原生构造函数,Date(..) 和 Error(..) 的用处要大很多,因为没有对应的常量形式来作为它们的替代。 创建日期对象必须使用 new Date()。Date可以带参数,用来指定日期和时间,而不带参数的话则使用当前的日期和时间。Date主要用来获得当前的 Unix 时间戳(从 1970 年 1 月 1 日开始计算,以秒为单位)。该值可以通过日期对象中的 getTime() 来获得。 从 ES5 开始引入了一个更简单的方法,即Date.now()。对 ES5 之前我们可以使用polyfill:

if (!Date.now) {
     Date.now = function(){
         return (new Date()).getTime();
     };
}

构造函数 Error带不带 new 关键字都可。错误对象通常与 throw 一起使用:

function foo(x) {
    if (!x) {
        throw new Error( "x wasn’t provided" );
    }
    // ... 
}

Symbol ES6 中新加入了一个基本数据类型 ——符号(Symbol)。符号是具有唯一性的特殊值(并非绝对),用它来命名对象属性不容易导致重名。该类型的引入主要源于 ES6 的一些特殊构造,此外符号也可以自行定义。 ES6中有一些预定义符号,以Symbol的静态属性形式出现,如 Symbol.create、Symbol.iterator 等,可以这样来使用:

obj[Symbol.iterator] = function(){ /*..*/ };

我们可以使用Symbol原生构造函数来自定义符号。但它比较特殊,不能new关键字,否则会出错:

var mysym = Symbol( "my own symbol" );
mysym; // Symbol(my own symbol)
mysym.toString(); // "Symbol(my own symbol)"
typeof mysym; // "symbol"
var a = { };
a[mysym] = "foobar";
Object.getOwnPropertySymbols( a );
// [ Symbol(my own symbol) ]

原生原型

原生构造函数有自己的 .prototype 对象,如 Array.prototype、String.prototype 等。这些对象包含其对应子类型所特有的行为特征。

  • String#indexOf 在字符串中找到指定子字符串的位置。
  • String#charAt 获得字符串指定位置上的字符。
  • String#substr、String#substring 和 String#slice 获得字符串的指定部分。
  • String#toUpperCase 和 String#toLowerCase 将字符串转换为大写 or 小写。
  • String#trim 去掉字符串前后的空格,返回新的字符串。以上方法并不改变原字符串的值,而是返回一个新字符串。

原生函数小结

JavaScript 为基本数据类型值提供了封装对象,称为原生函数(如 String、Number、Boolean等)。它们为基本数据类型值提供了该子类型所特有的方法和属性。

原文地址

墨渊书肆/你不知道的JS(中):类型与值

你不知道的 JS(上):原型与行为委托

作者 牛奶
2026年2月12日 20:51

你不知道的 JS(上):原型与行为委托

本文是《你不知道的JavaScript(上卷)》的阅读笔记,第三部分:原型与行为委托。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

原型

[[Prototype]]

JS 中的对象有一个特殊的 [[Prototype]] 内置属性,它是对其他对象的引用。几乎所有的对象在创建时都会被赋予一个非空的原型值。

当你试图引用对象的属性时会触发 [[Get]] 操作:

  1. 首先检查对象自身是否有该属性。
  2. 如果没有,则顺着 [[Prototype]] 链向上查找。
  3. 这个过程会持续到找到匹配的属性名或到达原型链顶端(Object.prototype)。如果还没找到,则返回 undefined
Object.prototype

所有普通的 [[Prototype]] 链最终都会指向内置的 Object.prototype

属性设置和屏蔽

当执行 myObject.foo = "bar" 时:

  1. 如果 foo 已存在于 myObject 中,直接修改它的值。
  2. 如果 foo 不在 myObject 中而在原型链上层:
    • 若原型链上的 foo 不是只读(writable:true),则在 myObject 上创建屏蔽属性 foo
    • 若为只读(writable:false),则无法设置。
    • 若是一个 setter,则调用该 setter。
  3. 如果 foo 既不在 myObject 也不在原型链上,直接添加到 myObject

“类”

JS 和面向类的语言不同,它并没有类作为蓝图,JS 中只有对象。

“类函数”与原型继承

JS 通过函数的 prototype 属性来模仿类。当你调用 new Foo() 时,创建的新对象会关联到 Foo.prototype

注意:在 JS 中,我们并不是将“类”复制到“实例”,而是将它们关联起来。

“构造函数”

Foo.prototype 默认有一个 .constructor 属性指向 Foo。 通过 new 调用的函数并不是真正的“构造函数”,new 只是劫持了普通函数,并以构造对象的形式来调用它。

(原型)继承

常见的“继承”写法:

function Foo(name) {
    this.name = name;
}
function Bar(name, label) {
    Foo.call( this, name );
    this.label = label;
}

// 创建一个新的 Bar.prototype 对象并关联到 Foo.prototype
Bar.prototype = Object.create( Foo.prototype );

Bar.prototype.myLabel = function() {
    return this.label;
};

Object.create(..) 会凭空创建一个新对象并将其 [[Prototype]] 关联到指定的对象。

检查“类”的关系:

  • a instanceof Foo:检查 Foo.prototype 是否出现在 a 的原型链上。
  • Foo.prototype.isPrototypeOf(a):更直观的检查方式。
  • Object.getPrototypeOf(a):获取对象的原型。

对象关联

原型链的本质就是对象之间的关联。Object.create(..) 是创建这种关联的直接方式,它避免了 new 构造函数调用带来的复杂性(如 .prototype.constructor 引用)。

关联关系是备用

比起直接在原型链上查找(直接委托),内部委托往往能让 API 设计更清晰:

var anotherObject = {
    cool: function() { console.log( "cool!" ); }
};
var myObject = Object.create( anotherObject );
myObject.doCool = function() {
    this.cool(); // 内部委托!
};

原型机制小结

JS 的 [[Prototype]] 机制本质上是行为委托。对象之间不是复制关系,而是关联关系。

行为委托

面向委托的设计

类理论 vs. 委托理论
  • 类理论:鼓励继承、重写和多态。将行为抽象到父类,子类实例化时进行复制。
  • 委托理论:认为对象之间是兄弟关系。定义基础对象,其他对象通过 Object.create(..) 关联并委托行为。
委托模式的特点
  1. 更具描述性的方法名:避免使用通用的方法名,提倡使用能体现具体行为的名字。
  2. 状态存储在委托者上:数据通常存储在具体对象上,行为委托给基础对象。

类与对象关联的比较

对象关联风格的代码通常更简洁,因为它省去了模拟类所需要的复杂包装(构造函数、prototype 等)。

更好的语法 (ES6)

ES6 的简洁方法语法让对象关联看起来更舒服:

var AuthController = {
    errors: [],
    checkAuth() { /* .. */ }
};
Object.setPrototypeOf(AuthController, LoginController);

内省 (Introspection)

在对象关联模式下,检查对象关系变得非常简单:

Foo.isPrototypeOf( Bar ); // true
Object.getPrototypeOf( Bar ) === Foo; // true

行为委托小结

行为委托是一种比类更强大的设计模式。它更符合 JS 的原型本质,能让代码结构更清晰、语法更简洁。

ES6 中的 Class

class 语法

ES6 引入了 class 关键字,它解决了:

  1. 不再需要显式引用杂乱的 .prototype
  2. extends 简化了继承。
  3. super 支持相对多态。

class 陷阱

尽管 class 语法更好看,但它仍然是基于原型机制的,存在一些隐患:

  1. 非静态复制:修改父类方法会实时影响所有子类 and 实例。
  2. 成员属性限制:无法在类体中直接定义数据属性(只能定义方法),通常仍需操作原型。
  3. 意外屏蔽:属性名可能屏蔽同名方法。
  4. super 绑定super 是在声明时静态绑定的,而非动态绑定。

结论:静态大于动态吗?

ES6 的 class 试图伪装成一种静态的类声明,但这与 JS 动态的原型本质相冲突。它隐藏了许多底层机制,有时反而会让问题变得更难理解。

ES6 Class 小结

class 很好地伪装了类和继承模式,但它实际上只是原型委托的一层语法糖。使用时应警惕它带来的新问题。

你不知道的JS(上):this指向与对象基础

作者 牛奶
2026年2月12日 20:48

你不知道的JS(上):this指向与对象基础

本文是《你不知道的JavaScript(上卷)》的阅读笔记,第二部分:this 指向与对象基础。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

动态作用域

JS 并不具有动态作用域,它只有词法作用域,但 this 机制在某种程度上很像动态作用域。

主要区别:

  • 词法作用域:在写代码或者说定义时确定的(静态),关注函数在何处声明
  • 动态作用域:在运行时确定的,关注函数从何处调用this 也是在运行时绑定的,这一点与动态作用域类似。

this 词法

var obj = {
    id: "awesome",
    cool: function coolFn() {
        console.log( this.id );
    }
};
var id = "not awesome";
obj.cool(); // awesome
setTimeout( obj.cool, 100 ); // not awesome

关于 this

this 关键字是 JS 中最复杂的机制之一,它被自动定义在所有函数的作用域中。

对 this 的误解

1. 为什么要用 this?

this 提供了一种更优雅的方式来隐式“传递”一个对象引用,因此可以将 API 设计得更加简洁并且易于复用。随着使用模式越来越复杂,显式传递上下文对象会让代码变得混乱,而使用 this 则能保持代码整洁。

2. 它的作用域

this 在任何情况下都不指向函数的词法作用域。

function foo() {
    var a = 2;
    this.bar();
}

function bar() {
    console.log(this.a);
}
foo(); // ReferenceError: a is not defined

this 到底是什么

this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式

当一个函数被调用时,会创建一个活动记录(也被称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的其中一个属性,会在函数执行的过程中被用到。

this 定义小结

this 既不指向函数自身,也不指向函数的词法作用域。this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。

this 全面解析

调用位置

在理解 this 的绑定过程之前,首先要理解调用位置:调用位置就是函数在代码中被调用的位置(而不是声明的位置)。只有仔细分析调用位置才能回答:这个 this 到底引用的是什么?

绑定规则

1. 默认绑定

独立函数调用:函数调用时应用了 this 的默认绑定,因此 this 指向了全局对象。

function foo () {
    console.log(this.a);
}
var a = 2;
foo(); // 2

如果使用严格模式(strict mode),全局对象将无法使用默认绑定,因此 this 会绑定到 undefined

2. 隐式绑定

这条规则需要考虑调用位置是否有上下文对象,或者说是否被某个对象拥有或包含。

function foo () {
    console.log(this.a);
}
var obj = {
    a: 2,
    foo: foo
}
obj.foo(); // 2

当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。

对象属性引用链: 只有最顶层或者说最后一层会影响调用位置。

function foo() {
    console.log( this.a );
}
var obj2 = {
    a: 42,
    foo: foo
};
var obj1 = {
    a: 2,
    obj2: obj2
};
obj1.obj2.foo(); // 42

隐式丢失:

一个最常见的 this 绑定问题就是被隐式绑定的函数会丢失绑定对象,从而应用默认绑定(绑定到全局对象或 undefined)。

function foo() {
    console.log(this.a);
}
var obj = {
    a: 2,
    foo: foo
};
var bar = obj.foo; // 函数别名,实际上引用的是 foo 函数本身
var a = "oops, global"; 
bar(); // "oops, global"

传入回调函数时也容易发生隐式丢失:

function doFoo(fn) {
    fn(); // 调用位置!
}
doFoo(obj.foo); // "oops, global"
3. 显式绑定

JS 提供了 call(..)apply(..) 两个方法来进行显式绑定。它们的第一个参数是一个对象,会把这个对象绑定到 this

function foo () {
    console.log(this.a);
}
var obj = { a: 2 };
foo.call(obj); // 2

装箱: 如果传入的是原始值(字符串、布尔或数字),它会被转换成对象形式(new String(..) 等)。

硬绑定:

function bind(fn, obj) {
    return function() {
        return fn.apply(obj, arguments);
    }
}
4. new 绑定

使用 new 来调用函数时,会自动执行以下操作:

  1. 创建(构造)一个全新的对象。
  2. 这个新对象会被执行 [[Prototype]] 连接。
  3. 这个新对象会绑定到函数调用的 this
  4. 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。
function foo(a) {
    this.a = a;
}
var bar = new foo(2);
console.log(bar.a); // 2

优先级

  1. new 绑定var bar = new foo()
  2. 显式绑定var bar = foo.call(obj2)
  3. 隐式绑定var bar = obj1.foo()
  4. 默认绑定var bar = foo()(严格模式下绑定到 undefined

绑定例外

被忽略的 this

如果你把 null 或者 undefined 作为 this 的绑定对象传入 callapply 或者 bind,这些值会被忽略,实际应用的是默认绑定规则。

更安全的 this: 使用 Object.create(null) 创建一个彻底的空对象(DMZ)。

var ø = Object.create(null);
foo.apply(ø, [2, 3]);
间接引用

赋值表达式 p.foo = o.foo 的返回值是目标函数的引用,调用时会应用默认绑定。

软绑定

硬绑定会降低函数的灵活性,软绑定可以在保留隐式/显式绑定能力的同时,提供一个默认绑定值。

this 词法(箭头函数)

箭头函数不使用 this 的四种标准规则,而是根据外层(函数或全局)作用域来决定 this

function foo() {
    return (a) => {
        // this 继承自 foo()
        console.log( this.a );
    };
}

this 绑定规则小结

判断 this 绑定对象的四条规则:

  1. new? 绑定到新创建的对象。
  2. call/apply/bind? 绑定到指定的对象。
  3. 上下文对象调用? 绑定到该上下文对象。
  4. 默认? 严格模式下 undefined,否则全局对象。

对象

语法

对象可以通过两种形式定义:字面量形式构造形式

// 文字语法(常用)
var myObj = { key: value };

// 构造形式
var myObj = new Object();
myObj.key = value;

类型

JS 的六种主要类型:stringnumberbooleannullundefinedobject

注意typeof null 返回 "object" 是语言本身的一个 bug。

内置对象StringNumberBooleanObjectFunctionArrayDateRegExpError。它们实际上都是内置函数。

内容

可计算属性名

ES6 允许在字面量中使用 [] 包裹表达式作为属性名。

var prefix = "foo";
var myObject = {
    [prefix + "bar"]: "hello"
};
数组

数组也是对象,可以添加属性,但如果属性名看起来像数字,会变成数值下标并修改 length

复制对象
  • 深拷贝:对于 JSON 安全的对象,可以使用 JSON.parse(JSON.stringify(obj))
  • 浅拷贝:ES6 提供了 Object.assign(..)
属性描述符 (Property Descriptors)
  • writable:是否可修改值。
  • configurable:是否可配置(修改描述符或删除属性)。
  • enumerable:是否出现在枚举中(如 for..in)。
不变性
  1. 对象常量writable:false + configurable:false
  2. 禁止扩展Object.preventExtensions(..)
  3. 密封Object.seal(..)(禁止扩展 + configurable:false)。
  4. 冻结Object.freeze(..)(最高级别,密封 + writable:false)。
[[Get]] 与 [[Put]]
  • [[Get]]:查找属性值,找不到返回 undefined
  • [[Put]]:设置属性值,涉及是否有 setter、是否可写等判断。
Getter 和 Setter

通过 getset 改写默认的 [[Get]][[Put]] 操作。定义了 getter/setter 的属性被称为“访问描述符”。

存在性
  • in 操作符:检查属性是否在对象及其原型链中。
  • hasOwnProperty(..):只检查属性是否在对象自身中。

遍历

  • for..in:遍历对象的可枚举属性(包括原型链)。
  • forEach(..)every(..)some(..):数组辅助迭代器。
  • for..of (ES6):直接遍历值(通过迭代器对象)。

混合对象“类”

类是一种设计模式。JS 虽然有 class 关键字,但其机制与传统面向对象语言完全不同。

类的机制

  • 实例化:类通过复制操作变为对象。
  • 继承:子类继承父类。
  • 多态:子类重写父类方法,通过 super 相对引用。

混入 (Mixin)

由于 JS 不会自动执行复制行为,开发者常使用“混入”来模拟类复制。

  • 显式混入:手动复制属性。
  • 寄生继承:显式混入的一种变体。
  • 隐式混入:通过 call(this) 借用函数。

对象与类小结

类意味着复制。JS 中没有真正的类,只有对象,对象之间是通过关联(原型链)而非复制来连接的。

你不知道的JS(上):作用域与闭包

作者 牛奶
2026年2月12日 20:43

你不知道的JS(上):作用域与闭包

本文是《你不知道的JavaScript(上卷)》的阅读笔记,第一部分:作用域与闭包。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

作用域是什么

JS 编译原理

JS 的编译流程和传统编译非常相似,程序中的一段源代码在执行之前会经历三个步骤:分词/词法分析(Tokeninzing/Lexing)解析/语法分析(parsing)代码生成。对于 JS 来说,大部分编译发生在代码执行前的几微秒。

分词/词法分析(Tokeninzing/Lexing)

这个过程会将由字符组成的字符串分解为有意义的代码块,这些代码块被称为词法单元(token)。

解析/语法分析(parsing)

这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为”抽象语法树“(AST:Abstract Syntax Tree)。

代码生成

将 AST 转换为可执行代码的过程称被称为代码生成。简单的说就是有某种方法可以将 var a = 2; 的 AST 转化为一组机器指令,用来创建一个叫作 a 的变量(包括分配内存等),并将一个值储存在 a 中。

理解作用域

想要完全理解作用域,需要先理解引擎。

模块
  • 引擎:从头到尾负责整个 JS 程序的编译及执行过程。
  • 编译器:引擎的好朋友之一,负责语法分析及代码生成等脏活累活。
  • 作用域:引擎的另一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对标识符的访问权限。
声明

例如 var a = 2; 这段看起来是一个声明,在 JS 里其实是两个完全不同的声明,一个由编译器在编译时处理,另一个则由引擎在运行时处理。下面我们看看引擎是如何和编译器、作用域协同工作的。

编译器首先会将这段程序分解成词法单元,然后将词法单元解析为一个树结构。但是当编译器开始执行代码生成的时候,对这段代码的处理方式和预期有所不一样。

我们的预期是:”为一个变量分配内存,将其命名为 a,然后将值 2 保存进这个变量。“

但事实上编译器会进行如下处理:

  1. 遇到 var a,编译器会先询问作用域是否已经有这个变量存在,没有则会在作用域集合声明一个新的变量 a;如果已经有了,则忽略该声明继续编译。
  2. 接下来为引擎生成运行时所需的代码,这些代码被用来处理 a = 2 这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合里是否有变量 a,如果有变量 a,则直接使用;如果没有则向上继续查找。最终如果找到了 a,则进行赋值操作,没有则进行异常抛错。

总结:变量的赋值操作会执行两个动作,首先去在当前作用域中声明一个变量(如果之前没声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就进行赋值。

引用执行

编译器在编译的过程的第二步中生成了代码,引擎执行它时,会通过查找变量 a 来判断它是否已声明过。查找的过程由作用域进行协助,当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧进行 RHS 查询。

RHS 可以理解为 retrieve his source value(取到它的源值)。 例如:console.log(a); 其中对 a 的引用是一个 RHS 引用,这里没有赋予任何值。 相比下 a = 2; 是 LHS 引用,赋值操作找到一个目标。

作用域嵌套

作用域是根据名称查找变量的一套规则。实际情况中,通常要同时查找好几个作用域。当一个块或函数嵌套在另一个块或者函数中时,就发生了作用域的嵌套。在当前作用域无法找到某个变量时,引擎就会在外层嵌套的作用域中进行继续查找,直到查到该变量或者最外层的全局作用域为止。

function foo(a) {
    // 其中b进行的RHS引用无法在函数foo内部完成,但可以在上一级作用域中完成
    console.log(a + b);
}

var b = 2;

foo(3); // 5

引用异常

在变量还没有声明的情况下,LHS 和 RHS 的行为是不一样的。

如果 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出 ReferenceError 的异常类型。

如果 RHS 找到了一个变量,但你尝试对这个变量的值进行不合理的操作,比如试图对非函数类型的值进行调用,或者引用 nullundefined 的值中的属性会报 TypeError

function foo(a) {
    // 对b进行RHS查询时是无法找到该变量的
    console.log(a + b);
    b = a;
}

foo(2); // ReferenceError: b is not defined

相比较下,执行 LHS 查询时,如果在全局作用域也无法找到目标变量,全局作用域中会创建一个具有该名称的变量,并返回给引擎(前提是程序运行在非严格模式下)。

function foo(a) {
    // 对b进行LHS查询时,没找到会创建一个全局变量
    b = a;
    console.log(a + b);
}
foo(2); // 4

作用域小结

作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询。赋值操作符会导致 LHS 查询。= 操作符或调用函数时传入参数的操作都会导致关联作用域的赋值操作。

词法作用域

作用域有两种主要的工作模型。第一种是最为普遍的词法作用域,另一种是动态作用域。JS 采用的是词法作用域。

词法阶段

编译器第一个工作阶段叫作词法化,词法化的过程会对源代码中的字符进行检查,如果有状态的解析过程,还会赋予单词语义。无论函数在哪里被调用或如何调用,词法作用域只由函数被声明时所处的位置决定。作用域查找会在第一个匹配的标识符时停止,在多层的嵌套作用域中可以定义同名的标识符,这叫作”遮蔽效应“。

欺骗词法

词法作用域完全由书写代码期间函数所声明的位置来定义,怎样才能在运行时来”修改“(欺骗)词法作用域呢?JS 中有两种机制来实现这个目的,使用这两种机制并不是好主意,而且欺骗词法作用域会导致性能下降。

eval

eval(...) 函数可以接受一个字符串为参数,并运行这个字符串来实现修改词法作用域环境。

function foo(str, a) {
    eval( str ); // 欺骗!
    console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1, 3

在严格模式下,eval 在运行时有其自己语法作用域,意味着其中的声明无法修改所在的作用域。

function foo(str) {
    "use strict";
    eval( str );
    console.log( a ); // ReferenceError: a is not defined
}
foo( "var a = 2"); 

JS 中还有一些其他功能效果和 eval 相似的方法,例如 setTimeout(...)setInterval(...) 的第一个参数可以是字符串,可以被解析为一段动态生成的函数代码。这些功能已经过时且并不被提倡。不要使用他们!

with

JS 中另一个难以掌握的用来欺骗词法作用域的功能是 with 关键字。with 通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。

var obj = {
    a: 1,
    b: 2,
    c: 3
};
// 单调乏味的重复”obj“
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 简单的快捷方式
with (obj) {
    a = 3;
    b = 4;
    c = 5;
}

但这样的使用方式会有奇怪的副作用,例如:

function foo(obj) {
    with (obj) {
        a = 2;
    }
}

var o1 = { a: 3 };
var o2 = { b: 3 };
foo(o1);
console.log(o1.a); // 2

foo(o2);
console.log(o2.a); // undefined
console.log(a); // 2 --a被泄露到全局作用域上了

当我们将 o2 作为作用域时,其中并没有 a 标识符,因此进行了正常的 LHS 标识符查找,在 o2foo、全局作用域都没有找到时,在非严格模式下自动创建了一个全局变量。

性能

JS 引擎会在编译阶段进行数项性能优化。其中有些优化依赖于代码词法的静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。如果代码中使用了 evalwith,它只能简单地假设关于标识符位置的判断都是无效的,因此代码中的优化就没有了意义。如果大量使用 evalwith,运行起来会非常慢。

词法作用域小结

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。

JavaScript 中有两个机制可以“欺骗”词法作用域:eval(..)with。前者可以对一段包含一个或多个声明的“代码”字符串进行演算,并借此来修改已经存在的词法作用域(在运行时)。后者本质上是通过将一个对象的引用当作作用域来处理,将对象的属性当作作用域中的标识符来处理,从而创建了一个新的词法作用域(同样是在运行时)。

这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认为这样的优化是无效的。使用这其中任何一个机制都将导致代码运行变慢。不要使用它们。

函数作用域和块作用域

函数中的作用域

函数作用域是指属于这个函数的全部变量都可以在整个函数的范围内使用及复用。这种设计方案是非常有用的,能充分利用 JS 变量根据需要改变值类型的”动态“特性。

隐藏内部实现

把变量和函数包裹在一个函数的作用域中,然后用这个作用域来”隐藏“它们。

function doSomething(a) {
    function doSomeThingElse(a) {
        return a - 1;
    }
    var b;
    b = a + doSomeThingElse(a*2);
    console.log(b*3);
}
doSomething(2); // 15
// b 和doSomeThingElse都无法从外部被访问,只能被doSomething所控制。从设计角度上,内容私有化了

”隐藏“作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突,两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突。

函数作用域

匿名和具名
setTimeout(function () {
    console.log('I waited 1 second!');
}, 1000);

因为 function 没有名称标识符,这就叫作匿名函数表达式。函数表达式可以匿名,但函数声明则不能省略函数名。

立即执行函数表达式
var a = 2;
(function foo() {
    var a = 3;
    console.log(a); // 3
})();

console.log(a); // 2

函数被包含在一对 ( ) 括号内部,因此成为一个表达式,通过在末尾加上另外一个 ( ) 可以立即执行这个函数,专业术语叫做 IIFE(Immediately Invoked Function Expression)。

IIFE 的另一个非常普遍的进阶用法是把它们当作函数调用并传递参数进去。例如:

var a = 2;
(function IIFE(global) {
    var a = 3;
    console.log(a); // 3
    console.log(global.a); // 2
})(global);

console.log(a); // 2

IIFE 还有一种变化的用途是倒置代码的运行顺序,将需要运行的函数放在第二位,在 IIFE 执行之后当作参数传递进去。这种模式在 UMD 项目中被广泛应用。

var a = 2;
(function IIFE(def) {
    def(window)
})(function def(global) {
    var a = 3;
    console.log(a); // 3
    console.log(global.a); // 2
})

块作用域

块作用域是一个用来对之前的最小授权原则进行扩展的工具,将代码从在函数中隐藏信息扩展为在块中隐藏信息。

with

它不仅是一个难以理解的结构,也是块作用域的一个例子,用 with 从对象中创建出的作用域仅在 with 声明中而非外部作用域中有效。

try/catch

非常少有人注意到 try/catch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效。

try {
    undefined(); // 执行一个异常
} catch (err) {
    console.log(err); // 能够执行
}
console.log(err); // ReferenceError: err not found
let

let 关键字可以将变量绑定到所在的任意作用域中(通常是 {...} 内部)。

var foo = true;
if (foo) {
    let bar = 2;
}

console.log(bar); // ReferenceError
const

ES6 还引入了 const,同样可以用来创建块作用域变量,但其值是固定的(常量)。之后任何试图修改值的操作都会引起错误。

if (true) {
    var a = 2;
    const b = 3; // 包含在if中的块作用域常量
    a = 3; // 正常
    b = 4; // 错误
}

console.log(a); // 3
console.log(b); // ReferenceError

函数与块作用域小结

函数是 JavaScript 中最常见的作用域单元。本质上,声明在一个函数内部的变量或函数会在所处的作用域中“隐藏”起来,这是有意为之的良好软件的设计原则。

但函数不是唯一的作用域单元. 块作用域指的是变量和函数不仅可以属于所处的作用域,也可以属于某个代码块(通常指 { .. } 内部)。

从 ES3 开始,try/catch 结构在 catch 分句中具有块作用域。 在 ES6 中引入了 let 关键字(var 关键字的表亲),用来在任意代码块中声明变量。if (..) { let a = 2; } 会声明一个劫持了 if{ .. } 块的变量,并且将变量添加到这个块中。

作用域提升

先声明还是先赋值

一般来说 JS 代码是在从上到下一行一行执行的,但实际上并不完全正确,存在作用域提升的特殊情况,例如:

a = 2;
var a;
console.log(a); // 2 不是undefined
console.log(a); // undefined
var a = 2;

编译器的作用

引擎会在执行 JS 代码前进行编译,编译阶段的一部分工作就是找到所有的声明,并用合适的作用域关联起来。所以正确的思路是,包括变量和函数在内的所有声明都是在代码执行前先处理。定义声明在编译阶段进行,赋值声明在原地等待执行阶段。上面的例子可以被解析为:

var a;
a = 2;
console.log(a); // 2

第二个解析为:

var a;
console.log(a); // undefined
a = 2;

这个过程就好像变量 and 函数声明从它们的代码中出现的位置被“移动”到了最上面。这个过程就叫作提升。

函数优先

函数声明和变量声明都会被提升,但函数会优先被提升,然后才是变量。

foo(); // 1
var foo;
function foo() {
    console.log(1);
}
foo = function() {
    console.log(2);
}

var foo 尽管出现在 function foo()... 的声明之前,但它是重复的声明,因为函数声明会被提升到普通变量之前。 尽管重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。

foo(); // 3
var foo;
function foo() {
    console.log(1);
}
var foo = function() {
    console.log(2);
}
function foo() {
    console.log(3);
}

尽量避免在块内部声明函数,这个行为并不可靠。

作用域提升小结

无论作用域中的声明在什么地方,都将在代码本身被执行前先进行处理,这一过程被称为提升。同时需要注意避免重复声明,特别是当普通的 var 声明和函数声明混合在一起的时候,会引起很多危险的问题!

作用域闭包

闭包无处不在

闭包无处不在,你只需要能够识别并拥抱它。

闭包的实质

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会有对原始定义作用域的引用,这就叫作闭包。闭包的神奇之处在于阻止外层作用域被引擎的垃圾回收器回收。 例如: 内部函数调用外部引用

function foo() {
    var a = 2;
    function bar() {
        console.log(a); // 2
    }
    bar();
}
foo(); // 这不是闭包,但包含了闭包的原理

把函数作为返回值,保持对外部的引用

function foo() {
    var a = 2;
    function bar() {
        console.log(a);
    }
    return bar;
}
var baz = foo();
baz(); // 2 这就是闭包的效果

对函数类型的值进行传递,在别处调用

function foo() {
    var a = 2;
    function baz() {
        console.log(a) // 2
    }
    bar(baz);
}

function bar(fn) {
    fn(); // 这也是闭包
}

间接传递函数

var fn;
function foo() {
    var a = 2;
    function baz() {
        console.log(a) // 2
    }
    fn = baz; // 将baz分配
}

function bar() {
    fn(); // 这也是闭包
}
foo();
bar(); // 2

闭包的使用

闭包的使用其实非常广泛,在我们无意中写的代码里有很多闭包。在定时器、事件监听、ajax 处理、通信、异步或同步任务中,只要使用了回调函数,实际上就是在使用闭包! 定时器的使用:

function wait(msg) {
    setTimeout(function timer() {
        console.log(msg)
    }, 1000);
}

wait('hello'); // wait执行1000毫秒后,它的内部作用域并不会消失,timer函数依然保有wait的作用域的闭包

事件监听:

function setupBot(name, selector) {
    $(selector).click(function (){
        console.log('Activating: ' + name);
    })
}

setupBot('name1', '#id1');

循环和闭包

要说明闭包,for 循环是最常见的例子。

for (var i=1; i<=5; i++) {
    setTimeout(function timer() {
        console.log( i );
    }, i*1000);
}

正常情况下,我们对这段代码行为的预期是分别输出数字 1~5,每秒一次,每次一个。 但实际上,这段代码在运行时会以每秒一次的频率输出五次 6。

因为延迟函数的回调会在循环结束时才执行。事实上,当定时器运行时即使每个迭代中执行的是 setTimeout(.., 0),所有的回调函数依然是在循环结束后才会被执行,因此会每次输出一个 6 出来。

尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i

缺陷是什么?我们需要更多的闭包作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域。

IIFE 会通过声明并立即执行一个函数来创建作用域。

for (var i=1; i<=5; i++) {
    (function() {
        var j = i;
        setTimeout( function timer() {
            console.log( j );
        }, j*1000 );
    })();
}

对这段代码进行改进,传入参数

for (var i=1; i<=5; i++) {
    (function(j) {
        setTimeout( function timer() {
            console.log( j );
        }, j*1000 );
    })(i);
}

当然现在可以使用 ES6 的 let 声明,来劫持块作用域:

for (let i=1; i<=5; i++) {
     setTimeout( function timer() {
        console.log( i );
    }, i*1000 );
}

模块

还有其他的代码模式利用闭包的强大威力,但从表面上看,它们似乎与回调无关。下面一起来研究其中最强大的一个:模块。

正如下面代码中所看到的,这里并没有明显的闭包,只有两个私有数据变量 somethinganother,以及 doSomething()doAnother() 两个内部函数,它们的词法作用域(而这就是闭包)也就是 foo() 的内部作用域。

function CoolModule() {
    var something = "cool";
    var another = [1, 2, 3];
    function doSomething() {
        console.log( something );
    }
    function doAnother() {
        console.log( another.join("!") );
    }
    
    return {
        doSomething: doSomething,
        doAnother: doAnother,
    }
}

var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1!2!3

模块模式需要具备两个必要条件。

  1. 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。
  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

上面的示例是个独立的模块创建器,可以被调用任意多次,每次调用都会创建一个新的模块实例。当只需要一个实例时,可以对这个模式进行简单的改进来实现单例模式:

var foo = (function CoolModule() {
    var something = "cool";
    var another = [1, 2, 3];
    function doSomething() {
        console.log( something );
    }
    function doAnother() {
        console.log( another.join("!") );
    }
    
    return {
        doSomething: doSomething,
        doAnother: doAnother,
    }
})();

foo.doSomething(); // cool
foo.doAnother(); // 1!2!3

我们将模块函数转成了 IIFE,立即调用这个函数并将返回值直接赋值给单例的模块实例标识符 foo

现代的模块机制

大多数模块依赖加载器/管理器本质上都是将这种模块定义封装进一个友好的 API。

var MyModules = (function Manager() {
    var modules = {};
    
    function define(name, deps, impl) {
        for (var i = 0; i < deps.length; i++) {
            deps[i] = modules[deps[i]];
        }
        modules[name] = impl.apply(impl, deps);
    } 
    
    function get (name) {
        return modules[name];
    }
    
    return {
        define: define,
        get: get
    }
})();

这段代码的核心是 modules[name] = impl.apply(impl, deps)。为了模块的定义引入了包装函数(可以传入任何依赖),并且将返回值,也就是模块的 API,存储在一个根据名字来管理的模块列表中。

// 下面展示了如何使用它来定义模块
MyModules.define("bar", [], function() {
    function hello(who) {
        return "Let me introduce: " + who;
    }
    
    return {
        hello: hello
    }
});

MyModules.define("foo", ["bar"], function(bar) {
    var hungry = "hippo";
    function awesome() {
        console.log(bar.hello(hungry).toUpperCase());
    }
    
    return {
        awesome: awesome
    }
});

var bar = MyModules.get("bar");
var foo = MyModules.get("foo");

console.log(bar.hello("hippo")); // Let me introduce: hippo
foo.awesome(); // LET ME INTRODUCE: HIPPO

foobar 模块都是通过一个返回公共的 API 的函数来定义的。foo 还接受 bar 作为依赖参数,并能相应的使用它。

未来的模块机制

ES6 中为模块增加了一级语法支持。通过模块系统进行加载时,ES6 会将文件当作独立的模块来处理。每个模块可以导入其他模块或特定的 API,同样也可以导出自己的 API 成员。

相比之下,ES6 的模块 API 更加稳定(API 不会在运行时改变)。

// bar.js
function hello(who) {
    return "Let me introduce: " + who;
}
export hello;

// foo.js
// 仅从 "bar" 模块导入 hello()
import hello from "bar";
var hungry = "hippo";
function awesome() {
    console.log(hello( hungry ).toUpperCase());
}
export awesome;

// baz.js
// 导入完整的 "foo" 和 "bar" 模块
module foo from "foo";
module bar from "bar";
console.log(bar.hello( "rhino" )); // Let me introduce: rhino
foo.awesome(); // LET ME INTRODUCE: HIPPO

import 可以将一个模块中的一个或多个 API 导入到当前作用域中,并分别绑定在一个变量上(在我们的例子里是 hello)。module 会将整个模块的 API 导入并绑定到一个变量上(在我们的例子里是 foobar)。export 会将当前模块的一个标识符(变量、函数)导出为公共 API。这些操作可以在模块定义中根据需要使用任意多次。

闭包与模块小结

当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。

闭包也是一个非常强大的工具,可以用多种形式来实现模块等模式。

《前端架构设计》:除了写代码,我们还得管点啥

作者 牛奶
2026年2月12日 20:35

《前端架构设计》:除了写代码,我们还得管点啥

很多人对“前端架构”这四个字有误解,觉得就是选个 React 还是 Vue,或者是折腾一下 Webpack 和 Vite。

但 Micah Godbolt 在《前端架构设计》里泼了一盆冷水:选工具只是最简单的一步。真正的架构,是让团队在业务疯狂迭代的时候,代码还能写得顺手,系统还能跑得稳当。

这本书的核心其实就是一句话:架构不是结果,而是为了让开发更爽、系统更强而设计的一整套制度。


一、 别把架构师当成“高级打工人”

在很多项目里,前端总是在“下游”接活:UI 给图,后端给口,前端就在中间拼命赶进度。但作者认为,前端架构师应该是项目里的“一等公民”,甚至是“城市规划师”。

1. 搭体系 (System Design)

你得像规划城市一样去规划代码。

  • 组件库的颗粒度:到底是一个按钮算一个组件,还是一个带图标的搜索框算一个组件?这直接决定了复用效率。
  • 样式冲突预防:不同业务线共用一套组件时,怎么保证 A 团队改的样式不会让 B 团队的页面崩掉?
  • 技术选型:不能光看 GitHub 上哪个库星星多,得看它能不能管个三五年。架构师要考虑的是“可持续性”,而不是“时髦值”。

2. 理流程 (Workflow Planning)

大家开发得顺不顺手,全看流程顺不顺。架构师得操心:

  • 环境一致性:是不是每个人的 Node 版本、依赖版本都一样?
  • 构建自动化:代码怎么自动化编译、压缩、分包?
  • 新人上手成本:能不能让新人进来半天就能配好环境开始写业务? 很多时候,一个好的构建工具(比如现在的 Vite 或者以前的 Webpack)配上一套好流程,比写出神仙代码更能救命。

3. 盯着质量 (Oversight)

业务跑得快,脏代码肯定多。架构师得心里有数:

  • 技术债管理:什么时候该去清理那些“为了上线先凑合”的代码?
  • 代码审查 (Code Review):不是为了找茬,而是为了同步思路。
  • 风险预判:业务下个月要搞大促,现在的系统架构能不能扛住高并发和频繁的样式变更? 别等项目成了“屎山”才去想重构,那时候可能连下脚的地方都没了。

二、 架构的四个核心支柱

作者把架构分成了四个板块:代码、流程、测试、文档。这四块拼图凑齐了,项目才算有了魂。

1. 代码 (Code):拒绝“意大利面条”

代码架构的核心就是“解耦”。别让 HTML、CSS 和 JS 粘得太死,要像乐高积木一样可以随时拆卸。

HTML:结构的纯粹性
  • 拒绝过度生成:要在自动化和控制力之间找平衡。别让后端逻辑直接吐一堆乱七八糟的标签出来,那样前端根本没法改。
  • 组件化思维:HTML 只负责描述“这是什么”(比如这是一个导航栏),而不负责“它长什么样”。
  • 语义化:保持 HTML 的简洁和语义化,是架构长青的基石。
CSS:架构的重灾区

这是全书聊得最细的地方,因为 CSS 最容易写乱,也最难维护。

  • OOCSS (面向对象 CSS)
    • 核心是“结构与皮肤分离”。
    • 比如一个按钮的形状、边距是“结构”,它的背景色、投影是“皮肤”。
  • SMACSS
    • 给样式分分类,就像衣柜收纳:
      1. Base:基础样式,比如 body, a 标签的默认外观。
      2. Layout:布局样式,负责页面大框架。
      3. Module:模块样式,这是重头戏,比如导航条、轮播图。
      4. State:状态样式,比如 is-active, is-hidden
      5. Theme:主题样式,换肤全靠它。
  • BEM 命名法
    • 虽然 block__element--modifier 名字长,但它能解决权重冲突。
    • 坚持用单类名(Single Class Selection),重构的时候底气才足,不用担心改个按钮全站崩。
  • 单一来源 (Single Source of Truth)
    • 变量(Variables)是神。颜色、字号、间距,全用变量管起来,改一个地方全站生效。
JavaScript:逻辑的独立性
  • 框架无关性:选框架要冷静。业务逻辑要尽量从 UI 库里抽出来。
  • 纯函数:尽量写不依赖外部环境的函数,这样不开启浏览器也能跑单元测试。
  • 状态管理:清晰的数据流向是大型项目不崩溃的前提。

2. 流程 (Process):别把时间花在重复劳动上

流程决定了代码从你的键盘到用户屏幕的距离。

  • 原型驱动 (Prototyping)

    • 别光看设计稿,先写个 HTML 原型。
    • 为什么?因为原型能让你早点体验到真实的交互,早点发现问题。
    • 改原型永远比改正式代码便宜。
  • 任务自动化

    • 凡是能让机器干的活(编译 Sass、压图、跑 Lint),都别让人干。
    • 现在的 npm scripts 或者是各种 Task Runner 就是为了解放生产力的。
  • 持续集成 (CI)

    • 代码合并前,必须经过一顿“毒打”——编译、Lint 检查、自动化测试。
    • 只有通过了,才能合并。这能保证主干代码永远是健康的。
  • 文档化工作流:让每个步骤都有迹可循,减少沟通成本。


3. 测试 (Testing):你的后悔药

没有测试的重构就是裸奔。

  • 视觉回归测试 (Visual Regression)

    • 这是作者最推崇的黑科技。
    • 重构 CSS 之后,用工具(比如 BackstopJS)自动截图和以前对比,像素级的差异都能找出来。
    • 这是重构老旧系统的“保命符”,有了它,你才敢动那些几年前写的样式。
  • 性能预算 (Performance Budget)

    • 性能不是后期优化的,是前期定好的。
    • 首屏时间不能超过几秒?JS 包体积不能超过多大?
    • 定死这些指标,加第三方库的时候你才会心疼,才会去想有没有更好的替代方案。
  • 自动化单元测试:保证核心逻辑的稳定性,改代码不再提心吊胆。


4. 文档 (Documentation):它是活的吗?

文档不是写给老板交差的,是给团队对齐思路的。

  • 动态样式指南 (Living Style Guides)

    • 静态文档写完就过期。
    • 理想的状态是:文档就是代码的一部分。代码改了,文档自动更新。
  • 模式库 (Pattern Lab)

    • 按照“原子设计”的思路管理组件:
      • 原子 (Atoms):标签、按钮、输入框。
      • 分子 (Molecules):带标签的搜索栏。
      • 有机体 (Organisms):整个导航头部。
      • 模板 (Templates):页面骨架。
      • 页面 (Pages):最终呈现。
    • 这种层级关系理清楚了,组件复用才不会乱成一团。
  • 开发者文档:记录“为什么要这么设计”,比“怎么用”更重要。


三、 Red Hat 的实战经验:怎么干重构?

作者在 Red Hat 负责过一次大规模重构,这部分的实战干货非常多,值得细品:

1. 解决命名空间冲突

在大型公司,不同团队可能都在写 CSS。以前样式到处打架,改个按钮全公司网站都变色。

  • 方案:重构时,他们给所有核心组件加了 rh- 这种命名前缀。

  • 感悟:技术虽然简单,但它建立了“地盘感”,彻底实现了样式隔离。

2. 语义化网格系统 (Semantic Grids)

  • 痛点:别在 HTML 里写死 .col-6 这种类名。如果你想把 6 列改成 4 列,你得改几百个 HTML 文件。

  • 绝招:在 Sass 里用 Mixins 定义布局。

    • HTML 只要保持语义(比如 .main-content)。
    • CSS 里写 @include make-column(8);
  • 结果:改布局只要动一个 CSS 配置文件,HTML 完全不用变。

3. 文档系统的四阶进化

Red Hat 的文档不是一步到位的,他们经历了四个阶段:

  1. 第一阶段:静态页面。写完就没人看了,很快就和代码脱节。
  2. 第二阶段:自动化 Pattern Lab。让文档和代码同步,实现了“活文档”。
  3. 第三阶段:独立组件库。把组件从业务项目里剥离出来,像发 npm 包一样管理,跨项目复用变得极其简单。
  4. 第四阶段:统一渲染引擎
    • 核心:用 JSON Schema 定义数据格式。
    • 效果:不管后端是 PHP 还是 Java,只要按这个 JSON 格式给数据,前端组件就能准确渲染。这彻底解决了前后端对接时的“扯皮”问题,前端成了真正的“界面引擎”。

四、 架构师的心法:BB 鸟与歪心狼

书中提到了两个非常有意思的隐喻,这才是架构设计的最高境界:

1. BB 鸟规则 (Roadrunner Rule)

看过动画片的都知道,BB 鸟跑得飞快,而歪心狼(Coyote)总是背着一堆高科技装备,结果最后都被装备给坑了。

  • 道理:架构要轻量,要解决的是“现在”和“可预见的未来”的问题。
  • 戒律:别为了解决那种万分之一才会出现的特殊情况,把系统搞得无比复杂。别把自己变成那个被装备压垮的歪心狼。

2. 解决“最后一英里”问题

代码写完、测试通过、合并进主干,这就算完了吗?架构师说:还没。

  • 全路径关注
    • 静态资源在 CDN 上刷了吗?
    • 用户的浏览器缓存策略配对了吗?
    • 第三方广告脚本会不会把页面卡死?
    • 在偏远地区、慢网环境下,用户看到的是白屏还是有意义的内容? 架构师得关注从代码仓库到用户浏览器的“每一寸路程”。

五、 写在最后

前端架构不是一个静态的目标,而是一直在变的过程。一个好的架构师得会沟通,在技术理想和业务现实之间找平衡。

说到底,架构就是为了把这四块拼图理顺:

  1. 代码 得够模块化,重构不心慌;
  2. 流程 得够自动化,开发不心累;
  3. 测试 得够全面,上线不背锅;
  4. 文档 得够实时,沟通不扯皮。

如果你能把这几件事干好了,你写的就不只是代码,而是一个能长久活下去、有生命力的系统。这,才是前端架构设计的真谛。

❌
❌