Written by SJTU-XHW

Reference: 《Professional JavaScript for Web Developers》 3rd Edition

本人学识有限,笔记难免有错,恳请读者能够批评指正,本人将不胜感激!


Chapter 7. 简单使用 DOM

假设你学习了初级数据结构,那么把 HTML 页面想象成一个以元素为结点的一般树。JavaScript DOM 操作就是操作这个文档树,达到改变前端页面的目的。

在浏览器运行的 JavaScript 引擎中,全局运行环境中会自动设置一个变量 document(DOM 对象),操作它就是在动态操作 HTML 页面。

7.1 基本操作

假设你学习了 CSS 的基础用法。

  • document.querySelector(<selectorStr>):JavaScript DOM 中的重要方法,document 对象的 按选择器查找 HTML 中的元素(CSS 中有效的所有选择器,除了伪类选择器都行);

    如果有多个符合选择器的对象,那么只会返回一个。想要返回全部,请使用 querySelectorAll() -> NodeList (or Array)

    注:还有一些老方法 getElementByIdgetElementByTagName 不建议使用,因为太多太繁了,有这个方法足够了。

  • <Node>.textContent:很多元素,例如 divbuttonpa 等等,可以通过对 DOM 结点设置这个属性来达到更改文本的目的。前提是这个元素能够显示文本;

  • <Node>.href:很多含有 href 属性的元素可以用这种方法更改链接,类似地,src 等属性也可以如此更改;

  • <Node>.classList:获取结点的 class 属性列表,相当于 <Node>.className.split(" ") 再转为 DOMTokenList

    对于 DOMTokenList,与 JavaScript 原生数组不一样,它有自己的属性、方法:

    • DOMTokenList.length只读);
    • DOMTokenList.item(idx)
    • DOMTokenList.contains(token)
    • DOMTokenList.add(token[, token2, ...])
    • DOMTokenList.remove(...)
    • DOMTokenList.toggle(token)修改属性非常方便的选择!!,若 token 存在其中,则删除并返回 false;若不存在,则添加并返回 true;
    • DOMTokenList.forEach(<callback>)
  • 网页刷新:document.location.reload()

7.2 创建新结点

一般分三步走:

  1. 创建结点 DOM 对象:document.createElement(<element-type>)
  2. 设置结点对象属性:<Node>.attr = ...
  3. 向指定结点对象追加新的对象:<Node>.appendChild(<Node>)

7.3 移动、删除结点

注意一个问题:DOM 对象引用与 HTML 元素一一对应。因此除非你使用 <Node>.cloneNode(),否则,之前新建的结点对象如果再一次 appendChild,那么它会移动到父结点的底部,例如:

1
2
3
4
5
6
7
8
const sect = document.querySelector("section");
const para = document.createElement("p");
para.textContent = "We hope you enjoyed the ride.";
sect.appendChild(para);

// 中间添加了一些结点

sect.appendChild(para); // para 会被移动到 sect 的底部,而非出现副本

删除结点就使用:<parentNode>.removeChild(<Node>),如果不知道父元素,那么可以借助 parentNode 属性:

1
<Node>.parentNode.removeChild(<Node>);

7.4 改变结点样式

JavaScript 可以通过修改 DOM 对象的属性来修改其 CSS 样式。

有两种方法可以实现:

  • <Node>.style 属性本身是个对象:

    1
    2
    3
    4
    5
    para.style.color = "white";
    para.style.backgroundColor = "black";
    para.style.padding = "10px";
    para.style.width = "250px";
    para.style.textAlign = "center";
  • <Node>.setAttribute(<attrName>, <attrValue>)

    对应有 getAttribute;它们不止能改 CSS 样式(style 属性),其他属性也能改;

个人建议使用后一种,因为不需要额外记忆内置变量名

Chapter 8. 事件

事件是 JavaScript 能够响应用户行为的重要手段之一。

事件可能有以下几种:

  • 用户选择、点击或将光标悬停在某一元素上。
  • 用户在键盘中按下某个按键。
  • 用户调整浏览器窗口的大小或者关闭浏览器窗口。
  • 网页结束加载。
  • 表单提交。
  • 视频播放、暂停或结束。
  • 发生错误。

为了对一个事件做出反应,就要在 JavaScript 中附加一个事件处理器。它通常是自己创建的、已注册的一个函数。具体使用方法举个例子:

