在简单学习了 HTML 和 CSS 之后,接下来是 JavaScript 的学习,学习的重点不是 JavaScript 作为一个脚本语言的语法内容,而是 JavaScript 对于网页的控制,参考教程:https://developer.mozilla.org。

  • HTML 定义了网页的内容
  • CSS 描述了网页的布局
  • JavaScript 控制了网页的行为

1. 在 HTML 中使用 JS

在 HTML 和 CSS 一起组装成一个网页之后,浏览器的 JavaScript 引擎将执行 JavaScript 代码,这保证了当 JavaScript 开始运行之前,网页的结构和样式已经就位,然后 JS 可以更新这个网页的具体内容等。

对于浏览器,它的每一个标签页使用了一个独立的运行环境,不同标签页之间没有任何联系,JS 代码也相互独立运行,一定程度上保证了信息安全。

script 标签

在 HTML 的 head 或者 body 部分都可以使用,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>demo title</title>
</head>
<body>
<script>
console.log("使用JavaScript");
document.write("<h1>这是一个标题</h1>");
</script>
</body>
</html>

建议在 body 部分的最后使用(也就是</body>之前),否则加载时可能遇到 HTML 元素还没出现等错误,通常 JS 需要等待 HTML/CSS 加载完成后进行执行,对网页进行进一步的处理。

链接 JS 文件

导入外部的 js 文件,在 HTML 的 head 或者 body 部分都可以使用,例如:

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>demo title</title>
</head>
<body>
<script src="abc.js"></script>
</body>
</html>

加载逻辑

对于外部脚本的加载执行,默认情况下,浏览器处理 HTML/CSS 生成页面时,遇到<script>元素时会中断页面的加载,立刻加载脚本并执行,然后加载页面的剩余部分。

可以使用 async 进行异步化加载,它告知浏览器:在遇到<script>元素时不要中断后续页面的加载,异步地进行脚本加载,等到 JS 脚本下载完成时中断页面的处理,执行脚本,执行完成后继续加载页面。使用 async 引入的脚本建议不要修改页面内容,并且不能依赖其他脚本,因为对于多个 async,加载的进度不可控,因此执行的顺序也不可控。

1
<script async src="abc.js"></script>

可以使用 defer 进行,它明确告知浏览器:遇到<script>元素时异步进行脚本加载,等到整个页面加载结束,才会执行脚本,脚本会在 DOMContentLoaded 事件触发之前执行完,并且对于多个脚本按照出现的顺序依次执行,例如

1
2
3
4
5
<script defer src="js/script1.js"></script>

<script defer src="js/script2.js"></script>

<script defer src="js/script3.js"></script>

2. JS 基础语法

作为脚本语言,JS 和 Python 非常类似,首先过一遍语法。

  • JS 的语句使用大括号划分语句块的结构层次等。
  • JS 对于大小写敏感!
  • JS 语句可以不使用结束符,但是建议使用分号作为语句的结束,没有分号的语句出现在行末总是默认语句结束了。
  • JS 支持注释,包括/**/和//都可以。

基本字面量

  • 数字 Number 包括整数和小数;(整数和小数根本不分开,特别注意)
  • 字符串 String 例如a="John",支持索引访问(从 0 开始),可以直接获取长度a.length;
  • 布尔类型 Boolean 例如 true 和 false;
  • 数组 Array 例如[1,2,3];
  • 对象 Object 例如{name:"abc",length:20};(JSON 语法就是由此衍生的,最后一个不要加逗号)
  • 函数 Function 例如function fa(a,b){ return a*b; }

数字字符串可以与数字互相转换类型:

1
2
3
4
(123).toString(); // 得到 "123"
String(123); // 同上

Number("3.14"); // 得到数字3.14

变量

和 Python 一样,JS 变量是没有固定类型的,变量可以赋任何类型的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
var a, b; // 变量声明(创建了变量,但是没有值,未定义undefined)
a = 1;

var length = 16; // 创建变量并赋值
var length; // 重复的变量声明没有语法错误,并且不会丢失原有的值

length = undefined; // 可以对变量赋值nundefined或者null,会清空变量原有的值

// null更适合释放一个复杂对象
// undefined是所有变量未获得值时的状态

var name = "John";
document.getElementById("demo").innerHTML = name; // 把id为demo的元素内容换成了字符串John

可以使用 typeof 获取当前变量的类型,例如

1
2
3
var a = 1;

typeof a; // 返回number

不声明直接使用变量也是可以的,这时会自动视为 window 的一个属性,例如

