系列最后一篇。HTML 管结构、CSS 管样式,JavaScript 负责行为——响应用户操作、修改页面内容。每节同样附了可运行的 demo。

一、三种导入方式

demo 11:JS 导入方式

和 CSS 类似,JS 也有三种写进页面的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- 1. 写在 head 里的内联脚本 -->
<head>
<script>
console.log("Hello,Head标签内联样式");
</script>

<!-- 2. 外部文件 -->
<script src="./js/myscript.js"></script>
</head>

<body>
<h1>JavaScript 的导入方式</h1>

<!-- 3. 写在 body 里的内联脚本 -->
<script>
console.log("Hello,body标签的内联样式");
alert("你好,内联样式弹窗");
</script>
</body>

外部的 js/myscript.js 内容也很简单:

1
2
console.log("Hello,外联样式");
alert("你好,外联样式弹窗");

打开 demo 后按 F12 打开控制台,就能看到四行输出(console.log 的内容不会显示在页面上,只在控制台里)。

script 为什么要放在 body 末尾

demo 里的脚本只是打印日志,所以写在 head 里没问题。但一旦脚本要操作页面元素,就必须放在 body 末尾:

1
2
3
4
5
6
7
8
<body>
<div id="box">内容</div>

<script>
// 如果这段脚本放在 head 里,执行时 #box 还不存在,会报 null 错误
document.getElementById('box').innerHTML = '新内容';
</script>
</body>

浏览器是从上往下解析 HTML 的。放在 head 的脚本执行时,<body> 里的元素还没被创建出来,自然找不到。放在末尾就能保证元素都已就绪。

另一种做法是给 <script> 加 defer 或 async 属性,或者把代码包进 DOMContentLoaded 事件里。但放进 body 末尾是最简单、最容易理解的方式,初学阶段推荐就用这个。

二、基本语法

demo 12:JS 基本语法

声明变量:var / let / const

1
2
3
4
5
var x;              // undefined(未赋值)
let y = 5;
const PI = 3.14;

console.log(x, y, PI); // undefined 5 3.14
关键字 能否重新赋值 说明
var 能 老写法,作用域规则容易出问题,现在基本不用
let 能 现代写法的默认选择
const 不能 声明常量,值不会变就用它

选择顺序很简单:先想 const,确实需要改再换 let,var 就别用了。这样能避免不小心覆盖变量的值。

数据类型

1
2
3
4
let name = "何意味";        // 字符串
let empty_value = null; // 空值:主动赋值为"没有"
let age = 9; // 数字
let isReady = true; // 布尔

undefined 和 null 容易混:undefined 是”声明了但没赋值”,null 是”我主动把它设成空”。上面 var x; 打印出来是 undefined,而 empty_value 是 null。

条件判断

1
2
3
4
5
6
7
let age = 9;

if (age > 18) {
console.log("你已经成年了");
} else {
console.log("未成年");
}

需要多个分支时用 else if:

1
2
3
4
5
6
7
8
9
let time = 22;

if (time < 12) {
alert("上午好");
} else if (time < 18) {
alert("下午好");
} else {
alert("晚上好");
}

if 的判断条件会从上往下依次检查,命中一个就跳出,后面的即使也成立也不会执行。

循环

1
2
3
4
5
6
7
8
9
10
11
// for 循环:适合次数已知的场景
for (let i = 1; i <= 10; i++) {
console.log(i);
}

// while 循环:适合"条件满足就一直做"的场景
let index = 1;
while (index <= 10) {
console.log(index);
index++; // 千万别忘,否则死循环
}

for 的三个部分用分号隔开:初始化;条件;每轮结束后执行。while 则需要自己在循环体里改变条件变量,忘了 index++ 就会把浏览器卡死。

break 和 continue

1
2
3
4
5
6
7
8
9
for (let i = 0; i < 7; i++) {
if (i == 2) {
continue; // 跳过本轮剩余代码,直接进入下一轮
}
if (i == 5) {
break; // 彻底结束整个循环
}
console.log(i);
}

这段代码的输出是 0 1 3 4:

  • i=2 时被 continue 跳过,所以没有 2;
  • i=5 时被 break 终止,所以 5 和 6 都不输出。

