普通视图

发现新文章,点击刷新页面。
昨天以前首页

你不知道的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. 文档 得够实时,沟通不扯皮。

如果你能把这几件事干好了,你写的就不只是代码,而是一个能长久活下去、有生命力的系统。这,才是前端架构设计的真谛。

设计模式-行为型

作者 牛奶
2026年2月11日 16:08

设计模式-行为型

本文主要介绍下行为型设计模式,包括策略模式模板方法模式观察者模式迭代器模式责任链模式命令模式备忘录模式状态模式访问者模式中介者模式解释器模式,提供前端场景和 ES6 代码的实现过程。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

引言

本文主要介绍下行为型设计模式,包括策略模式模板方法模式观察者模式迭代器模式责任链模式命令模式备忘录模式状态模式访问者模式中介者模式解释器模式,提供前端场景和 ES6 代码的实现过程。

什么是行为型

行为型模式(Behavioral Patterns)主要关注对象之间的通信职责分配。这些模式描述了对象之间如何协作共同完成任务,以及如何分配职责。行为型模式不仅关注类和对象的结构,更关注它们之间的相互作用,通过定义清晰的通信机制,解决系统中复杂的控制流问题,使得代码更加清晰、灵活和易于维护。

行为型模式

策略模式(Strategy)

策略模式(Strategy Pattern)定义一系列的算法,把它们一个个封装起来,并使它们可以相互替换。策略模式让算法独立于使用它的客户而变化。

前端中的策略模式场景

  • 表单验证:将不同的验证规则(如非空、邮箱格式、手机号格式)封装成策略,根据需要选择验证策略。
  • 不同业务逻辑处理:例如,根据用户权限(普通用户、VIP、管理员)展示不同的 UI 或执行不同的逻辑。
  • 缓动动画算法:在动画库中,提供多种缓动函数(如 linearease-inease-out)供用户选择。

策略模式-JS实现

// 策略对象
const strategies = {
  "S": (salary) => salary * 4,
  "A": (salary) => salary * 3,
  "B": (salary) => salary * 2
};

// 环境类(Context)
class Bonus {
  constructor(salary, strategy) {
    this.salary = salary;
    this.strategy = strategy;
  }

  getBonus() {
    return strategies[this.strategy](this.salary);
  }
}

// 客户端调用
const bonusS = new Bonus(10000, "S");
console.log(bonusS.getBonus()); // 40000

const bonusA = new Bonus(10000, "A");
console.log(bonusA.getBonus()); // 30000

模板方法模式(Template Method)

模板方法模式(Template Method Pattern)定义一个操作中的算法的骨架,而将一些步骤延迟到子类中实现。

模板方法使得子类可以在不改变算法结构的情况下,重新定义算法的某些步骤。

前端中的模板方法模式场景

  • UI组件生命周期VueReact 组件的生命周期钩子(如 componentDidMountcreated)就是模板方法模式的应用。框架定义了组件渲染的整体流程,开发者在特定的钩子中实现自定义逻辑。
  • HTTP请求封装:定义一个基础的请求类,处理通用的配置(如 URL、Headers),子类实现具体的请求逻辑(如 GET、POST 参数处理)。

模板方法模式-JS实现

// 抽象父类:饮料
class Beverage {
  // 模板方法,定义算法骨架
  makeBeverage() {
    this.boilWater();
    this.brew();
    this.pourInCup();
    this.addCondiments();
  }

  boilWater() {
    console.log("煮沸水");
  }

  pourInCup() {
    console.log("倒进杯子");
  }

  // 抽象方法,子类必须实现
  brew() {
    throw new Error("抽象方法不能调用");
  }

  addCondiments() {
    throw new Error("抽象方法不能调用");
  }
}

// 具体子类:咖啡
class Coffee extends Beverage {
  brew() {
    console.log("用沸水冲泡咖啡");
  }

  addCondiments() {
    console.log("加糖和牛奶");
  }
}

// 具体子类:茶
class Tea extends Beverage {
  brew() {
    console.log("用沸水浸泡茶叶");
  }

  addCondiments() {
    console.log("加柠檬");
  }
}

// 客户端调用
const coffee = new Coffee();
coffee.makeBeverage();
// 输出:
// 煮沸水
// 用沸水冲泡咖啡
// 倒进杯子
// 加糖和牛奶