8.1 注册、移除事件处理器

假设 HTML 上有个按钮:

1
2
3
<button class="sbtn groupt">
Click Me
</button>

那么:

1
2
3
4
5
6
7
8
9
10
11
12
13
const btn = document.querySelector("button.sBtn");

function random(num) {
return Math.floor(Math.random() & (num + 1));
}

/* JavaScript 中注册事件处理器的方法。下次如果该对象有动作,则立即调用这个函数。 */
/* 事件类型:click,回调函数:匿名 */
btn.addEventListener("click", () => {
const randCtl = `rgb(${random(255)}, ${random(255)}, ${random(255)})`;
/* document 对象 body 元素 -> style 属性 -> backgroundColor */
document.body.style.backgroundColor = randCtl;
});

知识补充: JavaScript 模板字符串(反引号引起的字符串)

  • 相当于 shell 中的单引号字符串、python 的 f 格式化字符串。

  • 可以向其中嵌入变量(${js_variable})而无需加法运算符;

于是我们认识了注册事件处理器的函数:<DOMObj>.addEventListener(<event-type>, <func>)

除了上面提到的 click 事件,button 元素常用的还有:

  • "mouseover" / "mouseout"(即 hover in / hover out);
  • "focus" / "blur"(即按钮聚焦、失焦,常见用 tab 的情况);
  • "dblclick"(即双击);
  • "submit"(对表单中的按钮而言,触发了 "click" 就接连触发了 "submit");

其实 "click" 事件几乎对其他所有元素都可用;

还有一些事件只有特定元素有,例如 "play" 事件只有 <video><audio> 元素有;


相对地,我们可以移除指定的事件处理器:removeEventListener(<event-type>, <func>)

这就像 Qt 的信号-槽机制中的 connectdisconnect,就连 “一对多”、“多对一”、“一对一” 等原则也与 Qt 一致,不再赘述。

⚠⚠ 友情提醒 ⚠⚠

不要使用内联事件处理器(写在 HTML 中的事件处理器),它们已经被 deprecated 了,例如:

1
2
3
<button onclick="alert('It was deprecated!');">
click me
</button>

8.2 事件对象

JavaScript 解释器在回调某个事件处理器时,会自动向参数列表中传入 event / evt / e(三个都行,开发时为了可读性请选择一个),

事件对象有个常用属性:

  • event.target:相当于 Qt 中信号-槽机制的 event.sender,返回发送方对象;
  • event.key:相当于 Qt QKeyEvent::key(),就是触发键击信号的键;

对常见的 DOM 元素,例如 divp,可以设置 <DOMObj>.textContent 改变文本值;

那么我们使用事件对象有什么用?

和 Qt 一样,答案是自定义默认的事件处理。有些默认的事件处理器(例如表单按钮的 "submit" 动作),开发者不好取消,我们可以通过事件对象的方法 event.preventDefault() 来阻止默认动作、进行自定义:

例如对这个表单,我们想让用户必须填写 First nameLast name,缺一不可,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<form>
<div>
<label for="fname">First name: </label>
<input id="fname" type="text" />
</div>
<div>
<label for="lname">Last name: </label>
<input id="lname" type="text" />
</div>
<div>
<input id="submit" type="submit" />
</div>
</form>
<p></p>

我们就能这样阻止表单的默认提交动作:

1
2
3
4
5
6
7
8
9
10
11
12
const form = document.querySelector("form");
const fname = document.getElementById("fname");
const lname = document.getElementById("lname");
const para = document.querySelector("p");

form.addEventListener("submit", (e) => {
/* 判断字符串为空也可以用 String.length(但要确保不是 null) */
if (fname.value === "" || lname.value === "") {
e.preventDefault();
para.textContent = "You need to fill in both names!";
}
});

8.3 事件传递

我们类比 Qt 的信号-槽机制,在 Qt 中,一个事件 / 信号被捕获后,进入事件 / 信号处理函数,我们以 bool eventFilter(QEvent* event, QObject* obj) 为例,如果该函数返回 true,表示事件处理结束,事件不会再向父控件传递,反之则会。

在 JavaScript 中,事件传递行为和 Qt 相近,只是收到并处理后,开发者无法自定义事件的传递行为,一定会继续向父元素传递

