JavaWeb 复习讲义
原始讲义
第一部分 基础篇:从一张网页说起

第3章 JavaScript 与 DOM 编程

难度:★★★☆☆ | 前置知识:第1章(HTML 标签)、第2章(CSS 选择器与样式) | 本章解决:让写死的静态页面"动"起来:用 JavaScript 取到页面上的标签、改它的内容和样式、对用户的操作做出反应,并在表单提交之前把不合格的数据挡在浏览器里

本章导读

到这里,你已经能用 HTML 搭出页面的结构、用 CSS 把页面打扮得像模像样。但这样的页面是"死"的:它只会静静地躺在那里,用户点按钮它不会理你,用户填错了手机号它也不吭声。JavaScript 就是给页面装上"神经"和"肌肉"的那门语言:它能看到页面上每一个标签,能改它们的文字、颜色、图片,能听见用户的点击和输入。

这一章在整条学习链路上的位置很关键:前三章(HTML、CSS、JavaScript)合起来就是"浏览器端"的全部内容,从第4章开始我们才转向"服务器端"。本章是前端部分的收官,也是理解后面所有"为什么还要在服务端再校验一遍"的前提。

具体来说,JavaScript 在 JavaWeb 项目里有两个用途,这一章就是围绕这两个用途展开的:

但下面这一点是本章最重要的伏笔:前端校验是"体验",不是"安全"。用户在浏览器里能按 F12 改掉你的 JS、能禁用 JS、能绕过页面直接发 HTTP 请求。所以前端校验挡住的只是"手滑的正常用户",挡不住"故意的攻击者"。真正必须成立的校验,一定要在服务端再做一遍。这件事我们会在第6章(Request 与 Response)里正式做:用 request.getParameter() 拿到参数,在 Servlet 里判断,不合格就转发回注册页并带上错误提示。到时候你会发现,服务端校验和本章写的校验逻辑几乎一模一样,只是换了个地方、换了个语言。

学完这一章,你应该能做到:

  1. 说清 JS 的引入方式、变量声明(var/let/const)、数据类型与类型转换规则;
  2. 用 document.getElementById() 等方法拿到页面元素,改它的 innerHTML、src、checked、style;
  3. 给元素绑定 onclick、onblur、onsubmit 等事件,并在 onsubmit 里 return false 阻止表单提交;
  4. 独立写出一个能跑的注册页校验(用户名 6~12 位、密码 6~12 位、手机号 11 位、用正则改进)。

几个常见的误解先在这里点破,免得你带着错误印象往下读:

3.1 JavaScript 简介

JavaScript 的定义:JavaScript 是一门跨平台、面向对象的脚本语言,它由浏览器直接解析执行,用来控制网页的行为,使网页可交互。

先把最容易混淆的地方讲清:Java 语言也是跨平台的、面向对象的语言,只不过 Java 是编译语言,需要编译成字节码文件(.class)才能运行;而 JavaScript 是脚本语言,不需要编译,由浏览器直接解析并执行。你写完一个 .html 双击打开,里面的 JS 就已经运行了,中间没有任何"编译"步骤。

JavaScript 是用来控制网页行为的,它能使网页可交互。那么它可以做什么呢?主要有三类事情:改变页面内容、修改指定元素的属性值、对表单进行校验。下面用文字把这三种效果描述清楚(原本这三处是配图的,这里改成文字说明,你照着敲一遍就能看到同样的现象):

JavaScript 和 Java 是完全不同的语言,不论是概念还是设计,只是名字比较像而已。但是基础语法类似:变量、if、for、while、switch、函数、数组,写起来和 Java 有七八分像。所以有 Java 的学习经验,再学 JavaScript 就相对比较容易。

关于它的来历,知道三个时间点就够了:JavaScript(简称 JS)在 1995 年由 Brendan Eich 发明,并于 1997 年成为一部 ECMA 标准。ECMA 规定了一套标准,就叫 ECMAScript,所有的客户端脚本语言都必须遵守这个标准,JavaScript 也遵守了这个标准。ECMAScript 6(简称 ES6) 是当时最新的 JavaScript 版本(发布于 2015 年),本课程就是基于 ES6 进行讲解的,所以后面你会看到 let、const、模板字符串这些 ES6 才有的东西。

【补充】

"不需要编译"这句话要理解得准确一点:JS 确实没有"先编译成 class 文件再运行"这一步,但现代浏览器(Chrome 的 V8、Firefox 的 SpiderMonkey)在执行前会把 JS 即时编译(JIT)成机器码,所以它跑得并不慢。真正要抓住的区别是执行时机和运行环境:Java 的字节码跑在 JVM 上,能读写文件、连数据库;JS 跑在浏览器的 JS 引擎里,被关在"沙箱"中,默认不许碰本地文件、不许跨域发请求。这也解释了一个初学者常问的问题:"JS 能不能直接连数据库?"不能,浏览器不给你这个权限,必须通过第5章的 Servlet 中转。顺便一提,后来有人把 V8 引擎单独拿出来做成了 Node.js,JS 才得以在服务器上运行,但那是另一条技术线,本课程不涉及。

【考点】 "JavaScript 和 Java 有什么关系?"标准答案是:没有关系,只是名字相似、语法风格相似。理由:Java 是编译型、强类型、运行在 JVM 上;JS 是解释(脚本)型、弱类型、运行在浏览器引擎里。考试爱考的衍生问法是"JS 需要编译吗":不需要,由浏览器直接解析执行。

3.2 JavaScript 引入方式

JavaScript 引入方式,问的是 HTML 和 JavaScript 怎么结合。有两种:

这两种方式没有优劣之分,页面专属的少量代码用内部脚本,多个页面共用的代码(比如校验函数库)用外部脚本。

3.2.1 内部脚本

在 HTML 中,JavaScript 代码必须位于 <script> 与 </script> 标签之间。这是内部脚本唯一的要求。代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

<script>
    alert("hello js1");
</script>
</body>
</html>

alert(数据) 是 JavaScript 的一个方法,作用是将参数数据以浏览器弹框的形式输出出来。用浏览器打开上面这个页面,你会先看到一个白底的弹框,上面写着 hello js1,点"确定"之后弹框消失、页面才露出来。弹框会阻塞页面的继续渲染,这一点后面讲输出语句时还会用到。

从结果可以看到,JS 代码已经执行了。

提示:在 HTML 文档中可以在任意地方放置任意数量的 <script> 标签。下面这段代码在 head 里放了一个、在 body 里放了一个、在 </html> 后面还放了一个:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script>
        alert("hello js1");
    </script>
</head>
<body>

<script>
    alert("hello js1");
</script>

</body>
</html>
<script>
    alert("hello js1");
</script>

三个弹框会依次弹出。不过一般把脚本置于 body 元素的底部,这样可以改善显示速度。原因很朴素:浏览器在加载页面的时候会从上往下进行加载并解析,<script> 一出现就暂停解析、立刻执行里面的 JS。如果脚本放在 head 里,用户就得先看完弹框、等脚本执行完,才能看到页面内容;放在 body 底部,则用户先看到页面内容,然后再展示动态效果,体验好得多。

【易错点】

脚本放 head 里还有一个更隐蔽的问题:head 里的脚本执行时,body 里的标签还不存在。如果你在 head 的 <script> 里写 document.getElementById("username"),拿到的是 null,下一行 .value 就会报 Uncaught TypeError: Cannot read properties of null。这是初学者最常见的报错之一。两种解法:把脚本挪到 body 底部(推荐),或者把代码包进 window.onload = function () { ... },等页面加载完再执行。原文第三个 <script> 写在 </html> 之后,虽然不是绝对的语法错误(浏览器会容错),但属于不规范写法,实际开发中不要这样写。

3.2.2 外部脚本

第一步:定义外部 js 文件。如定义名为 demo.js 的文件。项目结构如下:

project
├── css
├── imgs
├── js
│   └── demo.js
└── html
    └── demo.html

demo.js 文件内容如下:

alert("hello js");

注意这个文件里只有一行 JS 代码,没有任何 HTML 标签。

第二步:在页面中引入外部的 js 文件。在页面中使用 script 标签,用 src 属性指定 js 文件的 URL 路径:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

<script src="../js/demo.js"></script>
</body>
</html>

这里的 ../js/demo.js 是相对路径:从当前 html 文件所在的 html 目录往上退一级到 project,再进入 js 目录,找到 demo.js。

注意两点:

【易错点】

自闭合写法 <script src="demo.js" /> 在 HTML 里不会被当成"空标签",浏览器会认为 <script> 还没结束,于是一路往下找 </script>,把后面的页面内容全当成脚本文本吞掉,现象是"页面下半部分莫名消失"。这个 bug 极难靠肉眼定位,记住结论就行:script 标签必须成对写。