const tea = new Tea();
tea.makeBeverage();
// 输出:
// 煮沸水
// 用沸水浸泡茶叶
// 倒进杯子
// 加柠檬

观察者模式(Observer)

观察者模式(Observer Pattern)定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。

前端中的观察者模式场景

  • DOM事件监听document.addEventListener 就是最典型的观察者模式。
  • Promisethen 方法也是一种观察者模式,当 Promise 状态改变时,执行相应的回调。
  • Vue响应式系统Dep(目标)和 Watcher(观察者)实现了数据的响应式更新。
  • RxJS:基于观察者模式的响应式编程库。
  • Event Bus:事件总线。

观察者模式-JS实现

// 目标对象(Subject)
class Subject {
  constructor() {
    this.observers = [];
  }

  addObserver(observer) {
    this.observers.push(observer);
  }

  removeObserver(observer) {
    const index = this.observers.indexOf(observer);
    if (index > -1) {
      this.observers.splice(index, 1);
    }
  }

  notify(data) {
    this.observers.forEach(observer => {
      observer.update(data);
    });
  }
}

// 观察者对象(Observer)
class Observer {
  constructor(name) {
    this.name = name;
  }

  update(data) {
    console.log(`${this.name} 收到通知: ${data}`);
  }
}

// 客户端调用
const subject = new Subject();
const observer1 = new Observer("观察者1");
const observer2 = new Observer("观察者2");

subject.addObserver(observer1);
subject.addObserver(observer2);

subject.notify("更新数据了!");
// 输出:
// 观察者1 收到通知: 更新数据了!
// 观察者2 收到通知: 更新数据了!

迭代器模式(Iterator)

迭代器模式(Iterator Pattern)提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露其内部的表示。

前端中的迭代器模式场景

  • 数组遍历forEachmap 等数组方法。
  • ES6 IteratorSymbol.iterator 接口,使得对象可以使用 for...of 循环遍历。
  • Generators:生成器函数可以生成迭代器。

迭代器模式-JS实现

// 自定义迭代器
class Iterator {
  constructor(items) {
    this.items = items;
    this.index = 0;
  }

  hasNext() {
    return this.index < this.items.length;
  }

  next() {
    return this.items[this.index++];
  }
}

// 客户端调用
const items = [1, 2, 3];
const iterator = new Iterator(items);

while (iterator.hasNext()) {
  console.log(iterator.next());
}
// 输出:1 2 3

// ES6 Iterator 示例
const iterableObj = {
  items: [10, 20, 30],
  [Symbol.iterator]() {
    let index = 0;
    return {
      next: () => {
        if (index < this.items.length) {
          return { value: this.items[index++], done: false };
        } else {
          return { done: true };
        }
      }
    };
  }
};

for (const item of iterableObj) {
  console.log(item);
}
// 输出:10 20 30

责任链模式(Chain of Responsibility)

责任链模式(Chain of Responsibility Pattern)使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。

前端中的责任链模式场景

  • 事件冒泡:DOM 事件在 DOM 树中的冒泡机制就是责任链模式。
  • 中间件ExpressKoaRedux 中的中间件机制。
  • 拦截器Axios 的请求和响应拦截器。

责任链模式-JS实现

// 处理器基类
class Handler {
  setNext(handler) {
    this.nextHandler = handler;
    return handler; // 返回 handler 以支持链式调用
  }

  handleRequest(request) {
    if (this.nextHandler) {
      this.nextHandler.handleRequest(request);
    }
  }
}

// 具体处理器
class HandlerA extends Handler {
  handleRequest(request) {
    if (request === 'A') {
      console.log("HandlerA 处理了请求");
    } else {
      super.handleRequest(request);
    }
  }
}

class HandlerB extends Handler {
  handleRequest(request) {
    if (request === 'B') {
      console.log("HandlerB 处理了请求");
    } else {
      super.handleRequest(request);
    }
  }
}

class HandlerC extends Handler {
  handleRequest(request) {
    if (request === 'C') {
      console.log("HandlerC 处理了请求");
    } else {
      console.log("没有处理器处理该请求");
    }
  }
}

// 客户端调用
const handlerA = new HandlerA();
const handlerB = new HandlerB();
const handlerC = new HandlerC();