1
2
3
a = "abc";
// 不使用var a 声明变量而直接使用
// 此后也可以通过window.a访问变量

对于var a等变量声明语句,JS 解析时采用了声明提升的规则,也就是会自动把变量声明语句(不含初值)移动到语句块的最前方。

函数

见下例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 无参数
function myfunc_1() {
return 1;
}

// 含参数
// 可以定义变量
function myfunc_2(a, b) {
var x = a + b; // 这里默认是局部变量,函数退出后自动销毁
return x;
}

// 不一定需要返回值,可以无值返回,还可以混合
function myfunc_3(a, b) {
if (a > b) {
return;
}
return a;
}

对象

对象的定义以及属性的获取见下例,格式就是 JSON 那样:

1
2
3
4
5
6
7
var a = {
name: "abc",
length: 12,
};

a.length; //返回12
a["length"]; //返回12 注意需要加双引号

对象除了属性,还可以定义方法,也就是对象可调用的函数,例如

1
2
3
4
5
6
7
8
9
var a={
name: "abc"
f: function(){
return this.name;
}
}

a.f() // 调用方法,返回的是"abc"
a.f // 返回的是一个函数

流程控制

条件语句,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if (a > 0) {
// ...
}

if (a != 0) {
// ...
} else if (a > 0) {
// ...
} else {
// ...
}

switch (n) {
case 1:
// ...
break;
case 2:
// ...
break;
default:
// ...
}

循环语句,基本上也和 c 风格一样,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// c风格的for循环
for (var i = 0; i < s.length; i++) {
a += i;
}

// 遍历对象的所有属性
for (x in person) {
a = a + person[x];
}

while (i < 5) {
// ...
}

do {
// ...
} while (a > 0);

也支持 break 和 continue 跳出循环。

严格模式

JS 的语法较为宽松,但是可以在脚本或函数的开头使用下面一行代码开启严格模式,

1
"use strict";

严格模式会用更严格的标准来检查语法:

  • 不允许使用未声明的变量;
  • 不允许使用delete a;删除变量或对象或函数;
  • 不允许函数重名等。
  • …

3. JS 脚本如何执行

JS 脚本在网页中何时会被执行?主要有两类:

  • 网页加载时,或者网页加载完成时;
  • 特定的事件触发,例如点击按钮,在文本框输入内容,关闭页面等,主要是通过调用函数来执行。

加载时执行

例一,加载页面时 demo 元素会被 JS 修改内容,变成”段落已修改。”

1
2
3
4
5
<p id="demo">我的第一个段落</p>

<script>
document.getElementById("demo").innerHTML = "段落已修改。";
</script>

效果为:

我的第一个段落

例二,加载页面时直接在页面中添加内容,注意不要在文档加载完成之后使用document.write(),这会覆盖该文档。

1
2
3
<script>
document.write(Date());
</script>

效果为:

例三,加载页面时会自动触发弹窗,还会向浏览器控制台写入信息(未使用)

1
2
3
4
<script>
window.alert("hello,world");
console.log("hello");
</script>

由事件触发

例一,点击按钮会把<p>元素的内容设置为当前时间,因为把按钮的 onclick 绑定到了 JS 的 myFunction 函数,会触发执行 myFunction 函数。

1
2
3
4
5
6
7
8
9
10
<p>
当前时间:<span id="nowdate"></span>
<button onclick="myFunction()">刷新时间</button>
</p>
<script>
function myFunction() {
document.getElementById("nowdate").innerHTML = Date();
}
myFunction(); // 第一次在加载时会执行
</script>

效果为:

当前时间:

除了在 HTML 元素中绑定,也可以在 JS 脚本中对元素的特定事件绑定相应函数。例如下面通过 JS 脚本对文本绑定了很多函数,对应行为会分别触发相应函数。

源代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<p id="d0">与这段文字交互</p>

<script>
function whenclick() {
document.getElementById("d0").innerHTML = "你点击了这段文字";
}
function whenclickright() {
document.getElementById("d0").innerHTML = "你右键点击了这段文字";
}
function whenmouseover() {
document.getElementById("d0").style.color = "green";
}
function whenmouseout() {
document.getElementById("d0").style.color = "blue";
document.getElementById("d0").innerHTML = "与这段文字交互";
}

document.getElementById("d0").onclick = whenclick;
document.getElementById("d0").oncontextmenu = whenclickright;
document.getElementById("d0").onmouseover = whenmouseover;
document.getElementById("d0").onmouseout = whenmouseout;
</script>

效果为:

与这段文字交互

