CHAPTER 05 · 前端
Web 平台基础
后端工程师对 HTTP 并不陌生,但通常只站在服务器这一侧看它。本章换到浏览器那一侧:浏览器替你做了哪些事、强制执行哪些安全规则、怎么把 HTML 和 CSS 变成像素。这些规则不会因为你用了什么框架而改变。
- 按顺序讲出“从输入网址到页面可交互”的主要步骤。
- 解释为什么 curl 能成功的请求,在浏览器里会被 CORS 拦下,以及正确的修复方向。
- 说出登录凭证放在 HttpOnly Cookie 而不是 localStorage 里的理由。
- 读懂一段 Tailwind 代码,并用准确的词向 Agent 描述界面需求。
开场:curl 能通,浏览器不通
LinkNote 的前端部署在 app.linknote.dev,API 部署在 api.linknote.dev。你用 curl 调用 API,一切正常;前端页面里的 fetch 却在控制台报:blocked by CORS policy。Agent 建议你“在服务器上把 Access-Control-Allow-Origin 设成 *”。为什么 curl 和浏览器结果不同?Agent 的建议有什么问题?
答案方向:跨域限制不是服务器的规则,而是浏览器为了保护用户强制执行的规则。curl 不是浏览器,所以不受限制。浏览器之所以要拦,是因为它会自动带上用户的 Cookie——如果任意网站都能读取其他网站的响应,恶意网站就能以你的身份读取你的数据。设置 * 虽然能“消除报错”,但它不允许同时携带 Cookie,而且放开了所有来源。正确方向是:精确声明允许的来源,或者干脆让前端和 API 同源。
要真正理解这一点,我们需要从一次完整的页面加载讲起。
从输入网址到页面可交互
这张图后面会被反复用到:第 07 章讨论“为什么服务端渲染首屏更快”,本质上就是在讨论 ⑤ 里装了什么、⑨ 需要多少 JS。
HTTP:你需要熟练掌握的部分 原理
作为后端开发者,你应该熟悉请求方法和状态码。这里只强调在全栈开发中特别重要的几点:
| 状态码 | 含义 | 全栈开发中的典型场景 |
|---|---|---|
200 / 201 / 204 | 成功 / 已创建 / 无内容 | 正常请求;创建收藏;删除成功 |
301 / 302 / 307 / 308 | 重定向 | 登录后跳转;307/308 保留请求方法 |
304 | 未修改(用缓存) | ETag 协商缓存命中 |
400 / 422 | 请求格式错 / 校验不通过 | Zod 校验失败时返回,附带字段错误 |
401 / 403 | 未认证(你是谁?)/ 无权限(我知道你是谁,但不行) | 两者经常被混用,要分清 |
404 / 409 | 不存在 / 冲突 | 重复收藏同一个 URL 可返回 409 |
429 | 请求过多 | 限流;调用 LLM API 时你会经常收到 |
500 / 502 / 503 / 504 | 服务器错误 / 网关错误 / 不可用 / 网关超时 | 504 常见于 serverless 函数超时 |
还有一点:HTTP 是无状态的。服务器不会“记得”上一个请求是谁发的。所谓“登录状态”,是每个请求都带上某种凭证实现的,而浏览器里最常见的凭证载体就是 Cookie。
Cookie 与登录:浏览器会“自动”做的事 原理
Cookie 的关键特性是:浏览器会自动把它附加到发往对应域名的请求上,不需要你的 JS 做任何事。这既是它方便的原因,也是一整类安全问题的根源。
| 属性 | 作用 | 不设置的后果 |
|---|---|---|
HttpOnly | JS 无法读取(document.cookie 看不到) | 一旦页面被注入恶意脚本(XSS),会话凭证直接被偷走 |
Secure | 只通过 HTTPS 发送 | 在不安全网络中可能被窃听 |
SameSite=Lax | 从其他站点发起的请求(除顶层链接跳转外)不带这个 Cookie | 跨站请求伪造(CSRF):恶意网站诱导你的浏览器带着 Cookie 调用你的接口 |
Max-Age / Expires | 过期时间 | 浏览器关闭即失效(会话 Cookie) |
很多教程(和 Agent)会把 JWT 存在 localStorage,再手动加到请求头里。问题在于:localStorage 能被页面上的任何 JS 读取,一次 XSS 就能把令牌偷走。对于你自己的网站,更安全的默认做法是把会话凭证放进 HttpOnly + Secure + SameSite 的 Cookie。主流鉴权库(Better Auth、Auth.js、Clerk 等)默认就这样做。localStorage 适合存非敏感的界面偏好。
同源策略与 CORS 原理
源(origin) = 协议 + 域名 + 端口。https://app.linknote.dev 和 https://api.linknote.dev 是不同的源;http://localhost:5173 和 http://localhost:8787 也是不同的源。
同源策略(same-origin policy):浏览器默认不允许一个源的 JS 读取另一个源的响应。CORS(跨源资源共享)是服务器“主动豁免”的机制:服务器在响应头里声明“我允许哪些源读取我”。
回到开场问题中 Agent 的建议:
Access-Control-Allow-Origin: *不能和Allow-Credentials: true同时使用。需要带 Cookie 时,浏览器会直接拒绝,问题并没有真正解决。- 即使不带 Cookie,
*也意味着任何网站都能调用你的 API 并读取结果。对公开数据可以接受,对用户数据不行。
更好的方案往往是避免跨域:让前端和 API 同源。全栈框架(Next.js、TanStack Start)天然如此——页面和 API 路由由同一个服务提供;或者在同一个域名下用路径区分(/api/* 由后端处理)。开发环境里可以用开发服务器的代理(proxy)功能。
缓存:让同样的东西只传一次 会用
浏览器、CDN、服务器都可能缓存响应。控制它们的主要是 Cache-Control 响应头。现代前端的标准策略是“两类文件,两种策略”:
| 资源 | 文件名 | 缓存策略 | 原因 |
|---|---|---|---|
| JS / CSS / 图片 | app.3f9a1c.js(带内容哈希) | public, max-age=31536000, immutable | 内容变了文件名就变,旧文件可以永久缓存 |
| HTML | index.html | no-cache(每次向服务器确认) | HTML 引用了最新的哈希文件名,必须是最新的 |
| API 响应 | — | 视数据而定,用户私有数据用 private 或不缓存 | 避免 CDN 把 A 用户的数据给了 B 用户 |
打包器会自动给资源加哈希,部署平台也会设置合理的默认值。你需要知道这套机制,是为了排查“部署了但用户看到的还是旧版本”以及“CDN 缓存了不该缓存的东西”这两类问题。
DOM:浏览器里的界面数据结构
浏览器把 HTML 解析成一棵对象树,叫 DOM(文档对象模型)。JS 可以读写这棵树,浏览器会把变化反映到屏幕上。
这是命令式(imperative)的写法:你告诉浏览器每一步怎么改。页面简单时没问题;当同一份数据要显示在五个地方、还要处理加载中、出错、空列表等状态时,手动同步 DOM 会迅速失控。这正是 React 这类库要解决的问题——你描述“界面应该是什么样”,它来算出“DOM 该怎么改”。
日常开发中你基本不会直接操作 DOM,但要知道:DOM 事件会冒泡(bubbling)(点击子元素,父元素的监听器也会收到),表单默认会提交并刷新页面(所以常见 e.preventDefault())。知道
HTML:选对标签比你想的重要 会用
Agent 生成的代码里常见一个问题:用 <div onClick> 做按钮。它“能用”,但键盘无法聚焦、屏幕阅读器不知道它是按钮、在表单里也不会触发提交。语义化 HTML 免费提供了这些能力:
| 需求 | 用 | 不要用 |
|---|---|---|
| 点击执行操作 | <button type="button"> | <div onClick> |
| 跳转到另一个页面 | <a href>(或框架的 <Link>) | <button onClick={navigate}> |
| 收集输入并提交 | <form> + <label> + <input> | 散落的 input + 按钮点击读取值 |
| 页面结构 | <header> <nav> <main> <article> | 全部 <div> |
这些细节属于“可访问性(accessibility,常缩写为 a11y)”。你不需要成为专家,但要在验收标准里写上“可以只用键盘完成主要操作”。
CSS:读懂布局就够了 知道
CSS 是一个庞大的领域。对于转型中的全栈开发者,建议只掌握这几个概念,其余交给 Agent 和组件库:
- 盒模型:每个元素都是一个盒子,由内容、内边距(padding)、边框(border)、外边距(margin)组成。
- Flexbox:一维排列(一行或一列),处理对齐和分配剩余空间。导航栏、按钮组、卡片内部布局。
- Grid:二维网格。卡片列表、整体页面布局。
- 响应式:用媒体查询(media query)按屏幕宽度切换布局。通常采用“移动优先”:先写窄屏样式,再为宽屏添加规则。
Tailwind CSS 与 shadcn/ui 会用
Tailwind 是 2026 年 Agent 生成 UI 的默认选择。它不让你写 CSS 文件,而是提供大量“工具类(utility class)”,每个类对应一条样式规则,直接写在元素上:
读法:p-4 = 内边距 1rem;mt-1 = 上外边距;flex flex-wrap gap-2 = 弹性布局、可换行、间距;hover:、dark:、md: 这样的前缀表示“在某种状态或屏幕尺寸下才生效”。
它为什么适合 AI 时代?样式和结构写在一起,Agent 修改一个组件时不需要跨文件找 CSS;类名是有限的、标准化的词汇,模型非常熟悉;不需要给 CSS 类起名字。代价是类名很长,但这在 Agent 生成代码的场景里问题不大。
v4 把配置从 tailwind.config.js 移到了 CSS 文件里(@import "tailwindcss"; 加 @theme { ... } 定义设计变量)。模型常常按 v3 的方式生成配置文件。看到 Agent 创建 tailwind.config.js 时,先确认项目用的是哪个版本。
shadcn/ui 不是一个传统的组件库依赖。它是一套组件的源代码,用命令行工具复制到你的项目里(pnpm dlx shadcn@latest add button dialog),之后你可以随意修改。组件底层基于无样式的可访问性原语库(如 Radix UI),样式用 Tailwind。它成为主流的原因和 Tailwind 类似:代码就在你的仓库里,Agent 可以直接读和改。
值得知道的浏览器 API 知道
| API | 用途 |
|---|---|
fetch | 发 HTTP 请求(服务器端运行时也有) |
URL / URLSearchParams | 解析和构造网址、查询参数。不要手动拼字符串 |
FormData | 读取表单数据;React 的表单 Action 直接接收它 |
localStorage / IndexedDB | 浏览器本地存储:简单键值 / 结构化大量数据 |
EventSource(SSE) | 服务器单向推送;LLM 流式输出常用 |
WebSocket | 双向实时通信:聊天、协作 |
| Web Worker | 在后台线程跑计算,避免阻塞主线程 |
界面需求最容易说得含糊。下面这些词能让描述变得精确:
常用词汇:响应式断点(breakpoint)、骨架屏、空状态(empty state)、加载 / 错误状态、乐观更新(optimistic update)、防抖(debounce)、无限滚动 / 分页、模态框(modal / dialog)、抽屉(drawer / sheet)、提示(toast)、焦点管理、键盘可达。
- 从输入 URL 到页面显示发生了什么?按网络 → 服务器 → 解析 → 渲染 → 执行 JS 的主干讲,再挑一个你熟悉的环节深入(如 TLS 握手、CDN 缓存、关键渲染路径)。
- 什么是 CORS?为什么会有预检?先说同源策略保护的是用户(浏览器自动带凭证),再说 CORS 是服务器声明的豁免;带自定义头或 JSON 体的请求在发送前需要预检确认。
- XSS 和 CSRF 分别是什么?怎么防?XSS:注入脚本在你的页面上执行 → 转义输出(React 默认会转义)、CSP、HttpOnly Cookie。CSRF:别的网站借你的 Cookie 发请求 → SameSite Cookie、CSRF token、校验 Origin。
自检
1. 开发时,前端跑在 localhost:5173,API 跑在 localhost:8787。它们同源吗?如果不同源,最省事的解决办法是什么?
不同源(端口不同)。最省事的办法是在 Vite 开发服务器里配置代理:浏览器请求 localhost:5173/api/*,由开发服务器转发到 8787。对浏览器来说这是同源请求。生产环境则通过同域部署或正确的 CORS 配置解决。
2. 你发布了新版本,部分用户反馈“页面白屏”,控制台显示某个 app.3f9a1c.js 404。可能是什么原因?
用户浏览器(或 CDN)缓存了旧的 HTML,它引用了旧的哈希文件名,而新部署删除了旧文件。检查 HTML 是否被错误地设置了长缓存,以及部署平台是否保留了旧版本资源。
3. 一个 Cookie 设置了 HttpOnly,但没设 SameSite(且浏览器按旧规则处理)。它能防住 XSS 偷令牌吗?能防住 CSRF 吗?
能防止 XSS 脚本读取令牌(但注入的脚本仍然可以在页面上以用户身份发请求)。不能防 CSRF:Cookie 仍会随着其他网站发起的请求被自动发送。现代浏览器对未设置 SameSite 的 Cookie 默认按 Lax 处理,但不应依赖默认值,应显式设置。