一句话区分:continue 是”这一轮不干了”,break 是”全都不干了”。

三、函数与作用域

demo 13:JS 函数

三种常见形态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1. 无参数、无返回值
function hello() {
console.log("Hello,world!");
}
hello(); // 调用函数,括号不能省

// 2. 有返回值
function hello_with_return() {
return "Hello,world! - 返回值";
}
let a = hello_with_return(); // 把返回值存进变量
console.log(a);
console.log(hello_with_return()); // 也可以直接打印

// 3. 带参数
function hello_with_params(name) {
console.log("hello," + name);
}
hello_with_params("LiHua");

关键区别:return 会把结果交回给调用处,所以能赋值给变量;而 console.log 只是打印,函数本身的返回值是 undefined。

作用域

1
2
3
4
5
6
7
8
9
10
11
12
let global_var = '全局变量';

function local_var_function() {
let local_var = '局部变量';
console.log("函数内打印全局变量 " + global_var); // ✅ 能访问
console.log("函数内打印局部变量 " + local_var); // ✅ 能访问
}

local_var_function();

console.log("全局打印全局变量 " + global_var); // ✅ 能访问
console.log(local_var); // ❌ 报错:local_var is not defined

规则是单向的:

  • 函数内部可以访问外面的全局变量;
  • 函数外部访问不到函数内部的局部变量。

因为局部变量只在函数执行期间存在,函数一结束就销毁了。这就是”作用域链”最基本的表现——由内向外查找,不能反向。

原练习文件里最后一行写的是 console.log("全局打印局部变量==报错")——它只是打印了一句字符串,并没有真的访问 local_var,所以不会报错。想复现 local_var is not defined,必须真的去引用它:

1
console.log(local_var);   // ReferenceError: local_var is not defined

四、事件

demo 14:JS 事件

事件就是”用户做了什么”,绑定事件就是告诉浏览器”发生这件事时执行哪个函数”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<button onclick="click_event()">这是一个点击事件按钮</button>
<input type="text" onfocus="focus_event()" onblur="blur_event()">

<script>
// 点击事件
function click_event() {
alert("点击事件触发了");
}

// 获得焦点(光标进入输入框)
function focus_event() {
console.log("获取焦点");
}

// 失去焦点(光标离开输入框)
function blur_event() {
console.log("失去焦点");
}
</script>

三种常用事件:

事件 触发时机
onclick 鼠标点击
onfocus 元素获得焦点(光标进入)
onblur 元素失去焦点(光标离开)

onfocus / onblur 是表单校验的常用时机——比如用户离开输入框时立刻检查格式对不对。

demo 里把 onclick="..." 直接写在 HTML 标签上,这叫内联事件绑定,优点是直观,缺点是 HTML 和 JS 混在一起不好维护。下一篇 demo 会看到更规范的做法:addEventListener。

五、DOM 查找与修改

demo 15:JS DOM

DOM(文档对象模型)把 HTML 页面变成一棵可以用 JS 操作的对象树。要改页面,第一步永远是先找到那个元素。

三种查找方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<div id="box1">这是一个id选择器标签</div>
<div class="box2">这是一个类选择器标签</div>
<div>普通的div标签</div>
<button>点击按钮</button>

<script>
// 按 id 查找:返回单个元素
var element_id = document.getElementById('box1');

// 按 class 查找:返回集合,要用 [0] 取第一个
var element_class = document.getElementsByClassName('box2')[0];

// 按标签名查找:返回集合,[2] 是索引值
var element_tag = document.getElementsByTagName('div')[2];
</script>
方法 返回 注意
getElementById 单个元素 参数不带 #
getElementsByClassName 集合(类数组) 参数不带 .;要加索引取值
getElementsByTagName 集合(类数组) 要加索引取值

后面两个返回的不是数组而是集合,所以不能直接当元素用。忘记加 [0] 是新手最常见的错误之一。

修改内容:innerHTML 和 innerText

1
2
3
4
5
6
7
8
var element_id = document.getElementById('box1');
var element_class = document.getElementsByClassName('box2')[0];

// innerHTML:会把字符串当 HTML 解析
element_id.innerHTML = '<a href="#">跳转链接(假的)</a>';