常见的事件为:

  • 当前 HTML 元素被改变 onchange;
  • 当前 HTML 元素被单击(似乎左右键都行) onclick;
  • 当前 HTML 元素被右键点击 oncontextmenu;
  • 鼠标移动到 HTML 元素上 onmouseover;
  • 鼠标从 HTML 元素上移开 onmouseout;

4. JS 如何修改页面

接下来考虑:JS 脚本如何修改网页的内容,包括 HTML 元素的内容,属性和样式等。

接入 HTML 输入流

使用 document.write 在页面当前位置中添加内容,注意不要在文档加载完成之后使用 document.write(),这会覆盖该文档。

源代码如下(未使用)

1
2
3
<script>
document.write(Date());
</script>

改变元素内容

使用document.getElementById等方法可以获取元素,对元素的 innerHTML 属性赋值,就是直接修改元素的内容。

源代码如下

1
2
3
4
5
6
7
8
9
10
11
<ul>
<li><span id="p1">Hello World!</span></li>
<li><span name="p2">Hello World!</span></li>
<li><span class="p3">Hello World!</span></li>
</ul>

<script>
document.getElementById("p1").innerHTML = "新文本1 !";
document.getElementsByName("p2")[0].innerHTML = "新文本2 !"; // 注意是elements,得到的是数组
document.getElementsByClassName("p3")[0].innerHTML = "新文本3 !"; // 注意是elements,得到的是数组
</script>

效果如下:

  • Hello World!
  • Hello World!
  • Hello World!

改变元素属性/样式

使用document.getElementById等方法可以获取元素,对元素的 style 属性的子属性赋值,就是直接修改元素的样式。

源代码如下:

1
2
3
4
5
6
7
8
9
10
<ul>
<li><span id="q1">Hello World! 改蓝色</span></li>
<li><span id="q2">Hello World! 改字体</span></li>
<li><span id="q3">Hello World! 改字号</span></li>
</ul>
<script>
document.getElementById("q1").style.color = "blue";
document.getElementById("q2").style.fontFamily = "Arial";
document.getElementById("q3").style.fontSize = "larger";
</script>

效果如下:

  • Hello World! 改蓝色
  • Hello World! 改字体
  • Hello World! 改字号

直接弹窗

浏览器支持三种弹窗:警告框、确认框、提示框。

  • 警告框向用户弹出警告,用户只能关闭,
  • 确认框需要用户选择确认或取消,返回一个布尔值,
  • 提示框需要用户输入一个值(可以给默认值),然后点击确认或直接取消,如果取消返回值为 null,

可以分别点击按钮试一下,源代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<script>
function f1() {
alert("你好,我是一个警告框!");
}

function f2() {
var x;
var r = confirm("按下按钮!");
if (r == true) {
x = '你按下了"确定"按钮!';
} else {
x = '你按下了"取消"按钮!';
}
document.getElementById("append1").innerHTML = x;
}

function f3() {
var x;
var person = prompt("请输入你的名字", "Harry Potter");
if (person != null && person != "") {
x = "你好 " + person + "! 今天感觉如何?";
document.getElementById("append2").innerHTML = x;
}
}
</script>

计时事件

可以实现每秒获取时间,写入元素中: 也就是每秒钟运行一次代码,点击时间则可以停止这个计时事件。

源代码如下,似乎函数使用可以放在定义之前?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script>
// 接入变量myVar中,否则无法停止
var myVar = setInterval(function () {
myTimer();
}, 1000); // 间隔1000毫秒

function myTimer() {
var d = new Date();
var t = d.toLocaleTimeString();
document.getElementById("time1").innerHTML = t;
}

function myStopFunction() {
clearInterval(myVar); // 可以停止myVar这个计时事件
}
</script>

5. JS 模块化

前面的例子中,JavaScript 要么直接写在 HTML 的 <script> 标签中,要么通过:

1
<script src="abc.js"></script>

引入一个外部 JS 文件。这种脚本可以称为普通脚本(classic script)。它只是把 JS 代码从 HTML 中移到了另一个文件里,本质上仍然像一段直接插入页面的脚本,实际差异不大。

这种原始的脚本写法会把所有的顶层变量暴露到全局作用域中。多个普通脚本之间也共享同一个全局环境,因此后面的脚本可以使用前面脚本定义的变量和函数:

1
2
<script src="a.js"></script>
<script src="b.js"></script>