handlerA.setNext(handlerB).setNext(handlerC);

handlerA.handleRequest('A'); // HandlerA 处理了请求
handlerA.handleRequest('B'); // HandlerB 处理了请求
handlerA.handleRequest('C'); // HandlerC 处理了请求
handlerA.handleRequest('D'); // 没有处理器处理该请求

命令模式(Command)

命令模式(Command Pattern)将一个请求封装为一个对象,从而使用户可用不同的请求对客户进行参数化;对请求排队或记录请求日志,以及支持可撤销的操作。

前端中的命令模式场景

  • 富文本编辑器:执行加粗、斜体、下划线等操作,并支持撤销(Undo)和重做(Redo)。
  • 菜单和按钮:将菜单项或按钮的操作封装成命令对象。

命令模式-JS实现

// 接收者:执行实际命令
class Receiver {
  execute() {
    console.log("执行命令");
  }
}

// 命令对象
class Command {
  constructor(receiver) {
    this.receiver = receiver;
  }

  execute() {
    this.receiver.execute();
  }
}

// 调用者:发起命令
class Invoker {
  constructor(command) {
    this.command = command;
  }

  invoke() {
    console.log("调用者发起请求");
    this.command.execute();
  }
}

// 客户端调用
const receiver = new Receiver();
const command = new Command(receiver);
const invoker = new Invoker(command);

invoker.invoke();
// 输出:
// 调用者发起请求
// 执行命令

备忘录模式(Memento)

备忘录模式(Memento Pattern)在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。

前端中的备忘录模式场景

  • 状态管理Redux 等状态管理库的时间旅行(Time Travel)功能。
  • 表单草稿:保存用户输入的表单内容,以便下次恢复。
  • 撤销/重做:编辑器中的撤销和重做功能。

备忘录模式-JS实现

// 备忘录:保存状态
class Memento {
  constructor(state) {
    this.state = state;
  }

  getState() {
    return this.state;
  }
}

// 发起人:需要保存状态的对象
class Originator {
  constructor() {
    this.state = "";
  }

  setState(state) {
    this.state = state;
    console.log(`当前状态: ${this.state}`);
  }

  saveStateToMemento() {
    return new Memento(this.state);
  }

  getStateFromMemento(memento) {
    this.state = memento.getState();
    console.log(`恢复状态: ${this.state}`);
  }
}

// 管理者:管理备忘录
class Caretaker {
  constructor() {
    this.mementos = [];
  }

  add(memento) {
    this.mementos.push(memento);
  }

  get(index) {
    return this.mementos[index];
  }
}

// 客户端调用
const originator = new Originator();
const caretaker = new Caretaker();

originator.setState("状态1");
originator.setState("状态2");
caretaker.add(originator.saveStateToMemento()); // 保存状态2

originator.setState("状态3");
caretaker.add(originator.saveStateToMemento()); // 保存状态3

originator.setState("状态4");

originator.getStateFromMemento(caretaker.get(0)); // 恢复到状态2
originator.getStateFromMemento(caretaker.get(1)); // 恢复到状态3

状态模式(State)

状态模式(State Pattern)允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。

前端中的状态模式场景

  • 有限状态机(FSM):例如,Promise 的状态(Pending, Fulfilled, Rejected)。
  • 组件状态管理:例如,一个按钮可能有 loadingdisableddefault 等状态,不同状态下点击行为不同。
  • 游戏开发:角色的不同状态(如站立、奔跑、跳跃、攻击)。

状态模式-JS实现

// 状态接口
class State {
  handle(context) {
    throw new Error("抽象方法不能调用");
  }
}

// 具体状态A
class ConcreteStateA extends State {
  handle(context) {
    console.log("当前是状态A");
    context.setState(new ConcreteStateB());
  }
}

// 具体状态B
class ConcreteStateB extends State {
  handle(context) {
    console.log("当前是状态B");
    context.setState(new ConcreteStateA());
  }
}

// 上下文
class Context {
  constructor() {
    this.state = new ConcreteStateA();
  }

  setState(state) {
    this.state = state;
  }

  request() {
    this.state.handle(this);
  }
}

// 客户端调用
const context = new Context();
context.request(); // 当前是状态A
context.request(); // 当前是状态B
context.request(); // 当前是状态A

