浏览器中的 JavaScript 开发指南
1. 引言
JavaScript 最初作为浏览器脚本语言诞生,如今在该领域几乎占据了完全的垄断地位。在浏览器环境中使用 JavaScript 进行开发,虽然语言本身相同,但有一些特殊的考虑因素和 API。
2. ES5 还是 ES6?
ES6 带来了许多实用的增强功能,但在 Web 上全面可靠地支持 ES6 还需要一段时间。在服务器端,你可以确定哪些 ES6 特性被支持,但在 Web 上,代码通过 HTTP(S) 传输,由不受你控制的 JavaScript 引擎执行,甚至可能无法得知用户使用的浏览器信息。
“常青”浏览器(evergreen browsers)通过自动更新有助于新 Web 标准的快速一致推出,但只是缓解而非解决问题。除非能控制用户环境,否则在可预见的未来仍需使用 ES5。不过,转译(transcompilation)为如今编写 ES6 代码提供了途径,虽然会让部署和调试更麻烦,但这是进步的代价。本章假设使用转译器,示例代码在最新版 Firefox 中无需转译即可运行,若要发布代码供更多人使用,则需进行转译以确保在多个浏览器中可靠运行。
3. 文档对象模型(DOM)
3.1 DOM 概述
文档对象模型(DOM)是描述 HTML 文档结构的约定,是与浏览器交互的核心。从概念上讲,DOM 是一棵树,树由节点组成,每个节点(除根节点外)都有一个父节点和零个或多个子节点。根节点是文档,它有一个子节点
元素, 元素又有两个子节点: 元素和 元素。DOM 树中的每个节点(包括文档本身)都是 Node 类的实例(不要与 Node.js 混淆)。Node 对象有 parentNode 和 childNodes 属性,以及 nodeName 和 nodeType 等标识属性。DOM 完全由节点组成,其中只有部分是 HTML 元素,例如
标签是 HTML 元素,但其包含的文本是文本节点。虽然“节点”和“元素”这两个术语常可互换使用,但严格来说并不正确,本章主要处理作为 HTML 元素的节点,“元素”指“元素节点”。
3.2 示例 HTML 文件
为演示相关特性,使用一个简单的 HTML 文件 simple.html:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Simple HTML</title>
<style>
.callout {
border: solid 1px #ff0080;
margin: 2px 4px;
padding: 2px 6px;
}
.code {
background: #ccc;
margin: 1px 2px;
padding: 1px 4px;
font-family: monospace;
}
</style>
</head>
<body>
<header>
<h1>Simple HTML</h1>
</header>
<div id="content">
<p>This is a <i>simple</i> HTML file.</p>
<div class="callout">
<p>This is as fancy as we'll get!</p>
</div>
<p>IDs (such as <span class="code">#content</span>)
are unique (there can only be one per page).</p>
<p>Classes (such as <span class="code">.callout</span>)
can be used on many elements.</p>
<div id="callout2" class="callout fancy">
<p>A single HTML element can have multiple classes.</p>
</div>
</div>
</body>
</html>
3.3 节点属性
每个节点都有 nodeType 和 nodeName 等属性,nodeType 是一个整数,用于标识节点类型,Node 对象包含映射这些数字的常量。本章主要处理的节点类型有 Node.ELEMENT_NODE(HTML 元素)和 Node.TEXT_NODE(通常在 HTML 元素内的文本内容),更多信息可查看 MDN 上关于 nodeType 的文档。
3.4 遍历 DOM
可以编写一个递归函数来遍历整个 DOM 并将其打印到控制台:
function printDOM(node, prefix) {
console.log(prefix + node.nodeName);
for(let i=0; i<node.childNodes.length; i++) {
printDOM(node.childNodes[i], prefix + '\t');
}
}
printDOM(document, '');
这个递归函数实现了树的深度优先、前序遍历,即先遍历完一个分支再处理下一个分支。在浏览器中加载页面后运行该函数,会在控制台打印出页面的整个结构。不过,这只是一个有指导意义的练习,实际操作 HTML 时,这是一种繁琐且低效的方式,因为要遍历整个 DOM 才能找到所需内容。幸运的是,DOM 提供了更直接定位 HTML 元素的方法。
3.5 TreeWalker 对象
虽然自己编写遍历函数是个不错的练习,但 DOM API 提供了 TreeWalker 对象,可用于遍历 DOM 中的所有元素(可根据特定元素类型进行过滤),更多信息可查看 MDN 上关于 document.createTreeWalker 的文档。
3.6 树的术语
树的概念直观易懂,相关术语也类似。节点的父节点是其直接父节点(不是“祖父节点”),子节点是直接子节点(不是“孙节点”)。“后代”指子节点、子节点的子节点等,“祖先”指父节点、父节点的父节点等。
3.7 DOM “Get” 方法
DOM 提供了“get”方法,可快速定位特定的 HTML 元素:
-
document.getElementById
:页面上的每个 HTML 元素都可以分配一个唯一的 ID,该方法可通过 ID 检索元素,例如
document.getElementById('content');
可获取
-
document.getElementsByClassName
:返回具有给定类名的元素集合,例如
const callouts = document.getElementsByClassName('callout');
。
-
document.getElementsByTagName
:返回具有给定标签名的元素集合,例如
const paragraphs = document.getElementsByTagName('p');
。
所有返回集合的 DOM 方法返回的不是 JavaScript 数组,而是 HTMLCollection 实例,这是一个“类数组”对象,可以用 for 循环遍历,但 Array.prototype 方法(如 map、filter 和 reduce)不可用。可以使用扩展运算符将 HTMLCollection 转换为数组:
[...document.getElementsByTagName(p)]
。
3.8 查询 DOM 元素
getElementById
、
getElementsByClassName
和
getElementsByTagName
很有用,但还有一个更通用(且强大)的方法,即使用 CSS 选择器的
querySelector
和
querySelectorAll
方法,它不仅能根据单一条件(ID、类或名称)定位元素,还能根据元素与其他元素的关系定位。
CSS 选择器可根据元素名称(如
、
标签。通过类识别元素时,在类名前加句点,如 .callout 会匹配所有具有 callout 类的元素。要匹配多个类,用句点分隔,如 .callout.fancy 会匹配同时具有 callout 和 fancy 类的元素。还可以将它们组合使用,例如 a#callout2.callout.fancy 会匹配 ID 为 callout2 且具有 callout 和 fancy 类的 元素(虽然使用元素名称、ID 和类的选择器很少见,但确实可行)。
要掌握 CSS 选择器,最好的方法是在浏览器中加载本章提供的示例 HTML 文件,打开浏览器控制台,使用
querySelectorAll
进行尝试,例如在控制台输入
document.querySelectorAll('.callout')
。
CSS 选择器还能根据元素在 DOM 中的位置定位元素。如果用空格分隔多个元素选择器,可以选择具有特定祖先的节点,例如 #content p 会选择具有 ID 为 content 的元素的后代
元素;#content div p 会选择 ID 为 content 的元素内的
元素。如果用大于号(>)分隔多个元素选择器,可以选择直接子节点,例如 #content > p 会选择 ID 为 content 的元素的直接子节点
元素(与 “#content p” 形成对比)。还可以组合使用祖先和直接子节点选择器,例如 body .content > p 会选择具有 content 类且是
标签。
还有更复杂的选择器,这里介绍的是最常见的,要了解所有可用的选择器,可查看 MDN 上关于选择器的文档。
3.9 操作 DOM 元素
3.9.1 修改内容
现在知道如何遍历、获取和查询元素了,接下来看看如何操作它们。首先是内容修改,每个元素有 textContent 和 innerHTML 两个属性,可用于访问(和更改)元素的内容。textContent 会去除所有 HTML 标签,只提供文本数据,而 innerHTML 允许创建 HTML(会生成新的 DOM 节点)。以下是如何访问和修改示例中第一个段落的内容:
const para1 = document.getElementsByTagName('p')[0];
para1.textContent; // "This is a simple HTML file."
para1.innerHTML; // "This is a <i>simple</i> HTML file."
para1.textContent = "Modified HTML file"; // look for change in browser
para1.innerHTML = "<i>Modified</i> HTML file"; // look for change in browser
给 textContent 和 innerHTML 赋值是一种破坏性操作,会替换元素内的所有内容,无论其大小或复杂程度如何,例如可以通过设置
元素的 innerHTML 来替换整个页面内容!3.9.2 创建新的 DOM 元素
可以通过设置元素的 innerHTML 属性隐式创建新的 DOM 节点,也可以使用
document.createElement
显式创建新节点。该函数创建一个新元素,但不会将其添加到 DOM 中,需要单独进行添加操作。以下是创建两个新段落元素并将它们添加到
const p1 = document.createElement('p');
const p2 = document.createElement('p');
p1.textContent = "I was created dynamically!";
p2.textContent = "I was also created dynamically!";
const parent = document.getElementById('content');
const firstChild = parent.childNodes[0];
parent.insertBefore(p1, firstChild);
parent.appendChild(p2);
insertBefore
方法先接收要插入的元素,然后是“参考节点”,即要在其之前插入的节点。
appendChild
方法很简单,将指定元素添加为最后一个子节点。
3.9.3 元素样式设置
使用 DOM API 可以对元素样式进行全面且精细的控制,但通常建议使用 CSS 类而不是修改元素的单个属性。也就是说,如果要更改元素的样式,先创建一个新的 CSS 类,然后将该类应用到要设置样式的元素上。使用 JavaScript 很容易将现有的 CSS 类应用到元素上,例如,如果要突出显示所有包含“unique”一词的段落,首先创建一个新的 CSS 类:
.highlight {
background: #ff0;
font-style: italic;
}
然后编写一个函数来实现该功能:
function highlightParas(containing) {
if(typeof containing === 'string')
containing = new RegExp(`\\b${containing}\\b`, 'i');
const paras = document.getElementsByTagName('p');
console.log(paras);
for(let p of paras) {
if(!containing.test(p.textContent)) continue;
p.classList.add('highlight');
}
}
highlightParas('unique');
如果要移除突出显示效果,可以使用 classList.remove 方法:
function removeParaHighlights() {
const paras = document.querySelectorAll('p.highlight');
for(let p of paras) {
p.classList.remove('highlight');
}
}
移除 highlight 类时,也可以重用之前的 paras 变量,直接在每个段落元素上调用
remove('highlight')
,如果元素本身没有该类,该操作不会有任何效果。但由于移除操作可能在稍后进行,期间可能有其他代码添加了突出显示的段落,如果要清除所有突出显示,进行查询是更安全的方法。
3.9.4 数据属性
HTML5 引入了数据属性,允许向 HTML 元素添加任意数据,这些数据不会被浏览器渲染,但可以通过 JavaScript 轻松读取和修改。修改示例 HTML 文件,添加两个按钮,分别用于触发突出显示和移除突出显示的操作:
<button data-action="highlight" data-containing="unique">
Highlight paragraphs containing "unique"
</button>
<button data-action="removeHighlights">
Remove highlights
</button>
可以使用
document.querySelectorAll
查找所有 action 属性为 “highlight” 的元素:
const highlightActions = document.querySelectorAll('[data-action="highlight"]');
这引入了一种新的 CSS 选择器,方括号语法允许根据任何属性匹配元素,这里是根据特定的数据属性匹配。虽然只有一个按钮,但使用
querySelectorAll
可以支持多个元素触发相同的操作(这很常见,例如在同一页面上通过菜单、链接或工具栏访问的操作)。查看 highlightActions 中的一个元素,会发现它有一个 dataset 属性:
highlightActions[0].dataset;
// DOMStringMap { containing: "unique", action: "highlight" }
DOM API 将数据属性值存储为字符串(由 DOMStringMap 类暗示),意味着不能存储对象数据。jQuery 扩展了数据属性的功能,提供了一个接口,允许将对象作为数据属性存储。
还可以使用 JavaScript 修改或添加数据属性,例如,如果要突出显示包含“giraffe”一词的段落并指示进行区分大小写的匹配,可以这样做:
highlightActions[0].dataset.containing = "giraffe";
highlightActions[0].dataset.caseSensitive = "true";
3.9.5 事件
DOM API 描述了近 200 个事件,每个浏览器还实现了非标准事件,这里不会讨论所有事件,但会介绍需要了解的内容。以一个非常容易理解的事件——点击事件为例,使用点击事件将“highlight”按钮与
highlightParas
函数关联起来:
const highlightActions = document.querySelectorAll('[data-action="highlight"]');
for(let a of highlightActions) {
a.addEventListener('click', evt => {
evt.preventDefault();
highlightParas(a.dataset.containing);
});
}
const removeHighlightActions = document.querySelectorAll('[data-action="removeHighlights"]');
for(let a of removeHighlightActions) {
a.addEventListener('click', evt => {
evt.preventDefault();
removeParaHighlights();
});
}
每个元素都有一个
addEventListener
方法,用于指定事件发生时要调用的函数,该函数接收一个 Event 类型的对象作为参数。事件对象包含与事件相关的所有信息,具体取决于事件类型,例如点击事件有 clientX 和 clientY 属性,指示点击发生的坐标,以及 target 属性,指示触发点击事件的元素。事件模型允许多个处理程序处理同一个事件,许多事件有默认处理程序,例如用户点击
链接时,浏览器会通过加载请求的页面来处理该事件。如果要阻止默认行为,可以调用
evt.preventDefault()
。
下面是一个简单的 mermaid 流程图,展示点击按钮触发事件的流程:
graph TD;
A[点击按钮] --> B[触发 click 事件];
B --> C[执行 addEventListener 中的函数];
C --> D{判断按钮类型};
D -- 高亮按钮 --> E[调用 highlightParas 函数];
D -- 移除高亮按钮 --> F[调用 removeParaHighlights 函数];
综上所述,在浏览器中使用 JavaScript 进行开发时,ES6 虽好但在兼容性上存在问题,需要借助转译器。DOM 是与浏览器交互的核心,通过各种方法可以方便地遍历、查询、操作 DOM 元素,还能利用数据属性和事件来实现更丰富的交互效果。
4. 事件冒泡与捕获
4.1 基本概念
事件在 DOM 中传播有两种方式:冒泡和捕获。事件冒泡是指事件从最具体的元素开始触发,然后逐级向上传播到文档根节点;而事件捕获则是从文档根节点开始,逐级向下传播到最具体的元素。
例如,当用户点击一个按钮,该按钮位于一个
<div>
元素内,而
<div>
元素又位于
<body>
元素内。在事件冒泡阶段,事件首先在按钮上触发,然后传播到
<div>
元素,最后传播到
<body>
元素;在事件捕获阶段,事件首先在
<body>
元素上触发,然后传播到
<div>
元素,最后到达按钮。
4.2 代码示例
以下是一个简单的示例,展示如何使用
addEventListener
来监听事件的冒泡和捕获阶段:
// 获取元素
const outerDiv = document.getElementById('outerDiv');
const innerDiv = document.getElementById('innerDiv');
const button = document.getElementById('myButton');
// 捕获阶段监听
outerDiv.addEventListener('click', () => console.log('Outer div (capture)'), true);
innerDiv.addEventListener('click', () => console.log('Inner div (capture)'), true);
button.addEventListener('click', () => console.log('Button (capture)'), true);
// 冒泡阶段监听
outerDiv.addEventListener('click', () => console.log('Outer div (bubble)'), false);
innerDiv.addEventListener('click', () => console.log('Inner div (bubble)'), false);
button.addEventListener('click', () => console.log('Button (bubble)'), false);
在上述代码中,
addEventListener
的第三个参数设置为
true
表示在捕获阶段监听事件,设置为
false
或省略表示在冒泡阶段监听事件。
4.3 事件委托
事件委托是利用事件冒泡的特性,将事件处理程序绑定到一个父元素上,而不是每个子元素上。这样可以减少事件处理程序的数量,提高性能。
例如,假设有一个无序列表,每个列表项都需要处理点击事件:
<ul id="myList">
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
可以将点击事件处理程序绑定到
<ul>
元素上,而不是每个
<li>
元素上:
const myList = document.getElementById('myList');
myList.addEventListener('click', (event) => {
if (event.target.tagName === 'LI') {
console.log('Clicked on: ', event.target.textContent);
}
});
在上述代码中,当用户点击某个列表项时,点击事件会冒泡到
<ul>
元素,通过判断
event.target
的标签名是否为
LI
,可以确定是哪个列表项被点击。
以下是一个 mermaid 流程图,展示事件委托的流程:
graph TD;
A[点击列表项] --> B[事件冒泡到父元素];
B --> C[父元素的事件处理程序捕获事件];
C --> D{判断点击的元素是否为目标元素};
D -- 是 --> E[执行相应操作];
D -- 否 --> F[不做处理];
5. 异步操作与定时器
5.1
setTimeout
和
setInterval
在浏览器环境中,
setTimeout
和
setInterval
是常用的定时器函数。
setTimeout
用于在指定的延迟时间后执行一次回调函数,而
setInterval
用于每隔指定的时间重复执行回调函数。
以下是一个使用
setTimeout
的示例:
function sayHello() {
console.log('Hello!');
}
// 在 2 秒后执行 sayHello 函数
setTimeout(sayHello, 2000);
以下是一个使用
setInterval
的示例:
function printTime() {
const now = new Date();
console.log(now.toLocaleTimeString());
}
// 每隔 1 秒执行一次 printTime 函数
const intervalId = setInterval(printTime, 1000);
// 5 秒后停止定时器
setTimeout(() => {
clearInterval(intervalId);
}, 5000);
在上述代码中,
clearInterval
用于停止
setInterval
定时器,
clearTimeout
用于停止
setTimeout
定时器。
5.2 异步操作与回调地狱
在处理异步操作时,可能会遇到回调地狱的问题。回调地狱是指多个异步操作嵌套在一起,导致代码难以阅读和维护。
例如,以下是一个简单的回调地狱示例:
setTimeout(() => {
console.log('First operation');
setTimeout(() => {
console.log('Second operation');
setTimeout(() => {
console.log('Third operation');
}, 1000);
}, 1000);
}, 1000);
为了解决回调地狱的问题,可以使用 Promise、async/await 等技术。
5.3 Promise
Promise 是一种处理异步操作的对象,它有三种状态:pending(进行中)、fulfilled(已成功)和 rejected(已失败)。
以下是一个使用 Promise 的示例:
function asyncOperation() {
return new Promise((resolve, reject) => {
setTimeout(() => {
const randomNumber = Math.random();
if (randomNumber > 0.5) {
resolve('Operation succeeded');
} else {
reject('Operation failed');
}
}, 1000);
});
}
asyncOperation()
.then((result) => {
console.log(result);
})
.catch((error) => {
console.error(error);
});
在上述代码中,
asyncOperation
函数返回一个 Promise 对象,通过
then
方法处理成功的结果,通过
catch
方法处理失败的结果。
5.4 async/await
async/await
是 ES8 引入的语法糖,用于更简洁地处理异步操作。
async
函数返回一个 Promise 对象,
await
关键字只能在
async
函数内部使用,用于等待一个 Promise 对象的解决。
以下是一个使用
async/await
的示例:
function asyncOperation() {
return new Promise((resolve, reject) => {
setTimeout(() => {
const randomNumber = Math.random();
if (randomNumber > 0.5) {
resolve('Operation succeeded');
} else {
reject('Operation failed');
}
}, 1000);
});
}
async function main() {
try {
const result = await asyncOperation();
console.log(result);
} catch (error) {
console.error(error);
}
}
main();
在上述代码中,
main
函数是一个
async
函数,使用
await
关键字等待
asyncOperation
函数返回的 Promise 对象的解决。如果 Promise 对象被拒绝,会抛出一个错误,可以使用
try...catch
语句捕获并处理错误。
6. 跨域资源共享(CORS)
6.1 同源策略
同源策略是浏览器的一个重要安全机制,它限制了一个源(协议、域名和端口)的网页在未经允许的情况下访问另一个源的资源。例如,一个在
http://example.com
上的网页无法直接访问
http://anotherdomain.com
上的资源。
6.2 CORS 简介
跨域资源共享(CORS)是一种现代的解决方案,用于允许浏览器在跨域请求时访问资源。CORS 通过在服务器端设置响应头来允许特定的源访问资源。
6.3 服务器端配置
以下是一个使用 Node.js 和 Express 框架的示例,展示如何在服务器端配置 CORS:
const express = require('express');
const app = express();
// 允许所有源访问
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
next();
});
// 处理请求
app.get('/data', (req, res) => {
res.json({ message: 'This is some data' });
});
const port = 3000;
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
在上述代码中,通过设置
Access-Control-Allow-Origin
响应头为
*
,允许所有源访问服务器资源。还可以设置
Access-Control-Allow-Methods
和
Access-Control-Allow-Headers
响应头,指定允许的请求方法和请求头。
6.4 客户端请求
在客户端,可以使用
fetch
API 进行跨域请求。以下是一个使用
fetch
API 的示例:
fetch('http://example.com/data')
.then((response) => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then((data) => {
console.log(data);
})
.catch((error) => {
console.error('There was a problem with the fetch operation:', error);
});
在上述代码中,使用
fetch
函数发送一个跨域请求,通过
then
方法处理响应,通过
catch
方法处理错误。
以下是一个表格,总结了同源策略和 CORS 的区别:
| 特性 | 同源策略 | CORS |
| — | — | — |
| 限制范围 | 严格限制不同源之间的资源访问 | 允许在服务器端配置跨域访问 |
| 实现方式 | 浏览器内置的安全机制 | 服务器端设置响应头 |
| 灵活性 | 低 | 高 |
综上所述,在浏览器中使用 JavaScript 进行开发时,需要了解事件冒泡与捕获、异步操作与定时器、跨域资源共享等重要概念。通过合理运用这些技术,可以实现更复杂、更高效的 Web 应用程序。同时,要注意处理好兼容性问题,确保代码在不同的浏览器中都能正常运行。
超级会员免费看
1360

被折叠的 条评论
为什么被折叠?