// innerText:纯文本,HTML 标签会被原样显示出来
element_class.innerText = '<a href="#">跳转链接(假的)</a>';

这是两者最重要的差别:

  • innerHTML 会解析 HTML,所以第一个 div 里真的出现了一个可点击的链接;
  • innerText 只当普通文字,第二个 div 里会原样显示 <a href="#">...</a> 这串字符。

innerHTML 用起来方便,但不要用它插入用户输入的内容。用户如果输入 <img src=x onerror="恶意代码">,浏览器会真的执行——这是 XSS 攻击的经典入口。要插入用户内容,请用 innerText 或 textContent。

修改样式

1
2
element_tag.style.color = "red";
element_tag.style.fontSize = '50px';

注意 CSS 属性名要转成小驼峰:CSS 里的 font-size 在 JS 里写成 fontSize,background-color 写成 backgroundColor。原因是 font-size 里的横线会被当成减号。

绑定事件的两种方式

1
2
3
4
5
6
7
8
9
10
11
var button_element = document.getElementsByTagName('button')[0];

// 方法 1:DOM 属性绑定(同类型事件只能绑一个,后者会覆盖前者)
button_element.onclick = function () {
alert("DOM 属性按键触发");
};

// 方法 2:addEventListener(推荐,可以绑定多个)
button_element.addEventListener('click', function () {
alert("通过 addEventListener 触发按键");
});

addEventListener 的优势是同一个元素可以绑定多个同类型处理函数,它们会依次执行;而 onclick = ... 是赋值操作,再赋一次就把前一个覆盖了。

补充:现在更常用 querySelector 和 querySelectorAll,它们直接支持 CSS 选择器语法,比上面三个方法灵活得多:

1
2
3
document.querySelector('#box1');        // 按 id 找,返回第一个
document.querySelector('.box2'); // 按 class 找,返回第一个
document.querySelectorAll('div p'); // 按后代选择器找,返回全部

六、DOM 节点增删改查

demo 16:表格的增删改查

这是一个把前面的知识串起来的综合练习:用按钮动态给表格加行、改内容、删行。

HTML 部分,每行末尾放两个按钮,并且把 this 传进去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<button onclick="addRow()">新增数据</button>

<table id="table">
<tr>
<th>姓名</th>
<th>联系方式</th>
<th>操作</th>
</tr>
<tr>
<td>Zqh</td>
<td>1145141919810</td>
<td>
<button onclick="editRow(this)">编辑</button><button onclick="deleteRow(this)">删除</button>
</td>
</tr>
</table>

按钮上的 this 很关键——它把被点击的那个按钮元素作为参数传给函数,函数才能知道”是哪一行要操作”。

对应的 js/table.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 新增一行
function addRow() {
var table = document.getElementById('table');
var length = table.rows.length; // 当前行数,也就是新行要插入的位置

var newRow = table.insertRow(length); // 插入行节点

var nameCol = newRow.insertCell(0); // 在新行里依次插入三个单元格
var phoneCol = newRow.insertCell(1);
var actionCol = newRow.insertCell(2);

nameCol.innerHTML = '未命名';
phoneCol.innerHTML = '无联系方式';
actionCol.innerHTML = '<button onclick="editRow(this)">编辑</button><button onclick="deleteRow(this)">删除</button>';
}

// 删除一行
function deleteRow(button) {
var row = button.parentNode.parentNode; // button → td → tr
row.parentNode.removeChild(row); // 从表格里移除这一行
}

// 编辑一行
function editRow(button) {
var row = button.parentNode.parentNode;
var name = row.cells[0];
var phone = row.cells[1];

var inputname = prompt("请输入名字:");
var inputphone = prompt("请输入联系方式:");

name.innerHTML = inputname;
phone.innerHTML = inputphone;
}

三个关键 API

API 作用
table.insertRow(index) 在指定位置插入一行
row.insertCell(index) 在行内指定位置插入一个单元格
parentNode.removeChild(child) 从父节点中移除子节点

用 parentNode 往回找

deleteRow 和 editRow 里都有一句 button.parentNode.parentNode,这是从按钮反推所在行的固定套路:

1
2
3
4
5
button(按钮)
↑ parentNode
td(单元格)
↑ parentNode
tr(表格行)← 我们要操作的目标