访问者模式(Visitor)

访问者模式(Visitor Pattern)表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。

前端中的访问者模式场景

  • AST遍历Babel 插件开发中,通过访问者模式遍历和修改 AST(抽象语法树)节点。
  • 复杂数据结构处理:对树形结构或图形结构进行不同的操作(如渲染、序列化、验证)。

访问者模式-JS实现

// 元素类
class Element {
  accept(visitor) {
    throw new Error("抽象方法不能调用");
  }
}

class ConcreteElementA extends Element {
  accept(visitor) {
    visitor.visitConcreteElementA(this);
  }

  operationA() {
    return "ElementA";
  }
}

class ConcreteElementB extends Element {
  accept(visitor) {
    visitor.visitConcreteElementB(this);
  }

  operationB() {
    return "ElementB";
  }
}

// 访问者类
class Visitor {
  visitConcreteElementA(element) {
    console.log(`访问者访问 ${element.operationA()}`);
  }

  visitConcreteElementB(element) {
    console.log(`访问者访问 ${element.operationB()}`);
  }
}

// 客户端调用
const elementA = new ConcreteElementA();
const elementB = new ConcreteElementB();
const visitor = new Visitor();

elementA.accept(visitor); // 访问者访问 ElementA
elementB.accept(visitor); // 访问者访问 ElementB

中介者模式(Mediator)

中介者模式(Mediator Pattern)用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。

前端中的中介者模式场景

  • MVC/MVVM框架:Controller 或 ViewModel 充当中介者,协调 View 和 Model 之间的交互。
  • 复杂表单交互:例如,选择省份后,城市下拉框需要更新;选择城市后,区县下拉框需要更新。使用中介者统一管理这些联动逻辑。
  • 聊天室:用户之间不直接发送消息,而是通过服务器(中介者)转发。

中介者模式-JS实现

// 中介者
class ChatMediator {
  constructor() {
    this.users = [];
  }

  addUser(user) {
    this.users.push(user);
    user.setMediator(this);
  }

  sendMessage(message, user) {
    this.users.forEach(u => {
      if (u !== user) {
        u.receive(message);
      }
    });
  }
}

// 用户类
class User {
  constructor(name) {
    this.name = name;
    this.mediator = null;
  }

  setMediator(mediator) {
    this.mediator = mediator;
  }

  send(message) {
    console.log(`${this.name} 发送消息: ${message}`);
    this.mediator.sendMessage(message, this);
  }

  receive(message) {
    console.log(`${this.name} 收到消息: ${message}`);
  }
}

// 客户端调用
const mediator = new ChatMediator();
const user1 = new User("User1");
const user2 = new User("User2");
const user3 = new User("User3");

mediator.addUser(user1);
mediator.addUser(user2);
mediator.addUser(user3);

user1.send("大家好!");
// 输出:
// User1 发送消息: 大家好!
// User2 收到消息: 大家好!
// User3 收到消息: 大家好!

解释器模式(Interpreter)

解释器模式(Interpreter Pattern)给定一个语言,定义它的文法的一种表示,并定义一个解释器,该解释器用来解释语言中的句子。

前端中的解释器模式场景

  • 模板引擎MustacheHandlebars 等模板引擎,解析模板字符串并生成 HTML。
  • 编译器前端:将代码解析为 AST。
  • 数学表达式计算:解析并计算字符串形式的数学表达式。

解释器模式-JS实现

// 抽象表达式
class Expression {
  interpret(context) {
    throw new Error("抽象方法不能调用");
  }
}

// 终结符表达式:数字
class NumberExpression extends Expression {
  constructor(number) {
    super();
    this.number = number;
  }

  interpret(context) {
    return this.number;
  }
}

// 非终结符表达式:加法
class AddExpression extends Expression {
  constructor(left, right) {
    super();
    this.left = left;
    this.right = right;
  }

  interpret(context) {
    return this.left.interpret(context) + this.right.interpret(context);
  }
}

// 非终结符表达式:减法
class SubtractExpression extends Expression {
  constructor(left, right) {
    super();
    this.left = left;
    this.right = right;
  }

  interpret(context) {
    return this.left.interpret(context) - this.right.interpret(context);
  }
}