这种处理方式简单,但问题也很明显:

  • 多个脚本共享全局作用域,名字容易冲突;
  • 脚本之间依赖关系不清楚,必须靠 HTML 中的加载顺序保证;
  • 一个文件到底向外提供了什么功能、依赖了什么功能,不够明确;
  • 文件拆多以后,维护的重点不只是“文件变短”,而是“依赖关系是否清楚”。

ES Module 解决的就是这个问题。模块不是简单地把代码塞进另一个文件,而是让每个文件有自己的作用域,并且用 export 明确导出功能,用 import 明确导入依赖。

在 HTML 中,模块脚本需要写成:

1
<script type="module" src="./main.js"></script>

普通脚本和模块脚本的主要区别可以先记成:

普通脚本 模块脚本
<script src="abc.js"></script> <script type="module" src="./main.js"></script>
顶层变量可能进入全局作用域 每个模块有自己的作用域
依赖关系主要靠 HTML 中的脚本顺序 依赖关系写在 import 中
不能使用顶层 import/export 可以使用 import/export
默认不是严格模式 默认使用严格模式
默认会阻塞后续 HTML 解析 默认类似 defer,等 HTML 解析后执行

对于现代编程语言来说,模块化的重点不是“因为代码太长所以拆文件”,而是解耦——每个文件明确声明自己依赖什么、向外提供什么。

还有一个实践上的差异:普通 HTML + 普通脚本的例子,很多时候可以直接双击用浏览器打开 index.html,也就是通过 file:// 访问;但是使用 ES Module 之后,直接双击用浏览器打开可能无法正常工作,通常需要开启本地 HTTP server,从 localhost 的指定端口访问。

这是浏览器的安全检查导致的:模块脚本会按照模块加载规则继续请求 import 的其它文件,浏览器对 module 的跨源检查也更严格。file:// 的 origin 比较特殊,直接打开时可能遇到模块导入失败或 CORS 相关错误。

例如一个最简单的 TodoList,所有逻辑都写在一个 HTML 文件中:

index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>TodoList</title>
</head>
<body>
<h1>TodoList</h1>

<form id="form">
<input id="input" />
<button type="submit">添加</button>
</form>

<ul id="list"></ul>

<script>
const form = document.querySelector("#form");
const input = document.querySelector("#input");
const list = document.querySelector("#list");

form.addEventListener("submit", (event) => {
event.preventDefault();

const text = input.value.trim();
if (!text) {
return;
}

const item = document.createElement("li");
item.textContent = text;

const button = document.createElement("button");
button.textContent = "删除";
button.addEventListener("click", () => item.remove());

item.appendChild(button);
list.appendChild(item);
input.value = "";
});
</script>
</body>
</html>

注意:

  • 这个简单的 Todolist 其实并没有保存 Todo 数据。每个任务只是运行时创建出来的 DOM 节点,数据只存在于当前页面中;关闭或刷新页面后,浏览器会重新加载最初的 HTML,列表也会恢复为空。
  • 数据的存储有很多种实现,与具体情景有关。例如只是本地使用的小工具,一般可以把 Todo 数组转换成 JSON 后保存到浏览器的 localStorage,页面加载时再读取并恢复。如果需要登录、多设备同步或多人共享时,通常会把数据发送给后端,由服务器保存到数据库。

下面用模块化的方式改写,得到的是同一个目录下的三个文件:todo.js、main.js 和 index.html。

todo.js
1
2
3
4
5
6
7
8
9
10
11
export function createTodoItem(text) {
const item = document.createElement("li");
item.textContent = text;

const button = document.createElement("button");
button.textContent = "删除";
button.addEventListener("click", () => item.remove());

item.appendChild(button);
return item;
}

这里的 export 关键词表示函数 createTodoItem 可以被其它模块使用。

然后在入口模块 main.js 中导入并使用:

main.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { createTodoItem } from "./todo.js";

const form = document.querySelector("#form");
const input = document.querySelector("#input");
const list = document.querySelector("#list");

form.addEventListener("submit", (event) => {
event.preventDefault();

const text = input.value.trim();
if (!text) {
return;
}

list.appendChild(createTodoItem(text));
input.value = "";
});

这里的 import 语句表示当前模块依赖 ./todo.js 中导出的 createTodoItem。

对应的 index.html 可以简化为:

index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!DOCTYPE html>
<html lang="zh-CN">

<head>
<meta charset="UTF-8" />
<title>TodoList</title>
</head>

<body>
<h1>TodoList</h1>

<form id="form">
<input id="input" />
<button type="submit">添加</button>
</form>
<ul id="list"></ul>
<script type="module" src="./main.js"></script>

</body>

</html>