做了几年前端,日常绝大多数工作其实就是围着 HTML、CSS、JavaScript 这三样转。真正让我意识到自己“地基不牢”的,反而不是什么复杂的框架问题,而是一次很普通的页面改版:内容区有几个卡片,宽度明明设置了 flex,子项却怎么都不按预期收缩;一个输入框里的内容稍不注意就直接把页面结构撑坏了;后来给接口加参数,又在浏览器控制台里看到一串不明不白的请求,排查半天才发现是没做好基本的数据转义。那次之后,我把 HTML、JS、CSS 重新过了一遍,又专门补了 XSS 入门知识,整理出一套自己的学习笔记。
这篇笔记就是那次复盘后的产物。它不是什么花哨的框架教程,而是聚焦前端最根基的四个词:HTML、JS、CSS、XSS。适合两类人看:一类是刚开始学前端、想建立完整知识脉络的新手;另一类是已经会写页面但总在样式布局、交互数据、安全边界上反复踩坑的“野生前端”。看完你会知道,做页面时结构、表现、行为分别由谁负责;写交互时 JS 在 DOM 上到底做了什么;以及为什么一个入门级安全话题 XSS,会被我放进前端基础清单里。
1. 结构先行:HTML 与 CSS 的“搭积木”逻辑
1.1 语义化不是形式主义,而是给浏览器和队友看的说明书
很多人写 HTML 的习惯是:拿到设计稿,立刻 div 一把梭,哪里需要换行就 <br>,哪里需要大标题就随便挑个 <p> 加个 class 把字号调大。看起来页面效果差不多,但后续维护的人——通常就是几个月后的自己——看到一团 <div class="box1"> <div class="box2">...,想改结构就得从头捋,非常痛苦。
语义化标签的价值在于,它让“这块内容是什么”这件事从视觉层面提升到了结构层面。一个用 <header>、<nav>、<main>、<article>、<aside>、<footer> 搭出来的页面,哪怕完全没有 CSS,你扫一眼源码就能知道哪个是导航、哪个是正文、哪个是侧边栏。更重要的是,浏览器、搜索引擎、屏幕阅读器都依赖这些标签来理解页面。屏幕阅读器用户通常靠 <h1>~<h6> 的层级快速跳转内容,如果标题全用 div 模拟,读屏软件只会念出一堆“区块”,无障碍根本无从谈起。
我给自己定的基础规范很简单:先不碰样式,用纯 HTML 把页面大纲写出来,每个区块想清楚它“是什么角色”,选一个语义最接近的标签。标题层级严格按逻辑走,不要跳级。一个页面只保留一个 <h1>,小标题按内容嵌套在 section 或 article 里。结构稳定之后,再开始写 CSS 调整呈现效果。这样调整页面宽度、增删模块时,结构性改动很少,工作和返工量会明显小很多。
1.2 盒模型和 Flex 布局,CSS 里最值得先啃下来的两块骨头
CSS 入门有一道经典门槛:盒模型。每个元素在页面上都是一个矩形盒子,由 content(内容)、padding(内边距)、border(边框)、margin(外边距)组成。盒模型分两种计算方式:box-sizing: content-box 下,一个 width: 200px、padding: 20px 的元素,实际占用的宽度是 200 + 20×2 = 240px;而 box-sizing: border-box 下,width: 200px 表示的是最终可见宽度,内容区域会压缩成 200 − 20×2 = 160px。两种模型本身没有绝对的优劣,但同一个页面里混用会极易算错间距导致布局错位。我的习惯是全局统一 border-box,再也不用一边写宽度一边心算 padding,省下的脑力足够做别的。
另一块硬骨头就是布局。早期前端用 float、inline-block 拼布局,元素宽度稍有偏差就容易掉到下一行;后来有了 Flexbox,才把一个方向上的对齐、伸缩、换行问题变得非常直观。Flex 的核心思路是,在容器上声明 display: flex,子元素就默认沿着主轴排列,再通过 justify-content(主轴对齐)、align-items(交叉轴对齐)、flex-wrap(是否换行)这几个属性控制整体位置,子元素自己则用 flex-grow、flex-shrink、flex-basis 控制伸缩比例。热词里那个“css flex布局子元素宽度自适应”的问题,其实就是理解了 flex-basis 和 flex-shrink 之后自然能解决的事。如果希望三个卡片等宽分配空间,可以给子项设 flex: 1,这等价于 flex: 1 1 0%,意味着它们以 0 为基准,有剩余空间就均等扩张,空间不足也均等压缩,宽度自然就自适应了。
不要一开始就追求所有布局都用某一种方案实现。实践下来我的个人分法是:一维列表、导航栏、按钮组这类单向排列场景优先用 Flex;二维栅格、仪表盘这类需要同时处理行和列的场景用 Grid;简单的页面骨架、横向排列数量有限时,Flex 依然足够。先把这两种现代布局方式用熟,基本能覆盖绝大多数工作需求,曾经那些“灵异换行”也会消失大半。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JavaScript 的活法:让页面真正“会动”
2.1 DOM 操作和事件绑定:页面在你手里活过来的第一步
HTML 和 CSS 负责“长什么样”,JavaScript 则负责“能干什么”。浏览器把 HTML 解析成一棵对象树,叫 DOM(Document Object Model),每个标签都是树上的一个节点。JS 最常见的工作方式,就是先从这棵树上找到目标节点,再修改它的内容、样式或结构。比如 document.querySelector('.card') 能拿到第一个 .card 元素,.textContent 能改它的文本,.style.display 能控制显隐。新手最爱犯错的地方是频繁直接操作 innerHTML——这在简单场景下确实快,但后面会讲到,它也是 XSS 漏洞最常见的入口之一。
事件是先有用户行为,再有程序响应的机制。点击、输入、滚动、键盘按下,都会产生事件。给按钮绑点击事件,标准的做法是 addEventListener('click', handler)。之所以推荐它而不是老式的 onclick = function(){},是因为一个元素上可以绑多个监听器、互不覆盖,还能通过 { once: true } 让事件只触发一次,用 removeEventListener 精确解绑。新手容易进的误区是:以为事件只发生在被绑定的元素上,完全不理解事件冒泡。比如点一个列表里的子项,点击事件会从子项一路冒泡到容器,再到更上层。如果想让整个列表的点击都统一处理,完全可以把监听器绑在列表容器上,用 event.target 判断实际点了谁,也就是事件委托。这在动态增删列表项时特别好用,新加进来的项无需重新绑事件,天然就能响应。
2.2 数据、异步和调试:从“会写”到“写得不出事”
JS 里还有个绕不开的点是异步。初学者会疑惑:为什么 setTimeout 里的代码没有立刻执行?为什么 fetch 请求之后立刻打印数据是空的?理解异步的最简单模型是:JS 是单线程语言,主线程一次只做一件事,遇到定时器、网络请求这类耗时操作时,不会傻等着,而是先放进任务队列,等主线程当前工作完成后,再回来执行它们的回调。之前热词里出现“js宏”,其实就是指宏任务,JS 的任务还分宏任务和微任务,setTimeout 属于宏任务,Promise.then 属于微任务,微任务会优先于宏任务执行。理解这个顺序对排查“为什么这段代码先打印、那段后打印”非常有用。
现代前端处理异步,基本已经统一到 Promise 和 async/await 上了。fetch 返回的是 Promise,链式写法是 .then(res => res.json()).then(data => { ... }),但更直观的是写一个 async 函数,用 await fetch(...) 等待结果。这种写法让异步代码看起来像同步代码,读起来顺,维护也方便。我建议入门阶段就把“错误处理”刻进习惯里:任何涉及网络请求、JSON 解析的地方都要用 try/catch 或 .catch 兜住,否则接口一旦出错,页面会直接白屏或静默失败,控制台报错也只有开发者看得懂,用户只会觉得“这东西坏了”。
调试工具的使用也应该从一开始就养成习惯。不要只会 console.log,试试在浏览器 DevTools 的 Sources 面板打断点:代码执行到断点处会暂停,可以逐行看变量、看调用栈、在 Console 里手写表达式即时计算。调试状态下的信息量远比打日志多得多,很多“为什么数据不对”的问题,打断点看两步就懂了。日常我至少会保持一条原则:凡是操作 DOM 后的结果,先在 Console 里打印节点确认结构,再做进一步操作,能省掉大量因误以为“节点存在/内容已更新”而产生的无效排查。
3. XSS 入门:为什么前端基础清单里会有安全这一项
3.1 先弄明白 XSS 是什么,才能知道为什么要防
XSS 全称 Cross-Site Scripting,跨站脚本攻击。它的发生场景非常基础:网站把用户输入的内容,当成代码执行了。用户提交的往往是一段文本,但网站如果用错误的方式把它拼进页面,那这段文本里包含的 <script> 标签或事件属性就会被浏览器当成真正的 JS 执行。攻击者借此偷走登录凭据、伪造页面内容,甚至以你的身份发请求。XSS 之所以会被列进前端基础必修,不是因为它高级,而是因为它离日常业务太近——只要页面上有“用户输入 → 显示到页面”这类功能,就存在风险。留言板、评论、昵称、搜索框回显,都是高频入口。
我见过不少入门者觉得“XSS 是后端的事,前端加不加都行”,这是很危险的误解。现代前端大量使用接口渲染数据,页面内容很大一部分来自服务端或第三方;如果前端在把数据插入 DOM 时不做任何过滤和转义,攻击载荷就会被你亲手执行。后端能做过滤和输出编码,但前端作为数据展示的最后一道关卡,至少要做到“默认不信任任何输入”。尤其是执行上下文的差异非常大:同样是用户输入,“插入文本节点”和“拼接进 innerHTML”的安全级别完全是两回事。后一种写法相当于在告诉浏览器:把我给你的字符串当成 HTML 解析吧,里面要是有脚本,那就一起执行了。
3.2 三类 XSS 的基本辨识:存储型、反射型、DOM 型
XSS 按触发位置和持久性,通常分成三类。
存储型 XSS(Stored XSS):恶意脚本被持久化保存到服务器数据库里,后续每个用户加载页面时都会执行。典型的场景是论坛帖子、商品评价、个人简介。攻击者把恶意代码写进数据库,之后所有访问这条数据的用户都会中招,杀伤力最大。
反射型 XSS(Reflected XSS):恶意脚本不存储在服务器,而是藏在 URL 参数或表单提交内容里,服务器拿到参数后原样拼进返回的 HTML。用户需要“主动点击一条恶意链接”才会触发,脚本只在这一次响应中执行,所以也叫非持久型 XSS。搜索引擎、报错页、搜索关键词回显处都是高发点。这类问题修复时通常分三步:对输入做严格的校验和过滤,拒绝或清洗明显带恶意特征的参数;对输出到 HTML 的内容执行上下文相关的编码转义,也就是按 HTML 实体把 <、>、"、' 等字符转成无害形式;如果条件允许,在涉及敏感操作的位置再加一道 token 校验,防止恶意链接被拼接利用。
DOM 型 XSS(DOM-based XSS):恶意脚本既不经过服务器,也不存在数据库,而是在前端 JS 代码里完成整个触发链路。比如从 location.hash 或 URLSearchParams 里取出参数,再把值拼到 innerHTML 上——全程浏览器本地完成,服务端根本看不到恶意载荷。这种攻击隐蔽性更强,因为服务器日志里可能完全没有异常,问题只存在于前端代码的数据流转逻辑中。理解“数据从哪里来、经不经过 HTML 解释器”这条链路,是识别 DOM 型 XSS 的关键。
说实话,入门阶段不需要背所有攻击花样,但应该建立三条基本判断:
- 这段数据是可执行的 HTML 还是纯文本;
- 它展示时经过了解析上下文(innerHTML、href、style、事件属性),还是只作为文本放入
textContent; - 如果数据来自 URL 参数、
location.hash、document.referrer,就要怀疑它可能被人为构造过。
3.3 前端防 XSS 的落地措施:不要只靠“感觉安全”
理论知道了,关键是怎么落到实际代码里。最有价值的一条铁律是:默认使用 textContent,而不是 innerHTML。当你想把用户的名字显示在页面上时,node.textContent = userInput 会把内容原样按文本渲染,即使里面包含 <img onerror="..."> 也不会被当作标签解析。只有当你能确认内容来自可信来源,比如富文本编辑器产出且已经过后端白名单过滤的 HTML,才考虑使用 innerHTML,并且仍然需要对非法标签做清洗。
在 Vue、React 这类框架里,默认插值语法已经帮大家规避了大部分风险:{{ userContent }}(Vue)和 {userContent}(React)默认都会转义输出,不会执行 HTML 标签。真正需要注意的是框架提供的“绕过转义”开关:Vue 的 v-html、React 的 dangerouslySetInnerHTML。提供这些 API 本来是为了展示富文本,但一旦用了,就相当于亲手关掉了框架的安全护栏。我实践中的建议是:项目里禁用全局搜索直接写死这条约定——除非内容是可信的静态文案,否则任何用户数据都不允许通过 v-html / dangerouslySetInnerHTML 输出。
此外,有三样辅助手段能显著降低 XSS 风险,建议作为站点基础配置:
- 输入校验(Validation):前后端都要做。长度、字符集、格式白名单能挡掉一部分恶意输入,但不能作为唯一防线,因为攻击载荷可以绕过多层编码。
- CSP(Content-Security-Policy):在响应头里配置
Content-Security-Policy: default-src 'self',可以限制页面只能加载同源资源,外部脚本、内联脚本默认被拦截。它不能修复已存在的漏洞,但能极大降低漏洞被利用后的破坏范围,网上有不少免费的工具能帮你生成符合业务的 CSP 头。 - Cookie 标记 HttpOnly 和 SameSite:登录态 Cookie 加上
HttpOnly后,JS 里document.cookie就无法读取,即使 XSS 注入了脚本,也偷不到会话凭据;SameSite则能缓解跨站请求伪造的隐患。
修复已有 XSS 漏洞时,一个相对完整的流程是:先定位注入点,确认数据流向里哪一环使用了 innerHTML、v-html 等危险方法;然后在不影响业务的前提下,把输出方式改成 textContent 或框架转义语法,这是立竿见影的修复;再对确实需要展示 HTML 的模块封装一个清洗函数,或接入第三方白名单过滤库;最后全局搜索危险 API,统一排查同类隐患。这个思路对反射型和 DOM 型都适用。实际做过几轮之后就会知道,XSS 防护的本质不是背攻击代码,而是养成“输入不可信、输出必转义、代码默认走安全 API”的习惯。
4. 综合实战:从零手写一个带 XSS 防护的留言卡片
4.1 设计目标与页面结构
把前面三块知识串起来,最合适的小项目就是做一个极简的留言卡片页面:左侧一个文本输入框和发布按钮,右侧一个列表用来展示已发布的留言。功能看着简单,但它覆盖了“HTML 搭结构、CSS 做布局、JS 管交互、XSS 防护兜底”的全部知识点。为了专注基础,这里不引入框架、不引入构建工具,直接用一个 HTML 文件加少量原生 JS 就能跑通。
先写 HTML 结构。页面外层用 <main> 包住,里面分成两个区域:左边是 <section class="editor">,包含一个 <label>、一个 <textarea id="inputContent"> 和一个 <button id="publishBtn">;右边是 <section class="list">,里面放一个 <ul id="msgList"> 作为留言列表的容器。语义上使用 form 包裹输入和按钮会更合理,但为了防止页面刷新打断演示,我会在 JS 里对提交事件调用 preventDefault()。按钮初始可以加一个 disabled 属性,留给 JS 根据输入内容决定是否放开——这个细节很能体现“前后端都要对状态负责”的思路。
4.2 样式布局:Flex 一次搞定双栏结构
给这个页面做布局,用 Flex 是最直接的做法。容器 .app 设置 display: flex,左右两个子区域宽度固定分配:编辑器给 flex: 1,列表给 flex: 1.2,中间留一点间隔。如果视口变窄,需要变成上下排列,就加一条媒体查询在窄屏下把 flex-direction: column 切换成纵向。整体代码量很小,却已经把“Flex 分配空间”“响应式变化”这两个高频知识点都用上了。列表里的每一条留言,我会用 li 的 display: flex; justify-content: space-between 让留言文字和删除按钮一个在左一个在右,这也是 Flex 布局最经典的日常场景。
卡片本身的层次要进一步细分:.card 负责外层留白和阴影,.card-title 作为标题,.card-content 里再放表单或列表。统一使用 border-box,卡片间距靠 .app 的 gap 控制,而不是到处写 margin。这样即使之后要增加第三个区域,只需往容器里加一个子元素,间距会自动排布,不劳你再手改 CSS。
4.3 JS 交互逻辑与 XSS 防护闭环
JS 部分是这个项目的重点,也是安全防护的演练场。先把核心流程写出来:
javascript复制const inputBox = document.querySelector('#inputContent');
const publishBtn = document.querySelector('#publishBtn');
const msgList = document.querySelector('#msgList');
const msgForm = document.querySelector('#msgForm');
const messages = [];
function renderList() {
msgList.innerHTML = '';
for (const item of messages) {
const li = document.createElement('li');
const span = document.createElement('span');
span.textContent = item.content;
const delBtn = document.createElement('button');
delBtn.textContent = '删除';
delBtn.dataset.id = item.id;
li.appendChild(span);
li.appendChild(delBtn);
msgList.appendChild(li);
}
}
function addMessage() {
const text = inputBox.value.trim();
if (text === '') return;
messages.push({ id: Date.now(), content: text });
inputBox.value = '';
renderList();
}
publishBtn.addEventListener('click', addMessage);
msgForm.addEventListener('submit', (e) => {
e.preventDefault();
addMessage();
});
msgList.addEventListener('click', (e) => {
if (e.target.tagName === 'BUTTON') {
const id = Number(e.target.dataset.id);
const index = messages.findIndex((item) => item.id === id);
if (index !== -1) messages.splice(index, 1);
renderList();
}
});
这段代码里最核心的安全细节是:渲染留言时使用了 document.createElement('li') 配合 span.textContent = item.content,而不是把内容拼到 li.innerHTML 里。这样一来,即使用户在输入框里写入 <script>alert('xss')</script> 或 <img src=x onerror=alert(1)>,浏览器也只会把它们当作普通文本显示,不会执行。如果图省事把渲染改成:
javascript复制li.innerHTML = item.content; // 绝对不要这么写
那留言区马上就成了一个随时能引爆的攻击点——每一个打开页面的用户都会执行攻击者提前输入的内容。新手做练习时,非常推荐故意写一版 innerHTML 的代码,输入一段带 <img src=x onerror=alert(1)> 的文本,保存后刷新页面,亲眼看一次恶意脚本执行,对“为什么要用 textContent”的理解会上升一个量级。注意这个实验只在自己的本地演示文件里做,不要在任何正式站点或他人电脑上测试。
删除按钮用了事件委托:监听器只绑在 msgList 容器上,点击任意删除按钮时通过 e.target 找到目标,再通过 data-id 找到对应数据项删除。当列表项动态新增时,不需要给每个新按钮单独绑事件,这正是事件委托的核心优势。数据状态 messages 数组和视图 renderList() 分离的思路,也是框架里“状态驱动视图”的雏形,理解它之后,再学 Vue/React 会轻松很多。
5. 高频问题与排查经验速查表
5.1 HTML/CSS 布局类常见问题
很多入门者会遇到“设置宽度 100% 后页面还是横向滚动”的怪问题,十有八九是盒模型混用导致的:某个元素设了 width: 100%,又加了 padding 或 border,在 content-box 下实际宽度就超出了视口。这时打开 DevTools,点选该元素看 Computed 尺寸,基本一眼能找到元凶。习惯性建议是先在全局加 * { box-sizing: border-box; },再排查具体容器。
Flex 布局里常见的“子项被压缩得不成样子”或“内容溢出”问题,要先检查子项的 flex-shrink 默认值,它默认为 1,空间不足时会缩小;如果希望某个固定宽度的子项不被压缩,给它设 flex-shrink: 0。宽度分配不均时,优先检查 flex-basis,而不是盯着 width 反复调。还有一种情况是容器 display: flex 后,子项之间保留了空白间隙,如果间隙来自 HTML 源码里的换行和缩进,给容器设 font-size: 0 再在子项上恢复字号可以处理;但更推荐改用 gap 或直接接受间隙,不要用负 margin 硬凑。
“CSS 样式不生效”的问题也有一个排查顺序:先确认选择器是不是被更高优先级规则覆盖,再确认是不是标签没闭合导致后续 CSS 规则失效,最后打开 DevTools 看 Elements 面板检查样式是否被删除线划掉。越早学会看 Computed 面板,就越少浪费时间盲改。
5.2 JS 交互与数据渲染类常见问题
“绑定了事件但点击没反应”是入门阶段最容易让人抓狂的问题。排查顺序:第一步确认 JS 文件有没有报错,第二步确认元素在绑定事件时是否已经存在于 DOM 中。如果脚本写在 <head> 里、直接 querySelector 一个位于它后面的按钮,此时 DOM 还没解析完,选出来的是 null,绑定自然无效。解决方案是把 <script> 放到 body 结束时、或把代码包在 DOMContentLoaded 回调里,也可以直接用 defer 属性加载脚本文件。我自己日常写页面时最常用 defer,既不用记代码位置,又能保证 DOM 解析完成后再执行。
“为什么渲染出来的列表里有数据,但页面不更新”多半是忘记调用渲染函数,或者直接修改了数组元素但没重新执行 renderList()。要养成一个简单的思维模型:数据变更后,必有一个步骤把数据同步到视图。不主动调用渲染函数,视图就是旧状态。
“异步请求后拿到的数据总是 undefined”通常是忘了在 .then 里返回、或者没等 await 的结果就使用变量。处理接口拿到的数组时,记得 .map、.filter 返回的都是新数组,不会原地改动原数组;在控制台打印引用类型时,注意打印出来的是展开时的实时状态,不是语句执行那一刻的快照,必要时用 JSON.parse(JSON.stringify(obj)) 深拷贝一份再观察。
5.3 XSS 防护的常见困惑
“我把输入框里的 <script> 过滤掉了,是不是就安全了?”不一定。因为攻击载荷能出现在很多位置:标签属性、CSS 里的 url()、<a href="javascript:...">、SVG 的 onload 事件、各种编码绕过。所以过滤只是辅助手段,真正稳妥的做法还是“一律不把数据当作 HTML 解析”,以及配置合适的 CSP 做纵深防御。
“为什么我在详情页对内容做了 textContent,还是有报告说反射型 XSS?”这种情况要仔细看数据流还经过了哪些路径。内容可能除了详情页之外还被拼接进 document.title、被塞进某个 onclick 属性里,或者被 location.href 赋值。防 XSS 要覆盖的是所有输出位置,不是单个页面组件。
“框架不是已经自动转义了吗,还需要手动防吗?”框架默认确实做了转义,但数据只要进入 v-html、dangerouslySetInnerHTML、href 绑定这类“信任 HTML”的场景,默认保护就中断了。建议在项目里给团队立一条硬规则:所有动态绑定 HTML 的地方,必须经过一个统一的、带白名单的清洗函数,代码评审时重点盯这几个 API 的使用位置。我用这个规则最早给一个小项目排查时,五分钟内就找出了三处历史代码在用 innerHTML 拼接口返回的富文本,一处涉及用户头像的 alt 属性回显,全部改成安全写法后才放心上线。
最后再分享一个我复盘时的小心得:基础阶段看教程容易“看懂了”就翻篇,但同样的知识点,换成实操项目时又错得五花八门。建议每个刚学前端的人,把“这是结构、这是样式、这是行为、这是安全底线”当成一盏灯,边写页面边给代码归类。HTML、CSS、JS、XSS 从来不是四个孤立的名词,而是一条线上互相牵制的环节。等你哪一天不用刻意想 textContent 和 innerHTML 的区别、条件反射般地给所有用户输入留一个安全出口,那时候你的前端基础才算真正过关了。