在一些情况下,由于父子元素需要控制事件比较复杂,如果不控制事件的传递,很有可能引发一些问题,例如我们想做个页面包含一个视频,最初它为隐藏状态;还有一个标记为“显示视频”的按钮。我们希望有如下交互:

  • 当用户单击“显示视频”按钮时,显示包含视频的盒子,但不要开始播放视频。
  • 当用户在视频上单击时,开始播放视频。
  • 当用户单击盒子内视频以外的任何区域时,隐藏盒子。

如果这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<button>
显示视频
</button>

<div class="hidden"> <!-- 使用 CSS 的 class 属性标记隐藏内容 -->
<video>
<source
src="XXX.webm"
type="video/webm"
/>
<p>
您的浏览器不支持 webm 格式视频!
</p>
</video>
</div>
1
2
3
4
5
6
7
8
9
10
const btn = document.querySelector("button");
const box = document.querySelector("div");
const video = document.querySelector("video");

/* DOM 对象的另一个重要属性 classList */
/* 可以得到对应的 HTML 元素的 class 属性列表(空格间隔),返回对象是 Array */
btn.addEventListener("click", () => box.classList.remove("hidden"));
/* video 元素的 BOM 方法: play() */
video.addEventListener("click", () => video.play());
box.addEventListener("click", () => box.classList.add("hidden"));

知识补充 1: JavaScript DOM 对象 classList 属性;

知识补充 2: JavaScript video / audio 元素对应的 BOM 方法 play()

我们点击显示视频是没问题的,但是有个问题是,当我们点击视频开始播放的时候,容器会被隐藏!这是因为 video 空间在 div 内部,点击操作会传递到 div 中,相当于同时触发了两个事件处理器。

所以 JavaScript 中的解决方案是 event.stopPropagation(),在事件处理器中,可以停止事件自动地向父元素传播。

上面的 JavaScript 改成这样就能实现目的了:

1
2
3
4
5
6
7
8
9
10
11
12
const btn = document.querySelector("button");
const box = document.querySelector("div");
const video = document.querySelector("video");

btn.addEventListener("click", () => box.classList.remove("hidden"));

video.addEventListener("click", (event) => {
event.stopPropagation();
video.play();
});

box.addEventListener("click", () => box.classList.add("hidden"));

⚠⚠注意:event.target 在事件传递中保持不变,始终代表最内层的元素(事件传递的第一个收到的元素、事件捕获的最后一个捕获到的元素)⚠⚠

8.4 JavaScript 中的 “事件捕获”

JavaScript 中,除了事件传递,还有一种术语叫 “事件捕获”,它和事件传递 唯一区别 是,“事件捕获” 的事件传递方向是从父元素到子元素,与一般事件传递方向正好相反。

你只有在 addEventListener 注册事件处理器时加上第三参数 { capture: true } 才会启用。

你问为什么有这两种传递方式?唉,早期厂商的浏览器互不兼容(Netscape 只使用事件捕捉,而 Internet Explorer 只使用事件冒泡)。

现在的 W3C 指定标准后,将这两种方式都保留下来了,不过默认都是普通的事件传递。

因此这里强烈建议,在编写代码时,尽量使用一种传递方式,增强可读性和可维护性

Chapter 9. JavaScript 异步

网页端程序的响应速度极其重要,因此网页端应用采用异步的重要性不言而喻。

9.1 认识旧式异步实现:基于事件处理器 和 回调函数

上一章我们已经接触到了 JavaScript 异步编程的一种形式:事件处理器。它不会立即被调用,而是在事件发生时才被调用。只要我们把事件换成某个对象的状态变化,那么就变成了异步程序。

于是 早期的异步 API 就是采用这种方式:通过给 XMLHttpRequest 对象附加事件监听器来让程序在请求进展和最终完成时获得通知。

XMLHttpRequestloadend 事件;

1
2
3
4
<button id="xhr">click to send request</button>
<button id="reload">reload</button>

<pre readonly class="event-log"></pre>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const log = document.querySelector(".event-log");
document.querySelector("#xhr").addEventListener("click", () => {
log.textContent = "";
const xhr = new XMLHttpRequest();
xhr.addEventListener("loadend", () => {
log.textContent = `${log.textContent} finished! Status Code:${xhr.status}`;
});
xhr.open(
"GET",
"https://raw.githubusercontent.com/mdn/content/main/files/en-us/_wikihistory.json",
);
xhr.send();
log.textContent = `${log.textContent} request was sent\n`;
});
document.querySelector("#reload").addEventListener("click", () => {
log.textContent = "";
document.location.reload();
});