// 客户端调用:计算 10 + 5 - 2
const expression = new SubtractExpression(
  new AddExpression(new NumberExpression(10), new NumberExpression(5)),
  new NumberExpression(2)
);

console.log(expression.interpret()); // 13

项目地址

设计模式-结构型

作者 牛奶
2026年2月11日 16:00

设计模式-结构型

本文主要介绍下结构型设计模式,包括适配器模式装饰器模式代理模式外观模式桥接模式组合模式享元模式,提供前端场景和 ES6 代码的实现过程。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

引言

本文主要介绍下结构型设计模式,包括适配器模式装饰器模式代理模式外观模式桥接模式组合模式享元模式,提供前端场景和 ES6 代码的实现过程。

什么是结构型

结构型模式(Structural Patterns)主要关注对象组合。这些模式描述了如何将类或对象结合在一起形成更大的结构,同时保持结构的灵活高效。结构型模式通过继承组合的方式来简化系统的设计,解决对象之间的耦合问题,使得系统更加容易扩展和维护。

适配器模式(Adapter)

适配器模式(Adapter Pattern)将一个类的接口转换成客户希望的另一个接口,使原本因接口不兼容而不能一起工作的类可以一起工作。

适配器模式通常用于包装现有的类,以便与新的接口或系统进行交互。

前端中的适配器模式场景

  • 接口数据适配:后端返回的数据结构可能与前端组件需要的数据结构不一致,可以使用适配器模式进行转换。
  • 旧接口兼容:在系统重构或升级时,保持对旧接口的兼容性,使用适配器模式将新接口映射到旧接口。
  • Vue计算属性:Vue 中的 computed 属性可以看作是一种适配器,将原始数据转换为视图需要的数据格式。

适配器模式-JS实现

// 旧接口
class OldCalculator {
  constructor() {
    this.operations = function(term1, term2, operation) {
      switch (operation) {
        case 'add':
          return term1 + term2;
        case 'sub':
          return term1 - term2;
        default:
          return NaN;
      }
    };
  }
}

// 新接口
class NewCalculator {
  add(term1, term2) {
    return term1 + term2;
  }

  sub(term1, term2) {
    return term1 - term2;
  }
}

// 适配器类
class CalculatorAdapter {
  constructor() {
    this.calculator = new NewCalculator();
  }

  operations(term1, term2, operation) {
    switch (operation) {
      case 'add':
        return this.calculator.add(term1, term2);
      case 'sub':
        return this.calculator.sub(term1, term2);
      default:
        return NaN;
    }
  }
}

// 客户端调用
const oldCalc = new OldCalculator();
console.log(oldCalc.operations(10, 5, 'add')); // 15

const newCalc = new NewCalculator();
console.log(newCalc.add(10, 5)); // 15

const adapter = new CalculatorAdapter();
console.log(adapter.operations(10, 5, 'add')); // 15

装饰器模式(Decorator)

