前端入门:从浏览器渲染到可交付页面
把前端拆成「结构 — 表现 — 行为 — 运行环境 — 数据通路 — 工程交付」六层,逐层给出核心概念、术语、 最小示例与验收标准,并配知识结构图与流程图。写作语境按读者现有的 R / Python / SAS 心智模型来搭桥, 终点是能独立做出一个可用的页面并放到网上。
三类断言,分开标注。凡写「规范」,指的是现行标准或官方文档已经规定、可查附录 B 原文的行为; 凡写「实测」,指的是本机 Node v22.22.2 上跑出来的结果,原始输出见 §5.10;凡写「惯例」, 指的是工程界的取舍而非事实,可以被合理反对。三者混在一起读,最容易把「大家都这么写」当成「必须这么写」。
这篇笔记不覆盖什么。只到「能独立做出页面」为止的地平线:框架只给判断标准,不给 API 细节; 不涉及构建工具的插件体系、微前端、SSR 等服务端渲染话题;代码是最小可运行片段,不是完整项目骨架。
没有在本机复现的部分。CSS 布局、浏览器渲染与网络行为均按规范文档陈述,未在本机浏览器中逐步复现; 本文所有「实测」结论仅来自 Node 的 JavaScript 引擎,不包含任何 DOM 或渲染结论。
1前端是什么:三个职责层,一个运行环境
先把「前端」这个词拆干净,否则后面每一层都会含糊。
前端不是一个技术,而是三种职责在同一份交付物里的分工:HTML 说明有什么内容, CSS 说明这些内容长什么样、怎么排,JavaScript 说明用户动了之后发生什么。三者由浏览器统一解释执行。 判断一段代码属于前端还是后端,标准只有一条 —— 它在哪里执行。在用户的浏览器里执行的,就是前端。
1.1 三个层各自回答什么问题
| 层 | 回答的问题 | 用你已有的工具打比方 |
|---|---|---|
| HTML | 有哪些内容,各是什么 | 类似数据集的结构定义:哪一列是标识、哪一列是测量值、哪一列是分组标签。声明事实,不做计算。 |
| CSS | 长什么样,怎么排 | 类似绘图参数与主题:字体、颜色、坐标轴、面板间距。只影响呈现,不影响内容本身。 |
| JavaScript | 发生什么动作 | 类似 DATA step 或脚本逻辑:判断、循环、变换数据、响应输入。它也是三层里唯一能做运算的。 |
如果去掉 JavaScript,页面依然能显示,只是不能交互 —— 这正说明 HTML 与 CSS 的职责是呈现而不是行为。 反过来,一份只有 JavaScript 而内容由它临时拼出来的页面,在脚本报错时会变成白屏。这就是为什么后面反复强调: 内容优先用 HTML 写出来,JavaScript 只做增强。
1.2 代码在哪里运行:浏览器与服务器
1.3 从 R / Python / SAS 迁移过来,心智模型要改哪几处
语言语法是可以现查的,真正会持续误导你的是执行模型。下表左列是你在 R / Python / SAS 里已经形成的直觉, 右列是前端世界里对应的事实。这张表是整个入门阶段最值得反复回看的一页。
| 维度 | R / Python / SAS 的直觉 | JavaScript / 前端的实际情况 |
|---|---|---|
| 生命周期 | 脚本从头跑到尾,跑完进程退出 | 差异最大 页面在标签页里长期驻留。代码写完只是「注册」了行为,真正执行取决于用户点没点、网络通没通。 |
| 控制流 | 顺序执行,你用循环决定下一步 | 事件驱动:注册回调函数,交给浏览器在合适时机调用。for 循环仍然存在,但主结构是「有人触发 → 我的函数被调用」。 |
| 数值类型 | R 区分 integer / double,SAS 区分数值与字符 | 只有一种数值类型 number(IEEE-754 双精度);没有整型,因此 1/3 与精度问题需要主动处理(见 §5.3)。 |
| 类型宽松度 | 把字符当数值用通常直接报错 | '3' * 2 得到 6,'1' + 1 得到 '11'。同一套运算符有时转数字、有时拼字符串,是初学者 bug 的头号来源。实测 |
| 相等判断 | == 基本就是值相等 |
== 会先做类型转换,=== 才要求类型和值都相同。工程惯例是只用 ===,见 §5.2。 |
| 缺失值 | NA / NaN / SAS 的 .,语义明确 |
有 undefined 与 null 两个,且 undefined == null 为真。好消息是 NaN !== NaN 与 R 一致。直觉可迁移 |
| 向量化 | R 的向量、pandas 的列运算、SAS 隐式逐行 | 没有向量化。数组运算要么手写 map / filter / reduce,要么靠库。这是从数据语言转过来最需要补的一块,见 §5.6。 |
| 集合类型 | data.frame / DataFrame / 数据集 |
没有表格类型。数据是一维数组(array)加键值对象(object)拼出来的,表格要靠库或自己约定结构。 |
| 依赖管理 | install.packages() / pip / SAS autocall 宏 |
用 npm 装包,但依赖不会自动到达浏览器 —— 需要一步「打包」把依赖合进交付文件。这是工程化存在的根本原因(§8)。 |
| 错误可见性 | 报错即停,日志里能看到 | 错误可能只让某一个交互失效,页面其余部分照常工作。静默失败是常态,所以必须主动打开控制台看报错。 |
1.4 最小可运行页面
环境只有一个要求:一个能保存文件的编辑器,加一个现代浏览器。下面这个文件保存为 index.html,
双击就能在浏览器里打开 —— 不需要安装任何东西,这本身就是前端入门最大的优势。
<!-- (1) 文档类型与语言:告诉浏览器用标准模式解析 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<!-- (2) 这两行是所有页面都必须有的:字符集与视口 -->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>队列基线特征</title>
</head>
<body>
<!-- (3) 内容用语义标签写出来,而不是一堆 div -->
<h1>队列基线特征</h1>
<p>共入组 <strong>128</strong> 例。详见下表。</p>
<table>
<thead><tr><th>变量</th><th>均值(标准差)</th></tr></thead>
<tbody>
<tr><td>年龄</td><td>57.3 (11.4)</td></tr>
<tr><td>体质指数</td><td>24.8 (3.1)</td></tr>
</tbody>
</table>
</body>
</html>
注意最后那段:<table> 不是为了好看才写的,它是语义。浏览器、屏幕阅读器、搜索引擎都靠它理解
「这一行是数据,那一格是表头」。如果你用一堆 div 加边框视觉上拼出一张表,人看着一样,机器看着就是一堆散装文字。
2知识体系总览
先看地图,再走路线。六条主干之间是有依赖顺序的,跳着学会反复返工。
2.1 六条主干的分工
| 主干 | 解决什么 | 入门阶段要投入多少 |
|---|---|---|
| HTML · 结构 | 把内容正确、完整地表达出来,让机器也能读懂 | 重点 一两天可掌握常用部分 |
| CSS · 表现 | 布局与外观,决定页面能不能用、好不好看 | 重点 前两周最花时间的一支 |
| JavaScript · 行为 | 交互与数据处理,唯一能做运算的一层 | 重点 长期投入,占学习时间的一半 |
| 运行环境 · 浏览器 | 解释「为什么代码这么写才会快、才会对」 | 按需 先会看控制台与网络面板即可 |
| 数据通路 · 网络 | 把数据从服务器取到页面上,以及为什么会被拦 | 重点 会写一个 fetch 就够起步 |
| 工程与交付 | 依赖、构建、部署 —— 从「我的电脑上」到「网上可用」 | 按需 做出第一个页面之后再碰 |
直接上 React 或 Vue,能拼出界面,但一遇到样式不对、接口报错、打包失败就完全无从下手,因为底下三层是空的。 合理的顺序是先把 HTML 与 CSS 用到能默写出一个页面骨架,再补 JavaScript,框架放到最后 —— 它只是把 「用 DOM 操作界面」这件事换了一种更省心的写法,前提是你知道原来的写法是什么样。
3HTML:结构与语义
一层被严重低估的语言。它不难,但写错会让后面两层无处着力。
HTML(HyperText Markup Language)不是编程语言 —— 它没有变量、没有循环、没有判断,只有嵌套的标签。 一个标签声明一段内容「是什么」,浏览器把它解析成一棵盒子树,CSS 与 JavaScript 都在这棵树上工作。 所以 HTML 的质量决定了后两层的工作难度:结构扁平、语义清晰,CSS 选择器就短,JavaScript 的遍历就简单。
3.1 元素、属性、嵌套
| 概念 | 含义 | 写法 |
|---|---|---|
| 元素 | 一对开始标签与结束标签,加上它们包裹的内容 | <p>文字</p> |
| 空元素 | 没有内容、不需要结束标签的元素 | <img src="..." alt="..."> |
| 属性 | 给元素的附加说明,写在开始标签里 | <a href="/report"> |
| 嵌套 | 元素可以包含元素,形成树形结构;嵌套必须完整闭合 | 树形,越深越难维护 |
| 布尔属性 | 写了就是真,不写就是假,不写值 | <input required> |
浏览器对错误的 HTML 极其宽容:少一个闭合标签不会报错,它会猜。这个宽容是双刃剑 —— 页面看起来正常,但 DOM 树可能和你想的不是一回事。养成习惯:随手用开发者工具的「元素」面板看一眼真实结构。
3.2 语义化:为什么不能全用 div
<div> 是「一个没有含义的容器」。用它可以拼出任何视觉效果,这也是它被滥用的原因。
问题是含义是给机器用的:屏幕阅读器靠它决定怎么朗读,搜索引擎靠它决定哪段是正文,
键盘用户靠它判断能不能跳到主内容区。下表左列能替代的是右侧的 div 写法。
| 语义标签 | 含义 | 替代掉的无含义写法 |
|---|---|---|
<header> | 页面或区块的头部 | <div class="header"> |
<nav> | 导航链接的集合 | <div class="nav"> |
<main> | 本页唯一的主体内容(一页只应有一个) | <div id="content"> |
<section> | 有主题的一节,通常带标题 | <div class="sec"> |
<h1>…<h6> | 标题层级,表达从属关系而非字号 | <div class="big-bold"> |
<table> / <th> / <caption> | 表格数据、表头单元格、表标题 | 用边框拼出来的假表格 |
<button> | 可点击的动作 | <div onclick="..."> |
<label> | 表单控件的文字标签 | 控件旁边的普通文字 |
把 <div onclick="..."> 当按钮用,视觉上没问题,但:键盘 Tab 键跳不过去;
屏幕阅读器不会告诉用户「这是可点击的」;表单里按 Enter 不会提交。换成 <button>
这三个问题同时消失,而且不需要写一行额外代码。这就是「语义」的实际收益 —— 它是免费的功能,不是洁癖。
3.3 表单:把用户的输入送回服务器
表单是前端唯一一类「原生就能提交数据」的元素。<input> 的 type 属性不只是外观差异,
它决定了浏览器给你什么键盘、做什么校验、提交什么格式。
type | 浏览器额外做的事 |
|---|---|
text | 普通文本;value 永远是字符串,需要自己转数字 |
number | 数字键盘、min / max / step 约束、valueAsNumber |
date | 日期选择器,value 格式固定为 YYYY-MM-DD |
checkbox / radio | 布尔或单选;name 相同的 radio 互斥 |
file | 文件选择器,配合 accept 限制类型,得到 File 对象 |
一个统计场景的表单骨架 —— 用于筛选队列:
<form id="filter">
<!-- label 用 for 指向 id,点文字也能聚焦控件;for 与 id 必须严格对应 -->
<label for="sex">性别</label>
<select id="sex" name="sex">
<option value="">全部</option>
<option value="F">女</option>
<option value="M">男</option>
</select>
<label for="ageMin">最小年龄</label>
<input id="ageMin" name="ageMin" type="number" min="18" max="100">
<!-- type="submit" 的按钮会触发表单提交这种「原生行为」 -->
<button type="submit">应用筛选</button>
</form>
注意 <button> 必须显式写 type。不写时它在表单内的默认值是 submit,
于是「重置」按钮会意外提交整个表单 —— 这是真实项目里反复出现的 bug。
3.4 无障碍:最小可用集
无障碍(accessibility,常缩写为 a11y)听起来专业,入门阶段其实只有四条要记。 做对这四条,就能覆盖绝大多数常见问题。
- 图片写
alt。内容图写描述,纯装饰图写alt=""(空字符串表示「跳过」)。缺失alt会让屏幕阅读器读出文件名。 - 表单控件配
<label>。用for与id绑定,而不是把文字放在旁边。 - 该用原生元素就用原生元素。按钮用
<button>,链接用<a>,别用div加事件模拟。 - 标题层级不跳级。从
h1到h3不能跳过h2,它表达的是文档大纲,不是字号。
如果你需要给一个元素手工添加 role、tabindex 和键盘事件三样东西才能让它像按钮,
那说明原生按钮本来就能满足需求。ARIA 属性是补丁,正确用法是「优先用原生元素,只在原生表达不了时才用 ARIA」。
4CSS:盒模型、层叠与布局
样式表被低估的地方不在于「怎么让它好看」,而在于「为什么我写的这条规则没生效」。
CSS(Cascading Style Sheets,层叠样式表)的全部难点集中在两个字上:层叠。 页面里每个元素最终的样子,是所有匹配到它的规则按优先级与声明顺序「竞争」出来的结果。 所以学 CSS 的顺序应该是:先懂盒模型(一个元素占多大地方),再懂层叠(哪条规则赢),最后才是布局(怎么摆放)。
4.1 盒模型
width: 100% 却还是横向溢出」——
因为还有 padding 与 border 被加在外面。惯例是在样式表开头统一设置 *{box-sizing:border-box},让宽高包含边框与内边距。| 属性 | 作用与常见取值 |
|---|---|
display | 决定这个盒子在布局中的身份,最重要的一条。取值 block / inline / inline-block / flex / grid / none。 |
margin / padding | 上下左右间距;padding 有背景色,margin 永远透明。 |
border | 边框,写作 border: 1px solid #ccc。 |
overflow | 内容超出盒子时怎么办,auto 是最常用的(需要才出滚动条)。 |
position | 脱离正常流的方式。relative 相对自身偏移、absolute 相对最近的定位祖先、fixed 相对视口、sticky 滚动到阈值后粘住。 |
4.2 层叠与优先级:为什么我的样式没生效
当多条规则作用于同一个属性时,浏览器按下面的顺序决出胜者 —— 这是规范规定的判定顺序,不是经验法则:
- 来源与重要性。带
!important的声明优先于普通声明;作者样式优先于浏览器默认样式。 - 选择器特异性(specificity)。把选择器拆成三类计数,按「内联 > id > class / 属性 / 伪类 > 元素 / 伪元素」从高到低比较。
- 出现顺序。以上全部相同时,写在后面的赢。
| 选择器 | 特异性 | 说明 |
|---|---|---|
tbody td | 0,0,2 | 两个元素选择器,各计 1 |
table.data td | 0,1,2 | 一个 class 计 1,元素各计 1 |
#report td | 1,0,1 | 一个 id 直接压过上面两条 |
style="color:red" | 内联 | 写在标签上的内联样式,高于任何选择器 |
color: red !important | 最高 | 压过内联,因此也最难被覆盖 |
不要反复改代码猜。打开开发者工具,选中该元素,在右侧的样式面板里从上往下看:
(1) 你写的那条规则在不在列表里?不在说明选择器没匹配上(多半是类名拼错或层级写错);
(2) 在列表里但被划了删除线?说明被更高优先级的规则覆盖了,直接看压在上面的是谁;
(3) 规则生效了但看不到变化?说明属性写错了或者值非法(例如给 width 写了 auto 之外的字符串)。
这三步能定位九成以上的样式问题,比反复读代码快得多。
4.3 布局:三种机制,一条判断路径
display 是布局机制的总开关,flex 与 grid 写的是父容器,
子元素负责被排布,这是新手最容易搞反的一处。| 机制 | 适合 | 代价与注意 |
|---|---|---|
| 正常流 | 默认首选 | 零成本、自然响应。适合文章、表单这类线性内容。 |
float | 避免 | 当年用于多列布局,需要清浮动且易塌陷。今天只在「文字环绕图片」这一个场景仍合适。 |
position: absolute | 克制使用 | 脱离正常流,父元素不会为它撑高。适合角标、悬浮层,不适合做整体布局。 |
| Flex | 推荐 | 一维布局的标准答案。工具栏、卡片行、垂直居中都用它。 |
| Grid | 推荐 | 二维布局的标准答案。整体页面骨架、整齐的指标卡片矩阵用它最省心。 |
一个够用很久的写法组合 —— 页面骨架用 Grid,内部局部用 Flex:
/* 父容器:整体骨架,两栏(主内容 + 侧栏) */
.layout {
/* fr 表示「剩余空间按份数分」,比写百分比更好用 */
display: grid;
grid-template-columns: 1fr 320px;
gap: 24px;
}
/* 父容器:一行指标卡片,空间不够时自动换行 */
.cards {
display: flex;
flex-wrap: wrap; /* 不写这行就不会换行,只会在原地挤扁 */
gap: 16px;
align-items: center; /* 交叉轴对齐:这里指垂直居中 */
}
.cards > * {
flex: 1 1 200px; /* 可伸可缩,基准 200px —— 自适应卡片的标准写法 */
}
/* 小屏时骨架改成一栏 */
@media (max-width: 720px) {
.layout { grid-template-columns: 1fr; }
}
4.4 单位与响应式
| 单位 | 含义 | 什么时候用 |
|---|---|---|
px | CSS 像素,与设备物理像素无关 | 边框、细线、需要精确 1px 的地方 |
rem | 相对根元素字号;浏览器默认 1rem = 16px | 字号、间距的首选 —— 用户改了浏览器字号能整体缩放 |
em | 相对自身或父元素字号 | 组件内部相关的间距;会层层累积,容易失控,新手期少用 |
% | 相对父元素的同维度尺寸 | 宽度自适应;注意高度百分比需要父元素有确定高度 |
vw / vh | 视口宽 / 高的 1% | 全屏区块;vh 在移动端浏览器地址栏收发时会跳变,谨慎使用 |
fr | Grid 专用,剩余空间的一份 | Grid 里分配列宽 |
响应式(responsive)的目标是同一份 HTML 适配不同宽度,做法是移动优先 + 媒体查询:
/* (1) 先写窄屏样式作为默认值 —— 移动优先 */
.cards { display: flex; flex-direction: column; }
/* (2) 宽度够了再逐级增强,而不是先写桌面端再往回打补丁 */
@media (min-width: 720px) {
.cards { flex-direction: row; }
}
@media (min-width: 1100px) {
.layout { grid-template-columns: 240px 1fr 280px; }
}
4.5 三个高频坑
| 症状 | 原因与解法 |
|---|---|
子元素设了 margin-top,父元素跟着往下跑 | 外边距折叠(margin collapsing)—— 相邻的上下外边距会合并。解法:给父元素加 padding-top 或 overflow: hidden,或者直接用 Flex 让子元素不再相邻折叠。 |
z-index 写到 9999 还是压不住 | z-index 只在同一层叠上下文内比较。父元素若有 transform、opacity 小于 1、position 加 z-index,就会另起一个上下文,里面再大的值也越不出父级。 |
| 内容莫名溢出容器,右侧出现横向滚动条 | 固定宽度(如 width: 400px)加上 padding 与 border 超出父宽。解法:改用 max-width 与 box-sizing: border-box。 |
5JavaScript:语言核心
这一节是耗时最长的一支。以下所有「实测」均在 Node v22.22.2 上运行得到,原始输出见 §5.10。
JavaScript(常缩写为 JS)是前端唯一能运算的语言,也是三者里唯一需要真正花时间学的。 好消息是:你不需要学完它。下面这些概念覆盖了做页面时九成以上的场景,剩下的边用边查。 本节所有实测都只反映 JS 引擎行为,不涉及任何浏览器或 DOM 结论。
5.1 值与类型
JS 有 7 种原始类型(primitive)加 1 种引用类型(object)。做数据的人最容易踩的是前两行:
typeof 42 // 'number' —— 没有整型,42 与 42.0 是同一个东西
typeof '42' // 'string' —— 引号决定一切
typeof true // 'boolean'
typeof undefined // 'undefined'
typeof null // 'object' —— 实测,这是语言早期的遗留问题,不是笔误
typeof [] // 'object' —— 数组也是 object,typeof 分不出来
Array.isArray([]) // true —— 判断数组只能用它
typeof document // 浏览器里是 'object';Node 里是 'undefined'(实测)
拿不准一个值是数字还是字符串时,不要猜,直接 console.log(typeof v, v)。
从表里读出来的 value 永远是字符串,这是最经典的坑:'57.3' > '100' 为真(按字符串逐位比较),
而 57.3 > 100 为假。看到结果反直觉时,第一反应应该是检查类型。
5.2 相等:只用 === 就够了
== 会在比较前先做类型转换,=== 不做。转换规则复杂且反直觉,所以工程上的惯例是全部使用 ===。
下表是实测结果:
| 表达式 | 结果 | 为什么值得记住 |
|---|---|---|
'' == 0 | true | 空字符串被转成 0。用它判断「有没有填写」会误判。 |
'' === 0 | false | 换成严格相等就符合直觉了。 |
[] == false | true | 空数组既被当成空字符串又被当成假值。这就是为什么数组判空要用 arr.length === 0。 |
null == undefined | true | 唯一一个宽松相等有用的场合:v == null 同时覆盖两者。 |
null === undefined | false | 严格相等下它们是两个不同的值。 |
NaN === NaN | false | 与 R 一致,判断缺失/非法数值要用 Number.isNaN(v)。 |
[] 作为条件 | 真 | 空数组是真值。同理 Boolean('0') 为 true,因为非空字符串都是真。实测 |
Boolean('') | false | 假值只有固定的几个:false、0、''、null、undefined、NaN。 |
5.3 数字与精度
0.1 + 0.2 // 0.30000000000000004 —— 实测
0.1 + 0.2 === 0.3 // false —— 实测
// 结论:浮点数不要直接用 === 比较,用容差
Math.abs(a - b) < 1e-9 // 这才是「约等于」的正确写法
Number('12abc') // NaN —— 转换失败会给出 NaN,而不是报错
Number('') // 0 —— 注意:空字符串转出来是 0,不是 NaN
parseInt('12abc') // 12 —— 它更宽容,会解析到不能解析为止
补充一个记录细节:§5.10 里 Number('12abc') 显示为 null,
是因为输出用了 JSON.stringify,而 NaN 在 JSON 里没有表示法,会被编码成 null。
这本身也是个值得记住的事实 —— 把含 NaN 的数据发成 JSON,对方收到的是 null。
统计量在 JS 里没有「整数」概念,也不区分缺失与零。把 NaN 通过网络传成 JSON 会静默变成 null,
再读回来时你必须自己决定它代表「缺失」还是「真的没有」。这个决定最好在接口约定里写清楚,而不是在代码里猜。
5.4 作用域、声明与闭包
console.log(typeof hoistedVar); // 'undefined' —— 实测:var 声明被提升,值是 undefined
var hoistedVar = 1;
console.log(typeof tdzLet); // 抛 ReferenceError —— 实测:let 有暂时性死区
let tdzLet = 1;
// 闭包:函数记住了它定义时所在的作用域
const makeCounter = () => {
let n = 0; // 这个变量不会随函数返回而消失
return () => ++n; // 内部函数「捕获」了 n
};
const next = makeCounter();
next(); next(); // 2
| 声明 | 作用域 | 能不能改 |
|---|---|---|
var | 函数作用域,声明提升 | 可改可重声明。新代码不要用。 |
let | 块级作用域({} 内) | 可改,不可重声明。 |
const | 块级作用域 | 绑定不可改 —— 但内容可以改。实测:const a = [1,2]; a.push(3) 完全合法,得到 [1,2,3]。 |
const 只锁住「这个名字指向哪个对象」,不锁对象内部。const a = [1,2]; a = [] 会报错,
但 a.push(3) 不会。想要真正不可变需要 Object.freeze()(且只冻结一层)。
闭包在循环里的经典表现
// 用 var:三个函数共享同一个 i,循环结束后 i 是 3
const f1 = [];
for (var i = 0; i < 3; i++) f1.push(() => i);
f1.map(fn => fn()); // [3, 3, 3] —— 实测,几乎肯定不是你要的
// 用 let:每一轮循环都有自己的 i
const f2 = [];
for (let i = 0; i < 3; i++) f2.push(() => i);
f2.map(fn => fn()); // [0, 1, 2] —— 实测
5.5 this:谁调用,就指向谁
this 的值由调用方式决定,不由定义位置决定。这一条规则能解释绝大多数困惑:
const obj = { v: 7, normal() { return this.v; } };
obj.normal(); // 7 —— 实测:作为对象的方法被调用,this = obj
const bare = obj.normal;
bare(); // undefined —— 实测:脱钩后 this 不再是 obj(严格模式下为 undefined)
// 对策一:用箭头函数。箭头函数没有自己的 this,直接沿用外层的。
// 对策二:把方法写成 obj.normal = () => {...} 时注意,此时 this 是外层的,不是 obj。
实践建议:在事件回调里尽量用箭头函数,或者把需要的对象提前解构成局部变量,
这样就不必推理 this 是什么。
5.6 数组:从「向量化」到「遍历」
这是 R / Python / SAS 使用者最需要重学的部分。这些语言里对一列数据做变换是一次声明, JS 里是一次遍历。三个方法承担了这个工作,实测结果如下:
const v = [1, 2, 3];
v.map(x => x * 2); // [2, 4, 6] —— 实测:返回新数组,长度不变
v.forEach(x => x * 2); // undefined —— 实测:只遍历,不返回任何东西
v.filter(x => x > 1); // [2, 3] —— 返回新数组,长度可变
v.reduce((s, x) => s + x, 0); // 10 —— 折叠成单个值(求和、计数、分组)
v // [1, 2, 3] —— 原数组没被改动,三个方法都不改原数组
// 分组:用 reduce 把行按组归拢(可以理解为减少一次 group_by)
const rows = [{ g: 'A', v: 1 }, { g: 'B', v: 2 }, { g: 'A', v: 3 }];
rows.reduce((acc, r) => { (acc[r.g] ||= []).push(r.v); return acc; }, {});
// {"A":[1,3],"B":[2]} —— 实测
实测:[10, 9, 100].sort() 得到 [10, 100, 9] —— 因为默认把元素转成字符串逐位比较。
必须显式给比较函数:sort((a, b) => a - b) 才得到 [9, 10, 100]。
另外 sort 是原地修改的(实测 a.sort() === a 为 true),
所以它会改掉原数组 —— 想保留原顺序要先复制:[...rows].sort(...)。
5.7 对象:数据在 JS 里的形状
没有 data.frame,一行数据通常就是一个对象,多行就是一个对象数组。 这与 R 的按列存储是反的,是转过来之后需要习惯的第一件事。
// 一行数据 = 一个对象
const row = { id: 1, age: 57.3, sex: 'F' };
row.age // 57.3 点号取值
row['age'] // 57.3 方括号取值,键是变量时必须用这种
Object.keys(row) // ['id','age','sex'] —— 相当于列名
// 解构:把字段取成局部变量,读起来更短
const { age, sex } = row;
// 展开:生成新对象,而不是修改原对象(修改原对象会让页面状态难追踪)
const updated = { ...row, age: 58 };
在 R 或 pandas 里,df$age <- 58 是很自然的写法 —— 原地改一列。
在 JS 里惯例是生成新对象而不是改旧的,因为界面需要知道「什么变了」才能只重画变化的部分。
这条习惯在 §9 讲框架时会直接决定你能不能看懂状态更新的代码。
5.8 异步与事件循环
网络请求、读文件、等用户点击,这些事都需要等。JS 的处理方式是不阻塞: 把后续动作写成一个函数交给引擎,引擎在结果就绪时再调用它。
console.log('1 同步开始');
setTimeout(() => console.log('5 setTimeout 回调'), 0);
Promise.resolve().then(() => console.log('3 promise.then'));
queueMicrotask(() => console.log('4 queueMicrotask'));
console.log('2 同步结束');
// 实测输出顺序:1 同步开始 → 2 同步结束 → 3 promise.then → 4 queueMicrotask → 5 setTimeout 回调
setTimeout 写在前面,却最后才执行」,
也解释了为什么接口还没返回时页面不会卡死 —— 等待期间引擎继续处理其他事情。入门阶段只需记住那条执行顺序。实际写代码时用 async / await,它的含义是「在这里等结果,但等待期间不阻塞页面」:
async function loadRows() {
const res = await fetch('/api/rows'); // 等价于「等这个 Promise 出结果」
const data = await res.json(); // 解析 JSON 也是异步的
render(data);
}
// 错误必须显式处理,否则会静默失败
loadRows().catch(err => console.error('加载失败', err));
5.9 DOM 与事件:真正操作页面的地方
DOM(Document Object Model)是浏览器把 HTML 解析出来的那棵对象树。JS 通过它读改页面。 下面这段把一组统计结果渲染成表格 —— 它是后面结课项目的核心动作:
const rows = [
{ variable: '年龄', mean: 57.3, sd: 11.4 },
{ variable: '体质指数', mean: 24.8, sd: 3.1 },
];
const tbody = document.querySelector('#result tbody'); // (1) 选中容器
tbody.replaceChildren(); // (2) 清空,避免重复渲染
for (const r of rows) { // (3) 逐行构造真实元素
const tr = document.createElement('tr');
const tdName = document.createElement('td');
tdName.textContent = r.variable; // 用 textContent 而不是 innerHTML
const tdStat = document.createElement('td');
tdStat.textContent = `${r.mean.toFixed(1)} (${r.sd.toFixed(1)})`;
tr.append(tdName, tdStat);
tbody.append(tr);
}
innerHTML = '<td>' + value + '</td>' 会把 value 当成 HTML 解析。
如果这个值来自用户输入或外部文件,里面一个 <script> 或 <img onerror=...>
就能在你的页面上执行任意代码(跨站脚本攻击,XSS)。textContent 只设置纯文本,永远更安全,
而且更快。innerHTML 留给「你自己写的、不含任何外部数据的静态片段」。
事件:不要给每一行都绑监听
// 反例:行多了就是几百个监听器,新增的行还得补绑
document.querySelectorAll('tr').forEach(tr => tr.addEventListener('click', onClick));
// 正例:事件委托 —— 只在父容器上绑一次,靠 event.target 判断点到了谁
tbody.addEventListener('click', (e) => {
const tr = e.target.closest('tr'); // 从被点的元素向上找最近的行
if (!tr) return;
console.log('点击了第', tr.rowIndex, '行');
});
5.10 实测原始输出
本节所有标注「实测」的结论,来自下面这次运行。环境为 Node v22.22.2,命令为
node fe-verify.js,脚本只用语言内置对象,不涉及 DOM。
== runtime ==
node | v22.22.2
== types and equality ==
typeof null | "object"
typeof [] | "object"
Array.isArray([]) | true
typeof document | "undefined"
'1' + 1 | "11"
'3' * 2 | 6
'3' - 1 | 2
'12abc' * 1 | null <-- NaN,被 JSON 编码成 null
Number("12abc") | null <-- 同上,实际是 NaN
Number("") | 0
parseInt("12abc") | 12
"" == 0 | true
"" === 0 | false
[] == false | true
null == undefined | true
null === undefined | false
NaN === NaN | false
[] ? a : b | "truthy"
Boolean("0") | true
Boolean("") | false
0.1 + 0.2 | 0.30000000000000004
0.1 + 0.2 === 0.3 | false
== scope, hoisting, this, closures ==
typeof hoistedVar (var, before dec | "undefined"
typeof tdzLet (let, before decl) | THROW ReferenceError: Cannot access 'tdzLet' before initialization
objThis.normal() | 7
detached method call | undefined
var in for-loop closures | [3,3,3]
let in for-loop closures | [0,1,2]
== arrays: in-place vs returning ==
[10,9,100].sort() | [10,100,9]
[10,9,100].sort((a,b)=>a-b) | [9,10,100]
sort() returns the SAME array | true
[1,2,3].map(x=>x*2) | [2,4,6]
[1,2,3].forEach(x=>x*2) | undefined
map on sparse hole | "[2,null,6]"
const array stays mutable | [1,2,3]
push returns | 3
reduce sum | 10
groupBy-free tally pattern | {"A":[1,3],"B":[2]}
== event loop ==
observed order | ["1 sync start","2 sync end","3 promise.then","4 queueMicrotask","5 setTimeout(0) callback"]
三处需要额外说明:(1) 用 JSON.stringify 输出时 NaN 会变成 null,
所以 '12abc' * 1 显示为 null,它真实的值是 NaN;
(2) 稀疏数组的 map 会跳过空位,空位在输出里同样显示为 null;
(3) 这次运行里 typeof document 是 'undefined',因为 Node 没有 DOM ——
这也是 §1.2 那个边界的实测依据:同一门语言,在不同环境里能用的东西不一样。
6浏览器:从输入网址到屏幕上的像素
理解这条链路,是为了知道「代码写在哪、什么时候执行、什么操作贵」。
输入网址到看见页面,浏览器做的事可以分成两段:先取回文件(网络部分,见 §7), 再把文件变成像素(渲染部分,本节)。后者五个阶段依次进行,任一阶段被打断都会表现为「页面白了一下」或「点了没反应」。
<head> 里、把阻塞脚本放到最后或加 defer,页面会明显更快出现内容(§6.2)。6.1 五个阶段的含义
| 阶段 | 做什么 | 代价 |
|---|---|---|
| 解析 HTML → DOM | 边下载边解析成对象树;遇到 <img> 等资源会去下载,遇到同步脚本会停下来等它 | 受脚本影响 |
| 解析 CSS → CSSOM | 所有样式规则合成一张总表 | 阻塞渲染:CSS 没下完不画 |
| 合并 → 渲染树 | 把 display: none 的节点剔除掉 | 轻 |
| 布局 / 重排 | 算每个盒子的位置与大小 | 贵 |
| 绘制 / 重绘 | 把颜色、文字、边框填成像素 | 中 |
| 合成 | 分层交给 GPU 合成最终画面 | 轻(用 transform / opacity 可只走这一步) |
日常最有用的推论:改动的属性决定代价。改 width、top、font-size
会引发重排加重绘;改 color、background 只引发重绘;
而改 transform 与 opacity 一般只走合成。所以做动画优先用 transform: translate()
而不是 left。
6.2 script 怎么放:三种写法
| 写法 | 下载 | 执行时机与影响 |
|---|---|---|
<script src> | 解析到就下载 | 阻塞 下载完立即执行,解析暂停。放在 <head> 会让首屏延迟。 |
<script defer> | 与解析并行 | 不阻塞 等 HTML 解析完,按出现顺序执行。要操作 DOM 的脚本用这个。 |
<script async> | 与解析并行 | 不阻塞但乱序 下载完就执行,谁先下完谁先跑。适合统计代码这类互不依赖的脚本。 |
实用结论:把 <script> 放在 </body> 之前,或者写在 <head> 里加 defer。
两者都行,选一个并保持一致。
6.3 开发者工具:四个面板够用到出师
| 面板 | 用来回答什么问题 |
|---|---|
| Elements | 现在真实的 DOM 长什么样?这条样式为什么被覆盖?盒模型的四层数值各是多少? |
| Console | 有没有报错?console.log 打出了什么?可以直接在这里敲一行代码试验。 |
| Network | 请求发出去了吗?状态码是多少?返回的 JSON 是什么样子?这几个问题只能在这里回答。 |
| Application | 本地存储里存了什么?Cookie 有哪些? |
7数据通路:HTTP、fetch 与 JSON
页面上的数据从哪来。这一节也是「本地能跑、线上报错」的答案所在。
7.1 请求与响应是什么
| 部分 | 内容 |
|---|---|
| 请求方法 | GET 取数据、POST 新建、PUT/PATCH 更新、DELETE 删除。 |
| 请求头 | 元信息,如 Content-Type: application/json 声明「我发的是 JSON」。 |
| 请求体 | 只有 POST / PUT 这类才带,内容是字符串(JSON 要先用 JSON.stringify 序列化)。 |
| 响应状态码 | 三位数字,第一位表示类别,见下表。 |
| 响应体 | 返回的数据,通常是 JSON 文本。 |
| 状态码 | 含义 | 你的下一步 |
|---|---|---|
| 200 | 成功 | 解析响应体 |
| 301 / 302 | 重定向 | 通常不用管,浏览器会自动跟随 |
| 304 | 未修改,用缓存 | 正常现象,不是错误 |
| 400 | 请求格式有误 | 检查参数与 JSON 结构 |
| 401 / 403 | 未认证 / 无权限 | 检查登录状态与凭据 |
| 404 | 路径不存在 | 检查 URL,注意相对路径的基准点 |
| 500 | 服务器内部错误 | 不是前端问题,去看服务端日志 |
7.2 fetch:唯一的请求入口
// GET 取数据
async function getRows(sex) {
const res = await fetch(`/api/rows?sex=${encodeURIComponent(sex)}`);
// 关键:4xx / 5xx 不会让 fetch 抛错,必须自己检查 ok
if (!res.ok) throw new Error(`请求失败:HTTP ${res.status}`);
return res.json();
}
// POST 提交 JSON
async function saveRow(payload) {
const res = await fetch('/api/rows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // 不写这行,后端多半收不到内容
body: JSON.stringify(payload), // body 只能是字符串
});
if (!res.ok) throw new Error(`保存失败:HTTP ${res.status}`);
return res.json();
}
服务器返回 404 或 500,fetch 本身不会 reject,你会在 res.ok 为 false 的情况下
继续往下解析一个错误页面的响应体。这是「明明接口报错了,页面却显示成功」的最常见原因。
规范行为如此,所以每个请求后面都要跟一行 if (!res.ok) throw ...。
7.3 CORS:为什么本地打开 HTML 会报错
浏览器有一条同源策略:只有当协议、主机、端口三者完全相同,才允许读取对方的响应。
file:///C:/note.html 打开的页面没有正常的源,它发出的请求会被浏览器拦下。
| 现象 | 原因与解法 |
|---|---|
双击 HTML 打开,fetch 一律报 CORS 错误 | 页面来自 file://,没有合法的源。解法:起一个本地服务,例如在目录下运行 python -m http.server 8000,然后访问 http://localhost:8000。 |
| 请求本地服务成功,请求别的域名失败 | 跨源了,需要服务端在响应里加 CORS 头(如 Access-Control-Allow-Origin)。前端改代码解决不了。 |
| Network 里看到请求,但仍报 CORS 错误 | 这恰恰说明CORS 是浏览器在拦你,不是服务器拒绝了你 —— 请求确实发出去了,服务端日志里能看到。分清这一点能省下大量排查时间。 |
| 需带 Cookie 的跨源请求,额外多一个 OPTIONS 请求 | 这是预检(preflight),浏览器先问服务器允不允许。属于正常流程。 |
7.4 和你的现有工具对照
| 意图 | R | Python | JavaScript |
|---|---|---|---|
| GET 取数据 | httr::GET(url) | requests.get(url) | await fetch(url) |
| POST 提交 JSON | httr::POST(body = ...) | requests.post(url, json = p) | 见 §7.2 第二个片段 |
| 解析 JSON | jsonlite::fromJSON() | r.json() | await res.json() |
| URL 编码参数 | 参数自动编码 | 参数自动编码 | 要自己写 encodeURIComponent() |
| 检查请求是否成功 | 返回状态码需自取 | r.raise_for_status() | if (!res.ok) throw ... |
7.5 数据放在浏览器哪里
| 方式 | 适合 | 限制 |
|---|---|---|
localStorage | 记住用户的筛选条件、折叠状态这类小配置 | 只能存字符串(对象要 JSON.stringify);同源隔离;不要放敏感数据 |
| 内存变量 | 页面当前显示的这份数据 | 刷新即丢失 |
<input type="file"> | 让用户选本地 CSV 直接在浏览器里处理,不上传 | 需要用户手动选择;只能用 FileReader 或 file.text() 读 |
| Cookie | 登录凭据(由后端设置) | 每次请求都会带上,容量小,前端一般不直接写 |
8工程化:依赖、构建与部署
从「我电脑上能打开」到「别人点开链接就能用」,中间差的就是这一层。
前面七节的代码都可以「写完双击打开」,但真实项目很快就会遇到三件原生浏览器解决不了的事: 依赖包怎么管、多文件怎么组织、TypeScript 浏览器不认。工程化这一层就是为这三件事存在的。 入门阶段的策略是:先做出一个纯 HTML/CSS/JS 的页面并成功部署,再回来加工具 —— 否则你会把「工具配置问题」误当成「我不会写前端」。
8.1 为什么需要构建
| 浏览器原生不支持 | 构建工具做的事 |
|---|---|
裸模块导入 import x from 'pkg' | 把依赖解析出来并合进最终文件(打包,bundling) |
TypeScript(.ts 文件) | 类型检查并编译成普通 JavaScript |
JSX(<Card /> 这种语法) | 编译成创建元素的函数调用 |
| 新版语法、CSS 预处理器 | 转译成目标浏览器支持的写法 |
dist,不是源码。8.2 npm 与 package.json
| 命令 | 作用 |
|---|---|
npm init -y | 生成 package.json,记录项目信息与依赖清单 |
npm install 包名 | 装一个运行时要用的依赖 |
npm install -D 包名 | 装一个只在开发时用的依赖(构建工具、检查工具) |
npm run 脚本名 | 执行 package.json 里 scripts 定义的命令 |
npx 工具名 | 临时执行一个包,不装进项目 |
package.json 里的版本号(如 "vite": "^7.0.0")遵循语义化版本 主.次.补丁:
主版本号变化表示可能不兼容,补丁号变化表示只是修 bug。^ 表示允许升级到同一主版本内的最新版。
依赖目录 node_modules 与锁文件 package-lock.json 一起进版本库控制,
但 node_modules 本身不要提交(它体积大且可由锁文件还原)。
8.3 开发与构建
// package.json 里最常改的一段
{
"scripts": {
"dev": "vite", // 起本地服务,改文件自动刷新
"build": "vite build", // 产出可部署的 dist 目录
"preview": "vite preview" // 本地预览 build 的结果(部署前的最后一关)
}
}
dev 服务会做一些「贴心」的处理(路径重写、模块即时编译),这些问题在 build 之后才暴露。
如果 dev 里一切正常、上线却是白屏,第一步就是在本地跑 npm run preview,
通常立刻能看到真正的原因(最常见的是「绝对路径写成了相对路径」)。
8.4 TypeScript 值不值得
| 收益 | 成本 |
|---|---|
| 在编辑器里就发现类型错误,而不是等运行时白屏 | 需要写类型标注,前期代码量增加约三分之一 |
| 接口返回的数据结构有据可依,减少「字段名猜错」 | 需要理解泛型、联合类型等概念 |
| 重构时可信度高:改名、改结构后编辑器会指出所有受影响处 | 第三方库的类型定义质量参差,偶尔要写额外声明 |
建议:第一个页面不要用;第二个页面开始,如果你已经会写基本类型标注,就值得上。 对数据背景的人而言,TypeScript 的收益往往比一般前端更高 —— 因为你本来就习惯先想清楚字段是什么类型再动手。
8.5 部署:把文件放到网上
| 方式 | 适合 | 要点 |
|---|---|---|
| GitHub Pages | 个人项目、演示页 | 把 dist 推到指定分支并开启 Pages;免费且自带 HTTPS;子路径部署时注意资源路径 |
| 对象存储 + CDN | 正式一点的站点 | 上传 dist,绑定域名与证书;需要了解缓存刷新 |
| 平台托管(自动构建) | 想省心 | 连上代码仓库,push 即自动构建部署;本质是把图 9 的后半段交给平台 |
8.6 入门期的工具取舍
| 工具 | 什么时候需要 | 理由 |
|---|---|---|
| 编辑器 + 浏览器 | 第一天 | 真的够了。VS Code 与浏览器开发者工具是全部必需 |
| Git | 第一天 | 不是为了协作,是为了「改坏了能退回去」 |
| 本地 HTTP 服务 | 第一天 | 写第一个 fetch 就需要,见 §7.3 |
| npm + 构建工具 | 第二个项目 | 有了多文件、要用包的时候再上 |
| TypeScript | 会写基本类型后 | 见 §8.4 |
| 代码检查与格式化工具 | 有协作者时 | 一个人写代码时,风格统一收益有限 |
| 测试框架、CI、容器 | 先放下 | 入门阶段投入产出比最低的部分 |
9框架:什么时候才需要 React / Vue
框架不是进阶必修课,而是特定问题出现之后才值得引入的工具。
框架要解决的唯一问题是:当数据变化时,让界面跟着变。这个问题在原生写法里之所以麻烦, 是因为「数据」和「页面」是两份需要你手工同步的状态 —— 你改了数组,忘了更新表格,界面就与数据不一致了。 在数据类页面上这个矛盾尤其突出:筛选条件一多,需要手工同步的地方就成倍增长。
// 原生写法:数据与界面各改一次,漏一处就不一致
let rows = allRows;
function applyFilters() {
rows = allRows.filter(r => r.age >= minAge); // (1) 改数据
renderTable(rows); // (2) 别忘了重画表格
renderCount(rows.length); // (3) 别忘了同步计数
renderChart(rows); // (4) 别忘了同步图
}
框架的答案是声明式:你只描述「界面是数据的什么函数」,数据一变,框架负责找出要更新的部分。 这个转变就是从「命令式操作 DOM」变成「描述结果长什么样」。
| 概念 | 含义 |
|---|---|
| 组件 | 把「一段 HTML + 它的样式 + 它的逻辑」合成一个可复用的单元,例如 <ResultTable rows={rows} />。 |
| props | 父组件传给子组件的数据,只读。相当于函数参数。 |
| state | 组件自己拥有的、会变化的数据。变化后触发重渲染。 |
| 单向数据流 | 数据从上往下传,事件从下往上发。避免「谁改了这块数据」变成无解问题。 |
| 你的情况 | 结论 | 说明 |
|---|---|---|
| 一个统计报告页,展示若干张表与图,基本无交互 | 不要 | 纯 HTML/CSS 加一点 JS 就够,加载更快、问题更少 |
| 有筛选、排序、分页,但都在同一个页面上 | 可选 | 原生写法能撑住,要注意把「改数据 + 重渲染」收进同一个函数(如上例) |
| 多视图切换、表单联动、列表里每一项都有局部状态 | 该上了 | 手工同步的复杂度开始失控,框架的收益会明显超过学习成本 |
| 需要多人协作、页面会持续迭代几个月 | 该上了 | 组件的边界就是团队分工的边界 |
如果你的目标是「把分析结果做成一个可看的页面」,那么最高效的路线通常是: 用 R 或 Python 把数据整理好、导出成 JSON 或 CSV,再用纯 HTML/CSS/JS 做一个静态展示页。 这样前端只需要负责展示,不需要负责计算 —— 你已有的工具承担了它最擅长的部分, 而框架此时带来的复杂度是纯粹的负担。等展示层真的复杂到需要状态管理,再引入也不迟。
10学习路径与阶段验收
每个阶段都必须产出一个能给别人看的东西,否则无法判断自己是否真的学会了。
10.1 阶段目标与验收
| 阶段 | 必须掌握 | 验收产物 | 常见卡点 |
|---|---|---|---|
| 0 环境 | 编辑器、浏览器开发者工具、Git 基本操作、本地起服务 | 一个能双击打开的 HTML 页面;一个能通过 http://localhost 访问的目录 |
不知道「文件放在哪」;路径写错导致图片与 CSS 加载不出来。用相对路径并注意大小写。 |
| 1 结构 与表现 |
语义标签、表单与 label、盒模型、选择器与特异性、Flex、Grid、媒体查询 | 一个「统计结果一览页」:含表头区、筛选表单、数据表、页脚,在窄屏下自动重排 | 布局卡在居中与对齐上。先把目标拆成「一维还是二维」(图 4),再选机制;用开发者工具量数值而不是猜。 |
| 2 行为 | 类型与 ===、数组三件套、函数与作用域、DOM 增删改、事件委托、async/await |
把一份固定数据渲染成表格;点表头能按该列排序;有筛选控件且界面与数据始终一致 | 「数据改了界面没变」——绝大多数是忘了重新渲染,或者变量作用域搞错。用 console.log 检查数据,用 Elements 面板检查 DOM。 |
| 3 数据接通 | HTTP 方法与状态码、fetch 与 res.ok、JSON 解析、同源与 CORS |
页面从后端接口或本地 JSON 取数据并渲染;能处理加载中、失败、空数据三种状态 | CORS 报错与路径错误。记住:Network 面板里能看到请求就说明是浏览器拦的,不是服务器拒的。 |
| 4 工程与交付 | npm 与 package.json、构建与 dist、部署、缓存与 HTTPS 的基本概念 |
一个公网可访问的网址,并且你能说清楚它是由哪次 build 产出的 |
「本地好、线上白」。先跑 preview,再查资源路径与大小写。 |
10.2 资料的使用顺序
| 资料 | 怎么用 |
|---|---|
| MDN(权威参考) | 遇到某个属性或方法不确定时的第一查询处。查「这个属性有哪些取值」比查教程可靠得多。 |
| HTML / ECMAScript 规范 | 只在需要确认「这到底是不是规定行为」时看,例如 == 的转换规则、defer 的执行顺序。 |
| 框架官方文档 | 决定使用框架之后再读,按官方入门教程走一遍,不要先看二手教程。 |
| 视频教程 | 适合建立第一印象;细节与最新用法一律回到官方文档核对。 |
(1) 每学一个概念,立刻用它改一遍自己的页面 —— 脱离项目学的知识留存率极低。 (2) 遇到报错先读报错信息本身,再搜索;报错信息里通常已经给出了类型不对或属性不存在。 (3) 不要同时学两件新东西(比如「一边学 JS 一边学 React」),把变量控制成一个。
11排错手册
把「现象」直接映射到「最可能的三个原因」,比从原理推起更快。
| 症状 | 最可能的原因 | 第一步动作 |
|---|---|---|
| 整页白屏 | JS 在解析阶段就抛错,中断了后续渲染 | 打开 Console 看第一条红色报错,通常就在这一条上 |
| 页面能显示,但样式全没了 | CSS 文件没加载成功(路径错)或选择器没匹配 | Network 面板看 CSS 请求是否为 404;再看 Elements 里的样式列表 |
| 改了 CSS,页面没变化 | 被更高优先级规则覆盖,或浏览器缓存了旧文件 | 样式面板看是否有删除线;强制刷新(Ctrl+F5) |
| 图片显示成破图 | 路径错、文件名大小写不符、或文件没一起部署 | Network 面板看该图片的请求状态码 |
| 点击没有反应 | 元素没被选到、事件绑在了会被替换的节点上、或报错中断 | 先在监听里打一行 console.log,确认回调有没有执行 |
| 数据没出来 | fetch 失败、字段名与接口不一致、或忘了 await | Network 面板看原始响应内容,逐字核对字段名 |
| 数字算出来不对 | 拿到的是字符串('57.3')而不是数字 | console.log(typeof v, v),必要时 Number(v) |
| 本地正常,线上白屏 | 资源路径是绝对路径、文件名大小写不符(Linux 服务器区分大小写) | 本地跑 npm run preview,再看控制台 |
| 排序结果很奇怪 | sort 默认按字符串比较 | 补上比较函数 (a, b) => a - b,并注意它会改原数组 |
11.1 通用的三步定位法
- 先分域。是数据错了,还是渲染错了?在 Console 里把数据
console.log出来, 在 Elements 里看 DOM 是什么样。两者一比就知道该往哪边查。 - 再二分。在可疑区间中间打一行日志或注释掉一半代码,看问题还在不在。这比从头读代码快得多, 而且不会漏掉自己「以为对」的地方。
- 最后读报错原文。报错信息通常已经给出了文件和原因,逐字读完再搜索, 搜索时带上「关键字 + 你用的具体技术名」,而不是整段描述。
12结课项目:一份统计结果的展示页
把前十一节串成一个能拿去用的东西。目标产物:一个网址,打开后能看到可筛选、可排序的结果表。
12.1 目录结构
result-page/
├── index.html 页面结构:表头、筛选区、结果表
├── styles.css 全部样式,先用 grid 搭骨架
├── app.js 全部行为:载入、筛选、排序、渲染
└── data/
└── summary.csv 由 R / Python / SAS 导出的结果,前端只读不改
12.2 关键代码
载入与解析
// 把 CSV 文本变成对象数组。列名来自第一行,一行数据就是一个对象。
async function loadCsv(path) {
const res = await fetch(path);
if (!res.ok) throw new Error(`载入失败:HTTP ${res.status}`);
const text = await res.text();
const [head, ...lines] = text.trim().split(/\r?\n/);
const cols = head.split(',');
return lines.map((line) =>
Object.fromEntries(cols.map((c, i) => [c, line.split(',')[i]]))
);
}
// 注意:这种写法只适用于「字段内没有逗号」的规整文件。
// 真实 CSV 可能带引号包裹的逗号,那种情况请在 R / Python 侧先整理干净,
// 或改用成熟的解析库 —— 不要手写一个能处理所有情况的 CSV 解析器。
筛选、排序、渲染
let allRows = []; // 原始数据,永远不改
let viewRows = []; // 当前视图,由筛选与排序算出来
function applyView() {
const minAge = Number(document.querySelector('#ageMin').value) || 0;
viewRows = allRows
.filter(r => Number(r.age) >= minAge) // CSV 里全是字符串,必须转数字
.sort((a, b) => Number(b.mean) - Number(a.mean)); // 一定要给比较函数
render(viewRows);
document.querySelector('#count').textContent = `${viewRows.length} 条`;
}
function render(rows) {
const tbody = document.querySelector('#result tbody');
tbody.replaceChildren();
const max = Math.max(...rows.map(r => Number(r.mean)));
for (const r of rows) {
const tr = document.createElement('tr');
const tdName = document.createElement('td');
tdName.textContent = r.variable;
const tdStat = document.createElement('td');
tdStat.textContent = Number(r.mean).toFixed(1);
// 条形:宽度按最大值的比例给,纯 CSS 就够,不需要绘图库
const tdBar = document.createElement('td');
const bar = document.createElement('div');
bar.className = 'bar';
bar.style.width = `${(Number(r.mean) / max) * 100}%`;
tdBar.append(bar);
tr.append(tdName, tdStat, tdBar);
tbody.append(tr);
}
}
// 事件:筛选控件变了就重算视图。整个页面只有这一个同步入口。
document.querySelector('#filter').addEventListener('input', applyView);
loadCsv('data/summary.csv')
.then(rows => { allRows = rows; applyView(); })
.catch(err => {
document.querySelector('#result').textContent = `数据载入失败:${err.message}`;
});
部署
# 1. 把整个目录提交到一个 Git 仓库(data 目录一起提交,页面要靠它)
git init && git add . && git commit -m "结果展示页"
# 2. 推到 GitHub 后,在仓库设置里开启 Pages,指向分支根目录
# 3. 等一两分钟,得到形如 https://用户名.github.io/仓库名/ 的网址
# 若使用构建工具:先 npm run build,再部署 dist 目录
直接双击 index.html 打开,fetch('data/summary.csv') 会被浏览器按跨源规则拦下(§7.3)。
不要怀疑代码,在项目目录里运行 python -m http.server 8000,然后访问
http://localhost:8000 —— 这是每个前端项目都要迈过去的一道门槛:页面必须由一个服务提供,不能靠双击打开。
12.3 复用清单
- 列名要和数据文件一致。CSV 的表头就是代码里的字段名,改一处要改两处;建议列名先用 Latin 命名,显示时再换成中文表头。
- 所有从文件读来的值都是字符串。参与比较或计算前一律
Number(),这一条能消掉大半「结果不对」的问题。 - 把
allRows与viewRows分开。筛选永远基于原始数据算,不要在上一次的结果上再筛,否则条件会累加而无法撤销。 sort先复制再排。写[...rows].sort(...),避免改掉原始数据。- 三种状态都要处理。加载中、失败、空结果 —— 缺一个,用户就会看到一片空白而不知道发生了什么。
- 数值格式化用
toFixed。统计结果的位数要统一,且注意toFixed返回的是字符串。
附录 A · 术语对照
按出现频率排序。中英对照是为了让你能搜到准确资料 —— 用中文搜到的往往是二手教程。
| 英文 | 中文 | 一句话 |
|---|---|---|
| DOM | 文档对象模型 | HTML 在浏览器里被解析成的那棵对象树,JS 通过它读写页面 |
| CSSOM | CSS 对象模型 | 样式规则合成的一张总表,与 DOM 合并成渲染树 |
| Reflow / Layout | 重排 / 布局 | 重新计算盒子的位置与尺寸,代价最高 |
| Repaint | 重绘 | 重新填充像素,不改位置 |
| Composite | 合成 | 把分层结果交给 GPU 合成,改 transform 只走这一步 |
| Specificity | 特异性 | 决定哪条 CSS 规则胜出的权重计数 |
| Box model | 盒模型 | content / padding / border / margin 四层 |
| Event loop | 事件循环 | 调度「同步代码、微任务、宏任务」的机制 |
| Microtask / Task | 微任务 / 宏任务 | Promise 回调属微任务,setTimeout 属宏任务;微任务先清空 |
| Closure | 闭包 | 函数记住了定义时所在作用域里的变量 |
| TDZ | 暂时性死区 | let / const 声明前访问会直接报错 |
| Same-origin policy | 同源策略 | 协议、主机、端口三者相同才允许读取响应 |
| CORS / Preflight | 跨源资源共享 / 预检 | 服务端声明允许哪些源;复杂请求前先发 OPTIONS 询问 |
| Bundling / Build | 打包 / 构建 | 把多文件与依赖合成浏览器能直接运行的文件 |
| Declarative UI | 声明式界面 | 描述「界面是数据的什么函数」,由框架负责更新 |
| XSS | 跨站脚本攻击 | 把外部数据当 HTML 执行导致的注入,用 textContent 规避 |
| a11y | 无障碍 | accessibility 的缩写,让所有人(含辅助技术)都能用 |
附录 B · 参考来源
下列链接在 2026-09 逐一验证可访问。规范原文用于确证「规定行为」,MDN 用于日常查询。
- 入门总览 — MDN · Learn web development
- CSS 层叠与继承 — MDN · Cascade and inheritance
- 选择器特异性 — MDN · Specificity
- 盒模型 — MDN · Introduction to the CSS box model
- 事件循环 — MDN · Event loop;语言规范 ECMAScript Language Specification
- 脚本加载时机 — MDN ·
<script>(defer / async);解析规则见 HTML Standard · DOM - 关键渲染路径 — MDN · Critical rendering path
- 网络请求 — MDN · Fetch API;MDN · CORS
- 无障碍 — MDN · ARIA
- 工具与部署 — Vite 官方指南;npm · 语义化版本;GitHub Pages
- 框架(决定使用后再读) — React;Vue
- 本地运行时 — Node.js API 文档