← Notes
Languages and practice

前端入门:从浏览器渲染到可交付页面

把前端拆成「结构 — 表现 — 行为 — 运行环境 — 数据通路 — 工程交付」六层,逐层给出核心概念、术语、 最小示例与验收标准,并配知识结构图与流程图。写作语境按读者现有的 R / Python / SAS 心智模型来搭桥, 终点是能独立做出一个可用的页面并放到网上。

读者  零基础起步,现有主力语言为 R / Python / SAS 目标  能独立完成一个响应式页面并部署 版本基准  MDN 与 WHATWG / ECMAScript 现行文档;Node v22.22.2 实测(2026-09)
阅读约定与证据边界

三类断言,分开标注。凡写「规范」,指的是现行标准或官方文档已经规定、可查附录 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 代码在哪里运行:浏览器与服务器

浏览器(用户的设备) HTML 结构 —— 有哪些内容,各是什么 CSS 表现 —— 长什么样,怎么排列 JavaScript 行为 —— 用户动了以后发生什么 请求 响应 服务器 API 接口 数据库 / 文件 权限与业务规则 这里的代码用什么语言写, 前端原则上不关心。 前端 = 在浏览器里执行的那部分;后端 = 在服务器上执行的那部分。边界由「谁执行」决定,不由文件后缀决定。
图 1 | 三层职责都发生在浏览器一侧,服务器只负责给数据和规则。理解这一点的实际价值在于: 当页面「没反应」时,第一步永远是判断问题出在浏览器侧还是服务器侧 —— 打开开发者工具的网络面板,看请求有没有发出去。

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知识体系总览

先看地图,再走路线。六条主干之间是有依赖顺序的,跳着学会反复返工。

HTML · 结构 文档骨架 / 语义 / 表单 CSS · 表现 盒模型 / 层叠 / Flex / Grid JavaScript · 行为 类型 / 闭包 / 数组 / 异步 / DOM 运行环境 · 浏览器 渲染路径 / 重排重绘 / DevTools 数据通路 · 网络 HTTP / fetch / JSON / CORS 工程与交付 npm / 构建 / 部署 / 框架 前端 六条主干中,左边三条是可以立刻动手的;右上两条决定你为什么「本地能跑、线上报错」;右下一条决定交付形态。
图 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 假装按钮,会坏在三个地方

把 <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)听起来专业,入门阶段其实只有四条要记。 做对这四条,就能覆盖绝大多数常见问题。

  1. 图片写 alt。内容图写描述,纯装饰图写 alt=""(空字符串表示「跳过」)。缺失 alt 会让屏幕阅读器读出文件名。
  2. 表单控件配 <label>。用 for 与 id 绑定,而不是把文字放在旁边。
  3. 该用原生元素就用原生元素。按钮用 <button>,链接用 <a>,别用 div 加事件模拟。
  4. 标题层级不跳级。从 h1 到 h3 不能跳过 h2,它表达的是文档大纲,不是字号。
一条经验判断

如果你需要给一个元素手工添加 role、tabindex 和键盘事件三样东西才能让它像按钮, 那说明原生按钮本来就能满足需求。ARIA 属性是补丁,正确用法是「优先用原生元素,只在原生表达不了时才用 ARIA」。

4CSS:盒模型、层叠与布局

样式表被低估的地方不在于「怎么让它好看」,而在于「为什么我写的这条规则没生效」。

CSS(Cascading Style Sheets,层叠样式表)的全部难点集中在两个字上:层叠。 页面里每个元素最终的样子,是所有匹配到它的规则按优先级与声明顺序「竞争」出来的结果。 所以学 CSS 的顺序应该是:先懂盒模型(一个元素占多大地方),再懂层叠(哪条规则赢),最后才是布局(怎么摆放)。

4.1 盒模型

margin 外边距 border 边框 padding 内边距 content 内容区 width / height 默认只指这一层 默认 box-sizing: content-box:width 只指 content 那一层。 所以 200px 的盒子加上 padding 与 border 会明显更宽。统一改 border-box 可避免。
图 3 | 从内到外四层。新手最常踩的坑是「设了 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 层叠与优先级:为什么我的样式没生效

当多条规则作用于同一个属性时,浏览器按下面的顺序决出胜者 —— 这是规范规定的判定顺序,不是经验法则:

  1. 来源与重要性。带 !important 的声明优先于普通声明;作者样式优先于浏览器默认样式。
  2. 选择器特异性(specificity)。把选择器拆成三类计数,按「内联 > id > class / 属性 / 伪类 > 元素 / 伪元素」从高到低比较。
  3. 出现顺序。以上全部相同时,写在后面的赢。
选择器特异性说明
tbody td0,0,2两个元素选择器,各计 1
table.data td0,1,2一个 class 计 1,元素各计 1
#report td1,0,1一个 id 直接压过上面两条
style="color:red"内联写在标签上的内联样式,高于任何选择器
color: red !important最高压过内联,因此也最难被覆盖
排查「样式不生效」的三步动作