装饰器模式(Decorator Pattern动态地给一个对象添加一些额外的职责,而不影响该对象所属类的其他实例。

装饰器模式提供了一种灵活的替代继承方案,用于扩展对象的功能。

前端中的装饰器模式场景

  • 高阶组件(HOC):在 React 中,高阶组件本质上就是装饰器模式的应用,用于复用组件逻辑。
  • 类装饰器:在 ES7 装饰器语法或 TypeScript 中,可以使用装饰器来增强类或类的方法,例如用于日志记录、性能监控、权限控制等。

装饰器模式-JS实现

// 原始对象
class Circle {
  draw() {
    console.log("画一个圆形");
  }
}

// 装饰器基类
class Decorator {
  constructor(circle) {
    this.circle = circle;
  }

  draw() {
    this.circle.draw();
  }
}

// 具体装饰器:添加红色边框
class RedBorderDecorator extends Decorator {
  draw() {
    this.circle.draw();
    this.setRedBorder();
  }

  setRedBorder() {
    console.log("添加红色边框");
  }
}

// 客户端调用
const circle = new Circle();
circle.draw();
// 输出:
// 画一个圆形

const redCircle = new RedBorderDecorator(new Circle());
redCircle.draw();
// 输出:
// 画一个圆形
// 添加红色边框

代理模式(Proxy)

代理模式(Proxy Pattern)为其他对象提供一种代理以控制对这个对象的访问。

代理模式可以在访问对象之前或之后执行额外的操作,如权限验证、延迟加载、缓存等。

前端中的代理模式场景

  • 数据响应式Vue 3 使用 Proxy 对象来实现数据的响应式系统,拦截对象的读取和修改操作。
  • 网络请求代理:在开发环境中,配置代理服务器(如 webpack-dev-server 的 proxy)解决跨域问题。
  • 虚拟代理:例如图片懒加载,先显示占位图,等图片加载完成后再替换为真实图片。
  • 缓存代理:对于开销较大的计算结果或网络请求结果进行缓存,下次请求时直接返回缓存结果。

代理模式-JS实现

// 真实图片加载类
class RealImage {
  constructor(fileName) {
    this.fileName = fileName;
    this.loadFromDisk(fileName);
  }

  loadFromDisk(fileName) {
    console.log("正在从磁盘加载 " + fileName);
  }

  display() {
    console.log("显示 " + this.fileName);
  }
}

// 代理图片类
class ProxyImage {
  constructor(fileName) {
    this.fileName = fileName;
  }

  display() {
    if (!this.realImage) {
      this.realImage = new RealImage(this.fileName);
    }
    this.realImage.display();
  }
}

// 客户端调用
const image = new ProxyImage("test.jpg");

// 第一次调用,加载图片
image.display();
// 输出:
// 正在从磁盘加载 test.jpg
// 显示 test.jpg

// 第二次调用,直接显示
image.display();
// 输出:
// 显示 test.jpg

外观模式(Facade)

外观模式(Facade Pattern)提供一个统一的接口,用来访问子系统中的一群接口。外观模式定义了一个高层接口,让子系统更容易使用。

前端中的外观模式场景

  • 浏览器兼容性封装:封装不同浏览器的 API 差异,提供统一的接口。例如,封装事件监听函数,统一处理 addEventListenerattachEvent
  • 简化复杂库的使用:例如 jQueryAxios,它们为复杂的原生 DOM 操作或 XMLHttpRequest 提供了简单易用的接口。

外观模式-JS实现

// 子系统1:灯光
class Light {
  on() {
    console.log("开灯");
  }
  off() {
    console.log("关灯");
  }
}

// 子系统2:电视
class TV {
  on() {
    console.log("打开电视");
  }
  off() {
    console.log("关闭电视");
  }
}

// 子系统3:音响
class SoundSystem {
  on() {
    console.log("打开音响");
  }
  off() {
    console.log("关闭音响");
  }
}

// 外观类:家庭影院
class HomeTheaterFacade {
  constructor(light, tv, sound) {
    this.light = light;
    this.tv = tv;
    this.sound = sound;
  }

  watchMovie() {
    console.log("--- 准备看电影 ---");
    this.light.off();
    this.tv.on();
    this.sound.on();
  }

  endMovie() {
    console.log("--- 结束看电影 ---");
    this.light.on();
    this.tv.off();
    this.sound.off();
  }
}

// 客户端调用
const light = new Light();
const tv = new TV();
const sound = new SoundSystem();
const homeTheater = new HomeTheaterFacade(light, tv, sound);

homeTheater.watchMovie();
// 输出:
// --- 准备看电影 ---
// 关灯
// 打开电视
// 打开音响

homeTheater.endMovie();
// 输出:
// --- 结束看电影 ---
// 开灯
// 关闭电视
// 关闭音响

桥接模式(Bridge)

桥接模式(Bridge Pattern)将抽象部分与它的实现部分分离,使它们可以独立地变化。

前端中的桥接模式场景

  • UI组件与渲染引擎分离:例如,一个通用的图表库,可以将图表的逻辑(抽象部分)与具体的渲染方式(实现部分,如 Canvas、SVG、WebGL)分离。
  • 事件监听:在绑定事件时,将回调函数(实现部分)与事件绑定(抽象部分)分离,使得回调函数可以复用。

桥接模式-JS实现

// 实现部分接口:颜色
class Color {
  fill() {
    throw new Error("抽象方法不能调用");
  }
}

class Red extends Color {
  fill() {
    return "红色";
  }
}

class Green extends Color {
  fill() {
    return "绿色";
  }
}

// 抽象部分:形状
class Shape {
  constructor(color) {
    this.color = color;
  }

  draw() {
    throw new Error("抽象方法不能调用");
  }
}

class Circle extends Shape {
  draw() {
    console.log(`画一个${this.color.fill()}的圆形`);
  }
}

class Square extends Shape {
  draw() {
    console.log(`画一个${this.color.fill()}的正方形`);
  }
}

// 客户端调用
const redCircle = new Circle(new Red());
redCircle.draw(); // 画一个红色的圆形

const greenSquare = new Square(new Green());
greenSquare.draw(); // 画一个绿色的正方形

组合模式(Composite)

组合模式(Composite Pattern)将对象组合成树形结构以表示“部分-整体”的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。

前端中的组合模式场景

  • DOM树:DOM 树本身就是一个典型的组合模式结构,既包含具体的节点(如 divspan),也包含包含其他节点的容器。
  • 虚拟DOMVirtual DOM 也是树形结构,组件可以包含其他组件或原生元素。
  • 文件目录系统:文件夹可以包含文件或子文件夹。
  • 级联菜单:多级菜单的展示和操作。

组合模式-JS实现

// 组件基类
class Component {
  constructor(name) {
    this.name = name;
  }

  add(component) {
    throw new Error("不支持该操作");
  }

  remove(component) {
    throw new Error("不支持该操作");
  }

  print(indent = "") {
    throw new Error("不支持该操作");
  }
}

// 叶子节点:文件
class File extends Component {
  print(indent = "") {
    console.log(`${indent}- ${this.name}`);
  }
}

// 组合节点:文件夹
class Folder extends Component {
  constructor(name) {
    super(name);
    this.children = [];
  }

  add(component) {
    this.children.push(component);
  }

  remove(component) {
    const index = this.children.indexOf(component);
    if (index > -1) {
      this.children.splice(index, 1);
    }
  }

  print(indent = "") {
    console.log(`${indent}+ ${this.name}`);
    this.children.forEach(child => {
      child.print(indent + "  ");
    });
  }
}

// 客户端调用
const root = new Folder("根目录");
const folder1 = new Folder("文档");
const folder2 = new Folder("图片");

const file1 = new File("简历.doc");
const file2 = new File("照片.jpg");
const file3 = new File("logo.png");

root.add(folder1);
root.add(folder2);
folder1.add(file1);
folder2.add(file2);
folder2.add(file3);

root.print();
// 输出:
// + 根目录
//   + 文档
//     - 简历.doc
//   + 图片
//     - 照片.jpg
//     - logo.png

享元模式(Flyweight)

享元模式(Flyweight Pattern)通过共享来高效地支持大量细粒度的对象。

前端中的享元模式场景

  • 事件委托:在父元素上绑定事件监听器,通过事件冒泡处理子元素的事件,避免为每个子元素绑定监听器,节省内存。
  • 对象池:在游戏开发或复杂动画中,预先创建一组对象放入池中,使用时取出,用完归还,避免频繁创建和销毁对象。
  • DOM复用:在长列表滚动(虚拟滚动)中,只渲染可视区域的 DOM 节点,回收并复用移出可视区域的节点。

享元模式-JS实现

// 享元工厂
class ShapeFactory {
  constructor() {
    this.circleMap = {};
  }

  getCircle(color) {
    if (!this.circleMap[color]) {
      this.circleMap[color] = new Circle(color);
      console.log(`创建新的 ${color} 圆形`);
    }
    return this.circleMap[color];
  }
}

// 具体享元类
class Circle {
  constructor(color) {
    this.color = color;
  }

  draw(x, y) {
    console.log(`在 (${x}, ${y}) 画一个 ${this.color} 的圆形`);
  }
}

// 客户端调用
const factory = new ShapeFactory();

const redCircle1 = factory.getCircle("红色");
redCircle1.draw(10, 10);

const redCircle2 = factory.getCircle("红色");
redCircle2.draw(20, 20);

const blueCircle = factory.getCircle("蓝色");
blueCircle.draw(30, 30);

console.log(redCircle1 === redCircle2); // true
// 输出:
// 创建新的 红色 圆形
// 在 (10, 10) 画一个 红色 的圆形
// 在 (20, 20) 画一个 红色 的圆形
// 创建新的 蓝色 圆形
// 在 (30, 30) 画一个 蓝色 的圆形
// true

项目地址

❌
❌