CHAPTER 05 · 前端

Web 平台基础

后端工程师对 HTTP 并不陌生,但通常只站在服务器这一侧看它。本章换到浏览器那一侧:浏览器替你做了哪些事、强制执行哪些安全规则、怎么把 HTML 和 CSS 变成像素。这些规则不会因为你用了什么框架而改变。

⏱ 约 50 分钟原理(HTTP、Cookie、CORS)会用(HTML、Tailwind)知道(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 同源。

要真正理解这一点,我们需要从一次完整的页面加载讲起。

从输入网址到页面可交互

网络 ① DNS域名 → IP ② TCP + TLS建立加密连接 ③ HTTP 请求GET / + Cookie ④ 服务器 / CDN可能先经过 CDN 缓存 ⑤ 响应 HTML状态码 + 头 + 正文 浏览器 ⑥ 解析 HTML → DOM遇到 CSS/JS 链接就去下载 ⑦ CSS → CSSOMCSS 会阻塞渲染 ⑧ 布局 + 绘制算位置 → 画像素(首次可见) ⑨ 执行 JS绑定事件 → 可交互 之后 ⑩ 用户操作 → JS 发 fetch → 更新 DOM 不再整页刷新。这就是“单页应用(SPA)”的交互方式, React 负责把“数据变化”转换成“DOM 变化”(第 06 章)。 渲染模式决定 ⑤ 里有什么 客户端渲染:⑤ 是空壳,⑨ 之后才有内容; 服务端渲染:⑤ 已含完整内容,⑧ 时就可见(第 07 章)。
页面加载主干流程(简化:省略了 HTTP/2 多路复用、预加载、缓存命中等细节)。

这张图后面会被反复用到:第 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 做任何事。这既是它方便的原因,也是一整类安全问题的根源。

属性作用不设置的后果
HttpOnlyJS 无法读取(document.cookie 看不到)一旦页面被注入恶意脚本(XSS),会话凭证直接被偷走
Secure只通过 HTTPS 发送在不安全网络中可能被窃听
SameSite=Lax从其他站点发起的请求(除顶层链接跳转外)不带这个 Cookie跨站请求伪造(CSRF):恶意网站诱导你的浏览器带着 Cookie 调用你的接口
Max-Age / Expires过期时间浏览器关闭即失效(会话 Cookie)
令牌放哪里:Cookie 还是 localStorage?

很多教程(和 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(跨源资源共享)是服务器“主动豁免”的机制:服务器在响应头里声明“我允许哪些源读取我”。

浏览器 页面源:app.linknote.dev API 服务器 api.linknote.dev ① OPTIONS /links(预检)Origin: https://app.linknote.dev ② Access-Control-Allow-Origin: https://app.linknote.dev Access-Control-Allow-Credentials: true ③ POST /links(真正的请求,带 Cookie) ④ 201 + 同样的 CORS 头 → JS 才能读到响应
非简单请求(如带 JSON 请求体的 POST)会先发一次预检。服务器的回答决定浏览器是否放行。

回到开场问题中 Agent 的建议:

更好的方案往往是避免跨域:让前端和 API 同源。全栈框架(Next.js、TanStack Start)天然如此——页面和 API 路由由同一个服务提供;或者在同一个域名下用路径区分(/api/* 由后端处理)。开发环境里可以用开发服务器的代理(proxy)功能。

缓存:让同样的东西只传一次 会用

浏览器、CDN、服务器都可能缓存响应。控制它们的主要是 Cache-Control 响应头。现代前端的标准策略是“两类文件,两种策略”:

资源文件名缓存策略原因
JS / CSS / 图片app.3f9a1c.js(带内容哈希)public, max-age=31536000, immutable内容变了文件名就变,旧文件可以永久缓存
HTMLindex.htmlno-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 和组件库:

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 生成代码的场景里问题不大。

Tailwind v4 的变化

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在后台线程跑计算,避免阻塞主线程
对 Agent 怎么说:描述界面的词汇

界面需求最容易说得含糊。下面这些词能让描述变得精确:

模糊
精确
“做个好看的列表页。”
“收藏列表页:移动端单列,≥768px 两列网格,≥1280px 三列。每张卡片显示标题(单行截断)、摘要(最多两行)、标签(胶囊样式)。需要四种状态:加载中用骨架屏(skeleton)、空状态带引导按钮、错误状态带重试、正常列表。使用 shadcn/ui 的 Card 组件。”
“接口跨域了,修一下。”
“前端 app.linknote.dev 调用 api.linknote.dev 需要带会话 Cookie。在 API 上只允许这个来源,开启 credentials,并处理 OPTIONS 预检。不要使用通配符 *。或者评估:改成同源的 /api 路径是否更简单?”

常用词汇:响应式断点(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 处理,但不应依赖默认值,应显式设置。