不要反复改代码猜。打开开发者工具,选中该元素,在右侧的样式面板里从上往下看: (1) 你写的那条规则在不在列表里?不在说明选择器没匹配上(多半是类名拼错或层级写错); (2) 在列表里但被划了删除线?说明被更高优先级的规则覆盖了,直接看压在上面的是谁; (3) 规则生效了但看不到变化?说明属性写错了或者值非法(例如给 width 写了 auto 之外的字符串)。 这三步能定位九成以上的样式问题,比反复读代码快得多。

4.3 布局:三种机制,一条判断路径

要摆放一组元素 一维排列,还是二维对齐? 这个问题直接决定用哪种机制 正常流(默认) 块级上下排、行内左右排 Flex 弹性盒 一行或一列,自动分配余量 Grid 网格 行与列同时定义,精确对齐 判断顺序:能用正常流就用正常流 → 一维用 Flex → 行列都要对齐才用 Grid。不要一上来就 Grid。
图 4 | 布局机制的判断路径。这条路径的价值在于避免「用 Grid 硬做一行三个按钮」这类绕远路的写法。 另外注意: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 单位与响应式

单位含义什么时候用
pxCSS 像素,与设备物理像素无关边框、细线、需要精确 1px 的地方
rem相对根元素字号;浏览器默认 1rem = 16px字号、间距的首选 —— 用户改了浏览器字号能整体缩放
em相对自身或父元素字号组件内部相关的间距;会层层累积,容易失控,新手期少用
%相对父元素的同维度尺寸宽度自适应;注意高度百分比需要父元素有确定高度
vw / vh视口宽 / 高的 1%全屏区块;vh 在移动端浏览器地址栏收发时会跳变,谨慎使用
frGrid 专用,剩余空间的一份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 相等:只用 === 就够了

== 会在比较前先做类型转换,=== 不做。转换规则复杂且反直觉,所以工程上的惯例是全部使用 ===。 下表是实测结果:

表达式结果为什么值得记住
'' == 0true空字符串被转成 0。用它判断「有没有填写」会误判。
'' === 0false换成严格相等就符合直觉了。
[] == falsetrue空数组既被当成空字符串又被当成假值。这就是为什么数组判空要用 arr.length === 0。
null == undefinedtrue唯一一个宽松相等有用的场合:v == null 同时覆盖两者。
null === undefinedfalse严格相等下它们是两个不同的值。
NaN === NaNfalse与 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]}   —— 实测
最危险的一条:sort 默认按字符串比较

实测:[10, 9, 100].sort() 得到 [10, 100, 9] —— 因为默认把元素转成字符串逐位比较。 必须显式给比较函数:sort((a, b) => a - b) 才得到 [9, 10, 100]。 另外 sort 是原地修改的(实测 a.sort() === a 为 true), 所以它会改掉原数组 —— 想保留原顺序要先复制:[...rows].sort(...)。

同一个意图:取出 value 大于 10 的记录 R df$value[df$value > 10] # 一行声明,无需循环 Python df.loc[df.value > 10, "value" ] # 列表推导同理 SAS where value > 10; /* 在 PROC 步骤内声明 */ JS rows.filter(r => r.value > 10) 前三种是「声明要什么」,JS 是「说明怎么走一遍」—— 差别就来自有没有向量化和表格类型。
图 5 | 同一个意图的四种写法。R 与 pandas 的写法把条件当作列上的一个整体操作; JS 的写法显式遍历每一行。理解这一点,就理解了为什么 JS 处理表格数据通常要借助库(§9.4)。

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 回调
调用栈 Call Stack 正在执行的函数 事件循环 微任务队列 Promise.then / queueMicrotask 当前代码跑完后立刻全部清空 宏任务队列 setTimeout / 事件回调 / 网络回调 每轮只取一个,取完先清空微任务 1 同步代码 2 同步结束 3 promise.then 4 queueMicrotask 5 setTimeout 实测顺序(Node v22.22.2)。要点:setTimeout(fn, 0) 的「0」是最短等待,不是立刻执行。
图 6 | 事件循环。为什么要关心这个:它解释了「为什么我 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 拼接用户可见的数据

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), 再把文件变成像素(渲染部分,本节)。后者五个阶段依次进行,任一阶段被打断都会表现为「页面白了一下」或「点了没反应」。

HTML 文件 DOM 树 CSS 文件 CSSOM 树 渲染树 Render Tree 只保留要画的节点 布局 Layout(重排 Reflow) 算位置与尺寸 绘制 Paint(重绘 Repaint) 填像素 合成 Composite 分层交给 GPU 两者先各自 建树,再合并 任一步被 JS 阻塞即白屏
图 7 | 关键渲染路径。HTML 与 CSS 必须都就绪才能开始画 —— 这是为什么把 CSS 放在 <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

页面上的数据从哪来。这一节也是「本地能跑、线上报错」的答案所在。