【补充】 src 除了相对路径,也可以写绝对路径(如 /js/demo.js,从网站根目录算起)或完整 URL(如 https://cdn.xxx.com/jquery.js,通过 CDN 引入)。另外,<script> 还有两个可选属性值得知道:defer(脚本延迟到文档解析完成后执行,按顺序)和 async(脚本下载完就立刻执行,不保证顺序)。它们只对外部脚本有效,作用都是"别让脚本下载阻塞页面渲染"。本章的代码量很小,用不到它们,但你在别人的页面里看到 <script src="..." async> 时要知道那不是笔误。

3.3 JavaScript 基础语法

这一节是整个 JS 的语法的基础。好消息是:它和 Java 有大量重合,你可以用"对照"的方式快速过一遍;坏消息是:凡是和 Java 不一样的地方,恰恰就是考点和容易出错的地方,所以下面每讲到一个"和 Java 不同",都会单独标出来。

3.3.1 书写语法

if (count == 3) {
    alert(count);
}
【易错点】

"分号可有可无"是靠自动分号插入(ASI) 实现的,它的规则里有一条容易出错:遇到换行且下一行无法与上一行接续时,自动补分号。所以下面这段代码不会返回对象,而是返回 undefined:

function getUser() {
    return
    {
        name: "张三"
    };
}

因为 return 后面换行了,JS 自动补了一个分号,变成 return;,后面的对象成了一个孤立的代码块。正确写法是把 { 提到 return 同一行。结论: 分号可以省,但是 return、break、continue 后面千万别随便换行;团队协作时建议统一都写分号,免得出错。

3.3.2 输出语句

JS 可以通过以下方式进行内容的输出,只不过不同的语句输出到的位置不同。这三种输出方式必须都能区分清楚:

(1)使用 window.alert() 写入警告框

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

<script>
    window.alert("hello js");//写入警告框
</script>
</body>
</html>

用浏览器打开,你会看到一个居中的弹框,内容为 hello js,页面上其他地方是空白的,因为 alert 会阻塞渲染。点确定后弹框关闭。window. 可以省略,写成 alert("hello js") 效果完全一样。

(2)使用 document.write() 写入 HTML 输出

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

<script>
    document.write("hello js 2~");//写入html页面
</script>
</body>
</html>

用浏览器打开,可以在页面上直接看到 document.write(内容) 输出的文字 hello js 2~,就像你在 HTML 里手写了这行字一样。

(3)使用 console.log() 写入浏览器控制台

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

<script>
    console.log("hello js 3");//写入浏览器的控制台
</script>
</body>
</html>

用浏览器打开,页面上什么也看不到,因为 console.log(内容) 输出在控制台里。怎么看?在浏览器界面按 F12,切到 Console(控制台) 面板,就能看到 hello js 3。

把三者放在一起对照,这张表建议直接背下来,调试时会反复用到:

语句输出位置会不会阻塞页面典型用途
window.alert(数据)浏览器弹框会,必须点确定演示效果、强提示
document.write(内容)页面文档流中会(写入时)早期动态生成内容,现已少用
console.log(内容)浏览器控制台不会开发调试打日志
【补充】

原文提到"按 F12 查看控制台",这里把浏览器开发者工具的常用面板一次讲清,它是你后面调试 JS 的唯一武器(没有它,JS 报错你只能干瞪眼):

面板中文名你会在什么时候用它
Elements元素看 DOM 结构、临时改 HTML/样式,验证"我取到的是不是这个元素"
Console控制台看 console.log 输出、看红色报错、直接敲 JS 代码试效果
Sources源代码打断点调试(在行号上点一下),看变量当时的真实值
Network网络看表单提交发出的请求、参数、状态码(第4、6章会重度使用)

console 也不只有 log:console.warn() 黄色警告、console.error() 红色错误、console.table(数组) 用表格展示数组或对象、console.time()/timeEnd() 成对使用可测一段代码耗时。调试一个"取不到元素"的问题,最快的三步是:① Console 里有没有红色报错;② 在 Console 敲 document.getElementById("xxx") 看是不是 null;③ 在 Elements 面板确认这个 id 到底存不存在。

【考点】 "alert 和 console.log 有什么区别?"结论:输出位置不同,是否阻塞不同。alert 弹在页面上、会阻塞后续代码执行,正式产品里不该用它提示用户;console.log 打在控制台、不阻塞,只给开发者看,上线前可以留着(用户看不见)。

3.3.3 变量

JavaScript 中用 var 关键字(variable 的缩写)来声明变量,格式是 var 变量名 = 数据值;。

而 JavaScript 是一门弱类型语言,变量可以存放不同类型的值。比如下面这段代码,先给变量赋值为数字,再把它改成字符串,完全合法:

var test = 20;
test = "张三";

这在 Java 里是不可想象的(int test = 20; test = "张三"; 直接编译报错),但在 JS 里天经地义,因为 JS 的变量本身没有类型,类型跟着值走,不跟着变量走。

js 中的变量名命名规则和 Java 语言基本相同:

JavaScript 中的 var 关键字有点特殊,有以下两个地方和其他语言不一样:

(1)作用域:全局变量

{
    var age = 20;
}
alert(age); // 在代码块中定义的 age 变量,在代码块外边还可以使用

这里的大括号只是一个普通代码块,不是函数,所以 var 声明的 age 会"漏"到外面去,弹框能弹出 20。

(2)变量可以重复定义

{
    var age = 20;
    var age = 30;//JavaScript 会用 30 将之前 age 变量的 20 替换掉
}
alert(age); //打印的结果是 30

同一个变量名声明两次不报错,后一次覆盖前一次。

针对如上两个问题,ECMAScript 6 新增了 let 关键字来定义变量。它的用法类似于 var,但是所声明的变量只在 let 关键字所在的代码块内有效,且不允许重复声明。例如:

{
    let age = 20;
}
alert(age);

运行上面代码,浏览器并没有弹框输出结果,说明这段代码是有问题的。通过 F12 打开开发者模式,可以在 Console 里看到类似 Uncaught ReferenceError: age is not defined 的错误信息:age 出了大括号就"不存在"了。而如果在同一个代码块中定义两个同名的 let 变量,IDEA 开发工具就直接报错了(Identifier 'age' has already been declared),根本不用等到浏览器。

ECMAScript 6 还新增了 const 关键字,用来声明一个只读的常量。一旦声明,常量的值就不能改变。 例如:

const PI = 3.14;
PI = 3.15; // 报错:Assignment to constant variable.

可以看到给 PI 这个常量重新赋值时报错了。const 有两个硬性要求:声明时必须赋初始值,之后不能再指向别的值。

【对比】

var / let / const 三者的区别,是本章最高频的考点,直接看表:

对比项varletconst
作用域函数作用域(在 {} 里声明会漏到外面)块级作用域({} 内有效)块级作用域
能否重复声明能,后者覆盖前者不能,直接报错不能,直接报错
变量提升有,提升为 undefined有提升但不可访问(暂时性死区)同 let
声明前使用得到 undefined,不报错报 ReferenceError报 ReferenceError
是否必须初始化否否是
能否重新赋值能能不能(但对象内部属性可以改)
挂到 window 上会(全局 var 成为 window 属性)不会不会

实操结论:新代码一律用 const 打底,需要改值时才用 let,var 只用来读老代码。原文的示例代码大量使用 var,是为了和教材一致,你抄的时候不必改;但自己写新代码时请按这个结论来。

【补充】 上面表格里的"变量提升"和"暂时性死区"值得展开一句,因为它们解释了你见过的怪现象。变量提升(hoisting):JS 在执行一段代码前,会先把所有 var 声明的变量名和 function 声明的函数名提到作用域最前面,但赋值还留在原地。所以 console.log(a); var a = 1; 输出 undefined 而不是报错,因为 a 已经被"声明"了,只是还没赋值。let/const 也会提升,但提升后到声明语句之间的这段区域叫暂时性死区(TDZ),在这段区域里访问会直接抛 ReferenceError。这就是"为什么 var 得到 undefined、let 却报错"的根本原因。

【易错点】 for 循环 + var 是老面试题,也是真实 bug:

for (var i = 0; i < 3; i++) {
    setTimeout(function () {
        console.log(i);
    }, 100);
}
// 输出 3 3 3,而不是 0 1 2

因为 var 的 i 是函数作用域,三个回调共享同一个 i,等定时器执行时循环早已结束,i 已经是 3。把 var i 换成 let i,输出立刻变成 0 1 2:let 会为每次循环创建一个新的绑定。原文在 for 循环里特意写了注释"建议 for 循环小括号中定义的变量使用 let",原因就在这里。

3.3.4 数据类型

JavaScript 中提供了两类数据类型:原始类型和引用类型。用 typeof 运算符可以获取数据类型,比如 alert(typeof age); 以弹框的形式将 age 变量的数据类型输出。

原始数据类型有五种,逐个看:

(1)number:数字(整数、小数、NaN)

var age = 20;
var price = 99.8;
alert(typeof age);   // 结果是 : number
alert(typeof price); // 结果是 : number

注意:NaN 是一个特殊的 number 类型的值(Not a Number,表示"不是一个有效数字"的结果),后面用到再说。

(2)string:字符、字符串,单双引皆可

var ch = 'a';
var name = '张三';
var addr = "北京";
alert(typeof ch);   //结果是 string
alert(typeof name); //结果是 string
alert(typeof addr); //结果是 string

注意:在 js 中双引号和单引号都表示字符串类型的数据,两者没有区别,不像 Java 里单引号只能包单个字符。

(3)boolean:布尔。true,false

var flag = true;
var flag2 = false;

alert(typeof flag);  //结果是 boolean
alert(typeof flag2); //结果是 boolean

(4)null:对象为空

var obj = null;

alert(typeof obj);//结果是 object

为什么打印 obj 变量的数据类型,结果是 object?这是 JS 一个从 1995 年一直保留到今天的著名历史遗留问题,官方文档也是这么解释的:早期实现里,值的类型用一个低位标签表示,对象的标签是 000,而 null 被表示成空指针 0x00,判断类型时一看低位是 000,就归成了 object。改是改不了了(改了会破坏无数老网站),所以只能将错就错。你只需要记住结论:typeof null 是 "object",判断一个变量是不是 null 要用 === null,不能用 typeof。

(5)undefined:当声明的变量未初始化时,该变量的默认值是 undefined

var a;
alert(typeof a); //结果是 undefined

把这五种放在一张表里,考前扫一眼就够:

原始类型typeof 的结果示例说明
number"number"20、99.8、NaN整数小数不分家,NaN 也属于 number
string"string"'a'、"北京"单双引号等价
boolean"boolean"true、false
null"object"null历史 bug,判断要用 === null
undefined"undefined"var a;声明未赋值;函数无返回值时也是它
【补充】

原始类型只有这五种(ES6 又加了 symbol,ES2020 加了 bigint,本课程不涉及),其余全是引用类型,包括 Object、Array、Function、Date、RegExp,以及后面 3.6 节要讲的 DOM 里的 Element。原始类型和引用类型的关键差别在赋值和比较上:原始类型赋值是"拷贝值",改一个不影响另一个;引用类型赋值是"拷贝地址",两个变量指向同一个对象,改一个另一个也变。所以 [1,2] === [1,2] 是 false(两个不同的对象),而 1 === 1 是 true。考试若问"typeof [] 是什么",答案是 "object"(数组本质是对象,想精确判断用 Array.isArray())。

【易错点】 NaN 有个反直觉的特性:它不等于任何值,包括它自己。NaN == NaN 和 NaN === NaN 都是 false。所以判断一个值是不是 NaN,不能用 ==,要用 isNaN(x) 或更严格的 Number.isNaN(x)。另外 isNaN("abc") 是 true:因为它会先把 "abc" 转成数字得到 NaN 再判断,这个"隐式转换"的特性经常被误用。

3.3.5 运算符

JavaScript 提供了如下几类运算符,大部分和 Java 语言都是一样的,不同的是 JS 关系运算符中的 == 和 ===,下面重点演示这两个的区别,其他运算符不做演示(用法与 Java 一致)。

类别运算符示例
一元运算符++,--i++、--j
算术运算符+,-,*,/,%a + b、a % 2
赋值运算符=,+=,-=…sum += i
关系运算符>,<,>=,<=,!=,==,===…age >= 18
逻辑运算符&&,||,!flag && other
三元运算符条件表达式 ? true_value : false_valueage >= 18 ? "成年" : "未成年"

关于 == 和 === 的区别

先说概述:

代码:

var age1 = 20;
var age2 = "20";

alert(age1 == age2);  // true
alert(age1 === age2); // false

age1 是 number 类型的 20,age2 是 string 类型的 "20"。== 先把 "20" 转成数字 20,再比较,得到 true;=== 一看类型不同,直接 false。

再补一组经典结果,考试里出现频率极高:

表达式结果原因
20 == "20"true字符串转数字后比较
20 === "20"false类型不同,直接 false
null == undefinedtrue特例,规定二者相等
null === undefinedfalse类型不同(object vs undefined)
NaN == NaNfalseNaN 不等于自身
"" == 0true空串转数字为 0
"" === 0false类型不同
"1" == truetrue两边都转成 1
0 == falsetrue都转成 0
[] == falsetrue数组转成空串再转成 0
【易错点】

== 的类型转换规则很绕,绕到你无法凭直觉预测结果(上面 [] == false 为 true 就是明证)。真实项目里因为误用 == 导致的 bug 比比皆是。统一结论:比较一律用 ===,只有一种情况可以用 ==:判断"变量是否为 null 或 undefined"时写 x == null(因为它俩 == 相等,一句顶两句)。原文表单校验案例里大量使用 tel.length == 11,因为两边都是 number,用 == 不会出错,但你抄的时候改成 === 更稳。

类型转换

上面讲解 == 运算符时,发现会进行类型转换,所以接下来详细讲解一下 JavaScript 中的类型转换。

(1)其他类型转为 number

var str = +"20";
alert(str + 1) //21
var str = "20";
alert(parseInt(str) + 1); //21

建议使用 parseInt() 函数进行转换。

var flag = +false;
alert(flag); // 0

(2)其他类型转为 boolean

代码如下(把第一行的注释依次换掉,可以看到不同的分支):

// var flag = 3;
// var flag = "";
var flag = undefined;

if (flag) {
    alert("转为true");
} else {
    alert("转为false");
}

使用场景:在 Java 中使用字符串前,一般都会先判断字符串不是 null,并且不是空字符,才会做其他的一些操作。JavaScript 也有类似的操作,代码如下:

var str = "abc";

//健壮性判断
if (str != null && str.length > 0) {
    alert("转为true");
} else {
    alert("转为false");
}

但是由于 JavaScript 会自动进行类型转换,所以上述的判断可以简化成:

var str = "abc";

//健壮性判断
if (str) {
    alert("转为true");
} else {
    alert("转为false");
}

两句 if 的效果完全一样:str 非空、非 null 时进入第一个分支。这就是 JS 里所谓的"真值判断":不需要写 != null && length > 0,直接 if (str) 就行。这个写法在本章后面的表单校验里会反复出现。

【补充】

把"转成 boolean 为 false"的值集中记一下,一共只有 6 个(外加一个 -0):0、NaN、""(空字符串)、null、undefined、false。除此之外一切皆真,包括 "0"(非空字符串)、"false"(非空字符串)、[](空数组)、{}(空对象),这四个经常骗人:if ("0") 会进 true 分支,if ([]) 也会进 true 分支。要判断数组是否为空,得看 arr.length === 0。

【易错点】 parseInt() 和 + 正号的区别在"字符串带杂质"时才暴露:parseInt("12abc") 得到 12(从左往右解析,遇到非数字就停),而 +"12abc" 得到 NaN(要求整个字符串都是合法数字)。另外 parseInt("08") 在老浏览器里曾被当成八进制解析,所以建议始终带上第二个参数:parseInt(str, 10),明确按十进制解析。还要注意 parseInt(3.99) 得到 3,它只取整数部分,不四舍五入。

【考点】 "1 + "1" 等于多少?"答案 "11"。理由:+ 既是算术加也是字符串拼接,只要有一边是 string,另一边就会被转成 string 做拼接。而 "1" - 1 等于 0,因为 -、*、/、% 没有"拼接"语义,两边都会被转成数字。这是隐式类型转换最常考的一组。

3.3.6 流程控制语句

JavaScript 中提供了和 Java 一样的流程控制语句,共五种:if、switch、for、while、dowhile。语法与 Java 几乎逐字相同,直接看代码即可。

(1)if 语句

var count = 3;
if (count == 3) {
    alert(count);
}

(2)switch 语句

var num = 3;
switch (num) {
    case 1:
        alert("星期一");
        break;
    case 2:
        alert("星期二");
        break;
    case 3:
        alert("星期三");
        break;
    case 4:
        alert("星期四");
        break;
    case 5:
        alert("星期五");
        break;
    case 6:
        alert("星期六");
        break;
    case 7:
        alert("星期日");
        break;
    default:
        alert("输入的星期有误");
        break;
}

(3)for 循环语句

var sum = 0;
for (let i = 1; i <= 100; i++) { //建议for循环小括号中定义的变量使用let
    sum += i;
}
alert(sum); // 5050

(4)while 循环语句

var sum = 0;
var i = 1;
while (i <= 100) {
    sum += i;
    i++;
}
alert(sum);

(5)dowhile 循环语句

var sum = 0;
var i = 1;
do {
    sum += i;
    i++;
}
while (i <= 100);
alert(sum);

注意 do...while 的写法:while (条件); 后面有一个分号,这是 JS 里少数必须写分号的位置之一。它的特点是先执行一次再判断,所以循环体至少执行一次,这是它和 while 唯一的区别。

【易错点】

switch 在 JS 里用的是全等(===)比较,而 Java 的 switch 只支持整型/字符串常量。所以 switch ("3") { case 3: ... } 在 JS 里匹配不上,会直接走 default。如果你的 num 是从输入框 value 里拿的,它天生是 string,必须 parseInt(num) 之后再 switch。另外,漏写 break 会发生"穿透"(继续执行下一个 case 的代码),这在需要"多个值共用一段逻辑"时是有意利用的特性,但多数情况下就是 bug。

【补充】 ES6 给数组和对象加了两个更顺手的遍历语法,读别人的代码一定会遇到:for...of 遍历值(for (let v of arr) console.log(v);),for...in 遍历键/索引(for (let k in obj) console.log(k);)。记住一句:遍历数组用 for...of 或普通 for,遍历对象用 for...in。用 for...in 遍历数组会拿到字符串下标的 "0"、"1",做数字运算时会出错。

3.3.7 函数

函数(就是 Java 中的方法)是被设计为执行特定任务的代码块;JavaScript 函数通过 function 关键词进行定义。

(1)定义格式

函数定义格式有两种:

方式 1:声明式

function 函数名(参数1, 参数2..) {
    要执行的代码
}

方式 2:赋值式(函数表达式)

var 函数名 = function (参数列表) {
    要执行的代码
}

注意两点:

function add(a, b) {
    return a + b;
}

上述函数的参数 a 和 b 不需要定义数据类型,因为在每个参数前加上 var 也没有任何意义。

(2)函数调用

调用格式:

函数名称(实际参数列表);

例如:

let result = add(10, 20);

注意:JS 中,函数调用可以传递任意个数参数。例如:

let result = add(1, 2, 3);

它是将数据 1 传递给了变量 a,将数据 2 传递给了变量 b,而数据 3 没有变量接收(被丢掉,不会报错)。反过来,如果少传参数,没收到值的形参就是 undefined:add(1) 会返回 NaN,因为 1 + undefined 是 NaN。这一点和 Java 完全不同:Java 的方法签名一旦定死,参数多一个少一个都是编译错误;JS 只在运行时"对号入座"。

【补充】

函数在 JS 里是"一等公民":它可以赋值给变量、可以作为参数传给另一个函数、可以作为返回值。这正是"回调函数"的基础,也是本章后面 setInterval(function () {...}, 1000) 这种写法的由来:定时器接收的第二个东西,就是一个"待会儿再调用"的函数。除了原文的两种写法,ES6 还提供了箭头函数,function (a, b) { return a + b; } 可以简写成 (a, b) => a + b;只有一个参数时可写成 a => a * 2。箭头函数和普通函数最大的差别在 this 的指向(箭头函数没有自己的 this,会沿用外层的),这一点在写自定义对象的方法时要小心,初学阶段建议先熟练用 function。

【易错点】 两种定义格式在"调用时机"上不等价。声明式函数有提升,写在调用之后也能用:

alert(add(1, 2)); // 3,正常运行
function add(a, b) { return a + b; }

而赋值式函数没有提升(提升的只是 var add 这个变量名,值是 undefined),写在赋值之前调用会报 TypeError: add is not a function:

alert(add(1, 2)); // 报错
var add = function (a, b) { return a + b; };

本章表单校验案例里,usernameInput.onblur = checkUsername; 这一句写在 function checkUsername() {...} 之前却不出错,靠的就是声明式函数的提升。

【考点】

"JS 函数的形参需要写类型吗?调用时参数个数必须一致吗?"两个答案都是"不需要/不一致"。理由:JS 是弱类型语言,形参不带类型;实参按位置对号入座,多传的丢弃、少传的为 undefined,函数内部还可以用 arguments 对象拿到全部实参。

3.4 JavaScript 常用对象

JavaScript 提供了很多对象供使用者来使用。这些对象总共分为三类:

类别含义本章涉及
基本对象JS 语言自带的对象,如 Array、String、RegExp、Date3.4 节、3.9 节
BOM 对象Browser Object Model,浏览器对象模型,把浏览器的各个组成部分封装成对象3.5 节
DOM 对象Document Object Model,文档对象模型,把 HTML 文档的各个组成部分封装成对象3.6 节

DOM 中的对象比较多(Document、Element、Attribute、Text、Comment 以及每个 HTML 标签对应的专用对象),3.6 节会专门讲。这一小节我们先学习基本对象中的 Array 数组对象和 String 字符串对象。

3.4.1 Array 对象

JavaScript 的 Array 对象用于定义数组。

(1)定义格式

数组的定义格式有两种:

方式 1:var 变量名 = new Array(元素列表);

var arr = new Array(1, 2, 3); //1,2,3 是存储在数组中的数据(元素)

方式 2:var 变量名 = [元素列表];

var arr = [1, 2, 3]; //1,2,3 是存储在数组中的数据(元素)

注意:Java 中的数组静态初始化使用的是 {} 定义,而 JavaScript 中使用的是 [] 定义。这一条特别容易写错:在 JS 里写 var arr = {1,2,3}; 是语法错误。

(2)元素访问

访问数组中的元素和 Java 语言的一样,格式是 arr[索引] = 值;。代码演示:

// 方式一
var arr = new Array(1, 2, 3);
// alert(arr);

// 方式二
var arr2 = [1, 2, 3];
//alert(arr2);

// 访问
arr2[0] = 10;
alert(arr2); // 10,2,3

(3)特点

JavaScript 中的数组相当于 Java 中的集合。数组的长度是可以变化的,而 JavaScript 是弱类型,所以可以存储任意类型的数据。例如如下代码:

// 变长
var arr3 = [1, 2, 3];
arr3[10] = 10;
alert(arr3[10]); // 10
alert(arr3[9]);  // undefined

上面代码在定义数组时给了三个元素,又给索引是 10 的位置添加了数据 10。那么索引 3 到索引 9 位置的元素是什么呢?前面讲过,在 JavaScript 中没有赋值的话,默认就是 undefined,同时 arr3.length 也自动变成了 11。

如果给 arr3 数组添加字符串的数据,也是可以添加成功的:

arr3[5] = "hello";
alert(arr3[5]); // hello

一个数组里既有数字又有字符串,这在 Java 里做不到,在 JS 里很平常。

(4)属性

Array 对象提供了很多属性,我们只讲解 length 属性:该属性可以动态地获取数组的长度。有了这个属性,我们就可以遍历数组了:

var arr = [1, 2, 3];
for (let i = 0; i < arr.length; i++) {
    alert(arr[i]);
}

注意 length 是动态的:你往索引 10 塞一个值,length 立刻变成 11;你把它赋成 0,数组就清空了。

(5)方法

Array 对象同样也提供了很多方法,课堂上只演示 push 函数和 splice 函数。

// push:添加方法
var arr5 = [1, 2, 3];
arr5.push(10);
alert(arr5); //数组的元素是 1,2,3,10
// splice:删除元素
var arr5 = [1, 2, 3];
arr5.splice(0, 1); //从 0 索引位置开始删除,删除一个元素
alert(arr5); // 2,3
【补充】

Array 的常用方法远不止这两个,下面这张表按"你会不会用到"排序,标"原文未讲"的属于扩展内容,看得懂即可,不必背:

方法作用是否改变原数组
push(元素)末尾添加,返回新长度是
pop()删除并返回最后一个元素是
unshift(元素)开头添加是
shift()删除并返回第一个元素是
splice(索引, 个数)删除指定位置元素(原文已讲)是
slice(起, 止)截取子数组,不含"止"(原文未讲)否
concat(数组)拼接,返回新数组(原文未讲)否
join(分隔符)用分隔符把元素拼成字符串(原文未讲)否
indexOf(元素)返回下标,找不到返回 -1(原文未讲)否
sort() / reverse()排序 / 反转(原文未讲)是
forEach(函数)遍历,无返回值(原文未讲)否
map(函数)遍历并映射成新数组(原文未讲)否

注意 sort():它默认按字符串比较,所以 [10, 9, 1].sort() 得到 [1, 10, 9](因为 "10" < "9")。数字排序必须传比较函数:arr.sort((a, b) => a - b)。

【易错点】 splice 和 slice 只差一个字母,行为却完全不同:splice 是"剪切"(改原数组),slice 是"切片"(返回新数组,原数组不动)。还有一点:getElementsByTagName() 这类 DOM 方法返回的是"类数组"(HTMLCollection / NodeList),它并不是真数组,也没有 push、forEach 这些数组方法。想要用数组方法,得先转一下:Array.from(divs) 或 [...divs]。这个问题在 3.6 节遍历元素时会遇到。

3.4.2 String 对象

String 对象的创建方式有两种:

方式 1:var 变量名 = new String(s);

方式 2:var 变量名 = "数组";

实际开发中几乎只用方式 2(字面量),方式 1 会创建一个"包装对象",typeof 得到 object 而不是 string,还容易在比较时出问题,了解即可。

属性:String 对象提供了很多属性,这里列举一个 length,该属性用于动态地获取字符串的长度。

var s = "hello";
alert(s.length); // 5

函数(方法):String 对象提供了很多函数,下面列举常用的几个:

方法作用示例结果
charAt(i)返回索引 i 处的字符"abc".charAt(1)"b"
indexOf(s)返回子串首次出现的下标,找不到返回 -1"abcabc".indexOf("b")1
substring(a, b)截取 [a, b) 区间的子串"abcdef".substring(1, 3)"bc"
replace(a, b)把第一个 a 替换成 b"a-b".replace("-", "+")"a+b"
toUpperCase()转大写"ab".toUpperCase()"AB"
toLowerCase()转小写"AB".toLowerCase()"ab"
split(sep)按分隔符切成数组"a,b".split(",")["a","b"]
trim()去掉首尾空格" abc ".trim()"abc"

String 对象还有一个函数 trim(),该方法在文档中没有体现,但是所有的浏览器都支持;它是用来去掉字符串两端的空格的。

代码演示:

var str4 = ' abc ';
alert(1 + str4 + 1);

上面代码会输出内容 1 abc 1,很明显可以看到 abc 字符串左右两边是有空格的。接下来使用 trim() 函数:

var str4 = ' abc ';
alert(1 + str4.trim() + 1);

输出的内容是 1abc1。这就是 trim() 函数的作用。

trim() 函数在以后开发中还是比较常用的。以登录界面为例:用户在输入用户名和密码时,可能会习惯性地输入一些空格(尤其是从别处复制粘贴过来的账号),这样在后端程序中判断用户名和密码是否正确,结果肯定是失败,用户在界面上看到的是"用户名或密码错误",实际只是末尾多了一个空格。所以一般都会对用户输入的字符串数据进行去除前后空格的操作。本章 3.8 节的表单校验案例里,每一处取值都写了 usernameInput.value.trim(),原因就在这里。

【补充】

关于 String 还有三个必须知道的点。第一,字符串是不可变的:str.toUpperCase() 不会改变 str 本身,而是返回一个新字符串,必须用变量接住(str = str.toUpperCase()),这一点和 Array 的 push 直接改原数组不同。第二,substring(起, 止) 的参数顺序会自动纠正,substring(3, 1) 等价于 substring(1, 3);而 slice 遇到负数下标表示"从末尾数"。第三,replace() 默认只替换第一个匹配,想全部替换要用正则加 g 修饰符:str.replace(/\s/g, "") 才能去掉所有空白字符。

【易错点】 判断两个字符串是否相等,JS 里用 === 或 == 都行(都是比较值),不要用 Java 的习惯去 new String() 再比。反过来,Java 里 == 比较字符串比的是地址(所以必须用 equals),这个习惯带到 JS 里会让你误以为 === 不安全。JS 的字符串是原始类型,=== 就是比值。这是 Java 程序员迁移到 JS 时最常问的一个问题,记住结论:JS 里字符串比较直接用 ===。

3.4.3 自定义对象

在 JavaScript 中自定义对象特别简单,格式如下:

var 对象名称 = {
    属性名称1: 属性值1,
    属性名称2: 属性值2,
    ...,
    函数名称: function (形参列表) {},
    ...
};

调用属性的格式:对象名.属性名

调用函数的格式:对象名.函数名()

接下来通过代码演示一下:

var person = {
    name: "zhangsan",
    age: 23,
    eat: function () {
        alert("干饭~");
    }
};

alert(person.name); //zhangsan
alert(person.age);  //23
person.eat();       //干饭~

可以看到,对象就是用大括号把一堆"键: 值"括起来,值可以是数字、字符串,也可以是一个函数。属性用 . 取,函数用 . 取到之后再加括号调用。

【补充】

这种写法叫对象字面量,它和 JSON(JavaScript Object Notation)的格式几乎一样,区别只有两点:JSON 的键必须用双引号包起来,且值不能是函数。所以 JS 对象和 JSON 字符串之间可以互相转换:JSON.stringify(person) 把对象变成字符串(发请求时用),JSON.parse(str) 把字符串变回对象(收到响应时用)。这两个方法在第11章综合实战里前后端传数据时会正式用到,现在知道有这回事就行。另外,对象属性还有两种访问方式:person.name 和 person["name"],后者在"属性名是个变量"时才必须用,例如 person[fieldName]。

【易错点】 对象里写函数时,this 的指向取决于怎么调用,而不是"写在谁里面"。person.eat() 里的 this 是 person;但如果把函数单独拿出来:var f = person.eat; f();,此时 this 就变成了 window(严格模式下是 undefined),取 this.name 会得到 undefined。这就是"this 丢失"问题。初学阶段记住一个结论:方法要用 对象.方法() 的形式调用,别把方法单独赋值给变量再调。

3.5 BOM

BOM 的定义:BOM(Browser Object Model,浏览器对象模型)是 JavaScript 将浏览器的各个组成部分封装成的对象。我们要操作浏览器的各个组成部分,就可以通过操作 BOM 中的对象来实现。比如:现在想将浏览器地址栏的地址改为 https://www.itheima.com,就可以通过使用 BOM 中定义的 Location 对象的 href 属性:

location.href = "https://itheima.com";

BOM 中包含了如下对象:

对象对应浏览器的哪一部分本章是否讲解
Window浏览器窗口对象讲
Navigator浏览器对象(浏览器本身的名称、版本信息)不讲
Screen屏幕对象(分辨率等)不讲
History历史记录对象讲
Location地址栏对象讲

从这张表可以看出,BOM 的五个对象就是浏览器窗口的五个"部件":窗口本身、浏览器信息、屏幕、前进后退记录、地址栏。BOM 中的 Navigator 对象和 Screen 对象基本不会使用,所以这节课只对 Window、History、Location 三个对象进行讲解。

这里要特别强调一个层级关系,它是理解整个 BOM/DOM 体系的钥匙:window 是最顶层的对象,location、history、document 都是它的属性。我们 3.6 节要用的 document(DOM 的入口),是从 window 上取下来的:window.document。记住这条链:window → document → Element,取元素的代码 document.getElementById(...) 之所以能直接写,只是因为 window. 被省略了。

3.5.1 Window 对象

window 对象是 JavaScript 对浏览器的窗口进行封装的对象。

(1)获取 window 对象

该对象不需要创建,直接使用 window,其中 window. 可以省略。比如之前使用的 alert() 函数,就是 window 对象的函数,调用时可以写成如下两种:

两者完全等价。这也解释了为什么你从来没 import 过什么,alert 就能直接用,因为它挂在全局的 window 上。

(2)window 对象属性

window 对象提供了用于获取其他 BOM 组成对象的属性:

属性得到的对象用途
window.locationLocation操作地址栏、页面跳转
window.historyHistory前进、后退
window.navigatorNavigator浏览器信息
window.screenScreen屏幕信息
window.documentDocumentDOM 的入口,3.6 节的主角

想使用 Location 对象,就可以用 window 对象获取,写成 window.location;而 window. 可以省略,简化写成 location 来获取 Location 对象。

(3)window 对象函数

window 对象提供了很多函数供我们使用,很多都不常用,下面是常用的几个:

函数作用说明
alert(内容)弹出警告框只有一个"确定"按钮
confirm(内容)弹出确认框确定返回 true,取消返回 false
prompt(内容)弹出输入框返回用户输入的字符串,取消返回 null
setTimeout(function, 毫秒值)定时器在一定的时间间隔后执行一个 function,只执行一次
setInterval(function, 毫秒值)循环定时器在一定的时间间隔后执行一个 function,循环执行
clearTimeout(id) / clearInterval(id)清除定时器参数是上面两个函数返回的 id

confirm 代码演示:

// confirm(),点击确定按钮,返回true,点击取消按钮,返回false
var flag = confirm("确认删除?");
alert(flag);

效果是:页面先弹出一个带"确定""取消"两个按钮的框。点"确定",flag 变量值记录的就是 true;点"取消",flag 变量值记录的就是 false。

而这个函数在以后开发中很实用:在页面上删除数据时,每一条数据后面都有"删除"按钮,有可能是用户的误操作,所以对于删除操作需要用户进行再次确认,此时就需要用到 confirm() 函数。典型的写法是把它和事件、return false 结合起来(下一节就会学):

// 用户点"取消"时,flag 为 false,就阻止这次跳转/提交
var flag = confirm("确认删除这条记录吗?");
if (!flag) {
    return false;
}

定时器代码演示:

setTimeout(function () {
    alert("hehe");
}, 3000);

当我们打开浏览器,3 秒后才会弹框输出 hehe,并且只会弹出一次。

setInterval(function () {
    alert("hehe");
}, 2000);

当我们打开浏览器,每隔 2 秒都会弹框输出 hehe,永不停歇(除非关掉页面或调用 clearInterval)。

这两个函数的第一个参数都是"函数"。注意,是函数本身,不是函数的调用结果。写成 setTimeout(alert("hehe"), 3000) 是错的:alert("hehe") 加了括号,会立刻执行,然后把 undefined 传给 setTimeout,结果就是"马上弹一次,3 秒后什么都不发生"。

(4)案例:每隔 1 秒,灯泡切换一次状态

需求:每隔 1 秒,灯泡切换一次状态(实现定时进行开灯、关灯功能)。

初始页面环境如下(一个开灯按钮、一张灯泡图片、一个关灯按钮,图片初始为 off.gif):

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>JavaScript演示</title>
</head>
<body>

<input type="button" onclick="on()" value="开灯">
<img id="myImage" border="0" src="../imgs/off.gif" style="text-align:center;">
<input type="button" onclick="off()" value="关灯">

<script>
    function on() {
        document.getElementById('myImage').src = '../imgs/on.gif';
    }

    function off() {
        document.getElementById('myImage').src = '../imgs/off.gif'
    }
</script>
</body>
</html>

这个初始环境已经实现了"点按钮换图":on() 里把 img 的 src 改成 on.gif,off() 里改回 off.gif。注意 onclick="on()" 是上一节说的"事件属性绑定"。

现在要加的是"自动切换"。代码实现如下(在原页面基础上增加记录状态的变量和循环定时器):

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>JavaScript演示</title>
</head>
<body>

<input type="button" onclick="on()" value="开灯">
<img id="myImage" border="0" src="../imgs/off.gif" style="text-align:center;">
<input type="button" onclick="off()" value="关灯">

<script>
    function on() {
        document.getElementById('myImage').src = '../imgs/on.gif';
    }

    function off() {
        document.getElementById('myImage').src = '../imgs/off.gif'
    }

    //定义一个变量,用来记录灯的状态,偶数是开灯状态,奇数是关灯状态
    var x = 0;
    //使用循环定时器
    setInterval(function () {
        if (x % 2 == 0) {//表示是偶数,开灯状态,调用 on() 函数
            on();
        } else { //表示是奇数,关灯状态,调用 off() 函数
            off();
        }
        x++;//改变变量的值
    }, 1000);
</script>
</body>
</html>

思路只有一条:用 x 的奇偶性当"开关状态",用 setInterval 每秒翻转一次。x 从 0 开始,第 1 秒 x % 2 == 0 成立 → 开灯 → x 变 1;第 2 秒 x % 2 == 0 不成立 → 关灯 → x 变 2;如此循环。

【补充】

setInterval 有返回值:一个数字 id,配合 clearInterval(id) 可以停掉这个定时器。注意 id 是"编号"不是"句柄对象",clearInterval 传错 id 会静默失败(不报错、定时器照跑)。开发中常见的问题是"离开页面后定时器还在跑",尤其是轮询请求接口的场景,一定要在不需要时清掉:

var timerId = setInterval(function () {
    console.log("每2秒执行一次");
}, 2000);

// 需要停止时
clearInterval(timerId);

另外,setInterval 并不保证"精确":如果回调里的代码执行时间超过间隔,或者浏览器标签页被切到后台(浏览器会降频),实际间隔会比设定值大。需要"上一次执行完再等 N 毫秒"的场景,应该用 setTimeout 递归调用而不是 setInterval。

【易错点】

三个高频错误:①setTimeout(fn(), 3000) 多写了一对括号,导致立即执行(上面已说);②毫秒值写成秒(setTimeout(fn, 3) 是 3 毫秒,不是 3 秒);③在 setInterval 的回调里用 var 声明循环变量,导致变量被共享(见 3.3.3 的 for 循环问题)。还有一个隐蔽的:定时器不会因为你"以为页面关闭了"就停止,单页应用里切换路由时忘了 clearInterval,会造成内存泄漏和重复请求。

3.5.2 History 对象

History 对象是 JavaScript 对历史记录进行封装的对象。

函数作用
back()加载历史列表中的前一个 URL,相当于浏览器左上角的"←"
forward()加载历史列表中的下一个 URL,相当于浏览器左上角的"→"
go(n)前进或后退 n 步,go(-1) 等价于 back(),go(1) 等价于 forward()

这两个函数我们平时在访问其他网站时经常见到对应的效果:浏览器的左上角有一对左右箭头,当我们点击向左的箭头,就跳转到前一个访问的页面,这就是 back() 函数的作用;当我们点击向右的箭头,就跳转到下一个访问的页面,这就是 forward() 函数的作用。浏览器工具栏上那两个箭头,就是在调用 history.back() / history.forward()。

history.back();    // 后退一页
history.forward(); // 前进一页
history.go(-2);    // 后退两页
【补充】

history 在实际项目里最常见的用法是登录成功后"返回上一页":用户本来想访问订单页,被拦截到登录页,登录成功后再用 history.back() 把他送回去。不过要注意,back() 依赖浏览器确实有上一条记录;如果用户是直接输入网址进来的,history.length 可能只有 1,back() 什么都不会发生(页面纹丝不动)。所以更稳的写法是用 location.href 明确跳到一个已知地址。HTML5 还给 history 加了 pushState() / replaceState(),这是单页应用(SPA)路由的基础,本课程不涉及。

3.5.3 Location 对象

Location 对象是 JavaScript 对地址栏封装的对象。可以通过操作该对象,跳转到任意页面。

(1)获取 Location 对象

使用 window.location 获取,其中 window. 可以省略:

window.location.方法();
location.方法();

(2)Location 对象属性

Location 对象提供了很多属性,但以后常用的只有一个属性:href。

属性含义示例值
href完整的 URL(最常用,赋值即跳转)https://www.baidu.com
protocol协议https:
host主机名 + 端口www.baidu.com:443
hostname主机名www.baidu.com
port端口443
pathname路径/s
search查询字符串(? 及之后)?wd=java
hash锚点(# 及之后)#top

代码演示:

alert("要跳转了");
location.href = "https://www.baidu.com";

在浏览器中会首先弹框显示"要跳转了",当我们点击了"确定"就会跳转到百度的首页。这里的关键是:href 是"读的时候取当前地址、写的时候跳转":location.href = "..." 这一句赋值,就等价于在地址栏里敲了这个地址并回车。这也是本章唯一一个"不需要刷新按钮就能换页面"的手段。

(3)案例:3 秒跳转到百度首页

需求:3 秒跳转到百度首页。

分析:

  1. 3 秒后跳转,由此可以确定需要使用到定时器,而只跳转一次,所以使用 setTimeout()(不是 setInterval());
  2. 要进行页面跳转,所以需要用到 location 对象的 href 属性实现。

代码实现:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<script>
    document.write("3秒跳转到首页...");
    setTimeout(function () {
        location.href = "https://www.baidu.com"
    }, 3000);
</script>
</body>
</html>

页面上会先显示"3秒跳转到首页...",3 秒后自动变成百度首页。这个"提示 + 延时跳转"的模式,在真实项目里常用于"注册成功,3 秒后自动跳转到登录页":把 document.write 的提示换成页面上的一个 <span>、把地址换成 login.html 就是完整功能。

【补充】

页面跳转在 JS 里有四种写法,效果略有差别,列出来对比一下(location.href 是本章学的,其余三种见到要认识):

写法是否留下历史记录说明
location.href = "url"是最常用,等同点链接
location.assign("url")是与上一行完全等价
location.replace("url")否替换当前记录,用户点"后退"回不来
location.reload()—刷新当前页

什么时候用 replace?典型场景是"登录成功后跳主页":你不希望用户点后退又回到登录页,此时用 replace 更合适。这个区别在第6章讲转发与重定向时会再次出现,思想是相通的(重定向会改变地址栏、留下历史记录;转发不会)。

【易错点】 location.href 的赋值必须放在 setTimeout 的回调函数里面。如果写成 setTimeout(location.href = "https://www.baidu.com", 3000),location.href = ... 会被当作表达式立刻求值,结果是"立刻跳转",而且传给 setTimeout 的是跳转后返回的字符串,定时器根本不会生效。判断标准就一条:传给定时器的一定要是"函数"。

3.6 DOM

从这一节开始,我们正式进入"页面交互"的核心。前三节(3.4 常用对象、3.5 BOM)操作的是"浏览器"和"数据",这一节操作的是页面上的标签本身:改它的文字、改它的图片、改它的颜色。这是本章分量最重的一节,也是表单校验案例的技术基础。

3.6.1 概述

DOM 的定义:DOM(Document Object Model,文档对象模型)是 JavaScript 将 HTML 文档的各个组成部分封装为对象。

DOM 我们并不陌生,之前在学习 XML 时就接触过,只不过 XML 文档中的标签需要我们写代码解析,而 HTML 文档是由浏览器解析的。封装的对象分为五类:

对象对应 HTML 中的什么
Document整个文档对象
Element元素对象(标签)
Attribute属性对象(标签上的属性)
Text文本对象(标签里的文字)
Comment注释对象(<!-- -->)

浏览器把 HTML 源码读进来之后,会把它变成一棵树,这棵树就是 DOM 树。左边是 HTML 文档内容,右边是 DOM 树。由于图片无法带入,这里用字符画把这棵树画出来,你在 3.6.2 的示例页面上按 F12 看到的 Elements 面板,就是这棵树的图形版:

document
└── html
    ├── head
    │   ├── meta
    │   └── title
    │       └── "Title"          ← Text 节点
    └── body
        ├── img#light             ← Element 节点,id 是 light
        ├── br
        ├── div.cls
        │   └── "杉达学院"        ← Text 节点
        ├── br
        ├── div.cls
        │   └── "黑马程序员"
        ├── br
        ├── input[name=hobby]
        ├── input[name=hobby]
        └── input[name=hobby]

看着这棵树,本章所有代码都在做三件事:找到某个节点(3.6.2 的四个方法)、读/改这个节点的属性或内容(3.6.3)、对这个节点的某个"事情"做出反应(3.7 的事件)。

作用:JavaScript 通过 DOM,就能够对 HTML 进行操作了。具体包括四条:

DOM 相关概念:DOM 是 W3C(万维网联盟)定义的访问 HTML 和 XML 文档的标准。该标准被分为 3 个不同的部分:

  1. 核心 DOM:针对任何结构化文档的标准模型,XML 和 HTML 通用的标准。包括上面那五类对象:Document、Element、Attribute、Text、Comment。
  2. XML DOM:针对 XML 文档的标准模型。
  3. HTML DOM:针对 HTML 文档的标准模型。该标准是在核心 DOM 基础上,对 HTML 中的每个标签都封装成了不同的对象。例如:
    • <img> 标签在浏览器加载到内存中时会被封装成 Image 对象,同时该对象也是 Element 对象;
    • <input type='button'> 标签在浏览器加载到内存中时会被封装成 Button 对象,同时该对象也是 Element 对象。

所以要理解"继承链":Image → Element → Node → Object。我们平时写的 img.src,src 是 Image 自己的属性;写的 div.innerHTML,innerHTML 是 Element 共有的属性。查文档时如果发现 src 不在 Element 的属性列表里,就去 Image 或 HTMLImageElement 里找。这是"查文档查不到"的常见原因。

【补充】

把 DOM 的节点关系讲清,因为"取到元素之后还要取它的兄弟/父级"是实际开发中的常见需求,而原文只讲了怎么按 id/name/class 取,没讲节点之间怎么走。每个节点都有下面这些属性(注意区分"带 Element 的"和"不带 Element 的"):

属性含义注意
parentNode / parentElement父节点 / 父元素一般用后者
childNodes所有子节点包含空白文本节点,长度常比预期大
children所有子元素只算标签,最常用
firstChild / lastChild第一个/最后一个子节点可能是文本节点
firstElementChild / lastElementChild第一个/最后一个子元素只算标签
nextSibling / previousSibling下一个/上一个兄弟节点可能是文本节点
nextElementSibling / previousElementSibling下一个/上一个兄弟元素只算标签
nodeType节点类型:1 元素、2 属性、3 文本、8 注释、9 文档判断节点种类用
nodeName / nodeValue节点名 / 节点值文本节点的 nodeValue 才是文字

最容易出错的地方:HTML 里标签之间的换行和缩进,在 DOM 树里会变成"空白文本节点"。所以 <ul>\n <li>1</li>\n</ul> 的 ul.childNodes.length 是 3(两个空白文本 + 一个 li),而 ul.children.length 是 1。结论:按"元素"遍历一律用带 Element 的那一套或 children,别用 childNodes。

【考点】 "document 对象是从哪里来的?"答案:document 是 window 对象的一个属性(window.document),因为 window. 可以省略,所以能直接写 document。这条链是 window → document → element,考试常以"取元素的完整路径是什么"的形式出现。

3.6.2 获取 Element 对象

HTML 中的 Element 对象可以通过 Document 对象获取,而 Document 对象是通过 window 对象获取的(window.document,省略 window. 后就是 document)。

Document 对象中提供了以下获取 Element 元素对象的函数:

方法依据返回值找不到时
getElementById()根据 id 属性值获取单个 Element 对象返回 null
getElementsByTagName()根据标签名称获取Element 对象数组返回空集合(length 为 0)
getElementsByName()根据 name 属性值获取Element 对象数组返回空集合
getElementsByClassName()根据 class 属性值获取Element 对象数组返回空集合

先看下面这个提前准备好的页面(后面的四个例子都用它):

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<img id="light" src="../imgs/off.gif"> <br>

<div class="cls">杉达学院</div> <br>
<div class="cls">黑马程序员</div> <br>

<input type="checkbox" name="hobby"> 电影
<input type="checkbox" name="hobby"> 旅游
<input type="checkbox" name="hobby"> 游戏
<br>
<script>
    //在此处书写js代码
</script>
</body>
</html>

注意脚本放在 body 最底部、所有标签之后,这样执行时上面的 img、div、input 都已经存在于 DOM 树中,取得到。

例 1:根据 id 属性值获取上面的 img 元素对象,返回单个对象

var img = document.getElementById("light");
alert(img);

弹框里显示的是 [object HTMLImageElement],从弹框输出的内容也可以看出,它是一个图片元素对象。

例 2:根据标签名称获取所有的 div 元素对象

var divs = document.getElementsByTagName("div");// 返回一个数组,数组中存储的是 div 元素对象
// alert(divs.length); //输出 数组的长度,这里是 2
//遍历数组
for (let i = 0; i < divs.length; i++) {
    alert(divs[i]);
}

会依次弹出两个 [object HTMLDivElement]。

例 3:获取所有的满足 name = 'hobby' 条件的元素对象

//3. getElementsByName:根据name属性值获取,返回Element对象数组
var hobbys = document.getElementsByName("hobby");
for (let i = 0; i < hobbys.length; i++) {
    alert(hobbys[i]);
}

会依次弹出三个 [object HTMLInputElement]。

例 4:获取所有的满足 class='cls' 条件的元素对象

//4. getElementsByClassName:根据class属性值获取,返回Element对象数组
var clss = document.getElementsByClassName("cls");
for (let i = 0; i < clss.length; i++) {
    alert(clss[i]);
}

同样是两个 [object HTMLDivElement]。

四个方法的命名里藏着一个规律,记住它就不会拼错:带 s 的返回复数(数组),不带 s 的返回单个对象。getElementById 没有 s(id 在页面里应该唯一),其余三个都有 s。很多同学的报错 divs.innerText = "..." 就是因为把数组当成了单个元素,数组没有 innerText 属性,得写 divs[i].innerText。

【易错点】

这里集中列四个高频错误: ① getElementById 拼写:是 Element(单数)不是 Elements,写成 document.getElementsById 会报 "is not a function"。 ② 返回的是类数组不是真数组:getElementsByTagName / getElementsByName / getElementsByClassName 返回的是 HTMLCollection,它有 length、能用下标、能用 for 循环,但没有 forEach、push。想用数组方法得转换:Array.from(divs).forEach(...)。顺便说一个反直觉的点:HTMLCollection 是"活的"(live),你新加一个 div,之前取到的 divs.length 会自动变大;而 querySelectorAll 返回的 NodeList 是"静态的",取完就固定了。 ③ getElementById 拿不到时的报错链:拿不到返回 null,紧接着 .value 就抛 Cannot read properties of null (reading 'value')。看到这个报错,第一反应应该是"id 拼错了 / 脚本执行时元素还不存在 / 选择器写错了",而不是"JS 坏了"。 ④ id 重复:HTML 里两个标签写同一个 id,getElementById 只返回第一个,第二个永远取不到。id 必须唯一,这是 HTML 规范。

【补充】 除了这四个方法,现代开发里更常用的是两个"选择器"方法,语法和 CSS 选择器一模一样(正好接上第2章学的选择器):

方法依据返回值
document.querySelector("css选择器")CSS 选择器第一个匹配的元素,找不到返回 null
document.querySelectorAll("css选择器")CSS 选择器所有匹配元素(NodeList,可用 forEach)

例如 document.querySelector("#username") 等价于 getElementById("username");document.querySelectorAll(".cls") 等价于 getElementsByClassName("cls");而 document.querySelector("form input[name=tel]") 这种"层级 + 属性"的组合查询,用前四个方法要写好几行才能做到。建议:本章作业仍按原文的四个方法写(考试要考),但自己要会读 querySelector。

3.6.3 HTML Element 对象使用

HTML 中的 Element 元素对象有很多,不可能全部记住,以后是根据具体的需求查阅文档使用。下面通过具体案例演示文档的查询和对象的使用。仍然使用上面那个提前准备好的页面:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<img id="light" src="../imgs/off.gif"> <br>

<div class="cls">杉达学院</div> <br>
<div class="cls">黑马程序员</div> <br>

<input type="checkbox" name="hobby"> 电影
<input type="checkbox" name="hobby"> 旅游
<input type="checkbox" name="hobby"> 游戏
<br>
<script>
    //在此处写js代码
</script>
</body>
</html>

需求 1:点亮灯泡

此案例由于需要改变 img 标签的图片,所以我们要查文档,看 img 元素对象有没有能改图片地址的属性。查到的结论是 src。代码实现:

//1,根据 id='light' 获取 img 元素对象
var img = document.getElementById("light");
//2,修改 img 对象的 src 属性来改变图片
img.src = "../imgs/on.gif";

就两行:取到对象,改属性。DOM 操作 90% 的代码都是这个固定流程:先取对象,再改属性或调方法。

需求 2:将所有的 div 标签的标签体内容替换为"呵呵"

//1,获取所有的 div 元素对象
var divs = document.getElementsByTagName("div");
/*

    style:设置元素css样式
    innerHTML:设置元素内容
 */
//2,遍历数组,获取到每一个 div 元素对象,并修改元素内容
for (let i = 0; i < divs.length; i++) {
    //divs[i].style.color = 'red';
    divs[i].innerHTML = "呵呵";
}

两个关键属性在这里出现:style 用来设置元素 CSS 样式(例如 divs[i].style.color = 'red' 会把文字变红),innerHTML 用来设置元素内容。页面上的"杉达学院""黑马程序员"会立刻变成两行"呵呵"。

需求 3:使所有的复选框呈现被选中的状态

此案例我们需要看复选框元素对象有什么属性或者函数是来操作复选框的选中状态的。查文档可知是 checked 属性。代码实现:

//1,获取所有的 复选框 元素对象
var hobbys = document.getElementsByName("hobby");
//2,遍历数组,通过将 复选框 元素对象的 checked 属性值设置为 true 来改变复选框的选中状态
for (let i = 0; i < hobbys.length; i++) {
    hobbys[i].checked = true;
}

三个复选框会同时被打上勾。这个需求在真实项目里的变体是"全选/全不选"按钮:把 true 换成一个变量(如 hobbys[i].checked = allCheckbox.checked)就实现了。

把这三个需求用到的属性汇总成表,这张表是本章"查文档"的成果,值得记住:

属性作用典型用法
innerHTML设置/获取元素内部的 HTML 内容div.innerHTML = "<b>呵呵</b>"
innerText设置/获取元素内部的纯文本div.innerText = "呵呵"
value设置/获取表单控件的值input.value、input.value = "张三"
src图片/脚本的地址img.src = "../imgs/on.gif"
href超链接的地址a.href = "login.html"
checked复选框/单选框是否被选中checkbox.checked = true
disabled是否禁用btn.disabled = true
className元素的 class(注意不是 class,class 是关键字)div.className = "cls active"
style.xxx行内样式,多个单词用驼峰div.style.backgroundColor = "red"
style.display显示/隐藏('none' 隐藏,'' 恢复)3.8 节表单校验用它控制错误提示
【对比】

innerHTML、innerText、textContent、value 这四个太容易混,单独对照一次:

属性读写的是会不会解析 HTML 标签典型场景
innerHTML元素内部的 HTML 源码会(写 <b> 就是粗体)动态插入一段带标签的内容
innerText元素内部的可见文本不会(原样当文字)只改文字,怕标签被解析
textContent元素内部的全部文本(含隐藏元素)不会取文本、去标签
value表单控件当前的值—取输入框内容 input.value

最容易错的是最后一行:div 用 innerHTML 取值,input 必须用 value 取值。写 input.innerHTML 永远得到空字符串,因为输入框的"值"不是它的子节点内容。

【易错点】 innerHTML 是把双刃剑:它会把字符串当 HTML 解析执行。如果这段字符串来自用户输入(比如评论内容),用户输入 <img src=x onerror="alert(1)">,这段脚本就会在你的页面上运行:这就是 XSS(跨站脚本攻击)。所以规则是:内容来自用户、又不需要标签时,用 innerText 或 textContent,不要用 innerHTML。这条规则在后面的 JSP(第8章)里同样适用,届时 ${} 输出用户数据也要考虑转义。

【补充】 除了改属性,DOM 还能创建和删除元素(原文"作用"里提了"添加和删除 HTML 元素"但没展开,这里补上最小可用的一套):

方法作用
document.createElement("div")创建一个新元素(此时还在内存里,页面看不到)
父元素.appendChild(新元素)把新元素挂到父元素内部的末尾
父元素.removeChild(子元素)从父元素里删掉子元素
元素.remove()元素自己删自己(更简洁)

一个完整例子:var li = document.createElement("li"); li.innerText = "新条目"; document.getElementById("myUl").appendChild(li);,页面上立刻多出一个列表项。这套 API 在"动态添加表格行""删除一条记录"时必用,第11章综合实战会用到。

3.7 事件监听

要想知道什么是事件监听,首先先聊聊什么是事件。

HTML 事件是发生在 HTML 元素上的"事情"。比如:页面上的按钮被点击、鼠标移动到元素之上、按下键盘按键等都是事件。

那么事件监听就是:JavaScript 可以在事件被侦测到时执行一段逻辑代码。 例如当用户点击"开灯"按钮,就需要通过 js 代码实现替换图片;再比如输入框,当用户输入了用户名、光标离开输入框时,就需要通过 js 代码对输入的内容进行校验,没通过校验就在输入框后提示"用户名格式有误!"。

这里把两个词分开理解,考试常拿来辨析:

一次完整的事件监听 = 找到事件源 + 绑定事件 + 写处理函数。这和后面第9章 Filter 的思路很像:都是"在某件事发生时插一段代码进去"。

3.7.1 事件绑定

JavaScript 提供了两种事件绑定方式(实际上有三种,第三种是现代开发最常用的 addEventListener,见本节末尾的补充)。

方式一:通过 HTML 标签中的事件属性进行绑定

如下面代码,有一个按钮元素,我们在该标签上定义事件属性,在事件属性中绑定函数。onclick 就是单击事件的事件属性,onclick="on()" 表示该点击事件绑定了一个名为 on() 的函数:

<input type="button" onclick="on()">

下面是点击事件绑定的 on() 函数:

function on() {
    alert("我被点了");
}

方式二:通过 DOM 元素属性绑定

如下面代码是按钮标签,在该标签上我们并没有使用事件属性,绑定事件的操作需要在 js 代码中实现:

<input type="button" id="btn">

下面 js 代码获取了 id='btn' 的元素对象,然后将 onclick 作为该对象的属性,并且绑定匿名函数。该函数是在事件触发后自动执行:

document.getElementById("btn").onclick = function () {
    alert("我被点了");
}

代码演示(两种方式放在一个页面里对照):

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <!--方式1:在下面input标签上添加 onclick 属性,并绑定 on() 函数-->
    <input type="button" value="点我" onclick="on()"> <br>
    <input type="button" value="再点我" id="btn">

    <script>
        function on() {
            alert("我被点了");
        }
        //方式2:获取 id="btn" 元素对象,通过调用 onclick 属性 绑定点击事件
        document.getElementById("btn").onclick = function () {
            alert("我被点了");
        }
    </script>
</body>
</html>

两个按钮点下去效果一样,都会弹"我被点了"。区别在于:第一个按钮的处理逻辑写在 HTML 里,第二个按钮的写在 <script> 里。实际开发推荐方式二,因为结构(HTML)和行为(JS)分离,改逻辑不用动 HTML,一个函数也能复用于多个元素。

【对比】

第三种方式 addEventListener 与 onclick 的差别,是本章最容易被考到的对比之一:

对比项元素.onclick = fn元素.addEventListener("click", fn)
能否给同一事件绑多个函数不能,后绑的覆盖先绑的能,按绑定顺序依次执行
解除绑定元素.onclick = null元素.removeEventListener("click", fn)(必须传同一个函数引用)
是否支持事件捕获不支持支持,第三个参数传 true
兼容性所有浏览器IE9+ 及所有现代浏览器
写法简洁度简洁稍长

用 addEventListener 的写法:

document.getElementById("btn").addEventListener("click", function () {
    alert("我被点了");
});

注意事件名不带 on:addEventListener("click", ...),不是 "onclick"。这是最多人写错的地方:带了 on 不会报错,只是永远不触发,非常难查。

为什么"绑定多个"很重要? 比如一个按钮既要有"提交前校验"又要有"埋点统计",用 onclick 只能留一个,用 addEventListener 两个都能留。这是它成为现代标准写法的根本原因。

【补充】 事件流:一次点击,在 DOM 树上走了三段路,这三段叫事件流。以"点击一个按钮"为例,事件会先从最外层 document 一路往下传到按钮(捕获阶段),到达按钮本身(目标阶段),再从按钮一路往上传回 document(冒泡阶段)。默认绑定的事件处理函数在冒泡阶段执行,这就是为什么"点按钮时,按钮外面的 div 的点击事件也会触发"。

这套机制有两个常用 API:

API作用典型场景
event.stopPropagation()阻止事件继续冒泡(或捕获)弹窗里的按钮点击时,别让弹窗外层的"点击关闭"也触发
event.preventDefault()阻止元素的默认行为(不阻止冒泡)阻止 <a> 跳转、阻止表单提交
return false在 onXxx 绑定的处理函数里,同时做上面两件事老代码常见,addEventListener 里无效

preventDefault() 和 return false 的区别要记牢:addEventListener 里写 return false 什么也不会阻止(返回值被忽略),必须调 preventDefault();而 onclick = function () { return false; } 里 return false 有效。本章表单校验用的是后者(onsubmit 处理函数里 return false),所以能阻止提交。

事件对象 event:处理函数可以接收一个参数,浏览器会自动把"这次事件的详情"传进来:

属性/方法含义备注
event.type事件类型,如 "click"不帶 on
event.target真正触发事件的那个元素(事件源)冒泡时可能是子元素
event.currentTarget绑定处理函数的那个元素等同于处理函数里的 this
event.key按下的键名,如 "Enter"、"a"替代废弃的 keyCode
event.keyCode按键的 ASCII 码,回车是 13已废弃但老代码常见
event.button鼠标哪个键:0 左、1 中、2 右仅鼠标事件
event.clientX/Y相对浏览器可视区的坐标做拖拽、悬浮框定位用
event.preventDefault()阻止默认行为见上
event.stopPropagation()停止冒泡见上

一个实用例子:按回车就提交表单(而不是必须点按钮):

document.getElementById("username").addEventListener("keydown", function (event) {
    if (event.key === "Enter") {
        console.log("用户按了回车");
    }
});

注意形参名 event 可以和 window.event 区分开;老 IE 里事件对象不传参、只挂在 window.event 上,现代浏览器不需要兼容它。

【易错点】 用 onclick 绑定时,如果同一次代码执行里写了两次赋值,只有最后一次生效:

var btn = document.getElementById("btn");
btn.onclick = function () { alert("第一次"); };
btn.onclick = function () { alert("第二次"); }; // 覆盖了上面那个
// 点击结果:只弹"第二次"

这不是 bug。onclick 作为"属性"有固有特性:一个属性只能存一个值。想绑多个,用 addEventListener。这种问题在"引入两个 JS 文件、都绑了同一个按钮"时最容易出现,排查时极耗时间。

3.7.2 常见事件

上面案例中使用到了 onclick 事件属性,那都有哪些事件属性供我们使用呢?下面列举一些比较常用的。原文的这张表在排版时被拆成了两半,这里合并成一张完整的表:

事件属性名说明
onclick鼠标单击事件
onblur元素失去焦点
onfocus元素获得焦点
onload某个页面或图像被完成加载
onsubmit当表单提交时触发该事件
onmouseover鼠标被移到某元素之上
onmouseout鼠标从某元素移开

onfocus 获得焦点事件:当点击了输入框后,输入框就获得了焦点(光标在里面闪)。示例效果是:获取焦点后更改输入框的背景颜色。

document.getElementById("username").onfocus = function () {
    this.style.backgroundColor = "yellow";
}

onblur 失去焦点事件:当点击了输入框后,输入框就获得了焦点;再点击页面其他位置,那输入框就失去焦点了。示例效果是:把输入的文本转换为大写。

document.getElementById("username").onblur = function () {
    this.value = this.value.toUpperCase();
}

这两个事件是"天生一对",本章表单校验用的正是 onblur:用户填完用户名、光标一离开,就立刻校验并给出提示,不用等到点提交。

onmouseout / onmouseover:鼠标移出 / 鼠标移入事件。示例效果是:当鼠标移入到"苹果"图片上时,苹果图片变大;当鼠标移出苹果图片时,苹果图片变小。

var img = document.getElementById("apple");
img.onmouseover = function () {
    this.style.width = "200px";
}
img.onmouseout = function () {
    this.style.width = "100px";
}

这两个事件在真实项目里最常见的用途是"下拉菜单":鼠标移上去菜单展开,移开菜单收起。

onsubmit 表单提交事件

如下是带有表单的页面:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <form id="register" action="#">
        <input type="text" name="username" />
        <input type="submit" value="提交">
    </form>
    <script>
    </script>
</body>
</html>

如上代码的表单,当我们点击"提交"按钮后,表单就会提交,此处默认使用的是 GET 提交方式,会将提交的数据拼接到 URL 后(点击后你会在地址栏看到 ...?username=xxx)。

现需要通过 js 代码实现阻止表单提交的功能,js 代码实现分三步:

  1. 获取 form 表单元素对象;
  2. 给 form 表单元素对象绑定 onsubmit 事件,并绑定匿名函数;
  3. 该匿名函数如果返回的是 true,提交表单;如果返回的是 false,阻止表单提交。
document.getElementById("register").onsubmit = function () {
    //onsubmit 返回true,则表单会被提交,返回false,则表单不提交
    return true;
}

把 return true 改成 return false,再点"提交",你会发现页面毫无反应,地址栏也没有 ?username=... 了,提交被拦下来了。这就是本章表单校验"不合格就不让提交"的实现原理。

【考点】

"怎样阻止表单提交?"标准答案:给 form 绑定 onsubmit 事件,在处理函数里 return false。三个必须说对的细节:①绑在 form 上,不是绑在 submit 按钮上(绑按钮上只能拦"点击",拦不住回车提交);②必须是 onsubmit,不是 onclick;③必须 return 出来:只写一个 false; 语句没有任何作用,因为浏览器看的是处理函数的返回值。

【易错点】 与 onsubmit 相关的四个常见错误: ① 忘记 return:function () { false; } 是无效的,函数返回值是 undefined,表单照常提交。正确:return false;。 ② 想"校验完再提交"却把按钮改成 type="button":type="button" 的按钮压根不会提交表单,onsubmit 永远不会触发。想让"校验通过才提交",正确做法是按钮保持 type="submit",在 onsubmit 里判断。 ③ onsubmit 触发时机在"提交动作"之前,所以在这里改 DOM、弹提示都来得及;但它拦不住用 JS 手动调 form.submit() 的提交(form.submit() 不触发 onsubmit,这是规范行为),所以别用 form.submit() 做"校验后手动提交"。 ④ action="#" 表示提交到当前页面(带个 #),不是"不提交"。想让表单完全不发请求,得靠 onsubmit return false。

3.8 表单验证案例

这是本章的收官案例,它把前面所有知识点串了起来:取元素(3.6.2)+ 读 value(3.6.3)+ 绑 onblur、onsubmit(3.7)+ 改 style.display(3.6.3)+ 类型判断与 trim()(3.3、3.4.2)。看懂这个案例,本章就通了。

3.8.1 需求

有如下注册页面,对表单进行校验:如果输入的用户名、密码、手机号符合规则,则允许提交;如果不符合规则,则不允许提交。具体完成以下两条需求:

  1. 当输入框失去焦点时,验证输入内容是否符合要求;
  2. 当点击注册按钮时,判断所有输入框的内容是否都符合要求,如果不符合则阻止表单提交。

这两条需求对应两种"校验时机",各有各的道理:

所以两者必须都写,缺一不可。这也是本章最重要的设计思想。

3.8.2 环境准备

下面是初始页面(此时还没有任何校验代码):

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>欢迎注册</title>
    <link href="../css/register.css" rel="stylesheet">
</head>
<body>
    <div class="form-div">
        <div class="reg-content">
            <h1>欢迎注册</h1>
            <span>已有帐号?</span> <a href="#">登录</a>
        </div>
        <form id="reg-form" action="#" method="get">
            <table>
                <tr>
                    <td>用户名</td>
                    <td class="inputs">
                        <input name="username" type="text" id="username">
                        <br>
                        <span id="username_err" class="err_msg" style="display: none">用户名不太受欢迎</span>
                    </td>
                </tr>
                <tr>
                    <td>密码</td>
                    <td class="inputs">
                        <input name="password" type="password" id="password">
                        <br>
                        <span id="password_err" class="err_msg" style="display: none">密码格式有误</span>
                    </td>
                </tr>
                <tr>
                    <td>手机号</td>
                    <td class="inputs"><input name="tel" type="text" id="tel">
                        <br>
                        <span id="tel_err" class="err_msg" style="display: none">手机号格式有误</span>
                    </td>
                </tr>
            </table>
            <div class="buttons">
                <input value="注 册" type="submit" id="reg_btn">
            </div>
            <br class="clear">
        </form>
    </div>

    <script>

    </script>
</body>
</html>

这段 HTML 里有几个关键设计,读代码时要看明白:

页面顶上那行 <link href="../css/register.css" rel="stylesheet"> 引用的样式表只负责外观(居中、边框、红色错误文字等),不影响校验功能;如果你的目录里没有这个文件,删掉这一行,页面照样能跑,只是样子朴素一些。

【易错点】

<span> 是行内元素,style.display 的默认值是 ''(空字符串,表示"用样式表里的默认值"),不是 'block'。所以"显示提示"必须写 style.display = '',不能写 style.display = 'block',写 'block' 虽然也能显示,但会把行内元素变成块级元素,导致它独占一行、把后面的布局挤乱。同理,"隐藏"写 'none' 是安全的。这个细节在 3.8.3 的代码里反复出现,别嫌它啰嗦。

3.8.3 验证输入框

这一小节完成如下功能:

代码如下:

//1. 验证用户名是否符合规则
//1.1 获取用户名的输入框
var usernameInput = document.getElementById("username");

//1.2 绑定onblur事件 失去焦点
usernameInput.onblur = function () {

    //1.3 获取用户输入的用户名
    var username = usernameInput.value.trim();

    //1.4 判断用户名是否符合规则:长度 6~12
    if (username.length >= 6 && username.length <= 12) {

        //符合规则
        document.getElementById("username_err").style.display = 'none';
    } else {
        //不符合规则
        document.getElementById("username_err").style.display = '';
    }
}

//1. 验证密码是否符合规则
//1.1 获取密码的输入框
var passwordInput = document.getElementById("password");

//1.2 绑定onblur事件 失去焦点
passwordInput.onblur = function () {

    //1.3 获取用户输入的密码
    var password = passwordInput.value.trim();

    //1.4 判断密码是否符合规则:长度 6~12
    if (password.length >= 6 && password.length <= 12) {
        //符合规则
        document.getElementById("password_err").style.display = 'none';
    } else {
        //不符合规则
        document.getElementById("password_err").style.display = '';
    }
}

//1. 验证手机号是否符合规则
//1.1 获取手机号的输入框
var telInput = document.getElementById("tel");

//1.2 绑定onblur事件 失去焦点
telInput.onblur = function () {

    //1.3 获取用户输入的手机号
    var tel = telInput.value.trim();

    //1.4 判断手机号是否符合规则:长度 11
    if (tel.length == 11) {
        //符合规则
        document.getElementById("tel_err").style.display = 'none';
    } else {
        //不符合规则
        document.getElementById("tel_err").style.display = '';
    }
}

这三段代码的结构一模一样,都是"取值 → 判断 → 控制提示的显示隐藏"四步。读完第一段,后两段可以只看差异。几个细节值得停下来想一下:

3.8.4 验证表单

当用户点击"注册"按钮时,需要同时对输入的用户名、密码、手机号进行校验:如果都符合规则,则提交表单;如果有一个不符合规则,则不允许提交表单。实现该功能需要获取表单元素对象,并绑定 onsubmit 事件:

//1. 获取表单对象
var regForm = document.getElementById("reg-form");

//2. 绑定onsubmit 事件
regForm.onsubmit = function () {

}

那么 onsubmit 事件绑定的函数里要写什么?需要对输入的用户名、密码、手机号进行校验,而这些校验我们之前都已经实现过了。这里还需要再写一遍吗?不需要。只需要对之前校验的代码进行改造,把每个校验的代码专门抽象到有名字的函数中,方便调用;并且每个函数都要返回结果,用来决定是提交表单还是阻止表单提交。改造后的完整代码如下:

//1. 验证用户名是否符合规则
//1.1 获取用户名的输入框
var usernameInput = document.getElementById("username");

//1.2 绑定onblur事件 失去焦点
usernameInput.onblur = checkUsername;

function checkUsername() {
    //1.3 获取用户输入的用户名
    var username = usernameInput.value.trim();

    //1.4 判断用户名是否符合规则:长度 6~12
    var flag = username.length >= 6 && username.length <= 12;
    if (flag) {
        //符合规则
        document.getElementById("username_err").style.display = 'none';
    } else {
        //不符合规则
        document.getElementById("username_err").style.display = '';
    }
    return flag;
}

//1. 验证密码是否符合规则
//1.1 获取密码的输入框
var passwordInput = document.getElementById("password");

//1.2 绑定onblur事件 失去焦点
passwordInput.onblur = checkPassword;

function checkPassword() {
    //1.3 获取用户输入的密码
    var password = passwordInput.value.trim();

    //1.4 判断密码是否符合规则:长度 6~12
    var flag = password.length >= 6 && password.length <= 12;
    if (flag) {
        //符合规则
        document.getElementById("password_err").style.display = 'none';
    } else {
        //不符合规则
        document.getElementById("password_err").style.display = '';
    }
    return flag;
}

//1. 验证手机号是否符合规则
//1.1 获取手机号的输入框
var telInput = document.getElementById("tel");

//1.2 绑定onblur事件 失去焦点
telInput.onblur = checkTel;

function checkTel() {
    //1.3 获取用户输入的手机号
    var tel = telInput.value.trim();

    //1.4 判断手机号是否符合规则:长度 11
    var flag = tel.length == 11;
    if (flag) {
        //符合规则
        document.getElementById("tel_err").style.display = 'none';
    } else {
        //不符合规则
        document.getElementById("tel_err").style.display = '';
    }
    return flag;
}

//1. 获取表单对象
var regForm = document.getElementById("reg-form");

//2. 绑定onsubmit 事件
regForm.onsubmit = function () {

    //挨个判断每一个表单项是否都符合要求,如果有一个不符合,则返回false
    var flag = checkUsername() && checkPassword() && checkTel();

    return flag;
}

这次改造的三个要点,每一个都是"能复用"的关键:

  1. 把匿名函数改成实名函数(checkUsername / checkPassword / checkTel),这样它既能被 onblur 用,也能被 onsubmit 用;
  2. 绑定事件时只写函数名,不加括号:usernameInput.onblur = checkUsername; 是对的,写成 checkUsername() 会立刻调用并把返回值(true/false)赋给 onblur,事件触发时什么都不会发生;
  3. 每个函数 return flag,让调用者能拿到校验结果;onsubmit 里用 && 把三个结果串起来:只要有一个是 false,整个表达式就是 false,表单就被拦下。
【易错点】

上面那句 checkUsername() && checkPassword() && checkTel() 有一个不易察觉的副作用:&& 是短路的。如果用户名不合格(checkUsername() 返回 false),后面的 checkPassword() 和 checkTel() 根本不会执行,于是密码框和手机号框的错误提示也不会显示出来。现象是:用户三个框都填错了,点提交,却只看到"用户名不太受欢迎"一条提示,改完用户名再点,又冒出密码的错误,体验很差,也容易让人误以为是 bug。

改法:先都执行一遍、再与结果,保证每个框都完成自己的"显示/隐藏提示":

regForm.onsubmit = function () {
    //先各自执行一遍,保证三个错误提示都被更新
    var flag1 = checkUsername();
    var flag2 = checkPassword();
    var flag3 = checkTel();
    //再汇总结果
    return flag1 && flag2 && flag3;
}
【补充】

这个问题的通用结论:凡是"函数带副作用(这里副作用是改 DOM)"的地方,不要用 &&/|| 短路来串调用。短路只适合"纯函数"。

【补充】 案例到这里功能就完整了,但离"能上线"还有两小步,顺手补上: ① trim() 之后还要判断"是否为空"。现在如果用户什么都不填直接提交,"" 的 length 是 0,会被判为不合格,这没问题。但如果规则里允许空(比如"昵称选填"),就得单独判断 if (username === "")。 ② 后端必须再校验一次。前面反复说过:F12 里把 return flag 改成 return true,或者在 Console 里执行 document.getElementById("reg-form").onsubmit = null,前端校验就形同虚设,脏数据照样进数据库。这就是第6章要解决的事:在 Servlet 里用 request.getParameter("username") 取到参数,用 Java 再判断一遍长度、用正则再匹配一遍手机号,不合格就 request.setAttribute("msg", "用户名格式有误") 并且 forward 回注册页。你会发现那套代码和本章的 checkUsername() 逻辑几乎一一对应,只是 username.length >= 6 换成了 username.length() >= 6。

3.9 RegExp 对象

3.9.1 概述

正则表达式(Regular Expression,简称 RegExp)是用来描述"字符串的格式规则"的对象。它并不负责判断字符串相等,它回答的是另一类问题:这段字符串,长得符合我要求的样子吗?

回到 3.8 节的例子感受一下。我们校验手机号时写的是:

var flag = tel.length == 11;

这句代码只能保证"长度是 11",但下面这些字符串全都能通过:

abcdefghijk
11111111111
01234567890
-----------

它们长度都是 11,可没有一个是手机号。要真正判断"是不是手机号",需要同时描述三个条件:第 1 位是 1、后面 10 位都是数字、整串正好 11 位。用长度 + 类型判断来写,代码会变得又长又绕;而用正则,一行就够:

var reg = /^[1]\d{10}$/;

这就是正则的价值:把"格式规则"直接写成一行,替代一堆 if 和循环。本章 3.4.2 里的 String 方法(replace、split)也都可以接收正则作为参数,3.9.3 还会用它重写 3.8 的校验函数。

3.9.2 语法

(1)定义

正则表达式有两种定义方式,和字符串的"字面量 / new"两种写法是同一个道理:

方式 1:直接量(字面量),写在两条斜杠之间

var 变量名 = /正则表达式/;

方式 2:构造函数

var 变量名 = new RegExp("正则表达式");

日常开发几乎都用方式 1,它更短、也不用处理反斜杠转义。方式 2 的唯一优势是"正则字符串可以动态拼出来"(比如根据用户选的规则拼正则),初学阶段用不上。

(2)方法

正则对象最常用的方法是 test(字符串):

方法作用返回值
reg.test(str)测试 str 是否匹配 reg 描述的规则匹配返回 true,不匹配返回 false
reg.exec(str)查找匹配的内容并返回详情匹配返回数组(含匹配到的子串),否则 null

test() 的返回值直接就是布尔值,所以它天然适合放进 if 里:这正是表单校验的标准用法:

// 定义正则
var reg = /^\w{6,12}$/;

// 要校验的字符串
var username = "zhangsan";

// 使用 test 方法校验
var flag = reg.test(username);
alert(flag); // true

(3)规则

正则是"用符号拼出来的格式描述",符号分三类:位置符号、字符符号、量词符号。原文给出的规则表在 PDF 里被排成了两栏,这里合并完整重排:

符号含义举例
^表示开头^a 表示以 a 开头
$表示结尾a$ 表示以 a 结尾
[abc]只能是 a、b、c 中的一个字符[abc] 匹配单个 a 或 b 或 c
[^abc]除了 a、b、c 之外的一个字符[^abc] 匹配 d、1、# 等
[a-zA-Z]a 到 z、A 到 Z 中的一个字符(区间)[a-z] 匹配任意小写字母
.任意一个字符(换行除外)a.b 匹配 acb、a1b
\d一个数字,等价于 [0-9]\d\d 匹配两位数字
\D一个非数字字符\D 匹配字母、符号
\w一个单词字符,等价于 [a-zA-Z0-9_]\w 匹配字母、数字、下划线
\W一个非单词字符\W 匹配空格、@ 等
\s一个空白字符(空格、Tab、换行)\s 匹配空格
\S一个非空白字符\S 匹配字母
\转义,把有特殊含义的符号变回普通字符\. 匹配字面的点号

再看量词,它们必须紧跟在"单个字符规则"后面,表示"这个规则重复几次":

量词含义举例
+出现 1 次或多次(≥1)\d+ 匹配 1 个以上数字
*出现 0 次或多次(≥0)\d* 匹配空串或一串数字
?出现 0 次或 1 次(可有可无)-? 表示可有可无的负号
{x}恰好出现 x 次\d{11} 恰好 11 位数字
{m,}至少出现 m 次\w{6,} 至少 6 个字符
{m,n}出现 m 到 n 次\w{6,12} 6 到 12 个字符

把位置符号和量词配合起来,就能描述"整串的格式":^ 和 $ 分别钉住开头和结尾,中间的规则说明"整串长什么样"。如果只写 /\w{6,12}/ 不加 ^$,那么 "a1b2c3d4e5f6g7"(16 位)也会匹配成功,因为正则只要求"某处存在 6~12 个连续单词字符",而 16 位的串里当然存在。这是正则校验里最致命的错误,没有之一。

【补充】

原文规则表里没有讲分组与或运算,但实际校验经常要用,补三个:

符号含义举例
|或,表示"左边或者右边"^(\d{6}|[a-z]{4})$ 六位数字或四位小写字母
()分组,把一段规则当成整体,便于加量词^(ab)+$ 匹配 ab、abab
(?i) 类想法的替代JS 没有"忽略大小写"的内联写法,要在正则末尾加修饰符 i/^abc$/i 可匹配 ABC

修饰符(写在右斜杠之后)常用的三个:i 忽略大小写、g 全局匹配(replace 全替换要用它)、m 多行模式。例如 "a-b-c".replace(/-/g, "+") 得到 "a+b+c",不加 g 只换第一个。

【易错点】 五个高频错误,按出错人数排序: ① 漏写 ^ 和 $:只校验"包含",不校验"整串"(上面已详述)。 ② \w 不等于"任意字符":\w 只含字母、数字、下划线,不含中文、空格、@、.、-。想匹配"任意字符"用 .,想匹配"中文"用 [\u4e00-\u9fa5]。 ③ . 没有转义:想匹配一个点号本身(如邮箱里的 @qq.com),必须写 \.,写 . 会匹配任意字符,校验形同虚设。 ④ \d 写成 d:/^d{11}$/ 匹配的是"11 个字母 d",非常隐蔽,它不报错,只是永远返回 false(除了用户真的输入 dddddddddd)。 ⑤ 把正则写在字符串里忘了双重转义:用 new RegExp("\\d{11}") 时,字符串里的反斜杠本身要被转义,所以是两个 \\;而字面量 /\d{11}/ 只需一个。写 new RegExp("\d{11}") 里的 \d 会被当成"d",正则就废了。建议:一律用字面量写法,彻底绕开这个问题。

【考点】 三组"必背正则",考试和面试都直接问:

用途正则拆解
用户名 6~12 位单词字符/^\w{6,12}$/^+\w+{6,12}+$
11 位手机号(1 开头)/^[1]\d{10}$/第 1 位是 1,后 10 位是数字
电子邮箱/^\w+@\w+(\.\w+)+$/名字 + @ + 域名 + 至少一个 .后缀

手机号那条还可以写得更严:/^1[3-9]\d{9}$/(第二位只能是 3~9),能挡住 11111111111 这种假号。

【补充】 console 调试与浏览器开发者工具:本章所有代码都要在浏览器里跑,不会调试等于不会 JS。F12 打开开发者工具,重点用四个面板:

面板用途关键操作
Elements(元素)看实时 DOM 树、改 HTML/CSS 试效果右键元素 → Copy → Copy selector,直接拿到 querySelector 参数
Console(控制台)执行 JS、看输出和报错输入 document.getElementById("username") 回车即可交互
Sources(源代码)打断点单步调试在 JS 行号处点一下设断点,刷新后逐行执行、看变量值
Network(网络)看表单到底提交了没有、提交了什么参数提交表单后看请求的 URL 和 Payload

console 的四个常用方法,比 alert 好用得多:

方法作用
console.log(值)打印普通信息
console.error(值) / console.warn(值)打印红色错误 / 黄色警告
console.table(数组)把数组/对象按表格打印,看列表数据极方便
console.time("x") / console.timeEnd("x")计时,测一段代码耗时

为什么强调用 console.log 而不是 alert? 因为 alert 会阻塞页面(弹框不点掉,后面的代码不执行),而且只能显示字符串、看不了对象结构;console.log(obj) 能展开对象、能同时打印多条、不影响执行。本章表单校验排查"为什么 flag 是 false"时,在 if 前加一句 console.log(username, username.length),答案立刻出来。

还有一个必须知道的技巧:报错信息就是线索。Uncaught TypeError: Cannot read properties of null (reading 'value') 说明取元素返回了 null(id 写错或脚本执行太早);xxx is not a function 说明方法名拼错或对象类型不对(如对数组调了 innerText);Uncaught SyntaxError: Unexpected token 说明多半是括号/引号没配对,看报错行号往上一两行找。

3.9.3 改进表单校验案例

需求:使用正则表达式改进 3.8 节的表单校验案例,把"长度判断"升级成"格式判断"。改进后的完整代码如下(只贴改动的三个校验函数与表单提交部分,页面 HTML 与 3.8.2 完全相同):

//1. 验证用户名是否符合规则
//1.1 获取用户名的输入框
var usernameInput = document.getElementById("username");

//1.2 绑定onblur事件 失去焦点
usernameInput.onblur = checkUsername;

function checkUsername() {
    //1.3 获取用户输入的用户名
    var username = usernameInput.value.trim();

    //1.4 判断用户名是否符合规则:长度 6~12,单词字符组成
    var reg = /^\w{6,12}$/;
    var flag = reg.test(username);
    if (flag) {
        //符合规则
        document.getElementById("username_err").style.display = 'none';
    } else {
        //不符合规则
        document.getElementById("username_err").style.display = '';
    }
    return flag;
}

//1. 验证密码是否符合规则
//1.1 获取密码的输入框
var passwordInput = document.getElementById("password");

//1.2 绑定onblur事件 失去焦点
passwordInput.onblur = checkPassword;

function checkPassword() {
    //1.3 获取用户输入的密码
    var password = passwordInput.value.trim();

    //1.4 判断密码是否符合规则:长度 6~12,单词字符组成
    var reg = /^\w{6,12}$/;
    var flag = reg.test(password);
    if (flag) {
        //符合规则
        document.getElementById("password_err").style.display = 'none';
    } else {
        //不符合规则
        document.getElementById("password_err").style.display = '';
    }
    return flag;
}

//1. 验证手机号是否符合规则
//1.1 获取手机号的输入框
var telInput = document.getElementById("tel");

//1.2 绑定onblur事件 失去焦点
telInput.onblur = checkTel;

function checkTel() {
    //1.3 获取用户输入的手机号
    var tel = telInput.value.trim();

    //1.4 判断手机号是否符合规则:长度 11,1 开头,全数字
    var reg = /^[1]\d{10}$/;
    var flag = reg.test(tel);
    if (flag) {
        //符合规则
        document.getElementById("tel_err").style.display = 'none';
    } else {
        //不符合规则
        document.getElementById("tel_err").style.display = '';
    }
    return flag;
}

//1. 获取表单对象
var regForm = document.getElementById("reg-form");

//2. 绑定onsubmit 事件
regForm.onsubmit = function () {
    //挨个判断每一个表单项是否都符合要求,如果有一个不符合,则返回false
    var flag = checkUsername() && checkPassword() && checkTel();
    return flag;
}

对比 3.8.3 的旧代码,改动小得惊人:只把 username.length >= 6 && username.length <= 12 换成了 reg.test(username),其余结构一字未动。这说明前面"把校验抽成实名函数"的改造是值得的:规则变了,只改一行;校验时机、提示显示、提交拦截全都不用动。这就是"结构稳定、规则可换"的代码。

改进后再测一组数据,就能看出正则的威力:

输入旧规则(长度 6~12)新规则(/^\w{6,12}$/)
zhangsan通过通过
abc不通过不通过
abc def(中间有空格)通过(长度 7)不通过(空格不是 \w)
zhang@san通过(长度 9)不通过(@ 不是 \w)
张三张三张三(6 个汉字)通过(长度 6)不通过(汉字不是 \w)

同样的对比用在手机号上更明显:abcdefghijk、11111111111、----------- 全都能通过旧规则,而新规则 /^[1]\d{10}$/ 只放行"1 开头 + 10 位数字"。把这条正则再收紧成 /^1[3-9]\d{9}$/,11111111111 也会被拦下。

【补充】

需要提醒一句边界问题:\w{6,12} 不允许中文用户名,但很多国内产品是允许的。如果产品要求"中文也算合法字符",正则得改成 /^[\w\u4e00-\u9fa5]{6,12}$/(\u4e00-\u9fa5 是常用汉字的 Unicode 区间)。另外要记住 \w 里的"单词字符"是按英文习惯定义的,中文、日文、重音字母都不在其中,写国际化产品时这条会咬人。

【考点】 "前端校验了,后端还需要校验吗?"必须答"需要",并给出理由:前端校验的作用是提升体验、减少无效请求;但它没有任何安全性,因为: ① 用户可以在浏览器 Console 里执行 document.getElementById("reg-form").onsubmit = null 直接卸掉校验; ② 用 Postman、curl 等工具直接发 HTTP 请求,根本不经过浏览器,前端代码一行都不会跑; ③ 改一下本地 JS 文件、或者禁用 JS,校验同样失效。

前端校验是"防手滑",后端校验是"防攻击"。这正是第6章 Servlet 里要做的第一件事:拿到参数先校验、再使用。届时你会发现后端的校验代码和本章的 checkUsername() 是同构的:username.length >= 6 变成 username.length() >= 6,reg.test(username) 变成 username.matches("^\\w{6,12}$")(Java 里正则写在字符串中,反斜杠要写两个)。

本章小结

本章的主线只有一条:JavaScript 让静态页面长出了"反应能力"。第2章的 HTML+CSS 是一张"画好的纸",浏览器只能显示它;本章之后,页面能响应用户的点击、输入、提交,能在提交前就把不合格的数据拦下来。

知识结构回顾

板块小节核心内容记忆点
认识3.1、3.2是什么、为什么要学、怎么引进来三件事:交互、校验、动态效果;<script> 放 </body> 前
语法3.3变量、类型、运算符、流程、函数、数组、对象弱类型、=== 优先、函数参数随意
对象3.4Array、String、自定义对象数组可变长、trim() 必用
BOM3.5window / location / history / 定时器location.href 跳转、setInterval 循环
DOM3.6取元素四方法、改内容/样式/属性先取对象,再改属性
事件3.7两种绑定、常见事件、onsubmit return false事件 = 事情发生 + 执行代码
校验3.8失焦校验 + 提交兜底抽成实名函数,返回 flag
正则3.9/规则/、test()、^ $ \w \d {m,n}钉住首尾,校验整串

容易混淆的知识点对照

对照项区别结论
var / let / const作用域、重复声明、提升默认 const,要改才 let,var 只用来读懂老代码
== / ===是否先做类型转换一律 ===,只有 x == null 例外
innerHTML / innerText / valueHTML / 纯文本 / 表单值div 用前两个,input 必须用 value
onclick / addEventListener能否绑多个、能否捕获现代写法用后者,事件名不带 on
setTimeout / setInterval执行一次 / 循环执行延时跳转用 setTimeout,轮播用 setInterval
getElementById / 其余三个返回单个 / 返回类数组带 s 的都要遍历
preventDefault() / return false阻止默认行为 / 同时阻止冒泡addEventListener 里只能用前者
前端校验 / 后端校验体验 / 安全后端必须再做一遍

本章与后续章节的关系

给复习的一句提醒:本章代码量不大,但必须动手敲。JavaScript 的常见错误(null 报错、this 丢失、== 玄学、正则漏 ^$)全都属于"看的时候懂、写的时候错",只有把 3.8 的案例从空白文件敲一遍、故意制造几个错误再去 Console 里看报错,才算真的会了。

自测

一、选择题(每题只有一个正确答案)

  1. 下列声明变量的方式中,不允许在同一个作用域内重复声明同名变量的是( )。
    A. var a = 1; var a = 2;
    B. let a = 1; let a = 2;
    C. var a; var a = 2;
    D. var a = 1; a = 2;
  2. 表达式 20 == "20" 与 20 === "20" 的结果分别是( )。
    A. true、true
    B. false、false
    C. true、false
    D. false、true
  3. 要获取页面上所有 class 为 cls 的元素,应使用( )。
    A. document.getElementById("cls")
    B. document.getElementsByClassName("cls")
    C. document.getElementByClassName("cls")
    D. document.getElementsByName("cls")
  4. 使用 document.getElementsByTagName("div") 得到一个对象后,下列操作无法直接生效的是( )。
    A. divs.length
    B. divs[0].innerHTML = "abc"
    C. divs.innerText = "abc"
    D. for (let i = 0; i < divs.length; i++) {}
  5. 下列代码中,能够阻止表单提交的是( )。
    A. form.onclick = function () { return false; }
    B. form.onsubmit = function () { return false; }
    C. form.onsubmit = function () { false; }
    D. form.submit = function () { return false; }
  6. 正则表达式 /^\w{6,12}$/ 能匹配下列哪个字符串?( )
    A. abc B. zhang@san C. zhangsan D. 张三张三张三
  7. 下列关于 setInterval 与 setTimeout 的说法,正确的是( )。
    A. 两者都只执行一次
    B. setInterval 循环执行,setTimeout 只执行一次
    C. setTimeout 循环执行,setInterval 只执行一次
    D. 两者都必须传入字符串
  8. 执行 location.href = "https://www.baidu.com"; 的效果是( )。
    A. 在当前页面弹出一个链接
    B. 打开一个新窗口
    C. 当前页面跳转到百度首页
    D. 刷新当前页面

二、填空题

  1. JavaScript 中声明变量使用 ________(ES5)或 ________ / ________(ES6)。
  2. document.getElementById("light") 的返回值类型是 ________,找不到元素时返回 ________。
  3. 把 div 的内容设为"呵呵"应写 div.________ = "呵呵";;取输入框的值应写 input.________。
  4. 绑定 onblur 事件时,checkUsername() 函数必须在失焦后返回 ________ 才能表示校验通过。
  5. 正则 /^1[3-9]\d{9}$/ 中,^ 表示 ________,$ 表示 ________,\d 表示 ________,{9} 表示 ________。

三、判断题

  1. JavaScript 中数组的长度一旦定义就不能改变。( )
  2. var 声明的变量存在变量提升,let 声明的变量在声明前访问会报错。( )
  3. addEventListener("onclick", fn) 可以正常绑定点击事件。( )
  4. 前端已经做了表单校验,后端就可以不再校验。( )
  5. setTimeout 的第一个参数必须是函数本身,不能是函数的调用结果。( )

四、简答题

  1. 简述 var、let、const 三者的区别(至少答出作用域、重复声明、变量提升三个方面)。
  2. 简述 == 与 === 的区别,并说明实际开发中应优先使用哪一个,为什么。
  3. 事件监听有两种绑定方式:元素.onclick = fn 与 元素.addEventListener("click", fn)。请说明后者的两个优势。
  4. 简述获取元素的四种方法及其返回值特点。
  5. 说说 innerHTML、innerText、value 三者的区别,各举一个使用场景。

五、程序阅读题

  1. 写出下列代码的输出结果:
var x = 0;
var arr = [];
for (var i = 0; i < 3; i++) {
    setTimeout(function () { arr.push(i); }, 0);
}
setTimeout(function () { console.log(arr); }, 10);
  1. 阅读下列代码,说明点击"提交"后会发生什么,并指出代码的问题:
var usernameInput = document.getElementById("username");
usernameInput.onblur = checkUsername();

function checkUsername() {
    var username = usernameInput.value.trim();
    var reg = /^\w{6,12}$/;
    return reg.test(username);
}

六、编程题

  1. 使用 3.8 节的页面结构,完成手机号校验:要求 11 位、以 1 开头、第二位为 3~9、全部为数字;失焦时校验并显示/隐藏错误提示,提交时汇总校验结果并阻止非法提交。
  2. 写一个"全选/全不选"功能:页面上一个"全选"复选框和三个 name="hobby" 的兴趣复选框,勾上全选则三个全被勾上,取消则全部取消;反之,三个中只要有一个没勾,全选就取消。

参考答案

一、选择题

题号答案解析
1Blet / const 在同一作用域内不允许重复声明,var 允许
2C== 先做类型转换("20" 转数字 20),=== 类型不同直接 false
3BgetElementsByClassName(带 s)返回该类元素的集合;A 只能按 id 取单个;C 方法名拼错
4C返回的是类数组,本身没有 innerText 属性,必须 divs[i].innerText
5B必须绑 onsubmit 且 return false;C 只是算了一个表达式,没有 return
6C\w 只含字母/数字/下划线,@、中文都不匹配,且需 6~12 位
7BsetInterval 循环执行,setTimeout 延时一次
8Chref 赋值即跳转,等价于地址栏输入并回车

二、填空题

  1. var;let;const
  2. HTMLImageElement(更笼统地答"Element 对象"也对);null
  3. innerHTML;value
  4. true
  5. 开头;结尾;一个数字;恰好重复 9 次

三、判断题

  1. 错。JS 数组长度可变,可以越界赋值,中间未赋值的元素为 undefined。
  2. 对。var 提升后在声明前访问是 undefined;let 有"暂时性死区",声明前访问抛 ReferenceError。
  3. 错。addEventListener 的事件名不带 on,应写 "click"。
  4. 错。前端校验可被绕过(改 JS、禁 JS、直接用工具发请求),后端必须再校验。
  5. 对。写 setTimeout(fn(), 1000) 会立刻执行 fn,然后把返回值传给定时器。

四、简答题

  1. var:函数作用域、可重复声明、会提升(值为 undefined);let:块级作用域、不可重复声明、有暂时性死区(声明前访问报错)、可重新赋值;const:块级作用域、不可重复声明、声明时必须初始化、绑定不可改(但对象内部属性可改)。实践:默认用 const,需要改再用 let,避免用 var。
  2. == 在比较前会做类型转换(如 "20" 转成 20),=== 不做转换、类型不同直接 false。应优先用 ===,因为 == 的转换规则反直觉("" == 0、[] == false 都为 true),容易产生隐蔽 bug;唯一惯用例外是 x == null 同时判断 null 与 undefined。
  3. ①可以给同一元素的同一事件绑定多个处理函数,按绑定顺序依次执行,而 onclick 后绑的会覆盖先绑的;②支持事件流的选择(第三个参数为 true 时在捕获阶段触发),并且能用 removeEventListener 精确解绑某个函数。此外它不与 HTML 属性耦合,符合结构/行为分离。
  4. getElementById(id):返回单个 Element 对象,找不到返回 null;getElementsByTagName(标签名):返回该类元素的 HTMLCollection(类数组,可用 length 和下标遍历);getElementsByName(name):返回具有该 name 的 HTMLCollection;getElementsByClassName(class名):返回具有该 class 的 HTMLCollection。后三者找不到时返回空集合(length 为 0),不是 null。
  5. innerHTML 读写元素内部的 HTML 源码,写入的标签会被解析,适合"插入一段带标签的内容";innerText 读写元素内部的纯文本,标签按字面显示,适合"只改文字、避免标签被解析(防 XSS)";value 读写表单控件当前的值,适合"取输入框内容",如 input.value。注意 input 必须用 value,用 innerHTML 永远取不到输入的值。

五、程序阅读题

  1. 输出 [3, 3, 3]。原因:setTimeout 的回调在主线程的循环结束后才执行,而 var i 是函数/全局作用域、三个回调共享同一个 i,等回调执行时 i 已变成 3。若把 var i 改成 let i,则输出 [0, 1, 2](let 为每次循环创建独立绑定、形成块级作用域)。
  2. 页面加载时就会执行一次 checkUsername()(因为写了括号),把 true/false 赋给了 usernameInput.onblur。此后即使给输入框绑定成功,失焦时也不会执行校验函数,onblur 存的是一个布尔值,不是函数。问题有两处:①绑定事件时多写了括号,应写 usernameInput.onblur = checkUsername;;②函数定义写在绑定语句之后(本例中 function 声明有提升所以不算错,但如果改成赋值式 var checkUsername = function () {} 就会报 TypeError)。

六、编程题

  1. 参考实现(HTML 结构同 3.8.2,只给 JS):
var telInput = document.getElementById("tel");
telInput.onblur = checkTel;

function checkTel() {
    var tel = telInput.value.trim();
    var reg = /^1[3-9]\d{9}$/;
    var flag = reg.test(tel);
    document.getElementById("tel_err").style.display = flag ? 'none' : '';
    return flag;
}

var regForm = document.getElementById("reg-form");
regForm.onsubmit = function () {
    var flag = checkTel();   // 若还有用户名/密码,先各自调用再汇总,避免 && 短路跳过
    return flag;
}
  1. 参考实现:
var all = document.getElementById("all");
var hobbys = document.getElementsByName("hobby");

// 点全选:让所有兴趣框与全选框保持一致
all.onclick = function () {
    for (let i = 0; i < hobbys.length; i++) {
        hobbys[i].checked = all.checked;
    }
};

// 点任意兴趣框:只要有没勾的就取消全选,全勾上就选中全选
for (let i = 0; i < hobbys.length; i++) {
    hobbys[i].onclick = function () {
        var allChecked = true;
        for (let j = 0; j < hobbys.length; j++) {
            if (!hobbys[j].checked) {
                allChecked = false;
                break;
            }
        }
        all.checked = allChecked;
    };
}

关键点:①hobbys 是类数组,用 for 循环遍历;②全选与子项是双向同步,两个方向都要写;③checked 是布尔属性,直接赋 true/false,不要赋字符串 "true"(字符串 "false" 也是真值,会导致永远选中)。