因为点击事件只能拿到按钮本身,要操作整行就必须往上爬两层。DOM 树是双向的:既能用 children 往下找,也能用 parentNode 往上找。

prompt 在用户点”取消”时返回的是 null,直接赋值会把单元格内容变成字符串 "null"。实际项目里应该加个判断:

1
2
3
if (inputname !== null) {
name.innerHTML = inputname;
}

七、移动端 rem 适配

demo 17:移动端 rem 适配原理

这是系列里最”实用”的一个练习,解决的是同一套代码如何适配不同宽度的手机屏幕。

px 的问题

1
2
3
4
5
.box-px {
width: 300px; /* 写死的像素 */
height: 100px;
background-color: blueviolet;
}

px 是绝对单位。在 375px 宽的手机上,300px 占了 80% 的宽度;换到 414px 的手机上就只占 72%——比例变了,看起来就不一致。

rem 是什么

rem 是相对于根元素 html 的 font-size 的单位:

1
2
3
4
5
6
7
8
9
html {
font-size: 20px;
}

.box-rem {
width: 5rem; /* 5 × 20px = 100px */
height: 3rem; /* 3 × 20px = 60px */
background-color: yellow;
}

只要改 html 的 font-size,所有用 rem 写的尺寸会整体等比缩放。这就给了我们一个可调节的”缩放旋钮”。

动态设置缩放基准

把屏幕宽度平均分成 10 份,让 1rem 等于其中一份:

1
2
3
4
5
6
7
8
9
// 根据设备宽度计算 html 标签的 font-size 属性值
function resetHTMLFontSize() {
document.documentElement.style.fontSize = screen.width / 10 + 'px';
}

resetHTMLFontSize();

// 窗口尺寸变化时重新计算
window.onresize = resetHTMLFontSize;

这样在 375px 宽的设备上,1rem = 37.5px;在 414px 上 1rem = 41.4px。设计稿上量出多少 rem,在任何手机上占的比例都一样。

前面 Flex 那篇里写的 html { font-size: 10px; } 是把基准写死(1rem = 10px,纯粹为了好算);这里则是跟着屏幕宽度动态变。两者用途不同:前者方便换算,后者用于适配。

原文件标题里的 “rom” 是 rem 的笔误。

另外这段代码用的是 screen.width(设备屏幕宽度),它不会随浏览器窗口大小改变。所以在电脑上拖动窗口时 onresize 触发的重算结果其实没变化,看不出效果——真机或手机模拟器下才明显。

如果想用视口宽度(更符合直觉,桌面端也能看到效果),应该改成:

1
document.documentElement.style.fontSize = document.documentElement.clientWidth / 10 + 'px';

rem 需要手动换算,写起来略麻烦,所以现在更流行 vw 方案(1vw 就等于视口宽度的 1%,不需要 JS 参与),或者用 PostCSS 插件自动把设计稿的 px 转成 rem。理解了 rem 的原理,这些方案都是同一个思路的变体。

小结

知识点 核心记忆点
导入方式 操作 DOM 的脚本放 body 末尾;用 F12 控制台看输出
基本语法 优先 const,其次 let;continue 跳过本轮,break 结束循环
函数作用域 函数内能访问外面的变量,外面访问不到函数内的
事件 onclick / onfocus / onblur;推荐 addEventListener
DOM 查找 getElementById 返回单个,另外两个返回集合要加索引
innerHTML 会解析 HTML,有 XSS 风险,不要用于用户输入
增删改查 insertRow / insertCell / removeChild;parentNode 往上找
rem 适配 1rem = html 的 font-size,改基准即可整体缩放

四篇总结

整个系列走下来,前端的三个层次已经很清楚了:

层 负责 核心内容
HTML 结构 标签、属性、表单
CSS 样式 选择器、盒子模型、布局
JavaScript 行为 语法、事件、DOM 操作

这三者里,CSS 布局和 DOM 操作是新手最容易卡住的地方——浮动的高度坍塌、定位的参照物、集合忘记加索引,都是必踩的坑。把 demo 挨个改一改、故意改坏再修好,比只看代码印象深得多。

本系列的 demo 我都放在博客里可以直接运行,见每节开头的链接。


本系列其他文章