一段 JavaScript 在浏览器控制台里可以读取页面标题、响应按钮点击、发起网络请求;同样的代码放进 Node.js,却可能提示 window is not defined。问题不在语法,而在代码所处的运行环境。
浏览器也不是简单地“从上到下执行脚本”。它一边解析 HTML、加载资源和绘制页面,一边接收点击、定时器与网络结果。JavaScript 必须在这套协作机制中运行,页面才能保持交互,也不会在等待外部结果时失去响应。
这一章会从运行环境的边界出发,逐步连接 window、脚本加载、页面就绪事件、事件循环与浏览器安全策略。学完后,你不仅能写出能运行的代码,也能解释它为什么在那个时刻运行。

浏览器把语言运行时、Web APIs、页面渲染、网络与安全边界组合成了一个完整的网页运行环境。
完成本章后,你应该能够:
window、document、navigator、location 和 history 的职责;defer、async 或模块脚本;DOMContentLoaded 与 load 的触发时机;textContent 比直接拼接 HTML 更适合展示不可信文本。ECMAScript 定义 JavaScript 的语言规则,例如值与类型、变量、函数、对象、类、模块、Array、Map 和 Promise。这些能力不依赖某个具体网页。
浏览器是 JavaScript 的一种宿主环境。它在语言核心之外提供 Web APIs,让代码能够读取文档、监听输入、安排定时器、访问网络和控制地址栏。Node.js 是另一种宿主环境,它提供文件、进程和服务器网络等能力。
这个区分能解释“语法正确,但换个地方就不能运行”的现象。下面的代码适合分别放进浏览器控制台和 Node.js 中观察:
console.log(typeof Array);
console.log(typeof Promise);
console.log(typeof document);
console.log(typeof process);典型结果如下:
这里使用 typeof document 而不是直接读取 document,是因为 typeof 可以安全检测一个尚未声明的全局名字。直接在 Node.js 中写 document.title 会抛出 ReferenceError。
globalThis 是跨环境访问全局对象的统一入口。在普通网页的主线程里,globalThis === window 通常为 true;在 Node.js 和 Web Worker 中,globalThis 仍然存在,但它不再指向浏览器窗口。
“浏览器提供”不代表每个浏览器、每个页面都一定能使用。剪贴板、定位、摄像头等能力还可能受到版本、安全上下文、用户权限与浏览器策略限制。实际开发要检测能力并处理失败,不能只凭对象名字推断。
普通网页主线程中的 window 代表当前浏览上下文的全局窗口。许多浏览器能力都是它的属性,因此 window.document、window.location 与直接写 document、location 通常得到同一个对象。