页面里的 JavaScript 服务器 / API ① fetch('/api/rows?sex=F') ② 响应 200 + JSON 文本 ③ await res.json() 解析 → 更新 DOM ④ 用户点了筛选 → 回到 ① 注意 ③ 是所有前端 bug 的高发区:数据拿到了,但渲染用的结构假设错了。排查从 Network 面板看原始 JSON 开始。
图 8 | 一次数据往返。真正的前端工作有相当一部分是「①到③之间的差错定位」—— 请求有没有发出(Network 面板)、返回的是什么形状(看原始响应)、解析后有没有落到 DOM 上(Elements 面板)。

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();
}
fetch 只在网络层失败时抛错

服务器返回 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 和你的现有工具对照

意图RPythonJavaScript
GET 取数据httr::GET(url)requests.get(url)await fetch(url)
POST 提交 JSONhttr::POST(body = ...)requests.post(url, json = p)见 §7.2 第二个片段
解析 JSONjsonlite::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 预处理器转译成目标浏览器支持的写法
你的源码 HTML/CSS/JS/TS 本地服务 改完立即刷新 构建 build 合并、转译、压缩 dist 产物 一堆静态文件 静态托管 CDN / 对象存储 用户浏览器 HTTPS 访问 开发时:只在你电脑上跑 上线时:产物与源码无关,浏览器只认它 入门阶段实际只需要记住两条命令:npm run dev(开发预览)与 npm run build(出产物)。 dist 里全是静态文件 —— 这意味着部署不需要服务器,任何静态托管都能放(§8.5)。
图 9 | 工具链的两段。左边的三个框只在你的开发机上存在,右边的三个框是用户能接触到的一切。 分不清这两段,就会出现「本地改好了但线上没变」的困惑 —— 因为线上跑的是 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 的结果(部署前的最后一关)
  }
}
部署前一定要跑一次 preview

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学习路径与阶段验收

每个阶段都必须产出一个能给别人看的东西,否则无法判断自己是否真的学会了。

累积投入 → 阶段 0 环境与工具 阶段 1 HTML + CSS 阶段 2 JavaScript 阶段 3 数据接通 阶段 4 工程与交付
图 10 | 五个阶段。阶梯的用意是强调顺序:阶段 1 与阶段 2 是地基,跳过它们直接进阶段 4, 会遇到「工具链报错看不懂、样式不对查不出」的双重困境。每一阶段的具体目标与验收产物见下表。

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 失败、字段名与接口不一致、或忘了 awaitNetwork 面板看原始响应内容,逐字核对字段名
数字算出来不对拿到的是字符串('57.3')而不是数字console.log(typeof v, v),必要时 Number(v)
本地正常,线上白屏资源路径是绝对路径、文件名大小写不符(Linux 服务器区分大小写)本地跑 npm run preview,再看控制台
排序结果很奇怪sort 默认按字符串比较补上比较函数 (a, b) => a - b,并注意它会改原数组

11.1 通用的三步定位法

  1. 先分域。是数据错了,还是渲染错了?在 Console 里把数据 console.log 出来, 在 Elements 里看 DOM 是什么样。两者一比就知道该往哪边查。
  2. 再二分。在可疑区间中间打一行日志或注释掉一半代码,看问题还在不在。这比从头读代码快得多, 而且不会漏掉自己「以为对」的地方。
  3. 最后读报错原文。报错信息通常已经给出了文件和原因,逐字读完再搜索, 搜索时带上「关键字 + 你用的具体技术名」,而不是整段描述。

12结课项目:一份统计结果的展示页

把前十一节串成一个能拿去用的东西。目标产物:一个网址,打开后能看到可筛选、可排序的结果表。

数据文件 R/Python 导出的 CSV fetch 载入 需要本地服务 解析 转为对象数组 筛选排序 filter / sort 渲染 表格 + 条形 部署 静态托管 改了筛选条件就回到第 4 步重跑一遍 第 1~4 步全在浏览器里完成,不需要服务器参与计算 第 6 步之后才有网址
图 11 | 项目数据流。关键设计:计算留在你熟悉的工具里,前端只负责展示。 这样这个页面的复杂度被压到最低,而它已经能解决「把结果发给别人看」这个真实需求。

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 通过它读写页面
CSSOMCSS 对象模型样式规则合成的一张总表,与 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 用于日常查询。

证据状态。本文标注「实测」的结论来自本机一次运行:脚本 fe-verify.js,运行时 Node v22.22.2, 仅使用语言内置对象,不涉及 DOM、CSS 或网络。原始输出完整复制于 §5.10,未做删改,仅在两处 NaN 的显示上加了说明。

未执行的部分。CSS 层叠与布局、浏览器渲染路径、脚本加载时机、HTTP 与 CORS 行为均按 MDN 与 WHATWG / ECMAScript 现行文档陈述,未在本机浏览器中逐步复现;§12 的项目代码为可读的完整片段,但未在浏览器中运行验证,也未部署。 凡属工程取舍的论断均标注为「惯例」或放入了对比表中,供读者自行判断。

时效。参考链接于 2026-09-19 验证可访问。前端生态变化快,涉及具体版本与工具配置的部分请以官方文档为准。