JavaScript:执行上下文、对象与异步协作
最后核验:2026-06-30|适用版本:ECMAScript 2024–2026、现代常青浏览器与 Node.js 22+|前置知识:HTML
JavaScript 语言与浏览器宿主的边界
JavaScript 是 ECMAScript 语言在浏览器等宿主中的实现。语言规范定义值、对象、执行上下文、模块、Promise Job 等;HTML 和 Web API 规范定义事件循环、DOM、Fetch、计时器与渲染机会。混淆两层,就会把 setTimeout 当成语言语法,或误以为 Promise 会创建线程。
需要掌握执行模型,才能解释闭包为何保留变量、this 为何变化、循环依赖为何出现暂时性死区、await 后的代码何时继续,以及长任务为什么同时阻塞输入和渲染。
JavaScript 不适合替代声明式 HTML/CSS,也不应在主线程承担可拆分的重 CPU 工作。能用链接、表单约束、Grid 或数据库约束完成的事,优先交给对应平台;必须访问 DOM 的协调逻辑留在主线程,纯计算达到可测瓶颈后再交给 Worker。
读完本篇后,应能独立回答四组问题:一段源码怎样从文本变成可执行代码;值、绑定、对象和函数怎样决定同步行为;任务、Promise、计时器和取消怎样决定异步顺序;代码为何变慢、泄漏或在不同运行时出现差异。语法记忆只是起点,重点是能从现象反推规范机制并写出最小复现。
从源文本到机器执行的完整链路
JavaScript 引擎不会逐行“解释字符串”。输入先经过词法与语法分析,得到引擎内部语法树;随后创建 Script 或 Module Record、声明绑定并生成可执行表示。现代引擎通常先生成字节码或其他中间表示,收集运行时类型与调用反馈,再把热点路径优化为机器码;假设失效时可回退到较通用代码。ECMAScript 规范规定可观察语义,不规定字节码、JIT(即时编译)层级或具体优化器,因此不能把某个引擎的内部结构当成跨浏览器契约。
这条链解释三类常见故障:语法错误发生在业务分支执行前;模块循环可能在求值前已经成功解析和链接;同一函数先快后慢可能来自输入形状变化导致优化假设失效。工程上不要通过“让所有对象属性添加顺序一致”之类引擎技巧提前优化,先用 Performance/CPU profile 证明热点,再减少算法工作量和分配。
eval 与 Function 会把字符串重新送入编译链,还改变静态分析、内容安全策略(CSP)和优化边界。业务数据不应变成代码;表达式、规则或模板需求应设计有限数据结构和解释器,而不是执行不可信字符串。ECMAScript Scripts and Modules
值、类型转换与相等关系
ECMAScript 语言值分为 Undefined、Null、Boolean、String、Symbol、Number、BigInt 和 Object。前七类是原始值;对象具有身份,即使内容相同也不是同一个值。typeof null === 'object' 是历史兼容行为;数组、日期和函数的精确类别不能只靠 typeof 判断。
Number、BigInt 与数值边界
Number 使用 IEEE 754 双精度浮点,能精确表示的整数范围由 Number.MIN_SAFE_INTEGER 与 Number.MAX_SAFE_INTEGER 限定。二进制浮点不能精确表示许多十进制小数,因此金额不能用 0.1 + 0.2 === 0.3 作为正确性前提。货币通常以最小单位整数保存,或使用经过评估的十进制定点方案;显示交给 Intl.NumberFormat。
NaN 表示无效数值结果,且与自身不相等;使用 Number.isNaN 检查。Infinity 是合法 Number,不应被 typeof value === 'number' 误当作可接受业务输入。边界验证通常同时要求 Number.isFinite、整数性和范围。
function readQuantity(value) {
const number = typeof value === 'string' && value.trim() !== ''
? Number(value)
: value;
if (!Number.isInteger(number) || number < 0 || number > 10_000) {
throw new RangeError('数量必须是 0 到 10000 的整数');
}
return number;
}
console.assert(readQuantity('12') === 12);
console.assert(Number.isNaN(Number('十二')));BigInt 用于超出安全整数范围的整数运算,不能与 Number 隐式混算,也不能直接交给 JSON.stringify。它解决整数范围,不解决小数、时区或货币舍入规则。
String、Unicode 与文本边界
JavaScript String 是 UTF-16 code unit 序列。length、索引和 slice 按 code unit 工作,一个可见字符可能占两个 code unit,多个 code point 还可能组合成一个用户感知字符。按字符截断昵称、光标移动或长度计费时,应明确需要 code unit、code point 还是 grapheme cluster(字素簇);后者可使用 Intl.Segmenter。
const text = 'A🙂e\u0301';
console.assert(text.length === 5); // UTF-16 code unit
console.assert([...text].length === 4); // Unicode code point
const graphemes = [...new Intl.Segmenter('zh-CN', { granularity: 'grapheme' }).segment(text)];
console.assert(graphemes.length === 3); // 用户感知字符大小写转换和排序也可能依赖 locale。标识符、协议 token 通常使用规范规定的 ASCII 规则;用户文本搜索、排序和展示再使用明确 locale 的 Intl.Collator、toLocaleLowerCase 等能力。不要把界面语言规则用于安全协议比较。
隐式转换、真假值与相等
条件判断会执行 ToBoolean。假值只有 false、0、-0、0n、NaN、空字符串、null、undefined,以及浏览器历史对象 document.all;空数组和空对象都是真值。value || fallback 会把 0、false、空字符串一并当缺失,保留合法假值时用 value ?? fallback。
=== 不做类型转换,但 NaN !== NaN 且 0 === -0。Object.is 区分正负零并认为两个 NaN 相同;Map、Set 和 includes 使用 SameValueZero,认为 NaN 相同且不区分正负零。宽松相等 == 经过多步转换,除有意使用 value == null 同时匹配 null/undefined 等清楚边界外,业务代码应优先显式转换和严格相等。
| 需求 | 正确选择 | 易错写法 |
|---|---|---|
| 判断同类型业务值 | === | 依赖 == 转换字符串数字 |
检查 NaN | Number.isNaN(value) | value === NaN |
判断数组是否含 NaN | array.includes(NaN) | array.indexOf(NaN) |
| 只为 null/undefined 提供默认值 | value ?? fallback | `value |
区分 0 与 -0 | Object.is(value, -0) | value === -0 |
类型转换的调试方法是把隐式步骤改写成显式调用:Number(value)、String(value)、Boolean(value),再逐个观察。规范中的 ToPrimitive、ToNumber、ToString 等抽象操作是解释结果的依据,不是可直接调用的 API。ECMAScript Abstract Operations
声明、作用域、提升与暂时性死区
let、const、class 创建块级词法绑定;var 创建函数或全局变量绑定;函数声明的实例化规则还取决于 Script/Module、严格模式和所在语句位置。所谓“提升”不是源码移动,而是执行前创建绑定的结果。
var 绑定在进入作用域时初始化为 undefined,所以声明前读取不会报错但常产生错误结果;let、const、class 从作用域开始到执行初始化之间处于 TDZ(Temporal Dead Zone,暂时性死区),读取会抛 ReferenceError。const 限制绑定不能重新赋值,不会冻结对象内容。
{
// console.log(status); // ReferenceError:处于 TDZ
const status = { ready: false };
status.ready = true; // 合法:修改对象,不是重绑 status
// status = {}; // TypeError:不能重新赋值 const 绑定
}ESM 顶层绑定属于模块作用域,模块默认严格模式,不会像经典 Script 顶层 var 一样成为 globalThis 属性。浏览器 Console 为便于交互可能使用特殊求值规则,不能用一次 Console 尝试推断模块语义。
循环闭包是区分绑定的重要例子:for (let i...) 每次迭代建立独立绑定;var i 共享一个函数级绑定。修复应改用块级声明,而不是为每次迭代再造立即执行函数。
函数、参数与组合边界
函数既是可调用对象,也是闭包。函数声明、函数表达式、箭头函数、方法和 generator 的差异不只是写法:是否有自己的 this、arguments、super、new.target,能否作为构造器,以及原型属性都不同。
| 形式 | 自有 this | 可 new | 典型用途 |
|---|---|---|---|
function name(){} | 有,取决于调用方式 | 通常可以 | 普通函数、需要动态 receiver 或构造协议 |
(x) => x * 2 | 无,捕获外层 | 不可以 | 短回调、保留外层 this |
{ method() {} } | 有,取决于调用方式 | 不可以直接 new | 对象/类方法与 super |
function* () {} | 有 | 不可以 | 惰性迭代器 |
async function () {} | 有 | 不可以 | 返回 Promise 的异步流程 |
参数按值传递;对象参数传递的是对象引用这个值。函数可以修改对象,却不能通过给形参重赋值替换调用者变量。默认参数在独立参数环境中从左到右求值,可以引用前面的参数,不能安全依赖函数体内声明。
function rename(entry, name = entry.name.trim()) {
entry.name = name; // 修改同一个对象
entry = { ...entry }; // 只重绑局部参数
return entry;
}高阶函数接收或返回函数。map、filter、reduce 适合无副作用的数据变换,但不需要把每个流程强行压成一条链:当错误处理、提前退出或多个局部名称能提高可读性时,普通循环更清楚。回调的返回约定必须明确,尤其不要写 array.forEach(async ...) 期待外层等待。
尾调用、内联和函数分配属于引擎实现细节。选择函数抽象的首要标准是输入输出关系、错误边界与生命周期,而不是猜测某种语法“更快”。
执行上下文、词法环境与闭包
每段正在求值的代码位于 Execution Context(执行上下文)中。上下文关联 Realm、Lexical Environment、Variable Environment、函数和当前 Script/Module。函数调用把新上下文压栈,返回后弹栈;上下文本身不是操作系统线程。ECMAScript 执行上下文
词法环境由 Environment Record 与外层引用构成。标识符解析沿外层链查找绑定。闭包不是“函数复制了外部值”,而是函数保留了创建时可到达的词法环境;因此循环、缓存和事件监听器都可能延长对象生命周期。
function createSelection(limit = 4) {
let ids = [];
return (id) => {
ids = ids.includes(id)
? ids.filter((item) => item !== id)
: ids.length < limit ? [...ids, id] : ids;
return [...ids];
};
}属性描述符、原型链与 this
对象是属性集合与一组内部槽、内部方法的组合。属性描述符控制 value/get/set、writable、enumerable、configurable;普通属性读取先查自身,再沿 [[Prototype]] 链查找。class 提供一致语法,但仍建立原型关系,不把 JavaScript 变成基于类复制的对象模型。
this 由调用形式决定:object.method() 把 base object 作为 receiver;fn.call(value) 显式指定;普通函数直接调用在严格模式下是 undefined;箭头函数不创建自己的 this,而是捕获外层绑定。不要通过“函数定义在哪个对象里”猜 this。
属性读取调用对象的 [[Get]],赋值调用 [[Set]];getter/setter 是访问器描述符,不是普通 data property。Object.defineProperty 省略的布尔属性默认是 false,这与对象字面量创建的可写、可枚举、可配置属性不同。调试“属性存在却遍历不到”时,先用 Object.getOwnPropertyDescriptor 和 Reflect.ownKeys,不要只看 Object.keys。
const entry = {};
Object.defineProperty(entry, 'id', { value: 'javascript' });
console.assert(entry.id === 'javascript');
console.assert(Object.keys(entry).length === 0); // enumerable 默认为 false
console.assert(Object.getOwnPropertyDescriptor(entry, 'id').writable === false);对象展开只复制可枚举自身属性并触发读取,既不复制原型,也不保留 getter/setter 描述符;嵌套对象仍共享。需要深复制平台支持的数据时可用 structuredClone,但函数、DOM 节点和部分宿主对象不可克隆,类实例也不会保留自定义原型契约。复制不是不可变设计的替代品。
class:字段、私有状态、继承与组合
class 定义会创建构造器函数和 prototype methods,但具有自己的严格语义:类声明受 TDZ 约束;类体默认严格模式;方法不可作为构造器;调用类必须使用 new。实例字段在创建实例时初始化,方法通常共享在 prototype 上。
class Selection {
static maxDefault = 4;
#ids = new Set();
constructor(limit = Selection.maxDefault) {
if (!Number.isInteger(limit) || limit < 1) {
throw new RangeError('limit 必须是正整数');
}
this.limit = limit;
}
get size() {
return this.#ids.size;
}
has(id) {
return this.#ids.has(id);
}
toggle(id) {
if (this.#ids.delete(id)) return true;
if (this.#ids.size >= this.limit) return false;
this.#ids.add(id);
return true;
}
toJSON() {
return [...this.#ids];
}
}
const selection = new Selection(2);
console.assert(selection.toggle('html'));
console.assert(selection.toggle('css'));
console.assert(!selection.toggle('javascript'));
console.assert(JSON.stringify(selection) === '["html","css"]');public field、private field 与 static
public field 写在每个实例自身;private field 使用 #name,由语言执行 brand check,类外不能用字符串或方括号访问。TypeScript 的 private 是否产生运行时私有要看发射目标和写法,不能与 JavaScript #private 混淆。static field/method 属于类本身,不属于实例;static block 适合需要多步的类级初始化,但不应隐藏 I/O 或环境副作用。
私有字段提高封装,不自动解决序列化:JSON.stringify 只枚举公开可枚举属性,上例通过显式 toJSON 暴露稳定快照。不要把私有字段依赖的内部对象直接返回,否则调用者仍能修改内部状态。
extends、super 与派生构造器
extends 建立两条原型关系:实例原型链 Derived.prototype → Base.prototype,构造器链 Derived → Base。派生构造器在访问 this 前必须调用 super();覆盖方法可用 super.method() 访问父类实现。字段初始化顺序会影响覆盖:基类字段在基类构造阶段初始化,派生字段在 super() 返回后初始化。
class BaseEntry {
constructor(id) {
this.id = id;
}
label() {
return this.id;
}
}
class ArticleEntry extends BaseEntry {
constructor(id, title) {
super(id);
this.title = title;
}
label() {
return `${super.label()}: ${this.title}`;
}
}在基类构造器中调用可覆盖方法风险很高:派生字段尚未初始化,实际 dispatch 却可能进入派生实现。构造器只建立自身不变量;需要多态初始化时,在实例完整创建后显式调用方法。
this 丢失与方法绑定
把 instance.method 单独传给事件系统会丢失 receiver。修正有三种:调用处包装 () => instance.method();构造时 bind;把成员写成箭头 field。前两种保留 prototype 共享方法,箭头 field 每个实例创建一个函数但自然捕获实例。选择依据是 API 生命周期,不要为所有方法统一 bind。
class Counter {
count = 0;
increment() { this.count += 1; }
}
const counter = new Counter();
const callback = counter.increment;
// callback(); // TypeError:严格模式下 this 为 undefined
queueMicrotask(() => counter.increment());继承、组合与内建类型扩展
继承表达可替换关系,并把父类生命周期、protected assumptions 和构造协议带给所有子类。只有共享数据变换时,组合函数通常更直接:把 search(entries, query) 作为参数或模块函数,不必创建 SearchableCatalog extends Catalog。DOM 元素、Array、Error 等内建类型可以扩展,但跨 realm、序列化、species 与框架代理会增加边界;应用领域错误扩展 Error 是常见合理场景,其余先评估普通对象组合。
class 易错点的定位表:
| 表现 | 根因 | 修正 |
|---|---|---|
方法作为回调时报 this 为 undefined | receiver 在调用点丢失 | 包装、显式 bind 或按需箭头 field |
派生构造器访问 this 报错 | super() 尚未执行 | 先验证参数或调用 super(),再访问实例 |
#field in object 为 false | 对象未通过该类的 brand 初始化 | 不伪造实例;在边界验证公开协议 |
| JSON 缺少私有状态 | 私有字段不是可枚举属性 | 定义显式序列化方法/DTO |
| 基类构造期间派生方法读到 undefined | 过早虚调用 | 构造器只建不变量,完成后显式初始化 |
instanceof 跨 iframe 失败 | 两个 realm 的构造器身份不同 | 用公开协议、brand API 或结构验证 |
模块链接、实时绑定与循环依赖
ECMAScript Module(ESM)先解析与链接整个依赖图,再求值。导入是只读的 live binding(实时绑定),能观察导出方后续更新;它不是导出时值的拷贝。循环依赖可以链接,但若模块在绑定初始化前读取它,会触发 ReferenceError。解决方法通常是把共享常量移到无环叶子模块,或把读取延后到函数调用,而不是用动态导入掩盖任意环。
模块 specifier 如何变成 URL/文件由宿主决定。浏览器原生 ESM 通常要求相对路径包含 .//../ 并按 URL 加载,bare specifier 需要 import map;Node.js 根据 package type、扩展名和 exports 等规则解析;bundler 还可能增加 alias 和虚拟模块。源码能通过 TypeScript 不代表目标宿主能解析同一 specifier。
模块经历 Parse、Instantiate/Link、Evaluate:
- Parse 识别静态
import/export并建立请求列表。 - Link 解析整个依赖图,为导入导出建立 live binding;缺失导出在运行模块体前失败。
- Evaluate 按依赖顺序执行顶层代码,每个模块实例只求值一次。
- 带 top-level
await的模块把异步求值传播给依赖者,环中的等待关系可能让启动难以推理。
静态 import 让工具构建依赖图和 tree shaking;import() 在运行时返回 Promise,适合真正可选、路由级或成本高的模块,不用于修复随意循环。动态导入路径仍是代码加载信任边界,不能让用户输入直接决定任意模块 URL。
// feature-loader.mjs
const allowed = new Map([
['search', () => import('./search.mjs')],
['chart', () => import('./chart.mjs')],
]);
export async function loadFeature(name) {
const load = allowed.get(name);
if (!load) throw new RangeError(`未知功能:${name}`);
return load();
}CommonJS 的 require/module.exports 与 ESM 的链接/实时绑定模型不同,互操作行为取决于 Node/bundler。新浏览器代码使用 ESM;迁移 Node 包时同时检查 package exports、默认/命名导入、同步 require 调用者和测试运行时,不用一个 esModuleInterop 选项概括运行时兼容。
顶层副作用会在首次导入时执行,增加顺序耦合并妨碍按需加载。模块宜导出函数/值,把 DOM 注册、计时器、网络和环境读取放入显式 start() 边界;确实需要一次性 polyfill 或自注册插件时,在入口明确导入并记录顺序。
事件循环、Promise Job 与异步取消
浏览器事件循环从一个或多个 task queue 选择可运行任务。完成一个 task 后执行 microtask checkpoint,清空可运行微任务;随后浏览器才可能更新渲染。持续追加微任务会饿死渲染,这和一个长同步任务同样会造成页面卡顿。HTML Event Loops
Promise reaction 是 Job,在浏览器中由宿主排入微任务队列。async 函数总是返回 Promise;await 暂停当前 async 上下文,并在等待值完成后排队继续。它不阻塞线程,也不自动并行。并发启动多个独立请求应先创建 Promise 再 Promise.all;逐个 await 会串行。
Promise 组合、错误传播与协作取消
Promise 有 pending、fulfilled、rejected 三种状态,一旦 settled(fulfilled 或 rejected)就不可逆。resolve(value) 若收到 Promise/thenable 会采用其最终状态,而不是简单把对象作为 fulfilled 值;自己实现 thenable 容易产生多次调用、递归和异常边界问题,业务代码直接使用原生 Promise。
then(onFulfilled, onRejected) 总返回新 Promise。回调返回普通值会 fulfill 新 Promise,返回 Promise 会被展开,抛错会 reject;漏写 return 会让链提前得到 undefined。catch(fn) 等价于 then(undefined, fn),finally 用于不改变结果的清理;finally 中抛错或返回 rejected Promise 会覆盖原结果。
组合方法按失败语义选择:
| API | 完成条件 | 适用场景 | 关键边界 |
|---|---|---|---|
Promise.all | 全部成功;任一失败即 reject | 结果全部必需的独立任务 | fail-fast 不会自动取消其他任务 |
Promise.allSettled | 全部 settled | 批量任务要展示逐项结果 | 调用者必须检查每项 status |
Promise.any | 任一成功;全失败为 AggregateError | 多副本/候选源取首个成功 | 其他任务仍需取消或允许完成 |
Promise.race | 首个 settled | 与超时/外部信号竞速 | 只丢弃晚结果,不停止底层工作 |
async function loadBoth(signal) {
const [catalog, preferences] = await Promise.all([
fetch('/catalog.json', { signal }).then(checkResponse),
fetch('/preferences.json', { signal }).then(checkResponse),
]);
return { catalog: await catalog.json(), preferences: await preferences.json() };
}
function checkResponse(response) {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response;
}这里两个请求在进入 Promise.all 前已经启动。若第二项失败,第一项不会自动中止;共享同一个 AbortSignal 能让上层在不再需要整个结果时显式取消。对写操作还要考虑幂等:客户端取消只表示不再等待,服务器可能已经提交。
AbortSignal 是宿主协作协议,不是 ECMAScript Promise 状态。支持它的函数应尽早检查 signal.throwIfAborted(),把 signal 继续传给下游,并在 abort 时移除 listener、关闭流或终止 Worker。不要把所有取消包装成普通失败提示。
async function withTimeout(work, milliseconds) {
const signal = AbortSignal.timeout(milliseconds);
try {
return await work(signal);
} catch (error) {
if (signal.aborted) throw new Error(`操作超过 ${milliseconds} ms`, { cause: error });
throw error;
}
}异步错误有三层:同步启动阶段抛错、Promise rejection、宿主事件错误。try/catch 只有在 await 对应 Promise 时才接入 rejection;fire-and-forget 操作必须显式 void task().catch(reportError),否则可能成为 unhandled rejection。全局 rejection/error 监听只做兜底遥测,不能替代局部恢复。
错误对象应保留 cause 和可操作上下文,不把原始 token、响应体或用户隐私塞进 message。自定义领域错误可扩展 Error,用稳定 code 做程序分支、用 message 给日志;不要按本地化文案字符串判断错误类型。
class CatalogError extends Error {
constructor(code, message, options) {
super(message, options);
this.name = 'CatalogError';
this.code = code;
}
}
try {
await loadBoth(new AbortController().signal);
} catch (cause) {
throw new CatalogError('CATALOG_LOAD_FAILED', '技术目录加载失败', { cause });
}时间模型:Date、Temporal、Intl 与计时器
“时间”至少包含四个不同问题:某个绝对瞬间、某地墙上显示的日期时间、经过的持续时长,以及某项工作何时调度。把它们都塞进 Date 或 setTimeout 会产生时区、夏令时、系统时钟调整和任务延迟 bug。
| 问题 | 推荐模型 | 不应使用 |
|---|---|---|
| 记录事件发生瞬间 | UTC epoch/ISO instant;可用 Date 或可用时的 Temporal.Instant | 无时区的本地日期字符串 |
| 用户生日、账期日 | calendar date;Temporal.PlainDate 或结构化年月日 | 午夜 UTC 伪装纯日期 |
| 某地区会议时间 | local date-time + IANA time zone;Temporal.ZonedDateTime | 只保存当前 offset |
| 测量函数耗时 | performance.now() | Date.now() 差值 |
| 安排界面下一帧 | requestAnimationFrame | setInterval(..., 16) |
| 超时取消异步操作 | AbortSignal.timeout 或 controller + timer | 只用 Promise.race 丢弃结果但不取消工作 |
Date 表示一个毫秒级时间值
Date 实例核心是从 Unix epoch 起的毫秒 Number;getter 把同一时间值解释为本地或 UTC 字段。new Date() 不“存储时区”,getTimezoneOffset() 也只是对该瞬间按宿主时区求出的 offset。序列化通常使用 toISOString() 输出 UTC。
规范只保证 Date Time String Format 等格式。new Date('01/02/2026') 之类区域字符串可能歧义,不应作为数据协议;使用带 Z/offset 的 ISO 瞬间,或自己验证明确字段。日期月份参数从 0 开始也是高频错误。
const instant = new Date('2026-07-01T02:30:00Z');
console.assert(instant.toISOString() === '2026-07-01T02:30:00.000Z');
console.assert(Number.isFinite(instant.getTime()));
const invalid = new Date('not-a-date');
console.assert(Number.isNaN(invalid.getTime()));Date 是可变对象,setMonth、setDate 等会原地修改,并按本地时区处理对应方法。跨夏令时加“一天”不总等于加 86,400,000 毫秒:日历日和持续 24 小时是不同需求。调用前先写出业务语义。
Intl 负责本地化展示,不负责存储
Intl.DateTimeFormat 按 locale、timeZone 和格式选项显示同一瞬间。格式化器创建可能有成本,列表渲染可复用实例;不要解析格式化后的展示字符串恢复数据。
const formatter = new Intl.DateTimeFormat('zh-CN', {
dateStyle: 'long',
timeStyle: 'short',
timeZone: 'Asia/Shanghai',
});
console.log(formatter.format(new Date('2026-07-01T02:30:00Z')));
// 预期包含:2026年7月1日 10:30(标点随实现的 locale 数据可能不同)时区使用 IANA 标识如 Asia/Shanghai、America/New_York,不要保存 GMT+8 替代地区规则;offset 只能描述一个瞬间,不能推导未来夏令时变化。测试涉及 locale 文案时优先断言 formatToParts() 的结构化字段,而不是把整段标点写死。
Temporal 区分瞬间、日历与时区
截至 2026-06-30,Temporal 已是 Stage 4 规范草案。它提供不可变且职责分离的类型:Temporal.Instant 表示绝对瞬间,PlainDate 表示无时间/时区的日历日期,PlainDateTime 表示本地日期时间,ZonedDateTime 把本地时间与 IANA 时区规则结合,Duration 表示时长。Temporal Stage 4 Draft
// 运行前需要确认当前运行时存在 globalThis.Temporal。
if ('Temporal' in globalThis) {
const date = Temporal.PlainDate.from('2026-01-31');
const nextMonth = date.add({ months: 1 });
console.assert(nextMonth.toString() === '2026-02-28');
const meeting = Temporal.ZonedDateTime.from(
'2026-07-01T10:30:00+08:00[Asia/Shanghai]',
);
console.assert(meeting.toInstant().toString() === '2026-07-01T02:30:00Z');
}Stage 4 代表规范成熟度,不等于所有目标浏览器已经原生提供。应用应按支持范围和特性检测决定是否使用原生实现、经过评估的 polyfill 或继续使用明确封装的 Date;不要在同一领域混用多套日期模型。
Date.now 与 performance.now 的时钟差异
Date.now() 读取系统 wall clock,适合生成日志时间戳,但可能因 NTP、用户设置或休眠而跳变。performance.now() 是相对于 time origin 的高分辨率单调时钟,适合测量同一上下文内耗时;它不是跨设备或长期持久化时间戳。
const start = performance.now();
const result = Array.from({ length: 10_000 }, (_, index) => index).toSorted((a, b) => b - a);
const duration = performance.now() - start;
console.assert(result[0] === 9_999);
console.log(`排序耗时:${duration.toFixed(2)} ms`);一次测量不构成性能结论。需要预热、固定输入、记录设备/运行时版本并多次采样;浏览器还可能为隐私降低计时精度。业务 SLA 使用服务端/可观测系统的完整链路指标,不用客户端单次 stopwatch 代替。
setTimeout 只保证最短延迟
浏览器计时器由 HTML 宿主定义。setTimeout(fn, delay) 在延迟满足后把回调变为可调度任务,不保证精确时刻执行;当前长任务、微任务、后台标签页节流、嵌套 timer 最小延迟和设备负载都会推迟它。setInterval 不会补偿回调耗时,慢回调还可能导致连续追赶。
需要按目标时间周期执行时,用递归 timeout 根据单调时钟计算下一次延迟;需要“上一次完成后再等 N 秒”时,递归 timeout 在回调结束后安排即可。两者语义不同。
function startTicker(period, onTick) {
const controller = new AbortController();
const start = performance.now();
let tick = 1;
let timer = 0;
const schedule = () => {
const target = start + tick * period;
timer = setTimeout(() => {
if (controller.signal.aborted) return;
onTick({ tick, drift: performance.now() - target });
tick += 1;
schedule();
}, Math.max(0, target - performance.now()));
};
controller.signal.addEventListener('abort', () => clearTimeout(timer), { once: true });
schedule();
return controller;
}
const ticker = startTicker(1_000, ({ tick, drift }) => {
console.log(`第 ${tick} 次,漂移 ${drift.toFixed(1)} ms`);
});
setTimeout(() => ticker.abort(), 3_100);这段代码减少累计漂移,但不能把被后台策略暂停的页面变成可靠调度器。闹钟、账单、消息投递和必须执行的任务应交给服务端调度;页面恢复可根据持久状态重新计算,而不是期待 timer 一直存活。
debounce、throttle 与帧调度
debounce 在连续调用停止一段时间后执行,适合搜索建议、保存草稿等“只关心最后一次”场景;throttle 限制固定窗口内最多执行频率,适合滚动进度等持续反馈。两者都会改变调用语义,需要明确 leading/trailing、返回值、取消和 flush,简单表单若每次本地过滤都很便宜就不需要它们。
视觉更新使用 requestAnimationFrame,回调发生在下一次绘制前;在回调中集中 DOM 写入,测量读取放在明确阶段,避免读写交错导致强制同步布局。后台页面可能停止帧回调,因此它不适合业务计时。空闲工作可评估宿主调度 API,但必须可被推迟或取消,不能把关键保存放进“空闲时再做”。
不可变状态、URL 与 AbortSignal 示例
示例用不可变状态、URL 序列化、AbortSignal 和确定性的异步步骤模拟知识目录。环境为 Node.js 22+,不依赖第三方包。
const entries = Object.freeze([
{ id: 'html', name: 'HTML', tags: ['语义'] },
{ id: 'browser-web-apis', name: 'Browser / Web APIs', tags: ['Worker'] },
]);
function reduce(state, event) {
if (event.type === 'query') return { ...state, query: event.value.trim() };
if (event.type === 'select') {
const selected = state.selected.includes(event.id)
? state.selected.filter((id) => id !== event.id)
: state.selected.length < 4 ? [...state.selected, event.id] : state.selected;
return { ...state, selected };
}
return state;
}
function search(items, query) {
const term = query.toLocaleLowerCase('zh-CN');
return items.filter(({ name, tags }) =>
[name, ...tags].some((value) => value.toLocaleLowerCase('zh-CN').includes(term)));
}
async function loadCatalog(signal) {
await Promise.resolve();
signal.throwIfAborted();
return entries;
}
let state = { query: '', selected: [] };
state = reduce(state, { type: 'query', value: 'worker' });
state = reduce(state, { type: 'select', id: 'browser-web-apis' });
const controller = new AbortController();
const catalog = await loadCatalog(controller.signal);
const result = search(catalog, state.query);
const params = new URLSearchParams({ q: state.query, compare: state.selected.join(',') });
console.log(result.map(({ id }) => id));
console.log(params.toString());
console.log(state);保存为 demo.mjs 并运行:
node demo.mjs输入:固定查询 worker,选择 browser-web-apis。
预期输出:第一行是 [ 'browser-web-apis' ];第二行是 q=worker&compare=browser-web-apis;最后的状态是新对象,初始数组未被修改。
这个例子刻意不引入 Redux 风格框架。一个分支很少的纯函数和原生 URL 类型已覆盖需求;当状态转换跨多个独立领域、需要时间旅行或复杂中间件时,再评估状态库。
错误传播必须沿 Promise 链处理。try/catch 只能捕获其动态执行范围内同步抛出的错误,以及同一 try 中被 await 的拒绝;它捕获不到已经启动但没有 await 的 Promise。资源取消也不是“忽略旧结果”:把同一个 AbortSignal 传给支持取消的 API,离开页面或新请求开始时调用 abort(),并单独识别 AbortError。
状态转换与 DOM 协调边界
知识浏览器把可变状态压缩到一个 AppState:查询、专栏、对比 id 和当前详情。事件处理器不直接散落修改 DOM,而是先生成新状态,再由 render() 统一投影到结果列表、选择摘要和对比表。
事件委托只在结果容器注册一次 click 监听器,通过 closest('button[data-action]') 找到详情或对比动作。动态列表新增节点无需新增监听器;同时必须验证按钮确实属于当前容器并检查条目 id,不能信任任意 data-*。
筛选输入调用同步 searchTechnologies。41 条固定数据没有 Worker 性能需求;只有用户显式运行压力演示时,才复制数据并通过模块 Worker 搜索。Worker 失败立即回退同一个纯函数,因此没有两份搜索语义。
URL 状态使用 URL 与 URLSearchParams 读取和序列化,history.replaceState 避免每次键入制造历史条目。外部 JSON 先作为 unknown 验证,渲染只用 createElement、textContent 与 replaceChildren,不拼接 HTML。
对象、模块与异步对比:
| 机制 | 解决的问题 | 关键语义 | 常见误用 |
|---|---|---|---|
| 普通对象 | 聚合键值与行为 | 描述符、原型、内部方法 | 把展开语法误当深拷贝 |
| 闭包 | 保留词法环境 | 捕获绑定而非值快照 | 无意保留大对象和 DOM |
| ESM | 静态依赖与封装 | 链接、live binding、单次求值 | 循环依赖中提前读绑定 |
| Promise | 表达未来完成值 | 状态不可逆、reaction 排为 Job | 漏掉返回和拒绝处理 |
async/await | 线性书写 Promise 流程 | await 后异步恢复 | 把独立操作写成串行 |
| Worker | 隔离线程执行纯计算 | 消息、结构化克隆、无 DOM | 小数据也搬运导致更慢 |
运行完整示例:
pnpm --filter @roadmap/web-foundations test预期结果:模型、搜索、URL、Worker 协议和语义标记测试全部通过;编译输出不依赖 bundler。
调用栈、内存可达性与主线程证据
调试执行顺序时,先用最小日志验证调用栈、task 与 microtask,再用 Performance 面板观察长任务和渲染间隔。Async stack traces 能连接 Promise 链,但断点仍要放在错误产生处,而非只在最终 catch。
内存由可达性决定,不是“变量离开函数就一定释放”。全局集合、闭包、监听器、计时器和 DOM 引用都可能成为 GC Root 路径。Memory 面板比较 heap snapshot,沿 retaining path 找真正持有者;不要用手动置 null 替代生命周期设计。
性能优先级通常是算法与工作量,而非语法:避免重复扫描同一大数组,合并 DOM 写入,长计算拆成多个短任务或移入 Worker。数组的 toSorted()、toReversed()、toSpliced() 与 with() 返回新数组,适合不可变状态,但仍是浅层复制;大集合频繁复制应先测量。
安全上,原型链和动态属性都可能成为攻击面。处理不可信字典时用 Object.hasOwn() 检查自身属性,避免把输入合并进拥有敏感原型的配置对象;不要把字符串交给 eval、Function 或字符串计时器。DOM 输出遵守上下文编码,优先 textContent。
常见误区:
forEach(async () => ...)不会等待回调;需要串行用for...of,需要并发用Promise.all(map(...))。- 对象展开只复制可枚举自身属性,嵌套对象仍共享引用。
finally中return会覆盖先前结果或错误。setTimeout(fn, 0)不是立即执行,只是满足最短延迟后进入可选任务。- 微任务优先不等于无限快;递归排微任务会阻塞渲染。
- Worker 不共享 DOM,传输大对象也有克隆或转移成本。
ECMAScript 年度快照与运行时采用
ECMAScript 每年形成一个快照,https://tc39.es/ecma262/ 则包含最近快照与已完成的 Stage 4 提案。截至 2026-06-30,ECMAScript 2026 规范包含 Promise.try、Iterator Helpers 等已进入规范的能力;实际采用仍要核对目标运行时,而不是仅看提案阶段。ECMAScript 2026
近几个版本改变工程决策的能力包括返回新数组的不可变方法、Object.groupBy、Iterator Helpers、Promise.withResolvers、显式资源管理和更完整的正则能力。原则是优先使用已经进入目标运行时的标准方法,不为一个一行能力引入工具库;需要旧浏览器时由构建专栏统一决定转译与 polyfill,不在业务文件散布版本判断。
Iterator Helpers 允许在惰性迭代器上 map、filter、take 后再 toArray(),适合避免中间数组,但迭代器是一次性消费模型;普通小数组继续使用数组方法更清楚。显式资源管理适合具有明确释放协议的资源,不替代 AbortSignal 的协作取消。
集合、迭代协议与数据处理
集合选择直接影响身份、顺序和复杂度:
| 结构 | 键/成员语义 | 迭代顺序 | 典型用途与边界 |
|---|---|---|---|
Object | 字符串或 Symbol 属性 | 规范化属性顺序 | 有固定字段的数据记录;不作为任意对象键映射 |
Map | 任意值按 SameValueZero 比较 | 插入顺序 | 频繁增删、键不是字符串、需要可靠 size/迭代 |
Set | 唯一值按 SameValueZero 比较 | 插入顺序 | 去重与成员检查;不保存业务计数 |
WeakMap / WeakSet | 仅对象或非注册 Symbol,弱引用 | 不可枚举 | 把元数据绑定到对象生命周期;不能做可观察缓存 |
Array | 数字索引与 length | 索引顺序 | 有序序列;成员查找不是大型索引结构 |
Map/Set 的平均查找通常适合成员索引,但规范不承诺具体哈希实现或严格 O(1)。小集合优先可读性;只有真实热点才为数组额外维护 Map 索引,因为双结构带来同步成本。Weak 集合不可枚举是为了不让程序观察垃圾回收决定,不能用于“列出仍存活对象”。
数组方法按是否修改原数组区分:push/pop/splice/sort/reverse 会修改;map/filter/slice/concat/toSorted/toReversed/toSpliced/with 返回新数组。新方法返回浅副本,元素对象仍共享。sort() 默认把元素转字符串排序,数字必须提供比较器;比较器应满足纯、稳定符号和传递性,否则结果不可预测。
const entries = [
{ id: 'js', score: 10 },
{ id: 'html', score: 2 },
];
const sorted = entries.toSorted((left, right) =>
right.score - left.score || left.id.localeCompare(right.id));
console.assert(sorted[0].id === 'js');
console.assert(entries[0].id === 'js'); // 原数组顺序没有被 sort 修改reduce 能表达累计,但没有天然优于循环。聚合为单个值或 Map 时它清楚;需要多个分支、提前退出、异步或复杂错误时使用 for...of。反复在 reducer 中 { ...acc, [key]: value } 会每轮复制累计对象,数据大时可能退化为二次工作;在局部新建 Map/Object 后原地填充更直接。
可迭代对象实现 Symbol.iterator 并返回迭代器;迭代器的 next() 产生 { value, done }。for...of、展开、Array.from、Map 和 Set 构造器都消费该协议。生成器是创建迭代器的最小方式,但惰性序列通常只能消费一次;需要复用时返回新的 iterator,而不是共享一个已前进的实例。
function* take(iterable, limit) {
let count = 0;
for (const value of iterable) {
if (count++ === limit) return;
yield value;
}
}
console.assert([...take(new Set(['HTML', 'CSS', 'JS']), 2)].join(',') === 'HTML,CSS');这段自检覆盖协议边界:输入只需可迭代,不要求数组;输出按需产生,不复制完整集合。异步数据流对应 Symbol.asyncIterator 与 for await...of,它解决逐项到达,不自动提供取消或背压策略;仍应显式传递 AbortSignal 并理解底层流的生命周期。Iteration protocols
对象、模块、异步与 Worker 选择
| 需求 | 最小机制 | 何时升级 | 不必要的复杂度 |
|---|---|---|---|
| 小列表派生 | filter、map、toSorted | 数据量与频率经测量过大 | 先建通用查询引擎 |
| 局部状态 | 普通对象 + 纯转换函数 | 多领域协作与调试需求明确 | 为单页引入全局 store |
| 异步组合 | Promise + async/await | 需要流、背压或任务编排 | 手写 Promise 状态机 |
| 取消请求 | AbortController | 多来源取消时组合 signal | 只设布尔变量忽略结果 |
| CPU 密集计算 | 主线程同步执行小任务 | 录制证明长任务后用 Worker | 41 条数据默认 Worker |
| 模块拆分 | 按稳定职责拆 ESM | 独立发布或加载边界出现 | 每个函数一个文件 |
执行顺序与内存练习
- 画出一次函数调用的执行上下文栈和词法环境外层链。
- 用属性描述符解释 getter、不可枚举属性和只读属性的行为。
- 构造一个 ESM 循环依赖,分别观察延后读取与初始化前读取。
- 预测同步日志、Promise reaction、
queueMicrotask和setTimeout的顺序,再运行验证。 - 用
AbortController取消一次 Fetch,并区分取消与网络失败。 - 在 Memory 面板找到一个监听器闭包的 retaining path,随后移除监听器复测。
- 比较主线程与 Worker 处理 41 条、4,100 条数据的耗时和响应性。
验收标准:能区分语言与宿主,解释绑定、调用和异步恢复,而不是背诵“宏任务先后表”。对性能结论必须附带数据规模、测量工具与目标设备。
ECMAScript 与宿主规范资料
- ECMAScript 2026 Language Specification:语言语义基准;核验于 2026-06-30。
- ECMAScript Execution Contexts:Realm、环境记录与执行上下文;核验于 2026-06-30。
- ECMAScript Scripts and Modules:模块解析、链接和求值;核验于 2026-06-30。
- ECMAScript Date Objects:时间值、解析、UTC 与本地字段;核验于 2026-06-30。
- Temporal Stage 4 Draft:Instant、PlainDate、ZonedDateTime 与 Duration 的规范语义;核验于 2026-06-30。
- ECMA-402 Internationalization API:DateTimeFormat、NumberFormat、Collator 与 Segmenter;核验于 2026-06-30。
- HTML Event Loops:浏览器任务、微任务与渲染调度;核验于 2026-06-30。
- HTML Timers:
setTimeout、setInterval与宿主调度;核验于 2026-06-30。 - TC39 Proposals:提案阶段与入口,不等于运行时支持范围;核验于 2026-06-30。
- Test262:ECMAScript 实现一致性测试套件;核验于 2026-06-30。