DOM 负责文档结构;BOM 是对窗口、地址、历史和浏览器环境等能力的常用统称。
浏览器把 HTML 解析为一棵对象树,document 是进入这棵树的入口。下面的页面包含标题、段落和按钮:
<!doctype html>
<html lang="zh-CN">
<head>
<title>任务面板</title>
</head>
<body>
<h1>今日任务</h1>
<p>先完成浏览器环境练习。</p>
<button type="button">标记完成</button>
JavaScript 可以读取文档信息,也可以从树中找到元素:
console.log(document.title);
const heading = document.querySelector("h1");
console.log(heading.textContent);在这份页面中,输出是:
任务面板
今日任务document.querySelector() 找不到匹配元素时会返回 null。因此,代码还需要在元素已经被解析之后运行,并在不确定元素是否存在时处理 null。
navigator 汇集了语言偏好、在线状态和一部分设备能力入口:
console.log(navigator.language);
console.log(navigator.onLine);
console.log("clipboard" in navigator);这些值只能当作提示:
navigator.language 适合选择初始界面语言,但用户仍应能够手动切换;navigator.onLine 只表示浏览器对网络连接状态的判断,不保证某个 API 一定可达;navigator.clipboard 即使存在,也可能要求 HTTPS、安全权限和用户手势;例如,复制按钮应先检查能力,并捕获用户拒绝权限等失败:
async function copyText(text) {
if (!navigator.clipboard) {
throw new Error("当前环境没有开放剪贴板接口");
}
await navigator.clipboard.writeText(text);
}location 表示当前页面地址。假设地址是:
https://learn.example.com:8443/course/11?mode=practice#quiz常用属性会得到:
读取这些属性不会跳转,修改地址却可能触发导航:
// 新地址通常会留下可后退的历史条目
location.assign("/dashboard");
// 新地址通常替换当前历史条目
location.replace("/login");
// 重新加载当前页面
location.reload();如果只想分析一个地址而不跳转,应创建 URL 对象:
const target = new URL("/search?q=event+loop", location.href);
console.log(target.pathname);
console.log(target.searchParams.get("q"));输出为:
/search
event loophistory 管理当前浏览上下文的会话历史。history.back()、history.forward() 和 history.go(-1) 可以移动位置,history.length 可以给出条目数量。
浏览器不会让页面枚举用户完整的访问记录。脚本也不应假定一定存在上一页:用户可能直接打开当前地址,上一条记录也可能属于其他站点。
在控制台直接运行 history.back() 或给 location.href 赋值,会真的离开当前页面。只想观察对象时,优先读取属性或用 URL 做离线分析。
下面的实验台可以用来观察这些对象之间的关系。具体属性值会随浏览器、页面地址和窗口尺寸变化。
浏览器解析 HTML 时,如果遇到脚本,必须知道是立刻停下来执行,还是继续构建文档。加载方式选错,常见结果有两种:页面渲染被不必要地阻塞,或者脚本运行时依赖的元素和前置代码尚未准备好。
<script src="/main.js"></script>解析器遇到这种脚本时,通常会暂停 HTML 解析,等待脚本下载并执行,然后继续处理后面的标记。若脚本确实需要立刻写入后续文档流,这种行为有意义;普通应用脚本通常不必这样阻塞。
<script defer src="/vendor.js"></script>
<script defer src="/app.js"></script>两个文件可以在 HTML 继续解析时下载。等文档解析完成后,它们按照标签出现顺序执行,因此 app.js 可以依赖先出现的 vendor.js。所有延迟脚本执行完成后,浏览器才会触发 DOMContentLoaded。
defer 对外部经典脚本有意义;写在内联经典脚本上的 defer 不会带来同样效果。
<script async src="/analytics.js"></script>异步脚本也会在解析 HTML 时下载,但文件一旦准备好就会尽快执行。多个 async 脚本不保证按标签顺序运行,DOMContentLoaded 也不会专门等待它们。
因此,埋点、广告等独立脚本常适合 async;依赖页面下方元素或依赖另一个脚本先执行的代码不适合。
<script type="module" src="/app.js"></script>模块脚本会加载它的依赖图,并且默认具有延迟执行的特征:文档解析期间可以下载,解析完成后再执行。模块还拥有独立作用域,适合组织现代应用代码。如果给模块脚本添加 async,也应把它当作不依赖文档顺序的独立入口。

