Agent Developer Roadmap
Web 基础

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 证明热点,再减少算法工作量和分配。

evalFunction 会把字符串重新送入编译链,还改变静态分析、内容安全策略(CSP)和优化边界。业务数据不应变成代码;表达式、规则或模板需求应设计有限数据结构和解释器,而不是执行不可信字符串。ECMAScript Scripts and Modules

值、类型转换与相等关系

ECMAScript 语言值分为 UndefinedNullBooleanStringSymbolNumberBigIntObject。前七类是原始值;对象具有身份,即使内容相同也不是同一个值。typeof null === 'object' 是历史兼容行为;数组、日期和函数的精确类别不能只靠 typeof 判断。

Number、BigInt 与数值边界

Number 使用 IEEE 754 双精度浮点,能精确表示的整数范围由 Number.MIN_SAFE_INTEGERNumber.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.CollatortoLocaleLowerCase 等能力。不要把界面语言规则用于安全协议比较。

隐式转换、真假值与相等

条件判断会执行 ToBoolean。假值只有 false0-00nNaN、空字符串、nullundefined,以及浏览器历史对象 document.all;空数组和空对象都是真值。value || fallback 会把 0false、空字符串一并当缺失,保留合法假值时用 value ?? fallback

=== 不做类型转换,但 NaN !== NaN0 === -0Object.is 区分正负零并认为两个 NaN 相同;MapSetincludes 使用 SameValueZero,认为 NaN 相同且不区分正负零。宽松相等 == 经过多步转换,除有意使用 value == null 同时匹配 null/undefined 等清楚边界外,业务代码应优先显式转换和严格相等。

需求正确选择易错写法
判断同类型业务值===依赖 == 转换字符串数字
检查 NaNNumber.isNaN(value)value === NaN
判断数组是否含 NaNarray.includes(NaN)array.indexOf(NaN)
只为 null/undefined 提供默认值value ?? fallback`value
区分 0-0Object.is(value, -0)value === -0

类型转换的调试方法是把隐式步骤改写成显式调用:Number(value)String(value)Boolean(value),再逐个观察。规范中的 ToPrimitiveToNumberToString 等抽象操作是解释结果的依据,不是可直接调用的 API。ECMAScript Abstract Operations

声明、作用域、提升与暂时性死区

letconstclass 创建块级词法绑定;var 创建函数或全局变量绑定;函数声明的实例化规则还取决于 Script/Module、严格模式和所在语句位置。所谓“提升”不是源码移动,而是执行前创建绑定的结果。

var 绑定在进入作用域时初始化为 undefined,所以声明前读取不会报错但常产生错误结果;letconstclass 从作用域开始到执行初始化之间处于 TDZ(Temporal Dead Zone,暂时性死区),读取会抛 ReferenceErrorconst 限制绑定不能重新赋值,不会冻结对象内容。

{
  // 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 的差异不只是写法:是否有自己的 thisargumentssupernew.target,能否作为构造器,以及原型属性都不同。

形式自有 thisnew典型用途
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;
}

高阶函数接收或返回函数。mapfilterreduce 适合无副作用的数据变换,但不需要把每个流程强行压成一条链:当错误处理、提前退出或多个局部名称能提高可读性时,普通循环更清楚。回调的返回约定必须明确,尤其不要写 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/setwritableenumerableconfigurable;普通属性读取先查自身,再沿 [[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.getOwnPropertyDescriptorReflect.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 为 undefinedreceiver 在调用点丢失包装、显式 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:

  1. Parse 识别静态 import/export 并建立请求列表。
  2. Link 解析整个依赖图,为导入导出建立 live binding;缺失导出在运行模块体前失败。
  3. Evaluate 按依赖顺序执行顶层代码,每个模块实例只求值一次。
  4. 带 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 会让链提前得到 undefinedcatch(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 与计时器

“时间”至少包含四个不同问题:某个绝对瞬间、某地墙上显示的日期时间、经过的持续时长,以及某项工作何时调度。把它们都塞进 DatesetTimeout 会产生时区、夏令时、系统时钟调整和任务延迟 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() 差值
安排界面下一帧requestAnimationFramesetInterval(..., 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 是可变对象,setMonthsetDate 等会原地修改,并按本地时区处理对应方法。跨夏令时加“一天”不总等于加 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/ShanghaiAmerica/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 状态使用 URLURLSearchParams 读取和序列化,history.replaceState 避免每次键入制造历史条目。外部 JSON 先作为 unknown 验证,渲染只用 createElementtextContentreplaceChildren,不拼接 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() 检查自身属性,避免把输入合并进拥有敏感原型的配置对象;不要把字符串交给 evalFunction 或字符串计时器。DOM 输出遵守上下文编码,优先 textContent

常见误区:

  • forEach(async () => ...) 不会等待回调;需要串行用 for...of,需要并发用 Promise.all(map(...))
  • 对象展开只复制可枚举自身属性,嵌套对象仍共享引用。
  • finallyreturn 会覆盖先前结果或错误。
  • 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 允许在惰性迭代器上 mapfiltertake 后再 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.fromMapSet 构造器都消费该协议。生成器是创建迭代器的最小方式,但惰性序列通常只能消费一次;需要复用时返回新的 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.asyncIteratorfor await...of,它解决逐项到达,不自动提供取消或背压策略;仍应显式传递 AbortSignal 并理解底层流的生命周期。Iteration protocols

对象、模块、异步与 Worker 选择

需求最小机制何时升级不必要的复杂度
小列表派生filtermaptoSorted数据量与频率经测量过大先建通用查询引擎
局部状态普通对象 + 纯转换函数多领域协作与调试需求明确为单页引入全局 store
异步组合Promise + async/await需要流、背压或任务编排手写 Promise 状态机
取消请求AbortController多来源取消时组合 signal只设布尔变量忽略结果
CPU 密集计算主线程同步执行小任务录制证明长任务后用 Worker41 条数据默认 Worker
模块拆分按稳定职责拆 ESM独立发布或加载边界出现每个函数一个文件

执行顺序与内存练习

  • 画出一次函数调用的执行上下文栈和词法环境外层链。
  • 用属性描述符解释 getter、不可枚举属性和只读属性的行为。
  • 构造一个 ESM 循环依赖,分别观察延后读取与初始化前读取。
  • 预测同步日志、Promise reaction、queueMicrotasksetTimeout 的顺序,再运行验证。
  • AbortController 取消一次 Fetch,并区分取消与网络失败。
  • 在 Memory 面板找到一个监听器闭包的 retaining path,随后移除监听器复测。
  • 比较主线程与 Worker 处理 41 条、4,100 条数据的耗时和响应性。

验收标准:能区分语言与宿主,解释绑定、调用和异步恢复,而不是背诵“宏任务先后表”。对性能结论必须附带数据规模、测量工具与目标设备。

ECMAScript 与宿主规范资料

在 GitHub 上编辑此页

本页目录