还有一种异步实现的方式:回调函数。它是一个被传递到另一个函数中的、会在适当的时候被调用的函数。

我们看到之前很多参数名 <callback> 都是回调函数。

那么为什么这种方法被舍弃了呢?考虑这样的场景:假设有 N 个独立的步骤,需要异步执行,它们必须按照一定的执行次序执行。那么当使用回调函数的方式实现的时候,它们就会深度嵌套。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function doStep1(init, callback) {
// DO SOMETHING
}
function doStep2(init, callback) {
// DO SOMETHING
}
function doStep3(init, callback) {
// DO SOMETHING
}
function doOperation() {
doStep1(params, (result1) => {
doStep2(result1, (result2) => {
doStep3(result2, (result3) => {
console.log(`Result: ${result3}`);
});
});
});
}
doOperation();

这样不仅不美观,而且极大影响代码可读性、可维护性(尤其是 JavaScript 语言本身就难以调试);

所以,大多数现代的异步 API 都不使用上述的方法。现在的 JavaScript 异步编程的基础是 Promise;

9.2 新式异步:JavaScript Promise

JavaScript 的 Promise 对象是 一个由异步函数返回的、可以向我们指示当前操作所处的状态的 对象。

JavaScript 中有很多异步 API 都是基于 Promise 的,它们在被调用后会进行一些操作并立即返回 Promise 对象。开发者可以通过将处理函数附加到返回的 Promise 对象上实现回调。

9.2.1 API fetch()

[global.]fetch() 方法就是一个基于 Promise 的、替代 XMLHttpRequest 的 API,我们直接看一个使用示例:

1
2
3
4
5
6
7
8
9
10
11
const fetchPromise = fetch(
"https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

console.log(fetchPromise);

fetchPromise.then((response) => {
console.log(`Respond received: ${response.status}`);
});

console.log("Request sent...");

上面的程序分成几个步骤:

  1. 使用 fetch(默认)向指定网页发送 GET 请求,返回 Promise 对象存放在 fetchPromise 中;
  2. 输出 Promise 对象,能看到 Promise { <state>: "pending" },说明 Promise 对象有属性 state 来查看异步任务是否完成;
  3. 使用 Promise.then(<callback>) 向 Promise 对象加入回调函数,callback 函数传入参数就是之前使用 fetch API 规定的返回内容(Response 对象);

总结:fetch(<url>, <options>) -> Promise ->> Response

9.2.1-Ex 知识补充:Response 对象

常见实例属性(几乎都是只读,想要自定义请从构造函数传入):bodyheadersstatusurl(响应的 URL)、redirected(可以在 fetch 第二参数中禁止重定向:{redirect: "error"})等;

常见的实例方法

  • Response.blob() -> Promise ->> stream这是个异步 API,可以读取响应体中的二进制数据
  • Response.clone():完全复制 Response 对象,唯一区别是内存地址和引用不同;
  • Response.formData() -> Promise ->> FormData以表单数据的方式读取响应体,最终返回 FormData 对象(和之前的 ArrayDOMTokenList 又不一样,自己查 API);
  • Response.json() -> Promise ->> JSON以 JSON 数据的方式读取响应体,最终返回原生 JSON 对象
  • Response.text() -> Promise ->> USVString以字符串的方式读取响应体,最终返回类似 String 的对象,总是 UTF-8

9.2.2 链式 Promise

Promise 更加优雅的原因是,它的 then 实例方法也会返回 Promise 对象,不过这个对象指示 then 回调函数执行的情况。这样,我们就可以链式地使用 Promise:

1
2
3
4
5
6
7
8
9
10
const fetchPromise = fetch(
"https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise.then((response) => {
const jsonPromise = response.json();
jsonPromise.then((json) => {
console.log(json[0].name);
});
});

但我们在上一节中,回调实现遇到 “深层嵌套” 的问题,Promise 好像没有解决?它只不过换成了 then 的嵌套?

实际上不是这样,我们可以借助返回 Promise 的机制多返回几次,把上面的代码改写成:

1
2
3
4
5
6
7
8
9
10
11
const fetchPromise = fetch(
"https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
.then((response) => {
return response.json();
})
.then((json) => {
console.log(json[0].name);
});

9.2.3 Promise 异常处理

众所周知,网络就是不稳定的,不可避免会出现一些问题。Promise 作为异步的基石,必然要进行异常处理。

fetch() 为例,它本身可能因为各种原因出现错误(如没有网络连接、或 URL 本身存在问题),而我们也需要自己在特定的业务逻辑中主动抛出错误。

对此,Promise.catch(<callback>) 就能解决问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const fetchPromise = fetch(
"bad-scheme://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP Request error: ${response.status}`);
}
return response.json();
})
.then((json) => {
console.log(json[0].name);
})
.catch((error) => {
console.error(`Failed to fetch product list: ${error}`);
});

⚠⚠请注意,只要异步操作中接收到了服务器的响应(不管是什么,例如服务器返回的 404),除非开发者自己抛出错误,否则 Promise 认为 fulfilled⚠⚠

9.2.4 Promise state

之前我们在 9.2.1 中看到 Promise 对象有个属性 state 指示异步任务的完成情况。

它有 3 种状态:

  • pending:挂起等待,异步任务进行中;
  • fulfilled:异步任务成功完成,此时会执行 then 传入的回调函数;
  • rejected:异步任务执行失败,此时会执行 catch 传入的回调函数,如果没有就向上抛出错误;

fulfilled 和 rejected 统称 settled;

9.2.5 合并 Promise

除了上面提到的链式使用 Promise,可能还会遇到一些情况,需要合并使用多个 Promise。

  • Promise.all(Array[Promise]) -> Promise ->> Array[...] ,等待所有的 Promise 全部 settled,并且只有全部的 Promise 都 fulfilled,这个 Promise 才会 fulfilled,否则 rejected;
  • Promise.any(Array[Promise]) -> Promise ->> ...,等待任意一个 Promise settled,该 Promise 最终状态取决于这个等到的 Promise;

9.2.6 显式等待 Promise

如果在一些业务逻辑中必须使用同步编程(例如没有最终返回值就进行不下去),那么就可以对 Promise 进行等待处理。对 Promise 的等待必须放在 异步函数(用关键字 async 修饰,箭头函数也是这样) 中,在返回 Promise 的 API 前加上 await 关键字,这样程序能够等待在这里并且返回最终对象;同时错误也会成为普通的 JavaScript 异常向上抛出。

⚠⚠请注意,异步函数只能返回 Promise 对象。⚠⚠

为什么必须要在异步函数中?因为异步函数有等待,也允许开发者在外面等待 / 继续使用 Promise 处理这个异步函数。

await 和 Promise 链一样,强制异步操作串行完成(一般应用在 “前一个步骤是后一个步骤的参数“ 的情况)。如果不需要串行,Promise.all 可以有更好的性能。

9.2.7 自定义基于 Promise 的异步 API

这里需要了解 Promise 的构造函数 Promise(<executor>)executor 的需求参数如下:

1
function executor(resolve, reject) { /*...*/ }

resolvereject 都是函数,分别对应传入 thencatch 中的函数

当一个新的 Promise 对象被创建(new)后,它立即执行 executor 中的内容(通常是耗时任务)。

如果 executor 内调用了 resolve(也就是 then 传入的函数),那么 Promise 对象自动进入 fulfilled 状态;反之如果调用了 reject 或者抛出错误(自动调用 reject,就相当于调用了 catch 传入的函数,或向上抛出了错误。

9.2.8 异步的补充:JavaScript 多线程

这里只是介绍一下,JavaScript 中存在 workers 机制,例如 dedicated workers,创建线程,但不能访问 DOM 等等。有需要的话自行查询文档。

Chapter 10. JavaScript 简单保存会话状态

基本上有 2 种简单的方法:localStoragesessionStorage

二者唯一的区别是,前者是 cookie,可以在用户关闭页面后,仍然保留数据,直到下一次打开就能还原数据;后者是 session,数据只保留到用户关闭此会话(即浏览器的标签页)

使用方法是一样的:

  • 定义对象:直接赋值。myStorage = [window.]localStorage;
  • 异常:SecurityError,用户拒绝使用 cookie / session
  • 设置键值:localStorage.setItem("<key>", "<value>");
  • 获取键值:localStorage.getItem("<key>", "<value>");
  • 移除键值:localStorage.removeItem("<key>");
  • 清空:locaStorage.clear();