真正需要判断的不是哪种属性更“快”,而是脚本是否依赖 DOM、前置脚本和确定的执行顺序。
页面“准备好”不是一个瞬间,而是至少有两个常用里程碑。
DOMContentLoaded 表示初始 HTML 已经解析为 DOM,并且需要等待的延迟脚本已经执行。此时通常可以查询元素、绑定事件和初始化界面。
load 触发得更晚,它关注窗口及大多数非懒加载依赖资源已经完成,例如图片、样式表和子框架。读取图片最终尺寸或执行依赖全部资源的逻辑时,才更需要等待它。
document.addEventListener("DOMContentLoaded", () => {
console.log("DOM 可以初始化");
});
window.addEventListener("load", () => {
console.log("窗口资源加载完成");
});通常先看到:
DOM 可以初始化
窗口资源加载完成不过,监听器必须在事件发生前注册。如果脚本由动态导入、异步加载或浏览器扩展晚些执行,DOMContentLoaded 可能早已结束。稳妥的初始化函数可以检查 document.readyState:
function init() {
const button = document.querySelector("button");
if (!button) {
return;
}
button.addEventListener("click", () => {
console.log("按钮已点击");
});
}
if (document.readyState === "loading") {
document.addEventListener
三个状态可以这样理解:
loading:HTML 仍在解析;interactive:文档已经解析,延迟脚本或 DOMContentLoaded 可能仍在处理;complete:页面依赖资源已经完成,load 即将触发或已经触发。网页不能预知用户何时点击、输入或滚动。事件驱动代码的核心不是“现在执行”,而是“当某件事发生时,请执行这个函数”。
const button = document.querySelector("#save");
function handleSave(event) {
console.log("触发元素:", event.currentTarget.id);
}
button.addEventListener("click", handleSave);运行到 addEventListener 时,浏览器只是登记 handleSave。真正点击之后,回调才会进入等待执行的任务。
这里传入的是函数本身,所以写 handleSave,不能写 handleSave()。后者会立刻调用函数,并把返回值交给监听器。
需要额外参数时,可以用一个包装函数:
function saveDocument(documentId) {
console.log("保存文档:", documentId);
}
button.addEventListener("click", () => {
saveDocument("doc-42");
});事件属性只有一个槽位,后一次赋值会覆盖前一次:
button.onclick = showMessage;
button.onclick = saveDocument;上面的按钮最终只保留第二个属性值。addEventListener 可以让互不相关的功能分别注册:
button.addEventListener("click", showMessage);
button.addEventListener("click", recordMetric);如果稍后要移除监听器,需要保留同一个函数引用:
function handleClick() {
console.log("只在启用期间响应");
}
button.addEventListener("click", handleClick);
button.removeEventListener("click", handleClick);匿名包装函数每次创建的引用都不同,因此不能用一个“长得一样”的新函数移除旧监听器。
浏览器主线程上的 JavaScript 一次只执行一段代码。与此同时,浏览器可以在语言运行时之外处理定时、网络、用户输入和渲染准备。异步并不表示两个 JavaScript 回调在同一个主线程上同时运行,而是耗时等待可以交给宿主,完成后再排队。
可以先用四个角色建立模型:
queueMicrotask 等在当前任务结束后优先清空的工作。
同步代码先清空调用栈;当前任务结束后清空微任务;之后浏览器才有机会渲染并处理后续任务。
下面的例子在现代浏览器页面中运行:
console.log("A:同步开始");
setTimeout(() => {
console.log("D:定时器任务");
}, 0);
Promise.resolve().then(() => {
console.log("C:Promise 微任务");
});
console.log("B:同步结束");输出顺序是:
A:同步开始
B:同步结束
C:Promise 微任务
D:定时器任务原因可以逐步推导:
setTimeout 把回调安排为后续任务,零毫秒只是最短等待意图,不是立即插队;Promise.then 登记微任务;如果一个点击回调执行庞大的同步循环,按钮反馈、键盘输入和页面绘制都只能等待:
button.addEventListener("click", () => {
const startedAt = performance.now();
while (performance.now() - startedAt < 2000) {
// 模拟持续占用主线程的计算
}
status.textContent = "计算结束";
});即使先修改了 status.textContent,浏览器也常常要等当前任务结束才有机会绘制。长计算应拆分、交给 Web Worker,或者使用更适合的后台处理方式。
微任务也不能无限使用。每个任务结束后,浏览器会持续清空新产生的微任务;递归创建微任务可能让渲染和普通任务长期得不到机会。
网页脚本来自不同站点,却可能同时运行在一台已登录邮箱、网盘和后台系统的设备上。浏览器必须限制不同来源之间的数据访问,也必须防止页面把外部文本误当成自己的可执行代码。
两个 URL 同源,要求协议、主机和有效端口都相同。路径、查询参数和片段不同不会改变来源。
以 https://learn.example.com/course/11 为基准:
可以让 URL 完成默认端口归一化:
function isSameOrigin(base, target) {
return new URL(base).origin === new URL(target, base).origin;
}
const base = "https://learn.example.com/course/11";
console.log(isSameOrigin(base, "/profile"));
console.log(isSameOrigin(base, "https://api.example.com/data"));输出为:
true
false同源策略并非禁止所有跨源资源。页面常能嵌入其他站点的图片、样式或脚本,但 JavaScript 通常不能读取跨源响应正文、跨源框架的 DOM 或另一来源的存储。限制的重点是数据访问能力。
CORS 是一组 HTTP 响应头规则。跨源前端发起请求后,目标服务器可以明确允许某个来源读取响应:
Access-Control-Allow-Origin: https://app.example.com前端代码本身不能靠添加一个“允许跨域”请求头绕过限制。是否把响应交给 JavaScript,由浏览器根据服务器响应决定。
async function loadProfile() {
const response = await fetch("https://api.example.com/profile", {
credentials: "include",
});
if (!response.ok) {
throw new Error("请求失败:" + response.status);
}
return response.json();
}这段请求还需要服务器正确配置:
Access-Control-Allow-Credentials: true;* 作为允许来源;OPTIONS 预检,服务器必须允许实际方法与请求头。CORS 不是身份认证,也不能替代 CSRF 防护。它主要约束浏览器是否允许当前页面的 JavaScript 读取跨源响应。命令行客户端和服务器之间的请求不受浏览器 CORS 执行机制约束。
如果评论、URL 参数或接口字段未经处理就进入 HTML 解析位置,攻击者可能让浏览器执行页面原本没有的脚本。
危险做法:
output.innerHTML = userInput;只需要显示文字时,应明确使用文本接口:
output.textContent = userInput;textContent 会把尖括号和标签当作普通字符。若业务确实允许富文本,需要采用经过维护的清洗方案、限制允许的标签与属性,并结合内容安全策略等纵深防护。不要尝试用几条正则表达式自行“过滤危险字符”,因为 HTML、URL、属性和 JavaScript 字符串属于不同解析上下文。
同源策略也不会自动挡住 XSS。注入代码一旦在页面中执行,就会以该页面的来源身份访问它原本有权访问的数据。
做一个“浏览器环境观察台”。它要展示当前环境、记录页面生命周期、验证事件循环顺序、判断 URL 是否同源,并安全预览用户输入。
新建 index.html:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>浏览器环境观察台</title>
<script defer src="./app.js"></script>
脚本使用 defer,所以执行时页面元素已经解析,不必把脚本移动到 body 末尾。
新建 app.js,先放入这些代码:
function appendListItem(list, text) {
const item = document.createElement("li");
item.textContent = text;
list.append(item);
}
const environment = document.querySelector("#environment");
const entries = [
["页面标题", document.title],
["当前来源", location.origin],
如果直接用本地文件打开页面,location.origin 可能显示 "null"。这是文件 URL 没有普通 HTTP 来源的表现,不是变量丢失。使用本地开发服务器时会看到对应的 http://主机:端口。
继续添加:
const runButton = document.querySelector("#run-order");
const timeline = document.querySelector("#timeline");
runButton.addEventListener("click", () => {
timeline.replaceChildren();
runButton.disabled = true;
appendListItem(timeline, "1. 同步:点击回调开始");
setTimeout(() => {
appendListItem
点击后应稳定看到 1 → 2 → 3 → 4。按钮暂时禁用,可以避免连续点击让两组定时器日志互相穿插。
继续添加:
const targetInput = document.querySelector("#target-url");
const checkButton = document.querySelector("#check-origin");
const originResult = document.querySelector("#origin-result");
checkButton.addEventListener("click", () => {
try {
const target = new URL(targetInput.value, location.href);
const sameOrigin
这里故意让输入值看起来像 HTML。预览区仍应显示完整标签字符,而不是生成粗体元素,因为代码把它当成文本。
DOMContentLoaded 出现在 load 之前;1 → 2 → 3 → 4;<strong> 字样,不创建新的 HTML 元素;defer 的外部脚本入口加载应用代码。window 为全局窗口;document、navigator、location 和 history 各自负责不同边界。defer;真正独立的脚本才适合 async。DOMContentLoaded 关注文档结构,load 关注更完整的资源就绪状态。textContent,不要把外部数据直接送入 HTML 或代码执行位置。